@erclx/canon 5.14.0 → 5.14.1

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.
@@ -0,0 +1,628 @@
1
+ // Reports pull request movement since the last run. Reads only.
2
+ //
3
+ // Run as `bun poll.ts`. The pure pieces are exported so a test drives the code
4
+ // a loop runs, and `main` runs only when this file is the entry point.
5
+ import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
6
+ import { join } from 'node:path'
7
+
8
+ import { baseBranch, baseRef, mainRoot, run } from './repo'
9
+
10
+ interface Comment {
11
+ body?: null | string
12
+ createdAt?: null | string
13
+ }
14
+
15
+ interface Review {
16
+ body?: null | string
17
+ commit?: { oid?: null | string } | null
18
+ submittedAt?: null | string
19
+ }
20
+
21
+ export interface Payload {
22
+ comments?: Comment[]
23
+ headRefOid?: string
24
+ reviews?: Review[]
25
+ }
26
+
27
+ /** Four fields describing the last review pass, in the order the line carries them. */
28
+ export interface Scope {
29
+ age: number
30
+ commit: string
31
+ readAt: number
32
+ state: string
33
+ }
34
+
35
+ export interface Row {
36
+ age: number
37
+ head: string
38
+ heading: string
39
+ merges: string
40
+ number: string
41
+ passAt: number
42
+ prior: string
43
+ replyAt: number
44
+ resp: number
45
+ ui: string
46
+ unmatchedCount: number
47
+ unmatchedHeading: string
48
+ }
49
+
50
+ /** One baseline line, kept as the strings it was written with. */
51
+ export interface Baseline {
52
+ head: string
53
+ merges: string
54
+ number: string
55
+ prior: string
56
+ resp: string
57
+ state: string
58
+ ui: string
59
+ unmatched: string
60
+ }
61
+
62
+ // An open pass this old has nobody on it. The cycle it has to clear is a review
63
+ // landing and a worker pushing a follow-up, measured between ten and thirty
64
+ // minutes across a day of runs on 2026-08-14, so two hours sits about four times
65
+ // past the slowest observed and still well inside a working session. A project
66
+ // whose workers run longer than that raises it here.
67
+ export const STALE_AFTER = 7200
68
+
69
+ // The heading strings are owned elsewhere and pinned here. `review-pr` writes
70
+ // the review pair, `review-address` the reply family, `canon pr evidence` the
71
+ // evidence heading, and `review-ui` the UI pair. All of these ship separately,
72
+ // so a heading added in one of them breaks a test here that no check reaches
73
+ // across. The review family reaches this file through `canon pr review-state`,
74
+ // so its two headings are pinned only for the fallback that answers a target
75
+ // whose CLI predates that verb.
76
+ const REVIEW_HEADINGS = ['## Review', '## Review closed']
77
+ const REPLY_HEADINGS = [
78
+ '## Review response',
79
+ '## Rebase',
80
+ '## Post-review findings',
81
+ ]
82
+ // An evidence comment answers no review, so it stays out of the reply family
83
+ // and is excluded from the unmatched filter on its own.
84
+ const KNOWN_HEADINGS = [...REVIEW_HEADINGS, ...REPLY_HEADINGS, '## Evidence']
85
+ const UI_HEADINGS = ['## UI review', '## UI review closed']
86
+ const UI_MARKER = /^<!-- review-ui: head=([0-9a-f]{7,40}) -->$/
87
+
88
+ const firstLine = (body: null | string | undefined): string =>
89
+ (body ?? '').split('\n')[0].replace(/\r$/, '')
90
+
91
+ const toEpoch = (stamp: null | string | undefined): null | number => {
92
+ if (stamp === null || stamp === undefined) return null
93
+ const ms = Date.parse(stamp)
94
+
95
+ return Number.isNaN(ms) ? null : Math.floor(ms / 1000)
96
+ }
97
+
98
+ const ageOf = (at: number, now: number): number => (at === 0 ? 0 : now - at)
99
+
100
+ /**
101
+ * The count alone answers whether a reply is new to this script, which is not
102
+ * the same question as whether it is newer than the pass it answers, so the
103
+ * newest reply's stamp comes out of the same selection.
104
+ */
105
+ export const replyState = (
106
+ comments: Comment[],
107
+ ): { count: number; newestAt: number } => {
108
+ const replies = comments.filter((comment) =>
109
+ REPLY_HEADINGS.includes(firstLine(comment.body)),
110
+ )
111
+ const stamps = replies
112
+ .map((reply) => toEpoch(reply.createdAt))
113
+ .filter((stamp): stamp is number => stamp !== null)
114
+
115
+ return { count: replies.length, newestAt: Math.max(0, ...stamps) }
116
+ }
117
+
118
+ /**
119
+ * A comment matching no family is the gap this filter surfaces rather than
120
+ * absorbs: a worker inventing a new heading would otherwise reach this script
121
+ * as silence, indistinguishable from no comment at all.
122
+ */
123
+ export const unmatchedState = (
124
+ comments: Comment[],
125
+ ): { count: number; heading: string } => {
126
+ const unclassified = comments
127
+ .map((comment) => firstLine(comment.body))
128
+ .filter((line) => line.startsWith('## ') && !KNOWN_HEADINGS.includes(line))
129
+
130
+ return { count: unclassified.length, heading: unclassified.at(-1) ?? 'none' }
131
+ }
132
+
133
+ /**
134
+ * Reads the verb's record. `source` is what this branches on rather than an
135
+ * exit status, since a shell profile can wrap canon in a function that flattens
136
+ * every refusal to zero. A refusal record carries `reason` and no `source`, and
137
+ * yields null here, which sends the read to the fallback.
138
+ */
139
+ export const scopeFromVerb = (record: unknown, now: number): null | Scope => {
140
+ if (typeof record !== 'object' || record === null) return null
141
+ const fields = record as Record<string, unknown>
142
+ if (typeof fields.source !== 'string' || fields.source === '') return null
143
+
144
+ const submitted = fields.submittedAt
145
+ const at =
146
+ submitted === null || submitted === undefined
147
+ ? 0
148
+ : toEpoch(String(submitted))
149
+ if (at === null) return null
150
+
151
+ const read = fields.readAt
152
+ const readAt =
153
+ read === null || read === undefined ? at : toEpoch(String(read))
154
+ if (readAt === null) return null
155
+
156
+ return {
157
+ age: ageOf(at, now),
158
+ commit: typeof fields.commit === 'string' ? fields.commit : 'none',
159
+ readAt,
160
+ state: typeof fields.state === 'string' ? fields.state : 'none',
161
+ }
162
+ }
163
+
164
+ /**
165
+ * The fallback for a target whose CLI predates the verb. It reads the
166
+ * submission stamps, so it carries the defect the verb closes: a push landing
167
+ * between a pass's read and its post moves the stamp onto a commit that pass
168
+ * never opened. It does not parse the marker, since a second reader of that
169
+ * format here is the drift the verb exists to prevent.
170
+ */
171
+ export const scopeFallback = (payload: Payload, now: number): Scope => {
172
+ const last = (payload.reviews ?? [])
173
+ .filter((review) => REVIEW_HEADINGS.includes(firstLine(review.body)))
174
+ .at(-1)
175
+ if (last === undefined) {
176
+ return { age: 0, commit: 'none', readAt: 0, state: 'none' }
177
+ }
178
+
179
+ const at = toEpoch(last.submittedAt) ?? 0
180
+
181
+ return {
182
+ age: ageOf(at, now),
183
+ commit: last.commit?.oid ?? 'none',
184
+ readAt: at,
185
+ state: firstLine(last.body) === '## Review' ? 'open' : 'closed',
186
+ }
187
+ }
188
+
189
+ /**
190
+ * The UI family is posted as a pull request review, so it is read off
191
+ * `reviews`. The newest UI heading decides the state, and the head it covered
192
+ * comes from the marker on the body's last line, since the submission stamp
193
+ * moves onto a commit pushed while the pass drove. A marker may name a short
194
+ * sha, so coverage is a prefix match.
195
+ */
196
+ export const uiState = (payload: Payload, head: string): string => {
197
+ const last = (payload.reviews ?? [])
198
+ .filter((review) => UI_HEADINGS.includes(firstLine(review.body)))
199
+ .at(-1)
200
+ if (last === undefined) return 'none'
201
+
202
+ const heading = firstLine(last.body) === '## UI review' ? 'open' : 'closed'
203
+ const lastLine = (last.body ?? '')
204
+ .split('\n')
205
+ .map((line) => line.replace(/\r$/, ''))
206
+ .filter((line) => line !== '')
207
+ .at(-1)
208
+ const marked = lastLine === undefined ? null : UI_MARKER.exec(lastLine)
209
+ const commit = marked?.[1] ?? last.commit?.oid ?? 'none'
210
+ const covered = commit !== 'none' && head.startsWith(commit)
211
+
212
+ return `${heading}-${covered ? 'head' : 'behind'}-${commit.slice(0, 7)}`
213
+ }
214
+
215
+ /** Takes the first eight fields and ignores the rest, so a short or padded line still reads. */
216
+ export const parseBaseline = (line: string): Baseline => {
217
+ const fields = line.split(' ')
218
+
219
+ return {
220
+ head: fields[1] ?? '',
221
+ merges: fields[4] ?? '',
222
+ number: fields[0] ?? '',
223
+ prior: fields[2] ?? '',
224
+ resp: fields[3] ?? '',
225
+ state: fields[5] ?? '',
226
+ ui: fields[7] ?? '',
227
+ unmatched: fields[6] ?? '',
228
+ }
229
+ }
230
+
231
+ export const formatBaseline = (entry: Baseline): string =>
232
+ [
233
+ entry.number,
234
+ entry.head,
235
+ entry.prior,
236
+ entry.resp,
237
+ entry.merges,
238
+ entry.state,
239
+ entry.unmatched,
240
+ entry.ui,
241
+ ].join(' ')
242
+
243
+ const short = (head: string): string => head.slice(0, 7)
244
+
245
+ export interface Classification {
246
+ baseline: Baseline
247
+ isChanged: boolean
248
+ lines: string[]
249
+ }
250
+
251
+ export interface ClassifyContext {
252
+ base: string
253
+ /** Commits between the prior pass and the head, zero when the range does not resolve. */
254
+ countSince: (prior: string, head: string) => number
255
+ }
256
+
257
+ /**
258
+ * A pull request first seen here may already carry a pass, when it opened and
259
+ * was reviewed between two runs, so it reports SEEN rather than a new pull
260
+ * request that invites a first pass the thread already has.
261
+ */
262
+ const classifyFirstSighting = (
263
+ row: Row,
264
+ context: ClassifyContext,
265
+ ): Classification => {
266
+ const baseline: Baseline = {
267
+ head: row.head,
268
+ merges: row.merges,
269
+ number: row.number,
270
+ prior: row.prior,
271
+ resp: String(row.resp),
272
+ state: row.heading,
273
+ ui: row.ui,
274
+ unmatched: String(row.unmatchedCount),
275
+ }
276
+ const at = short(row.head)
277
+ let line = `OPENED #${row.number} at ${at}, ${row.merges} against ${context.base}`
278
+ if (row.prior === row.head) {
279
+ line = `SEEN #${row.number} at ${at}, first sighting, already covered by a pass`
280
+ } else if (row.merges === 'unknown') {
281
+ line = `OPENED #${row.number} at ${at}`
282
+ }
283
+
284
+ return { baseline, isChanged: true, lines: [line] }
285
+ }
286
+
287
+ const classifyUi = (row: Row, old: Baseline): string[] => {
288
+ if (row.ui === '' || row.ui === 'none' || row.ui === old.ui) return []
289
+
290
+ const heading = row.ui.split('-')[0]
291
+ const commit = row.ui.split('-').at(-1)
292
+ if (row.ui.startsWith('open-head-')) {
293
+ return [`UI-OPEN #${row.number} open at ${commit}`]
294
+ }
295
+ if (row.ui.startsWith('closed-head-')) {
296
+ return [`UI-CLOSED #${row.number} closed at ${commit}`]
297
+ }
298
+
299
+ return [
300
+ `UI-STALE #${row.number} ${heading} at ${commit}, behind ${short(row.head)}`,
301
+ ]
302
+ }
303
+
304
+ /** The heading field is the one column a classification rewrites. */
305
+ export const classify = (
306
+ row: Row,
307
+ old: Baseline | undefined,
308
+ context: ClassifyContext,
309
+ ): Classification => {
310
+ if (old === undefined) return classifyFirstSighting(row, context)
311
+
312
+ const lines: string[] = []
313
+ let state = row.heading
314
+
315
+ // A conflict arrives from the base moving, not from the branch, so it is
316
+ // reported on the transition rather than only when the head changes.
317
+ if (row.merges === 'conflict' && old.merges !== 'conflict') {
318
+ lines.push(`CONFLICT #${row.number} no longer merges into ${context.base}`)
319
+ }
320
+
321
+ // A rising count is what is new here. It fires on a tracked pull request
322
+ // only: a first sighting takes whatever already sits on the thread as its
323
+ // baseline rather than flagging history retroactively.
324
+ if (row.unmatchedCount > (Number.parseInt(old.unmatched, 10) || 0)) {
325
+ lines.push(
326
+ `UNMATCHED #${row.number} posted under '${row.unmatchedHeading}'`,
327
+ )
328
+ }
329
+
330
+ // The UI pass reports on its transition, since its state moves on a post or
331
+ // on a push and either is the moment it is owed an action.
332
+ lines.push(...classifyUi(row, old))
333
+
334
+ const oldResp = Number.parseInt(old.resp, 10)
335
+ if (row.head !== old.head) {
336
+ if (row.prior === 'none') {
337
+ lines.push(
338
+ `MOVED #${row.number} -> ${short(row.head)}, never reviewed`,
339
+ )
340
+ } else if (row.prior === row.head) {
341
+ // An out-of-band pass reviewed this head before the poll saw it move, so
342
+ // the range is empty because it is covered rather than because it broke.
343
+ lines.push(
344
+ `SEEN #${row.number} -> ${short(row.head)}, already covered by the last pass`,
345
+ )
346
+ } else {
347
+ const since = context.countSince(row.prior, row.head)
348
+ lines.push(
349
+ since > 0
350
+ ? `MOVED #${row.number} -> ${short(row.head)}, ${since} commit(s) since your last pass`
351
+ : `MOVED #${row.number} -> ${short(row.head)}, range unresolved, likely force-pushed`,
352
+ )
353
+ }
354
+ } else if (
355
+ row.resp > oldResp &&
356
+ (row.passAt === 0 || row.replyAt > row.passAt)
357
+ ) {
358
+ // The count says the reply is new to this script and the stamp says it is
359
+ // newer than the pass, and both are needed. A worker answers a finding and
360
+ // the reviewing session closes out seconds later, so the count alone
361
+ // reports an answered thread on the next run. A pull request with no pass
362
+ // reads as stamp zero and reports. The gate sits in the condition so a
363
+ // suppressed reply falls through to STALLED below.
364
+ lines.push(`RESPONSE #${row.number} answered with no new commit`)
365
+ } else if (
366
+ row.heading === 'open' &&
367
+ old.state !== 'reported' &&
368
+ row.prior === row.head &&
369
+ row.age >= STALE_AFTER
370
+ ) {
371
+ // The last pass is open, it covers the head so no commit followed it, and
372
+ // no reply came either. Any two of these describe an ordinary review
373
+ // waiting on a worker, so the age carries the third. STALLED reports a
374
+ // standing condition, so the written marker keeps it from firing on every
375
+ // later run.
376
+ lines.push(
377
+ `STALLED #${row.number} open at ${short(row.head)}, no commit or reply in ${Math.floor(row.age / 3600)}h`,
378
+ )
379
+ state = 'reported'
380
+ } else if (row.heading === 'open' && old.state === 'reported') {
381
+ // Carry the marker rather than the freshly derived heading, or the state
382
+ // re-enters next run and STALLED oscillates instead of reporting once.
383
+ state = 'reported'
384
+ }
385
+
386
+ const baseline: Baseline = {
387
+ head: row.head,
388
+ merges: row.merges,
389
+ number: row.number,
390
+ prior: row.prior,
391
+ resp: String(row.resp),
392
+ state,
393
+ ui: row.ui === '' ? old.ui : row.ui,
394
+ unmatched: String(row.unmatchedCount),
395
+ }
396
+
397
+ return { baseline, isChanged: lines.length > 0, lines }
398
+ }
399
+
400
+ /**
401
+ * A pull request this run could not read keeps the line it had, so the GONE
402
+ * sweep does not read the gap as a merge. Its heading is blanked to a value no
403
+ * branch matches unless it already reads `reported`, since echoing it intact
404
+ * would classify a pull request this run never reached.
405
+ */
406
+ export const carryForward = (old: Baseline): Baseline => ({
407
+ ...old,
408
+ state: old.state === 'reported' ? 'reported' : 'carried',
409
+ })
410
+
411
+ const parseJson = (text: string): unknown => {
412
+ try {
413
+ return JSON.parse(text)
414
+ } catch {
415
+ return null
416
+ }
417
+ }
418
+
419
+ type Snapshot = { baseline: Baseline } | { row: Row }
420
+
421
+ const nowSeconds = (): number => Math.floor(Date.now() / 1000)
422
+
423
+ const readHead = (number: string, payload: Payload): string => {
424
+ // The tip is the authority for the head, not the pull request object, which
425
+ // lags the ref by up to a minute after a push. The object's head is the
426
+ // fallback for a target on an older binary with no `pr head` verb.
427
+ const record = parseJson(
428
+ run('canon', ['pr', 'head', number, '--json']).stdout,
429
+ )
430
+ const tip =
431
+ typeof record === 'object' && record !== null
432
+ ? (record as Record<string, unknown>).tip
433
+ : undefined
434
+
435
+ return typeof tip === 'string' && tip !== ''
436
+ ? tip
437
+ : (payload.headRefOid ?? '')
438
+ }
439
+
440
+ /**
441
+ * `gh pr view --json mergeable` reports UNKNOWN until GitHub finishes
442
+ * computing it, which is exactly when a poll asks. merge-tree answers locally
443
+ * against the base this machine has. It exits non-zero on a ref it cannot
444
+ * resolve as well as on a real conflict, so both sides are checked first.
445
+ */
446
+ const readMerges = (number: string, head: string, ref: string): string => {
447
+ run('git', ['fetch', '-q', 'origin', `pull/${number}/head`])
448
+ if (
449
+ !run('git', ['cat-file', '-e', `${head}^{commit}`]).isOk ||
450
+ !run('git', ['rev-parse', '--verify', '-q', ref]).isOk
451
+ ) {
452
+ return 'unknown'
453
+ }
454
+
455
+ return run('git', ['merge-tree', '--write-tree', ref, head]).isOk
456
+ ? 'clean'
457
+ : 'conflict'
458
+ }
459
+
460
+ const buildRow = (
461
+ number: string,
462
+ payload: Payload,
463
+ head: string,
464
+ ref: string,
465
+ ): Row => {
466
+ const now = nowSeconds()
467
+ const replies = replyState(payload.comments ?? [])
468
+ const unmatched = unmatchedState(payload.comments ?? [])
469
+ const record = parseJson(
470
+ run('canon', ['pr', 'review-state', number, '--json']).stdout,
471
+ )
472
+ const scope = scopeFromVerb(record, now) ?? scopeFallback(payload, now)
473
+
474
+ return {
475
+ age: scope.age,
476
+ head,
477
+ heading: scope.state,
478
+ merges: readMerges(number, head, ref),
479
+ number,
480
+ passAt: scope.readAt,
481
+ prior: scope.commit,
482
+ replyAt: replies.newestAt,
483
+ resp: replies.count,
484
+ ui: uiState(payload, head),
485
+ unmatchedCount: unmatched.count,
486
+ unmatchedHeading: unmatched.heading,
487
+ }
488
+ }
489
+
490
+ const countSince =
491
+ (number: string) =>
492
+ (prior: string, head: string): number => {
493
+ // The range needs both commits local, and a force-push leaves the prior one
494
+ // unreachable, so a range that does not resolve counts as zero.
495
+ run('git', ['fetch', '-q', 'origin', `pull/${number}/head`])
496
+ const log = run('git', ['log', '--oneline', `${prior}..${head}`])
497
+
498
+ return log.isOk ? log.stdout.split('\n').filter((l) => l !== '').length : 0
499
+ }
500
+
501
+ const main = (): number => {
502
+ const root = mainRoot()
503
+ if (root === null) {
504
+ console.error('poll: not a git repository, so nothing is classified')
505
+
506
+ return 1
507
+ }
508
+
509
+ // The baseline is per-machine mutable state, so it stays in gitignored
510
+ // scratch even though the script is tracked.
511
+ const stateDir = join(root, '.canon', 'tmp', 'pr', 'poll')
512
+ mkdirSync(stateDir, { recursive: true })
513
+ const statePath = join(stateDir, 'baseline.txt')
514
+ appendFileSync(statePath, '')
515
+
516
+ const ref = baseRef()
517
+ const base = baseBranch(ref)
518
+ const known = new Map<string, Baseline>()
519
+ const previous = readFileSync(statePath, 'utf8')
520
+ .split('\n')
521
+ .filter((line) => line !== '')
522
+ .map(parseBaseline)
523
+ for (const entry of previous) {
524
+ if (!known.has(entry.number)) known.set(entry.number, entry)
525
+ }
526
+
527
+ run('git', ['fetch', '-q', 'origin', base])
528
+
529
+ // A failed list reads as no open pull requests, which reports every tracked
530
+ // one as GONE. That is a louder wrong answer than the one this script was
531
+ // fixed for, so the run aborts rather than classify on it.
532
+ const listed = run('gh', [
533
+ 'pr',
534
+ 'list',
535
+ '--state',
536
+ 'open',
537
+ '--json',
538
+ 'number',
539
+ '--jq',
540
+ '.[].number',
541
+ ])
542
+ if (!listed.isOk) {
543
+ console.error('poll: the open pull request list could not be read')
544
+ console.error(
545
+ 'poll: nothing is classified this run and the baseline is unchanged',
546
+ )
547
+
548
+ return 1
549
+ }
550
+
551
+ const snapshots: Snapshot[] = []
552
+ for (const number of listed.stdout.split(/\s+/).filter((n) => n !== '')) {
553
+ // A query that failed and a pull request with no reviews both arrive as
554
+ // an empty result, so one query per pull request gives the failure a
555
+ // single place to surface.
556
+ const view = run('gh', [
557
+ 'pr',
558
+ 'view',
559
+ number,
560
+ '--json',
561
+ 'headRefOid,reviews,comments',
562
+ ])
563
+ const payload = view.isOk
564
+ ? (parseJson(view.stdout) as Payload | null)
565
+ : null
566
+ const old = known.get(number)
567
+ const unreadable = (reason: string): void => {
568
+ if (old === undefined) {
569
+ console.error(
570
+ `poll: #${number} ${reason}, and it has no last known state, so it goes unclassified`,
571
+ )
572
+
573
+ return
574
+ }
575
+ console.error(
576
+ `poll: #${number} ${reason}, so it keeps its last known state and goes unclassified`,
577
+ )
578
+ snapshots.push({ baseline: carryForward(old) })
579
+ }
580
+
581
+ if (payload === null || typeof payload !== 'object') {
582
+ unreadable('could not be read')
583
+ continue
584
+ }
585
+
586
+ const head = readHead(number, payload)
587
+ if (head === '') {
588
+ unreadable('returned no head')
589
+ continue
590
+ }
591
+
592
+ snapshots.push({ row: buildRow(number, payload, head, ref) })
593
+ }
594
+
595
+ let isChanged = false
596
+ const final: string[] = []
597
+ const seen = new Set<string>()
598
+ for (const snapshot of snapshots) {
599
+ if ('baseline' in snapshot) {
600
+ seen.add(snapshot.baseline.number)
601
+ final.push(formatBaseline(snapshot.baseline))
602
+ continue
603
+ }
604
+
605
+ const { row } = snapshot
606
+ seen.add(row.number)
607
+ const result = classify(row, known.get(row.number), {
608
+ base,
609
+ countSince: countSince(row.number),
610
+ })
611
+ for (const line of result.lines) console.log(line)
612
+ if (result.isChanged) isChanged = true
613
+ final.push(formatBaseline(result.baseline))
614
+ }
615
+
616
+ for (const entry of previous) {
617
+ if (seen.has(entry.number)) continue
618
+ console.log(`GONE #${entry.number} merged or closed`)
619
+ isChanged = true
620
+ }
621
+
622
+ if (!isChanged) console.log('No movement.')
623
+ writeFileSync(statePath, final.map((line) => `${line}\n`).join(''))
624
+
625
+ return 0
626
+ }
627
+
628
+ if (import.meta.main) process.exitCode = main()
@@ -0,0 +1,69 @@
1
+ // Shared by the poll and the watch. Imports are `node:` builtins only, since
2
+ // the plugin ships `claude/` alone and a cached plugin root carries no
3
+ // `node_modules`.
4
+ import { spawnSync } from 'node:child_process'
5
+
6
+ export interface RunResult {
7
+ isOk: boolean
8
+ stdout: string
9
+ }
10
+
11
+ /** Runs a command to completion. A command that cannot start reads as failed. */
12
+ export const run = (command: string, args: string[]): RunResult => {
13
+ const result = spawnSync(command, args, {
14
+ encoding: 'utf8',
15
+ stdio: ['ignore', 'pipe', 'ignore'],
16
+ })
17
+
18
+ return { isOk: result.status === 0, stdout: result.stdout ?? '' }
19
+ }
20
+
21
+ /**
22
+ * The main worktree root rather than the current one, so a script started from
23
+ * a linked worktree reads the same repository and state as one started from
24
+ * main.
25
+ */
26
+ export const mainRoot = (): null | string => {
27
+ const listed = run('git', ['worktree', 'list', '--porcelain'])
28
+ if (!listed.isOk) return null
29
+
30
+ const first = listed.stdout
31
+ .split('\n')
32
+ .find((line) => line.startsWith('worktree '))
33
+
34
+ return first === undefined ? null : first.slice('worktree '.length)
35
+ }
36
+
37
+ /** The repository `.git` path, which is the field a session roster row carries. */
38
+ export const gitPath = (root: string): string => `${root}/.git`
39
+
40
+ /**
41
+ * The base ref is read rather than assumed, since this ships to projects that
42
+ * do not all call it `main`. A wrong base is not a visible failure: merge-tree
43
+ * reports every pull request as conflicted against a ref that does not resolve.
44
+ */
45
+ export const baseRef = (): string => {
46
+ const symbolic = run('git', [
47
+ 'symbolic-ref',
48
+ '--quiet',
49
+ '--short',
50
+ 'refs/remotes/origin/HEAD',
51
+ ])
52
+ const ref = symbolic.stdout.trim()
53
+ if (symbolic.isOk && ref !== '') return ref
54
+
55
+ const repo = run('gh', [
56
+ 'repo',
57
+ 'view',
58
+ '--json',
59
+ 'defaultBranchRef',
60
+ '--jq',
61
+ '.defaultBranchRef.name',
62
+ ])
63
+ const name = repo.isOk ? repo.stdout.trim() : ''
64
+
65
+ return `origin/${name === '' ? 'main' : name}`
66
+ }
67
+
68
+ export const baseBranch = (ref: string): string =>
69
+ ref.startsWith('origin/') ? ref.slice('origin/'.length) : ref