@markjaquith/agency 2.56.1 → 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.
@@ -171,9 +171,12 @@ const inspectRemote = (path: string) =>
171
171
  return result.exitCode === 0 ? result.stdout.trim() : null
172
172
  })
173
173
 
174
- const portableRemote = (path: string) =>
174
+ const portableRemote = (path: string, backend?: VersionControlBackend) =>
175
175
  Effect.gen(function* () {
176
- const remote = yield* inspectRemote(path)
176
+ const backendRemote = backend
177
+ ? yield* backend.remoteUrl(path, "origin")
178
+ : null
179
+ const remote = backendRemote ?? (yield* inspectRemote(path))
177
180
  if (!remote) {
178
181
  return yield* new RepositoryError({
179
182
  message: `Repository '${path}' has no portable origin remote`,
@@ -219,7 +222,7 @@ const removalBlockers = (
219
222
  Effect.gen(function* () {
220
223
  const fs = yield* FileSystemService
221
224
  const graph = yield* GraphService
222
- const report = yield* graph.get({ cwd: startPath })
225
+ const report = yield* graph.get({ cwd: startPath, backend })
223
226
  const repositoryId = `repository:${repository.alias}`
224
227
  const references = report.edges
225
228
  .filter(
@@ -373,50 +376,30 @@ export class RepositoryService extends Effect.Service<RepositoryService>()(
373
376
  : resolve(startPath, remote)
374
377
  const declaredRemote = inputIsPortable
375
378
  ? yield* validateRemote(remote)
376
- : yield* portableRemote(cloneSource)
379
+ : yield* portableRemote(cloneSource, backend)
377
380
  const staging = join(
378
381
  state.root,
379
382
  "repos",
380
383
  `.agency-clone-${validAlias}-${process.pid}-${Date.now()}`,
381
384
  )
382
385
  yield* fs.createDirectory(join(state.root, "repos"))
383
- const cloned = yield* fs.runCommand(
384
- [
385
- "git",
386
- "clone",
387
- ...(backend.kind === "git" ? ["--bare"] : []),
388
- "--",
389
- cloneSource,
390
- staging,
391
- ],
392
- { captureOutput: true },
386
+ yield* backend.cloneRepository(cloneSource, staging).pipe(
387
+ Effect.catchAll((cause) =>
388
+ fs.deleteDirectory(staging).pipe(
389
+ Effect.ignore,
390
+ Effect.zipRight(
391
+ Effect.fail(
392
+ new RepositoryError({
393
+ message: `Failed to clone repository '${remote}': ${cause instanceof Error ? cause.message : String(cause)}`,
394
+ cause,
395
+ }),
396
+ ),
397
+ ),
398
+ ),
399
+ ),
393
400
  )
394
- if (cloned.exitCode !== 0) {
395
- yield* fs.deleteDirectory(staging).pipe(Effect.ignore)
396
- return yield* new RepositoryError({
397
- message: `Failed to clone repository '${remote}': ${cloned.stderr.trim()}`,
398
- })
399
- }
400
- yield* backend.initializeRepository(staging)
401
401
  if (declaredRemote !== remote) {
402
- const setRemote = yield* fs.runCommand(
403
- [
404
- "git",
405
- "-C",
406
- staging,
407
- "remote",
408
- "set-url",
409
- "origin",
410
- declaredRemote,
411
- ],
412
- { captureOutput: true },
413
- )
414
- if (setRemote.exitCode !== 0) {
415
- yield* fs.deleteDirectory(staging).pipe(Effect.ignore)
416
- return yield* new RepositoryError({
417
- message: `Failed to record portable remote for repository '${validAlias}': ${setRemote.stderr.trim()}`,
418
- })
419
- }
402
+ yield* backend.setRemoteUrl(staging, "origin", declaredRemote)
420
403
  }
421
404
  const config = withDeclarations(state.config, {
422
405
  ...(state.config.repositories ?? {}),
@@ -465,19 +448,21 @@ export class RepositoryService extends Effect.Service<RepositoryService>()(
465
448
  message: `Repository path does not exist: ${resolvedTarget}`,
466
449
  })
467
450
  }
468
- const git = yield* fs.runCommand(
469
- ["git", "-C", resolvedTarget, "rev-parse", "--git-dir"],
470
- { captureOutput: true },
471
- )
472
- if (git.exitCode !== 0) {
451
+ const inspection = yield* backend.inspectRepository(resolvedTarget)
452
+ if (!inspection && backend.kind === "git") {
473
453
  return yield* new RepositoryError({
474
- message: `Path is not a Git repository: ${resolvedTarget}`,
454
+ message: `Path is not a ${backend.kind} repository: ${resolvedTarget}`,
475
455
  })
476
456
  }
477
457
  yield* backend.initializeRepository(resolvedTarget)
458
+ if (!(yield* backend.inspectRepository(resolvedTarget))) {
459
+ return yield* new RepositoryError({
460
+ message: `Path is not a ${backend.kind} repository: ${resolvedTarget}`,
461
+ })
462
+ }
478
463
  const declaredRemote =
479
464
  state.config.repositories?.[validAlias]?.remote ??
480
- (yield* portableRemote(resolvedTarget))
465
+ (yield* portableRemote(resolvedTarget, backend))
481
466
  const staging = join(
482
467
  state.root,
483
468
  "repos",
@@ -522,6 +507,8 @@ export class RepositoryService extends Effect.Service<RepositoryService>()(
522
507
  const { root, config } = yield* WorkbaseService.pipe(
523
508
  Effect.flatMap((service) => service.loadConfig(startPath)),
524
509
  )
510
+ const versionControl = yield* VersionControlService
511
+ const backend = yield* versionControl.forWorkbase(root)
525
512
  const reposPath = join(root, "repos")
526
513
  const entries = (yield* fs.isDirectory(reposPath))
527
514
  ? (yield* fs.readDirectory(reposPath)).filter(
@@ -569,20 +556,12 @@ export class RepositoryService extends Effect.Service<RepositoryService>()(
569
556
  const target = entry.isSymlink
570
557
  ? yield* fs.readSymlinkTarget(path)
571
558
  : null
572
- const git = yield* fs.runCommand(
573
- ["git", "-C", path, "rev-parse", "--git-dir"],
574
- { captureOutput: true },
575
- )
576
- const bare = yield* fs.runCommand(
577
- ["git", "-C", path, "rev-parse", "--is-bare-repository"],
578
- { captureOutput: true },
579
- )
580
- const remote =
581
- git.exitCode === 0 ? yield* inspectRemote(path) : null
559
+ const inspection = yield* backend.inspectRepository(path)
560
+ const remote = inspection?.remote ?? null
582
561
  const states: RepositoryState[] = []
583
562
  if (declaredRemote) states.push("declared")
584
563
  states.push(entry.isSymlink ? "linked" : "materialized")
585
- if (git.exitCode !== 0) states.push("invalid")
564
+ if (!inspection) states.push("invalid")
586
565
  if (declaredRemote && remote !== declaredRemote)
587
566
  states.push("remote-drifted")
588
567
  repositories.push({
@@ -590,9 +569,7 @@ export class RepositoryService extends Effect.Service<RepositoryService>()(
590
569
  path,
591
570
  kind: entry.isSymlink
592
571
  ? "symlink"
593
- : bare.stdout.trim() === "true"
594
- ? "bare"
595
- : "repository",
572
+ : (inspection?.kind ?? "repository"),
596
573
  remote,
597
574
  declaredRemote,
598
575
  target,
@@ -760,10 +737,12 @@ export class RepositoryService extends Effect.Service<RepositoryService>()(
760
737
  ) =>
761
738
  Effect.gen(function* () {
762
739
  const fs = yield* FileSystemService
740
+ const versionControl = yield* VersionControlService
763
741
  const repository = yield* find(alias, startPath)
764
742
  if (remote === undefined) return repository
765
743
  const portable = yield* validateRemote(remote)
766
744
  const state = yield* configState(startPath)
745
+ const backend = yield* versionControl.forWorkbase(state.root)
767
746
  const config = withDeclarations(state.config, {
768
747
  ...(state.config.repositories ?? {}),
769
748
  [repository.alias]: { remote: portable },
@@ -777,30 +756,12 @@ export class RepositoryService extends Effect.Service<RepositoryService>()(
777
756
  const previous = repository.remote
778
757
  const update = (value: string | null) =>
779
758
  Effect.runPromise(
780
- fs.runCommand(
781
- value === null
782
- ? [
783
- "git",
784
- "-C",
785
- repository.path,
786
- "remote",
787
- "remove",
788
- "origin",
789
- ]
790
- : [
791
- "git",
792
- "-C",
793
- repository.path,
794
- "remote",
795
- previous === null ? "add" : "set-url",
796
- "origin",
797
- value,
798
- ],
799
- { captureOutput: true },
800
- ),
801
- ).then((result) => {
802
- if (result.exitCode !== 0) throw new Error(result.stderr.trim())
803
- })
759
+ backend
760
+ .setRemoteUrl(repository.path, "origin", value)
761
+ .pipe(
762
+ Effect.provideService(FileSystemService, fs),
763
+ ) as Effect.Effect<void, unknown, never>,
764
+ )
804
765
  steps.push({
805
766
  label: `update origin for repos/${repository.alias}`,
806
767
  preflight: async () => {
@@ -810,21 +771,11 @@ export class RepositoryService extends Effect.Service<RepositoryService>()(
810
771
  `Repository alias '${repository.alias}' changed to a linked checkout; retry the remote update`,
811
772
  )
812
773
  }
813
- const current = await Effect.runPromise(
814
- fs.runCommand(
815
- [
816
- "git",
817
- "-C",
818
- repository.path,
819
- "remote",
820
- "get-url",
821
- "origin",
822
- ],
823
- { captureOutput: true },
824
- ),
774
+ const currentRemote = await Effect.runPromise(
775
+ backend
776
+ .remoteUrl(repository.path, "origin")
777
+ .pipe(Effect.provideService(FileSystemService, fs)),
825
778
  )
826
- const currentRemote =
827
- current.exitCode === 0 ? current.stdout.trim() : null
828
779
  if (currentRemote !== previous) {
829
780
  throw new Error(
830
781
  `Origin for repository '${repository.alias}' changed; retry the remote update`,
@@ -847,7 +798,7 @@ export class RepositoryService extends Effect.Service<RepositoryService>()(
847
798
  if (repository.states.includes("missing"))
848
799
  issues.push("Local materialization is missing")
849
800
  if (repository.states.includes("invalid"))
850
- issues.push("Path is not a Git repository")
801
+ issues.push("Path is not a valid repository")
851
802
  if (!repository.declaredRemote)
852
803
  issues.push("Portable remote is not declared")
853
804
  if (repository.states.includes("remote-drifted"))
@@ -877,7 +828,7 @@ export class RepositoryService extends Effect.Service<RepositoryService>()(
877
828
  unresolved.push({
878
829
  alias: repository.alias,
879
830
  state: "invalid",
880
- message: `Local path for '${repository.alias}' is not a valid Git repository`,
831
+ message: `Local path for '${repository.alias}' is not a valid repository`,
881
832
  action: `Repair the path or run 'agency repo remove ${repository.alias}' before setup`,
882
833
  })
883
834
  continue
@@ -936,25 +887,17 @@ export class RepositoryService extends Effect.Service<RepositoryService>()(
936
887
  `.agency-setup-${action.alias}-${process.pid}-${Date.now()}`,
937
888
  )
938
889
  yield* fs.createDirectory(join(state.root, "repos"))
939
- const cloned = yield* fs.runCommand(
940
- [
941
- "git",
942
- "clone",
943
- ...(backend.kind === "git" ? ["--bare"] : []),
944
- "--",
945
- action.remote,
946
- from,
947
- ],
948
- { captureOutput: true },
949
- )
950
- if (cloned.exitCode !== 0) {
890
+ const cloned = yield* backend
891
+ .cloneRepository(action.remote, from)
892
+ .pipe(Effect.either)
893
+ if (Either.isLeft(cloned)) {
951
894
  for (const item of staging)
952
895
  yield* fs.deleteDirectory(item.from).pipe(Effect.ignore)
953
896
  return yield* new RepositoryError({
954
- message: `Failed to materialize repository '${action.alias}': ${cloned.stderr.trim()}`,
897
+ message: `Failed to materialize repository '${action.alias}': ${cloned.left instanceof Error ? cloned.left.message : String(cloned.left)}`,
898
+ cause: cloned.left,
955
899
  })
956
900
  }
957
- yield* backend.initializeRepository(from)
958
901
  staging.push({
959
902
  alias: action.alias,
960
903
  from,
@@ -166,11 +166,11 @@ describe("ReviewService", () => {
166
166
  if (!Bun.which("jj")) return
167
167
  const repository = join(root, "repos/agency")
168
168
  await rm(repository, { recursive: true, force: true })
169
- await git(["clone", source, repository])
170
169
  await git(["switch", "-c", "jj-review"], source)
171
170
  await Bun.write(join(source, "README.md"), "jj review one\n")
172
171
  await git(["commit", "-am", "jj review one"], source)
173
- await jj(["git", "init", "--colocate", repository])
172
+ await jj(["git", "clone", "--no-colocate", source, repository])
173
+ expect(await Bun.file(join(repository, ".git")).exists()).toBe(false)
174
174
  await Bun.write(
175
175
  join(root, "agency.json"),
176
176
  JSON.stringify({ version: 2, vcs: "jj" }),
@@ -45,8 +45,15 @@ const githubRepository = (remote: string) => {
45
45
  const pinRef = (taskId: string) =>
46
46
  `refs/agency/reviews/${Buffer.from(taskId).toString("hex")}`
47
47
 
48
- const runGit = async (args: readonly string[]) => {
49
- const child = Bun.spawn([...args], { stdout: "pipe", stderr: "pipe" })
48
+ const runGit = async (
49
+ args: readonly string[],
50
+ environment: Record<string, string> = {},
51
+ ) => {
52
+ const child = Bun.spawn([...args], {
53
+ stdout: "pipe",
54
+ stderr: "pipe",
55
+ env: { ...process.env, ...environment },
56
+ })
50
57
  const [exitCode, stdout, stderr] = await Promise.all([
51
58
  child.exited,
52
59
  new Response(child.stdout).text(),
@@ -121,7 +128,7 @@ const restoreSnapshots = async (
121
128
  }
122
129
  }
123
130
 
124
- const normalizeBranch = (input: string, repositoryPath: string) =>
131
+ const normalizeBranch = (input: string) =>
125
132
  Effect.gen(function* () {
126
133
  const fs = yield* FileSystemService
127
134
  if (
@@ -150,7 +157,7 @@ const normalizeBranch = (input: string, repositoryPath: string) =>
150
157
  })
151
158
  }
152
159
  const checked = yield* fs.runCommand(
153
- ["git", "-C", repositoryPath, "check-ref-format", "--branch", name],
160
+ ["git", "check-ref-format", "--branch", name],
154
161
  { captureOutput: true },
155
162
  )
156
163
  if (checked.exitCode !== 0) {
@@ -168,6 +175,7 @@ const fetchCommit = (
168
175
  ) =>
169
176
  Effect.gen(function* () {
170
177
  const fs = yield* FileSystemService
178
+ const environment = yield* backend.gitEnvironment(repoPath)
171
179
  const temporaryRef = `refs/agency/review-fetch/${process.pid}-${randomUUID()}`
172
180
  const fetched = yield* fs.runCommand(
173
181
  [
@@ -179,12 +187,12 @@ const fetchCommit = (
179
187
  "origin",
180
188
  `+${sourceRef}:${temporaryRef}`,
181
189
  ],
182
- { captureOutput: true },
190
+ { captureOutput: true, env: environment },
183
191
  )
184
192
  if (fetched.exitCode !== 0) {
185
193
  const cleanup = yield* fs.runCommand(
186
194
  ["git", "-C", repoPath, "update-ref", "-d", temporaryRef],
187
- { captureOutput: true },
195
+ { captureOutput: true, env: environment },
188
196
  )
189
197
  return yield* new ReviewError({
190
198
  message: `Review source '${sourceRef}' could not be fetched: ${fetched.stderr.trim()}${cleanup.exitCode === 0 ? "" : `; temporary ref cleanup failed: ${cleanup.stderr.trim()}`}`,
@@ -199,13 +207,13 @@ const fetchCommit = (
199
207
  "--verify",
200
208
  `${temporaryRef}^{commit}`,
201
209
  ],
202
- { captureOutput: true },
210
+ { captureOutput: true, env: environment },
203
211
  )
204
212
  const commit = resolved.stdout.trim()
205
213
  if (resolved.exitCode !== 0 || !/^[a-f0-9]{40}$/.test(commit)) {
206
214
  yield* fs.runCommand(
207
215
  ["git", "-C", repoPath, "update-ref", "-d", temporaryRef],
208
- { captureOutput: true },
216
+ { captureOutput: true, env: environment },
209
217
  )
210
218
  return yield* new ReviewError({
211
219
  message: `Review source '${sourceRef}' did not resolve to a commit`,
@@ -214,7 +222,7 @@ const fetchCommit = (
214
222
  const imported = yield* Effect.either(backend.importGitRefs(repoPath))
215
223
  const cleanup = yield* fs.runCommand(
216
224
  ["git", "-C", repoPath, "update-ref", "-d", temporaryRef],
217
- { captureOutput: true },
225
+ { captureOutput: true, env: environment },
218
226
  )
219
227
  if (cleanup.exitCode !== 0) {
220
228
  return yield* new ReviewError({
@@ -284,7 +292,7 @@ export class ReviewService extends Effect.Service<ReviewService>()(
284
292
  fetchRef: sourceRef,
285
293
  }
286
294
  } else if (input.ref) {
287
- sourceRef = yield* normalizeBranch(input.ref, repository.path)
295
+ sourceRef = yield* normalizeBranch(input.ref)
288
296
  source = { kind: "branch", ref: sourceRef }
289
297
  } else {
290
298
  return yield* new ReviewError({
@@ -366,6 +374,11 @@ export class ReviewService extends Effect.Service<ReviewService>()(
366
374
  task.data.review.repo,
367
375
  root,
368
376
  )
377
+ const versionControl = yield* VersionControlService
378
+ const backend = yield* versionControl.forWorkbase(root)
379
+ const gitEnvironment = yield* backend.gitEnvironment(
380
+ repository.path,
381
+ )
369
382
  const hadCheckout = inspection.checkouts.some(
370
383
  (checkout) => checkout.exists || checkout.registered,
371
384
  )
@@ -391,25 +404,31 @@ export class ReviewService extends Effect.Service<ReviewService>()(
391
404
  steps.push({
392
405
  label: `advance review pin for ${taskId}`,
393
406
  apply: () =>
394
- runGit([
395
- "git",
396
- "-C",
397
- repository.path,
398
- "update-ref",
399
- pinRef(taskId),
400
- latest.commit,
401
- previousReview.commit,
402
- ]).then(() => undefined),
407
+ runGit(
408
+ [
409
+ "git",
410
+ "-C",
411
+ repository.path,
412
+ "update-ref",
413
+ pinRef(taskId),
414
+ latest.commit,
415
+ previousReview.commit,
416
+ ],
417
+ gitEnvironment,
418
+ ).then(() => undefined),
403
419
  rollback: () =>
404
- runGit([
405
- "git",
406
- "-C",
407
- repository.path,
408
- "update-ref",
409
- pinRef(taskId),
410
- previousReview.commit,
411
- latest.commit,
412
- ]).then(() => undefined),
420
+ runGit(
421
+ [
422
+ "git",
423
+ "-C",
424
+ repository.path,
425
+ "update-ref",
426
+ pinRef(taskId),
427
+ previousReview.commit,
428
+ latest.commit,
429
+ ],
430
+ gitEnvironment,
431
+ ).then(() => undefined),
413
432
  manualRecovery: `Reset ${pinRef(taskId)} to ${previousReview.commit}`,
414
433
  })
415
434
  if (hadCheckout) {
@@ -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 () => {
@@ -339,20 +339,34 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
339
339
  env: resolved.environment,
340
340
  })
341
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
- ])
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
+ )
350
359
  } else if (!config.delivery) {
360
+ const environment = yield* Effect.either(
361
+ backend.gitEnvironment(repositoryPath),
362
+ )
351
363
  result = yield* runExternal(
352
364
  [
353
365
  "gh",
354
366
  "pr",
355
367
  "list",
368
+ "--repo",
369
+ remoteRepository,
356
370
  "--head",
357
371
  data.branch,
358
372
  "--state",
@@ -360,7 +374,10 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
360
374
  "--json",
361
375
  "number,state,title,isDraft,headRefName,baseRefName,url,mergedAt,mergeCommit,mergeable",
362
376
  ],
363
- { cwd: repositoryPath },
377
+ {
378
+ cwd: repositoryPath,
379
+ env: Either.isRight(environment) ? environment.right : {},
380
+ },
364
381
  )
365
382
  }
366
383
  return [
@@ -531,14 +548,18 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
531
548
  let resolvedCommit: string | null = null
532
549
  if ("ref" in checkout) {
533
550
  if (!isCommitId(checkout.ref)) {
534
- const remoteRef = yield* runExternal([
535
- "git",
536
- "-C",
551
+ const remote = yield* backend.remoteUrl(
537
552
  repositoryPath,
538
- "ls-remote",
539
553
  "origin",
540
- originRef(checkout.ref),
541
- ])
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" }
542
563
  resolvedCommit =
543
564
  remoteRef.stdout.match(/^([0-9a-f]{40,64})\s/m)?.[1] ?? null
544
565
  if (!resolvedCommit) {
@@ -998,12 +1019,14 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
998
1019
  })
999
1020
  }
1000
1021
  const repositoryPath = join(root, "repos", data.review.repo)
1022
+ const reviewRemote = yield* backend.remoteUrl(
1023
+ repositoryPath,
1024
+ "origin",
1025
+ )
1001
1026
  const source = yield* runExternal([
1002
1027
  "git",
1003
- "-C",
1004
- repositoryPath,
1005
1028
  "ls-remote",
1006
- "origin",
1029
+ reviewRemote ?? "origin",
1007
1030
  data.review.source.kind === "pull-request"
1008
1031
  ? data.review.source.fetchRef
1009
1032
  : originRef(data.review.source.ref),
@@ -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
  })