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,782 @@
|
|
|
1
|
+
"""Persistent worktree management facade.
|
|
2
|
+
|
|
3
|
+
This module owns the worktree status/inspection layer (status detection, dirty
|
|
4
|
+
and merged checks, ahead/behind, record decoration, ``list``/``show``) and the
|
|
5
|
+
shared error envelope, and re-exports the record model (``worktree_records``),
|
|
6
|
+
the removal pipeline (``worktree_remove``), and the prune/gc pipelines
|
|
7
|
+
(``worktree_gc``) so callers — ``worktree_commands``, ``cli``, and the test
|
|
8
|
+
suite — keep importing the full surface from ``worktree_mgmt`` unchanged.
|
|
9
|
+
|
|
10
|
+
The seam functions tests monkeypatch via ``worktree_mgmt.<name>`` (e.g.
|
|
11
|
+
``_run_git``, ``porcelain_status``, ``merged_into_source``,
|
|
12
|
+
``detect_worktree_status``, ``_remove_branch``, ``prune_worktrees``) are either
|
|
13
|
+
defined here or re-exported here; the moved pipeline functions read them back
|
|
14
|
+
through this module so those patches keep taking effect.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from contextlib import suppress
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from delegate_agent import run_registry, worktree_summary
|
|
23
|
+
from delegate_agent.git_utils import (
|
|
24
|
+
GIT_QUICK_TIMEOUT_SECONDS,
|
|
25
|
+
rev_parse_verify,
|
|
26
|
+
)
|
|
27
|
+
from delegate_agent.git_utils import (
|
|
28
|
+
run_git as _run_git,
|
|
29
|
+
)
|
|
30
|
+
from delegate_agent.json_types import JsonObject
|
|
31
|
+
from delegate_agent.worktree_records import ( # noqa: F401 # re-exported
|
|
32
|
+
MAX_DIRTY_PATHS_REPORTED,
|
|
33
|
+
SCHEMA_GC,
|
|
34
|
+
SCHEMA_LIST,
|
|
35
|
+
SCHEMA_PRUNE,
|
|
36
|
+
SCHEMA_REMOVE,
|
|
37
|
+
SCHEMA_SHOW,
|
|
38
|
+
STATUS_MISSING,
|
|
39
|
+
STATUS_PRESENT,
|
|
40
|
+
STATUS_REMOVED,
|
|
41
|
+
STATUS_UNKNOWN,
|
|
42
|
+
VALID_STATUSES,
|
|
43
|
+
WORKTREE_ERROR_EXIT_CODE,
|
|
44
|
+
PersistentWorktreeRecord,
|
|
45
|
+
_branch_from,
|
|
46
|
+
_creation_context,
|
|
47
|
+
_execution_cwd_from,
|
|
48
|
+
_get_dict,
|
|
49
|
+
_get_str,
|
|
50
|
+
_is_persistent_worktree_run,
|
|
51
|
+
_record_for_run,
|
|
52
|
+
_registered_worktree_path_matches,
|
|
53
|
+
_registry_worktree_status,
|
|
54
|
+
_reload_record,
|
|
55
|
+
_shell,
|
|
56
|
+
_source_git_root_from,
|
|
57
|
+
_utc_now_iso,
|
|
58
|
+
latest_persistent_record_for_harness,
|
|
59
|
+
load_persistent_records,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class WorktreeManagementError(Exception):
|
|
64
|
+
def __init__(self, payload: JsonObject) -> None:
|
|
65
|
+
code = payload.get("code")
|
|
66
|
+
message = payload.get("message")
|
|
67
|
+
if not isinstance(code, str) or not code:
|
|
68
|
+
raise ValueError("worktree error payload requires non-empty string code")
|
|
69
|
+
if not isinstance(message, str) or not message:
|
|
70
|
+
raise ValueError("worktree error payload requires non-empty string message")
|
|
71
|
+
normalized = dict(payload)
|
|
72
|
+
normalized["ok"] = False
|
|
73
|
+
normalized["code"] = code
|
|
74
|
+
normalized["error"] = str(normalized.get("error") or code)
|
|
75
|
+
normalized["message"] = message
|
|
76
|
+
exit_code = normalized.get("exitCode")
|
|
77
|
+
normalized["exitCode"] = (
|
|
78
|
+
exit_code if isinstance(exit_code, int) else WORKTREE_ERROR_EXIT_CODE
|
|
79
|
+
)
|
|
80
|
+
super().__init__(message)
|
|
81
|
+
self.payload = normalized
|
|
82
|
+
self.code = code
|
|
83
|
+
self.message = message
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _branch_ref(branch: str) -> str:
|
|
87
|
+
return branch if branch.startswith("refs/heads/") else f"refs/heads/{branch}"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _branch_exists(source_git_root: str, branch: str) -> bool | None:
|
|
91
|
+
result = _run_git(
|
|
92
|
+
source_git_root,
|
|
93
|
+
["rev-parse", "--verify", "--quiet", _branch_ref(branch)],
|
|
94
|
+
timeout_seconds=GIT_QUICK_TIMEOUT_SECONDS,
|
|
95
|
+
)
|
|
96
|
+
if result.returncode == 0:
|
|
97
|
+
return True
|
|
98
|
+
if result.returncode == 1:
|
|
99
|
+
return False
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def detect_worktree_status(record: PersistentWorktreeRecord) -> tuple[str, list[str]]:
|
|
104
|
+
warnings: list[str] = []
|
|
105
|
+
registry_status = record.get("registryWorktreeStatus")
|
|
106
|
+
if registry_status == STATUS_REMOVED:
|
|
107
|
+
return STATUS_REMOVED, warnings
|
|
108
|
+
execution_cwd = record.get("executionCwd")
|
|
109
|
+
source_git_root = record.get("sourceGitRoot")
|
|
110
|
+
branch = record.get("branch")
|
|
111
|
+
if not isinstance(execution_cwd, str) or not execution_cwd:
|
|
112
|
+
return STATUS_UNKNOWN, ["missing executionCwd metadata"]
|
|
113
|
+
if not Path(execution_cwd).exists():
|
|
114
|
+
return STATUS_MISSING, warnings
|
|
115
|
+
if not isinstance(source_git_root, str) or not source_git_root:
|
|
116
|
+
return STATUS_UNKNOWN, ["missing sourceGitRoot metadata"]
|
|
117
|
+
if not isinstance(branch, str) or not branch:
|
|
118
|
+
return STATUS_UNKNOWN, ["missing branch metadata"]
|
|
119
|
+
listed_paths, list_warning = _worktree_list_paths_with_warning(source_git_root)
|
|
120
|
+
if listed_paths is None:
|
|
121
|
+
return STATUS_UNKNOWN, [list_warning or "could not list registered git worktrees"]
|
|
122
|
+
if not _registered_worktree_path_matches(listed_paths, execution_cwd):
|
|
123
|
+
return STATUS_UNKNOWN, ["worktree path is not registered with git"]
|
|
124
|
+
branch_exists = _branch_exists(source_git_root, branch)
|
|
125
|
+
if branch_exists is True:
|
|
126
|
+
return STATUS_PRESENT, warnings
|
|
127
|
+
if branch_exists is False:
|
|
128
|
+
return STATUS_UNKNOWN, ["branch does not resolve"]
|
|
129
|
+
return STATUS_UNKNOWN, ["could not determine branch status"]
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def porcelain_status(
|
|
133
|
+
execution_cwd: str,
|
|
134
|
+
*,
|
|
135
|
+
limit: int | None = None,
|
|
136
|
+
) -> tuple[list[str] | None, int | None, list[str]]:
|
|
137
|
+
result = _run_git(
|
|
138
|
+
execution_cwd,
|
|
139
|
+
["status", "--porcelain=v1", "--untracked-files=normal", "--ignore-submodules=none"],
|
|
140
|
+
timeout_seconds=GIT_QUICK_TIMEOUT_SECONDS,
|
|
141
|
+
)
|
|
142
|
+
if result.returncode != 0:
|
|
143
|
+
return None, None, [f"git status failed: {result.stderr.strip()}"]
|
|
144
|
+
lines = result.stdout.splitlines()
|
|
145
|
+
if limit is None:
|
|
146
|
+
return lines, len(lines), []
|
|
147
|
+
return lines[:limit], len(lines), []
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def dirty_info(
|
|
151
|
+
record: PersistentWorktreeRecord, status: str
|
|
152
|
+
) -> tuple[bool | None, list[str], int | None, list[str]]:
|
|
153
|
+
if status not in (STATUS_PRESENT, STATUS_UNKNOWN):
|
|
154
|
+
return None, [], None, []
|
|
155
|
+
execution_cwd = record.get("executionCwd")
|
|
156
|
+
if not isinstance(execution_cwd, str) or not execution_cwd:
|
|
157
|
+
return None, [], None, ["missing executionCwd metadata"]
|
|
158
|
+
lines, total, warnings = porcelain_status(execution_cwd)
|
|
159
|
+
if lines is None:
|
|
160
|
+
return None, [], total, warnings
|
|
161
|
+
return bool(lines), lines, total, warnings
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _merge_base_is_ancestor(source_git_root: str, branch: str) -> bool | None:
|
|
165
|
+
result = _run_git(
|
|
166
|
+
source_git_root,
|
|
167
|
+
["merge-base", "--is-ancestor", _branch_ref(branch), "HEAD"],
|
|
168
|
+
timeout_seconds=GIT_QUICK_TIMEOUT_SECONDS,
|
|
169
|
+
)
|
|
170
|
+
if result.returncode == 0:
|
|
171
|
+
return True
|
|
172
|
+
if result.returncode == 1:
|
|
173
|
+
return False
|
|
174
|
+
return None
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def merged_into_source(
|
|
178
|
+
record: PersistentWorktreeRecord,
|
|
179
|
+
status: str,
|
|
180
|
+
*,
|
|
181
|
+
include_detached: bool = False,
|
|
182
|
+
) -> tuple[bool | None, list[str]]:
|
|
183
|
+
if status not in (STATUS_PRESENT, STATUS_UNKNOWN):
|
|
184
|
+
return None, []
|
|
185
|
+
creation = record.get("creationContext")
|
|
186
|
+
if (
|
|
187
|
+
isinstance(creation, dict)
|
|
188
|
+
and creation.get("sourceHeadRef") is None
|
|
189
|
+
and not include_detached
|
|
190
|
+
):
|
|
191
|
+
return None, ["source was detached at creation; integration target unknown"]
|
|
192
|
+
source_git_root = record.get("sourceGitRoot")
|
|
193
|
+
branch = record.get("branch")
|
|
194
|
+
if not isinstance(source_git_root, str) or not isinstance(branch, str):
|
|
195
|
+
return None, ["missing sourceGitRoot or branch metadata"]
|
|
196
|
+
value = _merge_base_is_ancestor(source_git_root, branch)
|
|
197
|
+
if value is None:
|
|
198
|
+
return None, ["could not determine whether branch is merged into current source HEAD"]
|
|
199
|
+
return value, []
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _rev_parse(source_git_root: str, rev: str) -> str | None:
|
|
203
|
+
return rev_parse_verify(
|
|
204
|
+
source_git_root,
|
|
205
|
+
rev,
|
|
206
|
+
timeout_seconds=GIT_QUICK_TIMEOUT_SECONDS,
|
|
207
|
+
git_runner=_run_git,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _symbolic_ref(source_git_root: str, rev: str) -> str | None:
|
|
212
|
+
"""Return the symbolic ref (e.g. `refs/heads/main`) or None if detached/missing."""
|
|
213
|
+
result = _run_git(
|
|
214
|
+
source_git_root,
|
|
215
|
+
["symbolic-ref", "--quiet", rev],
|
|
216
|
+
timeout_seconds=GIT_QUICK_TIMEOUT_SECONDS,
|
|
217
|
+
)
|
|
218
|
+
if result.returncode != 0:
|
|
219
|
+
return None
|
|
220
|
+
return result.stdout.strip() or None
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _ahead_behind(source_git_root: str, branch: str, base: str) -> JsonObject | None:
|
|
224
|
+
result = _run_git(
|
|
225
|
+
source_git_root,
|
|
226
|
+
["rev-list", "--left-right", "--count", f"{base}...{branch}"],
|
|
227
|
+
timeout_seconds=GIT_QUICK_TIMEOUT_SECONDS,
|
|
228
|
+
)
|
|
229
|
+
if result.returncode != 0:
|
|
230
|
+
return None
|
|
231
|
+
parts = result.stdout.strip().split()
|
|
232
|
+
if len(parts) != 2:
|
|
233
|
+
return None
|
|
234
|
+
try:
|
|
235
|
+
behind = int(parts[0])
|
|
236
|
+
ahead = int(parts[1])
|
|
237
|
+
except ValueError:
|
|
238
|
+
return None
|
|
239
|
+
base_oid = _rev_parse(source_git_root, base) or base
|
|
240
|
+
return {"ahead": ahead, "behind": behind, "baseOid": base_oid}
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def ahead_behind(record: PersistentWorktreeRecord, status: str) -> JsonObject | None:
|
|
244
|
+
if status not in (STATUS_PRESENT, STATUS_UNKNOWN):
|
|
245
|
+
return None
|
|
246
|
+
source_git_root = record.get("sourceGitRoot")
|
|
247
|
+
branch = record.get("branch")
|
|
248
|
+
creation = record.get("creationContext")
|
|
249
|
+
if (
|
|
250
|
+
not isinstance(source_git_root, str)
|
|
251
|
+
or not isinstance(branch, str)
|
|
252
|
+
or not isinstance(creation, dict)
|
|
253
|
+
):
|
|
254
|
+
return None
|
|
255
|
+
creation_base = creation.get("sourceHeadOid")
|
|
256
|
+
vs_creation = (
|
|
257
|
+
_ahead_behind(source_git_root, branch, creation_base)
|
|
258
|
+
if isinstance(creation_base, str) and creation_base
|
|
259
|
+
else None
|
|
260
|
+
)
|
|
261
|
+
current_head = _rev_parse(source_git_root, "HEAD")
|
|
262
|
+
vs_current = _ahead_behind(source_git_root, branch, current_head) if current_head else None
|
|
263
|
+
return {
|
|
264
|
+
"vsCreationBase": vs_creation,
|
|
265
|
+
"vsCurrentHead": vs_current,
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _integration_status(
|
|
270
|
+
*,
|
|
271
|
+
branch_merged: bool | None,
|
|
272
|
+
has_uncommitted_changes: bool | None,
|
|
273
|
+
) -> str | None:
|
|
274
|
+
if branch_merged is None or has_uncommitted_changes is None:
|
|
275
|
+
return "unknown"
|
|
276
|
+
if branch_merged:
|
|
277
|
+
return "branch-merged-worktree-dirty" if has_uncommitted_changes else "fully-integrated"
|
|
278
|
+
return "branch-unmerged-worktree-dirty" if has_uncommitted_changes else "branch-unmerged"
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def integration_fields(
|
|
282
|
+
*,
|
|
283
|
+
branch_merged: bool | None,
|
|
284
|
+
has_uncommitted_changes: bool | None,
|
|
285
|
+
) -> JsonObject:
|
|
286
|
+
fully_integrated = (
|
|
287
|
+
branch_merged is True and has_uncommitted_changes is False
|
|
288
|
+
if branch_merged is not None and has_uncommitted_changes is not None
|
|
289
|
+
else None
|
|
290
|
+
)
|
|
291
|
+
uncommitted_changes_integrated = (
|
|
292
|
+
None if has_uncommitted_changes is None else not has_uncommitted_changes
|
|
293
|
+
)
|
|
294
|
+
return {
|
|
295
|
+
"branchMergedIntoSource": branch_merged,
|
|
296
|
+
"hasUncommittedChanges": has_uncommitted_changes,
|
|
297
|
+
# Backward compatibility: v1 exposed mergedIntoSource as the branch
|
|
298
|
+
# ancestry check. Keep that meaning and expose the aggregate state under
|
|
299
|
+
# a new field instead of silently repurposing the old one.
|
|
300
|
+
"mergedIntoSource": branch_merged,
|
|
301
|
+
"fullyIntegrated": fully_integrated,
|
|
302
|
+
"integrationStatus": _integration_status(
|
|
303
|
+
branch_merged=branch_merged,
|
|
304
|
+
has_uncommitted_changes=has_uncommitted_changes,
|
|
305
|
+
),
|
|
306
|
+
"uncommittedChangesIntegrated": uncommitted_changes_integrated,
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _suppress_merge_suggestions(
|
|
311
|
+
*,
|
|
312
|
+
status: str,
|
|
313
|
+
ahead_behind_payload: JsonObject | None,
|
|
314
|
+
) -> bool:
|
|
315
|
+
if status not in (STATUS_PRESENT, STATUS_UNKNOWN):
|
|
316
|
+
return False
|
|
317
|
+
if not isinstance(ahead_behind_payload, dict):
|
|
318
|
+
return False
|
|
319
|
+
vs_current = ahead_behind_payload.get("vsCurrentHead")
|
|
320
|
+
if not isinstance(vs_current, dict):
|
|
321
|
+
return False
|
|
322
|
+
ahead = vs_current.get("ahead")
|
|
323
|
+
return ahead == 0
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def suggested_commands(
|
|
327
|
+
record: PersistentWorktreeRecord,
|
|
328
|
+
status: str,
|
|
329
|
+
*,
|
|
330
|
+
ahead_behind_payload: JsonObject | None = None,
|
|
331
|
+
) -> JsonObject:
|
|
332
|
+
alias = record.get("alias") or record.get("runId") or "<handle>"
|
|
333
|
+
execution_cwd = record.get("executionCwd")
|
|
334
|
+
source_git_root = record.get("sourceGitRoot")
|
|
335
|
+
branch = record.get("branch")
|
|
336
|
+
creation = record.get("creationContext")
|
|
337
|
+
base = creation.get("sourceHeadOid") if isinstance(creation, dict) else None
|
|
338
|
+
review_diff = None
|
|
339
|
+
review_diff_base = None
|
|
340
|
+
merge = None
|
|
341
|
+
cherry = None
|
|
342
|
+
if isinstance(execution_cwd, str) and status in (STATUS_PRESENT, STATUS_UNKNOWN):
|
|
343
|
+
review_diff = _shell(["git", "-C", execution_cwd, "diff", "--stat", "HEAD"])
|
|
344
|
+
if isinstance(base, str) and base:
|
|
345
|
+
review_diff_base = _shell(["git", "-C", execution_cwd, "diff", "--stat", base])
|
|
346
|
+
suppress_merge = _suppress_merge_suggestions(
|
|
347
|
+
status=status,
|
|
348
|
+
ahead_behind_payload=ahead_behind_payload,
|
|
349
|
+
)
|
|
350
|
+
if not suppress_merge and isinstance(source_git_root, str) and isinstance(branch, str):
|
|
351
|
+
merge = _shell(["git", "-C", source_git_root, "merge", "--no-ff", branch])
|
|
352
|
+
if isinstance(base, str) and base:
|
|
353
|
+
cherry = _shell(["git", "-C", source_git_root, "cherry-pick", f"{base}..{branch}"])
|
|
354
|
+
return {
|
|
355
|
+
"reviewDiff": review_diff,
|
|
356
|
+
"reviewDiffVsCreationBase": review_diff_base,
|
|
357
|
+
"mergeIntoSource": merge,
|
|
358
|
+
"cherryPickRange": cherry,
|
|
359
|
+
"safeRemove": f"delegate worktree remove {alias}",
|
|
360
|
+
"discardAndRemove": f"delegate worktree remove {alias} --discard-uncommitted",
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def decorate_record(
|
|
365
|
+
record: PersistentWorktreeRecord,
|
|
366
|
+
*,
|
|
367
|
+
include_detached: bool = False,
|
|
368
|
+
include_work_summary: bool = False,
|
|
369
|
+
include_porcelain_cache: bool = False,
|
|
370
|
+
) -> JsonObject:
|
|
371
|
+
status, warnings = detect_worktree_status(record)
|
|
372
|
+
dirty, dirty_paths, dirty_total, dirty_warnings = dirty_info(record, status)
|
|
373
|
+
branch_merged, merged_warnings = merged_into_source(
|
|
374
|
+
record,
|
|
375
|
+
status,
|
|
376
|
+
include_detached=include_detached,
|
|
377
|
+
)
|
|
378
|
+
output: JsonObject = {
|
|
379
|
+
"alias": record.get("alias"),
|
|
380
|
+
"runId": record.get("runId"),
|
|
381
|
+
"harness": record.get("harness"),
|
|
382
|
+
"group": record.get("group"),
|
|
383
|
+
"branch": record.get("branch"),
|
|
384
|
+
"executionCwd": record.get("executionCwd"),
|
|
385
|
+
"sourceGitRoot": record.get("sourceGitRoot"),
|
|
386
|
+
"createdAt": record.get("createdAt"),
|
|
387
|
+
"lastActivityAt": record.get("lastActivityAt"),
|
|
388
|
+
"computedAt": _utc_now_iso(),
|
|
389
|
+
"registryWorktreeStatus": record.get("registryWorktreeStatus"),
|
|
390
|
+
"worktreeStatus": status,
|
|
391
|
+
"dirty": dirty,
|
|
392
|
+
**integration_fields(
|
|
393
|
+
branch_merged=branch_merged,
|
|
394
|
+
has_uncommitted_changes=dirty,
|
|
395
|
+
),
|
|
396
|
+
}
|
|
397
|
+
if record.get("resolutionKind") in {"latest", "latest_model"}:
|
|
398
|
+
output["requestedHandle"] = record.get("requestedHandle")
|
|
399
|
+
output["resolvedHandle"] = record.get("resolvedHandle")
|
|
400
|
+
output["resolutionKind"] = record.get("resolutionKind")
|
|
401
|
+
registry_status = record.get("registryWorktreeStatus")
|
|
402
|
+
if isinstance(registry_status, str) and registry_status != status:
|
|
403
|
+
output["registryStatusDiffers"] = True
|
|
404
|
+
all_warnings = [*warnings, *dirty_warnings, *merged_warnings]
|
|
405
|
+
if all_warnings:
|
|
406
|
+
output["warnings"] = all_warnings
|
|
407
|
+
if dirty_paths:
|
|
408
|
+
output["dirtyPaths"] = dirty_paths[:MAX_DIRTY_PATHS_REPORTED]
|
|
409
|
+
output["dirtyPathsTotal"] = dirty_total
|
|
410
|
+
if include_porcelain_cache:
|
|
411
|
+
output["_porcelainStatusLines"] = dirty_paths if dirty is not None else None
|
|
412
|
+
output["_porcelainStatusTotalLines"] = dirty_total
|
|
413
|
+
output["_porcelainStatusWarnings"] = dirty_warnings
|
|
414
|
+
if include_work_summary and status in (STATUS_PRESENT, STATUS_UNKNOWN):
|
|
415
|
+
prefetched_changed_files = (
|
|
416
|
+
worktree_summary.changed_files_from_porcelain_lines(
|
|
417
|
+
dirty_paths,
|
|
418
|
+
dirty_total if isinstance(dirty_total, int) else len(dirty_paths),
|
|
419
|
+
)
|
|
420
|
+
if dirty is not None
|
|
421
|
+
else None
|
|
422
|
+
)
|
|
423
|
+
summary = worktree_summary.build_work_summary(
|
|
424
|
+
source_git_root=record.get("sourceGitRoot")
|
|
425
|
+
if isinstance(record.get("sourceGitRoot"), str)
|
|
426
|
+
else None,
|
|
427
|
+
execution_cwd=record.get("executionCwd")
|
|
428
|
+
if isinstance(record.get("executionCwd"), str)
|
|
429
|
+
else "",
|
|
430
|
+
branch=record.get("branch") if isinstance(record.get("branch"), str) else None,
|
|
431
|
+
creation_context=record.get("creationContext")
|
|
432
|
+
if isinstance(record.get("creationContext"), dict)
|
|
433
|
+
else None,
|
|
434
|
+
prefetched_changed_files=prefetched_changed_files,
|
|
435
|
+
)
|
|
436
|
+
if summary is not None:
|
|
437
|
+
output["workSummary"] = summary
|
|
438
|
+
return output
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def list_worktrees(
|
|
442
|
+
registry_root: Path,
|
|
443
|
+
*,
|
|
444
|
+
harness: str | None = None,
|
|
445
|
+
group: str | None = None,
|
|
446
|
+
status: str | None = None,
|
|
447
|
+
limit: int = run_registry.DEFAULT_RUNS_LIMIT,
|
|
448
|
+
include_detached: bool = False,
|
|
449
|
+
) -> JsonObject:
|
|
450
|
+
entries: list[JsonObject] = []
|
|
451
|
+
all_status_counts: dict[str, int] = {status_key: 0 for status_key in sorted(VALID_STATUSES)}
|
|
452
|
+
records = load_persistent_records(registry_root)
|
|
453
|
+
total_persistent = len(records)
|
|
454
|
+
for record in records:
|
|
455
|
+
if harness is not None and record.get("harness") != harness:
|
|
456
|
+
continue
|
|
457
|
+
if group is not None and record.get("group") != group:
|
|
458
|
+
continue
|
|
459
|
+
unfiltered_entry = decorate_record(record, include_detached=include_detached)
|
|
460
|
+
unfiltered_status = unfiltered_entry.get("worktreeStatus")
|
|
461
|
+
if isinstance(unfiltered_status, str):
|
|
462
|
+
all_status_counts[unfiltered_status] = all_status_counts.get(unfiltered_status, 0) + 1
|
|
463
|
+
entry = unfiltered_entry
|
|
464
|
+
if status is not None and entry.get("worktreeStatus") != status:
|
|
465
|
+
continue
|
|
466
|
+
entries.append(entry)
|
|
467
|
+
entries.sort(key=lambda item: str(item.get("lastActivityAt", "")), reverse=True)
|
|
468
|
+
visible_status_counts: dict[str, int] = {}
|
|
469
|
+
warning_count = 0
|
|
470
|
+
registry_drift_count = 0
|
|
471
|
+
for entry in entries:
|
|
472
|
+
entry_status = entry.get("worktreeStatus")
|
|
473
|
+
if isinstance(entry_status, str):
|
|
474
|
+
visible_status_counts[entry_status] = visible_status_counts.get(entry_status, 0) + 1
|
|
475
|
+
entry_warnings = entry.get("warnings")
|
|
476
|
+
if isinstance(entry_warnings, list):
|
|
477
|
+
warning_count += len(entry_warnings)
|
|
478
|
+
if entry.get("registryStatusDiffers") is True:
|
|
479
|
+
registry_drift_count += 1
|
|
480
|
+
return {
|
|
481
|
+
"schema": SCHEMA_LIST,
|
|
482
|
+
"ok": True,
|
|
483
|
+
"entries": entries[:limit],
|
|
484
|
+
"limit": limit,
|
|
485
|
+
"summary": {
|
|
486
|
+
# Registry-wide count, independent of --harness/--status filters.
|
|
487
|
+
"totalPersistentWorktrees": total_persistent,
|
|
488
|
+
"visible": min(len(entries), limit),
|
|
489
|
+
"matched": len(entries),
|
|
490
|
+
"statusCounts": visible_status_counts,
|
|
491
|
+
# Pre-status-filter counts within the --harness scope (contrast
|
|
492
|
+
# with statusCounts, which reflects the visible filtered entries).
|
|
493
|
+
"allStatusCounts": all_status_counts,
|
|
494
|
+
"warningCount": warning_count,
|
|
495
|
+
"registryStatusDriftCount": registry_drift_count,
|
|
496
|
+
"readOnly": True,
|
|
497
|
+
},
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _error_payload(
|
|
502
|
+
code: str,
|
|
503
|
+
message: str,
|
|
504
|
+
*,
|
|
505
|
+
record: PersistentWorktreeRecord | JsonObject | None = None,
|
|
506
|
+
dirty_paths: list[str] | None = None,
|
|
507
|
+
next_actions: list[str] | None = None,
|
|
508
|
+
retry_safe: bool = False,
|
|
509
|
+
warnings: list[str] | None = None,
|
|
510
|
+
suggestions: list[str] | None = None,
|
|
511
|
+
suggestion_scope: str | None = None,
|
|
512
|
+
list_command: str | None = None,
|
|
513
|
+
) -> JsonObject:
|
|
514
|
+
payload: JsonObject = {
|
|
515
|
+
"ok": False,
|
|
516
|
+
"code": code,
|
|
517
|
+
"error": code,
|
|
518
|
+
"message": message,
|
|
519
|
+
"exitCode": WORKTREE_ERROR_EXIT_CODE,
|
|
520
|
+
"retrySafe": retry_safe,
|
|
521
|
+
}
|
|
522
|
+
if record is not None:
|
|
523
|
+
for key in ("alias", "runId", "branch", "executionCwd", "sourceGitRoot"):
|
|
524
|
+
value = record.get(key)
|
|
525
|
+
if isinstance(value, str) and value:
|
|
526
|
+
payload[key] = value
|
|
527
|
+
if dirty_paths is not None:
|
|
528
|
+
capped = dirty_paths[:MAX_DIRTY_PATHS_REPORTED]
|
|
529
|
+
if len(dirty_paths) > MAX_DIRTY_PATHS_REPORTED:
|
|
530
|
+
capped.append("...")
|
|
531
|
+
payload["dirtyPathsTotal"] = len(dirty_paths)
|
|
532
|
+
payload["dirtyPaths"] = capped
|
|
533
|
+
if next_actions:
|
|
534
|
+
payload["nextActions"] = next_actions
|
|
535
|
+
if warnings is not None:
|
|
536
|
+
payload["warnings"] = warnings
|
|
537
|
+
if suggestions is not None:
|
|
538
|
+
payload["suggestions"] = suggestions
|
|
539
|
+
if suggestion_scope is not None:
|
|
540
|
+
payload["suggestionScope"] = suggestion_scope
|
|
541
|
+
if list_command is not None:
|
|
542
|
+
payload["listCommand"] = list_command
|
|
543
|
+
return payload
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def suggest_worktree_handles(
|
|
547
|
+
registry_root: Path,
|
|
548
|
+
handle: str,
|
|
549
|
+
*,
|
|
550
|
+
limit: int = 8,
|
|
551
|
+
) -> list[str]:
|
|
552
|
+
records = load_persistent_records(registry_root)
|
|
553
|
+
scoped_index: JsonObject = {"aliases": {}, "runs": {}}
|
|
554
|
+
aliases = scoped_index["aliases"]
|
|
555
|
+
runs = scoped_index["runs"]
|
|
556
|
+
if not isinstance(aliases, dict) or not isinstance(runs, dict):
|
|
557
|
+
return []
|
|
558
|
+
for record in records:
|
|
559
|
+
alias = record.get("alias")
|
|
560
|
+
run_id = record.get("runId")
|
|
561
|
+
if not isinstance(alias, str) or not alias:
|
|
562
|
+
continue
|
|
563
|
+
if not isinstance(run_id, str) or not run_id:
|
|
564
|
+
continue
|
|
565
|
+
aliases[alias] = run_id
|
|
566
|
+
runs[run_id] = {"alias": alias}
|
|
567
|
+
return run_registry.suggest_handles(scoped_index, handle, limit=limit)
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def resolve_record(
|
|
571
|
+
registry_root: Path,
|
|
572
|
+
*,
|
|
573
|
+
handle: str | None,
|
|
574
|
+
latest_harness: str | None = None,
|
|
575
|
+
) -> PersistentWorktreeRecord:
|
|
576
|
+
index = run_registry.load_index(registry_root)
|
|
577
|
+
if latest_harness is not None:
|
|
578
|
+
latest_record = latest_persistent_record_for_harness(registry_root, latest_harness)
|
|
579
|
+
if latest_record is None:
|
|
580
|
+
raise WorktreeManagementError(
|
|
581
|
+
_error_payload(
|
|
582
|
+
"unknown_handle",
|
|
583
|
+
f"No persistent worktree runs found for harness: {latest_harness}",
|
|
584
|
+
next_actions=["delegate worktree list"],
|
|
585
|
+
)
|
|
586
|
+
)
|
|
587
|
+
latest_record.update(
|
|
588
|
+
{
|
|
589
|
+
"requestedHandle": latest_harness,
|
|
590
|
+
"resolvedHandle": latest_record.get("alias") or latest_record.get("runId"),
|
|
591
|
+
"resolutionKind": "latest",
|
|
592
|
+
}
|
|
593
|
+
)
|
|
594
|
+
return latest_record
|
|
595
|
+
if handle is None:
|
|
596
|
+
raise WorktreeManagementError(
|
|
597
|
+
_error_payload(
|
|
598
|
+
"missing_handle",
|
|
599
|
+
"A worktree handle is required.",
|
|
600
|
+
next_actions=["delegate worktree list"],
|
|
601
|
+
)
|
|
602
|
+
)
|
|
603
|
+
if handle in run_registry.HARNESS_NAMES:
|
|
604
|
+
latest_record = latest_persistent_record_for_harness(registry_root, handle)
|
|
605
|
+
if latest_record is None:
|
|
606
|
+
raise WorktreeManagementError(
|
|
607
|
+
_error_payload(
|
|
608
|
+
"unknown_handle",
|
|
609
|
+
f"No persistent worktree runs found for harness: {handle}",
|
|
610
|
+
next_actions=["delegate worktree list"],
|
|
611
|
+
)
|
|
612
|
+
)
|
|
613
|
+
latest_record.update(
|
|
614
|
+
{
|
|
615
|
+
"requestedHandle": handle,
|
|
616
|
+
"resolvedHandle": latest_record.get("alias") or latest_record.get("runId"),
|
|
617
|
+
"resolutionKind": "latest",
|
|
618
|
+
}
|
|
619
|
+
)
|
|
620
|
+
return latest_record
|
|
621
|
+
resolved = run_registry.resolve_handle(index, handle)
|
|
622
|
+
if resolved.run_id is None:
|
|
623
|
+
suggestions = suggest_worktree_handles(registry_root, handle)
|
|
624
|
+
next_actions = (
|
|
625
|
+
[f"delegate worktree show {suggestions[0]}"]
|
|
626
|
+
if suggestions
|
|
627
|
+
else ["delegate worktree list"]
|
|
628
|
+
)
|
|
629
|
+
raise WorktreeManagementError(
|
|
630
|
+
_error_payload(
|
|
631
|
+
"unknown_handle",
|
|
632
|
+
(
|
|
633
|
+
f"Unknown run handle: {handle}. Suggestions: "
|
|
634
|
+
f"{', '.join(suggestions) if suggestions else '(none)'}"
|
|
635
|
+
),
|
|
636
|
+
next_actions=next_actions,
|
|
637
|
+
suggestions=suggestions,
|
|
638
|
+
suggestion_scope="worktrees",
|
|
639
|
+
list_command="delegate worktree list",
|
|
640
|
+
)
|
|
641
|
+
)
|
|
642
|
+
run_id = resolved.run_id
|
|
643
|
+
index_entry = index.get("runs", {}).get(run_id)
|
|
644
|
+
record = _record_for_run(
|
|
645
|
+
registry_root,
|
|
646
|
+
run_id,
|
|
647
|
+
index_entry if isinstance(index_entry, dict) else {},
|
|
648
|
+
)
|
|
649
|
+
if record is None:
|
|
650
|
+
alias = run_registry.alias_for_run(index, run_id)
|
|
651
|
+
raise WorktreeManagementError(
|
|
652
|
+
_error_payload(
|
|
653
|
+
"not_worktree_run",
|
|
654
|
+
f"Run is not a persistent worktree run: {alias or run_id}",
|
|
655
|
+
record={"alias": alias, "runId": run_id},
|
|
656
|
+
next_actions=[f"delegate snapshot {alias or run_id}"],
|
|
657
|
+
)
|
|
658
|
+
)
|
|
659
|
+
return record
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
def show_worktree(
|
|
663
|
+
registry_root: Path,
|
|
664
|
+
*,
|
|
665
|
+
handle: str | None,
|
|
666
|
+
latest_harness: str | None = None,
|
|
667
|
+
include_detached: bool = False,
|
|
668
|
+
) -> JsonObject:
|
|
669
|
+
record = resolve_record(registry_root, handle=handle, latest_harness=latest_harness)
|
|
670
|
+
entry = decorate_record(
|
|
671
|
+
record,
|
|
672
|
+
include_detached=include_detached,
|
|
673
|
+
include_work_summary=True,
|
|
674
|
+
include_porcelain_cache=True,
|
|
675
|
+
)
|
|
676
|
+
execution_cwd = entry.get("executionCwd")
|
|
677
|
+
cached_porcelain = entry.pop("_porcelainStatusLines", None)
|
|
678
|
+
cached_porcelain_total = entry.pop("_porcelainStatusTotalLines", None)
|
|
679
|
+
cached_porcelain_warnings = entry.pop("_porcelainStatusWarnings", [])
|
|
680
|
+
porcelain: list[str] | None = None
|
|
681
|
+
porcelain_total = 0
|
|
682
|
+
porcelain_truncated = False
|
|
683
|
+
if entry.get("worktreeStatus") in (STATUS_PRESENT, STATUS_UNKNOWN) and isinstance(
|
|
684
|
+
execution_cwd, str
|
|
685
|
+
):
|
|
686
|
+
if isinstance(cached_porcelain, list):
|
|
687
|
+
porcelain = cached_porcelain[:50]
|
|
688
|
+
porcelain_total = (
|
|
689
|
+
cached_porcelain_total
|
|
690
|
+
if isinstance(cached_porcelain_total, int)
|
|
691
|
+
else len(cached_porcelain)
|
|
692
|
+
)
|
|
693
|
+
porcelain_truncated = porcelain_total > len(porcelain)
|
|
694
|
+
elif isinstance(cached_porcelain_warnings, list) and cached_porcelain_warnings:
|
|
695
|
+
entry["warnings"] = [*(entry.get("warnings") or []), *cached_porcelain_warnings]
|
|
696
|
+
entry["schema"] = SCHEMA_SHOW
|
|
697
|
+
entry["ok"] = True
|
|
698
|
+
entry["creationContext"] = record.get("creationContext") or {}
|
|
699
|
+
entry["porcelainStatus"] = porcelain
|
|
700
|
+
entry["porcelainStatusTotalLines"] = porcelain_total
|
|
701
|
+
entry["porcelainStatusTruncated"] = porcelain_truncated
|
|
702
|
+
entry["aheadBehind"] = ahead_behind(record, str(entry.get("worktreeStatus")))
|
|
703
|
+
entry["suggestedCommands"] = suggested_commands(
|
|
704
|
+
record,
|
|
705
|
+
str(entry.get("worktreeStatus")),
|
|
706
|
+
ahead_behind_payload=entry.get("aheadBehind")
|
|
707
|
+
if isinstance(entry.get("aheadBehind"), dict)
|
|
708
|
+
else None,
|
|
709
|
+
)
|
|
710
|
+
source_git_root = record.get("sourceGitRoot")
|
|
711
|
+
entry["currentSourceHeadRef"] = (
|
|
712
|
+
_symbolic_ref(source_git_root, "HEAD") if isinstance(source_git_root, str) else None
|
|
713
|
+
)
|
|
714
|
+
creation = record.get("creationContext")
|
|
715
|
+
if isinstance(creation, dict) and creation.get("sourceHeadRef") is None:
|
|
716
|
+
warnings = list(entry.get("warnings") or [])
|
|
717
|
+
warning = "source was detached at creation; integration target unknown"
|
|
718
|
+
if warning not in warnings:
|
|
719
|
+
warnings.append(warning)
|
|
720
|
+
entry["warnings"] = warnings
|
|
721
|
+
return entry
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
def _worktree_list_paths_with_warning(source_git_root: str) -> tuple[set[str] | None, str | None]:
|
|
725
|
+
result = _run_git(
|
|
726
|
+
source_git_root,
|
|
727
|
+
["worktree", "list", "--porcelain"],
|
|
728
|
+
timeout_seconds=GIT_QUICK_TIMEOUT_SECONDS,
|
|
729
|
+
)
|
|
730
|
+
if result.returncode != 0:
|
|
731
|
+
detail = result.stderr.strip() or f"git worktree list failed with exit {result.returncode}"
|
|
732
|
+
return None, detail
|
|
733
|
+
paths: set[str] = set()
|
|
734
|
+
for line in result.stdout.splitlines():
|
|
735
|
+
if line.startswith("worktree "):
|
|
736
|
+
path = line[len("worktree ") :]
|
|
737
|
+
paths.add(path)
|
|
738
|
+
with suppress(OSError):
|
|
739
|
+
paths.add(str(Path(path).resolve()))
|
|
740
|
+
return paths, None
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
# Re-export the removal pipeline so ``worktree_mgmt.<name>`` keeps resolving and
|
|
744
|
+
# the seam patches that target this module continue to reach the moved
|
|
745
|
+
# functions (which read their cross-module seams back through this module).
|
|
746
|
+
# Re-export the prune/gc pipelines for the same reason.
|
|
747
|
+
from delegate_agent.worktree_gc import ( # noqa: E402, F401 # re-exported
|
|
748
|
+
GcFreshAction,
|
|
749
|
+
_entry_ref,
|
|
750
|
+
_gc_missing_entry,
|
|
751
|
+
_gc_orphan_entry,
|
|
752
|
+
_gc_reconcile_list_failure,
|
|
753
|
+
_gc_reconcile_missing_branch,
|
|
754
|
+
_gc_reconcile_missing_metadata,
|
|
755
|
+
_gc_reconcile_missing_path,
|
|
756
|
+
_older_than,
|
|
757
|
+
_reload_gc_candidate,
|
|
758
|
+
_with_locked_fresh_gc_candidate,
|
|
759
|
+
gc_worktrees,
|
|
760
|
+
maybe_auto_prune,
|
|
761
|
+
prune_worktrees,
|
|
762
|
+
)
|
|
763
|
+
from delegate_agent.worktree_remove import ( # noqa: E402, F401 # re-exported
|
|
764
|
+
BranchRemovalResult,
|
|
765
|
+
RemoveWorktreeOptions,
|
|
766
|
+
RemoveWorktreePlan,
|
|
767
|
+
_apply_branch_removal_result,
|
|
768
|
+
_build_remove_worktree_plan,
|
|
769
|
+
_mark_worktree_removed,
|
|
770
|
+
_normalize_remove_options,
|
|
771
|
+
_raise_if_dirty_without_discard,
|
|
772
|
+
_raise_if_unmerged_without_override,
|
|
773
|
+
_remove_already_removed,
|
|
774
|
+
_remove_branch,
|
|
775
|
+
_remove_branch_if_requested,
|
|
776
|
+
_remove_missing_worktree_path,
|
|
777
|
+
_remove_payload,
|
|
778
|
+
_remove_present_worktree_path,
|
|
779
|
+
_remove_worktree_path,
|
|
780
|
+
_require_removal_metadata,
|
|
781
|
+
remove_worktree,
|
|
782
|
+
)
|