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

1"""High-level git operations interface. 

2 

3This module provides a clean abstraction over git subprocess calls, making the 

4codebase more testable and maintainable. 

5 

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

11 

12import subprocess 

13from abc import ABC, abstractmethod 

14from dataclasses import dataclass 

15from pathlib import Path 

16 

17import click 

18 

19 

20@dataclass(frozen=True) 

21class WorktreeInfo: 

22 """Information about a single git worktree.""" 

23 

24 path: Path 

25 branch: str | None 

26 

27 

28# ============================================================================ 

29# Abstract Interface 

30# ============================================================================ 

31 

32 

33class GitOps(ABC): 

34 """Abstract interface for git operations. 

35 

36 All implementations (real and fake) must implement this interface. 

37 This interface contains ONLY runtime operations - no test setup methods. 

38 """ 

39 

40 @abstractmethod 

41 def list_worktrees(self, repo_root: Path) -> list[WorktreeInfo]: 

42 """List all worktrees in the repository.""" 

43 ... 

44 

45 @abstractmethod 

46 def get_current_branch(self, cwd: Path) -> str | None: 

47 """Get the currently checked-out branch.""" 

48 ... 

49 

50 @abstractmethod 

51 def detect_default_branch(self, repo_root: Path) -> str: 

52 """Detect the default branch (main or master).""" 

53 ... 

54 

55 @abstractmethod 

56 def get_git_common_dir(self, cwd: Path) -> Path | None: 

57 """Get the common git directory.""" 

58 ... 

59 

60 @abstractmethod 

61 def has_staged_changes(self, repo_root: Path) -> bool: 

62 """Check if the repository has staged changes.""" 

63 ... 

64 

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. 

76 

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

85 

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

90 

91 @abstractmethod 

92 def remove_worktree(self, repo_root: Path, path: Path, *, force: bool) -> None: 

93 """Remove a worktree. 

94 

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

101 

102 @abstractmethod 

103 def checkout_branch(self, cwd: Path, branch: str) -> None: 

104 """Checkout a branch in the given directory.""" 

105 ... 

106 

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

111 

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

116 

117 @abstractmethod 

118 def prune_worktrees(self, repo_root: Path) -> None: 

119 """Prune stale worktree metadata.""" 

120 ... 

121 

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. 

125 

126 Args: 

127 repo_root: Path to the git repository root 

128 branch: Branch name to check 

129 

130 Returns: 

131 Path to the worktree where branch is checked out, or None if not checked out. 

132 """ 

133 ... 

134 

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. 

138 

139 Args: 

140 repo_root: Path to the git repository root 

141 branch: Branch name to query 

142 

143 Returns: 

144 Commit SHA as a string, or None if branch doesn't exist. 

145 """ 

146 ... 

147 

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. 

151 

152 Args: 

153 repo_root: Path to the git repository root 

154 commit_sha: Commit SHA to query 

155 

156 Returns: 

157 First line of commit message, or None if commit doesn't exist. 

158 """ 

159 ... 

160 

161 

162# ============================================================================ 

163# Production Implementation 

164# ============================================================================ 

165 

166 

167class RealGitOps(GitOps): 

168 """Production implementation using subprocess. 

169 

170 All git operations execute actual git commands via subprocess. 

171 """ 

172 

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 ) 

182 

183 worktrees: list[WorktreeInfo] = [] 

184 current_path: Path | None = None 

185 current_branch: str | None = None 

186 

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 

201 

202 if current_path is not None: 

203 worktrees.append(WorktreeInfo(path=current_path, branch=current_branch)) 

204 

205 return worktrees 

206 

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 

218 

219 branch = result.stdout.strip() 

220 if branch == "HEAD": 

221 return None 

222 

223 return branch 

224 

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 

239 

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 

250 

251 click.echo("Error: Could not find 'main' or 'master' branch.", err=True) 

252 raise SystemExit(1) 

253 

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 

265 

266 git_dir = Path(result.stdout.strip()) 

267 if not git_dir.is_absolute(): 

268 git_dir = cwd / git_dir 

269 

270 return git_dir.resolve() 

271 

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 

285 

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] 

304 

305 subprocess.run(cmd, cwd=repo_root, check=True, capture_output=True, text=True) 

306 

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) 

311 

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) 

319 

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 ) 

329 

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 ) 

339 

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) 

346 

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) 

350 

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 

358 

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 

370 

371 return result.stdout.strip() 

372 

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 

384 

385 return result.stdout.strip() 

386 

387 

388# ============================================================================ 

389# Dry-Run Wrapper 

390# ============================================================================ 

391 

392 

393class DryRunGitOps(GitOps): 

394 """Wrapper that prints dry-run messages instead of executing destructive operations. 

395 

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. 

398 

399 Usage: 

400 real_ops = RealGitOps() 

401 dry_run_ops = DryRunGitOps(real_ops) 

402 

403 # Prints message instead of deleting 

404 dry_run_ops.remove_worktree(repo_root, path, force=False) 

405 """ 

406 

407 def __init__(self, wrapped: GitOps) -> None: 

408 """Create a dry-run wrapper around a GitOps implementation. 

409 

410 Args: 

411 wrapped: The GitOps implementation to wrap (usually RealGitOps or FakeGitOps) 

412 """ 

413 self._wrapped = wrapped 

414 

415 # Read-only operations: delegate to wrapped implementation 

416 

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) 

420 

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) 

424 

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) 

428 

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) 

432 

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) 

436 

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) 

440 

441 # Destructive operations: print dry-run message instead of executing 

442 

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) 

446 

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) 

468 

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) 

472 

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) 

477 

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) 

482 

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) 

486 

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) 

490 

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) 

494 

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)