Skip to content

fix(repo-hygiene): gate git-tree-reset clean on reset success#460

Merged
kyle-sexton merged 4 commits into
mainfrom
fix/394-gate-clean-on-reset-success
Jul 19, 2026
Merged

fix(repo-hygiene): gate git-tree-reset clean on reset success#460
kyle-sexton merged 4 commits into
mainfrom
fix/394-gate-clean-on-reset-success

Conversation

@kyle-sexton

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

Copy link
Copy Markdown
Contributor

Problem

plugins/repo-hygiene/skills/clean/scripts/git-tree-reset.sh runs its --apply path under set -uo pipefail (no -e). It executed git reset --hard "$UPSTREAM" (~L184) without checking the exit status, then unconditionally ran git clean -fdx (~L187). A failed reset fell straight through to the destructive clean — the working tree got cleaned but not reset (a partial destructive op), and success lines were emitted for a command that failed.

Fix

Gate clean (and the reparse-point restore guard) on a successful reset:

  • Check the reset --hard exit status; on non-zero, skip clean and the restore guard, print an explicit FAILED: line, and exit 5.
  • Emit honest labels on failure — AppliedReset: failed / AppliedClean: none — never a success line for a command that failed.
  • Documented the new exit code 5 in the script header, --help, and context/git-tree-reset.md.

Scoped strictly to the reset→clean gating and this path's honest failure/success lines; the broader output-integrity sweep remains with companion issue #395.

Test

New regression case (case 9) forces a reset --hard failure via a PATH git shim that intercepts only reset and delegates everything else to the real git. It asserts the clean did not run: an untracked file clean -fdx would have removed survives, no AppliedClean: git clean success line is emitted, and the script exits 5.

Red-green proof: temporarily reintroducing the bug (bare git reset --hard) turns all four new assertions red (exit 0, untracked file removed, clean success line present); the fix makes them green.

Verification

  • bash plugins/repo-hygiene/skills/clean/scripts/git-tree-reset.test.sh — 25/25 pass
  • All 11 repo-hygiene clean tests pass
  • shellcheck --rcfile=.shellcheckrc clean on both changed shell files

Version bumped repo-hygiene 0.3.0 → 0.3.1 with a matching ## [0.3.1] Fixed changelog entry.

Related

Closes #394

🤖 Generated with Claude Code

https://claude.ai/code/session_011V31qpAHP3jfs76B9d5Rfo

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011V31qpAHP3jfs76B9d5Rfo
@claude

claude Bot commented Jul 19, 2026

Copy link
Copy Markdown

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


Code Review

  • Gather context from PR description and changed files
  • Review git-tree-reset.sh changes
  • Review git-tree-reset.test.sh regression test
  • Review git-tree-reset.md documentation update
  • Synthesize findings and post review

Overall: The fix is correct, minimal, and well-scoped. The if ! guard is the right idiom for gating on exit status without -e, and the PR achieves complete documentation coverage (header, --help, and context doc). Two minor test-coverage gaps noted below — neither is a blocker.


git-tree-reset.sh — core fix

Lines 186–195

The if ! git reset --hard "$UPSTREAM" guard is the correct approach for capturing exit status under set -uo pipefail (no -e). On failure it:

  • Skips git clean -fdx and the reparse-point restore guard — no partial destructive op.
  • Sends the FAILED: human-readable message to stderr and the machine-readable labels (AppliedReset: failed / AppliedClean: none) to stdout — consistent with the rest of the script's stdout/stderr split.
  • Exits 5, which is now documented in all three places (header, --help, context/git-tree-reset.md).

One minor observation on the inline comment (lines 186–190): it explains the hidden set -uo pipefail / no--e constraint, which is non-obvious, so the comment is justified. The phrase "Without -e" is accurate but could be slightly clearer as "without set -e" — not worth a change.


git-tree-reset.test.sh — case 9

Lines 115–150

