/** * The bundle object index, checked against real git: every object the pack * reader materializes must appear in the index with the extent, size, type * and delta base a standalone reader needs. */ import { execFile } from 'node:child_process'; import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { promisify } from 'node:util'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { parseBundle } from '../src/bundle.js'; import { indexPacks } from '../src/objects.js'; import { BASE_EXTERNAL, BASE_NONE, buildBundleIndex, decodePackIndex, encodePackIndex, } from '../src/pack-index.js'; const execFileAsync = promisify(execFile); const env = { .../** @type {Record} */ (process.env), GIT_AUTHOR_NAME: 'Test', GIT_AUTHOR_EMAIL: 'test@example.com', GIT_COMMITTER_NAME: 'Test', GIT_COMMITTER_EMAIL: 'test@example.com', }; /** * @param {string} cwd * @param {string[]} args */ async function runGit(cwd, args) { const { stdout } = await execFileAsync('git', args, { cwd, env }); return stdout.trim(); } /** @type {string} */ let dir = ''; /** @type {string} */ let repo = ''; /** @type {Uint8Array} */ let full = new Uint8Array(); /** @type {Uint8Array} */ let incremental = new Uint8Array(); beforeAll(async () => { dir = mkdtempSync(join(tmpdir(), 'pdsjs-pack-index-')); repo = join(dir, 'repo'); await runGit(dir, ['init', '-b', 'main', repo]); // Enough repeated content that the pack holds deltas. const lines = Array.from({ length: 200 }, (_, i) => `line ${i}`).join('\n'); writeFileSync(join(repo, 'a.txt'), `${lines}\n`); writeFileSync(join(repo, 'b.txt'), `${lines}\nb\n`); await runGit(repo, ['add', '.']); await runGit(repo, ['commit', '-m', 'one']); const first = await runGit(repo, ['rev-parse', 'HEAD']); await runGit(repo, ['bundle', 'create', join(dir, 'b1.bundle'), 'main']); writeFileSync(join(repo, 'a.txt'), `${lines}\nchanged\n`); await runGit(repo, ['add', '.']); await runGit(repo, ['commit', '-m', 'two']); await runGit(repo, [ 'bundle', 'create', join(dir, 'b2.bundle'), `^${first}`, 'main', ]); full = new Uint8Array(readFileSync(join(dir, 'b1.bundle'))); incremental = new Uint8Array(readFileSync(join(dir, 'b2.bundle'))); }); afterAll(() => { rmSync(dir, { recursive: true, force: true }); }); describe('buildBundleIndex', () => { it('lists every object the pack reader materializes', async () => { const index = await buildBundleIndex(full); const objects = await indexPacks([parseBundle(full).pack]); expect(index.entries.length).toBe(objects.size); for (const entry of index.entries) { const object = objects.get(entry.sha); expect(object, entry.sha).toBeDefined(); expect(entry.type).toBe(object?.type); expect(entry.size).toBe(object?.data.length); } }); it('reports the pack start and ascending entry extents', async () => { const index = await buildBundleIndex(full); const { pack } = parseBundle(full); expect(index.packStart).toBe(full.length - pack.length); // The first entry follows the 12-byte pack header; extents ascend and // the last one ends before the 20-byte pack trailer. expect(index.entries[0].offset).toBe(12); for (let i = 1; i < index.entries.length; i++) { expect(index.entries[i].offset).toBeGreaterThan( index.entries[i - 1].offset, ); } const last = index.entries[index.entries.length - 1]; expect(last.offset).toBeLessThan(pack.length - 20); }); it('points every in-pack delta base at a listed entry', async () => { const index = await buildBundleIndex(full); const offsets = new Set(index.entries.map((entry) => entry.offset)); let deltas = 0; for (const entry of index.entries) { if (entry.base === BASE_NONE) continue; deltas++; if (entry.base !== BASE_EXTERNAL) { expect(offsets.has(entry.base), `base of ${entry.sha}`).toBe(true); } } expect(deltas).toBeGreaterThan(0); }); it('marks a cross-bundle base external and names its target', async () => { // The incremental bundle rewrites a.txt, so its pack deltas against // prerequisite objects it does not contain. Building its index needs // those bases, which come from the chain's earlier pack here. const prior = await indexPacks([parseBundle(full).pack]); const index = await buildBundleIndex(incremental, async (sha) => { return prior.get(sha) ?? null; }); const offsets = new Set(index.entries.map((entry) => entry.offset)); let external = 0; for (const entry of index.entries) { if (entry.base === BASE_NONE) continue; if (entry.base === BASE_EXTERNAL) { external++; continue; } expect(offsets.has(entry.base), `base of ${entry.sha}`).toBe(true); } expect(external).toBeGreaterThan(0); }); it('round-trips through encode and decode', async () => { const index = await buildBundleIndex(full); const decoded = decodePackIndex(encodePackIndex(index)); expect(decoded).toEqual(index); }); it('rejects bytes that are not an index', () => { expect(() => decodePackIndex(new Uint8Array([1, 2, 3]))).toThrow( /not a pack index/, ); expect(() => decodePackIndex(full.subarray(0, 64))).toThrow( /not a pack index/, ); }); }); describe('indexPacks retention', () => { /** * Every entry the walk names, in order, whatever it keeps. * @param {object} [options] */ async function walk(options) { /** @type {Array<{sha: string, type: string, size: number}>} */ const seen = []; const kept = await indexPacks( [parseBundle(full).pack], (sha, meta) => seen.push({ sha, type: meta.type, size: meta.size }), undefined, options, ); return { seen, kept }; } it('names the same objects whatever it retains', async () => { const all = await walk({ retain: 'all' }); const none = await walk({ retain: 'none' }); expect(none.seen).toEqual(all.seen); expect(none.kept.size).toBe(0); expect(all.kept.size).toBe(all.seen.length); }); it('keeps the commit graph alone for a history walk', async () => { const { seen, kept } = await walk({ retain: 'commits' }); const history = seen.filter( (entry) => entry.type === 'commit' || entry.type === 'tag', ); expect(history.length).toBeGreaterThan(0); expect(kept.size).toBe(history.length); for (const entry of history) { expect(kept.get(entry.sha)?.type, entry.sha).toBe(entry.type); } }); it('builds an evicted delta base again from the pack', async () => { // A one-byte budget keeps no base between entries, so every delta walks // its chain back down to a whole object. const all = await walk({ retain: 'all' }); const starved = await walk({ retain: 'none', cacheBytes: 1 }); expect(starved.seen).toEqual(all.seen); }); it('resolves a cross-bundle base under a starved cache', async () => { const prior = await indexPacks([parseBundle(full).pack]); const index = await buildBundleIndex(incremental, async (sha) => { return prior.get(sha) ?? null; }); /** @type {string[]} */ const seen = []; await indexPacks( [parseBundle(incremental).pack], (sha) => seen.push(sha), async (sha) => prior.get(sha) ?? null, { retain: 'none', cacheBytes: 1 }, ); expect(seen).toEqual(index.entries.map((entry) => entry.sha)); }); });