diff options
| author | 2026-01-29 17:41:23 +0100 | |
|---|---|---|
| committer | 2026-01-29 17:43:52 +0100 | |
| commit | 17c9aee0e781026353ead4ac749a3ae89c83d007 (patch) | |
| tree | fc681ebc99fdcd21339265a7e0fcfd4fe7a17d67 /packed_write_test.go | |
| parent | README: Various updates (diff) | |
| signature | No signature | |
packed: Write packs with deltas
Diffstat (limited to 'packed_write_test.go')
| -rw-r--r-- | packed_write_test.go | 328 |
1 files changed, 321 insertions, 7 deletions
diff --git a/packed_write_test.go b/packed_write_test.go index da7ecfa7..08f4484b 100644 --- a/packed_write_test.go +++ b/packed_write_test.go @@ -3,12 +3,16 @@ package furgit import ( "bytes" "crypto/rand" - "errors" "fmt" "os" + "os/exec" "path/filepath" "strings" "testing" + + "codeberg.org/lindenii/furgit/internal/adler32" + "codeberg.org/lindenii/furgit/internal/bufpool" + "codeberg.org/lindenii/furgit/internal/flatex" ) func TestPackHeaderEncodeParseRoundtrip(t *testing.T) { @@ -169,7 +173,20 @@ func TestPackWriteNoDeltas(t *testing.T) { _ = os.Remove(idxPath) }() - _ = gitCmd(t, repoPath, "index-pack", "-o", idxPath, packPath) + if err := checkPackStream(packPath, repo.hashAlgo, len(objects)); err != nil { + t.Fatalf("pack stream invalid: %v", err) + } + + cmd := exec.Command("git", "index-pack", "-o", idxPath, packPath) + cmd.Dir = repoPath + cmd.Env = append(os.Environ(), + "GIT_CONFIG_GLOBAL=/dev/null", + "GIT_CONFIG_SYSTEM=/dev/null", + ) + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git index-pack failed: %v\n%s", err, output) + } verifyOut := gitCmd(t, repoPath, "verify-pack", "-v", idxPath) seen := make(map[string]struct{}) @@ -204,21 +221,318 @@ func TestPackWriteNoDeltas(t *testing.T) { _ = gitCmd(t, repoPath, "fsck", "--full", "--strict") } -func TestPackWriteDeltasUnimplemented(t *testing.T) { +func TestPackWriteDeltas(t *testing.T) { repoPath, cleanup := setupTestRepo(t) defer cleanup() + workDir, cleanupWork := setupWorkDir(t) + defer cleanupWork() + + const ( + fileCount = 200 + fileSize = 2048 + ) + base := bytes.Repeat([]byte("delta-base-"), fileSize/10) + for i := 0; i < fileCount; i++ { + buf := make([]byte, len(base)) + copy(buf, base) + buf[i%len(buf)] ^= byte(i) + name := filepath.Join(workDir, fmt.Sprintf("delta%04d.txt", i)) + if err := os.WriteFile(name, buf, 0o644); err != nil { + t.Fatalf("failed to write %s: %v", name, err) + } + } + + gitCmd(t, repoPath, "--work-tree="+workDir, "add", ".") + gitCmd(t, repoPath, "--work-tree="+workDir, "commit", "-m", "Delta 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() }() - buf := new(bytes.Buffer) - _, err = repo.packWrite(buf, nil, packWriteOptions{EnableDeltas: true}) - if !errors.Is(err, errPackDeltaUnimplemented) { - t.Fatalf("expected errPackDeltaUnimplemented, got %v", err) + 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-delta-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{ + EnableDeltas: true, + MinDeltaSavings: 1, + }); 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) + }() + + if err := checkPackStream(packPath, repo.hashAlgo, len(objects)); err != nil { + t.Fatalf("pack stream invalid: %v", err) + } + + cmd := exec.Command("git", "index-pack", "-o", idxPath, packPath) + cmd.Dir = repoPath + cmd.Env = append(os.Environ(), + "GIT_CONFIG_GLOBAL=/dev/null", + "GIT_CONFIG_SYSTEM=/dev/null", + ) + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git index-pack failed: %v\n%s", err, output) + } + + 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 checkPackStream(path string, algo hashAlgorithm, objectCount int) error { + data, err := os.ReadFile(path) + if err != nil { + return err + } + if len(data) < 12 { + return ErrInvalidObject + } + if readBE32(data[0:4]) != packMagic || readBE32(data[4:8]) != packVersion2 { + return ErrInvalidObject + } + pos := 12 + hashSize := algo.Size() + type objEntry struct { + offset uint64 + ty ObjectType + body []byte + } + byOffset := make(map[uint64]objEntry, objectCount) + byHash := make(map[string]objEntry, objectCount) + for i := 0; i < objectCount; i++ { + objOffset := uint64(pos) + ty, size, consumed, err := packHeaderParse(data[pos:]) + if err != nil { + return fmt.Errorf("obj %d header at %d: %v", i, pos, err) + } + pos += consumed + baseOffset := uint64(0) + baseTy := ObjectTypeInvalid + var baseBody []byte + var baseHash Hash + switch ty { + case ObjectTypeOfsDelta: + dist, distConsumed, err := packDeltaReadOfsDistance(data[pos:]) + if err != nil { + return fmt.Errorf("obj %d ofs at %d: %v", i, pos, err) + } + pos += distConsumed + if dist == 0 || dist > objOffset { + return fmt.Errorf("obj %d ofs at %d: invalid dist", i, pos) + } + baseOffset = objOffset - dist + base, ok := byOffset[baseOffset] + if !ok { + return fmt.Errorf("obj %d ofs at %d: missing base", i, pos) + } + baseTy = base.ty + baseBody = base.body + case ObjectTypeRefDelta: + if pos+hashSize > len(data) { + return ErrInvalidObject + } + copy(baseHash.data[:], data[pos:pos+hashSize]) + baseHash.algo = algo + baseEntry, ok := byHash[baseHash.String()] + if !ok { + return fmt.Errorf("obj %d ref base not found", i) + } + baseTy = baseEntry.ty + baseBody = baseEntry.body + pos += hashSize + default: + } + + payload, zconsumed, err := consumeZlibStream(data[pos:], size) + if err != nil { + return fmt.Errorf("obj %d zlib at %d: %v", i, pos, err) + } + pos += zconsumed + switch ty { + case ObjectTypeOfsDelta: + if baseBody == nil { + return fmt.Errorf("obj %d missing base body", i) + } + baseSize, resultSize, err := readDeltaSizes(payload) + if err != nil { + return fmt.Errorf("obj %d delta sizes: %v", i, err) + } + if baseSize != len(baseBody) { + return fmt.Errorf("obj %d delta base size mismatch: got %d want %d", i, baseSize, len(baseBody)) + } + out, err := packDeltaApply(bufpool.FromOwned(baseBody), bufpool.FromOwned(payload)) + if err != nil { + return fmt.Errorf("obj %d delta apply: %v", i, err) + } + body := append([]byte(nil), out.Bytes()...) + out.Release() + if resultSize != len(body) { + return fmt.Errorf("obj %d delta result size mismatch: got %d want %d", i, len(body), resultSize) + } + byOffset[objOffset] = objEntry{offset: objOffset, ty: baseTy, body: body} + case ObjectTypeRefDelta: + if baseBody == nil { + return fmt.Errorf("obj %d missing ref base body", i) + } + baseSize, resultSize, err := readDeltaSizes(payload) + if err != nil { + return fmt.Errorf("obj %d ref delta sizes: %v", i, err) + } + if baseSize != len(baseBody) { + return fmt.Errorf("obj %d ref delta base size mismatch: got %d want %d", i, baseSize, len(baseBody)) + } + out, err := packDeltaApply(bufpool.FromOwned(baseBody), bufpool.FromOwned(payload)) + if err != nil { + return fmt.Errorf("obj %d ref delta apply: %v", i, err) + } + body := append([]byte(nil), out.Bytes()...) + out.Release() + if resultSize != len(body) { + return fmt.Errorf("obj %d ref delta result size mismatch: got %d want %d", i, len(body), resultSize) + } + byOffset[objOffset] = objEntry{offset: objOffset, ty: baseTy, body: body} + default: + if size >= 0 && len(payload) != size { + return fmt.Errorf("obj %d size mismatch: got %d want %d", i, len(payload), size) + } + body := append([]byte(nil), payload...) + byOffset[objOffset] = objEntry{offset: objOffset, ty: ty, body: body} + } + + entry := byOffset[objOffset] + if entry.body != nil && entry.ty != ObjectTypeInvalid { + hdr, err := headerForType(entry.ty, entry.body) + if err != nil { + return err + } + raw := append(hdr, entry.body...) + hash := algo.Sum(raw) + byHash[hash.String()] = entry + } + } + return nil +} + +func consumeZlibStream(src []byte, sizeHint int) ([]byte, int, error) { + if len(src) < 6 { + return nil, 0, fmt.Errorf("zlib too short") + } + cmf := src[0] + flg := src[1] + if (cmf&0x0f != 8) || (cmf>>4 > 7) || (uint16(cmf)<<8|uint16(flg))%31 != 0 { + return nil, 0, fmt.Errorf("zlib header invalid") + } + if flg&0x20 != 0 { + return nil, 0, fmt.Errorf("zlib dict not supported") + } + deflateData := src[2:] + out, consumed, err := flatex.DecompressSized(deflateData, sizeHint) + if err != nil { + return nil, 0, err + } + payload := append([]byte(nil), out.Bytes()...) + out.Release() + total := 2 + consumed + 4 + if total > len(src) { + return nil, 0, fmt.Errorf("zlib truncated") + } + expected := readBE32(src[2+consumed : 2+consumed+4]) + if expected != adler32.Checksum(payload) { + return nil, 0, fmt.Errorf("zlib checksum mismatch") + } + return payload, total, nil +} + +func readDeltaSizes(delta []byte) (int, int, error) { + pos := 0 + baseSize, err := packVarintRead(delta, &pos) + if err != nil { + return 0, 0, err + } + resultSize, err := packVarintRead(delta, &pos) + if err != nil { + return 0, 0, err } + return baseSize, resultSize, nil } func removeLooseObject(repoPath, oid string) error { |
