From 0d372b73493f2f1252a5e936bedf089178f402b0 Mon Sep 17 00:00:00 2001 From: garrytan Date: Mon, 26 Oct 2026 22:15:38 +0000 Subject: [PATCH] fix: adversarial review, pass 5 (rule-23 grant, one finding) bin/gstack-worktree-audit: git status runs with --untracked-files=normal --ignore-submodules=none, so a user's status.showUntrackedFiles=no config cannot hide untracked files and let a worktree holding them read clean and safe. Test: the fresh worktree under that config via GIT_CONFIG_* still reads scratch:1 / review (red before the fix). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- CHANGELOG.md | 2 +- bin/gstack-worktree-audit | 4 +++- test/worktree-audit.test.ts | 11 +++++++++++ 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8dde5e6db..8b3cf94dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ ### Tests - `test/decision-trail.test.ts`: header once (an empty file gets one, a second append does not), the formula guard on every leading byte it covers, flattened tabs and line breaks, the start row's text and its idempotent rerun, a second run's start row, the `--run` refusal and admission, a `-h` cell appended as data, a missing final newline, CRLF rows and a repeated header, a blank file and a BOM, run ids with whitespace or quotes refused, usage errors. -- `test/worktree-audit.test.ts`: a bare origin, a clone and twelve worktrees (merged, wip with an open PR, scratch with a chat today, stale, closed PR, a merged one whose path has a space, a merged one with only an untracked file, a registered one whose directory is gone, a locked one whose directory is gone, a broken one inside the clone with a precious file, one whose branch shares its name with a tag, one with only a gitignored `.env`) classified into every bucket through a fake `gh` (which records its arguments and runs the audit's own `--jq` over canned JSON with a fork PR when `jq` is installed) and a `CLAUDE_CONFIG_DIR` transcript directory; the fixture, its worktree registry included, is byte-identical after the audit; `-b`, `--no-fetch`, an unpushed commit on a closed PR reading `review` and an open PR on the reused name winning, no `gh` on PATH (git resolved from `git --exec-path`, so a distribution's wrapper script does not matter), a failing `gh`, a missing base and a non-repo path. +- `test/worktree-audit.test.ts`: a bare origin, a clone and twelve worktrees (merged, wip with an open PR, scratch with a chat today, stale, closed PR, a merged one whose path has a space, a merged one with only an untracked file, a registered one whose directory is gone, a locked one whose directory is gone, a broken one inside the clone with a precious file, one whose branch shares its name with a tag, one with only a gitignored `.env`) classified into every bucket (also under `status.showUntrackedFiles=no`, which must not hide untracked files) through a fake `gh` (which records its arguments and runs the audit's own `--jq` over canned JSON with a fork PR when `jq` is installed) and a `CLAUDE_CONFIG_DIR` transcript directory; the fixture, its worktree registry included, is byte-identical after the audit; `-b`, `--no-fetch`, an unpushed commit on a closed PR reading `review` and an open PR on the reused name winning, no `gh` on PATH (git resolved from `git --exec-path`, so a distribution's wrapper script does not matter), a failing `gh`, a missing base and a non-repo path. ### Gate integrity - No threshold, timeout, trial count or case kind changed. No pinned skill changed; `landing-report/SKILL.md` grows by one line and is unpinned. `test/autoplan-driver.test.ts`'s line cap on `autoplan/DRIVER.md` moves 160 → 162 (measured 162) for the decision-trail paragraph at the end of the coordinator contract section; the file was at the cap before. diff --git a/bin/gstack-worktree-audit b/bin/gstack-worktree-audit index 2271c63ab..ef3d8ad99 100755 --- a/bin/gstack-worktree-audit +++ b/bin/gstack-worktree-audit @@ -154,7 +154,9 @@ git worktree list --porcelain | awk 'BEGIN { RS=""; FS="\n" } { # Tracked edits are work in progress; untracked files alone are scratch. A # clean tree still names its ignored entries (.env, build output): they do # not change the bucket, but they are what removing the worktree would lose. - if porcelain=$(git -C "$wt" status --porcelain 2>/dev/null); then + # The flags pin what status reports: a status.showUntrackedFiles=no config + # would otherwise hide untracked files and let a worktree holding them read clean. + if porcelain=$(git -C "$wt" status --porcelain --untracked-files=normal --ignore-submodules=none 2>/dev/null); then if [ -z "$porcelain" ]; then ignored=$(git -C "$wt" ls-files --others --ignored --exclude-standard --directory 2>/dev/null | grep -c .) [ "$ignored" -gt 0 ] && dirty="clean,ignored:$ignored" || dirty=clean diff --git a/test/worktree-audit.test.ts b/test/worktree-audit.test.ts index cf9d7b641..dee93341b 100644 --- a/test/worktree-audit.test.ts +++ b/test/worktree-audit.test.ts @@ -206,6 +206,17 @@ describe("gstack-worktree-audit", () => { expect(after).toBe(before); }); + test("status.showUntrackedFiles=no in the user's config does not hide untracked files", () => { + const r = spawnSync("bash", [BIN, "--no-fetch"], { + cwd: repo, encoding: "utf8", timeout: 60_000, + env: { ...process.env, PATH: `${fakeBin}:${process.env.PATH}`, CLAUDE_CONFIG_DIR: claudeDir, HOME: tmp, GIT_CONFIG_COUNT: "1", GIT_CONFIG_KEY_0: "status.showUntrackedFiles", GIT_CONFIG_VALUE_0: "no" }, + }); + expect(r.status).toBe(0); + const row = r.stdout.split("\n").find(l => l.endsWith("\t" + wts.fresh))!; + expect(row.split("\t").slice(3, 4)).toEqual(["scratch:1"]); + expect(row.split("\t")[7]).toBe("review"); + }); + test("an unpushed commit on a branch with a closed PR is review, an open PR on the same name wins and holds it, and -b picks the base", () => { git(wts.closed, "commit", "-q", "--allow-empty", "-m", "one more"); const r1 = audit("-b", "origin/main", "--no-fetch", repo);