mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 22:14:25 +01:00
* feat(bin): gstack-decision-trail and gstack-worktree-audit, ported from pstack (GSTA-123)
Two MIT ports from Lauren Tan's pstack (licenses/MIT-pstack.txt, NOTICE.md):
a TSV decision trail with the header-once append, the formula guard, a start
row per run and a --run admission check; and a read-only worktree audit with
size, merge state, dirty, remote, PR, last chat and a suggested bucket.
Docs: docs/unattended.md (the decision trail and its end-of-run audit),
autoplan/DRIVER.md, docs/CONTRIBUTOR_REFERENCE.md, docs/standalone-binaries.md,
docs/troubleshooting.md, one line in landing-report.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* test(egress): reasoned scanner exemption for gstack-worktree-audit's base fetch
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: pre-landing review of the pstack ports
bin/gstack-worktree-audit: a worktree path with a space is read whole (sed
on the porcelain 'worktree ' line, not awk's second field); a registered
worktree whose directory is gone prints a 'prunable' row instead of being
skipped; 'safe' now requires a clean tree and either merged into the base
or a closed/merged PR with every commit pushed, so unpushed commits or
untracked files on a merged branch read 'review' (the port's rule called
them safe); fork PRs are dropped and an open PR wins over an older closed
one on a reused branch name; GIT_TERMINAL_PROMPT=0 on the base fetch.
bin/gstack-decision-trail: only a leading -h/--help is help, so a '-h'
cell is appended as data; a trail without a final newline, with CRLF rows
or with a repeated header line is appended to correctly; a run id is
limited to [A-Za-z0-9._:@/-] (exit 2) so a tab or quote cannot break the
start-row idempotence check.
Tests cover each case; the 'without gh' test resolves git from
'git --exec-path' instead of a PATH rebuilt from a tool list. Docs,
CHANGELOG and --help text describe the stricter buckets.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* 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>
* fix: pre-landing review fixes, pass 3
bin/gstack-decision-trail: hasHeader looks for a header line anywhere in
the file, so a blank or hand-written trail gets exactly one header rather
than one per append. bin/gstack-worktree-audit: the broken-worktree check
uses rev-parse --show-cdup and the presence of /.git instead of
comparing two spellings of the same path, which differ on Git for Windows.
Test: the blank-trail case appends twice and asserts a single header.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* 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>
* docs: documentation audit corrections for the pstack ports
docs/standalone-binaries.md, docs/CONTRIBUTOR_REFERENCE.md,
docs/troubleshooting.md: the audit's safe condition is stated in full
(clean and merged, or PR closed/merged with every commit pushed), the
DIRTY list names missing, broken and unknown, start is a reserved phase
name, and 'read-only' names the base fetch as the one write --no-fetch
skips. CHANGELOG.md says the same (the audit's one decision item).
Audit id gsta123-docs-20261026T221657Z, status updated, no blockers.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* Stamp 1.91.268.0 for #3494 (train slot 1, base d9b0d600)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
---------
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
209 lines
12 KiB
Bash
Executable File
209 lines
12 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# gstack-worktree-audit — read-only audit of every git worktree beside the main
|
|
# one: size, age of its last commit, whether its head is merged into the base,
|
|
# tracked edits vs untracked scratch, remote state, the PR on its branch, the
|
|
# 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). 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
|
|
# Tan, MIT; https://github.com/cursor/plugins, pstack/skills/poteto-mode at
|
|
# 99b609a7). Changes from the source: the PR column comes from gh alone through
|
|
# its own --jq (no jq binary) and ignores fork PRs, LAST_CHAT reads Claude
|
|
# Code's per-worktree transcript directory instead of Cursor's, worktree paths
|
|
# may contain spaces, a registered worktree whose directory is gone gets a
|
|
# `prunable` row, `safe` also requires a clean tree with nothing unpushed, and
|
|
# --no-fetch is new. License
|
|
# text: licenses/MIT-pstack.txt; NOTICE.md carries the entry.
|
|
#
|
|
# Usage: gstack-worktree-audit [-b|--base <ref>] [--no-fetch] [repo-path]
|
|
# repo-path defaults to the repository the current directory is in.
|
|
# -b sets the ref the MERGED column checks against (origin/integration, say).
|
|
# Without it the base is origin/HEAD, then origin/main, then origin/master,
|
|
# and a warn line says which one was picked: origin/HEAD is the remote's
|
|
# default branch, not necessarily the branch PRs merge into.
|
|
# --no-fetch skips the best-effort fetch of the base before the merge check.
|
|
#
|
|
# 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 | 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 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
|
|
# else: unpushed commits, untracked files, nothing says it is done)
|
|
set -u
|
|
|
|
base=""
|
|
fetch=1
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
-b|--base) [ $# -ge 2 ] || { echo "gstack-worktree-audit: $1 needs a ref" >&2; exit 2; }; base="$2"; shift 2 ;;
|
|
--no-fetch) fetch=0; shift ;;
|
|
-h|--help) sed -n '2,/^set -u/p' "$0" | sed '$d; s/^# \{0,1\}//'; exit 0 ;;
|
|
-*) echo "gstack-worktree-audit: unknown option: $1" >&2; exit 2 ;;
|
|
*) break ;;
|
|
esac
|
|
done
|
|
|
|
repo="${1:-$(git rev-parse --show-toplevel 2>/dev/null)}"
|
|
[ -z "$repo" ] && { echo "gstack-worktree-audit: not in a git repository; pass a repo path" >&2; exit 1; }
|
|
cd "$repo" || exit 1
|
|
git rev-parse --git-dir >/dev/null 2>&1 || { echo "gstack-worktree-audit: not a git repository: $repo" >&2; exit 1; }
|
|
|
|
# The main worktree is the first entry; every other one is a candidate.
|
|
main_wt=$(git worktree list --porcelain | sed -n 's/^worktree //p' | head -1)
|
|
|
|
if [ -z "$base" ]; then
|
|
for cand in "$(git symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null)" origin/main origin/master; do
|
|
[ -n "$cand" ] && git rev-parse --verify --quiet "$cand^{commit}" >/dev/null && { base="$cand"; break; }
|
|
done
|
|
[ -z "$base" ] && { echo "gstack-worktree-audit: no base ref (origin/HEAD, origin/main, origin/master all missing); pass -b <ref>" >&2; exit 1; }
|
|
echo "warn: no -b given; MERGED checks against $base" >&2
|
|
fi
|
|
|
|
# A stale base only makes MERGED conservative, so the fetch is best-effort. The
|
|
# 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_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
|
|
git rev-parse --verify --quiet "$base^{commit}" >/dev/null 2>&1 || { echo "gstack-worktree-audit: base ref not found: $base" >&2; exit 1; }
|
|
|
|
# GNU coreutils reads `stat -f` as file-system status and `date -r` as a file,
|
|
# so pick the stat and date spellings once.
|
|
if stat -c %Y / >/dev/null 2>&1; then
|
|
stat_fmt=(-c '%Y %n'); fmt_day() { date -u -d "@$1" '+%Y-%m-%d'; }
|
|
else
|
|
stat_fmt=(-f '%m %N'); fmt_day() { date -u -r "$1" '+%Y-%m-%d'; }
|
|
fi
|
|
|
|
# PR state per branch, read once as "<branch>\t<number>\t<state>" lines through
|
|
# gh's own --jq; a fork's PR says nothing about a local branch of the same name,
|
|
# so cross-repository PRs are dropped. Empty when gh is missing or cannot read
|
|
# this repository.
|
|
prs=$(mktemp)
|
|
trap 'rm -f "$prs"' EXIT
|
|
if command -v gh >/dev/null 2>&1; then
|
|
gh pr list --state all --limit 1000 --json number,state,headRefName,isCrossRepository \
|
|
--jq '.[] | select(.isCrossRepository | not) | [.headRefName, (.number|tostring), (.state|ascii_downcase)] | @tsv' > "$prs" 2>/dev/null \
|
|
|| { : > "$prs"; echo "warn: gh could not list this repository's PRs; PR column is -" >&2; }
|
|
else
|
|
echo "warn: gh not on PATH; PR column is -" >&2
|
|
fi
|
|
|
|
projects="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/projects"
|
|
now=$(date +%s)
|
|
|
|
echo "# base: $base"
|
|
printf 'SIZE\tAGE\tMERGED\tDIRTY\tREMOTE\tPR\tLAST_CHAT\tBUCKET\tWORKTREE\n'
|
|
|
|
# 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
|
|
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
|
|
# --show-cdup is empty only when the directory is itself a toplevel; a path
|
|
# comparison would misfire where git and the shell spell one path differently.
|
|
if [ ! -e "$wt/.git" ] || [ -n "$(git -C "$wt" rev-parse --show-cdup 2>/dev/null || echo broken)" ]; 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
|
|
|
|
size=$(du -sh "$wt" 2>/dev/null | awk '{print $1}')
|
|
head=$(git -C "$wt" rev-parse HEAD 2>/dev/null)
|
|
head_ts=$(git -C "$wt" log -1 --format='%ct' HEAD 2>/dev/null || echo 0)
|
|
age=$([ "$head_ts" -gt 0 ] 2>/dev/null && echo "$(( (now - head_ts) / 86400 ))d" || echo "?")
|
|
|
|
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. 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.
|
|
# 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
|
|
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
|
|
|
|
# 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" ] \
|
|
&& remote=pushed \
|
|
|| remote="ahead$(git -C "$wt" rev-list --count "origin/$branch..HEAD" 2>/dev/null)"
|
|
else remote=no-remote; fi
|
|
|
|
# Several PRs can share a branch name over time; an open one describes the
|
|
# branch as it is now, so it wins over a closed or merged one.
|
|
pr="-"
|
|
[ -n "$branch" ] && [ -s "$prs" ] && pr=$(awk -F'\t' -v b="$branch" \
|
|
'$1==b { if ($3=="open" || $3=="draft") { print "#" $2 "/" $3; exit } if (first=="") first="#" $2 "/" $3 } END { if (first!="") print first }' "$prs" | head -1)
|
|
[ -z "$pr" ] && pr="-"
|
|
|
|
# Newest transcript in this worktree's own Claude Code project directory
|
|
# (the path with every non-alphanumeric byte replaced by -, as Claude names it).
|
|
last="-"; last_ts=0
|
|
chat_dir="$projects/$(printf '%s' "$wt" | sed 's/[^A-Za-z0-9]/-/g')"
|
|
if [ -d "$chat_dir" ]; then
|
|
f=$(find "$chat_dir" -type f -print0 2>/dev/null | xargs -0 stat "${stat_fmt[@]}" 2>/dev/null | sort -rn | head -1)
|
|
if [ -n "$f" ]; then last_ts=$(echo "$f" | awk '{print $1}'); last=$(fmt_day "$last_ts" 2>/dev/null); fi
|
|
fi
|
|
recent=$([ "$last_ts" -gt 0 ] 2>/dev/null && [ $(( (now - last_ts) / 86400 )) -le 4 ] && echo yes || echo no)
|
|
|
|
# safe means nothing in the worktree exists only there: a clean tree whose
|
|
# head is in the base, or whose PR is closed or merged with every commit
|
|
# pushed. Unpushed commits or untracked files on a merged branch are review.
|
|
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
|
|
else bucket=review; fi ;;
|
|
esac ;;
|
|
esac
|
|
|
|
printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
|
|
"$size" "$age" "$merged" "$dirty" "$remote" "$pr" "$last" "$bucket" "$wt"
|
|
done | sort -t"$(printf '\t')" -k1,1 -rh
|