diff --git a/openspec/changes/agent-claude-fix-cleanup-orphan-worktree-prune-2026-05-11-12-19/.openspec.yaml b/openspec/changes/agent-claude-fix-cleanup-orphan-worktree-prune-2026-05-11-12-19/.openspec.yaml new file mode 100644 index 0000000..81cd71f --- /dev/null +++ b/openspec/changes/agent-claude-fix-cleanup-orphan-worktree-prune-2026-05-11-12-19/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-11 diff --git a/openspec/changes/agent-claude-fix-cleanup-orphan-worktree-prune-2026-05-11-12-19/proposal.md b/openspec/changes/agent-claude-fix-cleanup-orphan-worktree-prune-2026-05-11-12-19/proposal.md new file mode 100644 index 0000000..ebd6433 --- /dev/null +++ b/openspec/changes/agent-claude-fix-cleanup-orphan-worktree-prune-2026-05-11-12-19/proposal.md @@ -0,0 +1,35 @@ +# Proposal: make `gx cleanup` dirt-aware of state-file globs + log why a worktree was kept + +## Problem + +Two related shortcomings in `templates/scripts/agent-worktree-prune.sh` made `gx cleanup` less useful than it should be: + +1. **State-file dirt was misclassified as real work.** `is_clean_worktree()` excluded exactly one path from its dirty check — `.omx/state/agent-file-locks.json` — and treated everything else as if it were authoritative content. A worktree with nothing dirty but agent state (`.omc/state/*.json`, `.omx/state/*.json`, `apps/logs/*.log`, `.dev-ports.json`) was therefore tagged "dirty" and skipped, leaving orphans on disk that the user had to delete manually. This contradicts the allowlist established by PRs #546 / #547. + +2. **The skip-dirty log was opaque.** When a worktree was preserved, the script printed `Skipping dirty worktree (): ` and nothing else. With multiple stale worktrees accumulated (8 locally at audit start), per-worktree `cd + git status` was the slow path. + +## Approach + +One file touched: `templates/scripts/agent-worktree-prune.sh` (the runtime canonical script; `scripts/agent-worktree-prune.sh` is a symlink to it as of PR #548). + +### 1. Expand the cleanliness check to honor the shared state-file allowlist + +Introduce `WORKTREE_STATE_EXCLUDE_GLOBS_DEFAULT` with the same colon-separated list used by PR #546 (`GUARDEX_AUTO_TRANSFER_EXCLUDE`) and PR #547 (`GUARDEX_FINISH_AUTO_RESOLVE_SAFE_GLOBS`): `.omc/**:.omx/state/**:.dev-ports.json:apps/logs/**:.agents/settings.local.json:.codex/state/**:.claude/state/**`. Convert to `:(exclude,glob)` pathspec args once at startup, pass them to all three `is_clean_worktree` subchecks (working-tree diff, cached diff, untracked enumeration). + +A worktree whose only deltas are agent state files is now considered clean and gets pruned. Override via `GUARDEX_PRUNE_STATE_EXCLUDE_GLOBS`. + +### 2. Add `summarize_worktree_dirt()`, wire it into the skip-dirty branch + +When the prune loop skips a dirty worktree, the helper prints up to 3 modified-tracked paths and up to 3 untracked paths (with `(+N more)` tail), using the same state-file excludes so the summary only surfaces work the user cares about. Indented under the existing `Skipping dirty worktree` line. + +## Compatibility + +- Worktrees with state-file-only dirt are now auto-pruned. `.omc/` and `.omx/state/**` are gitignored per CLAUDE.md and have no authoritative value. +- Worktrees with real code dirt remain skipped; the new diagnostic surfaces which files. +- Env override (`GUARDEX_PRUNE_STATE_EXCLUDE_GLOBS=`) restores strict behavior. +- No CLI surface change. + +## Risks + +- A user storing local-only notes under `.omc/` would see them deleted at prune. Mitigated by CLAUDE.md's "Never stage or commit: .omc/state/**, .omx/state/**" rule — those paths were always agent-only. +- `summarize_worktree_dirt` runs `git diff` / `git ls-files` twice each per skipped worktree (paths + count). Negligible at typical counts. diff --git a/openspec/changes/agent-claude-fix-cleanup-orphan-worktree-prune-2026-05-11-12-19/specs/fix-cleanup-orphan-worktree-prune/spec.md b/openspec/changes/agent-claude-fix-cleanup-orphan-worktree-prune-2026-05-11-12-19/specs/fix-cleanup-orphan-worktree-prune/spec.md new file mode 100644 index 0000000..d6eaede --- /dev/null +++ b/openspec/changes/agent-claude-fix-cleanup-orphan-worktree-prune-2026-05-11-12-19/specs/fix-cleanup-orphan-worktree-prune/spec.md @@ -0,0 +1,46 @@ +# Spec Delta: gx-cleanup worktree prune + +## ADDED Requirements + +### Requirement: `gx cleanup` MUST treat agent state-file dirt as clean for prune purposes + +The `is_clean_worktree` helper in `templates/scripts/agent-worktree-prune.sh` MUST exclude the canonical agent state-file glob list (`.omc/**`, `.omx/state/**`, `.dev-ports.json`, `apps/logs/**`, `.agents/settings.local.json`, `.codex/state/**`, `.claude/state/**`) from all three of its subchecks (working-tree diff, cached diff, untracked-files enumeration). A worktree whose only deltas relative to its HEAD match these globs MUST be classified as clean and become eligible for automatic prune. The exclude list MUST be overridable via the `GUARDEX_PRUNE_STATE_EXCLUDE_GLOBS` environment variable. + +#### Scenario: Worktree with only state-file dirt is pruned + +- **GIVEN** an orphan agent worktree under `.omc/agent-worktrees/` whose only working-tree changes are modifications to `.omc/state/some.json` and an untracked `apps/logs/run.log` +- **WHEN** `gx cleanup --base main` runs +- **THEN** the worktree is classified as clean +- **AND** the worktree is removed (`removed_worktrees` counter increments) + +#### Scenario: Worktree with real code dirt is still skipped + +- **GIVEN** an orphan agent worktree with a modified tracked file outside the state-file allowlist (e.g. `src/main.js`) +- **WHEN** `gx cleanup --base main` runs +- **THEN** the worktree is skipped as dirty (`skipped_dirty` counter increments) + +#### Scenario: Override restores strict cleanliness + +- **GIVEN** `GUARDEX_PRUNE_STATE_EXCLUDE_GLOBS=` (empty) is exported +- **AND** an orphan worktree with only state-file dirt +- **WHEN** `gx cleanup --base main` runs +- **THEN** the worktree is skipped as dirty (no globs excluded; pre-fix behavior preserved) + +### Requirement: `gx cleanup` MUST log what's dirty when it skips a worktree + +When a worktree is preserved because of dirty content (i.e., `Skipping dirty worktree ()` is printed), the script MUST also print a summary of up to 3 modified-tracked paths and up to 3 untracked paths, indented under the skip line. If more than 3 of either exist, a `(+N more)` line MUST be appended. The summary MUST honor the same state-file allowlist so it only surfaces real work. + +#### Scenario: Dirt summary appears under skip-dirty line + +- **GIVEN** an orphan worktree with `src/foo.js` modified and an untracked `src/new-feature.ts` +- **WHEN** `gx cleanup --base main` runs and skips the worktree as dirty +- **THEN** the log includes `Skipping dirty worktree (): ` +- **AND** the log includes an indented line ` modified: src/foo.js` +- **AND** the log includes an indented line ` untracked: src/new-feature.ts` + +#### Scenario: Truncated summary with N-more tail + +- **GIVEN** an orphan worktree with 8 modified-tracked files outside the state-file allowlist +- **WHEN** `gx cleanup --base main` runs and skips the worktree as dirty +- **THEN** the log shows 3 `modified:` lines +- **AND** a single ` modified: (+5 more)` line follows diff --git a/openspec/changes/agent-claude-fix-cleanup-orphan-worktree-prune-2026-05-11-12-19/tasks.md b/openspec/changes/agent-claude-fix-cleanup-orphan-worktree-prune-2026-05-11-12-19/tasks.md new file mode 100644 index 0000000..1ac5c7c --- /dev/null +++ b/openspec/changes/agent-claude-fix-cleanup-orphan-worktree-prune-2026-05-11-12-19/tasks.md @@ -0,0 +1,27 @@ +# Tasks + +## 1. Spec + +- [x] 1.1 Capture problem + approach in `proposal.md`. +- [x] 1.2 Add ADDED requirements + scenarios to `specs/gitguardex-agent-lifecycle/spec.md`. + +## 2. Implementation + +- [x] 2.1 `templates/scripts/agent-worktree-prune.sh`: add `WORKTREE_STATE_EXCLUDE_GLOBS_DEFAULT` + override env var. +- [x] 2.2 `templates/scripts/agent-worktree-prune.sh`: add `build_state_exclude_pathspec_args` helper and capture into `STATE_EXCLUDE_PATHSPEC_ARGS` array once. +- [x] 2.3 `templates/scripts/agent-worktree-prune.sh`: expand `is_clean_worktree` to apply state-exclude pathspecs to all three subchecks (working-tree diff, cached diff, untracked-files). +- [x] 2.4 `templates/scripts/agent-worktree-prune.sh`: add `summarize_worktree_dirt` helper printing top 3 modified + top 3 untracked with `(+N more)` tail. +- [x] 2.5 `templates/scripts/agent-worktree-prune.sh`: call `summarize_worktree_dirt` in the skip-dirty branch of `process_entry`. + +## 3. Verification + +- [x] 3.1 `bash -n` clean on the edited script. +- [x] 3.2 Live run: invoke the new script against the primary repo. 2 of the local stale worktrees correctly skipped as dirty, each with a modified-files summary listing real cockpit code (`.github/workflows/cr.yml`, `src/cockpit/control.js`, etc.) — proves the dirt summary works and the state-glob exclude doesn't mis-classify real work. +- [x] 3.3 Confirm symlink: `scripts/agent-worktree-prune.sh` (PR #548 symlink) resolves to the edited file. + +## 4. Cleanup + +- [ ] 4.1 Commit on `agent/claude/fix-cleanup-orphan-worktree-prune-2026-05-11-12-19`. +- [ ] 4.2 Push and open PR against `main`. +- [ ] 4.3 PR merged (record URL + MERGED state). +- [ ] 4.4 Sandbox worktree pruned via `gx branch finish --cleanup`. diff --git a/templates/scripts/agent-worktree-prune.sh b/templates/scripts/agent-worktree-prune.sh index 5acfa5d..9a6806a 100644 --- a/templates/scripts/agent-worktree-prune.sh +++ b/templates/scripts/agent-worktree-prune.sh @@ -249,11 +249,71 @@ branch_has_worktree() { git -C "$repo_root" worktree list --porcelain | grep -q "^branch refs/heads/${branch}$" } +# Globs treated as agent state, not real work. Worktrees whose only "dirty" +# content matches these are considered clean for prune purposes. Mirrors the +# auto-transfer + auto-resolve allowlist from PRs #546/#547 (state-file globs +# never carry authoritative content out of an agent branch). +WORKTREE_STATE_EXCLUDE_GLOBS_DEFAULT='.omc/**:.omx/state/**:.dev-ports.json:apps/logs/**:.agents/settings.local.json:.codex/state/**:.claude/state/**' +WORKTREE_STATE_EXCLUDE_GLOBS_RAW="${GUARDEX_PRUNE_STATE_EXCLUDE_GLOBS-$WORKTREE_STATE_EXCLUDE_GLOBS_DEFAULT}" + +build_state_exclude_pathspec_args() { + # Emit one ':(exclude,glob)' arg per non-empty pattern. + if [[ -z "$WORKTREE_STATE_EXCLUDE_GLOBS_RAW" ]]; then + return 0 + fi + local -a globs=() + IFS=':' read -ra globs <<< "$WORKTREE_STATE_EXCLUDE_GLOBS_RAW" + local pattern + for pattern in "${globs[@]}"; do + [[ -z "$pattern" ]] && continue + printf '%s\0' ":(exclude,glob)${pattern}" + done +} + +# Capture the state-exclude pathspecs once; reused by is_clean_worktree and +# the dirt-summary logger below. +STATE_EXCLUDE_PATHSPEC_ARGS=() +while IFS= read -r -d '' arg; do + STATE_EXCLUDE_PATHSPEC_ARGS+=("$arg") +done < <(build_state_exclude_pathspec_args) + is_clean_worktree() { local wt="$1" - git -C "$wt" diff --quiet -- . ":(exclude).omx/state/agent-file-locks.json" \ - && git -C "$wt" diff --cached --quiet -- . ":(exclude).omx/state/agent-file-locks.json" \ - && [[ -z "$(git -C "$wt" ls-files --others --exclude-standard)" ]] + git -C "$wt" diff --quiet -- . "${STATE_EXCLUDE_PATHSPEC_ARGS[@]}" \ + && git -C "$wt" diff --cached --quiet -- . "${STATE_EXCLUDE_PATHSPEC_ARGS[@]}" \ + && [[ -z "$(git -C "$wt" ls-files --others --exclude-standard -- . "${STATE_EXCLUDE_PATHSPEC_ARGS[@]}")" ]] +} + +# Returns a short, human-readable summary of why a worktree is considered dirty: +# up to 3 modified-tracked paths + up to 3 untracked paths + a "(+N more)" tail. +# Used to surface actionable context next to "Skipping dirty worktree" log lines +# (previously gave no clue what was actually dirty). +summarize_worktree_dirt() { + local wt="$1" + local modified_paths untracked_paths + modified_paths="$(git -C "$wt" diff --name-only --diff-filter=AMDR -- . "${STATE_EXCLUDE_PATHSPEC_ARGS[@]}" 2>/dev/null | head -3)" + local modified_count + modified_count="$(git -C "$wt" diff --name-only --diff-filter=AMDR -- . "${STATE_EXCLUDE_PATHSPEC_ARGS[@]}" 2>/dev/null | wc -l | tr -d ' ')" + untracked_paths="$(git -C "$wt" ls-files --others --exclude-standard -- . "${STATE_EXCLUDE_PATHSPEC_ARGS[@]}" 2>/dev/null | head -3)" + local untracked_count + untracked_count="$(git -C "$wt" ls-files --others --exclude-standard -- . "${STATE_EXCLUDE_PATHSPEC_ARGS[@]}" 2>/dev/null | wc -l | tr -d ' ')" + + if [[ -n "$modified_paths" ]]; then + while IFS= read -r p; do + [[ -n "$p" ]] && echo " modified: ${p}" + done <<< "$modified_paths" + if [[ "$modified_count" -gt 3 ]]; then + echo " modified: (+$((modified_count - 3)) more)" + fi + fi + if [[ -n "$untracked_paths" ]]; then + while IFS= read -r p; do + [[ -n "$p" ]] && echo " untracked: ${p}" + done <<< "$untracked_paths" + if [[ "$untracked_count" -gt 3 ]]; then + echo " untracked: (+$((untracked_count - 3)) more)" + fi + fi } resolve_worktree_common_dir() { @@ -482,6 +542,7 @@ process_entry() { if [[ "$FORCE_DIRTY" -ne 1 ]] && ! is_clean_worktree "$wt"; then skipped_dirty=$((skipped_dirty + 1)) echo "[agent-worktree-prune] Skipping dirty worktree (${remove_reason}): ${wt}" + summarize_worktree_dirt "$wt" return fi