@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.
@@ -74,21 +74,16 @@ describe("PushService", () => {
74
74
  await requireCommand(["git", "commit", "-m", "Initial commit"], seed)
75
75
  await requireCommand(["git", "remote", "add", "origin", remote], seed)
76
76
  await requireCommand(["git", "push", "-u", "origin", "main"], seed)
77
- await requireCommand(["git", "clone", "--bare", remote, repository])
78
77
  if (vcs === "jj") {
79
- await requireCommand(["jj", "git", "init", "--colocate", repository])
80
- await requireCommand(
81
- ["jj", "git", "remote", "add", "origin", remote],
82
- repository,
83
- )
84
- await requireCommand(
85
- ["jj", "git", "fetch", "--remote", "origin"],
86
- repository,
87
- )
88
- await requireCommand(
89
- ["jj", "bookmark", "create", "main", "-r", "main@origin"],
78
+ await requireCommand([
79
+ "jj",
80
+ "git",
81
+ "clone",
82
+ "--no-colocate",
83
+ remote,
90
84
  repository,
91
- )
85
+ ])
86
+ expect(await Bun.file(join(repository, ".git")).exists()).toBe(false)
92
87
  await requireCommand(
93
88
  ["jj", "config", "set", "--repo", "user.name", "Agency Test"],
94
89
  repository,
@@ -97,6 +92,8 @@ describe("PushService", () => {
97
92
  ["jj", "config", "set", "--repo", "user.email", "agency@example.com"],
98
93
  repository,
99
94
  )
95
+ } else {
96
+ await requireCommand(["git", "clone", "--bare", remote, repository])
100
97
  }
101
98
 
102
99
  await runTestEffect(
@@ -119,13 +119,18 @@ describe("RepositoryService", () => {
119
119
  ),
120
120
  )
121
121
  expect((await stat(join(destination, ".jj"))).isDirectory()).toBe(true)
122
+ expect(await Bun.file(join(destination, ".git")).exists()).toBe(false)
122
123
  expect(
123
124
  await runTestEffect(
124
125
  RepositoryService.pipe(
125
126
  Effect.flatMap((service) => service.show("agency", root)),
126
127
  ),
127
128
  ),
128
- ).toMatchObject({ kind: "repository" })
129
+ ).toMatchObject({
130
+ kind: "repository",
131
+ remote: portableRemote("jj-agency"),
132
+ states: ["declared", "materialized"],
133
+ })
129
134
  })
130
135
 
131
136
  test("links an existing repository", async () => {
@@ -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) {