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.
- erk/__init__.py +12 -0
- erk/__main__.py +6 -0
- erk/agent_docs/__init__.py +5 -0
- erk/agent_docs/models.py +123 -0
- erk/agent_docs/operations.py +666 -0
- erk/artifacts/__init__.py +5 -0
- erk/artifacts/artifact_health.py +623 -0
- erk/artifacts/detection.py +16 -0
- erk/artifacts/discovery.py +343 -0
- erk/artifacts/models.py +63 -0
- erk/artifacts/staleness.py +56 -0
- erk/artifacts/state.py +100 -0
- erk/artifacts/sync.py +624 -0
- erk/cli/__init__.py +0 -0
- erk/cli/activation.py +132 -0
- erk/cli/alias.py +53 -0
- erk/cli/cli.py +221 -0
- erk/cli/commands/__init__.py +0 -0
- erk/cli/commands/admin.py +153 -0
- erk/cli/commands/artifact/__init__.py +1 -0
- erk/cli/commands/artifact/check.py +260 -0
- erk/cli/commands/artifact/group.py +31 -0
- erk/cli/commands/artifact/list_cmd.py +89 -0
- erk/cli/commands/artifact/show.py +62 -0
- erk/cli/commands/artifact/sync_cmd.py +39 -0
- erk/cli/commands/branch/__init__.py +26 -0
- erk/cli/commands/branch/assign_cmd.py +152 -0
- erk/cli/commands/branch/checkout_cmd.py +357 -0
- erk/cli/commands/branch/create_cmd.py +161 -0
- erk/cli/commands/branch/list_cmd.py +82 -0
- erk/cli/commands/branch/unassign_cmd.py +197 -0
- erk/cli/commands/cc/__init__.py +15 -0
- erk/cli/commands/cc/jsonl_cmd.py +20 -0
- erk/cli/commands/cc/session/AGENTS.md +30 -0
- erk/cli/commands/cc/session/CLAUDE.md +1 -0
- erk/cli/commands/cc/session/__init__.py +15 -0
- erk/cli/commands/cc/session/list_cmd.py +167 -0
- erk/cli/commands/cc/session/show_cmd.py +175 -0
- erk/cli/commands/completion.py +89 -0
- erk/cli/commands/completions.py +165 -0
- erk/cli/commands/config.py +327 -0
- erk/cli/commands/docs/__init__.py +1 -0
- erk/cli/commands/docs/group.py +16 -0
- erk/cli/commands/docs/sync.py +121 -0
- erk/cli/commands/docs/validate.py +102 -0
- erk/cli/commands/doctor.py +243 -0
- erk/cli/commands/down.py +171 -0
- erk/cli/commands/exec/__init__.py +1 -0
- erk/cli/commands/exec/group.py +164 -0
- erk/cli/commands/exec/scripts/AGENTS.md +79 -0
- erk/cli/commands/exec/scripts/CLAUDE.md +1 -0
- erk/cli/commands/exec/scripts/__init__.py +5 -0
- erk/cli/commands/exec/scripts/add_reaction_to_comment.py +69 -0
- erk/cli/commands/exec/scripts/add_remote_execution_note.py +68 -0
- erk/cli/commands/exec/scripts/check_impl.py +152 -0
- erk/cli/commands/exec/scripts/ci_update_pr_body.py +294 -0
- erk/cli/commands/exec/scripts/create_extraction_branch.py +138 -0
- erk/cli/commands/exec/scripts/create_extraction_plan.py +242 -0
- erk/cli/commands/exec/scripts/create_issue_from_session.py +103 -0
- erk/cli/commands/exec/scripts/create_plan_from_context.py +103 -0
- erk/cli/commands/exec/scripts/create_worker_impl_from_issue.py +93 -0
- erk/cli/commands/exec/scripts/detect_trunk_branch.py +121 -0
- erk/cli/commands/exec/scripts/exit_plan_mode_hook.py +777 -0
- erk/cli/commands/exec/scripts/extract_latest_plan.py +49 -0
- erk/cli/commands/exec/scripts/extract_session_from_issue.py +150 -0
- erk/cli/commands/exec/scripts/find_project_dir.py +214 -0
- erk/cli/commands/exec/scripts/generate_pr_summary.py +112 -0
- erk/cli/commands/exec/scripts/get_closing_text.py +98 -0
- erk/cli/commands/exec/scripts/get_embedded_prompt.py +62 -0
- erk/cli/commands/exec/scripts/get_plan_metadata.py +95 -0
- erk/cli/commands/exec/scripts/get_pr_body_footer.py +70 -0
- erk/cli/commands/exec/scripts/get_pr_discussion_comments.py +149 -0
- erk/cli/commands/exec/scripts/get_pr_review_comments.py +155 -0
- erk/cli/commands/exec/scripts/impl_init.py +158 -0
- erk/cli/commands/exec/scripts/impl_signal.py +375 -0
- erk/cli/commands/exec/scripts/impl_verify.py +49 -0
- erk/cli/commands/exec/scripts/issue_title_to_filename.py +34 -0
- erk/cli/commands/exec/scripts/list_sessions.py +296 -0
- erk/cli/commands/exec/scripts/mark_impl_ended.py +188 -0
- erk/cli/commands/exec/scripts/mark_impl_started.py +188 -0
- erk/cli/commands/exec/scripts/marker.py +163 -0
- erk/cli/commands/exec/scripts/objective_save_to_issue.py +109 -0
- erk/cli/commands/exec/scripts/plan_save_to_issue.py +269 -0
- erk/cli/commands/exec/scripts/plan_update_issue.py +147 -0
- erk/cli/commands/exec/scripts/post_extraction_comment.py +237 -0
- erk/cli/commands/exec/scripts/post_or_update_pr_summary.py +133 -0
- erk/cli/commands/exec/scripts/post_pr_inline_comment.py +143 -0
- erk/cli/commands/exec/scripts/post_workflow_started_comment.py +168 -0
- erk/cli/commands/exec/scripts/preprocess_session.py +777 -0
- erk/cli/commands/exec/scripts/quick_submit.py +32 -0
- erk/cli/commands/exec/scripts/rebase_with_conflict_resolution.py +260 -0
- erk/cli/commands/exec/scripts/reply_to_discussion_comment.py +173 -0
- erk/cli/commands/exec/scripts/resolve_review_thread.py +170 -0
- erk/cli/commands/exec/scripts/session_id_injector_hook.py +52 -0
- erk/cli/commands/exec/scripts/setup_impl_from_issue.py +159 -0
- erk/cli/commands/exec/scripts/slot_objective.py +102 -0
- erk/cli/commands/exec/scripts/tripwires_reminder_hook.py +20 -0
- erk/cli/commands/exec/scripts/update_dispatch_info.py +116 -0
- erk/cli/commands/exec/scripts/user_prompt_hook.py +113 -0
- erk/cli/commands/exec/scripts/validate_plan_content.py +98 -0
- erk/cli/commands/exec/scripts/wrap_plan_in_metadata_block.py +34 -0
- erk/cli/commands/implement.py +695 -0
- erk/cli/commands/implement_shared.py +649 -0
- erk/cli/commands/info/__init__.py +14 -0
- erk/cli/commands/info/release_notes_cmd.py +128 -0
- erk/cli/commands/init.py +801 -0
- erk/cli/commands/land_cmd.py +690 -0
- erk/cli/commands/log_cmd.py +137 -0
- erk/cli/commands/md/__init__.py +5 -0
- erk/cli/commands/md/check.py +118 -0
- erk/cli/commands/md/group.py +14 -0
- erk/cli/commands/navigation_helpers.py +430 -0
- erk/cli/commands/objective/__init__.py +16 -0
- erk/cli/commands/objective/list_cmd.py +47 -0
- erk/cli/commands/objective_helpers.py +132 -0
- erk/cli/commands/plan/__init__.py +32 -0
- erk/cli/commands/plan/check_cmd.py +174 -0
- erk/cli/commands/plan/close_cmd.py +69 -0
- erk/cli/commands/plan/create_cmd.py +120 -0
- erk/cli/commands/plan/docs/__init__.py +18 -0
- erk/cli/commands/plan/docs/extract_cmd.py +53 -0
- erk/cli/commands/plan/docs/unextract_cmd.py +38 -0
- erk/cli/commands/plan/docs/unextracted_cmd.py +72 -0
- erk/cli/commands/plan/extraction/__init__.py +16 -0
- erk/cli/commands/plan/extraction/complete_cmd.py +101 -0
- erk/cli/commands/plan/extraction/create_raw_cmd.py +63 -0
- erk/cli/commands/plan/get.py +71 -0
- erk/cli/commands/plan/list_cmd.py +754 -0
- erk/cli/commands/plan/log_cmd.py +440 -0
- erk/cli/commands/plan/start_cmd.py +459 -0
- erk/cli/commands/planner/__init__.py +40 -0
- erk/cli/commands/planner/configure_cmd.py +73 -0
- erk/cli/commands/planner/connect_cmd.py +96 -0
- erk/cli/commands/planner/create_cmd.py +148 -0
- erk/cli/commands/planner/list_cmd.py +51 -0
- erk/cli/commands/planner/register_cmd.py +105 -0
- erk/cli/commands/planner/set_default_cmd.py +23 -0
- erk/cli/commands/planner/unregister_cmd.py +43 -0
- erk/cli/commands/pr/__init__.py +23 -0
- erk/cli/commands/pr/check_cmd.py +112 -0
- erk/cli/commands/pr/checkout_cmd.py +165 -0
- erk/cli/commands/pr/fix_conflicts_cmd.py +82 -0
- erk/cli/commands/pr/parse_pr_reference.py +10 -0
- erk/cli/commands/pr/submit_cmd.py +360 -0
- erk/cli/commands/pr/sync_cmd.py +181 -0
- erk/cli/commands/prepare_cwd_recovery.py +60 -0
- erk/cli/commands/project/__init__.py +16 -0
- erk/cli/commands/project/init_cmd.py +91 -0
- erk/cli/commands/run/__init__.py +17 -0
- erk/cli/commands/run/list_cmd.py +189 -0
- erk/cli/commands/run/logs_cmd.py +54 -0
- erk/cli/commands/run/shared.py +19 -0
- erk/cli/commands/shell_integration.py +29 -0
- erk/cli/commands/slot/__init__.py +23 -0
- erk/cli/commands/slot/check_cmd.py +277 -0
- erk/cli/commands/slot/common.py +314 -0
- erk/cli/commands/slot/init_pool_cmd.py +157 -0
- erk/cli/commands/slot/list_cmd.py +228 -0
- erk/cli/commands/slot/repair_cmd.py +190 -0
- erk/cli/commands/stack/__init__.py +23 -0
- erk/cli/commands/stack/consolidate_cmd.py +470 -0
- erk/cli/commands/stack/list_cmd.py +79 -0
- erk/cli/commands/stack/move_cmd.py +309 -0
- erk/cli/commands/stack/split_old/README.md +64 -0
- erk/cli/commands/stack/split_old/__init__.py +5 -0
- erk/cli/commands/stack/split_old/command.py +233 -0
- erk/cli/commands/stack/split_old/display.py +116 -0
- erk/cli/commands/stack/split_old/plan.py +216 -0
- erk/cli/commands/status.py +58 -0
- erk/cli/commands/submit.py +768 -0
- erk/cli/commands/up.py +154 -0
- erk/cli/commands/upgrade.py +82 -0
- erk/cli/commands/wt/__init__.py +29 -0
- erk/cli/commands/wt/checkout_cmd.py +110 -0
- erk/cli/commands/wt/create_cmd.py +998 -0
- erk/cli/commands/wt/current_cmd.py +35 -0
- erk/cli/commands/wt/delete_cmd.py +573 -0
- erk/cli/commands/wt/list_cmd.py +332 -0
- erk/cli/commands/wt/rename_cmd.py +66 -0
- erk/cli/config.py +242 -0
- erk/cli/constants.py +29 -0
- erk/cli/core.py +65 -0
- erk/cli/debug.py +9 -0
- erk/cli/ensure-conversion-tasks.md +288 -0
- erk/cli/ensure.py +628 -0
- erk/cli/github_parsing.py +96 -0
- erk/cli/graphite.py +81 -0
- erk/cli/graphite_command.py +80 -0
- erk/cli/help_formatter.py +345 -0
- erk/cli/output.py +361 -0
- erk/cli/presets/dagster.toml +12 -0
- erk/cli/presets/generic.toml +12 -0
- erk/cli/prompt_hooks_templates/README.md +68 -0
- erk/cli/script_output.py +32 -0
- erk/cli/shell_integration/bash_wrapper.sh +32 -0
- erk/cli/shell_integration/fish_wrapper.fish +39 -0
- erk/cli/shell_integration/handler.py +338 -0
- erk/cli/shell_integration/zsh_wrapper.sh +32 -0
- erk/cli/shell_utils.py +171 -0
- erk/cli/subprocess_utils.py +92 -0
- erk/cli/uvx_detection.py +59 -0
- erk/core/__init__.py +0 -0
- erk/core/claude_executor.py +511 -0
- erk/core/claude_settings.py +317 -0
- erk/core/command_log.py +406 -0
- erk/core/commit_message_generator.py +234 -0
- erk/core/completion.py +10 -0
- erk/core/consolidation_utils.py +177 -0
- erk/core/context.py +570 -0
- erk/core/display/__init__.py +4 -0
- erk/core/display/abc.py +24 -0
- erk/core/display/real.py +30 -0
- erk/core/display_utils.py +526 -0
- erk/core/file_utils.py +87 -0
- erk/core/health_checks.py +1315 -0
- erk/core/health_checks_dogfooder/__init__.py +85 -0
- erk/core/health_checks_dogfooder/deprecated_dot_agent_config.py +64 -0
- erk/core/health_checks_dogfooder/legacy_claude_docs.py +69 -0
- erk/core/health_checks_dogfooder/legacy_config_locations.py +122 -0
- erk/core/health_checks_dogfooder/legacy_erk_docs_agent.py +61 -0
- erk/core/health_checks_dogfooder/legacy_erk_kits_folder.py +60 -0
- erk/core/health_checks_dogfooder/legacy_hook_settings.py +104 -0
- erk/core/health_checks_dogfooder/legacy_kit_yaml.py +78 -0
- erk/core/health_checks_dogfooder/legacy_kits_toml.py +43 -0
- erk/core/health_checks_dogfooder/outdated_erk_skill.py +43 -0
- erk/core/implementation_queue/__init__.py +1 -0
- erk/core/implementation_queue/github/__init__.py +8 -0
- erk/core/implementation_queue/github/abc.py +7 -0
- erk/core/implementation_queue/github/noop.py +38 -0
- erk/core/implementation_queue/github/printing.py +43 -0
- erk/core/implementation_queue/github/real.py +119 -0
- erk/core/init_utils.py +227 -0
- erk/core/output_filter.py +338 -0
- erk/core/plan_store/__init__.py +6 -0
- erk/core/planner/__init__.py +1 -0
- erk/core/planner/registry_abc.py +8 -0
- erk/core/planner/registry_fake.py +129 -0
- erk/core/planner/registry_real.py +195 -0
- erk/core/planner/types.py +7 -0
- erk/core/pr_utils.py +30 -0
- erk/core/release_notes.py +263 -0
- erk/core/repo_discovery.py +126 -0
- erk/core/script_writer.py +41 -0
- erk/core/services/__init__.py +1 -0
- erk/core/services/plan_list_service.py +94 -0
- erk/core/shell.py +51 -0
- erk/core/user_feedback.py +11 -0
- erk/core/version_check.py +55 -0
- erk/core/workflow_display.py +75 -0
- erk/core/worktree_pool.py +190 -0
- erk/core/worktree_utils.py +300 -0
- erk/data/CHANGELOG.md +438 -0
- erk/data/__init__.py +1 -0
- erk/data/claude/agents/devrun.md +180 -0
- erk/data/claude/commands/erk/__init__.py +0 -0
- erk/data/claude/commands/erk/create-extraction-plan.md +360 -0
- erk/data/claude/commands/erk/fix-conflicts.md +25 -0
- erk/data/claude/commands/erk/git-pr-push.md +345 -0
- erk/data/claude/commands/erk/implement-stacked-plan.md +96 -0
- erk/data/claude/commands/erk/land.md +193 -0
- erk/data/claude/commands/erk/objective-create.md +370 -0
- erk/data/claude/commands/erk/objective-list.md +34 -0
- erk/data/claude/commands/erk/objective-next-plan.md +220 -0
- erk/data/claude/commands/erk/objective-update-with-landed-pr.md +216 -0
- erk/data/claude/commands/erk/plan-implement.md +202 -0
- erk/data/claude/commands/erk/plan-save.md +45 -0
- erk/data/claude/commands/erk/plan-submit.md +39 -0
- erk/data/claude/commands/erk/pr-address.md +367 -0
- erk/data/claude/commands/erk/pr-submit.md +58 -0
- erk/data/claude/skills/dignified-python/SKILL.md +48 -0
- erk/data/claude/skills/dignified-python/cli-patterns.md +155 -0
- erk/data/claude/skills/dignified-python/dignified-python-core.md +1190 -0
- erk/data/claude/skills/dignified-python/subprocess.md +99 -0
- erk/data/claude/skills/dignified-python/versions/python-3.10.md +517 -0
- erk/data/claude/skills/dignified-python/versions/python-3.11.md +536 -0
- erk/data/claude/skills/dignified-python/versions/python-3.12.md +662 -0
- erk/data/claude/skills/dignified-python/versions/python-3.13.md +653 -0
- erk/data/claude/skills/erk-diff-analysis/SKILL.md +27 -0
- erk/data/claude/skills/erk-diff-analysis/references/commit-message-prompt.md +78 -0
- erk/data/claude/skills/learned-docs/SKILL.md +362 -0
- erk/data/github/actions/setup-claude-erk/action.yml +11 -0
- erk/data/github/prompts/dignified-python-review.md +125 -0
- erk/data/github/workflows/dignified-python-review.yml +61 -0
- erk/data/github/workflows/erk-impl.yml +251 -0
- erk/hooks/__init__.py +1 -0
- erk/hooks/decorators.py +319 -0
- erk/status/__init__.py +8 -0
- erk/status/collectors/__init__.py +9 -0
- erk/status/collectors/base.py +52 -0
- erk/status/collectors/git.py +76 -0
- erk/status/collectors/github.py +81 -0
- erk/status/collectors/graphite.py +80 -0
- erk/status/collectors/impl.py +145 -0
- erk/status/models/__init__.py +4 -0
- erk/status/models/status_data.py +404 -0
- erk/status/orchestrator.py +169 -0
- erk/status/renderers/__init__.py +5 -0
- erk/status/renderers/simple.py +322 -0
- erk/tui/AGENTS.md +193 -0
- erk/tui/CLAUDE.md +1 -0
- erk/tui/__init__.py +1 -0
- erk/tui/app.py +1404 -0
- erk/tui/commands/__init__.py +1 -0
- erk/tui/commands/executor.py +66 -0
- erk/tui/commands/provider.py +165 -0
- erk/tui/commands/real_executor.py +63 -0
- erk/tui/commands/registry.py +121 -0
- erk/tui/commands/types.py +36 -0
- erk/tui/data/__init__.py +1 -0
- erk/tui/data/provider.py +492 -0
- erk/tui/data/types.py +104 -0
- erk/tui/filtering/__init__.py +1 -0
- erk/tui/filtering/logic.py +43 -0
- erk/tui/filtering/types.py +55 -0
- erk/tui/jsonl_viewer/__init__.py +1 -0
- erk/tui/jsonl_viewer/app.py +61 -0
- erk/tui/jsonl_viewer/models.py +208 -0
- erk/tui/jsonl_viewer/widgets.py +204 -0
- erk/tui/sorting/__init__.py +6 -0
- erk/tui/sorting/logic.py +55 -0
- erk/tui/sorting/types.py +68 -0
- erk/tui/styles/dash.tcss +95 -0
- erk/tui/widgets/__init__.py +1 -0
- erk/tui/widgets/command_output.py +112 -0
- erk/tui/widgets/plan_table.py +276 -0
- erk/tui/widgets/status_bar.py +116 -0
- erk-0.4.5.dist-info/METADATA +376 -0
- erk-0.4.5.dist-info/RECORD +331 -0
- erk-0.4.5.dist-info/WHEEL +4 -0
- erk-0.4.5.dist-info/entry_points.txt +2 -0
- 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)
|