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,75 @@
1
+ """Workflow run display utilities for worktree listings.
2
+
3
+ This module provides helpers for fetching and formatting workflow run status
4
+ information for worktrees with associated GitHub issues.
5
+ """
6
+
7
+ from pathlib import Path
8
+
9
+ from erk_shared.github.abc import GitHub
10
+ from erk_shared.github.issues import GitHubIssues
11
+ from erk_shared.github.status_history import extract_workflow_run_id
12
+ from erk_shared.github.types import WorkflowRun
13
+ from erk_shared.impl_folder import read_issue_reference
14
+
15
+
16
+ def get_workflow_run_for_worktree(
17
+ worktree_path: Path,
18
+ github: GitHub,
19
+ github_issues: GitHubIssues,
20
+ repo_root: Path,
21
+ ) -> tuple[WorkflowRun | None, str | None]:
22
+ """Get workflow run information for a worktree.
23
+
24
+ Args:
25
+ worktree_path: Path to the worktree directory
26
+ github: GitHub operations interface
27
+ github_issues: GitHub issues interface
28
+ repo_root: Repository root directory
29
+
30
+ Returns:
31
+ Tuple of (WorkflowRun, workflow_url) or (None, None) if no workflow found
32
+ """
33
+ # Check if worktree has .impl/issue.json
34
+ impl_dir = worktree_path / ".impl"
35
+ # Handle sentinel paths in tests (they raise RuntimeError on .exists())
36
+ # This is acceptable here because we're just checking for existence
37
+ # and returning early if not found - not using exceptions for control flow
38
+ try:
39
+ if not impl_dir.exists():
40
+ return (None, None)
41
+ except RuntimeError:
42
+ # Sentinel path in tests - treat as non-existent
43
+ return (None, None)
44
+
45
+ issue_ref = read_issue_reference(impl_dir)
46
+ if issue_ref is None:
47
+ return (None, None)
48
+
49
+ # Fetch issue comments (returns list of comment body strings)
50
+ comment_bodies = github_issues.get_issue_comments(repo_root, issue_ref.issue_number)
51
+ if not comment_bodies:
52
+ return (None, None)
53
+
54
+ # Extract workflow run ID from comments
55
+ run_id = extract_workflow_run_id(comment_bodies)
56
+ if run_id is None:
57
+ return (None, None)
58
+
59
+ # Fetch workflow run details
60
+ workflow_run = github.get_workflow_run(repo_root, run_id)
61
+ if workflow_run is None:
62
+ return (None, None)
63
+
64
+ # Build workflow URL
65
+ # Extract owner/repo from issue URL if available
66
+ workflow_url = None
67
+ if issue_ref.issue_url:
68
+ # Parse owner/repo from URL like https://github.com/owner/repo/issues/123
69
+ parts = issue_ref.issue_url.split("/")
70
+ if len(parts) >= 5:
71
+ owner = parts[-4]
72
+ repo = parts[-3]
73
+ workflow_url = f"https://github.com/{owner}/{repo}/actions/runs/{run_id}"
74
+
75
+ return (workflow_run, workflow_url)
@@ -0,0 +1,190 @@
1
+ """Worktree pool state management.
2
+
3
+ Provides dataclasses and persistence functions for managing a pool of
4
+ pre-created worktrees that can be assigned to branches on demand.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class SlotInfo:
16
+ """Represents an initialized pool slot.
17
+
18
+ A slot that has been pre-created with a worktree and placeholder branch,
19
+ ready for assignment to feature branches.
20
+
21
+ Attributes:
22
+ name: The pool slot identifier (e.g., "erk-managed-wt-01")
23
+ last_objective_issue: Issue number of the last objective worked on in this slot.
24
+ Persists across assignment cycles so /erk:objective-next-plan can default to it.
25
+ """
26
+
27
+ name: str
28
+ last_objective_issue: int | None
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class SlotAssignment:
33
+ """Represents a branch assignment to a worktree slot.
34
+
35
+ Attributes:
36
+ slot_name: The pool slot identifier (e.g., "erk-managed-wt-01")
37
+ branch_name: The git branch assigned to this slot
38
+ assigned_at: ISO timestamp when the assignment was made
39
+ worktree_path: Filesystem path to the worktree directory
40
+ """
41
+
42
+ slot_name: str
43
+ branch_name: str
44
+ assigned_at: str
45
+ worktree_path: Path
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class PoolState:
50
+ """Represents the complete state of the worktree pool.
51
+
52
+ Attributes:
53
+ version: Schema version for forward compatibility
54
+ pool_size: Maximum number of slots in the pool
55
+ slots: Tuple of initialized slots (may or may not have assignments)
56
+ assignments: Tuple of current slot assignments (immutable)
57
+ """
58
+
59
+ version: str
60
+ pool_size: int
61
+ slots: tuple[SlotInfo, ...]
62
+ assignments: tuple[SlotAssignment, ...]
63
+
64
+ @classmethod
65
+ def test(
66
+ cls,
67
+ *,
68
+ version: str | None = None,
69
+ pool_size: int | None = None,
70
+ slots: tuple[SlotInfo, ...] | None = None,
71
+ assignments: tuple[SlotAssignment, ...] | None = None,
72
+ ) -> PoolState:
73
+ """Create a PoolState with sensible test defaults.
74
+
75
+ All parameters are optional and use sensible defaults for testing:
76
+ - version: "1.0"
77
+ - pool_size: 4
78
+ - slots: ()
79
+ - assignments: ()
80
+ """
81
+ return cls(
82
+ version=version if version is not None else "1.0",
83
+ pool_size=pool_size if pool_size is not None else 4,
84
+ slots=slots if slots is not None else (),
85
+ assignments=assignments if assignments is not None else (),
86
+ )
87
+
88
+
89
+ def load_pool_state(pool_json_path: Path) -> PoolState | None:
90
+ """Load pool state from JSON file.
91
+
92
+ Args:
93
+ pool_json_path: Path to the pool.json file
94
+
95
+ Returns:
96
+ PoolState if file exists and is valid, None otherwise
97
+ """
98
+ if not pool_json_path.exists():
99
+ return None
100
+
101
+ content = pool_json_path.read_text(encoding="utf-8")
102
+ data = json.loads(content)
103
+
104
+ slots = tuple(
105
+ SlotInfo(name=s["name"], last_objective_issue=s.get("last_objective_issue"))
106
+ for s in data.get("slots", [])
107
+ )
108
+
109
+ assignments = tuple(
110
+ SlotAssignment(
111
+ slot_name=a["slot_name"],
112
+ branch_name=a["branch_name"],
113
+ assigned_at=a["assigned_at"],
114
+ worktree_path=Path(a["worktree_path"]),
115
+ )
116
+ for a in data.get("assignments", [])
117
+ )
118
+
119
+ return PoolState(
120
+ version=data.get("version", "1.0"),
121
+ pool_size=data.get("pool_size", 4),
122
+ slots=slots,
123
+ assignments=assignments,
124
+ )
125
+
126
+
127
+ def save_pool_state(pool_json_path: Path, state: PoolState) -> None:
128
+ """Save pool state to JSON file.
129
+
130
+ Creates parent directories if they don't exist.
131
+
132
+ Args:
133
+ pool_json_path: Path to the pool.json file
134
+ state: Pool state to persist
135
+ """
136
+ pool_json_path.parent.mkdir(parents=True, exist_ok=True)
137
+
138
+ data = {
139
+ "version": state.version,
140
+ "pool_size": state.pool_size,
141
+ "slots": [
142
+ {"name": s.name, "last_objective_issue": s.last_objective_issue} for s in state.slots
143
+ ],
144
+ "assignments": [
145
+ {
146
+ "slot_name": a.slot_name,
147
+ "branch_name": a.branch_name,
148
+ "assigned_at": a.assigned_at,
149
+ "worktree_path": str(a.worktree_path),
150
+ }
151
+ for a in state.assignments
152
+ ],
153
+ }
154
+
155
+ pool_json_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
156
+
157
+
158
+ def update_slot_objective(
159
+ state: PoolState, slot_name: str, objective_issue: int | None
160
+ ) -> PoolState:
161
+ """Return new PoolState with slot's last_objective_issue updated.
162
+
163
+ Args:
164
+ state: Current pool state
165
+ slot_name: Name of the slot to update
166
+ objective_issue: Issue number to set, or None to clear
167
+
168
+ Returns:
169
+ New PoolState with the updated slot. If slot_name is not found,
170
+ returns state unchanged.
171
+ """
172
+ new_slots: list[SlotInfo] = []
173
+ found = False
174
+
175
+ for slot in state.slots:
176
+ if slot.name == slot_name:
177
+ new_slots.append(SlotInfo(name=slot.name, last_objective_issue=objective_issue))
178
+ found = True
179
+ else:
180
+ new_slots.append(slot)
181
+
182
+ if not found:
183
+ return state
184
+
185
+ return PoolState(
186
+ version=state.version,
187
+ pool_size=state.pool_size,
188
+ slots=tuple(new_slots),
189
+ assignments=state.assignments,
190
+ )
@@ -0,0 +1,300 @@
1
+ """Utility functions for worktree operations.
2
+
3
+ This module provides pure business logic functions for worktree operations,
4
+ separated from I/O and CLI concerns. These functions work with data objects
5
+ (WorktreeInfo) and enable fast unit testing.
6
+ """
7
+
8
+ from dataclasses import dataclass
9
+ from enum import Enum
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from erk_shared.git.abc import WorktreeInfo
14
+
15
+
16
+ class MoveOperationType(Enum):
17
+ """Type of move operation to perform between worktrees."""
18
+
19
+ MOVE = "move" # Source has branch, target doesn't exist or is detached
20
+ SWAP = "swap" # Both source and target have branches
21
+ CREATE = "create" # Target doesn't exist
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class MoveOperation:
26
+ """Represents a move operation between worktrees.
27
+
28
+ Attributes:
29
+ operation_type: Type of operation (move, swap, or create)
30
+ source_path: Path to source worktree
31
+ target_path: Path to target worktree
32
+ source_branch: Branch in source worktree (None if detached)
33
+ target_branch: Branch in target worktree (None if doesn't exist or detached)
34
+ """
35
+
36
+ operation_type: MoveOperationType
37
+ source_path: Path
38
+ target_path: Path
39
+ source_branch: str | None
40
+ target_branch: str | None
41
+
42
+
43
+ def compute_relative_path_in_worktree(
44
+ worktrees: list[WorktreeInfo],
45
+ current_dir: Path,
46
+ ) -> Path | None:
47
+ """Compute relative path from current worktree root to current_dir.
48
+
49
+ Used to preserve the user's relative position when switching worktrees.
50
+ For example, if the user is at /repo/worktrees/feat/src/lib, switching
51
+ to another worktree should land them at <target>/src/lib if it exists.
52
+
53
+ Args:
54
+ worktrees: List of WorktreeInfo objects to search
55
+ current_dir: Current directory path (will be resolved internally)
56
+
57
+ Returns:
58
+ Relative path from worktree root to current_dir, or None if:
59
+ - current_dir is at the worktree root (relative path would be '.')
60
+ - current_dir is not within any known worktree
61
+
62
+ Examples:
63
+ >>> worktrees = [WorktreeInfo(Path("/repo/wt/feat"), "feat", False)]
64
+ >>> compute_relative_path_in_worktree(worktrees, Path("/repo/wt/feat/src/lib"))
65
+ Path("src/lib")
66
+ >>> compute_relative_path_in_worktree(worktrees, Path("/repo/wt/feat"))
67
+ None # At worktree root
68
+ """
69
+ worktree_root = find_worktree_containing_path(worktrees, current_dir)
70
+ if worktree_root is None:
71
+ return None
72
+
73
+ # Resolve both paths for reliable comparison
74
+ resolved_current = current_dir.resolve()
75
+ resolved_root = worktree_root.resolve()
76
+
77
+ # If at worktree root, return None (no relative subpath)
78
+ if resolved_current == resolved_root:
79
+ return None
80
+
81
+ # Compute relative path
82
+ relative = resolved_current.relative_to(resolved_root)
83
+
84
+ return relative
85
+
86
+
87
+ def find_worktree_containing_path(worktrees: list[WorktreeInfo], target_path: Path) -> Path | None:
88
+ """Find which worktree contains the given path.
89
+
90
+ Returns the most specific (deepest) match to handle nested worktrees correctly.
91
+ Handles symlink resolution differences (e.g., /var vs /private/var on macOS).
92
+
93
+ Args:
94
+ worktrees: List of WorktreeInfo objects to search
95
+ target_path: Path to check (will be resolved internally)
96
+
97
+ Returns:
98
+ Path to the worktree that contains target_path, or None if not found
99
+
100
+ Examples:
101
+ >>> worktrees = [WorktreeInfo(Path("/repo"), "main", True),
102
+ ... WorktreeInfo(Path("/repo/erks/feat"), "feat", False)]
103
+ >>> find_worktree_containing_path(worktrees, Path("/repo/erks/feat/src"))
104
+ Path("/repo/erks/feat") # Returns deepest match
105
+ """
106
+ best_match: Path | None = None
107
+ best_match_depth = -1
108
+
109
+ # Resolve target_path to handle symlinks consistently
110
+ resolved_target = target_path.resolve()
111
+
112
+ for wt in worktrees:
113
+ wt_path = wt.path.resolve()
114
+
115
+ # Check if target_path is within this worktree
116
+ # is_relative_to() returns True if target_path is under wt_path
117
+ if resolved_target.is_relative_to(wt_path):
118
+ # Count path depth to find most specific match
119
+ depth = len(wt_path.parts)
120
+ if depth > best_match_depth:
121
+ best_match = wt_path
122
+ best_match_depth = depth
123
+
124
+ return best_match
125
+
126
+
127
+ def find_current_worktree(worktrees: list[WorktreeInfo], current_dir: Path) -> WorktreeInfo | None:
128
+ """Find the WorktreeInfo object for the worktree containing current_dir.
129
+
130
+ Higher-level wrapper around find_worktree_containing_path that returns
131
+ the full WorktreeInfo object instead of just the path.
132
+
133
+ Args:
134
+ worktrees: List of WorktreeInfo objects to search
135
+ current_dir: Current directory path (should be resolved)
136
+
137
+ Returns:
138
+ WorktreeInfo object if found, None if not in any worktree
139
+
140
+ Examples:
141
+ >>> worktrees = [WorktreeInfo(Path("/repo"), "main", True)]
142
+ >>> find_current_worktree(worktrees, Path("/repo/src"))
143
+ WorktreeInfo(path=Path("/repo"), branch="main", is_root=True)
144
+ """
145
+ wt_path = find_worktree_containing_path(worktrees, current_dir)
146
+ if wt_path is None:
147
+ return None
148
+
149
+ # Find and return the matching WorktreeInfo object
150
+ for wt in worktrees:
151
+ if wt.path.resolve() == wt_path:
152
+ return wt
153
+
154
+ return None
155
+
156
+
157
+ def is_root_worktree(worktree_path: Path, repo_root: Path) -> bool:
158
+ """Check if a worktree path is the repository root worktree.
159
+
160
+ Compares resolved paths to determine if the worktree is the root.
161
+
162
+ Args:
163
+ worktree_path: Path to the worktree to check
164
+ repo_root: Path to the repository root
165
+
166
+ Returns:
167
+ True if worktree_path is the root worktree, False otherwise
168
+
169
+ Examples:
170
+ >>> is_root_worktree(Path("/repo"), Path("/repo"))
171
+ True
172
+ >>> is_root_worktree(Path("/repo/erks/feat"), Path("/repo"))
173
+ False
174
+ """
175
+ return worktree_path.resolve() == repo_root.resolve()
176
+
177
+
178
+ def get_worktree_branch(worktrees: list[WorktreeInfo], wt_path: Path) -> str | None:
179
+ """Get the branch checked out in a worktree.
180
+
181
+ Returns None if worktree is not found or is in detached HEAD state.
182
+
183
+ Args:
184
+ worktrees: List of WorktreeInfo objects to search
185
+ wt_path: Path to the worktree
186
+
187
+ Returns:
188
+ Branch name if found and checked out, None otherwise
189
+
190
+ Examples:
191
+ >>> worktrees = [WorktreeInfo(Path("/repo/erks/feat"), "feature-x", False)]
192
+ >>> get_worktree_branch(worktrees, Path("/repo/erks/feat"))
193
+ "feature-x"
194
+ >>> get_worktree_branch(worktrees, Path("/repo/erks/other"))
195
+ None
196
+ """
197
+ # Resolve paths for comparison to handle relative vs absolute paths
198
+ wt_path_resolved = wt_path.resolve()
199
+ for wt in worktrees:
200
+ if wt.path.resolve() == wt_path_resolved:
201
+ return wt.branch
202
+ return None
203
+
204
+
205
+ def find_worktree_with_branch(worktrees: list[WorktreeInfo], branch: str) -> Path | None:
206
+ """Find the worktree path containing the specified branch.
207
+
208
+ Returns None if the branch is not found in any worktree.
209
+
210
+ Args:
211
+ worktrees: List of WorktreeInfo objects to search
212
+ branch: Branch name to find
213
+
214
+ Returns:
215
+ Path to worktree containing the branch, or None if not found
216
+
217
+ Examples:
218
+ >>> worktrees = [WorktreeInfo(Path("/repo/erks/feat"), "feature-x", False)]
219
+ >>> find_worktree_with_branch(worktrees, "feature-x")
220
+ Path("/repo/erks/feat")
221
+ >>> find_worktree_with_branch(worktrees, "unknown")
222
+ None
223
+ """
224
+ for wt in worktrees:
225
+ if wt.branch == branch:
226
+ return wt.path
227
+ return None
228
+
229
+
230
+ def filter_non_trunk_branches(all_branches: dict[str, Any], stack: list[str]) -> list[str]:
231
+ """Filter out trunk branches from a stack.
232
+
233
+ Args:
234
+ all_branches: Dictionary mapping branch names to branch info (with is_trunk attribute)
235
+ stack: List of branch names to filter
236
+
237
+ Returns:
238
+ List of non-trunk branches from the stack
239
+
240
+ Examples:
241
+ >>> branches = {"main": BranchInfo(is_trunk=True), "feat": BranchInfo(is_trunk=False)}
242
+ >>> filter_non_trunk_branches(branches, ["main", "feat"])
243
+ ["feat"]
244
+ """
245
+ return [b for b in stack if b in all_branches and not all_branches[b].is_trunk]
246
+
247
+
248
+ def determine_move_operation(
249
+ worktrees: list[WorktreeInfo],
250
+ source_path: Path,
251
+ target_path: Path,
252
+ ) -> MoveOperation:
253
+ """Determine the type of move operation based on source and target states.
254
+
255
+ Pure function that analyzes worktree states to determine operation type:
256
+ - CREATE: Target doesn't exist
257
+ - SWAP: Both source and target have branches
258
+ - MOVE: Source has branch, target exists but is detached or doesn't exist
259
+
260
+ Args:
261
+ worktrees: List of all worktrees in the repository
262
+ source_path: Path to source worktree (must exist)
263
+ target_path: Path to target worktree (may not exist)
264
+
265
+ Returns:
266
+ MoveOperation object describing the operation to perform
267
+
268
+ Examples:
269
+ >>> worktrees = [
270
+ ... WorktreeInfo(Path("/repo/src"), "feat-1", False, False),
271
+ ... ]
272
+ >>> determine_move_operation(worktrees, Path("/repo/src"), Path("/repo/new"))
273
+ MoveOperation(operation_type=MoveOperationType.CREATE, ...)
274
+ """
275
+ # Get source branch
276
+ source_branch = get_worktree_branch(worktrees, source_path)
277
+
278
+ # Check if target exists in worktrees list
279
+ target_branch = get_worktree_branch(worktrees, target_path)
280
+
281
+ # Determine operation type
282
+ if target_branch is None:
283
+ # Target doesn't exist or is detached
284
+ # Check if any worktree exists at target_path
285
+ target_exists = any(wt.path.resolve() == target_path.resolve() for wt in worktrees)
286
+ if target_exists:
287
+ operation_type = MoveOperationType.MOVE
288
+ else:
289
+ operation_type = MoveOperationType.CREATE
290
+ else:
291
+ # Target exists with a branch - this is a swap
292
+ operation_type = MoveOperationType.SWAP
293
+
294
+ return MoveOperation(
295
+ operation_type=operation_type,
296
+ source_path=source_path,
297
+ target_path=target_path,
298
+ source_branch=source_branch,
299
+ target_branch=target_branch,
300
+ )