"""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