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,690 @@
|
|
|
1
|
+
"""Unified land command for PRs.
|
|
2
|
+
|
|
3
|
+
This command merges a PR and cleans up the worktree/branch.
|
|
4
|
+
It accepts a branch name, PR number, or PR URL as argument.
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
erk land # Land current branch's PR
|
|
8
|
+
erk land 123 # Land PR by number
|
|
9
|
+
erk land <url> # Land PR by URL
|
|
10
|
+
erk land <branch> # Land PR for branch
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
from dataclasses import replace
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Literal, NamedTuple
|
|
17
|
+
|
|
18
|
+
import click
|
|
19
|
+
|
|
20
|
+
from erk.cli.commands.branch.unassign_cmd import execute_unassign
|
|
21
|
+
from erk.cli.commands.navigation_helpers import (
|
|
22
|
+
activate_root_repo,
|
|
23
|
+
activate_worktree,
|
|
24
|
+
check_clean_working_tree,
|
|
25
|
+
delete_branch_and_worktree,
|
|
26
|
+
find_assignment_by_worktree_path,
|
|
27
|
+
)
|
|
28
|
+
from erk.cli.commands.objective_helpers import (
|
|
29
|
+
check_and_display_plan_issue_closure,
|
|
30
|
+
get_objective_for_branch,
|
|
31
|
+
prompt_objective_update,
|
|
32
|
+
)
|
|
33
|
+
from erk.cli.commands.wt.create_cmd import ensure_worktree_for_branch
|
|
34
|
+
from erk.cli.core import discover_repo_context
|
|
35
|
+
from erk.cli.ensure import Ensure
|
|
36
|
+
from erk.cli.help_formatter import CommandWithHiddenOptions, script_option
|
|
37
|
+
from erk.core.context import ErkContext, create_context
|
|
38
|
+
from erk.core.repo_discovery import RepoContext
|
|
39
|
+
from erk.core.worktree_pool import (
|
|
40
|
+
SlotAssignment,
|
|
41
|
+
load_pool_state,
|
|
42
|
+
save_pool_state,
|
|
43
|
+
update_slot_objective,
|
|
44
|
+
)
|
|
45
|
+
from erk_shared.gateway.gt.cli import render_events
|
|
46
|
+
from erk_shared.gateway.gt.operations.land_pr import execute_land_pr
|
|
47
|
+
from erk_shared.gateway.gt.types import LandPrError, LandPrSuccess
|
|
48
|
+
from erk_shared.github.types import PRDetails, PRNotFound
|
|
49
|
+
from erk_shared.output.output import user_confirm, user_output
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ParsedArgument(NamedTuple):
|
|
53
|
+
"""Result of parsing a land command argument."""
|
|
54
|
+
|
|
55
|
+
arg_type: Literal["pr-number", "pr-url", "branch"]
|
|
56
|
+
pr_number: int | None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def parse_argument(arg: str) -> ParsedArgument:
|
|
60
|
+
"""Parse argument to determine type.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
arg: The argument string (PR number, PR URL, or branch name)
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
ParsedArgument with:
|
|
67
|
+
- arg_type="pr-number", pr_number=N if arg is a numeric PR number
|
|
68
|
+
- arg_type="pr-url", pr_number=N if arg is a GitHub or Graphite PR URL
|
|
69
|
+
- arg_type="branch", pr_number=None if arg is a branch name
|
|
70
|
+
"""
|
|
71
|
+
# Try parsing as a plain number (PR number)
|
|
72
|
+
if arg.isdigit():
|
|
73
|
+
return ParsedArgument(arg_type="pr-number", pr_number=int(arg))
|
|
74
|
+
|
|
75
|
+
# Try parsing as a GitHub PR URL (e.g., https://github.com/owner/repo/pull/123)
|
|
76
|
+
match = re.search(r"/pull/(\d+)", arg)
|
|
77
|
+
if match:
|
|
78
|
+
return ParsedArgument(arg_type="pr-url", pr_number=int(match.group(1)))
|
|
79
|
+
|
|
80
|
+
# Try parsing as a Graphite PR URL (e.g., https://app.graphite.com/github/pr/owner/repo/123)
|
|
81
|
+
match = re.search(r"/pr/[^/]+/[^/]+/(\d+)", arg)
|
|
82
|
+
if match:
|
|
83
|
+
return ParsedArgument(arg_type="pr-url", pr_number=int(match.group(1)))
|
|
84
|
+
|
|
85
|
+
# Treat as branch name
|
|
86
|
+
return ParsedArgument(arg_type="branch", pr_number=None)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def resolve_branch_for_pr(ctx: ErkContext, repo_root: Path, pr_details: PRDetails) -> str:
|
|
90
|
+
"""Resolve the local branch name for a PR.
|
|
91
|
+
|
|
92
|
+
For same-repo PRs, returns the head branch name.
|
|
93
|
+
For fork PRs, returns "pr/{pr_number}" (the checkout convention).
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
ctx: ErkContext
|
|
97
|
+
repo_root: Repository root directory
|
|
98
|
+
pr_details: PR details from GitHub
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
Local branch name to use for this PR
|
|
102
|
+
"""
|
|
103
|
+
if pr_details.is_cross_repository:
|
|
104
|
+
# Fork PR - local checkout uses pr/{number} naming convention
|
|
105
|
+
return f"pr/{pr_details.number}"
|
|
106
|
+
return pr_details.head_ref_name
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def check_unresolved_comments(
|
|
110
|
+
ctx: ErkContext,
|
|
111
|
+
repo_root: Path,
|
|
112
|
+
pr_number: int,
|
|
113
|
+
force: bool,
|
|
114
|
+
) -> None:
|
|
115
|
+
"""Check for unresolved review threads and prompt if any exist.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
ctx: ErkContext
|
|
119
|
+
repo_root: Repository root directory
|
|
120
|
+
pr_number: PR number to check
|
|
121
|
+
force: If True, skip confirmation prompt
|
|
122
|
+
|
|
123
|
+
Raises:
|
|
124
|
+
SystemExit(0) if user declines to continue
|
|
125
|
+
"""
|
|
126
|
+
# Handle rate limit errors gracefully - this is an advisory check.
|
|
127
|
+
# We cannot LBYL for rate limits (no way to check quota before calling),
|
|
128
|
+
# so try/except is the appropriate pattern here.
|
|
129
|
+
try:
|
|
130
|
+
threads = ctx.github.get_pr_review_threads(repo_root, pr_number, include_resolved=False)
|
|
131
|
+
except RuntimeError as e:
|
|
132
|
+
error_str = str(e)
|
|
133
|
+
if "RATE_LIMIT" in error_str or "rate limit" in error_str.lower():
|
|
134
|
+
user_output(
|
|
135
|
+
click.style("⚠ ", fg="yellow")
|
|
136
|
+
+ "Could not check for unresolved comments (API rate limited)"
|
|
137
|
+
)
|
|
138
|
+
return # Continue without blocking
|
|
139
|
+
raise # Re-raise other errors
|
|
140
|
+
|
|
141
|
+
if threads and not force:
|
|
142
|
+
user_output(
|
|
143
|
+
click.style("⚠ ", fg="yellow")
|
|
144
|
+
+ f"PR #{pr_number} has {len(threads)} unresolved review comment(s)."
|
|
145
|
+
)
|
|
146
|
+
if not user_confirm("Continue anyway?", default=False):
|
|
147
|
+
raise SystemExit(0)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _cleanup_and_navigate(
|
|
151
|
+
ctx: ErkContext,
|
|
152
|
+
repo: RepoContext,
|
|
153
|
+
branch: str,
|
|
154
|
+
worktree_path: Path | None,
|
|
155
|
+
script: bool,
|
|
156
|
+
pull_flag: bool,
|
|
157
|
+
force: bool,
|
|
158
|
+
is_current_branch: bool,
|
|
159
|
+
target_child_branch: str | None,
|
|
160
|
+
objective_number: int | None,
|
|
161
|
+
) -> None:
|
|
162
|
+
"""Handle worktree/branch cleanup and navigation after PR merge.
|
|
163
|
+
|
|
164
|
+
This is shared logic used by both current-branch and specific-PR landing.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
ctx: ErkContext
|
|
168
|
+
repo: Repository context
|
|
169
|
+
branch: Branch name to clean up
|
|
170
|
+
worktree_path: Path to worktree (None if no worktree exists)
|
|
171
|
+
script: Whether to output activation script
|
|
172
|
+
pull_flag: Whether to pull after landing
|
|
173
|
+
force: Whether to skip cleanup confirmation
|
|
174
|
+
is_current_branch: True if landing from the branch's worktree
|
|
175
|
+
target_child_branch: Target child branch for --up navigation (None for trunk)
|
|
176
|
+
objective_number: Issue number of the objective linked to this branch (if any)
|
|
177
|
+
"""
|
|
178
|
+
main_repo_root = repo.main_repo_root if repo.main_repo_root else repo.root
|
|
179
|
+
|
|
180
|
+
if worktree_path is not None:
|
|
181
|
+
# Check if this is a slot worktree
|
|
182
|
+
state = load_pool_state(repo.pool_json_path)
|
|
183
|
+
assignment: SlotAssignment | None = None
|
|
184
|
+
if state is not None:
|
|
185
|
+
assignment = find_assignment_by_worktree_path(state, worktree_path)
|
|
186
|
+
|
|
187
|
+
if assignment is not None:
|
|
188
|
+
# Slot worktree: unassign instead of delete
|
|
189
|
+
# state is guaranteed to be non-None since assignment was found in it
|
|
190
|
+
assert state is not None
|
|
191
|
+
if not force and not ctx.dry_run:
|
|
192
|
+
if not user_confirm(
|
|
193
|
+
f"Unassign slot '{assignment.slot_name}' and delete branch '{branch}'?",
|
|
194
|
+
default=True,
|
|
195
|
+
):
|
|
196
|
+
user_output("Slot preserved. Branch still exists locally.")
|
|
197
|
+
return
|
|
198
|
+
# Record objective on slot BEFORE unassigning (so it persists after assignment removed)
|
|
199
|
+
if objective_number is not None:
|
|
200
|
+
state = update_slot_objective(state, assignment.slot_name, objective_number)
|
|
201
|
+
if ctx.dry_run:
|
|
202
|
+
user_output("[DRY RUN] Would save pool state")
|
|
203
|
+
else:
|
|
204
|
+
save_pool_state(repo.pool_json_path, state)
|
|
205
|
+
execute_unassign(ctx, repo, state, assignment)
|
|
206
|
+
ctx.git.delete_branch_with_graphite(main_repo_root, branch, force=True)
|
|
207
|
+
user_output(click.style("✓", fg="green") + " Unassigned slot and deleted branch")
|
|
208
|
+
else:
|
|
209
|
+
# Non-slot worktree: delete worktree and branch
|
|
210
|
+
if not force and not ctx.dry_run:
|
|
211
|
+
if not user_confirm(
|
|
212
|
+
f"Delete worktree '{worktree_path.name}' and branch '{branch}'?",
|
|
213
|
+
default=True,
|
|
214
|
+
):
|
|
215
|
+
user_output("Worktree preserved. Branch still exists locally.")
|
|
216
|
+
return
|
|
217
|
+
delete_branch_and_worktree(ctx, repo, branch, worktree_path)
|
|
218
|
+
user_output(click.style("✓", fg="green") + " Deleted worktree and branch")
|
|
219
|
+
else:
|
|
220
|
+
# No worktree - check if branch exists locally before deletion (LBYL)
|
|
221
|
+
local_branches = ctx.git.list_local_branches(main_repo_root)
|
|
222
|
+
if branch in local_branches:
|
|
223
|
+
ctx.git.delete_branch_with_graphite(main_repo_root, branch, force=True)
|
|
224
|
+
user_output(click.style("✓", fg="green") + f" Deleted branch '{branch}'")
|
|
225
|
+
# else: Branch doesn't exist locally - no cleanup needed (remote implementation or fork PR)
|
|
226
|
+
|
|
227
|
+
# In dry-run mode, skip navigation and show summary
|
|
228
|
+
if ctx.dry_run:
|
|
229
|
+
user_output(f"\n{click.style('[DRY RUN] No changes made', fg='yellow', bold=True)}")
|
|
230
|
+
raise SystemExit(0)
|
|
231
|
+
|
|
232
|
+
# Navigate (only if we were in the deleted worktree)
|
|
233
|
+
if is_current_branch:
|
|
234
|
+
_navigate_after_land(ctx, repo, script, pull_flag, target_child_branch)
|
|
235
|
+
else:
|
|
236
|
+
# Command succeeded but no navigation needed - exit cleanly
|
|
237
|
+
raise SystemExit(0)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _navigate_after_land(
|
|
241
|
+
ctx: ErkContext,
|
|
242
|
+
repo: RepoContext,
|
|
243
|
+
script: bool,
|
|
244
|
+
pull_flag: bool,
|
|
245
|
+
target_child_branch: str | None,
|
|
246
|
+
) -> None:
|
|
247
|
+
"""Navigate to appropriate location after landing.
|
|
248
|
+
|
|
249
|
+
Args:
|
|
250
|
+
ctx: ErkContext
|
|
251
|
+
repo: Repository context
|
|
252
|
+
script: Whether to output activation script
|
|
253
|
+
pull_flag: Whether to include git pull in activation
|
|
254
|
+
target_child_branch: If set, navigate to this child branch (--up mode)
|
|
255
|
+
"""
|
|
256
|
+
# Create post-deletion repo context with root pointing to main_repo_root
|
|
257
|
+
main_repo_root = repo.main_repo_root if repo.main_repo_root else repo.root
|
|
258
|
+
post_deletion_repo = replace(repo, root=main_repo_root)
|
|
259
|
+
|
|
260
|
+
if target_child_branch is not None:
|
|
261
|
+
target_path = ctx.git.find_worktree_for_branch(main_repo_root, target_child_branch)
|
|
262
|
+
if target_path is None:
|
|
263
|
+
# Auto-create worktree for child
|
|
264
|
+
target_path, _ = ensure_worktree_for_branch(
|
|
265
|
+
ctx, post_deletion_repo, target_child_branch
|
|
266
|
+
)
|
|
267
|
+
# Suggest running gt restack to update child branch's PR base
|
|
268
|
+
user_output(
|
|
269
|
+
click.style("💡", fg="cyan")
|
|
270
|
+
+ f" Run 'gt restack' in {target_child_branch} to update PR base branch"
|
|
271
|
+
)
|
|
272
|
+
activate_worktree(
|
|
273
|
+
ctx=ctx,
|
|
274
|
+
repo=post_deletion_repo,
|
|
275
|
+
target_path=target_path,
|
|
276
|
+
script=script,
|
|
277
|
+
command_name="land",
|
|
278
|
+
preserve_relative_path=True,
|
|
279
|
+
post_cd_commands=None,
|
|
280
|
+
)
|
|
281
|
+
# activate_worktree raises SystemExit(0)
|
|
282
|
+
else:
|
|
283
|
+
# Construct git pull commands if pull_flag is set
|
|
284
|
+
post_commands: list[str] | None = None
|
|
285
|
+
if pull_flag:
|
|
286
|
+
trunk_branch = ctx.git.detect_trunk_branch(main_repo_root)
|
|
287
|
+
post_commands = [
|
|
288
|
+
f'__erk_log "->" "git pull origin {trunk_branch}"',
|
|
289
|
+
f"git pull --ff-only origin {trunk_branch} || "
|
|
290
|
+
f'echo "Warning: git pull failed (try running manually)" >&2',
|
|
291
|
+
]
|
|
292
|
+
# Output activation script pointing to trunk/root repo
|
|
293
|
+
activate_root_repo(
|
|
294
|
+
ctx, post_deletion_repo, script, command_name="land", post_cd_commands=post_commands
|
|
295
|
+
)
|
|
296
|
+
# activate_root_repo raises SystemExit(0)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
@click.command("land", cls=CommandWithHiddenOptions)
|
|
300
|
+
@script_option
|
|
301
|
+
@click.argument("target", required=False)
|
|
302
|
+
@click.option(
|
|
303
|
+
"--up",
|
|
304
|
+
"up_flag",
|
|
305
|
+
is_flag=True,
|
|
306
|
+
help="Navigate to child branch instead of trunk after landing",
|
|
307
|
+
)
|
|
308
|
+
@click.option(
|
|
309
|
+
"-f",
|
|
310
|
+
"--force",
|
|
311
|
+
is_flag=True,
|
|
312
|
+
help="Skip all confirmation prompts (unresolved comments, worktree deletion)",
|
|
313
|
+
)
|
|
314
|
+
@click.option(
|
|
315
|
+
"--pull/--no-pull",
|
|
316
|
+
"pull_flag",
|
|
317
|
+
default=True,
|
|
318
|
+
help="Pull latest changes after landing (default: --pull)",
|
|
319
|
+
)
|
|
320
|
+
@click.option(
|
|
321
|
+
"--dry-run",
|
|
322
|
+
is_flag=True,
|
|
323
|
+
help="Print what would be done without executing destructive operations.",
|
|
324
|
+
)
|
|
325
|
+
@click.pass_obj
|
|
326
|
+
def land(
|
|
327
|
+
ctx: ErkContext,
|
|
328
|
+
script: bool,
|
|
329
|
+
target: str | None,
|
|
330
|
+
up_flag: bool,
|
|
331
|
+
force: bool,
|
|
332
|
+
pull_flag: bool,
|
|
333
|
+
dry_run: bool,
|
|
334
|
+
) -> None:
|
|
335
|
+
"""Merge PR and delete worktree.
|
|
336
|
+
|
|
337
|
+
Can land the current branch's PR, a specific PR by number/URL,
|
|
338
|
+
or a PR for a specific branch.
|
|
339
|
+
|
|
340
|
+
\b
|
|
341
|
+
Usage:
|
|
342
|
+
erk land # Land current branch's PR
|
|
343
|
+
erk land 123 # Land PR #123
|
|
344
|
+
erk land <url> # Land PR by GitHub URL
|
|
345
|
+
erk land <branch> # Land PR for branch
|
|
346
|
+
|
|
347
|
+
With shell integration (recommended):
|
|
348
|
+
erk land
|
|
349
|
+
|
|
350
|
+
Without shell integration:
|
|
351
|
+
source <(erk land --script)
|
|
352
|
+
|
|
353
|
+
Requires:
|
|
354
|
+
- Graphite enabled: 'erk config set use_graphite true'
|
|
355
|
+
- PR must be open and ready to merge
|
|
356
|
+
- PR's base branch must be trunk (one level from trunk)
|
|
357
|
+
"""
|
|
358
|
+
# Replace context with dry-run wrappers if needed
|
|
359
|
+
if dry_run:
|
|
360
|
+
ctx = create_context(dry_run=True)
|
|
361
|
+
script = False # Force human-readable output in dry-run mode
|
|
362
|
+
|
|
363
|
+
# Validate prerequisites
|
|
364
|
+
Ensure.gh_authenticated(ctx)
|
|
365
|
+
Ensure.graphite_available(ctx)
|
|
366
|
+
|
|
367
|
+
repo = discover_repo_context(ctx, ctx.cwd)
|
|
368
|
+
|
|
369
|
+
# Validate shell integration for activation script output (skip in dry-run mode)
|
|
370
|
+
if not script and not ctx.dry_run:
|
|
371
|
+
user_output(
|
|
372
|
+
click.style("Error: ", fg="red")
|
|
373
|
+
+ "This command requires shell integration for activation.\n\n"
|
|
374
|
+
+ "Options:\n"
|
|
375
|
+
+ " 1. Use shell integration: erk land\n"
|
|
376
|
+
+ " (Requires 'erk init --shell' setup)\n\n"
|
|
377
|
+
+ " 2. Use --script flag: source <(erk land --script)\n"
|
|
378
|
+
)
|
|
379
|
+
raise SystemExit(1)
|
|
380
|
+
|
|
381
|
+
# Determine if landing current branch or a specific target
|
|
382
|
+
if target is None:
|
|
383
|
+
# Landing current branch's PR (original behavior)
|
|
384
|
+
_land_current_branch(ctx, repo, script, up_flag, force, pull_flag)
|
|
385
|
+
else:
|
|
386
|
+
# Parse the target argument
|
|
387
|
+
parsed = parse_argument(target)
|
|
388
|
+
|
|
389
|
+
if parsed.arg_type == "branch":
|
|
390
|
+
# Landing a PR for a specific branch
|
|
391
|
+
_land_by_branch(ctx, repo, script, force, pull_flag, target)
|
|
392
|
+
else:
|
|
393
|
+
# Landing a specific PR by number or URL
|
|
394
|
+
if parsed.pr_number is None:
|
|
395
|
+
user_output(
|
|
396
|
+
click.style("Error: ", fg="red") + f"Invalid PR identifier: {target}\n"
|
|
397
|
+
"Expected a PR number (e.g., 123) or GitHub URL."
|
|
398
|
+
)
|
|
399
|
+
raise SystemExit(1)
|
|
400
|
+
_land_specific_pr(ctx, repo, script, up_flag, force, pull_flag, parsed.pr_number)
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _land_current_branch(
|
|
404
|
+
ctx: ErkContext,
|
|
405
|
+
repo: RepoContext,
|
|
406
|
+
script: bool,
|
|
407
|
+
up_flag: bool,
|
|
408
|
+
force: bool,
|
|
409
|
+
pull_flag: bool,
|
|
410
|
+
) -> None:
|
|
411
|
+
"""Land the current branch's PR (original behavior)."""
|
|
412
|
+
check_clean_working_tree(ctx)
|
|
413
|
+
|
|
414
|
+
# Get current branch and worktree path before landing
|
|
415
|
+
current_branch = Ensure.not_none(
|
|
416
|
+
ctx.git.get_current_branch(ctx.cwd), "Not currently on a branch (detached HEAD)"
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
current_worktree_path = Ensure.not_none(
|
|
420
|
+
ctx.git.find_worktree_for_branch(repo.root, current_branch),
|
|
421
|
+
f"Cannot find worktree for current branch '{current_branch}'.",
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
# Validate --up preconditions BEFORE any mutations (fail-fast)
|
|
425
|
+
target_child_branch: str | None = None
|
|
426
|
+
if up_flag:
|
|
427
|
+
children = ctx.graphite.get_child_branches(ctx.git, repo.root, current_branch)
|
|
428
|
+
if len(children) == 0:
|
|
429
|
+
user_output(
|
|
430
|
+
click.style("Error: ", fg="red")
|
|
431
|
+
+ f"Cannot use --up: branch '{current_branch}' has no children.\n"
|
|
432
|
+
"Use 'erk land' without --up to return to trunk."
|
|
433
|
+
)
|
|
434
|
+
raise SystemExit(1)
|
|
435
|
+
elif len(children) > 1:
|
|
436
|
+
children_list = ", ".join(f"'{c}'" for c in children)
|
|
437
|
+
user_output(
|
|
438
|
+
click.style("Error: ", fg="red")
|
|
439
|
+
+ f"Cannot use --up: branch '{current_branch}' has multiple children: "
|
|
440
|
+
f"{children_list}.\n"
|
|
441
|
+
"Use 'erk land' without --up, then 'erk co <branch>' to choose."
|
|
442
|
+
)
|
|
443
|
+
raise SystemExit(1)
|
|
444
|
+
else:
|
|
445
|
+
target_child_branch = children[0]
|
|
446
|
+
|
|
447
|
+
# Look up PR for current branch to check unresolved comments BEFORE merge
|
|
448
|
+
pr_details = ctx.github.get_pr_for_branch(repo.root, current_branch)
|
|
449
|
+
if not isinstance(pr_details, PRNotFound):
|
|
450
|
+
check_unresolved_comments(ctx, repo.root, pr_details.number, force)
|
|
451
|
+
|
|
452
|
+
# Step 1: Execute land-pr (merges the PR)
|
|
453
|
+
# render_events() uses click.echo() + sys.stderr.flush() for immediate unbuffered output
|
|
454
|
+
result = render_events(execute_land_pr(ctx, ctx.cwd))
|
|
455
|
+
|
|
456
|
+
if isinstance(result, LandPrError):
|
|
457
|
+
user_output(click.style("Error: ", fg="red") + result.message)
|
|
458
|
+
raise SystemExit(1)
|
|
459
|
+
|
|
460
|
+
# Success - PR was merged
|
|
461
|
+
success_result: LandPrSuccess = result
|
|
462
|
+
|
|
463
|
+
user_output(
|
|
464
|
+
click.style("✓", fg="green")
|
|
465
|
+
+ f" Merged PR #{success_result.pr_number} [{success_result.branch_name}]"
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
# Check and display plan issue closure
|
|
469
|
+
main_repo_root = repo.main_repo_root if repo.main_repo_root else repo.root
|
|
470
|
+
check_and_display_plan_issue_closure(ctx, main_repo_root, current_branch)
|
|
471
|
+
|
|
472
|
+
# Check for linked objective and offer to update
|
|
473
|
+
objective_number = get_objective_for_branch(ctx, main_repo_root, current_branch)
|
|
474
|
+
if objective_number is not None:
|
|
475
|
+
prompt_objective_update(
|
|
476
|
+
ctx, main_repo_root, objective_number, success_result.pr_number, current_branch, force
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
# Step 2: Cleanup and navigate
|
|
480
|
+
_cleanup_and_navigate(
|
|
481
|
+
ctx,
|
|
482
|
+
repo,
|
|
483
|
+
current_branch,
|
|
484
|
+
current_worktree_path,
|
|
485
|
+
script,
|
|
486
|
+
pull_flag,
|
|
487
|
+
force,
|
|
488
|
+
is_current_branch=True,
|
|
489
|
+
target_child_branch=target_child_branch,
|
|
490
|
+
objective_number=objective_number,
|
|
491
|
+
)
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _land_specific_pr(
|
|
495
|
+
ctx: ErkContext,
|
|
496
|
+
repo: RepoContext,
|
|
497
|
+
script: bool,
|
|
498
|
+
up_flag: bool,
|
|
499
|
+
force: bool,
|
|
500
|
+
pull_flag: bool,
|
|
501
|
+
pr_number: int,
|
|
502
|
+
) -> None:
|
|
503
|
+
"""Land a specific PR by number."""
|
|
504
|
+
# Validate --up is not used with PR argument
|
|
505
|
+
if up_flag:
|
|
506
|
+
user_output(
|
|
507
|
+
click.style("Error: ", fg="red") + "Cannot use --up when specifying a PR.\n"
|
|
508
|
+
"The --up flag only works when landing the current branch's PR."
|
|
509
|
+
)
|
|
510
|
+
raise SystemExit(1)
|
|
511
|
+
|
|
512
|
+
# Fetch PR details
|
|
513
|
+
main_repo_root = repo.main_repo_root if repo.main_repo_root else repo.root
|
|
514
|
+
pr_details = ctx.github.get_pr(main_repo_root, pr_number)
|
|
515
|
+
|
|
516
|
+
if isinstance(pr_details, PRNotFound):
|
|
517
|
+
user_output(click.style("Error: ", fg="red") + f"Pull request #{pr_number} not found.")
|
|
518
|
+
raise SystemExit(1)
|
|
519
|
+
|
|
520
|
+
# Resolve branch name (handles fork PRs)
|
|
521
|
+
branch = resolve_branch_for_pr(ctx, main_repo_root, pr_details)
|
|
522
|
+
|
|
523
|
+
# Determine if we're in the target branch's worktree
|
|
524
|
+
current_branch = ctx.git.get_current_branch(ctx.cwd)
|
|
525
|
+
is_current_branch = current_branch == branch
|
|
526
|
+
|
|
527
|
+
# Check if we're in a worktree for this branch
|
|
528
|
+
worktree_path = ctx.git.find_worktree_for_branch(main_repo_root, branch)
|
|
529
|
+
|
|
530
|
+
# If in target worktree, validate clean working tree
|
|
531
|
+
if is_current_branch:
|
|
532
|
+
check_clean_working_tree(ctx)
|
|
533
|
+
|
|
534
|
+
# Validate PR state
|
|
535
|
+
if pr_details.state != "OPEN":
|
|
536
|
+
user_output(
|
|
537
|
+
click.style("Error: ", fg="red")
|
|
538
|
+
+ f"Pull request #{pr_number} is not open (state: {pr_details.state})."
|
|
539
|
+
)
|
|
540
|
+
raise SystemExit(1)
|
|
541
|
+
|
|
542
|
+
# Validate PR base is trunk
|
|
543
|
+
trunk = ctx.git.detect_trunk_branch(main_repo_root)
|
|
544
|
+
if pr_details.base_ref_name != trunk:
|
|
545
|
+
user_output(
|
|
546
|
+
click.style("Error: ", fg="red")
|
|
547
|
+
+ f"PR #{pr_number} targets '{pr_details.base_ref_name}' "
|
|
548
|
+
+ f"but should target '{trunk}'.\n\n"
|
|
549
|
+
+ "The GitHub PR's base branch has diverged from your local stack.\n"
|
|
550
|
+
+ "Run: gt restack && gt submit\n"
|
|
551
|
+
+ f"Then retry: erk land {pr_number}"
|
|
552
|
+
)
|
|
553
|
+
raise SystemExit(1)
|
|
554
|
+
|
|
555
|
+
# Check for unresolved comments BEFORE merge
|
|
556
|
+
check_unresolved_comments(ctx, main_repo_root, pr_number, force)
|
|
557
|
+
|
|
558
|
+
# Merge the PR via GitHub API
|
|
559
|
+
user_output(f"Merging PR #{pr_number}...")
|
|
560
|
+
subject = f"{pr_details.title} (#{pr_number})" if pr_details.title else None
|
|
561
|
+
body = pr_details.body or None
|
|
562
|
+
merge_result = ctx.github.merge_pr(main_repo_root, pr_number, subject=subject, body=body)
|
|
563
|
+
|
|
564
|
+
if merge_result is not True:
|
|
565
|
+
error_detail = merge_result if isinstance(merge_result, str) else "Unknown error"
|
|
566
|
+
user_output(
|
|
567
|
+
click.style("Error: ", fg="red") + f"Failed to merge PR #{pr_number}\n\n{error_detail}"
|
|
568
|
+
)
|
|
569
|
+
raise SystemExit(1)
|
|
570
|
+
|
|
571
|
+
user_output(click.style("✓", fg="green") + f" Merged PR #{pr_number} [{branch}]")
|
|
572
|
+
|
|
573
|
+
# Check and display plan issue closure
|
|
574
|
+
check_and_display_plan_issue_closure(ctx, main_repo_root, branch)
|
|
575
|
+
|
|
576
|
+
# Check for linked objective and offer to update
|
|
577
|
+
objective_number = get_objective_for_branch(ctx, main_repo_root, branch)
|
|
578
|
+
if objective_number is not None:
|
|
579
|
+
prompt_objective_update(ctx, main_repo_root, objective_number, pr_number, branch, force)
|
|
580
|
+
|
|
581
|
+
# Cleanup and navigate
|
|
582
|
+
_cleanup_and_navigate(
|
|
583
|
+
ctx,
|
|
584
|
+
repo,
|
|
585
|
+
branch,
|
|
586
|
+
worktree_path,
|
|
587
|
+
script,
|
|
588
|
+
pull_flag,
|
|
589
|
+
force,
|
|
590
|
+
is_current_branch,
|
|
591
|
+
target_child_branch=None,
|
|
592
|
+
objective_number=objective_number,
|
|
593
|
+
)
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def _land_by_branch(
|
|
597
|
+
ctx: ErkContext,
|
|
598
|
+
repo: RepoContext,
|
|
599
|
+
script: bool,
|
|
600
|
+
force: bool,
|
|
601
|
+
pull_flag: bool,
|
|
602
|
+
branch_name: str,
|
|
603
|
+
) -> None:
|
|
604
|
+
"""Land a PR for a specific branch."""
|
|
605
|
+
main_repo_root = repo.main_repo_root if repo.main_repo_root else repo.root
|
|
606
|
+
|
|
607
|
+
# Look up PR for branch
|
|
608
|
+
pr_details = ctx.github.get_pr_for_branch(main_repo_root, branch_name)
|
|
609
|
+
|
|
610
|
+
if isinstance(pr_details, PRNotFound):
|
|
611
|
+
user_output(
|
|
612
|
+
click.style("Error: ", fg="red") + f"No pull request found for branch '{branch_name}'."
|
|
613
|
+
)
|
|
614
|
+
raise SystemExit(1)
|
|
615
|
+
|
|
616
|
+
pr_number = pr_details.number
|
|
617
|
+
|
|
618
|
+
# Determine if we're in the target branch's worktree
|
|
619
|
+
current_branch = ctx.git.get_current_branch(ctx.cwd)
|
|
620
|
+
is_current_branch = current_branch == branch_name
|
|
621
|
+
|
|
622
|
+
# Check if worktree exists for this branch
|
|
623
|
+
worktree_path = ctx.git.find_worktree_for_branch(main_repo_root, branch_name)
|
|
624
|
+
|
|
625
|
+
# If in target worktree, validate clean working tree
|
|
626
|
+
if is_current_branch:
|
|
627
|
+
check_clean_working_tree(ctx)
|
|
628
|
+
|
|
629
|
+
# Validate PR state
|
|
630
|
+
if pr_details.state != "OPEN":
|
|
631
|
+
user_output(
|
|
632
|
+
click.style("Error: ", fg="red")
|
|
633
|
+
+ f"Pull request #{pr_number} is not open (state: {pr_details.state})."
|
|
634
|
+
)
|
|
635
|
+
raise SystemExit(1)
|
|
636
|
+
|
|
637
|
+
# Validate PR base is trunk
|
|
638
|
+
trunk = ctx.git.detect_trunk_branch(main_repo_root)
|
|
639
|
+
if pr_details.base_ref_name != trunk:
|
|
640
|
+
user_output(
|
|
641
|
+
click.style("Error: ", fg="red")
|
|
642
|
+
+ f"PR #{pr_number} targets '{pr_details.base_ref_name}' "
|
|
643
|
+
+ f"but should target '{trunk}'.\n\n"
|
|
644
|
+
+ "The GitHub PR's base branch has diverged from your local stack.\n"
|
|
645
|
+
+ "Run: gt restack && gt submit\n"
|
|
646
|
+
+ f"Then retry: erk land {branch_name}"
|
|
647
|
+
)
|
|
648
|
+
raise SystemExit(1)
|
|
649
|
+
|
|
650
|
+
# Check for unresolved comments BEFORE merge
|
|
651
|
+
check_unresolved_comments(ctx, main_repo_root, pr_number, force)
|
|
652
|
+
|
|
653
|
+
# Merge the PR via GitHub API
|
|
654
|
+
user_output(f"Merging PR #{pr_number} for branch '{branch_name}'...")
|
|
655
|
+
subject = f"{pr_details.title} (#{pr_number})" if pr_details.title else None
|
|
656
|
+
body = pr_details.body or None
|
|
657
|
+
merge_result = ctx.github.merge_pr(main_repo_root, pr_number, subject=subject, body=body)
|
|
658
|
+
|
|
659
|
+
if merge_result is not True:
|
|
660
|
+
error_detail = merge_result if isinstance(merge_result, str) else "Unknown error"
|
|
661
|
+
user_output(
|
|
662
|
+
click.style("Error: ", fg="red") + f"Failed to merge PR #{pr_number}\n\n{error_detail}"
|
|
663
|
+
)
|
|
664
|
+
raise SystemExit(1)
|
|
665
|
+
|
|
666
|
+
user_output(click.style("✓", fg="green") + f" Merged PR #{pr_number} [{branch_name}]")
|
|
667
|
+
|
|
668
|
+
# Check and display plan issue closure
|
|
669
|
+
check_and_display_plan_issue_closure(ctx, main_repo_root, branch_name)
|
|
670
|
+
|
|
671
|
+
# Check for linked objective and offer to update
|
|
672
|
+
objective_number = get_objective_for_branch(ctx, main_repo_root, branch_name)
|
|
673
|
+
if objective_number is not None:
|
|
674
|
+
prompt_objective_update(
|
|
675
|
+
ctx, main_repo_root, objective_number, pr_number, branch_name, force
|
|
676
|
+
)
|
|
677
|
+
|
|
678
|
+
# Cleanup and navigate (uses shared function)
|
|
679
|
+
_cleanup_and_navigate(
|
|
680
|
+
ctx,
|
|
681
|
+
repo,
|
|
682
|
+
branch_name,
|
|
683
|
+
worktree_path,
|
|
684
|
+
script,
|
|
685
|
+
pull_flag,
|
|
686
|
+
force,
|
|
687
|
+
is_current_branch,
|
|
688
|
+
target_child_branch=None,
|
|
689
|
+
objective_number=objective_number,
|
|
690
|
+
)
|