ref: 175c8ed3c342f34110cdca42dc4027050b39d7fb
parent: dc7ce00cbe3c300caac3c13b6701240126b99e00
author: Runxi Yu <me@runxiyu.org>
date: Sat Mar 7 09:24:32 EST 2026
receivepack: Connect protocol with service
--- /dev/null
+++ b/receivepack/advertise.go
@@ -1,0 +1,57 @@
+package receivepack
+
+import (
+ "errors"
+
+ common "codeberg.org/lindenii/furgit/protocol/v0v1/server"
+ "codeberg.org/lindenii/furgit/ref"
+ "codeberg.org/lindenii/furgit/refstore"
+)
+
+func advertisedRefs(opts Options) ([]common.AdvertisedRef, error) {+ listed, err := opts.Refs.List("")+ if err != nil {+ return nil, err
+ }
+
+ return buildAdvertisedRefs(opts, listed)
+}
+
+func buildAdvertisedRefs(opts Options, listed []ref.Ref) ([]common.AdvertisedRef, error) {+ refs := make([]common.AdvertisedRef, 0, len(listed))
+ for _, entry := range listed {+ switch resolved := entry.(type) {+ case ref.Detached:
+ advertised := common.AdvertisedRef{+ Name: resolved.Name(),
+ ID: resolved.ID,
+ }
+
+ if resolved.Peeled != nil {+ advertised.Peeled = resolved.Peeled
+ }
+
+ refs = append(refs, advertised)
+ case ref.Symbolic:
+ if resolved.Name() != "HEAD" {+ continue
+ }
+
+ head, err := opts.Refs.ResolveFully("HEAD")+ if err != nil {+ if errors.Is(err, refstore.ErrReferenceNotFound) {+ continue
+ }
+
+ return nil, err
+ }
+
+ refs = append(refs, common.AdvertisedRef{+ Name: "HEAD",
+ ID: head.ID,
+ })
+ }
+ }
+
+ return refs, nil
+}
--- /dev/null
+++ b/receivepack/doc.go
@@ -1,0 +1,3 @@
+// Package receivepack provides the application-facing server-side push entry
+// point.
+package receivepack
--- /dev/null
+++ b/receivepack/errors.go
@@ -1,0 +1,15 @@
+package receivepack
+
+import "errors"
+
+var (
+ // ErrMissingAlgorithm reports one missing repository hash algorithm.
+ ErrMissingAlgorithm = errors.New("receivepack: missing object id algorithm")+ // ErrMissingRefs reports one missing reference store dependency.
+ ErrMissingRefs = errors.New("receivepack: missing refs store")+ // ErrMissingObjects reports one missing object store dependency.
+ ErrMissingObjects = errors.New("receivepack: missing objects store")+ // ErrUnsupportedProtocol reports one unsupported requested Git protocol
+ // version.
+ ErrUnsupportedProtocol = errors.New("receivepack: unsupported protocol version")+)
--- /dev/null
+++ b/receivepack/int_test.go
@@ -1,0 +1,234 @@
+package receivepack_test
+
+import (
+ "context"
+ "fmt"
+ "strings"
+ "testing"
+
+ "codeberg.org/lindenii/furgit/internal/testgit"
+ "codeberg.org/lindenii/furgit/objectid"
+ receivepack "codeberg.org/lindenii/furgit/receivepack"
+)
+
+// TODO: actually test with send-pack
+
+func TestReceivePackDeleteOnlyReportsNotImplemented(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 delete-refs object-format=" + algo.String() + "\n",
+ ))
+ input.WriteString("0000")+
+ err := receivepack.ReceivePack(context.Background(), &output, &strings.Builder{}, strings.NewReader(input.String()), receivepack.Options{+ GitProtocol: "",
+ Algorithm: algo,
+ Refs: repo.Refs(),
+ ExistingObjects: repo.Objects(),
+ })
+ if err != nil {+ t.Fatalf("ReceivePack: %v", err)+ }
+
+ got := output.String()
+ if !strings.Contains(got, "ng refs/heads/main ref updates not implemented yet\n") {+ t.Fatalf("unexpected receive-pack output %q", got)+ }
+ })
+}
+
+func TestReceivePackAdvertisesResolvedHEAD(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.SymbolicRef(t, "HEAD", "refs/heads/main")
+
+ repo := testRepo.OpenRepository(t)
+
+ var (
+ input strings.Builder
+ output bufferWriteFlusher
+ )
+
+ input.WriteString("0000")+
+ err := receivepack.ReceivePack(context.Background(), &output, &strings.Builder{}, strings.NewReader(input.String()), receivepack.Options{+ Algorithm: algo,
+ Refs: repo.Refs(),
+ ExistingObjects: repo.Objects(),
+ })
+ if err != nil {+ t.Fatalf("ReceivePack: %v", err)+ }
+
+ got := output.String()
+
+ want := commitID.String() + " HEAD"
+ if !strings.Contains(got, want) {+ t.Fatalf("HEAD advertisement missing %q in %q", want, got)+ }
+ })
+}
+
+func TestReceivePackVersion2FallsBackToV0(t *testing.T) {+ t.Parallel()
+
+ testReceivePackProtocolFallback(t, "version=2")
+}
+
+func TestReceivePackHighestRequestedVersionFallsBackToV0ForV2(t *testing.T) {+ t.Parallel()
+
+ testReceivePackProtocolFallback(t, "version=1:version=2")
+}
+
+func TestReceivePackWithoutReportStatusWritesNoStatusPayload(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\x00delete-refs object-format=" + algo.String() + "\n",
+ ))
+ input.WriteString("0000")+
+ err := receivepack.ReceivePack(context.Background(), &output, &strings.Builder{}, strings.NewReader(input.String()), receivepack.Options{+ Algorithm: algo,
+ Refs: repo.Refs(),
+ ExistingObjects: repo.Objects(),
+ })
+ if err != nil {+ t.Fatalf("ReceivePack: %v", err)+ }
+
+ got := output.String()
+ if strings.Contains(got, "unpack ") || strings.Contains(got, "ng refs/heads/main ") || strings.Contains(got, "ok refs/heads/main\n") {+ t.Fatalf("unexpected status payload %q", got)+ }
+ })
+}
+
+func testReceivePackProtocolFallback(t *testing.T, gitProtocol string) {+ t.Helper()
+
+ //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 delete-refs object-format=" + algo.String() + "\n",
+ ))
+ input.WriteString("0000")+
+ err := receivepack.ReceivePack(context.Background(), &output, &strings.Builder{}, strings.NewReader(input.String()), receivepack.Options{+ GitProtocol: gitProtocol,
+ Algorithm: algo,
+ Refs: repo.Refs(),
+ ExistingObjects: repo.Objects(),
+ })
+ if err != nil {+ t.Fatalf("ReceivePack: %v", err)+ }
+
+ if strings.HasPrefix(output.String(), pktlineData("version 1\n")) {+ t.Fatalf("receive-pack output started with protocol v1 preface for %q: %q", gitProtocol, output.String())+ }
+ })
+}
+
+func TestReceivePackPackRequestWithoutObjectsRootReportsNotConfigured(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() + " " + commitID.String() + " refs/heads/main\x00report-status object-format=" + algo.String() + "\n",
+ ))
+ input.WriteString("0000")+
+ err := receivepack.ReceivePack(context.Background(), &output, &strings.Builder{}, strings.NewReader(input.String()), receivepack.Options{+ Algorithm: algo,
+ Refs: repo.Refs(),
+ ExistingObjects: repo.Objects(),
+ })
+ if err != nil {+ t.Fatalf("ReceivePack: %v", err)+ }
+
+ got := output.String()
+ if !strings.Contains(got, "unpack objects root not configured\n") {+ t.Fatalf("unexpected receive-pack output %q", got)+ }
+ })
+}
+
+type bufferWriteFlusher struct {+ strings.Builder
+}
+
+func (bufferWriteFlusher) Flush() error {+ return nil
+}
+
+func pktlineData(payload string) string {+ return fmt.Sprintf("%04x%s", len(payload)+4, payload)+}
--- /dev/null
+++ b/receivepack/options.go
@@ -1,0 +1,43 @@
+package receivepack
+
+import (
+ "os"
+
+ "codeberg.org/lindenii/furgit/objectid"
+ "codeberg.org/lindenii/furgit/objectstore"
+ "codeberg.org/lindenii/furgit/refstore"
+)
+
+// Options configures one receive-pack invocation.
+type Options struct {+ // GitProtocol is the raw Git protocol version string from the transport,
+ // such as "version=1".
+ GitProtocol string
+ // Algorithm is the repository object ID algorithm used by the push session.
+ Algorithm objectid.Algorithm
+ // Refs is the reference store visible to the push.
+ Refs refstore.ReadingStore
+ // ExistingObjects is the object store visible to the push before any newly
+ // uploaded quarantined objects are promoted.
+ ExistingObjects objectstore.Store
+ // ObjectsRoot is the permanent object storage root beneath which per-push
+ // quarantine directories are derived.
+ ObjectsRoot *os.Root
+ // TODO: Hook and policy callbacks.
+}
+
+func validateOptions(opts Options) error {+ if opts.Algorithm == 0 {+ return ErrMissingAlgorithm
+ }
+
+ if opts.Refs == nil {+ return ErrMissingRefs
+ }
+
+ if opts.ExistingObjects == nil {+ return ErrMissingObjects
+ }
+
+ return nil
+}
--- /dev/null
+++ b/receivepack/receivepack.go
@@ -1,0 +1,94 @@
+package receivepack
+
+import (
+ "context"
+ "io"
+
+ "codeberg.org/lindenii/furgit/format/pktline"
+ common "codeberg.org/lindenii/furgit/protocol/v0v1/server"
+ protoreceive "codeberg.org/lindenii/furgit/protocol/v0v1/server/receivepack"
+ "codeberg.org/lindenii/furgit/receivepack/internal/service"
+)
+
+// ReceivePack serves one receive-pack session over r/w.
+func ReceivePack(
+ ctx context.Context,
+ w pktline.WriteFlusher,
+ e io.Writer,
+ r io.Reader,
+ opts Options,
+) error {+ _ = e // TODO: Use stderr/progress sink explicitly as hook/progress behavior expands.
+
+ err := validateOptions(opts)
+ if err != nil {+ return err
+ }
+
+ version := parseVersion(opts.GitProtocol)
+
+ base := common.NewSession(r, w, common.Options{+ Version: version,
+ Algorithm: opts.Algorithm,
+ })
+
+ protoSession := protoreceive.NewSession(base, protoreceive.Capabilities{+ ReportStatus: true,
+ ReportStatusV2: true,
+ DeleteRefs: true,
+ SideBand64K: true,
+ Quiet: true,
+ Atomic: true,
+ OfsDelta: true,
+ PushOptions: true,
+ ObjectFormat: opts.Algorithm,
+ // TODO: PushCertNonce, SessionID, Agent, whatever.
+ })
+
+ refs, err := advertisedRefs(opts)
+ if err != nil {+ return err
+ }
+
+ err = protoSession.AdvertiseRefs(common.Advertisement{Refs: refs})+ if err != nil {+ return err
+ }
+
+ req, err := protoSession.ReadRequest()
+ if err != nil {+ return err
+ }
+
+ serviceReq := &service.Request{+ Commands: translateCommands(req.Commands),
+ PushOptions: append([]string(nil), req.PushOptions...),
+ DeleteOnly: req.DeleteOnly,
+ PackExpected: req.PackExpected,
+ Pack: r,
+ }
+
+ svc := service.New(service.Options{+ Algorithm: opts.Algorithm,
+ Refs: opts.Refs,
+ ExistingObjects: opts.ExistingObjects,
+ ObjectsRoot: opts.ObjectsRoot,
+ })
+
+ result, err := svc.Execute(ctx, serviceReq)
+ if err != nil {+ return err
+ }
+
+ protoResult := translateResult(result)
+
+ if req.Capabilities.ReportStatusV2 {+ return protoSession.WriteReportStatusV2(protoResult)
+ }
+
+ if req.Capabilities.ReportStatus {+ return protoSession.WriteReportStatus(protoResult)
+ }
+
+ return nil
+}
--- /dev/null
+++ b/receivepack/translate.go
@@ -1,0 +1,35 @@
+package receivepack
+
+import (
+ protoreceive "codeberg.org/lindenii/furgit/protocol/v0v1/server/receivepack"
+ "codeberg.org/lindenii/furgit/receivepack/internal/service"
+)
+
+func translateCommands(commands []protoreceive.Command) []service.Command {+ out := make([]service.Command, 0, len(commands))
+ for _, command := range commands {+ out = append(out, service.Command{+ OldID: command.OldID,
+ NewID: command.NewID,
+ Name: command.Name,
+ })
+ }
+
+ return out
+}
+
+func translateResult(result *service.Result) protoreceive.ReportStatusResult {+ out := protoreceive.ReportStatusResult{+ UnpackError: result.UnpackError,
+ Commands: make([]protoreceive.CommandResult, 0, len(result.Commands)),
+ }
+
+ for _, command := range result.Commands {+ out.Commands = append(out.Commands, protoreceive.CommandResult{+ Name: command.Name,
+ Error: command.Error,
+ })
+ }
+
+ return out
+}
--- /dev/null
+++ b/receivepack/version.go
@@ -1,0 +1,35 @@
+package receivepack
+
+import (
+ "strings"
+
+ common "codeberg.org/lindenii/furgit/protocol/v0v1/server"
+)
+
+func parseVersion(gitProtocol string) common.Version {+ if gitProtocol == "" {+ return common.Version0
+ }
+
+ var highestRequested uint8
+
+ for field := range strings.SplitSeq(gitProtocol, ":") {+ switch field {+ case "version=0":
+ case "version=1":
+ if highestRequested < 1 {+ highestRequested = 1
+ }
+ case "version=2":
+ if highestRequested < 2 {+ highestRequested = 2
+ }
+ }
+ }
+
+ if highestRequested == 1 {+ return common.Version1
+ }
+
+ return common.Version0
+}
--
⑨