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