aboutsummaryrefslogtreecommitdiff
path: root/network/receivepack
diff options
context:
space:
mode:
authorGravatar Runxi Yu2026-03-26 09:14:59 +0000
committerGravatar Runxi Yu2026-03-26 09:14:59 +0000
commit3d25bda9d5da6814661828adabe8a09f9d01aefb (patch)
treed034e28079333f85e5d7b96d921282eddd4798d6 /network/receivepack
parentobject/id: Empty tree (diff)
signatureNo signature
network/receivepack: Rename from receivepack
Diffstat (limited to 'network/receivepack')
-rw-r--r--network/receivepack/advertise.go57
-rw-r--r--network/receivepack/capabilities_defaults.go17
-rw-r--r--network/receivepack/commands.go19
-rw-r--r--network/receivepack/doc.go3
-rw-r--r--network/receivepack/errors.go15
-rw-r--r--network/receivepack/hook.go93
-rw-r--r--network/receivepack/hooks/chain.go51
-rw-r--r--network/receivepack/hooks/doc.go2
-rw-r--r--network/receivepack/hooks/reject_force_push.go64
-rw-r--r--network/receivepack/int_test.go1047
-rw-r--r--network/receivepack/options.go68
-rw-r--r--network/receivepack/permissions.go27
-rw-r--r--network/receivepack/receivepack.go147
-rw-r--r--network/receivepack/results.go26
-rw-r--r--network/receivepack/service/apply.go134
-rw-r--r--network/receivepack/service/command.go32
-rw-r--r--network/receivepack/service/command_result.go13
-rw-r--r--network/receivepack/service/doc.go6
-rw-r--r--network/receivepack/service/execute.go123
-rw-r--r--network/receivepack/service/hook.go45
-rw-r--r--network/receivepack/service/hook_apply.go44
-rw-r--r--network/receivepack/service/ingest_quarantine.go144
-rw-r--r--network/receivepack/service/options.go36
-rw-r--r--network/receivepack/service/quarantine.go274
-rw-r--r--network/receivepack/service/quarantine_test.go184
-rw-r--r--network/receivepack/service/request.go16
-rw-r--r--network/receivepack/service/result.go14
-rw-r--r--network/receivepack/service/run_hook.go168
-rw-r--r--network/receivepack/service/service.go16
-rw-r--r--network/receivepack/service/service_test.go99
-rw-r--r--network/receivepack/service/update.go12
-rw-r--r--network/receivepack/version.go35
32 files changed, 3031 insertions, 0 deletions
diff --git a/network/receivepack/advertise.go b/network/receivepack/advertise.go
new file mode 100644
index 00000000..0fa010bf
--- /dev/null
+++ b/network/receivepack/advertise.go
@@ -0,0 +1,57 @@
+package receivepack
+
+import (
+ "errors"
+
+ common "codeberg.org/lindenii/furgit/network/protocol/v0v1/server"
+ "codeberg.org/lindenii/furgit/ref"
+ refstore "codeberg.org/lindenii/furgit/ref/store"
+)
+
+func advertisedRefs(opts Options) ([]common.AdvertisedRef, error) {
+ listed, err := opts.Refs.List("")
+ if err != nil {
+ return nil, err
+ }
+
+ return buildAdvertisedRefs(opts, listed)
+}
+
+func buildAdvertisedRefs(opts Options, listed []ref.Ref) ([]common.AdvertisedRef, error) {
+ refs := make([]common.AdvertisedRef, 0, len(listed))
+ for _, entry := range listed {
+ switch resolved := entry.(type) {
+ case ref.Detached:
+ advertised := common.AdvertisedRef{
+ Name: resolved.Name(),
+ ID: resolved.ID,
+ }
+
+ if resolved.Peeled != nil {
+ advertised.Peeled = resolved.Peeled
+ }
+
+ refs = append(refs, advertised)
+ case ref.Symbolic:
+ if resolved.Name() != "HEAD" {
+ continue
+ }
+
+ head, err := opts.Refs.ResolveToDetached("HEAD")
+ if err != nil {
+ if errors.Is(err, refstore.ErrReferenceNotFound) {
+ continue
+ }
+
+ return nil, err
+ }
+
+ refs = append(refs, common.AdvertisedRef{
+ Name: "HEAD",
+ ID: head.ID,
+ })
+ }
+ }
+
+ return refs, nil
+}
diff --git a/network/receivepack/capabilities_defaults.go b/network/receivepack/capabilities_defaults.go
new file mode 100644
index 00000000..72c36c30
--- /dev/null
+++ b/network/receivepack/capabilities_defaults.go
@@ -0,0 +1,17 @@
+package receivepack
+
+import (
+ "crypto/rand"
+)
+
+func defaultAgent() string {
+ return "furgit"
+}
+
+func defaultSessionID() string {
+ return "furgit-" + rand.Text()
+}
+
+func defaultPushCertNonce() string {
+ return "furgit-" + rand.Text()
+}
diff --git a/network/receivepack/commands.go b/network/receivepack/commands.go
new file mode 100644
index 00000000..a9edec1a
--- /dev/null
+++ b/network/receivepack/commands.go
@@ -0,0 +1,19 @@
+package receivepack
+
+import (
+ protoreceive "codeberg.org/lindenii/furgit/network/protocol/v0v1/server/receivepack"
+ "codeberg.org/lindenii/furgit/network/receivepack/service"
+)
+
+func translateCommands(commands []protoreceive.Command) []service.Command {
+ out := make([]service.Command, 0, len(commands))
+ for _, command := range commands {
+ out = append(out, service.Command{
+ OldID: command.OldID,
+ NewID: command.NewID,
+ Name: command.Name,
+ })
+ }
+
+ return out
+}
diff --git a/network/receivepack/doc.go b/network/receivepack/doc.go
new file mode 100644
index 00000000..b63f49d5
--- /dev/null
+++ b/network/receivepack/doc.go
@@ -0,0 +1,3 @@
+// Package receivepack provides the application-facing server-side push entry
+// point.
+package receivepack
diff --git a/network/receivepack/errors.go b/network/receivepack/errors.go
new file mode 100644
index 00000000..18e7a135
--- /dev/null
+++ b/network/receivepack/errors.go
@@ -0,0 +1,15 @@
+package receivepack
+
+import "errors"
+
+var (
+ // ErrMissingAlgorithm reports one missing repository hash algorithm.
+ ErrMissingAlgorithm = errors.New("receivepack: missing object id algorithm")
+ // ErrMissingRefs reports one missing reference store dependency.
+ ErrMissingRefs = errors.New("receivepack: missing refs store")
+ // ErrMissingObjects reports one missing object store dependency.
+ ErrMissingObjects = errors.New("receivepack: missing objects store")
+ // ErrUnsupportedProtocol reports one unsupported requested Git protocol
+ // version.
+ ErrUnsupportedProtocol = errors.New("receivepack: unsupported protocol version")
+)
diff --git a/network/receivepack/hook.go b/network/receivepack/hook.go
new file mode 100644
index 00000000..81286bea
--- /dev/null
+++ b/network/receivepack/hook.go
@@ -0,0 +1,93 @@
+package receivepack
+
+import (
+ "context"
+ "io"
+
+ objectid "codeberg.org/lindenii/furgit/object/id"
+ objectstorer "codeberg.org/lindenii/furgit/object/storer"
+ "codeberg.org/lindenii/furgit/network/receivepack/service"
+ refstore "codeberg.org/lindenii/furgit/ref/store"
+)
+
+type HookIO struct {
+ Progress io.Writer
+ Error io.Writer
+}
+
+// RefUpdate is one requested reference update presented to a receive-pack hook.
+type RefUpdate struct {
+ Name string
+ OldID objectid.ObjectID
+ NewID objectid.ObjectID
+}
+
+// UpdateDecision is one hook decision for a requested reference update.
+type UpdateDecision struct {
+ Accept bool
+ Message string
+}
+
+// HookRequest is the input presented to a receive-pack hook before quarantine
+// promotion and ref updates.
+//
+// Refs, ExistingObjects, and QuarantinedObjects are borrowed and are only
+// valid for the duration of the hook call.
+type HookRequest struct {
+ Refs refstore.ReadingStore
+ ExistingObjects objectstorer.Store
+ QuarantinedObjects objectstorer.Store
+ Updates []RefUpdate
+ PushOptions []string
+ IO HookIO
+}
+
+// Hook decides whether each requested update should proceed.
+//
+// The hook runs after pack ingestion into quarantine and before quarantine
+// promotion or ref updates. The returned decisions must have the same length as
+// HookRequest.Updates. Hook borrows the data and stores in HookRequest only for
+// the duration of the call.
+type Hook func(context.Context, HookRequest) ([]UpdateDecision, error)
+
+func translateHook(hook Hook) service.Hook {
+ if hook == nil {
+ return nil
+ }
+
+ return func(ctx context.Context, req service.HookRequest) ([]service.UpdateDecision, error) {
+ translatedUpdates := make([]RefUpdate, 0, len(req.Updates))
+ for _, update := range req.Updates {
+ translatedUpdates = append(translatedUpdates, RefUpdate{
+ Name: update.Name,
+ OldID: update.OldID,
+ NewID: update.NewID,
+ })
+ }
+
+ decisions, err := hook(ctx, HookRequest{
+ Refs: req.Refs,
+ ExistingObjects: req.ExistingObjects,
+ QuarantinedObjects: req.QuarantinedObjects,
+ Updates: translatedUpdates,
+ PushOptions: append([]string(nil), req.PushOptions...),
+ IO: HookIO{
+ Progress: req.IO.Progress,
+ Error: req.IO.Error,
+ },
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ out := make([]service.UpdateDecision, 0, len(decisions))
+ for _, decision := range decisions {
+ out = append(out, service.UpdateDecision{
+ Accept: decision.Accept,
+ Message: decision.Message,
+ })
+ }
+
+ return out, nil
+ }
+}
diff --git a/network/receivepack/hooks/chain.go b/network/receivepack/hooks/chain.go
new file mode 100644
index 00000000..f98c06f8
--- /dev/null
+++ b/network/receivepack/hooks/chain.go
@@ -0,0 +1,51 @@
+package hooks
+
+import (
+ "context"
+ "fmt"
+
+ receivepack "codeberg.org/lindenii/furgit/network/receivepack"
+)
+
+// Chain combines hooks by running them in order and intersecting their
+// decisions. The first rejecting message for each update is preserved.
+func Chain(hooks ...receivepack.Hook) receivepack.Hook {
+ return func(
+ ctx context.Context,
+ req receivepack.HookRequest,
+ ) ([]receivepack.UpdateDecision, error) {
+ decisions := make([]receivepack.UpdateDecision, len(req.Updates))
+ for i := range decisions {
+ decisions[i].Accept = true
+ }
+
+ for _, hook := range hooks {
+ if hook == nil {
+ continue
+ }
+
+ hookDecisions, err := hook(ctx, req)
+ if err != nil {
+ return nil, err
+ }
+
+ if len(hookDecisions) != len(req.Updates) {
+ return nil, fmt.Errorf("hook returned %d decisions for %d updates", len(hookDecisions), len(req.Updates))
+ }
+
+ for i, decision := range hookDecisions {
+ if decision.Accept {
+ continue
+ }
+
+ if decisions[i].Accept {
+ decisions[i].Message = decision.Message
+ }
+
+ decisions[i].Accept = false
+ }
+ }
+
+ return decisions, nil
+ }
+}
diff --git a/network/receivepack/hooks/doc.go b/network/receivepack/hooks/doc.go
new file mode 100644
index 00000000..bef2baf9
--- /dev/null
+++ b/network/receivepack/hooks/doc.go
@@ -0,0 +1,2 @@
+// Package hooks provides a few pre-defined hooks that callers might find useful.
+package hooks
diff --git a/network/receivepack/hooks/reject_force_push.go b/network/receivepack/hooks/reject_force_push.go
new file mode 100644
index 00000000..79714c8b
--- /dev/null
+++ b/network/receivepack/hooks/reject_force_push.go
@@ -0,0 +1,64 @@
+package hooks
+
+import (
+ "context"
+ "errors"
+ "fmt"
+
+ "codeberg.org/lindenii/furgit/commitquery"
+ objectid "codeberg.org/lindenii/furgit/object/id"
+ objectmix "codeberg.org/lindenii/furgit/object/storer/mix"
+ receivepack "codeberg.org/lindenii/furgit/network/receivepack"
+ refstore "codeberg.org/lindenii/furgit/ref/store"
+)
+
+// RejectForcePush rejects updates whose new value is not a fast-forward of the
+// currently resolved reference.
+func RejectForcePush() receivepack.Hook {
+ return func(
+ ctx context.Context,
+ req receivepack.HookRequest,
+ ) ([]receivepack.UpdateDecision, error) {
+ _ = ctx
+
+ objects := objectmix.New(req.QuarantinedObjects, req.ExistingObjects)
+
+ decisions := make([]receivepack.UpdateDecision, len(req.Updates))
+ for i := range decisions {
+ decisions[i].Accept = true
+ }
+
+ for i, update := range req.Updates {
+ if update.OldID == objectid.Zero(update.OldID.Algorithm()) || update.NewID == objectid.Zero(update.NewID.Algorithm()) {
+ continue
+ }
+
+ current, err := req.Refs.ResolveToDetached(update.Name)
+ switch {
+ case err == nil:
+ case errors.Is(err, refstore.ErrReferenceNotFound):
+ continue
+ default:
+ return nil, fmt.Errorf("resolve %s: %w", update.Name, err)
+ }
+
+ if current.ID == update.NewID {
+ continue
+ }
+
+ ok, err := commitquery.New(objects, nil).IsAncestor(current.ID, update.NewID)
+ if err != nil {
+ return nil, fmt.Errorf("check fast-forward %s: %w", update.Name, err)
+ }
+
+ if !ok {
+ decisions[i] = receivepack.UpdateDecision{
+ Accept: false,
+ Message: "non-fast-forward",
+ }
+ }
+ }
+
+ return decisions, nil
+ }
+}
diff --git a/network/receivepack/int_test.go b/network/receivepack/int_test.go
new file mode 100644
index 00000000..d17e6cd4
--- /dev/null
+++ b/network/receivepack/int_test.go
@@ -0,0 +1,1047 @@
+package receivepack_test
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "os"
+ "strings"
+ "testing"
+ "time"
+
+ "codeberg.org/lindenii/furgit/internal/testgit"
+ "codeberg.org/lindenii/furgit/network/protocol/pktline"
+ "codeberg.org/lindenii/furgit/network/protocol/sideband64k"
+ objectid "codeberg.org/lindenii/furgit/object/id"
+ receivepack "codeberg.org/lindenii/furgit/network/receivepack"
+ receivepackhooks "codeberg.org/lindenii/furgit/network/receivepack/hooks"
+)
+
+func TestReceivePackDeleteOnlyAtomicDeleteSucceeds(t *testing.T) {
+ t.Parallel()
+
+ //nolint:thelper
+ testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) {
+ t.Parallel()
+
+ testRepo := testgit.NewRepo(t, testgit.RepoOptions{ObjectFormat: algo})
+ _, _, commitID := testRepo.MakeCommit(t, "base")
+ testRepo.UpdateRef(t, "refs/heads/main", commitID)
+
+ repo := testRepo.OpenRepository(t)
+
+ var (
+ input strings.Builder
+ output bufferWriteFlusher
+ )
+
+ input.WriteString(pktlineData(
+ commitID.String() + " " + objectid.Zero(algo).String() + " refs/heads/main\x00report-status atomic delete-refs object-format=" + algo.String() + "\n",
+ ))
+ input.WriteString("0000")
+
+ err := receivepack.ReceivePack(context.Background(), &output, strings.NewReader(input.String()), receivepack.Options{
+ GitProtocol: "",
+ Algorithm: algo,
+ Refs: repo.Refs(),
+ ExistingObjects: repo.Objects(),
+ })
+ if err != nil {
+ t.Fatalf("ReceivePack: %v", err)
+ }
+
+ got := output.String()
+ if !strings.Contains(got, "ok refs/heads/main\n") {
+ t.Fatalf("unexpected receive-pack output %q", got)
+ }
+
+ _, err = repo.Refs().Resolve("refs/heads/main")
+ if err == nil {
+ t.Fatal("refs/heads/main still exists after delete push")
+ }
+ })
+}
+
+func TestReceivePackDeleteOnlyNonAtomicAppliesIndependentDeletes(t *testing.T) {
+ t.Parallel()
+
+ //nolint:thelper
+ testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) {
+ t.Parallel()
+
+ testRepo := testgit.NewRepo(t, testgit.RepoOptions{ObjectFormat: algo})
+ _, _, commitID := testRepo.MakeCommit(t, "base")
+ _, _, staleID := testRepo.MakeCommit(t, "stale")
+ testRepo.UpdateRef(t, "refs/heads/main", commitID)
+ testRepo.UpdateRef(t, "refs/heads/topic", commitID)
+
+ repo := testRepo.OpenRepository(t)
+
+ var (
+ input strings.Builder
+ output bufferWriteFlusher
+ )
+
+ input.WriteString(pktlineData(
+ staleID.String() + " " + objectid.Zero(algo).String() + " refs/heads/main\x00report-status delete-refs object-format=" + algo.String() + "\n",
+ ))
+ input.WriteString(pktlineData(
+ commitID.String() + " " + objectid.Zero(algo).String() + " refs/heads/topic\n",
+ ))
+ input.WriteString("0000")
+
+ err := receivepack.ReceivePack(context.Background(), &output, strings.NewReader(input.String()), receivepack.Options{
+ GitProtocol: "",
+ Algorithm: algo,
+ Refs: repo.Refs(),
+ ExistingObjects: repo.Objects(),
+ })
+ if err != nil {
+ t.Fatalf("ReceivePack: %v", err)
+ }
+
+ got := output.String()
+ if !strings.Contains(got, "ng refs/heads/main ") || !strings.Contains(got, "ok refs/heads/topic\n") {
+ t.Fatalf("unexpected receive-pack output %q", got)
+ }
+
+ _, err = repo.Refs().Resolve("refs/heads/main")
+ if err != nil {
+ t.Fatalf("Resolve(main): %v", err)
+ }
+
+ _, err = repo.Refs().Resolve("refs/heads/topic")
+ if err == nil {
+ t.Fatal("refs/heads/topic still exists after successful delete")
+ }
+ })
+}
+
+func TestReceivePackDeleteOnlyAtomicFailureLeavesAllRefsUntouched(t *testing.T) {
+ t.Parallel()
+
+ //nolint:thelper
+ testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) {
+ t.Parallel()
+
+ testRepo := testgit.NewRepo(t, testgit.RepoOptions{ObjectFormat: algo})
+ _, _, commitID := testRepo.MakeCommit(t, "base")
+ _, _, staleID := testRepo.MakeCommit(t, "stale")
+ testRepo.UpdateRef(t, "refs/heads/main", commitID)
+ testRepo.UpdateRef(t, "refs/heads/topic", commitID)
+
+ repo := testRepo.OpenRepository(t)
+
+ var (
+ input strings.Builder
+ output bufferWriteFlusher
+ )
+
+ input.WriteString(pktlineData(
+ staleID.String() + " " + objectid.Zero(algo).String() + " refs/heads/main\x00report-status atomic delete-refs object-format=" + algo.String() + "\n",
+ ))
+ input.WriteString(pktlineData(
+ commitID.String() + " " + objectid.Zero(algo).String() + " refs/heads/topic\n",
+ ))
+ input.WriteString("0000")
+
+ err := receivepack.ReceivePack(context.Background(), &output, strings.NewReader(input.String()), receivepack.Options{
+ GitProtocol: "",
+ Algorithm: algo,
+ Refs: repo.Refs(),
+ ExistingObjects: repo.Objects(),
+ })
+ if err != nil {
+ t.Fatalf("ReceivePack: %v", err)
+ }
+
+ got := output.String()
+ if !strings.Contains(got, "ng refs/heads/main ") || !strings.Contains(got, "ng refs/heads/topic ") {
+ t.Fatalf("unexpected receive-pack output %q", got)
+ }
+
+ _, err = repo.Refs().Resolve("refs/heads/main")
+ if err != nil {
+ t.Fatalf("Resolve(main): %v", err)
+ }
+
+ _, err = repo.Refs().Resolve("refs/heads/topic")
+ if err != nil {
+ t.Fatalf("Resolve(topic): %v", err)
+ }
+ })
+}
+
+func TestReceivePackAdvertisesResolvedHEAD(t *testing.T) {
+ t.Parallel()
+
+ //nolint:thelper
+ testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) {
+ t.Parallel()
+
+ testRepo := testgit.NewRepo(t, testgit.RepoOptions{ObjectFormat: algo})
+ _, _, commitID := testRepo.MakeCommit(t, "base")
+ testRepo.UpdateRef(t, "refs/heads/main", commitID)
+ testRepo.SymbolicRef(t, "HEAD", "refs/heads/main")
+
+ repo := testRepo.OpenRepository(t)
+
+ var (
+ input strings.Builder
+ output bufferWriteFlusher
+ )
+
+ input.WriteString("0000")
+
+ err := receivepack.ReceivePack(context.Background(), &output, strings.NewReader(input.String()), receivepack.Options{
+ Algorithm: algo,
+ Refs: repo.Refs(),
+ ExistingObjects: repo.Objects(),
+ })
+ if err != nil {
+ t.Fatalf("ReceivePack: %v", err)
+ }
+
+ got := output.String()
+
+ want := commitID.String() + " HEAD"
+ if !strings.Contains(got, want) {
+ t.Fatalf("HEAD advertisement missing %q in %q", want, got)
+ }
+ })
+}
+
+func TestReceivePackVersion2FallsBackToV0(t *testing.T) {
+ t.Parallel()
+
+ testReceivePackProtocolFallback(t, "version=2")
+}
+
+func TestReceivePackHighestRequestedVersionFallsBackToV0ForV2(t *testing.T) {
+ t.Parallel()
+
+ testReceivePackProtocolFallback(t, "version=1:version=2")
+}
+
+func TestReceivePackWithoutReportStatusWritesNoStatusPayload(t *testing.T) {
+ t.Parallel()
+
+ //nolint:thelper
+ testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) {
+ t.Parallel()
+
+ testRepo := testgit.NewRepo(t, testgit.RepoOptions{ObjectFormat: algo})
+ _, _, commitID := testRepo.MakeCommit(t, "base")
+ testRepo.UpdateRef(t, "refs/heads/main", commitID)
+
+ repo := testRepo.OpenRepository(t)
+
+ var (
+ input strings.Builder
+ output bufferWriteFlusher
+ )
+
+ input.WriteString(pktlineData(
+ commitID.String() + " " + objectid.Zero(algo).String() + " refs/heads/main\x00delete-refs atomic object-format=" + algo.String() + "\n",
+ ))
+ input.WriteString("0000")
+
+ err := receivepack.ReceivePack(context.Background(), &output, strings.NewReader(input.String()), receivepack.Options{
+ Algorithm: algo,
+ Refs: repo.Refs(),
+ ExistingObjects: repo.Objects(),
+ })
+ if err != nil {
+ t.Fatalf("ReceivePack: %v", err)
+ }
+
+ got := output.String()
+ if strings.Contains(got, "unpack ") || strings.Contains(got, "ng refs/heads/main ") || strings.Contains(got, "ok refs/heads/main\n") {
+ t.Fatalf("unexpected status payload %q", got)
+ }
+ })
+}
+
+func testReceivePackProtocolFallback(t *testing.T, gitProtocol string) {
+ t.Helper()
+
+ //nolint:thelper
+ testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) {
+ t.Parallel()
+
+ testRepo := testgit.NewRepo(t, testgit.RepoOptions{ObjectFormat: algo})
+ _, _, commitID := testRepo.MakeCommit(t, "base")
+ testRepo.UpdateRef(t, "refs/heads/main", commitID)
+
+ repo := testRepo.OpenRepository(t)
+
+ var (
+ input strings.Builder
+ output bufferWriteFlusher
+ )
+
+ input.WriteString(pktlineData(
+ commitID.String() + " " + objectid.Zero(algo).String() + " refs/heads/main\x00report-status atomic delete-refs object-format=" + algo.String() + "\n",
+ ))
+ input.WriteString("0000")
+
+ err := receivepack.ReceivePack(context.Background(), &output, strings.NewReader(input.String()), receivepack.Options{
+ GitProtocol: gitProtocol,
+ Algorithm: algo,
+ Refs: repo.Refs(),
+ ExistingObjects: repo.Objects(),
+ })
+ if err != nil {
+ t.Fatalf("ReceivePack: %v", err)
+ }
+
+ if strings.HasPrefix(output.String(), pktlineData("version 1\n")) {
+ t.Fatalf("receive-pack output started with protocol v1 preface for %q: %q", gitProtocol, output.String())
+ }
+ })
+}
+
+func TestReceivePackPackRequestWithoutObjectsRootReportsNotConfigured(t *testing.T) {
+ t.Parallel()
+
+ //nolint:thelper
+ testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) {
+ t.Parallel()
+
+ testRepo := testgit.NewRepo(t, testgit.RepoOptions{ObjectFormat: algo})
+ _, _, commitID := testRepo.MakeCommit(t, "base")
+ testRepo.UpdateRef(t, "refs/heads/main", commitID)
+
+ repo := testRepo.OpenRepository(t)
+
+ var (
+ input strings.Builder
+ output bufferWriteFlusher
+ )
+
+ input.WriteString(pktlineData(
+ commitID.String() + " " + commitID.String() + " refs/heads/main\x00report-status object-format=" + algo.String() + "\n",
+ ))
+ input.WriteString("0000")
+
+ err := receivepack.ReceivePack(context.Background(), &output, strings.NewReader(input.String()), receivepack.Options{
+ Algorithm: algo,
+ Refs: repo.Refs(),
+ ExistingObjects: repo.Objects(),
+ })
+ if err != nil {
+ t.Fatalf("ReceivePack: %v", err)
+ }
+
+ got := output.String()
+ if !strings.Contains(got, "unpack objects root not configured\n") {
+ t.Fatalf("unexpected receive-pack output %q", got)
+ }
+ })
+}
+
+func TestReceivePackPackCreatePromotesObjectsAndUpdatesRef(t *testing.T) {
+ t.Parallel()
+
+ //nolint:thelper
+ testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) {
+ t.Parallel()
+
+ sender := testgit.NewRepo(t, testgit.RepoOptions{ObjectFormat: algo})
+ _, _, commitID := sender.MakeCommit(t, "pushed commit")
+
+ receiver := testgit.NewRepo(t, testgit.RepoOptions{ObjectFormat: algo, Bare: true})
+ repo := receiver.OpenRepository(t)
+ objectsRoot := receiver.OpenObjectsRoot(t)
+
+ packStream := sender.PackObjectsReader(t, []string{commitID.String()}, false)
+ t.Cleanup(func() {
+ _ = packStream.Close()
+ })
+
+ var (
+ input strings.Builder
+ output bufferWriteFlusher
+ )
+
+ input.WriteString(pktlineData(
+ objectid.Zero(algo).String() + " " + commitID.String() + " refs/heads/main\x00report-status-v2 atomic object-format=" + algo.String() + "\n",
+ ))
+ input.WriteString("0000")
+
+ err := receivepack.ReceivePack(
+ context.Background(),
+ &output,
+ io.MultiReader(strings.NewReader(input.String()), packStream),
+ receivepack.Options{
+ Algorithm: algo,
+ Refs: repo.Refs(),
+ ExistingObjects: repo.Objects(),
+ ObjectsRoot: objectsRoot,
+ },
+ )
+ if err != nil {
+ t.Fatalf("ReceivePack: %v", err)
+ }
+
+ got := output.String()
+ if !strings.Contains(got, "unpack ok\n") || !strings.Contains(got, "ok refs/heads/main\n") {
+ t.Fatalf("unexpected receive-pack output %q", got)
+ }
+
+ reopened := receiver.OpenRepository(t)
+
+ resolved, err := reopened.Refs().ResolveToDetached("refs/heads/main")
+ if err != nil {
+ t.Fatalf("ResolveToDetached(main): %v", err)
+ }
+
+ if resolved.ID != commitID {
+ t.Fatalf("refs/heads/main = %s, want %s", resolved.ID, commitID)
+ }
+
+ if gotType := receiver.Run(t, "cat-file", "-t", commitID.String()); gotType != "commit" {
+ t.Fatalf("cat-file -t = %q, want commit", gotType)
+ }
+
+ packs := receiver.Run(t, "count-objects", "-v")
+ if !strings.Contains(packs, "packs: 1") {
+ t.Fatalf("count-objects output missing promoted pack: %q", packs)
+ }
+ })
+}
+
+func TestReceivePackHookSeesQuarantinedObjectsAndCanRejectBeforePromotion(t *testing.T) {
+ t.Parallel()
+
+ //nolint:thelper
+ testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) {
+ t.Parallel()
+
+ sender := testgit.NewRepo(t, testgit.RepoOptions{ObjectFormat: algo})
+ _, _, commitID := sender.MakeCommit(t, "pushed commit")
+
+ receiver := testgit.NewRepo(t, testgit.RepoOptions{ObjectFormat: algo, Bare: true})
+ repo := receiver.OpenRepository(t)
+ objectsRoot := receiver.OpenObjectsRoot(t)
+
+ packStream := sender.PackObjectsReader(t, []string{commitID.String()}, false)
+ t.Cleanup(func() {
+ _ = packStream.Close()
+ })
+
+ var (
+ input strings.Builder
+ output bufferWriteFlusher
+ hookCalled bool
+ )
+
+ input.WriteString(pktlineData(
+ objectid.Zero(algo).String() + " " + commitID.String() + " refs/heads/main\x00report-status-v2 atomic object-format=" + algo.String() + "\n",
+ ))
+ input.WriteString("0000")
+
+ err := receivepack.ReceivePack(
+ context.Background(),
+ &output,
+ io.MultiReader(strings.NewReader(input.String()), packStream),
+ receivepack.Options{
+ Algorithm: algo,
+ Refs: repo.Refs(),
+ ExistingObjects: repo.Objects(),
+ ObjectsRoot: objectsRoot,
+ Hook: func(ctx context.Context, req receivepack.HookRequest) ([]receivepack.UpdateDecision, error) {
+ hookCalled = true
+
+ if len(req.Updates) != 1 || req.Updates[0].NewID != commitID {
+ t.Fatalf("unexpected hook updates: %+v", req.Updates)
+ }
+
+ _, _, err := req.ExistingObjects.ReadHeader(commitID)
+ if err == nil {
+ t.Fatalf("existing objects unexpectedly contained quarantined commit %s", commitID)
+ }
+
+ _, _, err = req.QuarantinedObjects.ReadHeader(commitID)
+ if err != nil {
+ t.Fatalf("quarantined objects missing commit %s: %v", commitID, err)
+ }
+
+ return []receivepack.UpdateDecision{{
+ Accept: false,
+ Message: "blocked by hook",
+ }}, nil
+ },
+ },
+ )
+ if err != nil {
+ t.Fatalf("ReceivePack: %v", err)
+ }
+
+ if !hookCalled {
+ t.Fatal("hook was not called")
+ }
+
+ got := output.String()
+ if !strings.Contains(got, "unpack ok\n") || !strings.Contains(got, "ng refs/heads/main blocked by hook\n") {
+ t.Fatalf("unexpected receive-pack output %q", got)
+ }
+
+ _, err = repo.Refs().Resolve("refs/heads/main")
+ if err == nil {
+ t.Fatal("refs/heads/main exists after hook rejection")
+ }
+
+ packs := receiver.Run(t, "count-objects", "-v")
+ if !strings.Contains(packs, "packs: 0") {
+ t.Fatalf("count-objects output shows unexpected promoted pack: %q", packs)
+ }
+ })
+}
+
+func TestReceivePackHookCanRejectSubsetOfNonAtomicDeleteOnlyPush(t *testing.T) {
+ t.Parallel()
+
+ //nolint:thelper
+ testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) {
+ t.Parallel()
+
+ testRepo := testgit.NewRepo(t, testgit.RepoOptions{ObjectFormat: algo})
+ _, _, commitID := testRepo.MakeCommit(t, "base")
+ testRepo.UpdateRef(t, "refs/heads/main", commitID)
+ testRepo.UpdateRef(t, "refs/heads/topic", commitID)
+
+ repo := testRepo.OpenRepository(t)
+
+ var (
+ input strings.Builder
+ output bufferWriteFlusher
+ )
+
+ input.WriteString(pktlineData(
+ commitID.String() + " " + objectid.Zero(algo).String() + " refs/heads/main\x00report-status delete-refs object-format=" + algo.String() + "\n",
+ ))
+ input.WriteString(pktlineData(
+ commitID.String() + " " + objectid.Zero(algo).String() + " refs/heads/topic\n",
+ ))
+ input.WriteString("0000")
+
+ err := receivepack.ReceivePack(context.Background(), &output, strings.NewReader(input.String()), receivepack.Options{
+ Algorithm: algo,
+ Refs: repo.Refs(),
+ ExistingObjects: repo.Objects(),
+ Hook: func(ctx context.Context, req receivepack.HookRequest) ([]receivepack.UpdateDecision, error) {
+ return []receivepack.UpdateDecision{
+ {Accept: false, Message: "leave main alone"},
+ {Accept: true},
+ }, nil
+ },
+ })
+ if err != nil {
+ t.Fatalf("ReceivePack: %v", err)
+ }
+
+ got := output.String()
+ if !strings.Contains(got, "ng refs/heads/main leave main alone\n") || !strings.Contains(got, "ok refs/heads/topic\n") {
+ t.Fatalf("unexpected receive-pack output %q", got)
+ }
+
+ _, err = repo.Refs().Resolve("refs/heads/main")
+ if err != nil {
+ t.Fatalf("Resolve(main): %v", err)
+ }
+
+ _, err = repo.Refs().Resolve("refs/heads/topic")
+ if err == nil {
+ t.Fatal("refs/heads/topic still exists after successful delete")
+ }
+ })
+}
+
+func TestReceivePackHookProgressUsesSideBand64K(t *testing.T) {
+ t.Parallel()
+
+ //nolint:thelper
+ testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) {
+ t.Parallel()
+
+ testRepo := testgit.NewRepo(t, testgit.RepoOptions{ObjectFormat: algo})
+ _, _, commitID := testRepo.MakeCommit(t, "base")
+ testRepo.UpdateRef(t, "refs/heads/main", commitID)
+
+ repo := testRepo.OpenRepository(t)
+
+ var (
+ input strings.Builder
+ output bufferWriteFlusher
+ )
+
+ input.WriteString(pktlineData(
+ commitID.String() + " " + objectid.Zero(algo).String() + " refs/heads/main\x00report-status side-band-64k atomic delete-refs object-format=" + algo.String() + "\n",
+ ))
+ input.WriteString("0000")
+
+ err := receivepack.ReceivePack(context.Background(), &output, strings.NewReader(input.String()), receivepack.Options{
+ Algorithm: algo,
+ Refs: repo.Refs(),
+ ExistingObjects: repo.Objects(),
+ Hook: func(ctx context.Context, req receivepack.HookRequest) ([]receivepack.UpdateDecision, error) {
+ _, err := io.WriteString(req.IO.Progress, "hook says hello\n")
+ if err != nil {
+ return nil, err
+ }
+
+ return []receivepack.UpdateDecision{{Accept: true}}, nil
+ },
+ })
+ if err != nil {
+ t.Fatalf("ReceivePack: %v", err)
+ }
+
+ _, sidebandWire, ok := strings.Cut(output.String(), "0000")
+ if !ok {
+ t.Fatalf("output missing advertisement flush: %q", output.String())
+ }
+
+ dec := sideband64k.NewDecoder(strings.NewReader(sidebandWire), sideband64k.ReadOptions{})
+
+ sawHookProgress := false
+
+ var frame sideband64k.Frame
+
+ for {
+ var err error
+
+ frame, err = dec.ReadFrame()
+ if err != nil {
+ t.Fatalf("ReadFrame: %v", err)
+ }
+
+ if frame.Type == sideband64k.FrameProgress && string(frame.Payload) == "hook says hello\n" {
+ sawHookProgress = true
+ }
+
+ if frame.Type == sideband64k.FrameData {
+ break
+ }
+ }
+
+ if !sawHookProgress {
+ t.Fatal("missing hook progress frame")
+ }
+
+ statusDec := pktline.NewDecoder(strings.NewReader(string(frame.Payload)), pktline.ReadOptions{})
+
+ statusFrame, err := statusDec.ReadFrame()
+ if err != nil {
+ t.Fatalf("ReadFrame(status unpack): %v", err)
+ }
+
+ if statusFrame.Type != pktline.PacketData || string(statusFrame.Payload) != "unpack ok\n" {
+ t.Fatalf("status frame = %#v", statusFrame)
+ }
+ })
+}
+
+func TestReceivePackPredefinedRejectForcePushHookRejectsNonFastForward(t *testing.T) {
+ t.Parallel()
+
+ //nolint:thelper
+ testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) {
+ t.Parallel()
+
+ testRepo := testgit.NewRepo(t, testgit.RepoOptions{ObjectFormat: algo, Bare: true})
+ _, treeID := testRepo.MakeSingleFileTree(t, "base.txt", []byte("base\n"))
+ baseID := testRepo.CommitTree(t, treeID, "base")
+ currentID := testRepo.CommitTree(t, treeID, "current", baseID)
+ forcedID := testRepo.CommitTree(t, treeID, "forced", baseID)
+ testRepo.UpdateRef(t, "refs/heads/main", currentID)
+
+ repo := testRepo.OpenRepository(t)
+ objectsRoot := testRepo.OpenObjectsRoot(t)
+ packStream := testRepo.PackObjectsReader(t, []string{forcedID.String(), "^" + currentID.String()}, false)
+ t.Cleanup(func() {
+ _ = packStream.Close()
+ })
+
+ var (
+ input strings.Builder
+ output bufferWriteFlusher
+ )
+
+ input.WriteString(pktlineData(
+ currentID.String() + " " + forcedID.String() + " refs/heads/main\x00report-status atomic object-format=" + algo.String() + "\n",
+ ))
+ input.WriteString("0000")
+
+ err := receivepack.ReceivePack(
+ context.Background(),
+ &output,
+ io.MultiReader(strings.NewReader(input.String()), packStream),
+ receivepack.Options{
+ Algorithm: algo,
+ Refs: repo.Refs(),
+ ExistingObjects: repo.Objects(),
+ ObjectsRoot: objectsRoot,
+ Hook: receivepackhooks.RejectForcePush(),
+ },
+ )
+ if err != nil {
+ t.Fatalf("ReceivePack: %v", err)
+ }
+
+ got := output.String()
+ if !strings.Contains(got, "ng refs/heads/main non-fast-forward\n") {
+ t.Fatalf("unexpected receive-pack output %q", got)
+ }
+
+ resolved, err := repo.Refs().ResolveToDetached("refs/heads/main")
+ if err != nil {
+ t.Fatalf("ResolveToDetached(main): %v", err)
+ }
+
+ if resolved.ID != currentID {
+ t.Fatalf("refs/heads/main = %s, want %s", resolved.ID, currentID)
+ }
+ })
+}
+
+func TestReceivePackReportStatusV2IncludesRefDetails(t *testing.T) {
+ t.Parallel()
+
+ //nolint:thelper
+ testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) {
+ t.Parallel()
+
+ testRepo := testgit.NewRepo(t, testgit.RepoOptions{ObjectFormat: algo})
+ _, _, commitID := testRepo.MakeCommit(t, "base")
+ testRepo.UpdateRef(t, "refs/heads/main", commitID)
+
+ repo := testRepo.OpenRepository(t)
+
+ var (
+ input strings.Builder
+ output bufferWriteFlusher
+ )
+
+ input.WriteString(pktlineData(
+ commitID.String() + " " + objectid.Zero(algo).String() + " refs/heads/main\x00report-status-v2 atomic delete-refs object-format=" + algo.String() + "\n",
+ ))
+ input.WriteString("0000")
+
+ err := receivepack.ReceivePack(context.Background(), &output, strings.NewReader(input.String()), receivepack.Options{
+ Algorithm: algo,
+ Refs: repo.Refs(),
+ ExistingObjects: repo.Objects(),
+ })
+ if err != nil {
+ t.Fatalf("ReceivePack: %v", err)
+ }
+
+ got := output.String()
+ if !strings.Contains(got, "option refname refs/heads/main\n") {
+ t.Fatalf("missing option refname in %q", got)
+ }
+
+ if !strings.Contains(got, "option old-oid "+commitID.String()+"\n") {
+ t.Fatalf("missing option old-oid in %q", got)
+ }
+
+ if !strings.Contains(got, "option new-oid "+objectid.Zero(algo).String()+"\n") {
+ t.Fatalf("missing option new-oid in %q", got)
+ }
+ })
+}
+
+func TestReceivePackGitPushCreatesBranch(t *testing.T) {
+ t.Parallel()
+
+ testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { //nolint:thelper
+ t.Parallel()
+
+ sender := testgit.NewRepo(t, testgit.RepoOptions{ObjectFormat: algo})
+ _, _, commitID := sender.MakeCommit(t, "pushed commit")
+ sender.UpdateRef(t, "refs/heads/main", commitID)
+
+ receiver := testgit.NewRepo(t, testgit.RepoOptions{ObjectFormat: algo, Bare: true})
+ repo := receiver.OpenRepository(t)
+ objectsRoot := receiver.OpenObjectsRoot(t)
+
+ stdout, stderr, clientErr, serverErr := runGitPushFD(
+ t,
+ sender,
+ receivepack.Options{
+ Algorithm: algo,
+ Refs: repo.Refs(),
+ ExistingObjects: repo.Objects(),
+ ObjectsRoot: objectsRoot,
+ },
+ "push", "--porcelain", "fd::3,4/test", "refs/heads/main:refs/heads/main",
+ )
+ if clientErr != nil {
+ t.Fatalf("git push failed: %v\nstdout=%s\nstderr=%s", clientErr, stdout, stderr)
+ }
+
+ if serverErr != nil {
+ t.Fatalf("ReceivePack: %v", serverErr)
+ }
+
+ resolved, err := receiver.OpenRepository(t).Refs().ResolveToDetached("refs/heads/main")
+ if err != nil {
+ t.Fatalf("ResolveToDetached(main): %v", err)
+ }
+
+ if resolved.ID != commitID {
+ t.Fatalf("refs/heads/main = %s, want %s", resolved.ID, commitID)
+ }
+ })
+}
+
+func TestReceivePackGitPushRefUpdateWithoutNewObjectsSucceeds(t *testing.T) {
+ t.Parallel()
+
+ testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { //nolint:thelper
+ t.Parallel()
+
+ sender := testgit.NewRepo(t, testgit.RepoOptions{ObjectFormat: algo})
+ blobID, treeID := sender.MakeSingleFileTree(t, "base.txt", []byte("base\n"))
+ commitID := sender.CommitTree(t, treeID, "base")
+ sender.UpdateRef(t, "refs/heads/main", commitID)
+
+ receiver := testgit.NewRepo(t, testgit.RepoOptions{ObjectFormat: algo, Bare: true})
+ receiver.HashObject(t, "blob", sender.RunBytes(t, "cat-file", "blob", blobID.String()))
+ receiver.HashObject(t, "tree", sender.RunBytes(t, "cat-file", "tree", treeID.String()))
+ receiver.HashObject(t, "commit", sender.RunBytes(t, "cat-file", "commit", commitID.String()))
+ receiver.UpdateRef(t, "refs/heads/main", commitID)
+
+ repo := receiver.OpenRepository(t)
+ objectsRoot := receiver.OpenObjectsRoot(t)
+
+ stdout, stderr, clientErr, serverErr := runGitPushFD(
+ t,
+ sender,
+ receivepack.Options{
+ Algorithm: algo,
+ Refs: repo.Refs(),
+ ExistingObjects: repo.Objects(),
+ ObjectsRoot: objectsRoot,
+ },
+ "push", "--porcelain", "fd::3,4/test", "refs/heads/main:refs/heads/topic",
+ )
+ if clientErr != nil {
+ t.Fatalf("git push failed: %v\nstdout=%s\nstderr=%s", clientErr, stdout, stderr)
+ }
+
+ if serverErr != nil {
+ t.Fatalf("ReceivePack: %v", serverErr)
+ }
+
+ resolved, err := receiver.OpenRepository(t).Refs().ResolveToDetached("refs/heads/topic")
+ if err != nil {
+ t.Fatalf("ResolveToDetached(topic): %v", err)
+ }
+
+ if resolved.ID != commitID {
+ t.Fatalf("refs/heads/topic = %s, want %s", resolved.ID, commitID)
+ }
+
+ packs := receiver.Run(t, "count-objects", "-v")
+ if !strings.Contains(packs, "packs: 0") {
+ t.Fatalf("count-objects output shows unexpected promoted pack: %q", packs)
+ }
+ })
+}
+
+func TestReceivePackGitPushAtomicDelete(t *testing.T) {
+ t.Parallel()
+
+ testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { //nolint:thelper
+ t.Parallel()
+
+ sender := testgit.NewRepo(t, testgit.RepoOptions{ObjectFormat: algo})
+ receiver := testgit.NewRepo(t, testgit.RepoOptions{ObjectFormat: algo, Bare: true})
+ _, _, commitID := receiver.MakeCommit(t, "base")
+ receiver.UpdateRef(t, "refs/heads/main", commitID)
+
+ repo := receiver.OpenRepository(t)
+
+ stdout, stderr, clientErr, serverErr := runGitPushFD(
+ t,
+ sender,
+ receivepack.Options{
+ Algorithm: algo,
+ Refs: repo.Refs(),
+ ExistingObjects: repo.Objects(),
+ },
+ "push", "--porcelain", "--atomic", "fd::3,4/test", ":refs/heads/main",
+ )
+ if clientErr != nil {
+ t.Fatalf("git push failed: %v\nstdout=%s\nstderr=%s", clientErr, stdout, stderr)
+ }
+
+ if serverErr != nil {
+ t.Fatalf("ReceivePack: %v", serverErr)
+ }
+
+ _, err := receiver.OpenRepository(t).Refs().Resolve("refs/heads/main")
+ if err == nil {
+ t.Fatal("refs/heads/main still exists after delete push")
+ }
+ })
+}
+
+func TestReceivePackGitPushRejectsForcedUpdateViaHook(t *testing.T) {
+ t.Parallel()
+
+ testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { //nolint:thelper
+ t.Parallel()
+
+ sender := testgit.NewRepo(t, testgit.RepoOptions{ObjectFormat: algo})
+ blobID, treeID := sender.MakeSingleFileTree(t, "base.txt", []byte("base\n"))
+ baseID := sender.CommitTree(t, treeID, "base")
+ currentID := sender.CommitTree(t, treeID, "current", baseID)
+ forcedID := sender.CommitTree(t, treeID, "forced", baseID)
+ sender.UpdateRef(t, "refs/heads/main", forcedID)
+
+ receiver := testgit.NewRepo(t, testgit.RepoOptions{ObjectFormat: algo, Bare: true})
+ receiver.HashObject(t, "blob", sender.RunBytes(t, "cat-file", "blob", blobID.String()))
+ receiver.HashObject(t, "tree", sender.RunBytes(t, "cat-file", "tree", treeID.String()))
+ receiver.HashObject(t, "commit", sender.RunBytes(t, "cat-file", "commit", baseID.String()))
+ receiver.HashObject(t, "commit", sender.RunBytes(t, "cat-file", "commit", currentID.String()))
+ receiver.UpdateRef(t, "refs/heads/main", currentID)
+
+ repo := receiver.OpenRepository(t)
+ objectsRoot := receiver.OpenObjectsRoot(t)
+
+ stdout, stderr, clientErr, serverErr := runGitPushFD(
+ t,
+ sender,
+ receivepack.Options{
+ Algorithm: algo,
+ Refs: repo.Refs(),
+ ExistingObjects: repo.Objects(),
+ ObjectsRoot: objectsRoot,
+ Hook: receivepackhooks.RejectForcePush(),
+ },
+ "push", "--porcelain", "--force", "fd::3,4/test", "refs/heads/main:refs/heads/main",
+ )
+ if clientErr == nil {
+ t.Fatalf("git push unexpectedly succeeded\nstdout=%s\nstderr=%s", stdout, stderr)
+ }
+
+ if serverErr != nil {
+ t.Fatalf("ReceivePack: %v", serverErr)
+ }
+
+ if !strings.Contains(stdout, "non-fast-forward") && !strings.Contains(stderr, "non-fast-forward") {
+ t.Fatalf("git push output missing non-fast-forward message\nstdout=%s\nstderr=%s", stdout, stderr)
+ }
+
+ resolved, err := receiver.OpenRepository(t).Refs().ResolveToDetached("refs/heads/main")
+ if err != nil {
+ t.Fatalf("ResolveToDetached(main): %v", err)
+ }
+
+ if resolved.ID != currentID {
+ t.Fatalf("refs/heads/main = %s, want %s", resolved.ID, currentID)
+ }
+ })
+}
+
+type bufferWriteFlusher struct {
+ strings.Builder
+}
+
+func (bufferWriteFlusher) Flush() error {
+ return nil
+}
+
+func pktlineData(payload string) string {
+ return fmt.Sprintf("%04x%s", len(payload)+4, payload)
+}
+
+type fileWriteFlusher struct {
+ *os.File
+}
+
+func (fileWriteFlusher) Flush() error {
+ return nil
+}
+
+func runGitPushFD(
+ tb testing.TB,
+ sender *testgit.TestRepo,
+ opts receivepack.Options,
+ gitArgs ...string,
+) (stdout string, stderr string, clientErr error, serverErr error) {
+ tb.Helper()
+
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+
+ serverRead, clientWrite, err := os.Pipe()
+ if err != nil {
+ tb.Fatalf("os.Pipe(serverRead/clientWrite): %v", err)
+ }
+
+ clientRead, serverWrite, err := os.Pipe()
+ if err != nil {
+ tb.Fatalf("os.Pipe(clientRead/serverWrite): %v", err)
+ }
+
+ tb.Cleanup(func() {
+ _ = serverRead.Close()
+ _ = clientWrite.Close()
+ _ = clientRead.Close()
+ _ = serverWrite.Close()
+ })
+
+ go func() {
+ <-ctx.Done()
+
+ _ = serverRead.Close()
+ _ = clientWrite.Close()
+ _ = clientRead.Close()
+ _ = serverWrite.Close()
+ }()
+
+ serverErrCh := make(chan error, 1)
+
+ go func() {
+ defer func() {
+ _ = serverRead.Close()
+ _ = serverWrite.Close()
+ }()
+
+ serverErrCh <- receivepack.ReceivePack(
+ ctx,
+ fileWriteFlusher{serverWrite},
+ serverRead,
+ opts,
+ )
+ }()
+
+ stdoutBytes, stderrBytes, clientErr := sender.RunWithExtraFilesEnvContextE(
+ tb,
+ ctx,
+ nil,
+ []*os.File{clientRead, clientWrite},
+ gitArgs...,
+ )
+ _ = clientRead.Close()
+ _ = clientWrite.Close()
+
+ serverErr = <-serverErrCh
+
+ if ctx.Err() != nil {
+ tb.Fatalf(
+ "git push fd:: timed out\nstdout=%s\nstderr=%s\nclientErr=%v\nserverErr=%v",
+ stdoutBytes,
+ stderrBytes,
+ clientErr,
+ serverErr,
+ )
+ }
+
+ return string(stdoutBytes), string(stderrBytes), clientErr, serverErr
+}
diff --git a/network/receivepack/options.go b/network/receivepack/options.go
new file mode 100644
index 00000000..139c3839
--- /dev/null
+++ b/network/receivepack/options.go
@@ -0,0 +1,68 @@
+package receivepack
+
+import (
+ "os"
+
+ objectid "codeberg.org/lindenii/furgit/object/id"
+ objectstorer "codeberg.org/lindenii/furgit/object/storer"
+ refstore "codeberg.org/lindenii/furgit/ref/store"
+)
+
+// Options configures one receive-pack invocation.
+//
+// ReceivePack borrows all configured dependencies.
+//
+// Refs and ExistingObjects are required and must be non-nil.
+// ObjectsRoot is required if the invocation may need to ingest or promote a
+// pack.
+type Options struct {
+ // GitProtocol is the raw Git protocol version string from the transport,
+ // such as "version=1".
+ GitProtocol string
+ // Algorithm is the repository object ID algorithm used by the push session.
+ Algorithm objectid.Algorithm
+ // Refs is the reference store visible to the push.
+ Refs refstore.ReadWriteStore
+ // ExistingObjects is the object store visible to the push before any newly
+ // uploaded quarantined objects are promoted.
+ ExistingObjects objectstorer.Store
+ // ObjectsRoot is the permanent object storage root beneath which per-push
+ // quarantine directories are derived.
+ ObjectsRoot *os.Root
+ // PromotedObjectPermissions, when non-nil, is applied to objects and
+ // directories moved from quarantine into the permanent object store.
+ PromotedObjectPermissions *PromotedObjectPermissions
+ // Hook, when non-nil, runs after pack ingestion into quarantine and before
+ // quarantine promotion or ref updates. Hook is borrowed for the duration of
+ // ReceivePack.
+ Hook Hook
+ // Agent is the receive-pack agent string advertised via capability.
+ //
+ // When empty, ReceivePack derives one from build info and falls back to
+ // "furgit".
+ Agent string
+ // SessionID is the advertised receive-pack session-id capability value.
+ //
+ // When empty, ReceivePack generates one random value per invocation.
+ SessionID string
+ // PushCertNonce is the advertised push-cert nonce capability value.
+ //
+ // When empty, ReceivePack generates one random value per invocation.
+ PushCertNonce string
+}
+
+func validateOptions(opts Options) error {
+ if opts.Algorithm == 0 {
+ return ErrMissingAlgorithm
+ }
+
+ if opts.Refs == nil {
+ return ErrMissingRefs
+ }
+
+ if opts.ExistingObjects == nil {
+ return ErrMissingObjects
+ }
+
+ return nil
+}
diff --git a/network/receivepack/permissions.go b/network/receivepack/permissions.go
new file mode 100644
index 00000000..1aaa5a0c
--- /dev/null
+++ b/network/receivepack/permissions.go
@@ -0,0 +1,27 @@
+package receivepack
+
+import (
+ "io/fs"
+
+ "codeberg.org/lindenii/furgit/network/receivepack/service"
+)
+
+// PromotedObjectPermissions configures the destination permissions applied to
+// objects and directories promoted out of quarantine.
+type PromotedObjectPermissions struct {
+ DirMode fs.FileMode
+ FileMode fs.FileMode
+}
+
+func translatePromotedObjectPermissions(
+ perms *PromotedObjectPermissions,
+) *service.PromotedObjectPermissions {
+ if perms == nil {
+ return nil
+ }
+
+ return &service.PromotedObjectPermissions{
+ DirMode: perms.DirMode,
+ FileMode: perms.FileMode,
+ }
+}
diff --git a/network/receivepack/receivepack.go b/network/receivepack/receivepack.go
new file mode 100644
index 00000000..4ab4962f
--- /dev/null
+++ b/network/receivepack/receivepack.go
@@ -0,0 +1,147 @@
+package receivepack
+
+import (
+ "context"
+ "io"
+
+ "codeberg.org/lindenii/furgit/network/protocol/pktline"
+ common "codeberg.org/lindenii/furgit/network/protocol/v0v1/server"
+ protoreceive "codeberg.org/lindenii/furgit/network/protocol/v0v1/server/receivepack"
+ "codeberg.org/lindenii/furgit/network/receivepack/service"
+)
+
+// TODO: Some more designing to do. In particular, we'd like to have access to
+// commit graphs and stored object abstractions and such here, especially because
+// hooks might want to access full repos, but we risk creating
+// circular dependencies if we import repository/ here. Might need an interface-ish
+// design, but that risks being over-complicated.
+// Theoretically we could also just give the hooks an os.Root but that
+// feels a bit ugly.
+
+// ReceivePack serves one receive-pack session over r/w.
+//
+// ReceivePack borrows r, w, and all dependencies reachable through opts for
+// the duration of the call. It does not close any of them.
+func ReceivePack(
+ ctx context.Context,
+ w pktline.WriteFlusher,
+ r io.Reader,
+ opts Options,
+) error {
+ err := validateOptions(opts)
+ if err != nil {
+ return err
+ }
+
+ version := parseVersion(opts.GitProtocol)
+
+ base := common.NewSession(r, w, common.Options{
+ Version: version,
+ Algorithm: opts.Algorithm,
+ })
+
+ agent := opts.Agent
+ if agent == "" {
+ agent = defaultAgent()
+ }
+
+ sessionID := opts.SessionID
+ if sessionID == "" {
+ sessionID = defaultSessionID()
+ }
+
+ pushCertNonce := opts.PushCertNonce
+ if pushCertNonce == "" {
+ pushCertNonce = defaultPushCertNonce()
+ }
+
+ protoSession := protoreceive.NewSession(base, protoreceive.Capabilities{
+ ReportStatus: true,
+ ReportStatusV2: true,
+ DeleteRefs: true,
+ SideBand64K: true,
+ Quiet: true,
+ Atomic: true,
+ OfsDelta: true,
+ PushOptions: true,
+ PushCertNonce: pushCertNonce,
+ SessionID: sessionID,
+ ObjectFormat: opts.Algorithm,
+ Agent: agent,
+ })
+
+ refs, err := advertisedRefs(opts)
+ if err != nil {
+ return err
+ }
+
+ err = protoSession.AdvertiseRefs(common.Advertisement{Refs: refs})
+ if err != nil {
+ return err
+ }
+
+ err = base.FlushIO()
+ if err != nil {
+ return err
+ }
+
+ req, err := protoSession.ReadRequest()
+ if err != nil {
+ return err
+ }
+
+ progressWriter := protoSession.ProgressWriter()
+ progressFlush := base.FlushIO
+
+ if req.Capabilities.Quiet {
+ progressWriter = io.Discard
+ progressFlush = nil
+ }
+
+ serviceReq := &service.Request{
+ Commands: translateCommands(req.Commands),
+ PushOptions: append([]string(nil), req.PushOptions...),
+ Atomic: req.Capabilities.Atomic,
+ DeleteOnly: req.DeleteOnly,
+ PackExpected: req.PackExpected,
+ Pack: r,
+ }
+
+ svc := service.New(service.Options{
+ Algorithm: opts.Algorithm,
+ Refs: opts.Refs,
+ ExistingObjects: opts.ExistingObjects,
+ ObjectsRoot: opts.ObjectsRoot,
+ Progress: progressWriter,
+ ProgressFlush: progressFlush,
+ PromotedObjectPermissions: translatePromotedObjectPermissions(
+ opts.PromotedObjectPermissions,
+ ),
+ Hook: translateHook(opts.Hook),
+ HookIO: service.HookIO{
+ Progress: progressWriter,
+ Error: protoSession.ErrorWriter(),
+ },
+ })
+
+ result, err := svc.Execute(ctx, serviceReq)
+ if err != nil {
+ return err
+ }
+
+ protoResult := translateResult(result)
+
+ if req.Capabilities.ReportStatusV2 {
+ err = protoSession.WriteReportStatusV2(protoResult)
+ if err != nil {
+ return err
+ }
+ } else if req.Capabilities.ReportStatus {
+ err = protoSession.WriteReportStatus(protoResult)
+ if err != nil {
+ return err
+ }
+ }
+
+ return base.FlushIO()
+}
diff --git a/network/receivepack/results.go b/network/receivepack/results.go
new file mode 100644
index 00000000..d43bee73
--- /dev/null
+++ b/network/receivepack/results.go
@@ -0,0 +1,26 @@
+package receivepack
+
+import (
+ protoreceive "codeberg.org/lindenii/furgit/network/protocol/v0v1/server/receivepack"
+ "codeberg.org/lindenii/furgit/network/receivepack/service"
+)
+
+func translateResult(result *service.Result) protoreceive.ReportStatusResult {
+ out := protoreceive.ReportStatusResult{
+ UnpackError: result.UnpackError,
+ Commands: make([]protoreceive.CommandResult, 0, len(result.Commands)),
+ }
+
+ for _, command := range result.Commands {
+ out.Commands = append(out.Commands, protoreceive.CommandResult{
+ Name: command.Name,
+ Error: command.Error,
+ RefName: command.RefName,
+ OldID: command.OldID,
+ NewID: command.NewID,
+ ForcedUpdate: command.ForcedUpdate,
+ })
+ }
+
+ return out
+}
diff --git a/network/receivepack/service/apply.go b/network/receivepack/service/apply.go
new file mode 100644
index 00000000..8fa500ca
--- /dev/null
+++ b/network/receivepack/service/apply.go
@@ -0,0 +1,134 @@
+package service
+
+import (
+ "codeberg.org/lindenii/furgit/internal/utils"
+ objectid "codeberg.org/lindenii/furgit/object/id"
+ refstore "codeberg.org/lindenii/furgit/ref/store"
+)
+
+func (service *Service) applyAtomic(result *Result, commands []Command) error {
+ total := len(commands)
+ utils.BestEffortFprintf(service.opts.Progress, "updating refs: 0/%d\r", total)
+
+ tx, err := service.opts.Refs.BeginTransaction()
+ if err != nil {
+ return err
+ }
+
+ for i, command := range commands {
+ err = queueWriteTransaction(tx, command)
+ if err != nil {
+ _ = tx.Abort()
+
+ fillCommandErrors(result, commands, err.Error())
+ utils.BestEffortFprintf(service.opts.Progress, "updating refs: failed at %d/%d.\n", i+1, total)
+
+ return nil
+ }
+
+ utils.BestEffortFprintf(service.opts.Progress, "updating refs: %d/%d\r", i+1, total)
+ }
+
+ err = tx.Commit()
+ if err != nil {
+ fillCommandErrors(result, commands, err.Error())
+ utils.BestEffortFprintf(service.opts.Progress, "updating refs: failed at commit.\n")
+
+ return nil
+ }
+
+ result.Applied = true
+ for _, command := range commands {
+ result.Commands = append(result.Commands, successCommandResult(command))
+ }
+
+ utils.BestEffortFprintf(service.opts.Progress, "updating refs: done.\n")
+
+ return nil
+}
+
+func (service *Service) applyBatch(result *Result, commands []Command) error {
+ total := len(commands)
+
+ utils.BestEffortFprintf(service.opts.Progress, "updating refs...\r")
+
+ batch, err := service.opts.Refs.BeginBatch()
+ if err != nil {
+ return err
+ }
+
+ for _, command := range commands {
+ queueWriteBatch(batch, command)
+ }
+
+ batchResults, err := batch.Apply()
+ if err != nil && len(batchResults) == 0 {
+ utils.BestEffortFprintf(service.opts.Progress, "updating refs: failed at apply.\n")
+
+ return err
+ }
+
+ appliedAny := false
+ failedCount := 0
+
+ for i, command := range commands {
+ item := successCommandResult(command)
+ if i < len(batchResults) && batchResults[i].Error != nil {
+ item.Error = batchResults[i].Error.Error()
+ failedCount++
+ } else {
+ appliedAny = true
+ }
+
+ result.Commands = append(result.Commands, item)
+
+ utils.BestEffortFprintf(service.opts.Progress, "updating refs: %d/%d\r", i+1, total)
+ }
+
+ result.Applied = appliedAny
+
+ if failedCount == 0 {
+ utils.BestEffortFprintf(service.opts.Progress, "updating refs: done.\n")
+ } else {
+ utils.BestEffortFprintf(service.opts.Progress, "updating refs: failed (%d/%d).\n", failedCount, total)
+ }
+
+ return nil
+}
+
+func queueWriteTransaction(tx refstore.Transaction, command Command) error {
+ if isDelete(command) {
+ return tx.Delete(command.Name, command.OldID)
+ }
+
+ if command.OldID == objectid.Zero(command.OldID.Algorithm()) {
+ return tx.Create(command.Name, command.NewID)
+ }
+
+ return tx.Update(command.Name, command.NewID, command.OldID)
+}
+
+func queueWriteBatch(batch refstore.Batch, command Command) {
+ if isDelete(command) {
+ batch.Delete(command.Name, command.OldID)
+
+ return
+ }
+
+ if command.OldID == objectid.Zero(command.OldID.Algorithm()) {
+ batch.Create(command.Name, command.NewID)
+
+ return
+ }
+
+ batch.Update(command.Name, command.NewID, command.OldID)
+}
+
+func successCommandResult(command Command) CommandResult {
+ return CommandResult{
+ Name: command.Name,
+ RefName: command.Name,
+ OldID: objectIDPointer(command.OldID),
+ NewID: objectIDPointer(command.NewID),
+ }
+}
diff --git a/network/receivepack/service/command.go b/network/receivepack/service/command.go
new file mode 100644
index 00000000..0fd8961e
--- /dev/null
+++ b/network/receivepack/service/command.go
@@ -0,0 +1,32 @@
+package service
+
+import objectid "codeberg.org/lindenii/furgit/object/id"
+
+// 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,
+ RefName: command.Name,
+ OldID: objectIDPointer(command.OldID),
+ NewID: objectIDPointer(command.NewID),
+ })
+ }
+}
+
+func isDelete(command Command) bool {
+ return command.NewID == objectid.Zero(command.NewID.Algorithm())
+}
+
+func objectIDPointer(id objectid.ObjectID) *objectid.ObjectID {
+ out := id
+
+ return &out
+}
diff --git a/network/receivepack/service/command_result.go b/network/receivepack/service/command_result.go
new file mode 100644
index 00000000..37549f08
--- /dev/null
+++ b/network/receivepack/service/command_result.go
@@ -0,0 +1,13 @@
+package service
+
+import objectid "codeberg.org/lindenii/furgit/object/id"
+
+// CommandResult is one per-command execution result.
+type CommandResult struct {
+ Name string
+ Error string
+ RefName string
+ OldID *objectid.ObjectID
+ NewID *objectid.ObjectID
+ ForcedUpdate bool
+}
diff --git a/network/receivepack/service/doc.go b/network/receivepack/service/doc.go
new file mode 100644
index 00000000..37be23f4
--- /dev/null
+++ b/network/receivepack/service/doc.go
@@ -0,0 +1,6 @@
+// Package service implements the protocol-independent receive-pack service.
+//
+// A Service borrows the stores, roots, hooks, and I/O endpoints supplied in
+// Options. Callers retain ownership of those dependencies and must keep them
+// valid for each Execute call that uses them.
+package service
diff --git a/network/receivepack/service/execute.go b/network/receivepack/service/execute.go
new file mode 100644
index 00000000..9f373e0d
--- /dev/null
+++ b/network/receivepack/service/execute.go
@@ -0,0 +1,123 @@
+package service
+
+import (
+ "context"
+ "os"
+
+ "codeberg.org/lindenii/furgit/internal/utils"
+)
+
+// Execute validates one receive-pack request, optionally ingests its pack into
+// quarantine, runs the optional hook, and applies allowed ref updates.
+func (service *Service) Execute(ctx context.Context, req *Request) (*Result, error) {
+ result := &Result{
+ Commands: make([]CommandResult, 0, len(req.Commands)),
+ }
+
+ var (
+ quarantineName string
+ quarantineRoot *os.Root
+ err error
+ )
+
+ quarantineName, quarantineRoot, ok := service.ingestQuarantine(result, req.Commands, req)
+ if !ok {
+ return result, nil
+ }
+
+ if quarantineRoot != nil {
+ defer func() {
+ _ = quarantineRoot.Close()
+ _ = service.opts.ObjectsRoot.RemoveAll(quarantineName)
+ }()
+ }
+
+ for _, command := range req.Commands {
+ result.Planned = append(result.Planned, PlannedUpdate{
+ Name: command.Name,
+ OldID: command.OldID,
+ NewID: command.NewID,
+ Delete: isDelete(command),
+ })
+ }
+
+ if len(req.Commands) == 0 {
+ return result, nil
+ }
+
+ allowedCommands, allowedIndices, rejected, ok, errText := service.runHook(
+ ctx,
+ req,
+ req.Commands,
+ quarantineName,
+ )
+ if !ok {
+ fillCommandErrors(result, req.Commands, errText)
+
+ return result, nil
+ }
+
+ if req.Atomic && len(rejected) != 0 {
+ result.Commands = make([]CommandResult, 0, len(req.Commands))
+ for index, command := range req.Commands {
+ message := rejected[index]
+ if message == "" {
+ message = "atomic push rejected by hook"
+ }
+
+ result.Commands = append(result.Commands, resultForHookRejection(command, message))
+ }
+
+ return result, nil
+ }
+
+ if len(allowedCommands) == 0 {
+ result.Commands = mergeCommandResults(req.Commands, rejected, nil, nil)
+
+ return result, nil
+ }
+
+ if req.PackExpected && quarantineRoot != nil {
+ // Git migrates quarantined objects into permanent storage immediately
+ // before starting ref updates.
+ utils.BestEffortFprintf(service.opts.Progress, "promoting quarantine...\r")
+
+ err = service.promoteQuarantine(quarantineName, quarantineRoot)
+ if err != nil {
+ utils.BestEffortFprintf(service.opts.Progress, "promoting quarantine: failed: %v.\n", err)
+
+ result.UnpackError = err.Error()
+ fillCommandErrors(result, req.Commands, err.Error())
+
+ return result, nil
+ }
+
+ utils.BestEffortFprintf(service.opts.Progress, "promoting quarantine: done.\n")
+ }
+
+ if req.Atomic {
+ subresult := &Result{}
+
+ err := service.applyAtomic(subresult, allowedCommands)
+ if err != nil {
+ return result, err
+ }
+
+ result.Commands = mergeCommandResults(req.Commands, rejected, subresult.Commands, allowedIndices)
+ result.Applied = subresult.Applied
+
+ return result, nil
+ }
+
+ subresult := &Result{}
+
+ err = service.applyBatch(subresult, allowedCommands)
+ if err != nil {
+ return result, err
+ }
+
+ result.Commands = mergeCommandResults(req.Commands, rejected, subresult.Commands, allowedIndices)
+ result.Applied = subresult.Applied
+
+ return result, nil
+}
diff --git a/network/receivepack/service/hook.go b/network/receivepack/service/hook.go
new file mode 100644
index 00000000..750720dd
--- /dev/null
+++ b/network/receivepack/service/hook.go
@@ -0,0 +1,45 @@
+package service
+
+import (
+ "context"
+ "io"
+
+ objectid "codeberg.org/lindenii/furgit/object/id"
+ objectstorer "codeberg.org/lindenii/furgit/object/storer"
+ refstore "codeberg.org/lindenii/furgit/ref/store"
+)
+
+type HookIO struct {
+ Progress io.Writer
+ Error io.Writer
+}
+
+type RefUpdate struct {
+ Name string
+ OldID objectid.ObjectID
+ NewID objectid.ObjectID
+}
+
+type UpdateDecision struct {
+ Accept bool
+ Message string
+}
+
+// HookRequest is the borrowed view passed to one Hook invocation.
+//
+// Refs, ExistingObjects, and QuarantinedObjects are borrowed and are only
+// valid for the duration of the hook call.
+type HookRequest struct {
+ Refs refstore.ReadingStore
+ ExistingObjects objectstorer.Store
+ QuarantinedObjects objectstorer.Store
+ Updates []RefUpdate
+ PushOptions []string
+ IO HookIO
+}
+
+// Hook is an optional per-request validation hook.
+//
+// Hook borrows the data and stores in HookRequest only for the duration of the
+// call.
+type Hook func(context.Context, HookRequest) ([]UpdateDecision, error)
diff --git a/network/receivepack/service/hook_apply.go b/network/receivepack/service/hook_apply.go
new file mode 100644
index 00000000..5bd8f596
--- /dev/null
+++ b/network/receivepack/service/hook_apply.go
@@ -0,0 +1,44 @@
+package service
+
+func buildHookUpdates(commands []Command) []RefUpdate {
+ updates := make([]RefUpdate, 0, len(commands))
+ for _, command := range commands {
+ updates = append(updates, RefUpdate{
+ Name: command.Name,
+ OldID: command.OldID,
+ NewID: command.NewID,
+ })
+ }
+
+ return updates
+}
+
+func resultForHookRejection(command Command, message string) CommandResult {
+ result := successCommandResult(command)
+ result.Error = message
+
+ return result
+}
+
+func mergeCommandResults(
+ commands []Command,
+ rejected map[int]string,
+ applied []CommandResult,
+ appliedIndices []int,
+) []CommandResult {
+ out := make([]CommandResult, len(commands))
+
+ for index, message := range rejected {
+ out[index] = resultForHookRejection(commands[index], message)
+ }
+
+ for i, appliedResult := range applied {
+ if i >= len(appliedIndices) {
+ break
+ }
+
+ out[appliedIndices[i]] = appliedResult
+ }
+
+ return out
+}
diff --git a/network/receivepack/service/ingest_quarantine.go b/network/receivepack/service/ingest_quarantine.go
new file mode 100644
index 00000000..8e3e2455
--- /dev/null
+++ b/network/receivepack/service/ingest_quarantine.go
@@ -0,0 +1,144 @@
+package service
+
+import (
+ "os"
+
+ "codeberg.org/lindenii/furgit/internal/utils"
+ "codeberg.org/lindenii/furgit/packfile/ingest"
+)
+
+func (service *Service) ingestQuarantine(
+ result *Result,
+ commands []Command,
+ req *Request,
+) (string, *os.Root, bool) {
+ if !req.PackExpected {
+ return "", nil, true
+ }
+
+ if req.Pack == nil {
+ utils.BestEffortFprintf(service.opts.Progress, "unpack failed: missing pack stream.\n")
+
+ result.UnpackError = "missing pack stream"
+ fillCommandErrors(result, commands, "missing pack stream")
+
+ return "", nil, false
+ }
+
+ if service.opts.ObjectsRoot == nil {
+ utils.BestEffortFprintf(service.opts.Progress, "unpack failed: objects root not configured.\n")
+
+ result.UnpackError = "objects root not configured"
+ fillCommandErrors(result, commands, "objects root not configured")
+
+ return "", nil, false
+ }
+
+ var err error
+
+ err = service.opts.ExistingObjects.Refresh()
+ if err != nil {
+ utils.BestEffortFprintf(service.opts.Progress, "unpack failed: refresh existing objects: %v.\n", err)
+
+ result.UnpackError = err.Error()
+ fillCommandErrors(result, commands, err.Error())
+
+ return "", nil, false
+ }
+
+ pending, err := ingest.Ingest(
+ req.Pack,
+ service.opts.Algorithm,
+ ingest.Options{
+ FixThin: true,
+ WriteRev: true,
+ Base: service.opts.ExistingObjects,
+ Progress: service.opts.Progress,
+ ProgressFlush: service.opts.ProgressFlush,
+ },
+ )
+ if err != nil {
+ utils.BestEffortFprintf(service.opts.Progress, "unpack failed: %v.\n", err)
+
+ result.UnpackError = err.Error()
+ fillCommandErrors(result, commands, err.Error())
+
+ return "", nil, false
+ }
+
+ if pending.Header().ObjectCount == 0 {
+ discarded, err := pending.Discard()
+ if err != nil {
+ utils.BestEffortFprintf(service.opts.Progress, "unpack failed: %v.\n", err)
+
+ result.UnpackError = err.Error()
+ fillCommandErrors(result, commands, err.Error())
+
+ return "", nil, false
+ }
+
+ result.Ingest = &ingest.Result{
+ PackHash: discarded.PackHash,
+ ObjectCount: discarded.ObjectCount,
+ }
+
+ utils.BestEffortFprintf(
+ service.opts.Progress,
+ "unpacking: done (%d objects, %s).\n",
+ discarded.ObjectCount,
+ discarded.PackHash,
+ )
+
+ return "", nil, true
+ }
+
+ utils.BestEffortFprintf(service.opts.Progress, "creating quarantine...\r")
+
+ quarantineName, quarantineRoot, err := service.createQuarantineRoot()
+ if err != nil {
+ utils.BestEffortFprintf(service.opts.Progress, "unpack failed: %v.\n", err)
+
+ result.UnpackError = err.Error()
+ fillCommandErrors(result, commands, err.Error())
+
+ return "", nil, false
+ }
+
+ quarantinePackRoot, err := service.openQuarantinePackRoot(quarantineRoot)
+ if err != nil {
+ utils.BestEffortFprintf(service.opts.Progress, "unpack failed: %v.\n", err)
+
+ result.UnpackError = err.Error()
+ fillCommandErrors(result, commands, err.Error())
+
+ _ = quarantineRoot.Close()
+ _ = service.opts.ObjectsRoot.RemoveAll(quarantineName)
+
+ return "", nil, false
+ }
+
+ utils.BestEffortFprintf(service.opts.Progress, "creating quarantine: done.\n")
+ utils.BestEffortFprintf(service.opts.Progress, "unpacking...\r")
+
+ ingested, err := pending.Continue(quarantinePackRoot)
+
+ _ = quarantinePackRoot.Close()
+
+ if err != nil {
+ utils.BestEffortFprintf(service.opts.Progress, "unpack failed: %v.\n", err)
+
+ result.UnpackError = err.Error()
+ fillCommandErrors(result, commands, err.Error())
+
+ _ = quarantineRoot.Close()
+ _ = service.opts.ObjectsRoot.RemoveAll(quarantineName)
+
+ return "", nil, false
+ }
+
+ utils.BestEffortFprintf(service.opts.Progress, "unpacking: done (%d objects, %s).\n", ingested.ObjectCount, ingested.PackHash)
+
+ result.Ingest = &ingested
+
+ return quarantineName, quarantineRoot, true
+}
diff --git a/network/receivepack/service/options.go b/network/receivepack/service/options.go
new file mode 100644
index 00000000..7edf4f06
--- /dev/null
+++ b/network/receivepack/service/options.go
@@ -0,0 +1,36 @@
+package service
+
+import (
+ "io"
+ "io/fs"
+ "os"
+
+ objectid "codeberg.org/lindenii/furgit/object/id"
+ objectstorer "codeberg.org/lindenii/furgit/object/storer"
+ refstore "codeberg.org/lindenii/furgit/ref/store"
+)
+
+type PromotedObjectPermissions struct {
+ DirMode fs.FileMode
+ FileMode fs.FileMode
+}
+
+// Options configures one protocol-independent receive-pack service.
+//
+// Service borrows all configured dependencies.
+//
+// Refs and ExistingObjects are required and must be non-nil.
+// ObjectsRoot is required if Execute may need to ingest or promote a pack.
+// Progress, ProgressFlush, Hook, and HookIO are optional; when provided they
+// are also borrowed for the duration of Execute.
+type Options struct {
+ Algorithm objectid.Algorithm
+ Refs refstore.ReadWriteStore
+ ExistingObjects objectstorer.Store
+ ObjectsRoot *os.Root
+ Progress io.Writer
+ ProgressFlush func() error
+ PromotedObjectPermissions *PromotedObjectPermissions
+ Hook Hook
+ HookIO HookIO
+}
diff --git a/network/receivepack/service/quarantine.go b/network/receivepack/service/quarantine.go
new file mode 100644
index 00000000..0bd98aeb
--- /dev/null
+++ b/network/receivepack/service/quarantine.go
@@ -0,0 +1,274 @@
+package service
+
+import (
+ "bytes"
+ "crypto/rand"
+ "errors"
+ "fmt"
+ "io"
+ "io/fs"
+ "os"
+ "path"
+ "slices"
+)
+
+// createQuarantineRoot creates one per-push quarantine directory beneath the
+// permanent objects root.
+//
+// It returns both the quarantine directory name relative to ObjectsRoot and an
+// opened root for that directory. Callers use the name for later promotion or
+// removal relative to ObjectsRoot, and use the opened root for capability-based
+// access within the quarantine itself.
+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
+}
+
+func (service *Service) openQuarantinePackRoot(quarantineRoot *os.Root) (*os.Root, error) {
+ err := quarantineRoot.Mkdir("pack", 0o755)
+ if err != nil && !os.IsExist(err) {
+ return nil, err
+ }
+
+ return quarantineRoot.OpenRoot("pack")
+}
+
+func (service *Service) promoteQuarantine(quarantineName string, quarantineRoot *os.Root) error {
+ if quarantineName == "" || quarantineRoot == nil {
+ return nil
+ }
+
+ return service.promoteQuarantineDir(quarantineName, quarantineRoot, ".")
+}
+
+func (service *Service) promoteQuarantineDir(quarantineName string, quarantineRoot *os.Root, rel string) error {
+ entries, err := fs.ReadDir(quarantineRoot.FS(), rel)
+ if err != nil && !os.IsNotExist(err) {
+ return err
+ }
+
+ slices.SortFunc(entries, func(left, right fs.DirEntry) int {
+ return packCopyPriority(left.Name()) - packCopyPriority(right.Name())
+ })
+
+ for _, entry := range entries {
+ childRel := entry.Name()
+ if rel != "." {
+ childRel = path.Join(rel, entry.Name())
+ }
+
+ if entry.IsDir() {
+ err = service.opts.ObjectsRoot.Mkdir(childRel, 0o755)
+ if err != nil && !os.IsExist(err) {
+ return err
+ }
+
+ err = service.applyPromotedDirectoryPermissions(childRel)
+ if err != nil {
+ return err
+ }
+
+ err = service.promoteQuarantineDir(quarantineName, quarantineRoot, childRel)
+ if err != nil {
+ return err
+ }
+
+ continue
+ }
+
+ err = finalizeQuarantineFile(
+ service.opts.ObjectsRoot,
+ path.Join(quarantineName, childRel),
+ childRel,
+ isLooseObjectShardPath(rel),
+ service.opts.PromotedObjectPermissions,
+ )
+ if err == nil {
+ continue
+ }
+
+ return err
+ }
+
+ return nil
+}
+
+func packCopyPriority(name string) int {
+ if !pathHasPackPrefix(name) {
+ return 0
+ }
+
+ switch {
+ case path.Ext(name) == ".keep":
+ return 1
+ case path.Ext(name) == ".pack":
+ return 2
+ case path.Ext(name) == ".rev":
+ return 3
+ case path.Ext(name) == ".idx":
+ return 4
+ default:
+ return 5
+ }
+}
+
+func pathHasPackPrefix(name string) bool {
+ return len(name) >= 4 && name[:4] == "pack"
+}
+
+func isLooseObjectShardPath(rel string) bool {
+ return len(rel) == 2 && isHex(rel[0]) && isHex(rel[1])
+}
+
+func isHex(ch byte) bool {
+ return ('0' <= ch && ch <= '9') || ('a' <= ch && ch <= 'f') || ('A' <= ch && ch <= 'F')
+}
+
+func (service *Service) applyPromotedDirectoryPermissions(name string) error {
+ if service.opts.PromotedObjectPermissions == nil {
+ return nil
+ }
+
+ return service.opts.ObjectsRoot.Chmod(name, service.opts.PromotedObjectPermissions.DirMode)
+}
+
+func applyPromotedFilePermissions(
+ root *os.Root,
+ name string,
+ perms *PromotedObjectPermissions,
+) error {
+ if perms == nil {
+ return nil
+ }
+
+ return root.Chmod(name, perms.FileMode)
+}
+
+func finalizeQuarantineFile(
+ root *os.Root,
+ src, dst string,
+ skipCollisionCheck bool,
+ perms *PromotedObjectPermissions,
+) error {
+ const maxVanishedRetries = 5
+
+ for retries := 0; ; retries++ {
+ err := root.Link(src, dst)
+ switch {
+ case err == nil:
+ _ = root.Remove(src)
+
+ return applyPromotedFilePermissions(root, dst, perms)
+ case !errors.Is(err, fs.ErrExist):
+ _, statErr := root.Stat(dst)
+ switch {
+ case statErr == nil:
+ err = fs.ErrExist
+ case errors.Is(statErr, fs.ErrNotExist):
+ renameErr := root.Rename(src, dst)
+ if renameErr == nil {
+ return applyPromotedFilePermissions(root, dst, perms)
+ }
+
+ err = renameErr
+ default:
+ _ = root.Remove(src)
+
+ return statErr
+ }
+ }
+
+ if !errors.Is(err, fs.ErrExist) {
+ _ = root.Remove(src)
+
+ return fmt.Errorf("promote quarantine %q -> %q: %w", src, dst, err)
+ }
+
+ if skipCollisionCheck {
+ _ = root.Remove(src)
+
+ return applyPromotedFilePermissions(root, dst, perms)
+ }
+
+ equal, vanished, cmpErr := compareRootFiles(root, src, dst)
+ if vanished {
+ if retries >= maxVanishedRetries {
+ return fmt.Errorf("promote quarantine %q -> %q: destination repeatedly vanished", src, dst)
+ }
+
+ continue
+ }
+
+ if cmpErr != nil {
+ return cmpErr
+ }
+
+ if !equal {
+ return fmt.Errorf("promote quarantine %q -> %q: files differ in contents", src, dst)
+ }
+
+ _ = root.Remove(src)
+
+ return applyPromotedFilePermissions(root, dst, perms)
+ }
+}
+
+func compareRootFiles(root *os.Root, left, right string) (equal bool, vanished bool, err error) {
+ leftFile, err := root.Open(left)
+ if err != nil {
+ return false, false, err
+ }
+
+ defer func() {
+ _ = leftFile.Close()
+ }()
+
+ rightFile, err := root.Open(right)
+ if err != nil {
+ if errors.Is(err, fs.ErrNotExist) {
+ return false, true, nil
+ }
+
+ return false, false, err
+ }
+
+ defer func() {
+ _ = rightFile.Close()
+ }()
+
+ var leftBuf, rightBuf [4096]byte
+
+ for {
+ leftN, leftErr := leftFile.Read(leftBuf[:])
+ rightN, rightErr := rightFile.Read(rightBuf[:])
+
+ if leftErr != nil && !errors.Is(leftErr, io.EOF) {
+ return false, false, leftErr
+ }
+
+ if rightErr != nil && !errors.Is(rightErr, io.EOF) {
+ return false, false, rightErr
+ }
+
+ if leftN != rightN || !bytes.Equal(leftBuf[:leftN], rightBuf[:rightN]) {
+ return false, false, nil
+ }
+
+ if leftErr != nil || rightErr != nil {
+ return true, false, nil
+ }
+ }
+}
diff --git a/network/receivepack/service/quarantine_test.go b/network/receivepack/service/quarantine_test.go
new file mode 100644
index 00000000..86299be2
--- /dev/null
+++ b/network/receivepack/service/quarantine_test.go
@@ -0,0 +1,184 @@
+package service //nolint:testpackage
+
+// because we need access to quarantine internals
+
+import (
+ "os"
+ "path"
+ "testing"
+
+ objectid "codeberg.org/lindenii/furgit/object/id"
+ "codeberg.org/lindenii/furgit/object/storer/memory"
+)
+
+type quarantineFixture struct {
+ svc *Service
+ objectsRoot *os.Root
+ quarantineName string
+ quarantineRoot *os.Root
+}
+
+func newQuarantineFixture(tb testing.TB, opts Options) *quarantineFixture {
+ tb.Helper()
+
+ objectsRoot, err := os.OpenRoot(tb.TempDir())
+ if err != nil {
+ tb.Fatalf("os.OpenRoot: %v", err)
+ }
+
+ tb.Cleanup(func() {
+ _ = objectsRoot.Close()
+ })
+
+ opts.Algorithm = objectid.AlgorithmSHA1
+ opts.ExistingObjects = memory.New(objectid.AlgorithmSHA1)
+ opts.ObjectsRoot = objectsRoot
+
+ svc := New(opts)
+
+ quarantineName, quarantineRoot, err := svc.createQuarantineRoot()
+ if err != nil {
+ tb.Fatalf("createQuarantineRoot: %v", err)
+ }
+
+ tb.Cleanup(func() {
+ _ = quarantineRoot.Close()
+ _ = objectsRoot.RemoveAll(quarantineName)
+ })
+
+ return &quarantineFixture{
+ svc: svc,
+ objectsRoot: objectsRoot,
+ quarantineName: quarantineName,
+ quarantineRoot: quarantineRoot,
+ }
+}
+
+func writeMatchingPromotedFile(
+ tb testing.TB,
+ quarantineRoot, objectsRoot *os.Root,
+ dir, name, payload string,
+) {
+ tb.Helper()
+
+ err := quarantineRoot.Mkdir(dir, 0o755)
+ if err != nil {
+ tb.Fatalf("Mkdir(%s): %v", dir, err)
+ }
+
+ err = objectsRoot.Mkdir(dir, 0o755)
+ if err != nil {
+ tb.Fatalf("Mkdir(dst %s): %v", dir, err)
+ }
+
+ rel := path.Join(dir, name)
+
+ err = quarantineRoot.WriteFile(rel, []byte(payload), 0o644)
+ if err != nil {
+ tb.Fatalf("WriteFile(quarantine %s): %v", rel, err)
+ }
+
+ err = objectsRoot.WriteFile(rel, []byte(payload), 0o644)
+ if err != nil {
+ tb.Fatalf("WriteFile(permanent %s): %v", rel, err)
+ }
+}
+
+func TestPromoteQuarantineAppliesConfiguredPermissions(t *testing.T) {
+ t.Parallel()
+
+ fx := newQuarantineFixture(t, Options{
+ PromotedObjectPermissions: &PromotedObjectPermissions{
+ DirMode: 0o751,
+ FileMode: 0o640,
+ },
+ })
+
+ err := fx.quarantineRoot.Mkdir("ab", 0o700)
+ if err != nil {
+ t.Fatalf("Mkdir(ab): %v", err)
+ }
+
+ err = fx.quarantineRoot.WriteFile(path.Join("ab", "cdef"), []byte("payload"), 0o600)
+ if err != nil {
+ t.Fatalf("WriteFile(quarantine loose): %v", err)
+ }
+
+ err = fx.svc.promoteQuarantine(fx.quarantineName, fx.quarantineRoot)
+ if err != nil {
+ t.Fatalf("promoteQuarantine: %v", err)
+ }
+
+ dirInfo, err := fx.objectsRoot.Stat("ab")
+ if err != nil {
+ t.Fatalf("Stat(ab): %v", err)
+ }
+
+ if got := dirInfo.Mode().Perm(); got != 0o751 {
+ t.Fatalf("dir mode = %o, want 751", got)
+ }
+
+ fileInfo, err := fx.objectsRoot.Stat(path.Join("ab", "cdef"))
+ if err != nil {
+ t.Fatalf("Stat(ab/cdef): %v", err)
+ }
+
+ if got := fileInfo.Mode().Perm(); got != 0o640 {
+ t.Fatalf("file mode = %o, want 640", got)
+ }
+}
+
+func TestPromoteQuarantineTreatsExistingLooseObjectAsSuccess(t *testing.T) {
+ t.Parallel()
+
+ fx := newQuarantineFixture(t, Options{})
+ writeMatchingPromotedFile(t, fx.quarantineRoot, fx.objectsRoot, "ab", "cdef", "same object bytes")
+
+ err := fx.svc.promoteQuarantine(fx.quarantineName, fx.quarantineRoot)
+ if err != nil {
+ t.Fatalf("promoteQuarantine: %v", err)
+ }
+}
+
+func TestPromoteQuarantineRejectsDifferentExistingPackFile(t *testing.T) {
+ t.Parallel()
+
+ fx := newQuarantineFixture(t, Options{})
+
+ err := fx.quarantineRoot.Mkdir("pack", 0o755)
+ if err != nil {
+ t.Fatalf("Mkdir(pack): %v", err)
+ }
+
+ err = fx.objectsRoot.Mkdir("pack", 0o755)
+ if err != nil {
+ t.Fatalf("Mkdir(dst pack): %v", err)
+ }
+
+ err = fx.quarantineRoot.WriteFile(path.Join("pack", "pack-a.pack"), []byte("new bytes"), 0o644)
+ if err != nil {
+ t.Fatalf("WriteFile(quarantine pack): %v", err)
+ }
+
+ err = fx.objectsRoot.WriteFile(path.Join("pack", "pack-a.pack"), []byte("old bytes"), 0o644)
+ if err != nil {
+ t.Fatalf("WriteFile(permanent pack): %v", err)
+ }
+
+ err = fx.svc.promoteQuarantine(fx.quarantineName, fx.quarantineRoot)
+ if err == nil {
+ t.Fatal("promoteQuarantine unexpectedly succeeded")
+ }
+}
+
+func TestPromoteQuarantineAcceptsMatchingExistingPackFile(t *testing.T) {
+ t.Parallel()
+
+ fx := newQuarantineFixture(t, Options{})
+ writeMatchingPromotedFile(t, fx.quarantineRoot, fx.objectsRoot, "pack", "pack-a.pack", "identical pack bytes")
+
+ err := fx.svc.promoteQuarantine(fx.quarantineName, fx.quarantineRoot)
+ if err != nil {
+ t.Fatalf("promoteQuarantine: %v", err)
+ }
+}
diff --git a/network/receivepack/service/request.go b/network/receivepack/service/request.go
new file mode 100644
index 00000000..7f9bcc2f
--- /dev/null
+++ b/network/receivepack/service/request.go
@@ -0,0 +1,16 @@
+package service
+
+import "io"
+
+// Request is one protocol-independent receive-pack execution request.
+//
+// If PackExpected is true, Pack must be non-nil and remain valid until
+// Execute finishes consuming it.
+type Request struct {
+ Commands []Command
+ PushOptions []string
+ Atomic bool
+ DeleteOnly bool
+ PackExpected bool
+ Pack io.Reader
+}
diff --git a/network/receivepack/service/result.go b/network/receivepack/service/result.go
new file mode 100644
index 00000000..17fc0b6b
--- /dev/null
+++ b/network/receivepack/service/result.go
@@ -0,0 +1,14 @@
+package service
+
+import (
+ "codeberg.org/lindenii/furgit/packfile/ingest"
+)
+
+// Result is one receive-pack execution result.
+type Result struct {
+ UnpackError string
+ Commands []CommandResult
+ Ingest *ingest.Result
+ Planned []PlannedUpdate
+ Applied bool
+}
diff --git a/network/receivepack/service/run_hook.go b/network/receivepack/service/run_hook.go
new file mode 100644
index 00000000..94467078
--- /dev/null
+++ b/network/receivepack/service/run_hook.go
@@ -0,0 +1,168 @@
+package service
+
+import (
+ "context"
+ "os"
+
+ "codeberg.org/lindenii/furgit/internal/utils"
+ objectstorer "codeberg.org/lindenii/furgit/object/storer"
+ "codeberg.org/lindenii/furgit/object/storer/loose"
+ objectmix "codeberg.org/lindenii/furgit/object/storer/mix"
+ "codeberg.org/lindenii/furgit/object/storer/packed"
+)
+
+func (service *Service) runHook(
+ ctx context.Context,
+ req *Request,
+ commands []Command,
+ quarantineName string,
+) (
+ allowedCommands []Command,
+ allowedIndices []int,
+ rejected map[int]string,
+ ok bool,
+ errText string,
+) {
+ allowedCommands = append([]Command(nil), commands...)
+
+ allowedIndices = make([]int, 0, len(commands))
+ for index := range commands {
+ allowedIndices = append(allowedIndices, index)
+ }
+
+ rejected = make(map[int]string)
+ if service.opts.Hook == nil {
+ return allowedCommands, allowedIndices, rejected, true, ""
+ }
+
+ utils.BestEffortFprintf(service.opts.Progress, "running hooks...\r")
+
+ quarantinedObjects := service.opts.ExistingObjects
+
+ var (
+ quarantineObjectsStore objectstorer.Store
+ quarantineLooseStore *loose.Store
+ quarantinePackedStore *packed.Store
+ quarantineLooseRoot *os.Root
+ quarantinePackRoot *os.Root
+ err error
+ )
+
+ //nolint:nestif
+ if quarantineName != "" {
+ quarantineLooseRoot, err = service.opts.ObjectsRoot.OpenRoot(quarantineName)
+ if err != nil {
+ utils.BestEffortFprintf(service.opts.Progress, "running hooks: failed: %v.\n", err)
+
+ return nil, nil, nil, false, err.Error()
+ }
+
+ quarantineLooseStore, err = loose.New(quarantineLooseRoot, service.opts.Algorithm)
+ if err != nil {
+ _ = quarantineLooseRoot.Close()
+
+ utils.BestEffortFprintf(service.opts.Progress, "running hooks: failed: %v.\n", err)
+
+ return nil, nil, nil, false, err.Error()
+ }
+
+ quarantineObjectsStore = quarantineLooseStore
+ quarantinedObjects = quarantineLooseStore
+
+ quarantinePackRoot, err = quarantineLooseRoot.OpenRoot("pack")
+ if err == nil {
+ var packedErr error
+
+ quarantinePackedStore, packedErr = packed.New(quarantinePackRoot, service.opts.Algorithm, packed.Options{})
+ if packedErr != nil {
+ _ = quarantineLooseStore.Close()
+ _ = quarantinePackRoot.Close()
+ _ = quarantineLooseRoot.Close()
+
+ utils.BestEffortFprintf(service.opts.Progress, "running hooks: failed: %v.\n", packedErr)
+
+ return nil, nil, nil, false, packedErr.Error()
+ }
+
+ quarantineObjectsStore = objectmix.New(quarantineLooseStore, quarantinePackedStore)
+ quarantinedObjects = quarantineObjectsStore
+ } else if !os.IsNotExist(err) {
+ _ = quarantineLooseStore.Close()
+ _ = quarantineLooseRoot.Close()
+
+ utils.BestEffortFprintf(service.opts.Progress, "running hooks: failed: %v.\n", err)
+
+ return nil, nil, nil, false, err.Error()
+ }
+
+ defer func() {
+ if quarantineObjectsStore != nil {
+ _ = quarantineObjectsStore.Close()
+ }
+
+ if quarantinePackedStore != nil {
+ _ = quarantinePackedStore.Close()
+ }
+
+ if quarantineLooseStore != nil {
+ _ = quarantineLooseStore.Close()
+ }
+
+ if quarantinePackRoot != nil {
+ _ = quarantinePackRoot.Close()
+ }
+
+ if quarantineLooseRoot != nil {
+ _ = quarantineLooseRoot.Close()
+ }
+ }()
+ }
+
+ decisions, err := service.opts.Hook(ctx, HookRequest{
+ Refs: service.opts.Refs,
+ ExistingObjects: service.opts.ExistingObjects,
+ QuarantinedObjects: quarantinedObjects,
+ Updates: buildHookUpdates(commands),
+ PushOptions: append([]string(nil), req.PushOptions...),
+ IO: service.opts.HookIO,
+ })
+ if err != nil {
+ utils.BestEffortFprintf(service.opts.Progress, "running hooks: failed: %v.\n", err)
+
+ return nil, nil, nil, false, err.Error()
+ }
+
+ if len(decisions) != len(commands) {
+ utils.BestEffortFprintf(service.opts.Progress, "running hooks: failed: wrong decision count.\n")
+
+ return nil, nil, nil, false, "hook returned wrong number of update decisions"
+ }
+
+ allowedCommands = allowedCommands[:0]
+ allowedIndices = allowedIndices[:0]
+
+ for index, decision := range decisions {
+ if decision.Accept {
+ allowedCommands = append(allowedCommands, commands[index])
+ allowedIndices = append(allowedIndices, index)
+
+ continue
+ }
+
+ message := decision.Message
+ if message == "" {
+ message = "rejected by hook"
+ }
+
+ rejected[index] = message
+ }
+
+ utils.BestEffortFprintf(
+ service.opts.Progress,
+ "running hooks: done (%d/%d accepted).\n",
+ len(allowedCommands),
+ len(commands),
+ )
+
+ return allowedCommands, allowedIndices, rejected, true, ""
+}
diff --git a/network/receivepack/service/service.go b/network/receivepack/service/service.go
new file mode 100644
index 00000000..a57fd354
--- /dev/null
+++ b/network/receivepack/service/service.go
@@ -0,0 +1,16 @@
+package service
+
+// Service executes protocol-independent receive-pack requests.
+//
+// Service borrows all dependencies supplied in Options.
+type Service struct {
+ opts Options
+}
+
+// New creates one receive-pack service.
+//
+// The returned service borrows opts and does not take ownership of any stores,
+// roots, hooks, or I/O endpoints reachable through it.
+func New(opts Options) *Service {
+ return &Service{opts: opts}
+}
diff --git a/network/receivepack/service/service_test.go b/network/receivepack/service/service_test.go
new file mode 100644
index 00000000..e59113be
--- /dev/null
+++ b/network/receivepack/service/service_test.go
@@ -0,0 +1,99 @@
+package service_test
+
+import (
+ "context"
+ "io/fs"
+ "os"
+ "strings"
+ "testing"
+
+ "codeberg.org/lindenii/furgit/internal/testgit"
+ objectid "codeberg.org/lindenii/furgit/object/id"
+ "codeberg.org/lindenii/furgit/object/storer/memory"
+ "codeberg.org/lindenii/furgit/network/receivepack/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))
+ }
+ })
+}
diff --git a/network/receivepack/service/update.go b/network/receivepack/service/update.go
new file mode 100644
index 00000000..043f3d51
--- /dev/null
+++ b/network/receivepack/service/update.go
@@ -0,0 +1,12 @@
+package service
+
+import objectid "codeberg.org/lindenii/furgit/object/id"
+
+// 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
+}
diff --git a/network/receivepack/version.go b/network/receivepack/version.go
new file mode 100644
index 00000000..9a4544dc
--- /dev/null
+++ b/network/receivepack/version.go
@@ -0,0 +1,35 @@
+package receivepack
+
+import (
+ "strings"
+
+ common "codeberg.org/lindenii/furgit/network/protocol/v0v1/server"
+)
+
+func parseVersion(gitProtocol string) common.Version {
+ if gitProtocol == "" {
+ return common.Version0
+ }
+
+ var highestRequested uint8
+
+ for field := range strings.SplitSeq(gitProtocol, ":") {
+ switch field {
+ case "version=0":
+ case "version=1":
+ if highestRequested < 1 {
+ highestRequested = 1
+ }
+ case "version=2":
+ if highestRequested < 2 {
+ highestRequested = 2
+ }
+ }
+ }
+
+ if highestRequested == 1 {
+ return common.Version1
+ }
+
+ return common.Version0
+}