The git shim approach is well-designed:

  • $1 == "reset" correctly intercepts git reset … — the script never calls git -C for the reset at this point (it has already cd-ed), so $1 will be reset as expected.
  • $REAL_GIT is baked into the shim at heredoc-write time (unquoted heredoc), while $1 and $@ are escaped with \$ to expand at shim execution time — correct and intentional.
  • Case 9 uses a fresh $R2 repo, isolating itself from the shared $R state used by cases 1–8.
  • The combined 2>&1 capture means the stderr FAILED: line is included in $out, so the assert_contains check works correctly.

Minor gap — two missing positive assertions:

The PR description says the fix emits AppliedReset: failed and AppliedClean: none as honest labels. Case 9 checks their absence/success-variant absence but not their positive presence:

  1. AppliedReset: failed is never asserted to be in the output. Fix this →
  2. The assert_not_contains "AppliedClean: git clean" check confirms the success variant is absent, but AppliedClean: none is not positively confirmed. Fix this →

These are low-severity — the four existing assertions are sufficient to verify the bug is fixed and the primary behavior is correct. Adding positive assertions would complete the contract check.


context/git-tree-reset.md — documentation

Line 39

Placed correctly in the gates list (between the unpushed-commits guard and the restore guard). Wording is accurate and consistent with the script header and --help. Good.


Summary

Area Status
Core fix correctness ✅ Sound
Exit code documentation (3 places) ✅ Complete
stdout/stderr split in failure path ✅ Consistent
Regression test shim design ✅ Correct
Positive assertions for honest labels ⚠️ Missing (minor)

No blocking issues. The two missing positive assertions in case 9 are the only improvements worth considering before merge.

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011V31qpAHP3jfs76B9d5Rfo
@claude

claude Bot commented Jul 19, 2026

Copy link
Copy Markdown

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


Code Review

  • Gather PR context
  • Read changed files
  • Diff vs origin/main
  • Review fix correctness (git-tree-reset.sh)
  • Review regression test (case 9)
  • Review documentation and version bump
  • Post findings

Overall: The fix is correct, minimal, and well-scoped. The version bump commit is clean and appropriate. One pre-existing minor gap from the earlier review pass remains open (two missing positive assertions in case 9) — no new concerns introduced by the 0.3.1 bump commit.


git-tree-reset.sh — core fix

Lines 186–195

The if ! git reset --hard "$UPSTREAM" guard is the correct idiom for capturing exit status under set -uo pipefail (no -e). On failure it:

  • Skips git clean -fdx and the reparse-point restore guard — no partial destructive op.
  • Sends the FAILED: human-readable message to stderr and the machine-readable labels (AppliedReset: failed / AppliedClean: none) to stdout — consistent with the script's established stdout/stderr split.
  • Exits 5, which is now documented in all three required places (header, --help, context/git-tree-reset.md).

The inline comment (lines 186–190) explaining the hidden set -uo pipefail / no--e constraint is non-obvious and justified. $UPSTREAM is correctly quoted in the printf on line 191.


git-tree-reset.test.sh — case 9

Lines 115–150

The git shim approach is correct:

  • $REAL_GIT is baked into the shim at heredoc-write time (unquoted heredoc); \$1 and \$@ are escaped to expand at shim execution time — expansion timing is right.
  • $1 == "reset" correctly intercepts git reset … — the script has already cd-ed into the repo before the reset call, so the subcommand is $1.
  • Fresh $R2 repo isolates case 9 from the shared $R state used in cases 1–8.
  • 2>&1 capture on line 146 means the stderr FAILED: line lands in $out, so assert_contains works correctly against it.

Minor gap — two positive assertions still missing (carried from the prior review pass):

  1. AppliedReset: failed (stdout label) is never positively asserted — only the FAILED: stderr string is checked. Since 2>&1 is used, both channels are captured and an assert_contains would work. Fix this →
  2. AppliedClean: none (the honest label for the clean stage) is not positively confirmed — only its success variant is ruled out. Fix this →

