@markjaquith/agency 2.55.0 → 2.56.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.
@@ -48,6 +48,7 @@ interface RegisteredWorktree {
48
48
  readonly path: string
49
49
  readonly head: string | null
50
50
  readonly branch: string | null
51
+ readonly dirty?: boolean
51
52
  }
52
53
 
53
54
  interface SyncChange {
@@ -106,6 +107,13 @@ interface SyncResult {
106
107
  readonly repositories: RepositorySetupResult
107
108
  }
108
109
 
110
+ export interface SyncProgress {
111
+ readonly stage: "repositories" | "pull-requests" | "executions"
112
+ readonly current: number
113
+ readonly total: number
114
+ readonly target?: string
115
+ }
116
+
109
117
  const parseWorktrees = (output: string): RegisteredWorktree[] => {
110
118
  const worktrees: RegisteredWorktree[] = []
111
119
  let current: RegisteredWorktree | undefined
@@ -152,6 +160,7 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
152
160
  readonly cwd?: string
153
161
  readonly apply?: boolean
154
162
  readonly now?: Date
163
+ readonly onProgress?: (progress: SyncProgress) => void
155
164
  } = {},
156
165
  ) =>
157
166
  Effect.gen(function* () {
@@ -177,6 +186,11 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
177
186
  cwd: root,
178
187
  apply: options.apply === true,
179
188
  })
189
+ options.onProgress?.({
190
+ stage: "repositories",
191
+ current: repositorySetup.repositories.length,
192
+ total: repositorySetup.repositories.length,
193
+ })
180
194
 
181
195
  const apply = options.apply === true
182
196
  const now = options.now ?? new Date()
@@ -192,6 +206,10 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
192
206
  })
193
207
  }
194
208
  const executions: ExecutionSyncState[] = []
209
+ const registeredByRepository = new Map<
210
+ string,
211
+ RegisteredWorktree[] | null
212
+ >()
195
213
  const runExternal = (
196
214
  args: readonly string[],
197
215
  commandOptions?: {
@@ -214,8 +232,9 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
214
232
  }),
215
233
  ),
216
234
  )
235
+ const taskRecords = yield* tasks.list(root)
217
236
  const records: ExecutionRecord[] = []
218
- for (const task of yield* tasks.list(root)) {
237
+ for (const task of taskRecords) {
219
238
  if ("phases" in task.data) {
220
239
  for (const phase of yield* phases.list(task.id, root)) {
221
240
  records.push({
@@ -237,6 +256,147 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
237
256
  })
238
257
  }
239
258
  }
259
+ const listRegistered = (repositoryPath: string) =>
260
+ Effect.gen(function* () {
261
+ if (registeredByRepository.has(repositoryPath))
262
+ return registeredByRepository.get(repositoryPath)!
263
+ const listed = yield* Effect.either(
264
+ backend.listWorkspaces(repositoryPath),
265
+ )
266
+ if (Either.isLeft(listed)) {
267
+ registeredByRepository.set(repositoryPath, null)
268
+ return null
269
+ }
270
+ const registered: RegisteredWorktree[] = []
271
+ for (const item of listed.right) {
272
+ registered.push({
273
+ head: backend.kind === "jj" ? (item.head ?? null) : item.commit,
274
+ branch: item.branch,
275
+ path: (yield* fs.exists(item.path))
276
+ ? yield* fs.realPath(item.path)
277
+ : resolve(item.path),
278
+ ...(item.dirty === undefined ? {} : { dirty: item.dirty }),
279
+ })
280
+ }
281
+ registeredByRepository.set(repositoryPath, registered)
282
+ return registered
283
+ })
284
+ const queryRecords = records.filter((record) => !record.data.completion)
285
+ let queriedPullRequests = 0
286
+ const remoteName = config.delivery?.remote ?? "origin"
287
+ const repositoryPaths = [
288
+ ...new Set(
289
+ queryRecords.map((record) => join(root, "repos", record.data.repo)),
290
+ ),
291
+ ]
292
+ const remoteUrls = new Map(
293
+ yield* Effect.forEach(
294
+ repositoryPaths,
295
+ (repositoryPath) =>
296
+ backend
297
+ .remoteUrl(repositoryPath, remoteName)
298
+ .pipe(
299
+ Effect.map(
300
+ (remoteUrl) => [repositoryPath, remoteUrl] as const,
301
+ ),
302
+ ),
303
+ { concurrency: 8 },
304
+ ),
305
+ )
306
+ const prQueries = new Map(
307
+ yield* Effect.forEach(
308
+ queryRecords,
309
+ (record) =>
310
+ Effect.gen(function* () {
311
+ const data = record.data
312
+ const repositoryPath = join(root, "repos", data.repo)
313
+ const remoteUrl = remoteUrls.get(repositoryPath) ?? null
314
+ const remoteRepository = (remoteUrl ?? "")
315
+ .trim()
316
+ .replace(/^[a-z][a-z0-9+.-]*:\/\/(?:[^@/]+@)?[^/]+\//i, "")
317
+ .replace(/^[^:]+:/, "")
318
+ .replace(/\.git\/?$/, "")
319
+ .replace(/\/$/, "")
320
+ const existing = data.pr
321
+ ? normalizePullRequestRecord(data.pr)
322
+ : null
323
+ let result
324
+ if (config.delivery && remoteUrl) {
325
+ const resolved = resolveDeliveryCommand(
326
+ config.delivery,
327
+ "query",
328
+ {
329
+ repository: remoteRepository,
330
+ branch: data.branch,
331
+ base: data.base,
332
+ draft: existing ? String(existing.draft) : "",
333
+ url: existing?.url ?? "",
334
+ identifier: existing?.identifier ?? "",
335
+ },
336
+ )
337
+ result = yield* runExternal(resolved.argv, {
338
+ cwd: repositoryPath,
339
+ env: resolved.environment,
340
+ })
341
+ } else if (!config.delivery && existing) {
342
+ result = yield* runExternal([
343
+ "gh",
344
+ "pr",
345
+ "view",
346
+ existing.url,
347
+ "--json",
348
+ "number,state,title,isDraft,headRefName,baseRefName,url,mergedAt,mergeCommit,mergeable",
349
+ ])
350
+ } else if (!config.delivery) {
351
+ result = yield* runExternal(
352
+ [
353
+ "gh",
354
+ "pr",
355
+ "list",
356
+ "--head",
357
+ data.branch,
358
+ "--state",
359
+ "all",
360
+ "--json",
361
+ "number,state,title,isDraft,headRefName,baseRefName,url,mergedAt,mergeCommit,mergeable",
362
+ ],
363
+ { cwd: repositoryPath },
364
+ )
365
+ }
366
+ return [
367
+ record.key,
368
+ { remoteUrl, remoteRepository, result },
369
+ ] as const
370
+ }).pipe(
371
+ Effect.tap(() =>
372
+ Effect.sync(() => {
373
+ queriedPullRequests += 1
374
+ options.onProgress?.({
375
+ stage: "pull-requests",
376
+ current: queriedPullRequests,
377
+ total: queryRecords.length,
378
+ target: record.key,
379
+ })
380
+ }),
381
+ ),
382
+ ),
383
+ { concurrency: 8 },
384
+ ),
385
+ )
386
+ const reviewRecords = taskRecords.filter(
387
+ (task) => "review" in task.data,
388
+ )
389
+ const executionTotal = records.length + reviewRecords.length
390
+ let reconciledExecutions = 0
391
+ const reportExecution = (target: string) => {
392
+ reconciledExecutions += 1
393
+ options.onProgress?.({
394
+ stage: "executions",
395
+ current: reconciledExecutions,
396
+ total: executionTotal,
397
+ target,
398
+ })
399
+ }
240
400
 
241
401
  for (const record of records.sort((a, b) =>
242
402
  a.key.localeCompare(b.key),
@@ -271,10 +431,8 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
271
431
  workspaceConflict = true
272
432
  continue
273
433
  }
274
- const listed = yield* Effect.either(
275
- backend.listWorkspaces(repositoryPath),
276
- )
277
- if (Either.isLeft(listed)) {
434
+ const registered = yield* listRegistered(repositoryPath)
435
+ if (registered === null) {
278
436
  unresolved.push({
279
437
  kind: "worktree-inspection-failed",
280
438
  target: record.key,
@@ -284,26 +442,21 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
284
442
  continue
285
443
  }
286
444
  const exists = yield* fs.isDirectory(checkoutPath)
287
- const registered: RegisteredWorktree[] = []
288
- for (const item of listed.right) {
289
- registered.push({
290
- head: item.commit,
291
- branch:
292
- backend.kind === "jj" && "branch" in checkout
293
- ? checkout.branch
294
- : item.branch,
295
- path: (yield* fs.exists(item.path))
296
- ? yield* fs.realPath(item.path)
297
- : resolve(item.path),
298
- })
299
- }
300
445
  const expectedPath = exists
301
446
  ? yield* fs.realPath(checkoutPath)
302
447
  : (yield* fs.isDirectory(codePath))
303
448
  ? join(yield* fs.realPath(codePath), checkout.repo)
304
449
  : resolve(checkoutPath)
305
450
  let atPath = registered.find((item) => item.path === expectedPath)
306
- if (backend.kind === "jj" && atPath && exists) {
451
+ if (backend.kind === "jj" && atPath && "branch" in checkout) {
452
+ atPath = { ...atPath, branch: checkout.branch }
453
+ }
454
+ if (
455
+ backend.kind === "jj" &&
456
+ atPath &&
457
+ exists &&
458
+ atPath.head === null
459
+ ) {
307
460
  atPath = {
308
461
  ...atPath,
309
462
  head: yield* backend.workspaceHead(checkoutPath),
@@ -415,7 +568,8 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
415
568
 
416
569
  const dirty =
417
570
  exists && atPath
418
- ? yield* backend.workspaceDirty(checkoutPath)
571
+ ? (atPath.dirty ??
572
+ (yield* backend.workspaceDirty(checkoutPath)))
419
573
  : null
420
574
  if (exists && atPath && dirty === null) {
421
575
  warnings.push({
@@ -487,6 +641,7 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
487
641
  { silent: true },
488
642
  )
489
643
  for (const checkout of workspace.checkouts) {
644
+ const repositoryPath = join(root, "repos", checkout.repo)
490
645
  const index = checkoutStates.findIndex(
491
646
  (item) => item.repo === checkout.repo,
492
647
  )
@@ -504,6 +659,20 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
504
659
  : null,
505
660
  dirty: false,
506
661
  }
662
+ const cached = registeredByRepository.get(repositoryPath)
663
+ if (cached) {
664
+ cached.push({
665
+ path: yield* fs.realPath(checkout.path),
666
+ head: checkout.resolvedCommit,
667
+ branch:
668
+ checkout.kind === "writable"
669
+ ? backend.kind === "jj"
670
+ ? checkout.requestedRef
671
+ : `refs/heads/${checkout.requestedRef}`
672
+ : null,
673
+ dirty: false,
674
+ })
675
+ }
507
676
  }
508
677
  }
509
678
  }
@@ -569,6 +738,7 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
569
738
  checkouts: checkoutStates,
570
739
  pr: { url: null, state: "none" },
571
740
  })
741
+ reportExecution(record.key)
572
742
  continue
573
743
  }
574
744
 
@@ -579,15 +749,8 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
579
749
  state: "none",
580
750
  }
581
751
  let prConflict = false
582
- const repositoryPath = join(root, "repos", data.repo)
583
- const remoteName = config.delivery?.remote ?? "origin"
584
- const remoteUrl = yield* backend.remoteUrl(repositoryPath, remoteName)
585
- const remoteRepository = (remoteUrl ?? "")
586
- .trim()
587
- .replace(/^[a-z][a-z0-9+.-]*:\/\/(?:[^@/]+@)?[^/]+\//i, "")
588
- .replace(/^[^:]+:/, "")
589
- .replace(/\.git\/?$/, "")
590
- .replace(/\/$/, "")
752
+ const query = prQueries.get(record.key)!
753
+ const { remoteUrl, remoteRepository } = query
591
754
 
592
755
  if (
593
756
  existing &&
@@ -609,18 +772,7 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
609
772
  message: `Could not inspect delivery remote '${remoteName}'`,
610
773
  })
611
774
  } else if (config.delivery) {
612
- const resolved = resolveDeliveryCommand(config.delivery, "query", {
613
- repository: remoteRepository,
614
- branch: data.branch,
615
- base: data.base,
616
- draft: existing ? String(existing.draft) : "",
617
- url: existing?.url ?? "",
618
- identifier: existing?.identifier ?? "",
619
- })
620
- const queried = yield* runExternal(resolved.argv, {
621
- cwd: repositoryPath,
622
- env: resolved.environment,
623
- })
775
+ const queried = query.result!
624
776
  if (queried.exitCode === 0) {
625
777
  const parsed = yield* Effect.try({
626
778
  try: () => parseOptionalPullRequestRecord(queried.stdout),
@@ -668,14 +820,7 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
668
820
  })
669
821
  }
670
822
  } else if (existing) {
671
- const viewed = yield* runExternal([
672
- "gh",
673
- "pr",
674
- "view",
675
- existing.url,
676
- "--json",
677
- "number,state,title,isDraft,headRefName,baseRefName,url,mergedAt,mergeCommit,mergeable",
678
- ])
823
+ const viewed = query.result!
679
824
  if (viewed.exitCode === 0) {
680
825
  const detail = parseJson<Record<string, unknown>>(
681
826
  viewed.stdout,
@@ -704,20 +849,7 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
704
849
  })
705
850
  }
706
851
  } else {
707
- const listed = yield* runExternal(
708
- [
709
- "gh",
710
- "pr",
711
- "list",
712
- "--head",
713
- data.branch,
714
- "--state",
715
- "all",
716
- "--json",
717
- "number,state,title,isDraft,headRefName,baseRefName,url,mergedAt,mergeCommit,mergeable",
718
- ],
719
- { cwd: repositoryPath },
720
- )
852
+ const listed = query.result!
721
853
  if (listed.exitCode === 0) {
722
854
  const matches = parseJson<Record<string, unknown>[]>(
723
855
  listed.stdout,
@@ -816,11 +948,10 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
816
948
  checkouts: checkoutStates,
817
949
  pr,
818
950
  })
951
+ reportExecution(record.key)
819
952
  }
820
953
 
821
- for (const task of (yield* tasks.list(root)).filter(
822
- (task) => "review" in task.data,
823
- )) {
954
+ for (const task of reviewRecords) {
824
955
  if (!("review" in task.data)) continue
825
956
  let data = task.data
826
957
  let revision = task.revision
@@ -915,6 +1046,7 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
915
1046
  sourceAvailable: sourceCommit !== null,
916
1047
  },
917
1048
  })
1049
+ reportExecution(`task:${task.id}`)
918
1050
  }
919
1051
 
920
1052
  return {
@@ -371,6 +371,30 @@ status: done
371
371
  ).rejects.toThrow("{worktree}")
372
372
  })
