shithub: furgit

Download patch

ref: b82515530f10dfebbf99dca501890570f3466910
parent: 446993c94dc34c0374e00f3f5f21ece72b18a9f6
author: Runxi Yu <me@runxiyu.org>
date: Sat Mar 7 16:15:54 EST 2026

receivepack: Add hooks

--- /dev/null
+++ b/receivepack/hook.go
@@ -1,0 +1,39 @@
+package receivepack
+
+import (
+	"context"
+
+	"codeberg.org/lindenii/furgit/objectid"
+	"codeberg.org/lindenii/furgit/objectstore"
+	"codeberg.org/lindenii/furgit/refstore"
+)
+
+// 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.
+type HookRequest struct {
+	Refs               refstore.ReadingStore
+	ExistingObjects    objectstore.Store
+	QuarantinedObjects objectstore.Store
+	Updates            []RefUpdate
+	PushOptions        []string
+}
+
+// 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.
+type Hook func(context.Context, HookRequest) ([]UpdateDecision, error)
--- a/receivepack/int_test.go
+++ b/receivepack/int_test.go
@@ -403,6 +403,148 @@
 	})
 }
 
+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)
+					}
+
+					if _, _, err := req.ExistingObjects.ReadHeader(commitID); err == nil {
+						t.Fatalf("existing objects unexpectedly contained quarantined commit %s", commitID)
+					}
+
+					if _, _, err := req.QuarantinedObjects.ReadHeader(commitID); 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)
+		}
+
+		if _, err := repo.Refs().Resolve("refs/heads/main"); 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)
+		}
+
+		if _, err := repo.Refs().Resolve("refs/heads/main"); err != nil {
+			t.Fatalf("Resolve(main): %v", err)
+		}
+
+		if _, err := repo.Refs().Resolve("refs/heads/topic"); err == nil {
+			t.Fatal("refs/heads/topic still exists after successful delete")
+		}
+	})
+}
+
 func TestReceivePackReportStatusV2IncludesRefDetails(t *testing.T) {
 	t.Parallel()
 
--- a/receivepack/internal/service/execute.go
+++ b/receivepack/internal/service/execute.go
@@ -8,13 +8,8 @@
 )
 
 // 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.
+// quarantine, runs the optional hook, and applies allowed ref updates.
 func (service *Service) Execute(ctx context.Context, req *Request) (*Result, error) {
-	_ = ctx
-
 	result := &Result{
 		Commands: make([]CommandResult, 0, len(req.Commands)),
 	}
@@ -95,6 +90,83 @@
 		return result, nil
 	}
 
+	allowedCommands := append([]Command(nil), req.Commands...)
+	allowedIndices := make([]int, 0, len(req.Commands))
+	for index := range req.Commands {
+		allowedIndices = append(allowedIndices, index)
+	}
+	rejected := make(map[int]string)
+
+	if service.opts.Hook != nil {
+		quarantinedObjects, err := service.openQuarantinedObjects(quarantineName)
+		if err != nil {
+			fillCommandErrors(result, req.Commands, err.Error())
+
+			return result, nil
+		}
+
+		defer func() {
+			_ = quarantinedObjects.Close()
+		}()
+
+		decisions, err := service.opts.Hook(ctx, HookRequest{
+			Refs:               service.opts.Refs,
+			ExistingObjects:    service.opts.ExistingObjects,
+			QuarantinedObjects: quarantinedObjects,
+			Updates:            buildHookUpdates(req.Commands),
+			PushOptions:        append([]string(nil), req.PushOptions...),
+		})
+		if err != nil {
+			fillCommandErrors(result, req.Commands, err.Error())
+
+			return result, nil
+		}
+
+		if len(decisions) != len(req.Commands) {
+			fillCommandErrors(result, req.Commands, "hook returned wrong number of update decisions")
+
+			return result, nil
+		}
+
+		allowedCommands = allowedCommands[:0]
+		allowedIndices = allowedIndices[:0]
+		for index, decision := range decisions {
+			if decision.Accept {
+				allowedCommands = append(allowedCommands, req.Commands[index])
+				allowedIndices = append(allowedIndices, index)
+
+				continue
+			}
+
+			message := decision.Message
+			if message == "" {
+				message = "rejected by hook"
+			}
+
+			rejected[index] = message
+		}
+
+		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 {
 		// Git migrates quarantined objects into permanent storage immediately
 		// before starting ref updates.
@@ -108,18 +180,26 @@
 	}
 
 	if req.Atomic {
-		err := service.applyAtomic(result, req.Commands)
+		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
 	}
 
-	err = service.applyBatch(result, req.Commands)
+	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
 }
