diff options
| author | 2026-03-07 22:00:51 +0800 | |
|---|---|---|
| committer | 2026-03-07 22:03:26 +0800 | |
| commit | 7d0f942b3ae4903dded72a9524f6bd4ffa16feb9 (patch) | |
| tree | 32cbfc7841dc59759618c07905675cc3ece1c5a1 | |
| parent | protocol/v0v1/server: Add ProgessWriter and ErrorWriter (diff) | |
| signature | No signature | |
receivepack: Add HookIO
| -rw-r--r-- | receivepack/hook.go | 11 | ||||
| -rw-r--r-- | receivepack/int_test.go | 132 | ||||
| -rw-r--r-- | receivepack/internal/service/hook.go | 7 | ||||
| -rw-r--r-- | receivepack/internal/service/options.go | 1 | ||||
| -rw-r--r-- | receivepack/internal/service/run_hook.go | 1 | ||||
| -rw-r--r-- | receivepack/receivepack.go | 12 |
6 files changed, 164 insertions, 0 deletions
diff --git a/receivepack/hook.go b/receivepack/hook.go index c96911ac..bdb0b087 100644 --- a/receivepack/hook.go +++ b/receivepack/hook.go @@ -2,6 +2,7 @@ package receivepack import ( "context" + "io" "codeberg.org/lindenii/furgit/objectid" "codeberg.org/lindenii/furgit/objectstore" @@ -9,6 +10,11 @@ import ( "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 @@ type HookRequest struct { QuarantinedObjects objectstore.Store Updates []RefUpdate PushOptions []string + IO HookIO } // Hook decides whether each requested update should proceed. @@ -60,6 +67,10 @@ func translateHook(hook Hook) service.Hook { 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 diff --git a/receivepack/int_test.go b/receivepack/int_test.go index c2d260bb..ae8b71a7 100644 --- a/receivepack/int_test.go +++ b/receivepack/int_test.go @@ -7,9 +7,11 @@ import ( "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 @@ -555,6 +557,136 @@ func TestReceivePackHookCanRejectSubsetOfNonAtomicDeleteOnlyPush(t *testing.T) { }) } +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) + } + }) +} + func TestReceivePackReportStatusV2IncludesRefDetails(t *testing.T) { t.Parallel() diff --git a/receivepack/internal/service/hook.go b/receivepack/internal/service/hook.go index 85295e15..748a00b9 100644 --- a/receivepack/internal/service/hook.go +++ b/receivepack/internal/service/hook.go @@ -2,12 +2,18 @@ package service import ( "context" + "io" "codeberg.org/lindenii/furgit/objectid" "codeberg.org/lindenii/furgit/objectstore" "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 @@ type HookRequest struct { QuarantinedObjects objectstore.Store Updates []RefUpdate PushOptions []string + IO HookIO } type Hook func(context.Context, HookRequest) ([]UpdateDecision, error) diff --git a/receivepack/internal/service/options.go b/receivepack/internal/service/options.go index d9f8a265..b8dda2f7 100644 --- a/receivepack/internal/service/options.go +++ b/receivepack/internal/service/options.go @@ -22,4 +22,5 @@ type Options struct { ObjectsRoot *os.Root PromotedObjectPermissions *PromotedObjectPermissions Hook Hook + HookIO HookIO } diff --git a/receivepack/internal/service/run_hook.go b/receivepack/internal/service/run_hook.go index cf934664..3c76906e 100644 --- a/receivepack/internal/service/run_hook.go +++ b/receivepack/internal/service/run_hook.go @@ -41,6 +41,7 @@ func (service *Service) runHook( 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() diff --git a/receivepack/receivepack.go b/receivepack/receivepack.go index d1e54e58..4c1912cf 100644 --- a/receivepack/receivepack.go +++ b/receivepack/receivepack.go @@ -10,6 +10,14 @@ import ( "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 @@ func ReceivePack( opts.PromotedObjectPermissions, ), Hook: translateHook(opts.Hook), + HookIO: service.HookIO{ + Progress: base.ProgressWriter(), + Error: base.ErrorWriter(), + }, }) result, err := svc.Execute(ctx, serviceReq) |
