From 97a5e7810b636ed8104f0beac5c282e93a094687 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sun, 19 Jul 2026 05:13:56 -0400 Subject: [PATCH 1/4] fix(repo-hygiene): gate git-tree-reset clean on reset success The --apply path runs under `set -uo pipefail` (no -e). It ran `git reset --hard "$UPSTREAM"` without checking its exit status, then unconditionally ran `git clean -fdx`. A failed reset dropped straight through to the destructive clean, leaving the tree cleaned but not reset (a partial destructive op). Gate the clean and restore guard on a successful reset: on non-zero reset, print an explicit failure line, emit honest `AppliedReset: failed` / `AppliedClean: none` (no success lines), and exit 5. Document the new exit code in the header, --help, and the tree context doc. Regression test forces a reset failure via a PATH git shim and asserts clean did not run (untracked file survives, no clean success line, exit 5). Verified red against the pre-fix script, green after. Closes #394 Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_011V31qpAHP3jfs76B9d5Rfo --- .../skills/clean/context/git-tree-reset.md | 1 + .../skills/clean/scripts/git-tree-reset.sh | 17 +++++++-- .../clean/scripts/git-tree-reset.test.sh | 37 +++++++++++++++++++ 3 files changed, 52 insertions(+), 3 deletions(-) diff --git a/plugins/repo-hygiene/skills/clean/context/git-tree-reset.md b/plugins/repo-hygiene/skills/clean/context/git-tree-reset.md index e507a8f75..d96dde7ba 100644 --- a/plugins/repo-hygiene/skills/clean/context/git-tree-reset.md +++ b/plugins/repo-hygiene/skills/clean/context/git-tree-reset.md @@ -36,6 +36,7 @@ Skill data (`.claude/skills/*/data/`) is preserved unconditionally — no flag r - Upstream tracking branch required (`@{u}`). - Blocks on default branch (`main`/`master`/resolved default) unless `--force-default-branch` (exit 3). - Aborts when HEAD is ahead of upstream unless `--allow-unpushed` (exit 4) — prevents silent loss of unpushed commits. +- Aborts the apply if `reset --hard` fails (exit 5) — `clean` and the restore guard never run, so a failed reset can never leave the tree cleaned but not reset. - Post-clean restore guard: any tracked file deleted via reparse-point traversal is restored from the index (`RestoredTracked:` count; safe because `reset --hard` ran first). - Locked / in-use files git could not delete are reported (`Unremovable:`), not silently left. diff --git a/plugins/repo-hygiene/skills/clean/scripts/git-tree-reset.sh b/plugins/repo-hygiene/skills/clean/scripts/git-tree-reset.sh index dae115fb4..6fb155a45 100755 --- a/plugins/repo-hygiene/skills/clean/scripts/git-tree-reset.sh +++ b/plugins/repo-hygiene/skills/clean/scripts/git-tree-reset.sh @@ -20,7 +20,8 @@ # the apply unless --allow-unpushed. # # Exit: 0 success; 1 not a git repo; 2 usage/validation error; -# 3 blocked on default branch; 4 blocked on unpushed commits. +# 3 blocked on default branch; 4 blocked on unpushed commits; +# 5 reset --hard failed (apply aborted before clean). set -uo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -59,7 +60,8 @@ Output labels: RestoredTracked: (apply only — reparse-point casualties restored) Unremovable: (apply only — files git clean could not delete, e.g. locked) -Exit: 0; 1 not a git repo; 2 usage error; 3 blocked default branch; 4 unpushed commits. +Exit: 0; 1 not a git repo; 2 usage error; 3 blocked default branch; 4 unpushed commits; + 5 reset --hard failed (apply aborted before clean). EOF } @@ -181,7 +183,16 @@ if [[ "$AHEAD_COUNT" -gt 0 && "$ALLOW_UNPUSHED" -eq 0 ]]; then printf 'AppliedClean: none\n' exit 4 fi -git reset --hard "$UPSTREAM" +# Gate the destructive clean on a successful reset. Without -e, a failed +# reset --hard would otherwise fall through to git clean -fdx, leaving the tree +# cleaned but not reset (a partial destructive op). On failure: abort before +# clean and the restore guard, report honestly, exit non-zero. +if ! git reset --hard "$UPSTREAM"; then + printf 'FAILED: git reset --hard %s exited non-zero — aborting apply before clean; no files removed.\n' "$UPSTREAM" >&2 + printf 'AppliedReset: failed\n' + printf 'AppliedClean: none\n' + exit 5 +fi # Capture clean stderr to surface files git could not remove (locked / in use). CLEAN_STDERR="$(git clean -fdx "${PRESERVE_ARGS[@]}" 2>&1 >/dev/null)" diff --git a/plugins/repo-hygiene/skills/clean/scripts/git-tree-reset.test.sh b/plugins/repo-hygiene/skills/clean/scripts/git-tree-reset.test.sh index 78b36f918..e6d87b64f 100755 --- a/plugins/repo-hygiene/skills/clean/scripts/git-tree-reset.test.sh +++ b/plugins/repo-hygiene/skills/clean/scripts/git-tree-reset.test.sh @@ -112,6 +112,43 @@ out="$(run_reset 2>&1)" || rc=$? assert_exit "blocks default branch" 3 "$rc" assert_contains "blocked reason" "$out" "default-branch" +# --- 9. reset --hard failure aborts before clean (no partial destructive op) --- +# Force git reset --hard to fail via a PATH shim that intercepts only `reset` +# and delegates every other subcommand to the real git. A failed reset must not +# fall through to git clean -fdx, so an untracked file clean would have removed +# must survive, and no AppliedClean success line may be emitted. +REAL_GIT="$(command -v git)" +R2="$TEST_TMPDIR/repo-reset-fail" +git init "$R2" >/dev/null 2>&1 +git -C "$R2" config user.email "t@example.com" +git -C "$R2" config user.name "Test" +echo tracked >"$R2/tracked.txt" +git -C "$R2" add -A +git -C "$R2" commit -m "init" >/dev/null +git -C "$R2" branch -M main +git -C "$R2" checkout -b feat/reset-fail >/dev/null 2>&1 +git -C "$R2" branch -u main >/dev/null 2>&1 +echo untracked >"$R2/scratch.txt" + +SHIM="$TEST_TMPDIR/git-shim" +mkdir -p "$SHIM" +cat >"$SHIM/git" <&2 + exit 1 +fi +exec "$REAL_GIT" "\$@" +SHIMEOF +chmod +x "$SHIM/git" + +rc=0 +out="$(PATH="$SHIM:$PATH" bash -c "cd '$R2' && bash '$RESET' --apply" 2>&1)" || rc=$? +assert_exit "reset failure exits 5" 5 "$rc" +assert_contains "reset failure reports failure" "$out" "FAILED: git reset --hard" +assert_not_contains "reset failure emits no clean success line" "$out" "AppliedClean: git clean" +assert_file_exists "reset failure skips clean (untracked survives)" "$R2/scratch.txt" + if [[ $FAILED -ne 0 ]]; then echo "FAILED: $FAILED test(s)" exit 1 From ac26eaca13a46a979c030ae7e7afdf4fabc1516f Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sun, 19 Jul 2026 05:16:48 -0400 Subject: [PATCH 2/4] chore(repo-hygiene): bump to 0.3.1 for clean-gated-on-reset-success fix Per-change version + changelog convention: record the git-tree-reset clean-gating fix as a 0.3.1 Fixed entry and bump the plugin manifest. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_011V31qpAHP3jfs76B9d5Rfo --- plugins/repo-hygiene/.claude-plugin/plugin.json | 2 +- plugins/repo-hygiene/CHANGELOG.md | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/plugins/repo-hygiene/.claude-plugin/plugin.json b/plugins/repo-hygiene/.claude-plugin/plugin.json index 8c0089b67..df0345c5b 100644 --- a/plugins/repo-hygiene/.claude-plugin/plugin.json +++ b/plugins/repo-hygiene/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "repo-hygiene", - "version": "0.3.0", + "version": "0.3.1", "description": "Repo hygiene action-router: /repo-hygiene:clean sweeps reclaimable caches, build artifacts, and stale git metadata, and can realign the working tree to a fresh-pull state — dry-run-first, with destructive tiers gated behind explicit confirmation and a session-scoped destructive-command guard. Ecosystem targets are detected at runtime; secrets, runtime dependencies, and skill data are preserved by default.", "author": { "name": "Melodic Software", diff --git a/plugins/repo-hygiene/CHANGELOG.md b/plugins/repo-hygiene/CHANGELOG.md index d6c849f5d..a06425805 100644 --- a/plugins/repo-hygiene/CHANGELOG.md +++ b/plugins/repo-hygiene/CHANGELOG.md @@ -3,6 +3,19 @@ All notable changes to the `repo-hygiene` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.3.1] + +### Fixed + +- **`git-tree-reset.sh` — `clean` now gated on a successful `reset --hard`.** The + `--apply` path runs under `set -uo pipefail` (no `-e`) and never checked the + `git reset --hard` exit status before running `git clean -fdx`, so a failed reset + fell through to the destructive clean — leaving the tree cleaned but not reset (a + partial destructive op). A non-zero reset now aborts the apply before `clean` and + the reparse-point restore guard ever run, prints an explicit failure line, emits + honest `AppliedReset: failed` / `AppliedClean: none` (never a success line for a + command that failed), and exits 5. + ## [0.3.0] ### Added From 2f50cb17f9a7f32efe3f425c26604c21d5e2445a Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sun, 19 Jul 2026 05:47:10 -0400 Subject: [PATCH 3/4] test(repo-hygiene): assert honest failure labels in git-tree-reset case 9 Add positive assertions that a failed reset --hard emits the machine-readable labels AppliedReset: failed and AppliedClean: none, completing the contract check the four existing assertions only covered by absence. --- .../repo-hygiene/skills/clean/scripts/git-tree-reset.test.sh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/plugins/repo-hygiene/skills/clean/scripts/git-tree-reset.test.sh b/plugins/repo-hygiene/skills/clean/scripts/git-tree-reset.test.sh index e6d87b64f..a0e493985 100755 --- a/plugins/repo-hygiene/skills/clean/scripts/git-tree-reset.test.sh +++ b/plugins/repo-hygiene/skills/clean/scripts/git-tree-reset.test.sh @@ -146,7 +146,9 @@ rc=0 out="$(PATH="$SHIM:$PATH" bash -c "cd '$R2' && bash '$RESET' --apply" 2>&1)" || rc=$? assert_exit "reset failure exits 5" 5 "$rc" assert_contains "reset failure reports failure" "$out" "FAILED: git reset --hard" +assert_contains "reset failure emits AppliedReset: failed" "$out" "AppliedReset: failed" assert_not_contains "reset failure emits no clean success line" "$out" "AppliedClean: git clean" +assert_contains "reset failure emits AppliedClean: none" "$out" "AppliedClean: none" assert_file_exists "reset failure skips clean (untracked survives)" "$R2/scratch.txt" if [[ $FAILED -ne 0 ]]; then From 745def601d5015e3067c1f7eae42a706ed265c89 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sun, 19 Jul 2026 06:18:03 -0400 Subject: [PATCH 4/4] fix(repo-hygiene): honest git-tree-reset failure message (reset not atomic) A failed git reset --hard is not atomic: it can partially modify or remove tracked files before exiting non-zero. The prior message claimed "no files removed," implying an untouched worktree. Report that git clean was skipped and that the reset may have partially modified the tree. Co-Authored-By: Claude Sonnet 5 --- plugins/repo-hygiene/skills/clean/scripts/git-tree-reset.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/repo-hygiene/skills/clean/scripts/git-tree-reset.sh b/plugins/repo-hygiene/skills/clean/scripts/git-tree-reset.sh index 6fb155a45..e98a54fd3 100755 --- a/plugins/repo-hygiene/skills/clean/scripts/git-tree-reset.sh +++ b/plugins/repo-hygiene/skills/clean/scripts/git-tree-reset.sh @@ -188,7 +188,7 @@ fi # cleaned but not reset (a partial destructive op). On failure: abort before # clean and the restore guard, report honestly, exit non-zero. if ! git reset --hard "$UPSTREAM"; then - printf 'FAILED: git reset --hard %s exited non-zero — aborting apply before clean; no files removed.\n' "$UPSTREAM" >&2 + printf 'FAILED: git reset --hard %s exited non-zero — aborting apply; git clean skipped. Note: reset --hard is not atomic and may have partially modified tracked files.\n' "$UPSTREAM" >&2 printf 'AppliedReset: failed\n' printf 'AppliedClean: none\n' exit 5