shithub: furgit

Download patch

ref: 4b8d39764f9b54ea3090d0fea92a23025dbea30d
parent: a0d2b3a238d6e5dcdedb816cf838dd8fe003c632
author: Runxi Yu <me@runxiyu.org>
date: Sat Mar 7 09:24:05 EST 2026

protocol: Add v0v1 server protocol and its receivepack subprotocol

--- /dev/null
+++ b/protocol/v0v1/server/advertise.go
@@ -1,0 +1,55 @@
+package server
+
+import (
+	"fmt"
+	"strings"
+
+	"codeberg.org/lindenii/furgit/objectid"
+)
+
+// AdvertiseRefs writes one server ref advertisement.
+func (session *Session) AdvertiseRefs(ad Advertisement, capabilityTokens []string) error {
+	if session.opts.Version == Version1 {
+		err := session.enc.WriteData([]byte("version 1\n"))
+		if err != nil {
+			return err
+		}
+	}
+
+	capList := strings.Join(capabilityTokens, " ")
+
+	refs := sortAdvertisedRefs(ad.Refs)
+	if len(refs) == 0 {
+		line := fmt.Sprintf("%s capabilities^{}\x00%s\n", objectid.Zero(session.opts.Algorithm), capList)
+
+		err := session.enc.WriteData([]byte(line))
+		if err != nil {
+			return err
+		}
+
+		return session.WriteFlush()
+	}
+
+	for i, entry := range refs {
+		line := fmt.Sprintf("%s %s", entry.ID, entry.Name)
+		if i == 0 {
+			line += "\x00" + capList
+		}
+
+		err := session.enc.WriteData([]byte(line + "\n"))
+		if err != nil {
+			return err
+		}
+
+		if entry.Peeled != nil {
+			peeled := fmt.Sprintf("%s %s^{}\n", *entry.Peeled, entry.Name)
+
+			err = session.enc.WriteData([]byte(peeled))
+			if err != nil {
+				return err
+			}
+		}
+	}
+
+	return session.WriteFlush()
+}
--- /dev/null
+++ b/protocol/v0v1/server/advertise_test.go
@@ -1,0 +1,101 @@
+package server_test
+
+import (
+	"strings"
+	"testing"
+
+	"codeberg.org/lindenii/furgit/internal/testgit"
+	"codeberg.org/lindenii/furgit/objectid"
+	server "codeberg.org/lindenii/furgit/protocol/v0v1/server"
+)
+
+func TestAdvertiseRefsWritesVersionOneHeadCapsAndPeeledTag(t *testing.T) {
+	t.Parallel()
+
+	//nolint:thelper
+	testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) {
+		t.Parallel()
+
+		headID := mustHexID(t, algo, "1")
+		tagID := mustHexID(t, algo, "2")
+		peeledID := mustHexID(t, algo, "3")
+		mainID := mustHexID(t, algo, "4")
+
+		var out bufferWriteFlusher
+
+		session := server.NewSession(
+			strings.NewReader(""),
+			&out,
+			server.Options{
+				Version:   server.Version1,
+				Algorithm: algo,
+			},
+		)
+
+		err := session.AdvertiseRefs(server.Advertisement{
+			Refs: []server.AdvertisedRef{
+				{Name: "refs/tags/v1", ID: tagID, Peeled: &peeledID},
+				{Name: "HEAD", ID: headID},
+				{Name: "refs/heads/main", ID: mainID},
+			},
+		}, []string{
+			"report-status",
+			"delete-refs",
+			"object-format=" + algo.String(),
+			"agent=furgit-test/1",
+		})
+		if err != nil {
+			t.Fatalf("AdvertiseRefs: %v", err)
+		}
+
+		got := out.String()
+		wantParts := []string{
+			"000eversion 1\n",
+			headID.String() + " HEAD\x00report-status delete-refs object-format=" + algo.String() + " agent=furgit-test/1\n",
+			mainID.String() + " refs/heads/main\n",
+			tagID.String() + " refs/tags/v1\n",
+			peeledID.String() + " refs/tags/v1^{}\n",
+			"0000",
+		}
+
+		for _, part := range wantParts {
+			if !strings.Contains(got, part) {
+				t.Fatalf("advertisement missing %q in %q", part, got)
+			}
+		}
+	})
+}
+
+func TestAdvertiseRefsWritesNoRefsCapabilitiesLine(t *testing.T) {
+	t.Parallel()
+
+	//nolint:thelper
+	testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) {
+		t.Parallel()
+
+		var out bufferWriteFlusher
+
+		session := server.NewSession(
+			strings.NewReader(""),
+			&out,
+			server.Options{
+				Algorithm: algo,
+			},
+		)
+
+		err := session.AdvertiseRefs(server.Advertisement{}, []string{
+			"report-status",
+			"object-format=" + algo.String(),
+		})
+		if err != nil {
+			t.Fatalf("AdvertiseRefs: %v", err)
+		}
+
+		got := out.String()
+
+		want := objectid.Zero(algo).String() + " capabilities^{}\x00report-status object-format=" + algo.String() + "\n"
+		if !strings.Contains(got, want) {
+			t.Fatalf("unexpected no-refs advertisement %q", got)
+		}
+	})
+}
--- /dev/null
+++ b/protocol/v0v1/server/advertised_ref.go
@@ -1,0 +1,22 @@
+package server
+
+import "codeberg.org/lindenii/furgit/objectid"
+
+// AdvertisedRef is one ref entry in one v0/v1 server advertisement.
+type AdvertisedRef struct {
+	// Name is the advertised reference name. It may be HEAD or one full
+	// reference name.
+	Name string
+	// ID is the object ID currently advertised for Name.
+	ID objectid.ObjectID
+	// Peeled is the peeled annotated-tag target when available.
+	//
+	// If set, advertisement writes one immediate "<name>^{}" line after the
+	// main entry, matching Git's advertisement rules.
+	Peeled *objectid.ObjectID
+}
+
+// Advertisement is one server-side ref advertisement.
+type Advertisement struct {
+	Refs []AdvertisedRef
+}
--- /dev/null
+++ b/protocol/v0v1/server/doc.go
@@ -1,0 +1,2 @@
+// Package server implements shared server-side Git protocol v0/v1 framing.
+package server
--- /dev/null
+++ b/protocol/v0v1/server/errors.go
@@ -1,0 +1,18 @@
+package server
+
+// ProtocolError reports one malformed or unsupported protocol input.
+type ProtocolError struct {
+	Reason string
+}
+
+// Error returns the formatted error string.
+func (err *ProtocolError) Error() string {
+	return "protocol/v0v1/server: protocol error: " + err.Reason
+}
+
+// ErrUnexpectedPacket reports one unexpected pkt-line control packet.
+var ErrUnexpectedPacket = &ProtocolError{Reason: "unexpected control packet"}
+
+// ErrSideBandNotEnabled reports one attempt to write sideband frames without a
+// negotiated side-band-64k session.
+var ErrSideBandNotEnabled = &ProtocolError{Reason: "side-band-64k not enabled"}
--- /dev/null
+++ b/protocol/v0v1/server/frame.go
@@ -1,0 +1,20 @@
+package server
+
+import "codeberg.org/lindenii/furgit/format/pktline"
+
+// FrameType identifies one low-level v0/v1 server pkt-line frame type.
+type FrameType = pktline.PacketType
+
+const (
+	// FrameData is one data pkt-line.
+	FrameData = pktline.PacketData
+	// FrameFlush is one flush-pkt.
+	FrameFlush = pktline.PacketFlush
+	// FrameDelim is one delim-pkt.
+	FrameDelim = pktline.PacketDelim
+	// FrameResponseEnd is one response-end-pkt.
+	FrameResponseEnd = pktline.PacketResponseEnd
+)
+
+// Frame is one decoded low-level pkt-line frame.
+type Frame = pktline.Frame
--- /dev/null
+++ b/protocol/v0v1/server/helpers.go
@@ -1,0 +1,29 @@
+package server
+
+import (
+	"slices"
+)
+
+func sortAdvertisedRefs(refs []AdvertisedRef) []AdvertisedRef {
+	out := append([]AdvertisedRef(nil), refs...)
+	slices.SortFunc(out, func(left, right AdvertisedRef) int {
+		if left.Name == "HEAD" && right.Name != "HEAD" {
+			return -1
+		}
+
+		if left.Name != "HEAD" && right.Name == "HEAD" {
+			return 1
+		}
+
+		switch {
+		case left.Name < right.Name:
+			return -1
+		case left.Name > right.Name:
+			return 1
+		default:
+			return 0
+		}
+	})
+
+	return out
+}
--- /dev/null
+++ b/protocol/v0v1/server/helpers_test.go
@@ -1,0 +1,28 @@
+package server_test
+
+import (
+	"bytes"
+	"strings"
+	"testing"
+
+	"codeberg.org/lindenii/furgit/objectid"
+)
+
+type bufferWriteFlusher struct {
+	bytes.Buffer
+}
+
+func (bufferWriteFlusher) Flush() error {
+	return nil
+}
+
+func mustHexID(tb testing.TB, algo objectid.Algorithm, digit string) objectid.ObjectID {
+	tb.Helper()
+
+	id, err := objectid.ParseHex(algo, strings.Repeat(digit, algo.HexLen()))
+	if err != nil {
+		tb.Fatalf("objectid.ParseHex(%q): %v", strings.Repeat(digit, algo.HexLen()), err)
+	}
+
+	return id
+}
--- /dev/null
+++ b/protocol/v0v1/server/receivepack/capabilities.go
@@ -1,0 +1,192 @@
+package receivepack
+
+import (
+	"fmt"
+	"slices"
+	"strings"
+
+	"codeberg.org/lindenii/furgit/objectid"
+)
+
+// Capabilities describes one receive-pack capability set.
+type Capabilities struct {
+	ReportStatus   bool
+	ReportStatusV2 bool
+	DeleteRefs     bool
+	SideBand64K    bool
+	Quiet          bool
+	Atomic         bool
+	OfsDelta       bool
+	PushOptions    bool
+	PushCertNonce  string
+	ObjectFormat   objectid.Algorithm
+	SessionID      string
+	Agent          string
+}
+
+// Normalize returns one normalized copy of caps.
+func (caps Capabilities) Normalize(defaultAlgorithm objectid.Algorithm) Capabilities {
+	if caps.ObjectFormat == objectid.AlgorithmUnknown {
+		caps.ObjectFormat = defaultAlgorithm
+	}
+
+	return caps
+}
+
+// Tokens returns capabilities in Git advertisement order.
+func (caps Capabilities) Tokens(defaultAlgorithm objectid.Algorithm) []string {
+	caps = caps.Normalize(defaultAlgorithm)
+
+	tokens := make([]string, 0, 11)
+	if caps.ReportStatus {
+		tokens = append(tokens, "report-status")
+	}
+
+	if caps.ReportStatusV2 {
+		tokens = append(tokens, "report-status-v2")
+	}
+
+	if caps.DeleteRefs {
+		tokens = append(tokens, "delete-refs")
+	}
+
+	if caps.SideBand64K {
+		tokens = append(tokens, "side-band-64k")
+	}
+
+	if caps.Quiet {
+		tokens = append(tokens, "quiet")
+	}
+
+	if caps.Atomic {
+		tokens = append(tokens, "atomic")
+	}
+
+	if caps.OfsDelta {
+		tokens = append(tokens, "ofs-delta")
+	}
+
+	if caps.PushCertNonce != "" {
+		tokens = append(tokens, "push-cert="+caps.PushCertNonce)
+	}
+
+	if caps.PushOptions {
+		tokens = append(tokens, "push-options")
+	}
+
+	if caps.SessionID != "" {
+		tokens = append(tokens, "session-id="+caps.SessionID)
+	}
+
+	if caps.ObjectFormat != objectid.AlgorithmUnknown {
+		tokens = append(tokens, "object-format="+caps.ObjectFormat.String())
+	}
+
+	if caps.Agent != "" {
+		tokens = append(tokens, "agent="+caps.Agent)
+	}
+
+	return tokens
+}
+
+func (caps Capabilities) supportsToken(token string, defaultAlgorithm objectid.Algorithm) bool {
+	name, value, _ := strings.Cut(token, "=")
+
+	switch name {
+	case "report-status":
+		return caps.ReportStatus && value == ""
+	case "report-status-v2":
+		return caps.ReportStatusV2 && value == ""
+	case "delete-refs":
+		return caps.DeleteRefs && value == ""
+	case "side-band-64k":
+		return caps.SideBand64K && value == ""
+	case "quiet":
+		return caps.Quiet && value == ""
+	case "atomic":
+		return caps.Atomic && value == ""
+	case "ofs-delta":
+		return caps.OfsDelta && value == ""
+	case "push-options":
+		return caps.PushOptions && value == ""
+	case "push-cert":
+		return caps.PushCertNonce != "" && value != ""
+	case "object-format":
+		if value == "" {
+			return false
+		}
+
+		algo, ok := objectid.ParseAlgorithm(value)
+
+		return ok && algo == caps.Normalize(defaultAlgorithm).ObjectFormat
+	case "session-id":
+		return caps.SessionID != "" && value != ""
+	case "agent":
+		return caps.Agent != "" && value != ""
+	default:
+		return false
+	}
+}
+
+func parseCapabilityList(s string) ([]string, error) {
+	s = strings.TrimSuffix(s, "\n")
+	if s == "" {
+		return nil, nil
+	}
+
+	tokens := strings.Fields(s)
+	if slices.Contains(tokens, "") {
+		return nil, &ProtocolError{Reason: "empty capability token"}
+	}
+
+	return tokens, nil
+}
+
+func parseRequestedCapabilities(
+	tokens []string,
+	supported Capabilities,
+	defaultAlgorithm objectid.Algorithm,
+) (Capabilities, error) {
+	var requested Capabilities
+
+	requested.ObjectFormat = defaultAlgorithm
+
+	for _, token := range tokens {
+		if !supported.supportsToken(token, defaultAlgorithm) {
+			return Capabilities{}, &ProtocolError{
+				Reason: fmt.Sprintf("unsupported capability %q", token),
+			}
+		}
+
+		name, value, _ := strings.Cut(token, "=")
+		switch name {
+		case "report-status":
+			requested.ReportStatus = true
+		case "report-status-v2":
+			requested.ReportStatusV2 = true
+		case "delete-refs":
+			requested.DeleteRefs = true
+		case "side-band-64k":
+			requested.SideBand64K = true
+		case "quiet":
+			requested.Quiet = true
+		case "atomic":
+			requested.Atomic = true
+		case "ofs-delta":
+			requested.OfsDelta = true
+		case "push-options":
+			requested.PushOptions = true
+		case "push-cert":
+			requested.PushCertNonce = value
+		case "object-format":
+			algo, _ := objectid.ParseAlgorithm(value)
+			requested.ObjectFormat = algo
+		case "session-id":
+			requested.SessionID = value
+		case "agent":
+			requested.Agent = value
+		}
+	}
+
+	return requested, nil
+}
--- /dev/null
+++ b/protocol/v0v1/server/receivepack/doc.go
@@ -1,0 +1,2 @@
+// Package receivepack implements the receive-pack-specific server side of Git protocol v0/v1.
+package receivepack
--- /dev/null
+++ b/protocol/v0v1/server/receivepack/errors.go
@@ -1,0 +1,11 @@
+package receivepack
+
+// ProtocolError reports one malformed or unsupported receive-pack protocol input.
+type ProtocolError struct {
+	Reason string
+}
+
+// Error returns the formatted error string.
+func (err *ProtocolError) Error() string {
+	return "protocol/v0v1/server/receivepack: protocol error: " + err.Reason
+}
--- /dev/null
+++ b/protocol/v0v1/server/receivepack/helpers_test.go
@@ -1,0 +1,28 @@
+package receivepack_test
+
+import (
+	"bytes"
+	"strings"
+	"testing"
+
+	"codeberg.org/lindenii/furgit/objectid"
+)
+
+type bufferWriteFlusher struct {
+	bytes.Buffer
+}
+
+func (bufferWriteFlusher) Flush() error {
+	return nil
+}
+
+func mustHexID(tb testing.TB, algo objectid.Algorithm, digit string) objectid.ObjectID {
+	tb.Helper()
+
+	id, err := objectid.ParseHex(algo, strings.Repeat(digit, algo.HexLen()))
+	if err != nil {
+		tb.Fatalf("objectid.ParseHex(%q): %v", strings.Repeat(digit, algo.HexLen()), err)
+	}
+
+	return id
+}
--- /dev/null
+++ b/protocol/v0v1/server/receivepack/parse_test.go
@@ -1,0 +1,255 @@
+package receivepack_test
+
+import (
+	"errors"
+	"strings"
+	"testing"
+
+	"codeberg.org/lindenii/furgit/format/pktline"
+	"codeberg.org/lindenii/furgit/internal/testgit"
+	"codeberg.org/lindenii/furgit/objectid"
+	common "codeberg.org/lindenii/furgit/protocol/v0v1/server"
+	receivepack "codeberg.org/lindenii/furgit/protocol/v0v1/server/receivepack"
+)
+
+func TestReadRequestParsesCommandsAndPushOptions(t *testing.T) {
+	t.Parallel()
+
+	//nolint:thelper
+	testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) {
+		t.Parallel()
+
+		oldZero := objectid.Zero(algo).String()
+		oneID := mustHexID(t, algo, "1")
+
+		var wire bufferWriteFlusher
+
+		enc := pktline.NewEncoder(&wire)
+
+		err := enc.WriteData([]byte(
+			oldZero + " " + oneID.String() + " refs/heads/main\x00report-status push-options object-format=" + algo.String() + "\n",
+		))
+		if err != nil {
+			t.Fatalf("WriteData(first): %v", err)
+		}
+
+		err = enc.WriteData([]byte(
+			oneID.String() + " " + oldZero + " refs/heads/old\n",
+		))
+		if err != nil {
+			t.Fatalf("WriteData(second): %v", err)
+		}
+
+		err = enc.WriteFlush()
+		if err != nil {
+			t.Fatalf("WriteFlush(commands): %v", err)
+		}
+
+		err = enc.WriteData([]byte("ci.skip\n"))
+		if err != nil {
+			t.Fatalf("WriteData(push-option): %v", err)
+		}
+
+		err = enc.WriteFlush()
+		if err != nil {
+			t.Fatalf("WriteFlush(push-options): %v", err)
+		}
+
+		base := common.NewSession(strings.NewReader(wire.String()), &bufferWriteFlusher{}, common.Options{
+			Algorithm: algo,
+		})
+		session := receivepack.NewSession(base, receivepack.Capabilities{
+			ReportStatus: true,
+			PushOptions:  true,
+			ObjectFormat: algo,
+		})
+
+		req, err := session.ReadRequest()
+		if err != nil {
+			t.Fatalf("ReadRequest: %v", err)
+		}
+
+		if len(req.Commands) != 2 {
+			t.Fatalf("len(req.Commands) = %d, want 2", len(req.Commands))
+		}
+
+		if !req.Capabilities.ReportStatus || !req.Capabilities.PushOptions {
+			t.Fatalf("capabilities = %#v", req.Capabilities)
+		}
+
+		if len(req.PushOptions) != 1 || req.PushOptions[0] != "ci.skip" {
+			t.Fatalf("push options = %#v", req.PushOptions)
+		}
+
+		if !req.PackExpected {
+			t.Fatalf("PackExpected = false, want true")
+		}
+
+		if req.DeleteOnly {
+			t.Fatalf("DeleteOnly = true, want false")
+		}
+	})
+}
+
+func TestReadRequestDeleteOnlyDoesNotExpectPack(t *testing.T) {
+	t.Parallel()
+
+	//nolint:thelper
+	testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) {
+		t.Parallel()
+
+		oneID := mustHexID(t, algo, "1")
+
+		var wire bufferWriteFlusher
+
+		enc := pktline.NewEncoder(&wire)
+
+		err := enc.WriteData([]byte(
+			oneID.String() + " " + objectid.Zero(algo).String() + " refs/heads/old\x00delete-refs object-format=" + algo.String() + "\n",
+		))
+		if err != nil {
+			t.Fatalf("WriteData: %v", err)
+		}
+
+		err = enc.WriteFlush()
+		if err != nil {
+			t.Fatalf("WriteFlush: %v", err)
+		}
+
+		base := common.NewSession(strings.NewReader(wire.String()), &bufferWriteFlusher{}, common.Options{
+			Algorithm: algo,
+		})
+		session := receivepack.NewSession(base, receivepack.Capabilities{
+			DeleteRefs:   true,
+			ObjectFormat: algo,
+		})
+
+		req, err := session.ReadRequest()
+		if err != nil {
+			t.Fatalf("ReadRequest: %v", err)
+		}
+
+		if req.PackExpected {
+			t.Fatalf("PackExpected = true, want false")
+		}
+
+		if !req.DeleteOnly {
+			t.Fatalf("DeleteOnly = false, want true")
+		}
+	})
+}
+
+func TestReadRequestRejectsUnsupportedCapability(t *testing.T) {
+	t.Parallel()
+
+	//nolint:thelper
+	testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) {
+		t.Parallel()
+
+		oneID := mustHexID(t, algo, "1")
+
+		var wire bufferWriteFlusher
+
+		enc := pktline.NewEncoder(&wire)
+
+		err := enc.WriteData([]byte(
+			objectid.Zero(algo).String() + " " + oneID.String() + " refs/heads/main\x00atomic object-format=" + algo.String() + "\n",
+		))
+		if err != nil {
+			t.Fatalf("WriteData: %v", err)
+		}
+
+		err = enc.WriteFlush()
+		if err != nil {
+			t.Fatalf("WriteFlush: %v", err)
+		}
+
+		base := common.NewSession(strings.NewReader(wire.String()), &bufferWriteFlusher{}, common.Options{
+			Algorithm: algo,
+		})
+		session := receivepack.NewSession(base, receivepack.Capabilities{ObjectFormat: algo})
+
+		_, err = session.ReadRequest()
+		if err == nil {
+			t.Fatalf("ReadRequest error = nil, want error")
+		}
+
+		protocolErr, ok := errors.AsType[*receivepack.ProtocolError](err)
+		if !ok {
+			t.Fatalf("errors.AsType[*receivepack.ProtocolError](%T) = false", err)
+		}
+
+		if !strings.Contains(protocolErr.Reason, "unsupported capability") {
+			t.Fatalf("ProtocolError.Reason = %q", protocolErr.Reason)
+		}
+	})
+}
+
+func TestReadRequestParsesPushCertificate(t *testing.T) {
+	t.Parallel()
+
+	//nolint:thelper
+	testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) {
+		t.Parallel()
+
+		oneID := mustHexID(t, algo, "1")
+
+		var wire bufferWriteFlusher
+
+		enc := pktline.NewEncoder(&wire)
+
+		err := enc.WriteData([]byte("push-cert\x00push-cert=nonce object-format=" + algo.String() + "\n"))
+		if err != nil {
+			t.Fatalf("WriteData(push-cert): %v", err)
+		}
+
+		lines := []string{
+			"certificate version 0.1\n",
+			"pusher Example <example@example.com>\n",
+			"nonce nonce\n",
+			"push-option ci.skip\n",
+			"\n",
+			objectid.Zero(algo).String() + " " + oneID.String() + " refs/heads/main\n",
+			"-----BEGIN PGP SIGNATURE-----\n",
+			"abcdef\n",
+			"push-cert-end\n",
+		}
+
+		for _, line := range lines {
+			err = enc.WriteData([]byte(line))
+			if err != nil {
+				t.Fatalf("WriteData(%q): %v", line, err)
+			}
+		}
+
+		err = enc.WriteFlush()
+		if err != nil {
+			t.Fatalf("WriteFlush: %v", err)
+		}
+
+		base := common.NewSession(strings.NewReader(wire.String()), &bufferWriteFlusher{}, common.Options{
+			Algorithm: algo,
+		})
+		session := receivepack.NewSession(base, receivepack.Capabilities{
+			PushCertNonce: "server-nonce",
+			ObjectFormat:  algo,
+		})
+
+		req, err := session.ReadRequest()
+		if err != nil {
+			t.Fatalf("ReadRequest: %v", err)
+		}
+
+		if req.PushCert == nil {
+			t.Fatalf("PushCert = nil, want parsed certificate")
+		}
+
+		if len(req.Commands) != 1 {
+			t.Fatalf("len(req.Commands) = %d, want 1", len(req.Commands))
+		}
+
+		if len(req.PushCert.EmbeddedOption) != 1 || req.PushCert.EmbeddedOption[0] != "ci.skip" {
+			t.Fatalf("embedded options = %#v", req.PushCert.EmbeddedOption)
+		}
+	})
+}
--- /dev/null
+++ b/protocol/v0v1/server/receivepack/report_status_test.go
@@ -1,0 +1,189 @@
+package receivepack_test
+
+import (
+	"errors"
+	"strings"
+	"testing"
+
+	"codeberg.org/lindenii/furgit/format/pktline"
+	"codeberg.org/lindenii/furgit/format/sideband64k"
+	"codeberg.org/lindenii/furgit/internal/testgit"
+	"codeberg.org/lindenii/furgit/objectid"
+	common "codeberg.org/lindenii/furgit/protocol/v0v1/server"
+	receivepack "codeberg.org/lindenii/furgit/protocol/v0v1/server/receivepack"
+)
+
+func TestWriteReportStatusWritesClassicStatus(t *testing.T) {
+	t.Parallel()
+
+	var out bufferWriteFlusher
+
+	base := common.NewSession(strings.NewReader(""), &out, common.Options{})
+	session := receivepack.NewSession(base, receivepack.Capabilities{})
+
+	err := session.WriteReportStatus(receivepack.ReportStatusResult{
+		Commands: []receivepack.CommandResult{
+			{Name: "refs/heads/main"},
+			{Name: "refs/heads/dev", Error: "non-fast-forward"},
+		},
+	})
+	if err != nil {
+		t.Fatalf("WriteReportStatus: %v", err)
+	}
+
+	got := out.String()
+	wantParts := []string{
+		"unpack ok\n",
+		"ok refs/heads/main\n",
+		"ng refs/heads/dev non-fast-forward\n",
+		"0000",
+	}
+
+	for _, part := range wantParts {
+		if !strings.Contains(got, part) {
+			t.Fatalf("report-status missing %q in %q", part, got)
+		}
+	}
+}
+
+func TestWriteReportStatusUsesSideBand64KWhenNegotiated(t *testing.T) {
+	t.Parallel()
+
+	//nolint:thelper
+	testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) {
+		t.Parallel()
+
+		var requestWire bufferWriteFlusher
+
+		requestEnc := pktline.NewEncoder(&requestWire)
+
+		err := requestEnc.WriteData([]byte(
+			objectid.Zero(algo).String() + " " + mustHexID(t, algo, "1").String() + " refs/heads/main\x00report-status side-band-64k object-format=" + algo.String() + "\n",
+		))
+		if err != nil {
+			t.Fatalf("WriteData(request): %v", err)
+		}
+
+		err = requestEnc.WriteFlush()
+		if err != nil {
+			t.Fatalf("WriteFlush(request): %v", err)
+		}
+
+		var out bufferWriteFlusher
+
+		base := common.NewSession(strings.NewReader(requestWire.String()), &out, common.Options{
+			Algorithm: algo,
+		})
+		session := receivepack.NewSession(base, receivepack.Capabilities{
+			ReportStatus: true,
+			SideBand64K:  true,
+			ObjectFormat: algo,
+		})
+
+		_, err = session.ReadRequest()
+		if err != nil {
+			t.Fatalf("ReadRequest: %v", err)
+		}
+
+		err = session.WriteReportStatus(receivepack.ReportStatusResult{
+			Commands: []receivepack.CommandResult{
+				{Name: "refs/heads/main"},
+			},
+		})
+		if err != nil {
+			t.Fatalf("WriteReportStatus: %v", err)
+		}
+
+		dec := sideband64k.NewDecoder(strings.NewReader(out.String()), sideband64k.ReadOptions{})
+
+		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("first frame = %#v", frame)
+		}
+
+		frame, err = dec.ReadFrame()
+		if err != nil {
+			t.Fatalf("ReadFrame(ok): %v", err)
+		}
+
+		if frame.Type != sideband64k.FrameData || string(frame.Payload) != "ok refs/heads/main\n" {
+			t.Fatalf("second frame = %#v", frame)
+		}
+
+		frame, err = dec.ReadFrame()
+		if err != nil {
+			t.Fatalf("ReadFrame(flush): %v", err)
+		}
+
+		if frame.Type != sideband64k.FrameFlush {
+			t.Fatalf("flush frame.Type = %v, want FrameFlush", frame.Type)
+		}
+	})
+}
+
+func TestWriteReportStatusV2WritesOptionLines(t *testing.T) {
+	t.Parallel()
+
+	//nolint:thelper
+	testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) {
+		t.Parallel()
+
+		oldID := mustHexID(t, algo, "1")
+		newID := mustHexID(t, algo, "2")
+
+		var out bufferWriteFlusher
+
+		base := common.NewSession(strings.NewReader(""), &out, common.Options{})
+		session := receivepack.NewSession(base, receivepack.Capabilities{})
+
+		err := session.WriteReportStatusV2(receivepack.ReportStatusResult{
+			Commands: []receivepack.CommandResult{
+				{
+					Name:         "refs/pseudo/proc",
+					RefName:      "refs/heads/main",
+					OldID:        &oldID,
+					NewID:        &newID,
+					ForcedUpdate: true,
+				},
+				{Name: "refs/heads/dev", Error: "rejected"},
+			},
+		})
+		if err != nil {
+			t.Fatalf("WriteReportStatusV2: %v", err)
+		}
+
+		got := out.String()
+		wantParts := []string{
+			"unpack ok\n",
+			"ok refs/pseudo/proc\n",
+			"option refname refs/heads/main\n",
+			"option old-oid " + oldID.String() + "\n",
+			"option new-oid " + newID.String() + "\n",
+			"option forced-update\n",
+			"ng refs/heads/dev rejected\n",
+			"0000",
+		}
+
+		for _, part := range wantParts {
+			if !strings.Contains(got, part) {
+				t.Fatalf("report-status-v2 missing %q in %q", part, got)
+			}
+		}
+	})
+}
+
+func TestWriteProgressRequiresSideBand64K(t *testing.T) {
+	t.Parallel()
+
+	base := common.NewSession(strings.NewReader(""), &bufferWriteFlusher{}, common.Options{})
+	session := receivepack.NewSession(base, receivepack.Capabilities{})
+
+	err := session.WriteProgress([]byte("progress\n"))
+	if !errors.Is(err, common.ErrSideBandNotEnabled) {
+		t.Fatalf("WriteProgress error = %v, want %v", err, common.ErrSideBandNotEnabled)
+	}
+}
--- /dev/null
+++ b/protocol/v0v1/server/receivepack/session.go
@@ -1,0 +1,366 @@
+package receivepack
+
+import (
+	"fmt"
+	"strings"
+
+	"codeberg.org/lindenii/furgit/objectid"
+	common "codeberg.org/lindenii/furgit/protocol/v0v1/server"
+)
+
+// Session is one stateful server-side receive-pack protocol session.
+type Session struct {
+	base       *common.Session
+	supported  Capabilities
+	negotiated Capabilities
+}
+
+// NewSession creates one receive-pack session over one common server session.
+func NewSession(base *common.Session, supported Capabilities) *Session {
+	return &Session{
+		base:      base,
+		supported: supported,
+	}
+}
+
+// AdvertiseRefs writes one receive-pack ref advertisement.
+func (session *Session) AdvertiseRefs(ad common.Advertisement) error {
+	return session.base.AdvertiseRefs(ad, session.supported.Tokens(session.base.Algorithm()))
+}
+
+// ReadRequest reads one receive-pack request through optional push-options.
+func (session *Session) ReadRequest() (*Request, error) {
+	req := &Request{}
+
+	var sawCommands bool
+
+	for {
+		frame, err := session.base.ReadFrame()
+		if err != nil {
+			return nil, err
+		}
+
+		switch frame.Type {
+		case common.FrameFlush:
+			goto afterCommands
+		case common.FrameData:
+		case common.FrameDelim, common.FrameResponseEnd:
+			return nil, &ProtocolError{Reason: fmt.Sprintf("unexpected packet type %v", frame.Type)}
+		}
+
+		payload := string(frame.Payload)
+		if strings.HasPrefix(payload, "shallow ") {
+			line := trimOneLF(payload)
+
+			shallowID, err := parseObjectID(session.base.Algorithm(), line[len("shallow "):])
+			if err != nil {
+				return nil, err
+			}
+
+			req.Shallow = append(req.Shallow, shallowID)
+
+			continue
+		}
+
+		if strings.HasPrefix(payload, "push-cert\x00") {
+			if sawCommands {
+				return nil, &ProtocolError{Reason: "got both push certificate and unsigned commands"}
+			}
+
+			capabilityTokens, err := parseCapabilityList(payload[len("push-cert\x00"):])
+			if err != nil {
+				return nil, err
+			}
+
+			requested, err := parseRequestedCapabilities(
+				capabilityTokens,
+				session.supported,
+				session.base.Algorithm(),
+			)
+			if err != nil {
+				return nil, err
+			}
+
+			req.Capabilities = requested
+
+			cert, err := session.readPushCertificate()
+			if err != nil {
+				return nil, err
+			}
+
+			req.PushCert = cert
+			req.Commands = append(req.Commands, cert.Commands...)
+			sawCommands = true
+
+			continue
+		}
+
+		line := trimOneLF(payload)
+		if !sawCommands && strings.Contains(line, "\x00") {
+			commandPart, capPart, _ := strings.Cut(line, "\x00")
+
+			capabilityTokens, err := parseCapabilityList(capPart)
+			if err != nil {
+				return nil, err
+			}
+
+			requested, err := parseRequestedCapabilities(
+				capabilityTokens,
+				session.supported,
+				session.base.Algorithm(),
+			)
+			if err != nil {
+				return nil, err
+			}
+
+			req.Capabilities = requested
+			line = commandPart
+		}
+
+		cmd, err := parseCommand(session.base.Algorithm(), line)
+		if err != nil {
+			return nil, err
+		}
+
+		req.Commands = append(req.Commands, cmd)
+		sawCommands = true
+	}
+
+afterCommands:
+	if req.Capabilities.PushOptions {
+		for {
+			frame, err := session.base.ReadFrame()
+			if err != nil {
+				return nil, err
+			}
+
+			switch frame.Type {
+			case common.FrameFlush:
+				goto afterPushOptions
+			case common.FrameData:
+				req.PushOptions = append(req.PushOptions, trimOneLF(string(frame.Payload)))
+			case common.FrameDelim, common.FrameResponseEnd:
+				return nil, &ProtocolError{Reason: fmt.Sprintf("unexpected packet type %v", frame.Type)}
+			}
+		}
+	}
+
+afterPushOptions:
+	req.DeleteOnly = deleteOnly(req.Commands)
+
+	req.PackExpected = len(req.Commands) > 0 && !req.DeleteOnly
+
+	session.negotiated = req.Capabilities
+
+	if req.Capabilities.SideBand64K {
+		session.base.EnableSideBand64K()
+	}
+
+	return req, nil
+}
+
+// WriteReportStatus writes one classic report-status response.
+func (session *Session) WriteReportStatus(result ReportStatusResult) error {
+	unpackResult := "ok"
+	if result.UnpackError != "" {
+		unpackResult = result.UnpackError
+	}
+
+	err := session.base.WriteData(fmt.Appendf(nil, "unpack %s\n", unpackResult))
+	if err != nil {
+		return err
+	}
+
+	for _, command := range result.Commands {
+		line := fmt.Sprintf("ok %s\n", command.Name)
+		if command.Error != "" {
+			line = fmt.Sprintf("ng %s %s\n", command.Name, command.Error)
+		}
+
+		err = session.base.WriteData([]byte(line))
+		if err != nil {
+			return err
+		}
+	}
+
+	return session.base.WriteFlush()
+}
+
+// WriteReportStatusV2 writes one report-status-v2 response.
+func (session *Session) WriteReportStatusV2(result ReportStatusResult) error {
+	unpackResult := "ok"
+	if result.UnpackError != "" {
+		unpackResult = result.UnpackError
+	}
+
+	err := session.base.WriteData(fmt.Appendf(nil, "unpack %s\n", unpackResult))
+	if err != nil {
+		return err
+	}
+
+	for _, command := range result.Commands {
+		if command.Error != "" {
+			err = session.base.WriteData(fmt.Appendf(nil, "ng %s %s\n", command.Name, command.Error))
+			if err != nil {
+				return err
+			}
+
+			continue
+		}
+
+		err = session.base.WriteData(fmt.Appendf(nil, "ok %s\n", command.Name))
+		if err != nil {
+			return err
+		}
+
+		if command.RefName != "" {
+			err = session.base.WriteData(fmt.Appendf(nil, "option refname %s\n", command.RefName))
+			if err != nil {
+				return err
+			}
+		}
+
+		if command.OldID != nil {
+			err = session.base.WriteData(fmt.Appendf(nil, "option old-oid %s\n", *command.OldID))
+			if err != nil {
+				return err
+			}
+		}
+
+		if command.NewID != nil {
+			err = session.base.WriteData(fmt.Appendf(nil, "option new-oid %s\n", *command.NewID))
+			if err != nil {
+				return err
+			}
+		}
+
+		if command.ForcedUpdate {
+			err = session.base.WriteData([]byte("option forced-update\n"))
+			if err != nil {
+				return err
+			}
+		}
+	}
+
+	return session.base.WriteFlush()
+}
+
+// WriteProgress writes one progress packet.
+func (session *Session) WriteProgress(p []byte) error {
+	return session.base.WriteProgress(p)
+}
+
+// WriteError writes one fatal error packet.
+func (session *Session) WriteError(p []byte) error {
+	return session.base.WriteError(p)
+}
+
+func trimOneLF(s string) string {
+	return strings.TrimSuffix(s, "\n")
+}
+
+func parseObjectID(algo objectid.Algorithm, s string) (objectid.ObjectID, error) {
+	id, err := objectid.ParseHex(algo, s)
+	if err != nil {
+		return objectid.ObjectID{}, &ProtocolError{
+			Reason: fmt.Sprintf("invalid object id %q", s),
+		}
+	}
+
+	return id, nil
+}
+
+func commandIsDelete(cmd Command) bool {
+	return cmd.NewID == objectid.Zero(cmd.NewID.Algorithm())
+}
+
+func deleteOnly(commands []Command) bool {
+	if len(commands) == 0 {
+		return false
+	}
+
+	for _, cmd := range commands {
+		if !commandIsDelete(cmd) {
+			return false
+		}
+	}
+
+	return true
+}
+
+func parseCommand(algo objectid.Algorithm, line string) (Command, error) {
+	fields := strings.Fields(line)
+	if len(fields) != 3 {
+		return Command{}, &ProtocolError{Reason: fmt.Sprintf("malformed command %q", line)}
+	}
+
+	oldID, err := parseObjectID(algo, fields[0])
+	if err != nil {
+		return Command{}, err
+	}
+
+	newID, err := parseObjectID(algo, fields[1])
+	if err != nil {
+		return Command{}, err
+	}
+
+	return Command{OldID: oldID, NewID: newID, Name: fields[2]}, nil
+}
+
+func (session *Session) readPushCertificate() (*PushCertificate, error) {
+	cert := &PushCertificate{}
+	inCommands := false
+	inSignature := false
+
+	for {
+		frame, err := session.base.ReadFrame()
+		if err != nil {
+			return nil, err
+		}
+
+		switch frame.Type {
+		case common.FrameFlush:
+			return nil, &ProtocolError{Reason: "unexpected flush inside push certificate"}
+		case common.FrameData:
+		case common.FrameDelim, common.FrameResponseEnd:
+			return nil, &ProtocolError{Reason: fmt.Sprintf("unexpected packet type %v", frame.Type)}
+		}
+
+		line := string(frame.Payload)
+		if line == "push-cert-end\n" {
+			return cert, nil
+		}
+
+		if !inCommands {
+			if line == "\n" {
+				inCommands = true
+
+				continue
+			}
+
+			trimmed := trimOneLF(line)
+			cert.HeaderLines = append(cert.HeaderLines, trimmed)
+
+			if strings.HasPrefix(trimmed, "push-option ") {
+				cert.EmbeddedOption = append(cert.EmbeddedOption, trimmed[len("push-option "):])
+			}
+
+			continue
+		}
+
+		if !inSignature {
+			trimmed := trimOneLF(line)
+
+			cmd, err := parseCommand(session.base.Algorithm(), trimmed)
+			if err == nil {
+				cert.Commands = append(cert.Commands, cmd)
+
+				continue
+			}
+
+			inSignature = true
+		}
+
+		cert.SignatureLines = append(cert.SignatureLines, trimOneLF(line))
+	}
+}
--- /dev/null
+++ b/protocol/v0v1/server/receivepack/types.go
@@ -1,0 +1,45 @@
+package receivepack
+
+import "codeberg.org/lindenii/furgit/objectid"
+
+// Command is one requested reference update.
+type Command struct {
+	OldID objectid.ObjectID
+	NewID objectid.ObjectID
+	Name  string
+}
+
+// PushCertificate is one parsed push certificate block.
+type PushCertificate struct {
+	HeaderLines    []string
+	EmbeddedOption []string
+	Commands       []Command
+	SignatureLines []string
+}
+
+// Request is one parsed receive-pack request.
+type Request struct {
+	Capabilities Capabilities
+	Shallow      []objectid.ObjectID
+	Commands     []Command
+	PushCert     *PushCertificate
+	PushOptions  []string
+	PackExpected bool
+	DeleteOnly   bool
+}
+
+// CommandResult is one per-command report-status result.
+type CommandResult struct {
+	Name         string
+	Error        string
+	RefName      string
+	OldID        *objectid.ObjectID
+	NewID        *objectid.ObjectID
+	ForcedUpdate bool
+}
+
+// ReportStatusResult is one report-status payload.
+type ReportStatusResult struct {
+	UnpackError string
+	Commands    []CommandResult
+}
--- /dev/null
+++ b/protocol/v0v1/server/session.go
@@ -1,0 +1,88 @@
+package server
+
+import (
+	"io"
+
+	"codeberg.org/lindenii/furgit/format/pktline"
+	"codeberg.org/lindenii/furgit/format/sideband64k"
+	"codeberg.org/lindenii/furgit/objectid"
+)
+
+// Options configures one server-side v0/v1 session.
+type Options struct {
+	// Version selects protocol v0 or v1 framing.
+	Version Version
+	// Algorithm is the repository object ID algorithm for this session.
+	Algorithm objectid.Algorithm
+}
+
+// Session is one stateful server-side v0/v1 server protocol session.
+type Session struct {
+	dec         *pktline.Decoder
+	enc         *pktline.Encoder
+	sideband    *sideband64k.Encoder
+	opts        Options
+	useSideBand bool
+}
+
+// NewSession creates one v0/v1 server session over r and w.
+func NewSession(r io.Reader, w pktline.WriteFlusher, opts Options) *Session {
+	return &Session{
+		dec:      pktline.NewDecoder(r, pktline.ReadOptions{}),
+		enc:      pktline.NewEncoder(w),
+		sideband: sideband64k.NewEncoder(w),
+		opts:     opts,
+	}
+}
+
+// Algorithm returns the session object ID algorithm.
+func (session *Session) Algorithm() objectid.Algorithm {
+	return session.opts.Algorithm
+}
+
+// ReadFrame reads one low-level pkt-line frame from the session input.
+func (session *Session) ReadFrame() (Frame, error) {
+	return session.dec.ReadFrame()
+}
+
+// EnableSideBand64K enables side-band-64k output framing for subsequent data,
+// progress, error, and flush writes.
+func (session *Session) EnableSideBand64K() {
+	session.useSideBand = true
+}
+
+// WriteData writes one primary output packet.
+func (session *Session) WriteData(p []byte) error {
+	if session.useSideBand {
+		return session.sideband.WriteData(p)
+	}
+
+	return session.enc.WriteData(p)
+}
+
+// WriteProgress writes one progress packet.
+func (session *Session) WriteProgress(p []byte) error {
+	if !session.useSideBand {
+		return ErrSideBandNotEnabled
+	}
+
+	return session.sideband.WriteProgress(p)
+}
+
+// WriteError writes one fatal error packet.
+func (session *Session) WriteError(p []byte) error {
+	if !session.useSideBand {
+		return ErrSideBandNotEnabled
+	}
+
+	return session.sideband.WriteError(p)
+}
+
+// WriteFlush writes one trailing flush packet.
+func (session *Session) WriteFlush() error {
+	if session.useSideBand {
+		return session.sideband.WriteFlush()
+	}
+
+	return session.enc.WriteFlush()
+}
--- /dev/null
+++ b/protocol/v0v1/server/version.go
@@ -1,0 +1,12 @@
+package server
+
+// Version identifies the protocol version used on one v0/v1 server session.
+type Version uint8
+
+const (
+	// Version0 is the original protocol framing with no leading version line.
+	Version0 Version = iota
+	// Version1 is protocol v1, which is v0 plus one leading "version 1\n"
+	// pkt-line before ref advertisement.
+	Version1
+)
--