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
}
}
--
⑨