diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index de332f36ee44e..45942edc83a2c 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -5655,6 +5655,37 @@ def _git_branch_exists(repo_root: Path, branch_name: str) -> bool: return result.returncode == 0 +def _git_default_base(repo_root: Path) -> str: + """Resolve the commit a new worktree branch should be cut from. + + Prefers the remote default branch (``origin/HEAD``, e.g. ``origin/main``) + so worker branches are based on the integration branch rather than + whatever the primary checkout happens to be parked on. If the operator or + another session leaves the repo on an unmerged feature branch, cutting new + worktree branches from that parked ``HEAD`` would contaminate every worker + branch with unrelated commits (see issue #68201). + + Falls back to ``HEAD`` when no remote default is configured (e.g. a fresh + clone with no upstream, or an offline box), preserving the previous + behavior. + """ + try: + result = subprocess.run( + ["git", "-C", str(repo_root), "rev-parse", "--abbrev-ref", "origin/HEAD"], + capture_output=True, + text=True, + timeout=30, + check=False, + ) + except Exception: + return "HEAD" + if result.returncode == 0: + ref = (result.stdout or "").strip() + if ref and ref != "HEAD": + return ref + return "HEAD" + + def _git_common_dir(path: Path) -> Optional[Path]: try: result = subprocess.run( @@ -5748,9 +5779,13 @@ def _ensure_git_worktree(repo_root: Path, target: Path, branch_name: str) -> Non if _git_branch_exists(repo_root, branch_name): cmd = ["git", "-C", str(repo_root), "worktree", "add", str(target), branch_name] else: + # Cut the new branch from the remote default (origin/HEAD) when + # available, falling back to HEAD. Basing on a parked local HEAD can + # contaminate the worker branch with unrelated commits (#68201). + base = _git_default_base(repo_root) cmd = [ "git", "-C", str(repo_root), "worktree", "add", "-b", branch_name, - str(target), "HEAD", + str(target), base, ] result = subprocess.run( cmd, diff --git a/tests/hermes_cli/test_kanban_db.py b/tests/hermes_cli/test_kanban_db.py index 25ed7223129f5..5cd25c1547db9 100644 --- a/tests/hermes_cli/test_kanban_db.py +++ b/tests/hermes_cli/test_kanban_db.py @@ -4959,3 +4959,75 @@ def test_bare_connect_does_not_close_on_context_exit(tmp_path): # Still usable after with-block exit (the leak). conn.execute("SELECT 1").fetchone() conn.close() # explicit close to avoid leaking THIS test + + +# --------------------------------------------------------------------------- +# Worktree base resolution (issue #68201) +# --------------------------------------------------------------------------- + +def _add_remote_default(repo: Path, branch: str = "main") -> None: + """Point origin/HEAD at the given branch so _git_default_base can resolve it.""" + subprocess.run( + ["git", "-C", str(repo), "symbolic-ref", "refs/remotes/origin/HEAD", + f"refs/remotes/origin/{branch}"], + check=True, capture_output=True, text=True, + ) + + +def test_git_default_base_prefers_origin_head(tmp_path): + """_git_default_base resolves origin/HEAD when the remote default exists.""" + repo = tmp_path / "repo" + _init_git_repo(repo) + subprocess.run(["git", "-C", str(repo), "update-ref", "refs/remotes/origin/main", "HEAD"], + check=True, capture_output=True, text=True) + _add_remote_default(repo, "main") + assert kb._git_default_base(repo) == "origin/main" + + +def test_git_default_base_falls_back_to_head_without_remote(tmp_path): + """With no origin/HEAD configured, _git_default_base falls back to HEAD.""" + repo = tmp_path / "repo" + _init_git_repo(repo) + assert kb._git_default_base(repo) == "HEAD" + + +def test_ensure_git_worktree_bases_new_branch_on_origin_head(tmp_path): + """A new worktree branch is cut from origin/HEAD, not a parked local HEAD (#68201). + + Regression for #68201: if the primary checkout is left parked on an + unmerged feature branch, every worker worktree created during that window + inherited that branch's commits. We now base new branches on the remote + default branch when it resolves. + """ + repo = tmp_path / "repo" + _init_git_repo(repo) # default branch 'main', one commit on README.md + # Simulate a "parked" primary checkout: a feature branch with extra files. + subprocess.run(["git", "-C", str(repo), "checkout", "-b", "feature/parked"], + check=True, capture_output=True, text=True) + (repo / "feature_only.txt").write_text("should not leak\n", encoding="utf-8") + subprocess.run(["git", "-C", str(repo), "add", "feature_only.txt"], + check=True, capture_output=True, text=True) + subprocess.run(["git", "-C", str(repo), "commit", "-m", "feature work"], + check=True, capture_output=True, text=True) + # Leave the primary checkout parked on the feature branch. + subprocess.run(["git", "-C", str(repo), "update-ref", "refs/remotes/origin/main", "main"], + check=True, capture_output=True, text=True) + _add_remote_default(repo, "main") + + target = tmp_path / "worktrees" / "wt1" + kb._ensure_git_worktree(repo, target, "wt/issue-68201") + + # The new worktree branch must NOT contain the parked feature's file. + listing = subprocess.run( + ["git", "-C", str(target), "ls-files"], + check=True, capture_output=True, text=True, + ).stdout + assert "feature_only.txt" not in listing + assert "README.md" in listing + # And it must actually be based on origin/main (the parked commit is absent). + contained = subprocess.run( + ["git", "-C", str(repo), "merge-base", "--is-ancestor", "feature/parked", + "wt/issue-68201"], + capture_output=True, text=True, + ) + assert contained.returncode != 0, "worker branch should not descend from parked feature branch"