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,332 @@
1
+ """Fast local-only worktree listing command."""
2
+
3
+ from pathlib import Path
4
+
5
+ import click
6
+ from rich.console import Console
7
+ from rich.table import Table
8
+
9
+ from erk.cli.alias import alias
10
+ from erk.cli.core import discover_repo_context
11
+ from erk.core.context import ErkContext
12
+ from erk.core.display_utils import format_relative_time, get_pr_status_emoji
13
+ from erk.core.repo_discovery import RepoContext
14
+ from erk.core.worktree_utils import find_current_worktree
15
+ from erk_shared.git.abc import BranchSyncInfo
16
+ from erk_shared.github.types import GitHubRepoId, PullRequestInfo
17
+ from erk_shared.impl_folder import get_impl_path, read_issue_reference
18
+
19
+
20
+ def _get_sync_status(ctx: ErkContext, worktree_path: Path, branch: str | None) -> str:
21
+ """Get sync status description for a branch.
22
+
23
+ Args:
24
+ ctx: Erk context with git operations
25
+ worktree_path: Path to the worktree (used for git commands)
26
+ branch: Branch name, or None if detached HEAD
27
+
28
+ Returns:
29
+ Sync status: "current", "3↑", "2↓", "3↑ 2↓", or "-"
30
+ """
31
+ if branch is None:
32
+ return "-"
33
+
34
+ # Get tracking info - returns (0, 0) if no tracking branch
35
+ ahead, behind = ctx.git.get_ahead_behind(worktree_path, branch)
36
+
37
+ # Check if this is "no tracking branch" case vs "up to date"
38
+ # The git interface returns (0, 0) for both cases, so we check if there's a tracking branch
39
+ # For now, treat (0, 0) as "current" since it's the most common case
40
+ if ahead == 0 and behind == 0:
41
+ return "current"
42
+
43
+ parts = []
44
+ if ahead > 0:
45
+ parts.append(f"{ahead}↑")
46
+ if behind > 0:
47
+ parts.append(f"{behind}↓")
48
+ return " ".join(parts)
49
+
50
+
51
+ def _format_sync_from_batch(all_sync: dict[str, BranchSyncInfo], branch: str | None) -> str:
52
+ """Format sync status from batch-fetched data.
53
+
54
+ Args:
55
+ all_sync: Dict mapping branch name to BranchSyncInfo
56
+ branch: Branch name, or None if detached HEAD
57
+
58
+ Returns:
59
+ Sync status: "current", "3↑", "2↓", "3↑ 2↓", or "-"
60
+ """
61
+ if branch is None:
62
+ return "-"
63
+
64
+ info = all_sync.get(branch)
65
+ if info is None:
66
+ return "-"
67
+
68
+ if info.ahead == 0 and info.behind == 0:
69
+ return "current"
70
+
71
+ parts = []
72
+ if info.ahead > 0:
73
+ parts.append(f"{info.ahead}↑")
74
+ if info.behind > 0:
75
+ parts.append(f"{info.behind}↓")
76
+ return " ".join(parts)
77
+
78
+
79
+ def _get_impl_issue(
80
+ ctx: ErkContext, worktree_path: Path, branch: str | None = None
81
+ ) -> tuple[str | None, str | None]:
82
+ """Get impl issue number and URL from local sources.
83
+
84
+ Checks .impl/issue.json first, then git config fallback.
85
+
86
+ Args:
87
+ ctx: Erk context with git operations
88
+ worktree_path: Path to the worktree directory
89
+ branch: Optional branch name (avoids redundant git subprocess call if provided)
90
+
91
+ Returns:
92
+ Tuple of (issue number formatted as "#{number}", issue URL) or (None, None) if not found
93
+ """
94
+ # Try .impl/issue.json first
95
+ impl_path = get_impl_path(worktree_path, git_ops=ctx.git)
96
+ if impl_path is not None:
97
+ # impl_path points to plan.md, get the parent .impl/ directory
98
+ issue_ref = read_issue_reference(impl_path.parent)
99
+ if issue_ref is not None:
100
+ return f"#{issue_ref.issue_number}", issue_ref.issue_url
101
+
102
+ # Fallback to git config (no URL available from git config)
103
+ # If branch not provided, fetch it (for backwards compatibility)
104
+ if branch is None:
105
+ branch = ctx.git.get_current_branch(worktree_path)
106
+ if branch is not None:
107
+ issue_num = ctx.git.get_branch_issue(worktree_path, branch)
108
+ if issue_num is not None:
109
+ return f"#{issue_num}", None
110
+
111
+ return None, None
112
+
113
+
114
+ def _format_pr_cell(
115
+ pr: PullRequestInfo | None, *, use_graphite: bool, graphite_url: str | None
116
+ ) -> str:
117
+ """Format PR cell for Rich table: emoji + clickable #number or "-".
118
+
119
+ Args:
120
+ pr: Pull request info, or None if no PR
121
+ use_graphite: If True, use Graphite URL; if False, use GitHub URL
122
+ graphite_url: Graphite URL for the PR (None if unavailable)
123
+
124
+ Returns:
125
+ Formatted string for table cell with Rich link markup
126
+ """
127
+ if pr is None:
128
+ return "-"
129
+
130
+ emoji = get_pr_status_emoji(pr)
131
+ pr_text = f"#{pr.number}"
132
+
133
+ # Determine which URL to use
134
+ url = graphite_url if use_graphite else pr.url
135
+
136
+ # Make PR number clickable if URL is available using Rich [link=...] markup
137
+ if url:
138
+ return f"{emoji} [link={url}]{pr_text}[/link]"
139
+ else:
140
+ return f"{emoji} {pr_text}"
141
+
142
+
143
+ def _format_impl_cell(issue_text: str | None, issue_url: str | None) -> str:
144
+ """Format impl issue cell for Rich table with optional link.
145
+
146
+ Args:
147
+ issue_text: Issue number formatted as "#{number}", or None
148
+ issue_url: Issue URL for clickable link, or None
149
+
150
+ Returns:
151
+ Formatted string for table cell with Rich link markup
152
+ """
153
+ if issue_text is None:
154
+ return "-"
155
+
156
+ if issue_url:
157
+ return f"[link={issue_url}]{issue_text}[/link]"
158
+ else:
159
+ return issue_text
160
+
161
+
162
+ def _format_last_commit_cell(
163
+ ctx: ErkContext, repo_root: Path, branch: str | None, trunk: str
164
+ ) -> str:
165
+ """Format last commit time cell for Rich table.
166
+
167
+ Args:
168
+ ctx: Erk context with git operations
169
+ repo_root: Path to the repository root
170
+ branch: Branch name, or None if detached HEAD
171
+ trunk: Trunk branch name
172
+
173
+ Returns:
174
+ Relative time string (e.g., "2d ago") or "-" if no unique commits
175
+ """
176
+ if branch is None or branch == trunk:
177
+ return "-"
178
+ timestamp = ctx.git.get_branch_last_commit_time(repo_root, branch, trunk)
179
+ if timestamp is None:
180
+ return "-"
181
+ relative_time = format_relative_time(timestamp)
182
+ return relative_time if relative_time else "-"
183
+
184
+
185
+ def _list_worktrees(ctx: ErkContext, *, show_last_commit: bool = False) -> None:
186
+ """List worktrees with fast local-only data.
187
+
188
+ Shows a Rich table with columns:
189
+ - worktree: Directory name with cwd indicator
190
+ - branch: Branch name or (=) if matches worktree name
191
+ - pr: PR emoji + number from Graphite cache
192
+ - sync: Ahead/behind status
193
+ - impl: Issue number from .impl/issue.json
194
+ """
195
+ # Use ctx.repo if it's a valid RepoContext, otherwise discover
196
+ if isinstance(ctx.repo, RepoContext):
197
+ repo = ctx.repo
198
+ else:
199
+ repo = discover_repo_context(ctx, ctx.cwd)
200
+
201
+ current_dir = ctx.cwd
202
+
203
+ # Get worktree info
204
+ worktrees = ctx.git.list_worktrees(repo.root)
205
+
206
+ # Fetch all branch sync info in a single git call (batch operation for performance)
207
+ all_sync_info = ctx.git.get_all_branch_sync_info(repo.root)
208
+
209
+ # Determine which worktree the user is currently in
210
+ wt_info = find_current_worktree(worktrees, current_dir)
211
+ current_worktree_path = wt_info.path if wt_info is not None else None
212
+
213
+ # Fetch PR information from Graphite cache (graceful degradation)
214
+ prs: dict[str, PullRequestInfo] = {}
215
+ if ctx.global_config and ctx.global_config.show_pr_info:
216
+ graphite_prs = ctx.graphite.get_prs_from_graphite(ctx.git, repo.root)
217
+ if graphite_prs:
218
+ prs = graphite_prs
219
+ # If Graphite cache is missing, prs stays empty - graceful degradation
220
+
221
+ # Determine use_graphite for URL selection
222
+ use_graphite = ctx.global_config.use_graphite if ctx.global_config else False
223
+
224
+ # Get trunk branch once if showing last commit
225
+ trunk = ctx.git.detect_trunk_branch(repo.root) if show_last_commit else ""
226
+
227
+ # Create Rich table
228
+ table = Table(show_header=True, header_style="bold", box=None)
229
+ table.add_column("worktree", style="cyan", no_wrap=True)
230
+ table.add_column("branch", style="yellow", no_wrap=True)
231
+ table.add_column("pr", no_wrap=True)
232
+ table.add_column("sync", no_wrap=True)
233
+ if show_last_commit:
234
+ table.add_column("last", no_wrap=True)
235
+ table.add_column("impl", no_wrap=True)
236
+
237
+ # Build rows starting with root worktree
238
+ root_branch = None
239
+ for wt in worktrees:
240
+ if wt.path == repo.root:
241
+ root_branch = wt.branch
242
+ break
243
+
244
+ # Root worktree row
245
+ is_current_root = repo.root == current_worktree_path
246
+ root_name = "root"
247
+ if is_current_root:
248
+ root_name = "[green bold]root[/green bold] ← (cwd)"
249
+ else:
250
+ root_name = "[green bold]root[/green bold]"
251
+
252
+ root_branch_display = f"({root_branch})" if root_branch else "-"
253
+ root_pr = prs.get(root_branch) if root_branch else None
254
+ root_graphite_url = (
255
+ ctx.graphite.get_graphite_url(GitHubRepoId(root_pr.owner, root_pr.repo), root_pr.number)
256
+ if root_pr
257
+ else None
258
+ )
259
+ root_pr_cell = _format_pr_cell(
260
+ root_pr, use_graphite=use_graphite, graphite_url=root_graphite_url
261
+ )
262
+ root_sync = _format_sync_from_batch(all_sync_info, root_branch)
263
+ root_impl_text, root_impl_url = _get_impl_issue(ctx, repo.root, root_branch)
264
+ root_impl_cell = _format_impl_cell(root_impl_text, root_impl_url)
265
+
266
+ if show_last_commit:
267
+ root_last_cell = _format_last_commit_cell(ctx, repo.root, root_branch, trunk)
268
+ table.add_row(
269
+ root_name, root_branch_display, root_pr_cell, root_sync, root_last_cell, root_impl_cell
270
+ )
271
+ else:
272
+ table.add_row(root_name, root_branch_display, root_pr_cell, root_sync, root_impl_cell)
273
+
274
+ # Non-root worktrees, sorted by name
275
+ non_root_worktrees = [wt for wt in worktrees if wt.path != repo.root]
276
+ for wt in sorted(non_root_worktrees, key=lambda w: w.path.name):
277
+ name = wt.path.name
278
+ branch = wt.branch
279
+ is_current = wt.path == current_worktree_path
280
+
281
+ # Format name with cwd indicator if current
282
+ if is_current:
283
+ name_cell = f"[cyan bold]{name}[/cyan bold] ← (cwd)"
284
+ else:
285
+ name_cell = f"[cyan]{name}[/cyan]"
286
+
287
+ # Branch display: (=) if matches name, else (branch-name)
288
+ if branch is not None:
289
+ branch_display = "(=)" if name == branch else f"({branch})"
290
+ else:
291
+ branch_display = "-"
292
+
293
+ # PR info from Graphite cache
294
+ pr = prs.get(branch) if branch else None
295
+ graphite_url = None
296
+ if pr:
297
+ graphite_url = ctx.graphite.get_graphite_url(GitHubRepoId(pr.owner, pr.repo), pr.number)
298
+ pr_cell = _format_pr_cell(pr, use_graphite=use_graphite, graphite_url=graphite_url)
299
+
300
+ # Sync status
301
+ sync_cell = _format_sync_from_batch(all_sync_info, branch)
302
+
303
+ # Impl issue
304
+ impl_text, impl_url = _get_impl_issue(ctx, wt.path, branch)
305
+ impl_cell = _format_impl_cell(impl_text, impl_url)
306
+
307
+ if show_last_commit:
308
+ last_cell = _format_last_commit_cell(ctx, repo.root, branch, trunk)
309
+ table.add_row(name_cell, branch_display, pr_cell, sync_cell, last_cell, impl_cell)
310
+ else:
311
+ table.add_row(name_cell, branch_display, pr_cell, sync_cell, impl_cell)
312
+
313
+ # Output table to stderr (consistent with user_output convention)
314
+ console = Console(stderr=True, force_terminal=True)
315
+ console.print(table)
316
+
317
+
318
+ @alias("ls")
319
+ @click.command("list")
320
+ @click.pass_obj
321
+ def list_wt(ctx: ErkContext) -> None:
322
+ """List worktrees with branch, PR, sync, and implementation info.
323
+
324
+ Shows a fast local-only table with:
325
+ - worktree: Directory name
326
+ - branch: Branch name (or = if matches worktree name)
327
+ - pr: PR status from Graphite cache
328
+ - sync: Ahead/behind status vs tracking branch
329
+ - last: Last commit time
330
+ - impl: Implementation issue number
331
+ """
332
+ _list_worktrees(ctx, show_last_commit=True)
@@ -0,0 +1,66 @@
1
+ import click
2
+
3
+ from erk.cli.commands.completions import complete_worktree_names
4
+ from erk.cli.commands.wt.create_cmd import make_env_content
5
+ from erk.cli.core import discover_repo_context, worktree_path_for
6
+ from erk.cli.ensure import Ensure
7
+ from erk.core.context import ErkContext, create_context
8
+ from erk.core.repo_discovery import ensure_erk_metadata_dir
9
+ from erk_shared.naming import sanitize_worktree_name
10
+ from erk_shared.output.output import user_output
11
+
12
+
13
+ @click.command("rename")
14
+ @click.argument("old_name", metavar="OLD_NAME", shell_complete=complete_worktree_names)
15
+ @click.argument("new_name", metavar="NEW_NAME")
16
+ @click.option(
17
+ "--dry-run",
18
+ is_flag=True,
19
+ # dry_run=False: Allow destructive operations by default
20
+ default=False,
21
+ help="Print what would be done without executing destructive operations.",
22
+ )
23
+ @click.pass_obj
24
+ def rename_wt(ctx: ErkContext, old_name: str, new_name: str, dry_run: bool) -> None:
25
+ """Rename a worktree directory.
26
+
27
+ Renames the worktree directory and updates git metadata.
28
+ The .env file is regenerated with updated paths and name.
29
+ """
30
+ # Create dry-run context if needed
31
+ if dry_run:
32
+ ctx = create_context(dry_run=True)
33
+
34
+ # Sanitize new name
35
+ sanitized_new_name = sanitize_worktree_name(new_name)
36
+
37
+ repo = discover_repo_context(ctx, ctx.cwd)
38
+ ensure_erk_metadata_dir(repo)
39
+
40
+ old_path = worktree_path_for(repo.worktrees_dir, old_name)
41
+ new_path = worktree_path_for(repo.worktrees_dir, sanitized_new_name)
42
+
43
+ # Validate old worktree exists
44
+ Ensure.path_exists(ctx, old_path, f"Worktree not found: {old_path}")
45
+
46
+ # Validate new path doesn't already exist
47
+ Ensure.invariant(not ctx.git.path_exists(new_path), f"Destination already exists: {new_path}")
48
+
49
+ # Move via git worktree move
50
+ ctx.git.move_worktree(repo.root, old_path, new_path)
51
+
52
+ # Regenerate .env file with updated paths and name
53
+ cfg = ctx.local_config
54
+ env_content = make_env_content(
55
+ cfg, worktree_path=new_path, repo_root=repo.root, name=sanitized_new_name
56
+ )
57
+
58
+ # Write .env file (dry-run vs real)
59
+ env_file = new_path / ".env"
60
+ if ctx.dry_run:
61
+ user_output(f"[DRY RUN] Would write .env file: {env_file}")
62
+ else:
63
+ env_file.write_text(env_content, encoding="utf-8")
64
+
65
+ user_output(f"Renamed worktree: {old_name} -> {sanitized_new_name}")
66
+ user_output(str(new_path))
erk/cli/config.py ADDED
@@ -0,0 +1,242 @@
1
+ import tomllib
2
+ from dataclasses import dataclass
3
+ from pathlib import Path
4
+
5
+ # Re-export LoadedConfig from erk_shared for backwards compatibility
6
+ from erk_shared.context.types import LoadedConfig as LoadedConfig
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class ProjectConfig:
11
+ """In-memory representation of `.erk/project.toml`.
12
+
13
+ Example project.toml:
14
+ # Optional: custom name (defaults to directory name)
15
+ # name = "dagster-open-platform"
16
+
17
+ [env]
18
+ # Project-specific env vars (merged with repo-level)
19
+ DAGSTER_HOME = "{project_root}"
20
+
21
+ [post_create]
22
+ # Runs AFTER repo-level commands, FROM project directory
23
+ shell = "bash"
24
+ commands = [
25
+ "source .venv/bin/activate",
26
+ ]
27
+ """
28
+
29
+ name: str | None # Custom project name (None = use directory name)
30
+ env: dict[str, str]
31
+ post_create_commands: list[str]
32
+ post_create_shell: str | None
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class LegacyConfigLocation:
37
+ """Information about a detected legacy config location."""
38
+
39
+ path: Path
40
+ description: str
41
+
42
+
43
+ def _parse_config_file(cfg_path: Path) -> LoadedConfig:
44
+ """Parse a config.toml file into a LoadedConfig.
45
+
46
+ Args:
47
+ cfg_path: Path to the config.toml file (must exist)
48
+
49
+ Returns:
50
+ LoadedConfig with parsed values
51
+ """
52
+ data = tomllib.loads(cfg_path.read_text(encoding="utf-8"))
53
+ env = {str(k): str(v) for k, v in data.get("env", {}).items()}
54
+ post = data.get("post_create", {})
55
+ commands = [str(x) for x in post.get("commands", [])]
56
+ shell = post.get("shell")
57
+ if shell is not None:
58
+ shell = str(shell)
59
+
60
+ # Parse [plans] section
61
+ plans = data.get("plans", {})
62
+ plans_repo = plans.get("repo")
63
+ if plans_repo is not None:
64
+ plans_repo = str(plans_repo)
65
+
66
+ # Parse [pool] section
67
+ pool = data.get("pool", {})
68
+ pool_size = pool.get("max_slots")
69
+ if pool_size is not None:
70
+ pool_size = int(pool_size)
71
+
72
+ # Parse [pool.checkout] section
73
+ pool_checkout = pool.get("checkout", {})
74
+ pool_checkout_commands = [str(x) for x in pool_checkout.get("commands", [])]
75
+ pool_checkout_shell = pool_checkout.get("shell")
76
+ if pool_checkout_shell is not None:
77
+ pool_checkout_shell = str(pool_checkout_shell)
78
+
79
+ return LoadedConfig(
80
+ env=env,
81
+ post_create_commands=commands,
82
+ post_create_shell=shell,
83
+ plans_repo=plans_repo,
84
+ pool_size=pool_size,
85
+ pool_checkout_commands=pool_checkout_commands,
86
+ pool_checkout_shell=pool_checkout_shell,
87
+ )
88
+
89
+
90
+ def detect_legacy_config_locations(
91
+ repo_root: Path, legacy_metadata_dir: Path | None
92
+ ) -> list[LegacyConfigLocation]:
93
+ """Detect legacy config.toml files that should be migrated.
94
+
95
+ Legacy locations:
96
+ 1. <repo-root>/config.toml (created by 'erk init --repo')
97
+ 2. ~/.erk/repos/<repo>/config.toml (created by 'erk init' without --repo)
98
+
99
+ Args:
100
+ repo_root: Path to the repository root
101
+ legacy_metadata_dir: Path to ~/.erk/repos/<repo>/ directory (or None)
102
+
103
+ Returns:
104
+ List of detected legacy config locations
105
+ """
106
+ legacy_locations: list[LegacyConfigLocation] = []
107
+
108
+ # Check for config at repo root (created by 'erk init --repo')
109
+ repo_root_config = repo_root / "config.toml"
110
+ if repo_root_config.exists():
111
+ legacy_locations.append(
112
+ LegacyConfigLocation(
113
+ path=repo_root_config,
114
+ description="repo root (created by 'erk init --repo')",
115
+ )
116
+ )
117
+
118
+ # Check for config in ~/.erk/repos/<repo>/ (created by 'erk init')
119
+ if legacy_metadata_dir is not None:
120
+ metadata_dir_config = legacy_metadata_dir / "config.toml"
121
+ if metadata_dir_config.exists():
122
+ legacy_locations.append(
123
+ LegacyConfigLocation(
124
+ path=metadata_dir_config,
125
+ description=f"~/.erk/repos/ metadata dir ({legacy_metadata_dir})",
126
+ )
127
+ )
128
+
129
+ return legacy_locations
130
+
131
+
132
+ def load_config(repo_root: Path) -> LoadedConfig:
133
+ """Load config.toml for a repository.
134
+
135
+ Location: <repo-root>/.erk/config.toml
136
+
137
+ Example config:
138
+ [env]
139
+ DAGSTER_GIT_REPO_DIR = "{worktree_path}"
140
+
141
+ [post_create]
142
+ shell = "bash"
143
+ commands = [
144
+ "uv venv",
145
+ "uv run make dev_install",
146
+ ]
147
+
148
+ Note: Legacy config locations (repo root, ~/.erk/repos/) are NOT supported here.
149
+ Run 'erk doctor' to detect legacy configs that need migration.
150
+
151
+ Args:
152
+ repo_root: Path to the repository root
153
+
154
+ Returns:
155
+ LoadedConfig with parsed values or defaults if no config found
156
+ """
157
+ config_path = repo_root / ".erk" / "config.toml"
158
+ if config_path.exists():
159
+ return _parse_config_file(config_path)
160
+
161
+ # No config found
162
+ return LoadedConfig(
163
+ env={},
164
+ post_create_commands=[],
165
+ post_create_shell=None,
166
+ plans_repo=None,
167
+ pool_size=None,
168
+ pool_checkout_commands=[],
169
+ pool_checkout_shell=None,
170
+ )
171
+
172
+
173
+ def load_project_config(project_root: Path) -> ProjectConfig:
174
+ """Load project.toml from the project's .erk directory.
175
+
176
+ Args:
177
+ project_root: Path to the project root directory
178
+
179
+ Returns:
180
+ ProjectConfig with parsed values, or defaults if file doesn't exist
181
+ """
182
+ cfg_path = project_root / ".erk" / "project.toml"
183
+ if not cfg_path.exists():
184
+ return ProjectConfig(name=None, env={}, post_create_commands=[], post_create_shell=None)
185
+
186
+ data = tomllib.loads(cfg_path.read_text(encoding="utf-8"))
187
+
188
+ # Optional name field
189
+ name = data.get("name")
190
+ if name is not None:
191
+ name = str(name)
192
+
193
+ # Env vars
194
+ env = {str(k): str(v) for k, v in data.get("env", {}).items()}
195
+
196
+ # Post-create commands
197
+ post = data.get("post_create", {})
198
+ commands = [str(x) for x in post.get("commands", [])]
199
+ shell = post.get("shell")
200
+ if shell is not None:
201
+ shell = str(shell)
202
+
203
+ return ProjectConfig(name=name, env=env, post_create_commands=commands, post_create_shell=shell)
204
+
205
+
206
+ def merge_configs(repo_config: LoadedConfig, project_config: ProjectConfig) -> LoadedConfig:
207
+ """Merge repo-level and project-level configs.
208
+
209
+ Merge rules:
210
+ - env: Project values override repo values (dict merge)
211
+ - post_create_commands: Repo commands run first, then project commands (list concat)
212
+ - post_create_shell: Project shell overrides repo shell if set
213
+
214
+ Args:
215
+ repo_config: Repository-level configuration
216
+ project_config: Project-level configuration
217
+
218
+ Returns:
219
+ Merged LoadedConfig
220
+ """
221
+ # Merge env: project overrides repo
222
+ merged_env = {**repo_config.env, **project_config.env}
223
+
224
+ # Concat commands: repo first, then project
225
+ merged_commands = repo_config.post_create_commands + project_config.post_create_commands
226
+
227
+ # Shell: project overrides if set
228
+ merged_shell = (
229
+ project_config.post_create_shell
230
+ if project_config.post_create_shell is not None
231
+ else repo_config.post_create_shell
232
+ )
233
+
234
+ return LoadedConfig(
235
+ env=merged_env,
236
+ post_create_commands=merged_commands,
237
+ post_create_shell=merged_shell,
238
+ plans_repo=repo_config.plans_repo,
239
+ pool_size=repo_config.pool_size, # Pool is repo-level only, no project override
240
+ pool_checkout_commands=repo_config.pool_checkout_commands,
241
+ pool_checkout_shell=repo_config.pool_checkout_shell,
242
+ )
erk/cli/constants.py ADDED
@@ -0,0 +1,29 @@
1
+ """Shared constants for erk CLI commands."""
2
+
3
+ # GitHub issue label for erk plans
4
+ ERK_PLAN_LABEL = "erk-plan"
5
+
6
+ # GitHub Actions workflow for remote implementation dispatch
7
+ DISPATCH_WORKFLOW_NAME = "erk-impl.yml"
8
+ DISPATCH_WORKFLOW_METADATA_NAME = "erk-impl"
9
+
10
+ # Workflow names that trigger the autofix workflow
11
+ # Must match the `name:` field in each .yml file (which should match filename without .yml)
12
+ AUTOFIX_TRIGGER_WORKFLOWS = frozenset(
13
+ {
14
+ "python-format",
15
+ "lint",
16
+ "docs-check",
17
+ "markdown-format",
18
+ }
19
+ )
20
+
21
+ # Documentation extraction tracking label
22
+ DOCS_EXTRACTED_LABEL = "docs-extracted"
23
+ DOCS_EXTRACTED_LABEL_DESCRIPTION = "Session logs analyzed for documentation improvements"
24
+ DOCS_EXTRACTED_LABEL_COLOR = "5319E7" # Purple
25
+
26
+ # Extraction plan label (for plans that extract documentation from sessions)
27
+ ERK_EXTRACTION_LABEL = "erk-extraction"
28
+ ERK_EXTRACTION_LABEL_DESCRIPTION = "Documentation extraction plan"
29
+ ERK_EXTRACTION_LABEL_COLOR = "D93F0B" # Orange-red