Skip to content

feat(repo-hygiene): stash audit, worktree/no-upstream branch classes, single-walk scan, resolver notes - #1066

Merged
kyle-sexton merged 25 commits into
mainfrom
feat/repo-hygiene-stash-preflight-resolver
Jul 22, 2026
Merged

feat(repo-hygiene): stash audit, worktree/no-upstream branch classes, single-walk scan, resolver notes#1066
kyle-sexton merged 25 commits into
mainfrom
feat/repo-hygiene-stash-preflight-resolver

Conversation

@kyle-sexton

@kyle-sexton kyle-sexton commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Summary

Batch D small/medium fixes for the repo-hygiene clean skill, delivered as one PR. Version bumped 0.5.00.6.0 with a matching CHANGELOG entry.

Closes #996
Closes #997
Closes #998
Closes #1000
Closes #1011

Verification

  • All repo-hygiene clean *.test.sh green; shellcheck (--rcfile .shellcheckrc), shfmt -i 2, markdownlint, and check-changelog-parity --check-bump clean. No issue-refs / TODO markers in code comments (comment-hygiene).
  • New stash / branch audits run read-only (dry) against a real repo — never a destructive apply: the stash audit surfaces the field's "stale pre-776" stash without false-flagging the main-branch stash; the branch audit classified 15 worktree branches into the WORKTREE bucket and surfaced no-upstream branches' unpushed commit counts.
  • Independent reviewer run on the diff before opening.

Related

🤖 Generated with Claude Code

kyle-sexton and others added 17 commits July 22, 2026 12:35
…mary

Rework the clean skill's build/caches enumeration and apply flow as one change.

#993 — the selective caches/build tiers ran ~10 unpruned full-tree `find`
walks per repo; the `! -path` exclusions filtered output but did not `-prune`,
so every walk re-descended `.git/`, `node_modules/`, and `.venv/`. A single
pruned walk per tier (`clean_enumerate`) now prunes those three trees once and
`-print`s all dir-name and file-glob matches, then classifies on the result
list. Measured on a large .NET + node repo (Windows/NTFS): one pruned walk incl.
`du` sizing ~17s vs a 10-walk unpruned dry-run that exceeded 10 min (killed).

#995 — the dry-run now writes a session-scoped manifest
(`<class>\t<bytes>\t<relpath>` per eligible target), prints `Manifest: <path>`
and `Summary: planned=N bytes=K` so the gate can state reclaimable space, and
`--apply --manifest <path>` consumes it with a re-stat + re-classify staleness
guard instead of re-walking. Resume = re-run the same command (already-gone
entries are idempotent). `--apply` without a manifest builds one then applies it,
preserving the standalone CLI contract; `--include-caches` folds the caches tier
into the one build manifest (no subprocess).

#1002 — each `--apply` ends with `Summary: removed=N failed=M bytes=K` and exits
non-zero when failed>0, so a fleet sweep no longer needs per-log grepping.