These are low-severity — the four existing assertions are sufficient to verify the bug is fixed. Adding positive assertions would complete the contract check.


context/git-tree-reset.md — documentation

Line 39

Placed correctly between the unpushed-commits guard and the restore guard. Wording is accurate and consistent with the script header and --help. Good.


plugin.json + CHANGELOG.md — version bump (0.3.00.3.1)

Version choice is correct (semver patch for a bug fix). The CHANGELOG entry accurately describes the bug, the fix, the honest labels, and exit 5. Wording is clear and consistent with the existing entry style.


Summary

Area Status
Core fix correctness ✅ Sound
Exit code documentation (3 places) ✅ Complete
stdout/stderr split in failure path ✅ Consistent
Regression test shim design ✅ Correct
Positive assertions for honest labels ⚠️ Missing (minor)
Version bump (0.3.1) ✅ Correct
CHANGELOG entry ✅ Accurate

No blocking issues. The two missing positive assertions in case 9 are the only improvements worth considering before merge.

…se 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.
@claude

claude Bot commented Jul 19, 2026

Copy link
Copy Markdown

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


Code Review

  • Read changed files and gather context
  • Review git-tree-reset.sh core fix
  • Review git-tree-reset.test.sh regression test (case 9)
  • Review documentation and version bump
  • Synthesize findings and post review

Overall: The fix is correct, minimal, and well-scoped. The latest commit (2f50cb1) directly resolves the only open concern from prior reviews — the two missing positive assertions in case 9. The PR is ready to merge.


git-tree-reset.sh — core fix

Lines 186–195

The if ! git reset --hard "$UPSTREAM" idiom is correct for capturing exit status under set -uo pipefail (no -e). On failure the script:

  • Skips git clean -fdx and the reparse-point restore guard — no partial destructive op.
  • Sends the human-readable FAILED: line to stderr and the machine-readable labels (AppliedReset: failed / AppliedClean: none) to stdout — consistent with the script's established stdout/stderr split.
  • Exits 5, which is now documented in all three required places (header, --help, context/git-tree-reset.md).

The inline comment explaining the hidden set -uo pipefail / no--e constraint is non-obvious and justified. $UPSTREAM is correctly quoted in the printf format string.


git-tree-reset.test.sh — case 9

Lines 115–152

The shim design is correct and the assertion set is now complete:

  • $REAL_GIT is baked into the shim at heredoc-write time (unquoted heredoc); \$1 and \$@ escape to expand at execution time — expansion timing is correct.
  • $1 == "reset" intercepts git reset … reliably — the script has already cd-ed before the reset call, so the subcommand is $1.
  • Fresh $R2 repo isolates case 9 from the shared $R state used by cases 1–8.
  • 2>&1 on line 146 merges stderr into the capture, so both the FAILED: line and the machine-readable labels land in $out.

Six assertions now cover the full contract:

Assertion What it verifies
assert_exit … 5 Script aborts with the documented exit code
assert_contains … "FAILED: git reset --hard" Human-readable failure message present
assert_contains … "AppliedReset: failed" Honest machine-readable reset label ✅ (added in 2f50cb1)
assert_not_contains … "AppliedClean: git clean" No false success label for clean
assert_contains … "AppliedClean: none" Honest machine-readable clean label ✅ (added in 2f50cb1)
assert_file_exists … "scratch.txt" Clean did not run (untracked file survived)

context/git-tree-reset.md — documentation

Line 39

Placed correctly between the unpushed-commits guard and the restore guard. Wording is accurate and matches the script header and --help. Good.


plugin.json + CHANGELOG.md — version bump (0.3.00.3.1)

Semver patch is the correct choice for a bug fix. The CHANGELOG entry accurately describes the failure mode, the fix, the honest labels, and exit 5 in a style consistent with existing entries.


Summary

