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,529 @@
|
|
|
1
|
+
"""Worktree prune and gc pipelines plus opportunistic auto-prune.
|
|
2
|
+
|
|
3
|
+
Implements ``delegate worktree prune`` (filtered batch removal), ``delegate
|
|
4
|
+
worktree gc`` (registry reconciliation against the live ``git worktree list``),
|
|
5
|
+
and the ``maybe_auto_prune`` hook fired opportunistically from ``worktree list``.
|
|
6
|
+
``worktree_mgmt`` re-exports this surface so callers and tests keep importing
|
|
7
|
+
from ``worktree_mgmt``.
|
|
8
|
+
|
|
9
|
+
Cross-cutting seams monkeypatched on the ``worktree_mgmt`` module
|
|
10
|
+
(``detect_worktree_status``, ``dirty_info``, ``merged_into_source``,
|
|
11
|
+
``_worktree_list_paths_with_warning``, ``prune_worktrees``, ``remove_worktree``,
|
|
12
|
+
``_error_payload``) are read back through the ``worktree_mgmt`` facade (the
|
|
13
|
+
``wm`` alias) at call time so those patches still take effect.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from collections.abc import Callable
|
|
19
|
+
from datetime import UTC, datetime, timedelta
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from delegate_agent import run_registry
|
|
23
|
+
from delegate_agent.json_types import JsonObject, is_non_negative_int
|
|
24
|
+
from delegate_agent.worktree_records import (
|
|
25
|
+
SCHEMA_GC,
|
|
26
|
+
SCHEMA_PRUNE,
|
|
27
|
+
STATUS_MISSING,
|
|
28
|
+
STATUS_PRESENT,
|
|
29
|
+
STATUS_REMOVED,
|
|
30
|
+
STATUS_UNKNOWN,
|
|
31
|
+
WORKTREE_ERROR_EXIT_CODE,
|
|
32
|
+
PersistentWorktreeRecord,
|
|
33
|
+
_registered_worktree_path_matches,
|
|
34
|
+
_reload_record,
|
|
35
|
+
load_persistent_records,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _older_than(record: PersistentWorktreeRecord, days: int) -> bool | None:
|
|
40
|
+
timestamp = record.get("lastActivityAt")
|
|
41
|
+
dt = run_registry.parse_utc_timestamp(timestamp if isinstance(timestamp, str) else None)
|
|
42
|
+
if dt is None:
|
|
43
|
+
return None
|
|
44
|
+
return dt < datetime.now(UTC) - timedelta(days=days)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _entry_ref(record: PersistentWorktreeRecord, *, reason: str | None = None) -> JsonObject:
|
|
48
|
+
entry: JsonObject = {"alias": record.get("alias"), "runId": record.get("runId")}
|
|
49
|
+
if reason is not None:
|
|
50
|
+
entry["reason"] = reason
|
|
51
|
+
return entry
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def prune_worktrees(
|
|
55
|
+
registry_root: Path,
|
|
56
|
+
*,
|
|
57
|
+
merged: bool = False,
|
|
58
|
+
older_than_days: int | None = None,
|
|
59
|
+
harness: str | None = None,
|
|
60
|
+
group: str | None = None,
|
|
61
|
+
include_detached: bool = False,
|
|
62
|
+
dry_run: bool = False,
|
|
63
|
+
discard_uncommitted: bool = False,
|
|
64
|
+
force_branch: bool = False,
|
|
65
|
+
force: bool = False,
|
|
66
|
+
) -> JsonObject:
|
|
67
|
+
if not merged and older_than_days is None:
|
|
68
|
+
raise wm.WorktreeManagementError(
|
|
69
|
+
wm._error_payload(
|
|
70
|
+
"prune_filter_required",
|
|
71
|
+
"delegate worktree prune requires --merged and/or --older-than DAYS.",
|
|
72
|
+
)
|
|
73
|
+
)
|
|
74
|
+
if force:
|
|
75
|
+
discard_uncommitted = True
|
|
76
|
+
force_branch = True
|
|
77
|
+
planned: list[JsonObject] = []
|
|
78
|
+
removed: list[JsonObject] = []
|
|
79
|
+
skipped: list[JsonObject] = []
|
|
80
|
+
errors: list[JsonObject] = []
|
|
81
|
+
for record in load_persistent_records(registry_root):
|
|
82
|
+
alias = record.get("alias")
|
|
83
|
+
if harness is not None and record.get("harness") != harness:
|
|
84
|
+
skipped.append(_entry_ref(record, reason="harness_filter"))
|
|
85
|
+
continue
|
|
86
|
+
if group is not None and record.get("group") != group:
|
|
87
|
+
skipped.append(_entry_ref(record, reason="group_filter"))
|
|
88
|
+
continue
|
|
89
|
+
status, _warnings = wm.detect_worktree_status(record)
|
|
90
|
+
if status in (STATUS_REMOVED, STATUS_UNKNOWN):
|
|
91
|
+
# Not candidates — filter silently (spec L678).
|
|
92
|
+
continue
|
|
93
|
+
if status == STATUS_MISSING:
|
|
94
|
+
skipped.append(_entry_ref(record, reason="path_missing"))
|
|
95
|
+
continue
|
|
96
|
+
creation = record.get("creationContext")
|
|
97
|
+
if (
|
|
98
|
+
merged
|
|
99
|
+
and isinstance(creation, dict)
|
|
100
|
+
and creation.get("sourceHeadRef") is None
|
|
101
|
+
and not include_detached
|
|
102
|
+
):
|
|
103
|
+
skipped.append(_entry_ref(record, reason="detached_source"))
|
|
104
|
+
continue
|
|
105
|
+
# Compute dirty before the merged check so the merged branch path
|
|
106
|
+
# can test dirty state without rebinding a later assignment.
|
|
107
|
+
dirty, _dirty_paths, _dirty_total, _dirty_warnings = wm.dirty_info(record, status)
|
|
108
|
+
if dirty is None and not discard_uncommitted:
|
|
109
|
+
skipped.append(_entry_ref(record, reason="dirty_check_failed"))
|
|
110
|
+
continue
|
|
111
|
+
if dirty is True and not discard_uncommitted:
|
|
112
|
+
skipped.append(_entry_ref(record, reason="dirty"))
|
|
113
|
+
continue
|
|
114
|
+
keep_branch_for_prune = False
|
|
115
|
+
merged_check_already_passed = False
|
|
116
|
+
if merged:
|
|
117
|
+
merged_value, _merge_warnings = wm.merged_into_source(
|
|
118
|
+
record,
|
|
119
|
+
status,
|
|
120
|
+
include_detached=include_detached,
|
|
121
|
+
)
|
|
122
|
+
merged_check_already_passed = merged_value is True
|
|
123
|
+
if merged_value is None:
|
|
124
|
+
skipped.append(_entry_ref(record, reason="merge_check_failed"))
|
|
125
|
+
continue
|
|
126
|
+
if merged_value is False and not force_branch:
|
|
127
|
+
if dirty is not True:
|
|
128
|
+
keep_branch_for_prune = True
|
|
129
|
+
else:
|
|
130
|
+
skipped.append(_entry_ref(record, reason="unmerged_branch"))
|
|
131
|
+
continue
|
|
132
|
+
if older_than_days is not None:
|
|
133
|
+
old_enough = _older_than(record, older_than_days)
|
|
134
|
+
if old_enough is None:
|
|
135
|
+
skipped.append(_entry_ref(record, reason="invalid_last_activity"))
|
|
136
|
+
continue
|
|
137
|
+
if not old_enough:
|
|
138
|
+
skipped.append(_entry_ref(record, reason="not_yet_old_enough"))
|
|
139
|
+
continue
|
|
140
|
+
candidate = {
|
|
141
|
+
"alias": alias,
|
|
142
|
+
"runId": record.get("runId"),
|
|
143
|
+
"branch": record.get("branch"),
|
|
144
|
+
"executionCwd": record.get("executionCwd"),
|
|
145
|
+
"sourceGitRoot": record.get("sourceGitRoot"),
|
|
146
|
+
}
|
|
147
|
+
if keep_branch_for_prune:
|
|
148
|
+
candidate["keep_branch"] = True
|
|
149
|
+
planned.append(candidate)
|
|
150
|
+
if not dry_run:
|
|
151
|
+
try:
|
|
152
|
+
removed.append(
|
|
153
|
+
wm.remove_worktree(
|
|
154
|
+
registry_root,
|
|
155
|
+
handle=str(alias or record.get("runId")),
|
|
156
|
+
discard_uncommitted=discard_uncommitted,
|
|
157
|
+
force_branch=force_branch,
|
|
158
|
+
keep_branch=candidate.get("keep_branch", False),
|
|
159
|
+
_merged_check_already_passed=merged_check_already_passed,
|
|
160
|
+
)
|
|
161
|
+
)
|
|
162
|
+
if removed[-1].get("ok") is False:
|
|
163
|
+
errors.append(removed[-1])
|
|
164
|
+
except wm.WorktreeManagementError as exc:
|
|
165
|
+
errors.append(exc.payload)
|
|
166
|
+
payload = {
|
|
167
|
+
"schema": SCHEMA_PRUNE,
|
|
168
|
+
"ok": not errors,
|
|
169
|
+
"planned": planned,
|
|
170
|
+
"removed": removed,
|
|
171
|
+
"skipped": skipped,
|
|
172
|
+
"errors": errors,
|
|
173
|
+
"dryRun": dry_run,
|
|
174
|
+
}
|
|
175
|
+
if errors:
|
|
176
|
+
payload["exitCode"] = WORKTREE_ERROR_EXIT_CODE
|
|
177
|
+
return payload
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _reload_gc_candidate(
|
|
181
|
+
registry_root: Path,
|
|
182
|
+
record: PersistentWorktreeRecord,
|
|
183
|
+
) -> tuple[PersistentWorktreeRecord, str, str] | None:
|
|
184
|
+
fresh = _reload_record(registry_root, str(record["runId"]))
|
|
185
|
+
if fresh is None:
|
|
186
|
+
return None
|
|
187
|
+
fresh_current = fresh.get("registryWorktreeStatus")
|
|
188
|
+
if fresh_current not in (STATUS_PRESENT, STATUS_UNKNOWN, None):
|
|
189
|
+
return None
|
|
190
|
+
fresh_source = fresh.get("sourceGitRoot")
|
|
191
|
+
fresh_execution = fresh.get("executionCwd")
|
|
192
|
+
if not isinstance(fresh_source, str) or not isinstance(fresh_execution, str):
|
|
193
|
+
return None
|
|
194
|
+
return fresh, fresh_source, fresh_execution
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
GcFreshAction = Callable[[PersistentWorktreeRecord, str, str], None]
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _with_locked_fresh_gc_candidate(
|
|
201
|
+
registry_root: Path,
|
|
202
|
+
record: PersistentWorktreeRecord,
|
|
203
|
+
action: GcFreshAction,
|
|
204
|
+
) -> None:
|
|
205
|
+
with run_registry.registry_lock(registry_root):
|
|
206
|
+
fresh_candidate = _reload_gc_candidate(registry_root, record)
|
|
207
|
+
if fresh_candidate is None:
|
|
208
|
+
return
|
|
209
|
+
action(*fresh_candidate)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _gc_missing_entry(
|
|
213
|
+
record: PersistentWorktreeRecord,
|
|
214
|
+
execution: str,
|
|
215
|
+
*,
|
|
216
|
+
dry_run: bool,
|
|
217
|
+
) -> JsonObject:
|
|
218
|
+
return {
|
|
219
|
+
"alias": record.get("alias"),
|
|
220
|
+
"runId": record.get("runId"),
|
|
221
|
+
"executionCwd": execution,
|
|
222
|
+
"worktreeStatus": STATUS_MISSING,
|
|
223
|
+
"reason": "path_missing",
|
|
224
|
+
"action": "would_mark_missing" if dry_run else "marked_missing",
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _gc_orphan_entry(
|
|
229
|
+
record: PersistentWorktreeRecord,
|
|
230
|
+
execution: str,
|
|
231
|
+
reason: str,
|
|
232
|
+
message: str | None = None,
|
|
233
|
+
) -> JsonObject:
|
|
234
|
+
entry = {
|
|
235
|
+
"alias": record.get("alias"),
|
|
236
|
+
"runId": record.get("runId"),
|
|
237
|
+
"executionCwd": execution,
|
|
238
|
+
"branch": record.get("branch"),
|
|
239
|
+
"reason": reason,
|
|
240
|
+
}
|
|
241
|
+
if message:
|
|
242
|
+
entry["message"] = message
|
|
243
|
+
safe_actions = {
|
|
244
|
+
"worktree_metadata_missing": "inspect_path_before_manual_cleanup",
|
|
245
|
+
"branch_missing": "inspect_branch_metadata_before_manual_cleanup",
|
|
246
|
+
"worktree_list_failed": "retry_after_git_worktree_list_succeeds",
|
|
247
|
+
}
|
|
248
|
+
if reason in safe_actions:
|
|
249
|
+
entry["safeAction"] = safe_actions[reason]
|
|
250
|
+
return entry
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _gc_reconcile_missing_path(
|
|
254
|
+
registry_root: Path,
|
|
255
|
+
record: PersistentWorktreeRecord,
|
|
256
|
+
execution: str,
|
|
257
|
+
*,
|
|
258
|
+
dry_run: bool,
|
|
259
|
+
reconciled: list[JsonObject],
|
|
260
|
+
prune_roots: set[str],
|
|
261
|
+
) -> bool:
|
|
262
|
+
if Path(execution).exists():
|
|
263
|
+
return False
|
|
264
|
+
if dry_run:
|
|
265
|
+
reconciled.append(_gc_missing_entry(record, execution, dry_run=True))
|
|
266
|
+
return True
|
|
267
|
+
|
|
268
|
+
def mark_missing(
|
|
269
|
+
fresh: PersistentWorktreeRecord, fresh_source: str, fresh_execution: str
|
|
270
|
+
) -> None:
|
|
271
|
+
if Path(fresh_execution).exists():
|
|
272
|
+
return
|
|
273
|
+
run_registry.set_worktree_status_locked(
|
|
274
|
+
registry_root,
|
|
275
|
+
str(fresh["runId"]),
|
|
276
|
+
STATUS_MISSING,
|
|
277
|
+
)
|
|
278
|
+
prune_roots.add(fresh_source)
|
|
279
|
+
reconciled.append(_gc_missing_entry(fresh, fresh_execution, dry_run=False))
|
|
280
|
+
|
|
281
|
+
_with_locked_fresh_gc_candidate(registry_root, record, mark_missing)
|
|
282
|
+
return True
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _gc_reconcile_list_failure(
|
|
286
|
+
registry_root: Path,
|
|
287
|
+
record: PersistentWorktreeRecord,
|
|
288
|
+
execution: str,
|
|
289
|
+
*,
|
|
290
|
+
listed_paths: set[str] | None,
|
|
291
|
+
list_warning: str | None,
|
|
292
|
+
dry_run: bool,
|
|
293
|
+
orphans: list[JsonObject],
|
|
294
|
+
) -> bool:
|
|
295
|
+
if listed_paths is not None:
|
|
296
|
+
return False
|
|
297
|
+
if dry_run:
|
|
298
|
+
orphans.append(_gc_orphan_entry(record, execution, "worktree_list_failed", list_warning))
|
|
299
|
+
return True
|
|
300
|
+
|
|
301
|
+
def mark_unknown(
|
|
302
|
+
fresh: PersistentWorktreeRecord, _fresh_source: str, fresh_execution: str
|
|
303
|
+
) -> None:
|
|
304
|
+
if not Path(fresh_execution).exists():
|
|
305
|
+
return
|
|
306
|
+
run_registry.set_worktree_status_locked(
|
|
307
|
+
registry_root,
|
|
308
|
+
str(fresh["runId"]),
|
|
309
|
+
STATUS_UNKNOWN,
|
|
310
|
+
)
|
|
311
|
+
orphans.append(
|
|
312
|
+
_gc_orphan_entry(fresh, fresh_execution, "worktree_list_failed", list_warning)
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
_with_locked_fresh_gc_candidate(registry_root, record, mark_unknown)
|
|
316
|
+
return True
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _gc_reconcile_missing_metadata(
|
|
320
|
+
registry_root: Path,
|
|
321
|
+
record: PersistentWorktreeRecord,
|
|
322
|
+
execution: str,
|
|
323
|
+
*,
|
|
324
|
+
listed_paths: set[str] | None,
|
|
325
|
+
dry_run: bool,
|
|
326
|
+
orphans: list[JsonObject],
|
|
327
|
+
warnings: list[JsonObject],
|
|
328
|
+
) -> bool:
|
|
329
|
+
if listed_paths is None or _registered_worktree_path_matches(listed_paths, execution):
|
|
330
|
+
return False
|
|
331
|
+
if dry_run:
|
|
332
|
+
orphans.append(_gc_orphan_entry(record, execution, "worktree_metadata_missing"))
|
|
333
|
+
return True
|
|
334
|
+
|
|
335
|
+
def mark_unknown_if_still_orphaned(
|
|
336
|
+
fresh: PersistentWorktreeRecord,
|
|
337
|
+
fresh_source: str,
|
|
338
|
+
fresh_execution: str,
|
|
339
|
+
) -> None:
|
|
340
|
+
fresh_paths, warning = wm._worktree_list_paths_with_warning(fresh_source)
|
|
341
|
+
if warning is not None:
|
|
342
|
+
warnings.append({"sourceGitRoot": fresh_source, "message": warning})
|
|
343
|
+
if (
|
|
344
|
+
Path(fresh_execution).exists()
|
|
345
|
+
and fresh_paths is not None
|
|
346
|
+
and not _registered_worktree_path_matches(fresh_paths, fresh_execution)
|
|
347
|
+
):
|
|
348
|
+
run_registry.set_worktree_status_locked(
|
|
349
|
+
registry_root,
|
|
350
|
+
str(fresh["runId"]),
|
|
351
|
+
STATUS_UNKNOWN,
|
|
352
|
+
)
|
|
353
|
+
orphans.append(_gc_orphan_entry(fresh, fresh_execution, "worktree_metadata_missing"))
|
|
354
|
+
|
|
355
|
+
_with_locked_fresh_gc_candidate(registry_root, record, mark_unknown_if_still_orphaned)
|
|
356
|
+
return True
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _gc_reconcile_missing_branch(
|
|
360
|
+
registry_root: Path,
|
|
361
|
+
record: PersistentWorktreeRecord,
|
|
362
|
+
source: str,
|
|
363
|
+
execution: str,
|
|
364
|
+
branch: str | None,
|
|
365
|
+
*,
|
|
366
|
+
dry_run: bool,
|
|
367
|
+
orphans: list[JsonObject],
|
|
368
|
+
) -> bool:
|
|
369
|
+
if not isinstance(branch, str) or wm._branch_exists(source, branch) is not False:
|
|
370
|
+
return False
|
|
371
|
+
if dry_run:
|
|
372
|
+
orphans.append(_gc_orphan_entry(record, execution, "branch_missing"))
|
|
373
|
+
return True
|
|
374
|
+
|
|
375
|
+
def mark_unknown_if_branch_still_missing(
|
|
376
|
+
fresh: PersistentWorktreeRecord,
|
|
377
|
+
fresh_source: str,
|
|
378
|
+
fresh_execution: str,
|
|
379
|
+
) -> None:
|
|
380
|
+
fresh_branch = fresh.get("branch")
|
|
381
|
+
if (
|
|
382
|
+
isinstance(fresh_branch, str)
|
|
383
|
+
and Path(fresh_execution).exists()
|
|
384
|
+
and wm._branch_exists(fresh_source, fresh_branch) is False
|
|
385
|
+
):
|
|
386
|
+
run_registry.set_worktree_status_locked(
|
|
387
|
+
registry_root,
|
|
388
|
+
str(fresh["runId"]),
|
|
389
|
+
STATUS_UNKNOWN,
|
|
390
|
+
)
|
|
391
|
+
orphans.append(_gc_orphan_entry(fresh, fresh_execution, "branch_missing"))
|
|
392
|
+
|
|
393
|
+
_with_locked_fresh_gc_candidate(registry_root, record, mark_unknown_if_branch_still_missing)
|
|
394
|
+
return True
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def gc_worktrees(registry_root: Path, *, dry_run: bool = False) -> JsonObject:
|
|
398
|
+
records = load_persistent_records(registry_root)
|
|
399
|
+
paths_by_root: dict[str, set[str] | None] = {}
|
|
400
|
+
prune_roots: set[str] = set()
|
|
401
|
+
reconciled: list[JsonObject] = []
|
|
402
|
+
orphans: list[JsonObject] = []
|
|
403
|
+
warnings: list[JsonObject] = []
|
|
404
|
+
|
|
405
|
+
for record in records:
|
|
406
|
+
current = record.get("registryWorktreeStatus")
|
|
407
|
+
if current not in (STATUS_PRESENT, STATUS_UNKNOWN, None):
|
|
408
|
+
continue
|
|
409
|
+
source = record.get("sourceGitRoot")
|
|
410
|
+
execution = record.get("executionCwd")
|
|
411
|
+
branch = record.get("branch")
|
|
412
|
+
if not isinstance(source, str) or not isinstance(execution, str):
|
|
413
|
+
continue
|
|
414
|
+
if source not in paths_by_root:
|
|
415
|
+
listed, warning = wm._worktree_list_paths_with_warning(source)
|
|
416
|
+
paths_by_root[source] = listed
|
|
417
|
+
if warning is not None:
|
|
418
|
+
warnings.append({"sourceGitRoot": source, "message": warning})
|
|
419
|
+
listed_paths = paths_by_root[source]
|
|
420
|
+
list_warning = next(
|
|
421
|
+
(
|
|
422
|
+
str(warning.get("message"))
|
|
423
|
+
for warning in warnings
|
|
424
|
+
if warning.get("sourceGitRoot") == source
|
|
425
|
+
and isinstance(warning.get("message"), str)
|
|
426
|
+
),
|
|
427
|
+
None,
|
|
428
|
+
)
|
|
429
|
+
if _gc_reconcile_missing_path(
|
|
430
|
+
registry_root,
|
|
431
|
+
record,
|
|
432
|
+
execution,
|
|
433
|
+
dry_run=dry_run,
|
|
434
|
+
reconciled=reconciled,
|
|
435
|
+
prune_roots=prune_roots,
|
|
436
|
+
):
|
|
437
|
+
continue
|
|
438
|
+
if _gc_reconcile_list_failure(
|
|
439
|
+
registry_root,
|
|
440
|
+
record,
|
|
441
|
+
execution,
|
|
442
|
+
listed_paths=listed_paths,
|
|
443
|
+
list_warning=list_warning,
|
|
444
|
+
dry_run=dry_run,
|
|
445
|
+
orphans=orphans,
|
|
446
|
+
):
|
|
447
|
+
continue
|
|
448
|
+
if _gc_reconcile_missing_metadata(
|
|
449
|
+
registry_root,
|
|
450
|
+
record,
|
|
451
|
+
execution,
|
|
452
|
+
listed_paths=listed_paths,
|
|
453
|
+
dry_run=dry_run,
|
|
454
|
+
orphans=orphans,
|
|
455
|
+
warnings=warnings,
|
|
456
|
+
):
|
|
457
|
+
continue
|
|
458
|
+
_gc_reconcile_missing_branch(
|
|
459
|
+
registry_root,
|
|
460
|
+
record,
|
|
461
|
+
source,
|
|
462
|
+
execution,
|
|
463
|
+
branch,
|
|
464
|
+
dry_run=dry_run,
|
|
465
|
+
orphans=orphans,
|
|
466
|
+
)
|
|
467
|
+
if not dry_run:
|
|
468
|
+
for source in prune_roots:
|
|
469
|
+
wm._run_git(source, ["worktree", "prune"])
|
|
470
|
+
return {
|
|
471
|
+
"schema": SCHEMA_GC,
|
|
472
|
+
"ok": True,
|
|
473
|
+
"dryRun": dry_run,
|
|
474
|
+
"mode": "dry-run" if dry_run else "reconcile-registry",
|
|
475
|
+
"effects": {
|
|
476
|
+
"registryWrites": not dry_run,
|
|
477
|
+
"deletesWorktreePaths": False,
|
|
478
|
+
"runsGitWorktreePrune": (not dry_run and bool(prune_roots)),
|
|
479
|
+
},
|
|
480
|
+
"prunedSourceRoots": 0 if dry_run else len(prune_roots),
|
|
481
|
+
"reconciled": len(reconciled),
|
|
482
|
+
"reconciledEntries": reconciled,
|
|
483
|
+
"orphans": orphans,
|
|
484
|
+
"warnings": warnings,
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def maybe_auto_prune(
|
|
489
|
+
registry_root: Path,
|
|
490
|
+
config: JsonObject,
|
|
491
|
+
*,
|
|
492
|
+
no_auto_prune: bool = False,
|
|
493
|
+
) -> JsonObject | None:
|
|
494
|
+
if no_auto_prune:
|
|
495
|
+
return None
|
|
496
|
+
worktrees_cfg = config.get("worktrees")
|
|
497
|
+
if not isinstance(worktrees_cfg, dict):
|
|
498
|
+
return None
|
|
499
|
+
auto = worktrees_cfg.get("autoPrune")
|
|
500
|
+
if not isinstance(auto, dict) or auto.get("enabled") is not True:
|
|
501
|
+
return None
|
|
502
|
+
days = auto.get("mergedOlderThanDays", 7)
|
|
503
|
+
if not is_non_negative_int(days):
|
|
504
|
+
days = 7
|
|
505
|
+
try:
|
|
506
|
+
# Probe lock availability without blocking. The actual prune uses
|
|
507
|
+
# normal per-entry mutation locks so list doesn't monopolize the
|
|
508
|
+
# registry across a large cleanup pass.
|
|
509
|
+
with run_registry.registry_lock(registry_root, timeout_seconds=0.0):
|
|
510
|
+
pass
|
|
511
|
+
except TimeoutError:
|
|
512
|
+
return {"ok": False, "skipped": True, "reason": "lock_contended"}
|
|
513
|
+
try:
|
|
514
|
+
return wm.prune_worktrees(
|
|
515
|
+
registry_root,
|
|
516
|
+
merged=True,
|
|
517
|
+
older_than_days=days,
|
|
518
|
+
dry_run=False,
|
|
519
|
+
)
|
|
520
|
+
except wm.WorktreeManagementError as exc:
|
|
521
|
+
return dict(exc.payload)
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
# Deferred to the bottom to break the worktree_mgmt<->worktree_gc facade cycle:
|
|
525
|
+
# worktree_mgmt re-exports this module's surface (a top-level import here would
|
|
526
|
+
# fail when worktree_gc is imported first). All `wm.<seam>` access above is
|
|
527
|
+
# call-time, so binding the alias after our own definitions is sufficient and
|
|
528
|
+
# keeps the monkeypatch seams (mock.patch.object(worktree_mgmt, ...)) working.
|
|
529
|
+
from delegate_agent import worktree_mgmt as wm # noqa: E402
|