erk 0.4.5__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 (331) hide show
  1. erk/__init__.py +12 -0
  2. erk/__main__.py +6 -0
  3. erk/agent_docs/__init__.py +5 -0
  4. erk/agent_docs/models.py +123 -0
  5. erk/agent_docs/operations.py +666 -0
  6. erk/artifacts/__init__.py +5 -0
  7. erk/artifacts/artifact_health.py +623 -0
  8. erk/artifacts/detection.py +16 -0
  9. erk/artifacts/discovery.py +343 -0
  10. erk/artifacts/models.py +63 -0
  11. erk/artifacts/staleness.py +56 -0
  12. erk/artifacts/state.py +100 -0
  13. erk/artifacts/sync.py +624 -0
  14. erk/cli/__init__.py +0 -0
  15. erk/cli/activation.py +132 -0
  16. erk/cli/alias.py +53 -0
  17. erk/cli/cli.py +221 -0
  18. erk/cli/commands/__init__.py +0 -0
  19. erk/cli/commands/admin.py +153 -0
  20. erk/cli/commands/artifact/__init__.py +1 -0
  21. erk/cli/commands/artifact/check.py +260 -0
  22. erk/cli/commands/artifact/group.py +31 -0
  23. erk/cli/commands/artifact/list_cmd.py +89 -0
  24. erk/cli/commands/artifact/show.py +62 -0
  25. erk/cli/commands/artifact/sync_cmd.py +39 -0
  26. erk/cli/commands/branch/__init__.py +26 -0
  27. erk/cli/commands/branch/assign_cmd.py +152 -0
  28. erk/cli/commands/branch/checkout_cmd.py +357 -0
  29. erk/cli/commands/branch/create_cmd.py +161 -0
  30. erk/cli/commands/branch/list_cmd.py +82 -0
  31. erk/cli/commands/branch/unassign_cmd.py +197 -0
  32. erk/cli/commands/cc/__init__.py +15 -0
  33. erk/cli/commands/cc/jsonl_cmd.py +20 -0
  34. erk/cli/commands/cc/session/AGENTS.md +30 -0
  35. erk/cli/commands/cc/session/CLAUDE.md +1 -0
  36. erk/cli/commands/cc/session/__init__.py +15 -0
  37. erk/cli/commands/cc/session/list_cmd.py +167 -0
  38. erk/cli/commands/cc/session/show_cmd.py +175 -0
  39. erk/cli/commands/completion.py +89 -0
  40. erk/cli/commands/completions.py +165 -0
  41. erk/cli/commands/config.py +327 -0
  42. erk/cli/commands/docs/__init__.py +1 -0
  43. erk/cli/commands/docs/group.py +16 -0
  44. erk/cli/commands/docs/sync.py +121 -0
  45. erk/cli/commands/docs/validate.py +102 -0
  46. erk/cli/commands/doctor.py +243 -0
  47. erk/cli/commands/down.py +171 -0
  48. erk/cli/commands/exec/__init__.py +1 -0
  49. erk/cli/commands/exec/group.py +164 -0
  50. erk/cli/commands/exec/scripts/AGENTS.md +79 -0
  51. erk/cli/commands/exec/scripts/CLAUDE.md +1 -0
  52. erk/cli/commands/exec/scripts/__init__.py +5 -0
  53. erk/cli/commands/exec/scripts/add_reaction_to_comment.py +69 -0
  54. erk/cli/commands/exec/scripts/add_remote_execution_note.py +68 -0
  55. erk/cli/commands/exec/scripts/check_impl.py +152 -0
  56. erk/cli/commands/exec/scripts/ci_update_pr_body.py +294 -0
  57. erk/cli/commands/exec/scripts/create_extraction_branch.py +138 -0
  58. erk/cli/commands/exec/scripts/create_extraction_plan.py +242 -0
  59. erk/cli/commands/exec/scripts/create_issue_from_session.py +103 -0
  60. erk/cli/commands/exec/scripts/create_plan_from_context.py +103 -0
  61. erk/cli/commands/exec/scripts/create_worker_impl_from_issue.py +93 -0
  62. erk/cli/commands/exec/scripts/detect_trunk_branch.py +121 -0
  63. erk/cli/commands/exec/scripts/exit_plan_mode_hook.py +777 -0
  64. erk/cli/commands/exec/scripts/extract_latest_plan.py +49 -0
  65. erk/cli/commands/exec/scripts/extract_session_from_issue.py +150 -0
  66. erk/cli/commands/exec/scripts/find_project_dir.py +214 -0
  67. erk/cli/commands/exec/scripts/generate_pr_summary.py +112 -0
  68. erk/cli/commands/exec/scripts/get_closing_text.py +98 -0
  69. erk/cli/commands/exec/scripts/get_embedded_prompt.py +62 -0
  70. erk/cli/commands/exec/scripts/get_plan_metadata.py +95 -0
  71. erk/cli/commands/exec/scripts/get_pr_body_footer.py +70 -0
  72. erk/cli/commands/exec/scripts/get_pr_discussion_comments.py +149 -0
  73. erk/cli/commands/exec/scripts/get_pr_review_comments.py +155 -0
  74. erk/cli/commands/exec/scripts/impl_init.py +158 -0
  75. erk/cli/commands/exec/scripts/impl_signal.py +375 -0
  76. erk/cli/commands/exec/scripts/impl_verify.py +49 -0
  77. erk/cli/commands/exec/scripts/issue_title_to_filename.py +34 -0
  78. erk/cli/commands/exec/scripts/list_sessions.py +296 -0
  79. erk/cli/commands/exec/scripts/mark_impl_ended.py +188 -0
  80. erk/cli/commands/exec/scripts/mark_impl_started.py +188 -0
  81. erk/cli/commands/exec/scripts/marker.py +163 -0
  82. erk/cli/commands/exec/scripts/objective_save_to_issue.py +109 -0
  83. erk/cli/commands/exec/scripts/plan_save_to_issue.py +269 -0
  84. erk/cli/commands/exec/scripts/plan_update_issue.py +147 -0
  85. erk/cli/commands/exec/scripts/post_extraction_comment.py +237 -0
  86. erk/cli/commands/exec/scripts/post_or_update_pr_summary.py +133 -0
  87. erk/cli/commands/exec/scripts/post_pr_inline_comment.py +143 -0
  88. erk/cli/commands/exec/scripts/post_workflow_started_comment.py +168 -0
  89. erk/cli/commands/exec/scripts/preprocess_session.py +777 -0
  90. erk/cli/commands/exec/scripts/quick_submit.py +32 -0
  91. erk/cli/commands/exec/scripts/rebase_with_conflict_resolution.py +260 -0
  92. erk/cli/commands/exec/scripts/reply_to_discussion_comment.py +173 -0
  93. erk/cli/commands/exec/scripts/resolve_review_thread.py +170 -0
  94. erk/cli/commands/exec/scripts/session_id_injector_hook.py +52 -0
  95. erk/cli/commands/exec/scripts/setup_impl_from_issue.py +159 -0
  96. erk/cli/commands/exec/scripts/slot_objective.py +102 -0
  97. erk/cli/commands/exec/scripts/tripwires_reminder_hook.py +20 -0
  98. erk/cli/commands/exec/scripts/update_dispatch_info.py +116 -0
  99. erk/cli/commands/exec/scripts/user_prompt_hook.py +113 -0
  100. erk/cli/commands/exec/scripts/validate_plan_content.py +98 -0
  101. erk/cli/commands/exec/scripts/wrap_plan_in_metadata_block.py +34 -0
  102. erk/cli/commands/implement.py +695 -0
  103. erk/cli/commands/implement_shared.py +649 -0
  104. erk/cli/commands/info/__init__.py +14 -0
  105. erk/cli/commands/info/release_notes_cmd.py +128 -0
  106. erk/cli/commands/init.py +801 -0
  107. erk/cli/commands/land_cmd.py +690 -0
  108. erk/cli/commands/log_cmd.py +137 -0
  109. erk/cli/commands/md/__init__.py +5 -0
  110. erk/cli/commands/md/check.py +118 -0
  111. erk/cli/commands/md/group.py +14 -0
  112. erk/cli/commands/navigation_helpers.py +430 -0
  113. erk/cli/commands/objective/__init__.py +16 -0
  114. erk/cli/commands/objective/list_cmd.py +47 -0
  115. erk/cli/commands/objective_helpers.py +132 -0
  116. erk/cli/commands/plan/__init__.py +32 -0
  117. erk/cli/commands/plan/check_cmd.py +174 -0
  118. erk/cli/commands/plan/close_cmd.py +69 -0
  119. erk/cli/commands/plan/create_cmd.py +120 -0
  120. erk/cli/commands/plan/docs/__init__.py +18 -0
  121. erk/cli/commands/plan/docs/extract_cmd.py +53 -0
  122. erk/cli/commands/plan/docs/unextract_cmd.py +38 -0
  123. erk/cli/commands/plan/docs/unextracted_cmd.py +72 -0
  124. erk/cli/commands/plan/extraction/__init__.py +16 -0
  125. erk/cli/commands/plan/extraction/complete_cmd.py +101 -0
  126. erk/cli/commands/plan/extraction/create_raw_cmd.py +63 -0
  127. erk/cli/commands/plan/get.py +71 -0
  128. erk/cli/commands/plan/list_cmd.py +754 -0
  129. erk/cli/commands/plan/log_cmd.py +440 -0
  130. erk/cli/commands/plan/start_cmd.py +459 -0
  131. erk/cli/commands/planner/__init__.py +40 -0
  132. erk/cli/commands/planner/configure_cmd.py +73 -0
  133. erk/cli/commands/planner/connect_cmd.py +96 -0
  134. erk/cli/commands/planner/create_cmd.py +148 -0
  135. erk/cli/commands/planner/list_cmd.py +51 -0
  136. erk/cli/commands/planner/register_cmd.py +105 -0
  137. erk/cli/commands/planner/set_default_cmd.py +23 -0
  138. erk/cli/commands/planner/unregister_cmd.py +43 -0
  139. erk/cli/commands/pr/__init__.py +23 -0
  140. erk/cli/commands/pr/check_cmd.py +112 -0
  141. erk/cli/commands/pr/checkout_cmd.py +165 -0
  142. erk/cli/commands/pr/fix_conflicts_cmd.py +82 -0
  143. erk/cli/commands/pr/parse_pr_reference.py +10 -0
  144. erk/cli/commands/pr/submit_cmd.py +360 -0
  145. erk/cli/commands/pr/sync_cmd.py +181 -0
  146. erk/cli/commands/prepare_cwd_recovery.py +60 -0
  147. erk/cli/commands/project/__init__.py +16 -0
  148. erk/cli/commands/project/init_cmd.py +91 -0
  149. erk/cli/commands/run/__init__.py +17 -0
  150. erk/cli/commands/run/list_cmd.py +189 -0
  151. erk/cli/commands/run/logs_cmd.py +54 -0
  152. erk/cli/commands/run/shared.py +19 -0
  153. erk/cli/commands/shell_integration.py +29 -0
  154. erk/cli/commands/slot/__init__.py +23 -0
  155. erk/cli/commands/slot/check_cmd.py +277 -0
  156. erk/cli/commands/slot/common.py +314 -0
  157. erk/cli/commands/slot/init_pool_cmd.py +157 -0
  158. erk/cli/commands/slot/list_cmd.py +228 -0
  159. erk/cli/commands/slot/repair_cmd.py +190 -0
  160. erk/cli/commands/stack/__init__.py +23 -0
  161. erk/cli/commands/stack/consolidate_cmd.py +470 -0
  162. erk/cli/commands/stack/list_cmd.py +79 -0
  163. erk/cli/commands/stack/move_cmd.py +309 -0
  164. erk/cli/commands/stack/split_old/README.md +64 -0
  165. erk/cli/commands/stack/split_old/__init__.py +5 -0
  166. erk/cli/commands/stack/split_old/command.py +233 -0
  167. erk/cli/commands/stack/split_old/display.py +116 -0
  168. erk/cli/commands/stack/split_old/plan.py +216 -0
  169. erk/cli/commands/status.py +58 -0
  170. erk/cli/commands/submit.py +768 -0
  171. erk/cli/commands/up.py +154 -0
  172. erk/cli/commands/upgrade.py +82 -0
  173. erk/cli/commands/wt/__init__.py +29 -0
  174. erk/cli/commands/wt/checkout_cmd.py +110 -0
  175. erk/cli/commands/wt/create_cmd.py +998 -0
  176. erk/cli/commands/wt/current_cmd.py +35 -0
  177. erk/cli/commands/wt/delete_cmd.py +573 -0
  178. erk/cli/commands/wt/list_cmd.py +332 -0
  179. erk/cli/commands/wt/rename_cmd.py +66 -0
  180. erk/cli/config.py +242 -0
  181. erk/cli/constants.py +29 -0
  182. erk/cli/core.py +65 -0
  183. erk/cli/debug.py +9 -0
  184. erk/cli/ensure-conversion-tasks.md +288 -0
  185. erk/cli/ensure.py +628 -0
  186. erk/cli/github_parsing.py +96 -0
  187. erk/cli/graphite.py +81 -0
  188. erk/cli/graphite_command.py +80 -0
  189. erk/cli/help_formatter.py +345 -0
  190. erk/cli/output.py +361 -0
  191. erk/cli/presets/dagster.toml +12 -0
  192. erk/cli/presets/generic.toml +12 -0
  193. erk/cli/prompt_hooks_templates/README.md +68 -0
  194. erk/cli/script_output.py +32 -0
  195. erk/cli/shell_integration/bash_wrapper.sh +32 -0
  196. erk/cli/shell_integration/fish_wrapper.fish +39 -0
  197. erk/cli/shell_integration/handler.py +338 -0
  198. erk/cli/shell_integration/zsh_wrapper.sh +32 -0
  199. erk/cli/shell_utils.py +171 -0
  200. erk/cli/subprocess_utils.py +92 -0
  201. erk/cli/uvx_detection.py +59 -0
  202. erk/core/__init__.py +0 -0
  203. erk/core/claude_executor.py +511 -0
  204. erk/core/claude_settings.py +317 -0
  205. erk/core/command_log.py +406 -0
  206. erk/core/commit_message_generator.py +234 -0
  207. erk/core/completion.py +10 -0
  208. erk/core/consolidation_utils.py +177 -0
  209. erk/core/context.py +570 -0
  210. erk/core/display/__init__.py +4 -0
  211. erk/core/display/abc.py +24 -0
  212. erk/core/display/real.py +30 -0
  213. erk/core/display_utils.py +526 -0
  214. erk/core/file_utils.py +87 -0
  215. erk/core/health_checks.py +1315 -0
  216. erk/core/health_checks_dogfooder/__init__.py +85 -0
  217. erk/core/health_checks_dogfooder/deprecated_dot_agent_config.py +64 -0
  218. erk/core/health_checks_dogfooder/legacy_claude_docs.py +69 -0
  219. erk/core/health_checks_dogfooder/legacy_config_locations.py +122 -0
  220. erk/core/health_checks_dogfooder/legacy_erk_docs_agent.py +61 -0
  221. erk/core/health_checks_dogfooder/legacy_erk_kits_folder.py +60 -0
  222. erk/core/health_checks_dogfooder/legacy_hook_settings.py +104 -0
  223. erk/core/health_checks_dogfooder/legacy_kit_yaml.py +78 -0
  224. erk/core/health_checks_dogfooder/legacy_kits_toml.py +43 -0
  225. erk/core/health_checks_dogfooder/outdated_erk_skill.py +43 -0
  226. erk/core/implementation_queue/__init__.py +1 -0
  227. erk/core/implementation_queue/github/__init__.py +8 -0
  228. erk/core/implementation_queue/github/abc.py +7 -0
  229. erk/core/implementation_queue/github/noop.py +38 -0
  230. erk/core/implementation_queue/github/printing.py +43 -0
  231. erk/core/implementation_queue/github/real.py +119 -0
  232. erk/core/init_utils.py +227 -0
  233. erk/core/output_filter.py +338 -0
  234. erk/core/plan_store/__init__.py +6 -0
  235. erk/core/planner/__init__.py +1 -0
  236. erk/core/planner/registry_abc.py +8 -0
  237. erk/core/planner/registry_fake.py +129 -0
  238. erk/core/planner/registry_real.py +195 -0
  239. erk/core/planner/types.py +7 -0
  240. erk/core/pr_utils.py +30 -0
  241. erk/core/release_notes.py +263 -0
  242. erk/core/repo_discovery.py +126 -0
  243. erk/core/script_writer.py +41 -0
  244. erk/core/services/__init__.py +1 -0
  245. erk/core/services/plan_list_service.py +94 -0
  246. erk/core/shell.py +51 -0
  247. erk/core/user_feedback.py +11 -0
  248. erk/core/version_check.py +55 -0
  249. erk/core/workflow_display.py +75 -0
  250. erk/core/worktree_pool.py +190 -0
  251. erk/core/worktree_utils.py +300 -0
  252. erk/data/CHANGELOG.md +438 -0
  253. erk/data/__init__.py +1 -0
  254. erk/data/claude/agents/devrun.md +180 -0
  255. erk/data/claude/commands/erk/__init__.py +0 -0
  256. erk/data/claude/commands/erk/create-extraction-plan.md +360 -0
  257. erk/data/claude/commands/erk/fix-conflicts.md +25 -0
  258. erk/data/claude/commands/erk/git-pr-push.md +345 -0
  259. erk/data/claude/commands/erk/implement-stacked-plan.md +96 -0
  260. erk/data/claude/commands/erk/land.md +193 -0
  261. erk/data/claude/commands/erk/objective-create.md +370 -0
  262. erk/data/claude/commands/erk/objective-list.md +34 -0
  263. erk/data/claude/commands/erk/objective-next-plan.md +220 -0
  264. erk/data/claude/commands/erk/objective-update-with-landed-pr.md +216 -0
  265. erk/data/claude/commands/erk/plan-implement.md +202 -0
  266. erk/data/claude/commands/erk/plan-save.md +45 -0
  267. erk/data/claude/commands/erk/plan-submit.md +39 -0
  268. erk/data/claude/commands/erk/pr-address.md +367 -0
  269. erk/data/claude/commands/erk/pr-submit.md +58 -0
  270. erk/data/claude/skills/dignified-python/SKILL.md +48 -0
  271. erk/data/claude/skills/dignified-python/cli-patterns.md +155 -0
  272. erk/data/claude/skills/dignified-python/dignified-python-core.md +1190 -0
  273. erk/data/claude/skills/dignified-python/subprocess.md +99 -0
  274. erk/data/claude/skills/dignified-python/versions/python-3.10.md +517 -0
  275. erk/data/claude/skills/dignified-python/versions/python-3.11.md +536 -0
  276. erk/data/claude/skills/dignified-python/versions/python-3.12.md +662 -0
  277. erk/data/claude/skills/dignified-python/versions/python-3.13.md +653 -0
  278. erk/data/claude/skills/erk-diff-analysis/SKILL.md +27 -0
  279. erk/data/claude/skills/erk-diff-analysis/references/commit-message-prompt.md +78 -0
  280. erk/data/claude/skills/learned-docs/SKILL.md +362 -0
  281. erk/data/github/actions/setup-claude-erk/action.yml +11 -0
  282. erk/data/github/prompts/dignified-python-review.md +125 -0
  283. erk/data/github/workflows/dignified-python-review.yml +61 -0
  284. erk/data/github/workflows/erk-impl.yml +251 -0
  285. erk/hooks/__init__.py +1 -0
  286. erk/hooks/decorators.py +319 -0
  287. erk/status/__init__.py +8 -0
  288. erk/status/collectors/__init__.py +9 -0
  289. erk/status/collectors/base.py +52 -0
  290. erk/status/collectors/git.py +76 -0
  291. erk/status/collectors/github.py +81 -0
  292. erk/status/collectors/graphite.py +80 -0
  293. erk/status/collectors/impl.py +145 -0
  294. erk/status/models/__init__.py +4 -0
  295. erk/status/models/status_data.py +404 -0
  296. erk/status/orchestrator.py +169 -0
  297. erk/status/renderers/__init__.py +5 -0
  298. erk/status/renderers/simple.py +322 -0
  299. erk/tui/AGENTS.md +193 -0
  300. erk/tui/CLAUDE.md +1 -0
  301. erk/tui/__init__.py +1 -0
  302. erk/tui/app.py +1404 -0
  303. erk/tui/commands/__init__.py +1 -0
  304. erk/tui/commands/executor.py +66 -0
  305. erk/tui/commands/provider.py +165 -0
  306. erk/tui/commands/real_executor.py +63 -0
  307. erk/tui/commands/registry.py +121 -0
  308. erk/tui/commands/types.py +36 -0
  309. erk/tui/data/__init__.py +1 -0
  310. erk/tui/data/provider.py +492 -0
  311. erk/tui/data/types.py +104 -0
  312. erk/tui/filtering/__init__.py +1 -0
  313. erk/tui/filtering/logic.py +43 -0
  314. erk/tui/filtering/types.py +55 -0
  315. erk/tui/jsonl_viewer/__init__.py +1 -0
  316. erk/tui/jsonl_viewer/app.py +61 -0
  317. erk/tui/jsonl_viewer/models.py +208 -0
  318. erk/tui/jsonl_viewer/widgets.py +204 -0
  319. erk/tui/sorting/__init__.py +6 -0
  320. erk/tui/sorting/logic.py +55 -0
  321. erk/tui/sorting/types.py +68 -0
  322. erk/tui/styles/dash.tcss +95 -0
  323. erk/tui/widgets/__init__.py +1 -0
  324. erk/tui/widgets/command_output.py +112 -0
  325. erk/tui/widgets/plan_table.py +276 -0
  326. erk/tui/widgets/status_bar.py +116 -0
  327. erk-0.4.5.dist-info/METADATA +376 -0
  328. erk-0.4.5.dist-info/RECORD +331 -0
  329. erk-0.4.5.dist-info/WHEEL +4 -0
  330. erk-0.4.5.dist-info/entry_points.txt +2 -0
  331. erk-0.4.5.dist-info/licenses/LICENSE.md +3 -0
