@erclx/canon 5.49.0 → 5.51.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.
Files changed (86) hide show
  1. package/CHANGELOG.md +4024 -0
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/auto-ship/references/fan-in.md +20 -6
  4. package/claude/skills/canon-cli/SKILL.md +9 -7
  5. package/claude/skills/canvas-board/REQUIREMENT.md +4 -3
  6. package/claude/skills/canvas-board/SKILL.md +8 -9
  7. package/claude/skills/ci-workflow/REQUIREMENT.md +3 -3
  8. package/claude/skills/ci-workflow/SKILL.md +26 -1
  9. package/claude/skills/ci-workflow/references/workflows.md +28 -0
  10. package/claude/skills/context-fold/references/context.md +4 -3
  11. package/claude/skills/design-record/REQUIREMENT.md +33 -0
  12. package/claude/skills/design-record/SKILL.md +84 -0
  13. package/claude/skills/design-record/references/folder.md +69 -0
  14. package/claude/skills/design-taste/SKILL.md +3 -2
  15. package/claude/skills/git-followup/SKILL.md +1 -0
  16. package/claude/skills/git-pr/references/pr.md +1 -1
  17. package/claude/skills/markdown-craft/REQUIREMENT.md +1 -0
  18. package/claude/skills/markdown-craft/SKILL.md +6 -1
  19. package/claude/skills/markdown-craft/references/markdown.md +3 -3
  20. package/claude/skills/markdown-craft/references/tables.md +30 -0
  21. package/claude/skills/pick-by-eye/SKILL.md +10 -8
  22. package/claude/skills/plan-feature/SKILL.md +13 -1
  23. package/claude/skills/plan-feature/references/batches.md +1 -1
  24. package/claude/skills/session-worktree/SKILL.md +1 -1
  25. package/claude/skills/target-check/references/domains.md +8 -6
  26. package/docs/agents/index.md +2 -2
  27. package/docs/agents/markdown-audit.md +5 -2
  28. package/docs/agents/migrate.md +2 -0
  29. package/docs/agents/targets.md +8 -1
  30. package/docs/target/migrations.md +8 -1
  31. package/docs/target/projects.md +1 -1
  32. package/docs/workflow/parallel-features.md +1 -1
  33. package/docs/workflow/skill-map.md +2 -1
  34. package/docs/workflow/visual-design-workflow.md +2 -1
  35. package/governance/detect.toml +1 -1
  36. package/governance/ledger/tooling/ci-workflow.md +6 -0
  37. package/governance/ledger/ui/design-reference.md +8 -12
  38. package/governance/rules/tooling/ci-workflow.md +4 -0
  39. package/governance/rules/ui/design-reference.md +4 -6
  40. package/package.json +2 -1
  41. package/scripts/ci/check-browsers.sh +13 -0
  42. package/scripts/ci/delete-previews.sh +34 -0
  43. package/scripts/ci/publish-check.sh +29 -0
  44. package/scripts/ci/refresh-frames-pr.sh +63 -0
  45. package/scripts/ci/report-baseline.sh +23 -0
  46. package/scripts/ci/stack-issues.sh +24 -0
  47. package/scripts/ci/verify-npm-token.sh +14 -0
  48. package/src/claude/cases/misc.ts +5 -0
  49. package/src/cli/help.ts +1 -0
  50. package/src/cli.ts +2 -0
  51. package/src/commands/changelog.ts +146 -0
  52. package/src/commands/claude.ts +26 -1
  53. package/src/commands/gate.ts +57 -16
  54. package/src/commands/hooks.ts +7 -5
  55. package/src/commands/markdown.ts +98 -19
  56. package/src/commands/migrate.ts +77 -0
  57. package/src/commands/worktrees.ts +273 -0
  58. package/src/context/audit.ts +6 -5
  59. package/src/design/budget.ts +1 -1
  60. package/src/design/components.ts +1 -1
  61. package/src/design/document.ts +1 -1
  62. package/src/design/regen.ts +1 -1
  63. package/src/design/render.ts +1 -1
  64. package/src/gate/hand-suite.ts +52 -0
  65. package/src/gate/measures.ts +9 -2
  66. package/src/gate/sequencer.ts +17 -8
  67. package/src/gov/detect.ts +28 -10
  68. package/src/hooks/post-merge.ts +45 -6
  69. package/src/markdown/gated.ts +5 -7
  70. package/src/markdown/scope.ts +60 -10
  71. package/src/markdown/structure.ts +1 -1
  72. package/src/markdown/tables.ts +150 -0
  73. package/src/migrate/status.ts +128 -0
  74. package/src/tasks/validate.ts +15 -12
  75. package/src/version/changelog.ts +156 -0
  76. package/src/worktrees/members.ts +540 -0
  77. package/tooling/base/configs/.husky/post-merge +7 -6
  78. package/tooling/claude/reference.md +1 -0
  79. package/tooling/claude/seeds/.claude/hooks/standards-audit.sh +11 -1
  80. package/tooling/claude/seeds/CLAUDE.md +9 -0
  81. package/tooling/cloudflare/configs/.github/workflows/deploy.yml +10 -28
  82. package/tooling/cloudflare/configs/scripts/delete-previews.sh +36 -0
  83. package/tooling/cloudflare/reference.md +7 -4
  84. package/tooling/web/reference.md +5 -0
  85. package/tooling/web/seeds/canon/design/index.md +23 -0
  86. /package/claude/skills/{canvas-board → design-record}/references/design.md +0 -0
