"""Host-side git worktree operations for session-start worktrees. Runs ``git`` (via argv lists, never a shell) on the host in response to ``host.create_worktree`` / ``host.remove_worktree`` frames. Branch names are validated against git ref-format rules before reaching argv. See designs/SESSION_GIT_WORKTREE.md. """ from __future__ import annotations import re import subprocess from dataclasses import dataclass from pathlib import Path # fetch/add can be slow on large repos; bound it so git can't hang the # host's tunnel loop. _GIT_TIMEOUT_S: float = 120.0 # Max directory-collision suffixes (``-2`` .. ``-N``) before giving up. _MAX_DIR_COLLISION_SUFFIX: int = 50 # Chars git refuses in a ref: space, control chars, ``~^:?*[\``, DEL. # (``..``, leading ``-``/``.``, ``/`` edges, ``.lock``, ``@{`` are # checked separately.) _INVALID_BRANCH_CHARS = re.compile(r"[\x00-\x20~^:?*\[\\\x7f]") class WorktreeError(Exception): """Raised when a git worktree operation fails. The message is user-facing and surfaced verbatim in the ``host.*_worktree_result`` frame's ``error`` field. :param message: Human-readable failure reason, e.g. ``"not a git repository: /tmp/x"``. """ def __init__(self, message: str) -> None: """Initialize with the user-facing error message. :param message: Error string surfaced to the API caller. """ super().__init__(message) self.message = message def validate_branch_name(name: str) -> None: """Validate a git branch name against ``git check-ref-format`` rules. :param name: Proposed branch name, e.g. ``"feature/login"``. :raises WorktreeError: If the name is empty or violates any ref-format rule. The message names the specific violation. """ if not name: raise WorktreeError("branch name must not be empty") if name.startswith("-"): raise WorktreeError(f"branch name must not start with '-': {name!r}") if name.startswith("/") or name.endswith("/"): raise WorktreeError(f"branch name must not start or end with '/': {name!r}") if name.endswith("."): raise WorktreeError(f"branch name must not end with '.': {name!r}") if any(part.endswith(".lock") for part in name.split("/")): raise WorktreeError(f"branch name path components must not end with '.lock': {name!r}") if ".." in name: raise WorktreeError(f"branch name must not contain '..': {name!r}") if "//" in name: raise WorktreeError(f"branch name must not contain '//': {name!r}") if "@{" in name: raise WorktreeError(f"branch name must not contain '@{{': {name!r}") if name == "@": raise WorktreeError("branch name must not be '@'") if _INVALID_BRANCH_CHARS.search(name): raise WorktreeError( f"branch name {name!r} contains an invalid character; spaces, " f"control characters, and any of ~ ^ : ? * [ \\ are not allowed" ) # No path component may start with '.' (e.g. ".hidden" or "a/.b"). if any(part.startswith(".") for part in name.split("/")): raise WorktreeError(f"branch name path components must not start with '.': {name!r}") def _sanitize_dirname(branch_name: str) -> str: """Derive a single-segment directory name from a branch name. Slashes collapse to ``-`` so the worktree lives in one directory. :param branch_name: Validated branch name, e.g. ``"feature/login"``. :returns: Filesystem-safe single segment, e.g. ``"feature-login"``. """ return branch_name.strip("/").replace("/", "-") def _run_git( args: list[str], *, cwd: str, ) -> subprocess.CompletedProcess[str]: """Run a git command, returning the completed process. :param args: Git argv *after* ``git``, e.g. ``["rev-parse", "--show-toplevel"]``. Passed as a list so no shell parsing occurs. :param cwd: Working directory to run git in, e.g. ``"/Users/alice/myrepo"``. :returns: The completed process with captured text stdout/stderr. :raises WorktreeError: If git is not installed, or the command exceeds :data:`_GIT_TIMEOUT_S`. """ try: return subprocess.run( ["git", *args], cwd=cwd, capture_output=True, text=True, timeout=_GIT_TIMEOUT_S, check=False, ) except FileNotFoundError as exc: raise WorktreeError("git is not installed on the host") from exc except subprocess.TimeoutExpired as exc: raise WorktreeError(f"git command timed out after {_GIT_TIMEOUT_S:.0f}s") from exc def _git_error(label: str, result: subprocess.CompletedProcess[str]) -> WorktreeError: """Build a WorktreeError from a failed git command. Includes the exit code (always present) and stderr when non-empty, so no invented "unknown error" fallback is needed. :param label: What failed, e.g. ``"git worktree add failed"``. :param result: The completed process with a non-zero return code. :returns: A :class:`WorktreeError` with code + stderr detail. """ detail = result.stderr.strip() suffix = f": {detail}" if detail else "" return WorktreeError(f"{label} (exit {result.returncode}){suffix}") def _main_work_tree(repo_path: str) -> str: """Resolve the MAIN work tree for any path inside a git repo. ``git worktree list --porcelain`` enumerates every work tree of the repository; its first entry is always the main one (the checkout all linked worktrees share). Run from ``repo_path``, this resolves the same main work tree whether the user picked the main checkout, a subdirectory, or a *linked worktree* — so a new worktree is always created as a sibling of the MAIN repo (e.g. ``…/myrepo-worktrees/``) rather than nested inside a worktree the session happened to start in (which ``rev-parse --show-toplevel`` would produce: ``…/myrepo-worktrees/feature-worktrees/``). :param repo_path: Absolute path inside a git repository — the directory the user picked, e.g. ``"/Users/alice/myrepo-worktrees/feature"``. :returns: Absolute path of the main work tree, e.g. ``"/Users/alice/myrepo"``. :raises WorktreeError: If ``repo_path`` is not a directory or not inside a git work tree. """ if not Path(repo_path).is_dir(): raise WorktreeError(f"path is not a directory: {repo_path}") result = _run_git(["worktree", "list", "--porcelain"], cwd=repo_path) if result.returncode != 0: raise WorktreeError(f"not a git repository: {repo_path}") for line in result.stdout.splitlines(): # Porcelain format: the first record's ``worktree `` line is # the main work tree; linked worktrees follow. if line.startswith("worktree "): return line[len("worktree ") :].strip() raise WorktreeError(f"could not resolve main work tree for {repo_path}") @dataclass class WorktreeInfo: """One entry from ``git worktree list``. :param path: Absolute worktree directory, e.g. ``"/Users/alice/myrepo-worktrees/feature-login"``. :param branch: Checked-out branch without the ``refs/heads/`` prefix, e.g. ``"feature/login"``. ``None`` when the worktree is in detached-HEAD state. :param is_main: ``True`` for the repository's main work tree (the first ``git worktree list`` record), ``False`` for linked worktrees. :param detached: ``True`` when the worktree has a detached HEAD (no branch checked out). """ path: str branch: str | None is_main: bool detached: bool def list_worktrees(*, repo_path: str) -> list[WorktreeInfo]: """List the git worktrees of the repository containing ``repo_path``. Resolves the main work tree first (so a linked worktree resolves the same list as the main checkout), then parses ``git worktree list --porcelain``. The first record is always the main work tree; the rest are linked worktrees. :param repo_path: Absolute path inside a git repository — the directory the user picked, e.g. ``"/Users/alice/myrepo"``. :returns: One :class:`WorktreeInfo` per worktree, main first. :raises WorktreeError: If ``repo_path`` is not a directory or not inside a git work tree, or if ``git worktree list`` fails. """ repo_root = _main_work_tree(repo_path) result = _run_git(["worktree", "list", "--porcelain"], cwd=repo_root) if result.returncode != 0: raise _git_error("git worktree list failed", result) worktrees: list[WorktreeInfo] = [] path: str | None = None branch: str | None = None detached = False for line in result.stdout.splitlines(): if line.startswith("worktree "): path = line[len("worktree ") :].strip() branch = None detached = False elif line.startswith("branch "): ref = line[len("branch ") :].strip() branch = ref[len("refs/heads/") :] if ref.startswith("refs/heads/") else ref elif line == "detached": detached = True elif line == "" and path is not None: # Blank line terminates a record. worktrees.append( WorktreeInfo( path=path, branch=branch, is_main=not worktrees, detached=detached, ) ) path = None # The porcelain output may omit a trailing blank line for the last record. if path is not None: worktrees.append( WorktreeInfo(path=path, branch=branch, is_main=not worktrees, detached=detached) ) return worktrees def _local_branch_exists(repo_root: str, branch_name: str) -> bool: """Return whether a local branch already exists in the repo. :param repo_root: Absolute repo work-tree root, e.g. ``"/Users/alice/myrepo"``. :param branch_name: Branch name to check, e.g. ``"feature/login"``. :returns: ``True`` if ``refs/heads/`` resolves. """ return ( _run_git( ["rev-parse", "--verify", "--quiet", f"refs/heads/{branch_name}"], cwd=repo_root, ).returncode == 0 ) def _resolve_worktree_path(repo_root: str, branch_name: str) -> Path: """Compute a collision-free sibling worktree directory path. Places the worktree at ``/-worktrees/``, appending a numeric suffix if that path already exists on disk. :param repo_root: Absolute repo work-tree root, e.g. ``"/Users/alice/myrepo"``. :param branch_name: Validated branch name, e.g. ``"feature/login"``. :returns: A path that does not yet exist, e.g. ``Path("/Users/alice/myrepo-worktrees/feature-login")``. :raises WorktreeError: If no free path is found within :data:`_MAX_DIR_COLLISION_SUFFIX` attempts. """ root = Path(repo_root) base_dir = root.parent / f"{root.name}-worktrees" dirname = _sanitize_dirname(branch_name) candidate = base_dir / dirname if not candidate.exists(): return candidate for suffix in range(2, _MAX_DIR_COLLISION_SUFFIX + 1): candidate = base_dir / f"{dirname}-{suffix}" if not candidate.exists(): return candidate raise WorktreeError( f"could not find a free worktree directory under {base_dir} " f"after {_MAX_DIR_COLLISION_SUFFIX} attempts" ) def _ensure_base_resolvable(repo_root: str, base_branch: str) -> None: """Make ``base_branch`` resolvable, fetching once if needed. If the base ref doesn't resolve locally (e.g. a remote-tracking branch not yet fetched), attempt a single ``git fetch`` and re-check. A fetch failure (offline) is not fatal on its own — the subsequent re-check produces the user-facing error. :param repo_root: Absolute repo work-tree root, e.g. ``"/Users/alice/myrepo"``. :param base_branch: Base ref the user requested, e.g. ``"main"`` or ``"origin/main"``. :raises WorktreeError: If the base ref cannot be resolved even after a fetch attempt. """ # --end-of-options forces git to treat the user-supplied base_branch as a # rev, never an option, so a value like "--exec-path" can't inject a git # flag (argv-only, no shell). Note: a bare "--" would not work here — git # rev-parse treats args after "--" as pathspecs, not revs. if ( _run_git( ["rev-parse", "--verify", "--quiet", "--end-of-options", base_branch], cwd=repo_root ).returncode == 0 ): return # Best-effort fetch from the default remote, then re-verify. _run_git(["fetch"], cwd=repo_root) if ( _run_git( ["rev-parse", "--verify", "--quiet", "--end-of-options", base_branch], cwd=repo_root ).returncode != 0 ): raise WorktreeError(f"base branch does not exist: {base_branch}") @dataclass class CreatedWorktree: """Result of a successful worktree creation. :param worktree_path: Absolute path of the created worktree directory, e.g. ``"/Users/alice/myrepo-worktrees/feature-login"``. :param branch: The branch checked out in the worktree, e.g. ``"feature/login"``. """ worktree_path: str branch: str def create_worktree( *, repo_path: str, branch_name: str, base_branch: str | None = None, ) -> CreatedWorktree: """Create a git worktree with a new branch checked out. Resolves the repo root, picks a collision-free sibling directory, and runs ``git worktree add -b`` (fetching once if ``base_branch`` isn't locally resolvable). :param repo_path: Absolute path inside the source repo — the directory the user picked, e.g. ``"/Users/alice/myrepo"``. :param branch_name: New branch to create and check out, e.g. ``"feature/login"``. :param base_branch: Optional base ref, e.g. ``"main"``. ``None`` branches from the repo's current ``HEAD``. :returns: The created worktree's path and branch. :raises WorktreeError: If the branch name is invalid, the path is not a git repo, the base ref can't be resolved, or ``git worktree add`` fails (e.g. the branch already exists). """ validate_branch_name(branch_name) # Always create the worktree off the MAIN work tree, even when # ``repo_path`` is itself a linked worktree (e.g. the fork-resume # picker prefilled a worktree as the source). Otherwise the new # worktree would nest under the picked worktree # (``…/feature-worktrees/``); resolving to the main repo keeps # all worktrees as siblings (``…/myrepo-worktrees/``). repo_root = _main_work_tree(repo_path) # Friendly pre-check before git's raw "branch already exists" error. # We don't reuse the existing worktree: two sessions sharing one # working tree would clobber each other (designs/SESSION_GIT_WORKTREE.md). if _local_branch_exists(repo_root, branch_name): raise WorktreeError( f"a branch named {branch_name!r} already exists; choose a different branch name" ) if base_branch is not None: _ensure_base_resolvable(repo_root, base_branch) worktree_path = _resolve_worktree_path(repo_root, branch_name) worktree_path.parent.mkdir(parents=True, exist_ok=True) add_args = ["worktree", "add", "-b", branch_name, str(worktree_path)] if base_branch is not None: # --end-of-options: treat base_branch as a rev, never a git flag, so a # user-supplied value starting with '-' can't inject an option. add_args += ["--end-of-options", base_branch] result = _run_git(add_args, cwd=repo_root) if result.returncode != 0: raise _git_error("git worktree add failed", result) return CreatedWorktree(worktree_path=str(worktree_path), branch=branch_name) def _main_repo_for_worktree(worktree_path: str) -> str: """Find the main repository work tree for a linked worktree. Uses ``git rev-parse --git-common-dir`` (which points at the shared ``.git`` of the main work tree) and returns that directory's parent. Run from inside the worktree so the relative result resolves correctly. :param worktree_path: Absolute path of a linked worktree, e.g. ``"/Users/alice/myrepo-worktrees/feature-login"``. :returns: Absolute path of the main repo work tree, e.g. ``"/Users/alice/myrepo"``. :raises WorktreeError: If ``worktree_path`` is missing or not part of a git repository. """ if not Path(worktree_path).exists(): raise WorktreeError(f"worktree path does not exist: {worktree_path}") result = _run_git(["rev-parse", "--git-common-dir"], cwd=worktree_path) if result.returncode != 0: raise WorktreeError(f"not a git worktree: {worktree_path}") common_dir = Path(result.stdout.strip()) if not common_dir.is_absolute(): common_dir = (Path(worktree_path) / common_dir).resolve() return str(common_dir.parent) def remove_worktree( *, worktree_path: str, branch: str | None = None, delete_branch: bool = False, ) -> None: """Remove a git worktree and optionally delete its branch. Removes the directory with ``--force``, then (if requested) deletes the branch — in that order, since git refuses to delete a branch still checked out in a linked worktree. ``git worktree remove`` refuses to remove the main work tree. :param worktree_path: Absolute path of the worktree to remove, e.g. ``"/Users/alice/myrepo-worktrees/feature-login"``. :param branch: Branch to delete when ``delete_branch`` is ``True``, e.g. ``"feature/login"``. ``None`` skips branch deletion. :param delete_branch: When ``True``, run ``git branch -D`` on ``branch`` after removing the worktree directory. :raises WorktreeError: If the worktree path is missing/invalid, or a git command fails. """ main_repo = _main_repo_for_worktree(worktree_path) remove_result = _run_git( ["worktree", "remove", "--force", worktree_path], cwd=main_repo, ) if remove_result.returncode != 0: raise _git_error("git worktree remove failed", remove_result) if delete_branch and branch is not None: branch_result = _run_git(["branch", "-D", branch], cwd=main_repo) if branch_result.returncode != 0: raise _git_error("git branch -D failed", branch_result)