ref: 7d0f942b3ae4903dded72a9524f6bd4ffa16feb9
parent: 4c56d5a12febec96e819c7a165e5098f2c693deb
author: Runxi Yu <me@runxiyu.org>
date: Sat Mar 7 17:00:51 EST 2026
receivepack: Add HookIO
--- a/receivepack/hook.go
+++ b/receivepack/hook.go
@@ -2,6 +2,7 @@
import (
"context"
+ "io"
"codeberg.org/lindenii/furgit/objectid"
"codeberg.org/lindenii/furgit/objectstore"
@@ -9,6 +10,11 @@
"codeberg.org/lindenii/furgit/refstore"
)
+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
@@ -30,6 +36,7 @@
QuarantinedObjects objectstore.Store
Updates []RefUpdate
PushOptions []string
+ IO HookIO
}
// Hook decides whether each requested update should proceed.
@@ -60,6 +67,10 @@
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
--- a/receivepack/int_test.go
+++ b/receivepack/int_test.go
@@ -7,9 +7,11 @@
"strings"
"testing"
+ "codeberg.org/lindenii/furgit/format/sideband64k"
"codeberg.org/lindenii/furgit/internal/testgit"
"codeberg.org/lindenii/furgit/objectid"
receivepack "codeberg.org/lindenii/furgit/receivepack"
+ receivepackhooks "codeberg.org/lindenii/furgit/receivepack/hooks"
)
// TODO: actually test with send-pack
@@ -551,6 +553,136 @@
_, 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{})+
+ frame, err := dec.ReadFrame()
+ if err != nil {+ t.Fatalf("ReadFrame(progress): %v", err)+ }
+
+ if frame.Type != sideband64k.FrameProgress || string(frame.Payload) != "hook says hello\n" {+ t.Fatalf("first frame = %#v", frame)+ }
+
+ frame, err = dec.ReadFrame()
+ if err != nil {+ t.Fatalf("ReadFrame(unpack): %v", err)+ }
+
+ if frame.Type != sideband64k.FrameData || string(frame.Payload) != "unpack ok\n" {+ t.Fatalf("second frame = %#v", frame)+ }
+ })
+}
+
+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().ResolveFully("refs/heads/main")+ if err != nil {+ t.Fatalf("ResolveFully(main): %v", err)+ }
+
+ if resolved.ID != currentID {+ t.Fatalf("refs/heads/main = %s, want %s", resolved.ID, currentID)}
})
}
--- a/receivepack/internal/service/hook.go
+++ b/receivepack/internal/service/hook.go
@@ -2,6 +2,7 @@
import (
"context"
+ "io"
"codeberg.org/lindenii/furgit/objectid"
"codeberg.org/lindenii/furgit/objectstore"
@@ -8,6 +9,11 @@
"codeberg.org/lindenii/furgit/refstore"
)
+type HookIO struct {+ Progress io.Writer
+ Error io.Writer
+}
+
type RefUpdate struct {Name string
OldID objectid.ObjectID
@@ -25,6 +31,7 @@
QuarantinedObjects objectstore.Store
Updates []RefUpdate
PushOptions []string
+ IO HookIO
}
type Hook func(context.Context, HookRequest) ([]UpdateDecision, error)
--- a/receivepack/internal/service/options.go
+++ b/receivepack/internal/service/options.go
@@ -22,4 +22,5 @@
ObjectsRoot *os.Root
PromotedObjectPermissions *PromotedObjectPermissions
Hook Hook
+ HookIO HookIO
}
--- a/receivepack/internal/service/run_hook.go
+++ b/receivepack/internal/service/run_hook.go
@@ -41,6 +41,7 @@
QuarantinedObjects: quarantinedObjects,
Updates: buildHookUpdates(commands),
PushOptions: append([]string(nil), req.PushOptions...),
+ IO: service.opts.HookIO,
})
if err != nil {return nil, nil, nil, false, err.Error()
--- a/receivepack/receivepack.go
+++ b/receivepack/receivepack.go
@@ -10,6 +10,14 @@
"codeberg.org/lindenii/furgit/receivepack/internal/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.
func ReceivePack(
ctx context.Context,
@@ -75,6 +83,10 @@
opts.PromotedObjectPermissions,
),
Hook: translateHook(opts.Hook),
+ HookIO: service.HookIO{+ Progress: base.ProgressWriter(),
+ Error: base.ErrorWriter(),
+ },
})
result, err := svc.Execute(ctx, serviceReq)
--
⑨