diff --git a/backend/apps/agents/agent_manager.py b/backend/apps/agents/agent_manager.py index 1f8c1d2d..fbeaefd5 100644 --- a/backend/apps/agents/agent_manager.py +++ b/backend/apps/agents/agent_manager.py @@ -42,6 +42,7 @@ from backend.apps.agents.session_store import ( build_search_text, ) from backend.apps.agents.cloud_sync import _sync_session_close +from backend.apps.agents.workspace_git import _detect_git_identity, _ensure_cwd_git_repo logger = logging.getLogger(__name__) @@ -153,129 +154,6 @@ def get_all_tool_names() -> list[str]: return builtin_tools + mcp_names -def _ensure_cwd_git_repo(cwd: str, home: str | None = None) -> None: - """Idempotently make `cwd` into a git repo with a valid HEAD. - - The CLI's built-in Agent tool uses `isolation: "worktree"` to spawn - subagents, which runs `git rev-parse HEAD` + `git worktree add`. If - cwd isn't a git repo, or is a repo with no commits yet, that fails - with "worktree/base-branch metadata is broken for isolation" or - "repo doesn't have a valid HEAD yet". We silently init a minimal - repo with one empty commit so worktree add always has something to - anchor on. - - Safe to call on every request, does nothing if cwd is already a - valid repo (real project, previous init, or inside a parent repo). - """ - try: - home = home or os.path.expanduser("~") - cwd_abs = os.path.abspath(cwd) - risky_roots = { - os.path.abspath(home), - "/", - os.path.abspath(os.path.dirname(home)), # e.g. /Users - } - if cwd_abs in risky_roots: - return - if not os.path.isdir(cwd): - return - - import subprocess as _sp_git - # Case A: cwd is inside some git repo (possibly parent). Verify - # HEAD resolves. If the enclosing repo is broken (e.g. a stray - # `.git` in $HOME with no commits, which makes workspaces - # under ~/.openswarm/workspaces/ inherit a broken HEAD), we - # need to init a fresh repo AT cwd so it shadows the parent. - _inside = _sp_git.run( - ["git", "rev-parse", "--is-inside-work-tree"], - cwd=cwd, - stdout=_sp_git.PIPE, stderr=_sp_git.DEVNULL, timeout=5, - ) - if _inside.returncode == 0 and b"true" in _inside.stdout: - # Check HEAD resolves (has at least one commit). - _head = _sp_git.run( - ["git", "rev-parse", "--verify", "HEAD"], - cwd=cwd, - stdout=_sp_git.DEVNULL, stderr=_sp_git.DEVNULL, timeout=5, - ) - if _head.returncode == 0: - return # parent repo is healthy, leave it alone - # Parent repo exists but HEAD is broken. - if os.path.isdir(os.path.join(cwd, ".git")): - # .git is directly here, commit to fix it. - _sp_git.run( - ["git", "-c", "user.email=openswarm@local", - "-c", "user.name=OpenSwarm", - "commit", "--allow-empty", "-q", "-m", "openswarm init"], - cwd=cwd, - stdout=_sp_git.DEVNULL, stderr=_sp_git.DEVNULL, timeout=10, - ) - return - # .git is in a parent dir (broken home-dir repo, etc.). - # Init our own repo at cwd so it shadows the broken parent. - # Fall through to Case B. - - # Case B: cwd is not a git repo at all (or parent is broken): - # init + empty commit here. - _sp_git.run( - ["git", "init", "-q", "-b", "main"], - cwd=cwd, - stdout=_sp_git.DEVNULL, stderr=_sp_git.DEVNULL, timeout=10, - ) - _sp_git.run( - ["git", "-c", "user.email=openswarm@local", - "-c", "user.name=OpenSwarm", - "commit", "--allow-empty", "-q", "-m", "openswarm init"], - cwd=cwd, - stdout=_sp_git.DEVNULL, stderr=_sp_git.DEVNULL, timeout=10, - ) - except Exception as _e: - logger.info(f"[agent-cwd] git init skipped: {_e}") - - -def _detect_git_identity(cwd: str) -> tuple[str | None, str | None]: - """Resolve the origin remote and current branch for `cwd`. - - Used to label sessions in the session list ("Agent on owner/repo - @ branch") and to keep a resumed session pinned to the same project - even after the user `cd`'s elsewhere. Returns (None, None) for - non-git cwds, detached HEADs, repos without an origin, or any - subprocess failure. Credentials in the URL are stripped so a - `https://user:token@host/...` remote becomes `https://host/...`. - """ - if not cwd or not os.path.isdir(cwd): - return (None, None) - try: - import subprocess as _sp - url_proc = _sp.run( - ["git", "remote", "get-url", "origin"], - cwd=cwd, stdout=_sp.PIPE, stderr=_sp.DEVNULL, timeout=3, - ) - repo_url: str | None = None - if url_proc.returncode == 0: - raw = url_proc.stdout.decode("utf-8", errors="replace").strip() - if raw: - if "://" in raw: - scheme, _, rest = raw.partition("://") - if "@" in rest: - rest = rest.split("@", 1)[1] - repo_url = f"{scheme}://{rest}" - else: - repo_url = raw - branch_proc = _sp.run( - ["git", "branch", "--show-current"], - cwd=cwd, stdout=_sp.PIPE, stderr=_sp.DEVNULL, timeout=3, - ) - branch_name: str | None = None - if branch_proc.returncode == 0: - raw_b = branch_proc.stdout.decode("utf-8", errors="replace").strip() - if raw_b: - branch_name = raw_b - return (repo_url, branch_name) - except Exception: - return (None, None) - - class AgentManager: def __init__(self): self.sessions: dict[str, AgentSession] = {} diff --git a/backend/apps/agents/workspace_git.py b/backend/apps/agents/workspace_git.py new file mode 100644 index 00000000..c9dd68cb --- /dev/null +++ b/backend/apps/agents/workspace_git.py @@ -0,0 +1,127 @@ +import logging +import os + +logger = logging.getLogger(__name__) + + +def _ensure_cwd_git_repo(cwd: str, home: str | None = None) -> None: + """Idempotently make `cwd` into a git repo with a valid HEAD. + + The CLI's built-in Agent tool uses `isolation: "worktree"` to spawn + subagents, which runs `git rev-parse HEAD` + `git worktree add`. If + cwd isn't a git repo, or is a repo with no commits yet, that fails + with "worktree/base-branch metadata is broken for isolation" or + "repo doesn't have a valid HEAD yet". We silently init a minimal + repo with one empty commit so worktree add always has something to + anchor on. + + Safe to call on every request, does nothing if cwd is already a + valid repo (real project, previous init, or inside a parent repo). + """ + try: + home = home or os.path.expanduser("~") + cwd_abs = os.path.abspath(cwd) + risky_roots = { + os.path.abspath(home), + "/", + os.path.abspath(os.path.dirname(home)), # e.g. /Users + } + if cwd_abs in risky_roots: + return + if not os.path.isdir(cwd): + return + + import subprocess as _sp_git + # Case A: cwd is inside some git repo (possibly parent). Verify + # HEAD resolves. If the enclosing repo is broken (e.g. a stray + # `.git` in $HOME with no commits, which makes workspaces + # under ~/.openswarm/workspaces/ inherit a broken HEAD), we + # need to init a fresh repo AT cwd so it shadows the parent. + _inside = _sp_git.run( + ["git", "rev-parse", "--is-inside-work-tree"], + cwd=cwd, + stdout=_sp_git.PIPE, stderr=_sp_git.DEVNULL, timeout=5, + ) + if _inside.returncode == 0 and b"true" in _inside.stdout: + # Check HEAD resolves (has at least one commit). + _head = _sp_git.run( + ["git", "rev-parse", "--verify", "HEAD"], + cwd=cwd, + stdout=_sp_git.DEVNULL, stderr=_sp_git.DEVNULL, timeout=5, + ) + if _head.returncode == 0: + return # parent repo is healthy, leave it alone + # Parent repo exists but HEAD is broken. + if os.path.isdir(os.path.join(cwd, ".git")): + # .git is directly here, commit to fix it. + _sp_git.run( + ["git", "-c", "user.email=openswarm@local", + "-c", "user.name=OpenSwarm", + "commit", "--allow-empty", "-q", "-m", "openswarm init"], + cwd=cwd, + stdout=_sp_git.DEVNULL, stderr=_sp_git.DEVNULL, timeout=10, + ) + return + # .git is in a parent dir (broken home-dir repo, etc.). + # Init our own repo at cwd so it shadows the broken parent. + # Fall through to Case B. + + # Case B: cwd is not a git repo at all (or parent is broken): + # init + empty commit here. + _sp_git.run( + ["git", "init", "-q", "-b", "main"], + cwd=cwd, + stdout=_sp_git.DEVNULL, stderr=_sp_git.DEVNULL, timeout=10, + ) + _sp_git.run( + ["git", "-c", "user.email=openswarm@local", + "-c", "user.name=OpenSwarm", + "commit", "--allow-empty", "-q", "-m", "openswarm init"], + cwd=cwd, + stdout=_sp_git.DEVNULL, stderr=_sp_git.DEVNULL, timeout=10, + ) + except Exception as _e: + logger.info(f"[agent-cwd] git init skipped: {_e}") + + +def _detect_git_identity(cwd: str) -> tuple[str | None, str | None]: + """Resolve the origin remote and current branch for `cwd`. + + Used to label sessions in the session list ("Agent on owner/repo + @ branch") and to keep a resumed session pinned to the same project + even after the user `cd`'s elsewhere. Returns (None, None) for + non-git cwds, detached HEADs, repos without an origin, or any + subprocess failure. Credentials in the URL are stripped so a + `https://user:token@host/...` remote becomes `https://host/...`. + """ + if not cwd or not os.path.isdir(cwd): + return (None, None) + try: + import subprocess as _sp + url_proc = _sp.run( + ["git", "remote", "get-url", "origin"], + cwd=cwd, stdout=_sp.PIPE, stderr=_sp.DEVNULL, timeout=3, + ) + repo_url: str | None = None + if url_proc.returncode == 0: + raw = url_proc.stdout.decode("utf-8", errors="replace").strip() + if raw: + if "://" in raw: + scheme, _, rest = raw.partition("://") + if "@" in rest: + rest = rest.split("@", 1)[1] + repo_url = f"{scheme}://{rest}" + else: + repo_url = raw + branch_proc = _sp.run( + ["git", "branch", "--show-current"], + cwd=cwd, stdout=_sp.PIPE, stderr=_sp.DEVNULL, timeout=3, + ) + branch_name: str | None = None + if branch_proc.returncode == 0: + raw_b = branch_proc.stdout.decode("utf-8", errors="replace").strip() + if raw_b: + branch_name = raw_b + return (repo_url, branch_name) + except Exception: + return (None, None)