@imdeadpool/guardex 5.0.17 → 6.0.1

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.
@@ -1,16 +1,17 @@
1
1
  #!/usr/bin/env bash
2
2
  set -euo pipefail
3
3
 
4
- BASE_BRANCH="${MUSAFETY_BASE_BRANCH:-}"
4
+ BASE_BRANCH="${GUARDEX_BASE_BRANCH:-}"
5
5
  BASE_BRANCH_EXPLICIT=0
6
6
  DRY_RUN=0
7
7
  FORCE_DIRTY=0
8
+ FORCE_MERGED=0
8
9
  DELETE_BRANCHES=0
9
10
  DELETE_REMOTE_BRANCHES=0
10
11
  ONLY_DIRTY_WORKTREES=0
11
12
  TARGET_BRANCH=""
12
13
  IDLE_MINUTES=0
13
- NOW_EPOCH_RAW="${MUSAFETY_PRUNE_NOW_EPOCH:-}"
14
+ NOW_EPOCH_RAW="${GUARDEX_PRUNE_NOW_EPOCH:-}"
14
15
  IDLE_SECONDS=0
15
16
  NOW_EPOCH=0
16
17
 
@@ -33,6 +34,10 @@ while [[ $# -gt 0 ]]; do
33
34
  FORCE_DIRTY=1
34
35
  shift
35
36
  ;;
37
+ --force-merged)
38
+ FORCE_MERGED=1
39
+ shift
40
+ ;;
36
41
  --delete-branches)
37
42
  DELETE_BRANCHES=1
38
43
  shift
