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.
- spec_kitty_cli-0.12.1.dist-info/METADATA +1767 -0
- spec_kitty_cli-0.12.1.dist-info/RECORD +242 -0
- spec_kitty_cli-0.12.1.dist-info/WHEEL +4 -0
- spec_kitty_cli-0.12.1.dist-info/entry_points.txt +2 -0
- spec_kitty_cli-0.12.1.dist-info/licenses/LICENSE +21 -0
- specify_cli/__init__.py +171 -0
- specify_cli/acceptance.py +627 -0
- specify_cli/agent_utils/README.md +157 -0
- specify_cli/agent_utils/__init__.py +9 -0
- specify_cli/agent_utils/status.py +356 -0
- specify_cli/cli/__init__.py +6 -0
- specify_cli/cli/commands/__init__.py +46 -0
- specify_cli/cli/commands/accept.py +189 -0
- specify_cli/cli/commands/agent/__init__.py +22 -0
- specify_cli/cli/commands/agent/config.py +382 -0
- specify_cli/cli/commands/agent/context.py +191 -0
- specify_cli/cli/commands/agent/feature.py +1057 -0
- specify_cli/cli/commands/agent/release.py +11 -0
- specify_cli/cli/commands/agent/tasks.py +1253 -0
- specify_cli/cli/commands/agent/workflow.py +801 -0
- specify_cli/cli/commands/context.py +246 -0
- specify_cli/cli/commands/dashboard.py +85 -0
- specify_cli/cli/commands/implement.py +973 -0
- specify_cli/cli/commands/init.py +827 -0
- specify_cli/cli/commands/init_help.py +62 -0
- specify_cli/cli/commands/merge.py +755 -0
- specify_cli/cli/commands/mission.py +240 -0
- specify_cli/cli/commands/ops.py +265 -0
- specify_cli/cli/commands/orchestrate.py +640 -0
- specify_cli/cli/commands/repair.py +175 -0
- specify_cli/cli/commands/research.py +165 -0
- specify_cli/cli/commands/sync.py +364 -0
- specify_cli/cli/commands/upgrade.py +249 -0
- specify_cli/cli/commands/validate_encoding.py +186 -0
- specify_cli/cli/commands/validate_tasks.py +186 -0
- specify_cli/cli/commands/verify.py +310 -0
- specify_cli/cli/helpers.py +123 -0
- specify_cli/cli/step_tracker.py +91 -0
- specify_cli/cli/ui.py +192 -0
- specify_cli/core/__init__.py +53 -0
- specify_cli/core/agent_context.py +311 -0
- specify_cli/core/config.py +96 -0
- specify_cli/core/context_validation.py +362 -0
- specify_cli/core/dependency_graph.py +351 -0
- specify_cli/core/git_ops.py +129 -0
- specify_cli/core/multi_parent_merge.py +323 -0
- specify_cli/core/paths.py +260 -0
- specify_cli/core/project_resolver.py +110 -0
- specify_cli/core/stale_detection.py +263 -0
- specify_cli/core/tool_checker.py +79 -0
- specify_cli/core/utils.py +43 -0
- specify_cli/core/vcs/__init__.py +114 -0
- specify_cli/core/vcs/detection.py +341 -0
- specify_cli/core/vcs/exceptions.py +85 -0
- specify_cli/core/vcs/git.py +1304 -0
- specify_cli/core/vcs/jujutsu.py +1208 -0
- specify_cli/core/vcs/protocol.py +285 -0
- specify_cli/core/vcs/types.py +249 -0
- specify_cli/core/version_checker.py +261 -0
- specify_cli/core/worktree.py +506 -0
- specify_cli/dashboard/__init__.py +28 -0
- specify_cli/dashboard/diagnostics.py +204 -0
- specify_cli/dashboard/handlers/__init__.py +17 -0
- specify_cli/dashboard/handlers/api.py +143 -0
- specify_cli/dashboard/handlers/base.py +65 -0
- specify_cli/dashboard/handlers/features.py +390 -0
- specify_cli/dashboard/handlers/router.py +81 -0
- specify_cli/dashboard/handlers/static.py +50 -0
- specify_cli/dashboard/lifecycle.py +541 -0
- specify_cli/dashboard/scanner.py +437 -0
- specify_cli/dashboard/server.py +123 -0
- specify_cli/dashboard/static/dashboard/dashboard.css +722 -0
- specify_cli/dashboard/static/dashboard/dashboard.js +1424 -0
- specify_cli/dashboard/static/spec-kitty.png +0 -0
- specify_cli/dashboard/templates/__init__.py +36 -0
- specify_cli/dashboard/templates/index.html +258 -0
- specify_cli/doc_generators.py +621 -0
- specify_cli/doc_state.py +408 -0
- specify_cli/frontmatter.py +384 -0
- specify_cli/gap_analysis.py +915 -0
- specify_cli/gitignore_manager.py +300 -0
- specify_cli/guards.py +145 -0
- specify_cli/legacy_detector.py +83 -0
- specify_cli/manifest.py +286 -0
- specify_cli/merge/__init__.py +63 -0
- specify_cli/merge/executor.py +653 -0
- specify_cli/merge/forecast.py +215 -0
- specify_cli/merge/ordering.py +126 -0
- specify_cli/merge/preflight.py +230 -0
- specify_cli/merge/state.py +185 -0
- specify_cli/merge/status_resolver.py +354 -0
- specify_cli/mission.py +654 -0
- specify_cli/missions/documentation/command-templates/implement.md +309 -0
- specify_cli/missions/documentation/command-templates/plan.md +275 -0
- specify_cli/missions/documentation/command-templates/review.md +344 -0
- specify_cli/missions/documentation/command-templates/specify.md +206 -0
- specify_cli/missions/documentation/command-templates/tasks.md +189 -0
- specify_cli/missions/documentation/mission.yaml +113 -0
- specify_cli/missions/documentation/templates/divio/explanation-template.md +192 -0
- specify_cli/missions/documentation/templates/divio/howto-template.md +168 -0
- specify_cli/missions/documentation/templates/divio/reference-template.md +179 -0
- specify_cli/missions/documentation/templates/divio/tutorial-template.md +146 -0
- specify_cli/missions/documentation/templates/generators/jsdoc.json.template +18 -0
- specify_cli/missions/documentation/templates/generators/sphinx-conf.py.template +36 -0
- specify_cli/missions/documentation/templates/plan-template.md +269 -0
- specify_cli/missions/documentation/templates/release-template.md +222 -0
- specify_cli/missions/documentation/templates/spec-template.md +172 -0
- specify_cli/missions/documentation/templates/task-prompt-template.md +140 -0
- specify_cli/missions/documentation/templates/tasks-template.md +159 -0
- specify_cli/missions/research/command-templates/merge.md +388 -0
- specify_cli/missions/research/command-templates/plan.md +125 -0
- specify_cli/missions/research/command-templates/review.md +144 -0
- specify_cli/missions/research/command-templates/tasks.md +225 -0
- specify_cli/missions/research/mission.yaml +115 -0
- specify_cli/missions/research/templates/data-model-template.md +33 -0
- specify_cli/missions/research/templates/plan-template.md +161 -0
- specify_cli/missions/research/templates/research/evidence-log.csv +18 -0
- specify_cli/missions/research/templates/research/source-register.csv +18 -0
- specify_cli/missions/research/templates/research-template.md +35 -0
- specify_cli/missions/research/templates/spec-template.md +64 -0
- specify_cli/missions/research/templates/task-prompt-template.md +148 -0
- specify_cli/missions/research/templates/tasks-template.md +114 -0
- specify_cli/missions/software-dev/command-templates/accept.md +75 -0
- specify_cli/missions/software-dev/command-templates/analyze.md +183 -0
- specify_cli/missions/software-dev/command-templates/checklist.md +286 -0
- specify_cli/missions/software-dev/command-templates/clarify.md +157 -0
- specify_cli/missions/software-dev/command-templates/constitution.md +432 -0
- specify_cli/missions/software-dev/command-templates/dashboard.md +101 -0
- specify_cli/missions/software-dev/command-templates/implement.md +41 -0
- specify_cli/missions/software-dev/command-templates/merge.md +383 -0
- specify_cli/missions/software-dev/command-templates/plan.md +171 -0
- specify_cli/missions/software-dev/command-templates/review.md +32 -0
- specify_cli/missions/software-dev/command-templates/specify.md +321 -0
- specify_cli/missions/software-dev/command-templates/tasks.md +566 -0
- specify_cli/missions/software-dev/mission.yaml +100 -0
- specify_cli/missions/software-dev/templates/plan-template.md +132 -0
- specify_cli/missions/software-dev/templates/spec-template.md +116 -0
- specify_cli/missions/software-dev/templates/task-prompt-template.md +140 -0
- specify_cli/missions/software-dev/templates/tasks-template.md +159 -0
- specify_cli/orchestrator/__init__.py +75 -0
- specify_cli/orchestrator/agent_config.py +224 -0
- specify_cli/orchestrator/agents/__init__.py +170 -0
- specify_cli/orchestrator/agents/augment.py +112 -0
- specify_cli/orchestrator/agents/base.py +243 -0
- specify_cli/orchestrator/agents/claude.py +112 -0
- specify_cli/orchestrator/agents/codex.py +106 -0
- specify_cli/orchestrator/agents/copilot.py +137 -0
- specify_cli/orchestrator/agents/cursor.py +139 -0
- specify_cli/orchestrator/agents/gemini.py +115 -0
- specify_cli/orchestrator/agents/kilocode.py +94 -0
- specify_cli/orchestrator/agents/opencode.py +132 -0
- specify_cli/orchestrator/agents/qwen.py +96 -0
- specify_cli/orchestrator/config.py +455 -0
- specify_cli/orchestrator/executor.py +642 -0
- specify_cli/orchestrator/integration.py +1230 -0
- specify_cli/orchestrator/monitor.py +898 -0
- specify_cli/orchestrator/scheduler.py +832 -0
- specify_cli/orchestrator/state.py +508 -0
- specify_cli/orchestrator/testing/__init__.py +122 -0
- specify_cli/orchestrator/testing/availability.py +346 -0
- specify_cli/orchestrator/testing/fixtures.py +684 -0
- specify_cli/orchestrator/testing/paths.py +218 -0
- specify_cli/plan_validation.py +107 -0
- specify_cli/scripts/debug-dashboard-scan.py +61 -0
- specify_cli/scripts/tasks/acceptance_support.py +695 -0
- specify_cli/scripts/tasks/task_helpers.py +506 -0
- specify_cli/scripts/tasks/tasks_cli.py +848 -0
- specify_cli/scripts/validate_encoding.py +180 -0
- specify_cli/task_metadata_validation.py +274 -0
- specify_cli/tasks_support.py +447 -0
- specify_cli/template/__init__.py +47 -0
- specify_cli/template/asset_generator.py +206 -0
- specify_cli/template/github_client.py +334 -0
- specify_cli/template/manager.py +193 -0
- specify_cli/template/renderer.py +99 -0
- specify_cli/templates/AGENTS.md +190 -0
- specify_cli/templates/POWERSHELL_SYNTAX.md +229 -0
- specify_cli/templates/agent-file-template.md +35 -0
- specify_cli/templates/checklist-template.md +42 -0
- specify_cli/templates/claudeignore-template +58 -0
- specify_cli/templates/command-templates/accept.md +141 -0
- specify_cli/templates/command-templates/analyze.md +253 -0
- specify_cli/templates/command-templates/checklist.md +352 -0
- specify_cli/templates/command-templates/clarify.md +224 -0
- specify_cli/templates/command-templates/constitution.md +432 -0
- specify_cli/templates/command-templates/dashboard.md +175 -0
- specify_cli/templates/command-templates/implement.md +190 -0
- specify_cli/templates/command-templates/merge.md +374 -0
- specify_cli/templates/command-templates/plan.md +171 -0
- specify_cli/templates/command-templates/research.md +88 -0
- specify_cli/templates/command-templates/review.md +510 -0
- specify_cli/templates/command-templates/specify.md +321 -0
- specify_cli/templates/command-templates/status.md +92 -0
- specify_cli/templates/command-templates/tasks.md +199 -0
- specify_cli/templates/git-hooks/pre-commit +22 -0
- specify_cli/templates/git-hooks/pre-commit-agent-check +37 -0
- specify_cli/templates/git-hooks/pre-commit-encoding-check +142 -0
- specify_cli/templates/plan-template.md +108 -0
- specify_cli/templates/spec-template.md +118 -0
- specify_cli/templates/task-prompt-template.md +165 -0
- specify_cli/templates/tasks-template.md +161 -0
- specify_cli/templates/vscode-settings.json +13 -0
- specify_cli/text_sanitization.py +225 -0
- specify_cli/upgrade/__init__.py +18 -0
- specify_cli/upgrade/detector.py +239 -0
- specify_cli/upgrade/metadata.py +182 -0
- specify_cli/upgrade/migrations/__init__.py +65 -0
- specify_cli/upgrade/migrations/base.py +80 -0
- specify_cli/upgrade/migrations/m_0_10_0_python_only.py +359 -0
- specify_cli/upgrade/migrations/m_0_10_12_constitution_cleanup.py +99 -0
- specify_cli/upgrade/migrations/m_0_10_14_update_implement_slash_command.py +176 -0
- specify_cli/upgrade/migrations/m_0_10_1_populate_slash_commands.py +174 -0
- specify_cli/upgrade/migrations/m_0_10_2_update_slash_commands.py +172 -0
- specify_cli/upgrade/migrations/m_0_10_6_workflow_simplification.py +174 -0
- specify_cli/upgrade/migrations/m_0_10_8_fix_memory_structure.py +252 -0
- specify_cli/upgrade/migrations/m_0_10_9_repair_templates.py +168 -0
- specify_cli/upgrade/migrations/m_0_11_0_workspace_per_wp.py +182 -0
- specify_cli/upgrade/migrations/m_0_11_1_improved_workflow_templates.py +173 -0
- specify_cli/upgrade/migrations/m_0_11_1_update_implement_slash_command.py +160 -0
- specify_cli/upgrade/migrations/m_0_11_2_improved_workflow_templates.py +173 -0
- specify_cli/upgrade/migrations/m_0_11_3_workflow_agent_flag.py +114 -0
- specify_cli/upgrade/migrations/m_0_12_0_documentation_mission.py +155 -0
- specify_cli/upgrade/migrations/m_0_12_1_remove_kitty_specs_from_gitignore.py +183 -0
- specify_cli/upgrade/migrations/m_0_2_0_specify_to_kittify.py +80 -0
- specify_cli/upgrade/migrations/m_0_4_8_gitignore_agents.py +118 -0
- specify_cli/upgrade/migrations/m_0_5_0_encoding_hooks.py +141 -0
- specify_cli/upgrade/migrations/m_0_6_5_commands_rename.py +169 -0
- specify_cli/upgrade/migrations/m_0_6_7_ensure_missions.py +228 -0
- specify_cli/upgrade/migrations/m_0_7_2_worktree_commands_dedup.py +89 -0
- specify_cli/upgrade/migrations/m_0_7_3_update_scripts.py +114 -0
- specify_cli/upgrade/migrations/m_0_8_0_remove_active_mission.py +82 -0
- specify_cli/upgrade/migrations/m_0_8_0_worktree_agents_symlink.py +148 -0
- specify_cli/upgrade/migrations/m_0_9_0_frontmatter_only_lanes.py +346 -0
- specify_cli/upgrade/migrations/m_0_9_1_complete_lane_migration.py +656 -0
- specify_cli/upgrade/migrations/m_0_9_2_research_mission_templates.py +221 -0
- specify_cli/upgrade/registry.py +121 -0
- specify_cli/upgrade/runner.py +284 -0
- specify_cli/validators/__init__.py +14 -0
- specify_cli/validators/paths.py +154 -0
- specify_cli/validators/research.py +428 -0
- specify_cli/verify_enhanced.py +270 -0
- 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
|