Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion plugins/repo-hygiene/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
13 changes: 13 additions & 0 deletions plugins/repo-hygiene/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
17 changes: 14 additions & 3 deletions plugins/repo-hygiene/skills/clean/scripts/git-tree-reset.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
Expand Down Expand Up @@ -59,7 +60,8 @@ Output labels:
RestoredTracked: <count> (apply only — reparse-point casualties restored)
Unremovable: <count> (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
}

Expand Down Expand Up @@ -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)"
Expand Down
39 changes: 39 additions & 0 deletions plugins/repo-hygiene/skills/clean/scripts/git-tree-reset.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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" <<SHIMEOF
#!/usr/bin/env bash
if [[ "\$1" == "reset" ]]; then
echo "fatal: simulated reset --hard failure" >&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
Expand Down
Loading