feat: git worktree module
Adds hqt.git.worktree with slugify, is_git_repo, WorktreeState, create_worktree, add_worktree_for_branch, worktree_state, and remove_worktree; includes 25 real-git integration tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,284 @@
|
||||
"""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 (
|
||||
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 _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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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")
|
||||
(wt / "new.txt").write_text("content")
|
||||
_git(wt, "add", "new.txt")
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", "branch commit"],
|
||||
cwd=wt,
|
||||
capture_output=True,
|
||||
check=True,
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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")
|
||||
(wt / "new.txt").write_text("content")
|
||||
_git(wt, "add", "new.txt")
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", "unique"],
|
||||
cwd=wt,
|
||||
capture_output=True,
|
||||
check=True,
|
||||
)
|
||||
# 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
|
||||
Reference in New Issue
Block a user