shithub: furgit

Download patch

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)
--