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,470 @@
1
+ """Consolidate worktrees by removing others containing branches from current stack."""
2
+
3
+ import time
4
+ from pathlib import Path
5
+
6
+ import click
7
+
8
+ from erk.cli.activation import render_activation_script
9
+ from erk.cli.commands.branch.unassign_cmd import execute_unassign
10
+ from erk.cli.commands.navigation_helpers import find_assignment_by_worktree_path
11
+ from erk.cli.core import discover_repo_context, worktree_path_for
12
+ from erk.cli.graphite_command import GraphiteCommandWithHiddenOptions
13
+ from erk.cli.help_formatter import script_option
14
+ from erk.core.consolidation_utils import calculate_stack_range, create_consolidation_plan
15
+ from erk.core.context import ErkContext, create_context
16
+ from erk.core.repo_discovery import RepoContext, ensure_erk_metadata_dir
17
+ from erk.core.worktree_pool import load_pool_state
18
+ from erk_shared.git.abc import WorktreeInfo
19
+ from erk_shared.output.output import user_confirm, user_output
20
+
21
+
22
+ def _format_section_header(text: str, separator_length: int = 59) -> str:
23
+ """Format a section header with styled text and separator line."""
24
+ header = click.style(text, bold=True)
25
+ separator = "─" * separator_length
26
+ return f"{header}\n{separator}"
27
+
28
+
29
+ def _format_consolidation_plan(
30
+ stack_branches: list[str],
31
+ current_branch: str,
32
+ consolidated_branches: list[str],
33
+ target_name: str,
34
+ worktrees_to_remove: list[tuple[str, Path]],
35
+ ) -> str:
36
+ """Format the consolidation plan section with visual hierarchy."""
37
+ lines: list[str] = []
38
+
39
+ # Section header
40
+ lines.append(_format_section_header("📋 Consolidation Plan"))
41
+ lines.append("")
42
+
43
+ # Branches consolidating to current worktree
44
+ lines.append("Branches consolidating to current worktree:")
45
+ for branch in consolidated_branches:
46
+ if branch == current_branch:
47
+ branch_display = click.style(branch, fg="bright_green", bold=True)
48
+ lines.append(f" • {branch_display} ← (keeping this worktree)")
49
+ else:
50
+ lines.append(f" • {branch}")
51
+
52
+ lines.append("")
53
+
54
+ # Worktrees to remove
55
+ lines.append("Worktrees to remove:")
56
+ for branch, path in worktrees_to_remove:
57
+ lines.append(f" • {branch}")
58
+ lines.append(f" {click.style(str(path), fg='white', dim=True)}")
59
+
60
+ lines.append("")
61
+ lines.append("─" * 59)
62
+
63
+ return "\n".join(lines)
64
+
65
+
66
+ def _format_removal_progress(removed_paths: list[Path], unassigned_slots: list[str]) -> str:
67
+ """Format the removal execution output with grouped checkmarks."""
68
+ lines: list[str] = []
69
+
70
+ if removed_paths or unassigned_slots:
71
+ lines.append(_format_section_header("🗑️ Removing worktrees..."))
72
+ for path in removed_paths:
73
+ lines.append(f" ✓ {click.style(str(path), fg='green')}")
74
+ for slot in unassigned_slots:
75
+ lines.append(f" ✓ {click.style(slot, fg='cyan')} (slot unassigned)")
76
+
77
+ return "\n".join(lines)
78
+
79
+
80
+ def _remove_worktree_slot_aware(
81
+ ctx: ErkContext,
82
+ repo: RepoContext,
83
+ wt: WorktreeInfo,
84
+ ) -> tuple[Path | None, str | None]:
85
+ """Remove a worktree with slot awareness.
86
+
87
+ If worktree is a pool slot: unassigns slot (keeps directory for reuse).
88
+ If not a pool slot: removes worktree directory.
89
+
90
+ Args:
91
+ ctx: ErkContext with git operations
92
+ repo: Repository context
93
+ wt: WorktreeInfo for the worktree to remove
94
+
95
+ Returns:
96
+ Tuple of (removed_path, unassigned_slot_name):
97
+ - removed_path: Path if worktree was removed, None if slot unassigned
98
+ - unassigned_slot_name: Slot name if slot unassigned, None if worktree removed
99
+ """
100
+ state = load_pool_state(repo.pool_json_path)
101
+ assignment = None
102
+ if state is not None:
103
+ assignment = find_assignment_by_worktree_path(state, wt.path)
104
+
105
+ if assignment is not None:
106
+ # Slot worktree: unassign instead of remove
107
+ # state is guaranteed to be non-None since assignment was found in it
108
+ assert state is not None
109
+ execute_unassign(ctx, repo, state, assignment)
110
+ return (None, assignment.slot_name)
111
+ else:
112
+ # Non-slot worktree: remove normally
113
+ ctx.git.remove_worktree(repo.root, wt.path, force=True)
114
+ return (wt.path, None)
115
+
116
+
117
+ @click.command("consolidate", cls=GraphiteCommandWithHiddenOptions)
118
+ @click.argument("branch", required=False, default=None)
119
+ @click.option(
120
+ "--name",
121
+ type=str,
122
+ default=None,
123
+ help="Create and consolidate into a new worktree with this name",
124
+ )
125
+ @click.option("-f", "--force", is_flag=True, help="Skip confirmation prompt")
126
+ @click.option(
127
+ "--dry-run",
128
+ is_flag=True,
129
+ default=False,
130
+ help="Show what would be removed without executing",
131
+ )
132
+ @click.option(
133
+ "--down",
134
+ is_flag=True,
135
+ help="Only consolidate downstack (trunk to current branch). Default is entire stack.",
136
+ )
137
+ @script_option
138
+ @click.pass_obj
139
+ def consolidate_stack(
140
+ ctx: ErkContext,
141
+ branch: str | None,
142
+ name: str | None,
143
+ force: bool,
144
+ dry_run: bool,
145
+ down: bool,
146
+ script: bool,
147
+ ) -> None:
148
+ """Consolidate stack branches into a single worktree.
149
+
150
+ By default, consolidates full stack (trunk to leaf). With --down, consolidates
151
+ only downstack branches (trunk to current).
152
+
153
+ This command removes other worktrees that contain branches from the stack,
154
+ ensuring branches exist in only one worktree. This is useful before
155
+ stack-wide operations like 'gt restack'.
156
+
157
+ BRANCH: Optional branch name. If provided, consolidate only from trunk up to
158
+ this branch (partial consolidation). Cannot be used with --down.
159
+
160
+ \b
161
+ Examples:
162
+ # Consolidate full stack into current worktree (default)
163
+ $ erk consolidate
164
+
165
+ # Consolidate only downstack (trunk to current)
166
+ $ erk consolidate --down
167
+
168
+ # Consolidate trunk → feat-2 only (leaves feat-3+ in separate worktrees)
169
+ $ erk consolidate feat-2
170
+
171
+ # Create new worktree "my-stack" and consolidate full stack into it
172
+ $ erk consolidate --name my-stack
173
+
174
+ # Consolidate downstack into new worktree
175
+ $ erk consolidate --down --name my-partial
176
+
177
+ # Preview changes without executing
178
+ $ erk consolidate --dry-run
179
+
180
+ # Skip confirmation prompt
181
+ $ erk consolidate --force
182
+
183
+ Safety checks:
184
+ - Aborts if any worktree being consolidated has uncommitted changes
185
+ - Preserves the current worktree (or creates new one with --name)
186
+ - Shows preview before removal (unless --force)
187
+ - Never removes root worktree
188
+ """
189
+ # During dry-run, always show output regardless of shell integration
190
+ if dry_run:
191
+ script = False
192
+
193
+ # Validate that --down and BRANCH are not used together
194
+ if down and branch is not None:
195
+ user_output(click.style("❌ Error: Cannot use --down with BRANCH argument", fg="red"))
196
+ user_output(
197
+ "Use either --down (consolidate trunk to current) or "
198
+ "BRANCH (consolidate trunk to BRANCH)"
199
+ )
200
+ raise SystemExit(1)
201
+
202
+ # Get current worktree and branch
203
+ current_worktree = ctx.cwd
204
+ current_branch = ctx.git.get_current_branch(current_worktree)
205
+
206
+ if current_branch is None:
207
+ user_output("Error: Current worktree is in detached HEAD state")
208
+ user_output("Checkout a branch before running consolidate")
209
+ raise SystemExit(1)
210
+
211
+ # Get repository root
212
+ repo = discover_repo_context(ctx, current_worktree)
213
+ ensure_erk_metadata_dir(repo)
214
+
215
+ # Get current branch's stack
216
+ stack_branches = ctx.graphite.get_branch_stack(ctx.git, repo.root, current_branch)
217
+ if stack_branches is None:
218
+ user_output(f"Error: Branch '{current_branch}' is not tracked by Graphite")
219
+ user_output(
220
+ "Run 'gt repo init' to initialize Graphite, or use 'gt track' to track this branch"
221
+ )
222
+ raise SystemExit(1)
223
+
224
+ # Validate branch argument if provided
225
+ if branch is not None:
226
+ if branch not in stack_branches:
227
+ user_output(
228
+ click.style(f"❌ Error: Branch '{branch}' is not in the current stack", fg="red")
229
+ )
230
+ user_output("\nCurrent stack:")
231
+ for b in stack_branches:
232
+ marker = " ← current" if b == current_branch else ""
233
+ user_output(f" {click.style(b, fg='cyan')}{marker}")
234
+ raise SystemExit(1)
235
+
236
+ # Determine which portion of the stack to consolidate (now handled by utility)
237
+ # This will be used in create_consolidation_plan() below
238
+
239
+ # Get all worktrees
240
+ all_worktrees = ctx.git.list_worktrees(repo.root)
241
+
242
+ # Validate --name argument if provided
243
+ if name is not None:
244
+ # Check if a worktree with this name already exists
245
+ existing_names = [wt.path.name for wt in all_worktrees]
246
+
247
+ if name in existing_names:
248
+ user_output(click.style(f"❌ Error: Worktree '{name}' already exists", fg="red"))
249
+ user_output("\nSuggested action:")
250
+ user_output(" 1. Use a different name")
251
+ user_output(f" 2. Remove existing worktree: erk remove {name}")
252
+ user_output(" 3. Switch to existing: erk br co <branch>")
253
+ raise SystemExit(1)
254
+
255
+ # Calculate stack range early (needed for safety check)
256
+ # If --down is set, force end_branch to be current_branch
257
+ end_branch = current_branch if down else branch
258
+ stack_to_consolidate = calculate_stack_range(stack_branches, end_branch)
259
+
260
+ # Check worktrees in stack for uncommitted changes
261
+ # Only check worktrees that will actually be removed (skip root and current)
262
+ worktrees_with_changes: list[Path] = []
263
+ for wt in all_worktrees:
264
+ if wt.branch not in stack_to_consolidate:
265
+ continue
266
+ # Skip root worktree (never removed)
267
+ if wt.is_root:
268
+ continue
269
+ # Skip current worktree (consolidation target, never removed)
270
+ if wt.path.resolve() == current_worktree.resolve():
271
+ continue
272
+ if ctx.git.path_exists(wt.path) and ctx.git.has_uncommitted_changes(wt.path):
273
+ worktrees_with_changes.append(wt.path)
274
+
275
+ if worktrees_with_changes:
276
+ user_output(
277
+ click.style("Error: Uncommitted changes detected in worktrees:", fg="red", bold=True)
278
+ )
279
+ for wt_path in worktrees_with_changes:
280
+ user_output(f" - {wt_path}")
281
+ user_output("\nCommit or stash changes before running consolidate")
282
+ raise SystemExit(1)
283
+
284
+ # Safety check passed - all worktrees are clean
285
+ user_output(
286
+ click.style("✅ Safety check: All worktrees have no uncommitted changes", fg="green")
287
+ )
288
+ user_output()
289
+
290
+ # Create new worktree if --name is provided
291
+ # Track temp branch name for cleanup after source worktree removal
292
+ temp_branch_name: str | None = None
293
+
294
+ if name is not None:
295
+ if not dry_run:
296
+ # Generate temporary branch name to avoid "already used by worktree" error
297
+ # when the source worktree and new worktree would have the same branch checked out
298
+ temp_branch_name = f"temp-consolidate-{int(time.time())}"
299
+
300
+ # Use proper erks directory path resolution
301
+ new_worktree_path = worktree_path_for(repo.worktrees_dir, name)
302
+
303
+ # Create temporary branch on current commit (doesn't checkout)
304
+ # Git operations use check=True, so failures raise CalledProcessError
305
+ ctx.git.create_branch(current_worktree, temp_branch_name, current_branch)
306
+
307
+ # Checkout temporary branch in source worktree to free up the original branch
308
+ ctx.git.checkout_branch(current_worktree, temp_branch_name)
309
+
310
+ # Track temporary branch with Graphite
311
+ ctx.graphite.track_branch(current_worktree, temp_branch_name, current_branch)
312
+
313
+ # Create new worktree with original branch
314
+ # (now available since source is on temp branch)
315
+ ctx.git.add_worktree(
316
+ repo.root,
317
+ new_worktree_path,
318
+ branch=current_branch,
319
+ ref=None,
320
+ create_branch=False,
321
+ )
322
+
323
+ user_output(click.style(f"✅ Created new worktree: {name}", fg="green"))
324
+
325
+ # Change to new worktree directory BEFORE removing source worktree
326
+ # This prevents the shell from being in a deleted directory
327
+ # Always change directory regardless of script mode to ensure we're not in
328
+ # the source worktree when it gets deleted
329
+ if ctx.git.safe_chdir(new_worktree_path):
330
+ # Regenerate context with new cwd (context is immutable)
331
+ ctx = create_context(dry_run=ctx.dry_run)
332
+ user_output(click.style("✅ Changed directory to new worktree", fg="green"))
333
+
334
+ target_worktree_path = new_worktree_path
335
+ else:
336
+ user_output(
337
+ click.style(f"[DRY RUN] Would create new worktree: {name}", fg="yellow", bold=True)
338
+ )
339
+ target_worktree_path = current_worktree # In dry-run, keep current path
340
+ else:
341
+ # Use current worktree as target (existing behavior)
342
+ target_worktree_path = current_worktree
343
+
344
+ # Create consolidation plan using utility function
345
+ # Use the same end_branch logic as calculated above
346
+ plan = create_consolidation_plan(
347
+ all_worktrees=all_worktrees,
348
+ stack_branches=stack_branches,
349
+ end_branch=end_branch,
350
+ target_worktree_path=target_worktree_path,
351
+ source_worktree_path=current_worktree if name is not None else None,
352
+ )
353
+
354
+ # Extract data from plan for easier reference
355
+ worktrees_to_remove = plan.worktrees_to_remove
356
+ stack_to_consolidate = plan.stack_to_consolidate
357
+
358
+ # Display preview
359
+ if not worktrees_to_remove:
360
+ # If using --name, we still need to remove source worktree even if no other worktrees exist
361
+ if name is None:
362
+ user_output("No other worktrees found containing branches from current stack")
363
+ user_output(f"\nCurrent stack branches: {', '.join(stack_branches)}")
364
+ return
365
+ # Continue to source worktree removal when using --name
366
+
367
+ # Collect data for formatted output
368
+ worktrees_to_remove_list: list[tuple[str, Path]] = [
369
+ (wt.branch or "detached", wt.path) for wt in worktrees_to_remove
370
+ ]
371
+
372
+ # Add source worktree to removal list if creating new worktree
373
+ if name is not None:
374
+ worktrees_to_remove_list.append((current_branch, current_worktree))
375
+
376
+ # Display consolidation plan
377
+ user_output()
378
+ plan_output = _format_consolidation_plan(
379
+ stack_branches=stack_branches,
380
+ current_branch=current_branch,
381
+ consolidated_branches=stack_to_consolidate,
382
+ target_name=name if name is not None else str(current_worktree.name),
383
+ worktrees_to_remove=worktrees_to_remove_list,
384
+ )
385
+ user_output(plan_output)
386
+
387
+ # Exit if dry-run
388
+ if dry_run:
389
+ user_output(f"\n{click.style('[DRY RUN] No changes made', fg='yellow', bold=True)}")
390
+ return
391
+
392
+ # Get confirmation unless --force or --script
393
+ if not force and not script:
394
+ user_output()
395
+ if not user_confirm("All worktrees are clean. Proceed with removal?", default=False):
396
+ user_output(click.style("⭕ Aborted", fg="red", bold=True))
397
+ return
398
+
399
+ # Shell integration: generate script to activate new worktree BEFORE destructive operations
400
+ # This ensures the shell can navigate even if later steps fail (e.g., branch deletion).
401
+ # The handler will use this script instead of passthrough when available.
402
+ if name is not None and script and not dry_run:
403
+ script_content = render_activation_script(
404
+ worktree_path=target_worktree_path,
405
+ target_subpath=None,
406
+ post_cd_commands=None,
407
+ final_message='echo "✓ Went to consolidated worktree."',
408
+ comment="work activate-script (consolidate)",
409
+ )
410
+ activation_result = ctx.script_writer.write_activation_script(
411
+ script_content,
412
+ command_name="consolidate",
413
+ comment=f"activate {name}",
414
+ )
415
+ activation_result.output_for_shell_integration()
416
+
417
+ # Remove worktrees and collect paths for progress output
418
+ removed_paths: list[Path] = []
419
+ unassigned_slots: list[str] = []
420
+
421
+ for wt in worktrees_to_remove:
422
+ removed_path, slot_name = _remove_worktree_slot_aware(ctx, repo, wt)
423
+ if removed_path is not None:
424
+ removed_paths.append(removed_path)
425
+ if slot_name is not None:
426
+ unassigned_slots.append(slot_name)
427
+
428
+ # Remove source worktree if a new worktree was created
429
+ if name is not None:
430
+ # Create a temporary WorktreeInfo for the source worktree
431
+ source_wt = WorktreeInfo(
432
+ path=current_worktree.resolve(),
433
+ branch=current_branch,
434
+ is_root=False,
435
+ )
436
+ removed_path, slot_name = _remove_worktree_slot_aware(ctx, repo, source_wt)
437
+ if removed_path is not None:
438
+ removed_paths.append(removed_path)
439
+ if slot_name is not None:
440
+ unassigned_slots.append(slot_name)
441
+
442
+ # Delete temporary branch after source worktree is removed
443
+ # (can't delete while it's checked out in the source worktree)
444
+ if temp_branch_name is not None:
445
+ ctx.git.delete_branch(repo.root, temp_branch_name, force=True)
446
+
447
+ # Display grouped removal progress
448
+ user_output()
449
+ user_output(_format_removal_progress(removed_paths, unassigned_slots))
450
+
451
+ # Prune stale worktree metadata after all removals
452
+ # (explicit call now that remove_worktree no longer auto-prunes)
453
+ ctx.git.prune_worktrees(repo.root)
454
+
455
+ user_output(f"\n{click.style('✅ Consolidation complete', fg='green', bold=True)}")
456
+ user_output()
457
+ user_output("Next step:")
458
+ user_output(" Run 'gt restack' to update branch relationships")
459
+
460
+ # Early return when no worktree switch (consolidating into current worktree)
461
+ # Makes it explicit that no script is needed in this case
462
+ if name is None:
463
+ return # No script needed when not switching worktrees
464
+
465
+ # Manual cd instruction when not in script mode
466
+ # (Script mode already output activation script earlier, before destructive operations)
467
+ if not script and not dry_run:
468
+ user_output(f"Going to worktree: {click.style(name, fg='cyan', bold=True)}")
469
+ user_output(f"\n{click.style('ℹ️', fg='blue')} Run this command to switch:")
470
+ user_output(f" cd {target_worktree_path}")
@@ -0,0 +1,79 @@
1
+ """List worktree stack with branch info."""
2
+
3
+ import click
4
+ from rich.console import Console
5
+ from rich.table import Table
6
+
7
+ from erk.cli.alias import alias
8
+ from erk.cli.core import discover_repo_context
9
+ from erk.cli.graphite import find_worktrees_containing_branch
10
+ from erk.cli.graphite_command import GraphiteCommand
11
+ from erk.core.context import ErkContext
12
+
13
+
14
+ @alias("ls")
15
+ @click.command("list", cls=GraphiteCommand)
16
+ @click.pass_obj
17
+ def list_stack(ctx: ErkContext) -> None:
18
+ """List the worktree stack with branch info.
19
+
20
+ Shows branches in the current stack that have associated worktrees,
21
+ displayed top-to-bottom (upstack children at top, downstack trunk at bottom).
22
+
23
+ Table columns:
24
+ - Marker: → for current branch
25
+ - branch: Branch name
26
+ - worktree: Worktree directory name
27
+ """
28
+ repo = discover_repo_context(ctx, ctx.cwd)
29
+ current_branch = ctx.git.get_current_branch(repo.root)
30
+
31
+ if current_branch is None:
32
+ click.echo("Error: Not on a branch (detached HEAD state)", err=True)
33
+ raise SystemExit(1)
34
+
35
+ # Get the stack for current branch
36
+ stack_branches = ctx.graphite.get_branch_stack(ctx.git, repo.root, current_branch)
37
+ if stack_branches is None:
38
+ click.echo(f"Error: Branch '{current_branch}' is not tracked by Graphite", err=True)
39
+ click.echo("Run 'gt track' to track this branch, or 'gt create' to create a new branch.")
40
+ raise SystemExit(1)
41
+
42
+ # Get worktrees for branch-to-worktree mapping
43
+ worktrees = ctx.git.list_worktrees(repo.root)
44
+
45
+ # Build table
46
+ table = Table(show_header=True, header_style="bold", box=None)
47
+ table.add_column("", no_wrap=True) # Marker column
48
+ table.add_column("branch", style="cyan", no_wrap=True)
49
+ table.add_column("worktree", no_wrap=True)
50
+
51
+ # Show all branches in stack, using ancestor worktree for branches without their own
52
+ # Reverse order: upstack (children) at top, downstack (trunk) at bottom
53
+ for branch in reversed(stack_branches):
54
+ matching_worktrees = find_worktrees_containing_branch(ctx, repo.root, worktrees, branch)
55
+
56
+ if matching_worktrees:
57
+ wt = matching_worktrees[0]
58
+ else:
59
+ # Branch has no direct worktree - find closest ancestor with one
60
+ wt = ctx.graphite.find_ancestor_worktree(ctx.git, repo.root, branch)
61
+ if wt is None:
62
+ continue # Only skip if truly no worktree found
63
+
64
+ wt_name = "root" if wt.is_root else wt.path.name
65
+
66
+ is_current = branch == current_branch
67
+ marker = "→" if is_current else ""
68
+ branch_display = f"[bold cyan]{branch}[/bold cyan]" if is_current else branch
69
+
70
+ table.add_row(marker, branch_display, wt_name)
71
+
72
+ # Check if table has any rows
73
+ if table.row_count == 0:
74
+ click.echo("No branches in stack have worktrees", err=True)
75
+ return
76
+
77
+ # Output table to stderr (consistent with erk wt list)
78
+ console = Console(stderr=True, force_terminal=True)
79
+ console.print(table)