Skip to content

Worktree family hardening: fix P0 repo deletion, unify batch/exit-code/JSON contracts#178

Merged
elifarley merged 10 commits into
mainfrom
feat-wt-family-hardening
Jun 13, 2026
Merged

Worktree family hardening: fix P0 repo deletion, unify batch/exit-code/JSON contracts#178
elifarley merged 10 commits into
mainfrom
feat-wt-family-hardening

Conversation

@elifarley

Copy link
Copy Markdown
Owner

✅ Eliminate the P0 where wtdel -p <main-repo> --force deleted the entire main repository (no main-WT guard + rm -rf fallback bulldozing git's refusal). Unify wtc/wtdel/wtl on one batch model (validate-all-then-execute), one exit-code convention (0/1/2/3), and shared --json/-q scriptability.

🟢 Behavioral change worth a look: mixed-validity batches now remove NOTHING instead of best-effort partial removal (safer, but scripts relying on partial success need updating). 327 worktree-family BATS + 943 pytest green. Design doc: https://github.com/elifarley/hug-scm/blob/main/mgmt/plans/2026-06-12-worktree-family-hardening-design.md


What changed, per command:

wtdel:

  • P0 main-worktree guard in path mode (anchored to the target's own gitdir, correct for submodules)
  • rm -rf fallback abolished entirely; git refusals are surfaced, not overridden. Submodule refusal retries via git's own --force --force
  • Global prune replaced with targeted prune_worktree_entry (removing worktree A no longer destroys unrelated stale entry B)
  • 4-phase pipeline: RESOLVE, PRE-FLIGHT, CONFIRM, EXECUTE. One danger confirmation covers the whole batch

wtc:

  • HUG_FORCE env support, -f composes with --dry-run (was mutually exclusive)
  • New -p/--path, -B/--with-branch, --json, -q
  • Branch-in-use error names the holding worktree path

wtl:

  • Stale worktrees show (gone) instead of the commit hash
  • --json gains missing + dirty_details fields

Family-wide:

  • HUG_EX_OK/FAIL/USAGE/BLOCKED constants + error_usage(2)/error_blocked(3) in hug-output
  • -y on danger-tier now exits 3 (was 1)
Usage examples
# Scriptable removal with JSON output
hug wtdel feat-1 feat-2 --force --json | jq '.counts'

# Preview a batch that would fail (validates before touching anything)
hug wtdel feat-1 bogus-branch --force   # exit 1, nothing removed, reports both

# Blocked by safety = exit 3 (distinguishable from not-found = 1)
hug wtdel dirty-wt                       # exit 3, BLOCKED message
hug wtdel --bogus-flag                   # exit 2, usage error

# Create + capture result as JSON
hug wtc hotfix --new -y --json | jq '.path'

# Stale entries are visible
hug wtl                                  # shows (gone) for externally-deleted dirs
Details: root cause and deferred work

The P0 had three contributing factors: (1) path-mode -p had no main-worktree guard, so -p <main> reached the removal step; (2) when git refused removal ("is a main working tree"), the rm -rf fallback deleted the entire repo; (3) the stale flow used a global prune, destroying unrelated metadata. All three fixed.

--detach worktrees deferred to #177.

claude and others added 8 commits June 12, 2026 19:21
WHY: The 2026-06-03 wtc/wtl/wtdel exercise report catalogued 20 DX
findings. Empirical re-verification in throwaway sandboxes changed the
picture materially: one finding is false (DX-1 — --base accepts relative
refs; the test repo just lacked HEAD~3), two are by-design but
under-documented (the -y/-f confirmation tier model), and the report
missed a P0: `hug wtdel -p <main-worktree> -f` run from a linked worktree
rm -rf's the entire main repository after git correctly refuses to remove
it. Designing from the report without verification would have fixed the
wrong things.

WHAT: Approved design covering safety hardening (main-worktree guard in
all wtdel modes, abolition of the blind rm -rf fallback, scoped pruning,
dead-code removal), a validate-all-then-execute batch model, a documented
3-tier confirmation language, family exit codes (0/1/2/3), --json/-q
scriptability for wtc/wtdel, wtl staleness visibility, and flag
unification (-p/--path, -B/--with-branch).

HOW: Four contract decisions were answered explicitly by the maintainer
(batch model, -y semantics, exit codes, single-PR delivery) and are
recorded in §2 so future readers know they were deliberate, not
incidental.

IMPACT: This spec is the single source of truth for the implementation
commits that follow; each cites its sections. Lesson encoded: exercise
reports are hypotheses — re-verify findings empirically before designing
fixes (DX-1 was disproven by a 5-commit sandbox in seconds, and the
worst bug in the family was found only by trying the report's own
"workaround" suggestion in a sandbox).

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…f fallback, scoped prune

WHY: `wtdel -p <main-repo> --force` deleted the entire main repository. Three
independent bugs conspired:

1. Path-mode (-p) had NO main-worktree guard. Branch-mode refused main at
   resolve time, but -p bypassed that resolver entirely and reached the
   removal step unguarded. Git then refused ("is a main working tree"),
   which triggered bug #2.

2. When git refused, the rm -rf fallback treated every refusal as cruft and
   bulldozed the target — including the main repo. The P0 path was:
   `wtdel -p <main> --force` → git refuses → rm -rf <main> → total loss.

3. Stale-flow used global `git wt prune` per-removal, which silently
   erased every OTHER stale entry as a side effect.

WHAT: Three surgical safety fixes:

- main_worktree_of_gitdir(): new lib function that resolves the main
  working tree for an EXPLICIT gitdir. Unlike resolve_main_worktree_path
  (which anchors to CWD), this function anchors to the target's own gitdir
  — the only correct answer for submodule topologies where CWD's gitdir
  differs from the target wt's owning gitdir.

- prune_worktree_entry(): new lib function that deletes exactly one admin
  directory ($gitdir/worktrees/<id>) by matching the gitdir file's content
  against <wt-path>/.git. Replaces global `git wt prune` which
  destroyed all stale entries indiscriminately.

- Removed rm -rf fallback entirely. Git refuses removals for PRINCIPLED
  reasons (main wt, locks, submodules). Hug now surfaces git's
  reason instead of overriding it. Added a submodule double-force retry
  (git demands --force --force for wts containing submodules) as the
  sole exception — still git-managed, never a blind filesystem delete.

HOW: resolve_main_worktree_path() refactored into a thin CWD adapter that
delegates to main_worktree_of_gitdir(). The wtdel path loop gains a P0 guard
after gitdir resolution: compare the target path against the target's own
main wt (not CWD's). The stale flow uses prune_worktree_entry instead
of global prune. The execute block replaces rm-rf with error reporting.

IMPACT: Main-repo deletion P0 eliminated. Unrelated stale entries survive
when a different wt is removed. Submodule wts get the correct
gitdir-anchored main check. All 133 existing tests continue to pass;
9 new tests cover the three safety fixes.

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

Co-Authored-By: Claude <noreply@anthropic.com>
WHY: remove_worktree(), create_worktree(), and create_branch_if_needed()
had zero callers — git-wtdel/git-wtc reimplement their logic inline — and
remove_worktree() carried its own copy of the rm -rf fallback abolished in
the previous commit. Dead code with a live footgun is the worst kind.

WHAT: Deleted all three function bodies and their doc comments from
hug-git-worktree. Deleted the 7 test blocks that exercised them
(3 create_worktree + 4 remove_worktree) from test_hug-git-worktree.bats.

HOW: grep-verified zero references remain outside comments/history.
Test count dropped from 74→67 in lib tests (7 deleted, 0 regressions).

IMPACT: No behavior change. Removes the residual rm -rf hazard that could
confuse future developers into thinking it was safe to call these functions.

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

Co-Authored-By: Claude <noreply@anthropic.com>
WHY: Every hug command used exit 1 for every failure mode — usage errors,
safety blocks, and operational failures were indistinguishable.  Shell scripts
wrapping hug (CI pipelines, Make targets, dotfiles) could not tell "bad flag"
from "refused for safety" from "git error".  The danger-tier -y rejection was
particularly misleading: it signalled generic failure (exit 1) when the real
semantic was "blocked by policy", which callers should handle differently.

WHAT: Introduced four named exit-code constants (HUG_EX_OK/FAIL/USAGE/BLOCKED)
in hug-output and two thin helper wrappers (error_usage → exit 2,
error_blocked → exit 3).  Changed prompt_confirm_danger's HUG_YES rejection
from exit 1 to exit 3 ($HUG_EX_BLOCKED).  Added the confirmation-tier matrix
to hug-confirm's file header documenting how -y/-f interact with safe/warn/
danger tiers.  Updated the prompt_confirm_warn docstring to explain WHY -y
covers warn-tier (recoverable destructive) but not danger-tier.

HOW: Constants are defined at lib load time in hug-output (before any function
that might use them).  error_usage/error_blocked delegate to the existing
error() which already accepts an optional exit-code argument — no duplication.
The tier matrix in hug-confirm is a reference table, not executable code, so
it has zero runtime cost.  Existing tests for HUG_YES + danger used
assert_failure (any non-zero) and continue to pass; a new test explicitly
asserts exit 3.

IMPACT: Scripts can now branch on exit codes (2 = usage, 3 = safety-blocked)
while `if hug …` still works for simple success/failure checks.  The danger-tier
-y rejection is unchanged for interactive users — it still refuses, just with a
more specific exit code that automated callers can react to.

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

Co-Authored-By: Claude <noreply@anthropic.com>
…n, -q

WHY: The old wtdel processed worktrees one-at-a-time — remove #1, then
try #2 (even if #1 failed), etc. A batch with one bad target would
partially succeed, leaving the user with an inconsistent state. There
was no way to script wtdel (no --json, no quiet mode, no exit code
distinction between "not found" and "blocked").

WHAT: Restructured wtdel into 4 phases: RESOLVE → PRE-FLIGHT → CONFIRM →
EXECUTE. Every target is classified first (ok/stale/dirty/blocked/etc);
if ANY is blocked or invalid, NOTHING is removed. One confirmation covers
the whole batch. Added --json for scriptable output, -q for quiet mode.
Exit codes: 2=usage, 3=blocked, 1=operational error.

HOW: New wtdel_classify_target() in hug-git-worktree classifies targets
without mutation. git-wtdel builds plan arrays, validates the whole plan,
then executes. JSON emitted via hug-json helpers. getopt gains q/quiet/json.

PITFALL — interactive-menu guard: The old code populated worktree_paths[]
from branch_names[] BEFORE the interactive-menu check, so an empty
worktree_paths meant "no targets at all." The new code keeps branch_names
and worktree_paths separate (Phase 1 resolves branch_names into plan
records directly), so the guard must check BOTH arrays:
  if [[ ${#worktree_paths[@]} -eq 0 && ${#branch_names[@]} -eq 0 ]]; then
Missing the branch_names check causes all positional-branch invocations
to fall through to the interactive menu — silent and wrong.

IMPACT: Batch removals are now atomic (all-or-nothing). Scripts can parse
--json output and branch on exit codes. -q enables automation-friendly
output. Breaking change: mixed-validity batches now remove NOTHING (was
best-effort). The new behavior is safer and more predictable.

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

Co-Authored-By: Claude <noreply@anthropic.com>
…s, --json, -q, exit codes

WHY: wtc was the only worktree command without HUG_FORCE env support,
without scriptability flags (--json/-q), and without exit-code
distinction between usage errors and safety blocks. Its -f/--dry-run
mutual exclusion blocked legitimate use cases (preview with
force-override semantics). Branch-in-use errors didn't name the
holding worktree, forcing users to run `hug wtl` separately.

WHAT: Added HUG_FORCE env support (parity with wtdel). Removed the
-f/--dry-run mutual exclusion — they now compose naturally. Added
-p/--path for explicit worktree path, -B/--with-branch as a
discoverable alias for --new, --json for machine-readable output,
-q for quiet mode. Upgraded error guards to use error_blocked (exit 3)
for branch-in-use and main-checkout cases, error_usage (exit 2) for
bad arguments and getopt failures. Branch-in-use guard now names the
holding worktree path and suggests the removal command.

HOW: Extended getopt with new short options (B, q, p:) and long options
(with-branch, quiet, json, path:). hug-json is already sourced via
hug-common's library chain, so json_escape/to_json_nested are available
without an extra source line. -p/--path validation runs after the
positional-arg $# case block so it can detect the conflict between
positional and flag paths. --json emits to stdout after all chatter
has gone to stderr, keeping the output pipe-safe. HUG_QUIET guard
wraps only the decorative summary blocks (DRY RUN and SUMMARY) while
leaving info/success/tip calls outside — those already respect
HUG_QUIET internally.

IMPACT: Scripts can now create worktrees programmatically with --json
output and HUG_FORCE env var. -B provides a more discoverable alias
for --new (mnemonic: "with [this] branch"). Exit codes distinguish
usage errors (2) from safety blocks (3), enabling callers to branch on
$? for retry/abort logic. Fixed stale integration test assertions for
wtdel's pre-flight model (dirty worktree and current-worktree guards).

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

Co-Authored-By: Claude <noreply@anthropic.com>
…ils in JSON, usage exit 2

WHY: When a worktree directory was deleted externally (rm -rf), wtl still
showed the old commit hash — indistinguishable from a healthy worktree at a
glance.  Scripts consuming --json had no structured way to detect stale
entries.  Usage errors (unknown flags, missing values) exited 1 (general
error) instead of 2 (usage error), inconsistent with the rest of the worktree
family (wtdel, wtc) which adopted exit 2 in earlier tasks.

WHAT:
- Stale worktree rows now render "(gone)" instead of the commit hash
- wtl --json gains "missing" (bool) and "dirty_details" (array) fields
- Three usage-error sites in git-wtl now route through error_usage (exit 2)
- Existing pytest mocks updated to target _check_worktree_dirty_details
  (parse_worktree_list now calls it directly instead of the thin wrapper)

HOW:
- git-wtl: after extracting the commit hash in each rendering loop (filtered
  and unfiltered), check [[ -d "$path" ]] and replace commit with "gone" if
  the directory is absent.  The existing printf format ($commit) then renders
  it as (gone) — no new legend symbol, no column shift.
- worktree.py: added `missing` (bool) and `dirty_details` (tuple) fields to
  WorktreeInfo.  Extracted _dirty_detail_labels() helper that converts a
  WorktreeDirtyInfo into a tuple of category labels ("staged", "unstaged",
  "untracked") — DRY between parse_worktree_list and the dirty subcommand.
  Propagated through WorktreeList parallel arrays and to_json().
- Changed parse_worktree_list to call _check_worktree_dirty_details directly
  (not _check_worktree_dirty wrapper) to get both the dirty boolean AND the
  categorized labels in a single subprocess round-trip — avoids 3 extra git
  invocations per worktree.
- error_usage already existed in hug-output (exit 2 with HUG_EX_USAGE);
  simply replaced the three error()+exit 1 patterns.

LESSONS:
- BATS refute_output --regexp spans newlines (.* matches \n), making
  per-line negative assertions unreliable.  Use `grep -E` in an if/fail
  pattern for line-scoped negative checks.
- When adding fields to a dataclass with existing tests, the mock target
  must match the actual call site — changing from _check_worktree_dirty to
  _check_worktree_dirty_details requires updating ALL @patch decorators.

IMPACT: Users can immediately see which worktrees are gone from the listing.
Scripts can filter on missing/dirty_details in JSON output. Consistent exit
codes across the worktree family.

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

Co-Authored-By: Claude <noreply@anthropic.com>
…ing guide, CHANGELOG

WHY: The three worktree commands (wtc/wtdel/wtl) had inconsistent help
text, no shared safety language, no exit code documentation, and no
scripting guide. The CHANGELOG had no entry for the worktree family
hardening. Users could not discover the safety model or understand
exit codes from help alone.

WHAT: Unified SAFETY and EXIT CODES sections across wtc/wtdel/wtl help
text. Updated CHANGELOG with [Unreleased] Fixed/Changed/Added sections
documenting the hardening. Expanded docs/commands/worktree.md with
safety tier table, family-wide exit codes, scripting guide (--json/-q
recipes), stale worktree lifecycle, and deferred --detach reference.
Filed #177 for the --detach follow-up.

HOW: Help text in all three commands now follows the same structure:
OPTIONS, DESCRIPTION, EXAMPLES, SAFETY, EXIT CODES, SEE ALSO. The -y
flag description explicitly states it is NOT sufficient for dangerous
operations — that wording is identical across wtc and wtdel. EXIT CODES
uses the same 0/1/2/3 convention documented in the codebase.

IMPACT: Users discover the safety model and exit codes from --help.
Scripts have a documented JSON schema with missing/dirty_details fields
and scripting patterns (-q, -p -b recipe). The CHANGELOG provides a
reviewable record of all behavioral changes. The deferred --detach
issue gives a clear landing spot for future work.

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

Co-Authored-By: Claude <noreply@anthropic.com>
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

WHY: The implementation commits were formatted per-editor; the project's
canonical formatter (make sanitize) normalized trailing-comment spacing
and a few other whitespace conventions.

WHAT: shfmt and ruff whitespace normalization on the touched files only
(git-wtc, git-wtdel, worktree.py, test_worktree.py). No logic changes.

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

Co-Authored-By: Claude <noreply@anthropic.com>
@elifarley

Copy link
Copy Markdown
Owner Author

@codex review

@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: d9a873e8c3

ℹ️ 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 git-config/bin/git-wtdel
Comment thread git-config/bin/git-wtc
WHY: Two defensive gaps surfaced during Codex review of the worktree
family hardening PR:

1. wtdel: `hug wtdel feat feat --force` resolves both specs to the same
   path. Phase 1 (RESOLVE) appends two plan records; Phase 4 (EXECUTE)
   removes the first successfully, then the second fails with "not a
   working tree" — a partial removal that directly violates the
   all-or-nothing batch contract this PR introduced.

2. wtc: `-p ""` (easy from `-p "$UNSET_VAR"`) sets flag_path to empty,
   then `[[ -n "$flag_path" ]]` evaluates false, so the command silently
   falls back to auto-generating a path — creating the worktree somewhere
   the caller never intended.

WHAT:
- wtdel: Post-RESOLVE dedup by resolved path (empty paths dedup by spec).
  Duplicates emit a warning and are dropped; the batch proceeds with
  unique entries only.
- wtc: Reject `-p/--path` with an empty or missing value as a usage
  error (exit 2), caught at parse time before any worktree logic runs.

HOW:
- Dedup uses an associative array keyed on resolved path (or spec for
  no-worktree entries), placed between Phase 1 and Phase 2. A compaction
  loop rebuilds the six parallel plan arrays to only kept indices.
- The empty-value guard is a `[[ -z "${2:-}" ]]` check inside getopt's
  `-p|--path` case arm — fails fast before `$2` is consumed.

IMPACT: The batch contract is now structurally enforced against duplicate
targets. Empty `-p` is a parse-time error instead of a silent misroute.
Zero behavioral change for well-formed invocations.

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

Co-Authored-By: Claude <noreply@anthropic.com>
@elifarley elifarley merged commit f66cb37 into main Jun 13, 2026
11 of 12 checks passed
@elifarley elifarley deleted the feat-wt-family-hardening branch June 13, 2026 22:24
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.

2 participants