shithub: furgit

Download patch

ref: e6919174cfb82e283ba7201a06067650dab3cb15
parent: 52a2b00e1ab5a4bfe8c516e46424fc2dc3178be2
author: Runxi Yu <me@runxiyu.org>
date: Wed Jan 28 16:55:53 EST 2026

pack: basic packfile writing

--- /dev/null
+++ b/pack_pack_write.go
@@ -1,0 +1,273 @@
+package furgit
+
+import (
+	"crypto/sha1"
+	"crypto/sha256"
+	"encoding/binary"
+	"errors"
+	"hash"
+	"io"
+
+	"codeberg.org/lindenii/furgit/internal/zlib"
+)
+
+// TODO
+var errPackDeltaUnimplemented = errors.New("furgit: pack: delta writing not implemented")
+
+// packWriter writes a PACKv2 stream.
+type packWriter struct {
+	w            io.Writer
+	h            hash.Hash
+	algo         hashAlgorithm
+	objCount     uint32
+	wroteHeader  bool
+	bytesWritten uint64
+}
+
+func newPackWriter(w io.Writer, algo hashAlgorithm, objCount uint32) (*packWriter, error) {
+	if w == nil {
+		return nil, ErrInvalidObject
+	}
+	h, err := newHashWriter(algo)
+	if err != nil {
+		return nil, err
+	}
+	return &packWriter{
+		w:        w,
+		h:        h,
+		algo:     algo,
+		objCount: objCount,
+	}, nil
+}
+
+func newHashWriter(algo hashAlgorithm) (hash.Hash, error) {
+	switch algo {
+	case hashAlgoSHA1:
+		return sha1.New(), nil
+	case hashAlgoSHA256:
+		return sha256.New(), nil
+	default:
+		return nil, ErrInvalidObject
+	}
+}
+
+func (pw *packWriter) writePacked(p []byte) error {
+	if len(p) == 0 {
+		return nil
+	}
+	n, err := pw.w.Write(p)
+	if n > 0 {
+		_, _ = pw.h.Write(p[:n])
+		pw.bytesWritten += uint64(n)
+	}
+	if err != nil {
+		return err
+	}
+	if n != len(p) {
+		return io.ErrShortWrite
+	}
+	return nil
+}
+
+func (pw *packWriter) WriteHeader() error {
+	if pw == nil || pw.wroteHeader {
+		return ErrInvalidObject
+	}
+	var hdr [12]byte
+	binary.BigEndian.PutUint32(hdr[0:4], packMagic)
+	binary.BigEndian.PutUint32(hdr[4:8], packVersion2)
+	binary.BigEndian.PutUint32(hdr[8:12], pw.objCount)
+	if err := pw.writePacked(hdr[:]); err != nil {
+		return err
+	}
+	pw.wroteHeader = true
+	return nil
+}
+
+func (pw *packWriter) WriteObject(ty ObjectType, body []byte) error {
+	if pw == nil || !pw.wroteHeader {
+		return ErrInvalidObject
+	}
+	switch ty {
+	case ObjectTypeCommit, ObjectTypeTree, ObjectTypeBlob, ObjectTypeTag:
+		// remember that go switches don't fallthrough lol
+	default:
+		return ErrInvalidObject
+	}
+	if body == nil {
+		body = []byte{}
+	}
+
+	hdr, err := packHeaderEncode(ty, len(body))
+	if err != nil {
+		return err
+	}
+	if err := pw.writePacked(hdr); err != nil {
+		return err
+	}
+
+	zw := zlib.NewWriter(&packHashWriter{pw: pw})
+	if _, err := zw.Write(body); err != nil {
+		_ = zw.Close()
+		return err
+	}
+	return zw.Close()
+}
+
+func (pw *packWriter) WriteOfsDelta(baseOffset uint64, baseSize, resultSize int, delta []byte) error {
+	_ = baseOffset
+	_ = baseSize
+	_ = resultSize
+	_ = delta
+	return errPackDeltaUnimplemented
+}
+
+func (pw *packWriter) WriteRefDelta(base Hash, baseSize, resultSize int, delta []byte) error {
+	_ = base
+	_ = baseSize
+	_ = resultSize
+	_ = delta
+	return errPackDeltaUnimplemented
+}
+
+func (pw *packWriter) Close() (Hash, error) {
+	if pw == nil || !pw.wroteHeader {
+		return Hash{}, ErrInvalidObject
+	}
+	sum := pw.h.Sum(nil)
+	if _, err := pw.w.Write(sum); err != nil {
+		return Hash{}, err
+	}
+	var out Hash
+	copy(out.data[:], sum)
+	out.algo = pw.algo
+	return out, nil
+}
+
+type packHashWriter struct {
+	pw *packWriter
+}
+
+func (w *packHashWriter) Write(p []byte) (int, error) {
+	if w == nil || w.pw == nil {
+		return 0, ErrInvalidObject
+	}
+	if err := w.pw.writePacked(p); err != nil {
+		return 0, err
+	}
+	return len(p), nil
+}
+
+// packHeaderEncode encodes a pack object header (type + size).
+func packHeaderEncode(ty ObjectType, size int) ([]byte, error) {
+	if size < 0 {
+		return nil, ErrInvalidObject
+	}
+	var out [16]byte
+	pos := 0
+
+	b := byte(size & 0x0f)
+	size >>= 4
+	b |= byte(ty&0x07) << 4
+	if size > 0 {
+		b |= 0x80
+	}
+	out[pos] = b
+	pos++
+
+	for size > 0 {
+		b = byte(size & 0x7f)
+		size >>= 7
+		if size > 0 {
+			b |= 0x80
+		}
+		out[pos] = b
+		pos++
+	}
+
+	return out[:pos], nil
+}
+
+// packVarintEncode encodes a 7-bit varint.
+func packVarintEncode(size int) ([]byte, error) {
+	if size < 0 {
+		return nil, ErrInvalidObject
+	}
+	var out [16]byte
+	pos := 0
+	for {
+		b := byte(size & 0x7f)
+		size >>= 7
+		if size != 0 {
+			b |= 0x80
+		}
+		out[pos] = b
+		pos++
+		if size == 0 {
+			break
+		}
+	}
+	return out[:pos], nil
+}
+
+// packOfsEncode encodes an ofs-delta distance.
+func packOfsEncode(dist uint64) ([]byte, error) {
+	if dist == 0 {
+		return nil, ErrInvalidObject
+	}
+	var out [16]byte
+	pos := 0
+	out[pos] = byte(dist & 0x7f)
+	pos++
+	dist >>= 7
+	for dist != 0 {
+		b := byte((dist - 1) & 0x7f)
+		out[pos] = b | 0x80
+		pos++
+		dist >>= 7
+	}
+	for i, j := 0, pos-1; i < j; i, j = i+1, j-1 {
+		out[i], out[j] = out[j], out[i]
+	}
+	return out[:pos], nil
+}
+
+// packWrite writes a pack stream for the provided object ids.
+func (repo *Repository) packWrite(w io.Writer, objects []Hash, opts packWriteOptions) (Hash, error) {
+	if repo == nil {
+		return Hash{}, ErrInvalidObject
+	}
+	if opts.EnableDeltas || opts.EnableThinPack {
+		return Hash{}, errPackDeltaUnimplemented
+	}
+	if len(objects) > int(^uint32(0)) {
+		return Hash{}, ErrInvalidObject
+	}
+
+	pw, err := newPackWriter(w, repo.hashAlgo, uint32(len(objects)))
+	if err != nil {
+		return Hash{}, err
+	}
+	if err := pw.WriteHeader(); err != nil {
+		return Hash{}, err
+	}
+
+	for _, id := range objects {
+		ty, body, err := repo.ReadObjectTypeRaw(id)
+		if err != nil {
+			return Hash{}, err
+		}
+		if err := pw.WriteObject(ty, body); err != nil {
+			return Hash{}, err
+		}
+	}
+
+	return pw.Close()
+}
+
+type packWriteOptions struct {
+	EnableDeltas    bool
+	EnableThinPack  bool
+	MinDeltaSavings int
+	MaxDeltaDepth   int
+}
--- /dev/null
+++ b/pack_write_test.go
@@ -1,0 +1,226 @@
+package furgit
+
+import (
+	"bytes"
+	"errors"
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+)
+
+func TestPackHeaderEncodeParseRoundtrip(t *testing.T) {
+	cases := []struct {
+		ty    ObjectType
+		sizes []int
+	}{
+		{ObjectTypeCommit, []int{0, 1, 15, 16, 127, 128, 1024, 1 << 20}},
+		{ObjectTypeTree, []int{0, 3, 31, 32, 255, 256, 4096}},
+		{ObjectTypeBlob, []int{0, 7, 63, 64, 511, 512, 99999}},
+		{ObjectTypeTag, []int{0, 2, 14, 15, 16, 127, 128}},
+	}
+
+	for _, c := range cases {
+		for _, size := range c.sizes {
+			encoded, err := packHeaderEncode(c.ty, size)
+			if err != nil {
+				t.Fatalf("packHeaderEncode(%v,%d) error: %v", c.ty, size, err)
+			}
+			gotTy, gotSize, consumed, err := packHeaderParse(encoded)
+			if err != nil {
+				t.Fatalf("packHeaderParse error: %v", err)
+			}
+			if gotTy != c.ty || gotSize != size {
+				t.Fatalf("roundtrip mismatch: got (%v,%d), want (%v,%d)", gotTy, gotSize, c.ty, size)
+			}
+			if consumed != len(encoded) {
+				t.Fatalf("consumed=%d, encoded=%d", consumed, len(encoded))
+			}
+		}
+	}
+}
+
+func TestPackVarintEncodeRoundtrip(t *testing.T) {
+	values := []int{0, 1, 2, 7, 8, 127, 128, 129, 255, 1024, 1 << 20}
+	for _, v := range values {
+		encoded, err := packVarintEncode(v)
+		if err != nil {
+			t.Fatalf("packVarintEncode(%d) error: %v", v, err)
+		}
+		pos := 0
+		got, err := packVarintRead(encoded, &pos)
+		if err != nil {
+			t.Fatalf("packVarintRead error: %v", err)
+		}
+		if got != v {
+			t.Fatalf("roundtrip mismatch: got %d, want %d", got, v)
+		}
+		if pos != len(encoded) {
+			t.Fatalf("pos=%d, encoded=%d", pos, len(encoded))
+		}
+	}
+}
+
+func TestPackOfsEncodeRoundtrip(t *testing.T) {
+	values := []uint64{1, 2, 7, 8, 9, 0x7f, 0x80, 0x81, 0x1000, 0x12345}
+	for _, v := range values {
+		encoded, err := packOfsEncode(v)
+		if err != nil {
+			t.Fatalf("packOfsEncode(%d) error: %v", v, err)
+		}
+		dist, consumed, err := packDeltaReadOfsDistance(encoded)
+		if err != nil {
+			t.Fatalf("packDeltaReadOfsDistance error: %v", err)
+		}
+		if dist != v {
+			t.Fatalf("roundtrip mismatch: got %d, want %d", dist, v)
+		}
+		if consumed != len(encoded) {
+			t.Fatalf("consumed=%d, encoded=%d", consumed, len(encoded))
+		}
+	}
+}
+
+func TestPackWriteNoDeltas(t *testing.T) {
+	repoPath, cleanup := setupTestRepo(t)
+	defer cleanup()
+
+	workDir, cleanupWork := setupWorkDir(t)
+	defer cleanupWork()
+
+	if err := os.WriteFile(filepath.Join(workDir, "file1.txt"), []byte("content1"), 0o644); err != nil {
+		t.Fatalf("failed to write file1.txt: %v", err)
+	}
+	if err := os.WriteFile(filepath.Join(workDir, "file2.txt"), []byte("content2"), 0o644); err != nil {
+		t.Fatalf("failed to write file2.txt: %v", err)
+	}
+
+	gitCmd(t, repoPath, "--work-tree="+workDir, "add", ".")
+	gitCmd(t, repoPath, "--work-tree="+workDir, "commit", "-m", "Test commit")
+	commitHash := gitCmd(t, repoPath, "rev-parse", "HEAD")
+
+	commitBody := gitCatFile(t, repoPath, "commit", commitHash)
+	lines := bytes.Split(commitBody, []byte{'\n'})
+	if len(lines) == 0 || !bytes.HasPrefix(lines[0], []byte("tree ")) {
+		t.Fatalf("commit missing tree header")
+	}
+	treeHash := strings.TrimSpace(string(bytes.TrimPrefix(lines[0], []byte("tree "))))
+
+	lsTree := gitCmd(t, repoPath, "ls-tree", "-r", treeHash)
+	var blobHashes []string
+	for _, line := range strings.Split(lsTree, "\n") {
+		if line == "" {
+			continue
+		}
+		fields := strings.Fields(line)
+		if len(fields) < 3 {
+			t.Fatalf("unexpected ls-tree line: %q", line)
+		}
+		blobHashes = append(blobHashes, fields[2])
+	}
+
+	repo, err := OpenRepository(repoPath)
+	if err != nil {
+		t.Fatalf("OpenRepository failed: %v", err)
+	}
+	defer func() { _ = repo.Close() }()
+
+	var objects []Hash
+	commitID, _ := repo.ParseHash(commitHash)
+	objects = append(objects, commitID)
+	treeID, _ := repo.ParseHash(treeHash)
+	objects = append(objects, treeID)
+	for _, bh := range blobHashes {
+		id, _ := repo.ParseHash(bh)
+		objects = append(objects, id)
+	}
+	expectedOids := append([]string{commitHash, treeHash}, blobHashes...)
+
+	packDir := filepath.Join(repoPath, "objects", "pack")
+	if err := os.MkdirAll(packDir, 0o755); err != nil {
+		t.Fatalf("failed to create pack dir: %v", err)
+	}
+	pf, err := os.CreateTemp(packDir, "furgit-test-*.pack")
+	if err != nil {
+		t.Fatalf("failed to create pack file: %v", err)
+	}
+	packPath := pf.Name()
+	idxPath := strings.TrimSuffix(packPath, ".pack") + ".idx"
+	if _, err := repo.packWrite(pf, objects, packWriteOptions{}); err != nil {
+		_ = pf.Close()
+		t.Fatalf("packWrite failed: %v", err)
+	}
+	if err := pf.Close(); err != nil {
+		t.Fatalf("failed to close pack file: %v", err)
+	}
+
+	defer func() {
+		_ = os.Remove(packPath)
+		_ = os.Remove(idxPath)
+	}()
+
+	_ = gitCmd(t, repoPath, "index-pack", "-o", idxPath, packPath)
+
+	verifyOut := gitCmd(t, repoPath, "verify-pack", "-v", idxPath)
+	seen := make(map[string]struct{})
+	for _, line := range strings.Split(verifyOut, "\n") {
+		if strings.TrimSpace(line) == "" {
+			continue
+		}
+		if strings.HasPrefix(line, "chain length") || strings.HasPrefix(line, "non delta") {
+			continue
+		}
+		parts := strings.Fields(line)
+		if len(parts) == 0 {
+			continue
+		}
+		seen[parts[0]] = struct{}{}
+	}
+	for _, oid := range expectedOids {
+		if _, ok := seen[oid]; !ok {
+			t.Fatalf("verify-pack missing object %s", oid)
+		}
+	}
+
+	for _, oid := range expectedOids {
+		if err := removeLooseObject(repoPath, oid); err != nil {
+			t.Fatalf("remove loose object %s: %v", oid, err)
+		}
+	}
+	for _, oid := range expectedOids {
+		_ = gitCmd(t, repoPath, "cat-file", "-p", oid)
+	}
+
+	_ = gitCmd(t, repoPath, "fsck", "--full", "--strict")
+}
+
+func TestPackWriteDeltasUnimplemented(t *testing.T) {
+	repoPath, cleanup := setupTestRepo(t)
+	defer cleanup()
+
+	repo, err := OpenRepository(repoPath)
+	if err != nil {
+		t.Fatalf("OpenRepository failed: %v", err)
+	}
+	defer func() { _ = repo.Close() }()
+
+	buf := new(bytes.Buffer)
+	_, err = repo.packWrite(buf, nil, packWriteOptions{EnableDeltas: true})
+	if !errors.Is(err, errPackDeltaUnimplemented) {
+		t.Fatalf("expected errPackDeltaUnimplemented, got %v", err)
+	}
+}
+
+func removeLooseObject(repoPath, oid string) error {
+	if len(oid) < 2 {
+		return ErrInvalidObject
+	}
+	path := filepath.Join(repoPath, "objects", oid[:2], oid[2:])
+	if err := os.Remove(path); err != nil {
+		if os.IsNotExist(err) {
+			return nil
+		}
+		return err
+	}
+	return nil
+}
--