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,277 @@
1
+ """Slot check command - check pool state consistency with disk and git."""
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ from typing import Literal
6
+
7
+ import click
8
+
9
+ from erk.cli.commands.slot.common import generate_slot_name
10
+ from erk.cli.core import discover_repo_context
11
+ from erk.core.context import ErkContext
12
+ from erk.core.worktree_pool import PoolState, SlotAssignment, load_pool_state
13
+ from erk_shared.git.abc import Git, WorktreeInfo
14
+ from erk_shared.output.output import user_output
15
+
16
+ # Type alias for sync issue codes - using Literal for type safety
17
+ SyncIssueCode = Literal[
18
+ "orphan-state",
19
+ "orphan-dir",
20
+ "missing-branch",
21
+ "branch-mismatch",
22
+ "git-registry-missing",
23
+ "untracked-worktree",
24
+ ]
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class SyncIssue:
29
+ """A sync diagnostic issue found during pool state check."""
30
+
31
+ code: SyncIssueCode
32
+ message: str
33
+
34
+
35
+ def _find_erk_managed_dirs(worktrees_dir: Path, git: Git) -> set[str]:
36
+ """Find directories in worktrees_dir matching erk-managed-wt-* pattern.
37
+
38
+ Args:
39
+ worktrees_dir: Path to the worktrees directory
40
+ git: Git abstraction for path_exists and is_dir checks
41
+
42
+ Returns:
43
+ Set of slot names (e.g., {"erk-managed-wt-01", "erk-managed-wt-02"})
44
+ """
45
+ if not git.path_exists(worktrees_dir):
46
+ return set()
47
+
48
+ result: set[str] = set()
49
+ # Iterate over worktrees_dir contents
50
+ # Use path_exists to validate before iterdir
51
+ for entry in worktrees_dir.iterdir():
52
+ if entry.name.startswith("erk-managed-wt-") and git.is_dir(entry):
53
+ result.add(entry.name)
54
+ return result
55
+
56
+
57
+ def _get_git_managed_slots(
58
+ worktrees: list[WorktreeInfo], worktrees_dir: Path
59
+ ) -> dict[str, WorktreeInfo]:
60
+ """Get worktrees that are erk-managed pool slots.
61
+
62
+ Args:
63
+ worktrees: List of all git worktrees
64
+ worktrees_dir: Path to the worktrees directory
65
+
66
+ Returns:
67
+ Dict mapping slot name to WorktreeInfo for erk-managed slots
68
+ """
69
+ result: dict[str, WorktreeInfo] = {}
70
+ for wt in worktrees:
71
+ if wt.path.parent == worktrees_dir and wt.path.name.startswith("erk-managed-wt-"):
72
+ result[wt.path.name] = wt
73
+ return result
74
+
75
+
76
+ def _check_orphan_states(
77
+ assignments: tuple[SlotAssignment, ...],
78
+ ctx: ErkContext,
79
+ ) -> list[SyncIssue]:
80
+ """Check for assignments where the worktree directory doesn't exist.
81
+
82
+ Args:
83
+ assignments: Current pool assignments
84
+ ctx: Erk context (for git.path_exists)
85
+
86
+ Returns:
87
+ List of (issue_type, message) tuples
88
+ """
89
+ issues: list[SyncIssue] = []
90
+ for assignment in assignments:
91
+ if not ctx.git.path_exists(assignment.worktree_path):
92
+ issues.append(
93
+ SyncIssue(
94
+ code="orphan-state",
95
+ message=f"Slot {assignment.slot_name}: directory does not exist",
96
+ )
97
+ )
98
+ return issues
99
+
100
+
101
+ def _check_orphan_dirs(
102
+ state: PoolState,
103
+ fs_slots: set[str],
104
+ ) -> list[SyncIssue]:
105
+ """Check for directories that exist on filesystem but not in pool state.
106
+
107
+ Args:
108
+ state: Pool state (to check against known slots)
109
+ fs_slots: Set of slot names found on filesystem
110
+
111
+ Returns:
112
+ List of (issue_type, message) tuples
113
+ """
114
+ # Generate known slots from pool_size (same logic as slot list command)
115
+ known_slots = {generate_slot_name(i) for i in range(1, state.pool_size + 1)}
116
+
117
+ issues: list[SyncIssue] = []
118
+ for slot_name in fs_slots:
119
+ if slot_name not in known_slots:
120
+ issues.append(
121
+ SyncIssue(
122
+ code="orphan-dir",
123
+ message=f"Directory {slot_name}: not in pool state",
124
+ )
125
+ )
126
+ return issues
127
+
128
+
129
+ def _check_missing_branches(
130
+ assignments: tuple[SlotAssignment, ...],
131
+ ctx: ErkContext,
132
+ repo_root: Path,
133
+ ) -> list[SyncIssue]:
134
+ """Check for assignments where the branch no longer exists in git.
135
+
136
+ Args:
137
+ assignments: Current pool assignments
138
+ ctx: Erk context (for git.get_branch_head)
139
+ repo_root: Path to the repository root
140
+
141
+ Returns:
142
+ List of (issue_type, message) tuples
143
+ """
144
+ issues: list[SyncIssue] = []
145
+ for assignment in assignments:
146
+ # Check if branch exists by getting its head commit
147
+ if ctx.git.get_branch_head(repo_root, assignment.branch_name) is None:
148
+ msg = f"Slot {assignment.slot_name}: branch '{assignment.branch_name}' deleted"
149
+ issues.append(SyncIssue(code="missing-branch", message=msg))
150
+ return issues
151
+
152
+
153
+ def _check_git_worktree_mismatch(
154
+ state: PoolState,
155
+ git_slots: dict[str, WorktreeInfo],
156
+ ) -> list[SyncIssue]:
157
+ """Check for mismatches between pool state and git worktree registry.
158
+
159
+ Args:
160
+ state: Pool state (assignments and known slots)
161
+ git_slots: Dict of slot names to WorktreeInfo from git
162
+
163
+ Returns:
164
+ List of (issue_type, message) tuples
165
+ """
166
+ issues: list[SyncIssue] = []
167
+
168
+ # Check assignments against git registry
169
+ for assignment in state.assignments:
170
+ if assignment.slot_name in git_slots:
171
+ wt = git_slots[assignment.slot_name]
172
+ # Check if branch matches
173
+ if wt.branch != assignment.branch_name:
174
+ msg = (
175
+ f"Slot {assignment.slot_name}: pool says '{assignment.branch_name}', "
176
+ f"git says '{wt.branch}'"
177
+ )
178
+ issues.append(SyncIssue(code="branch-mismatch", message=msg))
179
+ else:
180
+ # Slot is in pool.json but not in git worktree registry
181
+ issues.append(
182
+ SyncIssue(
183
+ code="git-registry-missing",
184
+ message=f"Slot {assignment.slot_name}: not in git worktree registry",
185
+ )
186
+ )
187
+
188
+ # Check git registry for slots not in pool state
189
+ # Generate known slots from pool_size (same logic as slot list command)
190
+ known_slots = {generate_slot_name(i) for i in range(1, state.pool_size + 1)}
191
+ for slot_name, wt in git_slots.items():
192
+ if slot_name not in known_slots:
193
+ msg = f"Slot {slot_name}: in git registry (branch '{wt.branch}') but not in pool state"
194
+ issues.append(SyncIssue(code="untracked-worktree", message=msg))
195
+
196
+ return issues
197
+
198
+
199
+ def run_sync_diagnostics(ctx: ErkContext, state: PoolState, repo_root: Path) -> list[SyncIssue]:
200
+ """Run all sync diagnostics and return issues found.
201
+
202
+ Args:
203
+ ctx: Erk context
204
+ state: Pool state to check
205
+ repo_root: Repository root path
206
+
207
+ Returns:
208
+ List of (issue_type, message) tuples
209
+ """
210
+ repo = discover_repo_context(ctx, repo_root)
211
+
212
+ # Get git worktrees
213
+ worktrees = ctx.git.list_worktrees(repo.root)
214
+ git_slots = _get_git_managed_slots(worktrees, repo.worktrees_dir)
215
+
216
+ # Get filesystem state
217
+ fs_slots = _find_erk_managed_dirs(repo.worktrees_dir, ctx.git)
218
+
219
+ # Run all checks
220
+ issues: list[SyncIssue] = []
221
+ issues.extend(_check_orphan_states(state.assignments, ctx))
222
+ issues.extend(_check_orphan_dirs(state, fs_slots))
223
+ issues.extend(_check_missing_branches(state.assignments, ctx, repo.root))
224
+ issues.extend(_check_git_worktree_mismatch(state, git_slots))
225
+
226
+ return issues
227
+
228
+
229
+ @click.command("check")
230
+ @click.pass_obj
231
+ def slot_check(ctx: ErkContext) -> None:
232
+ """Check pool state consistency with disk and git.
233
+
234
+ Reports drift between:
235
+ - Pool state (pool.json)
236
+ - Filesystem (worktree directories)
237
+ - Git worktree registry
238
+
239
+ This is a diagnostic command - it does not modify anything.
240
+ """
241
+ repo = discover_repo_context(ctx, ctx.cwd)
242
+
243
+ # Load pool state
244
+ state = load_pool_state(repo.pool_json_path)
245
+ if state is None:
246
+ user_output("Error: No pool configured. Run `erk slot create` first.")
247
+ raise SystemExit(1) from None
248
+
249
+ # Get git worktrees
250
+ worktrees = ctx.git.list_worktrees(repo.root)
251
+ git_slots = _get_git_managed_slots(worktrees, repo.worktrees_dir)
252
+
253
+ # Get filesystem state
254
+ fs_slots = _find_erk_managed_dirs(repo.worktrees_dir, ctx.git)
255
+
256
+ # Run all checks
257
+ issues: list[SyncIssue] = []
258
+ issues.extend(_check_orphan_states(state.assignments, ctx))
259
+ issues.extend(_check_orphan_dirs(state, fs_slots))
260
+ issues.extend(_check_missing_branches(state.assignments, ctx, repo.root))
261
+ issues.extend(_check_git_worktree_mismatch(state, git_slots))
262
+
263
+ # Print report
264
+ user_output("Pool Check Report")
265
+ user_output("================")
266
+ user_output("")
267
+ user_output(f"Pool state: {len(state.assignments)} assignments")
268
+ user_output(f"Git worktrees: {len(worktrees)} registered ({len(git_slots)} erk-managed)")
269
+ user_output(f"Filesystem: {len(fs_slots)} slot directories")
270
+ user_output("")
271
+
272
+ if issues:
273
+ user_output("Issues Found:")
274
+ for issue in issues:
275
+ user_output(f" [{issue.code}] {issue.message}")
276
+ else:
277
+ user_output(click.style("✓ No issues found", fg="green"))
@@ -0,0 +1,314 @@
1
+ """Shared utilities for slot commands."""
2
+
3
+ import re
4
+ import shutil
5
+ from pathlib import Path
6
+
7
+ from erk.core.context import ErkContext
8
+ from erk.core.worktree_pool import PoolState, SlotAssignment
9
+ from erk_shared.git.abc import Git
10
+ from erk_shared.output.output import user_confirm, user_output
11
+
12
+ # Default pool configuration
13
+ DEFAULT_POOL_SIZE = 4
14
+ SLOT_NAME_PREFIX = "erk-managed-wt"
15
+
16
+
17
+ def extract_slot_number(slot_name: str) -> str | None:
18
+ """Extract slot number from slot name.
19
+
20
+ Args:
21
+ slot_name: Slot name like "erk-managed-wt-03"
22
+
23
+ Returns:
24
+ Two-digit slot number (e.g., "03") or None if not in expected format
25
+ """
26
+ if not slot_name.startswith(SLOT_NAME_PREFIX + "-"):
27
+ return None
28
+ suffix = slot_name[len(SLOT_NAME_PREFIX) + 1 :]
29
+ if len(suffix) != 2 or not suffix.isdigit():
30
+ return None
31
+ return suffix
32
+
33
+
34
+ def get_placeholder_branch_name(slot_name: str) -> str | None:
35
+ """Get placeholder branch name for a slot.
36
+
37
+ Args:
38
+ slot_name: Slot name like "erk-managed-wt-03"
39
+
40
+ Returns:
41
+ Placeholder branch name like "__erk-slot-03-placeholder__",
42
+ or None if slot_name is not in expected format
43
+ """
44
+ slot_number = extract_slot_number(slot_name)
45
+ if slot_number is None:
46
+ return None
47
+ return f"__erk-slot-{slot_number}-placeholder__"
48
+
49
+
50
+ def is_placeholder_branch(branch_name: str) -> bool:
51
+ """Check if a branch name is an erk slot placeholder branch.
52
+
53
+ Placeholder branches have the format: __erk-slot-XX-placeholder__
54
+
55
+ Args:
56
+ branch_name: Branch name to check
57
+
58
+ Returns:
59
+ True if branch_name matches the placeholder pattern
60
+ """
61
+ return bool(re.match(r"^__erk-slot-\d+-placeholder__$", branch_name))
62
+
63
+
64
+ def get_pool_size(ctx: ErkContext) -> int:
65
+ """Get effective pool size from config or default.
66
+
67
+ Args:
68
+ ctx: Current erk context with local_config
69
+
70
+ Returns:
71
+ Configured pool size or DEFAULT_POOL_SIZE if not set
72
+ """
73
+ if ctx.local_config is not None and ctx.local_config.pool_size is not None:
74
+ return ctx.local_config.pool_size
75
+ return DEFAULT_POOL_SIZE
76
+
77
+
78
+ def generate_slot_name(slot_number: int) -> str:
79
+ """Generate a slot name from a slot number.
80
+
81
+ Args:
82
+ slot_number: 1-based slot number
83
+
84
+ Returns:
85
+ Formatted slot name like "erk-managed-wt-01"
86
+ """
87
+ return f"{SLOT_NAME_PREFIX}-{slot_number:02d}"
88
+
89
+
90
+ def find_next_available_slot(state: PoolState, worktrees_dir: Path | None) -> int | None:
91
+ """Find the next available slot number for on-demand worktree creation.
92
+
93
+ This function finds a slot number that is:
94
+ 1. Not currently assigned to a branch (not in state.assignments)
95
+ 2. Not already initialized as a worktree (not in state.slots)
96
+ 3. Does not have an orphaned directory on disk (if worktrees_dir provided)
97
+
98
+ This ensures on-demand creation only targets slots where no worktree
99
+ exists on disk.
100
+
101
+ Args:
102
+ state: Current pool state
103
+ worktrees_dir: Directory containing worktrees, or None to skip disk check
104
+
105
+ Returns:
106
+ 1-based slot number if available, None if pool is full
107
+ """
108
+ assigned_slots = {a.slot_name for a in state.assignments}
109
+ initialized_slots = {s.name for s in state.slots}
110
+
111
+ for slot_num in range(1, state.pool_size + 1):
112
+ slot_name = generate_slot_name(slot_num)
113
+ if slot_name not in assigned_slots and slot_name not in initialized_slots:
114
+ # Check if directory exists on disk (orphaned worktree)
115
+ if worktrees_dir is not None:
116
+ slot_path = worktrees_dir / slot_name
117
+ if slot_path.exists():
118
+ continue # Skip - directory exists but not tracked
119
+ return slot_num
120
+
121
+ return None
122
+
123
+
124
+ def find_inactive_slot(
125
+ state: PoolState,
126
+ git: Git,
127
+ repo_root: Path,
128
+ ) -> tuple[str, Path] | None:
129
+ """Find an available managed slot for reuse.
130
+
131
+ Searches for worktrees that exist but are not assigned.
132
+ Uses git as source of truth for which worktrees exist.
133
+ Prefers slots in order (lowest slot number first).
134
+
135
+ Args:
136
+ state: Current pool state
137
+ git: Git gateway for worktree operations
138
+ repo_root: Repository root path
139
+
140
+ Returns:
141
+ Tuple of (slot_name, worktree_path) for an available slot,
142
+ or None if no inactive slot found
143
+ """
144
+ assigned_slots = {a.slot_name for a in state.assignments}
145
+
146
+ # Get all worktrees from git (source of truth)
147
+ worktrees = git.list_worktrees(repo_root)
148
+
149
+ # Build lookup of slot_name -> worktree_path for managed slots
150
+ managed_worktrees: dict[str, Path] = {}
151
+ for wt in worktrees:
152
+ slot_name = wt.path.name
153
+ if extract_slot_number(slot_name) is not None:
154
+ managed_worktrees[slot_name] = wt.path
155
+
156
+ # Find first unassigned slot (by slot number order)
157
+ for slot_num in range(1, state.pool_size + 1):
158
+ slot_name = generate_slot_name(slot_num)
159
+ if slot_name in managed_worktrees and slot_name not in assigned_slots:
160
+ return (slot_name, managed_worktrees[slot_name])
161
+
162
+ return None
163
+
164
+
165
+ def is_slot_initialized(state: PoolState, slot_name: str) -> bool:
166
+ """Check if a slot has been initialized.
167
+
168
+ Args:
169
+ state: Current pool state
170
+ slot_name: Name of the slot to check
171
+
172
+ Returns:
173
+ True if slot is in the initialized slots list
174
+ """
175
+ return any(slot.name == slot_name for slot in state.slots)
176
+
177
+
178
+ def find_branch_assignment(state: PoolState, branch_name: str) -> SlotAssignment | None:
179
+ """Find if a branch is already assigned to a slot.
180
+
181
+ Args:
182
+ state: Current pool state
183
+ branch_name: Branch to search for
184
+
185
+ Returns:
186
+ SlotAssignment if found, None otherwise
187
+ """
188
+ for assignment in state.assignments:
189
+ if assignment.branch_name == branch_name:
190
+ return assignment
191
+ return None
192
+
193
+
194
+ def find_assignment_by_worktree(state: PoolState, git: Git, cwd: Path) -> SlotAssignment | None:
195
+ """Find if cwd is within a managed slot using git.
196
+
197
+ Uses git to determine the worktree root of cwd, then matches exactly
198
+ against known slot assignments. This is more reliable than path
199
+ comparisons which can fail with symlinks, relative paths, etc.
200
+
201
+ Args:
202
+ state: Current pool state
203
+ git: Git gateway for repository operations
204
+ cwd: Current working directory
205
+
206
+ Returns:
207
+ SlotAssignment if cwd is within a managed slot, None otherwise
208
+ """
209
+ worktree_root = git.get_repository_root(cwd)
210
+ for assignment in state.assignments:
211
+ if assignment.worktree_path == worktree_root:
212
+ return assignment
213
+ return None
214
+
215
+
216
+ def find_oldest_assignment(state: PoolState) -> SlotAssignment | None:
217
+ """Find the oldest assignment by assigned_at timestamp.
218
+
219
+ Args:
220
+ state: Current pool state
221
+
222
+ Returns:
223
+ The oldest SlotAssignment, or None if no assignments
224
+ """
225
+ if not state.assignments:
226
+ return None
227
+
228
+ oldest: SlotAssignment | None = None
229
+ for assignment in state.assignments:
230
+ if oldest is None or assignment.assigned_at < oldest.assigned_at:
231
+ oldest = assignment
232
+ return oldest
233
+
234
+
235
+ def display_pool_assignments(state: PoolState) -> None:
236
+ """Display current pool assignments to user.
237
+
238
+ Args:
239
+ state: Current pool state
240
+ """
241
+ user_output("\nCurrent pool assignments:")
242
+ for assignment in sorted(state.assignments, key=lambda a: a.assigned_at):
243
+ slot = assignment.slot_name
244
+ branch = assignment.branch_name
245
+ assigned = assignment.assigned_at
246
+ user_output(f" {slot}: {branch} (assigned {assigned})")
247
+ user_output("")
248
+
249
+
250
+ def handle_pool_full_interactive(
251
+ state: PoolState,
252
+ force: bool,
253
+ is_tty: bool,
254
+ ) -> SlotAssignment | None:
255
+ """Handle pool-full condition: prompt to unassign oldest or error.
256
+
257
+ When the pool is full:
258
+ - If --force: auto-unassign the oldest assignment
259
+ - If interactive (TTY): show assignments and prompt user
260
+ - If non-interactive (no TTY): error with instructions
261
+
262
+ Args:
263
+ state: Current pool state
264
+ force: If True, auto-unassign oldest without prompting
265
+ is_tty: Whether running in an interactive terminal
266
+
267
+ Returns:
268
+ SlotAssignment to unassign, or None if user declined/error
269
+ """
270
+ oldest = find_oldest_assignment(state)
271
+ if oldest is None:
272
+ return None
273
+
274
+ if force:
275
+ user_output(f"Pool is full. --force specified, unassigning oldest: {oldest.branch_name}")
276
+ return oldest
277
+
278
+ if not is_tty:
279
+ user_output(
280
+ f"Error: Pool is full ({state.pool_size} slots). "
281
+ "Use --force to auto-unassign the oldest branch, "
282
+ "or run `erk slot list` to see assignments."
283
+ )
284
+ return None
285
+
286
+ # Interactive mode: show assignments and prompt
287
+ display_pool_assignments(state)
288
+ user_output(f"Pool is full ({state.pool_size} slots).")
289
+ user_output(f"Oldest assignment: {oldest.branch_name} ({oldest.slot_name})")
290
+
291
+ if user_confirm(f"Unassign '{oldest.branch_name}' to make room?", default=False):
292
+ return oldest
293
+
294
+ user_output("Aborted.")
295
+ return None
296
+
297
+
298
+ def cleanup_worktree_artifacts(worktree_path: Path) -> None:
299
+ """Remove stale artifacts from a worktree before reuse.
300
+
301
+ Cleans up .impl/ and .erk/scratch/ folders which persist across
302
+ branch switches since they are in .gitignore.
303
+
304
+ Args:
305
+ worktree_path: Path to the worktree to clean up
306
+ """
307
+ impl_folder = worktree_path / ".impl"
308
+ scratch_folder = worktree_path / ".erk" / "scratch"
309
+
310
+ if impl_folder.exists():
311
+ shutil.rmtree(impl_folder)
312
+
313
+ if scratch_folder.exists():
314
+ shutil.rmtree(scratch_folder)