mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-11 19:20:26 +01:00
fix: pre-landing review fixes, pass 2
bin/gstack-worktree-audit: a worktree whose own .git link is gone (git marks it prunable, or its toplevel is another repository) prints a 'broken' row in 'review' instead of describing the containing repository as clean and safe; a locked entry whose directory is gone reads 'review', not 'prunable', since prune would not touch it; a clean tree names its gitignored entries as 'clean,ignored:<n>' without changing the bucket; the branch name is read from refs/heads/ by hand so a tag of the same name cannot hide the PR; porcelain blocks are read with IFS=tab so paths with leading or trailing spaces survive; a failed git status reads 'unknown', never 'clean'; the base fetch also sets GIT_ASKPASS=true and ssh BatchMode. bin/gstack-decision-trail: a file whose first line is not the header (a blank file, rows written by hand) gets the header appended, and a UTF-8 BOM before the header no longer turns it into a data row. Tests: the fake gh records its argv and runs the audit's own --jq over canned JSON with a fork PR when jq is installed; row counts are asserted before the every() checks; each test starts from the same gh and closed worktree; the snapshot includes the worktree registry; new fixtures for locked, broken, tag-shadowed and ignored-only worktrees, a blank trail and a BOM. Docs and CHANGELOG describe the new DIRTY values and buckets. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
c9ae990209
commit
c555ce3a83
8 files changed
+171
-46
No files matched your search
+4
-4
@@ -5,12 +5,12 @@
|
||||
**Two read-and-write helpers absorbed from pstack: a decision trail a reviewer reads without the transcript, and a worktree audit that tells you which Conductor worktrees are safe to remove.** Both are ports of Lauren Tan's MIT scripts (header credit in each file, `licenses/MIT-pstack.txt`, NOTICE.md), approved with the rest of the pstack absorb (Garry, 2026-10-26: "Do it, send").
|
||||
|
||||
### Added
|
||||
- **`bin/gstack-decision-trail <file> <phase> <decision> <why> <evidence> <result>`**: appends one UTC-stamped row to a run's TSV decision trail, writing the header once and only ever appending; cells are flattened to one line and a cell that starts with `=`, `+`, `-` or `@` gets a leading quote so evidence lifted from a PR title or a filename cannot run as a formula in a reviewer's spreadsheet. `start <file> <run-id>` appends a run's `start` row (naming the earlier rows and `run:<id>`; the id is letters, digits and `. _ : @ / -`, so a tab or quote in it is a usage error rather than a row that breaks the idempotence check) and is a no-op while that row is the newest start row, so a rerun writes nothing twice; only a leading `-h`/`--help` is help, so a cell whose text is `-h` is appended as data; a trail without a final newline, with CRLF rows or with a repeated header line is appended to correctly; `--run <id>` on an append refuses with `DECISION_TRAIL_START_REQUIRED` (exit 3) until the run has opened its stretch. The end-of-run rule, in `docs/unattended.md` ("The decision trail") and `autoplan/DRIVER.md`: audit the trail against the transcript and correct the log, not the story (a wrong row is superseded, never edited), then a reviewer on another model family reads it and the report ends with an `Attention` section (`reviewed by <model>`, the flags, or `No flags`).
|
||||
- **`bin/gstack-worktree-audit [-b <ref>] [--no-fetch] [repo]`** (bash, read-only): one TSV row per git worktree beside the main one, largest first: size, age, merged into the base, `clean`/`wip:<n>`/`scratch:<n>`, `pushed`/`ahead<n>`/`no-remote`/`detached`, the PR on its branch through `gh`'s own `--jq` (no `jq` needed), the newest Claude Code chat that ran in the worktree (`${CLAUDE_CONFIG_DIR:-~/.claude}/projects/<slug>`) and a bucket: `prunable` (registered, directory gone), `hold-wip`, `hold-open-pr`, `verify-recent-chat`, `safe`, `review`. `safe` means nothing exists only in that worktree: a clean tree that is merged into the base or whose PR is closed or merged with every commit pushed; an unpushed commit or an untracked file on a merged branch reads `review`. A fork's PR on a same-named branch is ignored and an open PR wins over an older closed one on a reused name. Worktree paths with spaces are read whole. It never deletes or prunes. `/landing-report` offers it when the sibling table shows stale worktrees; `docs/CONTRIBUTOR_REFERENCE.md` has the guide for Conductor users, `docs/standalone-binaries.md` lists both bins.
|
||||
- **`bin/gstack-decision-trail <file> <phase> <decision> <why> <evidence> <result>`**: appends one UTC-stamped row to a run's TSV decision trail, writing the header once and only ever appending; cells are flattened to one line and a cell that starts with `=`, `+`, `-` or `@` gets a leading quote so evidence lifted from a PR title or a filename cannot run as a formula in a reviewer's spreadsheet. `start <file> <run-id>` appends a run's `start` row (naming the earlier rows and `run:<id>`; the id is letters, digits and `. _ : @ / -`, so a tab or quote in it is a usage error rather than a row that breaks the idempotence check) and is a no-op while that row is the newest start row, so a rerun writes nothing twice; only a leading `-h`/`--help` is help, so a cell whose text is `-h` is appended as data; a trail without a final newline, with CRLF rows, a UTF-8 BOM, a repeated header line or no header at all is appended to correctly; `--run <id>` on an append refuses with `DECISION_TRAIL_START_REQUIRED` (exit 3) until the run has opened its stretch. The end-of-run rule, in `docs/unattended.md` ("The decision trail") and `autoplan/DRIVER.md`: audit the trail against the transcript and correct the log, not the story (a wrong row is superseded, never edited), then a reviewer on another model family reads it and the report ends with an `Attention` section (`reviewed by <model>`, the flags, or `No flags`).
|
||||
- **`bin/gstack-worktree-audit [-b <ref>] [--no-fetch] [repo]`** (bash, read-only): one TSV row per git worktree beside the main one, largest first: size, age, merged into the base, `clean`/`wip:<n>`/`scratch:<n>`, `pushed`/`ahead<n>`/`no-remote`/`detached`, the PR on its branch through `gh`'s own `--jq` (no `jq` needed), the newest Claude Code chat that ran in the worktree (`${CLAUDE_CONFIG_DIR:-~/.claude}/projects/<slug>`) and a bucket: `prunable` (registered, directory gone), `hold-wip`, `hold-open-pr`, `verify-recent-chat`, `safe`, `review`. `safe` means nothing exists only in that worktree: a clean tree that is merged into the base or whose PR is closed or merged with every commit pushed; an unpushed commit or an untracked file on a merged branch reads `review`. A fork's PR on a same-named branch is ignored and an open PR wins over an older closed one on a reused name. A locked entry whose directory is gone and a `broken` worktree whose own `.git` link is missing (git would otherwise describe the containing repository and hide its files) read `review`; a clean tree names its gitignored entries as `clean,ignored:<n>` without changing the bucket; a branch name shared with a tag still resolves; worktree paths with leading, trailing or inner spaces are read whole. It never deletes or prunes. `/landing-report` offers it when the sibling table shows stale worktrees; `docs/CONTRIBUTOR_REFERENCE.md` has the guide for Conductor users, `docs/standalone-binaries.md` lists both bins.
|
||||
|
||||
### 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, run ids with whitespace or quotes refused, usage errors.
|
||||
- `test/worktree-audit.test.ts`: a bare origin, a clone and eight 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) classified into every bucket through a fake `gh` and a `CLAUDE_CONFIG_DIR` transcript directory; the fixture 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/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.
|
||||
|
||||
### 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.
|
||||
|
||||
@@ -68,7 +68,8 @@ function readRows(file: string): Row[] {
|
||||
const lines = fs.readFileSync(file, "utf8").split("\n");
|
||||
const rows: Row[] = [];
|
||||
lines.forEach((raw, i) => {
|
||||
const text = raw.endsWith("\r") ? raw.slice(0, -1) : raw;
|
||||
let text = raw.endsWith("\r") ? raw.slice(0, -1) : raw;
|
||||
if (i === 0 && text.startsWith("\uFEFF")) text = text.slice(1);
|
||||
// A repeated header (the append-on-flaky-mount case) is never a row.
|
||||
if (text === "" || text === HEADER.trimEnd()) return;
|
||||
rows.push({ line: i + 1, cells: text.split("\t") });
|
||||
@@ -81,14 +82,21 @@ function appendRow(file: string, cells: readonly string[]): number {
|
||||
if (dir && dir !== "." && !fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
// Append the header too, never truncate: on a flaky mount the worst case is
|
||||
// one stray header line, not lost rows.
|
||||
// A file whose first line is not the header (empty, blank, or rows written by
|
||||
// hand) gets one appended; readRows skips a header line wherever it sits.
|
||||
const size = fs.existsSync(file) ? fs.statSync(file).size : 0;
|
||||
if (size === 0) fs.appendFileSync(file, HEADER);
|
||||
else if (!endsWithNewline(file, size)) fs.appendFileSync(file, "\n");
|
||||
if (size > 0 && !endsWithNewline(file, size)) fs.appendFileSync(file, "\n");
|
||||
if (size === 0 || !hasHeader(file)) fs.appendFileSync(file, HEADER);
|
||||
const ts = new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
|
||||
fs.appendFileSync(file, [ts, ...cells.map(clean)].join("\t") + "\n");
|
||||
return readRows(file).length;
|
||||
}
|
||||
|
||||
function hasHeader(file: string): boolean {
|
||||
const first = fs.readFileSync(file, "utf8").split("\n", 1)[0] ?? "";
|
||||
return first.replace(/^\uFEFF/, "").replace(/\r$/, "") === HEADER.trimEnd();
|
||||
}
|
||||
|
||||
function endsWithNewline(file: string, size: number): boolean {
|
||||
const fd = fs.openSync(file, "r");
|
||||
try {
|
||||
|
||||
+49
-16
@@ -5,7 +5,8 @@
|
||||
# newest Claude Code chat that ran in it, and a suggested bucket. One TSV row
|
||||
# per worktree, largest first. It never deletes, prunes, checks out or edits a
|
||||
# worktree or a branch; its one write is the best-effort fetch of the base ref
|
||||
# (skip it with --no-fetch). Removing a worktree stays a step a person takes
|
||||
# (skip it with --no-fetch). In a fork workflow, where origin is your fork and
|
||||
# gh resolves the upstream, every PR is cross-repository and the PR column is -. Removing a worktree stays a step a person takes
|
||||
# after reading the table.
|
||||
#
|
||||
# Ported from pstack's poteto-mode/scripts/worktree-audit.sh (Copyright Lauren
|
||||
@@ -29,14 +30,20 @@
|
||||
# Columns: SIZE AGE MERGED DIRTY REMOTE PR LAST_CHAT BUCKET WORKTREE
|
||||
# MERGED YES when the worktree's head is an ancestor of the base. A
|
||||
# squash-merged branch is never an ancestor, so read PR with it.
|
||||
# DIRTY clean | wip:<n> (tracked edits) | scratch:<n> (untracked only) |
|
||||
# missing (registered, directory gone: `git worktree prune` territory)
|
||||
# REMOTE pushed | ahead<n> | no-remote | detached
|
||||
# DIRTY clean | clean,ignored:<n> (only gitignored entries: .env, build
|
||||
# output; the bucket ignores them, removing the worktree would not) |
|
||||
# wip:<n> (tracked edits) | scratch:<n> (untracked only) |
|
||||
# missing (registered, directory gone) | broken (the worktree's own
|
||||
# .git link is gone, so git reads describe another repository) |
|
||||
# unknown (git status failed)
|
||||
# REMOTE pushed | ahead<n> | no-remote | detached, against the local
|
||||
# origin/<branch> tracking ref as last fetched
|
||||
# PR #<n>/<state> from gh (open, merged, closed; an open PR wins when a
|
||||
# branch name has several, fork PRs are ignored), or - without gh
|
||||
# LAST_CHAT date of the newest Claude Code transcript under
|
||||
# ${CLAUDE_CONFIG_DIR:-~/.claude}/projects/<worktree slug>/, or -
|
||||
# BUCKET prunable (directory gone) > hold-wip (tracked edits) >
|
||||
# BUCKET prunable (directory gone and not locked) > review for a locked or
|
||||
# broken entry > hold-wip (tracked edits) >
|
||||
# hold-open-pr > verify-recent-chat (a chat in the last 4 days) >
|
||||
# safe (clean tree, and either merged into the base or its PR is
|
||||
# closed or merged with every commit pushed) > review (anything
|
||||
@@ -75,7 +82,7 @@ fi
|
||||
# explicit refspec creates the remote ref even in a single-branch clone.
|
||||
if [ "$fetch" = 1 ]; then
|
||||
case "$base" in
|
||||
origin/*) GIT_TERMINAL_PROMPT=0 git fetch origin "+refs/heads/${base#origin/}:refs/remotes/$base" --quiet 2>/dev/null \
|
||||
origin/*) GIT_TERMINAL_PROMPT=0 GIT_ASKPASS=true GIT_SSH_COMMAND="${GIT_SSH_COMMAND:-ssh -o BatchMode=yes}" git fetch origin "+refs/heads/${base#origin/}:refs/remotes/$base" --quiet 2>/dev/null \
|
||||
|| echo "warn: could not fetch $base; MERGED may be stale" >&2 ;;
|
||||
esac
|
||||
fi
|
||||
@@ -109,10 +116,29 @@ now=$(date +%s)
|
||||
echo "# base: $base"
|
||||
printf 'SIZE\tAGE\tMERGED\tDIRTY\tREMOTE\tPR\tLAST_CHAT\tBUCKET\tWORKTREE\n'
|
||||
|
||||
git worktree list --porcelain | sed -n 's/^worktree //p' | while read -r wt; do
|
||||
# One porcelain block per worktree: the path, then flag lines (locked, prunable).
|
||||
# IFS is a tab so a path keeps its leading and trailing spaces; a path with a
|
||||
# newline in it cannot be read from this format and is not supported.
|
||||
git worktree list --porcelain | awk 'BEGIN { RS=""; FS="\n" } {
|
||||
flags = "-"
|
||||
for (i = 2; i <= NF; i++) { if ($i ~ /^locked/) flags = flags "L"; if ($i ~ /^prunable/) flags = flags "P" }
|
||||
print flags "\t" substr($1, 10)
|
||||
}' | while IFS=$'\t' read -r flags wt; do
|
||||
[ "$wt" = "$main_wt" ] && continue
|
||||
# Directory gone: prunable, unless git holds the entry locked (an unmounted
|
||||
# volume, say), in which case prune would not touch it and the branch's
|
||||
# unpushed work may still exist elsewhere.
|
||||
if [ ! -d "$wt" ]; then
|
||||
printf '0\t?\t?\tmissing\t?\t-\t-\tprunable\t%s\n' "$wt"
|
||||
case "$flags" in *L*) bucket=review ;; *) bucket=prunable ;; esac
|
||||
printf '0\t?\t?\tmissing\t?\t-\t-\t%s\t%s\n' "$bucket" "$wt"
|
||||
continue
|
||||
fi
|
||||
# A worktree whose .git file is gone (git marks it prunable) makes every
|
||||
# git -C read describe whichever repository contains the directory, so its
|
||||
# own files would be invisible: report it broken, never safe.
|
||||
case "$flags" in *P*) printf '%s\t?\t?\tbroken\t?\t-\t-\treview\t%s\n' "$(du -sh "$wt" 2>/dev/null | awk '{print $1}')" "$wt"; continue ;; esac
|
||||
if [ "$(git -C "$wt" rev-parse --show-toplevel 2>/dev/null)" != "$(cd "$wt" && pwd -P)" ]; then
|
||||
printf '%s\t?\t?\tbroken\t?\t-\t-\treview\t%s\n' "$(du -sh "$wt" 2>/dev/null | awk '{print $1}')" "$wt"
|
||||
continue
|
||||
fi
|
||||
|
||||
@@ -123,14 +149,21 @@ git worktree list --porcelain | sed -n 's/^worktree //p' | while read -r wt; do
|
||||
|
||||
git merge-base --is-ancestor "$head" "$base" 2>/dev/null && merged=YES || merged=no
|
||||
|
||||
# Tracked edits are work in progress; untracked files alone are scratch.
|
||||
porcelain=$(git -C "$wt" status --porcelain 2>/dev/null)
|
||||
if [ -z "$porcelain" ]; then dirty=clean
|
||||
elif printf '%s\n' "$porcelain" | grep -qv '^??'; then
|
||||
dirty="wip:$(printf '%s\n' "$porcelain" | grep -cv '^??')"
|
||||
else dirty="scratch:$(printf '%s\n' "$porcelain" | grep -c '^??')"; fi
|
||||
# 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
|
||||
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
|
||||
elif printf '%s\n' "$porcelain" | grep -qv '^??'; then
|
||||
dirty="wip:$(printf '%s\n' "$porcelain" | grep -cv '^??')"
|
||||
else dirty="scratch:$(printf '%s\n' "$porcelain" | grep -c '^??')"; fi
|
||||
else dirty=unknown; fi
|
||||
|
||||
branch=$(git -C "$wt" symbolic-ref --quiet --short HEAD 2>/dev/null || echo "")
|
||||
# refs/heads/ is stripped by hand: --short prints heads/<name> when a tag
|
||||
# shares the branch's name, and then nothing downstream would match.
|
||||
branch=$(git -C "$wt" symbolic-ref --quiet HEAD 2>/dev/null | sed 's#^refs/heads/##')
|
||||
if [ -z "$branch" ]; then remote=detached
|
||||
elif git -C "$wt" show-ref --verify --quiet "refs/remotes/origin/$branch"; then
|
||||
[ "$(git -C "$wt" rev-parse "origin/$branch" 2>/dev/null)" = "$head" ] \
|
||||
@@ -161,7 +194,7 @@ git worktree list --porcelain | sed -n 's/^worktree //p' | while read -r wt; do
|
||||
case "$dirty" in wip:*) bucket=hold-wip ;; *)
|
||||
case "$pr" in */open|*/draft) bucket=hold-open-pr ;; *)
|
||||
if [ "$recent" = yes ]; then bucket=verify-recent-chat
|
||||
elif [ "$dirty" = clean ] && { [ "$merged" = YES ] || { [ "$pr" != "-" ] && [ "$remote" = pushed ]; }; }; then bucket=safe
|
||||
elif [ "${dirty%%,*}" = clean ] && { [ "$merged" = YES ] || { [ "$pr" != "-" ] && [ "$remote" = pushed ]; }; }; then bucket=safe
|
||||
else bucket=review; fi ;;
|
||||
esac ;;
|
||||
esac
|
||||
|
||||
@@ -179,18 +179,24 @@ Conductor workspaces are git worktrees, so after a few weeks of parallel
|
||||
lanes the checkout carries worktrees nobody remembers. `bin/gstack-worktree-audit
|
||||
[-b origin/<base>] [--no-fetch] [repo-path]` prints one TSV row per worktree
|
||||
beside the main one, largest first: size, age of the last commit, whether its
|
||||
head is merged into the base, `clean` / `wip:<n>` (tracked edits) /
|
||||
head is merged into the base, `clean` / `clean,ignored:<n>` (only gitignored
|
||||
entries such as `.env` or build output) / `wip:<n>` (tracked edits) /
|
||||
`scratch:<n>` (untracked only), `pushed` / `ahead<n>` / `no-remote` /
|
||||
`detached`, the PR on its branch through `gh` (`#n/open|merged|closed`, or
|
||||
`-`; a fork's PR on a same-named branch is ignored, and an open PR wins over
|
||||
an older closed one on a reused branch name), the date of the newest Claude
|
||||
Code chat that ran in it, and a bucket: `prunable` (registered, directory
|
||||
gone), then `hold-wip`, then `hold-open-pr`, then `verify-recent-chat` (a chat
|
||||
in the last four days), then `safe`, else `review`. `safe` means nothing
|
||||
exists only in that worktree: a clean tree whose head is merged into the base,
|
||||
or whose PR is closed or merged with every commit pushed; an unpushed commit or
|
||||
an untracked file on a merged branch is `review`. A squash-merged branch is
|
||||
never an ancestor of the base, so read `MERGED` together with `PR`. The audit
|
||||
only reads; removing a worktree (`git worktree remove <path>`) stays your call
|
||||
after the table. `/landing-report` points at it when the sibling table shows
|
||||
`detached` (against the local `origin/<branch>` tracking ref as last fetched),
|
||||
the PR on its branch through `gh` (`#n/open|merged|closed`, or `-`; a fork's
|
||||
PR on a same-named branch is ignored, so in a fork workflow where `origin` is
|
||||
your fork the column is `-`, and an open PR wins over an older closed one on a
|
||||
reused branch name), the date of the newest Claude Code chat that ran in it,
|
||||
and a bucket: `prunable` (registered, directory gone, not locked), then
|
||||
`review` for a locked entry whose directory is gone or a `broken` worktree
|
||||
whose own `.git` link is missing, then `hold-wip`, then `hold-open-pr`, then
|
||||
`verify-recent-chat` (a chat in the last four days), then `safe`, else
|
||||
`review`. `safe` means nothing tracked or untracked exists only in that
|
||||
worktree: a clean tree whose head is merged into the base, or whose PR is
|
||||
closed or merged with every commit pushed; an unpushed commit or an untracked
|
||||
file on a merged branch is `review`. Ignored entries do not change the bucket,
|
||||
but removing the worktree removes them too, so read the `ignored:<n>` count
|
||||
before you do. A squash-merged branch is never an ancestor of the base, so read
|
||||
`MERGED` together with `PR`. The audit only reads; removing a worktree
|
||||
(`git worktree remove <path>`) stays your call after the table. `/landing-report` points at it when the sibling table shows
|
||||
stale worktrees.
|
||||
@@ -13,7 +13,7 @@ Beyond the slash-command skills, gstack ships standalone CLIs for workflows that
|
||||
| `gstack-verify-gate` | **Verification stop hook (opt-in)** — blocks a Claude Code turn from ending until the project's declared verify command passes (after 3 blocked re-entries it yields with a loud still-RED warning instead of looping forever). Declare it on one line in CLAUDE.md: `<!-- gstack:verify: bun test -->`. Hooks bypass the permission system, so a declared command never runs until you trust it once per repo (`gstack-verify-gate --trust`); editing the command invalidates trust until re-granted, and every grant is audit-logged. `./setup` never registers it for you — opt in with `gstack-settings-hook add-event --event Stop --command ~/.claude/skills/gstack/bin/gstack-verify-gate --source verify-gate`, remove with `gstack-settings-hook remove-source --source verify-gate`. |
|
||||
| `gstack-memorable` | **Memorable recall bridge (opt-in, third party, Claude Code only)** — connects Claude Code to the external [Memorable](https://memorable.sh) CLI *through gstack* instead of the vendor's own installer, so the hook gets gstack's guarantees: an explicit consent key (`memorable_recall`, off by default, listed by `gstack-egress grants`), a fail-closed egress receipt for every prompt handed over (`gstack-egress list --sink memorable-recall`), a HIGH-tier secret pre-scan, a trust envelope and 8 KiB cap on whatever comes back, an allowlisted environment and process-group containment for the vendor process, and clean removal. `enable` registers the hook at the stable install with a 5 s timeout and never runs the vendor's own consent command; `disable` revokes the gate first and removes the entry by identity even after Claude Code strips the tag; `status` is read-only. gstack never installs Memorable, and what its binary sends is the vendor's claim, not gstack's. Not available on Windows yet. [Full guide](memorable-workflow-memory.md). |
|
||||
| `gstack-decision-trail` | **Decision trail** — append one row (ts, phase, decision, why, evidence, result) to a run's TSV trail so a reviewer reads what was chosen and why without the transcript. Header once, append-only, cells flattened to one line, and a cell starting with `=`, `+`, `-` or `@` quoted so a spreadsheet cannot run it. `start <file> <run-id>` opens a run's stretch of a shared trail (idempotent), and `--run <id>` refuses an append until that start row is the newest. Ported from pstack's `log.sh` (MIT, NOTICE.md). [Rule](unattended.md#the-decision-trail). |
|
||||
| `gstack-worktree-audit` | **Worktree audit** — read-only table of every git worktree beside the main one, largest first: size, age, merged into the base or not, `clean`/`wip:<n>`/`scratch:<n>`, `pushed`/`ahead<n>`/`no-remote`/`detached`, the PR on its branch through `gh` (fork PRs ignored), the newest Claude Code chat in it, and a bucket (`prunable` for a registered worktree whose directory is gone, `hold-wip`, `hold-open-pr`, `verify-recent-chat`, `safe` only for a clean tree with nothing unpushed, else `review`). `-b origin/<branch>` sets the base, `--no-fetch` skips the base fetch. Never deletes. Ported from pstack's `worktree-audit.sh` (MIT, NOTICE.md). [Guide](CONTRIBUTOR_REFERENCE.md#auditing-conductor-worktrees). |
|
||||
| `gstack-worktree-audit` | **Worktree audit** — read-only table of every git worktree beside the main one, largest first: size, age, merged into the base or not, `clean`/`clean,ignored:<n>`/`wip:<n>`/`scratch:<n>`, `pushed`/`ahead<n>`/`no-remote`/`detached`, the PR on its branch through `gh` (fork PRs ignored), the newest Claude Code chat in it, and a bucket (`prunable` for an unlocked registered worktree whose directory is gone, `review` for a locked or `broken` one, `hold-wip`, `hold-open-pr`, `verify-recent-chat`, `safe` only for a clean tree with nothing unpushed, else `review`). `-b origin/<branch>` sets the base, `--no-fetch` skips the base fetch. Never deletes. Ported from pstack's `worktree-audit.sh` (MIT, NOTICE.md). [Guide](CONTRIBUTOR_REFERENCE.md#auditing-conductor-worktrees). |
|
||||
| `gstack-wtree` | **Working-tree fingerprint** — prints a content hash of what's actually on disk (temp index seeded from the stat cache, ~40x cheaper than a full re-hash; untracked source counts, gitignored scratch doesn't). Identical content fingerprints identically through commits, rebases, amends, and squashes — it's what binds reviews and test evidence to content instead of commit SHAs. |
|
||||
| `gstack-review-log` | **Review-pass receipts** — `--start <skill>` captures the working-tree fingerprint before a diff review; `'<JSON>' --finish <token>` consumes that single-use, repository/branch/skill-scoped receipt. Binding requires matching start/end content and reviewer-reported `completed:true` and `converged:true`; it is not independent proof that a model read the source. `--check-shared-libs <token>` reads a current finding as JSON on stdin and checks prior Skip decisions against the actual branch, capture and eligible raw source blobs. It returns `reusable:false` when proof is missing or unsafe, including older records without logger-versioned coverage. Final review logging computes shared-code fingerprints and coverage rather than trusting supplied proof. |
|
||||
| `gstack-review-read` | **Review freshness** — emits review records with computed `review_freshness.status` and `reason`: CURRENT, STALE, or UNVERIFIED for diff reviews. `/ship` and `/land-and-deploy` use the same grade; a matching commit alone never certifies a diff review. [Dashboard rules](skills.md#review-readiness-dashboard). |
|
||||
|
||||
@@ -4556,4 +4556,7 @@ is normal and the bucket is still `safe` while the tree is clean and every
|
||||
commit is pushed; an unpushed commit or an untracked file on that branch makes
|
||||
it `review`, because deleting the worktree would lose it. The audit only reads;
|
||||
a worktree it calls `safe` is still yours to remove, and a `prunable` row names
|
||||
a registered worktree whose directory is already gone (`git worktree prune`).
|
||||
a registered, unlocked worktree whose directory is already gone (`git worktree
|
||||
prune`); a locked one, or a `broken` row whose `.git` link is missing, reads
|
||||
`review` instead. `clean,ignored:<n>` means the tree is clean but holds
|
||||
gitignored entries (`.env`, build output) that go with the worktree.
|
||||
@@ -75,6 +75,19 @@ describe("gstack-decision-trail append", () => {
|
||||
expect(fs.readFileSync(crlf, "utf8").split("\n").filter(Boolean).pop()!.split("\t")[3]).toBe("1 earlier row (2026-01-01T00:00:00Z..2026-01-01T00:00:00Z) written by other runs");
|
||||
});
|
||||
|
||||
test("a blank file gets a header, and a BOM before the header does not make it a row", () => {
|
||||
const blank = path.join(tmp, "blank.tsv");
|
||||
fs.writeFileSync(blank, "\n");
|
||||
expect(run(blank, "p", "d", "w", "e", "r").status).toBe(0);
|
||||
const lines = fs.readFileSync(blank, "utf8").split("\n");
|
||||
expect(lines[1]).toBe("ts\tphase\tdecision\twhy\tevidence\tresult");
|
||||
expect(lines[2]!.split("\t").slice(1)).toEqual(["p", "d", "w", "e", "r"]);
|
||||
const bom = path.join(tmp, "bom.tsv");
|
||||
fs.writeFileSync(bom, "\uFEFFts\tphase\tdecision\twhy\tevidence\tresult\n");
|
||||
expect(run("--run", "r1", bom, "p", "d", "w", "e", "r").status).toBe(0);
|
||||
expect(fs.readFileSync(bom, "utf8").split("\n").filter(Boolean)).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("a run id with whitespace or quotes is a usage error on start and on --run", () => {
|
||||
expect(run("start", file, "a\tb").status).toBe(2);
|
||||
expect(run("start", file, "a b").status).toBe(2);
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* Claude Code transcript directory under CLAUDE_CONFIG_DIR. The audit reads
|
||||
* only: the fixture is byte-compared before and after.
|
||||
*/
|
||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
@@ -41,9 +41,20 @@ function audit(...args: string[]) {
|
||||
return { status: r.status, stderr: r.stderr, base: lines[0], header, rows, byPath: Object.fromEntries(rows.map(r => [r.WORKTREE, r])) };
|
||||
}
|
||||
function snapshot(dir: string): string {
|
||||
const r = spawnSync("bash", ["-c", "find . -path ./.git -prune -o -type f -print0 | sort -z | xargs -0 sha256sum; git -C . status --porcelain; git -C . for-each-ref"], { cwd: dir, encoding: "utf8", timeout: 30_000 });
|
||||
const r = spawnSync("bash", ["-c", "find . -path ./.git -prune -o -type f -print0 | sort -z | xargs -0 sha256sum; git -C . status --porcelain; git -C . for-each-ref; git -C . worktree list --porcelain"], { cwd: dir, encoding: "utf8", timeout: 30_000 });
|
||||
return r.stdout;
|
||||
}
|
||||
// The fake gh records its argv and, when jq is installed, runs the audit's own --jq expression over
|
||||
// canned JSON (so the fork-PR filter is exercised); without jq it prints the TSV that expression yields.
|
||||
function fakeGh(prs: Array<{ number: number; state: string; headRefName: string; isCrossRepository: boolean }>): void {
|
||||
fs.writeFileSync(path.join(fakeBin, "prs.json"), JSON.stringify(prs));
|
||||
fs.writeFileSync(path.join(fakeBin, "prs.tsv"), prs.filter(p => !p.isCrossRepository).map(p => `${p.headRefName}\t${p.number}\t${p.state.toLowerCase()}`).join("\n") + "\n");
|
||||
fs.writeFileSync(path.join(fakeBin, "gh"), `#!/usr/bin/env bash
|
||||
printf '%s\\n' "$@" > "${fakeBin}/gh-args"
|
||||
expr=""; while [ $# -gt 0 ]; do [ "$1" = --jq ] && expr="$2"; shift; done
|
||||
if command -v jq >/dev/null 2>&1 && [ -n "$expr" ]; then jq -r "$expr" "${fakeBin}/prs.json"; else cat "${fakeBin}/prs.tsv"; fi
|
||||
`, { mode: 0o755 });
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
tmp = fs.mkdtempSync(path.join(os.tmpdir(), "gstack-wt-audit-"));
|
||||
@@ -94,8 +105,6 @@ beforeAll(() => {
|
||||
|
||||
fakeBin = path.join(tmp, "fakebin");
|
||||
fs.mkdirSync(fakeBin);
|
||||
// The fake gh prints the TSV the real one's --jq would.
|
||||
fs.writeFileSync(path.join(fakeBin, "gh"), "#!/usr/bin/env bash\nprintf 'feat/wip\\t41\\topen\\nfeat/closed\\t40\\tclosed\\n'\n", { mode: 0o755 });
|
||||
|
||||
// spaced: a worktree whose path has a space, merged into main, clean → safe.
|
||||
wts.spaced = path.join(tmp, "wt with space");
|
||||
@@ -113,6 +122,46 @@ beforeAll(() => {
|
||||
wts.gone = path.join(tmp, "wt-gone");
|
||||
git(repo, "worktree", "add", "-q", "-b", "feat/gone", wts.gone);
|
||||
fs.rmSync(wts.gone, { recursive: true, force: true });
|
||||
|
||||
// locked: registered and locked, directory gone → review (prune would not touch it).
|
||||
wts.locked = path.join(tmp, "wt-locked");
|
||||
git(repo, "worktree", "add", "-q", "-b", "feat/locked", wts.locked);
|
||||
git(repo, "worktree", "lock", wts.locked);
|
||||
fs.rmSync(wts.locked, { recursive: true, force: true });
|
||||
|
||||
// broken: a gitignored worktree inside the clone whose .git file is gone, with a
|
||||
// precious file only there; git -C would describe the clone itself → review.
|
||||
fs.appendFileSync(path.join(repo, ".git", "info", "exclude"), ".wt\n");
|
||||
wts.broken = path.join(repo, ".wt", "broken");
|
||||
git(repo, "worktree", "add", "-q", "-b", "feat/broken", wts.broken);
|
||||
fs.writeFileSync(path.join(wts.broken, "precious.txt"), "only here\n");
|
||||
fs.rmSync(path.join(wts.broken, ".git"));
|
||||
|
||||
// tagged: merged and clean, with a tag of the same name as its branch and an open PR → hold-open-pr.
|
||||
wts.tagged = path.join(tmp, "wt-tagged");
|
||||
git(repo, "worktree", "add", "-q", "-b", "feat/tagged", wts.tagged);
|
||||
git(repo, "tag", "feat/tagged");
|
||||
|
||||
// ignored: merged and clean, with a gitignored .env → safe, DIRTY names the ignored entry.
|
||||
wts.ignored = path.join(tmp, "wt-ignored");
|
||||
git(repo, "worktree", "add", "-q", "-b", "feat/ignored", wts.ignored);
|
||||
fs.writeFileSync(path.join(wts.ignored, ".gitignore"), ".env\n");
|
||||
git(wts.ignored, "add", ".gitignore");
|
||||
git(wts.ignored, "commit", "-q", "-m", "ignore .env");
|
||||
git(repo, "merge", "-q", "--ff-only", "feat/ignored");
|
||||
git(repo, "push", "-q", "origin", "main");
|
||||
fs.writeFileSync(path.join(wts.ignored, ".env"), "SECRET=1\n");
|
||||
});
|
||||
// Each test starts from the same gh (one open, one closed, one fork PR on feat/stale that must be
|
||||
// ignored) and the same closed worktree, whatever an earlier test changed.
|
||||
beforeEach(() => {
|
||||
fakeGh([
|
||||
{ number: 41, state: "OPEN", headRefName: "feat/wip", isCrossRepository: false },
|
||||
{ number: 40, state: "CLOSED", headRefName: "feat/closed", isCrossRepository: false },
|
||||
{ number: 99, state: "OPEN", headRefName: "feat/stale", isCrossRepository: true },
|
||||
{ number: 43, state: "OPEN", headRefName: "feat/tagged", isCrossRepository: false },
|
||||
]);
|
||||
git(wts.closed, "reset", "-q", "--hard", "origin/feat/closed");
|
||||
});
|
||||
afterAll(() => {
|
||||
if (repo) spawnSync("git", ["worktree", "prune"], { cwd: repo, timeout: 30_000 });
|
||||
@@ -128,12 +177,19 @@ describe("gstack-worktree-audit", () => {
|
||||
expect(r.stderr).toContain("warn: no -b given; MERGED checks against origin/main");
|
||||
expect(r.base).toBe("# base: origin/main");
|
||||
expect(r.header).toEqual(["SIZE", "AGE", "MERGED", "DIRTY", "REMOTE", "PR", "LAST_CHAT", "BUCKET", "WORKTREE"]);
|
||||
expect(r.rows).toHaveLength(8);
|
||||
expect(r.rows).toHaveLength(12);
|
||||
expect(r.byPath[repo]).toBeUndefined();
|
||||
expect(r.byPath[wts.locked]).toMatchObject({ SIZE: "0", DIRTY: "missing", BUCKET: "review" });
|
||||
expect(r.byPath[wts.broken]).toMatchObject({ DIRTY: "broken", BUCKET: "review" });
|
||||
expect(r.byPath[wts.tagged]).toMatchObject({ MERGED: "YES", DIRTY: "clean", PR: "#43/open", BUCKET: "hold-open-pr" });
|
||||
expect(r.byPath[wts.ignored]).toMatchObject({ MERGED: "YES", DIRTY: "clean,ignored:1", BUCKET: "safe" });
|
||||
const ghArgs = fs.readFileSync(path.join(fakeBin, "gh-args"), "utf8").split("\n");
|
||||
expect(ghArgs).toContain("number,state,headRefName,isCrossRepository");
|
||||
expect(ghArgs.find(a => a.startsWith(".[]"))).toContain("select(.isCrossRepository | not)");
|
||||
expect(r.byPath[wts.spaced]).toMatchObject({ MERGED: "YES", DIRTY: "clean", BUCKET: "safe" });
|
||||
expect(r.byPath[wts.fresh]).toMatchObject({ MERGED: "YES", DIRTY: "scratch:1", REMOTE: "no-remote", BUCKET: "review" });
|
||||
expect(r.byPath[wts.gone]).toMatchObject({ SIZE: "0", DIRTY: "missing", BUCKET: "prunable" });
|
||||
expect(r.rows[r.rows.length - 1]!.WORKTREE).toBe(wts.gone);
|
||||
expect(r.rows.slice(-2).map(row => row.WORKTREE).sort()).toEqual([wts.gone, wts.locked].sort());
|
||||
|
||||
const merged = r.byPath[wts.merged]!;
|
||||
expect(merged).toMatchObject({ MERGED: "YES", DIRTY: "clean", REMOTE: "no-remote", PR: "-", LAST_CHAT: "-", BUCKET: "safe", AGE: "0d" });
|
||||
@@ -155,7 +211,10 @@ describe("gstack-worktree-audit", () => {
|
||||
const r1 = audit("-b", "origin/main", "--no-fetch", repo);
|
||||
expect(r1.stderr).not.toContain("warn: no -b given");
|
||||
expect(r1.byPath[wts.closed]).toMatchObject({ REMOTE: "ahead1", PR: "#40/closed", BUCKET: "review" });
|
||||
fs.writeFileSync(path.join(fakeBin, "gh"), "#!/usr/bin/env bash\nprintf 'feat/closed\\t40\\tclosed\\nfeat/closed\\t42\\topen\\n'\n", { mode: 0o755 });
|
||||
fakeGh([
|
||||
{ number: 40, state: "CLOSED", headRefName: "feat/closed", isCrossRepository: false },
|
||||
{ number: 42, state: "OPEN", headRefName: "feat/closed", isCrossRepository: false },
|
||||
]);
|
||||
const r2 = audit("-b", "origin/main", "--no-fetch", repo);
|
||||
expect(r2.byPath[wts.closed]).toMatchObject({ REMOTE: "ahead1", PR: "#42/open", BUCKET: "hold-open-pr" });
|
||||
});
|
||||
@@ -176,11 +235,14 @@ describe("gstack-worktree-audit", () => {
|
||||
const r = spawnSync("bash", [BIN, "--no-fetch"], { cwd: repo, encoding: "utf8", timeout: 60_000, env: { ...process.env, PATH: noGh, CLAUDE_CONFIG_DIR: claudeDir, HOME: tmp } });
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stderr).toContain("PR column is -");
|
||||
expect(r.stdout.split("\n").filter(Boolean).slice(2).every(l => l.split("\t")[5] === "-")).toBe(true);
|
||||
const rows = r.stdout.split("\n").filter(Boolean).slice(2);
|
||||
expect(rows).toHaveLength(12);
|
||||
expect(rows.every(l => l.split("\t")[5] === "-")).toBe(true);
|
||||
fs.writeFileSync(path.join(fakeBin, "gh"), "#!/usr/bin/env bash\necho 'gh: not logged in' >&2; exit 4\n", { mode: 0o755 });
|
||||
const failing = audit("--no-fetch");
|
||||
expect(failing.status).toBe(0);
|
||||
expect(failing.stderr).toContain("gh could not list this repository's PRs; PR column is -");
|
||||
expect(failing.rows).toHaveLength(12);
|
||||
expect(failing.rows.every(row => row.PR === "-")).toBe(true);
|
||||
expect(audit("-b", "origin/nope", "--no-fetch").status).toBe(1);
|
||||
expect(audit("--no-fetch", path.join(tmp, "not-a-repo")).status).toBe(1);
|
||||
|
||||
Reference in new issue
Block a user