Coverage for src/workstack/core/gitops.py: 79%
184 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
1"""High-level git operations interface.
3This module provides a clean abstraction over git subprocess calls, making the
4codebase more testable and maintainable.
6Architecture:
7- GitOps: Abstract base class defining the interface
8- RealGitOps: Production implementation using subprocess
9- Standalone functions: Convenience wrappers delegating to module singleton
10"""
12import subprocess
13from abc import ABC, abstractmethod
14from dataclasses import dataclass
15from pathlib import Path
17import click
20@dataclass(frozen=True)
21class WorktreeInfo:
22 """Information about a single git worktree."""
24 path: Path
25 branch: str | None
28# ============================================================================
29# Abstract Interface
30# ============================================================================
33class GitOps(ABC):
34 """Abstract interface for git operations.
36 All implementations (real and fake) must implement this interface.
37 This interface contains ONLY runtime operations - no test setup methods.
38 """
40 @abstractmethod
41 def list_worktrees(self, repo_root: Path) -> list[WorktreeInfo]:
42 """List all worktrees in the repository."""
43 ...
45 @abstractmethod
46 def get_current_branch(self, cwd: Path) -> str | None:
47 """Get the currently checked-out branch."""
48 ...
50 @abstractmethod
51 def detect_default_branch(self, repo_root: Path) -> str:
52 """Detect the default branch (main or master)."""
53 ...
55 @abstractmethod
56 def get_git_common_dir(self, cwd: Path) -> Path | None:
57 """Get the common git directory."""
58 ...
60 @abstractmethod
61 def has_staged_changes(self, repo_root: Path) -> bool:
62 """Check if the repository has staged changes."""
63 ...
65 @abstractmethod
66 def add_worktree(
67 self,
68 repo_root: Path,
69 path: Path,
70 *,
71 branch: str | None,
72 ref: str | None,
73 create_branch: bool,
74 ) -> None:
75 """Add a new git worktree.
77 Args:
78 repo_root: Path to the git repository root
79 path: Path where the worktree should be created
80 branch: Branch name (None creates detached HEAD or uses ref)
81 ref: Git ref to base worktree on (None defaults to HEAD when creating branches)
82 create_branch: True to create new branch, False to checkout existing
83 """
84 ...
86 @abstractmethod
87 def move_worktree(self, repo_root: Path, old_path: Path, new_path: Path) -> None:
88 """Move a worktree to a new location."""
89 ...
91 @abstractmethod
92 def remove_worktree(self, repo_root: Path, path: Path, *, force: bool) -> None:
93 """Remove a worktree.
95 Args:
96 repo_root: Path to the git repository root
97 path: Path to the worktree to remove
98 force: True to force removal even if worktree has uncommitted changes
99 """
100 ...
102 @abstractmethod
103 def checkout_branch(self, cwd: Path, branch: str) -> None:
104 """Checkout a branch in the given directory."""
105 ...
107 @abstractmethod
108 def checkout_detached(self, cwd: Path, ref: str) -> None:
109 """Checkout a detached HEAD at the given ref (commit SHA, branch, etc)."""
110 ...
112 @abstractmethod
113 def delete_branch_with_graphite(self, repo_root: Path, branch: str, *, force: bool) -> None:
114 """Delete a branch using Graphite's gt delete command."""
115 ...
117 @abstractmethod
118 def prune_worktrees(self, repo_root: Path) -> None:
119 """Prune stale worktree metadata."""
120 ...
122 @abstractmethod
123 def is_branch_checked_out(self, repo_root: Path, branch: str) -> Path | None:
124 """Check if a branch is already checked out in any worktree.
126 Args:
127 repo_root: Path to the git repository root
128 branch: Branch name to check
130 Returns:
131 Path to the worktree where branch is checked out, or None if not checked out.
132 """
133 ...
135 @abstractmethod
136 def get_branch_head(self, repo_root: Path, branch: str) -> str | None:
137 """Get the commit SHA at the head of a branch.
139 Args:
140 repo_root: Path to the git repository root
141 branch: Branch name to query
143 Returns:
144 Commit SHA as a string, or None if branch doesn't exist.
145 """
146 ...
148 @abstractmethod
149 def get_commit_message(self, repo_root: Path, commit_sha: str) -> str | None:
150 """Get the commit message for a given commit SHA.
152 Args:
153 repo_root: Path to the git repository root
154 commit_sha: Commit SHA to query
156 Returns:
157 First line of commit message, or None if commit doesn't exist.
158 """
159 ...
162# ============================================================================
163# Production Implementation
164# ============================================================================
167class RealGitOps(GitOps):
168 """Production implementation using subprocess.
170 All git operations execute actual git commands via subprocess.
171 """
173 def list_worktrees(self, repo_root: Path) -> list[WorktreeInfo]:
174 """List all worktrees in the repository."""
175 result = subprocess.run(
176 ["git", "worktree", "list", "--porcelain"],
177 cwd=repo_root,
178 capture_output=True,
179 text=True,
180 check=True,
181 )
183 worktrees: list[WorktreeInfo] = []
184 current_path: Path | None = None
185 current_branch: str | None = None
187 for line in result.stdout.splitlines():
188 line = line.strip()
189 if line.startswith("worktree "):
190 current_path = Path(line.split(maxsplit=1)[1])
191 current_branch = None
192 elif line.startswith("branch "):
193 if current_path is None:
194 continue
195 branch_ref = line.split(maxsplit=1)[1]
196 current_branch = branch_ref.replace("refs/heads/", "")
197 elif line == "" and current_path is not None:
198 worktrees.append(WorktreeInfo(path=current_path, branch=current_branch))
199 current_path = None
200 current_branch = None
202 if current_path is not None:
203 worktrees.append(WorktreeInfo(path=current_path, branch=current_branch))
205 return worktrees
207 def get_current_branch(self, cwd: Path) -> str | None:
208 """Get the currently checked-out branch."""
209 result = subprocess.run(
210 ["git", "rev-parse", "--abbrev-ref", "HEAD"],
211 cwd=cwd,
212 capture_output=True,
213 text=True,
214 check=False,
215 )
216 if result.returncode != 0:
217 return None
219 branch = result.stdout.strip()
220 if branch == "HEAD":
221 return None
223 return branch
225 def detect_default_branch(self, repo_root: Path) -> str:
226 """Detect the default branch (main or master)."""
227 result = subprocess.run(
228 ["git", "symbolic-ref", "refs/remotes/origin/HEAD"],
229 cwd=repo_root,
230 capture_output=True,
231 text=True,
232 check=False,
233 )
234 if result.returncode == 0:
235 remote_head = result.stdout.strip()
236 if remote_head.startswith("refs/remotes/origin/"):
237 branch = remote_head.replace("refs/remotes/origin/", "")
238 return branch
240 for candidate in ["main", "master"]:
241 result = subprocess.run(
242 ["git", "rev-parse", "--verify", candidate],
243 cwd=repo_root,
244 capture_output=True,
245 text=True,
246 check=False,
247 )
248 if result.returncode == 0:
249 return candidate
251 click.echo("Error: Could not find 'main' or 'master' branch.", err=True)
252 raise SystemExit(1)
254 def get_git_common_dir(self, cwd: Path) -> Path | None:
255 """Get the common git directory."""
256 result = subprocess.run(
257 ["git", "rev-parse", "--git-common-dir"],
258 cwd=cwd,
259 capture_output=True,
260 text=True,
261 check=False,
262 )
263 if result.returncode != 0:
264 return None
266 git_dir = Path(result.stdout.strip())
267 if not git_dir.is_absolute():
268 git_dir = cwd / git_dir
270 return git_dir.resolve()
272 def has_staged_changes(self, repo_root: Path) -> bool:
273 """Check if the repository has staged changes."""
274 result = subprocess.run(
275 ["git", "diff", "--cached", "--quiet"],
276 cwd=repo_root,
277 capture_output=True,
278 text=True,
279 check=False,
280 )
281 if result.returncode in (0, 1):
282 return result.returncode == 1
283 result.check_returncode()
284 return False
286 def add_worktree(
287 self,
288 repo_root: Path,
289 path: Path,
290 *,
291 branch: str | None,
292 ref: str | None,
293 create_branch: bool,
294 ) -> None:
295 """Add a new git worktree."""
296 if branch and not create_branch:
297 cmd = ["git", "worktree", "add", str(path), branch]
298 elif branch and create_branch:
299 base_ref = ref or "HEAD"
300 cmd = ["git", "worktree", "add", "-b", branch, str(path), base_ref]
301 else:
302 base_ref = ref or "HEAD"
303 cmd = ["git", "worktree", "add", str(path), base_ref]
305 subprocess.run(cmd, cwd=repo_root, check=True, capture_output=True, text=True)
307 def move_worktree(self, repo_root: Path, old_path: Path, new_path: Path) -> None:
308 """Move a worktree to a new location."""
309 cmd = ["git", "worktree", "move", str(old_path), str(new_path)]
310 subprocess.run(cmd, cwd=repo_root, check=True)
312 def remove_worktree(self, repo_root: Path, path: Path, *, force: bool) -> None:
313 """Remove a worktree."""
314 cmd = ["git", "worktree", "remove"]
315 if force:
316 cmd.append("--force")
317 cmd.append(str(path))
318 subprocess.run(cmd, cwd=repo_root, check=True)
320 def checkout_branch(self, cwd: Path, branch: str) -> None:
321 """Checkout a branch in the given directory."""
322 subprocess.run(
323 ["git", "checkout", branch],
324 cwd=cwd,
325 check=True,
326 capture_output=True,
327 text=True,
328 )
330 def checkout_detached(self, cwd: Path, ref: str) -> None:
331 """Checkout a detached HEAD at the given ref."""
332 subprocess.run(
333 ["git", "checkout", "--detach", ref],
334 cwd=cwd,
335 check=True,
336 capture_output=True,
337 text=True,
338 )
340 def delete_branch_with_graphite(self, repo_root: Path, branch: str, *, force: bool) -> None:
341 """Delete a branch using Graphite's gt delete command."""
342 cmd = ["gt", "delete", branch]
343 if force:
344 cmd.insert(2, "-f")
345 subprocess.run(cmd, cwd=repo_root, check=True)
347 def prune_worktrees(self, repo_root: Path) -> None:
348 """Prune stale worktree metadata."""
349 subprocess.run(["git", "worktree", "prune"], cwd=repo_root, check=True)
351 def is_branch_checked_out(self, repo_root: Path, branch: str) -> Path | None:
352 """Check if a branch is already checked out in any worktree."""
353 worktrees = self.list_worktrees(repo_root)
354 for wt in worktrees:
355 if wt.branch == branch:
356 return wt.path
357 return None
359 def get_branch_head(self, repo_root: Path, branch: str) -> str | None:
360 """Get the commit SHA at the head of a branch."""
361 result = subprocess.run(
362 ["git", "rev-parse", branch],
363 cwd=repo_root,
364 capture_output=True,
365 text=True,
366 check=False,
367 )
368 if result.returncode != 0:
369 return None
371 return result.stdout.strip()
373 def get_commit_message(self, repo_root: Path, commit_sha: str) -> str | None:
374 """Get the first line of commit message for a given commit SHA."""
375 result = subprocess.run(
376 ["git", "log", "-1", "--format=%s", commit_sha],
377 cwd=repo_root,
378 capture_output=True,
379 text=True,
380 check=False,
381 )
382 if result.returncode != 0:
383 return None
385 return result.stdout.strip()
388# ============================================================================
389# Dry-Run Wrapper
390# ============================================================================
393class DryRunGitOps(GitOps):
394 """Wrapper that prints dry-run messages instead of executing destructive operations.
396 This wrapper intercepts destructive git operations and prints what would happen
397 instead of executing. Read-only operations are delegated to the wrapped implementation.
399 Usage:
400 real_ops = RealGitOps()
401 dry_run_ops = DryRunGitOps(real_ops)
403 # Prints message instead of deleting
404 dry_run_ops.remove_worktree(repo_root, path, force=False)
405 """
407 def __init__(self, wrapped: GitOps) -> None:
408 """Create a dry-run wrapper around a GitOps implementation.
410 Args:
411 wrapped: The GitOps implementation to wrap (usually RealGitOps or FakeGitOps)
412 """
413 self._wrapped = wrapped
415 # Read-only operations: delegate to wrapped implementation
417 def list_worktrees(self, repo_root: Path) -> list[WorktreeInfo]:
418 """List all worktrees (read-only, delegates to wrapped)."""
419 return self._wrapped.list_worktrees(repo_root)
421 def get_current_branch(self, cwd: Path) -> str | None:
422 """Get current branch (read-only, delegates to wrapped)."""
423 return self._wrapped.get_current_branch(cwd)
425 def detect_default_branch(self, repo_root: Path) -> str:
426 """Detect default branch (read-only, delegates to wrapped)."""
427 return self._wrapped.detect_default_branch(repo_root)
429 def get_git_common_dir(self, cwd: Path) -> Path | None:
430 """Get git common directory (read-only, delegates to wrapped)."""
431 return self._wrapped.get_git_common_dir(cwd)
433 def checkout_branch(self, cwd: Path, branch: str) -> None:
434 """Checkout branch (delegates to wrapped - considered read-only for dry-run)."""
435 return self._wrapped.checkout_branch(cwd, branch)
437 def checkout_detached(self, cwd: Path, ref: str) -> None:
438 """Checkout detached HEAD (delegates to wrapped - considered read-only for dry-run)."""
439 return self._wrapped.checkout_detached(cwd, ref)
441 # Destructive operations: print dry-run message instead of executing
443 def has_staged_changes(self, repo_root: Path) -> bool:
444 """Check for staged changes (read-only, delegates to wrapped)."""
445 return self._wrapped.has_staged_changes(repo_root)
447 def add_worktree(
448 self,
449 repo_root: Path,
450 path: Path,
451 *,
452 branch: str | None,
453 ref: str | None,
454 create_branch: bool,
455 ) -> None:
456 """Print dry-run message instead of adding worktree."""
457 if branch and create_branch:
458 base_ref = ref or "HEAD"
459 click.echo(
460 f"[DRY RUN] Would run: git worktree add -b {branch} {path} {base_ref}",
461 err=True,
462 )
463 elif branch:
464 click.echo(f"[DRY RUN] Would run: git worktree add {path} {branch}", err=True)
465 else:
466 base_ref = ref or "HEAD"
467 click.echo(f"[DRY RUN] Would run: git worktree add {path} {base_ref}", err=True)
469 def move_worktree(self, repo_root: Path, old_path: Path, new_path: Path) -> None:
470 """Print dry-run message instead of moving worktree."""
471 click.echo(f"[DRY RUN] Would run: git worktree move {old_path} {new_path}", err=True)
473 def remove_worktree(self, repo_root: Path, path: Path, *, force: bool) -> None:
474 """Print dry-run message instead of removing worktree."""
475 force_flag = "--force " if force else ""
476 click.echo(f"[DRY RUN] Would run: git worktree remove {force_flag}{path}", err=True)
478 def delete_branch_with_graphite(self, repo_root: Path, branch: str, *, force: bool) -> None:
479 """Print dry-run message instead of deleting branch."""
480 force_flag = "-f " if force else ""
481 click.echo(f"[DRY RUN] Would run: gt delete {force_flag}{branch}", err=True)
483 def prune_worktrees(self, repo_root: Path) -> None:
484 """Print dry-run message instead of pruning worktrees."""
485 click.echo("[DRY RUN] Would run: git worktree prune", err=True)
487 def is_branch_checked_out(self, repo_root: Path, branch: str) -> Path | None:
488 """Check if branch is checked out (read-only, delegates to wrapped)."""
489 return self._wrapped.is_branch_checked_out(repo_root, branch)
491 def get_branch_head(self, repo_root: Path, branch: str) -> str | None:
492 """Get branch head commit SHA (read-only, delegates to wrapped)."""
493 return self._wrapped.get_branch_head(repo_root, branch)
495 def get_commit_message(self, repo_root: Path, commit_sha: str) -> str | None:
496 """Get commit message (read-only, delegates to wrapped)."""
497 return self._wrapped.get_commit_message(repo_root, commit_sha)