spec-kitty-cli 0.12.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (242) hide show
  1. spec_kitty_cli-0.12.1.dist-info/METADATA +1767 -0
  2. spec_kitty_cli-0.12.1.dist-info/RECORD +242 -0
  3. spec_kitty_cli-0.12.1.dist-info/WHEEL +4 -0
  4. spec_kitty_cli-0.12.1.dist-info/entry_points.txt +2 -0
  5. spec_kitty_cli-0.12.1.dist-info/licenses/LICENSE +21 -0
  6. specify_cli/__init__.py +171 -0
  7. specify_cli/acceptance.py +627 -0
  8. specify_cli/agent_utils/README.md +157 -0
  9. specify_cli/agent_utils/__init__.py +9 -0
  10. specify_cli/agent_utils/status.py +356 -0
  11. specify_cli/cli/__init__.py +6 -0
  12. specify_cli/cli/commands/__init__.py +46 -0
  13. specify_cli/cli/commands/accept.py +189 -0
  14. specify_cli/cli/commands/agent/__init__.py +22 -0
  15. specify_cli/cli/commands/agent/config.py +382 -0
  16. specify_cli/cli/commands/agent/context.py +191 -0
  17. specify_cli/cli/commands/agent/feature.py +1057 -0
  18. specify_cli/cli/commands/agent/release.py +11 -0
  19. specify_cli/cli/commands/agent/tasks.py +1253 -0
  20. specify_cli/cli/commands/agent/workflow.py +801 -0
  21. specify_cli/cli/commands/context.py +246 -0
  22. specify_cli/cli/commands/dashboard.py +85 -0
  23. specify_cli/cli/commands/implement.py +973 -0
  24. specify_cli/cli/commands/init.py +827 -0
  25. specify_cli/cli/commands/init_help.py +62 -0
  26. specify_cli/cli/commands/merge.py +755 -0
  27. specify_cli/cli/commands/mission.py +240 -0
  28. specify_cli/cli/commands/ops.py +265 -0
  29. specify_cli/cli/commands/orchestrate.py +640 -0
  30. specify_cli/cli/commands/repair.py +175 -0
  31. specify_cli/cli/commands/research.py +165 -0
  32. specify_cli/cli/commands/sync.py +364 -0
  33. specify_cli/cli/commands/upgrade.py +249 -0
  34. specify_cli/cli/commands/validate_encoding.py +186 -0
  35. specify_cli/cli/commands/validate_tasks.py +186 -0
  36. specify_cli/cli/commands/verify.py +310 -0
  37. specify_cli/cli/helpers.py +123 -0
  38. specify_cli/cli/step_tracker.py +91 -0
  39. specify_cli/cli/ui.py +192 -0
  40. specify_cli/core/__init__.py +53 -0
  41. specify_cli/core/agent_context.py +311 -0
  42. specify_cli/core/config.py +96 -0
  43. specify_cli/core/context_validation.py +362 -0
  44. specify_cli/core/dependency_graph.py +351 -0
  45. specify_cli/core/git_ops.py +129 -0
  46. specify_cli/core/multi_parent_merge.py +323 -0
  47. specify_cli/core/paths.py +260 -0
  48. specify_cli/core/project_resolver.py +110 -0
  49. specify_cli/core/stale_detection.py +263 -0
  50. specify_cli/core/tool_checker.py +79 -0
  51. specify_cli/core/utils.py +43 -0
  52. specify_cli/core/vcs/__init__.py +114 -0
  53. specify_cli/core/vcs/detection.py +341 -0
  54. specify_cli/core/vcs/exceptions.py +85 -0
  55. specify_cli/core/vcs/git.py +1304 -0
  56. specify_cli/core/vcs/jujutsu.py +1208 -0
  57. specify_cli/core/vcs/protocol.py +285 -0
  58. specify_cli/core/vcs/types.py +249 -0
  59. specify_cli/core/version_checker.py +261 -0
  60. specify_cli/core/worktree.py +506 -0
  61. specify_cli/dashboard/__init__.py +28 -0
  62. specify_cli/dashboard/diagnostics.py +204 -0
  63. specify_cli/dashboard/handlers/__init__.py +17 -0
  64. specify_cli/dashboard/handlers/api.py +143 -0
  65. specify_cli/dashboard/handlers/base.py +65 -0
  66. specify_cli/dashboard/handlers/features.py +390 -0
  67. specify_cli/dashboard/handlers/router.py +81 -0
  68. specify_cli/dashboard/handlers/static.py +50 -0
  69. specify_cli/dashboard/lifecycle.py +541 -0
  70. specify_cli/dashboard/scanner.py +437 -0
  71. specify_cli/dashboard/server.py +123 -0
  72. specify_cli/dashboard/static/dashboard/dashboard.css +722 -0
  73. specify_cli/dashboard/static/dashboard/dashboard.js +1424 -0
  74. specify_cli/dashboard/static/spec-kitty.png +0 -0
  75. specify_cli/dashboard/templates/__init__.py +36 -0
  76. specify_cli/dashboard/templates/index.html +258 -0
  77. specify_cli/doc_generators.py +621 -0
  78. specify_cli/doc_state.py +408 -0
  79. specify_cli/frontmatter.py +384 -0
  80. specify_cli/gap_analysis.py +915 -0
  81. specify_cli/gitignore_manager.py +300 -0
  82. specify_cli/guards.py +145 -0
  83. specify_cli/legacy_detector.py +83 -0
  84. specify_cli/manifest.py +286 -0
  85. specify_cli/merge/__init__.py +63 -0
  86. specify_cli/merge/executor.py +653 -0
  87. specify_cli/merge/forecast.py +215 -0
  88. specify_cli/merge/ordering.py +126 -0
  89. specify_cli/merge/preflight.py +230 -0
  90. specify_cli/merge/state.py +185 -0
  91. specify_cli/merge/status_resolver.py +354 -0
  92. specify_cli/mission.py +654 -0
  93. specify_cli/missions/documentation/command-templates/implement.md +309 -0
  94. specify_cli/missions/documentation/command-templates/plan.md +275 -0
  95. specify_cli/missions/documentation/command-templates/review.md +344 -0
  96. specify_cli/missions/documentation/command-templates/specify.md +206 -0
  97. specify_cli/missions/documentation/command-templates/tasks.md +189 -0
  98. specify_cli/missions/documentation/mission.yaml +113 -0
  99. specify_cli/missions/documentation/templates/divio/explanation-template.md +192 -0
  100. specify_cli/missions/documentation/templates/divio/howto-template.md +168 -0
  101. specify_cli/missions/documentation/templates/divio/reference-template.md +179 -0
  102. specify_cli/missions/documentation/templates/divio/tutorial-template.md +146 -0
  103. specify_cli/missions/documentation/templates/generators/jsdoc.json.template +18 -0
  104. specify_cli/missions/documentation/templates/generators/sphinx-conf.py.template +36 -0
  105. specify_cli/missions/documentation/templates/plan-template.md +269 -0
  106. specify_cli/missions/documentation/templates/release-template.md +222 -0
  107. specify_cli/missions/documentation/templates/spec-template.md +172 -0
  108. specify_cli/missions/documentation/templates/task-prompt-template.md +140 -0
  109. specify_cli/missions/documentation/templates/tasks-template.md +159 -0
  110. specify_cli/missions/research/command-templates/merge.md +388 -0
  111. specify_cli/missions/research/command-templates/plan.md +125 -0
  112. specify_cli/missions/research/command-templates/review.md +144 -0
  113. specify_cli/missions/research/command-templates/tasks.md +225 -0
  114. specify_cli/missions/research/mission.yaml +115 -0
  115. specify_cli/missions/research/templates/data-model-template.md +33 -0
  116. specify_cli/missions/research/templates/plan-template.md +161 -0
  117. specify_cli/missions/research/templates/research/evidence-log.csv +18 -0
  118. specify_cli/missions/research/templates/research/source-register.csv +18 -0
  119. specify_cli/missions/research/templates/research-template.md +35 -0
  120. specify_cli/missions/research/templates/spec-template.md +64 -0
  121. specify_cli/missions/research/templates/task-prompt-template.md +148 -0
  122. specify_cli/missions/research/templates/tasks-template.md +114 -0
  123. specify_cli/missions/software-dev/command-templates/accept.md +75 -0
  124. specify_cli/missions/software-dev/command-templates/analyze.md +183 -0
  125. specify_cli/missions/software-dev/command-templates/checklist.md +286 -0
  126. specify_cli/missions/software-dev/command-templates/clarify.md +157 -0
  127. specify_cli/missions/software-dev/command-templates/constitution.md +432 -0
  128. specify_cli/missions/software-dev/command-templates/dashboard.md +101 -0
  129. specify_cli/missions/software-dev/command-templates/implement.md +41 -0
  130. specify_cli/missions/software-dev/command-templates/merge.md +383 -0
  131. specify_cli/missions/software-dev/command-templates/plan.md +171 -0
  132. specify_cli/missions/software-dev/command-templates/review.md +32 -0
  133. specify_cli/missions/software-dev/command-templates/specify.md +321 -0
  134. specify_cli/missions/software-dev/command-templates/tasks.md +566 -0
  135. specify_cli/missions/software-dev/mission.yaml +100 -0
  136. specify_cli/missions/software-dev/templates/plan-template.md +132 -0
  137. specify_cli/missions/software-dev/templates/spec-template.md +116 -0
  138. specify_cli/missions/software-dev/templates/task-prompt-template.md +140 -0
  139. specify_cli/missions/software-dev/templates/tasks-template.md +159 -0
  140. specify_cli/orchestrator/__init__.py +75 -0
  141. specify_cli/orchestrator/agent_config.py +224 -0
  142. specify_cli/orchestrator/agents/__init__.py +170 -0
  143. specify_cli/orchestrator/agents/augment.py +112 -0
  144. specify_cli/orchestrator/agents/base.py +243 -0
  145. specify_cli/orchestrator/agents/claude.py +112 -0
  146. specify_cli/orchestrator/agents/codex.py +106 -0
  147. specify_cli/orchestrator/agents/copilot.py +137 -0
  148. specify_cli/orchestrator/agents/cursor.py +139 -0
  149. specify_cli/orchestrator/agents/gemini.py +115 -0
  150. specify_cli/orchestrator/agents/kilocode.py +94 -0
  151. specify_cli/orchestrator/agents/opencode.py +132 -0
  152. specify_cli/orchestrator/agents/qwen.py +96 -0
  153. specify_cli/orchestrator/config.py +455 -0
  154. specify_cli/orchestrator/executor.py +642 -0
  155. specify_cli/orchestrator/integration.py +1230 -0
  156. specify_cli/orchestrator/monitor.py +898 -0
  157. specify_cli/orchestrator/scheduler.py +832 -0
  158. specify_cli/orchestrator/state.py +508 -0
  159. specify_cli/orchestrator/testing/__init__.py +122 -0
  160. specify_cli/orchestrator/testing/availability.py +346 -0
  161. specify_cli/orchestrator/testing/fixtures.py +684 -0
  162. specify_cli/orchestrator/testing/paths.py +218 -0
  163. specify_cli/plan_validation.py +107 -0
  164. specify_cli/scripts/debug-dashboard-scan.py +61 -0
  165. specify_cli/scripts/tasks/acceptance_support.py +695 -0
  166. specify_cli/scripts/tasks/task_helpers.py +506 -0
  167. specify_cli/scripts/tasks/tasks_cli.py +848 -0
  168. specify_cli/scripts/validate_encoding.py +180 -0
  169. specify_cli/task_metadata_validation.py +274 -0
  170. specify_cli/tasks_support.py +447 -0
  171. specify_cli/template/__init__.py +47 -0
  172. specify_cli/template/asset_generator.py +206 -0
  173. specify_cli/template/github_client.py +334 -0
  174. specify_cli/template/manager.py +193 -0
  175. specify_cli/template/renderer.py +99 -0
  176. specify_cli/templates/AGENTS.md +190 -0
  177. specify_cli/templates/POWERSHELL_SYNTAX.md +229 -0
  178. specify_cli/templates/agent-file-template.md +35 -0
  179. specify_cli/templates/checklist-template.md +42 -0
  180. specify_cli/templates/claudeignore-template +58 -0
  181. specify_cli/templates/command-templates/accept.md +141 -0
  182. specify_cli/templates/command-templates/analyze.md +253 -0
  183. specify_cli/templates/command-templates/checklist.md +352 -0
  184. specify_cli/templates/command-templates/clarify.md +224 -0
  185. specify_cli/templates/command-templates/constitution.md +432 -0
  186. specify_cli/templates/command-templates/dashboard.md +175 -0
  187. specify_cli/templates/command-templates/implement.md +190 -0
  188. specify_cli/templates/command-templates/merge.md +374 -0
  189. specify_cli/templates/command-templates/plan.md +171 -0
  190. specify_cli/templates/command-templates/research.md +88 -0
  191. specify_cli/templates/command-templates/review.md +510 -0
  192. specify_cli/templates/command-templates/specify.md +321 -0
  193. specify_cli/templates/command-templates/status.md +92 -0
  194. specify_cli/templates/command-templates/tasks.md +199 -0
  195. specify_cli/templates/git-hooks/pre-commit +22 -0
  196. specify_cli/templates/git-hooks/pre-commit-agent-check +37 -0
  197. specify_cli/templates/git-hooks/pre-commit-encoding-check +142 -0
  198. specify_cli/templates/plan-template.md +108 -0
  199. specify_cli/templates/spec-template.md +118 -0
  200. specify_cli/templates/task-prompt-template.md +165 -0
  201. specify_cli/templates/tasks-template.md +161 -0
  202. specify_cli/templates/vscode-settings.json +13 -0
  203. specify_cli/text_sanitization.py +225 -0
  204. specify_cli/upgrade/__init__.py +18 -0
  205. specify_cli/upgrade/detector.py +239 -0
  206. specify_cli/upgrade/metadata.py +182 -0
  207. specify_cli/upgrade/migrations/__init__.py +65 -0
  208. specify_cli/upgrade/migrations/base.py +80 -0
  209. specify_cli/upgrade/migrations/m_0_10_0_python_only.py +359 -0
  210. specify_cli/upgrade/migrations/m_0_10_12_constitution_cleanup.py +99 -0
  211. specify_cli/upgrade/migrations/m_0_10_14_update_implement_slash_command.py +176 -0
  212. specify_cli/upgrade/migrations/m_0_10_1_populate_slash_commands.py +174 -0
  213. specify_cli/upgrade/migrations/m_0_10_2_update_slash_commands.py +172 -0
  214. specify_cli/upgrade/migrations/m_0_10_6_workflow_simplification.py +174 -0
  215. specify_cli/upgrade/migrations/m_0_10_8_fix_memory_structure.py +252 -0
  216. specify_cli/upgrade/migrations/m_0_10_9_repair_templates.py +168 -0
  217. specify_cli/upgrade/migrations/m_0_11_0_workspace_per_wp.py +182 -0
  218. specify_cli/upgrade/migrations/m_0_11_1_improved_workflow_templates.py +173 -0
  219. specify_cli/upgrade/migrations/m_0_11_1_update_implement_slash_command.py +160 -0
  220. specify_cli/upgrade/migrations/m_0_11_2_improved_workflow_templates.py +173 -0
  221. specify_cli/upgrade/migrations/m_0_11_3_workflow_agent_flag.py +114 -0
  222. specify_cli/upgrade/migrations/m_0_12_0_documentation_mission.py +155 -0
  223. specify_cli/upgrade/migrations/m_0_12_1_remove_kitty_specs_from_gitignore.py +183 -0
  224. specify_cli/upgrade/migrations/m_0_2_0_specify_to_kittify.py +80 -0
  225. specify_cli/upgrade/migrations/m_0_4_8_gitignore_agents.py +118 -0
  226. specify_cli/upgrade/migrations/m_0_5_0_encoding_hooks.py +141 -0
  227. specify_cli/upgrade/migrations/m_0_6_5_commands_rename.py +169 -0
  228. specify_cli/upgrade/migrations/m_0_6_7_ensure_missions.py +228 -0
  229. specify_cli/upgrade/migrations/m_0_7_2_worktree_commands_dedup.py +89 -0
  230. specify_cli/upgrade/migrations/m_0_7_3_update_scripts.py +114 -0
  231. specify_cli/upgrade/migrations/m_0_8_0_remove_active_mission.py +82 -0
  232. specify_cli/upgrade/migrations/m_0_8_0_worktree_agents_symlink.py +148 -0
  233. specify_cli/upgrade/migrations/m_0_9_0_frontmatter_only_lanes.py +346 -0
  234. specify_cli/upgrade/migrations/m_0_9_1_complete_lane_migration.py +656 -0
  235. specify_cli/upgrade/migrations/m_0_9_2_research_mission_templates.py +221 -0
  236. specify_cli/upgrade/registry.py +121 -0
  237. specify_cli/upgrade/runner.py +284 -0
  238. specify_cli/validators/__init__.py +14 -0
  239. specify_cli/validators/paths.py +154 -0
  240. specify_cli/validators/research.py +428 -0
  241. specify_cli/verify_enhanced.py +270 -0
  242. specify_cli/workspace_context.py +224 -0
