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,1208 @@
1
+ """
2
+ Jujutsu VCS Implementation
3
+ ==========================
4
+
5
+ Full implementation of JujutsuVCS that wraps jj CLI commands.
6
+ Implements VCSProtocol for workspace management, sync operations,
7
+ conflict detection, and commit operations.
8
+
9
+ Key differences from Git:
10
+ - Conflicts are stored in commits (non-blocking) instead of blocking operations
11
+ - Working copy is always a commit (no staging area)
12
+ - Change IDs are stable across rebases
13
+ - Full operation log with undo capability
14
+ - Native workspace support
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import re
20
+ import shutil
21
+ import subprocess
22
+ from datetime import datetime, timezone
23
+ from pathlib import Path
24
+
25
+ from .exceptions import VCSSyncError
26
+ from .types import (
27
+ ChangeInfo,
28
+ ConflictInfo,
29
+ ConflictType,
30
+ JJ_CAPABILITIES,
31
+ OperationInfo,
32
+ SyncResult,
33
+ SyncStatus,
34
+ VCSBackend,
35
+ VCSCapabilities,
36
+ WorkspaceCreateResult,
37
+ WorkspaceInfo,
38
+ )
39
+
40
+
41
+ # Known benign stderr patterns from jj that should NOT be treated as errors
42
+ # These are info messages, hints, and warnings that jj prints to stderr
43
+ JJ_BENIGN_STDERR_PATTERNS = [
44
+ "Reset the working copy parent to",
45
+ "Done importing changes from the underlying Git repo",
46
+ "Created workspace in",
47
+ "Working copy",
48
+ "Parent commit",
49
+ "Added ",
50
+ "Warning:",
51
+ "Hint:",
52
+ "Concurrent modification detected, resolving automatically",
53
+ ]
54
+
55
+
56
+ def _extract_jj_error(stderr: str) -> str | None:
57
+ """
58
+ Extract actual error message from jj stderr output.
59
+
60
+ jj prints various informational messages to stderr (hints, warnings,
61
+ status updates) even during successful operations. This function
62
+ filters out benign messages and extracts only actual errors.
63
+
64
+ Actual jj errors start with "Error:" at the beginning of a line.
65
+
66
+ Args:
67
+ stderr: Raw stderr output from jj command
68
+
69
+ Returns:
70
+ Error message if found, None if no actual error
71
+ """
72
+ if not stderr:
73
+ return None
74
+
75
+ lines = stderr.strip().split("\n")
76
+ error_lines = []
77
+
78
+ for line in lines:
79
+ stripped = line.strip()
80
+
81
+ # Actual jj errors start with "Error:"
82
+ if stripped.startswith("Error:"):
83
+ error_lines.append(stripped)
84
+ # Also capture "Caused by:" lines that follow errors
85
+ elif stripped.startswith("Caused by:") and error_lines:
86
+ error_lines.append(stripped)
87
+ # Skip benign patterns
88
+ elif any(pattern in stripped for pattern in JJ_BENIGN_STDERR_PATTERNS):
89
+ continue
90
+ # Skip empty lines
91
+ elif not stripped:
92
+ continue
93
+
94
+ if error_lines:
95
+ return " ".join(error_lines)
96
+
97
+ return None
98
+
99
+
100
+ class JujutsuVCS:
101
+ """
102
+ Jujutsu VCS implementation.
103
+
104
+ Implements VCSProtocol for jj repositories, wrapping jj CLI commands
105
+ for workspace management, synchronization, conflict detection, and commits.
106
+
107
+ jj differs from git in key ways:
108
+ - Conflicts don't block operations - they're stored in commits
109
+ - Working copy is always a commit (use `jj describe` to set message)
110
+ - Change IDs are stable across rebases
111
+ - Full operation log with undo capability
112
+ """
113
+
114
+ @property
115
+ def backend(self) -> VCSBackend:
116
+ """Return which backend this is."""
117
+ return VCSBackend.JUJUTSU
118
+
119
+ @property
120
+ def capabilities(self) -> VCSCapabilities:
121
+ """Return capabilities of this backend."""
122
+ return JJ_CAPABILITIES
123
+
124
+ # =========================================================================
125
+ # Workspace Operations
126
+ # =========================================================================
127
+
128
+ def create_workspace(
129
+ self,
130
+ workspace_path: Path,
131
+ workspace_name: str,
132
+ base_branch: str | None = None,
133
+ base_commit: str | None = None,
134
+ repo_root: Path | None = None,
135
+ ) -> WorkspaceCreateResult:
136
+ """
137
+ Create a new jj workspace.
138
+
139
+ Args:
140
+ workspace_path: Where to create the workspace
141
+ workspace_name: Name for the workspace
142
+ base_branch: Branch/revision to base on
143
+ base_commit: Specific commit/change to base on
144
+ repo_root: Root of the jj repository (auto-detected if not provided)
145
+
146
+ Returns:
147
+ WorkspaceCreateResult with workspace info or error
148
+ """
149
+ try:
150
+ # Ensure parent directory exists
151
+ workspace_path.parent.mkdir(parents=True, exist_ok=True)
152
+
153
+ # Find repo root to run jj commands from
154
+ if repo_root is None:
155
+ repo_root = self.get_repo_root(workspace_path.parent)
156
+ if repo_root is None:
157
+ return WorkspaceCreateResult(
158
+ success=False,
159
+ workspace=None,
160
+ error="Could not find jj repository root",
161
+ )
162
+
163
+ # Build the jj workspace add command
164
+ cmd = ["jj", "workspace", "add", str(workspace_path), "--name", workspace_name]
165
+
166
+ # Add revision if specified
167
+ if base_commit:
168
+ cmd.extend(["--revision", base_commit])
169
+ elif base_branch:
170
+ cmd.extend(["--revision", base_branch])
171
+
172
+ result = subprocess.run(
173
+ cmd,
174
+ capture_output=True,
175
+ text=True,
176
+ timeout=60,
177
+ cwd=str(repo_root),
178
+ )
179
+
180
+ # jj has quirky error handling - sometimes returns exit 0 with "Error:" in stderr
181
+ # Check for actual errors in stderr even if returncode is 0
182
+ jj_error = _extract_jj_error(result.stderr)
183
+
184
+ if result.returncode != 0:
185
+ # Prefer extracted error over raw stderr
186
+ error_msg = jj_error or result.stderr.strip() or "Failed to create workspace"
187
+ return WorkspaceCreateResult(
188
+ success=False,
189
+ workspace=None,
190
+ error=error_msg,
191
+ )
192
+
193
+ # Even with returncode 0, jj might have printed "Error:" to stderr
194
+ if jj_error:
195
+ return WorkspaceCreateResult(
196
+ success=False,
197
+ workspace=None,
198
+ error=jj_error,
199
+ )
200
+
201
+ # Create a bookmark pointing to the workspace's current revision
202
+ # This is necessary because dependent WPs need to reference this
203
+ # workspace by name (e.g., "001-feature-WP01" as a base revision)
204
+ # Unlike git worktree which auto-creates branches, jj workspace add
205
+ # does NOT create bookmarks.
206
+ bookmark_result = subprocess.run(
207
+ ["jj", "bookmark", "create", workspace_name, "-r", "@"],
208
+ capture_output=True,
209
+ text=True,
210
+ timeout=30,
211
+ cwd=str(workspace_path), # Run from new workspace
212
+ )
213
+
214
+ # Check for bookmark creation errors
215
+ bookmark_error = _extract_jj_error(bookmark_result.stderr)
216
+ if bookmark_result.returncode != 0 or bookmark_error:
217
+ # Workspace was created but bookmark failed - clean up and report
218
+ error_msg = bookmark_error or bookmark_result.stderr.strip() or "Failed to create bookmark"
219
+ # Try to clean up the workspace
220
+ try:
221
+ subprocess.run(
222
+ ["jj", "workspace", "forget", workspace_name],
223
+ capture_output=True,
224
+ timeout=30,
225
+ cwd=str(repo_root),
226
+ )
227
+ shutil.rmtree(workspace_path, ignore_errors=True)
228
+ except Exception:
229
+ pass
230
+ return WorkspaceCreateResult(
231
+ success=False,
232
+ workspace=None,
233
+ error=f"Workspace created but bookmark failed: {error_msg}",
234
+ )
235
+
236
+ # Get workspace info for the newly created workspace
237
+ workspace_info = self.get_workspace_info(workspace_path)
238
+
239
+ return WorkspaceCreateResult(
240
+ success=True,
241
+ workspace=workspace_info,
242
+ error=None,
243
+ )
244
+
245
+ except subprocess.TimeoutExpired:
246
+ return WorkspaceCreateResult(
247
+ success=False,
248
+ workspace=None,
249
+ error="Workspace creation timed out",
250
+ )
251
+ except OSError as e:
252
+ return WorkspaceCreateResult(
253
+ success=False,
254
+ workspace=None,
255
+ error=f"OS error: {e}",
256
+ )
257
+
258
+ def remove_workspace(self, workspace_path: Path) -> bool:
259
+ """
260
+ Remove a jj workspace.
261
+
262
+ Uses `jj workspace forget` to unregister the workspace,
263
+ then removes the directory.
264
+
265
+ Args:
266
+ workspace_path: Path to the workspace to remove
267
+
268
+ Returns:
269
+ True if successful, False otherwise
270
+ """
271
+ try:
272
+ # First, find repo root and workspace name
273
+ repo_root = self.get_repo_root(workspace_path)
274
+ if repo_root is None:
275
+ return False
276
+
277
+ # Get workspace name from the directory
278
+ workspace_name = workspace_path.name
279
+
280
+ # Use jj workspace forget to unregister
281
+ result = subprocess.run(
282
+ ["jj", "workspace", "forget", workspace_name],
283
+ capture_output=True,
284
+ text=True,
285
+ timeout=30,
286
+ cwd=str(repo_root),
287
+ )
288
+
289
+ # Also delete the associated bookmark (created during create_workspace)
290
+ subprocess.run(
291
+ ["jj", "bookmark", "delete", workspace_name],
292
+ capture_output=True,
293
+ text=True,
294
+ timeout=30,
295
+ cwd=str(repo_root),
296
+ )
297
+
298
+ # Remove the directory even if forget failed
299
+ if workspace_path.exists():
300
+ shutil.rmtree(workspace_path)
301
+
302
+ return True
303
+ except (subprocess.TimeoutExpired, OSError):
304
+ return False
305
+
306
+ def get_workspace_info(self, workspace_path: Path) -> WorkspaceInfo | None:
307
+ """
308
+ Get information about a workspace.
309
+
310
+ Args:
311
+ workspace_path: Path to the workspace
312
+
313
+ Returns:
314
+ WorkspaceInfo or None if not a valid workspace
315
+ """
316
+ workspace_path = workspace_path.resolve()
317
+
318
+ if not workspace_path.exists():
319
+ return None
320
+
321
+ # Check if it's a jj repo (has .jj directory)
322
+ jj_dir = workspace_path / ".jj"
323
+ if not jj_dir.exists():
324
+ return None
325
+
326
+ try:
327
+ # Check for colocated mode
328
+ git_dir = workspace_path / ".git"
329
+ is_colocated = git_dir.exists()
330
+
331
+ # Get current change info using jj log
332
+ log_result = subprocess.run(
333
+ [
334
+ "jj",
335
+ "log",
336
+ "-r",
337
+ "@",
338
+ "--no-graph",
339
+ "-T",
340
+ 'change_id ++ "|" ++ commit_id ++ "|" ++ description.first_line() ++ "|" ++ if(conflict, "conflict", "") ++ "\n"',
341
+ ],
342
+ capture_output=True,
343
+ text=True,
344
+ timeout=10,
345
+ cwd=str(workspace_path),
346
+ )
347
+
348
+ if log_result.returncode != 0:
349
+ return None
350
+
351
+ # Parse the log output
352
+ line = log_result.stdout.strip().split("\n")[0] if log_result.stdout.strip() else ""
353
+ parts = line.split("|") if line else []
354
+
355
+ current_change_id = parts[0] if len(parts) > 0 else None
356
+ current_commit_id = parts[1] if len(parts) > 1 else ""
357
+ has_conflicts = len(parts) > 3 and parts[3] == "conflict"
358
+
359
+ # Check for uncommitted changes (in jj, working copy is always committed)
360
+ status_result = subprocess.run(
361
+ ["jj", "status"],
362
+ capture_output=True,
363
+ text=True,
364
+ timeout=10,
365
+ cwd=str(workspace_path),
366
+ )
367
+
368
+ has_uncommitted = "Working copy changes:" in status_result.stdout
369
+
370
+ # Derive workspace name from path
371
+ workspace_name = workspace_path.name
372
+
373
+ return WorkspaceInfo(
374
+ name=workspace_name,
375
+ path=workspace_path,
376
+ backend=VCSBackend.JUJUTSU,
377
+ is_colocated=is_colocated,
378
+ current_branch=None, # jj doesn't use branches the same way
379
+ current_change_id=current_change_id,
380
+ current_commit_id=current_commit_id,
381
+ base_branch=None,
382
+ base_commit_id=None,
383
+ is_stale=self.is_workspace_stale(workspace_path),
384
+ has_conflicts=has_conflicts,
385
+ has_uncommitted=has_uncommitted,
386
+ )
387
+
388
+ except (subprocess.TimeoutExpired, OSError):
389
+ return None
390
+
391
+ def list_workspaces(self, repo_root: Path) -> list[WorkspaceInfo]:
392
+ """
393
+ List all workspaces for a repository.
394
+
395
+ Args:
396
+ repo_root: Root of the repository
397
+
398
+ Returns:
399
+ List of WorkspaceInfo for all workspaces
400
+ """
401
+ try:
402
+ result = subprocess.run(
403
+ ["jj", "workspace", "list"],
404
+ capture_output=True,
405
+ text=True,
406
+ timeout=30,
407
+ cwd=str(repo_root),
408
+ )
409
+
410
+ if result.returncode != 0:
411
+ return []
412
+
413
+ workspaces = []
414
+ # Parse output like: "default: xvsrlyox 66070197 (no description set)"
415
+ for line in result.stdout.strip().split("\n"):
416
+ if not line or ":" not in line:
417
+ continue
418
+
419
+ workspace_name = line.split(":")[0].strip()
420
+
421
+ # For default workspace, the path is repo_root
422
+ if workspace_name == "default":
423
+ workspace_path = repo_root
424
+ else:
425
+ # For other workspaces, we need to find them
426
+ # jj workspace list doesn't show paths, so we check common locations
427
+ potential_paths = [
428
+ repo_root.parent / workspace_name,
429
+ repo_root / ".worktrees" / workspace_name,
430
+ ]
431
+ workspace_path = None
432
+ for path in potential_paths:
433
+ if path.exists() and (path / ".jj").exists():
434
+ workspace_path = path
435
+ break
436
+
437
+ if workspace_path is None:
438
+ continue
439
+
440
+ info = self.get_workspace_info(workspace_path)
441
+ if info:
442
+ workspaces.append(info)
443
+
444
+ return workspaces
445
+
446
+ except (subprocess.TimeoutExpired, OSError):
447
+ return []
448
+
449
+ # =========================================================================
450
+ # Sync Operations
451
+ # =========================================================================
452
+
453
+ def sync_workspace(self, workspace_path: Path) -> SyncResult:
454
+ """
455
+ Synchronize workspace with upstream changes.
456
+
457
+ Key difference from git: jj sync ALWAYS succeeds - conflicts are stored
458
+ in the commit rather than blocking the operation. This allows work to
459
+ continue even with conflicts present.
460
+
461
+ Args:
462
+ workspace_path: Path to the workspace to sync
463
+
464
+ Returns:
465
+ SyncResult with status, conflicts, and changes integrated
466
+ """
467
+ try:
468
+ # For colocated repos, fetch from git first
469
+ if (workspace_path / ".git").exists():
470
+ subprocess.run(
471
+ ["jj", "git", "fetch"],
472
+ capture_output=True,
473
+ text=True,
474
+ timeout=120,
475
+ cwd=str(workspace_path),
476
+ )
477
+
478
+ # Update stale workspace - this always succeeds in jj!
479
+ result = subprocess.run(
480
+ ["jj", "workspace", "update-stale"],
481
+ capture_output=True,
482
+ text=True,
483
+ timeout=60,
484
+ cwd=str(workspace_path),
485
+ )
486
+
487
+ # Check for conflicts AFTER successful sync
488
+ conflicts = self.detect_conflicts(workspace_path)
489
+
490
+ if result.returncode != 0:
491
+ return SyncResult(
492
+ status=SyncStatus.FAILED,
493
+ conflicts=conflicts,
494
+ files_updated=0,
495
+ files_added=0,
496
+ files_deleted=0,
497
+ changes_integrated=[],
498
+ message=f"Sync failed: {result.stderr.strip()}",
499
+ )
500
+
501
+ # Determine status based on output and conflicts
502
+ if "Nothing to do" in result.stdout or "already up to date" in result.stdout.lower():
503
+ status = SyncStatus.UP_TO_DATE
504
+ elif conflicts:
505
+ status = SyncStatus.CONFLICTS
506
+ else:
507
+ status = SyncStatus.SYNCED
508
+
509
+ # Parse file changes from output (if available)
510
+ files_updated, files_added, files_deleted = self._parse_sync_stats(result.stdout)
511
+
512
+ return SyncResult(
513
+ status=status,
514
+ conflicts=conflicts,
515
+ files_updated=files_updated,
516
+ files_added=files_added,
517
+ files_deleted=files_deleted,
518
+ changes_integrated=[],
519
+ message=result.stdout.strip() or "Workspace synchronized",
520
+ )
521
+
522
+ except subprocess.TimeoutExpired:
523
+ raise VCSSyncError("Sync operation timed out")
524
+ except OSError as e:
525
+ raise VCSSyncError(f"OS error during sync: {e}")
526
+
527
+ def is_workspace_stale(self, workspace_path: Path) -> bool:
528
+ """
529
+ Check if workspace needs sync (underlying revisions have changed).
530
+
531
+ Args:
532
+ workspace_path: Path to the workspace
533
+
534
+ Returns:
535
+ True if sync is needed, False if up-to-date
536
+ """
537
+ try:
538
+ # Check if workspace needs update
539
+ result = subprocess.run(
540
+ ["jj", "workspace", "update-stale", "--dry-run"],
541
+ capture_output=True,
542
+ text=True,
543
+ timeout=30,
544
+ cwd=str(workspace_path),
545
+ )
546
+
547
+ # If there's output about updating, it's stale
548
+ if result.returncode == 0:
549
+ return "Nothing to do" not in result.stdout
550
+
551
+ # Also check via status
552
+ status_result = subprocess.run(
553
+ ["jj", "status"],
554
+ capture_output=True,
555
+ text=True,
556
+ timeout=10,
557
+ cwd=str(workspace_path),
558
+ )
559
+
560
+ return "stale" in status_result.stdout.lower()
561
+
562
+ except (subprocess.TimeoutExpired, OSError):
563
+ return False
564
+
565
+ # =========================================================================
566
+ # Conflict Operations
567
+ # =========================================================================
568
+
569
+ def detect_conflicts(self, workspace_path: Path) -> list[ConflictInfo]:
570
+ """
571
+ Detect conflicts in a workspace.
572
+
573
+ In jj, conflicts are stored in commits rather than blocking operations.
574
+ This method parses `jj status` to find conflicted files.
575
+
576
+ Args:
577
+ workspace_path: Path to the workspace
578
+
579
+ Returns:
580
+ List of ConflictInfo for all conflicted files
581
+ """
582
+ try:
583
+ result = subprocess.run(
584
+ ["jj", "status"],
585
+ capture_output=True,
586
+ text=True,
587
+ timeout=30,
588
+ cwd=str(workspace_path),
589
+ )
590
+
591
+ if result.returncode != 0:
592
+ return []
593
+
594
+ conflicts = []
595
+ # Parse jj status output
596
+ # Conflicted files show as "C path/to/file"
597
+ for line in result.stdout.strip().split("\n"):
598
+ line = line.strip()
599
+ if line.startswith("C "):
600
+ file_path = Path(line[2:].strip())
601
+ conflicts.append(
602
+ ConflictInfo(
603
+ file_path=file_path,
604
+ conflict_type=ConflictType.CONTENT,
605
+ line_ranges=None,
606
+ sides=2, # Default, could be more in octopus merges
607
+ is_resolved=False,
608
+ our_content=None,
609
+ their_content=None,
610
+ base_content=None,
611
+ )
612
+ )
613
+
614
+ # Also check the log for conflict indicator
615
+ log_result = subprocess.run(
616
+ ["jj", "log", "-r", "@", "--no-graph", "-T", 'if(conflict, "conflict", "") ++ "\n"'],
617
+ capture_output=True,
618
+ text=True,
619
+ timeout=10,
620
+ cwd=str(workspace_path),
621
+ )
622
+
623
+ if log_result.returncode == 0 and "conflict" in log_result.stdout:
624
+ # Current commit has conflicts
625
+ # If we didn't find specific files, add a generic indicator
626
+ if not conflicts:
627
+ conflicts.append(
628
+ ConflictInfo(
629
+ file_path=Path("."),
630
+ conflict_type=ConflictType.CONTENT,
631
+ line_ranges=None,
632
+ sides=2,
633
+ is_resolved=False,
634
+ our_content=None,
635
+ their_content=None,
636
+ base_content=None,
637
+ )
638
+ )
639
+
640
+ return conflicts
641
+
642
+ except (subprocess.TimeoutExpired, OSError):
643
+ return []
644
+
645
+ def has_conflicts(self, workspace_path: Path) -> bool:
646
+ """
647
+ Check if workspace has any unresolved conflicts.
648
+
649
+ Args:
650
+ workspace_path: Path to the workspace
651
+
652
+ Returns:
653
+ True if conflicts exist, False otherwise
654
+ """
655
+ try:
656
+ # Check current commit for conflict marker
657
+ result = subprocess.run(
658
+ ["jj", "log", "-r", "@", "--no-graph", "-T", 'if(conflict, "yes", "no") ++ "\n"'],
659
+ capture_output=True,
660
+ text=True,
661
+ timeout=10,
662
+ cwd=str(workspace_path),
663
+ )
664
+
665
+ if result.returncode == 0 and "yes" in result.stdout:
666
+ return True
667
+
668
+ # Also check status for conflicted files
669
+ status_result = subprocess.run(
670
+ ["jj", "status"],
671
+ capture_output=True,
672
+ text=True,
673
+ timeout=10,
674
+ cwd=str(workspace_path),
675
+ )
676
+
677
+ # Look for conflict indicator in status
678
+ for line in status_result.stdout.split("\n"):
679
+ if line.strip().startswith("C "):
680
+ return True
681
+
682
+ return False
683
+
684
+ except (subprocess.TimeoutExpired, OSError):
685
+ return False
686
+
687
+ # =========================================================================
688
+ # Commit/Change Operations
689
+ # =========================================================================
690
+
691
+ def get_current_change(self, workspace_path: Path) -> ChangeInfo | None:
692
+ """
693
+ Get info about current working copy change.
694
+
695
+ In jj, the working copy is always a commit.
696
+
697
+ Args:
698
+ workspace_path: Path to the workspace
699
+
700
+ Returns:
701
+ ChangeInfo for current working copy, None if invalid
702
+ """
703
+ try:
704
+ # Use a comprehensive template
705
+ template = (
706
+ 'change_id ++ "|" ++ '
707
+ 'commit_id ++ "|" ++ '
708
+ 'description.first_line() ++ "|" ++ '
709
+ 'author.name() ++ "|" ++ '
710
+ 'author.email() ++ "|" ++ '
711
+ 'author.timestamp().format("%Y-%m-%dT%H:%M:%S%:z") ++ "|" ++ '
712
+ 'parents.map(|p| p.commit_id()).join(",") ++ "|" ++ '
713
+ 'if(conflict, "conflict", "") ++ "|" ++ '
714
+ 'if(empty, "empty", "") ++ "|" ++ '
715
+ 'description ++ "\n"'
716
+ )
717
+
718
+ result = subprocess.run(
719
+ ["jj", "log", "-r", "@", "--no-graph", "-T", template],
720
+ capture_output=True,
721
+ text=True,
722
+ timeout=30,
723
+ cwd=str(workspace_path),
724
+ )
725
+
726
+ if result.returncode != 0 or not result.stdout.strip():
727
+ return None
728
+
729
+ return self._parse_log_line(result.stdout.strip())
730
+
731
+ except (subprocess.TimeoutExpired, OSError):
732
+ return None
733
+
734
+ def get_changes(
735
+ self,
736
+ repo_path: Path,
737
+ revision_range: str | None = None,
738
+ limit: int | None = None,
739
+ ) -> list[ChangeInfo]:
740
+ """
741
+ Get list of changes/commits.
742
+
743
+ Args:
744
+ repo_path: Repository path
745
+ revision_range: jj revset expression (e.g., "::@", "main..@")
746
+ limit: Maximum number to return
747
+
748
+ Returns:
749
+ List of ChangeInfo
750
+ """
751
+ try:
752
+ template = (
753
+ 'change_id ++ "|" ++ '
754
+ 'commit_id ++ "|" ++ '
755
+ 'description.first_line() ++ "|" ++ '
756
+ 'author.name() ++ "|" ++ '
757
+ 'author.email() ++ "|" ++ '
758
+ 'author.timestamp().format("%Y-%m-%dT%H:%M:%S%:z") ++ "|" ++ '
759
+ 'parents.map(|p| p.commit_id()).join(",") ++ "|" ++ '
760
+ 'if(conflict, "conflict", "") ++ "|" ++ '
761
+ 'if(empty, "empty", "") ++ "\n"'
762
+ )
763
+
764
+ cmd = ["jj", "log", "--no-graph", "-T", template]
765
+
766
+ if revision_range:
767
+ cmd.extend(["-r", revision_range])
768
+ else:
769
+ cmd.extend(["-r", "::@"])
770
+
771
+ if limit:
772
+ cmd.extend(["--limit", str(limit)])
773
+
774
+ result = subprocess.run(
775
+ cmd,
776
+ capture_output=True,
777
+ text=True,
778
+ timeout=60,
779
+ cwd=str(repo_path),
780
+ )
781
+
782
+ if result.returncode != 0:
783
+ return []
784
+
785
+ changes = []
786
+ for line in result.stdout.strip().split("\n"):
787
+ if line:
788
+ change = self._parse_log_line_short(line)
789
+ if change:
790
+ changes.append(change)
791
+
792
+ return changes
793
+
794
+ except (subprocess.TimeoutExpired, OSError):
795
+ return []
796
+
797
+ def commit(
798
+ self,
799
+ workspace_path: Path,
800
+ message: str,
801
+ paths: list[Path] | None = None,
802
+ ) -> ChangeInfo | None:
803
+ """
804
+ Set the commit message for the current change.
805
+
806
+ In jj, the working copy is always a commit. This method:
807
+ 1. Sets the description on the current change with `jj describe`
808
+ 2. Creates a new empty change on top with `jj new`
809
+
810
+ Args:
811
+ workspace_path: Workspace to commit in
812
+ message: Commit message
813
+ paths: Ignored in jj (working copy is always committed)
814
+
815
+ Returns:
816
+ ChangeInfo for the commit that was described
817
+ """
818
+ try:
819
+ # First, describe the current change
820
+ describe_result = subprocess.run(
821
+ ["jj", "describe", "-m", message],
822
+ capture_output=True,
823
+ text=True,
824
+ timeout=30,
825
+ cwd=str(workspace_path),
826
+ )
827
+
828
+ if describe_result.returncode != 0:
829
+ return None
830
+
831
+ # Get info about the described commit before creating new
832
+ change = self.get_current_change(workspace_path)
833
+
834
+ # Create a new empty change on top
835
+ subprocess.run(
836
+ ["jj", "new"],
837
+ capture_output=True,
838
+ timeout=30,
839
+ cwd=str(workspace_path),
840
+ )
841
+
842
+ return change
843
+
844
+ except (subprocess.TimeoutExpired, OSError):
845
+ return None
846
+
847
+ # =========================================================================
848
+ # Repository Operations
849
+ # =========================================================================
850
+
851
+ def init_repo(self, path: Path, colocate: bool = True) -> bool:
852
+ """
853
+ Initialize a new jj repository.
854
+
855
+ Note: In jj 0.30+, colocate is the default. All jj repos use the Git backend.
856
+
857
+ Args:
858
+ path: Where to initialize
859
+ colocate: If True, create colocated repo (default behavior in jj 0.30+)
860
+
861
+ Returns:
862
+ True if successful, False otherwise
863
+ """
864
+ try:
865
+ path.mkdir(parents=True, exist_ok=True)
866
+
867
+ # In jj 0.30+, colocate is the default
868
+ # Use --colocate flag explicitly for clarity
869
+ if colocate:
870
+ result = subprocess.run(
871
+ ["jj", "git", "init", "--colocate"],
872
+ cwd=str(path),
873
+ capture_output=True,
874
+ timeout=30,
875
+ )
876
+ else:
877
+ # Non-colocated mode: .jj/ only (git backend still used)
878
+ result = subprocess.run(
879
+ ["jj", "git", "init"],
880
+ cwd=str(path),
881
+ capture_output=True,
882
+ timeout=30,
883
+ )
884
+
885
+ return result.returncode == 0
886
+ except (subprocess.TimeoutExpired, OSError):
887
+ return False
888
+
889
+ def is_repo(self, path: Path) -> bool:
890
+ """
891
+ Check if path is inside a jj repository.
892
+
893
+ Args:
894
+ path: Path to check
895
+
896
+ Returns:
897
+ True if valid jj repository
898
+ """
899
+ if not path.exists():
900
+ return False
901
+
902
+ # Check for .jj directory
903
+ jj_dir = path / ".jj"
904
+ if jj_dir.exists():
905
+ return True
906
+
907
+ # Also check if we're inside a jj repo
908
+ try:
909
+ result = subprocess.run(
910
+ ["jj", "workspace", "root"],
911
+ cwd=str(path),
912
+ capture_output=True,
913
+ text=True,
914
+ timeout=10,
915
+ )
916
+ return result.returncode == 0 and result.stdout.strip() != ""
917
+ except (subprocess.TimeoutExpired, OSError):
918
+ return False
919
+
920
+ def get_repo_root(self, path: Path) -> Path | None:
921
+ """
922
+ Get root directory of repository containing path.
923
+
924
+ Args:
925
+ path: Path within the repository
926
+
927
+ Returns:
928
+ Repository root or None if not in a repo
929
+ """
930
+ try:
931
+ result = subprocess.run(
932
+ ["jj", "workspace", "root"],
933
+ cwd=str(path),
934
+ capture_output=True,
935
+ text=True,
936
+ timeout=10,
937
+ )
938
+
939
+ if result.returncode == 0 and result.stdout.strip():
940
+ return Path(result.stdout.strip())
941
+ return None
942
+
943
+ except (subprocess.TimeoutExpired, OSError):
944
+ return None
945
+
946
+ # =========================================================================
947
+ # Private Helper Methods
948
+ # =========================================================================
949
+
950
+ def _parse_sync_stats(self, output: str) -> tuple[int, int, int]:
951
+ """Parse sync output for file statistics."""
952
+ # jj doesn't give detailed stats in a standard format
953
+ # Return zeros for now
954
+ return (0, 0, 0)
955
+
956
+ def _parse_log_line(self, line: str) -> ChangeInfo | None:
957
+ """Parse a jj log line with full description."""
958
+ try:
959
+ parts = line.split("|", 9)
960
+ if len(parts) < 9:
961
+ return None
962
+
963
+ change_id = parts[0]
964
+ commit_id = parts[1]
965
+ message = parts[2]
966
+ author = parts[3]
967
+ author_email = parts[4]
968
+ timestamp_str = parts[5]
969
+ parents_str = parts[6]
970
+ is_conflict = parts[7] == "conflict"
971
+ is_empty = parts[8] == "empty"
972
+ message_full = parts[9] if len(parts) > 9 else message
973
+
974
+ # Parse timestamp
975
+ try:
976
+ timestamp = datetime.fromisoformat(timestamp_str)
977
+ except ValueError:
978
+ timestamp = datetime.now(timezone.utc)
979
+
980
+ # Parse parents
981
+ parents = [p for p in parents_str.split(",") if p]
982
+
983
+ return ChangeInfo(
984
+ change_id=change_id,
985
+ commit_id=commit_id,
986
+ message=message,
987
+ message_full=message_full,
988
+ author=author,
989
+ author_email=author_email,
990
+ timestamp=timestamp,
991
+ parents=parents,
992
+ is_merge=len(parents) > 1,
993
+ is_conflicted=is_conflict,
994
+ is_empty=is_empty,
995
+ )
996
+ except (ValueError, IndexError):
997
+ return None
998
+
999
+ def _parse_log_line_short(self, line: str) -> ChangeInfo | None:
1000
+ """Parse a jj log line without full description."""
1001
+ try:
1002
+ parts = line.split("|", 8)
1003
+ if len(parts) < 8:
1004
+ return None
1005
+
1006
+ change_id = parts[0]
1007
+ commit_id = parts[1]
1008
+ message = parts[2]
1009
+ author = parts[3]
1010
+ author_email = parts[4]
1011
+ timestamp_str = parts[5]
1012
+ parents_str = parts[6]
1013
+ is_conflict = parts[7] == "conflict"
1014
+ is_empty = len(parts) > 8 and parts[8] == "empty"
1015
+
1016
+ # Parse timestamp
1017
+ try:
1018
+ timestamp = datetime.fromisoformat(timestamp_str)
1019
+ except ValueError:
1020
+ timestamp = datetime.now(timezone.utc)
1021
+
1022
+ # Parse parents
1023
+ parents = [p for p in parents_str.split(",") if p]
1024
+
1025
+ return ChangeInfo(
1026
+ change_id=change_id,
1027
+ commit_id=commit_id,
1028
+ message=message,
1029
+ message_full=message,
1030
+ author=author,
1031
+ author_email=author_email,
1032
+ timestamp=timestamp,
1033
+ parents=parents,
1034
+ is_merge=len(parents) > 1,
1035
+ is_conflicted=is_conflict,
1036
+ is_empty=is_empty,
1037
+ )
1038
+ except (ValueError, IndexError):
1039
+ return None
1040
+
1041
+
1042
+ # =============================================================================
1043
+ # jj-Specific Standalone Functions
1044
+ # =============================================================================
1045
+
1046
+
1047
+ def jj_get_operation_log(repo_path: Path, limit: int = 20) -> list[OperationInfo]:
1048
+ """
1049
+ Get jj operation log.
1050
+
1051
+ jj has a full operation log that records every change to the repository.
1052
+ Unlike git's reflog, this includes all operations (not just ref changes)
1053
+ and supports full undo.
1054
+
1055
+ Args:
1056
+ repo_path: Repository path
1057
+ limit: Maximum number of entries to return
1058
+
1059
+ Returns:
1060
+ List of OperationInfo from operation log
1061
+ """
1062
+ try:
1063
+ result = subprocess.run(
1064
+ ["jj", "op", "log", "--limit", str(limit)],
1065
+ capture_output=True,
1066
+ text=True,
1067
+ timeout=30,
1068
+ cwd=str(repo_path),
1069
+ )
1070
+
1071
+ if result.returncode != 0:
1072
+ return []
1073
+
1074
+ operations = []
1075
+ lines = result.stdout.strip().split("\n")
1076
+
1077
+ # Parse op log output
1078
+ # Format: "@ abc123def robert@host 5 seconds ago, lasted 1ms"
1079
+ # "│ description of operation"
1080
+ current_op = None
1081
+ for line in lines:
1082
+ # Operation header line
1083
+ if re.match(r"^[@○◆●]?\s*\S+\s+\S+@", line):
1084
+ if current_op:
1085
+ operations.append(current_op)
1086
+
1087
+ # Parse the line
1088
+ parts = line.split()
1089
+ if len(parts) >= 4:
1090
+ # Remove the graph character if present
1091
+ start_idx = 0
1092
+ if parts[0] in ("@", "○", "◆", "●", "│"):
1093
+ start_idx = 1
1094
+
1095
+ op_id = parts[start_idx] if start_idx < len(parts) else ""
1096
+
1097
+ # Try to parse timestamp from "X ago" format
1098
+ timestamp = datetime.now(timezone.utc)
1099
+
1100
+ current_op = OperationInfo(
1101
+ operation_id=op_id,
1102
+ timestamp=timestamp,
1103
+ description="",
1104
+ heads=[],
1105
+ working_copy_commit="",
1106
+ is_undoable=True, # jj ops are always undoable
1107
+ parent_operation=None,
1108
+ )
1109
+
1110
+ # Description line
1111
+ elif current_op and line.strip().startswith("│"):
1112
+ desc = line.strip().lstrip("│").strip()
1113
+ if desc:
1114
+ current_op = OperationInfo(
1115
+ operation_id=current_op.operation_id,
1116
+ timestamp=current_op.timestamp,
1117
+ description=desc,
1118
+ heads=current_op.heads,
1119
+ working_copy_commit=current_op.working_copy_commit,
1120
+ is_undoable=current_op.is_undoable,
1121
+ parent_operation=current_op.parent_operation,
1122
+ )
1123
+
1124
+ # Don't forget the last one
1125
+ if current_op:
1126
+ operations.append(current_op)
1127
+
1128
+ return operations
1129
+
1130
+ except (subprocess.TimeoutExpired, OSError):
1131
+ return []
1132
+
1133
+
1134
+ def jj_undo_operation(repo_path: Path, operation_id: str | None = None) -> bool:
1135
+ """
1136
+ Undo a jj operation.
1137
+
1138
+ jj has full undo capability - any operation can be undone,
1139
+ restoring the repository to its previous state.
1140
+
1141
+ Args:
1142
+ repo_path: Repository path
1143
+ operation_id: Specific operation to undo to, None = undo last
1144
+
1145
+ Returns:
1146
+ True if successful
1147
+ """
1148
+ try:
1149
+ cmd = ["jj", "op", "undo"]
1150
+ if operation_id:
1151
+ cmd.extend(["--what", operation_id])
1152
+
1153
+ result = subprocess.run(
1154
+ cmd,
1155
+ capture_output=True,
1156
+ timeout=30,
1157
+ cwd=str(repo_path),
1158
+ )
1159
+ return result.returncode == 0
1160
+ except (subprocess.TimeoutExpired, OSError):
1161
+ return False
1162
+
1163
+
1164
+ def jj_get_change_by_id(repo_path: Path, change_id: str) -> ChangeInfo | None:
1165
+ """
1166
+ Look up a change by its stable Change ID.
1167
+
1168
+ jj Change IDs are stable across rebases, unlike git commit SHAs.
1169
+ This makes them ideal for tracking work across operations.
1170
+
1171
+ Args:
1172
+ repo_path: Repository path
1173
+ change_id: The Change ID to look up
1174
+
1175
+ Returns:
1176
+ ChangeInfo for the change, None if not found
1177
+ """
1178
+ try:
1179
+ template = (
1180
+ 'change_id ++ "|" ++ '
1181
+ 'commit_id ++ "|" ++ '
1182
+ 'description.first_line() ++ "|" ++ '
1183
+ 'author.name() ++ "|" ++ '
1184
+ 'author.email() ++ "|" ++ '
1185
+ 'author.timestamp().format("%Y-%m-%dT%H:%M:%S%:z") ++ "|" ++ '
1186
+ 'parents.map(|p| p.commit_id()).join(",") ++ "|" ++ '
1187
+ 'if(conflict, "conflict", "") ++ "|" ++ '
1188
+ 'if(empty, "empty", "") ++ "|" ++ '
1189
+ 'description ++ "\n"'
1190
+ )
1191
+
1192
+ result = subprocess.run(
1193
+ ["jj", "log", "-r", change_id, "--no-graph", "-T", template],
1194
+ capture_output=True,
1195
+ text=True,
1196
+ timeout=30,
1197
+ cwd=str(repo_path),
1198
+ )
1199
+
1200
+ if result.returncode != 0 or not result.stdout.strip():
1201
+ return None
1202
+
1203
+ # Parse the log line
1204
+ vcs = JujutsuVCS()
1205
+ return vcs._parse_log_line(result.stdout.strip())
1206
+
1207
+ except (subprocess.TimeoutExpired, OSError):
1208
+ return None