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