Skip to content

Commit c100ca8

Browse files
Testclaude
andcommitted
fix(8f24-a2a5): emit per-worktree progress in worktree-cleanup.sh gather loop
worktree-cleanup.sh ran its scan silently while performing hundreds of lsof and ls-remote calls. With 22 worktrees + 11 concurrent Claude pids, the gather phase takes ~43s on average and longer under load, causing claude-safe to appear to hang after "Running automatic cleanup...". Add a "Scanning N worktree(s)..." header to stderr and a "[n/N] <name>" line per worktree inside the gather loop. Output goes to stderr so stdout (the summary table) is unchanged for any caller parsing it. Also add tests/scripts/test_worktree_cleanup_gather_progress.py which exercises the real script against 3 fresh worktrees and asserts each worktree name appears in stderr. Update .test-index to map the source to the new test file alongside the existing startup-config test. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 0527f86 commit c100ca8

File tree

3 files changed

+153
-1
lines changed

3 files changed

+153
-1
lines changed

.test-index

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ plugins/dso/scripts/recipe-adapters/ts-morph-normalize-imports.mjs:tests/scripts
5959
plugins/dso/scripts/runners/bash-runner.sh:tests/scripts/test-bash-runner-discovery.sh
6060
plugins/dso/scripts/skill-trace-analyze.py:tests/scripts/test-skill-trace-analyze.sh,tests/scripts/test-skill-trace-analyze-shim.sh
6161
plugins/dso/scripts/validate-phase.sh:tests/scripts/test-validate-phase-v2-removal.sh,tests/scripts/test-validate-phase-missing-test-unit.sh,tests/test-validate-phase-portability.sh
62-
plugins/dso/scripts/worktree-cleanup.sh:tests/scripts/test_worktree_cleanup_startup_config.py
62+
plugins/dso/scripts/worktree-cleanup.sh:tests/scripts/test_worktree_cleanup_startup_config.py,tests/scripts/test_worktree_cleanup_gather_progress.py
6363
plugins/dso/skills/architect-foundation/SKILL.md:tests/plugin/test-audit-skill-resolution.sh,tests/hooks/test-fix-bug-skill.sh,tests/hooks/test-fix-bug-skill-directive.sh,tests/hooks/test-generate-claude-md-skill.sh,tests/hooks/test-init-skill.sh,tests/scripts/test-sprint-skill-doc-writer-dispatch.sh,tests/scripts/test-qualify-skill-refs.sh,tests/scripts/test-skill-path-refs.sh,tests/scripts/test-v2-skills-cleanup.sh,tests/scripts/test-skill-script-paths.sh,tests/scripts/test-skill-trace-analyze.sh,tests/scripts/test-create-bug-skill-smoke.sh,tests/scripts/test-skill-trace-analyze-shim.sh,tests/scripts/test-check-skill-refs.sh,tests/skills/test-architect-foundation-skill.sh,tests/skills/test-onboarding-skill.sh,tests/skills/test-quick-ref-skill.sh,tests/skills/test-sprint-skill-step10-no-merge-to-main.sh,tests/skills/test-skill-nesting-depth.sh,tests/skills/test-design-skills-cross-stack.sh,tests/skills/test-architect-foundation-ci-skeleton.sh,tests/skills/test-architect-foundation-integration.sh
6464
plugins/dso/skills/brainstorm/SKILL.md:tests/plugin/test-audit-skill-resolution.sh,tests/hooks/test-fix-bug-skill.sh,tests/hooks/test-fix-bug-skill-directive.sh,tests/hooks/test-generate-claude-md-skill.sh,tests/hooks/test-init-skill.sh,tests/scripts/test-sprint-skill-doc-writer-dispatch.sh,tests/scripts/test-qualify-skill-refs.sh,tests/scripts/test-skill-path-refs.sh,tests/scripts/test-v2-skills-cleanup.sh,tests/scripts/test-skill-script-paths.sh,tests/scripts/test-skill-trace-analyze.sh,tests/scripts/test-create-bug-skill-smoke.sh,tests/scripts/test-skill-trace-analyze-shim.sh,tests/scripts/test-check-skill-refs.sh,tests/skills/test-architect-foundation-skill.sh,tests/skills/test-onboarding-skill.sh,tests/skills/test-quick-ref-skill.sh,tests/skills/test-sprint-skill-step10-no-merge-to-main.sh,tests/skills/test-skill-nesting-depth.sh,tests/skills/test-design-skills-cross-stack.sh,tests/scripts/test-brainstorm-skill-phase1-gate.sh
6565
plugins/dso/skills/brainstorm/SKILL.md:tests/plugin/test-audit-skill-resolution.sh,tests/hooks/test-fix-bug-skill.sh,tests/hooks/test-fix-bug-skill-directive.sh,tests/hooks/test-generate-claude-md-skill.sh,tests/hooks/test-init-skill.sh,tests/scripts/test-sprint-skill-doc-writer-dispatch.sh,tests/scripts/test-qualify-skill-refs.sh,tests/scripts/test-skill-path-refs.sh,tests/scripts/test-v2-skills-cleanup.sh,tests/scripts/test-skill-script-paths.sh,tests/scripts/test-skill-trace-analyze.sh,tests/scripts/test-create-bug-skill-smoke.sh,tests/scripts/test-skill-trace-analyze-shim.sh,tests/scripts/test-check-skill-refs.sh,tests/skills/test-architect-foundation-skill.sh,tests/skills/test-onboarding-skill.sh,tests/skills/test-quick-ref-skill.sh,tests/skills/test-sprint-skill-step10-no-merge-to-main.sh,tests/skills/test-skill-nesting-depth.sh,tests/skills/test-design-skills-cross-stack.sh,tests/hooks/test-scenario-sc-revision.sh,tests/hooks/test-residual-state-e2e.sh