373
373
 
374
+ test("rejects an unknown post-checkout command placeholder", async () => {
375
+ await write(
376
+ root,
377
+ "agency.json",
378
+ JSON.stringify({
379
+ version: 2,
380
+ repositories: {
381
+ agency: {
382
+ remote: "https://example.com/agency.git",
383
+ postCheckoutCommand: ["tool", "{unknown}"],
384
+ },
385
+ },
386
+ }),
387
+ )
388
+
389
+ await expect(
390
+ runTestEffect(
391
+ WorkbaseService.pipe(
392
+ Effect.flatMap((service) => service.discover(root)),
393
+ ),
394
+ ),
395
+ ).rejects.toThrow("Repository 'agency'")
396
+ })
397
+
374
398
  test("rejects an unknown runner command placeholder", async () => {
375
399
  await write(
376
400
  root,
@@ -20,6 +20,7 @@ import {
20
20
  type WorkbaseRegistration,
21
21
  } from "../workbase/schemas"
22
22
  import { validateWorktreeCreateCommand } from "../workbase/worktree-command"
23
+ import { validatePostCheckoutCommand } from "../workbase/checkout-command"
23
24
  import { validateRunners } from "../workbase/runner-command"
24
25
  import { findDependencyCycles } from "../workbase/dependency-graph"
25
26
  import { validateDelivery } from "../workbase/delivery-command"
@@ -274,6 +275,23 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
274
275
  })
275
276
  }
276
277
  }
278
+ for (const [alias, repository] of Object.entries(
279
+ decoded.value.repositories ?? {},
280
+ )) {
281
+ if (!repository.postCheckoutCommand) continue
282
+ try {
283
+ validatePostCheckoutCommand(repository.postCheckoutCommand)
284
+ } catch (cause) {
285
+ return yield* new WorkbaseConfigError({
286
+ path: configPath,
287
+ message: `Repository '${alias}': ${
288
+ cause instanceof Error
289
+ ? cause.message
290
+ : "Invalid postCheckoutCommand"
291
+ }`,
292
+ })
293
+ }
294
+ }
277
295
  try {
278
296
  validateRunners(decoded.value.runners)
279
297
  validateDelivery(decoded.value.delivery)