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,526 @@
1
+ """Display formatting utilities for erk.
2
+
3
+ This module contains pure business logic for formatting and displaying worktree
4
+ information in the CLI. All functions are pure (no I/O) and can be tested without
5
+ filesystem access.
6
+ """
7
+
8
+ import re
9
+ from datetime import datetime
10
+
11
+ import click
12
+
13
+ from erk_shared.github.types import PullRequestInfo, WorkflowRun
14
+
15
+
16
+ def get_visible_length(text: str) -> int:
17
+ """Calculate the visible length of text, excluding ANSI and OSC escape sequences.
18
+
19
+ Args:
20
+ text: Text that may contain escape sequences
21
+
22
+ Returns:
23
+ Number of visible characters
24
+ """
25
+ # Remove ANSI color codes (\033[...m)
26
+ text = re.sub(r"\033\[[0-9;]*m", "", text)
27
+ # Remove OSC 8 hyperlink sequences (\033]8;;URL\033\\)
28
+ text = re.sub(r"\033\]8;;[^\033]*\033\\", "", text)
29
+ return len(text)
30
+
31
+
32
+ def get_pr_status_emoji(pr: PullRequestInfo) -> str:
33
+ """Determine the emoji to display for a PR based on its status.
34
+
35
+ Args:
36
+ pr: Pull request information
37
+
38
+ Returns:
39
+ Emoji character representing the PR's current state,
40
+ with 💥 appended if there are merge conflicts
41
+ """
42
+ # Determine base emoji based on PR state
43
+ if pr.is_draft:
44
+ emoji = "🚧"
45
+ elif pr.state == "MERGED":
46
+ emoji = "🎉"
47
+ elif pr.state == "CLOSED":
48
+ emoji = "⛔"
49
+ elif pr.checks_passing is True:
50
+ emoji = "✅"
51
+ elif pr.checks_passing is False:
52
+ emoji = "❌"
53
+ else:
54
+ # Open PR with no checks
55
+ emoji = "👀"
56
+
57
+ # Append conflict indicator if PR has merge conflicts
58
+ # Only for open PRs (published or draft)
59
+ if pr.has_conflicts and pr.state == "OPEN":
60
+ emoji += "💥"
61
+
62
+ return emoji
63
+
64
+
65
+ def format_pr_info(
66
+ pr: PullRequestInfo | None,
67
+ graphite_url: str | None,
68
+ *,
69
+ use_graphite: bool = True,
70
+ ) -> str:
71
+ """Format PR status indicator with emoji and clickable link.
72
+
73
+ Args:
74
+ pr: Pull request information (None if no PR exists)
75
+ graphite_url: Graphite URL for the PR (None if unavailable)
76
+ use_graphite: If True, use Graphite URL; if False, use GitHub URL from pr.url
77
+
78
+ Returns:
79
+ Formatted PR info string (e.g., "✅ #23") or empty string if no PR
80
+ """
81
+ if pr is None:
82
+ return ""
83
+
84
+ emoji = get_pr_status_emoji(pr)
85
+
86
+ # Format PR number text
87
+ pr_text = f"#{pr.number}"
88
+
89
+ # Determine which URL to use based on use_graphite setting
90
+ url = graphite_url if use_graphite else pr.url
91
+
92
+ # If we have a URL, make it clickable using OSC 8 terminal escape sequence
93
+ if url:
94
+ # Wrap the link text in cyan color to distinguish from non-clickable bright_blue indicators
95
+ colored_pr_text = click.style(pr_text, fg="cyan")
96
+ clickable_link = f"\033]8;;{url}\033\\{colored_pr_text}\033]8;;\033\\"
97
+ return f"{emoji} {clickable_link}"
98
+ else:
99
+ # No URL available - just show colored text without link
100
+ colored_pr_text = click.style(pr_text, fg="cyan")
101
+ return f"{emoji} {colored_pr_text}"
102
+
103
+
104
+ def get_workflow_status_emoji(workflow_run: WorkflowRun) -> str:
105
+ """Determine the emoji to display for a workflow run based on its status.
106
+
107
+ Args:
108
+ workflow_run: Workflow run information
109
+
110
+ Returns:
111
+ Emoji character representing the workflow's current state
112
+ """
113
+ if workflow_run.status == "completed":
114
+ if workflow_run.conclusion == "success":
115
+ return "✅"
116
+ if workflow_run.conclusion == "failure":
117
+ return "❌"
118
+ if workflow_run.conclusion == "cancelled":
119
+ return "⛔"
120
+ # Other conclusions (skipped, timed_out, etc.)
121
+ return "❓"
122
+ if workflow_run.status == "in_progress":
123
+ return "⟳"
124
+ if workflow_run.status == "queued":
125
+ return "⧗"
126
+ # Unknown status
127
+ return "❓"
128
+
129
+
130
+ def format_workflow_status(workflow_run: WorkflowRun | None, workflow_url: str | None) -> str:
131
+ """Format workflow run status indicator with emoji and link.
132
+
133
+ Args:
134
+ workflow_run: Workflow run information (None if no workflow run)
135
+ workflow_url: GitHub Actions workflow run URL (None if unavailable)
136
+
137
+ Returns:
138
+ Formatted workflow status string (e.g., "✅ CI") or empty string if no workflow
139
+ """
140
+ if workflow_run is None:
141
+ return ""
142
+
143
+ emoji = get_workflow_status_emoji(workflow_run)
144
+
145
+ # Format status text
146
+ status_text = "CI"
147
+
148
+ # If we have a URL, make it clickable using OSC 8 terminal escape sequence
149
+ if workflow_url:
150
+ # Wrap the link text in cyan color
151
+ colored_status_text = click.style(status_text, fg="cyan")
152
+ clickable_link = f"\033]8;;{workflow_url}\033\\{colored_status_text}\033]8;;\033\\"
153
+ return f"{emoji} {clickable_link}"
154
+ else:
155
+ # No URL available - just show colored text without link
156
+ colored_status_text = click.style(status_text, fg="cyan")
157
+ return f"{emoji} {colored_status_text}"
158
+
159
+
160
+ def format_workflow_run_id(workflow_run: WorkflowRun | None, workflow_url: str | None) -> str:
161
+ """Format workflow run ID with linkification using Rich markup.
162
+
163
+ Args:
164
+ workflow_run: Workflow run information (None if no workflow run)
165
+ workflow_url: GitHub Actions workflow run URL (None if unavailable)
166
+
167
+ Returns:
168
+ Formatted workflow run ID string with Rich markup, or empty string if no workflow
169
+ """
170
+ if workflow_run is None:
171
+ return ""
172
+
173
+ run_id_text = workflow_run.run_id
174
+
175
+ # Use Rich markup for proper rendering in Rich tables
176
+ # Note: [cyan] must wrap [link], not vice versa, for Rich to render both correctly
177
+ if workflow_url:
178
+ return f"[cyan][link={workflow_url}]{run_id_text}[/link][/cyan]"
179
+ else:
180
+ return f"[cyan]{run_id_text}[/cyan]"
181
+
182
+
183
+ def get_workflow_run_state(workflow_run: WorkflowRun) -> str:
184
+ """Get normalized state string for a workflow run.
185
+
186
+ Combines status and conclusion into a single state string suitable for
187
+ filtering and display.
188
+
189
+ Args:
190
+ workflow_run: Workflow run information
191
+
192
+ Returns:
193
+ One of: "queued", "in_progress", "success", "failure", "cancelled"
194
+ """
195
+ if workflow_run.status == "completed":
196
+ if workflow_run.conclusion == "success":
197
+ return "success"
198
+ if workflow_run.conclusion == "failure":
199
+ return "failure"
200
+ if workflow_run.conclusion == "cancelled":
201
+ return "cancelled"
202
+ # Other conclusions (skipped, timed_out, etc.) map to failure
203
+ return "failure"
204
+ if workflow_run.status == "in_progress":
205
+ return "in_progress"
206
+ # status == "queued" or unknown
207
+ return "queued"
208
+
209
+
210
+ def format_workflow_outcome(workflow_run: WorkflowRun | None) -> str:
211
+ """Format workflow run outcome as emoji + text with Rich markup.
212
+
213
+ Args:
214
+ workflow_run: Workflow run information (None if no workflow run)
215
+
216
+ Returns:
217
+ Formatted outcome string with Rich markup (e.g., "[green]✅ Success[/green]")
218
+ or "[dim]-[/dim]" if no workflow run
219
+ """
220
+ if workflow_run is None:
221
+ return "[dim]-[/dim]"
222
+
223
+ state = get_workflow_run_state(workflow_run)
224
+
225
+ if state == "queued":
226
+ return "[yellow]⧗ Queued[/yellow]"
227
+ if state == "in_progress":
228
+ return "[blue]⟳ Running[/blue]"
229
+ if state == "success":
230
+ return "[green]✅ Success[/green]"
231
+ if state == "failure":
232
+ return "[red]❌ Failure[/red]"
233
+ if state == "cancelled":
234
+ return "[dim]⛔ Cancelled[/dim]"
235
+
236
+ # Fallback (shouldn't happen)
237
+ return "[dim]-[/dim]"
238
+
239
+
240
+ def format_branch_without_worktree(
241
+ branch_name: str,
242
+ pr_info: str | None,
243
+ max_branch_len: int = 0,
244
+ max_pr_info_len: int = 0,
245
+ ) -> str:
246
+ """Format a branch without a worktree for display.
247
+
248
+ Returns a line like: "branch-name PR #123 ✅"
249
+
250
+ Args:
251
+ branch_name: Name of the branch
252
+ pr_info: Formatted PR info string (e.g., "✅ #23") or None
253
+ max_branch_len: Maximum branch name length for alignment (0 disables)
254
+ max_pr_info_len: Maximum PR info length for alignment (0 disables)
255
+
256
+ Returns:
257
+ Formatted string with branch name and PR info
258
+ """
259
+ # Format branch name in yellow (same as worktree branches)
260
+ branch_styled = click.style(branch_name, fg="yellow")
261
+
262
+ # Add padding to branch name if alignment is enabled
263
+ if max_branch_len > 0:
264
+ branch_padding = max_branch_len - len(branch_name)
265
+ branch_styled += " " * branch_padding
266
+
267
+ line = branch_styled
268
+
269
+ # Add PR info if available
270
+ if pr_info:
271
+ # Calculate visible length for alignment
272
+ pr_info_visible_len = get_visible_length(pr_info)
273
+
274
+ # Add padding to PR info if alignment is enabled
275
+ if max_pr_info_len > 0:
276
+ pr_info_padding = max_pr_info_len - pr_info_visible_len
277
+ pr_info_padded = pr_info + (" " * pr_info_padding)
278
+ else:
279
+ pr_info_padded = pr_info
280
+
281
+ line += f" {pr_info_padded}"
282
+
283
+ return line
284
+
285
+
286
+ def format_worktree_line(
287
+ name: str,
288
+ branch: str | None,
289
+ pr_info: str | None,
290
+ plan_summary: str | None,
291
+ is_root: bool,
292
+ is_current: bool,
293
+ max_name_len: int = 0,
294
+ max_branch_len: int = 0,
295
+ max_pr_info_len: int = 0,
296
+ pr_title: str | None = None,
297
+ workflow_run: WorkflowRun | None = None,
298
+ workflow_url: str | None = None,
299
+ max_workflow_len: int = 0,
300
+ ) -> str:
301
+ """Format a single worktree line with colorization and optional alignment.
302
+
303
+ Args:
304
+ name: Worktree name to display
305
+ branch: Branch name (if any)
306
+ pr_info: Formatted PR info string (e.g., "✅ #23") or None
307
+ plan_summary: Plan title or None if no plan
308
+ is_root: True if this is the root repository worktree
309
+ is_current: True if this is the worktree the user is currently in
310
+ max_name_len: Maximum name length for alignment (0 = no alignment)
311
+ max_branch_len: Maximum branch length for alignment (0 = no alignment)
312
+ max_pr_info_len: Maximum PR info visible length for alignment (0 = no alignment)
313
+ pr_title: PR title from GitHub (preferred over plan_summary if available)
314
+ workflow_run: Workflow run information (None if no workflow)
315
+ workflow_url: GitHub Actions workflow run URL (None if unavailable)
316
+ max_workflow_len: Maximum workflow status visible length for alignment (0 = no alignment)
317
+
318
+ Returns:
319
+ Formatted line: name (branch) {PR info} {workflow status} {PR title or plan summary}
320
+ """
321
+ # Root worktree gets green to distinguish it from regular worktrees
322
+ name_color = "green" if is_root else "cyan"
323
+
324
+ # Calculate padding for name field
325
+ name_padding = max_name_len - len(name) if max_name_len > 0 else 0
326
+ name_with_padding = name + (" " * name_padding)
327
+ name_part = click.style(name_with_padding, fg=name_color, bold=True)
328
+
329
+ # Build parts for display: name (branch) {PR info} {plan summary}
330
+ parts = [name_part]
331
+
332
+ # Add branch in parentheses (yellow)
333
+ # If name matches branch, show "=" instead of repeating the branch name
334
+ if branch:
335
+ branch_display = "=" if name == branch else branch
336
+ # Calculate padding for branch field (including parentheses)
337
+ branch_with_parens = f"({branch_display})"
338
+ branch_padding = max_branch_len - len(branch_with_parens) if max_branch_len > 0 else 0
339
+ branch_with_padding = branch_with_parens + (" " * branch_padding)
340
+ branch_part = click.style(branch_with_padding, fg="yellow")
341
+ parts.append(branch_part)
342
+ elif max_branch_len > 0:
343
+ # Add spacing even if no branch to maintain alignment
344
+ parts.append(" " * max_branch_len)
345
+
346
+ # Add PR info or placeholder with alignment
347
+ pr_info_placeholder = click.style("[no PR]", fg="white", dim=True)
348
+ pr_display = pr_info if pr_info else pr_info_placeholder
349
+
350
+ if max_pr_info_len > 0:
351
+ # Calculate visible length and add padding
352
+ visible_len = get_visible_length(pr_display)
353
+ padding = max_pr_info_len - visible_len
354
+ pr_display_with_padding = pr_display + (" " * padding)
355
+ parts.append(pr_display_with_padding)
356
+ else:
357
+ parts.append(pr_display)
358
+
359
+ # Add workflow status with alignment
360
+ workflow_status = format_workflow_status(workflow_run, workflow_url)
361
+ if workflow_status:
362
+ if max_workflow_len > 0:
363
+ # Calculate visible length and add padding
364
+ visible_len = get_visible_length(workflow_status)
365
+ padding = max_workflow_len - visible_len
366
+ workflow_with_padding = workflow_status + (" " * padding)
367
+ parts.append(workflow_with_padding)
368
+ else:
369
+ parts.append(workflow_status)
370
+ elif max_workflow_len > 0:
371
+ # Add spacing to maintain alignment when no workflow
372
+ parts.append(" " * max_workflow_len)
373
+
374
+ # Add PR title, plan summary, or placeholder (PR title takes precedence)
375
+ if pr_title:
376
+ # PR title available - use it without emoji
377
+ title_colored = click.style(pr_title, fg="cyan")
378
+ parts.append(title_colored)
379
+ elif plan_summary:
380
+ # No PR title but have plan summary - use with emoji
381
+ plan_colored = click.style(f"📋 {plan_summary}", fg="bright_magenta")
382
+ parts.append(plan_colored)
383
+ else:
384
+ # No PR title and no plan summary
385
+ parts.append(click.style("[no plan]", fg="white", dim=True))
386
+
387
+ # Build the main line
388
+ line = " ".join(parts)
389
+
390
+ # Add indicator on the right for current worktree
391
+ if is_current:
392
+ indicator = click.style(" ← (cwd)", fg="bright_blue")
393
+ line += indicator
394
+
395
+ return line
396
+
397
+
398
+ def format_plan_display(
399
+ plan_identifier: str,
400
+ state: str,
401
+ title: str,
402
+ labels: list[str],
403
+ url: str | None = None,
404
+ ) -> str:
405
+ """Format a plan for display in lists.
406
+
407
+ Args:
408
+ plan_identifier: Plan identifier (e.g., "42", "PROJ-123")
409
+ state: Plan state ("OPEN" or "CLOSED")
410
+ title: Plan title
411
+ labels: List of label names
412
+ url: Optional URL for clickable link
413
+
414
+ Returns:
415
+ Formatted string: "#42 (OPEN) [erk-plan] Title"
416
+ """
417
+ # Format state with color
418
+ state_color = "green" if state == "OPEN" else "red"
419
+ state_str = click.style(state, fg=state_color)
420
+
421
+ # Format identifier
422
+ id_text = f"#{plan_identifier}"
423
+
424
+ # If we have a URL, make it clickable using OSC 8
425
+ if url:
426
+ colored_id = click.style(id_text, fg="cyan")
427
+ clickable_id = f"\033]8;;{url}\033\\{colored_id}\033]8;;\033\\"
428
+ else:
429
+ clickable_id = click.style(id_text, fg="cyan")
430
+
431
+ # Format labels
432
+ labels_str = ""
433
+ if labels:
434
+ labels_str = " " + " ".join(
435
+ click.style(f"[{label}]", fg="bright_magenta") for label in labels
436
+ )
437
+
438
+ return f"{clickable_id} ({state_str}){labels_str} {title}"
439
+
440
+
441
+ def format_submission_time(created_at: datetime | None) -> str:
442
+ """Format workflow run submission time as MM-DD HH:MM in local timezone.
443
+
444
+ Args:
445
+ created_at: UTC datetime when run was created, or None
446
+
447
+ Returns:
448
+ Formatted string like "11-26 14:30" in local timezone, or "[dim]-[/dim]" if None
449
+ """
450
+ if created_at is None:
451
+ return "[dim]-[/dim]"
452
+
453
+ # Convert UTC to local timezone
454
+ local_time = created_at.astimezone()
455
+
456
+ # Format as MM-DD HH:MM
457
+ return local_time.strftime("%m-%d %H:%M")
458
+
459
+
460
+ def format_relative_time(iso_timestamp: str | None, now: datetime | None = None) -> str:
461
+ """Format ISO timestamp as human-readable relative time.
462
+
463
+ Args:
464
+ iso_timestamp: ISO 8601 timestamp string, or None
465
+ now: Optional current time for testing (defaults to datetime.now(UTC))
466
+
467
+ Returns:
468
+ Relative time string like "just now", "5m ago", "2h ago", "3d ago"
469
+ Returns empty string if iso_timestamp is None or invalid
470
+ """
471
+ from datetime import UTC
472
+
473
+ if iso_timestamp is None:
474
+ return ""
475
+
476
+ # Parse ISO timestamp
477
+ try:
478
+ # Handle ISO format with timezone (e.g., "2025-01-15T10:30:00+00:00")
479
+ dt = datetime.fromisoformat(iso_timestamp)
480
+ # Ensure timezone-aware
481
+ if dt.tzinfo is None:
482
+ dt = dt.replace(tzinfo=UTC)
483
+ except ValueError:
484
+ return ""
485
+
486
+ # Get current time
487
+ current_time = now if now is not None else datetime.now(UTC)
488
+
489
+ # Calculate difference
490
+ delta = current_time - dt
491
+
492
+ # Format based on magnitude
493
+ total_seconds = int(delta.total_seconds())
494
+
495
+ # Handle future timestamps or very recent (within 30 seconds)
496
+ if total_seconds < 30:
497
+ return "just now"
498
+
499
+ # Minutes
500
+ if total_seconds < 3600:
501
+ minutes = total_seconds // 60
502
+ return f"{minutes}m ago"
503
+
504
+ # Hours
505
+ if total_seconds < 86400:
506
+ hours = total_seconds // 3600
507
+ return f"{hours}h ago"
508
+
509
+ # Days
510
+ if total_seconds < 604800: # 7 days
511
+ days = total_seconds // 86400
512
+ return f"{days}d ago"
513
+
514
+ # Weeks
515
+ if total_seconds < 2592000: # 30 days
516
+ weeks = total_seconds // 604800
517
+ return f"{weeks}w ago"
518
+
519
+ # Months (approximate)
520
+ months = total_seconds // 2592000
521
+ if months < 12:
522
+ return f"{months}mo ago"
523
+
524
+ # Years
525
+ years = total_seconds // 31536000
526
+ return f"{years}y ago"
erk/core/file_utils.py ADDED
@@ -0,0 +1,87 @@
1
+ """File operation utilities."""
2
+
3
+ from pathlib import Path
4
+
5
+ import frontmatter
6
+
7
+ from erk_shared.git.abc import Git
8
+
9
+
10
+ def extract_plan_title(plan_path: Path, git_ops: Git | None = None) -> str | None:
11
+ """Extract the first heading from a markdown plan file.
12
+
13
+ Uses python-frontmatter library to properly parse YAML frontmatter,
14
+ then extracts the first line starting with # from the content.
15
+ Common prefixes like "Plan: " and "Implementation Plan: " are stripped from the title.
16
+
17
+ Args:
18
+ plan_path: Path to the plan markdown file (e.g., .plan/plan.md)
19
+ git_ops: Optional Git interface for path checking (uses .exists() if None)
20
+
21
+ Returns:
22
+ The heading text (without the # prefix and common prefixes), or None if
23
+ not found or file doesn't exist
24
+ """
25
+ path_exists = git_ops.path_exists(plan_path) if git_ops is not None else plan_path.exists()
26
+ if not path_exists:
27
+ return None
28
+
29
+ # Parse file with frontmatter library (handles YAML frontmatter properly)
30
+ post = frontmatter.load(str(plan_path))
31
+
32
+ # Get the content (without frontmatter)
33
+ content = post.content
34
+ lines = content.splitlines()
35
+
36
+ # Common prefixes to strip from plan titles
37
+ COMMON_PREFIXES = [
38
+ "Plan: ",
39
+ "Implementation Plan: ",
40
+ "Implementation Plan - ",
41
+ ]
42
+
43
+ # Find first heading
44
+ for line in lines:
45
+ stripped = line.strip()
46
+ if stripped.startswith("#"):
47
+ # Remove all # symbols and strip whitespace
48
+ title = stripped.lstrip("#").strip()
49
+ if title:
50
+ # Strip common prefixes (case-insensitive)
51
+ for prefix in COMMON_PREFIXES:
52
+ if title.lower().startswith(prefix.lower()):
53
+ title = title[len(prefix) :].strip()
54
+ break
55
+ return title
56
+
57
+ return None
58
+
59
+
60
+ def extract_plan_title_from_folder(folder_path: Path, git_ops: Git | None = None) -> str | None:
61
+ """Extract the first heading from plan.md within a .plan/ folder.
62
+
63
+ Args:
64
+ folder_path: Path to the .plan/ directory
65
+ git_ops: Optional Git interface for path checking (uses .exists() if None)
66
+
67
+ Returns:
68
+ The heading text (without the # prefix and common prefixes), or None if
69
+ not found or folder/file doesn't exist
70
+ """
71
+ if git_ops is not None:
72
+ folder_exists = git_ops.path_exists(folder_path)
73
+ else:
74
+ folder_exists = folder_path.exists()
75
+ if not folder_exists:
76
+ return None
77
+
78
+ plan_file = folder_path / "plan.md"
79
+ if git_ops is not None:
80
+ plan_file_exists = git_ops.path_exists(plan_file)
81
+ else:
82
+ plan_file_exists = plan_file.exists()
83
+ if not plan_file_exists:
84
+ return None
85
+
86
+ # Delegate to existing title extraction logic
87
+ return extract_plan_title(plan_file, git_ops)