shithub: furgit

Download patch

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