delegate-agent-cli 0.11.0__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.
- delegate_agent/__init__.py +5 -0
- delegate_agent/archived_logs.py +44 -0
- delegate_agent/argv_builders.py +423 -0
- delegate_agent/argv_utils.py +36 -0
- delegate_agent/capability_commands.py +99 -0
- delegate_agent/cli.py +987 -0
- delegate_agent/cli_parser.py +1627 -0
- delegate_agent/command_errors.py +29 -0
- delegate_agent/command_help.py +1264 -0
- delegate_agent/config.py +1117 -0
- delegate_agent/config_commands.py +143 -0
- delegate_agent/constants.py +50 -0
- delegate_agent/describe_payload.py +1018 -0
- delegate_agent/errors.py +32 -0
- delegate_agent/git_utils.py +180 -0
- delegate_agent/harness_events.py +719 -0
- delegate_agent/inspection_commands.py +104 -0
- delegate_agent/isolation.py +316 -0
- delegate_agent/json_types.py +18 -0
- delegate_agent/log_output.py +78 -0
- delegate_agent/private_io.py +109 -0
- delegate_agent/profile_commands.py +42 -0
- delegate_agent/profile_guard.py +116 -0
- delegate_agent/profiles.py +389 -0
- delegate_agent/prompt_instructions.py +39 -0
- delegate_agent/prompt_transport.py +12 -0
- delegate_agent/reasoning.py +916 -0
- delegate_agent/redaction.py +193 -0
- delegate_agent/rendering.py +573 -0
- delegate_agent/request_build.py +1681 -0
- delegate_agent/request_models.py +191 -0
- delegate_agent/retention.py +321 -0
- delegate_agent/run_metadata.py +78 -0
- delegate_agent/run_output_commands.py +645 -0
- delegate_agent/run_registry.py +747 -0
- delegate_agent/run_status.py +300 -0
- delegate_agent/runner.py +1830 -0
- delegate_agent/safe_workspace.py +821 -0
- delegate_agent/snapshot_view.py +229 -0
- delegate_agent/wait_cancel_commands.py +559 -0
- delegate_agent/worktree_commands.py +218 -0
- delegate_agent/worktree_execution.py +654 -0
- delegate_agent/worktree_gc.py +529 -0
- delegate_agent/worktree_mgmt.py +782 -0
- delegate_agent/worktree_records.py +232 -0
- delegate_agent/worktree_remove.py +547 -0
- delegate_agent/worktree_summary.py +242 -0
- delegate_agent/wsl.py +65 -0
- delegate_agent_cli-0.11.0.dist-info/METADATA +325 -0
- delegate_agent_cli-0.11.0.dist-info/RECORD +54 -0
- delegate_agent_cli-0.11.0.dist-info/WHEEL +5 -0
- delegate_agent_cli-0.11.0.dist-info/entry_points.txt +2 -0
- delegate_agent_cli-0.11.0.dist-info/licenses/LICENSE +21 -0
- delegate_agent_cli-0.11.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,547 @@
|
|
|
1
|
+
"""Worktree removal pipeline.
|
|
2
|
+
|
|
3
|
+
Implements ``delegate worktree remove`` end to end: option normalization, dirty
|
|
4
|
+
and merged safety gates, the ``git worktree remove`` + branch-delete sequence,
|
|
5
|
+
and registry status updates. ``worktree_mgmt`` re-exports this surface so callers
|
|
6
|
+
and tests keep importing from ``worktree_mgmt``.
|
|
7
|
+
|
|
8
|
+
Cross-cutting seams that tests monkeypatch on the ``worktree_mgmt`` module
|
|
9
|
+
(``_run_git``, ``_remove_branch``, ``merged_into_source``, ``detect_worktree_status``,
|
|
10
|
+
``dirty_info``, ``resolve_record``, ``_error_payload``) are read back through the
|
|
11
|
+
``worktree_mgmt`` facade (the ``wm`` alias) at call time so those patches still
|
|
12
|
+
take effect.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from delegate_agent import run_registry
|
|
21
|
+
from delegate_agent.git_utils import GIT_TIMEOUT_RETURN_CODE
|
|
22
|
+
from delegate_agent.json_types import JsonObject
|
|
23
|
+
from delegate_agent.worktree_records import (
|
|
24
|
+
SCHEMA_REMOVE,
|
|
25
|
+
STATUS_MISSING,
|
|
26
|
+
STATUS_PRESENT,
|
|
27
|
+
STATUS_REMOVED,
|
|
28
|
+
STATUS_UNKNOWN,
|
|
29
|
+
WORKTREE_ERROR_EXIT_CODE,
|
|
30
|
+
PersistentWorktreeRecord,
|
|
31
|
+
_utc_now_iso,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class BranchRemovalResult:
|
|
37
|
+
removed: bool
|
|
38
|
+
kept_reason: str | None = None
|
|
39
|
+
error: str | None = None
|
|
40
|
+
error_code: str | None = None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class RemoveWorktreeOptions:
|
|
45
|
+
discard_uncommitted: bool
|
|
46
|
+
force_branch: bool
|
|
47
|
+
keep_branch: bool
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class RemoveWorktreePlan:
|
|
52
|
+
record: PersistentWorktreeRecord
|
|
53
|
+
alias: str
|
|
54
|
+
status: str
|
|
55
|
+
source_git_root: str
|
|
56
|
+
execution_cwd: str
|
|
57
|
+
branch: str | None
|
|
58
|
+
discarded_paths: list[str] | None
|
|
59
|
+
warnings: list[str]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _remove_branch(source_git_root: str, branch: str, *, force: bool) -> BranchRemovalResult:
|
|
63
|
+
flag = "-D" if force else "-d"
|
|
64
|
+
result = wm._run_git(source_git_root, ["branch", flag, branch])
|
|
65
|
+
if result.returncode == 0:
|
|
66
|
+
return BranchRemovalResult(removed=True)
|
|
67
|
+
stderr = result.stderr.strip() or "delete_failed"
|
|
68
|
+
if result.returncode == GIT_TIMEOUT_RETURN_CODE:
|
|
69
|
+
return BranchRemovalResult(removed=False, error=stderr, error_code="git_timeout")
|
|
70
|
+
lowered = stderr.lower()
|
|
71
|
+
if not force and ("not fully merged" in lowered or "not merged" in lowered):
|
|
72
|
+
return BranchRemovalResult(removed=False, kept_reason="unmerged")
|
|
73
|
+
return BranchRemovalResult(removed=False, error=stderr)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _apply_branch_removal_result(
|
|
77
|
+
payload: JsonObject,
|
|
78
|
+
result: BranchRemovalResult,
|
|
79
|
+
*,
|
|
80
|
+
alias: str,
|
|
81
|
+
) -> None:
|
|
82
|
+
payload["branchRemoved"] = result.removed
|
|
83
|
+
if result.kept_reason:
|
|
84
|
+
payload["branchKept"] = result.kept_reason
|
|
85
|
+
if result.kept_reason == "unmerged":
|
|
86
|
+
payload["nextActions"] = [f"delegate worktree remove {alias} --force-branch"]
|
|
87
|
+
if result.error:
|
|
88
|
+
payload["ok"] = False
|
|
89
|
+
code = result.error_code or "branch_remove_failed"
|
|
90
|
+
payload["code"] = code
|
|
91
|
+
payload["error"] = code
|
|
92
|
+
payload["exitCode"] = WORKTREE_ERROR_EXIT_CODE
|
|
93
|
+
payload["branchRemovalError"] = result.error
|
|
94
|
+
if payload.get("pathRemoved") is True:
|
|
95
|
+
payload["partialSuccess"] = True
|
|
96
|
+
payload["nextActions"] = [f"delegate worktree remove {alias} --force-branch"]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _normalize_remove_options(
|
|
100
|
+
*,
|
|
101
|
+
discard_uncommitted: bool,
|
|
102
|
+
force_branch: bool,
|
|
103
|
+
keep_branch: bool,
|
|
104
|
+
force: bool,
|
|
105
|
+
handle: str,
|
|
106
|
+
) -> tuple[bool, bool, bool]:
|
|
107
|
+
"""Normalize raw CLI flags into canonical removal booleans.
|
|
108
|
+
|
|
109
|
+
Returns (discard_uncommitted, force_branch, keep_branch) after applying
|
|
110
|
+
the ``--force`` shorthand and validating mutual exclusions.
|
|
111
|
+
"""
|
|
112
|
+
if force:
|
|
113
|
+
discard_uncommitted = True
|
|
114
|
+
force_branch = True
|
|
115
|
+
if keep_branch and force_branch:
|
|
116
|
+
raise wm.WorktreeManagementError(
|
|
117
|
+
wm._error_payload(
|
|
118
|
+
"invalid_option_combination",
|
|
119
|
+
"--keep-branch is mutually exclusive with --force-branch.",
|
|
120
|
+
next_actions=[f"delegate worktree remove {handle} --keep-branch"],
|
|
121
|
+
)
|
|
122
|
+
)
|
|
123
|
+
return discard_uncommitted, force_branch, keep_branch
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _raise_if_dirty_without_discard(
|
|
127
|
+
*,
|
|
128
|
+
dirty: bool | None,
|
|
129
|
+
dirty_paths: list[str],
|
|
130
|
+
dirty_warnings: list[str] | None = None,
|
|
131
|
+
discard_uncommitted: bool,
|
|
132
|
+
record: PersistentWorktreeRecord,
|
|
133
|
+
alias: str,
|
|
134
|
+
) -> None:
|
|
135
|
+
"""Fail closed when dirty state is unsafe to discard implicitly."""
|
|
136
|
+
if dirty is None and not discard_uncommitted:
|
|
137
|
+
raise wm.WorktreeManagementError(
|
|
138
|
+
wm._error_payload(
|
|
139
|
+
"dirty_check_failed",
|
|
140
|
+
"Could not determine whether the worktree has uncommitted changes; inspect it or pass --discard-uncommitted to remove anyway.",
|
|
141
|
+
record=record,
|
|
142
|
+
warnings=dirty_warnings or None,
|
|
143
|
+
next_actions=[
|
|
144
|
+
f"delegate worktree show {alias}",
|
|
145
|
+
f"delegate worktree remove {alias} --discard-uncommitted",
|
|
146
|
+
],
|
|
147
|
+
)
|
|
148
|
+
)
|
|
149
|
+
if dirty is True and not discard_uncommitted:
|
|
150
|
+
raise wm.WorktreeManagementError(
|
|
151
|
+
wm._error_payload(
|
|
152
|
+
"dirty_worktree",
|
|
153
|
+
f"Worktree has {len(dirty_paths)} uncommitted changes; pass --discard-uncommitted to remove anyway.",
|
|
154
|
+
record=record,
|
|
155
|
+
dirty_paths=dirty_paths,
|
|
156
|
+
next_actions=[
|
|
157
|
+
f"delegate worktree show {alias}",
|
|
158
|
+
f"delegate worktree remove {alias} --discard-uncommitted",
|
|
159
|
+
],
|
|
160
|
+
)
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _raise_if_unmerged_without_override(
|
|
165
|
+
*,
|
|
166
|
+
record: PersistentWorktreeRecord,
|
|
167
|
+
status: str,
|
|
168
|
+
branch: str | None,
|
|
169
|
+
keep_branch: bool,
|
|
170
|
+
force_branch: bool,
|
|
171
|
+
alias: str,
|
|
172
|
+
) -> None:
|
|
173
|
+
"""Raise ``unmerged_branch`` when the branch is not merged into source
|
|
174
|
+
and the caller did not request ``--keep-branch`` or ``--force-branch``."""
|
|
175
|
+
if status not in (STATUS_PRESENT, STATUS_UNKNOWN):
|
|
176
|
+
return
|
|
177
|
+
if not isinstance(branch, str) or not branch:
|
|
178
|
+
return
|
|
179
|
+
if keep_branch or force_branch:
|
|
180
|
+
return
|
|
181
|
+
merged, merge_warnings = wm.merged_into_source(record, status)
|
|
182
|
+
if merged is None:
|
|
183
|
+
raise wm.WorktreeManagementError(
|
|
184
|
+
wm._error_payload(
|
|
185
|
+
"merge_check_failed",
|
|
186
|
+
"Could not determine whether the worktree branch is merged into current source HEAD; inspect it, merge it, or pass --keep-branch/--force-branch explicitly.",
|
|
187
|
+
record=record,
|
|
188
|
+
next_actions=[
|
|
189
|
+
f"delegate worktree show {alias}",
|
|
190
|
+
f"delegate worktree remove {alias} --keep-branch",
|
|
191
|
+
f"delegate worktree remove {alias} --force-branch",
|
|
192
|
+
],
|
|
193
|
+
warnings=merge_warnings or None,
|
|
194
|
+
)
|
|
195
|
+
)
|
|
196
|
+
if merged is False:
|
|
197
|
+
raise wm.WorktreeManagementError(
|
|
198
|
+
wm._error_payload(
|
|
199
|
+
"unmerged_branch",
|
|
200
|
+
"Worktree branch is not merged into current source HEAD; inspect it, merge it, or pass --keep-branch/--force-branch explicitly.",
|
|
201
|
+
record=record,
|
|
202
|
+
next_actions=[
|
|
203
|
+
f"delegate worktree show {alias}",
|
|
204
|
+
f"delegate worktree remove {alias} --keep-branch",
|
|
205
|
+
f"delegate worktree remove {alias} --force-branch",
|
|
206
|
+
],
|
|
207
|
+
warnings=merge_warnings or None,
|
|
208
|
+
)
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _require_removal_metadata(record: PersistentWorktreeRecord) -> tuple[str, str]:
|
|
213
|
+
"""Validate that ``sourceGitRoot`` and ``executionCwd`` are present.
|
|
214
|
+
|
|
215
|
+
Returns ``(source_git_root, execution_cwd)`` for direct use by the caller.
|
|
216
|
+
"""
|
|
217
|
+
source_git_root = record.get("sourceGitRoot")
|
|
218
|
+
execution_cwd = record.get("executionCwd")
|
|
219
|
+
if not isinstance(source_git_root, str) or not isinstance(execution_cwd, str):
|
|
220
|
+
raise wm.WorktreeManagementError(
|
|
221
|
+
wm._error_payload(
|
|
222
|
+
"worktree_remove_failed",
|
|
223
|
+
"Run is missing sourceGitRoot or executionCwd metadata.",
|
|
224
|
+
record=record,
|
|
225
|
+
)
|
|
226
|
+
)
|
|
227
|
+
return source_git_root, execution_cwd
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _remove_worktree_path(
|
|
231
|
+
*,
|
|
232
|
+
source_git_root: str,
|
|
233
|
+
execution_cwd: str,
|
|
234
|
+
discard_uncommitted: bool,
|
|
235
|
+
record: PersistentWorktreeRecord,
|
|
236
|
+
alias: str,
|
|
237
|
+
) -> None:
|
|
238
|
+
"""Execute ``git worktree remove`` and raise on failure."""
|
|
239
|
+
remove_args = ["worktree", "remove"]
|
|
240
|
+
if discard_uncommitted:
|
|
241
|
+
remove_args.append("--force")
|
|
242
|
+
remove_args.append(execution_cwd)
|
|
243
|
+
result = wm._run_git(source_git_root, remove_args)
|
|
244
|
+
if result.returncode != 0:
|
|
245
|
+
if result.returncode == GIT_TIMEOUT_RETURN_CODE:
|
|
246
|
+
raise wm.WorktreeManagementError(
|
|
247
|
+
wm._error_payload(
|
|
248
|
+
"git_timeout",
|
|
249
|
+
f"git worktree remove timed out: {result.stderr.strip()}",
|
|
250
|
+
record=record,
|
|
251
|
+
next_actions=[f"delegate worktree show {alias}"],
|
|
252
|
+
retry_safe=True,
|
|
253
|
+
)
|
|
254
|
+
)
|
|
255
|
+
raise wm.WorktreeManagementError(
|
|
256
|
+
wm._error_payload(
|
|
257
|
+
"worktree_remove_failed",
|
|
258
|
+
f"git worktree remove failed: {result.stderr.strip()}",
|
|
259
|
+
record=record,
|
|
260
|
+
next_actions=[f"delegate worktree show {alias}"],
|
|
261
|
+
retry_safe=True,
|
|
262
|
+
)
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _remove_branch_if_requested(
|
|
267
|
+
*,
|
|
268
|
+
source_git_root: str | None,
|
|
269
|
+
branch: str | None,
|
|
270
|
+
keep_branch: bool,
|
|
271
|
+
force_branch: bool,
|
|
272
|
+
status: str,
|
|
273
|
+
) -> BranchRemovalResult:
|
|
274
|
+
"""Decide and execute branch removal after the worktree path is gone.
|
|
275
|
+
|
|
276
|
+
Returns the ``BranchRemovalResult`` describing what happened to the branch.
|
|
277
|
+
For the normal-success path (not missing, not already-removed), the caller
|
|
278
|
+
should post-process the result to handle prune-originated ``keep_branch``
|
|
279
|
+
with an unmerged branch (spec L673).
|
|
280
|
+
"""
|
|
281
|
+
if keep_branch:
|
|
282
|
+
return BranchRemovalResult(removed=False, kept_reason="requested")
|
|
283
|
+
if status == STATUS_REMOVED and not force_branch:
|
|
284
|
+
return BranchRemovalResult(removed=False)
|
|
285
|
+
if force_branch and isinstance(source_git_root, str) and isinstance(branch, str) and branch:
|
|
286
|
+
return wm._remove_branch(source_git_root, branch, force=True)
|
|
287
|
+
if status == STATUS_MISSING:
|
|
288
|
+
return BranchRemovalResult(removed=False, kept_reason="path_missing")
|
|
289
|
+
if isinstance(source_git_root, str) and isinstance(branch, str) and branch:
|
|
290
|
+
return wm._remove_branch(source_git_root, branch, force=False)
|
|
291
|
+
return BranchRemovalResult(removed=False)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _mark_worktree_removed(
|
|
295
|
+
*,
|
|
296
|
+
registry_root: Path,
|
|
297
|
+
run_id: str,
|
|
298
|
+
discarded_paths: list[str] | None,
|
|
299
|
+
) -> None:
|
|
300
|
+
"""Update registry state to mark the run as removed."""
|
|
301
|
+
run_registry.set_worktree_status_locked(
|
|
302
|
+
registry_root,
|
|
303
|
+
run_id,
|
|
304
|
+
"removed",
|
|
305
|
+
removed_at=_utc_now_iso(),
|
|
306
|
+
discarded_dirty_paths=discarded_paths,
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _remove_payload(
|
|
311
|
+
*,
|
|
312
|
+
record: PersistentWorktreeRecord,
|
|
313
|
+
branch: object,
|
|
314
|
+
execution_cwd: object,
|
|
315
|
+
source_git_root: object,
|
|
316
|
+
path_removed: bool,
|
|
317
|
+
noop: bool,
|
|
318
|
+
branch_result: BranchRemovalResult,
|
|
319
|
+
alias: str,
|
|
320
|
+
discarded_paths: list[str] | None = None,
|
|
321
|
+
warnings: list[str] | None = None,
|
|
322
|
+
) -> JsonObject:
|
|
323
|
+
payload: JsonObject = {
|
|
324
|
+
"schema": SCHEMA_REMOVE,
|
|
325
|
+
"ok": not bool(branch_result.error),
|
|
326
|
+
"alias": record.get("alias"),
|
|
327
|
+
"runId": record.get("runId"),
|
|
328
|
+
"branch": branch,
|
|
329
|
+
"executionCwd": execution_cwd,
|
|
330
|
+
"sourceGitRoot": source_git_root,
|
|
331
|
+
"removed": True,
|
|
332
|
+
"pathRemoved": path_removed,
|
|
333
|
+
"worktreeStatus": STATUS_REMOVED,
|
|
334
|
+
"noop": noop,
|
|
335
|
+
}
|
|
336
|
+
_apply_branch_removal_result(payload, branch_result, alias=alias)
|
|
337
|
+
if discarded_paths is not None:
|
|
338
|
+
payload["discardedDirtyPaths"] = discarded_paths
|
|
339
|
+
if warnings:
|
|
340
|
+
payload["warnings"] = warnings
|
|
341
|
+
return payload
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _build_remove_worktree_plan(
|
|
345
|
+
record: PersistentWorktreeRecord,
|
|
346
|
+
*,
|
|
347
|
+
alias: str,
|
|
348
|
+
status: str,
|
|
349
|
+
status_warnings: list[str],
|
|
350
|
+
options: RemoveWorktreeOptions,
|
|
351
|
+
merged_check_already_passed: bool,
|
|
352
|
+
) -> RemoveWorktreePlan:
|
|
353
|
+
dirty, dirty_paths, _dirty_total, dirty_warnings = wm.dirty_info(record, status)
|
|
354
|
+
all_warnings = [*status_warnings, *dirty_warnings]
|
|
355
|
+
if status in (STATUS_PRESENT, STATUS_UNKNOWN):
|
|
356
|
+
_raise_if_dirty_without_discard(
|
|
357
|
+
dirty=dirty,
|
|
358
|
+
dirty_paths=dirty_paths,
|
|
359
|
+
dirty_warnings=dirty_warnings,
|
|
360
|
+
discard_uncommitted=options.discard_uncommitted,
|
|
361
|
+
record=record,
|
|
362
|
+
alias=alias,
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
source_git_root, execution_cwd = _require_removal_metadata(record)
|
|
366
|
+
branch = record.get("branch")
|
|
367
|
+
if not merged_check_already_passed:
|
|
368
|
+
_raise_if_unmerged_without_override(
|
|
369
|
+
record=record,
|
|
370
|
+
status=status,
|
|
371
|
+
branch=branch,
|
|
372
|
+
keep_branch=options.keep_branch,
|
|
373
|
+
force_branch=options.force_branch,
|
|
374
|
+
alias=alias,
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
return RemoveWorktreePlan(
|
|
378
|
+
record=record,
|
|
379
|
+
alias=alias,
|
|
380
|
+
status=status,
|
|
381
|
+
source_git_root=source_git_root,
|
|
382
|
+
execution_cwd=execution_cwd,
|
|
383
|
+
branch=branch,
|
|
384
|
+
discarded_paths=dirty_paths if options.discard_uncommitted and dirty_paths else None,
|
|
385
|
+
warnings=all_warnings,
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _remove_already_removed(
|
|
390
|
+
record: PersistentWorktreeRecord,
|
|
391
|
+
*,
|
|
392
|
+
alias: str,
|
|
393
|
+
options: RemoveWorktreeOptions,
|
|
394
|
+
) -> JsonObject:
|
|
395
|
+
source_git_root = record.get("sourceGitRoot")
|
|
396
|
+
branch = record.get("branch")
|
|
397
|
+
branch_result = _remove_branch_if_requested(
|
|
398
|
+
source_git_root=source_git_root,
|
|
399
|
+
branch=branch,
|
|
400
|
+
keep_branch=options.keep_branch,
|
|
401
|
+
force_branch=options.force_branch,
|
|
402
|
+
status=STATUS_REMOVED,
|
|
403
|
+
)
|
|
404
|
+
return _remove_payload(
|
|
405
|
+
record=record,
|
|
406
|
+
branch=branch,
|
|
407
|
+
execution_cwd=record.get("executionCwd"),
|
|
408
|
+
source_git_root=source_git_root,
|
|
409
|
+
path_removed=False,
|
|
410
|
+
noop=not branch_result.removed,
|
|
411
|
+
branch_result=branch_result,
|
|
412
|
+
alias=alias,
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _remove_missing_worktree_path(
|
|
417
|
+
registry_root: Path,
|
|
418
|
+
plan: RemoveWorktreePlan,
|
|
419
|
+
*,
|
|
420
|
+
options: RemoveWorktreeOptions,
|
|
421
|
+
) -> JsonObject:
|
|
422
|
+
branch_result = _remove_branch_if_requested(
|
|
423
|
+
source_git_root=plan.source_git_root,
|
|
424
|
+
branch=plan.branch,
|
|
425
|
+
keep_branch=options.keep_branch,
|
|
426
|
+
force_branch=options.force_branch,
|
|
427
|
+
status=plan.status,
|
|
428
|
+
)
|
|
429
|
+
_mark_worktree_removed(
|
|
430
|
+
registry_root=registry_root,
|
|
431
|
+
run_id=str(plan.record["runId"]),
|
|
432
|
+
discarded_paths=plan.discarded_paths,
|
|
433
|
+
)
|
|
434
|
+
return _remove_payload(
|
|
435
|
+
record=plan.record,
|
|
436
|
+
branch=plan.branch,
|
|
437
|
+
execution_cwd=plan.execution_cwd,
|
|
438
|
+
source_git_root=plan.source_git_root,
|
|
439
|
+
path_removed=False,
|
|
440
|
+
noop=False,
|
|
441
|
+
branch_result=branch_result,
|
|
442
|
+
alias=plan.alias,
|
|
443
|
+
discarded_paths=plan.discarded_paths,
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def _remove_present_worktree_path(
|
|
448
|
+
registry_root: Path,
|
|
449
|
+
plan: RemoveWorktreePlan,
|
|
450
|
+
*,
|
|
451
|
+
options: RemoveWorktreeOptions,
|
|
452
|
+
) -> JsonObject:
|
|
453
|
+
_remove_worktree_path(
|
|
454
|
+
source_git_root=plan.source_git_root,
|
|
455
|
+
execution_cwd=plan.execution_cwd,
|
|
456
|
+
discard_uncommitted=options.discard_uncommitted,
|
|
457
|
+
record=plan.record,
|
|
458
|
+
alias=plan.alias,
|
|
459
|
+
)
|
|
460
|
+
branch_result = _remove_branch_if_requested(
|
|
461
|
+
source_git_root=plan.source_git_root,
|
|
462
|
+
branch=plan.branch,
|
|
463
|
+
keep_branch=options.keep_branch,
|
|
464
|
+
force_branch=options.force_branch,
|
|
465
|
+
status=plan.status,
|
|
466
|
+
)
|
|
467
|
+
# Override branchKept when keep_branch came from prune on a clean worktree
|
|
468
|
+
# whose branch is not merged into source (spec L673).
|
|
469
|
+
if (
|
|
470
|
+
options.keep_branch
|
|
471
|
+
and branch_result.kept_reason == "requested"
|
|
472
|
+
and isinstance(plan.branch, str)
|
|
473
|
+
):
|
|
474
|
+
merged_val, _ = wm.merged_into_source(plan.record, plan.status)
|
|
475
|
+
if merged_val is False:
|
|
476
|
+
branch_result = BranchRemovalResult(removed=False, kept_reason="unmerged")
|
|
477
|
+
|
|
478
|
+
_mark_worktree_removed(
|
|
479
|
+
registry_root=registry_root,
|
|
480
|
+
run_id=str(plan.record["runId"]),
|
|
481
|
+
discarded_paths=plan.discarded_paths,
|
|
482
|
+
)
|
|
483
|
+
return _remove_payload(
|
|
484
|
+
record=plan.record,
|
|
485
|
+
branch=plan.branch,
|
|
486
|
+
execution_cwd=plan.execution_cwd,
|
|
487
|
+
source_git_root=plan.source_git_root,
|
|
488
|
+
path_removed=True,
|
|
489
|
+
noop=False,
|
|
490
|
+
branch_result=branch_result,
|
|
491
|
+
alias=plan.alias,
|
|
492
|
+
discarded_paths=plan.discarded_paths,
|
|
493
|
+
warnings=plan.warnings,
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def remove_worktree(
|
|
498
|
+
registry_root: Path,
|
|
499
|
+
*,
|
|
500
|
+
handle: str,
|
|
501
|
+
discard_uncommitted: bool = False,
|
|
502
|
+
force_branch: bool = False,
|
|
503
|
+
keep_branch: bool = False,
|
|
504
|
+
force: bool = False,
|
|
505
|
+
_merged_check_already_passed: bool = False,
|
|
506
|
+
) -> JsonObject:
|
|
507
|
+
discard_uncommitted, force_branch, keep_branch = _normalize_remove_options(
|
|
508
|
+
discard_uncommitted=discard_uncommitted,
|
|
509
|
+
force_branch=force_branch,
|
|
510
|
+
keep_branch=keep_branch,
|
|
511
|
+
force=force,
|
|
512
|
+
handle=handle,
|
|
513
|
+
)
|
|
514
|
+
options = RemoveWorktreeOptions(
|
|
515
|
+
discard_uncommitted=discard_uncommitted,
|
|
516
|
+
force_branch=force_branch,
|
|
517
|
+
keep_branch=keep_branch,
|
|
518
|
+
)
|
|
519
|
+
with run_registry.registry_lock(registry_root):
|
|
520
|
+
record = wm.resolve_record(registry_root, handle=handle)
|
|
521
|
+
status, warnings = wm.detect_worktree_status(record)
|
|
522
|
+
alias = str(record.get("alias") or handle)
|
|
523
|
+
|
|
524
|
+
if status == STATUS_REMOVED:
|
|
525
|
+
return _remove_already_removed(record, alias=alias, options=options)
|
|
526
|
+
|
|
527
|
+
plan = _build_remove_worktree_plan(
|
|
528
|
+
record,
|
|
529
|
+
alias=alias,
|
|
530
|
+
status=status,
|
|
531
|
+
status_warnings=warnings,
|
|
532
|
+
options=options,
|
|
533
|
+
merged_check_already_passed=_merged_check_already_passed,
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
if status == STATUS_MISSING:
|
|
537
|
+
return _remove_missing_worktree_path(registry_root, plan, options=options)
|
|
538
|
+
|
|
539
|
+
return _remove_present_worktree_path(registry_root, plan, options=options)
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
# Deferred to the bottom to break the worktree_mgmt<->worktree_remove facade
|
|
543
|
+
# cycle: worktree_mgmt re-exports this module's surface (a top-level import here
|
|
544
|
+
# would fail when worktree_remove is imported first). All `wm.<seam>` access
|
|
545
|
+
# above is call-time, so binding the alias after our own definitions is
|
|
546
|
+
# sufficient and keeps mock.patch.object(worktree_mgmt, ...) seams working.
|
|
547
|
+
from delegate_agent import worktree_mgmt as wm # noqa: E402
|