Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions agents/conductors/hygiene/hygiene.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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/<branch>: gone]`, never the bare `[gone]`, so that grep counts 0
Expand Down Expand Up @@ -510,15 +514,21 @@ fi
# status vs the default branch) and stashes across the managed checkouts. Echoes
# TSV rows: "<repo>\t<type>\t<locator>\t<merged>".
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/<def> 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
Expand Down
64 changes: 64 additions & 0 deletions tests/test_hygiene_conductor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading