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,185 @@
|
|
|
1
|
+
"""Merge state persistence for resume capability.
|
|
2
|
+
|
|
3
|
+
Implements FR-021 through FR-024: persisting merge state to enable
|
|
4
|
+
resuming interrupted merge operations.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import subprocess
|
|
11
|
+
from dataclasses import asdict, dataclass, field
|
|
12
|
+
from datetime import UTC, datetime
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"MergeState",
|
|
18
|
+
"save_state",
|
|
19
|
+
"load_state",
|
|
20
|
+
"clear_state",
|
|
21
|
+
"has_active_merge",
|
|
22
|
+
"get_state_path",
|
|
23
|
+
"detect_git_merge_state",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
STATE_FILE = ".kittify/merge-state.json"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class MergeState:
|
|
31
|
+
"""Persisted state for resumable merge operations."""
|
|
32
|
+
|
|
33
|
+
feature_slug: str
|
|
34
|
+
target_branch: str
|
|
35
|
+
wp_order: list[str] # Ordered list of WP IDs to merge
|
|
36
|
+
completed_wps: list[str] = field(default_factory=list)
|
|
37
|
+
current_wp: str | None = None
|
|
38
|
+
has_pending_conflicts: bool = False
|
|
39
|
+
strategy: str = "merge" # "merge", "squash", "rebase"
|
|
40
|
+
started_at: str = field(default_factory=lambda: datetime.now(UTC).isoformat())
|
|
41
|
+
updated_at: str = field(default_factory=lambda: datetime.now(UTC).isoformat())
|
|
42
|
+
|
|
43
|
+
def to_dict(self) -> dict[str, Any]:
|
|
44
|
+
"""Convert to JSON-serializable dict."""
|
|
45
|
+
return asdict(self)
|
|
46
|
+
|
|
47
|
+
@classmethod
|
|
48
|
+
def from_dict(cls, data: dict[str, Any]) -> MergeState:
|
|
49
|
+
"""Create from dict (loaded JSON)."""
|
|
50
|
+
return cls(**data)
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def remaining_wps(self) -> list[str]:
|
|
54
|
+
"""WPs not yet merged."""
|
|
55
|
+
completed_set = set(self.completed_wps)
|
|
56
|
+
return [wp for wp in self.wp_order if wp not in completed_set]
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def progress_percent(self) -> float:
|
|
60
|
+
"""Completion percentage."""
|
|
61
|
+
if not self.wp_order:
|
|
62
|
+
return 0.0
|
|
63
|
+
return len(self.completed_wps) / len(self.wp_order) * 100
|
|
64
|
+
|
|
65
|
+
def mark_wp_complete(self, wp_id: str) -> None:
|
|
66
|
+
"""Mark a WP as successfully merged."""
|
|
67
|
+
if wp_id not in self.completed_wps:
|
|
68
|
+
self.completed_wps.append(wp_id)
|
|
69
|
+
self.current_wp = None
|
|
70
|
+
self.has_pending_conflicts = False
|
|
71
|
+
self.updated_at = datetime.now(UTC).isoformat()
|
|
72
|
+
|
|
73
|
+
def set_current_wp(self, wp_id: str) -> None:
|
|
74
|
+
"""Set the currently-merging WP."""
|
|
75
|
+
self.current_wp = wp_id
|
|
76
|
+
self.updated_at = datetime.now(UTC).isoformat()
|
|
77
|
+
|
|
78
|
+
def set_pending_conflicts(self, has_conflicts: bool = True) -> None:
|
|
79
|
+
"""Mark that there are pending conflicts to resolve."""
|
|
80
|
+
self.has_pending_conflicts = has_conflicts
|
|
81
|
+
self.updated_at = datetime.now(UTC).isoformat()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def get_state_path(repo_root: Path) -> Path:
|
|
85
|
+
"""Get path to merge state file."""
|
|
86
|
+
return repo_root / STATE_FILE
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def save_state(state: MergeState, repo_root: Path) -> None:
|
|
90
|
+
"""Save merge state to JSON file.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
state: MergeState to persist
|
|
94
|
+
repo_root: Repository root path
|
|
95
|
+
"""
|
|
96
|
+
state_path = get_state_path(repo_root)
|
|
97
|
+
state_path.parent.mkdir(parents=True, exist_ok=True)
|
|
98
|
+
|
|
99
|
+
# Update timestamp
|
|
100
|
+
state.updated_at = datetime.now(UTC).isoformat()
|
|
101
|
+
|
|
102
|
+
with open(state_path, "w", encoding="utf-8") as f:
|
|
103
|
+
json.dump(state.to_dict(), f, indent=2)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def load_state(repo_root: Path) -> MergeState | None:
|
|
107
|
+
"""Load merge state from JSON file.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
repo_root: Repository root path
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
MergeState if file exists and is valid, None otherwise
|
|
114
|
+
"""
|
|
115
|
+
state_path = get_state_path(repo_root)
|
|
116
|
+
|
|
117
|
+
if not state_path.exists():
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
try:
|
|
121
|
+
with open(state_path, encoding="utf-8") as f:
|
|
122
|
+
data = json.load(f)
|
|
123
|
+
return MergeState.from_dict(data)
|
|
124
|
+
except (json.JSONDecodeError, TypeError, KeyError):
|
|
125
|
+
# Invalid state file - return None, caller should clear
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def clear_state(repo_root: Path) -> bool:
|
|
130
|
+
"""Remove merge state file.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
repo_root: Repository root path
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
True if file was removed, False if it didn't exist
|
|
137
|
+
"""
|
|
138
|
+
state_path = get_state_path(repo_root)
|
|
139
|
+
|
|
140
|
+
if state_path.exists():
|
|
141
|
+
state_path.unlink()
|
|
142
|
+
return True
|
|
143
|
+
return False
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def has_active_merge(repo_root: Path) -> bool:
|
|
147
|
+
"""Check if there's an active merge state.
|
|
148
|
+
|
|
149
|
+
Returns True if state file exists and has remaining WPs.
|
|
150
|
+
"""
|
|
151
|
+
state = load_state(repo_root)
|
|
152
|
+
if state is None:
|
|
153
|
+
return False
|
|
154
|
+
return len(state.remaining_wps) > 0
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def detect_git_merge_state(repo_root: Path) -> bool:
|
|
158
|
+
"""Check if git has an active merge in progress.
|
|
159
|
+
|
|
160
|
+
Uses MERGE_HEAD presence to detect mid-merge state.
|
|
161
|
+
"""
|
|
162
|
+
result = subprocess.run(
|
|
163
|
+
["git", "rev-parse", "-q", "--verify", "MERGE_HEAD"],
|
|
164
|
+
cwd=str(repo_root),
|
|
165
|
+
capture_output=True,
|
|
166
|
+
check=False,
|
|
167
|
+
)
|
|
168
|
+
return result.returncode == 0
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def abort_git_merge(repo_root: Path) -> bool:
|
|
172
|
+
"""Abort an in-progress git merge.
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
True if merge was aborted, False if no merge was in progress
|
|
176
|
+
"""
|
|
177
|
+
if not detect_git_merge_state(repo_root):
|
|
178
|
+
return False
|
|
179
|
+
|
|
180
|
+
subprocess.run(
|
|
181
|
+
["git", "merge", "--abort"],
|
|
182
|
+
cwd=str(repo_root),
|
|
183
|
+
check=False,
|
|
184
|
+
)
|
|
185
|
+
return True
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
"""Status file auto-resolution for merge conflicts.
|
|
2
|
+
|
|
3
|
+
Implements FR-012 through FR-016: automatically resolving conflicts in
|
|
4
|
+
status tracking files (lane fields, checkboxes, history arrays).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import fnmatch
|
|
10
|
+
import re
|
|
11
|
+
import subprocess
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import yaml
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"ConflictRegion",
|
|
20
|
+
"ResolutionResult",
|
|
21
|
+
"parse_conflict_markers",
|
|
22
|
+
"resolve_status_conflicts",
|
|
23
|
+
"get_conflicted_files",
|
|
24
|
+
"is_status_file",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
CONFLICT_PATTERN = re.compile(
|
|
29
|
+
r"^<<<<<<< .*?\n(.*?)^=======\n(.*?)^>>>>>>> .*?\n",
|
|
30
|
+
re.MULTILINE | re.DOTALL,
|
|
31
|
+
)
|
|
32
|
+
CHECKBOX_PATTERN = re.compile(r"^(\s*-\s*\[)([x ])\](.*)$", re.MULTILINE)
|
|
33
|
+
LANE_PATTERN = re.compile(r"^(\s*lane:\s*)([\"']?)(\w+)([\"']?)\s*$", re.MULTILINE)
|
|
34
|
+
HISTORY_BLOCK_PATTERN = re.compile(
|
|
35
|
+
r"^(?P<indent>\s*)history:\s*\n(?P<body>(?:^(?P=indent)\s+-.*\n?)+)",
|
|
36
|
+
re.MULTILINE,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
STATUS_FILE_PATTERNS = [
|
|
40
|
+
"kitty-specs/*/tasks/*.md",
|
|
41
|
+
"kitty-specs/*/tasks.md",
|
|
42
|
+
"kitty-specs/*/*/tasks/*.md",
|
|
43
|
+
"kitty-specs/*/*/tasks.md",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
LANE_PRIORITY = {
|
|
47
|
+
"done": 4,
|
|
48
|
+
"for_review": 3,
|
|
49
|
+
"doing": 2,
|
|
50
|
+
"planned": 1,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class ConflictRegion:
|
|
56
|
+
"""A single conflict region in a file."""
|
|
57
|
+
|
|
58
|
+
start_line: int
|
|
59
|
+
end_line: int
|
|
60
|
+
ours: str
|
|
61
|
+
theirs: str
|
|
62
|
+
original: str
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass
|
|
66
|
+
class ResolutionResult:
|
|
67
|
+
"""Result of auto-resolving a status file conflict."""
|
|
68
|
+
|
|
69
|
+
file_path: Path
|
|
70
|
+
resolved: bool
|
|
71
|
+
resolution_type: str
|
|
72
|
+
original_conflicts: int
|
|
73
|
+
resolved_conflicts: int
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def parse_conflict_markers(content: str) -> list[ConflictRegion]:
|
|
77
|
+
"""Parse conflict markers from file content."""
|
|
78
|
+
regions = []
|
|
79
|
+
for match in CONFLICT_PATTERN.finditer(content):
|
|
80
|
+
regions.append(
|
|
81
|
+
ConflictRegion(
|
|
82
|
+
start_line=content[:match.start()].count("\n"),
|
|
83
|
+
end_line=content[:match.end()].count("\n"),
|
|
84
|
+
ours=match.group(1),
|
|
85
|
+
theirs=match.group(2),
|
|
86
|
+
original=match.group(0),
|
|
87
|
+
)
|
|
88
|
+
)
|
|
89
|
+
return regions
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _preserve_trailing_newline(resolved: str, original: str) -> str:
|
|
93
|
+
if original.endswith("\n") and not resolved.endswith("\n"):
|
|
94
|
+
return resolved + "\n"
|
|
95
|
+
return resolved
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def is_status_file(file_path: str) -> bool:
|
|
99
|
+
"""Check if file matches status file patterns."""
|
|
100
|
+
for pattern in STATUS_FILE_PATTERNS:
|
|
101
|
+
if fnmatch.fnmatch(file_path, pattern):
|
|
102
|
+
return True
|
|
103
|
+
return False
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def extract_lane_value(content: str) -> str | None:
|
|
107
|
+
"""Extract lane value from YAML frontmatter content."""
|
|
108
|
+
match = LANE_PATTERN.search(content)
|
|
109
|
+
return match.group(3) if match else None
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def replace_lane_value(content: str, lane_value: str) -> str:
|
|
113
|
+
"""Replace lane value in content with the provided value."""
|
|
114
|
+
if not LANE_PATTERN.search(content):
|
|
115
|
+
return content
|
|
116
|
+
return LANE_PATTERN.sub(
|
|
117
|
+
lambda match: f"{match.group(1)}{match.group(2)}{lane_value}{match.group(4)}",
|
|
118
|
+
content,
|
|
119
|
+
count=1,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def resolve_lane_conflict(ours: str, theirs: str) -> str | None:
|
|
124
|
+
"""Resolve lane conflict by choosing 'more done' value."""
|
|
125
|
+
our_lane = extract_lane_value(ours)
|
|
126
|
+
their_lane = extract_lane_value(theirs)
|
|
127
|
+
|
|
128
|
+
if not our_lane or not their_lane:
|
|
129
|
+
return None
|
|
130
|
+
|
|
131
|
+
our_priority = LANE_PRIORITY.get(our_lane, 0)
|
|
132
|
+
their_priority = LANE_PRIORITY.get(their_lane, 0)
|
|
133
|
+
chosen = their_lane if their_priority > our_priority else our_lane
|
|
134
|
+
|
|
135
|
+
return replace_lane_value(ours, chosen)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def resolve_checkbox_conflict(ours: str, theirs: str) -> str:
|
|
139
|
+
"""Resolve checkbox conflict by preferring checked [x]."""
|
|
140
|
+
our_lines = ours.strip().split("\n")
|
|
141
|
+
their_lines = theirs.strip().split("\n")
|
|
142
|
+
|
|
143
|
+
result_lines = []
|
|
144
|
+
max_lines = max(len(our_lines), len(their_lines))
|
|
145
|
+
|
|
146
|
+
for i in range(max_lines):
|
|
147
|
+
our_line = our_lines[i] if i < len(our_lines) else ""
|
|
148
|
+
their_line = their_lines[i] if i < len(their_lines) else ""
|
|
149
|
+
|
|
150
|
+
our_match = CHECKBOX_PATTERN.match(our_line)
|
|
151
|
+
their_match = CHECKBOX_PATTERN.match(their_line)
|
|
152
|
+
|
|
153
|
+
if our_match and their_match:
|
|
154
|
+
if their_match.group(2) == "x" and our_match.group(2) != "x":
|
|
155
|
+
result_lines.append(their_line)
|
|
156
|
+
else:
|
|
157
|
+
result_lines.append(our_line)
|
|
158
|
+
elif their_match and not our_line.strip():
|
|
159
|
+
result_lines.append(their_line)
|
|
160
|
+
else:
|
|
161
|
+
result_lines.append(our_line)
|
|
162
|
+
|
|
163
|
+
return "\n".join(result_lines)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _parse_history_entries(content: str) -> list[dict[str, Any]] | None:
|
|
167
|
+
match = HISTORY_BLOCK_PATTERN.search(content)
|
|
168
|
+
if not match:
|
|
169
|
+
return []
|
|
170
|
+
|
|
171
|
+
history_yaml = f"history:\n{match.group('body')}"
|
|
172
|
+
try:
|
|
173
|
+
data = yaml.safe_load(history_yaml) or {}
|
|
174
|
+
except Exception:
|
|
175
|
+
return None
|
|
176
|
+
|
|
177
|
+
if not isinstance(data, dict):
|
|
178
|
+
return None
|
|
179
|
+
|
|
180
|
+
entries = data.get("history", [])
|
|
181
|
+
if not isinstance(entries, list):
|
|
182
|
+
return None
|
|
183
|
+
|
|
184
|
+
return [entry for entry in entries if isinstance(entry, dict)]
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _merge_history_entries(
|
|
188
|
+
ours: list[dict[str, Any]], theirs: list[dict[str, Any]]
|
|
189
|
+
) -> list[dict[str, Any]]:
|
|
190
|
+
merged = ours + theirs
|
|
191
|
+
|
|
192
|
+
seen: set[tuple[str, str, str, str]] = set()
|
|
193
|
+
unique: list[dict[str, Any]] = []
|
|
194
|
+
for entry in merged:
|
|
195
|
+
key = (
|
|
196
|
+
str(entry.get("timestamp", "")),
|
|
197
|
+
str(entry.get("action", "")),
|
|
198
|
+
str(entry.get("lane", "")),
|
|
199
|
+
str(entry.get("agent", "")),
|
|
200
|
+
)
|
|
201
|
+
if key in seen:
|
|
202
|
+
continue
|
|
203
|
+
seen.add(key)
|
|
204
|
+
unique.append(entry)
|
|
205
|
+
|
|
206
|
+
unique.sort(key=lambda entry: str(entry.get("timestamp", "")))
|
|
207
|
+
return unique
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _build_history_block(entries: list[dict[str, Any]], indent: str = "") -> str:
|
|
211
|
+
dumped = yaml.safe_dump(
|
|
212
|
+
{"history": entries},
|
|
213
|
+
sort_keys=False,
|
|
214
|
+
default_flow_style=False,
|
|
215
|
+
).rstrip("\n")
|
|
216
|
+
if not indent:
|
|
217
|
+
return dumped
|
|
218
|
+
return "\n".join(f"{indent}{line}" if line else line for line in dumped.split("\n"))
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def resolve_history_conflict(ours: str, theirs: str) -> str | None:
|
|
222
|
+
"""Resolve history array conflict by merging chronologically."""
|
|
223
|
+
our_entries = _parse_history_entries(ours)
|
|
224
|
+
their_entries = _parse_history_entries(theirs)
|
|
225
|
+
|
|
226
|
+
if our_entries is None or their_entries is None:
|
|
227
|
+
return None
|
|
228
|
+
|
|
229
|
+
if not our_entries and not their_entries:
|
|
230
|
+
return None
|
|
231
|
+
|
|
232
|
+
merged = _merge_history_entries(our_entries, their_entries)
|
|
233
|
+
|
|
234
|
+
base = ours if HISTORY_BLOCK_PATTERN.search(ours) else theirs
|
|
235
|
+
match = HISTORY_BLOCK_PATTERN.search(base)
|
|
236
|
+
if not match:
|
|
237
|
+
return None
|
|
238
|
+
|
|
239
|
+
indent = match.group("indent")
|
|
240
|
+
history_block = _build_history_block(merged, indent=indent)
|
|
241
|
+
return base[: match.start()] + history_block + base[match.end() :]
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def resolve_status_conflicts(repo_root: Path) -> list[ResolutionResult]:
|
|
245
|
+
"""Auto-resolve conflicts in status files after merge."""
|
|
246
|
+
results: list[ResolutionResult] = []
|
|
247
|
+
conflicted = get_conflicted_files(repo_root)
|
|
248
|
+
|
|
249
|
+
for file_path in conflicted:
|
|
250
|
+
rel_path = str(file_path.relative_to(repo_root))
|
|
251
|
+
if not is_status_file(rel_path):
|
|
252
|
+
results.append(
|
|
253
|
+
ResolutionResult(
|
|
254
|
+
file_path=file_path,
|
|
255
|
+
resolved=False,
|
|
256
|
+
resolution_type="manual_required",
|
|
257
|
+
original_conflicts=1,
|
|
258
|
+
resolved_conflicts=0,
|
|
259
|
+
)
|
|
260
|
+
)
|
|
261
|
+
continue
|
|
262
|
+
|
|
263
|
+
try:
|
|
264
|
+
content = file_path.read_text(encoding="utf-8")
|
|
265
|
+
except Exception:
|
|
266
|
+
results.append(
|
|
267
|
+
ResolutionResult(
|
|
268
|
+
file_path=file_path,
|
|
269
|
+
resolved=False,
|
|
270
|
+
resolution_type="error",
|
|
271
|
+
original_conflicts=1,
|
|
272
|
+
resolved_conflicts=0,
|
|
273
|
+
)
|
|
274
|
+
)
|
|
275
|
+
continue
|
|
276
|
+
|
|
277
|
+
regions = parse_conflict_markers(content)
|
|
278
|
+
if not regions:
|
|
279
|
+
continue
|
|
280
|
+
|
|
281
|
+
resolved_content = content
|
|
282
|
+
resolved_count = 0
|
|
283
|
+
resolution_types: set[str] = set()
|
|
284
|
+
|
|
285
|
+
for region in regions:
|
|
286
|
+
if CHECKBOX_PATTERN.search(region.ours) or CHECKBOX_PATTERN.search(region.theirs):
|
|
287
|
+
resolved_region = resolve_checkbox_conflict(region.ours, region.theirs)
|
|
288
|
+
resolved_region = _preserve_trailing_newline(resolved_region, region.original)
|
|
289
|
+
resolved_content = resolved_content.replace(region.original, resolved_region)
|
|
290
|
+
resolved_count += 1
|
|
291
|
+
resolution_types.add("checkbox")
|
|
292
|
+
continue
|
|
293
|
+
|
|
294
|
+
resolved_region = resolve_history_conflict(region.ours, region.theirs)
|
|
295
|
+
if resolved_region is not None:
|
|
296
|
+
lane_resolved = resolve_lane_conflict(region.ours, region.theirs)
|
|
297
|
+
if lane_resolved is not None:
|
|
298
|
+
lane_value = extract_lane_value(lane_resolved)
|
|
299
|
+
if lane_value:
|
|
300
|
+
resolved_region = replace_lane_value(resolved_region, lane_value)
|
|
301
|
+
resolution_types.add("lane")
|
|
302
|
+
resolution_types.add("history")
|
|
303
|
+
resolved_region = _preserve_trailing_newline(resolved_region, region.original)
|
|
304
|
+
resolved_content = resolved_content.replace(region.original, resolved_region)
|
|
305
|
+
resolved_count += 1
|
|
306
|
+
continue
|
|
307
|
+
|
|
308
|
+
lane_resolved = resolve_lane_conflict(region.ours, region.theirs)
|
|
309
|
+
if lane_resolved is not None:
|
|
310
|
+
lane_resolved = _preserve_trailing_newline(lane_resolved, region.original)
|
|
311
|
+
resolved_content = resolved_content.replace(region.original, lane_resolved)
|
|
312
|
+
resolved_count += 1
|
|
313
|
+
resolution_types.add("lane")
|
|
314
|
+
|
|
315
|
+
resolved_all = resolved_count == len(regions)
|
|
316
|
+
if resolved_count:
|
|
317
|
+
file_path.write_text(resolved_content, encoding="utf-8")
|
|
318
|
+
if resolved_all:
|
|
319
|
+
subprocess.run(
|
|
320
|
+
["git", "add", str(file_path)],
|
|
321
|
+
cwd=str(repo_root),
|
|
322
|
+
check=False,
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
if not resolution_types:
|
|
326
|
+
resolution_types.add("manual_required")
|
|
327
|
+
|
|
328
|
+
results.append(
|
|
329
|
+
ResolutionResult(
|
|
330
|
+
file_path=file_path,
|
|
331
|
+
resolved=resolved_all,
|
|
332
|
+
resolution_type="mixed" if len(resolution_types) > 1 else next(iter(resolution_types)),
|
|
333
|
+
original_conflicts=len(regions),
|
|
334
|
+
resolved_conflicts=resolved_count,
|
|
335
|
+
)
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
return results
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def get_conflicted_files(repo_root: Path) -> list[Path]:
|
|
342
|
+
"""Get list of files with merge conflicts."""
|
|
343
|
+
result = subprocess.run(
|
|
344
|
+
["git", "diff", "--name-only", "--diff-filter=U"],
|
|
345
|
+
cwd=str(repo_root),
|
|
346
|
+
capture_output=True,
|
|
347
|
+
text=True,
|
|
348
|
+
check=False,
|
|
349
|
+
)
|
|
350
|
+
files: list[Path] = []
|
|
351
|
+
for line in result.stdout.strip().split("\n"):
|
|
352
|
+
if line:
|
|
353
|
+
files.append(repo_root / line)
|
|
354
|
+
return files
|