@erclx/canon 5.50.0 → 5.52.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/CHANGELOG.md +4037 -0
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/auto-ship/references/fan-in.md +20 -6
- package/claude/skills/canon-cli/SKILL.md +9 -7
- package/claude/skills/ci-workflow/REQUIREMENT.md +3 -3
- package/claude/skills/ci-workflow/SKILL.md +26 -1
- package/claude/skills/ci-workflow/references/workflows.md +28 -0
- package/claude/skills/design-record/references/folder.md +1 -1
- package/claude/skills/design-taste/SKILL.md +2 -1
- package/claude/skills/git-followup/SKILL.md +1 -0
- package/claude/skills/git-pr/references/pr.md +2 -2
- package/claude/skills/plan-feature/references/batches.md +1 -1
- package/claude/skills/review-address/SKILL.md +1 -1
- package/claude/skills/review-address/references/post-review-findings.md +1 -1
- package/claude/skills/review-pr/references/post.md +2 -2
- package/claude/skills/target-check/SKILL.md +2 -0
- package/claude/skills/target-check/references/domains.md +8 -6
- package/claude/skills/target-setup/SKILL.md +1 -1
- package/claude/skills/target-update/REQUIREMENT.md +33 -0
- package/claude/skills/target-update/SKILL.md +136 -0
- package/docs/agents/index.md +1 -1
- package/docs/agents/migrate.md +3 -0
- package/docs/agents/targets.md +8 -1
- package/docs/target/migrations.md +3 -1
- package/docs/target/projects.md +1 -1
- package/docs/target/sync.md +1 -1
- package/docs/workflow/parallel-features.md +1 -1
- package/docs/workflow/skill-map.md +1 -0
- package/governance/detect.toml +1 -1
- package/governance/ledger/tooling/ci-workflow.md +6 -0
- package/governance/ledger/ui/design-reference.md +8 -13
- package/governance/rules/tooling/ci-workflow.md +4 -0
- package/governance/rules/ui/design-reference.md +3 -6
- package/package.json +2 -1
- package/scripts/ci/check-browsers.sh +13 -0
- package/scripts/ci/delete-previews.sh +34 -0
- package/scripts/ci/publish-check.sh +29 -0
- package/scripts/ci/refresh-frames-pr.sh +63 -0
- package/scripts/ci/report-baseline.sh +23 -0
- package/scripts/ci/stack-issues.sh +24 -0
- package/scripts/ci/verify-npm-token.sh +14 -0
- package/src/claude/cases/setup.ts +7 -0
- package/src/cli/help.ts +1 -0
- package/src/cli.ts +2 -0
- package/src/commands/changelog.ts +146 -0
- package/src/commands/hooks.ts +7 -5
- package/src/commands/labels.ts +9 -7
- package/src/commands/migrate.ts +77 -0
- package/src/commands/worktrees.ts +273 -0
- package/src/gov/detect.ts +28 -10
- package/src/hooks/post-merge.ts +45 -6
- package/src/migrate/status.ts +132 -0
- package/src/version/changelog.ts +156 -0
- package/src/worktrees/members.ts +540 -0
- package/tooling/base/configs/.husky/post-merge +7 -6
- package/tooling/claude/seeds/.claude/hooks/review-post-scan.sh +1 -1
- package/tooling/cloudflare/configs/.github/workflows/deploy.yml +10 -28
- package/tooling/cloudflare/configs/scripts/delete-previews.sh +36 -0
- package/tooling/cloudflare/reference.md +7 -4
- package/tooling/web/reference.md +5 -0
- package/tooling/web/seeds/canon/design/index.md +23 -0
package/src/commands/migrate.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/gov/detect.ts
CHANGED
|
@@ -99,25 +99,35 @@ function walkFiles(target: string): string[] {
|
|
|
99
99
|
return files.sort()
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
+
interface Candidates {
|
|
103
|
+
readonly paths: readonly string[]
|
|
104
|
+
readonly folders: readonly string[]
|
|
105
|
+
}
|
|
106
|
+
|
|
102
107
|
/**
|
|
103
108
|
* The paths a file glob is matched against: every file to depth two, plus
|
|
104
109
|
* every folder on the way to any listed file, so an evidence folder such as
|
|
105
|
-
* `.github/workflows` matches without naming the file inside it.
|
|
110
|
+
* `.github/workflows` matches without naming the file inside it. `folders`
|
|
111
|
+
* holds the folders alone, which a pattern ending in `/` is matched against.
|
|
106
112
|
*/
|
|
107
|
-
async function candidatePaths(target: string): Promise<
|
|
113
|
+
async function candidatePaths(target: string): Promise<Candidates> {
|
|
108
114
|
const listed = (await listRepositoryFiles(target)) ?? walkFiles(target)
|
|
109
115
|
const paths = new Set<string>()
|
|
116
|
+
const folders = new Set<string>()
|
|
110
117
|
|
|
111
118
|
for (const file of listed) {
|
|
112
119
|
let dir = dirname(file)
|
|
113
120
|
while (dir !== '.') {
|
|
114
|
-
if (depth(dir) <= MAX_DIRS)
|
|
121
|
+
if (depth(dir) <= MAX_DIRS) {
|
|
122
|
+
paths.add(dir)
|
|
123
|
+
folders.add(dir)
|
|
124
|
+
}
|
|
115
125
|
dir = dirname(dir)
|
|
116
126
|
}
|
|
117
127
|
if (depth(file) <= MAX_DIRS) paths.add(file)
|
|
118
128
|
}
|
|
119
129
|
|
|
120
|
-
return [...paths].sort()
|
|
130
|
+
return { paths: [...paths].sort(), folders: [...folders].sort() }
|
|
121
131
|
}
|
|
122
132
|
|
|
123
133
|
/** A PEP 508 requirement's distribution name, normalized per PEP 503. */
|
|
@@ -231,6 +241,11 @@ function readDependencies(
|
|
|
231
241
|
return deps
|
|
232
242
|
}
|
|
233
243
|
|
|
244
|
+
/** A pattern ending in `/` matches folders only, so a file at that depth misses. */
|
|
245
|
+
function isFolderPattern(pattern: string): boolean {
|
|
246
|
+
return pattern.endsWith('/')
|
|
247
|
+
}
|
|
248
|
+
|
|
234
249
|
/** A glob carrying no wildcard names one path rather than a pattern. */
|
|
235
250
|
function isLiteral(pattern: string): boolean {
|
|
236
251
|
return !/[*?[\]{}]/.test(pattern)
|
|
@@ -245,7 +260,7 @@ function matchEntry(
|
|
|
245
260
|
entry: DetectEntry,
|
|
246
261
|
target: string,
|
|
247
262
|
deps: ReadonlyMap<string, readonly string[]>,
|
|
248
|
-
|
|
263
|
+
candidates: Candidates,
|
|
249
264
|
): Evidence[] {
|
|
250
265
|
const evidence: Evidence[] = []
|
|
251
266
|
|
|
@@ -255,9 +270,12 @@ function matchEntry(
|
|
|
255
270
|
}
|
|
256
271
|
|
|
257
272
|
for (const pattern of entry.files) {
|
|
258
|
-
const
|
|
273
|
+
const folderOnly = isFolderPattern(pattern)
|
|
274
|
+
const glob = new Bun.Glob(folderOnly ? pattern.slice(0, -1) : pattern)
|
|
259
275
|
const hit =
|
|
260
|
-
paths.find((path) =>
|
|
276
|
+
(folderOnly ? candidates.folders : candidates.paths).find((path) =>
|
|
277
|
+
glob.match(path),
|
|
278
|
+
) ??
|
|
261
279
|
(isLiteral(pattern) && existsSync(join(target, pattern))
|
|
262
280
|
? pattern
|
|
263
281
|
: undefined)
|
|
@@ -287,13 +305,13 @@ export async function detectRules(
|
|
|
287
305
|
}
|
|
288
306
|
|
|
289
307
|
const live = indexRuleSources(root)
|
|
290
|
-
const
|
|
291
|
-
const deps = readDependencies(target, paths)
|
|
308
|
+
const candidates = await candidatePaths(target)
|
|
309
|
+
const deps = readDependencies(target, candidates.paths)
|
|
292
310
|
|
|
293
311
|
const proposals = loadDetectEntries(root)
|
|
294
312
|
.filter((entry) => live.has(entry.rule) && !covered.has(entry.rule))
|
|
295
313
|
.flatMap((entry) => {
|
|
296
|
-
const evidence = matchEntry(entry, target, deps,
|
|
314
|
+
const evidence = matchEntry(entry, target, deps, candidates)
|
|
297
315
|
return evidence.length === 0 ? [] : [{ rule: entry.rule, evidence }]
|
|
298
316
|
})
|
|
299
317
|
|
package/src/hooks/post-merge.ts
CHANGED
|
@@ -39,6 +39,7 @@ export interface PostMergeInput {
|
|
|
39
39
|
export const STEP_VERBS = {
|
|
40
40
|
archive: ['tasks', 'archive'],
|
|
41
41
|
push: ['records', 'push'],
|
|
42
|
+
members: ['worktrees', 'retire-members'],
|
|
42
43
|
reclaim: ['worktrees', 'reclaim'],
|
|
43
44
|
upgrade: ['upgrade'],
|
|
44
45
|
pluginUpdate: ['claude', 'plugin-update'],
|
|
@@ -225,6 +226,33 @@ function reclaimLines(fields: Fields): string | undefined {
|
|
|
225
226
|
return lines.length > 0 ? lines : undefined
|
|
226
227
|
}
|
|
227
228
|
|
|
229
|
+
/**
|
|
230
|
+
* Keyed on the fields, like the reclaim, since a run that retires one member
|
|
231
|
+
* and fails on another exits 1 with a branch already deleted. A body naming no
|
|
232
|
+
* members is every merge but an integrator's, so it stays quiet.
|
|
233
|
+
*/
|
|
234
|
+
function memberLines(number: number, fields: Fields): string | undefined {
|
|
235
|
+
let lines = ''
|
|
236
|
+
const retired = count(fields, 'retired')
|
|
237
|
+
if (retired > 0) {
|
|
238
|
+
lines += `\n🌿 Retired ${retired} fan-in member(s) of #${number}.\n\n`
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const hint = `Run canon worktrees retire-members --pull-request ${number} --dry-run to read why.\n\n`
|
|
242
|
+
const reason = text(fields, 'reason')
|
|
243
|
+
const unfinished = count(fields, 'kept') + count(fields, 'failed')
|
|
244
|
+
if (
|
|
245
|
+
reason !== undefined &&
|
|
246
|
+
reason !== 'no-members' &&
|
|
247
|
+
reason !== 'gh-missing'
|
|
248
|
+
) {
|
|
249
|
+
lines += `\n🌿 Fan-in members of #${number} not retired: ${reason}\n${hint}`
|
|
250
|
+
} else if (unfinished > 0) {
|
|
251
|
+
lines += `\n🌿 ${unfinished} fan-in member(s) of #${number} kept or not fully deleted.\n${hint}`
|
|
252
|
+
}
|
|
253
|
+
return lines.length > 0 ? lines : undefined
|
|
254
|
+
}
|
|
255
|
+
|
|
228
256
|
/**
|
|
229
257
|
* Shared by the binary reinstall and the plugin update. `current` is the
|
|
230
258
|
* ordinary outcome on most merges and stays quiet, and `quiet` names the
|
|
@@ -271,8 +299,10 @@ async function step(
|
|
|
271
299
|
}
|
|
272
300
|
|
|
273
301
|
/**
|
|
274
|
-
* Runs the
|
|
275
|
-
* upgrade, plugin update. The
|
|
302
|
+
* Runs the six post-merge steps in order: archive, records push, member
|
|
303
|
+
* retirement, reclaim, upgrade, plugin update. The member retirement sits
|
|
304
|
+
* before the reclaim, so the reclaim reads a retired member's worktree as
|
|
305
|
+
* gone rather than racing it. Both sit before the upgrade, since the
|
|
276
306
|
* upgrade reinstalls the binary every later child runs under, and the two
|
|
277
307
|
* network-bound moves sit last so a slow registry delays nothing else.
|
|
278
308
|
*
|
|
@@ -309,11 +339,20 @@ export async function runPostMerge(input: PostMergeInput): Promise<void> {
|
|
|
309
339
|
pushLines,
|
|
310
340
|
)
|
|
311
341
|
|
|
312
|
-
// No `--root`
|
|
313
|
-
// directory to refuse the worktree it stands in, so a
|
|
314
|
-
// running worktree into an ordinary candidate and let a
|
|
315
|
-
// linked worktree delete the ground under itself.
|
|
342
|
+
// No `--root` on either worktree step, and that is load-bearing. Each verb
|
|
343
|
+
// reads its own working directory to refuse the worktree it stands in, so a
|
|
344
|
+
// root would turn the running worktree into an ordinary candidate and let a
|
|
345
|
+
// pull inside a linked worktree delete the ground under itself. One switch
|
|
346
|
+
// covers both, since both remove worktrees.
|
|
316
347
|
if (!isSkipped('CANON_SKIP_RECLAIM')) {
|
|
348
|
+
for (const number of pullRequestNumbers(input.subjects)) {
|
|
349
|
+
await step(
|
|
350
|
+
input,
|
|
351
|
+
at([...STEP_VERBS.members, '--pull-request', String(number), '--json']),
|
|
352
|
+
(_, fields) => memberLines(number, fields),
|
|
353
|
+
)
|
|
354
|
+
}
|
|
355
|
+
|
|
317
356
|
await step(input, at([...STEP_VERBS.reclaim, '--json']), (_, fields) =>
|
|
318
357
|
reclaimLines(fields),
|
|
319
358
|
)
|