@markjaquith/agency 2.56.1 → 2.58.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.
Files changed (34) hide show
  1. package/README.md +35 -9
  2. package/package.json +1 -1
  3. package/src/cli-parser.test.ts +8 -0
  4. package/src/cli-parser.ts +7 -1
  5. package/src/commands/archive.test.ts +39 -1
  6. package/src/commands/archive.ts +25 -2
  7. package/src/commands/pr.test.ts +40 -1
  8. package/src/commands/pr.ts +6 -0
  9. package/src/commands/restore.test.ts +8 -0
  10. package/src/services/ArchiveBulkService.test.ts +485 -0
  11. package/src/services/ArchiveService.test.ts +164 -1
  12. package/src/services/ArchiveService.ts +447 -29
  13. package/src/services/ContextService.ts +27 -10
  14. package/src/services/DoctorService.ts +6 -11
  15. package/src/services/GraphService.ts +60 -102
  16. package/src/services/PullRequestService.test.ts +45 -1
  17. package/src/services/PullRequestService.ts +18 -1
  18. package/src/services/PushService.test.ts +10 -13
  19. package/src/services/RepositoryService.test.ts +6 -1
  20. package/src/services/RepositoryService.ts +57 -114
  21. package/src/services/ReviewService.test.ts +2 -2
  22. package/src/services/ReviewService.ts +47 -28
  23. package/src/services/SyncService.test.ts +17 -2
  24. package/src/services/SyncService.ts +41 -18
  25. package/src/services/TaskService.ts +18 -2
  26. package/src/services/VcsMigrationService.test.ts +58 -1
  27. package/src/services/VcsMigrationService.ts +9 -0
  28. package/src/services/VersionControlService.test.ts +38 -0
  29. package/src/services/VersionControlService.ts +211 -2
  30. package/src/services/WorktreeService.test.ts +4 -0
  31. package/src/services/WorktreeService.ts +1 -5
  32. package/src/vcs-status-fast.ts +3 -8
  33. package/src/workbase/delivery-command.test.ts +11 -2
  34. package/src/workbase/delivery-command.ts +5 -1
@@ -33,6 +33,10 @@ import {
33
33
  type WorkStatus,
34
34
  } from "../workbase/schemas"
35
35
  import { FileSystemService } from "./FileSystemService"
36
+ import {
37
+ VersionControlService,
38
+ type VersionControlBackend,
39
+ } from "./VersionControlService"
36
40
  import { WorkbaseService, type ValidationReport } from "./WorkbaseService"
37
41
 
