@plot-pm/board 0.11.0 → 0.12.0
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/dist/board-server.mjs +124 -124
- package/package.json +5 -1
- package/plot-agent-monitor.sh +37 -13
- package/plot-approve.sh +6 -3
- package/plot-build-monitor.sh +435 -0
- package/plot-config.sh +8 -1
- package/plot-default-branch.sh +109 -0
- package/plot-deliver.sh +4 -2
- package/plot-dispatch.sh +500 -5
- package/plot-fleet-scan.sh +498 -175
- package/plot-host.sh +116 -13
- package/plot-monitor-subject.sh +194 -0
- package/plot-plan-meta.sh +111 -7
- package/plot-pr-merged.sh +180 -0
- package/plot-reap.sh +45 -5
- package/plot-release-refs.sh +188 -46
- package/plot-resolve-artifact.sh +115 -22
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Plot helper: the ONE answer to "did the host merge ANY PR for this branch?"
|
|
3
|
+
#
|
|
4
|
+
# SOURCED, NOT RUN. `. "$script_dir/plot-pr-merged.sh"` defines `pr_merged` and
|
|
5
|
+
# `pr_open`; the file does nothing else on load. That is what makes sourcing it
|
|
6
|
+
# safe, and it is the same shape — and the same reason — as
|
|
7
|
+
# `plot-worker-state.sh`: the logic could not simply stay in `plot-reap.sh`,
|
|
8
|
+
# because that file parses `$@` and `exit 2`s on an unknown argument at load
|
|
9
|
+
# time, so sourcing it would run the reaper's argument parser against its
|
|
10
|
+
# caller's arguments.
|
|
11
|
+
#
|
|
12
|
+
# FOUR CALLERS. `plot-reap.sh`, `plot-release-refs.sh`, `plot-dispatch.sh`, and
|
|
13
|
+
# `plot-quiet-stretch.sh` — the last sourcing it guarded and calling it behind
|
|
14
|
+
# `command -v pr_merged`, so it still works with this file absent.
|
|
15
|
+
#
|
|
16
|
+
# WHY IT WAS EXTRACTED. `plot-reap.sh` and `plot-release-refs.sh` gate on the
|
|
17
|
+
# SAME fact — has this branch's work landed — and they must never disagree
|
|
18
|
+
# about it. The reaper removes a checkout, which is re-creatable with
|
|
19
|
+
# `git worktree add`; ref deletion is not re-creatable at all. A second
|
|
20
|
+
# implementation that drifted toward permissive would therefore fail in the
|
|
21
|
+
# direction that cannot be undone. One function, one answer, no drift.
|
|
22
|
+
#
|
|
23
|
+
# THIS FILE IS NOW THE ADAPTER, AND THE DECISION IS THE DOMAIN'S. The two
|
|
24
|
+
# functions still ask the host, because the host is asked in shell; what they no
|
|
25
|
+
# longer do is decide. Each turns its lookup into one of three READINGS —
|
|
26
|
+
# `found`, `none`, `unaskable` — and hands the pair to
|
|
27
|
+
# `board/plot-landed.mjs`, which bundles `rules/landed.ts`. The layering runs
|
|
28
|
+
# one way: caller → this adapter → the rule.
|
|
29
|
+
#
|
|
30
|
+
# WHAT MOVED IS THE COUPLING. `pr_merged` and `pr_open` fail in the SAME
|
|
31
|
+
# direction and to OPPOSITE effect — an unreachable host makes the first refuse
|
|
32
|
+
# a removal and the second release its veto — so neither is safe alone and the
|
|
33
|
+
# pair is. That was a comment in this file and could not be checked. It is now
|
|
34
|
+
# `mayRemove` in the rule, asserted over all nine combinations of the two
|
|
35
|
+
# readings, and exactly one of them permits a removal.
|
|
36
|
+
#
|
|
37
|
+
# WHY THIS STILL ASKS `gh` AND NOT `plot-host.sh`. Measured 2026-09-06, and it
|
|
38
|
+
# is the answer to a question this file's exemption in
|
|
39
|
+
# `scripts/check-host-cli-callers.sh` was left open for.
|
|
40
|
+
#
|
|
41
|
+
# `plot-host.sh pr-merged` prints `merged`/`not-merged`/`unknown`, three words
|
|
42
|
+
# that read like the three readings below. THEY DO NOT MATCH. An ABSENT CLI
|
|
43
|
+
# arrives from the adapter as `not-merged`, where `_plot_merged_lookup` answers
|
|
44
|
+
# `unaskable`:
|
|
45
|
+
#
|
|
46
|
+
# plot-host.sh pr-merged <branch> → not-merged (exit 0)
|
|
47
|
+
# _plot_merged_lookup <branch> → unaskable
|
|
48
|
+
#
|
|
49
|
+
# The cause is the adapter's `is_lookup_miss`. A missing binary makes the shell
|
|
50
|
+
# say `bash: gh: command not found`, and that matches the same `not found` it
|
|
51
|
+
# uses to recognise a genuine "no pull requests found" — one phrase covering two
|
|
52
|
+
# conditions the rule below exists to keep apart.
|
|
53
|
+
#
|
|
54
|
+
# THE DIRECTION IS WHY IT BLOCKS RATHER THAN ANNOYS. `not-merged` reads as
|
|
55
|
+
# `none` — the host spoke and said nothing merged — so `mayRemove` may permit a
|
|
56
|
+
# removal where `unaskable` refuses. `plot-release-refs.sh` deletes remote refs
|
|
57
|
+
# on this answer and a deleted ref is not re-creatable.
|
|
58
|
+
#
|
|
59
|
+
# AND `pr_open` HAS NO OP TO CALL AT ALL. It needs found/none/unaskable about
|
|
60
|
+
# ANY open PR; `pr-state` answers about ONE — the newest — and reports a failed
|
|
61
|
+
# lookup with the same `state:"NONE"` payload as a real absence. It can express
|
|
62
|
+
# neither "any" nor "unaskable".
|
|
63
|
+
#
|
|
64
|
+
# THE COST WAS MEASURED TOO, and it is the smaller objection. Ten sequential
|
|
65
|
+
# calls on this machine: 4.75 s direct, 9.25 s through the adapter — +450 ms per
|
|
66
|
+
# branch, from the connector's slot and budget accounting rather than from the
|
|
67
|
+
# spawn. Across the 48 branches the fleet scan walks that is ~21 s. Real, but it
|
|
68
|
+
# is the correctness gap above that decides this, not the clock.
|
|
69
|
+
#
|
|
70
|
+
# Both halves are pinned in `test/reconcile/host.test.mjs`, so the exemption
|
|
71
|
+
# rests on tests that fail when it stops being true.
|
|
72
|
+
#
|
|
73
|
+
# `mergedAt` IS READ, NEVER `state`. A merged PR reports state CLOSED, and
|
|
74
|
+
# trusting `state` would refuse every squash-merged branch — which is the whole
|
|
75
|
+
# population these scripts exist for. Squash-merge rewrites the commits, so the
|
|
76
|
+
# branch stays "ahead of main" forever and ancestry alone can never clear it.
|
|
77
|
+
#
|
|
78
|
+
# AND THE QUESTION IS "ANY", NOT "THE NEWEST". This asked with `--limit 1`
|
|
79
|
+
# until 2026-08-27, which returns only the most recent PR — so a newer,
|
|
80
|
+
# unmerged PR sitting in front of the real merge reported `no merged PR` about
|
|
81
|
+
# a branch whose work was on main. Measured that day against the live host:
|
|
82
|
+
#
|
|
83
|
+
# an-unreachable-host-says-so newest #473 null → real merge #446
|
|
84
|
+
# the-scan-sees-a-stale-sprint-tally newest #464 null → real merge #463
|
|
85
|
+
# a-plan-cites-a-jira-key newest #476 null → real merge #447
|
|
86
|
+
#
|
|
87
|
+
# The masking PRs were ones the fleet opened ITSELF on already-merged waves,
|
|
88
|
+
# which closes a loop: a leftover worktree lets auto-dispatch adopt a merged
|
|
89
|
+
# branch, its worker opens a duplicate, the duplicate is newer, the reaper
|
|
90
|
+
# keeps the worktree — the input to step one. Reading only the newest PR is the
|
|
91
|
+
# SAME error as reading `state`, one level out: the newest PR is not the merge,
|
|
92
|
+
# just as the state is not the merge.
|
|
93
|
+
#
|
|
94
|
+
# 100 rather than unbounded: `gh` has no "all" sentinel, and this runs on paths
|
|
95
|
+
# where the estate may hold dozens of branches. A branch carrying more than 100
|
|
96
|
+
# PRs whose only merge is the oldest would still be missed — a far narrower
|
|
97
|
+
# window than "any duplicate at all", and it fails SAFE, toward keeping.
|
|
98
|
+
|
|
99
|
+
# Where the rule lives, resolved from THIS file rather than from the cwd.
|
|
100
|
+
#
|
|
101
|
+
# Both callers run with their cwd wherever the operator invoked them, and the
|
|
102
|
+
# reconcile suite runs them against sandbox repos in the temp directory. The
|
|
103
|
+
# artifact sits beside this script in the plot checkout and in the published
|
|
104
|
+
# npm package alike, which is why both are vendored together.
|
|
105
|
+
_plot_landed_mjs="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/board/plot-landed.mjs"
|
|
106
|
+
|
|
107
|
+
# Ask the rule about one branch's two lookups.
|
|
108
|
+
#
|
|
109
|
+
# $1=merged reading, $2=open reading, both `found`/`none`/`unaskable`.
|
|
110
|
+
# Prints the rule's two words; returns non-zero when it could not be asked at
|
|
111
|
+
# all, which every caller below reads as the refusing direction.
|
|
112
|
+
_plot_landed() {
|
|
113
|
+
[ -f "$_plot_landed_mjs" ] || return 1
|
|
114
|
+
printf '%s\t%s\n' "$1" "$2" | node "$_plot_landed_mjs" 2>/dev/null
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
# What the host says about ANY PR on this branch: found / none / unaskable.
|
|
118
|
+
#
|
|
119
|
+
# A MISSING CLI, AN UNAUTHED ONE AND A NETWORK FAILURE ARE ALL `unaskable`, and
|
|
120
|
+
# they are not `none`. The whole rule turns on that difference — a lookup that
|
|
121
|
+
# ran and matched nothing is the host speaking, while a lookup that did not run
|
|
122
|
+
# is silence, and silence is never permission.
|
|
123
|
+
_plot_merged_lookup() {
|
|
124
|
+
local br="$1" out
|
|
125
|
+
command -v gh >/dev/null 2>&1 || { echo unaskable; return; }
|
|
126
|
+
out=$(gh pr list --head "$br" --state all --limit 100 --json mergedAt 2>/dev/null) \
|
|
127
|
+
|| { echo unaskable; return; }
|
|
128
|
+
case "$out" in *'"mergedAt":"'*) echo found ;; *) echo none ;; esac
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
# What the host says about an OPEN PR on this branch: found / none / unaskable.
|
|
132
|
+
_plot_open_lookup() {
|
|
133
|
+
local br="$1" out
|
|
134
|
+
command -v gh >/dev/null 2>&1 || { echo unaskable; return; }
|
|
135
|
+
out=$(gh pr list --head "$br" --state open --limit 1 --json number 2>/dev/null) \
|
|
136
|
+
|| { echo unaskable; return; }
|
|
137
|
+
case "$out" in *'"number"'*) echo found ;; *) echo none ;; esac
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
# Did the host merge ANY PR for this branch?
|
|
141
|
+
#
|
|
142
|
+
# Returns 0 (merged) / 1 (not merged, or the host cannot be asked). The failure
|
|
143
|
+
# direction is deliberate and load-bearing: an unreachable host, an unauthed
|
|
144
|
+
# `gh`, a missing CLI all answer "not merged", so every caller KEEPS what it
|
|
145
|
+
# was considering removing. Silence is never permission.
|
|
146
|
+
#
|
|
147
|
+
# The rule answers `unknown` on that silence and this function reports it as a
|
|
148
|
+
# refusal, which is the same contract the four callers were written against —
|
|
149
|
+
# `plot-reap.sh:385` says so explicitly. A rule that cannot be reached at all
|
|
150
|
+
# refuses here too, for the same reason.
|
|
151
|
+
pr_merged() {
|
|
152
|
+
local answer
|
|
153
|
+
answer=$(_plot_landed "$(_plot_merged_lookup "$1")" none) || return 1
|
|
154
|
+
case "$answer" in landed*) return 0 ;; *) return 1 ;; esac
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
# Does the branch have an OPEN PR right now?
|
|
158
|
+
#
|
|
159
|
+
# A DIFFERENT question from `pr_merged`, and it exists for a case measured by
|
|
160
|
+
# hand on 2026-08-28. `changeset-release/main` is merged — repeatedly — and
|
|
161
|
+
# Changesets RECREATES and reuses the very same branch for the next release.
|
|
162
|
+
# Its ref carries a live release PR while an older PR of its own has merged, so
|
|
163
|
+
# the merged gate alone says "delete" about a branch somebody is actively
|
|
164
|
+
# using.
|
|
165
|
+
#
|
|
166
|
+
# So an open PR VETOES a deletion even where an older one merged. That is
|
|
167
|
+
# strictly narrower than the merge gate rather than a second opinion on it: it
|
|
168
|
+
# can only ever keep a ref, never release one.
|
|
169
|
+
#
|
|
170
|
+
# Returns 0 when an open PR exists. An unreachable host returns 1 — and note
|
|
171
|
+
# that this is the SAME direction as `pr_merged`'s failure, but it has the
|
|
172
|
+
# OPPOSITE effect, since this answer vetoes. The safety therefore does not come
|
|
173
|
+
# from this function: it comes from `pr_merged` already having refused on the
|
|
174
|
+
# same silence, so a host that cannot be asked deletes nothing regardless.
|
|
175
|
+
# `mayRemove` is where that pair is now asserted rather than described.
|
|
176
|
+
pr_open() {
|
|
177
|
+
local answer
|
|
178
|
+
answer=$(_plot_landed none "$(_plot_open_lookup "$1")") || return 1
|
|
179
|
+
case "$answer" in *" open-pr") return 0 ;; *) return 1 ;; esac
|
|
180
|
+
}
|
package/plot-reap.sh
CHANGED
|
@@ -18,6 +18,15 @@
|
|
|
18
18
|
# whose agent never existed, and 2 dirty desks holding 52 and 1 files that
|
|
19
19
|
# every run refused and nothing ever resolved.
|
|
20
20
|
#
|
|
21
|
+
# AND ONE THING IT ONLY REPORTS: a worktree git lists whose directory is gone.
|
|
22
|
+
# `git worktree list --porcelain` calls it `prunable`, and it is NOT a sixth
|
|
23
|
+
# refusal. A refusal says *do not remove this* and sends an operator to look; a
|
|
24
|
+
# vanished entry says *there is nothing to remove and the entry is stale*, with
|
|
25
|
+
# `git worktree prune` as the repair. It is reported before the refusals are
|
|
26
|
+
# asked, because it is the prior question: four of the five measure something
|
|
27
|
+
# inside a tree that is not there. Measured 2026-09-06 on this estate, 3 of 20
|
|
28
|
+
# worktrees were prunable and this script named none of them.
|
|
29
|
+
#
|
|
21
30
|
# EVERY KIND KEEPS ONE SHAPE: `--dry-run` by default, acting on `--yes`,
|
|
22
31
|
# bounded by `--max N`. The bound is PER KIND, because the kinds are different
|
|
23
32
|
# acts on different populations — a run bounded to five worktrees has not
|
|
@@ -317,12 +326,41 @@ manifest_for() {
|
|
|
317
326
|
return 1
|
|
318
327
|
}
|
|
319
328
|
|
|
320
|
-
reap=0; kept=0; removed=0; cleared=0
|
|
329
|
+
reap=0; kept=0; removed=0; cleared=0; vanished=0
|
|
321
330
|
printf '%-8s %-52s %s\n' "verdict" "branch" "why"
|
|
322
331
|
|
|
323
|
-
while IFS=$'\t' read -r wt br; do
|
|
332
|
+
while IFS=$'\t' read -r wt br prunable; do
|
|
324
333
|
[ -n "$wt" ] || continue
|
|
325
334
|
short=${br#refs/heads/}
|
|
335
|
+
[ "$wt" = "$ROOT" ] && continue
|
|
336
|
+
|
|
337
|
+
# 4a. GIT'S OWN ANSWER THAT THE DIRECTORY IS GONE, and it is a REPORT rather
|
|
338
|
+
# than a sixth refusal. The five below each say *do not remove this* and
|
|
339
|
+
# send an operator to look; this says *there is nothing to remove and the
|
|
340
|
+
# entry is stale*, and names the repair. Blurring the two would tell
|
|
341
|
+
# somebody to go and inspect a directory that is not there.
|
|
342
|
+
#
|
|
343
|
+
# IT IS ASKED FIRST because it is the PRIOR question. Four of the five
|
|
344
|
+
# refusals measure something inside the tree — a pid file, a marker, the
|
|
345
|
+
# porcelain status, the checked-out branch — and every one of them is
|
|
346
|
+
# unanswerable here. `rules/reapable.ts` says the same thing in the
|
|
347
|
+
# domain: a `vanished` tree makes those four conditions `unknown`.
|
|
348
|
+
#
|
|
349
|
+
# AND IT IS ASKED BEFORE THE DISPATCH-TREE FILTER, which would otherwise
|
|
350
|
+
# hide exactly this population. That filter accepts a tree by its
|
|
351
|
+
# `.plot-worker.pid` file or its legacy `plot-wt-` path — and a vanished
|
|
352
|
+
# desk under `Worktree root` has neither, the pid file having gone with
|
|
353
|
+
# the directory. Measured 2026-09-06: 3 of 20 worktrees here were
|
|
354
|
+
# prunable and the reaper reported none of them.
|
|
355
|
+
#
|
|
356
|
+
# NOTHING IS PRUNED ON THIS PATH. The reading came from the listing the
|
|
357
|
+
# loop already makes, no `git` call was added for it, and whether to
|
|
358
|
+
# prune stays the operator's decision — the same discipline that makes
|
|
359
|
+
# every refusal a measurement rather than an act.
|
|
360
|
+
if [ "$prunable" = "yes" ]; then
|
|
361
|
+
printf '%-8s %-52s %s\n' "vanished" "$short" "directory gone — 'git worktree prune' clears the entry"
|
|
362
|
+
vanished=$((vanished+1)); continue
|
|
363
|
+
fi
|
|
326
364
|
|
|
327
365
|
# 5. Only dispatch trees. A hand-made worktree and the main checkout are not
|
|
328
366
|
# this script's to remove, whatever state they are in.
|
|
@@ -346,7 +384,6 @@ while IFS=$'\t' read -r wt br; do
|
|
|
346
384
|
if [ ! -f "$wt/.plot-worker.pid" ]; then
|
|
347
385
|
case "$wt" in *"/plot-wt-"*) ;; *) continue ;; esac
|
|
348
386
|
fi
|
|
349
|
-
[ "$wt" = "$ROOT" ] && continue
|
|
350
387
|
|
|
351
388
|
# THE READINGS. Everything from here to the rule call MEASURES; nothing
|
|
352
389
|
# decides. Each of the four sources that can answer is read once, into a
|
|
@@ -514,7 +551,10 @@ NODE_EOF
|
|
|
514
551
|
fi
|
|
515
552
|
fi
|
|
516
553
|
done < <(git worktree list --porcelain \
|
|
517
|
-
| awk '/^worktree /{
|
|
554
|
+
| awk '/^worktree /{ if (br != "") print p"\t"br"\t"pr; p=$2; br=""; pr="no"; next }
|
|
555
|
+
/^branch / { br=$2; next }
|
|
556
|
+
/^prunable/ { pr="yes"; next }
|
|
557
|
+
END { if (br != "") print p"\t"br"\t"pr }')
|
|
518
558
|
|
|
519
559
|
[ "$DRY" -eq 0 ] && git worktree prune 2>/dev/null
|
|
520
560
|
|
|
@@ -900,5 +940,5 @@ fi
|
|
|
900
940
|
# which is why `plot-release-refs.sh` deletes those, plan-scoped, under its own
|
|
901
941
|
# licence and its own five guards. The asymmetry between the kinds is the whole
|
|
902
942
|
# safety argument and it stays.
|
|
903
|
-
echo "summary: reapable=$reap removed=$removed kept=$kept cleared=$cleared branches=$swept_branches branches_deleted=$deleted_branches branches_kept=$kept_branches claims=$swept_claims claims_deleted=$deleted_claims claims_kept=$kept_claims dirty_trees=$dirty_trees dry_run=$DRY"
|
|
943
|
+
echo "summary: reapable=$reap removed=$removed kept=$kept vanished=$vanished cleared=$cleared branches=$swept_branches branches_deleted=$deleted_branches branches_kept=$kept_branches claims=$swept_claims claims_deleted=$deleted_claims claims_kept=$kept_claims dirty_trees=$dirty_trees dry_run=$DRY"
|
|
904
944
|
exit 0
|
package/plot-release-refs.sh
CHANGED
|
@@ -44,6 +44,35 @@
|
|
|
44
44
|
# 4. a branch checked out in ANY worktree (somebody is reading it)
|
|
45
45
|
# 5. the default branch itself (never ours to delete)
|
|
46
46
|
#
|
|
47
|
+
# THE SCRIPT DOES NOT OWN THE FIVE. They are conditions in
|
|
48
|
+
# `packages/domain/src/rules/reapable.ts`'s `finishedWith`, which states every
|
|
49
|
+
# condition that can hold a desk and judges none of them. This script and
|
|
50
|
+
# `plot-reap.sh` were asking about the same desk in two places, and they had
|
|
51
|
+
# already drifted: this one never asked whether a worker was alive, and the
|
|
52
|
+
# reaper never asked `pr_open`. Each was blind to a condition the other
|
|
53
|
+
# measured, and neither omission was argued for anywhere.
|
|
54
|
+
#
|
|
55
|
+
# WHAT CHANGED IS WHERE THE CONDITIONS ARE STATED, NOT WHICH ONES THIS SCRIPT
|
|
56
|
+
# ASKS. The rule also answers `liveWorker`, `uncommittedChanges` and
|
|
57
|
+
# `blockedMarker` — the reaper's three — and this script reads none of them.
|
|
58
|
+
# Folding them in *"would silently widen a licence that was written narrow on
|
|
59
|
+
# purpose"*, which is what line 30 above has warned since this script existed.
|
|
60
|
+
# The rule makes the difference VISIBLE; making it disappear is a different
|
|
61
|
+
# change, and it is not this one.
|
|
62
|
+
#
|
|
63
|
+
# `unknown` PERMITS HERE, AND THAT IS THE CALLER'S HALF. Four of the rule's
|
|
64
|
+
# conditions need a worktree and 69% of branches have none (22 of 32, measured
|
|
65
|
+
# 2026-09-06), so the rule answers `unknown` rather than inventing `false`. The
|
|
66
|
+
# reaper reads `unknown` as *nothing to reap*; this reads it as *no evidence
|
|
67
|
+
# against deletion*, which is exactly what it did before the rule existed.
|
|
68
|
+
# Refusing on silence is the estate's rule for an unreachable HOST — applied to
|
|
69
|
+
# a missing tree it would keep every ref on two branches in three and make this
|
|
70
|
+
# script useless where the scan cost is highest.
|
|
71
|
+
#
|
|
72
|
+
# The SCOPE is still this script's and is not shared. The rule answers about
|
|
73
|
+
# one branch and enumerates nothing; which branches to ask about stays bounded
|
|
74
|
+
# by the plan file, for the reason the paragraph above gives.
|
|
75
|
+
#
|
|
47
76
|
# THE RULE THIS MUST NOT BREAK. `/plot-implement` says plainly: *"leave the ref
|
|
48
77
|
# in place — never delete a remote ref another session may be reading."* Read in
|
|
49
78
|
# context that rule governs GIVING A BRANCH UP — work that turned out
|
|
@@ -65,7 +94,7 @@ while [ $# -gt 0 ]; do
|
|
|
65
94
|
--yes) DRY=0 ;;
|
|
66
95
|
--dry-run) DRY=1 ;;
|
|
67
96
|
--max) MAX="${2:-0}"; shift ;;
|
|
68
|
-
-h|--help) sed -n '2,
|
|
97
|
+
-h|--help) sed -n '2,86p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
|
69
98
|
-*) echo "plot-release-refs: unknown argument: $1" >&2; exit 2 ;;
|
|
70
99
|
*) slug="$1" ;;
|
|
71
100
|
esac
|
|
@@ -115,7 +144,17 @@ prefix_re=$(bash "$script_dir/plot-config.sh" get "Branch prefixes" "idea/, feat
|
|
|
115
144
|
meta=$(bash "$script_dir/plot-plan-meta.sh" --prefixes "$prefix_re" "$plan_file" 2>/dev/null) || meta=""
|
|
116
145
|
[ -n "$meta" ] || die "cannot parse '$plan_file' — refusing rather than guessing"
|
|
117
146
|
|
|
118
|
-
# The
|
|
147
|
+
# The shared rule, resolved from THIS SCRIPT's location rather than the cwd, and
|
|
148
|
+
# as a `file://` URL because `import()` needs one for an absolute path. Missing
|
|
149
|
+
# or unreadable, the decision below reports "could not be asked" and keeps every
|
|
150
|
+
# ref — the same fail-safe `plot-reap.sh` applies to the same module.
|
|
151
|
+
#
|
|
152
|
+
# THIS SCRIPT NOW NEEDS NODE. The alternative is a second implementation of the
|
|
153
|
+
# guards living in shell where nothing can test it, which is what this branch
|
|
154
|
+
# exists to end: the two copies had already drifted apart by three readings.
|
|
155
|
+
RULE_PATH="file://$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." 2>/dev/null && pwd)/packages/domain/src/rules/reapable.ts"
|
|
156
|
+
|
|
157
|
+
# The default branch, via the host adapter when it can answer. The rule compares
|
|
119
158
|
# against it, and a wrong answer here can only ever protect MORE.
|
|
120
159
|
HOST="$script_dir/plot-host.sh"
|
|
121
160
|
DEFAULT=main
|
|
@@ -123,18 +162,31 @@ if [ -x "$HOST" ]; then
|
|
|
123
162
|
d=$("$HOST" default-branch 2>/dev/null) && [ -n "$d" ] && DEFAULT="$d"
|
|
124
163
|
fi
|
|
125
164
|
|
|
126
|
-
# Every branch currently checked out ANYWHERE,
|
|
165
|
+
# Every branch currently checked out ANYWHERE, WITH THE TREE THAT HOLDS IT.
|
|
127
166
|
#
|
|
128
167
|
# Collected once, before the loop, rather than asked per branch: `git worktree
|
|
129
168
|
# list` walks the whole estate and this script runs on the delivery path where
|
|
130
169
|
# that estate may hold dozens of trees. The answer cannot change underneath a
|
|
131
170
|
# single run in a way that matters — a worktree created mid-run holds a branch
|
|
132
171
|
# whose ref this run has not yet reached, and the next run sees it.
|
|
172
|
+
#
|
|
173
|
+
# THE TREE'S STATE IS COLLECTED TOO, and that is what the shared rule needed.
|
|
174
|
+
# `finishedWith` answers four conditions from a worktree, and it answers them
|
|
175
|
+
# `unknown` where there is none — so it has to be told whether one was found.
|
|
176
|
+
# That is a reading, not a refusal: this script asks none of those four, and
|
|
177
|
+
# passing the tree is what lets the rule say `unknown` instead of inventing
|
|
178
|
+
# `false`. Git's own `prunable` distinguishes a listed tree whose directory is
|
|
179
|
+
# gone from one that was never made; both are unaskable, and the rule keeps the
|
|
180
|
+
# two words apart because an operator acts on them differently.
|
|
133
181
|
checked_out=$(git worktree list --porcelain 2>/dev/null \
|
|
134
|
-
|
|
|
182
|
+
| awk '/^worktree /{wt=substr($0,10); pr="no"}
|
|
183
|
+
/^prunable/{pr="yes"}
|
|
184
|
+
/^branch refs\/heads\//{print substr($0,19) "\t" wt "\t" pr}')
|
|
135
185
|
|
|
136
|
-
|
|
137
|
-
|
|
186
|
+
# The worktree holding a branch and whether git calls it prunable, or empty
|
|
187
|
+
# when no tree holds it.
|
|
188
|
+
worktree_of() {
|
|
189
|
+
printf '%s\n' "$checked_out" | awk -F'\t' -v b="$1" '$1 == b {print $2 "\t" $3; exit}'
|
|
138
190
|
}
|
|
139
191
|
|
|
140
192
|
released=0; kept=0; deleted=0
|
|
@@ -149,51 +201,141 @@ printf '%-8s %-52s %s\n' "verdict" "branch" "why"
|
|
|
149
201
|
while IFS=$'\t' read -r br deferred; do
|
|
150
202
|
[ -n "$br" ] || continue
|
|
151
203
|
|
|
152
|
-
#
|
|
153
|
-
#
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
204
|
+
# THE READINGS, each taken once and none judged here. The script holds no
|
|
205
|
+
# `if` about whether a ref may go — only about which conditions it asks and
|
|
206
|
+
# what to do with the answers.
|
|
207
|
+
#
|
|
208
|
+
# `pr_merged` reads `mergedAt` on ANY PR (never `state`, never ancestry) and
|
|
209
|
+
# answers false when the host cannot be asked, so silence keeps the ref.
|
|
210
|
+
# `pr_open` is asked separately rather than derived from it: a branch carries
|
|
211
|
+
# both, and `changeset-release/main` is the measured case.
|
|
212
|
+
merge=not-merged
|
|
213
|
+
pr_merged "$br" && merge=merged
|
|
158
214
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
# worker, and it needs the REF to be there to read it against. Checked
|
|
162
|
-
# before the host is even asked: this is a decision a person already
|
|
163
|
-
# recorded, and no merge state overturns it.
|
|
164
|
-
if [ "$deferred" = "true" ]; then
|
|
165
|
-
printf '%-8s %-52s %s\n' "keep" "$br" "deferred — a given-up branch keeps its ref"
|
|
166
|
-
kept=$((kept+1)); continue
|
|
167
|
-
fi
|
|
215
|
+
open_pr=false
|
|
216
|
+
pr_open "$br" && open_pr=true
|
|
168
217
|
|
|
169
|
-
#
|
|
170
|
-
#
|
|
171
|
-
#
|
|
172
|
-
#
|
|
173
|
-
#
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
218
|
+
# WHETHER A TREE HOLDS THE BRANCH, AND IN WHAT STATE. Three words, because
|
|
219
|
+
# the rule answers its four tree-sourced conditions `unknown` without one and
|
|
220
|
+
# `unknown` is a reading rather than a failure. `vanished` is git's own
|
|
221
|
+
# `prunable`: it reads like `absent` to every condition — there is equally
|
|
222
|
+
# nothing to measure — and stays a separate word because `git worktree prune`
|
|
223
|
+
# is the repair for one and not the other.
|
|
224
|
+
#
|
|
225
|
+
# NOTHING INSIDE THE TREE IS READ. A live pid, an uncommitted file and a
|
|
226
|
+
# `PLOT-BLOCKED` marker are the reaper's three conditions and this script
|
|
227
|
+
# asks none of them; measuring them here would put a refusal within one edit's
|
|
228
|
+
# reach of a licence written narrow on purpose. A checked-out branch keeps its
|
|
229
|
+
# ref whatever is going on inside it, which is guard 4 and needs no reading
|
|
230
|
+
# from the tree at all.
|
|
231
|
+
wt_line=$(worktree_of "$br")
|
|
232
|
+
wt=${wt_line%%$'\t'*}
|
|
233
|
+
prunable=${wt_line#*$'\t'}
|
|
234
|
+
tree=absent
|
|
235
|
+
if [ -n "$wt" ]; then
|
|
236
|
+
if [ "$prunable" = "yes" ] || [ ! -d "$wt" ]; then tree=vanished; else tree=present; fi
|
|
177
237
|
fi
|
|
178
238
|
|
|
179
|
-
#
|
|
180
|
-
#
|
|
181
|
-
#
|
|
182
|
-
#
|
|
183
|
-
#
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
239
|
+
# THE DECISION. `packages/domain/src/rules/reapable.ts`, imported directly —
|
|
240
|
+
# the same shape and the same reason as `plot-reap.sh`: node 24 strips the
|
|
241
|
+
# types, so there is no build step between this script and the rule, and the
|
|
242
|
+
# JS arrives on STDIN from a QUOTED heredoc so the shell expands none of it.
|
|
243
|
+
#
|
|
244
|
+
# ONE RULE, TWO CALLERS, AND THE CALLERS STAY DIFFERENT. `finishedWith`
|
|
245
|
+
# STATES every condition that can hold a desk and JUDGES none of them; which
|
|
246
|
+
# conditions refuse a ref is this caller's half, and it names exactly the five
|
|
247
|
+
# this script has always asked. The reaper reads the same rule and names its
|
|
248
|
+
# own. Neither script gains the other's, which is what makes the difference
|
|
249
|
+
# visible instead of eliminating it.
|
|
250
|
+
#
|
|
251
|
+
# `unknown` PERMITS, AND THE ORDER OF THE TESTS IS THE ARGUMENT. Only the
|
|
252
|
+
# first two conditions are answerable without a tree, and 69% of branches have
|
|
253
|
+
# none. `=== "true"` is therefore the test at every guard: `unknown` falls
|
|
254
|
+
# through, exactly as this script behaved before the rule existed. That is
|
|
255
|
+
# deliberate and it is the caller's decision to make — the reaper reads the
|
|
256
|
+
# same `unknown` as *nothing to reap*.
|
|
257
|
+
#
|
|
258
|
+
# A rule that cannot be asked REFUSES: node missing, the import failing, the
|
|
259
|
+
# module throwing all leave `verdict` empty, and an empty verdict keeps the
|
|
260
|
+
# ref and says why. Silence is never permission on this path either — that is
|
|
261
|
+
# the module being absent, which is not the same reading as a condition
|
|
262
|
+
# answering `unknown`.
|
|
263
|
+
verdict=$(PLOT_BRANCH="$br" PLOT_DEFAULT="$DEFAULT" PLOT_MERGE="$merge" \
|
|
264
|
+
PLOT_GIVEN_UP="$deferred" PLOT_OPEN_PR="$open_pr" \
|
|
265
|
+
PLOT_TREE="$tree" \
|
|
266
|
+
PLOT_CHECKED_OUT="$([ -n "$wt" ] && echo true || echo false)" \
|
|
267
|
+
PLOT_RULE="$RULE_PATH" \
|
|
268
|
+
node --input-type=module - <<'NODE_EOF' 2>/dev/null
|
|
269
|
+
// An ABSOLUTE path derived from this script, never from the cwd: this runs
|
|
270
|
+
// wherever the operator invoked it, and the reconcile suite runs it against
|
|
271
|
+
// sandbox repos in the temp directory.
|
|
272
|
+
//
|
|
273
|
+
// NO APOSTROPHE MAY APPEAR ANYWHERE IN THIS BLOCK. bash 3.2 is /bin/bash on
|
|
274
|
+
// macOS, and it is what the reconcile suite runs this script under when it
|
|
275
|
+
// strips PATH. It parses the body of a quoted heredoc nested inside `$(...)`,
|
|
276
|
+
// so one contraction opens a string that never closes and the whole file fails
|
|
277
|
+
// to parse. The error reads `unexpected EOF` and names a line 30 further down,
|
|
278
|
+
// which points nowhere near the apostrophe.
|
|
279
|
+
const { finishedWith } = await import(process.env.PLOT_RULE);
|
|
280
|
+
|
|
281
|
+
const held = finishedWith({
|
|
282
|
+
branch: process.env.PLOT_BRANCH,
|
|
283
|
+
defaultBranch: process.env.PLOT_DEFAULT,
|
|
284
|
+
// This script never looks at the main checkout as a tree; the branch test
|
|
285
|
+
// the rule makes is what catches the default branch.
|
|
286
|
+
isMain: false,
|
|
287
|
+
// The three the reaper measures and this script does not. They are passed
|
|
288
|
+
// empty because the rule shape asks for them, and the tree reading below is
|
|
289
|
+
// what makes them honest: with no tree they answer `unknown`, and this
|
|
290
|
+
// caller reads none of the three either way.
|
|
291
|
+
workerPid: null,
|
|
292
|
+
dirtyPath: "",
|
|
293
|
+
blockedMarker: false,
|
|
294
|
+
merge: process.env.PLOT_MERGE,
|
|
295
|
+
givenUp: process.env.PLOT_GIVEN_UP === "true",
|
|
296
|
+
openPr: process.env.PLOT_OPEN_PR === "true",
|
|
297
|
+
checkedOut: process.env.PLOT_CHECKED_OUT === "true",
|
|
298
|
+
tree: process.env.PLOT_TREE,
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
// THE FIVE GUARDS, in the order they have always been tested, and each
|
|
302
|
+
// satisfied only by a condition answering `true`. `unknown` permits: it means
|
|
303
|
+
// no evidence against deletion, which on this estate is the majority reading
|
|
304
|
+
// and the one the script already acted on. `liveWorker`, `uncommittedChanges`
|
|
305
|
+
// and `blockedMarker` are in `held` and are deliberately not consulted.
|
|
306
|
+
const guards = [
|
|
307
|
+
["given-up", held.givenUp, ""],
|
|
308
|
+
["no-merged-pr", held.noMergedPr, ""],
|
|
309
|
+
["open-pr", held.openPr, ""],
|
|
310
|
+
["checked-out", held.checkedOut, ""],
|
|
311
|
+
["on-default-branch", held.onDefaultBranch, process.env.PLOT_DEFAULT],
|
|
312
|
+
];
|
|
313
|
+
|
|
314
|
+
const refusal = guards.find(([, reading]) => reading === "true");
|
|
315
|
+
|
|
316
|
+
process.stdout.write(refusal === undefined ? "delete\t" : `${refusal[0]}\t${refusal[2]}`);
|
|
317
|
+
NODE_EOF
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
refusal=${verdict%%$'\t'*}
|
|
321
|
+
detail=${verdict#*$'\t'}
|
|
188
322
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
323
|
+
if [ "$refusal" != "delete" ]; then
|
|
324
|
+
# The rule named the refusal; this renders it. A verdict the rule could not
|
|
325
|
+
# produce is empty, and an empty refusal keeps the ref and says so.
|
|
326
|
+
case "$refusal" in
|
|
327
|
+
given-up) why="deferred — a given-up branch keeps its ref" ;;
|
|
328
|
+
no-merged-pr) why="unlanded work — no merged PR" ;;
|
|
329
|
+
open-pr) why="an open PR is using this branch" ;;
|
|
330
|
+
checked-out) why="checked out in a worktree — somebody is reading it" ;;
|
|
331
|
+
on-default-branch) why="the default branch — never deleted" ;;
|
|
332
|
+
# No arm for `live-worker`, `uncommitted-changes` or `blocked-marker`.
|
|
333
|
+
# The rule answers all three and this script consults none, so none can
|
|
334
|
+
# reach here; an arm for one would be the first line of a licence this
|
|
335
|
+
# script does not hold.
|
|
336
|
+
*) why="the rule could not be asked — keeping the ref" ;;
|
|
337
|
+
esac
|
|
338
|
+
printf '%-8s %-52s %s\n' "keep" "$br" "$why"
|
|
197
339
|
kept=$((kept+1)); continue
|
|
198
340
|
fi
|
|
199
341
|
|