--- /dev/null
+++ b/receivepack/internal/service/hook.go
@@ -1,0 +1,30 @@
+package service
+
+import (
+	"context"
+
+	"codeberg.org/lindenii/furgit/objectid"
+	"codeberg.org/lindenii/furgit/objectstore"
+	"codeberg.org/lindenii/furgit/refstore"
+)
+
+type RefUpdate struct {
+	Name  string
+	OldID objectid.ObjectID
+	NewID objectid.ObjectID
+}
+
+type UpdateDecision struct {
+	Accept  bool
+	Message string
+}
+
+type HookRequest struct {
+	Refs               refstore.ReadingStore
+	ExistingObjects    objectstore.Store
+	QuarantinedObjects objectstore.Store
+	Updates            []RefUpdate
+	PushOptions        []string
+}
+
+type Hook func(context.Context, HookRequest) ([]UpdateDecision, error)
--- /dev/null
+++ b/receivepack/internal/service/hook_apply.go
@@ -1,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
+}
--- a/receivepack/internal/service/options.go
+++ b/receivepack/internal/service/options.go
@@ -16,10 +16,10 @@
 
 // Options configures one protocol-independent receive-pack service.
 type Options struct {
-	Algorithm                  objectid.Algorithm
-	Refs                       refstore.ReadWriteStore
-	ExistingObjects            objectstore.Store
-	ObjectsRoot                *os.Root
-	PromotedObjectPermissions  *PromotedObjectPermissions
-	// TODO: Hook and such callbacks.
+	Algorithm                 objectid.Algorithm
+	Refs                      refstore.ReadWriteStore
+	ExistingObjects           objectstore.Store
+	ObjectsRoot               *os.Root
+	PromotedObjectPermissions *PromotedObjectPermissions
+	Hook                      Hook
 }
--- /dev/null
+++ b/receivepack/internal/service/quarantine_objects.go
@@ -1,0 +1,50 @@
+package service
+
+import (
+	"os"
+
+	"codeberg.org/lindenii/furgit/objectstore"
+	objectmix "codeberg.org/lindenii/furgit/objectstore/mix"
+	"codeberg.org/lindenii/furgit/objectstore/memory"
+	"codeberg.org/lindenii/furgit/objectstore/loose"
+	"codeberg.org/lindenii/furgit/objectstore/packed"
+)
+
+func (service *Service) openQuarantinedObjects(quarantineName string) (objectstore.Store, error) {
+	if quarantineName == "" {
+		return memory.New(service.opts.Algorithm), nil
+	}
+
+	looseRoot, err := service.opts.ObjectsRoot.OpenRoot(quarantineName)
+	if err != nil {
+		return nil, err
+	}
+
+	looseStore, err := loose.New(looseRoot, service.opts.Algorithm)
+	if err != nil {
+		_ = looseRoot.Close()
+
+		return nil, err
+	}
+
+	packRoot, err := looseRoot.OpenRoot("pack")
+	if err == nil {
+		packedStore, packedErr := packed.New(packRoot, service.opts.Algorithm)
+		if packedErr != nil {
+			_ = packRoot.Close()
+			_ = looseStore.Close()
+
+			return nil, packedErr
+		}
+
+		return objectmix.New(looseStore, packedStore), nil
+	}
+
+	if !os.IsNotExist(err) {
+		_ = looseStore.Close()
+
+		return nil, err
+	}
+
+	return looseStore, nil
+}
--- a/receivepack/options.go
+++ b/receivepack/options.go
@@ -34,7 +34,9 @@
 	// PromotedObjectPermissions, when non-nil, is applied to objects and
 	// directories moved from quarantine into the permanent object store.
 	PromotedObjectPermissions *PromotedObjectPermissions
-	// TODO: Hook and policy callbacks.
+	// Hook, when non-nil, runs after pack ingestion into quarantine and before
+	// quarantine promotion or ref updates.
+	Hook Hook
 }
 
 func validateOptions(opts Options) error {
--- a/receivepack/receivepack.go
+++ b/receivepack/receivepack.go
@@ -74,6 +74,7 @@
 		PromotedObjectPermissions: translatePromotedObjectPermissions(
 			opts.PromotedObjectPermissions,
 		),
+		Hook: translateHook(opts.Hook),
 	})
 
 	result, err := svc.Execute(ctx, serviceReq)
@@ -104,5 +105,43 @@
 	return &service.PromotedObjectPermissions{
 		DirMode:  perms.DirMode,
 		FileMode: perms.FileMode,
+	}
+}
+
+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...),
+		})
+		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
 	}
 }
--