@markjaquith/agency 2.56.0 → 2.57.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.
@@ -59,6 +59,7 @@ describe("SyncService", () => {
59
59
  await Bun.write(
60
60
  gh,
61
61
  `#!/bin/sh
62
+ if [ -n "$GH_CAPTURE" ]; then printf '%s\n' "$@" "GIT_DIR=$GIT_DIR" > "$GH_CAPTURE"; fi
62
63
  case "$*" in
63
64
  *mergeable*) ;;
64
65
  *) echo "mergeable field was not requested" >&2; exit 2 ;;
@@ -82,6 +83,7 @@ JSON
82
83
  afterEach(async () => {
83
84
  if (originalPath === undefined) delete process.env.PATH
84
85
  else process.env.PATH = originalPath
86
+ delete process.env.GH_CAPTURE
85
87
  await cleanupTempDir(root)
86
88
  })
87
89
 
@@ -125,8 +127,14 @@ pr: null
125
127
  if (!Bun.which("jj")) return
126
128
  const repository = join(root, "repos/agency")
127
129
  await rm(repository, { recursive: true, force: true })
128
- await git(["clone", join(root, "source"), repository])
129
- await jj(["git", "init", "--colocate", repository])
130
+ await jj([
131
+ "git",
132
+ "clone",
133
+ "--no-colocate",
134
+ join(root, "source"),
135
+ repository,
136
+ ])
137
+ expect(await Bun.file(join(repository, ".git")).exists()).toBe(false)
130
138
  await Bun.write(
131
139
  join(root, "agency.json"),
132
140
  JSON.stringify({ version: 2, vcs: "jj" }),
@@ -147,6 +155,8 @@ pr: null
147
155
  ),
148
156
  ),
149
157
  )
158
+ const capture = join(root, "gh-capture")
159
+ process.env.GH_CAPTURE = capture
150
160
 
151
161
  const planned = await runTestEffect(
152
162
  SyncService.pipe(
@@ -160,6 +170,10 @@ pr: null
160
170
  status: "planned",
161
171
  }),
162
172
  )
173
+ const invocation = await Bun.file(capture).text()
174
+ expect(invocation).toContain("--repo\n")
175
+ expect(invocation).toContain("GIT_DIR=")
176
+ expect(invocation).toContain(".jj")
163
177
 
164
178
  const applied = await runTestEffect(
165
179
  SyncService.pipe(
@@ -174,6 +188,7 @@ pr: null
174
188
  branch: "task/jj-sync",
175
189
  dirty: false,
176
190
  })
191
+ delete process.env.GH_CAPTURE
177
192
  })
178
193
 
179
194
  test("observes drift without mutation and applies only safe transitions", async () => {
@@ -796,4 +811,119 @@ process.stdout.write(${JSON.stringify(JSON.stringify(record))})
796
811
  ),
797
812
  ).toMatchObject({ valid: true, issues: [] })
798
813
  })
814
+
815
+ test("reads each repository workspace inventory once", async () => {
816
+ for (const id of ["first", "second"]) {
817
+ await runTestEffect(
818
+ TaskService.pipe(
819
+ Effect.flatMap((service) =>
820
+ service.create(
821
+ {
822
+ id,
823
+ ticketUrl: null,
824
+ repo: "agency",
825
+ branch: `feat/${id}`,
826
+ base: "main",
827
+ },
828
+ root,
829
+ ),
830
+ ),
831
+ ),
832
+ )
833
+ await runTestEffect(
834
+ WorktreeService.pipe(
835
+ Effect.flatMap((service) => service.materialize(id, undefined, root)),
836
+ ),
837
+ )
838
+ await runTestEffect(
839
+ TaskService.pipe(
840
+ Effect.flatMap((service) =>
841
+ service.setStatus(id, "done", root, {
842
+ summary: `Completed ${id}`,
843
+ }),
844
+ ),
845
+ ),
846
+ )
847
+ }
848
+
849
+ const callsPath = join(root, "workspace-list-calls")
850
+ const realGit = Bun.which("git")!
851
+ const gitWrapper = join(root, "bin", "git")
852
+ await Bun.write(
853
+ gitWrapper,
854
+ `#!/bin/sh
855
+ case "$*" in
856
+ *"worktree list --porcelain -z"*) printf 'call\\n' >> ${JSON.stringify(callsPath)} ;;
857
+ esac
858
+ exec ${JSON.stringify(realGit)} "$@"
859
+ `,
860
+ )
861
+ await chmod(gitWrapper, 0o755)
862
+
863
+ await runTestEffect(
864
+ SyncService.pipe(
865
+ Effect.flatMap((service) => service.reconcile({ cwd: root })),
866
+ ),
867
+ )
868
+ expect((await Bun.file(callsPath).text()).trim().split("\n")).toHaveLength(
869
+ 1,
870
+ )
871
+ })
872
+
873
+ test("queries pull request providers concurrently", async () => {
874
+ for (const id of ["first", "second", "third"]) {
875
+ await runTestEffect(
876
+ TaskService.pipe(
877
+ Effect.flatMap((service) =>
878
+ service.create(
879
+ {
880
+ id,
881
+ ticketUrl: null,
882
+ repo: "agency",
883
+ branch: `feat/${id}`,
884
+ base: "main",
885
+ },
886
+ root,
887
+ ),
888
+ ),
889
+ ),
890
+ )
891
+ }
892
+
893
+ const barrier = join(root, "query-barrier")
894
+ await mkdir(barrier)
895
+ await Bun.write(
896
+ join(root, "bin", "gh"),
897
+ `#!/bin/sh
898
+ branch=""
899
+ while [ "$#" -gt 0 ]; do
900
+ if [ "$1" = "--head" ]; then branch="$2"; break; fi
901
+ shift
902
+ done
903
+ id="\${branch##*/}"
904
+ touch ${JSON.stringify(barrier)}/"\${id}"
905
+ attempt=0
906
+ while [ "$attempt" -lt 200 ]; do
907
+ set -- ${JSON.stringify(barrier)}/*
908
+ if [ -e "$1" ] && [ "$#" -ge 3 ]; then printf '[]\\n'; exit 0; fi
909
+ attempt=$((attempt + 1))
910
+ sleep 0.01
911
+ done
912
+ echo "provider queries were serialized" >&2
913
+ exit 9
914
+ `,
915
+ )
916
+ await chmod(join(root, "bin", "gh"), 0o755)
917
+
918
+ const result = await runTestEffect(
919
+ SyncService.pipe(
920
+ Effect.flatMap((service) => service.reconcile({ cwd: root })),
921
+ ),
922
+ )
923
+ expect(
924
+ result.warnings.filter(
925
+ (warning) => warning.kind === "pr-discovery-unavailable",
926
+ ),
927
+ ).toEqual([])
928
+ })
799
929
  })
@@ -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,164 @@ 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
+ const environment = yield* Effect.either(
343
+ backend.gitEnvironment(repositoryPath),
344
+ )
345
+ result = yield* runExternal(
346
+ [
347
+ "gh",
348
+ "pr",
349
+ "view",
350
+ existing.url,
351
+ "--json",
352
+ "number,state,title,isDraft,headRefName,baseRefName,url,mergedAt,mergeCommit,mergeable",
353
+ ],
354
+ {
355
+ cwd: repositoryPath,
356
+ env: Either.isRight(environment) ? environment.right : {},
357
+ },
358
+ )
359
+ } else if (!config.delivery) {
360
+ const environment = yield* Effect.either(
361
+ backend.gitEnvironment(repositoryPath),
362
+ )
363
+ result = yield* runExternal(
364
+ [
365
+ "gh",
366
+ "pr",
367
+ "list",
368
+ "--repo",
369
+ remoteRepository,
370
+ "--head",
371
+ data.branch,
372
+ "--state",
373
+ "all",
374
+ "--json",
375
+ "number,state,title,isDraft,headRefName,baseRefName,url,mergedAt,mergeCommit,mergeable",
376
+ ],
377
+ {
378
+ cwd: repositoryPath,
379
+ env: Either.isRight(environment) ? environment.right : {},
380
+ },
381
+ )
382
+ }
383
+ return [
384
+ record.key,
385
+ { remoteUrl, remoteRepository, result },
386
+ ] as const
387
+ }).pipe(
388
+ Effect.tap(() =>
389
+ Effect.sync(() => {
390
+ queriedPullRequests += 1
391
+ options.onProgress?.({
392
+ stage: "pull-requests",
393
+ current: queriedPullRequests,
394
+ total: queryRecords.length,
395
+ target: record.key,
396
+ })
397
+ }),
398
+ ),
399
+ ),
400
+ { concurrency: 8 },
401
+ ),
402
+ )
403
+ const reviewRecords = taskRecords.filter(
404
+ (task) => "review" in task.data,
405
+ )
406
+ const executionTotal = records.length + reviewRecords.length
407
+ let reconciledExecutions = 0
408
+ const reportExecution = (target: string) => {
409
+ reconciledExecutions += 1
410
+ options.onProgress?.({
411
+ stage: "executions",
412
+ current: reconciledExecutions,
413
+ total: executionTotal,
414
+ target,
415
+ })
416
+ }
240
417
 
241
418
  for (const record of records.sort((a, b) =>
242
419
  a.key.localeCompare(b.key),
@@ -271,10 +448,8 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
271
448
  workspaceConflict = true
272
449
  continue
273
450
  }
274
- const listed = yield* Effect.either(
275
- backend.listWorkspaces(repositoryPath),
276
- )
277
- if (Either.isLeft(listed)) {
451
+ const registered = yield* listRegistered(repositoryPath)
452
+ if (registered === null) {
278
453
  unresolved.push({
279
454
  kind: "worktree-inspection-failed",
280
455
  target: record.key,
@@ -284,26 +459,21 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
284
459
  continue
285
460
  }
286
461
  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
462
  const expectedPath = exists
301
463
  ? yield* fs.realPath(checkoutPath)
302
464
  : (yield* fs.isDirectory(codePath))
303
465
  ? join(yield* fs.realPath(codePath), checkout.repo)
304
466
  : resolve(checkoutPath)
305
467
  let atPath = registered.find((item) => item.path === expectedPath)
306
- if (backend.kind === "jj" && atPath && exists) {
468
+ if (backend.kind === "jj" && atPath && "branch" in checkout) {
469
+ atPath = { ...atPath, branch: checkout.branch }
470
+ }
471
+ if (
472
+ backend.kind === "jj" &&
473
+ atPath &&
474
+ exists &&
475
+ atPath.head === null
476
+ ) {
307
477
  atPath = {
308
478
  ...atPath,
309
479
  head: yield* backend.workspaceHead(checkoutPath),
@@ -378,14 +548,18 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
378
548
  let resolvedCommit: string | null = null
379
549
  if ("ref" in checkout) {
380
550
  if (!isCommitId(checkout.ref)) {
381
- const remoteRef = yield* runExternal([
382
- "git",
383
- "-C",
551
+ const remote = yield* backend.remoteUrl(
384
552
  repositoryPath,
385
- "ls-remote",
386
553
  "origin",
387
- originRef(checkout.ref),
388
- ])
554
+ )
555
+ const remoteRef = remote
556
+ ? yield* runExternal([
557
+ "git",
558
+ "ls-remote",
559
+ remote,
560
+ originRef(checkout.ref),
561
+ ])
562
+ : { exitCode: -1, stdout: "", stderr: "origin unavailable" }
389
563
  resolvedCommit =
390
564
  remoteRef.stdout.match(/^([0-9a-f]{40,64})\s/m)?.[1] ?? null
391
565
  if (!resolvedCommit) {
@@ -415,7 +589,8 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
415
589
 
416
590
  const dirty =
417
591
  exists && atPath
418
- ? yield* backend.workspaceDirty(checkoutPath)
592
+ ? (atPath.dirty ??
593
+ (yield* backend.workspaceDirty(checkoutPath)))
419
594
  : null
420
595
  if (exists && atPath && dirty === null) {
421
596
  warnings.push({
@@ -487,6 +662,7 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
487
662
  { silent: true },
488
663
  )
489
664
  for (const checkout of workspace.checkouts) {
665
+ const repositoryPath = join(root, "repos", checkout.repo)
490
666
  const index = checkoutStates.findIndex(
491
667
  (item) => item.repo === checkout.repo,
492
668
  )
@@ -504,6 +680,20 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
504
680
  : null,
505
681
  dirty: false,
506
682
  }
683
+ const cached = registeredByRepository.get(repositoryPath)
684
+ if (cached) {
685
+ cached.push({
686
+ path: yield* fs.realPath(checkout.path),
687
+ head: checkout.resolvedCommit,
688
+ branch:
689
+ checkout.kind === "writable"
690
+ ? backend.kind === "jj"
691
+ ? checkout.requestedRef
692
+ : `refs/heads/${checkout.requestedRef}`
693
+ : null,
694
+ dirty: false,
695
+ })
696
+ }
507
697
  }
508
698
  }
509
699
  }
@@ -569,6 +759,7 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
569
759
  checkouts: checkoutStates,
570
760
  pr: { url: null, state: "none" },
571
761
  })
762
+ reportExecution(record.key)
572
763
  continue
573
764
  }
574
765
 
@@ -579,15 +770,8 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
579
770
  state: "none",
580
771
  }
581
772
  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(/\/$/, "")
773
+ const query = prQueries.get(record.key)!
774
+ const { remoteUrl, remoteRepository } = query
591
775
 
592
776
  if (
593
777
  existing &&
@@ -609,18 +793,7 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
609
793
  message: `Could not inspect delivery remote '${remoteName}'`,
610
794
  })
611
795
  } 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
- })
796
+ const queried = query.result!
624
797
  if (queried.exitCode === 0) {
625
798
  const parsed = yield* Effect.try({
626
799
  try: () => parseOptionalPullRequestRecord(queried.stdout),
@@ -668,14 +841,7 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
668
841
  })
669
842
  }
670
843
  } 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
- ])
844
+ const viewed = query.result!
679
845
  if (viewed.exitCode === 0) {
680
846
  const detail = parseJson<Record<string, unknown>>(
681
847
  viewed.stdout,
@@ -704,20 +870,7 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
704
870
  })
705
871
  }
706
872
  } 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
- )
873
+ const listed = query.result!
721
874
  if (listed.exitCode === 0) {
722
875
  const matches = parseJson<Record<string, unknown>[]>(
723
876
  listed.stdout,
@@ -816,11 +969,10 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
816
969
  checkouts: checkoutStates,
817
970
  pr,
818
971
  })
972
+ reportExecution(record.key)
819
973
  }
820
974
 
821
- for (const task of (yield* tasks.list(root)).filter(
822
- (task) => "review" in task.data,
823
- )) {
975
+ for (const task of reviewRecords) {
824
976
  if (!("review" in task.data)) continue
825
977
  let data = task.data
826
978
  let revision = task.revision
@@ -867,12 +1019,14 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
867
1019
  })
868
1020
  }
869
1021
  const repositoryPath = join(root, "repos", data.review.repo)
1022
+ const reviewRemote = yield* backend.remoteUrl(
1023
+ repositoryPath,
1024
+ "origin",
1025
+ )
870
1026
  const source = yield* runExternal([
871
1027
  "git",
872
- "-C",
873
- repositoryPath,
874
1028
  "ls-remote",
875
- "origin",
1029
+ reviewRemote ?? "origin",
876
1030
  data.review.source.kind === "pull-request"
877
1031
  ? data.review.source.fetchRef
878
1032
  : originRef(data.review.source.ref),
@@ -915,6 +1069,7 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
915
1069
  sourceAvailable: sourceCommit !== null,
916
1070
  },
917
1071
  })
1072
+ reportExecution(`task:${task.id}`)
918
1073
  }
919
1074
 
920
1075
  return {
@@ -3,6 +3,7 @@ import { Data, Effect, Either } from "effect"
3
3
  import { join } from "node:path"
4
4
  import { FileSystemService } from "./FileSystemService"
5
5
  import { WorkbaseService } from "./WorkbaseService"
6
+ import { VersionControlService } from "./VersionControlService"
6
7
  import { EpicService, type EpicRecord } from "./EpicService"
7
8
  import {
8
9
  EntityId,
@@ -88,11 +89,16 @@ const reviewPinStep = (
88
89
  root: string,
89
90
  taskId: string,
90
91
  review: ReviewRecord,
92
+ environment: Record<string, string>,
91
93
  ): TransactionStep => {
92
94
  const repositoryPath = join(root, "repos", review.repo)
93
95
  const ref = reviewPinRef(taskId)
94
96
  const run = async (args: readonly string[]) => {
95
- const child = Bun.spawn([...args], { stdout: "pipe", stderr: "pipe" })
97
+ const child = Bun.spawn([...args], {
98
+ stdout: "pipe",
99
+ stderr: "pipe",
100
+ env: { ...process.env, ...environment },
101
+ })
96
102
  const [exitCode, stderr] = await Promise.all([
97
103
  child.exited,
98
104
  new Response(child.stderr).text(),
@@ -132,6 +138,7 @@ export class TaskService extends Effect.Service<TaskService>()("TaskService", {
132
138
  const fs = yield* FileSystemService
133
139
  const workbase = yield* WorkbaseService
134
140
  const epics = yield* EpicService
141
+ const versionControl = yield* VersionControlService
135
142
  const root = yield* workbase.discover(startPath)
136
143
  const id = yield* decodeId(input.id)
137
144
  const directory = join(root, "tasks", id)
@@ -257,13 +264,22 @@ export class TaskService extends Effect.Service<TaskService>()("TaskService", {
257
264
  const updated = formatMarkdownDocument(epicData, parsed.body)
258
265
  writes.push({ path: parentEpic.path, content: updated })
259
266
  }
267
+ let reviewEnvironment: Record<string, string> = {}
268
+ if (input.review) {
269
+ const backend = yield* versionControl.forWorkbase(root)
270
+ reviewEnvironment = yield* backend.gitEnvironment(
271
+ join(root, "repos", input.review.repo),
272
+ )
273
+ }
260
274
  yield* runLifecycleTransaction({
261
275
  root,
262
276
  preconditions: parentEpic
263
277
  ? [{ path: parentEpic.path, revision: parentEpic.revision }]
264
278
  : [],
265
279
  steps: [
266
- ...(input.review ? [reviewPinStep(root, id, input.review)] : []),
280
+ ...(input.review
281
+ ? [reviewPinStep(root, id, input.review, reviewEnvironment)]
282
+ : []),
267
283
  documentWriteStep(root, writes),
268
284
  ],
269
285
  })