Area Status
Core fix correctness ✅ Sound
Exit code documentation (3 places) ✅ Complete
stdout/stderr split in failure path ✅ Consistent
Regression test shim design ✅ Correct
Positive assertions for honest labels ✅ Complete (resolved in 2f50cb1)
Version bump (0.3.1) ✅ Correct
CHANGELOG entry ✅ Accurate

No blocking issues. No suggestions for improvement — the prior review's open point has been addressed.

@kyle-sexton

Copy link
Copy Markdown
Contributor Author

Addressing both Code Review passes (runs 29681230062 and 29681298531) — both carry the same two positive-assertion gaps in git-tree-reset.test.sh case 9.

# Finding Classification Evidence Reacted
1 AppliedReset: failed never positively asserted VALID — fixed Script emits AppliedReset: failed to stdout on reset failure (git-tree-reset.sh:192); case 9 captures via 2>&1, so an assert_contains passes 👍
2 AppliedClean: none not positively confirmed (only success variant ruled out) VALID — fixed Script emits AppliedClean: none to stdout on reset failure (git-tree-reset.sh:193); positive assertion added 👍

Both assertions added in 2f50cb1. Full suite passes (27/27, including new tests 24 and 26). Neither was a blocker; these complete the honest-label contract check.

@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: 2f50cb17f9

ℹ️ 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-tree-reset.sh Outdated
…tomic)

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 <noreply@anthropic.com>
@claude

claude Bot commented Jul 19, 2026

Copy link
Copy Markdown

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


Code Review

  • Gather PR context and prior review history
  • Read changed files at HEAD (745def6)
  • Review core fix and honest-failure message update
  • Review regression test (case 9)
  • Synthesize and post findings

Overall: This pass reviews only the final commit (745def6), which addresses the Codex P2 finding that the FAILED: message over-promised an untouched worktree after a failed reset --hard. The change is correct. All prior open items from earlier review passes have been resolved. The PR is ready to merge.


git-tree-reset.sh745def6 message update

Line 191

The updated message:

aborting apply; git clean skipped. Note: reset --hard is not atomic and may have partially modified tracked files.

This correctly replaces the prior "no files removed" text. The message goes to stderr (>&2), which is consistent with the script's stdout/stderr split. The printf '...' "$UPSTREAM" >&2 form passes $UPSTREAM as a positional argument to %s — not interpolated into the format string — so there is no format-string injection risk.

Minor (no action required): context/git-tree-reset.md line 39 still reads "a failed reset can never leave the tree cleaned but not reset" without the non-atomicity caveat. This sentence correctly describes the gating behavior (clean never runs on a failed reset), so it is not wrong — but it is now slightly incomplete relative to what the stderr message surfaces. Future callers reading only the doc might not know to expect partially modified tracked files after exit 5. A parenthetical like "(the reset itself may have partially modified tracked files)" would make the doc consistent with the updated message. Not a blocker.


Regression test (case 9) — no changes since prior pass

Lines 115–152

The six assertions are intact and cover the full contract:

Assertion What it verifies
assert_exit … 5 Script exits with documented code
assert_contains … "FAILED: git reset --hard" Human-readable failure message present
assert_contains … "AppliedReset: failed" Honest machine-readable reset label
assert_not_contains … "AppliedClean: git clean" No false success label for clean
assert_contains … "AppliedClean: none" Honest machine-readable clean label
assert_file_exists … "scratch.txt" Clean did not run (untracked file survived)

The test does not assert the exact wording of the updated failure message beyond the "FAILED: git reset --hard" prefix — that is fine, the behavioral contract (exit 5, clean skipped, honest labels) is fully asserted.


Summary

Area Status
Core fix correctness ✅ Sound
Exit code documentation (header, --help, context doc) ✅ Complete
Honest failure message (non-atomicity caveat) ✅ Correct (745def6)
stdout/stderr split in failure path ✅ Consistent
Regression test shim design ✅ Correct
Positive assertions for honest labels ✅ Complete
Version bump (0.3.1) + CHANGELOG ✅ Accurate
context/git-tree-reset.md non-atomicity doc ⚠️ Minor gap (no action required)

