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,755 @@
|
|
|
1
|
+
"""Merge command implementation.
|
|
2
|
+
|
|
3
|
+
Merges completed work packages into target branch with VCS abstraction support.
|
|
4
|
+
Supports both git and jujutsu backends through the VCS abstraction layer.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import subprocess
|
|
12
|
+
import warnings
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import typer
|
|
16
|
+
|
|
17
|
+
from specify_cli.cli import StepTracker
|
|
18
|
+
from specify_cli.cli.helpers import check_version_compatibility, console, show_banner
|
|
19
|
+
from specify_cli.core.git_ops import run_command
|
|
20
|
+
from specify_cli.core.vcs import VCSBackend, get_vcs
|
|
21
|
+
from specify_cli.core.context_validation import require_main_repo
|
|
22
|
+
from specify_cli.merge.executor import execute_legacy_merge, execute_merge
|
|
23
|
+
from specify_cli.merge.preflight import (
|
|
24
|
+
display_preflight_result,
|
|
25
|
+
run_preflight,
|
|
26
|
+
)
|
|
27
|
+
from specify_cli.merge.state import (
|
|
28
|
+
MergeState,
|
|
29
|
+
abort_git_merge,
|
|
30
|
+
clear_state,
|
|
31
|
+
detect_git_merge_state,
|
|
32
|
+
get_state_path,
|
|
33
|
+
load_state,
|
|
34
|
+
)
|
|
35
|
+
from specify_cli.tasks_support import TaskCliError, find_repo_root
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def get_main_repo_root(repo_root: Path) -> Path:
|
|
39
|
+
"""Get the main repository root, even if called from a worktree.
|
|
40
|
+
|
|
41
|
+
If repo_root is a worktree, find its main repository.
|
|
42
|
+
Otherwise, return repo_root as-is.
|
|
43
|
+
"""
|
|
44
|
+
git_dir = repo_root / ".git"
|
|
45
|
+
|
|
46
|
+
# If .git is a directory, we're in the main repo
|
|
47
|
+
if git_dir.is_dir():
|
|
48
|
+
return repo_root
|
|
49
|
+
|
|
50
|
+
# If .git is a file, we're in a worktree - read it to find main repo
|
|
51
|
+
if git_dir.is_file():
|
|
52
|
+
git_file_content = git_dir.read_text().strip()
|
|
53
|
+
# Format: "gitdir: /path/to/main/repo/.git/worktrees/feature-name"
|
|
54
|
+
if git_file_content.startswith("gitdir: "):
|
|
55
|
+
gitdir_path = Path(git_file_content[8:]) # Remove "gitdir: " prefix
|
|
56
|
+
# Go up from .git/worktrees/feature-name to main repo root
|
|
57
|
+
# gitdir_path points to: /main/repo/.git/worktrees/feature-name
|
|
58
|
+
# We want: /main/repo
|
|
59
|
+
if "worktrees" in gitdir_path.parts:
|
|
60
|
+
# Find the .git parent
|
|
61
|
+
main_git_dir = gitdir_path
|
|
62
|
+
while main_git_dir.name != ".git":
|
|
63
|
+
main_git_dir = main_git_dir.parent
|
|
64
|
+
if main_git_dir == main_git_dir.parent:
|
|
65
|
+
# Reached root without finding .git
|
|
66
|
+
break
|
|
67
|
+
return main_git_dir.parent
|
|
68
|
+
|
|
69
|
+
# Fallback: return as-is
|
|
70
|
+
return repo_root
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def detect_worktree_structure(repo_root: Path, feature_slug: str) -> str:
|
|
74
|
+
"""Detect if feature uses legacy or workspace-per-WP model.
|
|
75
|
+
|
|
76
|
+
Returns: "legacy", "workspace-per-wp", or "none"
|
|
77
|
+
|
|
78
|
+
IMPORTANT: This function must work correctly when called from within a worktree.
|
|
79
|
+
repo_root may be a worktree directory, so we need to find the main repo first.
|
|
80
|
+
"""
|
|
81
|
+
# Get the main repository root (handles case where repo_root is a worktree)
|
|
82
|
+
main_repo = get_main_repo_root(repo_root)
|
|
83
|
+
worktrees_dir = main_repo / ".worktrees"
|
|
84
|
+
|
|
85
|
+
if not worktrees_dir.exists():
|
|
86
|
+
return "none"
|
|
87
|
+
|
|
88
|
+
# Look for workspace-per-WP pattern FIRST (takes precedence per spec)
|
|
89
|
+
# Pattern: .worktrees/###-feature-WP##/
|
|
90
|
+
wp_pattern = list(worktrees_dir.glob(f"{feature_slug}-WP*"))
|
|
91
|
+
if wp_pattern:
|
|
92
|
+
return "workspace-per-wp"
|
|
93
|
+
|
|
94
|
+
# Look for legacy pattern: .worktrees/###-feature/
|
|
95
|
+
legacy_pattern = worktrees_dir / feature_slug
|
|
96
|
+
if legacy_pattern.exists() and legacy_pattern.is_dir():
|
|
97
|
+
return "legacy"
|
|
98
|
+
|
|
99
|
+
return "none"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def extract_wp_id(worktree_path: Path) -> str | None:
|
|
103
|
+
"""Extract WP ID from worktree directory name.
|
|
104
|
+
|
|
105
|
+
Example: .worktrees/010-feature-WP01/ → WP01
|
|
106
|
+
"""
|
|
107
|
+
name = worktree_path.name
|
|
108
|
+
match = re.search(r'-(WP\d{2})$', name)
|
|
109
|
+
if match:
|
|
110
|
+
return match.group(1)
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def find_wp_worktrees(repo_root: Path, feature_slug: str) -> list[tuple[Path, str, str]]:
|
|
115
|
+
"""Find all WP worktrees for a feature.
|
|
116
|
+
|
|
117
|
+
Returns: List of (worktree_path, wp_id, branch_name) tuples, sorted by WP ID.
|
|
118
|
+
|
|
119
|
+
IMPORTANT: This function must work correctly when called from within a worktree.
|
|
120
|
+
"""
|
|
121
|
+
# Get the main repository root (handles case where repo_root is a worktree)
|
|
122
|
+
main_repo = get_main_repo_root(repo_root)
|
|
123
|
+
worktrees_dir = main_repo / ".worktrees"
|
|
124
|
+
pattern = f"{feature_slug}-WP*"
|
|
125
|
+
|
|
126
|
+
wp_worktrees = sorted(worktrees_dir.glob(pattern))
|
|
127
|
+
|
|
128
|
+
wp_workspaces = []
|
|
129
|
+
for wt_path in wp_worktrees:
|
|
130
|
+
wp_id = extract_wp_id(wt_path)
|
|
131
|
+
if wp_id:
|
|
132
|
+
branch_name = wt_path.name # Directory name = branch name
|
|
133
|
+
wp_workspaces.append((wt_path, wp_id, branch_name))
|
|
134
|
+
|
|
135
|
+
return wp_workspaces
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def extract_feature_slug(branch_name: str) -> str:
|
|
139
|
+
"""Extract feature slug from a WP branch name.
|
|
140
|
+
|
|
141
|
+
Example: 010-workspace-per-wp-WP01 → 010-workspace-per-wp
|
|
142
|
+
"""
|
|
143
|
+
match = re.match(r'(.*?)-WP\d{2}$', branch_name)
|
|
144
|
+
if match:
|
|
145
|
+
return match.group(1)
|
|
146
|
+
return branch_name # Return as-is for legacy branches
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def validate_wp_ready_for_merge(repo_root: Path, worktree_path: Path, branch_name: str) -> tuple[bool, str]:
|
|
150
|
+
"""Validate WP workspace is ready to merge."""
|
|
151
|
+
# Check 1: Branch exists in git (check from repo root)
|
|
152
|
+
result = subprocess.run(
|
|
153
|
+
["git", "rev-parse", "--verify", branch_name],
|
|
154
|
+
cwd=str(repo_root),
|
|
155
|
+
capture_output=True,
|
|
156
|
+
check=False
|
|
157
|
+
)
|
|
158
|
+
if result.returncode != 0:
|
|
159
|
+
return False, f"Branch {branch_name} does not exist"
|
|
160
|
+
|
|
161
|
+
# Check 2: No uncommitted changes in worktree
|
|
162
|
+
result = subprocess.run(
|
|
163
|
+
["git", "status", "--porcelain"],
|
|
164
|
+
cwd=str(worktree_path),
|
|
165
|
+
capture_output=True,
|
|
166
|
+
text=True
|
|
167
|
+
)
|
|
168
|
+
if result.stdout.strip():
|
|
169
|
+
return False, f"Worktree {worktree_path.name} has uncommitted changes"
|
|
170
|
+
|
|
171
|
+
return True, ""
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def merge_workspace_per_wp(
|
|
175
|
+
repo_root: Path,
|
|
176
|
+
merge_root: Path,
|
|
177
|
+
feature_slug: str,
|
|
178
|
+
current_branch: str,
|
|
179
|
+
target_branch: str,
|
|
180
|
+
strategy: str,
|
|
181
|
+
delete_branch: bool,
|
|
182
|
+
remove_worktree: bool,
|
|
183
|
+
push: bool,
|
|
184
|
+
dry_run: bool,
|
|
185
|
+
tracker: StepTracker,
|
|
186
|
+
resume_state: MergeState | None = None,
|
|
187
|
+
) -> None:
|
|
188
|
+
"""Handle merge for workspace-per-WP features.
|
|
189
|
+
|
|
190
|
+
IMPORTANT: repo_root may be a worktree directory. All worktree detection
|
|
191
|
+
and operations use get_main_repo_root() to find the actual main repository.
|
|
192
|
+
"""
|
|
193
|
+
# Get the main repository root (handles case where repo_root is a worktree)
|
|
194
|
+
main_repo = get_main_repo_root(repo_root)
|
|
195
|
+
|
|
196
|
+
# Find all WP worktrees (this function also uses get_main_repo_root internally)
|
|
197
|
+
wp_workspaces = find_wp_worktrees(repo_root, feature_slug)
|
|
198
|
+
|
|
199
|
+
# Filter out already-completed WPs if resuming
|
|
200
|
+
if resume_state and resume_state.completed_wps:
|
|
201
|
+
completed_set = set(resume_state.completed_wps)
|
|
202
|
+
wp_workspaces = [
|
|
203
|
+
(wt_path, wp_id, branch)
|
|
204
|
+
for wt_path, wp_id, branch in wp_workspaces
|
|
205
|
+
if wp_id not in completed_set
|
|
206
|
+
]
|
|
207
|
+
console.print(f"[cyan]Resuming merge:[/cyan] {len(resume_state.completed_wps)} WPs already merged")
|
|
208
|
+
|
|
209
|
+
if not wp_workspaces:
|
|
210
|
+
console.print(tracker.render())
|
|
211
|
+
console.print(f"\n[yellow]Warning:[/yellow] No WP worktrees found for feature {feature_slug}")
|
|
212
|
+
console.print("Feature may already be merged or not yet implemented")
|
|
213
|
+
raise typer.Exit(1)
|
|
214
|
+
|
|
215
|
+
console.print(f"\n[cyan]Workspace-per-WP feature detected:[/cyan] {len(wp_workspaces)} work packages")
|
|
216
|
+
for wt_path, wp_id, branch in wp_workspaces:
|
|
217
|
+
console.print(f" - {wp_id}: {branch}")
|
|
218
|
+
|
|
219
|
+
# Validate all WP workspaces are ready
|
|
220
|
+
console.print(f"\n[cyan]Validating all WP workspaces...[/cyan]")
|
|
221
|
+
errors = []
|
|
222
|
+
for wt_path, wp_id, branch in wp_workspaces:
|
|
223
|
+
is_valid, error_msg = validate_wp_ready_for_merge(main_repo, wt_path, branch)
|
|
224
|
+
if not is_valid:
|
|
225
|
+
errors.append(f" - {wp_id}: {error_msg}")
|
|
226
|
+
|
|
227
|
+
if errors:
|
|
228
|
+
tracker.error("verify", "WP workspaces not ready")
|
|
229
|
+
console.print(tracker.render())
|
|
230
|
+
console.print(f"\n[red]Cannot merge:[/red] WP workspaces not ready")
|
|
231
|
+
for err in errors:
|
|
232
|
+
console.print(err)
|
|
233
|
+
raise typer.Exit(1)
|
|
234
|
+
|
|
235
|
+
console.print(f"[green]✓[/green] All WP workspaces validated")
|
|
236
|
+
|
|
237
|
+
# Dry run: show what would be done
|
|
238
|
+
if dry_run:
|
|
239
|
+
console.print(tracker.render())
|
|
240
|
+
console.print("\n[cyan]Dry run - would execute:[/cyan]")
|
|
241
|
+
steps = [
|
|
242
|
+
f"git checkout {target_branch}",
|
|
243
|
+
"git pull --ff-only",
|
|
244
|
+
]
|
|
245
|
+
for wt_path, wp_id, branch in wp_workspaces:
|
|
246
|
+
if strategy == "squash":
|
|
247
|
+
steps.extend([
|
|
248
|
+
f"git merge --squash {branch}",
|
|
249
|
+
f"git commit -m 'Merge {wp_id} from {feature_slug}'",
|
|
250
|
+
])
|
|
251
|
+
else:
|
|
252
|
+
steps.append(f"git merge --no-ff {branch} -m 'Merge {wp_id} from {feature_slug}'")
|
|
253
|
+
|
|
254
|
+
if push:
|
|
255
|
+
steps.append(f"git push origin {target_branch}")
|
|
256
|
+
|
|
257
|
+
if remove_worktree:
|
|
258
|
+
for wt_path, wp_id, branch in wp_workspaces:
|
|
259
|
+
steps.append(f"git worktree remove {wt_path}")
|
|
260
|
+
|
|
261
|
+
if delete_branch:
|
|
262
|
+
for wt_path, wp_id, branch in wp_workspaces:
|
|
263
|
+
steps.append(f"git branch -d {branch}")
|
|
264
|
+
|
|
265
|
+
for idx, step in enumerate(steps, start=1):
|
|
266
|
+
console.print(f" {idx}. {step}")
|
|
267
|
+
return
|
|
268
|
+
|
|
269
|
+
# Checkout and update target branch
|
|
270
|
+
tracker.start("checkout")
|
|
271
|
+
try:
|
|
272
|
+
console.print(f"[cyan]Operating from {merge_root}[/cyan]")
|
|
273
|
+
os.chdir(merge_root)
|
|
274
|
+
_, target_status, _ = run_command(["git", "status", "--porcelain"], capture=True)
|
|
275
|
+
if target_status.strip():
|
|
276
|
+
raise RuntimeError(f"Target repository at {merge_root} has uncommitted changes.")
|
|
277
|
+
run_command(["git", "checkout", target_branch])
|
|
278
|
+
tracker.complete("checkout", f"using {merge_root}")
|
|
279
|
+
except Exception as exc:
|
|
280
|
+
tracker.error("checkout", str(exc))
|
|
281
|
+
console.print(tracker.render())
|
|
282
|
+
raise typer.Exit(1)
|
|
283
|
+
|
|
284
|
+
tracker.start("pull")
|
|
285
|
+
try:
|
|
286
|
+
run_command(["git", "pull", "--ff-only"])
|
|
287
|
+
tracker.complete("pull")
|
|
288
|
+
except Exception as exc:
|
|
289
|
+
tracker.error("pull", str(exc))
|
|
290
|
+
console.print(tracker.render())
|
|
291
|
+
console.print(f"\n[yellow]Warning:[/yellow] Could not fast-forward {target_branch}.")
|
|
292
|
+
console.print("You may need to resolve conflicts manually.")
|
|
293
|
+
raise typer.Exit(1)
|
|
294
|
+
|
|
295
|
+
# Merge all WP branches
|
|
296
|
+
tracker.start("merge")
|
|
297
|
+
try:
|
|
298
|
+
for wt_path, wp_id, branch in wp_workspaces:
|
|
299
|
+
console.print(f"[cyan]Merging {wp_id} ({branch})...[/cyan]")
|
|
300
|
+
|
|
301
|
+
if strategy == "squash":
|
|
302
|
+
run_command(["git", "merge", "--squash", branch])
|
|
303
|
+
run_command(["git", "commit", "-m", f"Merge {wp_id} from {feature_slug}"])
|
|
304
|
+
elif strategy == "rebase":
|
|
305
|
+
console.print("\n[yellow]Note:[/yellow] Rebase strategy not supported for workspace-per-WP.")
|
|
306
|
+
console.print("Use 'merge' or 'squash' strategy instead.")
|
|
307
|
+
tracker.skip("merge", "rebase not supported for workspace-per-WP")
|
|
308
|
+
console.print(tracker.render())
|
|
309
|
+
raise typer.Exit(1)
|
|
310
|
+
else: # merge (default)
|
|
311
|
+
run_command(["git", "merge", "--no-ff", branch, "-m", f"Merge {wp_id} from {feature_slug}"])
|
|
312
|
+
|
|
313
|
+
console.print(f"[green]✓[/green] {wp_id} merged")
|
|
314
|
+
|
|
315
|
+
tracker.complete("merge", f"merged {len(wp_workspaces)} work packages")
|
|
316
|
+
except Exception as exc:
|
|
317
|
+
tracker.error("merge", str(exc))
|
|
318
|
+
console.print(tracker.render())
|
|
319
|
+
console.print(f"\n[red]Merge failed.[/red] Resolve conflicts and try again.")
|
|
320
|
+
raise typer.Exit(1)
|
|
321
|
+
|
|
322
|
+
# Push if requested
|
|
323
|
+
if push:
|
|
324
|
+
tracker.start("push")
|
|
325
|
+
try:
|
|
326
|
+
run_command(["git", "push", "origin", target_branch])
|
|
327
|
+
tracker.complete("push")
|
|
328
|
+
except Exception as exc:
|
|
329
|
+
tracker.error("push", str(exc))
|
|
330
|
+
console.print(tracker.render())
|
|
331
|
+
console.print(f"\n[yellow]Warning:[/yellow] Merge succeeded but push failed.")
|
|
332
|
+
console.print(f"Run manually: git push origin {target_branch}")
|
|
333
|
+
|
|
334
|
+
# Remove worktrees
|
|
335
|
+
if remove_worktree:
|
|
336
|
+
tracker.start("worktree")
|
|
337
|
+
failed_removals = []
|
|
338
|
+
for wt_path, wp_id, branch in wp_workspaces:
|
|
339
|
+
try:
|
|
340
|
+
run_command(["git", "worktree", "remove", str(wt_path), "--force"])
|
|
341
|
+
console.print(f"[green]✓[/green] Removed worktree: {wp_id}")
|
|
342
|
+
except Exception as exc:
|
|
343
|
+
failed_removals.append((wp_id, wt_path))
|
|
344
|
+
|
|
345
|
+
if failed_removals:
|
|
346
|
+
tracker.error("worktree", f"could not remove {len(failed_removals)} worktrees")
|
|
347
|
+
console.print(tracker.render())
|
|
348
|
+
console.print(f"\n[yellow]Warning:[/yellow] Could not remove some worktrees:")
|
|
349
|
+
for wp_id, wt_path in failed_removals:
|
|
350
|
+
console.print(f" {wp_id}: git worktree remove {wt_path}")
|
|
351
|
+
else:
|
|
352
|
+
tracker.complete("worktree", f"removed {len(wp_workspaces)} worktrees")
|
|
353
|
+
|
|
354
|
+
# Delete branches
|
|
355
|
+
if delete_branch:
|
|
356
|
+
tracker.start("branch")
|
|
357
|
+
failed_deletions = []
|
|
358
|
+
for wt_path, wp_id, branch in wp_workspaces:
|
|
359
|
+
try:
|
|
360
|
+
run_command(["git", "branch", "-d", branch])
|
|
361
|
+
console.print(f"[green]✓[/green] Deleted branch: {branch}")
|
|
362
|
+
except Exception:
|
|
363
|
+
# Try force delete
|
|
364
|
+
try:
|
|
365
|
+
run_command(["git", "branch", "-D", branch])
|
|
366
|
+
console.print(f"[green]✓[/green] Force deleted branch: {branch}")
|
|
367
|
+
except Exception:
|
|
368
|
+
failed_deletions.append((wp_id, branch))
|
|
369
|
+
|
|
370
|
+
if failed_deletions:
|
|
371
|
+
tracker.error("branch", f"could not delete {len(failed_deletions)} branches")
|
|
372
|
+
console.print(tracker.render())
|
|
373
|
+
console.print(f"\n[yellow]Warning:[/yellow] Could not delete some branches:")
|
|
374
|
+
for wp_id, branch in failed_deletions:
|
|
375
|
+
console.print(f" {wp_id}: git branch -D {branch}")
|
|
376
|
+
else:
|
|
377
|
+
tracker.complete("branch", f"deleted {len(wp_workspaces)} branches")
|
|
378
|
+
|
|
379
|
+
console.print(tracker.render())
|
|
380
|
+
console.print(f"\n[bold green]✓ Feature {feature_slug} ({len(wp_workspaces)} WPs) successfully merged into {target_branch}[/bold green]")
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
@require_main_repo
|
|
384
|
+
def merge(
|
|
385
|
+
strategy: str = typer.Option("merge", "--strategy", help="Merge strategy: merge, squash, or rebase"),
|
|
386
|
+
delete_branch: bool = typer.Option(True, "--delete-branch/--keep-branch", help="Delete feature branch after merge"),
|
|
387
|
+
remove_worktree: bool = typer.Option(True, "--remove-worktree/--keep-worktree", help="Remove feature worktree after merge"),
|
|
388
|
+
push: bool = typer.Option(False, "--push", help="Push to origin after merge"),
|
|
389
|
+
target_branch: str = typer.Option("main", "--target", help="Target branch to merge into"),
|
|
390
|
+
dry_run: bool = typer.Option(False, "--dry-run", help="Show what would be done without executing"),
|
|
391
|
+
feature: str = typer.Option(None, "--feature", help="Feature slug when merging from main branch"),
|
|
392
|
+
resume: bool = typer.Option(False, "--resume", help="Resume an interrupted merge from saved state"),
|
|
393
|
+
abort: bool = typer.Option(False, "--abort", help="Abort and clear merge state"),
|
|
394
|
+
) -> None:
|
|
395
|
+
"""Merge a completed feature branch into the target branch and clean up resources.
|
|
396
|
+
|
|
397
|
+
For workspace-per-WP features (0.11.0+), merges all WP branches
|
|
398
|
+
(010-feature-WP01, 010-feature-WP02, etc.) to main in sequence.
|
|
399
|
+
|
|
400
|
+
For legacy features (0.10.x), merges single feature branch.
|
|
401
|
+
|
|
402
|
+
Use --resume to continue an interrupted merge from saved state.
|
|
403
|
+
Use --abort to clear merge state and abort any in-progress git merge.
|
|
404
|
+
"""
|
|
405
|
+
show_banner()
|
|
406
|
+
|
|
407
|
+
# Handle --abort flag early (before any other processing)
|
|
408
|
+
if abort:
|
|
409
|
+
try:
|
|
410
|
+
repo_root = find_repo_root()
|
|
411
|
+
except TaskCliError as exc:
|
|
412
|
+
console.print(f"[red]Error:[/red] {exc}")
|
|
413
|
+
raise typer.Exit(1)
|
|
414
|
+
|
|
415
|
+
main_repo = get_main_repo_root(repo_root)
|
|
416
|
+
state = load_state(main_repo)
|
|
417
|
+
|
|
418
|
+
if state is None:
|
|
419
|
+
console.print("[yellow]No merge state to abort[/yellow]")
|
|
420
|
+
else:
|
|
421
|
+
clear_state(main_repo)
|
|
422
|
+
console.print(f"[green]✓[/green] Merge state cleared for {state.feature_slug}")
|
|
423
|
+
console.print(f" Progress was: {len(state.completed_wps)}/{len(state.wp_order)} WPs complete")
|
|
424
|
+
|
|
425
|
+
# Also abort git merge if in progress
|
|
426
|
+
if abort_git_merge(main_repo):
|
|
427
|
+
console.print("[green]✓[/green] Git merge aborted")
|
|
428
|
+
|
|
429
|
+
raise typer.Exit(0)
|
|
430
|
+
|
|
431
|
+
# Handle --resume flag
|
|
432
|
+
resume_state: MergeState | None = None
|
|
433
|
+
if resume:
|
|
434
|
+
try:
|
|
435
|
+
repo_root = find_repo_root()
|
|
436
|
+
except TaskCliError as exc:
|
|
437
|
+
console.print(f"[red]Error:[/red] {exc}")
|
|
438
|
+
raise typer.Exit(1)
|
|
439
|
+
|
|
440
|
+
main_repo = get_main_repo_root(repo_root)
|
|
441
|
+
resume_state = load_state(main_repo)
|
|
442
|
+
|
|
443
|
+
if resume_state is None:
|
|
444
|
+
state_path = get_state_path(main_repo)
|
|
445
|
+
if state_path.exists():
|
|
446
|
+
clear_state(main_repo)
|
|
447
|
+
console.print("[yellow]⚠ Invalid merge state file cleared[/yellow]")
|
|
448
|
+
console.print("[red]Error:[/red] No merge state to resume")
|
|
449
|
+
console.print("Run 'spec-kitty merge --feature <slug>' to start a new merge.")
|
|
450
|
+
raise typer.Exit(1)
|
|
451
|
+
|
|
452
|
+
console.print(f"[cyan]Resuming merge of {resume_state.feature_slug}[/cyan]")
|
|
453
|
+
console.print(f" Progress: {len(resume_state.completed_wps)}/{len(resume_state.wp_order)} WPs")
|
|
454
|
+
console.print(f" Remaining: {', '.join(resume_state.remaining_wps)}")
|
|
455
|
+
|
|
456
|
+
# Check for pending git merge
|
|
457
|
+
if detect_git_merge_state(main_repo):
|
|
458
|
+
console.print("[yellow]⚠ Git merge in progress - resolve conflicts first[/yellow]")
|
|
459
|
+
console.print("Then run 'spec-kitty merge --resume' again.")
|
|
460
|
+
raise typer.Exit(1)
|
|
461
|
+
|
|
462
|
+
# Set feature from state and override options
|
|
463
|
+
feature = resume_state.feature_slug
|
|
464
|
+
target_branch = resume_state.target_branch
|
|
465
|
+
strategy = resume_state.strategy
|
|
466
|
+
|
|
467
|
+
tracker = StepTracker("Feature Merge")
|
|
468
|
+
tracker.add("detect", "Detect current feature and branch")
|
|
469
|
+
tracker.add("preflight", "Pre-flight validation")
|
|
470
|
+
tracker.add("verify", "Verify merge readiness")
|
|
471
|
+
tracker.add("checkout", f"Switch to {target_branch}")
|
|
472
|
+
tracker.add("pull", f"Update {target_branch}")
|
|
473
|
+
tracker.add("merge", "Merge feature branch")
|
|
474
|
+
if push: tracker.add("push", "Push to origin")
|
|
475
|
+
if remove_worktree: tracker.add("worktree", "Remove feature worktree")
|
|
476
|
+
if delete_branch: tracker.add("branch", "Delete feature branch")
|
|
477
|
+
console.print()
|
|
478
|
+
|
|
479
|
+
try:
|
|
480
|
+
repo_root = find_repo_root()
|
|
481
|
+
except TaskCliError as exc:
|
|
482
|
+
console.print(f"[red]Error:[/red] {exc}")
|
|
483
|
+
raise typer.Exit(1)
|
|
484
|
+
|
|
485
|
+
check_version_compatibility(repo_root, "merge")
|
|
486
|
+
|
|
487
|
+
# Detect VCS backend
|
|
488
|
+
try:
|
|
489
|
+
vcs = get_vcs(repo_root)
|
|
490
|
+
vcs_backend = vcs.backend
|
|
491
|
+
except Exception:
|
|
492
|
+
# Fall back to git if VCS detection fails
|
|
493
|
+
vcs_backend = VCSBackend.GIT
|
|
494
|
+
|
|
495
|
+
# Show VCS backend info
|
|
496
|
+
console.print(f"[dim]VCS Backend: git[/dim]")
|
|
497
|
+
|
|
498
|
+
feature_worktree_path = merge_root = repo_root
|
|
499
|
+
tracker.start("detect")
|
|
500
|
+
try:
|
|
501
|
+
_, current_branch, _ = run_command(["git", "rev-parse", "--abbrev-ref", "HEAD"], capture=True)
|
|
502
|
+
if current_branch == target_branch:
|
|
503
|
+
# Check if --feature flag was provided
|
|
504
|
+
if feature:
|
|
505
|
+
# Validate feature exists by checking for worktrees
|
|
506
|
+
main_repo = get_main_repo_root(repo_root)
|
|
507
|
+
worktrees_dir = main_repo / ".worktrees"
|
|
508
|
+
wp_pattern = list(worktrees_dir.glob(f"{feature}-WP*")) if worktrees_dir.exists() else []
|
|
509
|
+
|
|
510
|
+
if not wp_pattern:
|
|
511
|
+
tracker.error("detect", f"no WP worktrees found for {feature}")
|
|
512
|
+
console.print(tracker.render())
|
|
513
|
+
console.print(f"\n[red]Error:[/red] No WP worktrees found for feature '{feature}'.")
|
|
514
|
+
console.print("Check the feature slug or create workspaces first.")
|
|
515
|
+
raise typer.Exit(1)
|
|
516
|
+
|
|
517
|
+
# Use the provided feature slug and continue
|
|
518
|
+
feature_slug = feature
|
|
519
|
+
tracker.complete("detect", f"using --feature {feature_slug}")
|
|
520
|
+
|
|
521
|
+
# Get WP workspaces for preflight and merge
|
|
522
|
+
wp_workspaces = find_wp_worktrees(repo_root, feature_slug)
|
|
523
|
+
|
|
524
|
+
# Run preflight checks
|
|
525
|
+
tracker.skip("verify", "handled in preflight")
|
|
526
|
+
tracker.start("preflight")
|
|
527
|
+
preflight_result = run_preflight(
|
|
528
|
+
feature_slug=feature_slug,
|
|
529
|
+
target_branch=target_branch,
|
|
530
|
+
repo_root=main_repo,
|
|
531
|
+
wp_workspaces=wp_workspaces,
|
|
532
|
+
)
|
|
533
|
+
display_preflight_result(preflight_result, console)
|
|
534
|
+
|
|
535
|
+
if not preflight_result.passed:
|
|
536
|
+
tracker.error("preflight", "validation failed")
|
|
537
|
+
console.print(tracker.render())
|
|
538
|
+
raise typer.Exit(1)
|
|
539
|
+
tracker.complete("preflight", "all checks passed")
|
|
540
|
+
|
|
541
|
+
# Proceed directly to workspace-per-wp merge
|
|
542
|
+
merge_workspace_per_wp(
|
|
543
|
+
repo_root=repo_root,
|
|
544
|
+
merge_root=merge_root,
|
|
545
|
+
feature_slug=feature_slug,
|
|
546
|
+
current_branch=current_branch,
|
|
547
|
+
target_branch=target_branch,
|
|
548
|
+
strategy=strategy,
|
|
549
|
+
delete_branch=delete_branch,
|
|
550
|
+
remove_worktree=remove_worktree,
|
|
551
|
+
push=push,
|
|
552
|
+
dry_run=dry_run,
|
|
553
|
+
tracker=tracker,
|
|
554
|
+
resume_state=resume_state,
|
|
555
|
+
)
|
|
556
|
+
return
|
|
557
|
+
else:
|
|
558
|
+
tracker.error("detect", f"already on {target_branch}")
|
|
559
|
+
console.print(tracker.render())
|
|
560
|
+
console.print(f"\n[red]Error:[/red] Already on {target_branch} branch.")
|
|
561
|
+
console.print("Use --feature <slug> to specify the feature to merge.")
|
|
562
|
+
raise typer.Exit(1)
|
|
563
|
+
|
|
564
|
+
_, git_dir_output, _ = run_command(["git", "rev-parse", "--git-dir"], capture=True)
|
|
565
|
+
git_dir_path = Path(git_dir_output).resolve()
|
|
566
|
+
in_worktree = "worktrees" in git_dir_path.parts
|
|
567
|
+
if in_worktree:
|
|
568
|
+
merge_root = git_dir_path.parents[2]
|
|
569
|
+
if not merge_root.exists():
|
|
570
|
+
raise RuntimeError(f"Primary repository path not found: {merge_root}")
|
|
571
|
+
tracker.complete(
|
|
572
|
+
"detect",
|
|
573
|
+
f"on {current_branch}" + (f" (worktree → operating from {merge_root})" if in_worktree else ""),
|
|
574
|
+
)
|
|
575
|
+
except Exception as exc:
|
|
576
|
+
tracker.error("detect", str(exc))
|
|
577
|
+
console.print(tracker.render())
|
|
578
|
+
raise typer.Exit(1)
|
|
579
|
+
|
|
580
|
+
# Detect workspace structure and extract feature slug
|
|
581
|
+
feature_slug = extract_feature_slug(current_branch)
|
|
582
|
+
structure = detect_worktree_structure(repo_root, feature_slug)
|
|
583
|
+
|
|
584
|
+
# Branch to workspace-per-WP merge if detected
|
|
585
|
+
if structure == "workspace-per-wp":
|
|
586
|
+
tracker.skip("verify", "handled in preflight")
|
|
587
|
+
# Get main repo for preflight
|
|
588
|
+
main_repo = get_main_repo_root(repo_root)
|
|
589
|
+
wp_workspaces = find_wp_worktrees(repo_root, feature_slug)
|
|
590
|
+
|
|
591
|
+
# Run preflight checks
|
|
592
|
+
tracker.start("preflight")
|
|
593
|
+
preflight_result = run_preflight(
|
|
594
|
+
feature_slug=feature_slug,
|
|
595
|
+
target_branch=target_branch,
|
|
596
|
+
repo_root=main_repo,
|
|
597
|
+
wp_workspaces=wp_workspaces,
|
|
598
|
+
)
|
|
599
|
+
display_preflight_result(preflight_result, console)
|
|
600
|
+
|
|
601
|
+
if not preflight_result.passed:
|
|
602
|
+
tracker.error("preflight", "validation failed")
|
|
603
|
+
console.print(tracker.render())
|
|
604
|
+
raise typer.Exit(1)
|
|
605
|
+
tracker.complete("preflight", "all checks passed")
|
|
606
|
+
|
|
607
|
+
merge_workspace_per_wp(
|
|
608
|
+
repo_root=repo_root,
|
|
609
|
+
merge_root=merge_root,
|
|
610
|
+
feature_slug=feature_slug,
|
|
611
|
+
current_branch=current_branch,
|
|
612
|
+
target_branch=target_branch,
|
|
613
|
+
strategy=strategy,
|
|
614
|
+
delete_branch=delete_branch,
|
|
615
|
+
remove_worktree=remove_worktree,
|
|
616
|
+
push=push,
|
|
617
|
+
dry_run=dry_run,
|
|
618
|
+
tracker=tracker,
|
|
619
|
+
resume_state=resume_state,
|
|
620
|
+
)
|
|
621
|
+
return
|
|
622
|
+
|
|
623
|
+
# Continue with legacy merge logic for single worktree
|
|
624
|
+
# Skip preflight for legacy merges (single worktree validation is done above in verify step)
|
|
625
|
+
tracker.skip("preflight", "legacy single-worktree merge")
|
|
626
|
+
tracker.start("verify")
|
|
627
|
+
try:
|
|
628
|
+
_, status_output, _ = run_command(["git", "status", "--porcelain"], capture=True)
|
|
629
|
+
if status_output.strip():
|
|
630
|
+
tracker.error("verify", "uncommitted changes")
|
|
631
|
+
console.print(tracker.render())
|
|
632
|
+
console.print(f"\n[red]Error:[/red] Working directory has uncommitted changes.")
|
|
633
|
+
console.print("Commit or stash your changes before merging.")
|
|
634
|
+
raise typer.Exit(1)
|
|
635
|
+
tracker.complete("verify", "clean working directory")
|
|
636
|
+
except Exception as exc:
|
|
637
|
+
tracker.error("verify", str(exc))
|
|
638
|
+
console.print(tracker.render())
|
|
639
|
+
raise typer.Exit(1)
|
|
640
|
+
|
|
641
|
+
merge_root, feature_worktree_path = merge_root.resolve(), feature_worktree_path.resolve()
|
|
642
|
+
if dry_run:
|
|
643
|
+
console.print(tracker.render())
|
|
644
|
+
console.print("\n[cyan]Dry run - would execute:[/cyan]")
|
|
645
|
+
checkout_prefix = f"(from {merge_root}) " if in_worktree else ""
|
|
646
|
+
steps = [
|
|
647
|
+
f"{checkout_prefix}git checkout {target_branch}",
|
|
648
|
+
"git pull --ff-only",
|
|
649
|
+
]
|
|
650
|
+
if strategy == "squash":
|
|
651
|
+
steps.extend([
|
|
652
|
+
f"git merge --squash {current_branch}",
|
|
653
|
+
f"git commit -m 'Merge feature {current_branch}'",
|
|
654
|
+
])
|
|
655
|
+
elif strategy == "rebase":
|
|
656
|
+
steps.append(f"git merge --ff-only {current_branch} (after rebase)")
|
|
657
|
+
else:
|
|
658
|
+
steps.append(f"git merge --no-ff {current_branch}")
|
|
659
|
+
if push:
|
|
660
|
+
steps.append(f"git push origin {target_branch}")
|
|
661
|
+
if in_worktree and remove_worktree:
|
|
662
|
+
steps.append(f"git worktree remove {feature_worktree_path}")
|
|
663
|
+
if delete_branch:
|
|
664
|
+
steps.append(f"git branch -d {current_branch}")
|
|
665
|
+
for idx, step in enumerate(steps, start=1):
|
|
666
|
+
console.print(f" {idx}. {step}")
|
|
667
|
+
return
|
|
668
|
+
|
|
669
|
+
tracker.start("checkout")
|
|
670
|
+
try:
|
|
671
|
+
if in_worktree:
|
|
672
|
+
console.print(f"[cyan]Detected worktree. Merge operations will run from {merge_root}[/cyan]")
|
|
673
|
+
os.chdir(merge_root)
|
|
674
|
+
_, target_status, _ = run_command(["git", "status", "--porcelain"], capture=True)
|
|
675
|
+
if target_status.strip():
|
|
676
|
+
raise RuntimeError(f"Target repository at {merge_root} has uncommitted changes.")
|
|
677
|
+
run_command(["git", "checkout", target_branch])
|
|
678
|
+
tracker.complete("checkout", f"using {merge_root}")
|
|
679
|
+
except Exception as exc:
|
|
680
|
+
tracker.error("checkout", str(exc))
|
|
681
|
+
console.print(tracker.render())
|
|
682
|
+
raise typer.Exit(1)
|
|
683
|
+
|
|
684
|
+
tracker.start("pull")
|
|
685
|
+
try:
|
|
686
|
+
run_command(["git", "pull", "--ff-only"])
|
|
687
|
+
tracker.complete("pull")
|
|
688
|
+
except Exception as exc:
|
|
689
|
+
tracker.error("pull", str(exc))
|
|
690
|
+
console.print(tracker.render())
|
|
691
|
+
console.print(f"\n[yellow]Warning:[/yellow] Could not fast-forward {target_branch}.")
|
|
692
|
+
console.print("You may need to resolve conflicts manually.")
|
|
693
|
+
raise typer.Exit(1)
|
|
694
|
+
|
|
695
|
+
tracker.start("merge")
|
|
696
|
+
try:
|
|
697
|
+
if strategy == "squash":
|
|
698
|
+
run_command(["git", "merge", "--squash", current_branch])
|
|
699
|
+
run_command(["git", "commit", "-m", f"Merge feature {current_branch}"])
|
|
700
|
+
tracker.complete("merge", "squashed")
|
|
701
|
+
elif strategy == "rebase":
|
|
702
|
+
console.print("\n[yellow]Note:[/yellow] Rebase strategy requires manual intervention.")
|
|
703
|
+
console.print(f"Please run: git checkout {current_branch} && git rebase {target_branch}")
|
|
704
|
+
tracker.skip("merge", "requires manual rebase")
|
|
705
|
+
console.print(tracker.render())
|
|
706
|
+
raise typer.Exit(0)
|
|
707
|
+
else:
|
|
708
|
+
run_command(["git", "merge", "--no-ff", current_branch, "-m", f"Merge feature {current_branch}"])
|
|
709
|
+
tracker.complete("merge", "merged with merge commit")
|
|
710
|
+
except Exception as exc:
|
|
711
|
+
tracker.error("merge", str(exc))
|
|
712
|
+
console.print(tracker.render())
|
|
713
|
+
console.print(f"\n[red]Merge failed.[/red] You may need to resolve conflicts.")
|
|
714
|
+
raise typer.Exit(1)
|
|
715
|
+
|
|
716
|
+
if push:
|
|
717
|
+
tracker.start("push")
|
|
718
|
+
try:
|
|
719
|
+
run_command(["git", "push", "origin", target_branch])
|
|
720
|
+
tracker.complete("push")
|
|
721
|
+
except Exception as exc:
|
|
722
|
+
tracker.error("push", str(exc))
|
|
723
|
+
console.print(tracker.render())
|
|
724
|
+
console.print(f"\n[yellow]Warning:[/yellow] Merge succeeded but push failed.")
|
|
725
|
+
console.print(f"Run manually: git push origin {target_branch}")
|
|
726
|
+
|
|
727
|
+
if in_worktree and remove_worktree:
|
|
728
|
+
tracker.start("worktree")
|
|
729
|
+
try:
|
|
730
|
+
run_command(["git", "worktree", "remove", str(feature_worktree_path), "--force"])
|
|
731
|
+
tracker.complete("worktree", f"removed {feature_worktree_path}")
|
|
732
|
+
except Exception as exc:
|
|
733
|
+
tracker.error("worktree", str(exc))
|
|
734
|
+
console.print(tracker.render())
|
|
735
|
+
console.print(f"\n[yellow]Warning:[/yellow] Could not remove worktree.")
|
|
736
|
+
console.print(f"Run manually: git worktree remove {feature_worktree_path}")
|
|
737
|
+
|
|
738
|
+
if delete_branch:
|
|
739
|
+
tracker.start("branch")
|
|
740
|
+
try:
|
|
741
|
+
run_command(["git", "branch", "-d", current_branch])
|
|
742
|
+
tracker.complete("branch", f"deleted {current_branch}")
|
|
743
|
+
except Exception as exc:
|
|
744
|
+
try:
|
|
745
|
+
run_command(["git", "branch", "-D", current_branch])
|
|
746
|
+
tracker.complete("branch", f"force deleted {current_branch}")
|
|
747
|
+
except Exception:
|
|
748
|
+
tracker.error("branch", str(exc))
|
|
749
|
+
console.print(tracker.render())
|
|
750
|
+
console.print(f"\n[yellow]Warning:[/yellow] Could not delete branch {current_branch}.")
|
|
751
|
+
console.print(f"Run manually: git branch -d {current_branch}")
|
|
752
|
+
|
|
753
|
+
console.print(tracker.render())
|
|
754
|
+
console.print(f"\n[bold green]✓ Feature {current_branch} successfully merged into {target_branch}[/bold green]")
|
|
755
|
+
__all__ = ["merge"]
|