Coverage for src/workstack/cli/core.py: 94%
50 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-10-19 09:31 -0400
« prev ^ index » next coverage.py v7.11.0, created at 2025-10-19 09:31 -0400
1from dataclasses import dataclass
2from pathlib import Path
4import click
6from workstack.core.context import WorkstackContext
9@dataclass(frozen=True)
10class RepoContext:
11 """Represents a git repo root and its managed worktrees directory."""
13 root: Path
14 repo_name: str
15 workstacks_dir: Path
18def discover_repo_context(ctx: WorkstackContext, start: Path) -> RepoContext:
19 """Walk up from `start` to find a directory containing `.git`.
21 Returns a RepoContext pointing to the repo root and the global worktrees directory
22 for this repository.
23 Raises FileNotFoundError if not inside a git repo or if global config is missing.
25 Note: Properly handles git worktrees by finding the main repository root,
26 not the worktree's .git file.
27 """
28 if not start.exists():
29 raise FileNotFoundError(f"Start path '{start}' does not exist.")
31 cur = start.resolve()
33 root: Path | None = None
34 git_common_dir = ctx.git_ops.get_git_common_dir(cur)
35 if git_common_dir is not None:
36 root = git_common_dir.parent.resolve()
37 else:
38 for parent in [cur, *cur.parents]:
39 git_path = parent / ".git"
40 if not git_path.exists():
41 continue
43 if git_path.is_dir():
44 root = parent
45 break
47 if root is None:
48 raise FileNotFoundError("Not inside a git repository (no .git found up the tree).")
50 repo_name = root.name
51 workstacks_dir = ctx.global_config_ops.get_workstacks_root() / repo_name
53 return RepoContext(root=root, repo_name=repo_name, workstacks_dir=workstacks_dir)
56def ensure_workstacks_dir(repo: RepoContext) -> Path:
57 """Ensure the workstacks directory exists and return it."""
58 repo.workstacks_dir.mkdir(parents=True, exist_ok=True)
59 return repo.workstacks_dir
62def worktree_path_for(workstacks_dir: Path, name: str) -> Path:
63 """Return the absolute path for a named worktree within workstacks_dir.
65 Note: Does not handle 'root' as a special case. Commands that support
66 'root' must check for it explicitly and use repo.root directly.
68 Args:
69 workstacks_dir: The directory containing all workstacks for this repo
70 name: The worktree name (e.g., 'feature-a')
72 Returns:
73 Absolute path to the worktree (e.g., ~/worktrees/myrepo/feature-a/)
74 """
75 return (workstacks_dir / name).resolve()
78def validate_worktree_name_for_removal(name: str) -> None:
79 """Validate that a worktree name is safe for removal.
81 Rejects:
82 - Empty strings
83 - `.` or `..` (current/parent directory references)
84 - `root` (explicit root worktree name)
85 - Names starting with `/` (absolute paths)
86 - Names containing `/` (path separators)
88 Raises SystemExit(1) with error message if validation fails.
89 """
90 if not name or not name.strip():
91 click.echo("Error: Worktree name cannot be empty", err=True)
92 raise SystemExit(1)
94 if name in (".", ".."):
95 click.echo(f"Error: Cannot remove '{name}' - directory references not allowed", err=True)
96 raise SystemExit(1)
98 if name == "root":
99 click.echo("Error: Cannot remove 'root' - root worktree name not allowed", err=True)
100 raise SystemExit(1)
102 if name.startswith("/"):
103 click.echo(f"Error: Cannot remove '{name}' - absolute paths not allowed", err=True)
104 raise SystemExit(1)
106 if "/" in name:
107 click.echo(f"Error: Cannot remove '{name}' - path separators not allowed", err=True)
108 raise SystemExit(1)