@erclx/canon 5.50.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 (48) 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/ci-workflow/REQUIREMENT.md +3 -3
  6. package/claude/skills/ci-workflow/SKILL.md +26 -1
  7. package/claude/skills/ci-workflow/references/workflows.md +28 -0
  8. package/claude/skills/design-record/references/folder.md +1 -1
  9. package/claude/skills/design-taste/SKILL.md +2 -1
  10. package/claude/skills/git-followup/SKILL.md +1 -0
  11. package/claude/skills/git-pr/references/pr.md +1 -1
  12. package/claude/skills/plan-feature/references/batches.md +1 -1
  13. package/claude/skills/target-check/references/domains.md +8 -6
  14. package/docs/agents/index.md +1 -1
  15. package/docs/agents/migrate.md +2 -0
  16. package/docs/agents/targets.md +8 -1
  17. package/docs/target/migrations.md +1 -1
  18. package/docs/workflow/parallel-features.md +1 -1
  19. package/governance/detect.toml +1 -1
  20. package/governance/ledger/tooling/ci-workflow.md +6 -0
  21. package/governance/ledger/ui/design-reference.md +8 -13
  22. package/governance/rules/tooling/ci-workflow.md +4 -0
  23. package/governance/rules/ui/design-reference.md +3 -6
  24. package/package.json +2 -1
  25. package/scripts/ci/check-browsers.sh +13 -0
  26. package/scripts/ci/delete-previews.sh +34 -0
  27. package/scripts/ci/publish-check.sh +29 -0
  28. package/scripts/ci/refresh-frames-pr.sh +63 -0
  29. package/scripts/ci/report-baseline.sh +23 -0
  30. package/scripts/ci/stack-issues.sh +24 -0
  31. package/scripts/ci/verify-npm-token.sh +14 -0
  32. package/src/cli/help.ts +1 -0
  33. package/src/cli.ts +2 -0
  34. package/src/commands/changelog.ts +146 -0
  35. package/src/commands/hooks.ts +7 -5
  36. package/src/commands/migrate.ts +77 -0
  37. package/src/commands/worktrees.ts +273 -0
  38. package/src/gov/detect.ts +28 -10
  39. package/src/hooks/post-merge.ts +45 -6
  40. package/src/migrate/status.ts +128 -0
  41. package/src/version/changelog.ts +156 -0
  42. package/src/worktrees/members.ts +540 -0
  43. package/tooling/base/configs/.husky/post-merge +7 -6
  44. package/tooling/cloudflare/configs/.github/workflows/deploy.yml +10 -28
  45. package/tooling/cloudflare/configs/scripts/delete-previews.sh +36 -0
  46. package/tooling/cloudflare/reference.md +7 -4
  47. package/tooling/web/reference.md +5 -0
  48. package/tooling/web/seeds/canon/design/index.md +23 -0
@@ -0,0 +1,24 @@
1
+ #!/usr/bin/env bash
2
+ # Opens, updates, or closes one issue per stack from the outcomes stack-verify.yml
3
+ # recorded under results/<stack>, called by its report job. Takes the stacks as
4
+ # arguments and reads GH_TOKEN, GH_REPO, and RUN_URL from the step env.
5
+ set -euo pipefail
6
+
7
+ for stack in "$@"; do
8
+ title="stack-verify: $stack"
9
+ # A job that died before recording counts as a failure
10
+ outcome=failure
11
+ [ -f "results/$stack" ] && outcome=$(cat "results/$stack")
12
+ number=$(gh issue list --state open --search "\"$title\" in:title" --json number,title \
13
+ --jq "[.[] | select(.title == \"$title\")][0].number // empty")
14
+ if [ "$outcome" = success ]; then
15
+ if [ -n "$number" ]; then
16
+ gh issue close "$number" --comment "Passing again in $RUN_URL"
17
+ fi
18
+ elif [ -n "$number" ]; then
19
+ gh issue comment "$number" --body "Still failing in $RUN_URL"
20
+ else
21
+ gh issue create --title "$title" \
22
+ --body "\`canon tooling verify $stack\` failed in $RUN_URL. The issue closes itself when a later run passes."
23
+ fi
24
+ done
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env bash
2
+ # Checks that the npm publish credential is set and authenticates, so a release
3
+ # is never cut it cannot publish. release-please.yml passes NPM_TOKEN from the
4
+ # step env, which keeps the secret named in the workflow.
5
+ set -e
6
+
7
+ if [ -z "$NPM_TOKEN" ]; then
8
+ echo "::error::NPM_TOKEN is not set. Add the secret before a release can be cut."
9
+ exit 1
10
+ fi
11
+ if ! curl -sf -H "Authorization: Bearer $NPM_TOKEN" https://registry.npmjs.org/-/whoami >/dev/null; then
12
+ echo "::error::NPM_TOKEN did not authenticate. It is likely expired or revoked."
13
+ exit 1
14
+ fi
package/src/cli/help.ts CHANGED
@@ -20,6 +20,7 @@ export const COMMAND_GROUPS: { title: string; rows: CommandRow[] }[] = [
20
20
  ['sync [path]', 'Sync all installed domains in a project'],
21
21
  ['upgrade', 'Reinstall the CLI with the manager that installed it'],
22
22
  ['migrate [cmd]', 'Move a project off a retired name or layout'],
23
+ ['changelog', 'Read the releases between a stamp and this binary'],
23
24
  ['targets [cmd]', 'Projects this toolkit installed into'],
24
25
  ],
