@danmoisan/drm-copilot-mcp 1.1.14 → 1.1.16
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.
- package/package.json +1 -1
- package/resources/claude-customizations/.claude/agents/powershell-typed-engineer.md +4 -5
- package/resources/claude-customizations/.claude/hooks/enforce-powershell-batch-budget-route.ps1 +137 -0
- package/resources/claude-customizations/.claude/hooks/enforce-powershell-batch-budget.ps1 +95 -66
- package/resources/claude-customizations/.claude/lib/ci-gate/Invoke-CiGateParser.ps1 +330 -0
- package/resources/claude-customizations/.claude/rules/powershell.md +3 -3
- package/resources/claude-customizations/.claude/rules/shell.md +8 -7
- package/resources/claude-customizations/.claude/skills/cleanup-merged-worktrees/SKILL.md +8 -8
- package/resources/claude-customizations/.claude/skills/cleanup-merged-worktrees/scripts/cleanup-worktrees.sh +234 -0
- package/resources/claude-customizations/.claude/skills/cleanup-merged-worktrees/scripts/cleanup_worktrees_actions_lib.sh +451 -0
- package/resources/claude-customizations/.claude/skills/cleanup-merged-worktrees/scripts/cleanup_worktrees_detached_lib.sh +301 -0
- package/resources/claude-customizations/.claude/skills/cleanup-merged-worktrees/scripts/cleanup_worktrees_dirt_lib.sh +495 -0
- package/resources/claude-customizations/.claude/skills/cleanup-merged-worktrees/scripts/cleanup_worktrees_enumerate_lib.sh +252 -0
- package/resources/claude-customizations/.claude/skills/cleanup-merged-worktrees/scripts/cleanup_worktrees_lib.sh +496 -0
- package/resources/claude-customizations/.claude/skills/cleanup-merged-worktrees/scripts/cleanup_worktrees_preserve_eol_lib.sh +212 -0
- package/resources/claude-customizations/.claude/skills/cleanup-merged-worktrees/scripts/cleanup_worktrees_preserve_lib.sh +492 -0
- package/resources/claude-customizations/.claude/skills/cleanup-merged-worktrees/scripts/cleanup_worktrees_report_records_lib.sh +476 -0
- package/resources/claude-customizations/.claude/skills/cleanup-merged-worktrees/scripts/cleanup_worktrees_scan_helper.sh +182 -0
- package/resources/claude-customizations/.claude/skills/epic-orchestrate/SKILL.md +1 -1
- package/resources/claude-customizations/.claude/skills/invoke-powershell-engineer/SKILL.md +3 -4
- package/resources/claude-customizations/.claude/skills/orchestrate/SKILL.md +1 -1
- package/resources/claude-customizations/.claude/skills/powershell-change-budget-router/SKILL.md +6 -6
- package/resources/claude-customizations/pack-manifests/core.json +11 -0
- package/resources/claude-customizations/pack-manifests/powershell.json +1 -0
- package/resources/codex-and-agents-customizations/.agents/skills/invoke-powershell-engineer/SKILL.md +1 -1
- package/resources/codex-and-agents-customizations/.agents/skills/powershell/SKILL.md +1 -2
- package/resources/codex-and-agents-customizations/.codex/agents/powershell-typed-engineer-c1.toml +2 -3
- package/resources/codex-and-agents-customizations/.codex/agents/powershell-typed-engineer-c2.toml +2 -3
- package/resources/codex-and-agents-customizations/.codex/agents/powershell-typed-engineer-c3-elevated.toml +2 -3
- package/resources/codex-and-agents-customizations/.codex/agents/powershell-typed-engineer-c3.toml +2 -3
- package/resources/codex-and-agents-customizations/.codex/agents/powershell-typed-engineer-c4.toml +2 -3
- package/resources/codex-and-agents-customizations/.codex/agents/powershell-typed-engineer.toml +2 -3
- package/resources/codex-and-agents-customizations/.codex/config.toml +1 -1
- package/resources/codex-and-agents-customizations/.codex/hooks/enforce-powershell-batch-budget.ps1 +285 -80
- package/resources/customizations/.github/agents/powershell-orchestrator.agent.md +7 -7
- package/resources/customizations/.github/agents/powershell-typed-engineer.agent.md +8 -10
- package/resources/customizations/.github/prompts/orchestrate-powershell-work.prompt.md +2 -2
- package/resources/customizations/.github/skills/powershell-change-budget-router/SKILL.md +3 -3
- package/resources/powershell/PoshQC/settings/pester.runsettings.psd1 +6 -0
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# cleanup_worktrees_report_records_lib.sh: sourceable report-record function group for
|
|
3
|
+
# the cleanup-worktrees tool. It adds the report-mode visibility records that the
|
|
4
|
+
# classification ladder cannot see on its own: filesystem directories that are no
|
|
5
|
+
# longer registered worktrees, remote-tracking refs whose remote is gone, worktrees
|
|
6
|
+
# whose gitdir pointer no longer resolves, and the ancestor relationship between two
|
|
7
|
+
# branches that both resolve NOT_MERGED. The enumeration/protection seam
|
|
8
|
+
# (cleanup_wt_git, enumerate_branches, parse_worktree_list, normalize_wt_path,
|
|
9
|
+
# compute_protected, check_main_freshness) lives in cleanup_worktrees_enumerate_lib.sh
|
|
10
|
+
# and the classification ladder (classify_branch) lives in cleanup_worktrees_lib.sh;
|
|
11
|
+
# both MUST be sourced before this file. Keeping these records in a sibling file keeps
|
|
12
|
+
# every file within the 500-line cap.
|
|
13
|
+
#
|
|
14
|
+
# Sourcing contract: this library defines functions only; it never runs work at source
|
|
15
|
+
# time, so the wrapper and the bats suites can source it without side effects.
|
|
16
|
+
#
|
|
17
|
+
# Report line contract added by this library (pipe-delimited, LC_ALL=C ordered, one
|
|
18
|
+
# record per line; all four are advisory/read-only and never unlock a destructive
|
|
19
|
+
# action):
|
|
20
|
+
# ORPHAN_DIR|<path>|<size> a directory under a worktree-tracking root
|
|
21
|
+
# with no `.git` file and no registration.
|
|
22
|
+
# <size> is best-effort and may be the
|
|
23
|
+
# literal `unknown`.
|
|
24
|
+
# STALE_REF|<refname> a refs/remotes/<name>/* ref whose <name> is
|
|
25
|
+
# not a configured remote. <refname> is the
|
|
26
|
+
# full ref form (refs/remotes/...).
|
|
27
|
+
# CHILD_OF|<branch>|<ancestor> advisory, informational: <branch> is a git
|
|
28
|
+
# ancestor of <ancestor>, and BOTH resolved
|
|
29
|
+
# exactly NOT_MERGED through their own
|
|
30
|
+
# unchanged ladders. Emitted alongside, never
|
|
31
|
+
# instead of, the branch's own BRANCH| line.
|
|
32
|
+
# It names a containment relationship only; no
|
|
33
|
+
# ladder rung is skipped for either branch and
|
|
34
|
+
# neither verdict is derived from the other.
|
|
35
|
+
# WARN|registration-lost|<path> a worktree directory whose `.git` gitdir
|
|
36
|
+
# pointer names a target that does not exist.
|
|
37
|
+
#
|
|
38
|
+
# Filesystem-scan seam: every filesystem read goes through cleanup_wt_scan_bin so the
|
|
39
|
+
# bats suites can stub the scan deterministically via CLEANUP_WT_SCAN_BIN, mirroring
|
|
40
|
+
# the CLEANUP_WT_GIT_BIN seam in cleanup_worktrees_enumerate_lib.sh. Unlike that seam,
|
|
41
|
+
# the fallback target is the bundled real implementation
|
|
42
|
+
# .claude/skills/cleanup-merged-worktrees/scripts/cleanup_worktrees_scan_helper.sh rather than a PATH lookup, because no
|
|
43
|
+
# single standard system binary emits the combined
|
|
44
|
+
# `path|has_gitfile|gitdir_target_exists|size` tuple these records need.
|
|
45
|
+
|
|
46
|
+
cleanup_wt_scan_bin() {
|
|
47
|
+
# Resolve the filesystem-scan helper honoring the CLEANUP_WT_SCAN_BIN override seam
|
|
48
|
+
# and echo its path; the caller invokes it.
|
|
49
|
+
#
|
|
50
|
+
# When CLEANUP_WT_SCAN_BIN is set to a non-empty value it must point to an existing
|
|
51
|
+
# executable; an empty or nonexistent value is treated as missing and falls back to
|
|
52
|
+
# the bundled cleanup_worktrees_scan_helper.sh alongside this library.
|
|
53
|
+
#
|
|
54
|
+
# Args: none. Always returns 0 and echoes exactly one path.
|
|
55
|
+
local override=${CLEANUP_WT_SCAN_BIN:-}
|
|
56
|
+
if [[ -n $override && -x $override ]]; then
|
|
57
|
+
printf '%s\n' "$override"
|
|
58
|
+
return 0
|
|
59
|
+
fi
|
|
60
|
+
local here
|
|
61
|
+
here=$(dirname "${BASH_SOURCE[0]}")
|
|
62
|
+
printf '%s\n' "$here/cleanup_worktrees_scan_helper.sh"
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
scan_stale_refs() {
|
|
66
|
+
# Emit STALE_REF|<refname> for every remote-tracking ref whose remote is gone.
|
|
67
|
+
#
|
|
68
|
+
# A ref under refs/remotes/<name>/ belongs to the configured remote <name>. When
|
|
69
|
+
# <name> is absent from `git remote`'s output the ref is a leftover: the remote was
|
|
70
|
+
# removed (or was never configured in this checkout) but its tracking refs were not
|
|
71
|
+
# pruned. The record is advisory and read-only; nothing here deletes a ref.
|
|
72
|
+
#
|
|
73
|
+
# The refname is emitted in full ref form (refs/remotes/<name>/<branch>) rather than
|
|
74
|
+
# the :short form, so the record names the ref exactly as `git for-each-ref` and
|
|
75
|
+
# `git update-ref` spell it. Output is LC_ALL=C sorted.
|
|
76
|
+
#
|
|
77
|
+
# Both git reads are captured in the PARENT shell (`out=$(...) || rc=$?`) so a
|
|
78
|
+
# non-zero exit is observed here rather than lost inside a process substitution. On a
|
|
79
|
+
# hard failure of either read this emits no STALE_REF line and returns git's non-zero
|
|
80
|
+
# exit code, mirroring parse_worktree_list's hard-failure contract: a git failure must
|
|
81
|
+
# never read as "no stale refs".
|
|
82
|
+
local rc=0 refs_out remotes_out ref name remote
|
|
83
|
+
refs_out=$(cleanup_wt_git for-each-ref --format='%(refname)' refs/remotes/) || rc=$?
|
|
84
|
+
if ((rc != 0)); then
|
|
85
|
+
printf 'cleanup-worktrees: git for-each-ref refs/remotes/ failed (rc=%s)\n' "$rc" >&2
|
|
86
|
+
return "$rc"
|
|
87
|
+
fi
|
|
88
|
+
remotes_out=$(cleanup_wt_git remote) || rc=$?
|
|
89
|
+
if ((rc != 0)); then
|
|
90
|
+
printf 'cleanup-worktrees: git remote failed (rc=%s)\n' "$rc" >&2
|
|
91
|
+
return "$rc"
|
|
92
|
+
fi
|
|
93
|
+
local -A configured=()
|
|
94
|
+
while IFS= read -r remote; do
|
|
95
|
+
[[ -z $remote ]] && continue
|
|
96
|
+
configured[$remote]=1
|
|
97
|
+
done <<<"$remotes_out"
|
|
98
|
+
local -a stale=()
|
|
99
|
+
while IFS= read -r ref; do
|
|
100
|
+
[[ -z $ref ]] && continue
|
|
101
|
+
[[ $ref == refs/remotes/* ]] || continue
|
|
102
|
+
name=${ref#refs/remotes/}
|
|
103
|
+
name=${name%%/*}
|
|
104
|
+
[[ -z $name ]] && continue
|
|
105
|
+
[[ -n ${configured[$name]:-} ]] && continue
|
|
106
|
+
stale+=("STALE_REF|$ref")
|
|
107
|
+
done <<<"$refs_out"
|
|
108
|
+
if ((${#stale[@]} > 0)); then
|
|
109
|
+
printf '%s\n' "${stale[@]}" | LC_ALL=C sort
|
|
110
|
+
fi
|
|
111
|
+
return 0
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
cleanup_wt_scan_roots() {
|
|
115
|
+
# Echo the worktree-tracking roots to scan, one per line.
|
|
116
|
+
#
|
|
117
|
+
# CLEANUP_WT_ORPHAN_ROOTS overrides the derivation with a colon-separated list.
|
|
118
|
+
# Otherwise BOTH roots derive from the main worktree path — the first
|
|
119
|
+
# `git worktree list --porcelain` stanza, the same derivation
|
|
120
|
+
# consolidation_worktree_path uses in cleanup_worktrees_actions_lib.sh. They are
|
|
121
|
+
# `<main-worktree-path>` joined with `.claude/worktrees` (the agent worktree folder)
|
|
122
|
+
# and `<main-worktree-path>-wt` (the sibling worktree folder), in that order.
|
|
123
|
+
#
|
|
124
|
+
# Deriving both from one read means a parse_worktree_list hard failure emits no root
|
|
125
|
+
# at all, rather than a bare relative path that would be resolved against whatever
|
|
126
|
+
# the process's current working directory happened to be. cleanup_wt_scan_records
|
|
127
|
+
# returns 0 with no record for an empty root list, so the advisory records degrade to
|
|
128
|
+
# silence rather than to a misleading scan. Always returns 0.
|
|
129
|
+
local override=${CLEANUP_WT_ORPHAN_ROOTS:-}
|
|
130
|
+
if [[ -n $override ]]; then
|
|
131
|
+
local IFS=:
|
|
132
|
+
local part
|
|
133
|
+
for part in $override; do
|
|
134
|
+
[[ -n $part ]] && printf '%s\n' "$part"
|
|
135
|
+
done
|
|
136
|
+
return 0
|
|
137
|
+
fi
|
|
138
|
+
local out rc=0 first_record main_wt=""
|
|
139
|
+
out=$(parse_worktree_list) || rc=$?
|
|
140
|
+
if ((rc != 0)); then
|
|
141
|
+
return 0
|
|
142
|
+
fi
|
|
143
|
+
first_record=${out%%$'\n'*}
|
|
144
|
+
main_wt=${first_record%%|*}
|
|
145
|
+
if [[ -n $main_wt ]]; then
|
|
146
|
+
printf '%s\n' "${main_wt}/.claude/worktrees"
|
|
147
|
+
printf '%s\n' "${main_wt}-wt"
|
|
148
|
+
fi
|
|
149
|
+
return 0
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
cleanup_wt_scan_records() {
|
|
153
|
+
# Echo the raw `<path>|<has_gitfile>|<gitdir_target_exists>|<size>` records for every
|
|
154
|
+
# candidate directory under the resolved scan roots, one per line.
|
|
155
|
+
#
|
|
156
|
+
# Every filesystem read goes through the CLEANUP_WT_SCAN_BIN seam, so the bats suites
|
|
157
|
+
# replay canned records instead of touching the real filesystem. The scan output is
|
|
158
|
+
# captured in the PARENT shell (`out=$(...) || rc=$?`) so a non-zero exit is observed
|
|
159
|
+
# here; on a hard failure this emits no record and returns the scan's exit code
|
|
160
|
+
# rather than letting a failed scan read as "nothing found".
|
|
161
|
+
local -a roots=()
|
|
162
|
+
local root
|
|
163
|
+
while IFS= read -r root; do
|
|
164
|
+
[[ -z $root ]] && continue
|
|
165
|
+
roots+=("$root")
|
|
166
|
+
done < <(cleanup_wt_scan_roots)
|
|
167
|
+
if ((${#roots[@]} == 0)); then
|
|
168
|
+
return 0
|
|
169
|
+
fi
|
|
170
|
+
local bin out rc=0
|
|
171
|
+
bin=$(cleanup_wt_scan_bin)
|
|
172
|
+
# Every script under scripts/bash/ is tracked mode 100644 and is invoked through the
|
|
173
|
+
# interpreter rather than by its exec bit, so the bundled fallback helper is run with
|
|
174
|
+
# `bash` when it is not executable. An override supplied through CLEANUP_WT_SCAN_BIN
|
|
175
|
+
# is always executable (cleanup_wt_scan_bin only accepts it when it is) and is run
|
|
176
|
+
# directly, so an override may be any executable, not only a bash script.
|
|
177
|
+
if [[ -x $bin ]]; then
|
|
178
|
+
out=$("$bin" scan-dirs "${roots[@]}") || rc=$?
|
|
179
|
+
else
|
|
180
|
+
out=$(bash "$bin" scan-dirs "${roots[@]}") || rc=$?
|
|
181
|
+
fi
|
|
182
|
+
if ((rc != 0)); then
|
|
183
|
+
printf 'cleanup-worktrees: filesystem scan failed (rc=%s)\n' "$rc" >&2
|
|
184
|
+
return "$rc"
|
|
185
|
+
fi
|
|
186
|
+
if [[ -n $out ]]; then
|
|
187
|
+
printf '%s\n' "$out"
|
|
188
|
+
fi
|
|
189
|
+
return 0
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
scan_orphan_dirs() {
|
|
193
|
+
# Emit ORPHAN_DIR|<path>|<size> for every scanned directory that is no longer a
|
|
194
|
+
# worktree: it carries no `.git` pointer file and no `git worktree list` entry names
|
|
195
|
+
# it. These are the residue of a `git worktree remove` that partially ran or failed.
|
|
196
|
+
# The record is advisory and read-only; nothing here deletes a directory.
|
|
197
|
+
#
|
|
198
|
+
# Registered paths are compared after normalize_wt_path normalization, so a
|
|
199
|
+
# backslash/case/trailing-slash difference between the porcelain listing and the
|
|
200
|
+
# filesystem scan cannot make a live worktree look orphaned. Output is LC_ALL=C
|
|
201
|
+
# sorted. A record whose size field is the literal `unknown` is emitted verbatim
|
|
202
|
+
# rather than dropped: size is best-effort and its absence is not a reason to hide a
|
|
203
|
+
# real orphan.
|
|
204
|
+
#
|
|
205
|
+
# Args: $1 = OPTIONAL pre-scanned records, in cleanup_wt_scan_records' output shape.
|
|
206
|
+
# When an argument is supplied it is used verbatim and no scan is performed, so a
|
|
207
|
+
# caller that already scanned (run_report_scans) does not scan a second time. The test
|
|
208
|
+
# is on the ARGUMENT COUNT, not on the value, so "records supplied and empty" is
|
|
209
|
+
# distinguishable from "no records supplied": an empty supplied value means the scan
|
|
210
|
+
# found nothing and this function emits nothing, while no argument at all means the
|
|
211
|
+
# function scans for itself. Direct callers may therefore keep calling it with no
|
|
212
|
+
# argument.
|
|
213
|
+
#
|
|
214
|
+
# Returns the scan's exit code on a hard scan failure (no record emitted), else 0.
|
|
215
|
+
local rc=0 recs wlout wlrc=0 record path has_gitfile size norm
|
|
216
|
+
if (($# > 0)); then
|
|
217
|
+
recs="$1"
|
|
218
|
+
else
|
|
219
|
+
recs=$(cleanup_wt_scan_records) || rc=$?
|
|
220
|
+
if ((rc != 0)); then
|
|
221
|
+
return "$rc"
|
|
222
|
+
fi
|
|
223
|
+
fi
|
|
224
|
+
if [[ -z $recs ]]; then
|
|
225
|
+
return 0
|
|
226
|
+
fi
|
|
227
|
+
local -A registered=()
|
|
228
|
+
wlout=$(parse_worktree_list) || wlrc=$?
|
|
229
|
+
if ((wlrc == 0)); then
|
|
230
|
+
while IFS= read -r record; do
|
|
231
|
+
[[ -z $record ]] && continue
|
|
232
|
+
path=${record%%|*}
|
|
233
|
+
norm=$(normalize_wt_path "$path")
|
|
234
|
+
[[ -n $norm ]] && registered[$norm]=1
|
|
235
|
+
done <<<"$wlout"
|
|
236
|
+
fi
|
|
237
|
+
local -a orphans=()
|
|
238
|
+
while IFS= read -r record; do
|
|
239
|
+
[[ -z $record ]] && continue
|
|
240
|
+
IFS='|' read -r path has_gitfile _ size <<<"$record"
|
|
241
|
+
[[ -z $path ]] && continue
|
|
242
|
+
[[ $has_gitfile == "0" ]] || continue
|
|
243
|
+
norm=$(normalize_wt_path "$path")
|
|
244
|
+
[[ -n ${registered[$norm]:-} ]] && continue
|
|
245
|
+
[[ -z $size ]] && size="unknown"
|
|
246
|
+
orphans+=("ORPHAN_DIR|$path|$size")
|
|
247
|
+
done <<<"$recs"
|
|
248
|
+
if ((${#orphans[@]} > 0)); then
|
|
249
|
+
printf '%s\n' "${orphans[@]}" | LC_ALL=C sort
|
|
250
|
+
fi
|
|
251
|
+
return 0
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
scan_registration_loss() {
|
|
255
|
+
# Emit WARN|registration-lost|<path> for every scanned directory that still carries a
|
|
256
|
+
# `.git` pointer file whose gitdir target no longer exists.
|
|
257
|
+
#
|
|
258
|
+
# This is the half-removed shape: the worktree still looks like a worktree from the
|
|
259
|
+
# outside, but the administrative `.git/worktrees/<name>` entry it points at is gone,
|
|
260
|
+
# so git commands run inside it fail in confusing ways. The record is advisory and
|
|
261
|
+
# read-only; nothing here deletes a directory or prunes a registration.
|
|
262
|
+
#
|
|
263
|
+
# It consumes the same scan output and the same root resolution as scan_orphan_dirs,
|
|
264
|
+
# and the mechanism that makes that true is the caller: run_report_scans performs one
|
|
265
|
+
# cleanup_wt_scan_records call and passes the SAME captured records to both functions,
|
|
266
|
+
# so the two records are always derived from one consistent view of the filesystem. A
|
|
267
|
+
# direct call with no argument scans for itself instead.
|
|
268
|
+
# Output is LC_ALL=C sorted. A record whose shape is unexpected (an empty path or a
|
|
269
|
+
# gitdir_target_exists field that is neither 0 nor 1, such as the NA a pointer-less
|
|
270
|
+
# directory carries) is skipped silently rather than reported or raised, mirroring
|
|
271
|
+
# check_main_freshness's never-blocking contract: this warning must never be the
|
|
272
|
+
# reason a report fails.
|
|
273
|
+
#
|
|
274
|
+
# Args: $1 = OPTIONAL pre-scanned records, in cleanup_wt_scan_records' output shape.
|
|
275
|
+
# The test is on the ARGUMENT COUNT, not on the value, so "records supplied and empty"
|
|
276
|
+
# is distinguishable from "no records supplied".
|
|
277
|
+
#
|
|
278
|
+
# Returns the scan's exit code on a hard scan failure (no record emitted), else 0.
|
|
279
|
+
local rc=0 recs record path has_gitfile target_exists
|
|
280
|
+
if (($# > 0)); then
|
|
281
|
+
recs="$1"
|
|
282
|
+
else
|
|
283
|
+
recs=$(cleanup_wt_scan_records) || rc=$?
|
|
284
|
+
if ((rc != 0)); then
|
|
285
|
+
return "$rc"
|
|
286
|
+
fi
|
|
287
|
+
fi
|
|
288
|
+
if [[ -z $recs ]]; then
|
|
289
|
+
return 0
|
|
290
|
+
fi
|
|
291
|
+
local -a lost=()
|
|
292
|
+
while IFS= read -r record; do
|
|
293
|
+
[[ -z $record ]] && continue
|
|
294
|
+
IFS='|' read -r path has_gitfile target_exists _ <<<"$record"
|
|
295
|
+
[[ -z $path ]] && continue
|
|
296
|
+
[[ $has_gitfile == "1" ]] || continue
|
|
297
|
+
[[ $target_exists == "0" ]] || continue
|
|
298
|
+
lost+=("WARN|registration-lost|$path")
|
|
299
|
+
done <<<"$recs"
|
|
300
|
+
if ((${#lost[@]} > 0)); then
|
|
301
|
+
printf '%s\n' "${lost[@]}" | LC_ALL=C sort
|
|
302
|
+
fi
|
|
303
|
+
return 0
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
run_report_scans() {
|
|
307
|
+
# The sole report-mode entry point for the three advisory scans. It exists so that
|
|
308
|
+
# exactly ONE filesystem scan — and therefore exactly one `du` pass — occurs per
|
|
309
|
+
# report: scan_orphan_dirs and scan_registration_loss each scan for themselves when
|
|
310
|
+
# called directly, so a caller that invoked both would scan the tree twice and could
|
|
311
|
+
# derive its two records from two different views of it.
|
|
312
|
+
#
|
|
313
|
+
# Emission order is the documented one: STALE_REF, then ORPHAN_DIR, then
|
|
314
|
+
# WARN|registration-lost. scan_stale_refs consumes no scan records, so it runs in both
|
|
315
|
+
# branches below.
|
|
316
|
+
#
|
|
317
|
+
# Guarded parent-shell capture: a non-zero cleanup_wt_scan_records exit is observed
|
|
318
|
+
# here, emits no scan-derived record, and is returned, mirroring the hard-failure
|
|
319
|
+
# contract the two record functions already have.
|
|
320
|
+
#
|
|
321
|
+
# Args: none. Returns the maximum non-zero return code observed, else 0.
|
|
322
|
+
local rc=0 srrc=0 recs scanrc=0 orc=0 lrc=0
|
|
323
|
+
recs=$(cleanup_wt_scan_records) || scanrc=$?
|
|
324
|
+
if ((scanrc != 0)); then
|
|
325
|
+
scan_stale_refs || srrc=$?
|
|
326
|
+
if ((srrc > scanrc)); then
|
|
327
|
+
return "$srrc"
|
|
328
|
+
fi
|
|
329
|
+
return "$scanrc"
|
|
330
|
+
fi
|
|
331
|
+
scan_stale_refs || srrc=$?
|
|
332
|
+
if ((srrc > rc)); then
|
|
333
|
+
rc=$srrc
|
|
334
|
+
fi
|
|
335
|
+
scan_orphan_dirs "$recs" || orc=$?
|
|
336
|
+
if ((orc > rc)); then
|
|
337
|
+
rc=$orc
|
|
338
|
+
fi
|
|
339
|
+
scan_registration_loss "$recs" || lrc=$?
|
|
340
|
+
if ((lrc > rc)); then
|
|
341
|
+
rc=$lrc
|
|
342
|
+
fi
|
|
343
|
+
return "$rc"
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
classify_all_branches() {
|
|
347
|
+
# Shared classification driver: classify every enumerated branch once through the
|
|
348
|
+
# unchanged per-branch ladder, emit exactly the BRANCH|/COMMIT| lines that ladder
|
|
349
|
+
# produced, add the advisory CHILD_OF| record where one applies, and return the
|
|
350
|
+
# maximum per-branch return code observed.
|
|
351
|
+
#
|
|
352
|
+
# NO LADDER RUNG IS EVER SKIPPED, and no branch's verdict is ever derived from another
|
|
353
|
+
# branch's verdict. Ancestry determines a branch's state in neither direction:
|
|
354
|
+
#
|
|
355
|
+
# - At rung 2, a branch merged into main by a merge commit is an ancestor of main
|
|
356
|
+
# and therefore of every branch cut from a main that already contains it, while
|
|
357
|
+
# resolving MERGED_CLEAN itself.
|
|
358
|
+
# - At rung 3, a branch whose net diff against main is empty resolves
|
|
359
|
+
# MERGED_CONTENT_NEUTRAL while still being an ancestor of an unmerged branch.
|
|
360
|
+
# - At rung 5, classify_residual_commit decides each residual commit by comparing
|
|
361
|
+
# the BRANCH TIP's blob against main's. Two branches in an ancestor relationship
|
|
362
|
+
# have different tips, so the same residual commit can resolve CONTENT_ON_MAIN for
|
|
363
|
+
# one and UNIQUE for the other. That rung is the last before the verdict and
|
|
364
|
+
# cannot be derived from any ancestor's result under any topology.
|
|
365
|
+
#
|
|
366
|
+
# There is no sound cut point, so there is no cut. CHILD_OF is consequently an
|
|
367
|
+
# informational record about a containment relationship, not a licence to skip work,
|
|
368
|
+
# and the outcome-preservation invariant holds by construction: the BRANCH| line this
|
|
369
|
+
# function emits is the byte-identical line classify_branch produced.
|
|
370
|
+
#
|
|
371
|
+
# Two-phase contract:
|
|
372
|
+
#
|
|
373
|
+
# 1. Classification. Every enumerated branch is classified by one classify_branch
|
|
374
|
+
# call. Its captured output is recorded verbatim and its state is read from the
|
|
375
|
+
# first BRANCH| line's third field. rc rises to the maximum return observed.
|
|
376
|
+
#
|
|
377
|
+
# 2. Pairwise ancestry, restricted to the NOT_MERGED set. For every ordered pair
|
|
378
|
+
# (X, Y) with X != Y where BOTH X and Y recorded exactly NOT_MERGED, probe
|
|
379
|
+
# `git merge-base --is-ancestor <X> <Y>` with the exit code captured via
|
|
380
|
+
# `|| mrc=$?`. Exit 0 means X is an ancestor of Y and appends CHILD_OF|X|Y to X's
|
|
381
|
+
# recorded lines, for the LC_ALL=C-first such Y so the record is deterministic
|
|
382
|
+
# when X has several NOT_MERGED ancestor-targets. Exit 1 means "not an ancestor".
|
|
383
|
+
# Any exit above 1 is a hard git failure: it emits no CHILD_OF record for that
|
|
384
|
+
# pair, leaves X's already-correct BRANCH| line exactly as classify_branch
|
|
385
|
+
# produced it — overwriting it would itself break the invariant above — and
|
|
386
|
+
# raises rc to at least 2 so the failure surfaces in the exit status instead of
|
|
387
|
+
# degrading silently to "not an ancestor". A hard failure of the ladder's OWN
|
|
388
|
+
# rung-2 probe is a separate, unaffected case that classify_branch still maps to
|
|
389
|
+
# BRANCH|<name>|ANCESTRY_ERROR.
|
|
390
|
+
#
|
|
391
|
+
# Restricting the probe to the NOT_MERGED set bounds its cost at k*(k-1) probes for k
|
|
392
|
+
# such branches rather than n*(n-1) for n branches. The base branch `main` never
|
|
393
|
+
# enters the probe set: compute_protected protects it by name (CLEANUP_WT_BASE_BRANCH)
|
|
394
|
+
# in every checkout topology, so classify_branch resolves it PROTECTED_CURRENT at rung 1.
|
|
395
|
+
#
|
|
396
|
+
# Output order is enumerate_branches' original LC_ALL=C order, regardless of the
|
|
397
|
+
# order in which the phases above resolved each branch, so the report is
|
|
398
|
+
# deterministic. An enumerate_branches hard failure aborts before any line is emitted
|
|
399
|
+
# and returns git's exit code.
|
|
400
|
+
local rc=0 crc ebout ebrc=0 cbout
|
|
401
|
+
ebout=$(enumerate_branches) || ebrc=$?
|
|
402
|
+
if ((ebrc != 0)); then
|
|
403
|
+
return "$ebrc"
|
|
404
|
+
fi
|
|
405
|
+
local -a order=()
|
|
406
|
+
local name
|
|
407
|
+
while read -r name _; do
|
|
408
|
+
[[ -z $name ]] && continue
|
|
409
|
+
order+=("$name")
|
|
410
|
+
done <<<"$ebout"
|
|
411
|
+
if ((${#order[@]} == 0)); then
|
|
412
|
+
return 0
|
|
413
|
+
fi
|
|
414
|
+
local -A branch_state=() branch_out=()
|
|
415
|
+
local x y mrc
|
|
416
|
+
# Phase 1: the unchanged full ladder, once for every branch. No rung is skipped and
|
|
417
|
+
# no verdict is substituted, so each recorded line is the ladder's own.
|
|
418
|
+
for x in "${order[@]}"; do
|
|
419
|
+
crc=0
|
|
420
|
+
cbout=$(classify_branch "$x") || crc=$?
|
|
421
|
+
branch_out[$x]=$cbout
|
|
422
|
+
branch_state[$x]=$(printf '%s\n' "$cbout" | awk -F'|' '/^BRANCH\|/{print $3; exit}')
|
|
423
|
+
if ((crc > rc)); then
|
|
424
|
+
rc=$crc
|
|
425
|
+
fi
|
|
426
|
+
done
|
|
427
|
+
# Phase 2: pairwise ancestry, over the NOT_MERGED set only. A branch that resolved
|
|
428
|
+
# anything else is neither a subject nor a target, so `main`, PROTECTED_CURRENT by the
|
|
429
|
+
# unconditional base-branch protection in compute_protected, is excluded by its verdict.
|
|
430
|
+
local -a not_merged=()
|
|
431
|
+
for x in "${order[@]}"; do
|
|
432
|
+
if [[ ${branch_state[$x]:-} == "NOT_MERGED" ]]; then
|
|
433
|
+
not_merged+=("$x")
|
|
434
|
+
fi
|
|
435
|
+
done
|
|
436
|
+
if ((${#not_merged[@]} > 1)); then
|
|
437
|
+
local sorted hit
|
|
438
|
+
local -a probe=()
|
|
439
|
+
# LC_ALL=C order makes the recorded ancestor-target deterministic when a branch
|
|
440
|
+
# has more than one NOT_MERGED ancestor-target.
|
|
441
|
+
sorted=$(printf '%s\n' "${not_merged[@]}" | LC_ALL=C sort)
|
|
442
|
+
while IFS= read -r name; do
|
|
443
|
+
if [[ -n $name ]]; then
|
|
444
|
+
probe+=("$name")
|
|
445
|
+
fi
|
|
446
|
+
done <<<"$sorted"
|
|
447
|
+
for x in "${probe[@]}"; do
|
|
448
|
+
hit=""
|
|
449
|
+
for y in "${probe[@]}"; do
|
|
450
|
+
if [[ $x == "$y" ]]; then
|
|
451
|
+
continue
|
|
452
|
+
fi
|
|
453
|
+
mrc=0
|
|
454
|
+
cleanup_wt_git merge-base --is-ancestor "$x" "$y" >/dev/null 2>&1 || mrc=$?
|
|
455
|
+
if ((mrc == 0)); then
|
|
456
|
+
hit=$y
|
|
457
|
+
break
|
|
458
|
+
fi
|
|
459
|
+
# A hard probe failure emits no record for this pair and never rewrites
|
|
460
|
+
# the branch's own verdict; it surfaces in the return code instead.
|
|
461
|
+
if ((mrc > 1)) && ((rc < 2)); then
|
|
462
|
+
rc=2
|
|
463
|
+
fi
|
|
464
|
+
done
|
|
465
|
+
if [[ -n $hit ]]; then
|
|
466
|
+
branch_out[$x]="${branch_out[$x]:-}"$'\n'"CHILD_OF|$x|$hit"
|
|
467
|
+
fi
|
|
468
|
+
done
|
|
469
|
+
fi
|
|
470
|
+
for x in "${order[@]}"; do
|
|
471
|
+
if [[ -n ${branch_out[$x]:-} ]]; then
|
|
472
|
+
printf '%s\n' "${branch_out[$x]}"
|
|
473
|
+
fi
|
|
474
|
+
done
|
|
475
|
+
return "$rc"
|
|
476
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# cleanup_worktrees_scan_helper.sh: the bundled real implementation behind the
|
|
3
|
+
# cleanup-worktrees filesystem-scan seam (CLEANUP_WT_SCAN_BIN, resolved by
|
|
4
|
+
# cleanup_wt_scan_bin in cleanup_worktrees_report_records_lib.sh). It performs the
|
|
5
|
+
# read-only filesystem observations the report-record functions need and emits them as
|
|
6
|
+
# one pipe-delimited tuple per candidate directory. It never writes, moves, or deletes
|
|
7
|
+
# anything.
|
|
8
|
+
#
|
|
9
|
+
# Usage:
|
|
10
|
+
# cleanup_worktrees_scan_helper.sh scan-dirs <root-dir> [<root-dir> ...]
|
|
11
|
+
#
|
|
12
|
+
# For each <root-dir> that exists as a directory, and for each immediate subdirectory
|
|
13
|
+
# of that root, one record is written to stdout:
|
|
14
|
+
#
|
|
15
|
+
# <path>|<has_gitfile>|<gitdir_target_exists>|<size>
|
|
16
|
+
#
|
|
17
|
+
# path the subdirectory path as constructed from the given root.
|
|
18
|
+
# has_gitfile 1 when <path>/.git exists as a regular file (the worktree
|
|
19
|
+
# pointer-file shape), else 0.
|
|
20
|
+
# gitdir_target_exists NA when has_gitfile is 0 (there is no pointer to resolve);
|
|
21
|
+
# otherwise 1 when the `gitdir: <target>` line's target exists
|
|
22
|
+
# (resolved relative to <path> when the target is not absolute),
|
|
23
|
+
# else 0. A target is absolute when it begins with `/` or with a
|
|
24
|
+
# drive letter followed by `/` or `\` (for example `C:/repo`).
|
|
25
|
+
# size best-effort `du -sh` size of <path>, or the literal `unknown`
|
|
26
|
+
# when du fails or prints nothing. Size is advisory only, so a
|
|
27
|
+
# du failure never fails the scan.
|
|
28
|
+
#
|
|
29
|
+
# A root that does not exist is skipped silently: an absent worktree-tracking folder is
|
|
30
|
+
# a normal state, not an error. Records are emitted in the shell's glob order per root;
|
|
31
|
+
# callers that need a total order sort the combined output themselves.
|
|
32
|
+
#
|
|
33
|
+
# Pointer-filename test seam: the pointer file's name is read from
|
|
34
|
+
# CLEANUP_WT_SCAN_GITFILE_NAME and defaults to `.git`, which is the only name a real
|
|
35
|
+
# worktree ever uses. The seam exists because git refuses to index any path component
|
|
36
|
+
# named `.git`, so a checked-in fixture tree cannot carry a real `.git` pointer file and
|
|
37
|
+
# the repository's test policy prohibits creating one at test time. The bats fixtures
|
|
38
|
+
# under tests/fixtures/cleanup_worktrees/scan_roots/ therefore name the pointer file
|
|
39
|
+
# `dotgit` and set this variable, exactly as the git-binary seam CLEANUP_WT_GIT_BIN and
|
|
40
|
+
# the SHELL_QC_<TOOL>_BIN seams substitute a checked-in stand-in for a real dependency.
|
|
41
|
+
# An empty value is treated as missing and falls back to `.git`.
|
|
42
|
+
set -euo pipefail
|
|
43
|
+
|
|
44
|
+
scan_helper_dir_size() {
|
|
45
|
+
# Echo the best-effort human-readable size of a directory, or `unknown`.
|
|
46
|
+
#
|
|
47
|
+
# The du/cut pipeline is captured with `|| size=""` so a du failure under
|
|
48
|
+
# `set -euo pipefail` degrades to `unknown` rather than aborting the scan.
|
|
49
|
+
#
|
|
50
|
+
# Args: $1 = directory path. Always returns 0.
|
|
51
|
+
local dir=${1:-}
|
|
52
|
+
local size=""
|
|
53
|
+
size=$(du -sh "$dir" 2>/dev/null | cut -f1) || size=""
|
|
54
|
+
if [[ -z $size ]]; then
|
|
55
|
+
size="unknown"
|
|
56
|
+
fi
|
|
57
|
+
printf '%s\n' "$size"
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
scan_helper_gitfile_name() {
|
|
61
|
+
# Echo the worktree pointer file's name, honoring the CLEANUP_WT_SCAN_GITFILE_NAME
|
|
62
|
+
# test seam. An empty or unset value falls back to the real name `.git`.
|
|
63
|
+
#
|
|
64
|
+
# Args: none. Always returns 0 and echoes exactly one name.
|
|
65
|
+
local override=${CLEANUP_WT_SCAN_GITFILE_NAME:-}
|
|
66
|
+
if [[ -n $override ]]; then
|
|
67
|
+
printf '%s\n' "$override"
|
|
68
|
+
return 0
|
|
69
|
+
fi
|
|
70
|
+
printf '%s\n' ".git"
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
scan_helper_is_absolute_path() {
|
|
74
|
+
# Return 0 when <path> is absolute, else 1. Pure: no filesystem access.
|
|
75
|
+
#
|
|
76
|
+
# Absolute forms are a leading `/` (POSIX paths, MSYS `/c/...` paths, and `//server`
|
|
77
|
+
# UNC paths) and a drive letter followed by `/` or `\` (`C:/...`, `c:/...`,
|
|
78
|
+
# `C:\...`), which is the form Git for Windows writes into a worktree's `.git`
|
|
79
|
+
# pointer file (issue #706). A drive letter with no separator (`C:rel`) is
|
|
80
|
+
# drive-relative and is not absolute; an empty path is not absolute.
|
|
81
|
+
#
|
|
82
|
+
# Args: $1 = path. Returns 0 (absolute) or 1 (not absolute).
|
|
83
|
+
local path=${1:-}
|
|
84
|
+
[[ $path == /* || $path == [A-Za-z]:[/\\]* ]]
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
scan_helper_target_present() {
|
|
88
|
+
# Return 0 when <path> exists, else non-zero. This is the helper's single
|
|
89
|
+
# filesystem existence check, kept in its own function as a test seam: bats tests
|
|
90
|
+
# redefine this function after sourcing the helper so that a drive-letter target
|
|
91
|
+
# can be reported present on a runner that has no such drive (issue #706).
|
|
92
|
+
#
|
|
93
|
+
# Args: $1 = path.
|
|
94
|
+
[[ -e ${1:-} ]]
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
scan_helper_gitdir_target_exists() {
|
|
98
|
+
# Echo 1 when the worktree pointer file in <dir> names an existing gitdir target,
|
|
99
|
+
# else 0. A missing, unreadable, or malformed pointer file yields 0 (the pointer
|
|
100
|
+
# does not resolve), never a crash.
|
|
101
|
+
#
|
|
102
|
+
# Args: $1 = directory path holding the pointer file, $2 = pointer file name.
|
|
103
|
+
# Always returns 0.
|
|
104
|
+
local dir=${1:-}
|
|
105
|
+
local gitfile=${2:-.git}
|
|
106
|
+
local line="" target=""
|
|
107
|
+
line=$(grep -m1 '^gitdir:' "$dir/$gitfile" 2>/dev/null) || line=""
|
|
108
|
+
target=${line#gitdir:}
|
|
109
|
+
# Strip a trailing CR (a pointer file written on Windows) and leading whitespace.
|
|
110
|
+
target=${target%$'\r'}
|
|
111
|
+
target=${target#"${target%%[![:space:]]*}"}
|
|
112
|
+
if [[ -z $target ]]; then
|
|
113
|
+
printf '0\n'
|
|
114
|
+
return 0
|
|
115
|
+
fi
|
|
116
|
+
if ! scan_helper_is_absolute_path "$target"; then
|
|
117
|
+
target="$dir/$target"
|
|
118
|
+
fi
|
|
119
|
+
if scan_helper_target_present "$target"; then
|
|
120
|
+
printf '1\n'
|
|
121
|
+
else
|
|
122
|
+
printf '0\n'
|
|
123
|
+
fi
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
scan_helper_scan_dirs() {
|
|
127
|
+
# Emit one `<path>|<has_gitfile>|<gitdir_target_exists>|<size>` record per immediate
|
|
128
|
+
# subdirectory of each given root. Roots that are not directories are skipped.
|
|
129
|
+
#
|
|
130
|
+
# Args: the root directories. Always returns 0.
|
|
131
|
+
local root entry path has_gitfile target_exists size gitfile
|
|
132
|
+
gitfile=$(scan_helper_gitfile_name)
|
|
133
|
+
for root in "$@"; do
|
|
134
|
+
[[ -d $root ]] || continue
|
|
135
|
+
for entry in "$root"/*/; do
|
|
136
|
+
[[ -d $entry ]] || continue
|
|
137
|
+
path=${entry%/}
|
|
138
|
+
has_gitfile=0
|
|
139
|
+
target_exists="NA"
|
|
140
|
+
if [[ -f "$path/$gitfile" ]]; then
|
|
141
|
+
has_gitfile=1
|
|
142
|
+
target_exists=$(scan_helper_gitdir_target_exists "$path" "$gitfile")
|
|
143
|
+
fi
|
|
144
|
+
size=$(scan_helper_dir_size "$path")
|
|
145
|
+
printf '%s|%s|%s|%s\n' "$path" "$has_gitfile" "$target_exists" "$size"
|
|
146
|
+
done
|
|
147
|
+
done
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
scan_helper_usage() {
|
|
151
|
+
# Print the helper usage text.
|
|
152
|
+
cat <<'EOF'
|
|
153
|
+
Usage: cleanup_worktrees_scan_helper.sh scan-dirs <root-dir> [<root-dir> ...]
|
|
154
|
+
|
|
155
|
+
Emits one read-only record per immediate subdirectory of each root:
|
|
156
|
+
<path>|<has_gitfile:0|1>|<gitdir_target_exists:0|1|NA>|<size-or-unknown>
|
|
157
|
+
EOF
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
scan_helper_main() {
|
|
161
|
+
# Dispatch on the subcommand. Only `scan-dirs` is supported; anything else prints
|
|
162
|
+
# usage to stderr and exits 2 (usage-error parity with cleanup-worktrees.sh).
|
|
163
|
+
local command=${1:-}
|
|
164
|
+
case "$command" in
|
|
165
|
+
scan-dirs)
|
|
166
|
+
shift
|
|
167
|
+
scan_helper_scan_dirs "$@"
|
|
168
|
+
;;
|
|
169
|
+
*)
|
|
170
|
+
scan_helper_usage >&2
|
|
171
|
+
return 2
|
|
172
|
+
;;
|
|
173
|
+
esac
|
|
174
|
+
return 0
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
# Guard so the file can be sourced without executing the scan.
|
|
178
|
+
if [[ ${BASH_SOURCE[0]} == "${0}" ]]; then
|
|
179
|
+
rc=0
|
|
180
|
+
scan_helper_main "$@" || rc=$?
|
|
181
|
+
exit "$rc"
|
|
182
|
+
fi
|
|
@@ -106,7 +106,7 @@ function of the DAG.
|
|
|
106
106
|
`epic-orchestrator` drives a final PR merging `epic/<epic-slug>-integration` into `main`,
|
|
107
107
|
delegating PR authoring to `Agent(pr-author)` and refreshing context via
|
|
108
108
|
`mcp__drm-copilot__collect_pr_context`. `epic-orchestrator` runs the same S9 CI-green
|
|
109
|
-
procedure (`
|
|
109
|
+
procedure (`pwsh -NoProfile -File .claude/lib/ci-gate/Invoke-CiGateParser.ps1`) directly against this PR, records
|
|
110
110
|
the result under the epic checkpoint's `epic_merge_pr` object, then executes
|
|
111
111
|
`gh pr merge --merge` once green, gated by `enforce-epic-merge-gate.ps1`.
|
|
112
112
|
|