multi-forge 0.2.0__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.
- forge/__init__.py +3 -0
- forge/_extensions/agents/.gitkeep +0 -0
- forge/_extensions/commands/.gitkeep +0 -0
- forge/_extensions/skills/analyze/SKILL.md +87 -0
- forge/_extensions/skills/challenge/SKILL.md +91 -0
- forge/_extensions/skills/consensus/SKILL.md +120 -0
- forge/_extensions/skills/consensus/resources/code_consensus_evaluation.md +94 -0
- forge/_extensions/skills/consensus/resources/consensus_evaluation.md +70 -0
- forge/_extensions/skills/consensus/resources/synthesis.md +101 -0
- forge/_extensions/skills/debate/SKILL.md +116 -0
- forge/_extensions/skills/debate/resources/code_debate_evaluation.md +101 -0
- forge/_extensions/skills/debate/resources/debate_evaluation.md +90 -0
- forge/_extensions/skills/panel/SKILL.md +141 -0
- forge/_extensions/skills/panel/resources/synthesis.md +103 -0
- forge/_extensions/skills/qa/SKILL.md +704 -0
- forge/_extensions/skills/qa/resources/checklist/0-enable.md +78 -0
- forge/_extensions/skills/qa/resources/checklist/1-preflight.md +24 -0
- forge/_extensions/skills/qa/resources/checklist/10-resume.md +143 -0
- forge/_extensions/skills/qa/resources/checklist/11-config.md +150 -0
- forge/_extensions/skills/qa/resources/checklist/12-search.md +58 -0
- forge/_extensions/skills/qa/resources/checklist/13-guard.md +237 -0
- forge/_extensions/skills/qa/resources/checklist/14-workflow.md +305 -0
- forge/_extensions/skills/qa/resources/checklist/15-skills.md +155 -0
- forge/_extensions/skills/qa/resources/checklist/16-handoff.md +224 -0
- forge/_extensions/skills/qa/resources/checklist/17-info.md +50 -0
- forge/_extensions/skills/qa/resources/checklist/18-disable.md +84 -0
- forge/_extensions/skills/qa/resources/checklist/19-uninstall.md +146 -0
- forge/_extensions/skills/qa/resources/checklist/2-extensions.md +188 -0
- forge/_extensions/skills/qa/resources/checklist/20-cleanup.md +36 -0
- forge/_extensions/skills/qa/resources/checklist/3-auth.md +234 -0
- forge/_extensions/skills/qa/resources/checklist/4-proxy.md +481 -0
- forge/_extensions/skills/qa/resources/checklist/5-session.md +541 -0
- forge/_extensions/skills/qa/resources/checklist/6-hooks.md +275 -0
- forge/_extensions/skills/qa/resources/checklist/7-costs.md +309 -0
- forge/_extensions/skills/qa/resources/checklist/8-status-line.md +174 -0
- forge/_extensions/skills/qa/resources/checklist/9-direct-commands.md +146 -0
- forge/_extensions/skills/qa/resources/checklist.md +103 -0
- forge/_extensions/skills/qa/resources/report-template.md +62 -0
- forge/_extensions/skills/qa/scripts/start-container.sh +529 -0
- forge/_extensions/skills/qa/scripts/walkthrough-state.py +1137 -0
- forge/_extensions/skills/review/SKILL.md +125 -0
- forge/_extensions/skills/review/references/claude-4.6.md +474 -0
- forge/_extensions/skills/review/references/claude-4.7.md +710 -0
- forge/_extensions/skills/review/references/gemini-3.1.md +546 -0
- forge/_extensions/skills/review/references/gpt-5.5.md +490 -0
- forge/_extensions/skills/review/references/skills-writing-guide.md +1588 -0
- forge/_extensions/skills/review/resources/code-anthropic.md +160 -0
- forge/_extensions/skills/review/resources/code-gemini.md +184 -0
- forge/_extensions/skills/review/resources/code-openai.md +203 -0
- forge/_extensions/skills/review/resources/code.md +160 -0
- forge/_extensions/skills/review-docs/SKILL.md +121 -0
- forge/_extensions/skills/review-docs/resources/docs-anthropic.md +170 -0
- forge/_extensions/skills/review-docs/resources/docs-gemini.md +204 -0
- forge/_extensions/skills/review-docs/resources/docs-openai.md +231 -0
- forge/_extensions/skills/review-docs/resources/docs.md +170 -0
- forge/_extensions/skills/smoke-test/SKILL.md +27 -0
- forge/_extensions/skills/smoke-test/scripts/smoke-test.sh +118 -0
- forge/_extensions/skills/understand/SKILL.md +148 -0
- forge/_extensions/skills/understand/resources/code-anthropic.md +163 -0
- forge/_extensions/skills/understand/resources/code-gemini.md +194 -0
- forge/_extensions/skills/understand/resources/code-openai.md +181 -0
- forge/_extensions/skills/understand/resources/code.md +163 -0
- forge/_extensions/skills/understand/resources/docs-anthropic.md +177 -0
- forge/_extensions/skills/understand/resources/docs-gemini.md +202 -0
- forge/_extensions/skills/understand/resources/docs-openai.md +191 -0
- forge/_extensions/skills/understand/resources/docs.md +177 -0
- forge/_extensions/skills/walkthrough/SKILL.md +599 -0
- forge/_extensions/skills/walkthrough/resources/checklist.md +765 -0
- forge/_extensions/skills/walkthrough/scripts/run-in-repo.sh +118 -0
- forge/_extensions/skills/walkthrough/scripts/setup-test-repo.sh +198 -0
- forge/_extensions/skills/walkthrough/scripts/walkthrough-state.py +1137 -0
- forge/backend/__init__.py +174 -0
- forge/backend/adapters/__init__.py +38 -0
- forge/backend/adapters/litellm.py +158 -0
- forge/backend/creation.py +89 -0
- forge/backend/registry.py +178 -0
- forge/cli/__init__.py +16 -0
- forge/cli/auth.py +483 -0
- forge/cli/backend.py +298 -0
- forge/cli/claude.py +411 -0
- forge/cli/config_cmd.py +303 -0
- forge/cli/extensions.py +1001 -0
- forge/cli/gc.py +165 -0
- forge/cli/guard.py +1018 -0
- forge/cli/guards.py +106 -0
- forge/cli/handoff.py +110 -0
- forge/cli/hooks/__init__.py +36 -0
- forge/cli/hooks/_group.py +20 -0
- forge/cli/hooks/_helpers.py +149 -0
- forge/cli/hooks/commands.py +1677 -0
- forge/cli/hooks/direct_commands.py +1304 -0
- forge/cli/hooks/install.py +232 -0
- forge/cli/hooks/policy.py +151 -0
- forge/cli/hooks/read_hygiene.py +74 -0
- forge/cli/hooks/verification.py +370 -0
- forge/cli/logs.py +406 -0
- forge/cli/main.py +292 -0
- forge/cli/proxy.py +1821 -0
- forge/cli/proxy_costs.py +313 -0
- forge/cli/search.py +416 -0
- forge/cli/session.py +892 -0
- forge/cli/session_addendum.py +81 -0
- forge/cli/session_fork.py +750 -0
- forge/cli/session_handoff.py +141 -0
- forge/cli/session_lifecycle.py +2053 -0
- forge/cli/session_manage.py +1336 -0
- forge/cli/session_memory.py +201 -0
- forge/cli/status_line.py +1398 -0
- forge/cli/workflow.py +1964 -0
- forge/config/__init__.py +110 -0
- forge/config/dataclass_utils.py +88 -0
- forge/config/defaults/__init__.py +0 -0
- forge/config/defaults/backends/__init__.py +0 -0
- forge/config/defaults/backends/litellm.yaml +196 -0
- forge/config/defaults/templates/__init__.py +0 -0
- forge/config/defaults/templates/litellm-anthropic-local.yaml +33 -0
- forge/config/defaults/templates/litellm-anthropic.yaml +24 -0
- forge/config/defaults/templates/litellm-gemini-flash-local.yaml +37 -0
- forge/config/defaults/templates/litellm-gemini-local.yaml +32 -0
- forge/config/defaults/templates/litellm-gemini-test.yaml +34 -0
- forge/config/defaults/templates/litellm-gemini.yaml +21 -0
- forge/config/defaults/templates/litellm-openai-codex-local.yaml +36 -0
- forge/config/defaults/templates/litellm-openai-local.yaml +38 -0
- forge/config/defaults/templates/litellm-openai.yaml +28 -0
- forge/config/defaults/templates/openrouter-anthropic.yaml +23 -0
- forge/config/defaults/templates/openrouter-deepseek.yaml +26 -0
- forge/config/defaults/templates/openrouter-gemini-flash.yaml +26 -0
- forge/config/defaults/templates/openrouter-gemini.yaml +23 -0
- forge/config/defaults/templates/openrouter-glm.yaml +23 -0
- forge/config/defaults/templates/openrouter-kimi.yaml +30 -0
- forge/config/defaults/templates/openrouter-minimax.yaml +26 -0
- forge/config/defaults/templates/openrouter-openai-codex.yaml +23 -0
- forge/config/defaults/templates/openrouter-openai.yaml +28 -0
- forge/config/defaults/templates/openrouter-qwen.yaml +25 -0
- forge/config/loader.py +675 -0
- forge/config/schema.py +448 -0
- forge/core/__init__.py +5 -0
- forge/core/auth/__init__.py +67 -0
- forge/core/auth/capabilities.py +219 -0
- forge/core/auth/credentials_file.py +244 -0
- forge/core/auth/protocols.py +18 -0
- forge/core/auth/secrets.py +243 -0
- forge/core/auth/template_secrets.py +112 -0
- forge/core/data/__init__.py +5 -0
- forge/core/data/model_catalog.yaml +1522 -0
- forge/core/data/pricing.yaml +140 -0
- forge/core/data/system_prompt_addendums/__init__.py +0 -0
- forge/core/data/system_prompt_addendums/gemini.md +330 -0
- forge/core/data/system_prompt_addendums/openai.md +328 -0
- forge/core/llm/__init__.py +231 -0
- forge/core/llm/clients/__init__.py +14 -0
- forge/core/llm/clients/base.py +115 -0
- forge/core/llm/clients/litellm.py +619 -0
- forge/core/llm/clients/openai_compat.py +244 -0
- forge/core/llm/clients/openrouter.py +234 -0
- forge/core/llm/credentials.py +439 -0
- forge/core/llm/detection.py +86 -0
- forge/core/llm/errors.py +44 -0
- forge/core/llm/protocols.py +80 -0
- forge/core/llm/types.py +176 -0
- forge/core/logging.py +146 -0
- forge/core/models/__init__.py +91 -0
- forge/core/models/catalog.py +467 -0
- forge/core/models/pricing.py +165 -0
- forge/core/models/types.py +167 -0
- forge/core/naming.py +212 -0
- forge/core/ops/__init__.py +73 -0
- forge/core/ops/context.py +141 -0
- forge/core/ops/gc.py +802 -0
- forge/core/ops/proxy.py +146 -0
- forge/core/ops/resolution.py +135 -0
- forge/core/ops/session.py +344 -0
- forge/core/ops/session_context.py +548 -0
- forge/core/paths.py +38 -0
- forge/core/process.py +54 -0
- forge/core/reactive/__init__.py +38 -0
- forge/core/reactive/cost_tracking.py +300 -0
- forge/core/reactive/env.py +180 -0
- forge/core/reactive/proxy.py +78 -0
- forge/core/reactive/routing.py +622 -0
- forge/core/reactive/session_runner.py +185 -0
- forge/core/reactive/structured_output.py +62 -0
- forge/core/reactive/tagger.py +94 -0
- forge/core/reactive/throttle.py +132 -0
- forge/core/state/__init__.py +59 -0
- forge/core/state/exceptions.py +59 -0
- forge/core/state/io.py +140 -0
- forge/core/state/lock.py +99 -0
- forge/core/state/timestamps.py +60 -0
- forge/core/transcript.py +78 -0
- forge/core/typing_helpers.py +24 -0
- forge/core/workqueue/__init__.py +67 -0
- forge/core/workqueue/queue.py +552 -0
- forge/core/workqueue/types.py +63 -0
- forge/guard/__init__.py +26 -0
- forge/guard/deterministic/__init__.py +26 -0
- forge/guard/deterministic/base.py +158 -0
- forge/guard/deterministic/coding_standards.py +256 -0
- forge/guard/deterministic/registry.py +148 -0
- forge/guard/deterministic/tdd.py +171 -0
- forge/guard/engine.py +216 -0
- forge/guard/protocols.py +91 -0
- forge/guard/queries.py +96 -0
- forge/guard/semantic/__init__.py +34 -0
- forge/guard/semantic/promotion.py +18 -0
- forge/guard/semantic/supervisor.py +813 -0
- forge/guard/semantic/verdict.py +183 -0
- forge/guard/store.py +124 -0
- forge/guard/team/__init__.py +6 -0
- forge/guard/team/config.py +24 -0
- forge/guard/team/handlers.py +209 -0
- forge/guard/team/prompts.py +41 -0
- forge/guard/types.py +125 -0
- forge/guard/workflow/__init__.py +17 -0
- forge/guard/workflow/branches.py +67 -0
- forge/guard/workflow/config.py +63 -0
- forge/guard/workflow/divergence.py +113 -0
- forge/guard/workflow/policy.py +87 -0
- forge/guard/workflow/stages.py +205 -0
- forge/install/__init__.py +55 -0
- forge/install/cli.py +281 -0
- forge/install/exceptions.py +163 -0
- forge/install/hooks.py +109 -0
- forge/install/installer.py +1037 -0
- forge/install/models.py +321 -0
- forge/install/preset.py +272 -0
- forge/install/settings_merge.py +831 -0
- forge/install/tracking.py +238 -0
- forge/install/version.py +141 -0
- forge/proxy/__init__.py +0 -0
- forge/proxy/base_client.py +181 -0
- forge/proxy/client_adapter.py +476 -0
- forge/proxy/client_factory.py +531 -0
- forge/proxy/converters.py +1206 -0
- forge/proxy/cost_logger.py +132 -0
- forge/proxy/cost_tracker.py +242 -0
- forge/proxy/data_models.py +338 -0
- forge/proxy/error_hints.py +92 -0
- forge/proxy/metrics.py +222 -0
- forge/proxy/model_spec.py +158 -0
- forge/proxy/proxies.py +333 -0
- forge/proxy/proxy_identity.py +134 -0
- forge/proxy/proxy_orchestrator.py +1018 -0
- forge/proxy/proxy_startup.py +54 -0
- forge/proxy/server.py +1561 -0
- forge/proxy/utils.py +537 -0
- forge/review/__init__.py +6 -0
- forge/review/adversarial.py +111 -0
- forge/review/consensus.py +236 -0
- forge/review/engine.py +356 -0
- forge/review/models.py +437 -0
- forge/review/resources/__init__.py +5 -0
- forge/review/resources/codereview-performance.md +85 -0
- forge/review/resources/codereview-quick.md +75 -0
- forge/review/resources/codereview-security.md +92 -0
- forge/review/resources/codereview.md +85 -0
- forge/review/resources/docreview-quick.md +75 -0
- forge/review/resources/docreview.md +86 -0
- forge/review/resources/thinkdeep.md +89 -0
- forge/review/routing.py +368 -0
- forge/review/synthesis.py +73 -0
- forge/runtime_config.py +438 -0
- forge/search/__init__.py +55 -0
- forge/search/bm25_store.py +264 -0
- forge/search/content_store.py +197 -0
- forge/search/engine.py +352 -0
- forge/search/exceptions.py +51 -0
- forge/search/extractor.py +234 -0
- forge/search/index_state.py +295 -0
- forge/search/store.py +215 -0
- forge/search/tokenizer.py +24 -0
- forge/session/__init__.py +130 -0
- forge/session/active.py +339 -0
- forge/session/artifacts.py +202 -0
- forge/session/claude/__init__.py +50 -0
- forge/session/claude/cleanup.py +105 -0
- forge/session/claude/invoke.py +236 -0
- forge/session/claude/paths.py +200 -0
- forge/session/cleanup.py +216 -0
- forge/session/config.py +34 -0
- forge/session/direct_model.py +107 -0
- forge/session/effective.py +169 -0
- forge/session/exceptions.py +255 -0
- forge/session/handoff.py +881 -0
- forge/session/handoff_agent.py +544 -0
- forge/session/hooks/__init__.py +35 -0
- forge/session/hooks/models.py +73 -0
- forge/session/hooks/session_start.py +507 -0
- forge/session/identity.py +84 -0
- forge/session/index.py +553 -0
- forge/session/manager.py +1506 -0
- forge/session/models.py +572 -0
- forge/session/overrides.py +344 -0
- forge/session/plan_resolution.py +286 -0
- forge/session/prev_sessions.py +128 -0
- forge/session/store.py +431 -0
- forge/session/validation.py +47 -0
- forge/session/worktree/__init__.py +65 -0
- forge/session/worktree/cleanup.py +262 -0
- forge/session/worktree/config_copy.py +203 -0
- forge/session/worktree/create.py +332 -0
- forge/sidecar/__init__.py +29 -0
- forge/sidecar/container.py +161 -0
- forge/sidecar/docker.py +86 -0
- forge/sidecar/secrets.py +19 -0
- multi_forge-0.2.0.dist-info/METADATA +242 -0
- multi_forge-0.2.0.dist-info/RECORD +311 -0
- multi_forge-0.2.0.dist-info/WHEEL +4 -0
- multi_forge-0.2.0.dist-info/entry_points.txt +2 -0
- multi_forge-0.2.0.dist-info/licenses/LICENSE +203 -0
- multi_forge-0.2.0.dist-info/licenses/NOTICE +14 -0
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""Git worktree cleanup utilities.
|
|
2
|
+
|
|
3
|
+
This module provides functions for safely removing git worktrees.
|
|
4
|
+
Cleanup order:
|
|
5
|
+
1. Try git worktree remove
|
|
6
|
+
2. If dirty: remove untracked config files, retry
|
|
7
|
+
3. git branch delete (with -D if force, else -d)
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import shutil
|
|
13
|
+
import subprocess
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from ..exceptions import (
|
|
18
|
+
BranchInUseError,
|
|
19
|
+
BranchNotMergedError,
|
|
20
|
+
DirtyWorktreeError,
|
|
21
|
+
GitWorktreeError,
|
|
22
|
+
)
|
|
23
|
+
from .config_copy import get_copied_config_files
|
|
24
|
+
from .create import find_git_binary, get_main_repo_root, get_repo_root
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class CleanupResult:
|
|
29
|
+
"""Result of worktree cleanup."""
|
|
30
|
+
|
|
31
|
+
worktree_removed: bool = False
|
|
32
|
+
branch_deleted: bool = False
|
|
33
|
+
config_files_removed: list[str] = field(default_factory=list)
|
|
34
|
+
errors: list[str] = field(default_factory=list)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def is_worktree_dirty(worktree_path: Path) -> bool:
|
|
38
|
+
"""Check if worktree has uncommitted changes.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
worktree_path: Path to worktree.
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
True if worktree has uncommitted changes.
|
|
45
|
+
"""
|
|
46
|
+
git = find_git_binary()
|
|
47
|
+
|
|
48
|
+
result = subprocess.run(
|
|
49
|
+
[git, "status", "--porcelain"],
|
|
50
|
+
cwd=str(worktree_path),
|
|
51
|
+
capture_output=True,
|
|
52
|
+
text=True,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
return bool(result.stdout.strip())
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def remove_config_files(worktree_path: Path) -> list[str]:
|
|
59
|
+
"""Remove untracked config files from worktree.
|
|
60
|
+
|
|
61
|
+
Only removes files that are:
|
|
62
|
+
- In the allowlist AND
|
|
63
|
+
- NOT tracked by git
|
|
64
|
+
|
|
65
|
+
Called by cleanup_worktree() after a failed removal attempt to clear
|
|
66
|
+
untracked config files that make the worktree dirty.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
worktree_path: Path to worktree.
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
List of removed file paths (relative to worktree root).
|
|
73
|
+
"""
|
|
74
|
+
removed: list[str] = []
|
|
75
|
+
config_files = get_copied_config_files(worktree_path)
|
|
76
|
+
|
|
77
|
+
for file_path in config_files:
|
|
78
|
+
try:
|
|
79
|
+
if file_path.is_dir():
|
|
80
|
+
shutil.rmtree(file_path)
|
|
81
|
+
else:
|
|
82
|
+
file_path.unlink()
|
|
83
|
+
try:
|
|
84
|
+
removed.append(str(file_path.relative_to(worktree_path)))
|
|
85
|
+
except ValueError:
|
|
86
|
+
removed.append(file_path.name)
|
|
87
|
+
except OSError:
|
|
88
|
+
pass # Best effort
|
|
89
|
+
|
|
90
|
+
return removed
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def remove_worktree(
|
|
94
|
+
worktree_path: Path,
|
|
95
|
+
force: bool = False,
|
|
96
|
+
repo_root: Path | None = None,
|
|
97
|
+
) -> bool:
|
|
98
|
+
"""Remove a git worktree.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
worktree_path: Path to worktree.
|
|
102
|
+
force: Force removal even if dirty.
|
|
103
|
+
repo_root: Main repository root (must be provided, not derived from worktree).
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
True if worktree was removed.
|
|
107
|
+
|
|
108
|
+
Raises:
|
|
109
|
+
DirtyWorktreeError: If worktree is dirty and force=False.
|
|
110
|
+
GitWorktreeError: If removal fails for other reasons.
|
|
111
|
+
"""
|
|
112
|
+
if not worktree_path.exists():
|
|
113
|
+
return False
|
|
114
|
+
|
|
115
|
+
if not force and is_worktree_dirty(worktree_path):
|
|
116
|
+
raise DirtyWorktreeError(str(worktree_path))
|
|
117
|
+
|
|
118
|
+
git = find_git_binary()
|
|
119
|
+
|
|
120
|
+
# Get the main repo root to run git worktree remove from there
|
|
121
|
+
# (git worktree remove needs to be run from the main repo, not from the worktree itself)
|
|
122
|
+
if repo_root is None:
|
|
123
|
+
repo_root = get_main_repo_root(worktree_path)
|
|
124
|
+
|
|
125
|
+
cmd = [git, "worktree", "remove", str(worktree_path)]
|
|
126
|
+
if force:
|
|
127
|
+
cmd.append("--force")
|
|
128
|
+
|
|
129
|
+
result = subprocess.run(
|
|
130
|
+
cmd,
|
|
131
|
+
cwd=str(repo_root),
|
|
132
|
+
capture_output=True,
|
|
133
|
+
text=True,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
if result.returncode != 0:
|
|
137
|
+
stderr = result.stderr.strip()
|
|
138
|
+
if "contains modified or untracked files" in stderr.lower():
|
|
139
|
+
raise DirtyWorktreeError(str(worktree_path))
|
|
140
|
+
raise GitWorktreeError("remove", stderr, result.returncode)
|
|
141
|
+
|
|
142
|
+
return True
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def delete_branch(
|
|
146
|
+
branch: str,
|
|
147
|
+
cwd: Path | None = None,
|
|
148
|
+
force: bool = False,
|
|
149
|
+
) -> bool:
|
|
150
|
+
"""Delete a git branch.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
branch: Branch name to delete.
|
|
154
|
+
cwd: Working directory (should be main repo, not the worktree).
|
|
155
|
+
force: Use -D (force delete) instead of -d.
|
|
156
|
+
|
|
157
|
+
Returns:
|
|
158
|
+
True if branch was deleted.
|
|
159
|
+
|
|
160
|
+
Raises:
|
|
161
|
+
BranchInUseError: If branch is checked out elsewhere.
|
|
162
|
+
BranchNotMergedError: If branch is not fully merged and force=False.
|
|
163
|
+
GitWorktreeError: If deletion fails for other reasons.
|
|
164
|
+
"""
|
|
165
|
+
git = find_git_binary()
|
|
166
|
+
repo_root = get_repo_root(cwd)
|
|
167
|
+
|
|
168
|
+
flag = "-D" if force else "-d"
|
|
169
|
+
result = subprocess.run(
|
|
170
|
+
[git, "branch", flag, branch],
|
|
171
|
+
cwd=str(repo_root),
|
|
172
|
+
capture_output=True,
|
|
173
|
+
text=True,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
if result.returncode != 0:
|
|
177
|
+
stderr = result.stderr.strip().lower()
|
|
178
|
+
stderr_orig = result.stderr.strip()
|
|
179
|
+
|
|
180
|
+
if "not found" in stderr:
|
|
181
|
+
return False
|
|
182
|
+
|
|
183
|
+
# Check for "checked out" or "used by worktree" - branch is in use
|
|
184
|
+
if "checked out" in stderr or "used by worktree" in stderr:
|
|
185
|
+
# Try to extract worktree path from error message
|
|
186
|
+
# Format: "error: Cannot delete branch 'X' checked out at '/path/to/worktree'"
|
|
187
|
+
# or: "error: cannot delete branch 'X' used by worktree at '/path/to/worktree'"
|
|
188
|
+
worktree = "another worktree"
|
|
189
|
+
if "at '" in stderr_orig:
|
|
190
|
+
try:
|
|
191
|
+
worktree = stderr_orig.split("at '")[1].split("'")[0]
|
|
192
|
+
except IndexError:
|
|
193
|
+
pass
|
|
194
|
+
raise BranchInUseError(branch, worktree)
|
|
195
|
+
|
|
196
|
+
if "not fully merged" in stderr:
|
|
197
|
+
raise BranchNotMergedError(branch)
|
|
198
|
+
|
|
199
|
+
raise GitWorktreeError("branch delete", stderr_orig, result.returncode)
|
|
200
|
+
|
|
201
|
+
return True
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def cleanup_worktree(
|
|
205
|
+
worktree_path: Path,
|
|
206
|
+
branch: str | None = None,
|
|
207
|
+
delete_branch_flag: bool = False,
|
|
208
|
+
force: bool = False,
|
|
209
|
+
repo_root: Path | None = None,
|
|
210
|
+
) -> CleanupResult:
|
|
211
|
+
"""Full cleanup of a worktree and optionally its branch.
|
|
212
|
+
|
|
213
|
+
Order:
|
|
214
|
+
1. Try removing worktree
|
|
215
|
+
2. If dirty: remove untracked config files, retry
|
|
216
|
+
3. Delete branch if requested
|
|
217
|
+
|
|
218
|
+
Args:
|
|
219
|
+
worktree_path: Path to worktree.
|
|
220
|
+
branch: Branch name (required if delete_branch_flag=True).
|
|
221
|
+
delete_branch_flag: Whether to delete the branch.
|
|
222
|
+
force: Force removal even if dirty, and use -D for branch.
|
|
223
|
+
repo_root: Main repository root (derived from worktree if not provided).
|
|
224
|
+
|
|
225
|
+
Returns:
|
|
226
|
+
CleanupResult with details of what was done.
|
|
227
|
+
"""
|
|
228
|
+
result = CleanupResult()
|
|
229
|
+
|
|
230
|
+
# Get main repo root before removing worktree (so we can delete branch later)
|
|
231
|
+
# Must use get_main_repo_root to get the main repo, not the worktree itself
|
|
232
|
+
if repo_root is None and worktree_path.exists():
|
|
233
|
+
try:
|
|
234
|
+
repo_root = get_main_repo_root(worktree_path)
|
|
235
|
+
except GitWorktreeError:
|
|
236
|
+
pass # Will fail to delete branch if repo not found
|
|
237
|
+
|
|
238
|
+
# 1. Try removing worktree first. If it fails due to untracked config
|
|
239
|
+
# files making it dirty, remove those files and retry — this avoids
|
|
240
|
+
# deleting config files when the removal will fail for other reasons.
|
|
241
|
+
try:
|
|
242
|
+
result.worktree_removed = remove_worktree(worktree_path, force=force, repo_root=repo_root)
|
|
243
|
+
except DirtyWorktreeError:
|
|
244
|
+
if worktree_path.exists():
|
|
245
|
+
result.config_files_removed = remove_config_files(worktree_path)
|
|
246
|
+
try:
|
|
247
|
+
result.worktree_removed = remove_worktree(worktree_path, force=force, repo_root=repo_root)
|
|
248
|
+
except (DirtyWorktreeError, GitWorktreeError) as e:
|
|
249
|
+
result.errors.append(str(e))
|
|
250
|
+
return result
|
|
251
|
+
except GitWorktreeError as e:
|
|
252
|
+
result.errors.append(str(e))
|
|
253
|
+
return result
|
|
254
|
+
|
|
255
|
+
# 3. Delete branch if requested
|
|
256
|
+
if delete_branch_flag and branch and repo_root:
|
|
257
|
+
try:
|
|
258
|
+
result.branch_deleted = delete_branch(branch, cwd=repo_root, force=force)
|
|
259
|
+
except (BranchInUseError, BranchNotMergedError, GitWorktreeError) as e:
|
|
260
|
+
result.errors.append(str(e))
|
|
261
|
+
|
|
262
|
+
return result
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""Copy runtime configuration to worktree.
|
|
2
|
+
|
|
3
|
+
This module handles safe copying of runtime config files (.env, .mcp.json, etc.)
|
|
4
|
+
from the main repository to a new worktree. Safety rules:
|
|
5
|
+
1. Only copy if file exists in source
|
|
6
|
+
2. Only copy if file does NOT already exist in target
|
|
7
|
+
3. Skip files that are tracked by git
|
|
8
|
+
|
|
9
|
+
Entries support glob patterns (``**/`` prefix) for nested project structures.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import shutil
|
|
15
|
+
import subprocess
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from .create import find_git_binary
|
|
20
|
+
|
|
21
|
+
# Allowlist of runtime config files/directories to copy (relative to repo root).
|
|
22
|
+
# Entries with glob metacharacters are resolved via Path.glob(); exact paths are
|
|
23
|
+
# matched directly. ``**/X`` matches X at any depth including root.
|
|
24
|
+
DEFAULT_CONFIG_ALLOWLIST: tuple[str, ...] = (
|
|
25
|
+
".env",
|
|
26
|
+
".env.local",
|
|
27
|
+
".envrc",
|
|
28
|
+
"docker/certs",
|
|
29
|
+
"**/.claude/settings.json",
|
|
30
|
+
"**/.claude/settings.local.json",
|
|
31
|
+
"**/.mcp.json",
|
|
32
|
+
"**/.mcp.local.json",
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class ConfigCopyResult:
|
|
38
|
+
"""Result of config copy operation."""
|
|
39
|
+
|
|
40
|
+
copied: list[str] = field(default_factory=list)
|
|
41
|
+
skipped_exists: list[str] = field(default_factory=list) # Already exists in target
|
|
42
|
+
skipped_tracked: list[str] = field(default_factory=list) # Tracked by git
|
|
43
|
+
skipped_not_found: list[str] = field(default_factory=list) # Not in source
|
|
44
|
+
failed: list[tuple[str, str]] = field(default_factory=list) # (file, error)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def is_file_tracked(file_path: Path, cwd: Path) -> bool:
|
|
48
|
+
"""Check if a file is tracked by git.
|
|
49
|
+
|
|
50
|
+
Uses `git ls-files --error-unmatch` to check if the file is tracked.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
file_path: Path to the file (can be relative or absolute).
|
|
54
|
+
cwd: Working directory for git command.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
True if file is tracked by git.
|
|
58
|
+
"""
|
|
59
|
+
git = find_git_binary()
|
|
60
|
+
|
|
61
|
+
if file_path.is_absolute():
|
|
62
|
+
try:
|
|
63
|
+
file_path = file_path.relative_to(cwd)
|
|
64
|
+
except ValueError:
|
|
65
|
+
# File is not under cwd, can't be tracked
|
|
66
|
+
return False
|
|
67
|
+
|
|
68
|
+
result = subprocess.run(
|
|
69
|
+
[git, "ls-files", "--error-unmatch", str(file_path)],
|
|
70
|
+
cwd=str(cwd),
|
|
71
|
+
capture_output=True,
|
|
72
|
+
text=True,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
return result.returncode == 0
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _is_glob_pattern(pattern: str) -> bool:
|
|
79
|
+
"""Check if a pattern contains glob metacharacters."""
|
|
80
|
+
return any(c in pattern for c in ("*", "?", "["))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _resolve_glob(root: Path, pattern: str) -> list[Path]:
|
|
84
|
+
"""Resolve a glob pattern relative to root.
|
|
85
|
+
|
|
86
|
+
Returns sorted relative paths matching the pattern.
|
|
87
|
+
"""
|
|
88
|
+
return sorted(match.relative_to(root) for match in root.glob(pattern))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _copy_single(
|
|
92
|
+
source_root: Path,
|
|
93
|
+
worktree_path: Path,
|
|
94
|
+
filename: str,
|
|
95
|
+
result: ConfigCopyResult,
|
|
96
|
+
) -> None:
|
|
97
|
+
"""Copy a single file or directory from source to worktree with safety checks."""
|
|
98
|
+
source_path = source_root / filename
|
|
99
|
+
dest_path = worktree_path / filename
|
|
100
|
+
|
|
101
|
+
if source_path.is_dir():
|
|
102
|
+
if dest_path.exists():
|
|
103
|
+
result.skipped_exists.append(filename)
|
|
104
|
+
return
|
|
105
|
+
try:
|
|
106
|
+
shutil.copytree(source_path, dest_path)
|
|
107
|
+
result.copied.append(filename)
|
|
108
|
+
except OSError as e:
|
|
109
|
+
result.failed.append((filename, str(e)))
|
|
110
|
+
return
|
|
111
|
+
|
|
112
|
+
if not source_path.is_file():
|
|
113
|
+
result.skipped_not_found.append(filename)
|
|
114
|
+
return
|
|
115
|
+
|
|
116
|
+
if dest_path.exists():
|
|
117
|
+
result.skipped_exists.append(filename)
|
|
118
|
+
return
|
|
119
|
+
|
|
120
|
+
if is_file_tracked(Path(filename), worktree_path):
|
|
121
|
+
result.skipped_tracked.append(filename)
|
|
122
|
+
return
|
|
123
|
+
|
|
124
|
+
try:
|
|
125
|
+
dest_path.parent.mkdir(parents=True, exist_ok=True)
|
|
126
|
+
shutil.copy2(source_path, dest_path)
|
|
127
|
+
result.copied.append(filename)
|
|
128
|
+
except OSError as e:
|
|
129
|
+
result.failed.append((filename, str(e)))
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def copy_runtime_config(
|
|
133
|
+
source_root: Path,
|
|
134
|
+
worktree_path: Path,
|
|
135
|
+
allowlist: tuple[str, ...] | None = None,
|
|
136
|
+
) -> ConfigCopyResult:
|
|
137
|
+
"""Copy runtime configuration files to worktree.
|
|
138
|
+
|
|
139
|
+
Safely copies files from the allowlist, respecting:
|
|
140
|
+
- Only copy if file exists in source
|
|
141
|
+
- Only copy if file does NOT already exist in target
|
|
142
|
+
- Skip files that are tracked by git (they'll be in the worktree already)
|
|
143
|
+
|
|
144
|
+
Allowlist entries may be exact relative paths or glob patterns (containing
|
|
145
|
+
``*``, ``?``, or ``[``). Glob patterns are resolved via ``Path.glob()``
|
|
146
|
+
with excluded directories filtered out (node_modules, .git, etc.).
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
source_root: Path to source repository.
|
|
150
|
+
worktree_path: Path to worktree.
|
|
151
|
+
allowlist: Files to copy (defaults to DEFAULT_CONFIG_ALLOWLIST).
|
|
152
|
+
|
|
153
|
+
Returns:
|
|
154
|
+
ConfigCopyResult with detailed status of each file.
|
|
155
|
+
"""
|
|
156
|
+
result = ConfigCopyResult()
|
|
157
|
+
files_to_copy = allowlist if allowlist is not None else DEFAULT_CONFIG_ALLOWLIST
|
|
158
|
+
|
|
159
|
+
for entry in files_to_copy:
|
|
160
|
+
if _is_glob_pattern(entry):
|
|
161
|
+
resolved = _resolve_glob(source_root, entry)
|
|
162
|
+
if not resolved:
|
|
163
|
+
result.skipped_not_found.append(entry)
|
|
164
|
+
continue
|
|
165
|
+
for rel_path in resolved:
|
|
166
|
+
_copy_single(source_root, worktree_path, str(rel_path), result)
|
|
167
|
+
else:
|
|
168
|
+
_copy_single(source_root, worktree_path, entry, result)
|
|
169
|
+
|
|
170
|
+
return result
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def get_copied_config_files(worktree_path: Path) -> list[Path]:
|
|
174
|
+
"""Get list of untracked config files in worktree that match allowlist.
|
|
175
|
+
|
|
176
|
+
Used for cleanup to identify which files can be safely removed.
|
|
177
|
+
Only returns files that are NOT tracked by git. Handles both exact
|
|
178
|
+
paths and glob patterns in the allowlist.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
worktree_path: Path to worktree.
|
|
182
|
+
|
|
183
|
+
Returns:
|
|
184
|
+
List of existing untracked config file paths.
|
|
185
|
+
"""
|
|
186
|
+
config_files: list[Path] = []
|
|
187
|
+
|
|
188
|
+
for entry in DEFAULT_CONFIG_ALLOWLIST:
|
|
189
|
+
if _is_glob_pattern(entry):
|
|
190
|
+
for rel_path in _resolve_glob(worktree_path, entry):
|
|
191
|
+
file_path = worktree_path / rel_path
|
|
192
|
+
if file_path.is_dir():
|
|
193
|
+
config_files.append(file_path)
|
|
194
|
+
elif file_path.is_file() and not is_file_tracked(rel_path, worktree_path):
|
|
195
|
+
config_files.append(file_path)
|
|
196
|
+
else:
|
|
197
|
+
file_path = worktree_path / entry
|
|
198
|
+
if file_path.is_dir():
|
|
199
|
+
config_files.append(file_path)
|
|
200
|
+
elif file_path.is_file() and not is_file_tracked(Path(entry), worktree_path):
|
|
201
|
+
config_files.append(file_path)
|
|
202
|
+
|
|
203
|
+
return config_files
|