25
26
  },
package/src/cli.ts CHANGED
@@ -40,6 +40,7 @@ import { register as census } from '@/commands/census'
40
40
  import { register as upstream } from '@/commands/upstream'
41
41
  import { register as targets } from '@/commands/targets'
42
42
  import { register as upgrade } from '@/commands/upgrade'
43
+ import { register as changelog } from '@/commands/changelog'
43
44
  import { readInstalled, UNKNOWN_LABEL } from '@/version/installed'
44
45
  import { renderHelp } from '@/cli/help'
45
46
  import { renderAll, routeHelp } from '@/cli/help-all'
@@ -100,6 +101,7 @@ upstream(program)
100
101
  audits(program)
101
102
  gate(program)
102
103
  upgrade(program)
104
+ changelog(program)
103
105
 
104
106
  const route = routeHelp(program, process.argv.slice(2))
105
107
  if (route.kind === 'top') showHelp()
@@ -0,0 +1,146 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { join, resolve } from 'node:path'
3
+ import type { Command } from 'commander'
4
+ import { PROJECT_ROOT } from '@/roots/project'
5
+ import { readStamp } from '@/sync/stamp'
6
+ import {
7
+ intro,
8
+ logInfo,
9
+ logStep,
10
+ logWarn,
11
+ outro,
12
+ pipeOutput,
13
+ plural,
14
+ } from '@/cli/ui'
15
+ import { type ChangelogReport, resolveChangelog } from '@/version/changelog'
16
+ import { readInstalled, UNKNOWN_LABEL } from '@/version/installed'
17
+
18
+ interface ChangelogOptions {
19
+ readonly since?: string
20
+ readonly root?: string
21
+ readonly json?: boolean
22
+ }
23
+
24
+ const REFUSALS = {
25
+ 'no-stamp':
26
+ 'the target carries no stamped version and --since named none, so there is no start to range from',
27
+ 'no-changelog':
28
+ 'the installed package carries no CHANGELOG.md with release entries to read',
29
+ 'unknown-version':
30
+ 'the changelog lists no such release, so the range would be a guess',
31
+ } as const
32
+
33
+ const HELP = [
34
+ '',
35
+ 'Prints the release entries after a version, up to the running one, from the',
36
+ 'CHANGELOG.md the installed package ships. It reads no registry and needs no',
37
+ '`gh`, so it answers offline.',
38
+ '',
39
+ 'The start defaults to the oldest `version` across the target stamp domains,',
40
+ 'since the oldest domain is the one furthest behind. --since overrides it.',
41
+ '',
42
+ 'Exit codes:',
43
+ ' 0 the range was read, empty when the target is already current',
44
+ ' 1 refused, with the reason on stderr',
45
+ '',
46
+ 'A refusal names a "reason" rather than printing an empty range. no-stamp',
47
+ 'means no start was recorded or given, no-changelog means the file or its',
48
+ 'release headings are missing, and unknown-version means the start or the',
49
+ 'running version is absent from the file, which covers a pre-release and a',
50
+ 'start newer than the binary.',
51
+ '',
52
+ 'The JSON carries "since", "running", and "entries", each an object with the',
53
+ 'release "version" and its raw "markdown". Read the record rather than the',
54
+ 'exit when a skill consumes this, since a shell function wrapping the binary',
55
+ 'may flatten the status.',
56
+ '',
57
+ 'Examples:',
58
+ ' canon changelog',
59
+ ' canon changelog --since 5.47.0 --json',
60
+ ' canon changelog --root ../frameboard',
61
+ '',
62
+ ].join('\n')
63
+
64
+ export function register(program: Command): void {
65
+ program
66
+ .command('changelog')
67
+ .description(
68
+ 'Print the release entries after a version up to the running one',
69
+ )
70
+ .helpOption('-h, --help', 'Show this help message')
71
+ .option(
72
+ '--since <version>',
73
+ 'Start after this version instead of the stamp',
74
+ )
75
+ .option('--root <path>', 'Target whose stamp sets the start', '.')
76
+ .option('--json', 'Add a machine-readable record on stdout')
77
+ .addHelpText('after', HELP)
78
+ .action((opts: ChangelogOptions) => {
79
+ process.exitCode = runChangelog(opts)
80
+ })
81
+ }
82
+
83
+ function readChangelogText(): string | undefined {
84
+ const path = join(PROJECT_ROOT, 'CHANGELOG.md')
85
+
86
+ return existsSync(path) ? readFileSync(path, 'utf8') : undefined
87
+ }
88
+
89
+ function stampedVersions(root: string): string[] {
90
+ const stamp = readStamp(root)
91
+ if (stamp === undefined) return []
92
+
93
+ return Object.values(stamp.domains)
94
+ .map((domain) => domain?.version)
95
+ .filter((version): version is string => version !== undefined)
96
+ }
97
+
98
+ function runChangelog(opts: ChangelogOptions): number {
99
+ intro('canon changelog')
100
+
101
+ const root = resolve(opts.root ?? '.')
102
+ const report = resolveChangelog({
103
+ text: readChangelogText(),
104
+ running: readInstalled().version ?? UNKNOWN_LABEL,
105
+ since: opts.since,
106
+ stamped: stampedVersions(root),
107
+ })
108
+
109
+ if (report.kind === 'range') {
110
+ reportRange(report)
111
+ } else {
112
+ reportRefusal(report)
113
+ }
114
+ outro()
115
+
116
+ if (opts.json) {
117
+ process.stdout.write(`${JSON.stringify(report)}\n`)
118
+ }
119
+
120
+ return report.kind === 'range' ? 0 : 1
121
+ }
122
+
123
+ function reportRange(
124
+ report: Extract<ChangelogReport, { kind: 'range' }>,
125
+ ): void {
126
+ logStep('Range')
127
+ logInfo(`${report.since} to ${report.running}`)
128
+
129
+ if (report.entries.length === 0) {
130
+ logInfo('No release after the start, so nothing is pending')
131
+ return
132
+ }
133
+
134
+ logStep(plural(report.entries.length, 'release'))
135
+ for (const entry of report.entries) {
136
+ pipeOutput(entry.markdown)
137
+ }
138
+ }
139
+
140
+ function reportRefusal(
141
+ report: Extract<ChangelogReport, { kind: 'refused' }>,
142
+ ): void {
143
+ logStep('Refused')
144
+ const version = report.version === undefined ? '' : ` (${report.version})`
145
+ logWarn(`${report.reason}${version}: ${REFUSALS[report.reason]}`)
146
+ }
@@ -39,14 +39,16 @@ export function register(program: Command): void {
39
39
  'Runs these steps in order, each a child call reading its --json record:',
40
40
  ...Object.values(STEP_VERBS).map((verb) => ` canon ${verb.join(' ')}`),
41
41
  '',
42
- 'The archive runs once per pull request number the merge brought in.',
43
- 'Lines go to stderr, and an ordinary merge prints nothing.',
42
+ 'The archive and the member retirement run once per pull request',
43
+ 'number the merge brought in. Lines go to stderr, and an ordinary merge',
44
+ 'prints nothing.',
44
45
  '',
45
- 'The reclaim runs from the working directory with no root, so a pull',
46
- 'inside a linked worktree never removes the worktree it stands in.',
46
+ 'The member retirement and the reclaim run from the working directory',
47
+ 'with no root, so a pull inside a linked worktree never removes the',
48
+ 'worktree it stands in.',
47
49
  '',
48
50
  'Environment:',
49
- ' CANON_SKIP_RECLAIM=1 skip the worktree reclaim',
51
+ ' CANON_SKIP_RECLAIM=1 skip the member retirement and the reclaim',
50
52
  ' CANON_SKIP_UPGRADE=1 skip the CLI reinstall',
51
53
  ' CANON_SKIP_PLUGIN_UPDATE=1 skip the plugin update',
52
54
  '',
@@ -1,6 +1,7 @@
1
1
  import { existsSync, readFileSync } from 'node:fs'
2
2
  import { join, resolve } from 'node:path'
3
3
  import type { Command } from 'commander'
4
+ import { cliRunner } from '@/gate/sequencer'
4
5
  import { listRepositoryFiles } from '@/git/files'
5
6
  import { indexSourceRules } from '@/gov/adapter'
6
7
  import { applyRecordsMove, applyRename, readSources } from '@/migrate/apply'
@@ -35,6 +36,7 @@ import {
35
36
  SKILL_NAME_MAP,
36
37
  SKILL_NAME_RULES,
37
38
  } from '@/migrate/skill-names'
39
+ import { readMigrationStatus, statusExitCode } from '@/migrate/status'
38
40
  import {
39
41
  planSurfaceRootsMove,
40
42
  type SurfaceRootsPlan,
@@ -1039,6 +1041,43 @@ function toSessionDefaultsRecord(
1039
1041
  }
1040
1042
  }
1041
1043
 
1044
+ interface StatusOptions {
1045
+ readonly json?: boolean
1046
+ readonly root?: string
1047
+ }
1048
+
1049
+ /**
1050
+ * Lists every migration a project still needs by running each subcommand's own
1051
+ * dry run, so it reads the contract they share and writes nothing.
1052
+ */
1053
+ async function runStatus(opts: StatusOptions): Promise<number> {
1054
+ const root = resolve(opts.root ?? process.cwd())
1055
+
1056
+ const mismatch = checkoutMismatchWarning(process.cwd())
1057
+ if (mismatch !== undefined) logWarn(mismatch)
1058
+
1059
+ const report = await readMigrationStatus(cliRunner(root))
1060
+
1061
+ if (opts.json) {
1062
+ process.stdout.write(
1063
+ `${JSON.stringify({ ok: report.refused === 0, ...report })}\n`,
1064
+ )
1065
+ }
1066
+
1067
+ for (const entry of report.migrations) {
1068
+ if (entry.state === 'pending') logInfo(`Pending: ${entry.verb}`)
1069
+ if (entry.state === 'refused') {
1070
+ logError(`Refused: ${entry.verb}: ${entry.reason ?? 'no reason given'}`)
1071
+ }
1072
+ }
1073
+
1074
+ if (report.pending === 0 && report.refused === 0) {
1075
+ logInfo('No migration is pending.')
1076
+ }
1077
+
1078
+ return statusExitCode(report)
1079
+ }
1080
+
1042
1081
  export function register(program: Command): void {
1043
1082
  const migrate = program
1044
1083
  .command('migrate')
@@ -1446,4 +1485,42 @@ export function register(program: Command): void {
1446
1485
  .action(async (opts: SessionDefaultsOptions) => {
1447
1486
  process.exitCode = await runSessionDefaults(opts)
1448
1487
  })
1488
+
1489
+ migrate
1490
+ .command('status')
1491
+ .description('List every migration a project still needs, in run order')
1492
+ .helpOption('-h, --help', 'Show this help message')
1493
+ .option('--json', 'Add a machine-readable record on stdout')
1494
+ .option(
1495
+ '--root <path>',
1496
+ 'Project root, defaulting to the working directory',
1497
+ )
1498
+ .addHelpText(
1499
+ 'after',
1500
+ [
1501
+ '',
1502
+ 'Runs each migrate subcommand as a dry run and sorts it by its exit',
1503
+ 'code, in the order a project runs them, records first. It passes',
1504
+ 'no --write and writes nothing. A migration is pending or not, with',
1505
+ "the subcommand's own record attached, since the records share no",
1506
+ 'count field.',
1507
+ '',
1508
+ "Each dry run reads today's tree, so a clean entry can turn pending",
1509
+ 'once an earlier one is applied. Run status again after each --write.',
1510
+ '',
1511
+ 'Exit codes:',
1512
+ ' 0 no migration is pending',
1513
+ ' 1 a subcommand refused, which outranks a pending plan',
1514
+ ' 2 at least one migration is pending',
1515
+ '',
1516
+ 'Examples:',
1517
+ ' canon migrate status',
1518
+ ' canon migrate status --json',
1519
+ ' canon migrate status --root ../project',
1520
+ '',
1521
+ ].join('\n'),
1522
+ )
1523
+ .action(async (opts: StatusOptions) => {
1524
+ process.exitCode = await runStatus(opts)
1525
+ })
1449
1526
  }
@@ -17,6 +17,14 @@ import {
17
17
  type WorktreeVerdict,
18
18
  } from '@/worktrees/reclaim'
19
19
  import { mainWorktreeRoot } from '@/git/worktree'
20
+ import {
21
+ type Half,
22
+ type MemberOutcome,
23
+ type MemberRefusal,
24
+ type MembersRefusal,
25
+ type MembersReport,
26
+ retireMembers,
27
+ } from '@/worktrees/members'
20
28
  import { machineListenerReader } from '@/listeners/machine'
21
29
  import { repairBareFlag } from '@/worktrees/bare-flag'
22
30
  import { type StopReport, stopListeners } from '@/worktrees/stop'
@@ -40,6 +48,31 @@ interface ReclaimCommandOptions {
40
48
  readonly json?: boolean
41
49
  }
42
50
 
51
+ interface RetireMembersCommandOptions {
52
+ readonly pullRequest: string
53
+ readonly dryRun?: boolean
54
+ readonly json?: boolean
55
+ }
56
+
57
+ /**
58
+ * What the post-merge hook reads after an integrator merges. Every field it
59
+ * branches on is a number or a fixed word, and `members` sits last, for the
60
+ * reason `ReclaimRecord` states.
61
+ */
62
+ export interface RetireMembersRecord {
63
+ readonly pullRequest: number
64
+ readonly reason: MembersRefusal | null
65
+ readonly detail: string | null
66
+ readonly dryRun: boolean
67
+ /** Members with a half deleted, or that a dry run would delete, and none kept or failed. */
68
+ readonly retired: number
69
+ /** Members with a half kept and none failed. */
70
+ readonly kept: number
71
+ /** Members with a half whose deletion failed. */
72
+ readonly failed: number
73
+ readonly members: readonly MemberOutcome[]
74
+ }
75
+
43
76
  /**
44
77
  * What a caller reads instead of the frame.
45
78
  *
@@ -145,6 +178,59 @@ const CONDITIONS = [
145
178
  'keeps a directory rather than removing one.',
146
179
  ]
147
180
 
181
+ const MEMBERS_REFUSALS: Record<MembersRefusal, string> = {
182
+ 'gh-missing': 'gh is not on the path, so the pull request could not be read',
183
+ 'gh-failed': 'the pull request read failed',
184
+ 'sessions-unreadable': 'the session roster could not be read',
185
+ 'sessions-unchecked':
186
+ 'some session records come from another pid namespace and no socket answers for them',
187
+ 'not-merged': 'the pull request has not merged',
188
+ 'no-members': 'the body carries no "Fan-in members:" line',
189
+ 'trunk-named': 'the line names the branch the pull request merged into',
190
+ 'head-named': "the line names the integrator's own branch",
191
+ 'open-pull-request': 'a named branch has an open pull request of its own',
192
+ 'head-unreadable':
193
+ 'refs/pull/<n>/head could not be fetched, or names another commit than the merged head',
194
+ }
195
+
196
+ const MEMBER_REFUSALS: Record<MemberRefusal, string> = {
197
+ 'not-in-merged-head':
198
+ "its tip is not an ancestor of the integrator's merged head, so it holds work the merge did not carry",
199
+ 'remote-unreadable': 'origin could not be read for this branch',
200
+ 'worktree-kept':
201
+ 'its worktree was kept, and git refuses to delete a branch a worktree holds',
202
+ 'main-worktree': 'it is checked out in the main worktree',
203
+ 'current-worktree': 'its worktree is the one this command runs in',
204
+ 'detached-head': 'its worktree has a detached HEAD',
205
+ 'uncommitted-changes': 'its worktree holds uncommitted work',
206
+ 'unreadable-worktree': 'its worktree could not be read',
207
+ 'held-by-session': 'a live session holds its worktree',
208
+ }
209
+
210
+ const RETIRE_CONDITIONS = [
211
+ 'Reads the "Fan-in members:" line of a merged integrator pull request,',
212
+ "whose backticked branches are the members, and deletes each one's",
213
+ 'origin branch, worktree, and local branch. Nothing outside that line',
214
+ 'is read.',
215
+ '',
216
+ "A member half is merged when its tip is an ancestor of the integrator's",
217
+ 'merged head, fetched from refs/pull/<n>/head, rather than of the trunk,',
218
+ 'which a squash merge never makes it. Origin and local are judged apart,',
219
+ 'so an unpushed commit keeps the local branch and a commit pushed after',
220
+ 'the merge keeps the origin branch. The worktree takes the same reading',
221
+ '`canon worktrees list` gives, with the members counted as merged.',
222
+ '',
223
+ 'These refuse the whole run, and nothing is deleted:',
224
+ ...Object.entries(MEMBERS_REFUSALS).map(
225
+ ([reason, meaning]) => ` ${reason.padEnd(22)}${meaning}`,
226
+ ),
227
+ '',
228
+ 'These keep one half of one member, named in its "reasons":',
229
+ ...Object.entries(MEMBER_REFUSALS).map(
230
+ ([reason, meaning]) => ` ${reason.padEnd(22)}${meaning}`,
231
+ ),
232
+ ]
233
+
148
234
  export function register(program: Command): void {
149
235
  const worktrees = program
150
236
  .command('worktrees')
@@ -240,6 +326,42 @@ export function register(program: Command): void {
240
326
  process.exitCode = await runReclaim(opts)
241
327
  })
242
328
 
329
+ worktrees
330
+ .command('retire-members')
331
+ .description(
332
+ 'Delete the fan-in members a merged integrator pull request names, unless --dry-run',
333
+ )
334
+ .helpOption('-h, --help', 'Show this help message')
335
+ .requiredOption(
336
+ '--pull-request <number>',
337
+ 'The merged integrator pull request',
338
+ )
339
+ .option('--dry-run', 'Report what would be deleted without deleting it')
340
+ .option('--json', 'Add a machine-readable record on stdout')
341
+ .addHelpText(
342
+ 'after',
343
+ [
344
+ '',
345
+ 'Exit codes:',
346
+ ' 0 every member was retired or kept, or the body names none',
347
+ ' 1 refused, or a deletion failed, with the reason on stderr',
348
+ '',
349
+ ...RETIRE_CONDITIONS,
350
+ '',
351
+ 'The post-merge hook runs this once per merged pull request number,',
352
+ 'and CANON_SKIP_RECLAIM turns it off there. Run it by hand for a',
353
+ 'merge the hook missed.',
354
+ '',
355
+ 'Examples:',
356
+ ' canon worktrees retire-members --pull-request 2570 --dry-run',
357
+ ' canon worktrees retire-members --pull-request 2570 --json',
358
+ '',
359
+ ].join('\n'),
360
+ )
361
+ .action(async (opts: RetireMembersCommandOptions) => {
362
+ process.exitCode = await runRetireMembers(opts)
363
+ })
364
+
243
365
  worktrees
244
366
  .command('repair-bare-flag', { hidden: true })
245
367
  .description('Clear a core.bare flag that worktree entry left set')
@@ -563,3 +685,154 @@ export function describe(verdict: WorktreeVerdict): string {
563
685
  ` Reclaimable. Pull request #${verdict.pullRequest} merged it, ${tree}, and no session holds it.`,
564
686
  ].join('\n')
565
687
  }
688
+
689
+ function halves(member: MemberOutcome): readonly Half[] {
690
+ return member.worktree === null
691
+ ? [member.origin, member.local]
692
+ : [member.origin, member.worktree, member.local]
693
+ }
694
+
695
+ /**
696
+ * Builds the record from the report, separate from the run so the counts are
697
+ * testable without a repository. A member already gone everywhere counts
698
+ * nowhere, so a run repeated by hand does not report a retirement it did not
699
+ * make.
700
+ */
701
+ export function retireMembersRecord(
702
+ pullRequest: number,
703
+ dryRun: boolean,
704
+ report: MembersReport,
705
+ ): RetireMembersRecord {
706
+ if (report.kind === 'refused') {
707
+ return {
708
+ pullRequest,
709
+ reason: report.reason,
710
+ detail: report.detail,
711
+ dryRun,
712
+ retired: 0,
713
+ kept: 0,
714
+ failed: 0,
715
+ members: [],
716
+ }
717
+ }
718
+
719
+ const states = report.members.map((member) =>
720
+ halves(member).map((half) => half.state),
721
+ )
722
+ const failed = states.filter((each) => each.includes('failed')).length
723
+ const kept = states.filter(
724
+ (each) => !each.includes('failed') && each.includes('kept'),
725
+ ).length
726
+ const retired = states.filter(
727
+ (each) =>
728
+ !each.includes('failed') &&
729
+ !each.includes('kept') &&
730
+ each.some((state) => state === 'deleted' || state === 'would-delete'),
731
+ ).length
732
+
733
+ return {
734
+ pullRequest,
735
+ reason: null,
736
+ detail: null,
737
+ dryRun,
738
+ retired,
739
+ kept,
740
+ failed,
741
+ members: report.members,
742
+ }
743
+ }
744
+
745
+ /**
746
+ * A kept half is a refusal doing its job and a body naming no members is the
747
+ * ordinary merge, so neither fails the run.
748
+ */
749
+ export function retireMembersExit(record: RetireMembersRecord): number {
750
+ if (record.reason !== null && record.reason !== 'no-members') return 1
751
+ return record.failed > 0 ? 1 : 0
752
+ }
753
+
754
+ function describeHalf(name: string, half: Half): string {
755
+ if (half.state === 'kept') {
756
+ const reasons = half.reasons.map((reason) => MEMBER_REFUSALS[reason])
757
+ return ` ${name}: kept, since ${reasons.join('; ')}.`
758
+ }
759
+ const state = half.state.replace('-', ' ')
760
+ return half.detail === null
761
+ ? ` ${name}: ${state}.`
762
+ : ` ${name}: ${state}. ${half.detail}`
763
+ }
764
+
765
+ async function runRetireMembers(
766
+ opts: RetireMembersCommandOptions,
767
+ ): Promise<number> {
768
+ const dryRun = opts.dryRun ?? false
769
+ const pullRequest = Number(opts.pullRequest)
770
+
771
+ intro(
772
+ dryRun
773
+ ? 'canon worktrees retire-members (dry-run)'
774
+ : 'canon worktrees retire-members',
775
+ )
776
+
777
+ if (!Number.isInteger(pullRequest) || pullRequest <= 0) {
778
+ logStep('Refused')
779
+ logWarn(
780
+ `--pull-request takes a pull request number, and "${opts.pullRequest}" is not one.`,
781
+ )
782
+ outro()
783
+ return 1
784
+ }
785
+
786
+ // The reading stays on the caller's directory, which is what refuses the
787
+ // worktree it stands in, and every write runs from the main root.
788
+ const report = await retireMembers(pullRequest, {
789
+ cwd: process.cwd(),
790
+ root: await mainWorktreeRoot(),
791
+ dryRun,
792
+ })
793
+ const record = retireMembersRecord(pullRequest, dryRun, report)
794
+
795
+ if (report.kind === 'refused') {
796
+ logStep(report.reason === 'no-members' ? 'Members' : 'Refused')
797
+ if (report.reason === 'no-members') logInfo(report.detail)
798
+ else {
799
+ logWarn(`Nothing was deleted: ${MEMBERS_REFUSALS[report.reason]}.`)
800
+ logInfo(report.detail)
801
+ }
802
+ } else {
803
+ logStep('Members')
804
+ pipeOutput(
805
+ report.members
806
+ .map((member) =>
807
+ [
808
+ member.branch,
809
+ describeHalf('origin', member.origin),
810
+ ...(member.worktree === null
811
+ ? []
812
+ : [
813
+ describeHalf(
814
+ `worktree ${member.worktree.path}`,
815
+ member.worktree,
816
+ ),
817
+ ]),
818
+ describeHalf('local', member.local),
819
+ ].join('\n'),
820
+ )
821
+ .join('\n'),
822
+ )
823
+ if (record.failed > 0) {
824
+ logWarn(
825
+ `${plural(record.failed, 'member')} could not be fully deleted. Read the reason above and clear it by hand.`,
826
+ )
827
+ }
828
+ if (dryRun) {
829
+ logWarn(
830
+ 'Nothing was deleted. Run this again without --dry-run to delete.',
831
+ )
832
+ }
833
+ }
834
+
835
+ outro()
836
+ if (opts.json === true) process.stdout.write(`${JSON.stringify(record)}\n`)
837
+ return retireMembersExit(record)
838
+ }