plugins/dso/scripts/worktree-cleanup.sh

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -382,6 +382,13 @@ declare -a WT_UNPUSHED=() # "yes" or "no" (has unpushed commits)
382382
declare -a WT_ACTIONS=() # "remove" or reason to keep
383383
declare -a WT_REMOVABLE=() # "true" or "false"
384384

385+
# Count non-main worktrees for progress reporting. Each worktree block in
386+
# --porcelain output starts with "worktree <path>"; one of those is main.
387+
_wt_scan_total=$(($(git -C "$MAIN_WORKTREE" worktree list --porcelain | grep -c '^worktree ') - 1))
388+
[[ "$_wt_scan_total" -lt 0 ]] && _wt_scan_total=0
389+
_wt_scan_index=0
390+
echo "Scanning $_wt_scan_total worktree(s)..." >&2
391+
385392
# Parse porcelain output
386393
current_path=""
387394
current_branch=""
@@ -401,6 +408,8 @@ while IFS= read -r line; do
401408
fi
402409

403410
local_name=$(basename "$current_path")
411+
_wt_scan_index=$((_wt_scan_index + 1))
412+
printf ' [%d/%d] %s\n' "$_wt_scan_index" "$_wt_scan_total" "$local_name" >&2
404413
WT_NAMES+=("$local_name")
405414
WT_PATHS+=("$current_path")
406415
WT_BRANCHES+=("${current_branch:-detached}")
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
"""Tests that worktree-cleanup.sh emits per-worktree progress lines to stderr
2+
during the gather loop.
3+
4+
Bug 8f24-a2a5: With many worktrees the gather loop ran silently for 40+ seconds,
5+
causing users to perceive a hang and kill the process.
6+
7+
The fix: emit a progress line to stderr for each worktree as it is being scanned,
8+
so the user sees activity and can confirm the process is alive.
9+
10+
TDD: test_gather_loop_emits_worktree_names_to_stderr is the primary RED→GREEN test.
11+
"""
12+
13+
from __future__ import annotations
14+
15+
import os
16+
import subprocess
17+
import tempfile
18+
from pathlib import Path
19+
20+
import pytest
21+
22+
REPO_ROOT = Path(__file__).resolve().parents[2]
23+
SCRIPT = REPO_ROOT / "plugins" / "dso" / "scripts" / "worktree-cleanup.sh"
24+
25+
26+
def _init_git_repo_with_worktrees(base: Path, num_worktrees: int = 3) -> Path:
27+
"""Create a minimal git repo in *base* with *num_worktrees* named worktrees.
28+
29+
Returns the path to the main repo root.
30+
"""
31+
main_repo = base / "main-repo"
32+
main_repo.mkdir(parents=True)
33+
34+
env = {
35+
**os.environ,
36+
"GIT_AUTHOR_NAME": "Test",
37+
"GIT_AUTHOR_EMAIL": "t@t.com",
38+
"GIT_COMMITTER_NAME": "Test",
39+
"GIT_COMMITTER_EMAIL": "t@t.com",
40+
}
41+
42+
def git(*args: str, cwd: Path = main_repo) -> subprocess.CompletedProcess:
43+
return subprocess.run(
44+
["git", *args],
45+
cwd=str(cwd),
46+
capture_output=True,
47+
text=True,
48+
env=env,
49+
check=True,
50+
)
51+
52+
git("init", "-b", "main")
53+
git("config", "user.email", "test@test.com")
54+
git("config", "user.name", "Test")
55+
# Need at least one commit so HEAD is valid
56+
(main_repo / "README.md").write_text("test\n")
57+
git("add", "README.md")
58+
git("commit", "-m", "init")
59+
60+
# Create numbered worktrees with their own branches
61+
worktree_names = []
62+
for i in range(num_worktrees):
63+
branch = f"worktree-scan-test-{i:02d}"
64+
wt_path = base / branch
65+
git("worktree", "add", "-b", branch, str(wt_path))
66+
worktree_names.append(branch)
67+
68+
return main_repo
69+
70+
71+
@pytest.mark.scripts
72+
class TestGatherLoopProgress:
73+
"""worktree-cleanup.sh emits per-worktree progress lines to stderr during gather."""
74+
75+
def test_gather_loop_emits_worktree_names_to_stderr(self) -> None:
76+
"""Given 3 git worktrees, when the cleanup script runs its gather loop,
77+
then each worktree's basename must appear in stderr output.
78+
79+
RED: fails on the current script because the gather loop emits nothing to
80+
stderr — stderr is empty, so the assertion that each worktree name appears
81+
in stderr fails.
82+
83+
GREEN: passes after the fix adds per-worktree progress lines to stderr
84+
(e.g., 'echo "Scanning worktree-scan-test-00..." >&2' inside the loop).
85+
86+
The assertion is deliberately format-agnostic: it checks only that the
87+
worktree *name* appears in stderr, not the specific progress format (\\r,
88+
\\n, "Scanning", percentage, etc.). This means any reasonable progress
89+
implementation satisfies the test.
90+
"""
91+
with tempfile.TemporaryDirectory() as tmpdir:
92+
base = Path(tmpdir)
93+
main_repo = _init_git_repo_with_worktrees(base, num_worktrees=3)
94+
95+
# Create a fake home directory so the log file write doesn't fail
96+
# with a missing-directory error (would pollute stderr with noise).
97+
fake_home = base / "fake-home"
98+
fake_home.mkdir(parents=True, exist_ok=True)
99+
100+
# Run the script with --dry-run --all so it traverses the gather loop
101+
# but does not actually remove anything. We also pass --force to skip
102+
# interactive confirmation prompts that would block a non-TTY subprocess.
103+
result = subprocess.run(
104+
[
105+
"bash",
106+
str(SCRIPT),
107+
"--dry-run",
108+
"--all",
109+
"--force",
110+
"--include-branches",
111+
],
112+
capture_output=True,
113+
text=True,
114+
cwd=str(main_repo),
115+
env={
116+
**os.environ,
117+
"HOME": str(fake_home),
118+
# Prevent accidental Docker side effects
119+
"CONFIG_COMPOSE_DB_FILE": "",
120+
"CONFIG_COMPOSE_PROJECT": "",
121+
# Keep AGE_HOURS small so worktrees are not filtered as "too recent"
122+
"AGE_HOURS": "0",
123+
},
124+
timeout=60,
125+
)
126+
127+
# The script may exit 0 or non-zero depending on whether there are
128+
# removable worktrees; we only care that it ran (no timeout/crash) and
129+
# that stderr captured progress output naming the worktrees.
130+
stderr = result.stderr
131+
132+
# Each of the three worktree basenames must appear in stderr.
133+
# The gather loop sets `local_name=$(basename "$current_path")` and the
134+
# fix must emit that name (or the full path, which also contains it).
135+
for i in range(3):
136+
expected_name = f"worktree-scan-test-{i:02d}"
137+
assert expected_name in stderr, (
138+
f"Expected worktree name '{expected_name}' to appear in stderr "
139+
f"(progress output during the gather loop), but it was absent.\n"
140+
f"Full stderr:\n{stderr}\n"
141+
f"Full stdout:\n{result.stdout}\n"
142+
f"Exit code: {result.returncode}"
143+
)

0 commit comments

Comments
 (0)