diff --git a/bin/gstack-wtree b/bin/gstack-wtree index 03e6cedcf..94265fa7d 100755 --- a/bin/gstack-wtree +++ b/bin/gstack-wtree @@ -48,7 +48,13 @@ if [ -n "$REAL_INDEX" ] && [ -f "$REAL_INDEX" ] && cp "$REAL_INDEX" "$TMPIDX" 2> # non-racy and lets a same-size rewrite in the same second as the original # `git add` keep its stale stat-cache entry — the content change vanishes # from the fingerprint. touch -r restores the original racy window. - touch -r "$REAL_INDEX" "$TMPIDX" 2>/dev/null || true + # #2687 hardening: a FAILED touch silently reopened that exact hole (the + # copy keeps its "now" stamp). Fall through to the HEAD seed instead — + # slower, but every entry gets re-hashed, so the fingerprint stays honest. + if ! touch -r "$REAL_INDEX" "$TMPIDX" 2>/dev/null; then + rm -f "$TMPIDX" 2>/dev/null || true + git -C "$TOP" read-tree HEAD 2>/dev/null || exit 1 + fi else git -C "$TOP" read-tree HEAD 2>/dev/null || exit 1 fi diff --git a/test/review-log.test.ts b/test/review-log.test.ts index 32c7aa510..a1fd226d3 100644 --- a/test/review-log.test.ts +++ b/test/review-log.test.ts @@ -211,6 +211,40 @@ describe('gstack-wtree', () => { }); }); + // #2687 hardening: `touch -r ... || true` meant a FAILED touch silently + // reopened the racy-window hole (the temp index copy keeps its "now" stamp + // and every entry reads non-racy). A failed touch must fall through to the + // read-tree HEAD seed, which re-hashes everything. + test('racy-git window stays closed even when touch fails (stubbed-touch fallback)', () => { + withScratchRepo((repoDir, _wtree) => { + const file = path.join(repoDir, 'a.txt'); + const indexPath = path.join(repoDir, '.git', 'index'); + gitIn(repoDir, 'config core.trustctime false'); + const pinned = new Date('2026-01-01T12:00:00Z'); + fs.utimesSync(file, pinned, pinned); + gitIn(repoDir, 'add a.txt'); + // PATH-stubbed `touch` that always fails. + const stubDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-touch-stub-')); + fs.writeFileSync(path.join(stubDir, 'touch'), '#!/bin/sh\nexit 1\n', { mode: 0o755 }); + const wtreeStubbed = () => + execSync(`${BIN}/gstack-wtree`, { + cwd: repoDir, + encoding: 'utf-8', + timeout: 10000, + env: { ...process.env, PATH: `${stubDir}:${process.env.PATH ?? ''}` }, + }).trim(); + try { + const clean = wtreeStubbed(); + fs.writeFileSync(file, 'howdy\n'); // same byte length as 'hello\n' + fs.utimesSync(file, pinned, pinned); + fs.utimesSync(indexPath, pinned, pinned); + expect(wtreeStubbed()).not.toBe(clean); + } finally { + fs.rmSync(stubDir, { recursive: true, force: true }); + } + }); + }); + test('exits non-zero outside a git repo', () => { const nonGit = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-wtree-nongit-')); try {