399 lines
15 KiB
Python
399 lines
15 KiB
Python
"""Tests for host-side git worktree operations.
|
|
|
|
Exercises ``omnigent.host.git_worktree`` against real ``git`` in a
|
|
temp repository — the operations run actual ``git worktree add`` /
|
|
``remove`` / ``branch -D`` so a regression in argv construction, repo-
|
|
root resolution, or removal ordering fails loud here.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import subprocess
|
|
from collections.abc import Iterator
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from omnigent.host.git_worktree import (
|
|
CreatedWorktree,
|
|
WorktreeError,
|
|
create_worktree,
|
|
list_worktrees,
|
|
remove_worktree,
|
|
validate_branch_name,
|
|
)
|
|
|
|
# Deterministic identity + config so the tests don't depend on the
|
|
# developer's global git config (user.name / init.defaultBranch).
|
|
_GIT_ENV = {
|
|
"GIT_AUTHOR_NAME": "t",
|
|
"GIT_AUTHOR_EMAIL": "t@t",
|
|
"GIT_COMMITTER_NAME": "t",
|
|
"GIT_COMMITTER_EMAIL": "t@t",
|
|
}
|
|
|
|
|
|
def _git(repo: Path, *args: str) -> None:
|
|
"""Run a git command in ``repo``, raising on failure.
|
|
|
|
:param repo: Repository directory to run in.
|
|
:param args: Git arguments after ``git``, e.g. ``("add", ".")``.
|
|
"""
|
|
import os
|
|
|
|
subprocess.run(
|
|
["git", *args],
|
|
cwd=repo,
|
|
env={**os.environ, **_GIT_ENV},
|
|
check=True,
|
|
capture_output=True,
|
|
)
|
|
|
|
|
|
def _current_branch(path: Path) -> str:
|
|
"""Return the checked-out branch name at ``path``.
|
|
|
|
:param path: A work tree (main or linked worktree) directory.
|
|
:returns: Branch name, e.g. ``"feature/login"``.
|
|
"""
|
|
import os
|
|
|
|
return subprocess.run(
|
|
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
cwd=path,
|
|
env={**os.environ, **_GIT_ENV},
|
|
capture_output=True,
|
|
text=True,
|
|
).stdout.strip()
|
|
|
|
|
|
def _rev_parse(path: Path, ref: str = "HEAD") -> str:
|
|
"""Return the commit sha that ``ref`` resolves to at ``path``.
|
|
|
|
:param path: A work tree directory.
|
|
:param ref: Ref to resolve, e.g. ``"HEAD"`` or ``"develop"``.
|
|
:returns: The 40-char commit sha.
|
|
"""
|
|
import os
|
|
|
|
return subprocess.run(
|
|
["git", "rev-parse", ref],
|
|
cwd=path,
|
|
env={**os.environ, **_GIT_ENV},
|
|
capture_output=True,
|
|
text=True,
|
|
).stdout.strip()
|
|
|
|
|
|
def _branch_exists(repo: Path, branch: str) -> bool:
|
|
"""Return whether ``branch`` exists in ``repo``.
|
|
|
|
:param repo: Repository directory.
|
|
:param branch: Branch name to check, e.g. ``"feature/login"``.
|
|
:returns: ``True`` if the local branch exists.
|
|
"""
|
|
import os
|
|
|
|
out = subprocess.run(
|
|
["git", "branch", "--list", branch],
|
|
cwd=repo,
|
|
env={**os.environ, **_GIT_ENV},
|
|
capture_output=True,
|
|
text=True,
|
|
).stdout.strip()
|
|
return out != ""
|
|
|
|
|
|
def _worktree_count(repo: Path) -> int:
|
|
"""Return how many worktrees are registered for ``repo``.
|
|
|
|
:param repo: Repository directory.
|
|
:returns: Worktree count, where ``1`` means only the main work
|
|
tree exists (no linked worktree was added).
|
|
"""
|
|
import os
|
|
|
|
out = subprocess.run(
|
|
["git", "worktree", "list", "--porcelain"],
|
|
cwd=repo,
|
|
env={**os.environ, **_GIT_ENV},
|
|
capture_output=True,
|
|
text=True,
|
|
).stdout
|
|
# --porcelain emits one "worktree <path>" line per worktree.
|
|
return out.count("worktree ")
|
|
|
|
|
|
@pytest.fixture()
|
|
def git_repo(tmp_path: Path) -> Iterator[Path]:
|
|
"""Create a one-commit git repo and yield its resolved root.
|
|
|
|
:returns: Iterator yielding the repo root path (realpath, so it
|
|
matches what ``git rev-parse --show-toplevel`` returns).
|
|
"""
|
|
# Resolve so comparisons match git's realpath output (macOS
|
|
# /tmp -> /private/tmp).
|
|
repo = (tmp_path / "myrepo").resolve()
|
|
repo.mkdir()
|
|
_git(repo, "init", "-q", "-b", "main")
|
|
(repo / "README.md").write_text("hi")
|
|
_git(repo, "add", ".")
|
|
_git(repo, "commit", "-q", "-m", "init")
|
|
yield repo
|
|
|
|
|
|
def test_create_worktree_places_sibling_of_repo_root(git_repo: Path) -> None:
|
|
"""A new worktree lands at ``<repo>-worktrees/<branch>`` with the branch checked out."""
|
|
created = create_worktree(repo_path=str(git_repo), branch_name="feature/login")
|
|
expected = git_repo.parent / "myrepo-worktrees" / "feature-login"
|
|
# Path proves the sibling layout + slash->dash dir sanitization;
|
|
# a regression in _resolve_worktree_path would change this.
|
|
assert created.worktree_path == str(expected)
|
|
assert Path(created.worktree_path).is_dir()
|
|
# The branch is actually checked out in the worktree (not just the dir made).
|
|
assert _current_branch(Path(created.worktree_path)) == "feature/login"
|
|
assert isinstance(created, CreatedWorktree)
|
|
|
|
|
|
def test_create_worktree_resolves_repo_root_from_subdir(git_repo: Path) -> None:
|
|
"""Picking a subdir still anchors the worktree at the repo root's sibling."""
|
|
sub = git_repo / "src"
|
|
sub.mkdir()
|
|
created = create_worktree(repo_path=str(sub), branch_name="wip")
|
|
# Sibling of the repo ROOT, not of the picked subdir — proves
|
|
# rev-parse --show-toplevel is used rather than the raw repo_path.
|
|
assert created.worktree_path == str(git_repo.parent / "myrepo-worktrees" / "wip")
|
|
|
|
|
|
def test_create_worktree_from_linked_worktree_anchors_at_main_repo(git_repo: Path) -> None:
|
|
"""Creating a worktree while inside a LINKED worktree anchors at the MAIN repo.
|
|
|
|
Resolving the repo root naively (``rev-parse --show-toplevel``) from a
|
|
linked worktree would nest the new worktree under it
|
|
(``…/feature-a-worktrees/feature-b``). ``_main_work_tree`` resolves to
|
|
the main checkout so worktrees stay siblings
|
|
(``…/myrepo-worktrees/feature-b``) — the fork-resume picker prefills a
|
|
worktree as the source session's workspace, so this is the common path.
|
|
"""
|
|
# First worktree, created off the main repo.
|
|
first = create_worktree(repo_path=str(git_repo), branch_name="feature/a")
|
|
first_path = Path(first.worktree_path)
|
|
assert first_path == git_repo.parent / "myrepo-worktrees" / "feature-a"
|
|
|
|
# Second worktree, requested from INSIDE the first (linked) worktree.
|
|
second = create_worktree(repo_path=str(first_path), branch_name="feature/b")
|
|
|
|
# Sibling of the MAIN repo, NOT nested under the first worktree. A
|
|
# regression to --show-toplevel would put it under
|
|
# ``feature-a-worktrees/`` and this fails.
|
|
assert second.worktree_path == str(git_repo.parent / "myrepo-worktrees" / "feature-b")
|
|
assert "feature-a-worktrees" not in second.worktree_path
|
|
assert Path(second.worktree_path).is_dir()
|
|
assert _current_branch(Path(second.worktree_path)) == "feature/b"
|
|
|
|
|
|
def test_create_worktree_from_base_branch(git_repo: Path) -> None:
|
|
"""A worktree branches from the explicit base ref's tip, not HEAD."""
|
|
# Advance develop with its own commit so it differs from main —
|
|
# otherwise the test would pass even if base_branch were ignored
|
|
# (both would resolve to the same single commit).
|
|
_git(git_repo, "checkout", "-q", "-b", "develop")
|
|
(git_repo / "dev.txt").write_text("dev-only")
|
|
_git(git_repo, "add", ".")
|
|
_git(git_repo, "commit", "-q", "-m", "dev commit")
|
|
_git(git_repo, "checkout", "-q", "main")
|
|
|
|
created = create_worktree(
|
|
repo_path=str(git_repo), branch_name="from-develop", base_branch="develop"
|
|
)
|
|
assert _current_branch(Path(created.worktree_path)) == "from-develop"
|
|
# Points at develop's tip, not main's — proves base_branch routed
|
|
# the new branch to develop rather than falling back to HEAD.
|
|
assert _rev_parse(Path(created.worktree_path)) == _rev_parse(git_repo, "develop")
|
|
assert _rev_parse(Path(created.worktree_path)) != _rev_parse(git_repo, "main")
|
|
|
|
|
|
def test_create_worktree_unknown_base_branch_fails(git_repo: Path) -> None:
|
|
"""An unresolvable base ref fails loud (after the best-effort fetch)."""
|
|
with pytest.raises(WorktreeError) as exc:
|
|
create_worktree(repo_path=str(git_repo), branch_name="x", base_branch="nope-not-a-branch")
|
|
# Proves _ensure_base_resolvable rejects rather than silently
|
|
# branching from HEAD when the requested base is missing.
|
|
assert "base branch does not exist" in exc.value.message
|
|
|
|
|
|
@pytest.mark.parametrize("option_like", ["-f", "--exec-path"])
|
|
def test_create_worktree_option_like_base_branch_not_executed(
|
|
git_repo: Path, option_like: str
|
|
) -> None:
|
|
"""A base_branch that looks like a git flag is rejected, never executed.
|
|
|
|
``base_branch`` is user-supplied and reaches ``git rev-parse`` and
|
|
``git worktree add`` argv. An option-like value (e.g. ``"-f"``, which
|
|
is ``git worktree add``'s ``--force``) must be treated as an
|
|
unresolvable rev, not parsed as a flag. This guards the end-to-end
|
|
security property at the public API: the ref-resolution pre-check and
|
|
the ``--end-of-options`` argv terminators together keep such a value
|
|
from creating a worktree. A regression that let ``"-f"`` through as a
|
|
flag would build a worktree from the wrong base (and force-create it)
|
|
instead of failing — so the assertion below would see a linked
|
|
worktree appear.
|
|
"""
|
|
with pytest.raises(WorktreeError):
|
|
create_worktree(repo_path=str(git_repo), branch_name="from-flag", base_branch=option_like)
|
|
# Still only the main work tree — no linked worktree was added, proving
|
|
# git treated the value as a (rejected) rev rather than a flag that
|
|
# would have run `worktree add`. If `-f` were parsed as --force, the
|
|
# count would be 2.
|
|
assert _worktree_count(git_repo) == 1
|
|
|
|
|
|
def test_create_worktree_duplicate_branch_fails(git_repo: Path) -> None:
|
|
"""Creating two worktrees for the same branch name fails loud with the friendly error."""
|
|
create_worktree(repo_path=str(git_repo), branch_name="dup")
|
|
with pytest.raises(WorktreeError) as exc:
|
|
create_worktree(repo_path=str(git_repo), branch_name="dup")
|
|
# The pre-check catches the existing branch before git's raw error;
|
|
# we must NOT silently reuse the existing worktree.
|
|
assert "already exists" in exc.value.message
|
|
|
|
|
|
def test_create_worktree_existing_branch_no_worktree_fails(git_repo: Path) -> None:
|
|
"""A branch that exists WITHOUT a worktree is still rejected by the pre-check.
|
|
|
|
Proves the pre-check keys off branch existence, not directory
|
|
occupancy — creating a worktree for a plain pre-existing branch
|
|
would otherwise hit git's raw error.
|
|
"""
|
|
_git(git_repo, "branch", "preexisting")
|
|
with pytest.raises(WorktreeError) as exc:
|
|
create_worktree(repo_path=str(git_repo), branch_name="preexisting")
|
|
assert "already exists" in exc.value.message
|
|
assert "preexisting" in exc.value.message
|
|
|
|
|
|
def test_create_worktree_non_repo_fails(tmp_path: Path) -> None:
|
|
"""A directory that isn't a git repo is rejected."""
|
|
plain = tmp_path / "plain"
|
|
plain.mkdir()
|
|
with pytest.raises(WorktreeError) as exc:
|
|
create_worktree(repo_path=str(plain), branch_name="x")
|
|
assert "not a git repository" in exc.value.message
|
|
|
|
|
|
def test_remove_worktree_deletes_dir_and_branch(git_repo: Path) -> None:
|
|
"""``delete_branch=True`` removes the directory AND the branch."""
|
|
created = create_worktree(repo_path=str(git_repo), branch_name="feature/login")
|
|
remove_worktree(
|
|
worktree_path=created.worktree_path, branch="feature/login", delete_branch=True
|
|
)
|
|
# Directory gone (git worktree remove --force ran)...
|
|
assert not Path(created.worktree_path).exists()
|
|
# ...and the branch deleted (git branch -D ran, after the worktree
|
|
# was removed — git would refuse otherwise).
|
|
assert not _branch_exists(git_repo, "feature/login")
|
|
|
|
|
|
def test_remove_worktree_keeps_branch_when_flag_false(git_repo: Path) -> None:
|
|
"""``delete_branch=False`` removes the directory but keeps the branch."""
|
|
created = create_worktree(repo_path=str(git_repo), branch_name="feature/keep")
|
|
remove_worktree(
|
|
worktree_path=created.worktree_path, branch="feature/keep", delete_branch=False
|
|
)
|
|
assert not Path(created.worktree_path).exists()
|
|
# Branch survives — only the checkout directory was removed.
|
|
assert _branch_exists(git_repo, "feature/keep")
|
|
|
|
|
|
def test_remove_worktree_missing_path_fails(git_repo: Path) -> None:
|
|
"""Removing a non-existent worktree path fails loud."""
|
|
with pytest.raises(WorktreeError) as exc:
|
|
remove_worktree(
|
|
worktree_path=str(git_repo.parent / "myrepo-worktrees" / "ghost"),
|
|
branch=None,
|
|
delete_branch=False,
|
|
)
|
|
assert "does not exist" in exc.value.message
|
|
|
|
|
|
def test_list_worktrees_returns_main_first(git_repo: Path) -> None:
|
|
"""With no linked worktrees, only the main tree is listed."""
|
|
result = list_worktrees(repo_path=str(git_repo))
|
|
assert len(result) == 1
|
|
main = result[0]
|
|
assert main.path == str(git_repo)
|
|
assert main.branch == "main"
|
|
assert main.is_main is True
|
|
assert main.detached is False
|
|
|
|
|
|
def test_list_worktrees_includes_linked(git_repo: Path) -> None:
|
|
"""A created worktree shows up with its branch and is not flagged main."""
|
|
created = create_worktree(repo_path=str(git_repo), branch_name="feature/login")
|
|
result = list_worktrees(repo_path=str(git_repo))
|
|
# Main first, then the linked worktree.
|
|
assert result[0].is_main is True
|
|
linked = next(w for w in result if not w.is_main)
|
|
assert linked.path == created.worktree_path
|
|
assert linked.branch == "feature/login"
|
|
assert linked.detached is False
|
|
|
|
|
|
def test_list_worktrees_from_linked_resolves_same_list(git_repo: Path) -> None:
|
|
"""Listing from inside a linked worktree resolves the main repo's full list."""
|
|
created = create_worktree(repo_path=str(git_repo), branch_name="feature/a")
|
|
# Query from the linked worktree — should still see BOTH worktrees.
|
|
result = list_worktrees(repo_path=created.worktree_path)
|
|
paths = {w.path for w in result}
|
|
assert str(git_repo) in paths
|
|
assert created.worktree_path in paths
|
|
|
|
|
|
def test_list_worktrees_reports_detached_head(git_repo: Path) -> None:
|
|
"""A detached-HEAD worktree lists with ``branch=None`` and ``detached=True``."""
|
|
head = _rev_parse(git_repo)
|
|
wt = git_repo.parent / "myrepo-worktrees" / "detached"
|
|
wt.parent.mkdir(parents=True, exist_ok=True)
|
|
# Add a worktree checked out at a bare commit → detached HEAD.
|
|
_git(git_repo, "worktree", "add", "--detach", str(wt), head)
|
|
result = list_worktrees(repo_path=str(git_repo))
|
|
detached = next(w for w in result if w.path == str(wt))
|
|
assert detached.branch is None
|
|
assert detached.detached is True
|
|
|
|
|
|
def test_list_worktrees_non_git_path_fails(tmp_path: Path) -> None:
|
|
"""A non-git directory fails loud (the route maps this to 'no worktrees')."""
|
|
plain = (tmp_path / "plain").resolve()
|
|
plain.mkdir()
|
|
with pytest.raises(WorktreeError):
|
|
list_worktrees(repo_path=str(plain))
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"bad",
|
|
[
|
|
"",
|
|
"-leading",
|
|
"a..b",
|
|
"a/.hidden",
|
|
"x.lock",
|
|
"x.lock/y",
|
|
"a b",
|
|
"a~b",
|
|
"a:b",
|
|
"/lead",
|
|
"trail/",
|
|
],
|
|
)
|
|
def test_validate_branch_name_rejects_bad(bad: str) -> None:
|
|
"""Branch names violating git ref-format are rejected before reaching argv."""
|
|
with pytest.raises(WorktreeError):
|
|
validate_branch_name(bad)
|
|
|
|
|
|
@pytest.mark.parametrize("good", ["feature/login", "fix-123", "a/b/c", "release_2", "v1.2"])
|
|
def test_validate_branch_name_accepts_good(good: str) -> None:
|
|
"""Well-formed branch names pass validation."""
|
|
validate_branch_name(good) # must not raise
|