From 89d4728ecf743330275cf6802188cfdd9ad0fed3 Mon Sep 17 00:00:00 2001 From: garrytan Date: Sun, 1 Nov 2026 15:30:53 +0000 Subject: [PATCH] fix: write the graph's tree copy blob by blob so .gitattributes can't rewrite or hide files Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- docs/test-impact.md | 2 +- lib/test-impact/key.ts | 26 ++++++++++++++++++-------- test/test-impact-key.test.ts | 7 ++++++- 3 files changed, 25 insertions(+), 10 deletions(-) diff --git a/docs/test-impact.md b/docs/test-impact.md index b41d07e82..418ca07d8 100644 --- a/docs/test-impact.md +++ b/docs/test-impact.md @@ -18,7 +18,7 @@ The recorder runs `strace -f -y -qq -v` with `openat, open, execve, execveat, ge ## What the key covers -`lib/test-impact/key.ts` reads everything at the given tree through git, never from the working tree. The graph runs over a checkout of that tree made through a private index, so the tree's own `.gitattributes` can't hide files the way `export-ignore` would in `git archive`. The key is a sha256 over: +`lib/test-impact/key.ts` reads everything at the given tree through git, never from the working tree. The graph runs over a copy of that tree written blob by blob, with no attribute processing, so the tree's own `.gitattributes` (`export-ignore`, `working-tree-encoding`, filters) can't hide an import from it. The key is a sha256 over: - each traced path's bytes (read with `git cat-file --batch`), or that it is a directory, or that it is absent; - the names in each traced directory listing, at the tree; diff --git a/lib/test-impact/key.ts b/lib/test-impact/key.ts index 2380e8329..46289d2e5 100644 --- a/lib/test-impact/key.ts +++ b/lib/test-impact/key.ts @@ -105,21 +105,31 @@ export function catText(repoRoot: string, treeish: string, rel: string): string } /** - * A tree-ish checked out into a fresh temp directory with every file staged, so the import graph (git ls-files) sees exactly - * that tree. It goes through a private index and checkout-index, never `git archive`, whose export-ignore and export-subst - * attributes would let the tree itself hide files from the graph. + * A tree-ish written to a fresh temp directory blob by blob from `git cat-file --batch`, with a git index loaded by read-tree, + * so the import graph (git ls-files) sees exactly that tree's paths and bytes. Nothing applies attributes: `git archive` + * honors export-ignore and checkout-index honors working-tree-encoding and filters, and either would let the tree hide + * an import from the graph. Submodules are skipped; symlinks become symlinks. */ export function materializeTree(repoRoot: string, treeish: string): string { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-test-impact-tree-')); - const env = { ...process.env, GIT_INDEX_FILE: `${dir}.index` }; try { - git(repoRoot, ['read-tree', treeish], undefined, env); - git(repoRoot, ['checkout-index', '-a', '-f', `--prefix=${dir}/`], undefined, env); + const blobs = [...readTreeIndex(repoRoot, treeish).entries].filter(([, e]) => e.type === 'blob'); + const buf = blobs.length ? git(repoRoot, ['cat-file', '--batch'], blobs.map(([, e]) => e.oid).join('\n') + '\n') : Buffer.alloc(0); + let at = 0; + for (const [rel, e] of blobs) { + const nl = buf.indexOf(10, at); + const size = Number(buf.subarray(at, nl).toString('utf8').split(' ')[2]); + const bytes = buf.subarray(nl + 1, nl + 1 + size); + at = nl + 1 + size + 1; + const abs = path.join(dir, rel); + fs.mkdirSync(path.dirname(abs), { recursive: true }); + if (e.mode === '120000') fs.symlinkSync(bytes.toString('utf8'), abs); + else fs.writeFileSync(abs, bytes, { mode: e.mode === '100755' ? 0o755 : 0o644 }); + } git(dir, ['init', '-q']); - git(dir, ['add', '-A', '-f']); + git(repoRoot, ['read-tree', treeish], undefined, { ...process.env, GIT_INDEX_FILE: path.join(dir, '.git', 'index') }); return dir; } catch (error) { fs.rmSync(dir, { recursive: true, force: true }); throw error; } - finally { fs.rmSync(env.GIT_INDEX_FILE, { force: true }); } } /** The runtime manifest a key binds: platform, architecture and Bun version. */ diff --git a/test/test-impact-key.test.ts b/test/test-impact-key.test.ts index b772c8572..d1d4c42a2 100644 --- a/test/test-impact-key.test.ts +++ b/test/test-impact-key.test.ts @@ -80,7 +80,7 @@ describe('test-impact key: what changes it', () => { expect(keyAt(repo, 'HEAD', t).key).not.toBe(before.key); }); - test('a branch .gitattributes export-ignore cannot hide a graph input', () => { + test('a branch .gitattributes (export-ignore, working-tree-encoding) cannot hide a graph input', () => { const t = 'test/a.test.ts'; const repo = fixture({ 'lib/hidden.ts': 'export const x = 1;\n', [t]: "import { x } from '../lib/hidden';\nimport { test } from 'bun:test';\ntest('a', () => x);\n" }); traceFrom(repo, t); @@ -89,6 +89,11 @@ describe('test-impact key: what changes it', () => { const after = keyAt(repo, 'HEAD', t); expect(after.inputs).toContainEqual(expect.objectContaining({ kind: 'graph', path: 'lib/hidden.ts' })); expect(after.key).not.toBe(before.key); + fs.writeFileSync(path.join(repo.work, '.gitattributes'), `${t} working-tree-encoding=UTF-16LE\n`); + fs.writeFileSync(path.join(repo.work, t), Buffer.from(fs.readFileSync(path.join(repo.work, t), 'utf8'), 'utf16le')); + repo.git('add', '-A'); + repo.git('commit', '-q', '-m', 'utf-16 checkout'); + expect(keyAt(repo, 'HEAD', t).inputs).toContainEqual(expect.objectContaining({ kind: 'graph', path: 'lib/hidden.ts' })); }); test('a file added under a directory the test listed changes the key', () => {