No blocking issues. One cosmetic doc gap noted, no code changes warranted.

@kyle-sexton

Copy link
Copy Markdown
Contributor Author

Re: latest review at 745def6 (run 29683112447).

# Finding Classification Evidence Reacted
1 context/git-tree-reset.md:39 omits the non-atomicity caveat the updated stderr message now surfaces VALID (defer) — optional doc-completeness follow-up, not applied The line is accurate as written: it describes the gating contract (clean and the restore guard never run on a failed reset, so the tree can never be left cleaned-but-not-reset), which the code enforces at git-tree-reset.sh:190-195. The reviewer marked it "no action required / no code changes warranted / not a blocker / cosmetic." Adding a parenthetical about partial tracked-file modification would improve completeness but the doc is not wrong, and touching it would move head off the ready-to-merge SHA and re-trigger a fresh review cycle. Deferring the nicety to keep the ready state intact. 👍

No open blocking items. PR remains ready to merge at 745def6.

@kyle-sexton

Copy link
Copy Markdown
Contributor Author

Follow-up on the deferred context/git-tree-reset.md:39 doc-completeness item (the non-atomicity parenthetical the 745def6 re-review flagged as "no action required"): now tracked in #485 so the deferral is honest, rather than dropped. Not applied here to keep head at the merge-ready 745def6 and avoid re-triggering a fresh review cycle on a converged PR. No open blocking items.

