diff --git a/agents/conductors/hygiene/hygiene.sh b/agents/conductors/hygiene/hygiene.sh index 10f169c3..3beb769a 100755 --- a/agents/conductors/hygiene/hygiene.sh +++ b/agents/conductors/hygiene/hygiene.sh @@ -186,9 +186,13 @@ prescan_tidy() { dir="$ROOT/$repo" repo_is_checked_out "$repo" || continue scanned=$((scanned + 1)) - local b s g + local b s g cur + # The branch HEAD points at is the one being WORKED ON — never debris. + cur=$(git -C "$dir" symbolic-ref --quiet --short HEAD 2>/dev/null) + local -a skip=(-e main -e master -e HEAD) + [[ -n "$cur" ]] && skip+=(-e "$cur") b=$(git -C "$dir" for-each-ref --format='%(refname:short)' refs/heads 2>/dev/null \ - | grep -vxE 'main|master|HEAD' | wc -l | tr -d ' ') + | grep -vxF "${skip[@]}" | wc -l | tr -d ' ') s=$(git -C "$dir" stash list 2>/dev/null | wc -l | tr -d ' ') # NOT `branch -vv | grep -c '[gone]'` — porcelain prints the upstream as # `[origin/: gone]`, never the bare `[gone]`, so that grep counts 0 @@ -510,15 +514,21 @@ fi # status vs the default branch) and stashes across the managed checkouts. Echoes # TSV rows: "\t\t\t". enumerate_condemn_candidates() { - local repo dir def br + local repo dir def br cur for repo in "${CODE_REPOS[@]}"; do dir="$ROOT/$repo" repo_is_checked_out "$repo" || continue def=$(git -C "$dir" symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null | sed 's@^origin/@@') [[ -n "$def" ]] || def=main + # The branch HEAD points at is the one being WORKED ON — never condemn it. + # A freshly cut branch is an ancestor of origin/ until its first + # commit, so without this it always reads merged=yes and is recommended for + # straight delete: every agent session would be told to bin its own branch. + cur=$(git -C "$dir" symbolic-ref --quiet --short HEAD 2>/dev/null) while IFS= read -r br; do [[ -z "$br" ]] && continue case "$br" in main|master|HEAD|"$def") continue ;; esac + [[ -n "$cur" && "$br" == "$cur" ]] && continue local merged=no if git -C "$dir" merge-base --is-ancestor "$br" "origin/$def" 2>/dev/null \ || git -C "$dir" merge-base --is-ancestor "$br" "$def" 2>/dev/null; then diff --git a/tests/test_hygiene_conductor.py b/tests/test_hygiene_conductor.py index 9a088787..4e92bd6b 100644 --- a/tests/test_hygiene_conductor.py +++ b/tests/test_hygiene_conductor.py @@ -982,6 +982,70 @@ def test_refs_findings_reach_the_default_worklist(tmp_path): assert rows["refs"]["count"] == len(_refs_row(tmp_path)["findings"]) > 0 +def _init_git_repo_with_branch(path, branch, default="main"): + """A checkout whose HEAD is `branch`, cut from an unpushed `default`. + + Mirrors the state every agent session starts in: the working branch exists + locally, shares its tip with the default branch, and has no commits of its + own yet. + """ + _init_git_repo(path) + run = lambda *a: subprocess.run( # noqa: E731 + ["git", "-C", str(path), *a], check=True, capture_output=True + ) + run("config", "user.email", "t@example.com") + run("config", "user.name", "t") + run("checkout", "-q", "-b", default) + (path / "f.txt").write_text("x\n") + run("add", "f.txt") + run("commit", "-q", "-m", "base") + run("checkout", "-q", "-b", branch) + + +def test_tidy_never_condemns_the_checked_out_branch(tmp_path): + # Regression: a freshly cut agent branch is an ancestor of the default + # branch until its first commit, so it reads merged=yes. Without a HEAD + # guard the conductor recommends "straight delete" of the branch the + # session is actively working on. + _init_git_repo_with_branch( + tmp_path / "PyAutoBrain", "claude/some-agent-run-abc123" + ) + + result = _run(["tidy", "--json"], tmp_path) + + assert result.returncode == 0, result.stderr + doc = json.loads(result.stdout) + assert [c["locator"] for c in doc["candidates"]] == [] + + +def test_tidy_prescan_does_not_count_the_checked_out_branch(tmp_path): + # The same guard on the counting leg, so the ranked worklist and the + # condemn plan cannot disagree about what is debris. + _init_git_repo_with_branch( + tmp_path / "PyAutoBrain", "claude/some-agent-run-abc123" + ) + + result = _run(["--json"], tmp_path) + rows = {row["mode"]: row for row in json.loads(result.stdout)["rows"]} + assert rows["tidy"]["count"] == 0 + assert "0 stale branches" in rows["tidy"]["summary"] + + +def test_tidy_still_condemns_a_branch_that_is_not_checked_out(tmp_path): + # The guard is narrow: only HEAD is spared, not every merged branch. + repo = tmp_path / "PyAutoBrain" + _init_git_repo_with_branch(repo, "claude/some-agent-run-abc123") + subprocess.run( + ["git", "-C", str(repo), "branch", "feature/spent"], + check=True, + capture_output=True, + ) + + doc = json.loads(_run(["tidy", "--json"], tmp_path).stdout) + + assert [c["locator"] for c in doc["candidates"]] == ["feature/spent"] + + # --- Body-map-derived coverage ------------------------------------------------- # # The conductor scans repositories, so WHICH repositories must come from the body