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