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 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..e98a54fd3 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; 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 +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..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 @@ -112,6 +112,45 @@ 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_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 echo "FAILED: $FAILED test(s)" exit 1