@@ -0,0 +1,1304 @@
1
+ """
2
+ Git VCS Implementation
3
+ ======================
4
+
5
+ Full implementation of GitVCS that wraps git CLI commands.
6
+ Implements VCSProtocol for workspace management, sync operations,
7
+ conflict detection, and commit operations.
8
+
9
+ This module wraps existing git operations from git_ops.py where appropriate
10
+ and adds VCS abstraction layer functionality.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import re
16
+ import subprocess
17
+ from datetime import datetime, timezone
18
+ from pathlib import Path
19
+
20
+ from .exceptions import VCSSyncError
21
+ from .types import (
22
+ ChangeInfo,
23
+ ConflictInfo,
24
+ ConflictType,
25
+ GIT_CAPABILITIES,
26
+ OperationInfo,
27
+ SyncResult,
28
+ SyncStatus,
29
+ VCSBackend,
30
+ VCSCapabilities,
31
+ WorkspaceCreateResult,
32
+ WorkspaceInfo,
33
+ )
34
+
35
+ # Import existing git helpers where they provide reusable functionality
36
+ from ..git_ops import get_current_branch, is_git_repo, run_command
37
+
38
+
39
+ class GitVCS:
40
+ """
41
+ Git VCS implementation.
42
+
43
+ Implements VCSProtocol for git repositories, wrapping git CLI commands
44
+ for workspace management, synchronization, conflict detection, and commits.
45
+ """
46
+
47
+ @property
48
+ def backend(self) -> VCSBackend:
49
+ """Return which backend this is."""
50
+ return VCSBackend.GIT
51
+
52
+ @property
53
+ def capabilities(self) -> VCSCapabilities:
54
+ """Return capabilities of this backend."""
55
+ return GIT_CAPABILITIES
56
+
57
+ # =========================================================================
58
+ # Workspace Operations
59
+ # =========================================================================
60
+
61
+ def create_workspace(
62
+ self,
63
+ workspace_path: Path,
64
+ workspace_name: str,
65
+ base_branch: str | None = None,
66
+ base_commit: str | None = None,
67
+ repo_root: Path | None = None,
68
+ sparse_exclude: list[str] | None = None,
69
+ ) -> WorkspaceCreateResult:
70
+ """
71
+ Create a new git worktree for a work package.
72
+
73
+ Args:
74
+ workspace_path: Where to create the workspace
75
+ workspace_name: Name for the workspace branch (e.g., "015-feature-WP01")
76
+ base_branch: Branch to base on (for --base flag)
77
+ base_commit: Specific commit to base on (alternative to branch)
78
+ repo_root: Root of the git repository (auto-detected if not provided)
79
+ sparse_exclude: List of paths to exclude via sparse-checkout (e.g., ["kitty-specs/"])
80
+
81
+ Returns:
82
+ WorkspaceCreateResult with workspace info or error
83
+ """
84
+ try:
85
+ # Ensure parent directory exists
86
+ workspace_path.parent.mkdir(parents=True, exist_ok=True)
87
+
88
+ # Find repo root to run git commands from
89
+ if repo_root is None:
90
+ repo_root = self.get_repo_root(workspace_path.parent)
91
+ if repo_root is None:
92
+ return WorkspaceCreateResult(
93
+ success=False,
94
+ workspace=None,
95
+ error="Could not find git repository root",
96
+ )
97
+
98
+ # Build the git worktree add command
99
+ cmd = ["git", "worktree", "add"]
100
+
101
+ # Determine the base point for the new branch
102
+ if base_commit:
103
+ # Branch from specific commit
104
+ cmd.extend(["-b", workspace_name, str(workspace_path), base_commit])
105
+ elif base_branch:
106
+ # Branch from specified branch
107
+ cmd.extend(["-b", workspace_name, str(workspace_path), base_branch])
108
+ else:
109
+ # Default: branch from current HEAD
110
+ cmd.extend(["-b", workspace_name, str(workspace_path)])
111
+
112
+ result = subprocess.run(
113
+ cmd,
114
+ capture_output=True,
115
+ text=True,
116
+ timeout=60,
117
+ cwd=str(repo_root),
118
+ )
119
+
120
+ if result.returncode != 0:
121
+ return WorkspaceCreateResult(
122
+ success=False,
123
+ workspace=None,
124
+ error=result.stderr.strip() or "Failed to create worktree",
125
+ )
126
+
127
+ # Apply sparse-checkout if exclusions specified
128
+ if sparse_exclude:
129
+ sparse_error = self._apply_sparse_checkout(workspace_path, sparse_exclude)
130
+ if sparse_error:
131
+ # Non-fatal: workspace created but sparse-checkout failed
132
+ # Log warning but continue
133
+ pass
134
+
135
+ # Get workspace info for the newly created workspace
136
+ workspace_info = self.get_workspace_info(workspace_path)
137
+
138
+ return WorkspaceCreateResult(
139
+ success=True,
140
+ workspace=workspace_info,
141
+ error=None,
142
+ )
143
+
144
+ except subprocess.TimeoutExpired:
145
+ return WorkspaceCreateResult(
146
+ success=False,
147
+ workspace=None,
148
+ error="Worktree creation timed out",
149
+ )
150
+ except OSError as e:
151
+ return WorkspaceCreateResult(
152
+ success=False,
153
+ workspace=None,
154
+ error=f"OS error: {e}",
155
+ )
156
+
157
+ def _apply_sparse_checkout(
158
+ self,
159
+ workspace_path: Path,
160
+ exclude_paths: list[str],
161
+ ) -> str | None:
162
+ """
163
+ Apply sparse-checkout to exclude specified paths from worktree.
164
+
165
+ This mirrors the logic from implement.py to ensure kitty-specs/
166
+ and other paths can be excluded from worktrees for proper isolation.
167
+
168
+ Args:
169
+ workspace_path: Path to the workspace/worktree
170
+ exclude_paths: List of paths to exclude (e.g., ["kitty-specs/"])
171
+
172
+ Returns:
173
+ Error message if failed, None if successful
174
+ """
175
+ try:
176
+ # Get sparse-checkout file path via git (works for worktrees)
177
+ sparse_checkout_result = subprocess.run(
178
+ ["git", "rev-parse", "--git-path", "info/sparse-checkout"],
179
+ cwd=workspace_path,
180
+ capture_output=True,
181
+ text=True,
182
+ timeout=10,
183
+ )
184
+
185
+ if sparse_checkout_result.returncode != 0:
186
+ return "Unable to locate sparse-checkout file"
187
+
188
+ sparse_checkout_file = Path(sparse_checkout_result.stdout.strip())
189
+
190
+ # Enable sparse-checkout (disable cone mode for exclusion patterns)
191
+ subprocess.run(
192
+ ["git", "config", "core.sparseCheckout", "true"],
193
+ cwd=workspace_path,
194
+ capture_output=True,
195
+ timeout=10,
196
+ )
197
+ subprocess.run(
198
+ ["git", "config", "core.sparseCheckoutCone", "false"],
199
+ cwd=workspace_path,
200
+ capture_output=True,
201
+ timeout=10,
202
+ )
203
+
204
+ # Build sparse-checkout patterns
205
+ # Pattern: Include everything (/*), then exclude specified paths
206
+ patterns = ["/*"]
207
+ for path in exclude_paths:
208
+ # Normalize path (remove trailing slash if present)
209
+ normalized = path.rstrip("/")
210
+ patterns.append(f"!/{normalized}/")
211
+ patterns.append(f"!/{normalized}/**")
212
+
213
+ # Write sparse-checkout patterns
214
+ sparse_checkout_file.parent.mkdir(parents=True, exist_ok=True)
215
+ sparse_checkout_file.write_text("\n".join(patterns) + "\n", encoding="utf-8")
216
+
217
+ # Apply sparse-checkout (updates working tree)
218
+ apply_result = subprocess.run(
219
+ ["git", "read-tree", "-mu", "HEAD"],
220
+ cwd=workspace_path,
221
+ capture_output=True,
222
+ timeout=30,
223
+ )
224
+
225
+ if apply_result.returncode != 0:
226
+ return "Failed to apply sparse-checkout patterns"
227
+
228
+ # Also add excluded paths to .gitignore to prevent manual git add
229
+ # Sparse-checkout only controls checkout, NOT staging
230
+ worktree_gitignore = workspace_path / ".gitignore"
231
+ gitignore_entries = []
232
+ for path in exclude_paths:
233
+ normalized = path.rstrip("/")
234
+ gitignore_entries.append(f"\n# Excluded via sparse-checkout\n{normalized}/\n")
235
+
236
+ if worktree_gitignore.exists():
237
+ existing_content = worktree_gitignore.read_text(encoding="utf-8")
238
+ for entry in gitignore_entries:
239
+ path_pattern = entry.strip().split("\n")[-1]
240
+ if path_pattern and path_pattern not in existing_content:
241
+ worktree_gitignore.write_text(
242
+ existing_content.rstrip() + entry, encoding="utf-8"
243
+ )
244
+ existing_content = worktree_gitignore.read_text(encoding="utf-8")
245
+ else:
246
+ worktree_gitignore.write_text("".join(gitignore_entries).lstrip(), encoding="utf-8")
247
+
248
+ return None
249
+
250
+ except subprocess.TimeoutExpired:
251
+ return "Sparse-checkout operation timed out"
252
+ except OSError as e:
253
+ return f"OS error during sparse-checkout: {e}"
254
+
255
+ def remove_workspace(self, workspace_path: Path) -> bool:
256
+ """
257
+ Remove a git worktree.
258
+
259
+ Args:
260
+ workspace_path: Path to the workspace to remove
261
+
262
+ Returns:
263
+ True if successful, False otherwise
264
+ """
265
+ try:
266
+ # Find repo root to run git commands from
267
+ repo_root = self.get_repo_root(workspace_path)
268
+ if repo_root is None:
269
+ # Try parent directory if workspace_path is the worktree itself
270
+ repo_root = self.get_repo_root(workspace_path.parent)
271
+ if repo_root is None:
272
+ return False
273
+
274
+ result = subprocess.run(
275
+ ["git", "worktree", "remove", str(workspace_path), "--force"],
276
+ capture_output=True,
277
+ text=True,
278
+ timeout=30,
279
+ cwd=str(repo_root),
280
+ )
281
+ return result.returncode == 0
282
+ except (subprocess.TimeoutExpired, OSError):
283
+ return False
284
+
285
+ def get_workspace_info(self, workspace_path: Path) -> WorkspaceInfo | None:
286
+ """
287
+ Get information about a workspace.
288
+
289
+ Args:
290
+ workspace_path: Path to the workspace
291
+
292
+ Returns:
293
+ WorkspaceInfo or None if not a valid workspace
294
+ """
295
+ workspace_path = workspace_path.resolve()
296
+
297
+ if not workspace_path.exists():
298
+ return None
299
+
300
+ # Check if it's a worktree
301
+ git_dir = workspace_path / ".git"
302
+ if not git_dir.exists():
303
+ return None
304
+
305
+ try:
306
+ # Get current branch using existing helper from git_ops.py
307
+ current_branch = get_current_branch(workspace_path)
308
+ if current_branch == "HEAD":
309
+ current_branch = None # Detached HEAD
310
+
311
+ # Get current commit
312
+ commit_result = subprocess.run(
313
+ ["git", "-C", str(workspace_path), "rev-parse", "HEAD"],
314
+ capture_output=True,
315
+ text=True,
316
+ timeout=10,
317
+ )
318
+ current_commit = (
319
+ commit_result.stdout.strip() if commit_result.returncode == 0 else ""
320
+ )
321
+
322
+ # Check for uncommitted changes
323
+ status_result = subprocess.run(
324
+ ["git", "-C", str(workspace_path), "status", "--porcelain"],
325
+ capture_output=True,
326
+ text=True,
327
+ timeout=10,
328
+ )
329
+ has_uncommitted = bool(status_result.stdout.strip())
330
+
331
+ # Check for conflicts
332
+ has_conflicts = self.has_conflicts(workspace_path)
333
+
334
+ # Derive workspace name from path
335
+ workspace_name = workspace_path.name
336
+
337
+ # Try to determine base branch from tracking
338
+ base_branch = self._get_tracking_branch(workspace_path)
339
+
340
+ return WorkspaceInfo(
341
+ name=workspace_name,
342
+ path=workspace_path,
343
+ backend=VCSBackend.GIT,
344
+ is_colocated=False,
345
+ current_branch=current_branch,
346
+ current_change_id=None, # Git doesn't have change IDs
347
+ current_commit_id=current_commit,
348
+ base_branch=base_branch,
349
+ base_commit_id=None, # Would need to track this separately
350
+ is_stale=self.is_workspace_stale(workspace_path),
351
+ has_conflicts=has_conflicts,
352
+ has_uncommitted=has_uncommitted,
353
+ )
354
+
355
+ except (subprocess.TimeoutExpired, OSError):
356
+ return None
357
+
358
+ def list_workspaces(self, repo_root: Path) -> list[WorkspaceInfo]:
359
+ """
360
+ List all worktrees for a repository.
361
+
362
+ Args:
363
+ repo_root: Root of the repository
364
+
365
+ Returns:
366
+ List of WorkspaceInfo for all worktrees
367
+ """
368
+ try:
369
+ result = subprocess.run(
370
+ ["git", "-C", str(repo_root), "worktree", "list", "--porcelain"],
371
+ capture_output=True,
372
+ text=True,
373
+ timeout=30,
374
+ )
375
+
376
+ if result.returncode != 0:
377
+ return []
378
+
379
+ workspaces = []
380
+ lines = result.stdout.strip().split("\n")
381
+ current_path = None
382
+
383
+ for line in lines:
384
+ if line.startswith("worktree "):
385
+ current_path = Path(line[9:])
386
+ elif line == "" and current_path:
387
+ # End of entry
388
+ info = self.get_workspace_info(current_path)
389
+ if info:
390
+ workspaces.append(info)
391
+ current_path = None
392
+
393
+ # Don't forget the last entry
394
+ if current_path:
395
+ info = self.get_workspace_info(current_path)
396
+ if info:
397
+ workspaces.append(info)
398
+
399
+ return workspaces
400
+
401
+ except (subprocess.TimeoutExpired, OSError):
402
+ return []
403
+
404
+ # =========================================================================
405
+ # Sync Operations
406
+ # =========================================================================
407
+
408
+ def sync_workspace(self, workspace_path: Path) -> SyncResult:
409
+ """
410
+ Synchronize workspace with upstream changes.
411
+
412
+ For git, this fetches and attempts to rebase. Conflicts will
413
+ block the operation (unlike jj where conflicts are stored).
414
+
415
+ Args:
416
+ workspace_path: Path to the workspace to sync
417
+
418
+ Returns:
419
+ SyncResult with status, conflicts, and changes integrated
420
+ """
421
+ try:
422
+ # 1. Fetch latest
423
+ fetch_result = subprocess.run(
424
+ ["git", "-C", str(workspace_path), "fetch", "--all"],
425
+ capture_output=True,
426
+ text=True,
427
+ timeout=120,
428
+ )
429
+
430
+ if fetch_result.returncode != 0:
431
+ return SyncResult(
432
+ status=SyncStatus.FAILED,
433
+ conflicts=[],
434
+ files_updated=0,
435
+ files_added=0,
436
+ files_deleted=0,
437
+ changes_integrated=[],
438
+ message=f"Fetch failed: {fetch_result.stderr.strip()}",
439
+ )
440
+
441
+ # 2. Get the base branch to rebase onto
442
+ base_branch = self._get_tracking_branch(workspace_path)
443
+ if not base_branch:
444
+ # Try to find upstream
445
+ base_branch = self._get_upstream_branch(workspace_path)
446
+
447
+ if not base_branch:
448
+ return SyncResult(
449
+ status=SyncStatus.UP_TO_DATE,
450
+ conflicts=[],
451
+ files_updated=0,
452
+ files_added=0,
453
+ files_deleted=0,
454
+ changes_integrated=[],
455
+ message="No upstream branch configured",
456
+ )
457
+
458
+ # 3. Check if already up to date
459
+ merge_base_result = subprocess.run(
460
+ [
461
+ "git",
462
+ "-C",
463
+ str(workspace_path),
464
+ "merge-base",
465
+ "HEAD",
466
+ base_branch,
467
+ ],
468
+ capture_output=True,
469
+ text=True,
470
+ timeout=30,
471
+ )
472
+
473
+ head_result = subprocess.run(
474
+ ["git", "-C", str(workspace_path), "rev-parse", base_branch],
475
+ capture_output=True,
476
+ text=True,
477
+ timeout=10,
478
+ )
479
+
480
+ if (
481
+ merge_base_result.returncode == 0
482
+ and head_result.returncode == 0
483
+ and merge_base_result.stdout.strip() == head_result.stdout.strip()
484
+ ):
485
+ return SyncResult(
486
+ status=SyncStatus.UP_TO_DATE,
487
+ conflicts=[],
488
+ files_updated=0,
489
+ files_added=0,
490
+ files_deleted=0,
491
+ changes_integrated=[],
492
+ message="Already up to date",
493
+ )
494
+
495
+ # 4. Get commits that will be integrated
496
+ changes_to_integrate = self._get_commits_between(
497
+ workspace_path, "HEAD", base_branch
498
+ )
499
+
500
+ # 4b. Capture HEAD before rebase for stats calculation
501
+ pre_rebase_result = subprocess.run(
502
+ ["git", "-C", str(workspace_path), "rev-parse", "HEAD"],
503
+ capture_output=True,
504
+ text=True,
505
+ timeout=10,
506
+ )
507
+ pre_rebase_head = (
508
+ pre_rebase_result.stdout.strip()
509
+ if pre_rebase_result.returncode == 0
510
+ else None
511
+ )
512
+
513
+ # 5. Try rebase
514
+ rebase_result = subprocess.run(
515
+ ["git", "-C", str(workspace_path), "rebase", base_branch],
516
+ capture_output=True,
517
+ text=True,
518
+ timeout=300,
519
+ )
520
+
521
+ if rebase_result.returncode != 0:
522
+ # Check for conflicts
523
+ conflicts = self.detect_conflicts(workspace_path)
524
+ if conflicts:
525
+ return SyncResult(
526
+ status=SyncStatus.CONFLICTS,
527
+ conflicts=conflicts,
528
+ files_updated=0,
529
+ files_added=0,
530
+ files_deleted=0,
531
+ changes_integrated=changes_to_integrate,
532
+ message="Rebase has conflicts that must be resolved",
533
+ )
534
+ else:
535
+ # Abort the failed rebase
536
+ subprocess.run(
537
+ ["git", "-C", str(workspace_path), "rebase", "--abort"],
538
+ capture_output=True,
539
+ timeout=30,
540
+ )
541
+ return SyncResult(
542
+ status=SyncStatus.FAILED,
543
+ conflicts=[],
544
+ files_updated=0,
545
+ files_added=0,
546
+ files_deleted=0,
547
+ changes_integrated=[],
548
+ message=f"Rebase failed: {rebase_result.stderr.strip()}",
549
+ )
550
+
551
+ # 6. Count changed files by comparing pre/post rebase commits
552
+ files_updated, files_added, files_deleted = (0, 0, 0)
553
+ if pre_rebase_head:
554
+ files_updated, files_added, files_deleted = self._parse_rebase_stats(
555
+ workspace_path, pre_rebase_head, "HEAD"
556
+ )
557
+
558
+ return SyncResult(
559
+ status=SyncStatus.SYNCED,
560
+ conflicts=[],
561
+ files_updated=files_updated,
562
+ files_added=files_added,
563
+ files_deleted=files_deleted,
564
+ changes_integrated=changes_to_integrate,
565
+ message="Successfully rebased onto upstream",
566
+ )
567
+
568
+ except subprocess.TimeoutExpired:
569
+ raise VCSSyncError("Sync operation timed out")
570
+ except OSError as e:
571
+ raise VCSSyncError(f"OS error during sync: {e}")
572
+
573
+ def is_workspace_stale(self, workspace_path: Path) -> bool:
574
+ """
575
+ Check if workspace needs sync (base has changed).
576
+
577
+ Args:
578
+ workspace_path: Path to the workspace
579
+
580
+ Returns:
581
+ True if sync is needed, False if up-to-date
582
+ """
583
+ try:
584
+ # Get the tracking branch
585
+ base_branch = self._get_tracking_branch(workspace_path)
586
+ if not base_branch:
587
+ return False
588
+
589
+ # Compare HEAD with upstream
590
+ result = subprocess.run(
591
+ [
592
+ "git",
593
+ "-C",
594
+ str(workspace_path),
595
+ "rev-list",
596
+ "--count",
597
+ f"HEAD..{base_branch}",
598
+ ],
599
+ capture_output=True,
600
+ text=True,
601
+ timeout=30,
602
+ )
603
+
604
+ if result.returncode != 0:
605
+ return False
606
+
607
+ # If there are commits in upstream not in HEAD, we're stale
608
+ count = int(result.stdout.strip()) if result.stdout.strip() else 0
609
+ return count > 0
610
+
611
+ except (subprocess.TimeoutExpired, OSError, ValueError):
612
+ return False
613
+
614
+ # =========================================================================
615
+ # Conflict Operations
616
+ # =========================================================================
617
+
618
+ def detect_conflicts(self, workspace_path: Path) -> list[ConflictInfo]:
619
+ """
620
+ Detect conflicts in a workspace.
621
+
622
+ Args:
623
+ workspace_path: Path to the workspace
624
+
625
+ Returns:
626
+ List of ConflictInfo for all conflicted files
627
+ """
628
+ try:
629
+ # Get list of conflicted files
630
+ result = subprocess.run(
631
+ ["git", "-C", str(workspace_path), "diff", "--name-only", "--diff-filter=U"],
632
+ capture_output=True,
633
+ text=True,
634
+ timeout=30,
635
+ )
636
+
637
+ if result.returncode != 0 or not result.stdout.strip():
638
+ # Also check git status for unmerged paths
639
+ status_result = subprocess.run(
640
+ ["git", "-C", str(workspace_path), "status", "--porcelain"],
641
+ capture_output=True,
642
+ text=True,
643
+ timeout=30,
644
+ )
645
+
646
+ conflicts = []
647
+ for line in status_result.stdout.strip().split("\n"):
648
+ if line and line[:2] in ("UU", "AA", "DD", "AU", "UA", "DU", "UD"):
649
+ file_path = Path(line[3:].strip())
650
+ conflict_type = self._status_to_conflict_type(line[:2])
651
+ full_path = workspace_path / file_path
652
+
653
+ line_ranges = None
654
+ if full_path.exists() and conflict_type == ConflictType.CONTENT:
655
+ line_ranges = self._parse_conflict_markers(full_path)
656
+
657
+ conflicts.append(
658
+ ConflictInfo(
659
+ file_path=file_path,
660
+ conflict_type=conflict_type,
661
+ line_ranges=line_ranges,
662
+ sides=2,
663
+ is_resolved=False,
664
+ our_content=None,
665
+ their_content=None,
666
+ base_content=None,
667
+ )
668
+ )
669
+ return conflicts
670
+
671
+ conflicts = []
672
+ for line in result.stdout.strip().split("\n"):
673
+ if not line:
674
+ continue
675
+
676
+ file_path = Path(line.strip())
677
+ full_path = workspace_path / file_path
678
+
679
+ # Parse conflict markers to get line ranges
680
+ line_ranges = None
681
+ if full_path.exists():
682
+ line_ranges = self._parse_conflict_markers(full_path)
683
+
684
+ conflicts.append(
685
+ ConflictInfo(
686
+ file_path=file_path,
687
+ conflict_type=ConflictType.CONTENT,
688
+ line_ranges=line_ranges,
689
+ sides=2,
690
+ is_resolved=False,
691
+ our_content=None, # Could extract from markers
692
+ their_content=None,
693
+ base_content=None,
694
+ )
695
+ )
696
+
697
+ return conflicts
698
+
699
+ except (subprocess.TimeoutExpired, OSError):
700
+ return []
701
+
702
+ def has_conflicts(self, workspace_path: Path) -> bool:
703
+ """
704
+ Check if workspace has any unresolved conflicts.
705
+
706
+ Args:
707
+ workspace_path: Path to the workspace
708
+
709
+ Returns:
710
+ True if conflicts exist, False otherwise
711
+ """
712
+ try:
713
+ result = subprocess.run(
714
+ ["git", "-C", str(workspace_path), "diff", "--name-only", "--diff-filter=U"],
715
+ capture_output=True,
716
+ text=True,
717
+ timeout=30,
718
+ )
719
+
720
+ if result.returncode == 0 and result.stdout.strip():
721
+ return True
722
+
723
+ # Also check git status
724
+ status_result = subprocess.run(
725
+ ["git", "-C", str(workspace_path), "status", "--porcelain"],
726
+ capture_output=True,
727
+ text=True,
728
+ timeout=30,
729
+ )
730
+
731
+ for line in status_result.stdout.strip().split("\n"):
732
+ if line and line[:2] in ("UU", "AA", "DD", "AU", "UA", "DU", "UD"):
733
+ return True
734
+
735
+ return False
736
+
737
+ except (subprocess.TimeoutExpired, OSError):
738
+ return False
739
+
740
+ # =========================================================================
741
+ # Commit/Change Operations
742
+ # =========================================================================
743
+
744
+ def get_current_change(self, workspace_path: Path) -> ChangeInfo | None:
745
+ """
746
+ Get info about current working copy commit/change.
747
+
748
+ Args:
749
+ workspace_path: Path to the workspace
750
+
751
+ Returns:
752
+ ChangeInfo for current HEAD, None if invalid
753
+ """
754
+ try:
755
+ result = subprocess.run(
756
+ [
757
+ "git",
758
+ "-C",
759
+ str(workspace_path),
760
+ "log",
761
+ "-1",
762
+ "--format=%H|%an|%ae|%at|%s|%P|%B",
763
+ ],
764
+ capture_output=True,
765
+ text=True,
766
+ timeout=30,
767
+ )
768
+
769
+ if result.returncode != 0 or not result.stdout.strip():
770
+ return None
771
+
772
+ return self._parse_log_line(result.stdout.strip())
773
+
774
+ except (subprocess.TimeoutExpired, OSError):
775
+ return None
776
+
777
+ def get_changes(
778
+ self,
779
+ repo_path: Path,
780
+ revision_range: str | None = None,
781
+ limit: int | None = None,
782
+ ) -> list[ChangeInfo]:
783
+ """
784
+ Get list of changes/commits.
785
+
786
+ Args:
787
+ repo_path: Repository path
788
+ revision_range: Git revision range (e.g., "main..HEAD")
789
+ limit: Maximum number to return
790
+
791
+ Returns:
792
+ List of ChangeInfo
793
+ """
794
+ try:
795
+ cmd = [
796
+ "git",
797
+ "-C",
798
+ str(repo_path),
799
+ "log",
800
+ "--format=%H|%an|%ae|%at|%s|%P",
801
+ ]
802
+
803
+ if limit:
804
+ cmd.append(f"-{limit}")
805
+
806
+ if revision_range:
807
+ cmd.append(revision_range)
808
+
809
+ result = subprocess.run(
810
+ cmd,
811
+ capture_output=True,
812
+ text=True,
813
+ timeout=60,
814
+ )
815
+
816
+ if result.returncode != 0:
817
+ return []
818
+
819
+ changes = []
820
+ for line in result.stdout.strip().split("\n"):
821
+ if line:
822
+ change = self._parse_log_line_short(line)
823
+ if change:
824
+ changes.append(change)
825
+
826
+ return changes
827
+
828
+ except (subprocess.TimeoutExpired, OSError):
829
+ return []
830
+
831
+ def commit(
832
+ self,
833
+ workspace_path: Path,
834
+ message: str,
835
+ paths: list[Path] | None = None,
836
+ ) -> ChangeInfo | None:
837
+ """
838
+ Create a commit with current changes.
839
+
840
+ Args:
841
+ workspace_path: Workspace to commit in
842
+ message: Commit message
843
+ paths: Specific paths to commit (None = all)
844
+
845
+ Returns:
846
+ ChangeInfo for new commit, None if nothing to commit
847
+ """
848
+ try:
849
+ # Stage files
850
+ if paths:
851
+ for path in paths:
852
+ subprocess.run(
853
+ ["git", "-C", str(workspace_path), "add", str(path)],
854
+ capture_output=True,
855
+ timeout=30,
856
+ )
857
+ else:
858
+ subprocess.run(
859
+ ["git", "-C", str(workspace_path), "add", "-A"],
860
+ capture_output=True,
861
+ timeout=30,
862
+ )
863
+
864
+ # Check if there are staged changes
865
+ status_result = subprocess.run(
866
+ ["git", "-C", str(workspace_path), "diff", "--cached", "--quiet"],
867
+ capture_output=True,
868
+ timeout=30,
869
+ )
870
+
871
+ if status_result.returncode == 0:
872
+ # No changes to commit
873
+ return None
874
+
875
+ # Commit
876
+ commit_result = subprocess.run(
877
+ ["git", "-C", str(workspace_path), "commit", "-m", message],
878
+ capture_output=True,
879
+ text=True,
880
+ timeout=60,
881
+ )
882
+
883
+ if commit_result.returncode != 0:
884
+ return None
885
+
886
+ # Return info about the new commit
887
+ return self.get_current_change(workspace_path)
888
+
889
+ except (subprocess.TimeoutExpired, OSError):
890
+ return None
891
+
892
+ # =========================================================================
893
+ # Repository Operations
894
+ # =========================================================================
895
+
896
+ def init_repo(self, path: Path, colocate: bool = True) -> bool:
897
+ """
898
+ Initialize a new git repository.
899
+
900
+ Args:
901
+ path: Where to initialize
902
+ colocate: Ignored for git (only relevant for jj)
903
+
904
+ Returns:
905
+ True if successful, False otherwise
906
+ """
907
+ try:
908
+ path.mkdir(parents=True, exist_ok=True)
909
+ result = subprocess.run(
910
+ ["git", "init"],
911
+ cwd=str(path),
912
+ capture_output=True,
913
+ timeout=30,
914
+ )
915
+ return result.returncode == 0
916
+ except (subprocess.TimeoutExpired, OSError):
917
+ return False
918
+
919
+ def is_repo(self, path: Path) -> bool:
920
+ """
921
+ Check if path is inside a git repository.
922
+
923
+ Wraps existing is_git_repo from git_ops.py.
924
+
925
+ Args:
926
+ path: Path to check
927
+
928
+ Returns:
929
+ True if valid git repository
930
+ """
931
+ return is_git_repo(path)
932
+
933
+ def get_repo_root(self, path: Path) -> Path | None:
934
+ """
935
+ Get root directory of repository containing path.
936
+
937
+ Args:
938
+ path: Path within the repository
939
+
940
+ Returns:
941
+ Repository root or None if not in a repo
942
+ """
943
+ try:
944
+ result = subprocess.run(
945
+ ["git", "rev-parse", "--show-toplevel"],
946
+ cwd=str(path),
947
+ capture_output=True,
948
+ text=True,
949
+ timeout=10,
950
+ )
951
+
952
+ if result.returncode == 0 and result.stdout.strip():
953
+ return Path(result.stdout.strip())
954
+ return None
955
+
956
+ except (subprocess.TimeoutExpired, OSError):
957
+ return None
958
+
959
+ # =========================================================================
960
+ # Private Helper Methods
961
+ # =========================================================================
962
+
963
+ def _get_tracking_branch(self, workspace_path: Path) -> str | None:
964
+ """Get the tracking branch for the current branch."""
965
+ try:
966
+ result = subprocess.run(
967
+ [
968
+ "git",
969
+ "-C",
970
+ str(workspace_path),
971
+ "rev-parse",
972
+ "--abbrev-ref",
973
+ "--symbolic-full-name",
974
+ "@{u}",
975
+ ],
976
+ capture_output=True,
977
+ text=True,
978
+ timeout=10,
979
+ )
980
+ if result.returncode == 0:
981
+ return result.stdout.strip()
982
+ return None
983
+ except (subprocess.TimeoutExpired, OSError):
984
+ return None
985
+
986
+ def _get_upstream_branch(self, workspace_path: Path) -> str | None:
987
+ """Try to find the upstream branch (origin/main or origin/master)."""
988
+ for branch in ["origin/main", "origin/master", "main", "master"]:
989
+ try:
990
+ result = subprocess.run(
991
+ [
992
+ "git",
993
+ "-C",
994
+ str(workspace_path),
995
+ "rev-parse",
996
+ "--verify",
997
+ branch,
998
+ ],
999
+ capture_output=True,
1000
+ timeout=10,
1001
+ )
1002
+ if result.returncode == 0:
1003
+ return branch
1004
+ except (subprocess.TimeoutExpired, OSError):
1005
+ continue
1006
+ return None
1007
+
1008
+ def _get_commits_between(
1009
+ self, workspace_path: Path, from_ref: str, to_ref: str
1010
+ ) -> list[ChangeInfo]:
1011
+ """Get commits between two refs."""
1012
+ return self.get_changes(workspace_path, f"{from_ref}..{to_ref}")
1013
+
1014
+ def _parse_rebase_stats(
1015
+ self,
1016
+ workspace_path: Path,
1017
+ before_commit: str,
1018
+ after_commit: str,
1019
+ ) -> tuple[int, int, int]:
1020
+ """
1021
+ Calculate file statistics from a rebase by diffing before/after commits.
1022
+
1023
+ Git rebase doesn't give detailed stats in machine-readable format during
1024
+ the rebase itself, so we compute the diff between the commit before and
1025
+ after the rebase to determine files updated/added/deleted.
1026
+
1027
+ Args:
1028
+ workspace_path: Path to the workspace
1029
+ before_commit: Commit SHA before rebase
1030
+ after_commit: Commit SHA after rebase (typically HEAD)
1031
+
1032
+ Returns:
1033
+ Tuple of (files_updated, files_added, files_deleted)
1034
+ """
1035
+ try:
1036
+ # Use git diff --name-status to get file changes
1037
+ result = subprocess.run(
1038
+ [
1039
+ "git",
1040
+ "-C",
1041
+ str(workspace_path),
1042
+ "diff",
1043
+ "--name-status",
1044
+ before_commit,
1045
+ after_commit,
1046
+ ],
1047
+ capture_output=True,
1048
+ text=True,
1049
+ timeout=30,
1050
+ )
1051
+
1052
+ if result.returncode != 0:
1053
+ return (0, 0, 0)
1054
+
1055
+ files_updated = 0
1056
+ files_added = 0
1057
+ files_deleted = 0
1058
+
1059
+ for line in result.stdout.strip().split("\n"):
1060
+ if not line:
1061
+ continue
1062
+ # Format: "M\tfilename" or "A\tfilename" or "D\tfilename"
1063
+ # Also handles "R100\told\tnew" for renames
1064
+ status = line[0]
1065
+ if status == "M":
1066
+ files_updated += 1
1067
+ elif status == "A":
1068
+ files_added += 1
1069
+ elif status == "D":
1070
+ files_deleted += 1
1071
+ elif status == "R":
1072
+ # Rename counts as delete + add
1073
+ files_deleted += 1
1074
+ files_added += 1
1075
+
1076
+ return (files_updated, files_added, files_deleted)
1077
+
1078
+ except (subprocess.TimeoutExpired, OSError):
1079
+ return (0, 0, 0)
1080
+
1081
+ def _parse_conflict_markers(self, file_path: Path) -> list[tuple[int, int]]:
1082
+ """Find line ranges with conflict markers."""
1083
+ ranges = []
1084
+ in_conflict = False
1085
+ start_line = 0
1086
+
1087
+ try:
1088
+ with open(file_path, "r", encoding="utf-8", errors="replace") as f:
1089
+ for i, line in enumerate(f, 1):
1090
+ if line.startswith("<<<<<<<"):
1091
+ in_conflict = True
1092
+ start_line = i
1093
+ elif line.startswith(">>>>>>>") and in_conflict:
1094
+ ranges.append((start_line, i))
1095
+ in_conflict = False
1096
+ except OSError:
1097
+ pass
1098
+
1099
+ return ranges
1100
+
1101
+ def _status_to_conflict_type(self, status: str) -> ConflictType:
1102
+ """Convert git status code to ConflictType."""
1103
+ if status == "UU":
1104
+ return ConflictType.CONTENT
1105
+ elif status == "AA":
1106
+ return ConflictType.ADD_ADD
1107
+ elif status == "DD":
1108
+ return ConflictType.MODIFY_DELETE
1109
+ elif status in ("AU", "UA"):
1110
+ return ConflictType.MODIFY_DELETE
1111
+ elif status in ("DU", "UD"):
1112
+ return ConflictType.MODIFY_DELETE
1113
+ return ConflictType.CONTENT
1114
+
1115
+ def _parse_log_line(self, line: str) -> ChangeInfo | None:
1116
+ """Parse a git log line with full body."""
1117
+ try:
1118
+ parts = line.split("|", 6)
1119
+ if len(parts) < 6:
1120
+ return None
1121
+
1122
+ commit_id = parts[0]
1123
+ author = parts[1]
1124
+ author_email = parts[2]
1125
+ timestamp = datetime.fromtimestamp(int(parts[3]), tz=timezone.utc)
1126
+ message = parts[4]
1127
+ parents = parts[5].split() if parts[5] else []
1128
+ message_full = parts[6] if len(parts) > 6 else message
1129
+
1130
+ return ChangeInfo(
1131
+ change_id=None, # Git doesn't have change IDs
1132
+ commit_id=commit_id,
1133
+ message=message,
1134
+ message_full=message_full,
1135
+ author=author,
1136
+ author_email=author_email,
1137
+ timestamp=timestamp,
1138
+ parents=parents,
1139
+ is_merge=len(parents) > 1,
1140
+ is_conflicted=False,
1141
+ is_empty=False,
1142
+ )
1143
+ except (ValueError, IndexError):
1144
+ return None
1145
+
1146
+ def _parse_log_line_short(self, line: str) -> ChangeInfo | None:
1147
+ """Parse a git log line without full body."""
1148
+ try:
1149
+ parts = line.split("|", 5)
1150
+ if len(parts) < 5:
1151
+ return None
1152
+
1153
+ commit_id = parts[0]
1154
+ author = parts[1]
1155
+ author_email = parts[2]
1156
+ timestamp = datetime.fromtimestamp(int(parts[3]), tz=timezone.utc)
1157
+ message = parts[4]
1158
+ parents = parts[5].split() if len(parts) > 5 and parts[5] else []
1159
+
1160
+ return ChangeInfo(
1161
+ change_id=None,
1162
+ commit_id=commit_id,
1163
+ message=message,
1164
+ message_full=message,
1165
+ author=author,
1166
+ author_email=author_email,
1167
+ timestamp=timestamp,
1168
+ parents=parents,
1169
+ is_merge=len(parents) > 1,
1170
+ is_conflicted=False,
1171
+ is_empty=False,
1172
+ )
1173
+ except (ValueError, IndexError):
1174
+ return None
1175
+
1176
+
1177
+ # =============================================================================
1178
+ # Git-Specific Standalone Functions
1179
+ # =============================================================================
1180
+
1181
+
1182
+ def git_get_reflog(repo_path: Path, limit: int = 20) -> list[OperationInfo]:
1183
+ """
1184
+ Get git reflog as operation history.
1185
+
1186
+ git-specific: Less powerful than jj operation log, but provides
1187
+ some visibility into repository history.
1188
+
1189
+ Args:
1190
+ repo_path: Repository path
1191
+ limit: Maximum number of entries to return
1192
+
1193
+ Returns:
1194
+ List of OperationInfo from reflog
1195
+ """
1196
+ try:
1197
+ result = subprocess.run(
1198
+ [
1199
+ "git",
1200
+ "-C",
1201
+ str(repo_path),
1202
+ "reflog",
1203
+ f"-{limit}",
1204
+ "--format=%H|%gD|%gs|%ci",
1205
+ ],
1206
+ capture_output=True,
1207
+ text=True,
1208
+ timeout=30,
1209
+ )
1210
+
1211
+ if result.returncode != 0:
1212
+ return []
1213
+
1214
+ operations = []
1215
+ for i, line in enumerate(result.stdout.strip().split("\n")):
1216
+ if not line:
1217
+ continue
1218
+ try:
1219
+ parts = line.split("|", 3)
1220
+ if len(parts) < 4:
1221
+ continue
1222
+
1223
+ commit_id = parts[0]
1224
+ ref = parts[1]
1225
+ description = parts[2]
1226
+ timestamp_str = parts[3]
1227
+
1228
+ # Parse timestamp
1229
+ try:
1230
+ timestamp = datetime.fromisoformat(
1231
+ timestamp_str.replace(" ", "T").replace(" ", "")
1232
+ )
1233
+ except ValueError:
1234
+ timestamp = datetime.now(timezone.utc)
1235
+
1236
+ operations.append(
1237
+ OperationInfo(
1238
+ operation_id=f"reflog-{i}",
1239
+ timestamp=timestamp,
1240
+ description=description,
1241
+ heads=[commit_id],
1242
+ working_copy_commit=commit_id,
1243
+ is_undoable=False, # Git reflog entries aren't truly undoable
1244
+ parent_operation=f"reflog-{i+1}" if i < limit - 1 else None,
1245
+ )
1246
+ )
1247
+ except (ValueError, IndexError):
1248
+ continue
1249
+
1250
+ return operations
1251
+
1252
+ except (subprocess.TimeoutExpired, OSError):
1253
+ return []
1254
+
1255
+
1256
+ def git_stash(workspace_path: Path, message: str | None = None) -> bool:
1257
+ """
1258
+ Stash working directory changes.
1259
+
1260
+ git-specific: jj doesn't need stash (working copy always committed).
1261
+
1262
+ Args:
1263
+ workspace_path: Workspace path
1264
+ message: Optional stash message
1265
+
1266
+ Returns:
1267
+ True if successful
1268
+ """
1269
+ try:
1270
+ cmd = ["git", "-C", str(workspace_path), "stash", "push"]
1271
+ if message:
1272
+ cmd.extend(["-m", message])
1273
+
1274
+ result = subprocess.run(
1275
+ cmd,
1276
+ capture_output=True,
1277
+ timeout=30,
1278
+ )
1279
+ return result.returncode == 0
1280
+ except (subprocess.TimeoutExpired, OSError):
1281
+ return False
1282
+
1283
+
1284
+ def git_stash_pop(workspace_path: Path) -> bool:
1285
+ """
1286
+ Pop stashed changes.
1287
+
1288
+ git-specific: jj doesn't need stash.
1289
+
1290
+ Args:
1291
+ workspace_path: Workspace path
1292
+
1293
+ Returns:
1294
+ True if successful
1295
+ """
1296
+ try:
1297
+ result = subprocess.run(
1298
+ ["git", "-C", str(workspace_path), "stash", "pop"],
1299
+ capture_output=True,
1300
+ timeout=30,
1301
+ )
1302
+ return result.returncode == 0
1303
+ except (subprocess.TimeoutExpired, OSError):
1304
+ return False