diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index c82d762d592..e074bde32cf 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -4702,6 +4702,196 @@ def delete_task(conn: sqlite3.Connection, task_id: str) -> bool: # Workspace resolution # --------------------------------------------------------------------------- +def _git_toplevel(path: Path) -> Optional[Path]: + """Return the git toplevel containing ``path``, or ``None`` if not in a repo.""" + try: + result = subprocess.run( + ["git", "-C", str(path), "rev-parse", "--show-toplevel"], + capture_output=True, + text=True, + timeout=30, + check=False, + ) + except Exception: + return None + if result.returncode != 0: + return None + out = (result.stdout or "").strip() + if not out: + return None + try: + return Path(out).expanduser().resolve() + except Exception: + return Path(out).expanduser() + + +def _git_branch_exists(repo_root: Path, branch_name: str) -> bool: + try: + result = subprocess.run( + ["git", "-C", str(repo_root), "show-ref", "--verify", f"refs/heads/{branch_name}"], + capture_output=True, + text=True, + timeout=30, + check=False, + ) + except Exception: + return False + return result.returncode == 0 + + +def _git_common_dir(path: Path) -> Optional[Path]: + try: + result = subprocess.run( + ["git", "-C", str(path), "rev-parse", "--path-format=absolute", "--git-common-dir"], + capture_output=True, + text=True, + timeout=30, + check=False, + ) + except Exception: + return None + if result.returncode != 0: + return None + out = (result.stdout or "").strip() + if not out: + return None + return Path(out).expanduser().resolve(strict=False) + + +def _git_dir(path: Path) -> Optional[Path]: + try: + result = subprocess.run( + ["git", "-C", str(path), "rev-parse", "--path-format=absolute", "--git-dir"], + capture_output=True, + text=True, + timeout=30, + check=False, + ) + except Exception: + return None + if result.returncode != 0: + return None + out = (result.stdout or "").strip() + if not out: + return None + return Path(out).expanduser().resolve(strict=False) + + +def _git_current_branch(path: Path) -> Optional[str]: + try: + result = subprocess.run( + ["git", "-C", str(path), "branch", "--show-current"], + capture_output=True, + text=True, + timeout=30, + check=False, + ) + except Exception: + return None + if result.returncode != 0: + return None + branch = (result.stdout or "").strip() + return branch or None + + +def _is_linked_worktree_checkout(path: Path) -> bool: + git_dir = _git_dir(path) + common_dir = _git_common_dir(path) + if git_dir is None or common_dir is None: + return False + return git_dir != common_dir + + +def _nearest_existing_path(path: Path) -> Path: + current = path + while not current.exists() and current != current.parent: + current = current.parent + return current + + +def _repo_root_for_worktree_target(path: Path) -> Optional[Path]: + current = _nearest_existing_path(path).resolve(strict=False) + while True: + repo_root = _git_toplevel(current) + if repo_root is not None: + return repo_root + if current == current.parent: + return None + current = current.parent + + +def _ensure_git_worktree(repo_root: Path, target: Path, branch_name: str) -> None: + """Materialize ``target`` as a linked git worktree under ``repo_root``.""" + target = target.expanduser() + repo_common = _git_common_dir(repo_root) + if target.exists() and repo_common is not None: + target_common = _git_common_dir(target) + if target_common == repo_common: + return + target.parent.mkdir(parents=True, exist_ok=True) + if _git_branch_exists(repo_root, branch_name): + cmd = ["git", "-C", str(repo_root), "worktree", "add", str(target), branch_name] + else: + cmd = [ + "git", "-C", str(repo_root), "worktree", "add", "-b", branch_name, + str(target), "HEAD", + ] + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=60, + check=False, + ) + if result.returncode != 0: + stderr = (result.stderr or result.stdout or "").strip() + raise RuntimeError( + f"git worktree add failed for {target} on branch {branch_name}: {stderr}" + ) + + +def _resolve_worktree_workspace(task: Task) -> tuple[Path, str]: + """Resolve + materialize a linked git worktree for ``task``.""" + branch_name = (task.branch_name or "").strip() or f"wt/{task.id}" + if not task.workspace_path: + repo_root = _git_toplevel(Path.cwd()) + if repo_root is None: + raise ValueError( + f"task {task.id} has workspace_kind=worktree but no workspace_path, " + "and the dispatcher's current working directory is not inside a git repo" + ) + target = repo_root / ".worktrees" / task.id + _ensure_git_worktree(repo_root, target, branch_name) + return target, branch_name + + requested = Path(task.workspace_path).expanduser() + if not requested.is_absolute(): + raise ValueError( + f"task {task.id} has non-absolute worktree path " + f"{task.workspace_path!r}; use an absolute path" + ) + requested_resolved = requested.resolve(strict=False) + + if requested.exists() and _is_linked_worktree_checkout(requested): + actual_branch = _git_current_branch(requested) + return requested_resolved, actual_branch or branch_name + + repo_root = _git_toplevel(requested) + if repo_root is not None and requested_resolved == repo_root: + target = repo_root / ".worktrees" / task.id + _ensure_git_worktree(repo_root, target, branch_name) + return target, branch_name + + repo_root = _repo_root_for_worktree_target(requested.parent) + if repo_root is None: + raise ValueError( + f"task {task.id} worktree path {task.workspace_path!r} is not inside a git repo " + "and does not point at a git repo root" + ) + _ensure_git_worktree(repo_root, requested, branch_name) + return requested, branch_name + + def resolve_workspace(task: Task, *, board: Optional[str] = None) -> Path: """Resolve (and create if needed) the workspace for a task. @@ -4715,9 +4905,11 @@ def resolve_workspace(task: Task, *, board: Optional[str] = None) -> Path: resolves against the dispatcher's CWD instead of a meaningful root. Users who want a kanban-root-relative workspace should compute the absolute path themselves. - - ``worktree``: a git worktree at ``workspace_path``. Not created - automatically in v1 -- the kanban-worker skill documents - ``git worktree add`` as a worker-side step. Returns the intended path. + - ``worktree``: a real linked git worktree. If ``workspace_path`` names + a repo root, Hermes treats it as an anchor and materializes a linked + worktree at ``/.worktrees/``. If ``workspace_path`` names + a concrete target path, Hermes creates/reuses that linked worktree. When + ``branch_name`` is empty, Hermes uses ``wt/``. Persist the resolved path back to the task row via ``set_workspace_path`` so subsequent runs reuse the same directory. @@ -4753,15 +4945,7 @@ def resolve_workspace(task: Task, *, board: Optional[str] = None) -> Path: p.mkdir(parents=True, exist_ok=True) return p if kind == "worktree": - if not task.workspace_path: - # Default: .worktrees// under CWD. Worker skill creates it. - return Path.cwd() / ".worktrees" / task.id - p = Path(task.workspace_path).expanduser() - if not p.is_absolute(): - raise ValueError( - f"task {task.id} has non-absolute worktree path " - f"{task.workspace_path!r}; use an absolute path" - ) + p, _branch_name = _resolve_worktree_workspace(task) return p raise ValueError(f"unknown workspace_kind: {kind}") @@ -4776,6 +4960,16 @@ def set_workspace_path( ) +def set_branch_name( + conn: sqlite3.Connection, task_id: str, branch_name: str +) -> None: + with write_txn(conn): + conn.execute( + "UPDATE tasks SET branch_name = ? WHERE id = ?", + (str(branch_name), task_id), + ) + + # --------------------------------------------------------------------------- def schedule_task( conn: sqlite3.Connection, @@ -6373,7 +6567,11 @@ def dispatch_once( if claimed is None: continue try: - workspace = resolve_workspace(claimed, board=board) + resolved_branch_name = None + if claimed.workspace_kind == "worktree": + workspace, resolved_branch_name = _resolve_worktree_workspace(claimed) + else: + workspace = resolve_workspace(claimed, board=board) except Exception as exc: auto = _record_spawn_failure( conn, claimed.id, f"workspace: {exc}", @@ -6384,6 +6582,8 @@ def dispatch_once( continue # Persist the resolved workspace path so the worker can cd there. set_workspace_path(conn, claimed.id, str(workspace)) + if claimed.workspace_kind == "worktree": + set_branch_name(conn, claimed.id, resolved_branch_name or (claimed.branch_name or "").strip() or f"wt/{claimed.id}") _maybe_emit_scratch_tip(conn, claimed.id, claimed.workspace_kind) _spawn = spawn_fn if spawn_fn is not None else _default_spawn try: @@ -6459,7 +6659,11 @@ def dispatch_once( if claimed is None: continue try: - workspace = resolve_workspace(claimed, board=board) + resolved_branch_name = None + if claimed.workspace_kind == "worktree": + workspace, resolved_branch_name = _resolve_worktree_workspace(claimed) + else: + workspace = resolve_workspace(claimed, board=board) except Exception as exc: auto = _record_spawn_failure( conn, claimed.id, f"workspace: {exc}", @@ -6470,6 +6674,8 @@ def dispatch_once( continue # Persist the resolved workspace path so the worker can cd there. set_workspace_path(conn, claimed.id, str(workspace)) + if claimed.workspace_kind == "worktree": + set_branch_name(conn, claimed.id, resolved_branch_name or (claimed.branch_name or "").strip() or f"wt/{claimed.id}") _maybe_emit_scratch_tip(conn, claimed.id, claimed.workspace_kind) # Force-load sdlc-review skill for review agents. The # _default_spawn function already auto-loads kanban-worker, and diff --git a/tests/hermes_cli/test_kanban_db.py b/tests/hermes_cli/test_kanban_db.py index 1386b1ebdc4..6328365122e 100644 --- a/tests/hermes_cli/test_kanban_db.py +++ b/tests/hermes_cli/test_kanban_db.py @@ -5,6 +5,7 @@ from __future__ import annotations import concurrent.futures import os import sqlite3 +import subprocess import sys import time import types @@ -27,6 +28,16 @@ def kanban_home(tmp_path, monkeypatch): return home +def _init_git_repo(repo: Path) -> None: + repo.mkdir(parents=True, exist_ok=True) + subprocess.run(["git", "init", "-b", "main", str(repo)], check=True, capture_output=True, text=True) + subprocess.run(["git", "-C", str(repo), "config", "user.email", "kanban@example.com"], check=True, capture_output=True, text=True) + subprocess.run(["git", "-C", str(repo), "config", "user.name", "Kanban Test"], check=True, capture_output=True, text=True) + (repo / "README.md").write_text("hello\n", encoding="utf-8") + subprocess.run(["git", "-C", str(repo), "add", "README.md"], check=True, capture_output=True, text=True) + subprocess.run(["git", "-C", str(repo), "commit", "-m", "init"], check=True, capture_output=True, text=True) + + # --------------------------------------------------------------------------- # Schema / init # --------------------------------------------------------------------------- @@ -2064,6 +2075,7 @@ def test_scratch_workspace_created_under_hermes_home(kanban_home): with kb.connect() as conn: t = kb.create_task(conn, title="x") task = kb.get_task(conn, t) + assert task is not None ws = kb.resolve_workspace(task) assert ws.exists() assert ws.is_dir() @@ -2077,21 +2089,188 @@ def test_dir_workspace_honors_given_path(kanban_home, tmp_path): conn, title="biz", workspace_kind="dir", workspace_path=str(target) ) task = kb.get_task(conn, t) + assert task is not None ws = kb.resolve_workspace(task) assert ws == target assert ws.exists() -def test_worktree_workspace_returns_intended_path(kanban_home, tmp_path): - target = str(tmp_path / ".worktrees" / "my-task") +def test_worktree_workspace_repo_root_anchor_materializes_linked_worktree(kanban_home, tmp_path): + repo = tmp_path / "repo" + _init_git_repo(repo) with kb.connect() as conn: t = kb.create_task( - conn, title="ship", workspace_kind="worktree", workspace_path=target + conn, title="ship", workspace_kind="worktree", workspace_path=str(repo) ) task = kb.get_task(conn, t) + assert task is not None ws = kb.resolve_workspace(task) - # We do NOT auto-create worktrees; the worker's skill handles that. - assert str(ws) == target + + expected = repo / ".worktrees" / t + assert ws == expected + assert ws.exists() + repo_common = subprocess.run( + ["git", "-C", str(repo), "rev-parse", "--path-format=absolute", "--git-common-dir"], + check=True, + capture_output=True, + text=True, + ).stdout.strip() + ws_common = subprocess.run( + ["git", "-C", str(ws), "rev-parse", "--path-format=absolute", "--git-common-dir"], + check=True, + capture_output=True, + text=True, + ).stdout.strip() + assert ws_common == repo_common + listed = subprocess.run( + ["git", "-C", str(repo), "worktree", "list", "--porcelain"], + check=True, + capture_output=True, + text=True, + ).stdout + assert f"worktree {expected}" in listed + assert f"branch refs/heads/wt/{t}" in listed + + +def test_worktree_workspace_explicit_target_materializes_linked_worktree(kanban_home, tmp_path): + repo = tmp_path / "repo" + _init_git_repo(repo) + target = repo / ".worktrees" / "custom-task" + branch = "wt/custom-task" + with kb.connect() as conn: + t = kb.create_task( + conn, + title="ship", + workspace_kind="worktree", + workspace_path=str(target), + branch_name=branch, + ) + task = kb.get_task(conn, t) + assert task is not None + ws = kb.resolve_workspace(task) + + assert ws == target + assert ws.exists() + repo_common = subprocess.run( + ["git", "-C", str(repo), "rev-parse", "--path-format=absolute", "--git-common-dir"], + check=True, + capture_output=True, + text=True, + ).stdout.strip() + ws_common = subprocess.run( + ["git", "-C", str(ws), "rev-parse", "--path-format=absolute", "--git-common-dir"], + check=True, + capture_output=True, + text=True, + ).stdout.strip() + assert ws_common == repo_common + listed = subprocess.run( + ["git", "-C", str(repo), "worktree", "list", "--porcelain"], + check=True, + capture_output=True, + text=True, + ).stdout + assert f"worktree {target}" in listed + assert f"branch refs/heads/{branch}" in listed + + +def test_dispatch_worktree_task_persists_materialized_workspace_and_branch(kanban_home, tmp_path, monkeypatch): + repo = tmp_path / "repo" + _init_git_repo(repo) + kb.create_board("worktree-board", default_workdir=str(repo)) + import hermes_cli.profiles as profiles + monkeypatch.setattr(profiles, "profile_exists", lambda _name: True) + spawns: list[tuple[str, str]] = [] + + def fake_spawn(task, workspace, board=None): + spawns.append((task.id, workspace)) + return None + + with kb.connect(board="worktree-board") as conn: + tid = kb.create_task( + conn, + title="ship", + assignee="sentinel", + workspace_kind="worktree", + board="worktree-board", + ) + result = kb.dispatch_once(conn, spawn_fn=fake_spawn, board="worktree-board") + task = kb.get_task(conn, tid) + + expected = repo / ".worktrees" / tid + assert result.spawned == [(tid, "sentinel", str(expected))] + assert spawns == [(tid, str(expected))] + assert task is not None + assert task.workspace_path == str(expected) + assert task.branch_name == f"wt/{tid}" + listed = subprocess.run( + ["git", "-C", str(repo), "worktree", "list", "--porcelain"], + check=True, + capture_output=True, + text=True, + ).stdout + assert f"worktree {expected}" in listed + assert f"branch refs/heads/wt/{tid}" in listed + + +def test_dispatch_worktree_task_rerun_reuses_existing_linked_worktree_and_branch(kanban_home, tmp_path, monkeypatch): + repo = tmp_path / "repo" + _init_git_repo(repo) + kb.create_board("worktree-rerun-board", default_workdir=str(repo)) + import hermes_cli.profiles as profiles + monkeypatch.setattr(profiles, "profile_exists", lambda _name: True) + spawns: list[tuple[str, str]] = [] + + def fake_spawn(task, workspace, board=None): + spawns.append((task.id, workspace)) + return None + + with kb.connect(board="worktree-rerun-board") as conn: + tid = kb.create_task( + conn, + title="ship", + assignee="sentinel", + workspace_kind="worktree", + board="worktree-rerun-board", + ) + first = kb.dispatch_once(conn, spawn_fn=fake_spawn, board="worktree-rerun-board") + first_task = kb.get_task(conn, tid) + assert first_task is not None + expected = repo / ".worktrees" / tid + assert first_task.workspace_path == str(expected) + assert first_task.branch_name == f"wt/{tid}" + + conn.execute( + "UPDATE tasks SET status='ready', claim_lock=NULL, claim_expires=NULL, worker_pid=NULL WHERE id=?", + (tid,), + ) + conn.commit() + + second = kb.dispatch_once(conn, spawn_fn=fake_spawn, board="worktree-rerun-board") + second_task = kb.get_task(conn, tid) + + assert first.spawned == [(tid, "sentinel", str(expected))] + assert second.spawned == [(tid, "sentinel", str(expected))] + assert spawns == [(tid, str(expected)), (tid, str(expected))] + assert second_task is not None + assert second_task.workspace_path == str(expected) + actual_branch = subprocess.run( + ["git", "-C", str(expected), "branch", "--show-current"], + check=True, + capture_output=True, + text=True, + ).stdout.strip() + assert actual_branch == f"wt/{tid}" + assert second_task.branch_name == actual_branch + listed = subprocess.run( + ["git", "-C", str(repo), "worktree", "list", "--porcelain"], + check=True, + capture_output=True, + text=True, + ).stdout + assert listed.count(f"worktree {expected}\n") == 1 + assert f"worktree {expected}/.worktrees/{tid}" not in listed + assert f"branch refs/heads/{actual_branch}" in listed # --------------------------------------------------------------------------- @@ -2103,6 +2282,7 @@ def test_cleanup_workspace_removes_managed_scratch_dir(kanban_home): with kb.connect() as conn: t = kb.create_task(conn, title="scratchy") task = kb.get_task(conn, t) + assert task is not None ws = kb.resolve_workspace(task) kb.set_workspace_path(conn, t, ws) assert ws.is_dir()