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.
Files changed (311) hide show
  1. forge/__init__.py +3 -0
  2. forge/_extensions/agents/.gitkeep +0 -0
  3. forge/_extensions/commands/.gitkeep +0 -0
  4. forge/_extensions/skills/analyze/SKILL.md +87 -0
  5. forge/_extensions/skills/challenge/SKILL.md +91 -0
  6. forge/_extensions/skills/consensus/SKILL.md +120 -0
  7. forge/_extensions/skills/consensus/resources/code_consensus_evaluation.md +94 -0
  8. forge/_extensions/skills/consensus/resources/consensus_evaluation.md +70 -0
  9. forge/_extensions/skills/consensus/resources/synthesis.md +101 -0
  10. forge/_extensions/skills/debate/SKILL.md +116 -0
  11. forge/_extensions/skills/debate/resources/code_debate_evaluation.md +101 -0
  12. forge/_extensions/skills/debate/resources/debate_evaluation.md +90 -0
  13. forge/_extensions/skills/panel/SKILL.md +141 -0
  14. forge/_extensions/skills/panel/resources/synthesis.md +103 -0
  15. forge/_extensions/skills/qa/SKILL.md +704 -0
  16. forge/_extensions/skills/qa/resources/checklist/0-enable.md +78 -0
  17. forge/_extensions/skills/qa/resources/checklist/1-preflight.md +24 -0
  18. forge/_extensions/skills/qa/resources/checklist/10-resume.md +143 -0
  19. forge/_extensions/skills/qa/resources/checklist/11-config.md +150 -0
  20. forge/_extensions/skills/qa/resources/checklist/12-search.md +58 -0
  21. forge/_extensions/skills/qa/resources/checklist/13-guard.md +237 -0
  22. forge/_extensions/skills/qa/resources/checklist/14-workflow.md +305 -0
  23. forge/_extensions/skills/qa/resources/checklist/15-skills.md +155 -0
  24. forge/_extensions/skills/qa/resources/checklist/16-handoff.md +224 -0
  25. forge/_extensions/skills/qa/resources/checklist/17-info.md +50 -0
  26. forge/_extensions/skills/qa/resources/checklist/18-disable.md +84 -0
  27. forge/_extensions/skills/qa/resources/checklist/19-uninstall.md +146 -0
  28. forge/_extensions/skills/qa/resources/checklist/2-extensions.md +188 -0
  29. forge/_extensions/skills/qa/resources/checklist/20-cleanup.md +36 -0
  30. forge/_extensions/skills/qa/resources/checklist/3-auth.md +234 -0
  31. forge/_extensions/skills/qa/resources/checklist/4-proxy.md +481 -0
  32. forge/_extensions/skills/qa/resources/checklist/5-session.md +541 -0
  33. forge/_extensions/skills/qa/resources/checklist/6-hooks.md +275 -0
  34. forge/_extensions/skills/qa/resources/checklist/7-costs.md +309 -0
  35. forge/_extensions/skills/qa/resources/checklist/8-status-line.md +174 -0
  36. forge/_extensions/skills/qa/resources/checklist/9-direct-commands.md +146 -0
  37. forge/_extensions/skills/qa/resources/checklist.md +103 -0
  38. forge/_extensions/skills/qa/resources/report-template.md +62 -0
  39. forge/_extensions/skills/qa/scripts/start-container.sh +529 -0
  40. forge/_extensions/skills/qa/scripts/walkthrough-state.py +1137 -0
  41. forge/_extensions/skills/review/SKILL.md +125 -0
  42. forge/_extensions/skills/review/references/claude-4.6.md +474 -0
  43. forge/_extensions/skills/review/references/claude-4.7.md +710 -0
  44. forge/_extensions/skills/review/references/gemini-3.1.md +546 -0
  45. forge/_extensions/skills/review/references/gpt-5.5.md +490 -0
  46. forge/_extensions/skills/review/references/skills-writing-guide.md +1588 -0
  47. forge/_extensions/skills/review/resources/code-anthropic.md +160 -0
  48. forge/_extensions/skills/review/resources/code-gemini.md +184 -0
  49. forge/_extensions/skills/review/resources/code-openai.md +203 -0
  50. forge/_extensions/skills/review/resources/code.md +160 -0
  51. forge/_extensions/skills/review-docs/SKILL.md +121 -0
  52. forge/_extensions/skills/review-docs/resources/docs-anthropic.md +170 -0
  53. forge/_extensions/skills/review-docs/resources/docs-gemini.md +204 -0
  54. forge/_extensions/skills/review-docs/resources/docs-openai.md +231 -0
  55. forge/_extensions/skills/review-docs/resources/docs.md +170 -0
  56. forge/_extensions/skills/smoke-test/SKILL.md +27 -0
  57. forge/_extensions/skills/smoke-test/scripts/smoke-test.sh +118 -0
  58. forge/_extensions/skills/understand/SKILL.md +148 -0
  59. forge/_extensions/skills/understand/resources/code-anthropic.md +163 -0
  60. forge/_extensions/skills/understand/resources/code-gemini.md +194 -0
  61. forge/_extensions/skills/understand/resources/code-openai.md +181 -0
  62. forge/_extensions/skills/understand/resources/code.md +163 -0
  63. forge/_extensions/skills/understand/resources/docs-anthropic.md +177 -0
  64. forge/_extensions/skills/understand/resources/docs-gemini.md +202 -0
  65. forge/_extensions/skills/understand/resources/docs-openai.md +191 -0
  66. forge/_extensions/skills/understand/resources/docs.md +177 -0
  67. forge/_extensions/skills/walkthrough/SKILL.md +599 -0
  68. forge/_extensions/skills/walkthrough/resources/checklist.md +765 -0
  69. forge/_extensions/skills/walkthrough/scripts/run-in-repo.sh +118 -0
  70. forge/_extensions/skills/walkthrough/scripts/setup-test-repo.sh +198 -0
  71. forge/_extensions/skills/walkthrough/scripts/walkthrough-state.py +1137 -0
  72. forge/backend/__init__.py +174 -0
  73. forge/backend/adapters/__init__.py +38 -0
  74. forge/backend/adapters/litellm.py +158 -0
  75. forge/backend/creation.py +89 -0
  76. forge/backend/registry.py +178 -0
  77. forge/cli/__init__.py +16 -0
  78. forge/cli/auth.py +483 -0
  79. forge/cli/backend.py +298 -0
  80. forge/cli/claude.py +411 -0
  81. forge/cli/config_cmd.py +303 -0
  82. forge/cli/extensions.py +1001 -0
  83. forge/cli/gc.py +165 -0
  84. forge/cli/guard.py +1018 -0
  85. forge/cli/guards.py +106 -0
  86. forge/cli/handoff.py +110 -0
  87. forge/cli/hooks/__init__.py +36 -0
  88. forge/cli/hooks/_group.py +20 -0
  89. forge/cli/hooks/_helpers.py +149 -0
  90. forge/cli/hooks/commands.py +1677 -0
  91. forge/cli/hooks/direct_commands.py +1304 -0
  92. forge/cli/hooks/install.py +232 -0
  93. forge/cli/hooks/policy.py +151 -0
  94. forge/cli/hooks/read_hygiene.py +74 -0
  95. forge/cli/hooks/verification.py +370 -0
  96. forge/cli/logs.py +406 -0
  97. forge/cli/main.py +292 -0
  98. forge/cli/proxy.py +1821 -0
  99. forge/cli/proxy_costs.py +313 -0
  100. forge/cli/search.py +416 -0
  101. forge/cli/session.py +892 -0
  102. forge/cli/session_addendum.py +81 -0
  103. forge/cli/session_fork.py +750 -0
  104. forge/cli/session_handoff.py +141 -0
  105. forge/cli/session_lifecycle.py +2053 -0
  106. forge/cli/session_manage.py +1336 -0
  107. forge/cli/session_memory.py +201 -0
  108. forge/cli/status_line.py +1398 -0
  109. forge/cli/workflow.py +1964 -0
  110. forge/config/__init__.py +110 -0
  111. forge/config/dataclass_utils.py +88 -0
  112. forge/config/defaults/__init__.py +0 -0
  113. forge/config/defaults/backends/__init__.py +0 -0
  114. forge/config/defaults/backends/litellm.yaml +196 -0
  115. forge/config/defaults/templates/__init__.py +0 -0
  116. forge/config/defaults/templates/litellm-anthropic-local.yaml +33 -0
  117. forge/config/defaults/templates/litellm-anthropic.yaml +24 -0
  118. forge/config/defaults/templates/litellm-gemini-flash-local.yaml +37 -0
  119. forge/config/defaults/templates/litellm-gemini-local.yaml +32 -0
  120. forge/config/defaults/templates/litellm-gemini-test.yaml +34 -0
  121. forge/config/defaults/templates/litellm-gemini.yaml +21 -0
  122. forge/config/defaults/templates/litellm-openai-codex-local.yaml +36 -0
  123. forge/config/defaults/templates/litellm-openai-local.yaml +38 -0
  124. forge/config/defaults/templates/litellm-openai.yaml +28 -0
  125. forge/config/defaults/templates/openrouter-anthropic.yaml +23 -0
  126. forge/config/defaults/templates/openrouter-deepseek.yaml +26 -0
  127. forge/config/defaults/templates/openrouter-gemini-flash.yaml +26 -0
  128. forge/config/defaults/templates/openrouter-gemini.yaml +23 -0
  129. forge/config/defaults/templates/openrouter-glm.yaml +23 -0
  130. forge/config/defaults/templates/openrouter-kimi.yaml +30 -0
  131. forge/config/defaults/templates/openrouter-minimax.yaml +26 -0
  132. forge/config/defaults/templates/openrouter-openai-codex.yaml +23 -0
  133. forge/config/defaults/templates/openrouter-openai.yaml +28 -0
  134. forge/config/defaults/templates/openrouter-qwen.yaml +25 -0
  135. forge/config/loader.py +675 -0
  136. forge/config/schema.py +448 -0
  137. forge/core/__init__.py +5 -0
  138. forge/core/auth/__init__.py +67 -0
  139. forge/core/auth/capabilities.py +219 -0
  140. forge/core/auth/credentials_file.py +244 -0
  141. forge/core/auth/protocols.py +18 -0
  142. forge/core/auth/secrets.py +243 -0
  143. forge/core/auth/template_secrets.py +112 -0
  144. forge/core/data/__init__.py +5 -0
  145. forge/core/data/model_catalog.yaml +1522 -0
  146. forge/core/data/pricing.yaml +140 -0
  147. forge/core/data/system_prompt_addendums/__init__.py +0 -0
  148. forge/core/data/system_prompt_addendums/gemini.md +330 -0
  149. forge/core/data/system_prompt_addendums/openai.md +328 -0
  150. forge/core/llm/__init__.py +231 -0
  151. forge/core/llm/clients/__init__.py +14 -0
  152. forge/core/llm/clients/base.py +115 -0
  153. forge/core/llm/clients/litellm.py +619 -0
  154. forge/core/llm/clients/openai_compat.py +244 -0
  155. forge/core/llm/clients/openrouter.py +234 -0
  156. forge/core/llm/credentials.py +439 -0
  157. forge/core/llm/detection.py +86 -0
  158. forge/core/llm/errors.py +44 -0
  159. forge/core/llm/protocols.py +80 -0
  160. forge/core/llm/types.py +176 -0
  161. forge/core/logging.py +146 -0
  162. forge/core/models/__init__.py +91 -0
  163. forge/core/models/catalog.py +467 -0
  164. forge/core/models/pricing.py +165 -0
  165. forge/core/models/types.py +167 -0
  166. forge/core/naming.py +212 -0
  167. forge/core/ops/__init__.py +73 -0
  168. forge/core/ops/context.py +141 -0
  169. forge/core/ops/gc.py +802 -0
  170. forge/core/ops/proxy.py +146 -0
  171. forge/core/ops/resolution.py +135 -0
  172. forge/core/ops/session.py +344 -0
  173. forge/core/ops/session_context.py +548 -0
  174. forge/core/paths.py +38 -0
  175. forge/core/process.py +54 -0
  176. forge/core/reactive/__init__.py +38 -0
  177. forge/core/reactive/cost_tracking.py +300 -0
  178. forge/core/reactive/env.py +180 -0
  179. forge/core/reactive/proxy.py +78 -0
  180. forge/core/reactive/routing.py +622 -0
  181. forge/core/reactive/session_runner.py +185 -0
  182. forge/core/reactive/structured_output.py +62 -0
  183. forge/core/reactive/tagger.py +94 -0
  184. forge/core/reactive/throttle.py +132 -0
  185. forge/core/state/__init__.py +59 -0
  186. forge/core/state/exceptions.py +59 -0
  187. forge/core/state/io.py +140 -0
  188. forge/core/state/lock.py +99 -0
  189. forge/core/state/timestamps.py +60 -0
  190. forge/core/transcript.py +78 -0
  191. forge/core/typing_helpers.py +24 -0
  192. forge/core/workqueue/__init__.py +67 -0
  193. forge/core/workqueue/queue.py +552 -0
  194. forge/core/workqueue/types.py +63 -0
  195. forge/guard/__init__.py +26 -0
  196. forge/guard/deterministic/__init__.py +26 -0
  197. forge/guard/deterministic/base.py +158 -0
  198. forge/guard/deterministic/coding_standards.py +256 -0
  199. forge/guard/deterministic/registry.py +148 -0
  200. forge/guard/deterministic/tdd.py +171 -0
  201. forge/guard/engine.py +216 -0
  202. forge/guard/protocols.py +91 -0
  203. forge/guard/queries.py +96 -0
  204. forge/guard/semantic/__init__.py +34 -0
  205. forge/guard/semantic/promotion.py +18 -0
  206. forge/guard/semantic/supervisor.py +813 -0
  207. forge/guard/semantic/verdict.py +183 -0
  208. forge/guard/store.py +124 -0
  209. forge/guard/team/__init__.py +6 -0
  210. forge/guard/team/config.py +24 -0
  211. forge/guard/team/handlers.py +209 -0
  212. forge/guard/team/prompts.py +41 -0
  213. forge/guard/types.py +125 -0
  214. forge/guard/workflow/__init__.py +17 -0
  215. forge/guard/workflow/branches.py +67 -0
  216. forge/guard/workflow/config.py +63 -0
  217. forge/guard/workflow/divergence.py +113 -0
  218. forge/guard/workflow/policy.py +87 -0
  219. forge/guard/workflow/stages.py +205 -0
  220. forge/install/__init__.py +55 -0
  221. forge/install/cli.py +281 -0
  222. forge/install/exceptions.py +163 -0
  223. forge/install/hooks.py +109 -0
  224. forge/install/installer.py +1037 -0
  225. forge/install/models.py +321 -0
  226. forge/install/preset.py +272 -0
  227. forge/install/settings_merge.py +831 -0
  228. forge/install/tracking.py +238 -0
  229. forge/install/version.py +141 -0
  230. forge/proxy/__init__.py +0 -0
  231. forge/proxy/base_client.py +181 -0
  232. forge/proxy/client_adapter.py +476 -0
  233. forge/proxy/client_factory.py +531 -0
  234. forge/proxy/converters.py +1206 -0
  235. forge/proxy/cost_logger.py +132 -0
  236. forge/proxy/cost_tracker.py +242 -0
  237. forge/proxy/data_models.py +338 -0
  238. forge/proxy/error_hints.py +92 -0
  239. forge/proxy/metrics.py +222 -0
  240. forge/proxy/model_spec.py +158 -0
  241. forge/proxy/proxies.py +333 -0
  242. forge/proxy/proxy_identity.py +134 -0
  243. forge/proxy/proxy_orchestrator.py +1018 -0
  244. forge/proxy/proxy_startup.py +54 -0
  245. forge/proxy/server.py +1561 -0
  246. forge/proxy/utils.py +537 -0
  247. forge/review/__init__.py +6 -0
  248. forge/review/adversarial.py +111 -0
  249. forge/review/consensus.py +236 -0
  250. forge/review/engine.py +356 -0
  251. forge/review/models.py +437 -0
  252. forge/review/resources/__init__.py +5 -0
  253. forge/review/resources/codereview-performance.md +85 -0
  254. forge/review/resources/codereview-quick.md +75 -0
  255. forge/review/resources/codereview-security.md +92 -0
  256. forge/review/resources/codereview.md +85 -0
  257. forge/review/resources/docreview-quick.md +75 -0
  258. forge/review/resources/docreview.md +86 -0
  259. forge/review/resources/thinkdeep.md +89 -0
  260. forge/review/routing.py +368 -0
  261. forge/review/synthesis.py +73 -0
  262. forge/runtime_config.py +438 -0
  263. forge/search/__init__.py +55 -0
  264. forge/search/bm25_store.py +264 -0
  265. forge/search/content_store.py +197 -0
  266. forge/search/engine.py +352 -0
  267. forge/search/exceptions.py +51 -0
  268. forge/search/extractor.py +234 -0
  269. forge/search/index_state.py +295 -0
  270. forge/search/store.py +215 -0
  271. forge/search/tokenizer.py +24 -0
  272. forge/session/__init__.py +130 -0
  273. forge/session/active.py +339 -0
  274. forge/session/artifacts.py +202 -0
  275. forge/session/claude/__init__.py +50 -0
  276. forge/session/claude/cleanup.py +105 -0
  277. forge/session/claude/invoke.py +236 -0
  278. forge/session/claude/paths.py +200 -0
  279. forge/session/cleanup.py +216 -0
  280. forge/session/config.py +34 -0
  281. forge/session/direct_model.py +107 -0
  282. forge/session/effective.py +169 -0
  283. forge/session/exceptions.py +255 -0
  284. forge/session/handoff.py +881 -0
  285. forge/session/handoff_agent.py +544 -0
  286. forge/session/hooks/__init__.py +35 -0
  287. forge/session/hooks/models.py +73 -0
  288. forge/session/hooks/session_start.py +507 -0
  289. forge/session/identity.py +84 -0
  290. forge/session/index.py +553 -0
  291. forge/session/manager.py +1506 -0
  292. forge/session/models.py +572 -0
  293. forge/session/overrides.py +344 -0
  294. forge/session/plan_resolution.py +286 -0
  295. forge/session/prev_sessions.py +128 -0
  296. forge/session/store.py +431 -0
  297. forge/session/validation.py +47 -0
  298. forge/session/worktree/__init__.py +65 -0
  299. forge/session/worktree/cleanup.py +262 -0
  300. forge/session/worktree/config_copy.py +203 -0
  301. forge/session/worktree/create.py +332 -0
  302. forge/sidecar/__init__.py +29 -0
  303. forge/sidecar/container.py +161 -0
  304. forge/sidecar/docker.py +86 -0
  305. forge/sidecar/secrets.py +19 -0
  306. multi_forge-0.2.0.dist-info/METADATA +242 -0
  307. multi_forge-0.2.0.dist-info/RECORD +311 -0
  308. multi_forge-0.2.0.dist-info/WHEEL +4 -0
  309. multi_forge-0.2.0.dist-info/entry_points.txt +2 -0
  310. multi_forge-0.2.0.dist-info/licenses/LICENSE +203 -0
  311. 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