@kyle-sexton
kyle-sexton merged commit 4381de3 into main Jul 19, 2026
15 checks passed
@kyle-sexton
kyle-sexton deleted the fix/394-gate-clean-on-reset-success branch July 19, 2026 16:18
kyle-sexton added a commit that referenced this pull request Jul 19, 2026
…r reset --hard @{u} (#542)

## Summary

`plugins/repo-hygiene/skills/clean/scripts/git-tree-reset.sh` (the
`tree` action of `/repo-hygiene:clean`) did a **partial destructive op**
on a branch whose upstream is configured but whose remote-tracking ref
is absent — e.g. a feature branch whose squash-merged PR left the remote
branch deleted and pruned.

When `refs/remotes/<remote>/<branch>` is gone, `git rev-parse
--abbrev-ref '@{u}'` prints the **literal token** `@{u}` instead of a
ref name, and the trailing `| tr -d '\r' || true` pipe masks git's
non-zero exit — so the non-empty guard passes and `UPSTREAM=@{u}`. On
`--apply` this reached `git reset --hard @{u}` → `fatal: ambiguous
argument '@{u}'`, and (before the reset-success gate #460) `git clean
-fdx` still ran, leaving the tree cleaned but not reset.

## Fix

Add a first-class resolution gate immediately after the existing
no-upstream check, **before any label output or destructive op**. It
verifies `@{u}` names a real, resolvable ref and, if not, skips the
repo:

```sh
if ! git rev-parse --verify --quiet '@{u}' >/dev/null 2>&1; then
  UNRESOLVED_REMOTE="$(git config "branch.${CURRENT_BRANCH}.remote" 2>/dev/null | tr -d '\r' || true)"
  UNRESOLVED_MERGE="$(git config "branch.${CURRENT_BRANCH}.merge" 2>/dev/null | tr -d '\r' || true)"
  printf 'Blocked: upstream-unresolved (%s/%s)\n' "${UNRESOLVED_REMOTE:-?}" "${UNRESOLVED_MERGE#refs/heads/}"
  printf 'PlannedReset: none\n'
  printf 'PlannedClean: none\n'
  exit 6
fi
```

- Verifies `@{u}` **directly** (the actual reset target), robust against
the literal-token quirk regardless of what `--abbrev-ref` echoed.
- A local-only upstream (`branch.<name>.remote="."`) still resolves to
its local branch → gate falls through → **resolvable common case is
unchanged** (existing dry-run / apply / default-branch / unpushed /
reset-fail tests all still pass).
- Distinct exit code `6` (vs exit `2` "no upstream"): different
remediation — configured-but-remote-ref-gone (`fetch --prune` / delete
branch) vs never-configured (`push -u`). Mirrors #460's convention of a
distinct code per gate.
- No destructive command (`reset`/`clean`) can run on the unresolved
path — the gate returns before the dry-run preview and before the apply
block.

Also bumps `repo-hygiene` `0.3.1` → `0.3.2` (patch, bug), adds the
CHANGELOG entry, extends the header exit map + `git-tree-reset.md` gate
list, and adds a regression test.

## Verification

**Lint (all clean):**
- `shellcheck --rcfile .shellcheckrc` on `git-tree-reset.sh` and
`git-tree-reset.test.sh` → both clean.
- `typos`, `editorconfig-checker`, `markdownlint-cli2` on changed md +
json → 0 findings.

**Test suite:** `git-tree-reset.test.sh` → 37/37 pass, including the new
pruned-upstream case asserting exit 6, the `Blocked:` message,
`PlannedReset`/`PlannedClean: none`, no `AppliedReset`/`AppliedClean`
line, and the untracked file surviving.

**Empirical before/after** (real bare remote; `feat/gone` pushed, remote
branch deleted + pruned; `--dry-run` to sidestep the reset-guard hook):

BEFORE (pre-fix script) — the smoking gun:

```text
Upstream: @{u}
Blocked: none
PlannedReset: git reset --hard @{u}
PlannedClean: git clean -fdx (+18 preserve excludes)
--- clean preview (git clean -fdxn, default-preserve applied) ---
Would remove scratch.txt
exit=0
```

On `--apply` this hits `fatal: ambiguous argument '@{u}'`.

AFTER (fixed script) — no destructive op planned or run:

```text
Blocked: upstream-unresolved (origin/feat/gone)
PlannedReset: none
PlannedClean: none
exit=6
```

`scratch.txt` still present: yes.

## Related

Closes #393.

- Source: #393.
- Companions in the same `git-tree-reset.sh` gate family: #394, #460
(reset-success gate, merged — this PR coordinates with it: `clean` never
runs when the reset precondition fails), #395, #396. This PR is scoped
strictly to the `@{u}`-resolution gate; the others are noted here, not
touched.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
kyle-sexton added a commit that referenced this pull request Jul 20, 2026
…ss when git clean failed (#395) (#590)

## Summary

`git-tree-reset.sh --apply` printed `AppliedClean: git clean -fdx …`
**unconditionally**, regardless of whether `git clean` actually
succeeded — a false success signal that misleads any operator or
automation keying off that line to conclude the tree reached a
known-good state.

Scope note: the sibling `AppliedReset` false-success and the
unresolvable-`@{u}` gate that issue #395 references were already closed
by #460 (reset-failure aborts clean, exit 5) and #542
(upstream-unresolved block, exit 6). This PR fixes the one remaining
unguarded case — `AppliedClean` — so #395's acceptance criteria are
fully met.

## Fix

- Capture `git clean -fdx`'s exit status (`CLEAN_RC`), read on the line
immediately after the capture so `$?` isn't clobbered (the script runs
`set -uo pipefail`, no `-e`).
- Gate the `AppliedClean` success line on the real outcome. A non-zero
clean exit whose cause is **locked/in-use files** is the expected
non-fatal case — `git clean` returns non-zero when it fails to remove
any path, and that case is already reported honestly via `Unremovable:`
— so it is **not** treated as failure (avoids regressing the deliberate
locked-file handling on Windows). Only a non-zero exit with **no**
`failed to remove` warnings is a genuine failure: it emits an explicit
`FAILED:` line, `AppliedClean: failed`, and exits **7** instead of a
success line.
- Emit the `AppliedReset:` success line as soon as `reset --hard`
genuinely succeeds — **before** `clean` — so a subsequent clean failure
still surfaces the truthful reset outcome rather than swallowing it.
- Run the reparse-point restore guard on the failure path too (data-loss
guard: a clean that errored mid-run may still have deleted tracked files
first; safe because `reset --hard` ran first).
- Documented exit 7 in the script header, `usage()`, and the `clean`
skill's `context/git-tree-reset.md` gates list.

## Verification

New test case #11 (`git-tree-reset.test.sh`) forces a genuine clean
failure via a `PATH` shim that intercepts only `git clean` (delegating
every other subcommand — crucially `reset` — to the real git, so the
reset genuinely succeeds). The full suite passes, including the 6 new
assertions:

```
PASS: [38] clean failure exits 7
PASS: [39] clean failure reports failure
PASS: [40] clean failure emits AppliedClean: failed
PASS: [41] clean failure emits no clean success line
PASS: [42] clean failure still reports the successful reset
PASS: [43] clean failure leaves untracked intact (clean did not silently succeed)
OK: git-tree-reset.sh tests passed
```

(All 43 tests pass; the pre-existing reset-failure suite #22–27 and
upstream-unresolved suite #28–37 continue to pass — no regression.
`shellcheck` is clean on both files.)

Live `--apply` run against a repo where `git clean` fails after a
successful reset — the report is now honest and exits non-zero:

```
AppliedReset: git reset --hard main
FAILED: git clean -fdx exited 1 (non-locked-file cause) — untracked removal incomplete; reset --hard already applied.
fatal: simulated git clean failure
AppliedClean: failed
RestoredTracked: 0
EXIT=7
scratch.txt still present (clean did NOT silently succeed): YES
```

Before this fix the same run printed `AppliedClean: git clean -fdx` and
exited `0`.

Closes #395

## Related

- #395 — this PR.
- #396 — sibling issue on the same script (dry-run does not validate
`@{u}` upstream). **Not addressed here** to avoid a merge collision on
the same lines; a separate cycle picks it up.

🤖 Generated with a Claude Code implementation subagent (issue #395)

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
kyle-sexton added a commit that referenced this pull request Jul 20, 2026
…-5 gate (#485) (#670)

## Summary

The exit-5 gate bullet in
`plugins/repo-hygiene/skills/clean/context/git-tree-reset.md` accurately
described the *gating* contract but was incomplete relative to the
runtime stderr message: it did not tell a doc-only reader that a failed
`reset --hard` may have partially modified tracked files before it
failed. This is a one-line doc-consistency fix (patch bump `0.4.1` →
`0.4.2`).

## Fix

Added a parenthetical to the exit-5 bullet so the context doc is
consistent with the exit-5 stderr message the script already emits.

## Verification

**Doc — before (`git-tree-reset.md`):**

> - 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.

**Doc — after:**

> - 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 (the reset itself may have partially modified
tracked files, since `reset --hard` is not atomic).

**Script stderr it stays consistent with
(`skills/clean/scripts/git-tree-reset.sh`, exit-5 path):**

```sh
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
  ...
  exit 5
```

The added parenthetical ("the reset itself may have partially modified
tracked files, since `reset --hard` is not atomic") mirrors the script's
stderr wording ("reset --hard is not atomic and may have partially
modified tracked files"). No behavioral change; docs only.

Closes #485

## Related

- Follow-up to #485 (this issue).
- Deferred from PR #460 (fixes #394) — the parenthetical was flagged in
the #460 review but left unfixed to preserve that PR's merge-ready head
SHA (`745def6`, the commit that added the stderr caveat).

🤖 Generated with a Claude Code implementation subagent (issue #485)

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(repo-hygiene): git-tree-reset runs clean -fdx even when reset --hard failed (partial destructive op)

1 participant