@@ -55,7 +60,7 @@ while [[ $# -gt 0 ]]; do
55
60
  ;;
56
61
  *)
57
62
  echo "[agent-worktree-prune] Unknown argument: $1" >&2
58
- echo "Usage: $0 [--base <branch>] [--dry-run] [--force-dirty] [--delete-branches] [--delete-remote-branches] [--only-dirty-worktrees] [--branch <agent/...>] [--idle-minutes <minutes>]" >&2
63
+ echo "Usage: $0 [--base <branch>] [--dry-run] [--force-dirty] [--force-merged] [--delete-branches] [--delete-remote-branches] [--only-dirty-worktrees] [--branch <agent/...|__agent_integrate_*|__source-probe-*>]" >&2
59
64
  exit 1
60
65
  ;;
61
66
  esac
@@ -66,7 +71,15 @@ if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
66
71
  exit 1
67
72
  fi
68
73
 
69
- repo_root="$(git rev-parse --show-toplevel)"
74
+ current_worktree_root="$(git rev-parse --show-toplevel)"
75
+ common_git_dir_raw="$(git -C "$current_worktree_root" rev-parse --git-common-dir)"
76
+ if [[ "$common_git_dir_raw" == /* ]]; then
77
+ repo_common_dir="$common_git_dir_raw"
78
+ else
79
+ repo_common_dir="${current_worktree_root}/${common_git_dir_raw}"
80
+ fi
81
+ repo_common_dir="$(cd "$repo_common_dir" && pwd -P)"
82
+ repo_root="$(cd "$repo_common_dir/.." && pwd -P)"
70
83
  current_pwd="$(pwd -P)"
71
84
  worktree_root="${repo_root}/.omx/agent-worktrees"
72
85
  repo_common_dir="$(
@@ -101,13 +114,28 @@ resolve_base_branch() {
101
114
  printf '%s' ""
102
115
  }
103
116
 
117
+ is_agent_branch() {
118
+ local branch="$1"
119
+ [[ "$branch" == agent/* ]]
120
+ }
121
+
122
+ is_temporary_branch() {
123
+ local branch="$1"
124
+ [[ "$branch" == __agent_integrate_* || "$branch" == __source-probe-* ]]
125
+ }
126
+
127
+ is_supported_target_branch() {
128
+ local branch="$1"
129
+ is_agent_branch "$branch" || is_temporary_branch "$branch"
130
+ }
131
+
104
132
  if [[ "$BASE_BRANCH_EXPLICIT" -eq 1 && -z "$BASE_BRANCH" ]]; then
105
133
  echo "[agent-worktree-prune] --base requires a non-empty branch name." >&2
106
134
  exit 1
107
135
  fi
108
136
 
109
- if [[ -n "$TARGET_BRANCH" && "$TARGET_BRANCH" != agent/* ]]; then
110
- echo "[agent-worktree-prune] --branch must reference an agent/* branch: ${TARGET_BRANCH}" >&2
137
+ if [[ -n "$TARGET_BRANCH" ]] && ! is_supported_target_branch "$TARGET_BRANCH"; then
138
+ echo "[agent-worktree-prune] --branch must reference agent/*, __agent_integrate_*, or __source-probe-*: ${TARGET_BRANCH}" >&2
111
139
  exit 1
112
140
  fi
113
141
 
@@ -117,7 +145,7 @@ if [[ ! "$IDLE_MINUTES" =~ ^[0-9]+$ ]]; then
117
145
  fi
118
146
 
119
147
  if [[ -n "$NOW_EPOCH_RAW" && ! "$NOW_EPOCH_RAW" =~ ^[0-9]+$ ]]; then
120
- echo "[agent-worktree-prune] MUSAFETY_PRUNE_NOW_EPOCH must be a unix timestamp integer." >&2
148
+ echo "[agent-worktree-prune] GUARDEX_PRUNE_NOW_EPOCH must be a unix timestamp integer." >&2
121
149
  exit 1
122
150
  fi
123
151
 
@@ -152,7 +180,10 @@ run_cmd() {
152
180
 
153
181
  branch_has_worktree() {
154
182
  local branch="$1"
155
- git -C "$repo_root" worktree list --porcelain | grep -q "^branch refs/heads/${branch}$"
183
+ git -C "$repo_root" worktree list --porcelain | awk -v target="refs/heads/${branch}" '
184
+ $1 == "branch" && $2 == target { found = 1; exit }
185
+ END { exit(found ? 0 : 1) }
186
+ '
156
187
  }
157
188
 
158
189
  is_clean_worktree() {
@@ -162,6 +193,166 @@ is_clean_worktree() {
162
193
  && [[ -z "$(git -C "$wt" ls-files --others --exclude-standard)" ]]
163
194
  }
164
195
 
196
+ has_unmerged_conflicts() {
197
+ local wt="$1"
198
+ [[ -n "$(git -C "$wt" diff --name-only --diff-filter=U 2>/dev/null || true)" ]]
199
+ }
200
+
201
+ filtered_status_output() {
202
+ local wt="$1"
203
+ # Use --untracked-files=all so untracked paths are reported as individual
204
+ # files (not collapsed directories). The bootstrap manifest stores file
205
+ # paths, so dir-level entries would never match.
206
+ git -C "$wt" status --porcelain --untracked-files=all -- \
207
+ . \
208
+ ":(exclude).omx/state/agent-file-locks.json" \
209
+ ":(exclude).dev-ports.json" \
210
+ ":(exclude)apps/logs/*.log"
211
+ }
212
+
213
+ resolve_worktree_git_dir() {
214
+ local wt="$1"
215
+ local git_dir=""
216
+ git_dir="$(git -C "$wt" rev-parse --git-dir 2>/dev/null || true)"
217
+ if [[ -z "$git_dir" ]]; then
218
+ return 1
219
+ fi
220
+ if [[ "$git_dir" == /* ]]; then
221
+ git_dir="$(cd "$git_dir" 2>/dev/null && pwd -P || true)"
222
+ else
223
+ git_dir="$(cd "$wt/$git_dir" 2>/dev/null && pwd -P || true)"
224
+ fi
225
+ if [[ -z "$git_dir" ]]; then
226
+ return 1
227
+ fi
228
+ printf '%s' "$git_dir"
229
+ }
230
+
231
+ bootstrap_manifest_path_for_worktree() {
232
+ local wt="$1"
233
+ local git_dir=""
234
+ git_dir="$(resolve_worktree_git_dir "$wt" || true)"
235
+ if [[ -z "$git_dir" ]]; then
236
+ return 1
237
+ fi
238
+ printf '%s/guardex-bootstrap-manifest.json' "$git_dir"
239
+ }
240
+
241
+ worktree_matches_bootstrap_manifest() {
242
+ local wt="$1"
243
+ local manifest_path=""
244
+ local status_output=""
245
+
246
+ manifest_path="$(bootstrap_manifest_path_for_worktree "$wt" || true)"
247
+ if [[ -z "$manifest_path" || ! -f "$manifest_path" ]]; then
248
+ return 1
249
+ fi
250
+
251
+ status_output="$(filtered_status_output "$wt")"
252
+ if [[ -z "$status_output" ]]; then
253
+ return 1
254
+ fi
255
+
256
+ STATUS_OUTPUT="$status_output" python3 - "$wt" "$manifest_path" <<'PY'
257
+ from __future__ import annotations
258
+
259
+ import hashlib
260
+ import json
261
+ import os
262
+ import sys
263
+ from pathlib import Path
264
+
265
+
266
+ def parse_status_paths(raw: str) -> list[str]:
267
+ paths: list[str] = []
268
+ for line in raw.splitlines():
269
+ if len(line) < 4:
270
+ continue
271
+ path_part = line[3:]
272
+ if " -> " in path_part:
273
+ path_part = path_part.split(" -> ", 1)[1]
274
+ path_part = path_part.strip()
275
+ if path_part:
276
+ paths.append(path_part)
277
+ return paths
278
+
279
+
280
+ def sha256_for_path(path: Path) -> str | None:
281
+ if not path.exists() or not path.is_file():
282
+ return None
283
+ digest = hashlib.sha256()
284
+ with path.open("rb") as handle:
285
+ for chunk in iter(lambda: handle.read(65536), b""):
286
+ digest.update(chunk)
287
+ return digest.hexdigest()
288
+
289
+
290
+ if len(sys.argv) != 3:
291
+ sys.exit(1)
292
+
293
+ worktree_root = Path(sys.argv[1])
294
+ manifest_path = Path(sys.argv[2])
295
+ status_raw = os.environ.get("STATUS_OUTPUT", "")
296
+ status_paths = sorted(set(parse_status_paths(status_raw)))
297
+ if not status_paths:
298
+ sys.exit(1)
299
+
300
+ try:
301
+ payload = json.loads(manifest_path.read_text(encoding="utf-8"))
302
+ except Exception:
303
+ sys.exit(1)
304
+
305
+ entries = payload.get("files")
306
+ if not isinstance(entries, list):
307
+ sys.exit(1)
308
+
309
+ manifest_by_path: dict[str, str | None] = {}
310
+ for entry in entries:
311
+ if not isinstance(entry, dict):
312
+ continue
313
+ path_value = entry.get("path")
314
+ if not isinstance(path_value, str) or not path_value:
315
+ continue
316
+ sha_value = entry.get("sha256")
317
+ if sha_value is not None and not isinstance(sha_value, str):
318
+ continue
319
+ manifest_by_path[path_value] = sha_value
320
+
321
+ if not manifest_by_path:
322
+ sys.exit(1)
323
+
324
+ for rel_path in status_paths:
325
+ if rel_path not in manifest_by_path:
326
+ sys.exit(1)
327
+ file_path = worktree_root / rel_path
328
+ current_sha = sha256_for_path(file_path)
329
+ if current_sha != manifest_by_path.get(rel_path):
330
+ sys.exit(1)
331
+
332
+ sys.exit(0)
333
+ PY
334
+ }
335
+
336
+ sanitize_branch_component() {
337
+ local raw="$1"
338
+ raw="$(printf '%s' "$raw" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//; s/-+/-/g')"
339
+ if [[ -z "$raw" ]]; then
340
+ raw="sandbox"
341
+ fi
342
+ printf '%s' "$raw"
343
+ }
344
+
345
+ resolve_unique_recovery_branch_name() {
346
+ local seed="$1"
347
+ local candidate="$seed"
348
+ local suffix=2
349
+ while git -C "$repo_root" show-ref --verify --quiet "refs/heads/${candidate}"; do
350
+ candidate="${seed}-${suffix}"
351
+ suffix=$((suffix + 1))
352
+ done
353
+ printf '%s' "$candidate"
354
+ }
355
+
165
356
  resolve_worktree_common_dir() {
166
357
  local wt="$1"
167
358
  local common_dir=""
@@ -192,68 +383,45 @@ select_unique_worktree_path() {
192
383
  printf '%s' "$candidate"
193
384
  }
194
385
 
195
- read_branch_activity_epoch() {
196
- local branch="$1"
197
- local wt="${2:-}"
198
- local activity_epoch=""
199
-
200
- activity_epoch="$(
201
- git -C "$repo_root" reflog show --format='%ct' -n 1 "refs/heads/${branch}" 2>/dev/null \
202
- | head -n 1 \
203
- | tr -d '[:space:]'
204
- )"
205
- if [[ -z "$activity_epoch" ]]; then
206
- activity_epoch="$(
207
- git -C "$repo_root" log -1 --format='%ct' "$branch" 2>/dev/null \
208
- | head -n 1 \
209
- | tr -d '[:space:]'
210
- )"
211
- fi
212
-
213
- if [[ -n "$wt" && -d "$wt" ]]; then
214
- local lock_file="${wt}/.omx/state/agent-file-locks.json"
215
- if [[ -f "$lock_file" ]]; then
216
- local lock_mtime=""
217
- lock_mtime="$(stat -c %Y "$lock_file" 2>/dev/null || stat -f %m "$lock_file" 2>/dev/null || true)"
218
- if [[ "$lock_mtime" =~ ^[0-9]+$ ]]; then
219
- if [[ -z "$activity_epoch" || "$lock_mtime" -gt "$activity_epoch" ]]; then
220
- activity_epoch="$lock_mtime"
221
- fi
222
- fi
223
- fi
224
- fi
225
-
226
- printf '%s' "$activity_epoch"
227
- }
228
-
229
386
  skipped_recent=0
230
387
 
231
388
  branch_idle_gate() {
232
389
  local branch="$1"
233
390
  local wt="$2"
234
391
  local reason="$3"
392
+ local subject=""
393
+ local commit_epoch=""
394
+ local age=0
395
+ local wait_remaining=0
396
+
235
397
  if [[ "$IDLE_SECONDS" -le 0 ]]; then
236
398
  return 0
237
399
  fi
238
- if [[ -z "$branch" ]]; then
239
- return 0
400
+
401
+ if [[ -n "$branch" ]] && git -C "$repo_root" show-ref --verify --quiet "refs/heads/${branch}"; then
402
+ commit_epoch="$(git -C "$repo_root" log -1 --format=%ct "$branch" 2>/dev/null || true)"
403
+ subject="$branch"
404
+ elif [[ -n "$wt" ]]; then
405
+ commit_epoch="$(git -C "$wt" log -1 --format=%ct 2>/dev/null || true)"
406
+ subject="$wt"
240
407
  fi
241
408
 
242
- local last_activity_epoch=""
243
- last_activity_epoch="$(read_branch_activity_epoch "$branch" "$wt")"
244
- if [[ ! "$last_activity_epoch" =~ ^[0-9]+$ ]]; then
409
+ if [[ -z "$commit_epoch" || ! "$commit_epoch" =~ ^[0-9]+$ ]]; then
245
410
  return 0
246
411
  fi
247
412
 
248
- local idle_age=$((NOW_EPOCH - last_activity_epoch))
249
- if [[ "$idle_age" -lt 0 ]]; then
250
- idle_age=0
413
+ age=$((NOW_EPOCH - commit_epoch))
414
+ if (( age < 0 )); then
415
+ age=0
251
416
  fi
252
- if [[ "$idle_age" -lt "$IDLE_SECONDS" ]]; then
417
+
418
+ if (( age < IDLE_SECONDS )); then
419
+ wait_remaining=$((IDLE_SECONDS - age))
253
420
  skipped_recent=$((skipped_recent + 1))
254
- echo "[agent-worktree-prune] Skipping recent branch (${reason}): ${branch} (idle=${idle_age}s < ${IDLE_SECONDS}s)"
421
+ echo "[agent-worktree-prune] Skipping recent ${reason}: ${subject} (age=${age}s, threshold=${IDLE_SECONDS}s, wait~${wait_remaining}s)"
255
422
  return 1
256
423
  fi
424
+
257
425
  return 0
258
426
  }
259
427
 
@@ -316,6 +484,10 @@ removed_worktrees=0
316
484
  removed_branches=0
317
485
  skipped_active=0
318
486
  skipped_dirty=0
487
+ repaired_detached_conflicts=0
488
+ failed_ops=0
489
+
490
+ relocate_foreign_worktree_entries
319
491
 
320
492
  relocate_foreign_worktree_entries
321
493
 
@@ -342,20 +514,26 @@ process_entry() {
342
514
  fi
343
515
 
344
516
  local remove_reason=""
517
+ local wt_name
518
+ wt_name="$(basename "$wt")"
345
519
 
346
- if [[ -z "$branch_ref" ]]; then
520
+ if [[ "$wt_name" == __integrate-* || "$wt_name" == __source-probe-* ]]; then
521
+ remove_reason="temporary-worktree"
522
+ elif [[ -z "$branch_ref" ]]; then
347
523
  remove_reason="detached-worktree"
348
524
  elif ! git -C "$repo_root" show-ref --verify --quiet "refs/heads/${branch}"; then
349
525
  remove_reason="missing-branch"
350
- elif [[ "$branch" == agent/* ]]; then
526
+ elif is_agent_branch "$branch"; then
351
527
  if git -C "$repo_root" merge-base --is-ancestor "$branch" "$BASE_BRANCH"; then
352
528
  if [[ "$DELETE_BRANCHES" -eq 1 ]]; then
353
529
  remove_reason="merged-agent-branch"
530
+ else
531
+ remove_reason="merged-agent-worktree"
354
532
  fi
355
533
  elif [[ "$ONLY_DIRTY_WORKTREES" -eq 1 ]] && is_clean_worktree "$wt"; then
356
534
  remove_reason="clean-agent-worktree"
357
535
  fi
358
- elif [[ "$branch" == __agent_integrate_* || "$branch" == __source-probe-* ]]; then
536
+ elif is_temporary_branch "$branch"; then
359
537
  remove_reason="temporary-worktree"
360
538
  fi
361
539
 
@@ -367,22 +545,75 @@ process_entry() {
367
545
  return
368
546
  fi
369
547
 
370
- if [[ "$FORCE_DIRTY" -ne 1 ]] && ! is_clean_worktree "$wt"; then
371
- skipped_dirty=$((skipped_dirty + 1))
372
- echo "[agent-worktree-prune] Skipping dirty worktree (${remove_reason}): ${wt}"
548
+ if [[ "$FORCE_DIRTY" -ne 1 ]] \
549
+ && [[ "$remove_reason" == "detached-worktree" ]] \
550
+ && has_unmerged_conflicts "$wt"; then
551
+ local wt_component
552
+ local base_component
553
+ local recovery_seed
554
+ local recovery_branch
555
+
556
+ wt_component="$(sanitize_branch_component "$wt_name")"
557
+ base_component="$(sanitize_branch_component "$BASE_BRANCH")"
558
+ recovery_seed="agent/recover/${base_component}-${wt_component}-$(date +%Y%m%d-%H%M%S)"
559
+ recovery_branch="$(resolve_unique_recovery_branch_name "$recovery_seed")"
560
+
561
+ if [[ "$DRY_RUN" -eq 1 ]]; then
562
+ echo "[agent-worktree-prune] [dry-run] Would recover detached conflicted worktree: ${wt} -> ${recovery_branch}"
563
+ repaired_detached_conflicts=$((repaired_detached_conflicts + 1))
564
+ return
565
+ fi
566
+
567
+ if git -C "$wt" checkout -b "$recovery_branch" >/dev/null 2>&1; then
568
+ repaired_detached_conflicts=$((repaired_detached_conflicts + 1))
569
+ echo "[agent-worktree-prune] Recovered detached conflicted worktree: ${wt} -> ${recovery_branch}"
570
+ return
571
+ fi
572
+
573
+ failed_ops=$((failed_ops + 1))
574
+ echo "[agent-worktree-prune] Failed to recover detached conflicted worktree: ${wt}" >&2
373
575
  return
374
576
  fi
375
577
 
578
+ if [[ "$FORCE_DIRTY" -ne 1 ]] && ! is_clean_worktree "$wt"; then
579
+ if [[ "$remove_reason" == "temporary-worktree" ]]; then
580
+ # __integrate-* and __source-probe-* are disposable scaffolding owned by
581
+ # agent-branch-finish. When they're dirty it's because a rebase/merge
582
+ # aborted mid-flight; the underlying branch ref is untouched. Always
583
+ # force-remove so stranded probes never accumulate in VS Code SCM.
584
+ echo "[agent-worktree-prune] Force-removing dirty temporary worktree (${remove_reason}): ${wt}"
585
+ git -C "$wt" rebase --abort >/dev/null 2>&1 || true
586
+ git -C "$wt" merge --abort >/dev/null 2>&1 || true
587
+ elif [[ "$remove_reason" == "merged-agent-branch" || "$remove_reason" == "merged-agent-worktree" ]] \
588
+ && worktree_matches_bootstrap_manifest "$wt"; then
589
+ # Bootstrap-manifest match means every dirty path is a scaffold file
590
+ # unchanged since branch-start — safe to remove even without --branch.
591
+ echo "[agent-worktree-prune] Treating bootstrap-only sandbox as safe to remove (${remove_reason}): ${wt}"
592
+ elif [[ "$FORCE_MERGED" -eq 1 ]] \
593
+ && [[ "$remove_reason" == "merged-agent-branch" || "$remove_reason" == "merged-agent-worktree" ]]; then
594
+ echo "[agent-worktree-prune] Force-removing dirty merged worktree (${remove_reason}): ${wt}"
595
+ else
596
+ skipped_dirty=$((skipped_dirty + 1))
597
+ echo "[agent-worktree-prune] Skipping dirty worktree (${remove_reason}): ${wt}"
598
+ return
599
+ fi
600
+ fi
601
+
376
602
  echo "[agent-worktree-prune] Removing worktree (${remove_reason}): ${wt}"
377
- run_cmd git -C "$repo_root" worktree remove "$wt" --force
378
- removed_worktrees=$((removed_worktrees + 1))
603
+ if run_cmd git -C "$repo_root" worktree remove "$wt" --force; then
604
+ removed_worktrees=$((removed_worktrees + 1))
605
+ else
606
+ failed_ops=$((failed_ops + 1))
607
+ echo "[agent-worktree-prune] Failed to remove worktree (${remove_reason}): ${wt}" >&2
608
+ return
609
+ fi
379
610
 
380
611
  if [[ -z "$branch" ]]; then
381
612
  return
382
613
  fi
383
614
 
384
615
  if git -C "$repo_root" show-ref --verify --quiet "refs/heads/${branch}" && ! branch_has_worktree "$branch"; then
385
- if [[ "$branch" == agent/* && "$DELETE_BRANCHES" -eq 1 ]]; then
616
+ if is_agent_branch "$branch" && [[ "$DELETE_BRANCHES" -eq 1 ]]; then
386
617
  if run_cmd git -C "$repo_root" branch -d "$branch" >/dev/null 2>&1; then
387
618
  removed_branches=$((removed_branches + 1))
388
619
  echo "[agent-worktree-prune] Deleted merged branch: ${branch}"
@@ -393,7 +624,7 @@ process_entry() {
393
624
  fi
394
625
  fi
395
626
  fi
396
- elif [[ "$branch" == __agent_integrate_* || "$branch" == __source-probe-* ]]; then
627
+ elif is_temporary_branch "$branch"; then
397
628
  run_cmd git -C "$repo_root" branch -D "$branch" >/dev/null 2>&1 || true
398
629
  removed_branches=$((removed_branches + 1))
399
630
  echo "[agent-worktree-prune] Deleted temporary branch: ${branch}"
@@ -433,27 +664,81 @@ if [[ "$DELETE_BRANCHES" -eq 1 ]]; then
433
664
  if branch_has_worktree "$branch"; then
434
665
  continue
435
666
  fi
667
+ if is_temporary_branch "$branch"; then
668
+ if ! branch_idle_gate "$branch" "" "stale-temporary-branch"; then
669
+ continue
670
+ fi
671
+ if run_cmd git -C "$repo_root" branch -D "$branch" >/dev/null 2>&1; then
672
+ removed_branches=$((removed_branches + 1))
673
+ if [[ "$DRY_RUN" -eq 1 ]]; then
674
+ echo "[agent-worktree-prune] Would delete stale temporary branch: ${branch}"
675
+ else
676
+ echo "[agent-worktree-prune] Deleted stale temporary branch: ${branch}"
677
+ fi
678
+ fi
679
+ continue
680
+ fi
681
+ if ! is_agent_branch "$branch"; then
682
+ continue
683
+ fi
436
684
  if ! branch_idle_gate "$branch" "" "stale-merged-branch"; then
437
685
  continue
438
686
  fi
439
687
  if git -C "$repo_root" merge-base --is-ancestor "$branch" "$BASE_BRANCH"; then
440
688
  if run_cmd git -C "$repo_root" branch -d "$branch" >/dev/null 2>&1; then
441
689
  removed_branches=$((removed_branches + 1))
442
- echo "[agent-worktree-prune] Deleted stale merged branch: ${branch}"
690
+ if [[ "$DRY_RUN" -eq 1 ]]; then
691
+ echo "[agent-worktree-prune] Would delete stale merged branch: ${branch}"
692
+ else
693
+ echo "[agent-worktree-prune] Deleted stale merged branch: ${branch}"
694
+ fi
443
695
  if [[ "$DELETE_REMOTE_BRANCHES" -eq 1 ]]; then
444
696
  if git -C "$repo_root" ls-remote --exit-code --heads origin "$branch" >/dev/null 2>&1; then
445
697
  run_cmd git -C "$repo_root" push origin --delete "$branch" >/dev/null 2>&1 || true
446
- echo "[agent-worktree-prune] Deleted stale merged remote branch: ${branch}"
698
+ if [[ "$DRY_RUN" -eq 1 ]]; then
699
+ echo "[agent-worktree-prune] Would delete stale merged remote branch: ${branch}"
700
+ else
701
+ echo "[agent-worktree-prune] Deleted stale merged remote branch: ${branch}"
702
+ fi
447
703
  fi
448
704
  fi
449
705
  fi
450
706
  fi
451
- done < <(git -C "$repo_root" for-each-ref --format='%(refname:short)' refs/heads/agent)
707
+ done < <(git -C "$repo_root" for-each-ref --format='%(refname:short)' refs/heads | awk '/^agent\// || /^__agent_integrate_/ || /^__source-probe-/')
452
708
  fi
453
709
 
454
- run_cmd git -C "$repo_root" worktree prune
710
+ if [[ "$DELETE_REMOTE_BRANCHES" -eq 1 ]]; then
711
+ while IFS= read -r remote_ref; do
712
+ [[ -z "$remote_ref" ]] && continue
713
+ local_branch="${remote_ref#origin/}"
714
+ if [[ -n "$TARGET_BRANCH" && "$local_branch" != "$TARGET_BRANCH" ]]; then
715
+ continue
716
+ fi
717
+ if git -C "$repo_root" show-ref --verify --quiet "refs/heads/${local_branch}"; then
718
+ continue
719
+ fi
720
+ if ! is_agent_branch "$local_branch"; then
721
+ continue
722
+ fi
723
+ if git -C "$repo_root" merge-base --is-ancestor "$remote_ref" "$BASE_BRANCH"; then
724
+ if run_cmd git -C "$repo_root" push origin --delete "$local_branch" >/dev/null 2>&1; then
725
+ removed_branches=$((removed_branches + 1))
726
+ if [[ "$DRY_RUN" -eq 1 ]]; then
727
+ echo "[agent-worktree-prune] Would delete stale merged remote-only branch: ${local_branch}"
728
+ else
729
+ echo "[agent-worktree-prune] Deleted stale merged remote-only branch: ${local_branch}"
730
+ fi
731
+ fi
732
+ fi
733
+ done < <(git -C "$repo_root" for-each-ref --format='%(refname:short)' refs/remotes/origin/agent)
734
+ fi
455
735
 
456
- echo "[agent-worktree-prune] Summary: base=${BASE_BRANCH}, idle_minutes=${IDLE_MINUTES}, removed_worktrees=${removed_worktrees}, removed_branches=${removed_branches}, skipped_active=${skipped_active}, skipped_dirty=${skipped_dirty}, skipped_recent=${skipped_recent}"
736
+ if ! run_cmd git -C "$repo_root" worktree prune; then
737
+ failed_ops=$((failed_ops + 1))
738
+ echo "[agent-worktree-prune] Warning: git worktree prune failed." >&2
739
+ fi
740
+
741
+ echo "[agent-worktree-prune] Summary: base=${BASE_BRANCH}, removed_worktrees=${removed_worktrees}, removed_branches=${removed_branches}, skipped_active=${skipped_active}, skipped_dirty=${skipped_dirty}, repaired_detached_conflicts=${repaired_detached_conflicts}"
457
742
  if [[ "$relocated_foreign" -gt 0 || "$skipped_foreign" -gt 0 ]]; then
458
743
  echo "[agent-worktree-prune] Foreign routing: relocated=${relocated_foreign}, skipped=${skipped_foreign}"
459
744
  fi
@@ -461,8 +746,11 @@ if [[ "$skipped_active" -gt 0 ]]; then
461
746
  echo "[agent-worktree-prune] Tip: leave active agent worktree directories, then run this command again for full cleanup." >&2
462
747
  fi
463
748
  if [[ "$skipped_dirty" -gt 0 ]]; then
464
- echo "[agent-worktree-prune] Tip: dirty worktrees were preserved. Clean/finish them first, or pass --force-dirty to remove anyway." >&2
749
+ echo "[agent-worktree-prune] Tip: dirty worktrees were preserved. Clean/finish them first, pass --force-merged to remove dirty worktrees whose branch is already merged into ${BASE_BRANCH}, or pass --force-dirty to remove any dirty worktree." >&2
465
750
  fi
466
751
  if [[ "$IDLE_SECONDS" -gt 0 && "$skipped_recent" -gt 0 ]]; then
467
752
  echo "[agent-worktree-prune] Tip: recent branches were preserved by --idle-minutes=${IDLE_MINUTES}. Re-run later or lower the threshold." >&2
468
753
  fi
754
+ if [[ "$failed_ops" -gt 0 ]]; then
755
+ echo "[agent-worktree-prune] Tip: some cleanup operations failed and were skipped. Re-run after fixing file-system or permission blockers." >&2
756
+ fi