ref: dc7ce00cbe3c300caac3c13b6701240126b99e00
parent: 04b424a6398637dd0f5d29857f489a03fd5e38f5
author: Runxi Yu <me@runxiyu.org>
date: Sat Mar 7 09:24:22 EST 2026
receivepack: Add service semantics thingy
--- /dev/null
+++ b/receivepack/internal/service/command.go
@@ -1,0 +1,23 @@
+package service
+
+import "codeberg.org/lindenii/furgit/objectid"
+
+// Command is one protocol-independent requested ref update.
+type Command struct {+ OldID objectid.ObjectID
+ NewID objectid.ObjectID
+ Name string
+}
+
+func fillCommandErrors(result *Result, commands []Command, errText string) {+ for _, command := range commands {+ result.Commands = append(result.Commands, CommandResult{+ Name: command.Name,
+ Error: errText,
+ })
+ }
+}
+
+func isDelete(command Command) bool {+ return command.NewID == objectid.Zero(command.NewID.Algorithm())
+}
--- /dev/null
+++ b/receivepack/internal/service/command_result.go
@@ -1,0 +1,7 @@
+package service
+
+// CommandResult is one per-command execution result.
+type CommandResult struct {+ Name string
+ Error string
+}
--- /dev/null
+++ b/receivepack/internal/service/doc.go
@@ -1,0 +1,2 @@
+// Package service implements the protocol-independent receive-pack service.
+package service
--- /dev/null
+++ b/receivepack/internal/service/execute.go
@@ -1,0 +1,88 @@
+package service
+
+import (
+ "context"
+ "log"
+
+ "codeberg.org/lindenii/furgit/format/pack/ingest"
+)
+
+// Execute validates one receive-pack request, optionally ingests its pack into
+// quarantine, and plans ref updates.
+//
+// TODO: Invoke hook or policy callbacks to decide whether each planned update
+// should be allowed.
+// TODO: Apply planned ref updates with one atomic compare-and-swap ref
+// transaction once ref writing exists.
+func (service *Service) Execute(ctx context.Context, req *Request) (*Result, error) {+ _ = ctx
+
+ result := &Result{+ Commands: make([]CommandResult, 0, len(req.Commands)),
+ }
+
+ if req.PackExpected {+ if req.Pack == nil {+ result.UnpackError = "missing pack stream"
+ fillCommandErrors(result, req.Commands, "missing pack stream")
+
+ return result, nil
+ }
+
+ if service.opts.ObjectsRoot == nil {+ result.UnpackError = "objects root not configured"
+ fillCommandErrors(result, req.Commands, "objects root not configured")
+
+ return result, nil
+ }
+
+ quarantineName, quarantineRoot, err := service.createQuarantineRoot()
+ if err != nil {+ result.UnpackError = err.Error()
+ fillCommandErrors(result, req.Commands, err.Error())
+
+ return result, nil
+ }
+
+ defer func() {+ _ = quarantineRoot.Close()
+ // TODO: Promote accepted quarantined objects into the permanent object
+ // store once atomic ref application exists.
+ _ = service.opts.ObjectsRoot.RemoveAll(quarantineName)
+ }()
+
+ ingested, err := ingest.Ingest(
+ req.Pack,
+ quarantineRoot,
+ service.opts.Algorithm,
+ true,
+ true,
+ service.opts.ExistingObjects,
+ )
+ if err != nil {+ result.UnpackError = err.Error()
+ fillCommandErrors(result, req.Commands, err.Error())
+
+ return result, nil
+ }
+
+ result.Ingest = &ingested
+ }
+
+ for _, command := range req.Commands {+ result.Planned = append(result.Planned, PlannedUpdate{+ Name: command.Name,
+ OldID: command.OldID,
+ NewID: command.NewID,
+ Delete: isDelete(command),
+ })
+ }
+
+ fillCommandErrors(result, req.Commands, "ref updates not implemented yet")
+ log.Printf(
+ "receivepack: planned %d ref updates, but hook/policy checks and atomic ref writes are not implemented yet",
+ len(result.Planned),
+ )
+
+ return result, nil
+}
--- /dev/null
+++ b/receivepack/internal/service/options.go
@@ -1,0 +1,18 @@
+package service
+
+import (
+ "os"
+
+ "codeberg.org/lindenii/furgit/objectid"
+ "codeberg.org/lindenii/furgit/objectstore"
+ "codeberg.org/lindenii/furgit/refstore"
+)
+
+// Options configures one protocol-independent receive-pack service.
+type Options struct {+ Algorithm objectid.Algorithm
+ Refs refstore.ReadingStore
+ ExistingObjects objectstore.Store
+ ObjectsRoot *os.Root
+ // TODO: Hook and such callbacks.
+}
--- /dev/null
+++ b/receivepack/internal/service/quarantine.go
@@ -1,0 +1,26 @@
+package service
+
+import (
+ "crypto/rand"
+ "os"
+)
+
+// createQuarantineRoot creates one per-push quarantine directory beneath the
+// permanent objects root.
+func (service *Service) createQuarantineRoot() (string, *os.Root, error) {+ name := "tmp_objdir-incoming-" + rand.Text()
+
+ err := service.opts.ObjectsRoot.Mkdir(name, 0o700)
+ if err != nil {+ return "", nil, err
+ }
+
+ root, err := service.opts.ObjectsRoot.OpenRoot(name)
+ if err != nil {+ _ = service.opts.ObjectsRoot.RemoveAll(name)
+
+ return "", nil, err
+ }
+
+ return name, root, nil
+}
--- /dev/null
+++ b/receivepack/internal/service/request.go
@@ -1,0 +1,12 @@
+package service
+
+import "io"
+
+// Request is one protocol-independent receive-pack execution request.
+type Request struct {+ Commands []Command
+ PushOptions []string
+ DeleteOnly bool
+ PackExpected bool
+ Pack io.Reader
+}
--- /dev/null
+++ b/receivepack/internal/service/result.go
@@ -1,0 +1,14 @@
+package service
+
+import (
+ "codeberg.org/lindenii/furgit/format/pack/ingest"
+)
+
+// Result is one receive-pack execution result.
+type Result struct {+ UnpackError string
+ Commands []CommandResult
+ Ingest *ingest.Result
+ Planned []PlannedUpdate
+ Applied bool
+}
--- /dev/null
+++ b/receivepack/internal/service/service.go
@@ -1,0 +1,11 @@
+package service
+
+// Service executes protocol-independent receive-pack requests.
+type Service struct {+ opts Options
+}
+
+// New creates one receive-pack service.
+func New(opts Options) *Service {+ return &Service{opts: opts}+}
--- /dev/null
+++ b/receivepack/internal/service/service_test.go
@@ -1,0 +1,99 @@
+package service_test
+
+import (
+ "context"
+ "io/fs"
+ "os"
+ "strings"
+ "testing"
+
+ "codeberg.org/lindenii/furgit/internal/testgit"
+ "codeberg.org/lindenii/furgit/objectid"
+ "codeberg.org/lindenii/furgit/objectstore/memory"
+ "codeberg.org/lindenii/furgit/receivepack/internal/service"
+)
+
+func TestExecutePackExpectedWithoutObjectsRoot(t *testing.T) {+ t.Parallel()
+
+ //nolint:thelper
+ testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) {+ t.Parallel()
+
+ store := memory.New(algo)
+ svc := service.New(service.Options{+ Algorithm: algo,
+ ExistingObjects: store,
+ })
+
+ result, err := svc.Execute(context.Background(), &service.Request{+ Commands: []service.Command{{+ Name: "refs/heads/main",
+ OldID: objectid.Zero(algo),
+ NewID: objectid.Zero(algo),
+ }},
+ PackExpected: true,
+ Pack: strings.NewReader("not a pack"),+ })
+ if err != nil {+ t.Fatalf("Execute: %v", err)+ }
+
+ if result.UnpackError != "objects root not configured" {+ t.Fatalf("unexpected unpack error %q", result.UnpackError)+ }
+ })
+}
+
+func TestExecuteRemovesDerivedQuarantineAfterIngestFailure(t *testing.T) {+ t.Parallel()
+
+ //nolint:thelper
+ testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) {+ t.Parallel()
+
+ store := memory.New(algo)
+ objectsDir := t.TempDir()
+
+ objectsRoot, err := os.OpenRoot(objectsDir)
+ if err != nil {+ t.Fatalf("os.OpenRoot: %v", err)+ }
+
+ t.Cleanup(func() {+ _ = objectsRoot.Close()
+ })
+
+ svc := service.New(service.Options{+ Algorithm: algo,
+ ExistingObjects: store,
+ ObjectsRoot: objectsRoot,
+ })
+
+ result, err := svc.Execute(context.Background(), &service.Request{+ Commands: []service.Command{{+ Name: "refs/heads/main",
+ OldID: objectid.Zero(algo),
+ NewID: objectid.Zero(algo),
+ }},
+ PackExpected: true,
+ Pack: strings.NewReader("not a pack"),+ })
+ if err != nil {+ t.Fatalf("Execute: %v", err)+ }
+
+ if result.UnpackError == "" {+ t.Fatal("Execute returned empty unpack error for invalid pack")+ }
+
+ entries, err := fs.ReadDir(objectsRoot.FS(), ".")
+ if err != nil {+ t.Fatalf("fs.ReadDir: %v", err)+ }
+
+ if len(entries) != 0 {+ t.Fatalf("objects root still has entries after failed ingest: %d", len(entries))+ }
+ })
+}
--- /dev/null
+++ b/receivepack/internal/service/update.go
@@ -1,0 +1,12 @@
+package service
+
+import "codeberg.org/lindenii/furgit/objectid"
+
+// PlannedUpdate is one ref update that would be applied once ref writing
+// exists.
+type PlannedUpdate struct {+ Name string
+ OldID objectid.ObjectID
+ NewID objectid.ObjectID
+ Delete bool
+}
--
⑨