@@ -0,0 +1,998 @@
1
+ import json
2
+ import shlex
3
+ import subprocess
4
+ from collections.abc import Iterable
5
+ from pathlib import Path
6
+
7
+ import click
8
+
9
+ from erk.cli.config import LoadedConfig
10
+ from erk.cli.core import discover_repo_context, worktree_path_for
11
+ from erk.cli.ensure import Ensure
12
+ from erk.cli.github_parsing import parse_issue_identifier
13
+ from erk.cli.help_formatter import CommandWithHiddenOptions, script_option
14
+ from erk.cli.shell_utils import render_navigation_script
15
+ from erk.cli.subprocess_utils import run_with_error_reporting
16
+ from erk.core.context import ErkContext
17
+ from erk.core.repo_discovery import RepoContext, ensure_erk_metadata_dir
18
+ from erk_shared.impl_folder import create_impl_folder, get_impl_path, save_issue_reference
19
+ from erk_shared.issue_workflow import (
20
+ IssueBranchSetup,
21
+ IssueValidationFailed,
22
+ prepare_plan_for_worktree,
23
+ )
24
+ from erk_shared.naming import (
25
+ default_branch_for_worktree,
26
+ ensure_simple_worktree_name,
27
+ ensure_unique_worktree_name,
28
+ ensure_unique_worktree_name_with_date,
29
+ sanitize_worktree_name,
30
+ strip_plan_from_filename,
31
+ )
32
+ from erk_shared.output.output import user_output
33
+ from erk_shared.plan_store.types import Plan
34
+
35
+
36
+ def run_post_worktree_setup(
37
+ ctx: ErkContext,
38
+ config: LoadedConfig,
39
+ worktree_path: Path,
40
+ repo_root: Path,
41
+ name: str,
42
+ ) -> None:
43
+ """Run post-worktree-creation setup: .env file and post-create commands.
44
+
45
+ Args:
46
+ ctx: Erk context
47
+ config: Loaded local configuration
48
+ worktree_path: Path to the newly created worktree
49
+ repo_root: Path to repository root
50
+ name: Worktree name
51
+ """
52
+ # Write .env file if template exists
53
+ env_content = make_env_content(
54
+ config, worktree_path=worktree_path, repo_root=repo_root, name=name
55
+ )
56
+ if env_content:
57
+ env_path = worktree_path / ".env"
58
+ env_path.write_text(env_content, encoding="utf-8")
59
+
60
+ # Run post-create commands
61
+ if config.post_create_commands:
62
+ run_commands_in_worktree(
63
+ ctx=ctx,
64
+ commands=config.post_create_commands,
65
+ worktree_path=worktree_path,
66
+ shell=config.post_create_shell,
67
+ )
68
+
69
+
70
+ def ensure_worktree_for_branch(
71
+ ctx: ErkContext,
72
+ repo: RepoContext,
73
+ branch: str,
74
+ *,
75
+ is_plan_derived: bool = False,
76
+ ) -> tuple[Path, bool]:
77
+ """Ensure worktree exists for branch, creating if necessary.
78
+
79
+ This function checks if a worktree already exists for the given branch.
80
+ If it does, validates branch match and returns path. If not, creates a new worktree
81
+ with config-driven post-create commands and .env generation.
82
+
83
+ Args:
84
+ ctx: The Erk context with git operations
85
+ repo: Repository context with root and worktrees directory
86
+ branch: The branch name to ensure a worktree for
87
+ is_plan_derived: If True, use dated worktree names (for plan workflows).
88
+ If False, use simple names (for manual checkout).
89
+
90
+ Returns:
91
+ Tuple of (worktree_path, was_created)
92
+ - worktree_path: Path to the worktree directory
93
+ - was_created: True if worktree was newly created, False if it already existed
94
+
95
+ Raises:
96
+ SystemExit: If branch doesn't exist, tracking branch creation fails,
97
+ or worktree name collision with different branch
98
+ """
99
+ # Check if worktree already exists for this branch
100
+ existing_path = ctx.git.is_branch_checked_out(repo.root, branch)
101
+ if existing_path is not None:
102
+ return existing_path, False
103
+
104
+ # Get trunk branch for validation
105
+ trunk_branch = ctx.git.detect_trunk_branch(repo.root)
106
+
107
+ # Validate that we're not trying to create worktree for trunk branch
108
+ Ensure.invariant(
109
+ branch != trunk_branch,
110
+ f'Cannot create worktree for trunk branch "{trunk_branch}".\n'
111
+ f"The trunk branch should be checked out in the root worktree.\n"
112
+ f"To switch to {trunk_branch}, use:\n"
113
+ f" erk br co root",
114
+ )
115
+
116
+ # Branch not checked out - need to create worktree
117
+ # First check if branch exists locally
118
+ local_branches = ctx.git.list_local_branches(repo.root)
119
+
120
+ if branch not in local_branches:
121
+ # Not a local branch - check if remote branch exists
122
+ remote_branches = ctx.git.list_remote_branches(repo.root)
123
+ remote_ref = f"origin/{branch}"
124
+
125
+ if remote_ref not in remote_branches:
126
+ # Branch doesn't exist locally or on origin
127
+ user_output(
128
+ f"Error: Branch '{branch}' does not exist.\n"
129
+ f"To create a new branch and worktree, run:\n"
130
+ f" erk wt create --branch {branch}"
131
+ )
132
+ raise SystemExit(1) from None
133
+
134
+ # Remote branch exists - create local tracking branch
135
+ user_output(f"Branch '{branch}' exists on origin, creating local tracking branch...")
136
+ try:
137
+ ctx.git.create_tracking_branch(repo.root, branch, remote_ref)
138
+ except subprocess.CalledProcessError as e:
139
+ user_output(
140
+ f"Error: Failed to create local tracking branch from {remote_ref}\n"
141
+ f"Details: {e.stderr}\n"
142
+ f"Suggested action:\n"
143
+ f" 1. Check git status and resolve any issues\n"
144
+ f" 2. Manually create branch: git branch --track {branch} {remote_ref}\n"
145
+ f" 3. Or use: erk wt create --branch {branch}"
146
+ )
147
+ raise SystemExit(1) from e
148
+
149
+ # Branch exists but not checked out - auto-create worktree
150
+ user_output(f"Branch '{branch}' not checked out, creating worktree...")
151
+
152
+ # Load local config for .env template and post-create commands
153
+ config = ctx.local_config if ctx.local_config is not None else LoadedConfig.test()
154
+
155
+ # Generate and ensure unique worktree name
156
+ name = sanitize_worktree_name(branch)
157
+
158
+ # Use appropriate naming strategy based on whether worktree is plan-derived
159
+ if is_plan_derived:
160
+ # Plan workflows need date suffixes to create multiple worktrees from same plan
161
+ name = ensure_unique_worktree_name_with_date(name, repo.worktrees_dir, ctx.git)
162
+ else:
163
+ # Manual checkouts use simple names for predictability
164
+ name = ensure_simple_worktree_name(name, repo.worktrees_dir, ctx.git)
165
+
166
+ # Calculate worktree path
167
+ wt_path = worktree_path_for(repo.worktrees_dir, name)
168
+
169
+ # Check for name collision with different branch (for non-plan checkouts)
170
+ if not is_plan_derived and ctx.git.path_exists(wt_path):
171
+ # Worktree exists - check what branch it has
172
+ worktrees = ctx.git.list_worktrees(repo.root)
173
+ for wt in worktrees:
174
+ if wt.path == wt_path:
175
+ if wt.branch != branch:
176
+ # Detached HEAD: provide specific guidance
177
+ if wt.branch is None:
178
+ user_output(
179
+ f"Error: Worktree '{name}' is in detached HEAD state "
180
+ f"(possibly mid-rebase).\n\n"
181
+ f"Cannot create new worktree for branch '{branch}' with same name.\n\n"
182
+ f"Options:\n"
183
+ f" 1. Resume work in existing worktree: erk wt co {name}\n"
184
+ f" 2. Complete or abort the rebase first, then try again\n"
185
+ f" 3. Use a different branch name"
186
+ )
187
+ raise SystemExit(1) from None
188
+ # Different branch: existing error handling
189
+ user_output(
190
+ f"Error: Worktree '{name}' already exists "
191
+ f"with different branch '{wt.branch}'.\n"
192
+ f"Cannot create worktree for branch '{branch}' with same name.\n"
193
+ f"Options:\n"
194
+ f" 1. Switch to existing worktree: erk wt co {name}\n"
195
+ f" 2. Use a different branch name"
196
+ )
197
+ raise SystemExit(1) from None
198
+ # Same branch - return existing path
199
+ return wt_path, False
200
+ # Path exists but not in worktree list (shouldn't happen, but handle gracefully)
201
+ user_output(
202
+ f"Error: Directory '{wt_path}' exists but is not a git worktree.\n"
203
+ f"Please remove or rename the directory and try again."
204
+ )
205
+ raise SystemExit(1) from None
206
+
207
+ # Create worktree from existing branch
208
+ add_worktree(
209
+ ctx,
210
+ repo.root,
211
+ wt_path,
212
+ branch=branch,
213
+ ref=None,
214
+ use_existing_branch=True,
215
+ use_graphite=False,
216
+ skip_remote_check=True,
217
+ )
218
+
219
+ user_output(click.style(f"✓ Created worktree: {name}", fg="green"))
220
+
221
+ # Run post-worktree setup (.env and post-create commands)
222
+ run_post_worktree_setup(ctx, config, wt_path, repo.root, name)
223
+
224
+ return wt_path, True
225
+
226
+
227
+ def add_worktree(
228
+ ctx: ErkContext,
229
+ repo_root: Path,
230
+ path: Path,
231
+ *,
232
+ branch: str | None,
233
+ ref: str | None,
234
+ use_existing_branch: bool,
235
+ use_graphite: bool,
236
+ skip_remote_check: bool,
237
+ ) -> None:
238
+ """Create a git worktree.
239
+
240
+ If `use_existing_branch` is True and `branch` is provided, checks out the existing branch
241
+ in the new worktree: `git worktree add <path> <branch>`.
242
+
243
+ If `use_existing_branch` is False and `branch` is provided, creates a new branch:
244
+ - With graphite: `gt create <branch>` followed by `git worktree add <path> <branch>`
245
+ - Without graphite: `git worktree add -b <branch> <path> <ref or HEAD>`
246
+
247
+ Otherwise, uses `git worktree add <path> <ref or HEAD>`.
248
+ """
249
+
250
+ if branch and use_existing_branch:
251
+ # Validate branch is not already checked out
252
+ existing_path = ctx.git.is_branch_checked_out(repo_root, branch)
253
+ if existing_path:
254
+ user_output(
255
+ f"Error: Branch '{branch}' is already checked out at {existing_path}\n"
256
+ f"Git doesn't allow the same branch to be checked out in multiple worktrees.\n\n"
257
+ f"Options:\n"
258
+ f" • Use a different branch name\n"
259
+ f" • Create a new branch instead: erk create {path.name}\n"
260
+ f" • Switch to that worktree: erk br co {branch}",
261
+ )
262
+ raise SystemExit(1) from None
263
+
264
+ ctx.git.add_worktree(repo_root, path, branch=branch, ref=None, create_branch=False)
265
+
266
+ # Track existing branch with Graphite if enabled
267
+ if use_graphite and ref:
268
+ # Only track if not already tracked (idempotent)
269
+ all_branches = ctx.graphite.get_all_branches(ctx.git, repo_root)
270
+ if branch not in all_branches:
271
+ ctx.graphite.track_branch(repo_root, branch, ref)
272
+ elif branch:
273
+ # Check if branch name exists on remote origin (only when creating new branches)
274
+ if not skip_remote_check:
275
+ try:
276
+ remote_branches = ctx.git.list_remote_branches(repo_root)
277
+ remote_ref = f"origin/{branch}"
278
+
279
+ if remote_ref in remote_branches:
280
+ user_output(
281
+ click.style("Error: ", fg="red")
282
+ + f"Branch '{branch}' already exists on remote 'origin'\n\n"
283
+ + "A branch with this name is already pushed to the remote repository.\n"
284
+ + "Please choose a different name for your new branch."
285
+ )
286
+ raise SystemExit(1) from None
287
+ except Exception as e:
288
+ # Remote unavailable or other error - proceed with warning
289
+ user_output(
290
+ click.style("Warning: ", fg="yellow")
291
+ + f"Could not check remote branches: {e}\n"
292
+ + "Proceeding with branch creation..."
293
+ )
294
+
295
+ if use_graphite:
296
+ cwd = ctx.cwd
297
+ original_branch = ctx.git.get_current_branch(cwd)
298
+ if original_branch is None:
299
+ raise ValueError("Cannot create graphite branch from detached HEAD")
300
+ if ctx.git.has_staged_changes(repo_root):
301
+ user_output(
302
+ "Error: Staged changes detected. "
303
+ "Graphite cannot create a branch while staged changes are present.\n"
304
+ "`gt create --no-interactive` attempts to commit staged files but fails when "
305
+ "no commit message is provided.\n\n"
306
+ "Resolve the staged changes before running `erk create`:\n"
307
+ ' • Commit them: git commit -m "message"\n'
308
+ " • Unstage them: git reset\n"
309
+ " • Stash them: git stash\n"
310
+ " • Disable Graphite: erk config set use_graphite false",
311
+ )
312
+ raise SystemExit(1) from None
313
+ run_with_error_reporting(
314
+ ["gt", "create", "--no-interactive", branch],
315
+ cwd=cwd,
316
+ error_prefix=f"Failed to create Graphite branch '{branch}'",
317
+ troubleshooting=[
318
+ "Check if branch name is valid",
319
+ "Ensure Graphite is properly configured (gt repo init)",
320
+ f"Try creating the branch manually: gt create {branch}",
321
+ "Disable Graphite: erk config set use_graphite false",
322
+ ],
323
+ )
324
+ ctx.git.checkout_branch(cwd, original_branch)
325
+ ctx.git.add_worktree(repo_root, path, branch=branch, ref=None, create_branch=False)
326
+ else:
327
+ ctx.git.add_worktree(repo_root, path, branch=branch, ref=ref, create_branch=True)
328
+ else:
329
+ ctx.git.add_worktree(repo_root, path, branch=None, ref=ref, create_branch=False)
330
+
331
+
332
+ def make_env_content(
333
+ cfg: LoadedConfig,
334
+ *,
335
+ worktree_path: Path,
336
+ repo_root: Path,
337
+ name: str,
338
+ ) -> str:
339
+ """Render .env content using config templates.
340
+
341
+ Substitution variables:
342
+ - {worktree_path} - Path to the worktree directory
343
+ - {repo_root} - Path to the git repository root
344
+ - {name} - Worktree name
345
+ """
346
+
347
+ variables: dict[str, str] = {
348
+ "worktree_path": str(worktree_path),
349
+ "repo_root": str(repo_root),
350
+ "name": name,
351
+ }
352
+
353
+ lines: list[str] = []
354
+ for key, template in cfg.env.items():
355
+ value = template.format(**variables)
356
+ # Quote value to be safe; dotenv parsers commonly accept quotes.
357
+ lines.append(f"{key}={quote_env_value(value)}")
358
+
359
+ # Always include these basics for convenience
360
+ lines.append(f"WORKTREE_PATH={quote_env_value(str(worktree_path))}")
361
+ lines.append(f"REPO_ROOT={quote_env_value(str(repo_root))}")
362
+ lines.append(f"WORKTREE_NAME={quote_env_value(name)}")
363
+
364
+ return "\n".join(lines) + "\n"
365
+
366
+
367
+ def quote_env_value(value: str) -> str:
368
+ """Return a quoted value suitable for .env files."""
369
+ escaped = value.replace("\\", "\\\\").replace('"', '\\"')
370
+ return f'"{escaped}"'
371
+
372
+
373
+ def _create_json_response(
374
+ *,
375
+ worktree_name: str,
376
+ worktree_path: Path,
377
+ branch_name: str | None,
378
+ plan_file_path: Path | None,
379
+ status: str,
380
+ ) -> str:
381
+ """Generate JSON response for create command.
382
+
383
+ Args:
384
+ worktree_name: Name of the worktree
385
+ worktree_path: Path to the worktree directory
386
+ branch_name: Git branch name (may be None if not available)
387
+ plan_file_path: Path to plan file if exists, None otherwise
388
+ status: Status string ("created" or "exists")
389
+
390
+ Returns:
391
+ JSON string with worktree information
392
+ """
393
+ return json.dumps(
394
+ {
395
+ "worktree_name": worktree_name,
396
+ "worktree_path": str(worktree_path),
397
+ "branch_name": branch_name,
398
+ "plan_file": str(plan_file_path) if plan_file_path else None,
399
+ "status": status,
400
+ }
401
+ )
402
+
403
+
404
+ @click.command("create", cls=CommandWithHiddenOptions)
405
+ @click.argument("name", metavar="NAME", required=False)
406
+ @click.option(
407
+ "--branch",
408
+ "branch",
409
+ type=str,
410
+ help=("Branch name to create and check out in the worktree. Defaults to NAME if omitted."),
411
+ )
412
+ @click.option(
413
+ "--ref",
414
+ "ref",
415
+ type=str,
416
+ default=None,
417
+ help=("Git ref to base the worktree on (e.g. HEAD, origin/main). Defaults to HEAD if omitted."),
418
+ )
419
+ @click.option(
420
+ "--no-post",
421
+ is_flag=True,
422
+ help="Skip running post-create commands from config.toml.",
423
+ )
424
+ @click.option(
425
+ "--from-plan-file",
426
+ "from_plan_file",
427
+ type=click.Path(exists=True, dir_okay=False, path_type=Path),
428
+ help=(
429
+ "Path to a plan markdown file. Will derive worktree name from filename "
430
+ "and create .impl/ folder with plan.md in the worktree. "
431
+ "Worktree names are automatically suffixed with the current date (-YY-MM-DD) "
432
+ "and versioned if duplicates exist."
433
+ ),
434
+ )
435
+ @click.option(
436
+ "--keep-plan-file",
437
+ is_flag=True,
438
+ help="Copy the plan file instead of moving it (requires --from-plan-file).",
439
+ )
440
+ @click.option(
441
+ "--from-plan",
442
+ "from_plan",
443
+ type=str,
444
+ help=(
445
+ "GitHub issue number or URL with erk-plan label. Fetches issue content "
446
+ "and creates worktree with .impl/ folder and .impl/issue.json metadata. "
447
+ "Worktree names are automatically suffixed with the current date (-YY-MM-DD) "
448
+ "and versioned if duplicates exist."
449
+ ),
450
+ )
451
+ @click.option(
452
+ "--copy-plan",
453
+ is_flag=True,
454
+ default=False,
455
+ help=(
456
+ "Copy .impl directory from current worktree to new worktree. "
457
+ "Useful for multi-phase workflows where each phase builds on the previous plan. "
458
+ "Mutually exclusive with --from-plan."
459
+ ),
460
+ )
461
+ @click.option(
462
+ "--from-current-branch",
463
+ is_flag=True,
464
+ help=(
465
+ "Move the current branch to the new worktree, then switch current worktree to --ref "
466
+ "(defaults to main/master). NAME defaults to current branch name."
467
+ ),
468
+ )
469
+ @click.option(
470
+ "--from-branch",
471
+ "from_branch",
472
+ type=str,
473
+ default=None,
474
+ help=("Create worktree from an existing branch. NAME defaults to the branch name."),
475
+ )
476
+ @script_option
477
+ @click.option(
478
+ "--json",
479
+ "output_json",
480
+ is_flag=True,
481
+ help="Output JSON with worktree information instead of human-readable messages.",
482
+ )
483
+ @click.option(
484
+ "--stay",
485
+ is_flag=True,
486
+ help="Stay in current directory instead of switching to new worktree.",
487
+ )
488
+ @click.option(
489
+ "--skip-remote-check",
490
+ is_flag=True,
491
+ default=False,
492
+ help="Skip checking if branch exists on remote (for offline work)",
493
+ )
494
+ @click.pass_obj
495
+ def create_wt(
496
+ ctx: ErkContext,
497
+ name: str | None,
498
+ branch: str | None,
499
+ ref: str | None,
500
+ no_post: bool,
501
+ from_plan_file: Path | None,
502
+ keep_plan_file: bool,
503
+ from_plan: str | None,
504
+ copy_plan: bool,
505
+ from_current_branch: bool,
506
+ from_branch: str | None,
507
+ script: bool,
508
+ output_json: bool,
509
+ stay: bool,
510
+ skip_remote_check: bool,
511
+ ) -> None:
512
+ """Create a worktree and write a .env file.
513
+
514
+ Reads config.toml for env templates and post-create commands (if present).
515
+ If --from-plan-file is provided, derives name from the plan filename and creates
516
+ .impl/ folder in the worktree.
517
+ If --from-plan is provided, fetches the GitHub issue, validates the erk-plan label,
518
+ derives name from the issue title, and creates .impl/ folder with issue.json metadata.
519
+ If --from-current-branch is provided, moves the current branch to the new worktree.
520
+ If --from-branch is provided, creates a worktree from an existing branch.
521
+
522
+ By default, the command checks if a branch with the same name already exists on
523
+ the 'origin' remote. If a conflict is detected, the command fails with an error.
524
+ Use --skip-remote-check to bypass this validation for offline workflows.
525
+ """
526
+
527
+ # Validate mutually exclusive options
528
+ flags_set = sum(
529
+ [
530
+ from_current_branch,
531
+ from_branch is not None,
532
+ from_plan_file is not None,
533
+ from_plan is not None,
534
+ ]
535
+ )
536
+ Ensure.invariant(
537
+ flags_set <= 1,
538
+ "Cannot use multiple of: --from-current-branch, --from-branch, "
539
+ "--from-plan-file, --from-plan",
540
+ )
541
+
542
+ # Validate --json and --script are mutually exclusive
543
+ Ensure.invariant(not (output_json and script), "Cannot use both --json and --script")
544
+
545
+ # Validate --keep-plan-file requires --from-plan-file
546
+ Ensure.invariant(
547
+ not keep_plan_file or from_plan_file is not None,
548
+ "--keep-plan-file requires --from-plan-file",
549
+ )
550
+
551
+ # Validate --copy-plan and --from-plan-file/--from-plan are mutually exclusive
552
+ Ensure.invariant(
553
+ not (copy_plan and (from_plan_file is not None or from_plan is not None)),
554
+ "--copy-plan and --from-plan-file/--from-plan are mutually exclusive. "
555
+ "Use --copy-plan to copy from current worktree OR --from-plan-file <file> to use a plan "
556
+ "file OR --from-plan <number> to use a GitHub issue.",
557
+ )
558
+
559
+ # Note: --copy-plan validation is deferred until after repo discovery
560
+ # to ensure we check for .impl at the worktree root, not ctx.cwd
561
+
562
+ # Initialize variables used in conditional blocks (for type checking)
563
+ issue_number_parsed: int | None = None
564
+ plan: Plan | None = None
565
+
566
+ # Handle --from-current-branch flag
567
+ if from_current_branch:
568
+ # Get the current branch
569
+ current_branch = Ensure.not_none(
570
+ ctx.git.get_current_branch(ctx.cwd), "HEAD is detached (not on a branch)"
571
+ )
572
+
573
+ # Set branch to current branch and derive name if not provided
574
+ Ensure.invariant(
575
+ not branch, "Cannot specify --branch with --from-current-branch (uses current branch)."
576
+ )
577
+ branch = current_branch
578
+
579
+ if not name:
580
+ name = sanitize_worktree_name(current_branch)
581
+
582
+ # Handle --from-branch flag
583
+ elif from_branch:
584
+ Ensure.invariant(
585
+ not branch, "Cannot specify --branch with --from-branch (uses the specified branch)."
586
+ )
587
+ branch = from_branch
588
+
589
+ if not name:
590
+ name = sanitize_worktree_name(from_branch)
591
+
592
+ # Handle --from-plan-file flag
593
+ elif from_plan_file:
594
+ Ensure.invariant(
595
+ not name, "Cannot specify both NAME and --from-plan-file. Use one or the other."
596
+ )
597
+ # Derive name from plan filename (strip extension)
598
+ plan_stem = from_plan_file.stem # filename without extension
599
+ cleaned_stem = strip_plan_from_filename(plan_stem)
600
+ base_name = sanitize_worktree_name(cleaned_stem)
601
+ # Note: Apply ensure_unique_worktree_name() and truncation after getting erks_dir
602
+ name = base_name
603
+
604
+ # Handle --from-plan flag (GitHub issue)
605
+ elif from_plan:
606
+ Ensure.invariant(
607
+ not name, "Cannot specify both NAME and --from-plan. Use one or the other."
608
+ )
609
+ # Parse issue number from URL or plain number - raises click.ClickException if invalid
610
+ issue_number_parsed = parse_issue_identifier(from_plan)
611
+ # Note: name will be derived from issue title after fetching
612
+ # Defer fetch until after repo discovery below
613
+ name = None # Will be set after fetching issue
614
+
615
+ # Regular create (no special flags)
616
+ else:
617
+ # Allow --branch alone to derive name from branch
618
+ if not name and branch:
619
+ name = sanitize_worktree_name(branch)
620
+ elif not name:
621
+ user_output(
622
+ "Must provide NAME or --from-plan-file or --from-branch "
623
+ "or --from-current-branch or --from-plan or --branch option."
624
+ )
625
+ raise SystemExit(1) from None
626
+
627
+ # Track if name came from plan file (will need unique naming with date suffix)
628
+ is_plan_derived = from_plan_file is not None
629
+
630
+ # Discover repo context (needed for all paths)
631
+ repo = discover_repo_context(ctx, ctx.cwd)
632
+ ensure_erk_metadata_dir(repo)
633
+
634
+ # Validate .impl directory exists if --copy-plan is used (now that we have repo.root)
635
+ # .impl always lives at worktree/repo root
636
+ if copy_plan:
637
+ impl_source_check = repo.root / ".impl"
638
+ Ensure.path_is_dir(
639
+ ctx,
640
+ impl_source_check,
641
+ f"No .impl directory found at {repo.root}. "
642
+ "Use 'erk create --from-plan-file <file>' to create a worktree with a plan.",
643
+ )
644
+
645
+ # Track linked branch name and setup for issue-based worktrees
646
+ linked_branch_name: str | None = None
647
+ setup: IssueBranchSetup | None = None
648
+
649
+ # Handle issue fetching after repo discovery
650
+ if from_plan:
651
+ # Type narrowing: issue_number_parsed must be set if from_plan is True
652
+ assert issue_number_parsed is not None, (
653
+ "issue_number_parsed must be set when from_plan is True"
654
+ )
655
+
656
+ # Fetch plan using plan_store (composed from issues layer)
657
+ try:
658
+ plan = ctx.plan_store.get_plan(repo.root, str(issue_number_parsed))
659
+ except RuntimeError as e:
660
+ user_output(
661
+ click.style("Error: ", fg="red")
662
+ + f"Failed to fetch issue #{issue_number_parsed}\n"
663
+ + f"Details: {e}\n\n"
664
+ + "Troubleshooting:\n"
665
+ + " • Verify issue number is correct\n"
666
+ + " • Check repository access: gh auth status\n"
667
+ + f" • Try viewing manually: gh issue view {issue_number_parsed}"
668
+ )
669
+ raise SystemExit(1) from e
670
+
671
+ # Prepare and validate using shared helper (returns union type)
672
+ trunk_branch = ctx.git.detect_trunk_branch(repo.root)
673
+ result = prepare_plan_for_worktree(plan, ctx.time.now())
674
+
675
+ if isinstance(result, IssueValidationFailed):
676
+ user_output(click.style("Error: ", fg="red") + result.message)
677
+ raise SystemExit(1) from None
678
+
679
+ setup = result
680
+ for warning in setup.warnings:
681
+ user_output(click.style("Warning: ", fg="yellow") + warning)
682
+
683
+ # Create branch directly via git
684
+ ctx.git.create_branch(repo.root, setup.branch_name, trunk_branch)
685
+ user_output(f"Created branch: {setup.branch_name}")
686
+
687
+ # Track linked branch name for add_worktree call
688
+ linked_branch_name = setup.branch_name
689
+
690
+ # Use the branch name for the worktree name
691
+ name = setup.worktree_name
692
+
693
+ # At this point, name should always be set
694
+ assert name is not None, "name must be set by now"
695
+
696
+ # Sanitize the name to ensure consistency (truncate to 31 chars, normalize)
697
+ # This applies to user-provided names as well as derived names
698
+ # Note: sanitize_worktree_name is idempotent - preserves timestamp suffixes
699
+ if not is_plan_derived:
700
+ name = sanitize_worktree_name(name)
701
+
702
+ # Validate that name is not a reserved word
703
+ Ensure.invariant(
704
+ name.lower() != "root", '"root" is a reserved name and cannot be used for a worktree.'
705
+ )
706
+
707
+ cfg = ctx.local_config
708
+ trunk_branch = ctx.git.detect_trunk_branch(repo.root)
709
+
710
+ # Validate that name is not trunk branch (should use root worktree)
711
+ if name == trunk_branch:
712
+ user_output(
713
+ f'Error: "{name}" cannot be used as a worktree name.\n'
714
+ f"To switch to the {name} branch in the root repository, use:\n"
715
+ f" erk br co root",
716
+ )
717
+ raise SystemExit(1) from None
718
+
719
+ # Apply date prefix and uniqueness for plan-derived names
720
+ if is_plan_derived:
721
+ name = ensure_unique_worktree_name(name, repo.worktrees_dir, ctx.git)
722
+
723
+ wt_path = worktree_path_for(repo.worktrees_dir, name)
724
+
725
+ if ctx.git.path_exists(wt_path):
726
+ if output_json:
727
+ # For JSON output, emit a status: "exists" response with available info
728
+ existing_branch = ctx.git.get_current_branch(wt_path)
729
+ plan_path = get_impl_path(wt_path, git_ops=ctx.git)
730
+ json_response = _create_json_response(
731
+ worktree_name=name,
732
+ worktree_path=wt_path,
733
+ branch_name=existing_branch,
734
+ plan_file_path=plan_path,
735
+ status="exists",
736
+ )
737
+ user_output(json_response)
738
+ raise SystemExit(1) from None
739
+ else:
740
+ user_output(f"Worktree path already exists: {wt_path}")
741
+ raise SystemExit(1) from None
742
+
743
+ # Handle from-current-branch logic: switch current worktree first
744
+ to_branch = None
745
+ if from_current_branch:
746
+ current_branch = Ensure.not_none(
747
+ ctx.git.get_current_branch(ctx.cwd), "Unable to determine current branch"
748
+ )
749
+
750
+ # Determine preferred branch to checkout (prioritize Graphite parent)
751
+ parent_branch = (
752
+ ctx.graphite.get_parent_branch(ctx.git, repo.root, current_branch)
753
+ if current_branch
754
+ else None
755
+ )
756
+
757
+ if parent_branch:
758
+ # Prefer Graphite parent branch
759
+ to_branch = parent_branch
760
+ elif ref:
761
+ # Use ref if provided
762
+ to_branch = ref
763
+ else:
764
+ # Fall back to default branch (main/master)
765
+ to_branch = ctx.git.detect_trunk_branch(repo.root)
766
+
767
+ # Check for edge case: can't move main to worktree then switch to main
768
+ Ensure.invariant(
769
+ current_branch != to_branch,
770
+ f"Cannot use --from-current-branch when on '{current_branch}'.\n"
771
+ f"The current branch cannot be moved to a worktree and then checked out again.\n\n"
772
+ f"Alternatives:\n"
773
+ f" • Create a new branch: erk create {name}\n"
774
+ f" • Switch to a feature branch first, then use --from-current-branch\n"
775
+ f" • Use --from-branch to create from a different existing branch",
776
+ )
777
+
778
+ # Check if target branch is available (not checked out in another worktree)
779
+ checkout_path = ctx.git.is_branch_checked_out(repo.root, to_branch)
780
+ if checkout_path is not None:
781
+ # Target branch is in use, fall back to detached HEAD
782
+ ctx.git.checkout_detached(ctx.cwd, current_branch)
783
+ else:
784
+ # Target branch is available, checkout normally
785
+ ctx.git.checkout_branch(ctx.cwd, to_branch)
786
+
787
+ # Create worktree with existing branch
788
+ add_worktree(
789
+ ctx,
790
+ repo.root,
791
+ wt_path,
792
+ branch=branch,
793
+ ref=None,
794
+ use_existing_branch=True,
795
+ use_graphite=False,
796
+ skip_remote_check=skip_remote_check,
797
+ )
798
+ elif from_branch:
799
+ # Validate that we're not trying to create worktree for trunk branch
800
+ if branch == trunk_branch:
801
+ user_output(
802
+ f'Error: Cannot create worktree for trunk branch "{trunk_branch}".\n'
803
+ f"The trunk branch should be checked out in the root worktree.\n"
804
+ f"To switch to {trunk_branch}, use:\n"
805
+ f" erk br co root"
806
+ )
807
+ raise SystemExit(1) from None
808
+
809
+ # Create worktree with existing branch
810
+ add_worktree(
811
+ ctx,
812
+ repo.root,
813
+ wt_path,
814
+ branch=branch,
815
+ ref=None,
816
+ use_existing_branch=True,
817
+ use_graphite=False,
818
+ skip_remote_check=skip_remote_check,
819
+ )
820
+ elif linked_branch_name:
821
+ # Issue-based worktree: use the branch created for this issue
822
+ use_graphite = ctx.global_config.use_graphite if ctx.global_config else False
823
+ add_worktree(
824
+ ctx,
825
+ repo.root,
826
+ wt_path,
827
+ branch=linked_branch_name,
828
+ ref=trunk_branch, # Needed for Graphite tracking
829
+ use_existing_branch=True,
830
+ use_graphite=use_graphite, # Respect global config
831
+ skip_remote_check=skip_remote_check,
832
+ )
833
+ else:
834
+ # Create worktree via git. If no branch provided, derive a sensible default.
835
+ if branch is None:
836
+ branch = default_branch_for_worktree(name)
837
+
838
+ # Get graphite setting from global config
839
+ use_graphite = ctx.global_config.use_graphite if ctx.global_config else False
840
+ add_worktree(
841
+ ctx,
842
+ repo.root,
843
+ wt_path,
844
+ branch=branch,
845
+ ref=ref,
846
+ use_graphite=use_graphite,
847
+ use_existing_branch=False,
848
+ skip_remote_check=skip_remote_check,
849
+ )
850
+
851
+ # Write .env based on config
852
+ env_content = make_env_content(
853
+ cfg,
854
+ worktree_path=wt_path,
855
+ repo_root=repo.root,
856
+ name=name,
857
+ )
858
+ (wt_path / ".env").write_text(env_content, encoding="utf-8")
859
+
860
+ # Create impl folder if plan file provided
861
+ # Track impl folder destination: set to .impl/ path only if
862
+ # --from-plan-file or --from-plan was provided
863
+ impl_folder_destination: Path | None = None
864
+ if from_plan_file:
865
+ # Read plan content from source file
866
+ plan_content = from_plan_file.read_text(encoding="utf-8")
867
+
868
+ # Create .impl/ folder in new worktree
869
+ # Use overwrite=False since fresh worktree should not have .impl/
870
+ impl_folder_destination = create_impl_folder(wt_path, plan_content, overwrite=False)
871
+
872
+ # Handle --keep-plan-file flag
873
+ if keep_plan_file:
874
+ if not script and not output_json:
875
+ user_output(f"Copied plan to {impl_folder_destination}")
876
+ else:
877
+ from_plan_file.unlink() # Remove source file
878
+ if not script and not output_json:
879
+ user_output(f"Moved plan to {impl_folder_destination}")
880
+
881
+ # Create impl folder if GitHub issue provided
882
+ if from_plan:
883
+ # Type narrowing: setup must be set if from_plan is True
884
+ assert setup is not None, "setup must be set when from_plan is True"
885
+
886
+ # Create .impl/ folder in new worktree
887
+ # Use overwrite=False since fresh worktree should not have .impl/
888
+ impl_folder_destination = create_impl_folder(wt_path, setup.plan_content, overwrite=False)
889
+
890
+ # Create .impl/issue.json metadata using shared helper
891
+ save_issue_reference(
892
+ wt_path / ".impl",
893
+ setup.issue_number,
894
+ setup.issue_url,
895
+ setup.issue_title,
896
+ )
897
+
898
+ if not script and not output_json:
899
+ user_output(f"Created worktree from issue #{setup.issue_number}: {setup.issue_title}")
900
+
901
+ # Copy .impl directory if --copy-plan flag is set
902
+ if copy_plan:
903
+ import shutil
904
+
905
+ # .impl always lives at worktree/repo root
906
+ impl_source = repo.root / ".impl"
907
+ impl_dest = wt_path / ".impl"
908
+
909
+ # Copy entire directory
910
+ shutil.copytree(impl_source, impl_dest)
911
+
912
+ # Set impl_folder_destination for JSON response
913
+ impl_folder_destination = impl_dest
914
+
915
+ if not script and not output_json:
916
+ user_output(
917
+ " "
918
+ + click.style("✓", fg="green")
919
+ + f" Copied .impl from {click.style(str(repo.root), fg='yellow')}"
920
+ )
921
+
922
+ # Post-create commands (suppress output if JSON mode)
923
+ if not no_post and cfg.post_create_commands:
924
+ if not output_json:
925
+ user_output("Running post-create commands...")
926
+ run_commands_in_worktree(
927
+ ctx=ctx,
928
+ commands=cfg.post_create_commands,
929
+ worktree_path=wt_path,
930
+ shell=cfg.post_create_shell,
931
+ )
932
+
933
+ if script and not stay:
934
+ script_content = render_navigation_script(
935
+ wt_path,
936
+ repo.root,
937
+ comment="cd to new worktree",
938
+ success_message="✓ Went to new worktree.",
939
+ )
940
+ result = ctx.script_writer.write_activation_script(
941
+ script_content,
942
+ command_name="create",
943
+ comment=f"cd to {name}",
944
+ )
945
+ result.output_for_shell_integration()
946
+ elif output_json:
947
+ # Output JSON with worktree information
948
+ json_response = _create_json_response(
949
+ worktree_name=name,
950
+ worktree_path=wt_path,
951
+ branch_name=branch,
952
+ plan_file_path=impl_folder_destination,
953
+ status="created",
954
+ )
955
+ user_output(json_response)
956
+ elif stay:
957
+ # User explicitly opted out of navigation
958
+ user_output(f"Created worktree at {wt_path} checked out at branch '{branch}'")
959
+ else:
960
+ # Shell integration not detected - provide setup instructions
961
+ user_output(f"Created worktree at {wt_path} checked out at branch '{branch}'")
962
+ user_output("\nShell integration not detected. Run 'erk init --shell' to set up.")
963
+ user_output("Or use: source <(erk wt create --from-current-branch --script)")
964
+
965
+
966
+ def run_commands_in_worktree(
967
+ *,
968
+ ctx: ErkContext,
969
+ commands: Iterable[str],
970
+ worktree_path: Path,
971
+ shell: str | None,
972
+ ) -> None:
973
+ """Run commands serially in the worktree directory.
974
+
975
+ Each command is executed in its own subprocess. If `shell` is provided, commands
976
+ run through that shell (e.g., "bash -lc <cmd>"). Otherwise, commands are tokenized
977
+ via `shlex.split` and run directly.
978
+
979
+ Args:
980
+ ctx: Erk context
981
+ commands: Iterable of commands to run
982
+ worktree_path: Path to worktree where commands should run
983
+ shell: Optional shell to use for command execution
984
+ """
985
+
986
+ for cmd in commands:
987
+ # Output per-command diagnostic
988
+ ctx.feedback.info(f"Running: {cmd}")
989
+ cmd_list = [shell, "-lc", cmd] if shell else shlex.split(cmd)
990
+ run_with_error_reporting(
991
+ cmd_list,
992
+ cwd=worktree_path,
993
+ error_prefix="Post-create command failed",
994
+ troubleshooting=[
995
+ "The worktree was created successfully, but a post-create command failed",
996
+ "You can still use the worktree or re-run the command manually",
997
+ ],
998
+ )