Files
hqt/tests/test_worktree.py
felixm cc49859b08 fix: harden git worktree module (review findings)
- is_git_repo: check out.strip() == "true" (bare repos / .git dirs exit 0 with "false")
- _validate_branch: new shared helper calls check-ref-format; used in all three
  public branch-taking functions to block option-injection (--detach, --all, etc.)
- _ensure_worktrees_excluded: prepend newline when existing content lacks trailing newline
- WorktreeState: make frozen=True (codebase precedent)
- create_worktree invalid-branch ServiceError now includes git stderr
- remove_worktree: log.debug for silently-swallowed branch -d / worktree prune failures
- worktree_state docstring: document git-failure → clean-state behaviour
- Tests: 9 new regression tests; raw subprocess.run commit calls replaced with _commit helper

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 19:59:16 -04:00

343 lines
11 KiB
Python

"""Tests for hqt.git.worktree — real git repos in tmp_path."""
import subprocess
from pathlib import Path
import pytest
from hqt.errors import ServiceError
from hqt.git.worktree import (
WorktreeState,
add_worktree_for_branch,
create_worktree,
is_git_repo,
remove_worktree,
slugify,
worktree_state,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _git(repo: Path, *args: str) -> str:
"""Run a git command in repo; return stdout stripped."""
result = subprocess.run(
["git", *args],
cwd=repo,
capture_output=True,
text=True,
check=True,
)
return result.stdout.strip()
def _commit(repo: Path, filename: str, message: str) -> None:
"""Create a file, stage it, and commit it in repo."""
(repo / filename).write_text("content")
_git(repo, "add", filename)
_git(repo, "commit", "-m", message)
def _make_repo(path: Path) -> Path:
"""Init a bare-minimum git repo with one commit; return path."""
path.mkdir(parents=True, exist_ok=True)
_git(path, "init")
_git(path, "config", "user.name", "Test User")
_git(path, "config", "user.email", "test@example.com")
(path / "README").write_text("init")
_git(path, "add", "README")
_git(path, "commit", "-m", "init")
return path
# ---------------------------------------------------------------------------
# slugify
# ---------------------------------------------------------------------------
def test_slugify_lowercase():
assert slugify("Hello World") == "hello-world"
def test_slugify_mixed_case_and_spaces():
assert slugify("My Feature Branch") == "my-feature-branch"
def test_slugify_punctuation():
assert slugify("hello, world!") == "hello-world"
def test_slugify_leading_trailing_junk():
assert slugify("---foo---") == "foo"
def test_slugify_empty_string():
assert slugify("") == ""
def test_slugify_collapse_runs():
# Multiple consecutive non-alphanumeric chars collapse to one dash.
assert slugify("foo!!!bar___baz") == "foo-bar-baz"
def test_slugify_numbers_kept():
assert slugify("feature-123") == "feature-123"
# ---------------------------------------------------------------------------
# is_git_repo
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_is_git_repo_true(tmp_path):
repo = _make_repo(tmp_path / "repo")
assert await is_git_repo(repo) is True
@pytest.mark.asyncio
async def test_is_git_repo_plain_dir(tmp_path):
plain = tmp_path / "plain"
plain.mkdir()
assert await is_git_repo(plain) is False
@pytest.mark.asyncio
async def test_is_git_repo_missing_dir(tmp_path):
missing = tmp_path / "does_not_exist"
assert await is_git_repo(missing) is False
@pytest.mark.asyncio
async def test_is_git_repo_bare_repo_returns_false(tmp_path):
"""git rev-parse --is-inside-work-tree exits 0 and prints 'false' in bare repos."""
bare = tmp_path / "bare.git"
bare.mkdir()
subprocess.run(
["git", "init", "--bare", str(bare)], check=True, capture_output=True
)
assert await is_git_repo(bare) is False
@pytest.mark.asyncio
async def test_is_git_repo_dot_git_dir_returns_false(tmp_path):
"""git rev-parse --is-inside-work-tree exits 0 and prints 'false' inside .git."""
repo = _make_repo(tmp_path / "repo")
dot_git = repo / ".git"
assert await is_git_repo(dot_git) is False
# ---------------------------------------------------------------------------
# WorktreeState frozen dataclass
# ---------------------------------------------------------------------------
def test_worktree_state_is_frozen():
state = WorktreeState(exists=True, dirty=False, unique_commits=0)
with pytest.raises(Exception):
state.exists = False # type: ignore[misc]
# ---------------------------------------------------------------------------
# create_worktree
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_create_worktree_creates_directory(tmp_path):
repo = _make_repo(tmp_path / "repo")
wt = await create_worktree(repo, "feature-x")
assert wt.exists()
assert wt == repo / ".worktrees" / "feature-x"
@pytest.mark.asyncio
async def test_create_worktree_branch_checked_out(tmp_path):
repo = _make_repo(tmp_path / "repo")
wt = await create_worktree(repo, "feature-x")
branch = _git(wt, "rev-parse", "--abbrev-ref", "HEAD")
assert branch == "feature-x"
@pytest.mark.asyncio
async def test_create_worktree_exclude_updated(tmp_path):
repo = _make_repo(tmp_path / "repo")
await create_worktree(repo, "feature-x")
exclude = repo / ".git" / "info" / "exclude"
assert exclude.exists()
assert ".worktrees/" in exclude.read_text()
@pytest.mark.asyncio
async def test_create_worktree_exclude_not_duplicated(tmp_path):
repo = _make_repo(tmp_path / "repo")
await create_worktree(repo, "feature-x")
await create_worktree(repo, "feature-y")
exclude = repo / ".git" / "info" / "exclude"
content = exclude.read_text()
assert content.count(".worktrees/") == 1
@pytest.mark.asyncio
async def test_create_worktree_duplicate_branch_raises(tmp_path):
repo = _make_repo(tmp_path / "repo")
await create_worktree(repo, "feature-x")
with pytest.raises(ServiceError):
await create_worktree(repo, "feature-x")
@pytest.mark.asyncio
async def test_create_worktree_invalid_branch_name_raises(tmp_path):
repo = _make_repo(tmp_path / "repo")
with pytest.raises(ServiceError, match="Invalid branch name"):
await create_worktree(repo, "foo..bar")
@pytest.mark.asyncio
async def test_create_worktree_exclude_no_trailing_newline(tmp_path):
"""Exclude file lacking trailing newline must not corrupt the last pattern."""
repo = _make_repo(tmp_path / "repo")
exclude = repo / ".git" / "info" / "exclude"
exclude.parent.mkdir(parents=True, exist_ok=True)
# Write a pattern WITHOUT a trailing newline.
exclude.write_text("*.log")
await create_worktree(repo, "feature-x")
content = exclude.read_text()
lines = [ln for ln in content.splitlines() if ln.strip()]
assert "*.log" in lines, f"*.log missing from: {content!r}"
assert ".worktrees/" in lines, f".worktrees/ missing from: {content!r}"
# ---------------------------------------------------------------------------
# add_worktree_for_branch
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_add_worktree_for_branch_creates_directory(tmp_path):
repo = _make_repo(tmp_path / "repo")
# Create a branch first then remove the worktree dir manually.
wt = await create_worktree(repo, "feature-z")
_git(repo, "worktree", "remove", "--force", str(wt))
# Now recreate via add_worktree_for_branch.
wt2 = await add_worktree_for_branch(repo, "feature-z")
assert wt2.exists()
assert wt2 == repo / ".worktrees" / "feature-z"
branch = _git(wt2, "rev-parse", "--abbrev-ref", "HEAD")
assert branch == "feature-z"
@pytest.mark.asyncio
async def test_add_worktree_for_branch_option_like_name_raises(tmp_path):
"""Option-looking branch names (e.g. --detach) must be rejected before git sees them."""
repo = _make_repo(tmp_path / "repo")
with pytest.raises(ServiceError, match="Invalid branch name"):
await add_worktree_for_branch(repo, "--detach")
# ---------------------------------------------------------------------------
# worktree_state
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_worktree_state_fresh(tmp_path):
repo = _make_repo(tmp_path / "repo")
wt = await create_worktree(repo, "feature-a")
state = await worktree_state(repo, wt, "feature-a")
assert state.exists is True
assert state.dirty is False
assert state.unique_commits == 0
@pytest.mark.asyncio
async def test_worktree_state_dirty(tmp_path):
repo = _make_repo(tmp_path / "repo")
wt = await create_worktree(repo, "feature-b")
(wt / "untracked.txt").write_text("new")
state = await worktree_state(repo, wt, "feature-b")
assert state.dirty is True
@pytest.mark.asyncio
async def test_worktree_state_unique_commits(tmp_path):
repo = _make_repo(tmp_path / "repo")
wt = await create_worktree(repo, "feature-c")
_commit(wt, "new.txt", "branch commit")
state = await worktree_state(repo, wt, "feature-c")
assert state.unique_commits == 1
@pytest.mark.asyncio
async def test_worktree_state_missing_dir(tmp_path):
repo = _make_repo(tmp_path / "repo")
wt = repo / ".worktrees" / "ghost"
state = await worktree_state(repo, wt, "ghost")
assert state.exists is False
assert state.dirty is False
assert state.unique_commits == 0
@pytest.mark.asyncio
async def test_worktree_state_option_like_branch_raises(tmp_path):
"""Option-looking branch names (e.g. --all) must be rejected."""
repo = _make_repo(tmp_path / "repo")
wt = repo / ".worktrees" / "whatever"
with pytest.raises(ServiceError, match="Invalid branch name"):
await worktree_state(repo, wt, "--all")
# ---------------------------------------------------------------------------
# remove_worktree
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_remove_worktree_clean_merged(tmp_path):
repo = _make_repo(tmp_path / "repo")
wt = await create_worktree(repo, "feature-d")
await remove_worktree(repo, wt, "feature-d", force=False)
assert not wt.exists()
# Branch should be deleted (no unique commits → git branch -d succeeds).
branches = _git(repo, "branch", "--list", "feature-d")
assert branches == ""
@pytest.mark.asyncio
async def test_remove_worktree_dirty_without_force_raises(tmp_path):
repo = _make_repo(tmp_path / "repo")
wt = await create_worktree(repo, "feature-e")
(wt / "dirty.txt").write_text("unsaved")
_git(wt, "add", "dirty.txt")
with pytest.raises(ServiceError):
await remove_worktree(repo, wt, "feature-e", force=False)
assert wt.exists()
@pytest.mark.asyncio
async def test_remove_worktree_dirty_with_force_removes(tmp_path):
repo = _make_repo(tmp_path / "repo")
wt = await create_worktree(repo, "feature-f")
(wt / "dirty.txt").write_text("unsaved")
_git(wt, "add", "dirty.txt")
await remove_worktree(repo, wt, "feature-f", force=True)
assert not wt.exists()
@pytest.mark.asyncio
async def test_remove_worktree_unique_commits_branch_survives(tmp_path):
repo = _make_repo(tmp_path / "repo")
wt = await create_worktree(repo, "feature-g")
_commit(wt, "new.txt", "unique")
# remove_worktree must NOT raise even though branch -d will fail.
await remove_worktree(repo, wt, "feature-g", force=True)
assert not wt.exists()
# Branch still exists because git branch -d failed (unmerged) — silently ignored.
branches = _git(repo, "branch", "--list", "feature-g")
assert "feature-g" in branches