38
42
  class GraphError extends Data.TaggedError("GraphError")<{
@@ -68,6 +72,7 @@ export interface GraphOptions {
68
72
  readonly repositories?: readonly string[]
69
73
  readonly kinds?: readonly GraphNodeKind[]
70
74
  readonly include?: readonly GraphInclude[]
75
+ readonly backend?: VersionControlBackend
71
76
  }
72
77
 
73
78
  const epicNodeId = (id: string) => `epic:${id}`
@@ -127,6 +132,11 @@ export class GraphService extends Effect.Service<GraphService>()(
127
132
  const fs = yield* FileSystemService
128
133
  const workbase = yield* WorkbaseService
129
134
  const { root, config } = yield* workbase.loadConfig(options.cwd)
135
+ const backend =
136
+ options.backend ??
137
+ (yield* VersionControlService.pipe(
138
+ Effect.flatMap((service) => service.forWorkbase(root)),
139
+ ))
130
140
  const includes = [...new Set(options.include ?? [])].sort()
131
141
  const include = new Set(includes)
132
142
  const epics = new Map<string, Document<EpicData>>()
@@ -623,38 +633,20 @@ export class GraphService extends Effect.Service<GraphService>()(
623
633
 
624
634
  const inspectGit = (path: string) =>
625
635
  Effect.gen(function* () {
626
- const bare = yield* run(fs, [
627
- "git",
628
- "-C",
629
- path,
630
- "rev-parse",
631
- "--is-bare-repository",
632
- ])
636
+ const inspection = yield* backend.inspectRepository(path)
637
+ const workspaces = inspection
638
+ ? yield* backend
639
+ .listWorkspaces(path)
640
+ .pipe(Effect.catchAll(() => Effect.succeed([])))
641
+ : []
642
+ const primary = workspaces.find(
643
+ (workspace) => workspace.path === path,
644
+ )
633
645
  return {
634
- kind:
635
- bare === null
636
- ? null
637
- : bare === "true"
638
- ? "bare"
639
- : "repository",
640
- remote: yield* run(fs, [
641
- "git",
642
- "-C",
643
- path,
644
- "remote",
645
- "get-url",
646
- "origin",
647
- ]),
648
- head: yield* run(fs, ["git", "-C", path, "rev-parse", "HEAD"]),
649
- branch: yield* run(fs, [
650
- "git",
651
- "-C",
652
- path,
653
- "symbolic-ref",
654
- "--quiet",
655
- "--short",
656
- "HEAD",
657
- ]),
646
+ kind: inspection?.kind ?? null,
647
+ remote: inspection?.remote ?? null,
648
+ head: inspection ? yield* backend.workspaceHead(path) : null,
649
+ branch: primary?.branch?.replace(/^refs\/heads\//, "") ?? null,
658
650
  } satisfies GraphRepositoryGit
659
651
  })
660
652
 
@@ -678,6 +670,27 @@ export class GraphService extends Effect.Service<GraphService>()(
678
670
  }
679
671
  }
680
672
  if (include.has("git")) {
673
+ const remote = yield* backend.remoteUrl(
674
+ repositoryPath,
675
+ "origin",
676
+ )
677
+ const canonicalCheckoutPath = materialized
678
+ ? yield* fs.realPath(checkoutPath)
679
+ : checkoutPath
680
+ const actualBranch = materialized
681
+ ? yield* backend.listWorkspaces(repositoryPath).pipe(
682
+ Effect.map(
683
+ (workspaces) =>
684
+ workspaces
685
+ .find(
686
+ (workspace) =>
687
+ workspace.path === canonicalCheckoutPath,
688
+ )
689
+ ?.branch?.replace(/^refs\/heads\//, "") ?? null,
690
+ ),
691
+ Effect.catchAll(() => Effect.succeed(null)),
692
+ )
693
+ : null
681
694
  result.git =
682
695
  "review" in data
683
696
  ? {
@@ -686,10 +699,8 @@ export class GraphService extends Effect.Service<GraphService>()(
686
699
  value?.split(/\s+/)[0] ?? null)(
687
700
  yield* run(fs, [
688
701
  "git",
689
- "-C",
690
- repositoryPath,
691
702
  "ls-remote",
692
- "origin",
703
+ remote ?? "origin",
693
704
  data.review.source.kind === "pull-request"
694
705
  ? data.review.source.fetchRef
695
706
  : data.review.source.ref
@@ -698,86 +709,33 @@ export class GraphService extends Effect.Service<GraphService>()(
698
709
  ]),
699
710
  ),
700
711
  checkoutCommit: materialized
701
- ? yield* run(fs, [
702
- "git",
703
- "-C",
704
- checkoutPath,
705
- "rev-parse",
706
- "HEAD",
707
- ])
708
- : null,
709
- checkoutBranch: materialized
710
- ? yield* run(fs, [
711
- "git",
712
- "-C",
713
- checkoutPath,
714
- "symbolic-ref",
715
- "--quiet",
716
- "--short",
717
- "HEAD",
718
- ])
712
+ ? yield* backend.workspaceHead(checkoutPath)
719
713
  : null,
714
+ checkoutBranch: null,
720
715
  dirty: materialized
721
- ? ((status) =>
722
- status === null ? null : status.length > 0)(
723
- yield* runText(fs, [
724
- "git",
725
- "-C",
726
- checkoutPath,
727
- "status",
728
- "--porcelain",
729
- ]),
730
- )
716
+ ? yield* backend.workspaceDirty(checkoutPath)
731
717
  : null,
732
718
  }
733
719
  : {
734
720
  branch: data.branch,
735
721
  base: data.base,
736
- branchCommit: yield* run(fs, [
737
- "git",
738
- "-C",
722
+ branchCommit: yield* backend.resolveRevision(
739
723
  repositoryPath,
740
- "rev-parse",
741
- `${data.branch}^{commit}`,
742
- ]),
743
- baseCommit: yield* run(fs, [
744
- "git",
745
- "-C",
724
+ data.branch,
725
+ ),
726
+ baseCommit: yield* backend.resolveRevision(
746
727
  repositoryPath,
747
- "rev-parse",
748
- `${data.base}^{commit}`,
749
- ]),
728
+ data.base,
729
+ ),
750
730
  checkoutCommit: materialized
751
- ? yield* run(fs, [
752
- "git",
753
- "-C",
754
- checkoutPath,
755
- "rev-parse",
756
- "HEAD",
757
- ])
758
- : null,
759
- checkoutBranch: materialized
760
- ? yield* run(fs, [
761
- "git",
762
- "-C",
763
- checkoutPath,
764
- "symbolic-ref",
765
- "--quiet",
766
- "--short",
767
- "HEAD",
768
- ])
731
+ ? yield* backend.workspaceHead(checkoutPath)
769
732
  : null,
733
+ checkoutBranch:
734
+ backend.kind === "jj" && materialized
735
+ ? data.branch
736
+ : actualBranch,
770
737
  dirty: materialized
771
- ? ((status) =>
772
- status === null ? null : status.length > 0)(
773
- yield* runText(fs, [
774
- "git",
775
- "-C",
776
- checkoutPath,
777
- "status",
778
- "--porcelain",
779
- ]),
780
- )
738
+ ? yield* backend.workspaceDirty(checkoutPath)
781
739
  : null,
782
740
  }
783
741
  }
@@ -97,7 +97,7 @@ describe("PullRequestService", () => {
97
97
  await Bun.write(
98
98
  path,
99
99
  `#!/usr/bin/env bun
100
- await Bun.write(${JSON.stringify(ghCallPath)}, JSON.stringify({ args: Bun.argv.slice(2), cwd: process.cwd() }))
100
+ await Bun.write(${JSON.stringify(ghCallPath)}, JSON.stringify({ args: Bun.argv.slice(2), cwd: process.cwd(), ...(process.env.GIT_DIR ? { gitDir: process.env.GIT_DIR } : {}) }))
101
101
  process.stdout.write(${JSON.stringify(stdout)})
102
102
  process.stderr.write(${JSON.stringify(stderr)})
103
103
  process.exit(${exitCode})
@@ -110,6 +110,7 @@ process.exit(${exitCode})
110
110
  (await Bun.file(ghCallPath).json()) as {
111
111
  args: string[]
112
112
  cwd: string
113
+ gitDir?: string
113
114
  }
114
115
 
115
116
  const expectRemoteBranch = async (branch: string, exists = true) => {
@@ -241,6 +242,49 @@ process.exit(${exitCode})
241
242
  expect(updated.endsWith(`${body}\n`)).toBe(true)
242
243
  })
243
244
 
245
+ test("creates a PR from a non-colocated jj workspace with backing Git context", async () => {
246
+ if (!Bun.which("jj")) return
247
+ await rm(join(root, "repos/agency"), { recursive: true, force: true })
248
+ await requireCommand([
249
+ "jj",
250
+ "git",
251
+ "clone",
252
+ "--no-colocate",
253
+ remotePath,
254
+ join(root, "repos/agency"),
255
+ ])
256
+ await requireCommand(
257
+ ["jj", "config", "set", "--repo", "user.name", "Agency Test"],
258
+ join(root, "repos/agency"),
259
+ )
260
+ await requireCommand(
261
+ ["jj", "config", "set", "--repo", "user.email", "agency@example.com"],
262
+ join(root, "repos/agency"),
263
+ )
264
+ await Bun.write(
265
+ join(root, "agency.json"),
266
+ JSON.stringify({ version: 2, vcs: "jj" }),
267
+ )
268
+ await createTask("jj-example", "task/jj-example")
269
+ const workspace = await materialize("jj-example")
270
+ await Bun.write(join(workspace.writablePath!, "CHANGE.md"), "jj change\n")
271
+ await requireCommand(
272
+ ["jj", "describe", "-m", "feat: add jj example"],
273
+ workspace.writablePath!,
274
+ )
275
+ await requireCommand(["jj", "new"], workspace.writablePath!)
276
+ const url = "https://github.com/example/agency/pull/44"
277
+ await writeFakeGh({ stdout: `${url}\n` })
278
+
279
+ expect(await createPullRequest("jj-example")).toBe(url)
280
+ await expectRemoteBranch("task/jj-example")
281
+ const ghCall = await readGhCall()
282
+ expect(ghCall.args).toContain("--head")
283
+ expect(ghCall.args).toContain("--repo")
284
+ expect(ghCall.gitDir).toContain(".jj")
285
+ expect(await Bun.file(join(root, "repos/agency/.git")).exists()).toBe(false)
286
+ })
287
+
244
288
  test("passes --draft to gh", async () => {
245
289
  await createTask()
246
290
  await writeFakeGh({
@@ -172,6 +172,19 @@ export class PullRequestService extends Effect.Service<PullRequestService>()(
172
172
  }
173
173
 
174
174
  yield* backend.push(workspace.writablePath, remote, execution.branch)
175
+ const defaults =
176
+ backend.kind === "jj"
177
+ ? yield* backend.pullRequestDefaults(
178
+ workspace.writablePath,
179
+ execution.base,
180
+ )
181
+ : null
182
+ if (backend.kind === "jj" && !defaults) {
183
+ return yield* new PullRequestError({
184
+ message:
185
+ "Failed to derive pull request title and body from jj commits",
186
+ })
187
+ }
175
188
 
176
189
  const remoteUrl = yield* backend.remoteUrl(
177
190
  workspace.writablePath,
@@ -198,11 +211,15 @@ export class PullRequestService extends Effect.Service<PullRequestService>()(
198
211
  repository,
199
212
  draft,
200
213
  vcs: config.vcs ?? "git",
214
+ ...(defaults ? { defaults } : {}),
201
215
  })
216
+ const gitEnvironment = config.delivery
217
+ ? {}
218
+ : yield* backend.gitEnvironment(workspace.writablePath)
202
219
  const created = yield* fs.runCommand(resolved.argv, {
203
220
  cwd: workspace.writablePath,
204
221
  captureOutput: true,
205
- env: resolved.environment,
222
+ env: { ...gitEnvironment, ...resolved.environment },
206
223
  })
207
224
  if (created.exitCode !== 0) {
208
225
  return yield* new PullRequestError({
@@ -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,