chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,398 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user