@@ -0,0 +1,156 @@
1
+ import { compareVersions, parseVersion } from '@/version/compare'
2
+
3
+ export interface ChangelogEntry {
4
+ readonly version: string
5
+ /** The entry's heading line through its last line, trimmed. */
6
+ readonly markdown: string
7
+ }
8
+
9
+ export type ChangelogRange =
10
+ | { readonly kind: 'range'; readonly entries: readonly ChangelogEntry[] }
11
+ | { readonly kind: 'unknown-version'; readonly version: string }
12
+
13
+ /**
14
+ * `release-please` writes each release as `## [x.y.z](compare-url) (date)`, so
15
+ * this is the one form the split reads. A heading in any other form is body
16
+ * text of the entry above it, and a change to that form in the release tool
17
+ * leaves the file reading as no entries at all.
18
+ */
19
+ const HEADING = /^## \[([^\]]+)\]/
20
+
21
+ /** Entries in file order, which `release-please` keeps newest first. */
22
+ export function parseChangelog(text: string): ChangelogEntry[] {
23
+ const entries: ChangelogEntry[] = []
24
+ let current: { version: string; lines: string[] } | undefined
25
+
26
+ const close = () => {
27
+ if (current === undefined) return
28
+ entries.push({
29
+ version: current.version,
30
+ markdown: current.lines.join('\n').trim(),
31
+ })
32
+ }
33
+
34
+ for (const line of text.split('\n')) {
35
+ const match = HEADING.exec(line)
36
+ if (match !== null) {
37
+ close()
38
+ current = { version: match[1] ?? '', lines: [line] }
39
+ } else {
40
+ current?.lines.push(line)
41
+ }
42
+ }
43
+ close()
44
+
45
+ return entries
46
+ }
47
+
48
+ /**
49
+ * The releases after `since` up to and including `running`, oldest first so a
50
+ * reader meets them in the order they shipped.
51
+ *
52
+ * A version the file never listed, or one that does not parse, or a `since`
53
+ * newer than `running`, names no range. Each reads as `unknown-version` rather
54
+ * than an empty success, since an empty range reads as a target with nothing
55
+ * to catch up on. `since` equal to `running` is the one empty range, because
56
+ * that target is current.
57
+ */
58
+ export function entriesSince(
59
+ entries: readonly ChangelogEntry[],
60
+ since: string,
61
+ running: string,
62
+ ): ChangelogRange {
63
+ const sinceParsed = parseVersion(since)
64
+ const runningParsed = parseVersion(running)
65
+ const listed = new Set(entries.map((entry) => entry.version))
66
+
67
+ if (sinceParsed === undefined || !listed.has(since)) {
68
+ return { kind: 'unknown-version', version: since }
69
+ }
70
+ if (runningParsed === undefined || !listed.has(running)) {
71
+ return { kind: 'unknown-version', version: running }
72
+ }
73
+ if (compareVersions(sinceParsed, runningParsed) > 0) {
74
+ return { kind: 'unknown-version', version: since }
75
+ }
76
+
77
+ const inRange = entries.filter((entry) => {
78
+ const parsed = parseVersion(entry.version)
79
+ return (
80
+ parsed !== undefined &&
81
+ compareVersions(parsed, sinceParsed) > 0 &&
82
+ compareVersions(parsed, runningParsed) <= 0
83
+ )
84
+ })
85
+
86
+ return { kind: 'range', entries: inRange.toReversed() }
87
+ }
88
+
89
+ export interface ChangelogInput {
90
+ /** The file's text, or undefined when it could not be read. */
91
+ readonly text: string | undefined
92
+ readonly running: string
93
+ /** An explicit start, which wins over the stamp. */
94
+ readonly since?: string
95
+ /** The version each stamped domain recorded. */
96
+ readonly stamped: readonly string[]
97
+ }
98
+
99
+ export type ChangelogReport =
100
+ | {
101
+ readonly kind: 'range'
102
+ readonly since: string
103
+ readonly running: string
104
+ readonly entries: readonly ChangelogEntry[]
105
+ }
106
+ | {
107
+ readonly kind: 'refused'
108
+ readonly reason: 'no-stamp' | 'no-changelog' | 'unknown-version'
109
+ readonly version?: string
110
+ }
111
+
112
+ /**
113
+ * Every input arrives as a value, so the refusals are decided here rather than
114
+ * where the file and the stamp are read. A file with no release heading refuses
115
+ * as `no-changelog`, since the entries it would range over are not there.
116
+ */
117
+ export function resolveChangelog(input: ChangelogInput): ChangelogReport {
118
+ const since = input.since ?? oldestVersion(input.stamped)
119
+ if (since === undefined) return { kind: 'refused', reason: 'no-stamp' }
120
+
121
+ const entries = input.text === undefined ? [] : parseChangelog(input.text)
122
+ if (entries.length === 0) return { kind: 'refused', reason: 'no-changelog' }
123
+
124
+ const range = entriesSince(entries, since, input.running)
125
+ if (range.kind === 'unknown-version') {
126
+ return {
127
+ kind: 'refused',
128
+ reason: 'unknown-version',
129
+ version: range.version,
130
+ }
131
+ }
132
+
133
+ return {
134
+ kind: 'range',
135
+ since,
136
+ running: input.running,
137
+ entries: range.entries,
138
+ }
139
+ }
140
+
141
+ /** The oldest version that parses, or undefined when none does. */
142
+ export function oldestVersion(versions: readonly string[]): string | undefined {
143
+ let oldest:
144
+ | { raw: string; parsed: NonNullable<ReturnType<typeof parseVersion>> }
145
+ | undefined
146
+
147
+ for (const raw of versions) {
148
+ const parsed = parseVersion(raw)
149
+ if (parsed === undefined) continue
150
+ if (oldest === undefined || compareVersions(parsed, oldest.parsed) < 0) {
151
+ oldest = { raw, parsed }
152
+ }
153
+ }
154
+
155
+ return oldest?.raw
156
+ }
@@ -0,0 +1,540 @@
1
+ import { execa } from 'execa'
2
+ import { gitEnv } from '@/git/env'
3
+ import {
4
+ type ReclaimOptions,
5
+ reclaimReport,
6
+ type Refusal,
7
+ type Unreadable,
8
+ type WorktreeVerdict,
9
+ } from '@/worktrees/reclaim'
10
+ import { removeReclaimable } from '@/worktrees/remove'
11
+
12
+ const GH_TIMEOUT_MS = 30_000
13
+ const GIT_TIMEOUT_MS = 60_000
14
+
15
+ /** Matches the reclaim's merged read, so an open pull request older than this is missed the same way. */
16
+ const OPEN_LIMIT = 200
17
+
18
+ /**
19
+ * The fixed lead the integrator's body carries, stated in the auto-ship
20
+ * fan-in reference. Anything outside this one line is never read, so a branch
21
+ * named in prose is never a candidate.
22
+ */
23
+ const MEMBER_LINE = /^[ \t]*(?:[-*][ \t]+)?Fan-in members:(.*)$/m
24
+ const BACKTICKED = /`([^`\s]+)`/g
25
+
26
+ /**
27
+ * Why the whole run was refused before any member was judged. Every one of
28
+ * these deletes nothing at all, since each is an input the per-branch checks
29
+ * stand on or a body that names something no merge retires.
30
+ */
31
+ export type MembersRefusal =
32
+ | Extract<
33
+ Unreadable,
34
+ 'gh-missing' | 'gh-failed' | 'sessions-unreadable' | 'sessions-unchecked'
35
+ >
36
+ | 'not-merged'
37
+ | 'no-members'
38
+ | 'trunk-named'
39
+ | 'head-named'
40
+ | 'open-pull-request'
41
+ | 'head-unreadable'
42
+
43
+ /**
44
+ * Why one half of one member was kept. The worktree reasons are the reclaim's
45
+ * own, since the same reading decided them.
46
+ */
47
+ export type MemberRefusal =
48
+ | 'not-in-merged-head'
49
+ | 'remote-unreadable'
50
+ | 'worktree-kept'
51
+ | Exclude<Refusal, 'no-merged-pull-request'>
52
+
53
+ export type HalfState =
54
+ | 'deleted'
55
+ | 'would-delete'
56
+ | 'kept'
57
+ | 'absent'
58
+ | 'failed'
59
+
60
+ export interface Half {
61
+ readonly state: HalfState
62
+ /** Every reason the half was kept, empty for any other state. */
63
+ readonly reasons: readonly MemberRefusal[]
64
+ /** The git error on a failed half, null otherwise. */
65
+ readonly detail: string | null
66
+ }
67
+
68
+ export interface MemberOutcome {
69
+ readonly branch: string
70
+ readonly origin: Half
71
+ /** Null when no worktree holds the branch. */
72
+ readonly worktree: (Half & { readonly path: string }) | null
73
+ readonly local: Half
74
+ }
75
+
76
+ export type MembersReport =
77
+ | {
78
+ readonly kind: 'refused'
79
+ readonly reason: MembersRefusal
80
+ readonly detail: string
81
+ /** The branches the member line named, empty when no line was read. */
82
+ readonly members: readonly string[]
83
+ }
84
+ | { readonly kind: 'read'; readonly members: readonly MemberOutcome[] }
85
+
86
+ export interface CommandResult {
87
+ readonly exitCode: number
88
+ readonly stdout: string
89
+ readonly stderr: string
90
+ }
91
+
92
+ type Runner = (cwd: string, args: readonly string[]) => Promise<CommandResult>
93
+
94
+ export interface MembersOptions {
95
+ /** Where the reclaim reading runs, which is what refuses the worktree the caller stands in. */
96
+ readonly cwd: string
97
+ /** The main worktree root, where every git write runs, for the reason `removeReclaimable` states. */
98
+ readonly root: string
99
+ readonly dryRun?: boolean
100
+ /** Null when `gh` is not on the path. */
101
+ readonly gh?: Runner | null
102
+ readonly git?: Runner
103
+ /** Overrides for the reclaim reading, past the merged read this module always supplies. */
104
+ readonly reclaim?: Omit<ReclaimOptions, 'cwd' | 'mergedPullRequests'>
105
+ }
106
+
107
+ async function run(
108
+ command: string,
109
+ cwd: string,
110
+ args: readonly string[],
111
+ timeout: number,
112
+ ): Promise<CommandResult> {
113
+ // A hook exports git's resolution variables, which beat both `-C` and the
114
+ // working directory `gh` resolves its repository through.
115
+ const result = await execa(command, [...args], {
116
+ cwd,
117
+ timeout,
118
+ env: gitEnv(),
119
+ extendEnv: false,
120
+ reject: false,
121
+ })
122
+ return {
123
+ exitCode: result.exitCode ?? 1,
124
+ stdout: result.stdout,
125
+ stderr: result.stderr.trim(),
126
+ }
127
+ }
128
+
129
+ const runGit: Runner = (cwd, args) => run('git', cwd, args, GIT_TIMEOUT_MS)
130
+ const runGh: Runner = (cwd, args) => run('gh', cwd, args, GH_TIMEOUT_MS)
131
+
132
+ /**
133
+ * Reads the branches an integrator's body names on its member line, in the
134
+ * order named. Only backticked names after the fixed lead count, and a body
135
+ * carrying no such line names nobody.
136
+ */
137
+ export function parseMembers(body: string): string[] {
138
+ const line = MEMBER_LINE.exec(body)
139
+ if (line === null) return []
140
+
141
+ const names = [...(line[1] ?? '').matchAll(BACKTICKED)].map(
142
+ (match) => match[1] ?? '',
143
+ )
144
+ return [...new Set(names.filter((name) => name.length > 0))]
145
+ }
146
+
147
+ interface PullRequestView {
148
+ readonly state: string
149
+ readonly body: string
150
+ readonly headRefName: string
151
+ readonly headRefOid: string
152
+ readonly baseRefName: string
153
+ }
154
+
155
+ function isView(value: unknown): value is PullRequestView {
156
+ if (value === null || typeof value !== 'object') return false
157
+ const fields = value as Record<string, unknown>
158
+ return ['state', 'body', 'headRefName', 'headRefOid', 'baseRefName'].every(
159
+ (key) => typeof fields[key] === 'string',
160
+ )
161
+ }
162
+
163
+ function parseJson(stdout: string): unknown {
164
+ try {
165
+ return JSON.parse(stdout)
166
+ } catch {
167
+ return undefined
168
+ }
169
+ }
170
+
171
+ function refused(
172
+ reason: MembersRefusal,
173
+ detail: string,
174
+ members: readonly string[] = [],
175
+ ): MembersReport {
176
+ return { kind: 'refused', reason, detail, members }
177
+ }
178
+
179
+ const kept = (...reasons: MemberRefusal[]): Half => ({
180
+ state: 'kept',
181
+ reasons,
182
+ detail: null,
183
+ })
184
+ const plain = (state: HalfState, detail: string | null = null): Half => ({
185
+ state,
186
+ reasons: [],
187
+ detail,
188
+ })
189
+
190
+ interface Plan {
191
+ readonly branch: string
192
+ /** The tip origin holds, null when origin has no such branch. */
193
+ readonly originTip: string | null
194
+ readonly origin: Half
195
+ readonly verdict: WorktreeVerdict | null
196
+ readonly localTip: string | null
197
+ readonly worktree: Half | null
198
+ readonly local: Half
199
+ }
200
+
201
+ /**
202
+ * Retires the fan-in members an integrator's merged pull request names: each
203
+ * one's origin branch, its worktree, and its local branch.
204
+ *
205
+ * Merged here means reachable from the integrator's merged head, not merged
206
+ * into the trunk. The integrator squash merges, so no member is ever an
207
+ * ancestor of the trunk, but every member was merged into the integrator's
208
+ * branch before it shipped. A commit landing on a member after that merge is
209
+ * work the squash did not carry, and its tip stops being an ancestor.
210
+ *
211
+ * Origin and local are judged apart, since an unpushed local commit and a
212
+ * commit pushed from elsewhere each keep only their own half. The worktree
213
+ * takes the reclaim's reading with the members injected as its merged answer,
214
+ * so a held, dirty, or current worktree is refused by the same rules as
215
+ * everywhere else, and its local branch stays with it because git refuses to
216
+ * delete a branch a registered worktree holds.
217
+ *
218
+ * Every input is read before anything is deleted, and an unreadable one
219
+ * refuses the whole run, for the reason `reclaimReport` states.
220
+ */
221
+ export async function retireMembers(
222
+ pullRequest: number,
223
+ opts: MembersOptions,
224
+ ): Promise<MembersReport> {
225
+ const { cwd, root } = opts
226
+ const dryRun = opts.dryRun ?? false
227
+ const gh = opts.gh === undefined ? (Bun.which('gh') ? runGh : null) : opts.gh
228
+ const git = opts.git ?? runGit
229
+
230
+ if (gh === null) {
231
+ return refused(
232
+ 'gh-missing',
233
+ 'gh is not on the path, so the pull request could not be read.',
234
+ )
235
+ }
236
+
237
+ const viewed = await gh(root, [
238
+ 'pr',
239
+ 'view',
240
+ String(pullRequest),
241
+ '--json',
242
+ 'state,body,headRefName,headRefOid,baseRefName',
243
+ ])
244
+ const view = viewed.exitCode === 0 ? parseJson(viewed.stdout) : undefined
245
+ if (!isView(view)) {
246
+ return refused(
247
+ 'gh-failed',
248
+ viewed.stderr || `gh pr view ${pullRequest} returned no readable record.`,
249
+ )
250
+ }
251
+
252
+ const members = parseMembers(view.body)
253
+ if (view.state !== 'MERGED') {
254
+ return refused(
255
+ 'not-merged',
256
+ `#${pullRequest} is ${view.state.toLowerCase()}, so nothing it names has shipped.`,
257
+ members,
258
+ )
259
+ }
260
+ if (members.length === 0) {
261
+ return refused(
262
+ 'no-members',
263
+ `#${pullRequest} carries no "Fan-in members:" line.`,
264
+ )
265
+ }
266
+
267
+ if (members.includes(view.baseRefName)) {
268
+ return refused(
269
+ 'trunk-named',
270
+ `The member line names ${view.baseRefName}, the branch #${pullRequest} merged into.`,
271
+ members,
272
+ )
273
+ }
274
+ if (members.includes(view.headRefName)) {
275
+ return refused(
276
+ 'head-named',
277
+ `The member line names ${view.headRefName}, the integrator's own branch.`,
278
+ members,
279
+ )
280
+ }
281
+
282
+ const listed = await gh(root, [
283
+ 'pr',
284
+ 'list',
285
+ '--state',
286
+ 'open',
287
+ '--limit',
288
+ String(OPEN_LIMIT),
289
+ '--json',
290
+ 'headRefName,number',
291
+ ])
292
+ const open = listed.exitCode === 0 ? parseJson(listed.stdout) : undefined
293
+ if (!Array.isArray(open)) {
294
+ return refused(
295
+ 'gh-failed',
296
+ listed.stderr || 'gh pr list returned no readable record.',
297
+ members,
298
+ )
299
+ }
300
+ const openHeads = new Set(
301
+ open.flatMap((row: unknown) =>
302
+ row !== null &&
303
+ typeof row === 'object' &&
304
+ typeof (row as Record<string, unknown>).headRefName === 'string'
305
+ ? [(row as Record<string, string>).headRefName]
306
+ : [],
307
+ ),
308
+ )
309
+ const stillOpen = members.filter((branch) => openHeads.has(branch))
310
+ if (stillOpen.length > 0) {
311
+ return refused(
312
+ 'open-pull-request',
313
+ `${stillOpen.join(', ')} has an open pull request of its own, so it is not a member this merge retires.`,
314
+ members,
315
+ )
316
+ }
317
+
318
+ const reading = await reclaimReport({
319
+ ...opts.reclaim,
320
+ cwd,
321
+ mergedPullRequests: async () => ({
322
+ kind: 'read',
323
+ merged: members.map((branch) => ({ branch, number: pullRequest })),
324
+ }),
325
+ })
326
+ if (reading.kind === 'unreadable') {
327
+ // The merged read is injected, so only the session refusals reach here.
328
+ const reason =
329
+ reading.reason === 'sessions-unchecked'
330
+ ? 'sessions-unchecked'
331
+ : 'sessions-unreadable'
332
+ return refused(reason, reading.detail, members)
333
+ }
334
+
335
+ // With head-branch deletion on, the integrator's branch is gone from origin
336
+ // by now, and GitHub keeps this ref for every pull request it has opened.
337
+ const fetched = await git(root, [
338
+ 'fetch',
339
+ '--quiet',
340
+ '--no-tags',
341
+ 'origin',
342
+ `refs/pull/${pullRequest}/head`,
343
+ ])
344
+ const fetchHead =
345
+ fetched.exitCode === 0
346
+ ? await git(root, ['rev-parse', '--verify', '--quiet', 'FETCH_HEAD'])
347
+ : null
348
+ if (fetchHead === null || fetchHead.stdout.trim() !== view.headRefOid) {
349
+ return refused(
350
+ 'head-unreadable',
351
+ fetched.stderr ||
352
+ `refs/pull/${pullRequest}/head does not resolve to ${view.headRefOid}, the head GitHub reports merged.`,
353
+ members,
354
+ )
355
+ }
356
+
357
+ const merged = async (tip: string) =>
358
+ (await git(root, ['merge-base', '--is-ancestor', tip, view.headRefOid]))
359
+ .exitCode === 0
360
+
361
+ const plans: Plan[] = []
362
+ for (const branch of members) {
363
+ plans.push(
364
+ await judge(branch, {
365
+ git,
366
+ root,
367
+ merged,
368
+ dryRun,
369
+ verdict:
370
+ reading.worktrees.find((entry) => entry.branch === branch) ?? null,
371
+ }),
372
+ )
373
+ }
374
+
375
+ if (dryRun) {
376
+ return {
377
+ kind: 'read',
378
+ members: plans.map(({ branch, origin, verdict, worktree, local }) => ({
379
+ branch,
380
+ origin,
381
+ worktree:
382
+ verdict === null || worktree === null
383
+ ? null
384
+ : { ...worktree, path: verdict.path },
385
+ local,
386
+ })),
387
+ }
388
+ }
389
+
390
+ const outcomes: MemberOutcome[] = []
391
+ for (const plan of plans) outcomes.push(await act(plan, git, root))
392
+ return { kind: 'read', members: outcomes }
393
+ }
394
+
395
+ interface JudgeContext {
396
+ readonly git: Runner
397
+ readonly root: string
398
+ readonly merged: (tip: string) => Promise<boolean>
399
+ readonly dryRun: boolean
400
+ readonly verdict: WorktreeVerdict | null
401
+ }
402
+
403
+ /** Decides each half of one member from its own tip, deleting nothing. */
404
+ async function judge(branch: string, context: JudgeContext): Promise<Plan> {
405
+ const { git, root, merged, dryRun, verdict } = context
406
+ const go: Half = plain(dryRun ? 'would-delete' : 'deleted')
407
+
408
+ // Read from origin itself rather than the tracking ref, which holds only
409
+ // what the last fetch saw and misses a commit pushed since.
410
+ const remote = await git(root, [
411
+ 'ls-remote',
412
+ '--heads',
413
+ 'origin',
414
+ `refs/heads/${branch}`,
415
+ ])
416
+ const originTip =
417
+ remote.exitCode === 0 ? (remote.stdout.trim().split(/\s/)[0] ?? '') : null
418
+ const origin =
419
+ originTip === null
420
+ ? kept('remote-unreadable')
421
+ : originTip === ''
422
+ ? plain('absent')
423
+ : (await merged(originTip))
424
+ ? go
425
+ : kept('not-in-merged-head')
426
+
427
+ const head = await git(root, [
428
+ 'rev-parse',
429
+ '--verify',
430
+ '--quiet',
431
+ `refs/heads/${branch}`,
432
+ ])
433
+ const localTip = head.exitCode === 0 ? head.stdout.trim() : null
434
+ const localMerged = localTip !== null && (await merged(localTip))
435
+
436
+ const worktreeReasons: MemberRefusal[] = [
437
+ ...(verdict?.refusals.filter(
438
+ (refusal): refusal is Exclude<Refusal, 'no-merged-pull-request'> =>
439
+ refusal !== 'no-merged-pull-request',
440
+ ) ?? []),
441
+ ...(localTip !== null && !localMerged
442
+ ? (['not-in-merged-head'] as const)
443
+ : []),
444
+ ]
445
+ const worktree =
446
+ verdict === null
447
+ ? null
448
+ : worktreeReasons.length > 0
449
+ ? kept(...worktreeReasons)
450
+ : go
451
+
452
+ const local =
453
+ localTip === null
454
+ ? plain('absent')
455
+ : !localMerged
456
+ ? kept('not-in-merged-head')
457
+ : worktree !== null && worktree.state === 'kept'
458
+ ? kept('worktree-kept')
459
+ : go
460
+
461
+ return {
462
+ branch,
463
+ originTip: originTip === '' ? null : originTip,
464
+ origin,
465
+ verdict,
466
+ localTip,
467
+ worktree,
468
+ local,
469
+ }
470
+ }
471
+
472
+ /** Carries out one member's plan: origin first, then the worktree and its branch. */
473
+ async function act(
474
+ plan: Plan,
475
+ git: Runner,
476
+ root: string,
477
+ ): Promise<MemberOutcome> {
478
+ const { branch, verdict } = plan
479
+
480
+ let origin = plan.origin
481
+ if (origin.state === 'deleted' && plan.originTip !== null) {
482
+ // The lease names the tip judged above, so a commit pushed between the
483
+ // judgment and the push keeps the branch rather than going with it.
484
+ const pushed = await git(root, [
485
+ 'push',
486
+ '--quiet',
487
+ `--force-with-lease=refs/heads/${branch}:${plan.originTip}`,
488
+ 'origin',
489
+ `:refs/heads/${branch}`,
490
+ ])
491
+ if (pushed.exitCode !== 0) origin = plain('failed', pushed.stderr || null)
492
+ }
493
+
494
+ if (verdict !== null && plan.worktree !== null) {
495
+ if (plan.worktree.state !== 'deleted') {
496
+ return {
497
+ branch,
498
+ origin,
499
+ worktree: { ...plan.worktree, path: verdict.path },
500
+ local: plan.local,
501
+ }
502
+ }
503
+
504
+ const [removed] = (
505
+ await removeReclaimable([{ ...verdict, reclaimable: true }], {
506
+ cwd: root,
507
+ git,
508
+ })
509
+ ).outcomes
510
+ const worktreeGone = removed !== undefined && removed.failedAt !== 'remove'
511
+ const worktree = worktreeGone
512
+ ? plain('deleted')
513
+ : plain('failed', removed?.detail ?? null)
514
+ const local = !worktreeGone
515
+ ? kept('worktree-kept')
516
+ : removed.failedAt === 'branch'
517
+ ? plain('failed', removed.detail)
518
+ : plain('deleted')
519
+ return {
520
+ branch,
521
+ origin,
522
+ worktree: { ...worktree, path: verdict.path },
523
+ local,
524
+ }
525
+ }
526
+
527
+ let local = plan.local
528
+ if (local.state === 'deleted' && plan.localTip !== null) {
529
+ // The old value guards the delete the way the lease guards the push.
530
+ const deleted = await git(root, [
531
+ 'update-ref',
532
+ '-d',
533
+ `refs/heads/${branch}`,
534
+ plan.localTip,
535
+ ])
536
+ if (deleted.exitCode !== 0) local = plain('failed', deleted.stderr || null)
537
+ }
538
+
539
+ return { branch, origin, worktree: null, local }
540
+ }