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,129 @@
|
|
|
1
|
+
"""Git and subprocess helpers for the Spec Kitty CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import subprocess
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Sequence
|
|
9
|
+
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
|
|
12
|
+
ConsoleType = Console | None
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _resolve_console(console: ConsoleType) -> Console:
|
|
16
|
+
"""Return the provided console or lazily create one."""
|
|
17
|
+
return console if console is not None else Console()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def run_command(
|
|
21
|
+
cmd: Sequence[str] | str,
|
|
22
|
+
*,
|
|
23
|
+
check_return: bool = True,
|
|
24
|
+
capture: bool = False,
|
|
25
|
+
shell: bool = False,
|
|
26
|
+
console: ConsoleType = None,
|
|
27
|
+
cwd: Path | str | None = None,
|
|
28
|
+
) -> tuple[int, str, str]:
|
|
29
|
+
"""Run a shell command and return (returncode, stdout, stderr).
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
cmd: Command to run
|
|
33
|
+
check_return: If True, raise on non-zero exit
|
|
34
|
+
capture: If True, capture stdout/stderr
|
|
35
|
+
shell: If True, run through shell
|
|
36
|
+
console: Rich console for output
|
|
37
|
+
cwd: Working directory for command execution
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
Tuple of (returncode, stdout, stderr)
|
|
41
|
+
"""
|
|
42
|
+
try:
|
|
43
|
+
result = subprocess.run(
|
|
44
|
+
cmd,
|
|
45
|
+
check=check_return,
|
|
46
|
+
capture_output=capture,
|
|
47
|
+
text=True,
|
|
48
|
+
shell=shell,
|
|
49
|
+
cwd=str(cwd) if cwd else None,
|
|
50
|
+
)
|
|
51
|
+
stdout = (result.stdout or "").strip() if capture else ""
|
|
52
|
+
stderr = (result.stderr or "").strip() if capture else ""
|
|
53
|
+
return result.returncode, stdout, stderr
|
|
54
|
+
except subprocess.CalledProcessError as exc:
|
|
55
|
+
if check_return:
|
|
56
|
+
resolved_console = _resolve_console(console)
|
|
57
|
+
resolved_console.print(f"[red]Error running command:[/red] {cmd if isinstance(cmd, str) else ' '.join(cmd)}")
|
|
58
|
+
resolved_console.print(f"[red]Exit code:[/red] {exc.returncode}")
|
|
59
|
+
if exc.stderr:
|
|
60
|
+
resolved_console.print(f"[red]Error output:[/red] {exc.stderr.strip()}")
|
|
61
|
+
raise
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def is_git_repo(path: Path | None = None) -> bool:
|
|
65
|
+
"""Return True when the provided path lives inside a git repository."""
|
|
66
|
+
target = (path or Path.cwd()).resolve()
|
|
67
|
+
if not target.is_dir():
|
|
68
|
+
return False
|
|
69
|
+
try:
|
|
70
|
+
subprocess.run(
|
|
71
|
+
["git", "rev-parse", "--is-inside-work-tree"],
|
|
72
|
+
check=True,
|
|
73
|
+
capture_output=True,
|
|
74
|
+
cwd=target,
|
|
75
|
+
)
|
|
76
|
+
return True
|
|
77
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
78
|
+
return False
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def init_git_repo(project_path: Path, quiet: bool = False, console: ConsoleType = None) -> bool:
|
|
82
|
+
"""Initialize a git repository with an initial commit."""
|
|
83
|
+
resolved_console = _resolve_console(console)
|
|
84
|
+
original_cwd = Path.cwd()
|
|
85
|
+
try:
|
|
86
|
+
os.chdir(project_path)
|
|
87
|
+
if not quiet:
|
|
88
|
+
resolved_console.print("[cyan]Initializing git repository...[/cyan]")
|
|
89
|
+
subprocess.run(["git", "init"], check=True, capture_output=True)
|
|
90
|
+
subprocess.run(["git", "add", "."], check=True, capture_output=True)
|
|
91
|
+
subprocess.run(
|
|
92
|
+
["git", "commit", "-m", "Initial commit from Specify template"],
|
|
93
|
+
check=True,
|
|
94
|
+
capture_output=True,
|
|
95
|
+
)
|
|
96
|
+
if not quiet:
|
|
97
|
+
resolved_console.print("[green]✓[/green] Git repository initialized")
|
|
98
|
+
return True
|
|
99
|
+
except subprocess.CalledProcessError as exc:
|
|
100
|
+
if not quiet:
|
|
101
|
+
resolved_console.print(f"[red]Error initializing git repository:[/red] {exc}")
|
|
102
|
+
return False
|
|
103
|
+
finally:
|
|
104
|
+
os.chdir(original_cwd)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def get_current_branch(path: Path | None = None) -> str | None:
|
|
108
|
+
"""Return the current git branch name for the provided repository path."""
|
|
109
|
+
repo_path = (path or Path.cwd()).resolve()
|
|
110
|
+
try:
|
|
111
|
+
result = subprocess.run(
|
|
112
|
+
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
113
|
+
check=True,
|
|
114
|
+
capture_output=True,
|
|
115
|
+
text=True,
|
|
116
|
+
cwd=repo_path,
|
|
117
|
+
)
|
|
118
|
+
branch = result.stdout.strip()
|
|
119
|
+
return branch or None
|
|
120
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
__all__ = [
|
|
125
|
+
"get_current_branch",
|
|
126
|
+
"init_git_repo",
|
|
127
|
+
"is_git_repo",
|
|
128
|
+
"run_command",
|
|
129
|
+
]
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
"""Multi-parent dependency handling via automatic merge commits.
|
|
2
|
+
|
|
3
|
+
This module provides deterministic base branch calculation for work packages
|
|
4
|
+
with multiple dependencies. Instead of forcing the user to pick one dependency
|
|
5
|
+
as base and manually merge others, we automatically create a merge commit
|
|
6
|
+
combining all dependencies.
|
|
7
|
+
|
|
8
|
+
Example:
|
|
9
|
+
WP04 depends on both WP02 and WP03:
|
|
10
|
+
|
|
11
|
+
Before (ambiguous):
|
|
12
|
+
spec-kitty implement WP04 --base WP03 # Why WP03? Why not WP02?
|
|
13
|
+
cd .worktrees/010-feature-WP04/
|
|
14
|
+
git merge 010-feature-WP02 # Manual merge required
|
|
15
|
+
|
|
16
|
+
After (deterministic):
|
|
17
|
+
spec-kitty implement WP04 # Auto-detects multi-parent, creates merge
|
|
18
|
+
# WP04 branches from merge commit combining WP02 + WP03
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import subprocess
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
from rich.console import Console
|
|
28
|
+
|
|
29
|
+
console = Console()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class MergeResult:
|
|
34
|
+
"""Result of creating a multi-parent merge base."""
|
|
35
|
+
|
|
36
|
+
success: bool
|
|
37
|
+
branch_name: str | None # Temporary branch name (e.g., "010-feature-WP04-merge-base")
|
|
38
|
+
commit_sha: str | None # SHA of merge commit
|
|
39
|
+
error: str | None # Error message if failed
|
|
40
|
+
conflicts: list[str] # List of files with conflicts (if any)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def create_multi_parent_base(
|
|
44
|
+
feature_slug: str,
|
|
45
|
+
wp_id: str,
|
|
46
|
+
dependencies: list[str],
|
|
47
|
+
repo_root: Path,
|
|
48
|
+
) -> MergeResult:
|
|
49
|
+
"""Create a merge commit combining all dependencies for a work package.
|
|
50
|
+
|
|
51
|
+
This function:
|
|
52
|
+
1. Creates a temporary branch from the first dependency
|
|
53
|
+
2. Merges all remaining dependencies into it
|
|
54
|
+
3. Returns the merge commit SHA for use as base branch
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
feature_slug: Feature slug (e.g., "010-workspace-per-wp")
|
|
58
|
+
wp_id: Work package ID (e.g., "WP04")
|
|
59
|
+
dependencies: List of dependency WP IDs (e.g., ["WP02", "WP03"])
|
|
60
|
+
repo_root: Repository root path
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
MergeResult with success status and merge commit details
|
|
64
|
+
|
|
65
|
+
Example:
|
|
66
|
+
result = create_multi_parent_base(
|
|
67
|
+
feature_slug="010-feature",
|
|
68
|
+
wp_id="WP04",
|
|
69
|
+
dependencies=["WP02", "WP03"],
|
|
70
|
+
repo_root=Path("."),
|
|
71
|
+
)
|
|
72
|
+
if result.success:
|
|
73
|
+
# Branch WP04 from result.branch_name or result.commit_sha
|
|
74
|
+
print(f"Merge base: {result.commit_sha}")
|
|
75
|
+
"""
|
|
76
|
+
if len(dependencies) < 2:
|
|
77
|
+
return MergeResult(
|
|
78
|
+
success=False,
|
|
79
|
+
branch_name=None,
|
|
80
|
+
commit_sha=None,
|
|
81
|
+
error="Multi-parent merge requires at least 2 dependencies",
|
|
82
|
+
conflicts=[],
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
# Sort dependencies for deterministic ordering
|
|
86
|
+
sorted_deps = sorted(dependencies)
|
|
87
|
+
|
|
88
|
+
# Temporary branch name
|
|
89
|
+
temp_branch = f"{feature_slug}-{wp_id}-merge-base"
|
|
90
|
+
|
|
91
|
+
# Dependency branch names
|
|
92
|
+
dep_branches = [f"{feature_slug}-{dep}" for dep in sorted_deps]
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
# Step 1: Validate all dependency branches exist
|
|
96
|
+
for dep, branch in zip(sorted_deps, dep_branches):
|
|
97
|
+
result = subprocess.run(
|
|
98
|
+
["git", "rev-parse", "--verify", branch],
|
|
99
|
+
cwd=repo_root,
|
|
100
|
+
capture_output=True,
|
|
101
|
+
text=True,
|
|
102
|
+
check=False,
|
|
103
|
+
)
|
|
104
|
+
if result.returncode != 0:
|
|
105
|
+
return MergeResult(
|
|
106
|
+
success=False,
|
|
107
|
+
branch_name=None,
|
|
108
|
+
commit_sha=None,
|
|
109
|
+
error=f"Dependency branch {branch} does not exist (implement {dep} first)",
|
|
110
|
+
conflicts=[],
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
# Step 2: Check if temp branch already exists (cleanup from previous run)
|
|
114
|
+
result = subprocess.run(
|
|
115
|
+
["git", "rev-parse", "--verify", temp_branch],
|
|
116
|
+
cwd=repo_root,
|
|
117
|
+
capture_output=True,
|
|
118
|
+
check=False,
|
|
119
|
+
)
|
|
120
|
+
if result.returncode == 0:
|
|
121
|
+
# Delete existing temp branch
|
|
122
|
+
subprocess.run(
|
|
123
|
+
["git", "branch", "-D", temp_branch],
|
|
124
|
+
cwd=repo_root,
|
|
125
|
+
capture_output=True,
|
|
126
|
+
check=False,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
# Step 3: Create temp branch from first dependency
|
|
130
|
+
base_branch = dep_branches[0]
|
|
131
|
+
result = subprocess.run(
|
|
132
|
+
["git", "branch", temp_branch, base_branch],
|
|
133
|
+
cwd=repo_root,
|
|
134
|
+
capture_output=True,
|
|
135
|
+
text=True,
|
|
136
|
+
check=False,
|
|
137
|
+
)
|
|
138
|
+
if result.returncode != 0:
|
|
139
|
+
return MergeResult(
|
|
140
|
+
success=False,
|
|
141
|
+
branch_name=None,
|
|
142
|
+
commit_sha=None,
|
|
143
|
+
error=f"Failed to create temp branch from {base_branch}: {result.stderr}",
|
|
144
|
+
conflicts=[],
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
# Step 4: Checkout temp branch
|
|
148
|
+
result = subprocess.run(
|
|
149
|
+
["git", "checkout", temp_branch],
|
|
150
|
+
cwd=repo_root,
|
|
151
|
+
capture_output=True,
|
|
152
|
+
text=True,
|
|
153
|
+
check=False,
|
|
154
|
+
)
|
|
155
|
+
if result.returncode != 0:
|
|
156
|
+
return MergeResult(
|
|
157
|
+
success=False,
|
|
158
|
+
branch_name=None,
|
|
159
|
+
commit_sha=None,
|
|
160
|
+
error=f"Failed to checkout temp branch: {result.stderr}",
|
|
161
|
+
conflicts=[],
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
# Step 5: Merge remaining dependencies
|
|
165
|
+
conflicts = []
|
|
166
|
+
for dep_branch in dep_branches[1:]:
|
|
167
|
+
result = subprocess.run(
|
|
168
|
+
["git", "merge", "--no-edit", dep_branch, "-m",
|
|
169
|
+
f"Merge {dep_branch} into multi-parent base for {wp_id}"],
|
|
170
|
+
cwd=repo_root,
|
|
171
|
+
capture_output=True,
|
|
172
|
+
text=True,
|
|
173
|
+
check=False,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
if result.returncode != 0:
|
|
177
|
+
# Check if merge conflicts
|
|
178
|
+
conflict_result = subprocess.run(
|
|
179
|
+
["git", "diff", "--name-only", "--diff-filter=U"],
|
|
180
|
+
cwd=repo_root,
|
|
181
|
+
capture_output=True,
|
|
182
|
+
text=True,
|
|
183
|
+
check=False,
|
|
184
|
+
)
|
|
185
|
+
if conflict_result.returncode == 0 and conflict_result.stdout.strip():
|
|
186
|
+
conflicts = conflict_result.stdout.strip().split("\n")
|
|
187
|
+
|
|
188
|
+
# Abort merge
|
|
189
|
+
subprocess.run(
|
|
190
|
+
["git", "merge", "--abort"],
|
|
191
|
+
cwd=repo_root,
|
|
192
|
+
capture_output=True,
|
|
193
|
+
check=False,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
# Cleanup: delete temp branch
|
|
197
|
+
subprocess.run(
|
|
198
|
+
["git", "checkout", "-"], # Return to previous branch
|
|
199
|
+
cwd=repo_root,
|
|
200
|
+
capture_output=True,
|
|
201
|
+
check=False,
|
|
202
|
+
)
|
|
203
|
+
subprocess.run(
|
|
204
|
+
["git", "branch", "-D", temp_branch],
|
|
205
|
+
cwd=repo_root,
|
|
206
|
+
capture_output=True,
|
|
207
|
+
check=False,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
return MergeResult(
|
|
211
|
+
success=False,
|
|
212
|
+
branch_name=None,
|
|
213
|
+
commit_sha=None,
|
|
214
|
+
error=f"Merge conflict when merging {dep_branch}",
|
|
215
|
+
conflicts=conflicts,
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
# Step 6: Get merge commit SHA
|
|
219
|
+
result = subprocess.run(
|
|
220
|
+
["git", "rev-parse", "HEAD"],
|
|
221
|
+
cwd=repo_root,
|
|
222
|
+
capture_output=True,
|
|
223
|
+
text=True,
|
|
224
|
+
check=False,
|
|
225
|
+
)
|
|
226
|
+
if result.returncode != 0:
|
|
227
|
+
return MergeResult(
|
|
228
|
+
success=False,
|
|
229
|
+
branch_name=temp_branch,
|
|
230
|
+
commit_sha=None,
|
|
231
|
+
error="Failed to get merge commit SHA",
|
|
232
|
+
conflicts=[],
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
merge_commit_sha = result.stdout.strip()
|
|
236
|
+
|
|
237
|
+
# Step 7: Return to previous branch
|
|
238
|
+
subprocess.run(
|
|
239
|
+
["git", "checkout", "-"],
|
|
240
|
+
cwd=repo_root,
|
|
241
|
+
capture_output=True,
|
|
242
|
+
check=False,
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
console.print(f"[cyan]→ Created merge base: {temp_branch} ({merge_commit_sha[:7]})[/cyan]")
|
|
246
|
+
console.print(f"[cyan] Combined dependencies: {', '.join(sorted_deps)}[/cyan]")
|
|
247
|
+
|
|
248
|
+
return MergeResult(
|
|
249
|
+
success=True,
|
|
250
|
+
branch_name=temp_branch,
|
|
251
|
+
commit_sha=merge_commit_sha,
|
|
252
|
+
error=None,
|
|
253
|
+
conflicts=[],
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
except Exception as e:
|
|
257
|
+
# Cleanup on exception
|
|
258
|
+
subprocess.run(
|
|
259
|
+
["git", "checkout", "-"],
|
|
260
|
+
cwd=repo_root,
|
|
261
|
+
capture_output=True,
|
|
262
|
+
check=False,
|
|
263
|
+
)
|
|
264
|
+
subprocess.run(
|
|
265
|
+
["git", "branch", "-D", temp_branch],
|
|
266
|
+
cwd=repo_root,
|
|
267
|
+
capture_output=True,
|
|
268
|
+
check=False,
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
return MergeResult(
|
|
272
|
+
success=False,
|
|
273
|
+
branch_name=None,
|
|
274
|
+
commit_sha=None,
|
|
275
|
+
error=f"Unexpected error: {e}",
|
|
276
|
+
conflicts=[],
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def cleanup_merge_base_branch(
|
|
281
|
+
feature_slug: str,
|
|
282
|
+
wp_id: str,
|
|
283
|
+
repo_root: Path,
|
|
284
|
+
) -> bool:
|
|
285
|
+
"""Delete temporary merge base branch after workspace creation.
|
|
286
|
+
|
|
287
|
+
Args:
|
|
288
|
+
feature_slug: Feature slug (e.g., "010-workspace-per-wp")
|
|
289
|
+
wp_id: Work package ID (e.g., "WP04")
|
|
290
|
+
repo_root: Repository root path
|
|
291
|
+
|
|
292
|
+
Returns:
|
|
293
|
+
True if deleted, False if branch didn't exist
|
|
294
|
+
"""
|
|
295
|
+
temp_branch = f"{feature_slug}-{wp_id}-merge-base"
|
|
296
|
+
|
|
297
|
+
# Check if branch exists
|
|
298
|
+
result = subprocess.run(
|
|
299
|
+
["git", "rev-parse", "--verify", temp_branch],
|
|
300
|
+
cwd=repo_root,
|
|
301
|
+
capture_output=True,
|
|
302
|
+
check=False,
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
if result.returncode != 0:
|
|
306
|
+
return False # Branch doesn't exist
|
|
307
|
+
|
|
308
|
+
# Delete branch
|
|
309
|
+
result = subprocess.run(
|
|
310
|
+
["git", "branch", "-D", temp_branch],
|
|
311
|
+
cwd=repo_root,
|
|
312
|
+
capture_output=True,
|
|
313
|
+
check=False,
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
return result.returncode == 0
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
__all__ = [
|
|
320
|
+
"MergeResult",
|
|
321
|
+
"create_multi_parent_base",
|
|
322
|
+
"cleanup_merge_base_branch",
|
|
323
|
+
]
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"""Enhanced path resolution for spec-kitty CLI with worktree detection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import subprocess
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Optional, Tuple
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def locate_project_root(start: Path | None = None) -> Optional[Path]:
|
|
12
|
+
"""
|
|
13
|
+
Locate the MAIN spec-kitty project root directory, even from within worktrees.
|
|
14
|
+
|
|
15
|
+
This function correctly handles git worktrees by detecting when .git is a
|
|
16
|
+
file (worktree pointer) vs a directory (main repo), and following the
|
|
17
|
+
pointer back to the main repository.
|
|
18
|
+
|
|
19
|
+
Resolution order:
|
|
20
|
+
1. SPECIFY_REPO_ROOT environment variable (highest priority)
|
|
21
|
+
2. Walk up directory tree, detecting worktree .git files and following to main repo
|
|
22
|
+
3. Fall back to .kittify/ marker search
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
start: Starting directory for search (defaults to current working directory)
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
Path to MAIN project root (not worktree), or None if not found
|
|
29
|
+
|
|
30
|
+
Examples:
|
|
31
|
+
>>> # From main repo
|
|
32
|
+
>>> root = locate_project_root()
|
|
33
|
+
>>> assert (root / ".kittify").exists()
|
|
34
|
+
|
|
35
|
+
>>> # From worktree - returns MAIN repo, not worktree
|
|
36
|
+
>>> root = locate_project_root(Path(".worktrees/my-feature"))
|
|
37
|
+
>>> assert ".worktrees" not in str(root)
|
|
38
|
+
"""
|
|
39
|
+
# Tier 1: Check environment variable (allows override for CI/CD)
|
|
40
|
+
if env_root := os.getenv("SPECIFY_REPO_ROOT"):
|
|
41
|
+
env_path = Path(env_root).resolve()
|
|
42
|
+
if env_path.exists() and (env_path / ".kittify").is_dir():
|
|
43
|
+
return env_path
|
|
44
|
+
# Invalid env var - fall through to other methods
|
|
45
|
+
|
|
46
|
+
# Tier 2: Walk up directory tree, handling worktree .git files
|
|
47
|
+
current = (start or Path.cwd()).resolve()
|
|
48
|
+
|
|
49
|
+
for candidate in [current, *current.parents]:
|
|
50
|
+
git_path = candidate / ".git"
|
|
51
|
+
|
|
52
|
+
if git_path.is_file():
|
|
53
|
+
# This is a worktree! The .git file contains a pointer to the main repo.
|
|
54
|
+
# Format: "gitdir: /path/to/main/.git/worktrees/worktree-name"
|
|
55
|
+
try:
|
|
56
|
+
content = git_path.read_text().strip()
|
|
57
|
+
if content.startswith("gitdir:"):
|
|
58
|
+
gitdir = Path(content.split(":", 1)[1].strip())
|
|
59
|
+
# Navigate: .git/worktrees/name -> .git -> main repo root
|
|
60
|
+
main_git_dir = gitdir.parent.parent
|
|
61
|
+
main_repo = main_git_dir.parent
|
|
62
|
+
if main_repo.exists() and (main_repo / ".kittify").is_dir():
|
|
63
|
+
return main_repo
|
|
64
|
+
except (OSError, ValueError):
|
|
65
|
+
# If we can't read or parse the .git file, continue searching
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
elif git_path.is_dir():
|
|
69
|
+
# This is the main repo (or a regular git repo)
|
|
70
|
+
if (candidate / ".kittify").is_dir():
|
|
71
|
+
return candidate
|
|
72
|
+
|
|
73
|
+
# Also check for .kittify marker (fallback for non-git scenarios)
|
|
74
|
+
kittify_path = candidate / ".kittify"
|
|
75
|
+
if kittify_path.is_symlink() and not kittify_path.exists():
|
|
76
|
+
# Broken symlink - skip this candidate
|
|
77
|
+
continue
|
|
78
|
+
if kittify_path.is_dir():
|
|
79
|
+
return candidate
|
|
80
|
+
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def is_worktree_context(path: Path) -> bool:
|
|
85
|
+
"""
|
|
86
|
+
Detect if the given path is within a git worktree directory.
|
|
87
|
+
|
|
88
|
+
Checks if '.worktrees' appears in the path hierarchy, indicating
|
|
89
|
+
execution from within a feature worktree.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
path: Path to check (typically current working directory)
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
True if path is within .worktrees/ directory, False otherwise
|
|
96
|
+
|
|
97
|
+
Examples:
|
|
98
|
+
>>> is_worktree_context(Path("/repo/.worktrees/feature-001"))
|
|
99
|
+
True
|
|
100
|
+
>>> is_worktree_context(Path("/repo/kitty-specs"))
|
|
101
|
+
False
|
|
102
|
+
"""
|
|
103
|
+
return ".worktrees" in path.parts
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def resolve_with_context(start: Path | None = None) -> Tuple[Optional[Path], bool]:
|
|
107
|
+
"""
|
|
108
|
+
Resolve project root and detect worktree context in one call.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
start: Starting directory for search (defaults to current working directory)
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
Tuple of (project_root, is_worktree)
|
|
115
|
+
- project_root: Path to repo root or None if not found
|
|
116
|
+
- is_worktree: True if executing from within .worktrees/
|
|
117
|
+
|
|
118
|
+
Examples:
|
|
119
|
+
>>> # From main repo
|
|
120
|
+
>>> root, in_worktree = resolve_with_context()
|
|
121
|
+
>>> assert in_worktree is False
|
|
122
|
+
|
|
123
|
+
>>> # From worktree
|
|
124
|
+
>>> root, in_worktree = resolve_with_context(Path(".worktrees/my-feature"))
|
|
125
|
+
>>> assert in_worktree is True
|
|
126
|
+
"""
|
|
127
|
+
current = (start or Path.cwd()).resolve()
|
|
128
|
+
root = locate_project_root(current)
|
|
129
|
+
in_worktree = is_worktree_context(current)
|
|
130
|
+
return root, in_worktree
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def check_broken_symlink(path: Path) -> bool:
|
|
134
|
+
"""
|
|
135
|
+
Check if a path is a broken symlink (symlink pointing to non-existent target).
|
|
136
|
+
|
|
137
|
+
This helper is useful for graceful error handling when dealing with
|
|
138
|
+
worktree symlinks that may become invalid.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
path: Path to check
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
True if path is a broken symlink, False otherwise
|
|
145
|
+
|
|
146
|
+
Note:
|
|
147
|
+
A broken symlink returns True for is_symlink() but False for exists().
|
|
148
|
+
Always check is_symlink() before exists() to detect this condition.
|
|
149
|
+
"""
|
|
150
|
+
return path.is_symlink() and not path.exists()
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def get_main_repo_root(current_path: Path) -> Path:
|
|
154
|
+
"""
|
|
155
|
+
Get the main repository root, even if called from a worktree.
|
|
156
|
+
|
|
157
|
+
When in a worktree, .git is a file pointing to the main repo's .git directory.
|
|
158
|
+
This function follows that pointer to find the main repo root.
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
current_path: Current repo root (may be worktree or main repo)
|
|
162
|
+
|
|
163
|
+
Returns:
|
|
164
|
+
Path to the main repository root (resolves worktree pointers)
|
|
165
|
+
|
|
166
|
+
Examples:
|
|
167
|
+
>>> # From main repo - returns same path
|
|
168
|
+
>>> get_main_repo_root(Path("/repo"))
|
|
169
|
+
Path('/repo')
|
|
170
|
+
|
|
171
|
+
>>> # From worktree - returns main repo
|
|
172
|
+
>>> get_main_repo_root(Path("/repo/.worktrees/feature-001"))
|
|
173
|
+
Path('/repo')
|
|
174
|
+
"""
|
|
175
|
+
git_file = current_path / ".git"
|
|
176
|
+
|
|
177
|
+
if git_file.is_file():
|
|
178
|
+
try:
|
|
179
|
+
git_content = git_file.read_text().strip()
|
|
180
|
+
if git_content.startswith("gitdir:"):
|
|
181
|
+
gitdir = Path(git_content.split(":", 1)[1].strip())
|
|
182
|
+
# Navigate: .git/worktrees/name -> .git -> main repo root
|
|
183
|
+
main_git_dir = gitdir.parent.parent
|
|
184
|
+
main_repo_root = main_git_dir.parent
|
|
185
|
+
return main_repo_root
|
|
186
|
+
except (OSError, ValueError):
|
|
187
|
+
pass
|
|
188
|
+
|
|
189
|
+
return current_path
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def find_feature_slug(repo_root: Path) -> Optional[str]:
|
|
193
|
+
"""
|
|
194
|
+
Auto-detect feature slug from git branch or highest-numbered feature in kitty-specs.
|
|
195
|
+
|
|
196
|
+
Detection strategies (in order):
|
|
197
|
+
1. Git branch name matching pattern ###-slug (strips -WPxx suffix)
|
|
198
|
+
2. Highest-numbered directory in kitty-specs/
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
repo_root: Repository root path (can be worktree or main repo)
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
Feature slug (e.g., "016-jujutsu-vcs-documentation") or None if not found
|
|
205
|
+
|
|
206
|
+
Examples:
|
|
207
|
+
>>> find_feature_slug(Path("/repo"))
|
|
208
|
+
'016-jujutsu-vcs-documentation'
|
|
209
|
+
"""
|
|
210
|
+
import re
|
|
211
|
+
|
|
212
|
+
# Get main repo root for kitty-specs access
|
|
213
|
+
main_repo_root = get_main_repo_root(repo_root)
|
|
214
|
+
|
|
215
|
+
# Strategy 1: Get from git branch name
|
|
216
|
+
try:
|
|
217
|
+
result = subprocess.run(
|
|
218
|
+
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
219
|
+
cwd=repo_root,
|
|
220
|
+
capture_output=True,
|
|
221
|
+
text=True,
|
|
222
|
+
check=True
|
|
223
|
+
)
|
|
224
|
+
branch_name = result.stdout.strip()
|
|
225
|
+
|
|
226
|
+
# Strip -WPxx suffix if present (worktree branches)
|
|
227
|
+
branch_name = re.sub(r'-WP\d+$', '', branch_name)
|
|
228
|
+
|
|
229
|
+
# Validate format: ###-slug
|
|
230
|
+
if len(branch_name) >= 3 and branch_name[:3].isdigit():
|
|
231
|
+
return branch_name
|
|
232
|
+
|
|
233
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
234
|
+
pass
|
|
235
|
+
|
|
236
|
+
# Strategy 2: Auto-detect highest-numbered feature in kitty-specs
|
|
237
|
+
kitty_specs_dir = main_repo_root / "kitty-specs"
|
|
238
|
+
if kitty_specs_dir.is_dir():
|
|
239
|
+
candidates = []
|
|
240
|
+
for path in kitty_specs_dir.iterdir():
|
|
241
|
+
if not path.is_dir():
|
|
242
|
+
continue
|
|
243
|
+
match = re.match(r"^(\d{3})-", path.name)
|
|
244
|
+
if match:
|
|
245
|
+
candidates.append((int(match.group(1)), path.name))
|
|
246
|
+
if candidates:
|
|
247
|
+
_, slug = max(candidates, key=lambda item: item[0])
|
|
248
|
+
return slug
|
|
249
|
+
|
|
250
|
+
return None
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
__all__ = [
|
|
254
|
+
"locate_project_root",
|
|
255
|
+
"is_worktree_context",
|
|
256
|
+
"resolve_with_context",
|
|
257
|
+
"check_broken_symlink",
|
|
258
|
+
"get_main_repo_root",
|
|
259
|
+
"find_feature_slug",
|
|
260
|
+
]
|