@erclx/aitk 0.104.0 → 0.105.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.
@@ -0,0 +1,383 @@
1
+ import { execaSync } from 'execa'
2
+ import { gitEnv } from '@/git-env'
3
+
4
+ /**
5
+ * Preferred first, matching `src/tasks/trunk.ts`. A clone with no remote still
6
+ * answers off its local trunk, and a local `main` trailing the remote widens
7
+ * the range rather than narrowing it, which over-reports instead of hiding.
8
+ */
9
+ const TRUNK_REFS = ['origin/main', 'main'] as const
10
+
11
+ /**
12
+ * The extensions this check can pair. A test sits beside its subject under one
13
+ * name throughout this corpus, so the weakest assumption available is also the
14
+ * one the corpus already honors. Every other extension is read past and named,
15
+ * since a bash script and its test are related by nothing a filename carries.
16
+ */
17
+ export const SOURCE_EXTENSIONS = ['.ts', '.tsx'] as const
18
+
19
+ /** The suffix removed to derive the subject a test covers. */
20
+ export const TEST_SUFFIXES = ['.test.ts', '.test.tsx'] as const
21
+
22
+ /** A `%H` line, which is 40 hex characters under sha1 and 64 under sha256. */
23
+ const COMMIT = /^[0-9a-f]{40,64}$/
24
+
25
+ /** A `--name-status` line: a status letter, a tab, then one path. */
26
+ const NAME_STATUS = /^([A-Z])\d*\t(.+)$/
27
+
28
+ export type Verdict = 'satisfied' | 'implementation-first' | 'unclassified'
29
+
30
+ export interface PairRecord {
31
+ /** The implementation path, which is what a subject is named by. */
32
+ readonly subject: string
33
+ /** The test paired to it, or `null` when the pairing found no partner. */
34
+ readonly test: string | null
35
+ readonly verdict: Verdict
36
+ readonly implementationCommit: string | null
37
+ readonly testCommit: string | null
38
+ /** Why the verdict reads the way it does, in the report's own words. */
39
+ readonly reason: string
40
+ }
41
+
42
+ export interface Scope {
43
+ readonly extensions: readonly string[]
44
+ readonly testSuffixes: readonly string[]
45
+ }
46
+
47
+ export type TestOrderReport =
48
+ | {
49
+ readonly kind: 'measured'
50
+ readonly base: string
51
+ readonly head: string
52
+ readonly scope: Scope
53
+ readonly satisfied: readonly PairRecord[]
54
+ readonly findings: readonly PairRecord[]
55
+ readonly unclassified: readonly PairRecord[]
56
+ /** Changed paths outside the pairing's reach, named rather than counted. */
57
+ readonly ignored: readonly string[]
58
+ }
59
+ | { readonly kind: 'unreadable'; readonly reason: string }
60
+
61
+ export interface TestOrderOptions {
62
+ /** The far side of the range, defaulting to the merge base against the trunk. */
63
+ readonly base?: string
64
+ }
65
+
66
+ /** One path as one commit in the range touched it. */
67
+ interface Change {
68
+ readonly path: string
69
+ readonly status: string
70
+ readonly commit: string
71
+ /** Position in the range, oldest first, which is what orders a pair. */
72
+ readonly order: number
73
+ }
74
+
75
+ /**
76
+ * Reads `--name-status` log output, oldest commit first, into one entry per
77
+ * path per commit.
78
+ *
79
+ * The order field rather than the commit is what a comparison reads, since two
80
+ * commits carry no ordering a caller can derive from their hashes alone.
81
+ */
82
+ function parseChanges(output: string): Change[] {
83
+ const changes: Change[] = []
84
+ let commit = ''
85
+ let order = -1
86
+
87
+ for (const line of output.split('\n')) {
88
+ const trimmed = line.trimEnd()
89
+ if (trimmed === '') continue
90
+
91
+ if (COMMIT.test(trimmed)) {
92
+ commit = trimmed
93
+ order += 1
94
+ continue
95
+ }
96
+
97
+ const match = NAME_STATUS.exec(trimmed)
98
+ if (match === null || commit === '') continue
99
+
100
+ changes.push({ path: match[2], status: match[1], commit, order })
101
+ }
102
+
103
+ return changes
104
+ }
105
+
106
+ /** Whether a path is a test this check can derive a subject from. */
107
+ function testSuffix(path: string): string | undefined {
108
+ return TEST_SUFFIXES.find((suffix) => path.endsWith(suffix))
109
+ }
110
+
111
+ /**
112
+ * The implementation a test covers, derived by removing the test suffix.
113
+ *
114
+ * A behavior split across two modules pairs wrongly or not at all under this,
115
+ * which is what the unclassified bucket exists to catch rather than hide.
116
+ */
117
+ function subjectOf(path: string): string {
118
+ const suffix = testSuffix(path)
119
+ if (suffix === undefined) return path
120
+ return `${path.slice(0, -suffix.length)}${suffix.replace('.test', '')}`
121
+ }
122
+
123
+ /** The one test path that would cover `subject` under the beside convention. */
124
+ function testPathFor(subject: string): string {
125
+ return subject.replace(/\.(tsx?)$/, '.test.$1')
126
+ }
127
+
128
+ /**
129
+ * Whether a path is an implementation this check can pair. A declaration file
130
+ * carries no behavior to test, so it is read past rather than reported as a
131
+ * module nothing covers.
132
+ */
133
+ function isImplementation(path: string): boolean {
134
+ if (path.endsWith('.d.ts')) return false
135
+ if (testSuffix(path) !== undefined) return false
136
+ return SOURCE_EXTENSIONS.some((extension) => path.endsWith(extension))
137
+ }
138
+
139
+ /** The first commit in the range that added `path`, or `undefined`. */
140
+ function introduction(
141
+ changes: readonly Change[],
142
+ path: string,
143
+ ): Change | undefined {
144
+ return changes.find((change) => change.path === path && change.status === 'A')
145
+ }
146
+
147
+ function classify(
148
+ changes: readonly Change[],
149
+ atBase: ReadonlySet<string>,
150
+ ): {
151
+ satisfied: PairRecord[]
152
+ findings: PairRecord[]
153
+ unclassified: PairRecord[]
154
+ } {
155
+ const satisfied: PairRecord[] = []
156
+ const findings: PairRecord[] = []
157
+ const unclassified: PairRecord[] = []
158
+
159
+ // Keyed by subject so a module whose implementation and test both moved is
160
+ // one record rather than two, and so a test with no partner still reports
161
+ // under the implementation path a reader would go looking for.
162
+ const subjects = new Set<string>()
163
+ for (const change of changes) {
164
+ if (isImplementation(change.path)) subjects.add(change.path)
165
+ else if (testSuffix(change.path) !== undefined) {
166
+ subjects.add(subjectOf(change.path))
167
+ }
168
+ }
169
+
170
+ for (const subject of [...subjects].sort()) {
171
+ const candidate = testPathFor(subject)
172
+ const testChange = introduction(changes, candidate)
173
+ const testAtBase = atBase.has(candidate)
174
+ const test = testAtBase || testChange !== undefined ? candidate : undefined
175
+
176
+ const implementation = introduction(changes, subject)
177
+
178
+ if (implementation === undefined) {
179
+ unclassified.push({
180
+ subject,
181
+ test: test ?? null,
182
+ verdict: 'unclassified',
183
+ implementationCommit: null,
184
+ testCommit: null,
185
+ reason: atBase.has(subject)
186
+ ? 'the implementation predates the range, so a change to it cannot be separated from a refactor'
187
+ : 'no implementation reached the range beside this test',
188
+ })
189
+ continue
190
+ }
191
+
192
+ if (test === undefined) {
193
+ unclassified.push({
194
+ subject,
195
+ test: null,
196
+ verdict: 'unclassified',
197
+ implementationCommit: implementation.commit,
198
+ testCommit: null,
199
+ reason: 'no test names this module, so the ordering has no second side',
200
+ })
201
+ continue
202
+ }
203
+
204
+ // A paired test the range never added is one that already sat at the base
205
+ // commit, since those are the only two ways `test` gets a value at all.
206
+ if (testChange === undefined) {
207
+ satisfied.push({
208
+ subject,
209
+ test,
210
+ verdict: 'satisfied',
211
+ implementationCommit: implementation.commit,
212
+ testCommit: null,
213
+ reason: 'the test predates the range',
214
+ })
215
+ continue
216
+ }
217
+
218
+ // One commit carrying both sides counts as satisfied. The rule asks that
219
+ // the test not come after, and a single commit is the shape a small change
220
+ // takes here, so reporting it would flag most of the corpus.
221
+ const record: PairRecord = {
222
+ subject,
223
+ test,
224
+ verdict:
225
+ testChange.order <= implementation.order
226
+ ? 'satisfied'
227
+ : 'implementation-first',
228
+ implementationCommit: implementation.commit,
229
+ testCommit: testChange.commit,
230
+ reason:
231
+ testChange.order <= implementation.order
232
+ ? 'the test reached history no later than the implementation'
233
+ : 'the implementation reached history before the test covering it',
234
+ }
235
+
236
+ if (record.verdict === 'satisfied') satisfied.push(record)
237
+ else findings.push(record)
238
+ }
239
+
240
+ return { satisfied, findings, unclassified }
241
+ }
242
+
243
+ /**
244
+ * Where an implementation reached a commit ahead of the test covering it,
245
+ * between `base` and the current `HEAD` of the tree at `root`.
246
+ *
247
+ * This reports and never gates. Pairing a test to an implementation is a
248
+ * judgment, so a change the pairing cannot read lands in `unclassified` with
249
+ * its reason stated rather than being counted as a pass. Coverage is narrower
250
+ * than the rule the check answers to, and `scope` and `ignored` are what say so
251
+ * on every run.
252
+ */
253
+ export function readTestOrder(
254
+ root: string,
255
+ options: TestOrderOptions = {},
256
+ ): TestOrderReport {
257
+ const head = revParse(root, 'HEAD')
258
+ if (head === undefined) {
259
+ return {
260
+ kind: 'unreadable',
261
+ reason: `No git history under ${root}. History is the only surface carrying the ordering, so there is nothing to read.`,
262
+ }
263
+ }
264
+
265
+ const base = resolveBase(root, options.base, head)
266
+ if (typeof base !== 'string') return base
267
+
268
+ const log = git(root, [
269
+ 'log',
270
+ '--reverse',
271
+ '--name-status',
272
+ '--no-renames',
273
+ '--format=%H',
274
+ `${base}..${head}`,
275
+ ])
276
+
277
+ if (log === undefined) {
278
+ return {
279
+ kind: 'unreadable',
280
+ reason: `Reading history between ${base} and HEAD failed under ${root}.`,
281
+ }
282
+ }
283
+
284
+ const tree = git(root, ['ls-tree', '-r', '--name-only', base])
285
+ if (tree === undefined) {
286
+ return {
287
+ kind: 'unreadable',
288
+ reason: `Reading the tree at ${base} failed under ${root}. Without it a test written before the range reads as absent.`,
289
+ }
290
+ }
291
+
292
+ const changes = parseChanges(log)
293
+ const atBase = new Set(tree.split('\n').filter((line) => line !== ''))
294
+
295
+ const ignored = [
296
+ ...new Set(
297
+ changes
298
+ .map((change) => change.path)
299
+ .filter(
300
+ (path) => !isImplementation(path) && testSuffix(path) === undefined,
301
+ ),
302
+ ),
303
+ ].sort()
304
+
305
+ return {
306
+ kind: 'measured',
307
+ base,
308
+ head,
309
+ scope: { extensions: SOURCE_EXTENSIONS, testSuffixes: TEST_SUFFIXES },
310
+ ...classify(changes, atBase),
311
+ ignored,
312
+ }
313
+ }
314
+
315
+ /**
316
+ * The far side of the range. A ref the caller named has to resolve, since
317
+ * falling back to the trunk there would measure a range nobody asked for. With
318
+ * no ref named, the merge base against the trunk scopes the run to the branch,
319
+ * and a repository carrying no trunk falls back to the root commit rather than
320
+ * refusing.
321
+ */
322
+ function resolveBase(
323
+ root: string,
324
+ ref: string | undefined,
325
+ head: string,
326
+ ): string | { kind: 'unreadable'; reason: string } {
327
+ if (ref !== undefined) {
328
+ const resolved = revParse(root, ref)
329
+ if (resolved === undefined) {
330
+ return {
331
+ kind: 'unreadable',
332
+ reason: `Ref ${ref} resolves to no commit in ${root}. Pass a commit this tree carries.`,
333
+ }
334
+ }
335
+ return resolved
336
+ }
337
+
338
+ for (const trunk of TRUNK_REFS) {
339
+ if (revParse(root, trunk) === undefined) continue
340
+ const merged = git(root, ['merge-base', head, trunk])
341
+ if (merged !== undefined && merged !== '') return merged
342
+ }
343
+
344
+ const rootCommits = git(root, ['rev-list', '--max-parents=0', head])
345
+ if (rootCommits === undefined || rootCommits === '') {
346
+ return {
347
+ kind: 'unreadable',
348
+ reason: `No base resolves against ${root}. Fetch origin or pass --base.`,
349
+ }
350
+ }
351
+
352
+ return rootCommits.split('\n')[0]
353
+ }
354
+
355
+ /**
356
+ * `execaSync` with git's repository-resolution variables stripped, so `-C`
357
+ * resolves against `root` and not against whatever repository a hook exported.
358
+ * `undefined` is the refusal, which every caller turns into its own reason.
359
+ */
360
+ function git(root: string, args: readonly string[]): string | undefined {
361
+ const result = execaSync('git', ['-C', root, ...args], {
362
+ reject: false,
363
+ env: gitEnv(),
364
+ extendEnv: false,
365
+ })
366
+
367
+ return result.exitCode === 0 ? result.stdout.trimEnd() : undefined
368
+ }
369
+
370
+ /**
371
+ * The commit a ref names. `^{commit}` is what turns a tag or a tree into the
372
+ * commit behind it, so a caller never compares a ref against another type.
373
+ */
374
+ function revParse(root: string, ref: string): string | undefined {
375
+ const resolved = git(root, [
376
+ 'rev-parse',
377
+ '--verify',
378
+ '--quiet',
379
+ `${ref}^{commit}`,
380
+ ])
381
+
382
+ return resolved === undefined || resolved === '' ? undefined : resolved.trim()
383
+ }
@@ -6,6 +6,7 @@ import { regenOne } from '@/indexes/regen'
6
6
  const TASKS_DIR = join('.claude', 'tasks')
