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,690 @@
1
+ """Unified land command for PRs.
2
+
3
+ This command merges a PR and cleans up the worktree/branch.
4
+ It accepts a branch name, PR number, or PR URL as argument.
5
+
6
+ Usage:
7
+ erk land # Land current branch's PR
8
+ erk land 123 # Land PR by number
9
+ erk land <url> # Land PR by URL
10
+ erk land <branch> # Land PR for branch
11
+ """
12
+
13
+ import re
14
+ from dataclasses import replace
15
+ from pathlib import Path
16
+ from typing import Literal, NamedTuple
17
+
18
+ import click
19
+
20
+ from erk.cli.commands.branch.unassign_cmd import execute_unassign
21
+ from erk.cli.commands.navigation_helpers import (
22
+ activate_root_repo,
23
+ activate_worktree,
24
+ check_clean_working_tree,
25
+ delete_branch_and_worktree,
26
+ find_assignment_by_worktree_path,
27
+ )
28
+ from erk.cli.commands.objective_helpers import (
29
+ check_and_display_plan_issue_closure,
30
+ get_objective_for_branch,
31
+ prompt_objective_update,
32
+ )
33
+ from erk.cli.commands.wt.create_cmd import ensure_worktree_for_branch
34
+ from erk.cli.core import discover_repo_context
35
+ from erk.cli.ensure import Ensure
36
+ from erk.cli.help_formatter import CommandWithHiddenOptions, script_option
37
+ from erk.core.context import ErkContext, create_context
38
+ from erk.core.repo_discovery import RepoContext
39
+ from erk.core.worktree_pool import (
40
+ SlotAssignment,
41
+ load_pool_state,
42
+ save_pool_state,
43
+ update_slot_objective,
44
+ )
45
+ from erk_shared.gateway.gt.cli import render_events
46
+ from erk_shared.gateway.gt.operations.land_pr import execute_land_pr
47
+ from erk_shared.gateway.gt.types import LandPrError, LandPrSuccess
48
+ from erk_shared.github.types import PRDetails, PRNotFound
49
+ from erk_shared.output.output import user_confirm, user_output
50
+
51
+
52
+ class ParsedArgument(NamedTuple):
53
+ """Result of parsing a land command argument."""
54
+
55
+ arg_type: Literal["pr-number", "pr-url", "branch"]
56
+ pr_number: int | None
57
+
58
+
59
+ def parse_argument(arg: str) -> ParsedArgument:
60
+ """Parse argument to determine type.
61
+
62
+ Args:
63
+ arg: The argument string (PR number, PR URL, or branch name)
64
+
65
+ Returns:
66
+ ParsedArgument with:
67
+ - arg_type="pr-number", pr_number=N if arg is a numeric PR number
68
+ - arg_type="pr-url", pr_number=N if arg is a GitHub or Graphite PR URL
69
+ - arg_type="branch", pr_number=None if arg is a branch name
70
+ """
71
+ # Try parsing as a plain number (PR number)
72
+ if arg.isdigit():
73
+ return ParsedArgument(arg_type="pr-number", pr_number=int(arg))
74
+
75
+ # Try parsing as a GitHub PR URL (e.g., https://github.com/owner/repo/pull/123)
76
+ match = re.search(r"/pull/(\d+)", arg)
77
+ if match:
78
+ return ParsedArgument(arg_type="pr-url", pr_number=int(match.group(1)))
79
+
80
+ # Try parsing as a Graphite PR URL (e.g., https://app.graphite.com/github/pr/owner/repo/123)
81
+ match = re.search(r"/pr/[^/]+/[^/]+/(\d+)", arg)
82
+ if match:
83
+ return ParsedArgument(arg_type="pr-url", pr_number=int(match.group(1)))
84
+
85
+ # Treat as branch name
86
+ return ParsedArgument(arg_type="branch", pr_number=None)
87
+
88
+
89
+ def resolve_branch_for_pr(ctx: ErkContext, repo_root: Path, pr_details: PRDetails) -> str:
90
+ """Resolve the local branch name for a PR.
91
+
92
+ For same-repo PRs, returns the head branch name.
93
+ For fork PRs, returns "pr/{pr_number}" (the checkout convention).
94
+
95
+ Args:
96
+ ctx: ErkContext
97
+ repo_root: Repository root directory
98
+ pr_details: PR details from GitHub
99
+
100
+ Returns:
101
+ Local branch name to use for this PR
102
+ """
103
+ if pr_details.is_cross_repository:
104
+ # Fork PR - local checkout uses pr/{number} naming convention
105
+ return f"pr/{pr_details.number}"
106
+ return pr_details.head_ref_name
107
+
108
+
109
+ def check_unresolved_comments(
110
+ ctx: ErkContext,
111
+ repo_root: Path,
112
+ pr_number: int,
113
+ force: bool,
114
+ ) -> None:
115
+ """Check for unresolved review threads and prompt if any exist.
116
+
117
+ Args:
118
+ ctx: ErkContext
119
+ repo_root: Repository root directory
120
+ pr_number: PR number to check
121
+ force: If True, skip confirmation prompt
122
+
123
+ Raises:
124
+ SystemExit(0) if user declines to continue
125
+ """
126
+ # Handle rate limit errors gracefully - this is an advisory check.
127
+ # We cannot LBYL for rate limits (no way to check quota before calling),
128
+ # so try/except is the appropriate pattern here.
129
+ try:
130
+ threads = ctx.github.get_pr_review_threads(repo_root, pr_number, include_resolved=False)
131
+ except RuntimeError as e:
132
+ error_str = str(e)
133
+ if "RATE_LIMIT" in error_str or "rate limit" in error_str.lower():
134
+ user_output(
135
+ click.style("⚠ ", fg="yellow")
136
+ + "Could not check for unresolved comments (API rate limited)"
137
+ )
138
+ return # Continue without blocking
139
+ raise # Re-raise other errors
140
+
141
+ if threads and not force:
142
+ user_output(
143
+ click.style("⚠ ", fg="yellow")
144
+ + f"PR #{pr_number} has {len(threads)} unresolved review comment(s)."
145
+ )
146
+ if not user_confirm("Continue anyway?", default=False):
147
+ raise SystemExit(0)
148
+
149
+
150
+ def _cleanup_and_navigate(
151
+ ctx: ErkContext,
152
+ repo: RepoContext,
153
+ branch: str,
154
+ worktree_path: Path | None,
155
+ script: bool,
156
+ pull_flag: bool,
157
+ force: bool,
158
+ is_current_branch: bool,
159
+ target_child_branch: str | None,
160
+ objective_number: int | None,
161
+ ) -> None:
162
+ """Handle worktree/branch cleanup and navigation after PR merge.
163
+
164
+ This is shared logic used by both current-branch and specific-PR landing.
165
+
166
+ Args:
167
+ ctx: ErkContext
168
+ repo: Repository context
169
+ branch: Branch name to clean up
170
+ worktree_path: Path to worktree (None if no worktree exists)
171
+ script: Whether to output activation script
172
+ pull_flag: Whether to pull after landing
173
+ force: Whether to skip cleanup confirmation
174
+ is_current_branch: True if landing from the branch's worktree
175
+ target_child_branch: Target child branch for --up navigation (None for trunk)
176
+ objective_number: Issue number of the objective linked to this branch (if any)
177
+ """
178
+ main_repo_root = repo.main_repo_root if repo.main_repo_root else repo.root
179
+
180
+ if worktree_path is not None:
181
+ # Check if this is a slot worktree
182
+ state = load_pool_state(repo.pool_json_path)
183
+ assignment: SlotAssignment | None = None
184
+ if state is not None:
185
+ assignment = find_assignment_by_worktree_path(state, worktree_path)
186
+
187
+ if assignment is not None:
188
+ # Slot worktree: unassign instead of delete
189
+ # state is guaranteed to be non-None since assignment was found in it
190
+ assert state is not None
191
+ if not force and not ctx.dry_run:
192
+ if not user_confirm(
193
+ f"Unassign slot '{assignment.slot_name}' and delete branch '{branch}'?",
194
+ default=True,
195
+ ):
196
+ user_output("Slot preserved. Branch still exists locally.")
197
+ return
198
+ # Record objective on slot BEFORE unassigning (so it persists after assignment removed)
199
+ if objective_number is not None:
200
+ state = update_slot_objective(state, assignment.slot_name, objective_number)
201
+ if ctx.dry_run:
202
+ user_output("[DRY RUN] Would save pool state")
203
+ else:
204
+ save_pool_state(repo.pool_json_path, state)
205
+ execute_unassign(ctx, repo, state, assignment)
206
+ ctx.git.delete_branch_with_graphite(main_repo_root, branch, force=True)
207
+ user_output(click.style("✓", fg="green") + " Unassigned slot and deleted branch")
208
+ else:
209
+ # Non-slot worktree: delete worktree and branch
210
+ if not force and not ctx.dry_run:
211
+ if not user_confirm(
212
+ f"Delete worktree '{worktree_path.name}' and branch '{branch}'?",
213
+ default=True,
214
+ ):
215
+ user_output("Worktree preserved. Branch still exists locally.")
216
+ return
217
+ delete_branch_and_worktree(ctx, repo, branch, worktree_path)
218
+ user_output(click.style("✓", fg="green") + " Deleted worktree and branch")
219
+ else:
220
+ # No worktree - check if branch exists locally before deletion (LBYL)
221
+ local_branches = ctx.git.list_local_branches(main_repo_root)
222
+ if branch in local_branches:
223
+ ctx.git.delete_branch_with_graphite(main_repo_root, branch, force=True)
224
+ user_output(click.style("✓", fg="green") + f" Deleted branch '{branch}'")
225
+ # else: Branch doesn't exist locally - no cleanup needed (remote implementation or fork PR)
226
+
227
+ # In dry-run mode, skip navigation and show summary
228
+ if ctx.dry_run:
229
+ user_output(f"\n{click.style('[DRY RUN] No changes made', fg='yellow', bold=True)}")
230
+ raise SystemExit(0)
231
+
232
+ # Navigate (only if we were in the deleted worktree)
233
+ if is_current_branch:
234
+ _navigate_after_land(ctx, repo, script, pull_flag, target_child_branch)
235
+ else:
236
+ # Command succeeded but no navigation needed - exit cleanly
237
+ raise SystemExit(0)
238
+
239
+
240
+ def _navigate_after_land(
241
+ ctx: ErkContext,
242
+ repo: RepoContext,
243
+ script: bool,
244
+ pull_flag: bool,
245
+ target_child_branch: str | None,
246
+ ) -> None:
247
+ """Navigate to appropriate location after landing.
248
+
249
+ Args:
250
+ ctx: ErkContext
251
+ repo: Repository context
252
+ script: Whether to output activation script
253
+ pull_flag: Whether to include git pull in activation
254
+ target_child_branch: If set, navigate to this child branch (--up mode)
255
+ """
256
+ # Create post-deletion repo context with root pointing to main_repo_root
257
+ main_repo_root = repo.main_repo_root if repo.main_repo_root else repo.root
258
+ post_deletion_repo = replace(repo, root=main_repo_root)
259
+
260
+ if target_child_branch is not None:
261
+ target_path = ctx.git.find_worktree_for_branch(main_repo_root, target_child_branch)
262
+ if target_path is None:
263
+ # Auto-create worktree for child
264
+ target_path, _ = ensure_worktree_for_branch(
265
+ ctx, post_deletion_repo, target_child_branch
266
+ )
267
+ # Suggest running gt restack to update child branch's PR base
268
+ user_output(
269
+ click.style("💡", fg="cyan")
270
+ + f" Run 'gt restack' in {target_child_branch} to update PR base branch"
271
+ )
272
+ activate_worktree(
273
+ ctx=ctx,
274
+ repo=post_deletion_repo,
275
+ target_path=target_path,
276
+ script=script,
277
+ command_name="land",
278
+ preserve_relative_path=True,
279
+ post_cd_commands=None,
280
+ )
281
+ # activate_worktree raises SystemExit(0)
282
+ else:
283
+ # Construct git pull commands if pull_flag is set
284
+ post_commands: list[str] | None = None
285
+ if pull_flag:
286
+ trunk_branch = ctx.git.detect_trunk_branch(main_repo_root)
287
+ post_commands = [
288
+ f'__erk_log "->" "git pull origin {trunk_branch}"',
289
+ f"git pull --ff-only origin {trunk_branch} || "
290
+ f'echo "Warning: git pull failed (try running manually)" >&2',
291
+ ]
292
+ # Output activation script pointing to trunk/root repo
293
+ activate_root_repo(
294
+ ctx, post_deletion_repo, script, command_name="land", post_cd_commands=post_commands
295
+ )
296
+ # activate_root_repo raises SystemExit(0)
297
+
298
+
299
+ @click.command("land", cls=CommandWithHiddenOptions)
300
+ @script_option
301
+ @click.argument("target", required=False)
302
+ @click.option(
303
+ "--up",
304
+ "up_flag",
305
+ is_flag=True,
306
+ help="Navigate to child branch instead of trunk after landing",
307
+ )
308
+ @click.option(
309
+ "-f",
310
+ "--force",
311
+ is_flag=True,
312
+ help="Skip all confirmation prompts (unresolved comments, worktree deletion)",
313
+ )
314
+ @click.option(
315
+ "--pull/--no-pull",
316
+ "pull_flag",
317
+ default=True,
318
+ help="Pull latest changes after landing (default: --pull)",
319
+ )
320
+ @click.option(
321
+ "--dry-run",
322
+ is_flag=True,
323
+ help="Print what would be done without executing destructive operations.",
324
+ )
325
+ @click.pass_obj
326
+ def land(
327
+ ctx: ErkContext,
328
+ script: bool,
329
+ target: str | None,
330
+ up_flag: bool,
331
+ force: bool,
332
+ pull_flag: bool,
333
+ dry_run: bool,
334
+ ) -> None:
335
+ """Merge PR and delete worktree.
336
+
337
+ Can land the current branch's PR, a specific PR by number/URL,
338
+ or a PR for a specific branch.
339
+
340
+ \b
341
+ Usage:
342
+ erk land # Land current branch's PR
343
+ erk land 123 # Land PR #123
344
+ erk land <url> # Land PR by GitHub URL
345
+ erk land <branch> # Land PR for branch
346
+
347
+ With shell integration (recommended):
348
+ erk land
349
+
350
+ Without shell integration:
351
+ source <(erk land --script)
352
+
353
+ Requires:
354
+ - Graphite enabled: 'erk config set use_graphite true'
355
+ - PR must be open and ready to merge
356
+ - PR's base branch must be trunk (one level from trunk)
357
+ """
358
+ # Replace context with dry-run wrappers if needed
359
+ if dry_run:
360
+ ctx = create_context(dry_run=True)
361
+ script = False # Force human-readable output in dry-run mode
362
+
363
+ # Validate prerequisites
364
+ Ensure.gh_authenticated(ctx)
365
+ Ensure.graphite_available(ctx)
366
+
367
+ repo = discover_repo_context(ctx, ctx.cwd)
368
+
369
+ # Validate shell integration for activation script output (skip in dry-run mode)
370
+ if not script and not ctx.dry_run:
371
+ user_output(
372
+ click.style("Error: ", fg="red")
373
+ + "This command requires shell integration for activation.\n\n"
374
+ + "Options:\n"
375
+ + " 1. Use shell integration: erk land\n"
376
+ + " (Requires 'erk init --shell' setup)\n\n"
377
+ + " 2. Use --script flag: source <(erk land --script)\n"
378
+ )
379
+ raise SystemExit(1)
380
+
381
+ # Determine if landing current branch or a specific target
382
+ if target is None:
383
+ # Landing current branch's PR (original behavior)
384
+ _land_current_branch(ctx, repo, script, up_flag, force, pull_flag)
385
+ else:
386
+ # Parse the target argument
387
+ parsed = parse_argument(target)
388
+
389
+ if parsed.arg_type == "branch":
390
+ # Landing a PR for a specific branch
391
+ _land_by_branch(ctx, repo, script, force, pull_flag, target)
392
+ else:
393
+ # Landing a specific PR by number or URL
394
+ if parsed.pr_number is None:
395
+ user_output(
396
+ click.style("Error: ", fg="red") + f"Invalid PR identifier: {target}\n"
397
+ "Expected a PR number (e.g., 123) or GitHub URL."
398
+ )
399
+ raise SystemExit(1)
400
+ _land_specific_pr(ctx, repo, script, up_flag, force, pull_flag, parsed.pr_number)
401
+
402
+
403
+ def _land_current_branch(
404
+ ctx: ErkContext,
405
+ repo: RepoContext,
406
+ script: bool,
407
+ up_flag: bool,
408
+ force: bool,
409
+ pull_flag: bool,
410
+ ) -> None:
411
+ """Land the current branch's PR (original behavior)."""
412
+ check_clean_working_tree(ctx)
413
+
414
+ # Get current branch and worktree path before landing
415
+ current_branch = Ensure.not_none(
416
+ ctx.git.get_current_branch(ctx.cwd), "Not currently on a branch (detached HEAD)"
417
+ )
418
+
419
+ current_worktree_path = Ensure.not_none(
420
+ ctx.git.find_worktree_for_branch(repo.root, current_branch),
421
+ f"Cannot find worktree for current branch '{current_branch}'.",
422
+ )
423
+
424
+ # Validate --up preconditions BEFORE any mutations (fail-fast)
425
+ target_child_branch: str | None = None
426
+ if up_flag:
427
+ children = ctx.graphite.get_child_branches(ctx.git, repo.root, current_branch)
428
+ if len(children) == 0:
429
+ user_output(
430
+ click.style("Error: ", fg="red")
431
+ + f"Cannot use --up: branch '{current_branch}' has no children.\n"
432
+ "Use 'erk land' without --up to return to trunk."
433
+ )
434
+ raise SystemExit(1)
435
+ elif len(children) > 1:
436
+ children_list = ", ".join(f"'{c}'" for c in children)
437
+ user_output(
438
+ click.style("Error: ", fg="red")
439
+ + f"Cannot use --up: branch '{current_branch}' has multiple children: "
440
+ f"{children_list}.\n"
441
+ "Use 'erk land' without --up, then 'erk co <branch>' to choose."
442
+ )
443
+ raise SystemExit(1)
444
+ else:
445
+ target_child_branch = children[0]
446
+
447
+ # Look up PR for current branch to check unresolved comments BEFORE merge
448
+ pr_details = ctx.github.get_pr_for_branch(repo.root, current_branch)
449
+ if not isinstance(pr_details, PRNotFound):
450
+ check_unresolved_comments(ctx, repo.root, pr_details.number, force)
451
+
452
+ # Step 1: Execute land-pr (merges the PR)
453
+ # render_events() uses click.echo() + sys.stderr.flush() for immediate unbuffered output
454
+ result = render_events(execute_land_pr(ctx, ctx.cwd))
455
+
456
+ if isinstance(result, LandPrError):
457
+ user_output(click.style("Error: ", fg="red") + result.message)
458
+ raise SystemExit(1)
459
+
460
+ # Success - PR was merged
461
+ success_result: LandPrSuccess = result
462
+
463
+ user_output(
464
+ click.style("✓", fg="green")
465
+ + f" Merged PR #{success_result.pr_number} [{success_result.branch_name}]"
466
+ )
467
+
468
+ # Check and display plan issue closure
469
+ main_repo_root = repo.main_repo_root if repo.main_repo_root else repo.root
470
+ check_and_display_plan_issue_closure(ctx, main_repo_root, current_branch)
471
+
472
+ # Check for linked objective and offer to update
473
+ objective_number = get_objective_for_branch(ctx, main_repo_root, current_branch)
474
+ if objective_number is not None:
475
+ prompt_objective_update(
476
+ ctx, main_repo_root, objective_number, success_result.pr_number, current_branch, force
477
+ )
478
+
479
+ # Step 2: Cleanup and navigate
480
+ _cleanup_and_navigate(
481
+ ctx,
482
+ repo,
483
+ current_branch,
484
+ current_worktree_path,
485
+ script,
486
+ pull_flag,
487
+ force,
488
+ is_current_branch=True,
489
+ target_child_branch=target_child_branch,
490
+ objective_number=objective_number,
491
+ )
492
+
493
+
494
+ def _land_specific_pr(
495
+ ctx: ErkContext,
496
+ repo: RepoContext,
497
+ script: bool,
498
+ up_flag: bool,
499
+ force: bool,
500
+ pull_flag: bool,
501
+ pr_number: int,
502
+ ) -> None:
503
+ """Land a specific PR by number."""
504
+ # Validate --up is not used with PR argument
505
+ if up_flag:
506
+ user_output(
507
+ click.style("Error: ", fg="red") + "Cannot use --up when specifying a PR.\n"
508
+ "The --up flag only works when landing the current branch's PR."
509
+ )
510
+ raise SystemExit(1)
511
+
512
+ # Fetch PR details
513
+ main_repo_root = repo.main_repo_root if repo.main_repo_root else repo.root
514
+ pr_details = ctx.github.get_pr(main_repo_root, pr_number)
515
+
516
+ if isinstance(pr_details, PRNotFound):
517
+ user_output(click.style("Error: ", fg="red") + f"Pull request #{pr_number} not found.")
518
+ raise SystemExit(1)
519
+
520
+ # Resolve branch name (handles fork PRs)
521
+ branch = resolve_branch_for_pr(ctx, main_repo_root, pr_details)
522
+
523
+ # Determine if we're in the target branch's worktree
524
+ current_branch = ctx.git.get_current_branch(ctx.cwd)
525
+ is_current_branch = current_branch == branch
526
+
527
+ # Check if we're in a worktree for this branch
528
+ worktree_path = ctx.git.find_worktree_for_branch(main_repo_root, branch)
529
+
530
+ # If in target worktree, validate clean working tree
531
+ if is_current_branch:
532
+ check_clean_working_tree(ctx)
533
+
534
+ # Validate PR state
535
+ if pr_details.state != "OPEN":
536
+ user_output(
537
+ click.style("Error: ", fg="red")
538
+ + f"Pull request #{pr_number} is not open (state: {pr_details.state})."
539
+ )
540
+ raise SystemExit(1)
541
+
542
+ # Validate PR base is trunk
543
+ trunk = ctx.git.detect_trunk_branch(main_repo_root)
544
+ if pr_details.base_ref_name != trunk:
545
+ user_output(
546
+ click.style("Error: ", fg="red")
547
+ + f"PR #{pr_number} targets '{pr_details.base_ref_name}' "
548
+ + f"but should target '{trunk}'.\n\n"
549
+ + "The GitHub PR's base branch has diverged from your local stack.\n"
550
+ + "Run: gt restack && gt submit\n"
551
+ + f"Then retry: erk land {pr_number}"
552
+ )
553
+ raise SystemExit(1)
554
+
555
+ # Check for unresolved comments BEFORE merge
556
+ check_unresolved_comments(ctx, main_repo_root, pr_number, force)
557
+
558
+ # Merge the PR via GitHub API
559
+ user_output(f"Merging PR #{pr_number}...")
560
+ subject = f"{pr_details.title} (#{pr_number})" if pr_details.title else None
561
+ body = pr_details.body or None
562
+ merge_result = ctx.github.merge_pr(main_repo_root, pr_number, subject=subject, body=body)
563
+
564
+ if merge_result is not True:
565
+ error_detail = merge_result if isinstance(merge_result, str) else "Unknown error"
566
+ user_output(
567
+ click.style("Error: ", fg="red") + f"Failed to merge PR #{pr_number}\n\n{error_detail}"
568
+ )
569
+ raise SystemExit(1)
570
+
571
+ user_output(click.style("✓", fg="green") + f" Merged PR #{pr_number} [{branch}]")
572
+
573
+ # Check and display plan issue closure
574
+ check_and_display_plan_issue_closure(ctx, main_repo_root, branch)
575
+
576
+ # Check for linked objective and offer to update
577
+ objective_number = get_objective_for_branch(ctx, main_repo_root, branch)
578
+ if objective_number is not None:
579
+ prompt_objective_update(ctx, main_repo_root, objective_number, pr_number, branch, force)
580
+
581
+ # Cleanup and navigate
582
+ _cleanup_and_navigate(
583
+ ctx,
584
+ repo,
585
+ branch,
586
+ worktree_path,
587
+ script,
588
+ pull_flag,
589
+ force,
590
+ is_current_branch,
591
+ target_child_branch=None,
592
+ objective_number=objective_number,
593
+ )
594
+
595
+
596
+ def _land_by_branch(
597
+ ctx: ErkContext,
598
+ repo: RepoContext,
599
+ script: bool,
600
+ force: bool,
601
+ pull_flag: bool,
602
+ branch_name: str,
603
+ ) -> None:
604
+ """Land a PR for a specific branch."""
605
+ main_repo_root = repo.main_repo_root if repo.main_repo_root else repo.root
606
+
607
+ # Look up PR for branch
608
+ pr_details = ctx.github.get_pr_for_branch(main_repo_root, branch_name)
609
+
610
+ if isinstance(pr_details, PRNotFound):
611
+ user_output(
612
+ click.style("Error: ", fg="red") + f"No pull request found for branch '{branch_name}'."
613
+ )
614
+ raise SystemExit(1)
615
+
616
+ pr_number = pr_details.number
617
+
618
+ # Determine if we're in the target branch's worktree
619
+ current_branch = ctx.git.get_current_branch(ctx.cwd)
620
+ is_current_branch = current_branch == branch_name
621
+
622
+ # Check if worktree exists for this branch
623
+ worktree_path = ctx.git.find_worktree_for_branch(main_repo_root, branch_name)
624
+
625
+ # If in target worktree, validate clean working tree
626
+ if is_current_branch:
627
+ check_clean_working_tree(ctx)
628
+
629
+ # Validate PR state
630
+ if pr_details.state != "OPEN":
631
+ user_output(
632
+ click.style("Error: ", fg="red")
633
+ + f"Pull request #{pr_number} is not open (state: {pr_details.state})."
634
+ )
635
+ raise SystemExit(1)
636
+
637
+ # Validate PR base is trunk
638
+ trunk = ctx.git.detect_trunk_branch(main_repo_root)
639
+ if pr_details.base_ref_name != trunk:
640
+ user_output(
641
+ click.style("Error: ", fg="red")
642
+ + f"PR #{pr_number} targets '{pr_details.base_ref_name}' "
643
+ + f"but should target '{trunk}'.\n\n"
644
+ + "The GitHub PR's base branch has diverged from your local stack.\n"
645
+ + "Run: gt restack && gt submit\n"
646
+ + f"Then retry: erk land {branch_name}"
647
+ )
648
+ raise SystemExit(1)
649
+
650
+ # Check for unresolved comments BEFORE merge
651
+ check_unresolved_comments(ctx, main_repo_root, pr_number, force)
652
+
653
+ # Merge the PR via GitHub API
654
+ user_output(f"Merging PR #{pr_number} for branch '{branch_name}'...")
655
+ subject = f"{pr_details.title} (#{pr_number})" if pr_details.title else None
656
+ body = pr_details.body or None
657
+ merge_result = ctx.github.merge_pr(main_repo_root, pr_number, subject=subject, body=body)
658
+
659
+ if merge_result is not True:
660
+ error_detail = merge_result if isinstance(merge_result, str) else "Unknown error"
661
+ user_output(
662
+ click.style("Error: ", fg="red") + f"Failed to merge PR #{pr_number}\n\n{error_detail}"
663
+ )
664
+ raise SystemExit(1)
665
+
666
+ user_output(click.style("✓", fg="green") + f" Merged PR #{pr_number} [{branch_name}]")
667
+
668
+ # Check and display plan issue closure
669
+ check_and_display_plan_issue_closure(ctx, main_repo_root, branch_name)
670
+
671
+ # Check for linked objective and offer to update
672
+ objective_number = get_objective_for_branch(ctx, main_repo_root, branch_name)
673
+ if objective_number is not None:
674
+ prompt_objective_update(
675
+ ctx, main_repo_root, objective_number, pr_number, branch_name, force
676
+ )
677
+
678
+ # Cleanup and navigate (uses shared function)
679
+ _cleanup_and_navigate(
680
+ ctx,
681
+ repo,
682
+ branch_name,
683
+ worktree_path,
684
+ script,
685
+ pull_flag,
686
+ force,
687
+ is_current_branch,
688
+ target_child_branch=None,
689
+ objective_number=objective_number,
690
+ )