Cross-tier nested-target dedup drops any eligible path under an eligible ancestor
so byte totals never double-count. scan.sh keeps the old walk pattern for now,
tracked as TODO(#1011).

Closes #993
Closes #995
Closes #1002

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016D1oCPX8LaUKnLUi3TXmih
…ding

The manifest is a consumed contract (#994 parses it), so assert the exact
class<TAB>bytes<TAB>relpath shape per tier rather than substrings, and cover
--include-caches folding both a build and a caches entry into one manifest.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016D1oCPX8LaUKnLUi3TXmih
`clean-build.sh --apply` ran `dotnet clean <solution>` before removing bin/obj
wholesale, but the universal artifact removal already deletes everything the
driver would — running it first was pure overhead: a full MSBuild evaluation
(minutes on a large solution) that also re-created obj/ evaluation artifacts. One
walk + rm is strictly faster and equally complete. Removes the driver and its
`Planned: dotnet clean …` (dry-run) / `DRIVER_FAILED:` (apply) output markers,
and the now-stale driver references in the config/ecosystem/README docs.

Closes #999

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016D1oCPX8LaUKnLUi3TXmih
The repo's comment-hygiene gate forbids tracker/issue references and TODO
markers in code comments; version control, the CHANGELOG, and the tracker own
that history. Strips the `(#NNN)` refs and the scan.sh `TODO(#…)` note from the
clean scripts and their comments — no behavior change. The scan.sh migration
deferral remains tracked by its own issue and this PR.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016D1oCPX8LaUKnLUi3TXmih
…edup

The `--apply --manifest <path>` surface is documented and caller-supplied, so
`clean_apply_manifest` now treats the manifest as untrusted input:

- Containment: an entry whose repo-relative path is absolute or traverses a
  parent (`..`, either separator) is rejected before any stat/rm and counted as
  a failure — an entry like `../outside` can no longer make `rm -rf` escape the
  repository.
- Byte field as data: the size field is validated as an unsigned decimal before
  it reaches Bash arithmetic (which evaluates array subscripts recursively), so
  a crafted value cannot execute embedded command substitution.
- Fail closed: `--apply --manifest <missing>` now exits non-zero with a clear
  message instead of printing `removed=0 failed=0` and exiting 0, which would let
  automation treat a mistyped path as a successful sweep.

Also replaces the O(n^2) nested-target dedup with an O(n log n) sort pass: each
path is sorted under a trailing-'/' key so an ancestor sorts immediately before
all its descendants (and never swallows a sibling like `buildstuff`), then a
single scan drops anything under the last kept ancestor — the dry-run no longer
spends minutes deduplicating a monorepo's thousands of candidates before it can
show the confirmation plan.

Regression tests cover containment rejection, arithmetic-injection refusal via a
sentinel, and the missing-manifest fail-closed exit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016D1oCPX8LaUKnLUi3TXmih
…rules

The manifest containment guard stopped `..`/absolute escapes but still trusted
that any in-repo path a manifest listed was a real cleanup target. A caller-
supplied or concurrently-altered entry such as `caches\t1\tnotes` therefore
passed the protection gate (an ordinary untracked dir is unprotected) and was
removed while the run reported success.

`clean_apply_manifest` now takes the tier's allowed classes and, per entry,
rejects (fail closed, counted as a failure) any entry whose class the tier does
not produce, and any path that is not a legitimate target for its class per the
same candidate rules enumeration uses to find targets (explicit repo-root paths,
a recognized dir-name leaf, or a file-glob leaf). clean-caches accepts only
`caches`; clean-build accepts `build` and the caches it folds in.

Regression tests cover a non-target untracked dir (`notes/`) and a wrong-tier
entry — both rejected, preserved, non-zero exit. The rm-failure case now targets
a valid explicit cache so it exercises the genuine Unremovable branch rather than
target rejection.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016D1oCPX8LaUKnLUi3TXmih
…rwrite

Two more hardenings of the untrusted --manifest surface:

- Target type: manifest target validation now also checks the filesystem type
  each candidate rule emits (dir names / explicit dirs are found -type d, globs /
  explicit files -type f). A regular file merely named `bin` (or `__pycache__`)
  can no longer be removed by a crafted `build\t1\timportant/bin` — the dry-run
  could never have planned it. The existence check now precedes validation so a
  resumed, already-removed entry stays an idempotent no-op rather than a
  rejection.
- Manifest overwrite: `--manifest <path>` is a caller-supplied input, so the
  dry-run build refuses to truncate an existing NON-manifest file (e.g. a mistyped
  `~/.config/app/settings`) and exits non-zero instead of silently erasing it;
  an absent path or an existing manifest-format file is still (re)written.

Regression tests cover a file-as-dir-name target rejection and the
refuse-to-overwrite guard (including that a real manifest is still rewritable).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016D1oCPX8LaUKnLUi3TXmih
…orever

A trailing `--manifest` (e.g. `clean-caches.sh --apply --manifest`) hung: `$2`
is empty so `shift 2` fails on the single remaining arg, and without `set -e`
`$#` is unchanged, so the case loop reprocesses `--manifest` forever. Both entry
scripts now require a value to follow `--manifest` and exit with the documented
usage error (2) otherwise. Regression test guards with a `timeout` so a
regression fails loudly instead of spinning.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016D1oCPX8LaUKnLUi3TXmih
Enumeration prunes .git/node_modules/.venv and never emits a target inside them,
but the apply path did not re-exclude them: a manifest entry such as
`caches\t1\t.git/objects/__pycache__` passed every guard (its basename is a valid
target name and .git is not in the protected-substring set) and was removed —
removing files under .git can corrupt the repository.

Lift the prune set into a single CLEAN_PRUNE_DIRS SSOT that the single-walk
enumeration builds its `-prune` alternation from AND the apply path rejects
against, so the prune set is enforced uniformly on both sides. Any manifest entry
with a pruned segment is now rejected (fail closed). Regression test covers a
`.git/...` entry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016D1oCPX8LaUKnLUi3TXmih
… records

Two manifest-parsing gaps that let a bad manifest report success:

- A valid final record with no trailing newline (common in caller-written
  files) was dropped: `read` returns nonzero at EOF so the loop body never ran,
  and `--apply` reported `removed=0 failed=0` while leaving the target in place.
  The loop now processes a nonempty final record via `|| [[ -n … ]]`.
- A nonblank but truncated record missing the path field (e.g. `caches<TAB>1`,
  or a partial write by a concurrent process) was silently skipped. It is now a
  malformed-record failure (fail closed); only genuinely blank lines are ignored.

Regression tests cover both.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016D1oCPX8LaUKnLUi3TXmih
…targets

Two more manifest-surface hardenings, both keeping apply to exactly what the
dry-run could have emitted:

- Manifest creation: clean_manifest_path only truncated its target and never
  checked the result, so `--dry-run --manifest <unwritable-or-missing-parent>`
  printed a `Manifest:`/`Summary: planned=` plan for a file that was never
  written, handing apply a bogus path. It now fails (non-zero, prints nothing on
  stdout) when the file cannot be created.
- Symlinked targets: the enumerator matches with `find -type d`/`-type f`, which
  never follow a symlink or Windows junction, but Bash `[[ -d ]]`/`[[ -f ]]` do.
  Target validation now requires a plain dir/file (not a symlink/reparse point),
  so a manifest naming a link (or a path swapped for one after the dry-run) is
  rejected instead of having its link removed.

Regression tests cover the uncreatable-manifest failure and the symlinked-target
rejection (the latter skips where the FS cannot create a symlink).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016D1oCPX8LaUKnLUi3TXmih
…ly parity

Keeps the dry-run plan and the apply outcome faithful to each other and to the
filesystem:

- Apply loop fd isolation (root-cause bug): a per-entry validator shells out to
  `fsutil` (the reparse-point check), which drains stdin. Read on fd 0, it
  swallowed the rest of the manifest — every entry after the first was silently
  skipped, so `clean-build --apply --include-caches` removed the first target and
  reported success while leaving the rest. The loop now reads the manifest on a
  dedicated fd (3), and the reparse check gives fsutil its own `</dev/null`.
- Reclaimed bytes are re-measured from the filesystem at removal time instead of
  trusting the manifest's dry-run byte field: the target may have changed since
  the plan, and a caller-supplied value never reaches the summary arithmetic (no
  overflow, no evaluation of untrusted input).
- Dry-run/apply parity for explicit caches: an explicit cache path that is a file
  or symlink (not the plain dir `find` would emit) is filtered at planning, so
  the advertised plan is always applyable rather than being rejected at --apply.
- Unencodable paths: a candidate whose path contains a tab or newline cannot be
  encoded in the tab-delimited, newline-terminated manifest, so it is skipped
  with a warning rather than written as a record that maps to the wrong path.

Regression tests cover the file-named explicit cache, the recomputed bytes, and
the unencodable-path skip; the existing include-caches apply test now exercises
the multi-entry loop that the fd bug broke.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016D1oCPX8LaUKnLUi3TXmih
Comment-hygiene forbids tracker references in code comments; reword to name the
downstream consumer generically.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016D1oCPX8LaUKnLUi3TXmih
`typos` flagged `applyable` and `mis-mapped` in the new comments; reword to
plain phrasing ("the plan can still be applied", "not mapped to a wrong path").

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016D1oCPX8LaUKnLUi3TXmih
The `..`/absolute containment guard and the final-component reparse check did not
cover a symlinked ANCESTOR: a manifest naming `link/__pycache__`, where `link`
points outside the repo, passed every check and `rm -rf` followed the ancestor
symlink to delete the external directory while reporting success. Enumeration
uses `find -type d`, which never descends a symlinked dir, so such a path is
never one the dry-run emitted.

Apply now walks every component of `root/rel` and rejects the entry (fail closed)
if any ancestor or the target is a symlink/reparse point — junction-aware via the
existing fsutil check, so it also catches Windows reparse-point ancestors that
`realpath`/`-L` miss. Regression test covers a symlinked ancestor pointing
outside the repo (skips where the FS cannot create a symlink).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016D1oCPX8LaUKnLUi3TXmih
The new symlinked-ancestor walk also catches a symlinked final target (it walks
every component including the leaf), which fires before the type check — so a
symlinked `.turbo` is now rejected as a symlinked path, not "not a caches
target". Rename the marker to `Rejected (symlinked path)` (accurate for both a
symlinked ancestor and a symlinked target) and align the two symlink regression
tests, which now run on Linux CI.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016D1oCPX8LaUKnLUi3TXmih
… single-walk scan, resolver notes

Add a stash audit action and script (age, source branch, diffstat, PR/merge
signal, per-stash keep/drop advisory; never auto-drops; deduped across linked
worktrees by --git-common-dir). Give branches checked out in a linked worktree
their own WORKTREE bucket routed to the worktree tool, and surface never-pushed
branches' unpushed commits (no-upstream count vs origin/<default>) as their own
class plus an Unpushed line. Migrate scan.sh's per-pattern unpruned find walks
onto the shared clean_enumerate engine so the read-only inventory and the
mutating tiers share one prune set. Teach the resolver to echo trailing free
text after an action token as an advisory Note.

Closes #996
Closes #997
Closes #998
Closes #1000
Closes #1011

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016D1oCPX8LaUKnLUi3TXmih
@claude

claude Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Claude finished @kyle-sexton's task in 4m 1s —— View job


PR Review — feat(repo-hygiene): stash audit, worktree/no-upstream branch classes, single-walk scan, resolver notes

Status: Complete

  • Gather context and PR diff
  • Review git-stash-audit.sh and tests
  • Review git-branch-audit.sh changes (worktree + no-upstream)
  • Review clean-common.sh (single-walk engine)
  • Review scan.sh migration
  • Review resolve-clean-action.sh (resolver notes)
  • Review docs/SKILL.md/CHANGELOG changes
  • Post findings

Overall this is a well-structured batch. The manifest security hardening is thorough, the test suite is excellent, and the design decisions (read-only stash audit, WORKTREE bucket priority ordering, exit-status gate for upstream detection) are all correct. Three items worth addressing before merge.


Findings

1. --git-common-dir fallback emits a relative path on git < 2.31 — breaks worktree dedup [correctness]

git-stash-audit.sh L47–48:

COMMON_DIR="$(git -C "$REPO_ROOT" rev-parse --path-format=absolute --git-common-dir 2>/dev/null | tr -d '\r')"
[[ -z "$COMMON_DIR" ]] && COMMON_DIR="$(git -C "$REPO_ROOT" rev-parse --git-common-dir 2>/dev/null | tr -d '\r')"

--path-format=absolute was added in Git 2.31. When it's absent (the first call silently emits nothing on older git), the fallback re-runs rev-parse --git-common-dir without that flag, which returns a relative path such as .git or ../../.git. Two worktrees of the same repo visited from different working directories would then produce different COMMON_DIR values (/work/a/.git vs ../../.git), so the dedup key doesn't match and the stash list is counted twice for the fleet total.

Fix: Either resolve to absolute in the fallback, or document the known limitation:

[[ -z "$COMMON_DIR" ]] && COMMON_DIR="$(cd "$REPO_ROOT" && git rev-parse --git-common-dir 2>/dev/null | tr -d '\r')"
# Still relative (.git), but anchored to REPO_ROOT — so use:
[[ "$COMMON_DIR" != /* ]] && COMMON_DIR="$REPO_ROOT/$COMMON_DIR"

Fix this →


2. Stash test doesn't exercise the MERGED-PR advisory path [test-coverage]

git-stash-audit.test.sh — the gh stub always exits 1, so PR_STATE is always empty. The "likely superseded — source branch PR merged" advisory at L91–94 of git-stash-audit.sh is never reached by any test — only the git-ancestry "possibly superseded" path is covered. This means a future refactor of that branch is unprotected.

Fix: Add a test block that provides a gh stub emitting a MERGED PR JSON for feat/done and asserts the output contains "likely superseded".

Fix this →


3. resolve-clean-action.sh — embedded action token in trailing free text is silently swallowed [correctness]

resolve-clean-action.sh L92–105

The note-extraction path only checks whether $2 resolves to an action. With three or more tokens where the third is a distinct action word, the conflict is silently absorbed into the note:

$ ./resolve-clean-action.sh git foo scan
Action: git
Note: foo scan

vs.

$ ./resolve-clean-action.sh git scan
Action: menu

A user typing "git what about scan" gets Action: git + Note: what about scan (reasonable), but "git also scan" gets the same result even though scan is an explicit action token. This is unlikely to produce wrong behavior in practice (the note surfaces to the agent who can clarify), but it is an undocumented asymmetry worth a comment — or alternatively, check whether any word in the remainder resolves to an action (not just $2) and fall through to the token loop if so.

The current PR description acknowledges the intent ("trailing text is no longer re-interpreted"), so this may be a deliberate trade-off. If so, a comment near the check noting "words beyond $2 are not re-interpreted even if they match action tokens" would prevent future confusion.

Fix this →


Confirmed correct

  • Worktree priority ordering (protected checks before worktree check at L92–101 of git-branch-audit.sh) correctly ensures a release/* branch checked out in a worktree stays PROTECTED, not WORKTREE.
  • rev-parse --abbrev-ref @{upstream} exit-status gate (L80–84): correctly rejects the echoed literal branch@{upstream} string that a configured-but-unfetched upstream produces — this is a subtle fix, and the test at L102–115 of git-branch-audit.test.sh pins it well.
  • clean_enumerate dedup sort (L414–428 of clean-common.sh): the key = abs + "/" sort + "$key" == "$last_key"* ancestor check is correct — the trailing slash on the key prevents a sibling buildstuff/ from being swallowed by build/.
  • Manifest security tests in clean-caches.test.sh: arithmetic injection (byte field), .. escape, wrong-tier, pruned-tree, type mismatch, symlinked ancestor, no-trailing-newline, rm-failure accounting — thorough and correct.
  • clean_apply_manifest byte re-measurement: the manifest's $bytes field is read into a variable that is never used in arithmetic; bytes are re-measured from the filesystem before the Summary: output. Confirms the injection test is exercising the right path.
  • Stash never-auto-drop guarantee: documented in SKILL.md §4.3 and action-router.md, enforced in the script (no git stash drop call), and tested.

@claude

claude Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Claude finished @kyle-sexton's task in 6m 22s —— View job


Security Review

  • Read PR diff and changed files
  • Analyze shell scripts for injection surfaces
  • Check credential/secret handling
  • Review command construction patterns
  • Assess path traversal risks
  • Report findings

No security vulnerabilities found. The changes are defensively written and address the relevant threat models.

Below is a full account of the surfaces I examined and my reasoning.


Surfaces audited

git-stash-audit.sh (new)

PR map via gh pr list — branch names arrive from GitHub's API, pass through jq -r … | @tsv (which encodes embedded tabs/newlines as \t/\n escape sequences, preventing IFS-based field-splitting attacks), and land in PR_STATE["$head"] / PR_NUM["$head"]. Bash associative array assignment with a double-quoted subscript is safe against arbitrary key content. The values are only ever used as printf arguments or read-only array lookups — never executed. Confidence: high.

Source-branch extraction from stash subjectssrc is extracted by sed from the git reflog subject line (On X: …). It is used in:

  • git show-ref --verify --quiet "refs/heads/${src}" — double-quoted, so the value is a literal string; --verify also validates the ref exists before rev-list is even called
  • git rev-list "origin/${DEFAULT_BRANCH}..refs/heads/${src}" — single double-quoted argument; no flag injection possible because it starts with refs/
  • printf 'Source branch: %s\n' — safe by construction

DEFAULT_BRANCH from gh repo view — used only in double-quoted git ref arguments and printf calls. A maliciously crafted API response could affect the advisory text but cannot cause data loss since no stash is ever auto-dropped. Confidence: high.

Age arithmeticage_days=$(((NOW - ts) / 86400)): ts is %ct from git's own stash list output; non-numeric values would cause a bash arithmetic error but the script omits -e/-o pipefail intentionally and always exits 0. Not exploitable. Confidence: high.


git-branch-audit.sh (modified)

Upstream detection"${branch}@{upstream}" is double-quoted. The fix that gates on the exit status of rev-parse --abbrev-ref (rather than trusting empty output) is correct and removes the latent bug where a configured-but-unfetched upstream echoed its input and was misread as a real upstream.

Worktree-attached branch detectiongrep -qxF "$branch" <<<"$WORKTREE_BRANCHES" uses -x (full-line match) and -F (fixed string). A branch name containing regex metacharacters cannot match other branches. Correct.

rev-list --count "origin/${DEFAULT_BRANCH}..refs/heads/${branch}" — both variables are double-quoted and the value always starts with refs/ or origin/, making flag injection structurally impossible. Confidence: high.


resolve-clean-action.sh (modified)

Note feature — trailing positional parameters after the leading action token are captured as note="$*" and emitted with printf 'Note: %s\n' "$note". %s is the format specifier, so $note is treated as a string, never as a format string. Not executed. Confidence: high.


lib/clean-common.sh — enumeration + manifest engine (new)

This is the highest-stakes addition. The threat model is an adversarially-supplied or concurrently-modified manifest causing rm -rf to escape the repository. The code defends with five independent gates applied to every manifest entry before removal:

Gate What it blocks
clean_manifest_rel_safe absolute paths, ../ traversal, empty entries
clean_path_has_pruned_segment paths inside .git/, node_modules/, .venv/
clean_path_has_reparse_ancestor symlink/junction ancestors that rm -rf would follow out of the repo
clean_manifest_target_valid arbitrary untracked paths not in the known-safe pattern set; also rejects symlinks/junctions via clean_is_plain_dir/clean_is_plain_file
clean_target_eligible re-runs the protection gate at apply time (staleness guard)

A few specific checks I verified:

  • Manifest fd isolation3<"$manifest" / <&3 keeps the manifest on a dedicated fd so that tools called per-entry (e.g. fsutil reparsepoint query) cannot drain stdin and silently skip later entries. The PR also adds </dev/null to the fsutil call in clean_path_is_reparse_point for the same reason.
  • bytes field from manifest never reaches arithmeticdu -sk re-measures size at removal time; the manifest's byte field is read into bytes but discarded. A caller-supplied overflowed or injected value cannot affect the summary counters.
  • du output validated before arithmetic[[ "$kb" =~ ^[0-9]+$ ]] || kb=0 in clean_apply_manifest guards against non-numeric du output.
  • Dedup prefix check[[ "$key" == "$last_key"* ]] after an LC_ALL=C sort with /-appended keys. The trailing / correctly prevents build/ from swallowing a sibling buildstuff/ (the pattern /repo/buildstuff/ does not start with /repo/build/ because the char after build in the LHS is s, not /).
  • Manifest writable-target guardclean_manifest_writable_target validates an existing file at the caller-supplied path matches ^(caches|build)\t[0-9]+\t on every non-blank line before truncating it, so a mistyped path pointing at an unrelated file is refused rather than silently erased.
  • clean_enumerate find invocation — built entirely from hardcoded constants (CLEAN_PRUNE_DIRS, CLEAN_CACHE_FIND_DIR_NAMES, etc.) plus a repo-root path from git. No user-controlled data reaches find's arguments.

One acknowledged TOCTOU — between dry-run manifest creation and --apply consumption, a concurrent process could modify the manifest or swap a validated path for a symlink. The per-entry validation at apply time is the stated mitigation, and it is comprehensive. This is the documented design constraint, not a new gap introduced by this PR. SUGGESTION severity; no fix required.


scan.sh (modified)

emit_path_line now takes an absolute path directly from clean_caches_candidates/clean_build_candidates and derives rel via ${abs#"$REPO_ROOT"/}. The ${kb:-0} guard on the du output before $((...)) arithmetic is a minor correctness improvement over the old bare-subshell form. No injection surface.


No GitHub Actions files changed

No .github/workflows/ files were modified; the Actions-specific injection patterns (untrusted github context in run: steps, pull_request_target + secrets, unpinned action refs) are not in scope for this PR.


Summary: All external data (git output, gh API responses, stash subjects, manifest entries) is handled through double-quoted expansions, printf %s format strings, or explicit validation chains before any destructive operation. No command injection, path traversal, or privilege escalation vectors were found.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a331f34f08

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread plugins/repo-hygiene/skills/clean/scripts/clean-build.sh
Comment thread plugins/repo-hygiene/skills/clean/scripts/git-stash-audit.test.sh Outdated
@kyle-sexton

Copy link
Copy Markdown
Contributor Author

🔒 babysit-prs lane claiming this PR for on-branch fix work this cycle (amendment-round: 16, safe tier). Will fix clear branch-owned findings and push; will not resolve threads or merge (safe tier).

kyle-sexton and others added 2 commits July 22, 2026 17:07
Bring the PR up to date with main (now containing #1023). Conflicts resolved:
plugin.json keeps the 0.6.0 bump over main's 0.5.0; clean-common.sh takes main's
finalized version; cleanup-paths.sh, CHANGELOG.md, action-router.md keep this
branch's Batch-D additions layered over main's content.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016D1oCPX8LaUKnLUi3TXmih
The merge inadvertently carried this branch's older base snapshot of the
source-control plugin (0.18.0), reverting main's 0.19.0. Reset those files to
origin/main so the PR diff is exactly the Batch-D repo-hygiene changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016D1oCPX8LaUKnLUi3TXmih
@claude

claude Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Claude encountered an error after 0s —— View job


I'll analyze this and get back to you.

@claude

claude Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Claude encountered an error after 0s —— View job


I'll analyze this and get back to you.

@github-actions

github-actions Bot commented Jul 22, 2026

Copy link
Copy Markdown

Warning

Automated review did not complete — this is an infrastructure failure, not a review verdict.

Treat any Claude comment on this PR (including a placeholder like "I'll analyze this and get back to you") as incomplete, not "no findings."

Re-running the job, or pushing a new commit, will retry the review.

@github-actions

github-actions Bot commented Jul 22, 2026

Copy link
Copy Markdown

Warning

Automated security review did not complete — this is an infrastructure failure, not a review verdict.

Treat any Claude comment on this PR (including a placeholder like "I'll analyze this and get back to you") as incomplete, not "no findings."

Re-running the job, or pushing a new commit, will retry the review.

@claude

claude Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Claude encountered an error after 0s —— View job


I'll analyze this and get back to you.

@claude

claude Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Claude encountered an error after 0s —— View job


I'll analyze this and get back to you.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 564f36cd01

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread plugins/repo-hygiene/skills/clean/scripts/git-branch-audit.sh
Comment thread plugins/repo-hygiene/skills/clean/scripts/git-stash-audit.sh
Initialize the stash-audit and branch-audit test repositories with `git init -b
main` so the `main`-branch assumptions hold on machines where `git init` still
defaults to `master` — otherwise the stash on the initial branch is attributed to
`master`, flagged possibly-superseded against origin/main, and the case fails
before validating the intended behavior. Verified green under a forced
init.defaultBranch=master environment.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016D1oCPX8LaUKnLUi3TXmih
@claude

claude Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Claude encountered an error after 0s —— View job


I'll analyze this and get back to you.

@claude

claude Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Claude encountered an error after 0s —— View job


I'll analyze this and get back to you.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 03c63e1aaf

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread plugins/repo-hygiene/skills/clean/SKILL.md
Address Codex review threads on the Batch-D PR:
- git-branch-audit: a branch whose upstream is gone but that still carries
  commits absent from origin/<default> is now REVIEW, not the LIKELY-SAFE
  deletion candidate the bare gone-check assigned (prevents losing unmerged
  local commits). Ahead-of-default is computed for every branch.
- git-stash-audit: emit each stash's stable commit id (Commit:) so multi-drop
  workflows do not rely on the volatile stash@{n} selector that renumbers after
  every drop.
- destructive-guard: gate `git stash drop`/`clear` behind the CLEAN_GUARD_ACK
  acknowledgement, closing the fail-open where a confirmed-then-mistaken drop
  could discard saved WIP.
- Portability: the branch-audit example table uses origin/<default> instead of a
  hardcoded origin/main; new audit scripts get the executable bit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016D1oCPX8LaUKnLUi3TXmih

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4b7c976389

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread plugins/repo-hygiene/skills/clean/scripts/git-branch-audit.sh Outdated
…ght-resolver

Batch C's fleet-batch mode merged to main first (first-ready-wins). Rebase this
PR's version to 0.7.0 over C's 0.6.0 and reorder the CHANGELOG so 0.7.0 (stash
audit, worktree/no-upstream branch classes, resolver notes, single-walk scan)
sits above 0.6.0 (fleet batch). Shared-file conflicts resolved to carry BOTH
feature sets: SKILL.md description/argument-hint list the stash action and the
caches/build/git/all batch forms; resolve-clean-action.sh keeps the action-token
note short-circuit and adopts C's reset-intent split (tree-batch vs all-batch).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016D1oCPX8LaUKnLUi3TXmih
@claude

claude Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Claude encountered an error after 1s —— View job


I'll analyze this and get back to you.

@claude

claude Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Claude encountered an error after 0s —— View job


I'll analyze this and get back to you.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 08be94ae7d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread plugins/repo-hygiene/skills/clean/scripts/resolve-clean-action.sh Outdated
kyle-sexton and others added 2 commits July 22, 2026 18:16
…outing

Two review-thread fixes on the reconcile push:
- git-branch-audit: a gone-upstream branch with no fetched origin/<default> to
  compare against was still LIKELY-SAFE (a deletion candidate) despite unprovable
  safety. Classify it REVIEW ("upstream gone, cannot compare against
  origin/<default>") — fail closed so local-only commits are never lost.
- resolve-clean-action: gate the advisory-note short-circuit on
  `! has_fleet_indicator` (not just `! is_fleet_phrase`), so a fleet request the
  phrase list misses ("all across the fleet") is no longer preempted into a
  single-repo action with the fleet words demoted to a note; it routes to the
  fleet/batch resolution instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016D1oCPX8LaUKnLUi3TXmih
@claude

claude Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Claude encountered an error after 0s —— View job


I'll analyze this and get back to you.

@claude

claude Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Claude encountered an error after 1s —— View job


I'll analyze this and get back to you.

@kyle-sexton
kyle-sexton merged commit a924d7b into main Jul 22, 2026
25 checks passed
@kyle-sexton
kyle-sexton deleted the feat/repo-hygiene-stash-preflight-resolver branch July 22, 2026 22:20

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d4d8cf0eff

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

| main | PROTECTED | 0d | — | 0 ahead of origin/<default> | default branch |
| feat/parked | WORKTREE | 3d | — | 0 ahead of origin/feat/parked | checked out in worktree — clean up the worktree first |
| feat/old-thing | SAFE | 45d | #123 MERGED | 0 ahead of origin/feat/old-thing | PR merged |
| refactor/x | LIKELY-SAFE | 12d | — | no upstream (no origin/<default> to compare) | upstream gone |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep gone/no-default branches out of deletion examples

Fresh evidence after the gone-branch fix: this updated report example still presents Unpushed: no upstream (no origin/<default> to compare) as LIKELY-SAFE, even though the script now correctly classifies that scenario as REVIEW because it cannot prove the branch is merged. Since the same section defines deletion candidates as SAFE + LIKELY-SAFE, agents following this context can offer an uncompareable gone branch for deletion despite the fail-closed behavior the script now enforces.

Useful? React with 👍 / 👎.

second_lower="$(printf '%s' "$2" | tr '[:upper:]' '[:lower:]')"
second_action="$(resolve_one "$second_lower")"
fi
if [[ -n "$first_action" && $# -ge 2 && -z "$second_action" ]] && ! has_fleet_indicator "$joined"; then

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve notes after explicit batch actions

When the first token is already an explicit batch/fleet action, this full-input has_fleet_indicator guard treats that action token itself as a fleet indicator and disables note extraction, so inputs like caches-batch mind the live sessions or tree-batch skip repo X resolve to only Action: ... and silently drop the trailing constraint. Check the fleet indicator on the suffix after the action token instead, so explicit batch actions can still surface advisory Note: text while phrases like all across the fleet remain protected from single-repo routing.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment