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,35 @@
1
+ """Current command implementation - displays current erk name."""
2
+
3
+ import click
4
+
5
+ from erk.cli.core import discover_repo_context
6
+ from erk.cli.ensure import Ensure
7
+ from erk.core.context import ErkContext
8
+ from erk.core.repo_discovery import RepoContext
9
+ from erk.core.worktree_utils import find_current_worktree
10
+ from erk_shared.output.output import user_output
11
+
12
+
13
+ @click.command("current", hidden=True)
14
+ @click.pass_obj
15
+ def current_wt(ctx: ErkContext) -> None:
16
+ """Show current worktree name (hidden command for automation)."""
17
+ # Use ctx.repo if it's a valid RepoContext, otherwise discover
18
+ if isinstance(ctx.repo, RepoContext):
19
+ repo = ctx.repo
20
+ else:
21
+ # Discover repository context (handles None and NoRepoSentinel)
22
+ # If not in a git repo, FileNotFoundError will bubble up
23
+ repo = discover_repo_context(ctx, ctx.cwd)
24
+
25
+ current_dir = ctx.cwd
26
+ worktrees = ctx.git.list_worktrees(repo.root)
27
+ wt_info = Ensure.not_none(
28
+ find_current_worktree(worktrees, current_dir), "Not in an erk worktree"
29
+ )
30
+
31
+ # Use WorktreeInfo.is_root which is set by git when listing worktrees
32
+ if wt_info.is_root:
33
+ user_output("root")
34
+ else:
35
+ user_output(wt_info.path.name)
@@ -0,0 +1,573 @@
1
+ import shutil
2
+ import subprocess
3
+ from pathlib import Path
4
+
5
+ import click
6
+
7
+ from erk.cli.commands.branch.unassign_cmd import execute_unassign
8
+ from erk.cli.commands.completions import complete_worktree_names
9
+ from erk.cli.commands.navigation_helpers import (
10
+ check_pending_extraction_marker,
11
+ find_assignment_by_worktree_path,
12
+ )
13
+ from erk.cli.core import (
14
+ discover_repo_context,
15
+ validate_worktree_name_for_deletion,
16
+ worktree_path_for,
17
+ )
18
+ from erk.cli.ensure import Ensure
19
+ from erk.core.context import ErkContext, create_context, regenerate_context
20
+ from erk.core.repo_discovery import RepoContext, ensure_erk_metadata_dir
21
+ from erk.core.worktree_pool import load_pool_state
22
+ from erk.core.worktree_utils import (
23
+ find_worktree_containing_path,
24
+ get_worktree_branch,
25
+ )
26
+ from erk_shared.gateway.graphite.abc import Graphite
27
+ from erk_shared.git.abc import Git
28
+ from erk_shared.github.metadata.plan_header import extract_plan_header_worktree_name
29
+ from erk_shared.github.types import PRNotFound
30
+ from erk_shared.output.output import user_confirm, user_output
31
+ from erk_shared.plan_store.types import PlanQuery, PlanState
32
+
33
+
34
+ def _get_pr_info_for_branch(
35
+ ctx: ErkContext, repo_root: Path, branch: str
36
+ ) -> tuple[int, str] | None:
37
+ """Get PR info for display during planning phase.
38
+
39
+ Args:
40
+ ctx: Erk context with GitHub operations
41
+ repo_root: Repository root directory
42
+ branch: Branch name to find PR for
43
+
44
+ Returns:
45
+ Tuple of (PR number, state) if found, None otherwise.
46
+ State is one of: "OPEN", "CLOSED", "MERGED"
47
+ """
48
+ pr = ctx.github.get_pr_for_branch(repo_root, branch)
49
+ if isinstance(pr, PRNotFound):
50
+ return None
51
+ return (pr.number, pr.state)
52
+
53
+
54
+ def _get_plan_info_for_worktree(
55
+ ctx: ErkContext, repo_root: Path, worktree_name: str
56
+ ) -> tuple[int, PlanState] | None:
57
+ """Find a plan associated with a worktree name (any state).
58
+
59
+ Args:
60
+ ctx: Erk context with plan store
61
+ repo_root: Repository root directory
62
+ worktree_name: Name of the worktree to find a plan for
63
+
64
+ Returns:
65
+ Tuple of (plan number, state) if found, None otherwise.
66
+ """
67
+ # Search ALL states (open and closed) to find the plan
68
+ query = PlanQuery(labels=["erk-plan"])
69
+ plans = ctx.plan_store.list_plans(repo_root, query)
70
+
71
+ for plan in plans:
72
+ plan_worktree_name = extract_plan_header_worktree_name(plan.body)
73
+ if plan_worktree_name == worktree_name:
74
+ return (int(plan.plan_identifier), plan.state)
75
+
76
+ return None
77
+
78
+
79
+ def _close_pr_for_branch(
80
+ ctx: ErkContext,
81
+ repo_root: Path,
82
+ branch: str,
83
+ ) -> int | None:
84
+ """Close the PR associated with a branch if it exists and is open.
85
+
86
+ Args:
87
+ ctx: Erk context with GitHub operations
88
+ repo_root: Repository root directory
89
+ branch: Branch name to find PR for
90
+
91
+ Returns:
92
+ PR number if closed, None otherwise
93
+ """
94
+ pr = ctx.github.get_pr_for_branch(repo_root, branch)
95
+
96
+ if isinstance(pr, PRNotFound):
97
+ return None
98
+
99
+ if pr.state == "OPEN":
100
+ ctx.github.close_pr(repo_root, pr.number)
101
+ user_output(
102
+ click.style("ℹ️ ", fg="blue", bold=True)
103
+ + f"Closed PR #{pr.number}: {click.style(pr.title, fg='cyan')}"
104
+ )
105
+ return pr.number
106
+
107
+ # PR exists but is already closed/merged
108
+ state_color = "green" if pr.state == "MERGED" else "yellow"
109
+ user_output(
110
+ click.style("ℹ️ ", fg="blue", bold=True)
111
+ + f"PR #{pr.number} already {click.style(pr.state.lower(), fg=state_color)}"
112
+ )
113
+ return None
114
+
115
+
116
+ def _close_plan_for_worktree(
117
+ ctx: ErkContext,
118
+ repo_root: Path,
119
+ worktree_name: str,
120
+ ) -> int | None:
121
+ """Close the plan associated with a worktree name if it exists and is open.
122
+
123
+ Args:
124
+ ctx: Erk context with plan store
125
+ repo_root: Repository root directory
126
+ worktree_name: Name of the worktree to find a plan for
127
+
128
+ Returns:
129
+ Plan issue number if closed, None otherwise
130
+ """
131
+ plan_info = _get_plan_info_for_worktree(ctx, repo_root, worktree_name)
132
+
133
+ if plan_info is None:
134
+ user_output(click.style("ℹ️ ", fg="blue", bold=True) + "No associated plan found")
135
+ return None
136
+
137
+ plan_number, state = plan_info
138
+ if state == PlanState.CLOSED:
139
+ user_output(
140
+ click.style("ℹ️ ", fg="blue", bold=True) + f"Plan #{plan_number} already closed"
141
+ )
142
+ return None
143
+
144
+ ctx.plan_store.close_plan(repo_root, str(plan_number))
145
+ user_output(click.style("ℹ️ ", fg="blue", bold=True) + f"Closed plan #{plan_number}")
146
+ return plan_number
147
+
148
+
149
+ def _try_git_worktree_delete(git_ops: Git, repo_root: Path, wt_path: Path) -> bool:
150
+ """Attempt git worktree remove, returning success status.
151
+
152
+ This function violates LBYL norms because there's no reliable way to
153
+ check a priori if git worktree remove will succeed. The worktree might be:
154
+ - Already removed from git metadata
155
+ - In a partially corrupted state
156
+ - Referenced by stale lock files
157
+
158
+ Git's own error handling is unreliable for these edge cases, so we use
159
+ try/except as an error boundary and rely on manual cleanup + prune.
160
+
161
+ Returns:
162
+ True if git removal succeeded, False otherwise
163
+ """
164
+ try:
165
+ git_ops.remove_worktree(repo_root, wt_path, force=True)
166
+ return True
167
+ except Exception:
168
+ # Git removal failed - manual cleanup will handle it
169
+ return False
170
+
171
+
172
+ def _prune_worktrees_safe(git_ops: Git, repo_root: Path) -> None:
173
+ """Prune worktree metadata, ignoring errors if nothing to prune.
174
+
175
+ This function violates LBYL norms because git worktree prune can fail
176
+ for various reasons (no stale worktrees, permission issues, etc.) that
177
+ are not easily detectable beforehand. Since pruning is a cleanup operation
178
+ and failure doesn't affect the primary operation, we allow silent failure.
179
+ """
180
+ try:
181
+ git_ops.prune_worktrees(repo_root)
182
+ except Exception:
183
+ # Prune might fail if there's nothing to prune or other non-critical issues
184
+ pass
185
+
186
+
187
+ def _escape_worktree_if_inside(
188
+ ctx: ErkContext, repo_root: Path, wt_path: Path, dry_run: bool
189
+ ) -> ErkContext:
190
+ """Change to repository root if currently inside the worktree being deleted.
191
+
192
+ Prevents the shell from being left in a deleted directory. Returns a new
193
+ context if directory was changed (context is immutable), otherwise returns
194
+ the original context.
195
+ """
196
+ if not ctx.git.path_exists(ctx.cwd):
197
+ return ctx
198
+
199
+ current_dir = ctx.cwd.resolve()
200
+ worktrees = ctx.git.list_worktrees(repo_root)
201
+ current_worktree_path = find_worktree_containing_path(worktrees, current_dir)
202
+
203
+ if current_worktree_path is None:
204
+ return ctx
205
+
206
+ if current_worktree_path.resolve() != wt_path.resolve():
207
+ return ctx
208
+
209
+ # Change to repository root before deletion
210
+ user_output(
211
+ click.style("ℹ️ ", fg="blue", bold=True)
212
+ + f"Changing directory to repository root: {click.style(str(repo_root), fg='cyan')}"
213
+ )
214
+
215
+ # Change directory using safe_chdir which handles both real and sentinel paths
216
+ if not dry_run and ctx.git.safe_chdir(repo_root):
217
+ # Regenerate context with new cwd (context is immutable)
218
+ return regenerate_context(ctx)
219
+
220
+ return ctx
221
+
222
+
223
+ def _collect_branch_to_delete(
224
+ ctx: ErkContext, repo_root: Path, wt_path: Path, name: str
225
+ ) -> str | None:
226
+ """Get the branch checked out on the worktree, if any.
227
+
228
+ Returns the branch name, or None if in detached HEAD state.
229
+ """
230
+ worktrees = ctx.git.list_worktrees(repo_root)
231
+ worktree_branch = get_worktree_branch(worktrees, wt_path)
232
+
233
+ if worktree_branch is None:
234
+ user_output(
235
+ f"Warning: Worktree {name} is in detached HEAD state. Cannot delete branch.",
236
+ )
237
+ return None
238
+
239
+ return worktree_branch
240
+
241
+
242
+ def _display_planned_operations(
243
+ wt_path: Path,
244
+ branch_to_delete: str | None,
245
+ close_all: bool,
246
+ pr_info: tuple[int, str] | None,
247
+ plan_info: tuple[int, PlanState] | None,
248
+ ) -> None:
249
+ """Display the operations that will be performed.
250
+
251
+ Args:
252
+ wt_path: Path to the worktree being deleted
253
+ branch_to_delete: Branch name to delete, or None if detached HEAD
254
+ close_all: Whether -a/--all flag was passed
255
+ pr_info: Tuple of (PR number, state) if found, None otherwise
256
+ plan_info: Tuple of (plan number, state) if found, None otherwise
257
+ """
258
+ user_output(click.style("📋 Planning to perform the following operations:", bold=True))
259
+ worktree_text = click.style(str(wt_path), fg="cyan")
260
+ step = 1
261
+ user_output(f" {step}. 🗑️ Delete worktree: {worktree_text}")
262
+
263
+ if close_all and branch_to_delete:
264
+ step += 1
265
+ pr_text = _format_pr_plan_text(pr_info, "PR")
266
+ user_output(f" {step}. 🔒 {pr_text}")
267
+ step += 1
268
+ plan_text = _format_plan_text(plan_info)
269
+ user_output(f" {step}. 📝 {plan_text}")
270
+
271
+ if branch_to_delete:
272
+ step += 1
273
+ branch_text = click.style(branch_to_delete, fg="yellow")
274
+ user_output(f" {step}. 🌳 Delete branch: {branch_text}")
275
+
276
+
277
+ def _format_pr_plan_text(pr_info: tuple[int, str] | None, item_type: str) -> str:
278
+ """Format PR info for display in planning phase."""
279
+ if pr_info is None:
280
+ return f"Close associated {item_type} (if any)"
281
+
282
+ number, state = pr_info
283
+ if state == "OPEN":
284
+ return f"Close {item_type} #{number} (currently open)"
285
+ elif state == "MERGED":
286
+ state_text = click.style("merged", fg="green")
287
+ return f"{item_type} #{number} already {state_text}"
288
+ else:
289
+ state_text = click.style("closed", fg="yellow")
290
+ return f"{item_type} #{number} already {state_text}"
291
+
292
+
293
+ def _format_plan_text(plan_info: tuple[int, PlanState] | None) -> str:
294
+ """Format plan info for display in planning phase."""
295
+ if plan_info is None:
296
+ return "Close associated plan (if any)"
297
+
298
+ number, state = plan_info
299
+ if state == PlanState.OPEN:
300
+ return f"Close plan #{number} (currently open)"
301
+ else:
302
+ state_text = click.style("closed", fg="yellow")
303
+ return f"Plan #{number} already {state_text}"
304
+
305
+
306
+ def _confirm_operations(force: bool, dry_run: bool) -> bool:
307
+ """Prompt for confirmation unless force or dry-run mode.
308
+
309
+ Returns True if operations should proceed, False if aborted.
310
+ """
311
+ if force or dry_run:
312
+ return True
313
+
314
+ user_output()
315
+ if not user_confirm("Proceed with these operations?", default=True):
316
+ user_output(click.style("⭕ Aborted.", fg="red", bold=True))
317
+ return False
318
+
319
+ return True
320
+
321
+
322
+ def _delete_worktree_directory(ctx: ErkContext, repo: RepoContext, wt_path: Path) -> bool:
323
+ """Delete the worktree directory from filesystem (slot-aware).
324
+
325
+ If worktree is a pool slot: unassigns slot (keeps directory for reuse).
326
+ If not a pool slot: removes worktree directory.
327
+
328
+ First attempts git worktree remove, then manually deletes if still present.
329
+ This function encapsulates the legitimate error boundary for shutil.rmtree
330
+ because in pure test mode, the path may be a sentinel that doesn't exist
331
+ on the real filesystem.
332
+
333
+ Returns:
334
+ True if this was a slot worktree (slot was unassigned), False otherwise.
335
+ """
336
+ # Check if this is a slot worktree
337
+ state = load_pool_state(repo.pool_json_path)
338
+ assignment = None
339
+ if state is not None:
340
+ assignment = find_assignment_by_worktree_path(state, wt_path)
341
+
342
+ if assignment is not None:
343
+ # Slot worktree: unassign instead of delete
344
+ # state is guaranteed to be non-None since assignment was found in it
345
+ assert state is not None
346
+ execute_unassign(ctx, repo, state, assignment)
347
+ user_output(
348
+ click.style("✓", fg="green")
349
+ + f" Unassigned slot {click.style(assignment.slot_name, fg='cyan')}"
350
+ )
351
+ return True
352
+
353
+ # Non-slot worktree: delete normally
354
+ # Try to delete via git first - this updates git's metadata when possible
355
+ _try_git_worktree_delete(ctx.git, repo.root, wt_path)
356
+
357
+ # Always manually delete directory if it still exists
358
+ if not ctx.git.path_exists(wt_path):
359
+ return False
360
+
361
+ if ctx.dry_run:
362
+ user_output(f"[DRY RUN] Would delete directory: {wt_path}")
363
+ return False
364
+
365
+ # Only call shutil.rmtree() if we're on a real filesystem.
366
+ # In pure test mode, we skip the actual deletion since it's a sentinel path.
367
+ # This violates LBYL because there's no reliable way to distinguish sentinel
368
+ # paths from real paths that have been deleted between the path_exists check
369
+ # and the rmtree call (race condition).
370
+ try:
371
+ shutil.rmtree(wt_path)
372
+ except OSError:
373
+ # Path doesn't exist on real filesystem (sentinel path), skip deletion
374
+ pass
375
+
376
+ # Prune worktree metadata to clean up any stale references
377
+ _prune_worktrees_safe(ctx.git, repo.root)
378
+ return False
379
+
380
+
381
+ def _delete_branch_at_error_boundary(
382
+ ctx: ErkContext,
383
+ repo_root: Path,
384
+ branch: str,
385
+ force: bool,
386
+ dry_run: bool,
387
+ graphite: Graphite,
388
+ ) -> None:
389
+ """Delete a branch after its worktree has been removed.
390
+
391
+ This function encapsulates a legitimate error boundary because:
392
+ 1. `gt delete` prompts for user confirmation, which can be declined (exit 1)
393
+ 2. `git branch -d` may fail if branch is not fully merged
394
+ 3. There's no LBYL way to predict user's response to interactive prompt
395
+ 4. This is a CLI error boundary - appropriate place per AGENTS.md
396
+
397
+ The exception handling distinguishes between user-declined (expected) and
398
+ actual errors (propagated as SystemExit).
399
+
400
+ Note: run_subprocess_with_context catches CalledProcessError and re-raises
401
+ as RuntimeError with the original exception in __cause__.
402
+ """
403
+ use_graphite = ctx.global_config.use_graphite if ctx.global_config else False
404
+
405
+ # Determine if branch is tracked by Graphite (LBYL check using gt branch info)
406
+ branch_is_tracked = False
407
+ if use_graphite:
408
+ branch_is_tracked = graphite.is_branch_tracked(repo_root, branch)
409
+
410
+ try:
411
+ if branch_is_tracked:
412
+ ctx.git.delete_branch_with_graphite(repo_root, branch, force=force)
413
+ else:
414
+ ctx.git.delete_branch(repo_root, branch, force=force)
415
+ if not dry_run:
416
+ branch_text = click.style(branch, fg="green")
417
+ user_output(f"✅ Deleted branch: {branch_text}")
418
+ except RuntimeError as e:
419
+ _handle_branch_deletion_error(e, branch, force)
420
+
421
+
422
+ def _handle_branch_deletion_error(e: RuntimeError, branch: str, force: bool) -> None:
423
+ """Handle errors from branch deletion commands.
424
+
425
+ This function encapsulates the error boundary logic for branch deletion.
426
+ Exit code 1 with --force off typically means user declined the confirmation
427
+ prompt, which is expected behavior. Other errors are propagated as SystemExit.
428
+
429
+ Args:
430
+ e: RuntimeError from run_subprocess_with_context, with the original
431
+ CalledProcessError accessible via e.__cause__
432
+ branch: Name of the branch that failed to delete
433
+ force: Whether --force flag was used
434
+ """
435
+ branch_text = click.style(branch, fg="yellow")
436
+
437
+ # Extract returncode from the original CalledProcessError in __cause__
438
+ returncode: int | None = None
439
+ if isinstance(e.__cause__, subprocess.CalledProcessError):
440
+ returncode = e.__cause__.returncode
441
+
442
+ if returncode == 1 and not force:
443
+ # User declined - this is expected behavior, not an error
444
+ user_output(f"⭕ Skipped deletion of branch: {branch_text} (user declined or not eligible)")
445
+ else:
446
+ # Other error (branch doesn't exist, git failure, etc.)
447
+ # The RuntimeError message already contains stderr from run_subprocess_with_context
448
+ user_output(
449
+ click.style("Error: ", fg="red") + f"Failed to delete branch {branch_text}: {e}"
450
+ )
451
+ raise SystemExit(1) from e
452
+
453
+
454
+ def _delete_worktree(
455
+ ctx: ErkContext,
456
+ name: str,
457
+ force: bool,
458
+ delete_branch: bool,
459
+ dry_run: bool,
460
+ quiet: bool = False,
461
+ close_all: bool = False,
462
+ ) -> None:
463
+ """Internal function to delete a worktree.
464
+
465
+ Args:
466
+ ctx: Erk context with git operations
467
+ name: Name of the worktree to delete
468
+ force: Skip confirmation prompts and use -D for branch deletion
469
+ delete_branch: Delete the branch checked out on the worktree
470
+ dry_run: Print what would be done without executing destructive operations
471
+ quiet: Suppress planning output (still shows final confirmation)
472
+ close_all: Also close associated PR and plan
473
+ """
474
+ if dry_run:
475
+ ctx = create_context(dry_run=True)
476
+
477
+ validate_worktree_name_for_deletion(name)
478
+
479
+ repo = discover_repo_context(ctx, ctx.cwd)
480
+ ensure_erk_metadata_dir(repo)
481
+ wt_path = worktree_path_for(repo.worktrees_dir, name)
482
+
483
+ Ensure.path_exists(ctx, wt_path, f"Worktree not found: {wt_path}")
484
+
485
+ # Check for pending extraction marker
486
+ check_pending_extraction_marker(wt_path, force)
487
+
488
+ # main_repo_root is always set by RepoContext.__post_init__, but ty doesn't know
489
+ main_repo = repo.main_repo_root if repo.main_repo_root else repo.root
490
+ ctx = _escape_worktree_if_inside(ctx, main_repo, wt_path, dry_run)
491
+
492
+ branch_to_delete: str | None = None
493
+ if delete_branch:
494
+ branch_to_delete = _collect_branch_to_delete(ctx, repo.root, wt_path, name)
495
+
496
+ # Fetch PR/plan info before displaying plan (for informative planning output)
497
+ pr_info: tuple[int, str] | None = None
498
+ plan_info: tuple[int, PlanState] | None = None
499
+ if close_all and branch_to_delete:
500
+ pr_info = _get_pr_info_for_branch(ctx, repo.root, branch_to_delete)
501
+ plan_info = _get_plan_info_for_worktree(ctx, repo.root, name)
502
+
503
+ if not quiet:
504
+ _display_planned_operations(wt_path, branch_to_delete, close_all, pr_info, plan_info)
505
+
506
+ if not _confirm_operations(force, dry_run):
507
+ return
508
+
509
+ # Order of operations: worktree delete → PR close → plan close → branch delete
510
+ was_slot = _delete_worktree_directory(ctx, repo, wt_path)
511
+
512
+ if close_all and branch_to_delete:
513
+ # Close PR for the branch (if exists and open)
514
+ _close_pr_for_branch(ctx, repo.root, branch_to_delete)
515
+ # Close plan for the worktree (if exists and open)
516
+ _close_plan_for_worktree(ctx, repo.root, name)
517
+
518
+ if branch_to_delete:
519
+ # User already confirmed via _confirm_operations(), so force=True for branch deletion
520
+ # to avoid redundant Graphite prompt
521
+ _delete_branch_at_error_boundary(
522
+ ctx, repo.root, branch_to_delete, force=True, dry_run=dry_run, graphite=ctx.graphite
523
+ )
524
+
525
+ if not dry_run and not was_slot:
526
+ # Only show "Deleted worktree" message if not a slot (slot shows its own message)
527
+ path_text = click.style(str(wt_path), fg="green")
528
+ user_output(f"✅ Deleted worktree: {path_text}")
529
+
530
+
531
+ @click.command("delete")
532
+ @click.argument("name", metavar="NAME", shell_complete=complete_worktree_names)
533
+ @click.option("-f", "--force", is_flag=True, help="Do not prompt for confirmation.")
534
+ @click.option(
535
+ "-b",
536
+ "--branch",
537
+ is_flag=True,
538
+ help="Delete the branch checked out on the worktree.",
539
+ )
540
+ @click.option(
541
+ "-a",
542
+ "--all",
543
+ "close_all", # Use different name to avoid shadowing builtin
544
+ is_flag=True,
545
+ help="Delete branch, close associated PR and plan.",
546
+ )
547
+ @click.option(
548
+ "--dry-run",
549
+ is_flag=True,
550
+ # dry_run=False: Allow destructive operations by default
551
+ default=False,
552
+ help="Print what would be done without executing destructive operations.",
553
+ )
554
+ @click.pass_obj
555
+ def delete_wt(
556
+ ctx: ErkContext,
557
+ name: str,
558
+ force: bool,
559
+ branch: bool,
560
+ close_all: bool,
561
+ dry_run: bool,
562
+ ) -> None:
563
+ """Delete the worktree directory.
564
+
565
+ With `-f/--force`, skips the confirmation prompt and uses -D for branch deletion.
566
+ Attempts `git worktree remove` before deleting the directory.
567
+
568
+ With `-a/--all`, also closes the associated PR and plan (implies --branch).
569
+ """
570
+ # --all implies --branch
571
+ if close_all:
572
+ branch = True
573
+ _delete_worktree(ctx, name, force, branch, dry_run, close_all=close_all)