7
7
  const ARCHIVE_DIR = join('.claude', 'task-archive')
8
8
  const PLANS_DIR = join('.claude', 'plans')
9
+ const PLANS_ARCHIVE_DIR = join('.claude', 'plans-archive')
9
10
 
10
11
  /**
11
12
  * Siblings that sit on the board without being tasks: the generated index, the
@@ -192,13 +193,152 @@ function isUnder(path: string, dir: string): boolean {
192
193
  * both, which is how `claude-docs` reads the same line. It accepts `../plans/x.md`
193
194
  * and `.claude/plans/x.md` as one file, so a gate reading only the first form
194
195
  * would pass the second and strand the plan this exists to protect.
196
+ *
197
+ * The resolved path is returned rather than a boolean, because the citation
198
+ * count below compares two tasks by where their targets land and not by the
199
+ * strings they wrote. A target outside the live plans folder yields nothing,
200
+ * which is an archived plan or a pointer into somewhere else entirely.
195
201
  */
196
- function isLivePlan(target: string, dir: string, root: string): boolean {
202
+ export function resolveLivePlan(
203
+ target: string,
204
+ dir: string,
205
+ root: string,
206
+ ): string | undefined {
197
207
  const plans = join(root, PLANS_DIR)
208
+ const fromBoard = resolve(dir, target)
209
+ const fromRoot = resolve(root, target)
210
+
211
+ if (isUnder(fromBoard, plans)) return fromBoard
212
+ if (isUnder(fromRoot, plans)) return fromRoot
213
+ return undefined
214
+ }
215
+
216
+ /**
217
+ * Names the other live tasks whose `Plan:` line lands on the same file. This is
218
+ * the rule `claude-docs` applies before it archives a plan, held here so one
219
+ * question has one implementation: a plan another live task still cites is a
220
+ * plan the sweep is correct to leave, and a guard that read the folder instead
221
+ * refused every task sharing one plan and deadlocked the board against the
222
+ * sweep that was behaving correctly.
223
+ *
224
+ * The closing task is excluded by name. It cites the plan itself, so counting
225
+ * it would never reach zero and the count would answer nothing.
226
+ */
227
+ export async function otherTasksCitingPlan(
228
+ dir: string,
229
+ root: string,
230
+ plan: string,
231
+ closing: string,
232
+ ): Promise<string[]> {
233
+ const stems = (await listTaskStems(dir)).filter((stem) => stem !== closing)
234
+
235
+ const read = await Promise.all(
236
+ stems.map(async (stem) => {
237
+ const target = readPlanTarget(
238
+ await readFile(join(dir, `${stem}.md`), 'utf8'),
239
+ )
240
+ const resolved = target && resolveLivePlan(target, dir, root)
241
+ return resolved === plan ? stem : undefined
242
+ }),
243
+ )
244
+
245
+ return read.filter((stem): stem is string => stem !== undefined)
246
+ }
247
+
248
+ /**
249
+ * Where a task's `Plan:` target resolves, which is what decides whether the
250
+ * plan is the sweep's to move. `unstated` is a task carrying no line at all,
251
+ * and it is distinct from a line resolving somewhere unexpected.
252
+ */
253
+ export const CITATION_LOCATIONS = [
254
+ 'unstated',
255
+ 'live',
256
+ 'archived',
257
+ 'outside',
258
+ ] as const
259
+
260
+ export type CitationLocation = (typeof CITATION_LOCATIONS)[number]
261
+
262
+ export interface PlanCitations {
263
+ readonly ok: true
264
+ readonly stem: string
265
+ readonly target: string | undefined
266
+ readonly location: CitationLocation
267
+ /** Other live tasks landing on the same file. Empty unless `location` is `live`. */
268
+ readonly citedBy: readonly string[]
269
+ }
270
+
271
+ export type CitationOutcome = PlanCitations | ArchiveRefused
272
+
273
+ /**
274
+ * Answers where one task's plan sits and who else holds it, which is the whole
275
+ * of the last-live-citation rule. `claude-docs` reads this rather than scanning
276
+ * the board itself, so the sweep that moves a plan and the gate that refuses a
277
+ * task archive cannot drift into disagreeing about which plan is free.
278
+ *
279
+ * It reports and never writes. The move, the retarget, and the ordering the two
280
+ * happen in belong to the caller, and a verb that performed them would be
281
+ * deciding a question the sweep is there to decide.
282
+ */
283
+ export async function planCitations(
284
+ root: string,
285
+ stem: string,
286
+ ): Promise<CitationOutcome> {
287
+ const dir = tasksDir(root)
288
+
289
+ if (!existsSync(dir)) {
290
+ return refuse('no-board', `No task board at ${relative(root, dir)}.`)
291
+ }
292
+
293
+ const stems = await listTaskStems(dir)
294
+ if (!stems.includes(stem)) {
295
+ return refuse('no-match', `No task named ${stem} on the board.`, stems)
296
+ }
297
+
298
+ const target = readPlanTarget(await readFile(join(dir, `${stem}.md`), 'utf8'))
299
+ if (!target) {
300
+ return {
301
+ ok: true,
302
+ stem,
303
+ target: undefined,
304
+ location: 'unstated',
305
+ citedBy: [],
306
+ }
307
+ }
308
+
309
+ const live = resolveLivePlan(target, dir, root)
310
+ if (!live) {
311
+ const location = resolvesUnder(target, dir, root, PLANS_ARCHIVE_DIR)
312
+ ? 'archived'
313
+ : 'outside'
314
+ return { ok: true, stem, target, location, citedBy: [] }
315
+ }
316
+
317
+ return {
318
+ ok: true,
319
+ stem,
320
+ target,
321
+ location: 'live',
322
+ citedBy: await otherTasksCitingPlan(dir, root, live, stem),
323
+ }
324
+ }
325
+
326
+ /**
327
+ * Runs the two-spelling resolution `resolveLivePlan` applies against a folder
328
+ * other than the live one, so an archived plan is read as archived whichever
329
+ * root the task wrote its path against.
330
+ */
331
+ function resolvesUnder(
332
+ target: string,
333
+ dir: string,
334
+ root: string,
335
+ folder: string,
336
+ ): boolean {
337
+ const resolved = join(root, folder)
198
338
 
199
339
  return (
200
- isUnder(resolve(dir, target), plans) ||
201
- isUnder(resolve(root, target), plans)
340
+ isUnder(resolve(dir, target), resolved) ||
341
+ isUnder(resolve(root, target), resolved)
202
342
  )
203
343
  }
204
344
 
@@ -310,12 +450,21 @@ export async function archiveTask(
310
450
  }
311
451
 
312
452
  const planTarget = readPlanTarget(text)
313
- if (planTarget && isLivePlan(planTarget, dir, root)) {
314
- return refuse(
315
- 'plan-unswept',
316
- `${stem} still points at a live plan. Run /claude-docs to sweep it first, then archive.`,
317
- [planTarget],
318
- )
453
+ const livePlan = planTarget && resolveLivePlan(planTarget, dir, root)
454
+
455
+ // A live plan is unswept only when nothing else on the board holds it. A plan
456
+ // several tasks share stays live by design, so refusing on the folder alone
457
+ // parked every one of those tasks behind a sweep that was right to decline.
458
+ if (livePlan) {
459
+ const shared = await otherTasksCitingPlan(dir, root, livePlan, stem)
460
+
461
+ if (shared.length === 0) {
462
+ return refuse(
463
+ 'plan-unswept',
464
+ `${stem} is the last task pointing at a live plan. Run /claude-docs to sweep it first, then archive.`,
465
+ [planTarget],
466
+ )
467
+ }
319
468
  }
320
469
 
321
470
  const destination = archiveDir(root)
@@ -0,0 +1,89 @@
1
+ import { execa } from 'execa'
2
+ import { gitEnv } from '@/git-env'
3
+
4
+ const GIT_TIMEOUT_MS = 10_000
5
+
6
+ /** Preferred first. A clone with no remote still answers off its local trunk. */
7
+ const TRUNK_REFS = ['origin/main', 'main'] as const
8
+
9
+ /**
10
+ * Reports whether a pull request's work reached the trunk. `undefined` means
11
+ * the trunk could not be read, which is a different answer from `false` and has
12
+ * to stay one: a check that degraded to "not landed" on an unreachable trunk
13
+ * would report a board silently rather than say what it failed to test.
14
+ */
15
+ export type TrunkReader = (pullRequest: number) => Promise<boolean | undefined>
16
+
17
+ /**
18
+ * Matches the two subjects a landed pull request leaves on the trunk: the
19
+ * `(#NNN)` suffix a squash merge writes, and the subject GitHub writes for a
20
+ * merge commit. Both carry the number in a form no other commit spells, so a
21
+ * pull request numbered 12 cannot match one numbered 123.
22
+ */
23
+ function grepArgs(pullRequest: number): string[] {
24
+ return [
25
+ '--extended-regexp',
26
+ '--grep',
27
+ `\\(#${pullRequest}\\)`,
28
+ '--grep',
29
+ `Merge pull request #${pullRequest} from`,
30
+ ]
31
+ }
32
+
33
+ /**
34
+ * Reads the trunk as this clone already holds it and never fetches. A validate
35
+ * run happens several times a sweep and a fetch per run is a cost the check
36
+ * does not carry today, so a clone behind the remote reports the row untested
37
+ * or leaves it parked rather than claiming work landed.
38
+ */
39
+ export function gitTrunkReader(root: string): TrunkReader {
40
+ const answered = new Map<number, boolean | undefined>()
41
+
42
+ return async (pullRequest) => {
43
+ if (answered.has(pullRequest)) return answered.get(pullRequest)
44
+
45
+ const landed = await readTrunk(root, pullRequest)
46
+ answered.set(pullRequest, landed)
47
+ return landed
48
+ }
49
+ }
50
+
51
+ async function readTrunk(
52
+ root: string,
53
+ pullRequest: number,
54
+ ): Promise<boolean | undefined> {
55
+ for (const ref of TRUNK_REFS) {
56
+ const result = await execa(
57
+ 'git',
58
+ [
59
+ '-C',
60
+ root,
61
+ 'log',
62
+ ref,
63
+ '-n',
64
+ '1',
65
+ '--format=%H',
66
+ ...grepArgs(pullRequest),
67
+ // The ref is a revision, and a repository tracking a path under the
68
+ // same name would otherwise fail the whole read as ambiguous.
69
+ '--',
70
+ ],
71
+ // `post-merge` drives the task verbs, and a hook exports the repository
72
+ // variables git reads ahead of `-C`, so the ambient environment would
73
+ // answer for whatever repository fired the hook.
74
+ {
75
+ reject: false,
76
+ timeout: GIT_TIMEOUT_MS,
77
+ env: gitEnv(),
78
+ extendEnv: false,
79
+ },
80
+ )
81
+
82
+ // A missing ref exits non-zero, which is the next ref's turn rather than an
83
+ // answer. An exit of zero with no commit is the ref saying the work is not
84
+ // on it, which is an answer and stops the walk.
85
+ if (result.exitCode === 0) return result.stdout.trim().length > 0
86
+ }
87
+
88
+ return undefined
89
+ }