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

1from dataclasses import dataclass 

2from pathlib import Path 

3 

4import click 

5 

6from workstack.core.context import WorkstackContext 

7 

8 

9@dataclass(frozen=True) 

10class RepoContext: 

11 """Represents a git repo root and its managed worktrees directory.""" 

12 

13 root: Path 

14 repo_name: str 

15 workstacks_dir: Path 

16 

17 

18def discover_repo_context(ctx: WorkstackContext, start: Path) -> RepoContext: 

19 """Walk up from `start` to find a directory containing `.git`. 

20 

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. 

24 

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

30 

31 cur = start.resolve() 

32 

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 

42 

43 if git_path.is_dir(): 

44 root = parent 

45 break 

46 

47 if root is None: 

48 raise FileNotFoundError("Not inside a git repository (no .git found up the tree).") 

49 

50 repo_name = root.name 

51 workstacks_dir = ctx.global_config_ops.get_workstacks_root() / repo_name 

52 

53 return RepoContext(root=root, repo_name=repo_name, workstacks_dir=workstacks_dir) 

54 

55 

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 

60 

61 

62def worktree_path_for(workstacks_dir: Path, name: str) -> Path: 

63 """Return the absolute path for a named worktree within workstacks_dir. 

64 

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. 

67 

68 Args: 

69 workstacks_dir: The directory containing all workstacks for this repo 

70 name: The worktree name (e.g., 'feature-a') 

71 

72 Returns: 

73 Absolute path to the worktree (e.g., ~/worktrees/myrepo/feature-a/) 

74 """ 

75 return (workstacks_dir / name).resolve() 

76 

77 

78def validate_worktree_name_for_removal(name: str) -> None: 

79 """Validate that a worktree name is safe for removal. 

80 

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) 

87 

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) 

93 

94 if name in (".", ".."): 

95 click.echo(f"Error: Cannot remove '{name}' - directory references not allowed", err=True) 

96 raise SystemExit(1) 

97 

98 if name == "root": 

99 click.echo("Error: Cannot remove 'root' - root worktree name not allowed", err=True) 

100 raise SystemExit(1) 

101 

102 if name.startswith("/"): 

103 click.echo(f"Error: Cannot remove '{name}' - absolute paths not allowed", err=True) 

104 raise SystemExit(1) 

105 

106 if "/" in name: 

107 click.echo(f"Error: Cannot remove '{name}' - path separators not allowed", err=True) 

108 raise SystemExit(1)