@markjaquith/agency 2.50.0 → 2.51.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 (36) hide show
  1. package/README.md +34 -8
  2. package/cli.ts +33 -1
  3. package/package.json +1 -1
  4. package/schemas/agency-graph-v1.schema.json +1 -0
  5. package/src/cli-parser.test.ts +19 -0
  6. package/src/cli-parser.ts +23 -0
  7. package/src/cli.test.ts +4 -4
  8. package/src/commands/init.test.ts +2 -0
  9. package/src/commands/vcs.test.ts +75 -0
  10. package/src/commands/vcs.ts +72 -0
  11. package/src/commands/worktree.ts +1 -1
  12. package/src/graph-schema.test.ts +1 -1
  13. package/src/graph-schema.ts +1 -0
  14. package/src/services/ArchiveService.ts +23 -0
  15. package/src/services/ContextService.ts +62 -13
  16. package/src/services/DoctorService.ts +13 -6
  17. package/src/services/GraphService.ts +1 -0
  18. package/src/services/PhaseService.ts +191 -70
  19. package/src/services/PullRequestService.ts +14 -30
  20. package/src/services/RepositoryService.test.ts +31 -1
  21. package/src/services/RepositoryService.ts +63 -31
  22. package/src/services/ReviewService.ts +23 -0
  23. package/src/services/SyncService.test.ts +67 -0
  24. package/src/services/SyncService.ts +51 -84
  25. package/src/services/TaskPhaseService.test.ts +80 -0
  26. package/src/services/VcsMigrationService.test.ts +211 -0
  27. package/src/services/VcsMigrationService.ts +816 -0
  28. package/src/services/VersionControlService.test.ts +100 -0
  29. package/src/services/VersionControlService.ts +479 -0
  30. package/src/services/WorkbaseService.ts +5 -1
  31. package/src/services/WorktreeService.test.ts +72 -1
  32. package/src/services/WorktreeService.ts +808 -308
  33. package/src/test-utils.ts +10 -0
  34. package/src/workbase/AGENTS.md +2 -2
  35. package/src/workbase/schemas.ts +1 -0
  36. package/src/workbase/version-control.ts +5 -0
@@ -17,6 +17,10 @@ import {
17
17
  WorkbaseConfig,
18
18
  } from "../workbase/schemas"
19
19
  import { documentRevision } from "../workbase/document-revision"
20
+ import {
21
+ VersionControlService,
22
+ type VersionControlBackend,
23
+ } from "./VersionControlService"
20
24
 
21
25
  class RepositoryError extends Data.TaggedError("RepositoryError")<{
22
26
  readonly message: string
@@ -207,7 +211,11 @@ const requireMaterialized = (repository: RepositoryInfo) =>
207
211
  )
208
212
  : Effect.succeed(repository)
209
213
 
210
- const removalBlockers = (repository: RepositoryInfo, startPath: string) =>
214
+ const removalBlockers = (
215
+ repository: RepositoryInfo,
216
+ startPath: string,
217
+ backend: VersionControlBackend,
218
+ ) =>
211
219
  Effect.gen(function* () {
212
220
  const fs = yield* FileSystemService
213
221
  const graph = yield* GraphService
@@ -223,28 +231,29 @@ const removalBlockers = (repository: RepositoryInfo, startPath: string) =>
223
231
  .sort()
224
232
  const worktrees: string[] = []
225
233
  if (repository.kind !== null && !repository.states.includes("invalid")) {
234
+ const repositoryTarget = yield* fs.realPath(repository.path)
226
235
  const linkedTarget =
227
236
  repository.kind === "symlink"
228
237
  ? yield* fs.realPath(repository.path)
229
238
  : null
230
- const result = yield* fs.runCommand(
231
- ["git", "-C", repository.path, "worktree", "list", "--porcelain"],
232
- { captureOutput: true },
233
- )
234
- if (result.exitCode === 0) {
235
- for (const block of result.stdout.trim().split(/\n\n+/)) {
236
- if (!block || /(^|\n)bare(\n|$)/.test(block)) continue
237
- const path = block.match(/^worktree (.+)$/m)?.[1]
238
- if (path && path !== linkedTarget) worktrees.push(path)
239
- }
239
+ for (const workspace of yield* backend.listWorkspaces(repository.path)) {
240
+ if (
241
+ workspace.path !== repositoryTarget &&
242
+ workspace.path !== linkedTarget
243
+ )
244
+ worktrees.push(workspace.path)
240
245
  }
241
246
  }
242
247
  return { references, worktrees }
243
248
  })
244
249
 
245
- const assertRemovable = (repository: RepositoryInfo, startPath: string) =>
250
+ const assertRemovable = (
251
+ repository: RepositoryInfo,
252
+ startPath: string,
253
+ backend: VersionControlBackend,
254
+ ) =>
246
255
  Effect.gen(function* () {
247
- const blockers = yield* removalBlockers(repository, startPath)
256
+ const blockers = yield* removalBlockers(repository, startPath, backend)
248
257
  const details = [
249
258
  ...blockers.references.map((item) => `active reference ${item}`),
250
259
  ...blockers.worktrees.map((item) => `linked worktree ${item}`),
@@ -258,10 +267,11 @@ const assertRemovable = (repository: RepositoryInfo, startPath: string) =>
258
267
 
259
268
  const effectPreflightStep = (
260
269
  label: string,
261
- check: Effect.Effect<void, unknown, never>,
270
+ check: Effect.Effect<void, unknown, any>,
262
271
  ): TransactionStep => ({
263
272
  label,
264
- preflight: () => Effect.runPromise(check),
273
+ preflight: () =>
274
+ Effect.runPromise(check as Effect.Effect<void, unknown, never>),
265
275
  apply: async () => undefined,
266
276
  })
267
277
 
@@ -341,8 +351,10 @@ export class RepositoryService extends Effect.Service<RepositoryService>()(
341
351
  add: (alias: string, remote: string, startPath: string = process.cwd()) =>
342
352
  Effect.gen(function* () {
343
353
  const fs = yield* FileSystemService
354
+ const versionControl = yield* VersionControlService
344
355
  const validAlias = yield* validateAlias(alias)
345
356
  const state = yield* configState(startPath)
357
+ const backend = yield* versionControl.forWorkbase(state.root)
346
358
  const destination = join(state.root, "repos", validAlias)
347
359
  if (
348
360
  state.config.repositories?.[validAlias] ||
@@ -369,7 +381,14 @@ export class RepositoryService extends Effect.Service<RepositoryService>()(
369
381
  )
370
382
  yield* fs.createDirectory(join(state.root, "repos"))
371
383
  const cloned = yield* fs.runCommand(
372
- ["git", "clone", "--bare", "--", cloneSource, staging],
384
+ [
385
+ "git",
386
+ "clone",
387
+ ...(backend.kind === "git" ? ["--bare"] : []),
388
+ "--",
389
+ cloneSource,
390
+ staging,
391
+ ],
373
392
  { captureOutput: true },
374
393
  )
375
394
  if (cloned.exitCode !== 0) {
@@ -378,6 +397,7 @@ export class RepositoryService extends Effect.Service<RepositoryService>()(
378
397
  message: `Failed to clone repository '${remote}': ${cloned.stderr.trim()}`,
379
398
  })
380
399
  }
400
+ yield* backend.initializeRepository(staging)
381
401
  if (declaredRemote !== remote) {
382
402
  const setRemote = yield* fs.runCommand(
383
403
  [
@@ -417,10 +437,12 @@ export class RepositoryService extends Effect.Service<RepositoryService>()(
417
437
  ) =>
418
438
  Effect.gen(function* () {
419
439
  const fs = yield* FileSystemService
440
+ const versionControl = yield* VersionControlService
420
441
  const graph = yield* GraphService
421
442
  const workbase = yield* WorkbaseService
422
443
  const validAlias = yield* validateAlias(alias)
423
444
  const state = yield* configState(startPath)
445
+ const backend = yield* versionControl.forWorkbase(state.root)
424
446
  const destination = join(state.root, "repos", validAlias)
425
447
  const resolvedTarget = resolve(startPath, target)
426
448
  const existing = (yield* RepositoryService)
@@ -452,6 +474,7 @@ export class RepositoryService extends Effect.Service<RepositoryService>()(
452
474
  message: `Path is not a Git repository: ${resolvedTarget}`,
453
475
  })
454
476
  }
477
+ yield* backend.initializeRepository(resolvedTarget)
455
478
  const declaredRemote =
456
479
  state.config.repositories?.[validAlias]?.remote ??
457
480
  (yield* portableRemote(resolvedTarget))
@@ -474,7 +497,7 @@ export class RepositoryService extends Effect.Service<RepositoryService>()(
474
497
  const safety = localCurrent
475
498
  ? effectPreflightStep(
476
499
  `verify repository safety for ${validAlias}`,
477
- assertRemovable(localCurrent, state.root).pipe(
500
+ assertRemovable(localCurrent, state.root, backend).pipe(
478
501
  Effect.provideService(FileSystemService, fs),
479
502
  Effect.provideService(GraphService, graph),
480
503
  Effect.provideService(WorkbaseService, workbase),
@@ -584,19 +607,12 @@ export class RepositoryService extends Effect.Service<RepositoryService>()(
584
607
 
585
608
  fetch: (alias: string, startPath: string = process.cwd()) =>
586
609
  Effect.gen(function* () {
587
- const fs = yield* FileSystemService
610
+ const versionControl = yield* VersionControlService
588
611
  const repository = yield* find(alias, startPath).pipe(
589
612
  Effect.flatMap(requireMaterialized),
590
613
  )
591
- const result = yield* fs.runCommand(
592
- ["git", "-C", repository.path, "fetch", "--prune"],
593
- { captureOutput: true },
594
- )
595
- if (result.exitCode !== 0) {
596
- return yield* new RepositoryError({
597
- message: `Failed to fetch repository '${alias}': ${result.stderr.trim()}`,
598
- })
599
- }
614
+ const backend = yield* versionControl.forWorkbase(startPath)
615
+ yield* backend.fetch(repository.path, undefined, undefined)
600
616
  return repository
601
617
  }),
602
618
 
@@ -605,8 +621,10 @@ export class RepositoryService extends Effect.Service<RepositoryService>()(
605
621
  const fs = yield* FileSystemService
606
622
  const graph = yield* GraphService
607
623
  const workbase = yield* WorkbaseService
624
+ const versionControl = yield* VersionControlService
608
625
  const repository = yield* find(alias, startPath)
609
626
  const state = yield* configState(startPath)
627
+ const backend = yield* versionControl.forWorkbase(state.root)
610
628
  const declarations = { ...(state.config.repositories ?? {}) }
611
629
  delete declarations[repository.alias]
612
630
  const config = withDeclarations(state.config, declarations)
@@ -618,7 +636,7 @@ export class RepositoryService extends Effect.Service<RepositoryService>()(
618
636
  )
619
637
  const safety = effectPreflightStep(
620
638
  `verify repository safety for ${repository.alias}`,
621
- assertRemovable(repository, startPath).pipe(
639
+ assertRemovable(repository, startPath, backend).pipe(
622
640
  Effect.provideService(FileSystemService, fs),
623
641
  Effect.provideService(GraphService, graph),
624
642
  Effect.provideService(WorkbaseService, workbase),
@@ -642,6 +660,7 @@ export class RepositoryService extends Effect.Service<RepositoryService>()(
642
660
  const fs = yield* FileSystemService
643
661
  const graph = yield* GraphService
644
662
  const workbase = yield* WorkbaseService
663
+ const versionControl = yield* VersionControlService
645
664
  const repository = yield* find(alias, startPath)
646
665
  if (repository.kind !== "symlink") {
647
666
  return yield* new RepositoryError({
@@ -649,6 +668,7 @@ export class RepositoryService extends Effect.Service<RepositoryService>()(
649
668
  })
650
669
  }
651
670
  const state = yield* configState(startPath)
671
+ const backend = yield* versionControl.forWorkbase(state.root)
652
672
  const staging = join(
653
673
  state.root,
654
674
  "repos",
@@ -660,7 +680,7 @@ export class RepositoryService extends Effect.Service<RepositoryService>()(
660
680
  steps: [
661
681
  effectPreflightStep(
662
682
  `verify repository safety for ${repository.alias}`,
663
- assertRemovable(repository, startPath).pipe(
683
+ assertRemovable(repository, startPath, backend).pipe(
664
684
  Effect.provideService(FileSystemService, fs),
665
685
  Effect.provideService(GraphService, graph),
666
686
  Effect.provideService(WorkbaseService, workbase),
@@ -685,9 +705,11 @@ export class RepositoryService extends Effect.Service<RepositoryService>()(
685
705
  const fs = yield* FileSystemService
686
706
  const graph = yield* GraphService
687
707
  const workbase = yield* WorkbaseService
708
+ const versionControl = yield* VersionControlService
688
709
  const repository = yield* find(alias, startPath)
689
710
  const validNewAlias = yield* validateAlias(newAlias)
690
711
  const state = yield* configState(startPath)
712
+ const backend = yield* versionControl.forWorkbase(state.root)
691
713
  const destination = join(state.root, "repos", validNewAlias)
692
714
  if (
693
715
  state.config.repositories?.[validNewAlias] ||
@@ -712,7 +734,7 @@ export class RepositoryService extends Effect.Service<RepositoryService>()(
712
734
  const exists = yield* fs.exists(repository.path)
713
735
  const safety = effectPreflightStep(
714
736
  `verify repository safety for ${repository.alias}`,
715
- assertRemovable(repository, startPath).pipe(
737
+ assertRemovable(repository, startPath, backend).pipe(
716
738
  Effect.provideService(FileSystemService, fs),
717
739
  Effect.provideService(GraphService, graph),
718
740
  Effect.provideService(WorkbaseService, workbase),
@@ -843,7 +865,9 @@ export class RepositoryService extends Effect.Service<RepositoryService>()(
843
865
  Effect.gen(function* () {
844
866
  const service = yield* RepositoryService
845
867
  const fs = yield* FileSystemService
868
+ const versionControl = yield* VersionControlService
846
869
  const state = yield* configState(options.cwd ?? process.cwd())
870
+ const backend = yield* versionControl.forWorkbase(state.root)
847
871
  const repositories = yield* service.list(state.root)
848
872
  const planned: Omit<RepositorySetupAction, "status">[] = []
849
873
  const unresolved: RepositorySetupIssue[] = []
@@ -913,7 +937,14 @@ export class RepositoryService extends Effect.Service<RepositoryService>()(
913
937
  )
914
938
  yield* fs.createDirectory(join(state.root, "repos"))
915
939
  const cloned = yield* fs.runCommand(
916
- ["git", "clone", "--bare", "--", action.remote, from],
940
+ [
941
+ "git",
942
+ "clone",
943
+ ...(backend.kind === "git" ? ["--bare"] : []),
944
+ "--",
945
+ action.remote,
946
+ from,
947
+ ],
917
948
  { captureOutput: true },
918
949
  )
919
950
  if (cloned.exitCode !== 0) {
@@ -923,6 +954,7 @@ export class RepositoryService extends Effect.Service<RepositoryService>()(
923
954
  message: `Failed to materialize repository '${action.alias}': ${cloned.stderr.trim()}`,
924
955
  })
925
956
  }
957
+ yield* backend.initializeRepository(from)
926
958
  staging.push({
927
959
  alias: action.alias,
928
960
  from,
@@ -11,6 +11,11 @@ import {
11
11
  WorktreeService,
12
12
  type WorktreeRemovalSnapshot,
13
13
  } from "./WorktreeService"
14
+ import {
15
+ GitVersionControlService,
16
+ JjVersionControlService,
17
+ VersionControlService,
18
+ } from "./VersionControlService"
14
19
  import { withWorktreeLocks } from "./WorktreeLock"
15
20
  import {
16
21
  documentWriteStep,
@@ -53,6 +58,9 @@ const runGit = async (args: readonly string[]) => {
53
58
  const WorktreeLayer = Layer.mergeAll(
54
59
  FileSystemService.Default,
55
60
  WorkbaseService.Default,
61
+ GitVersionControlService.Default,
62
+ JjVersionControlService.Default,
63
+ VersionControlService.Default,
56
64
  TaskService.Default,
57
65
  PhaseService.Default,
58
66
  WorktreeService.Default,
@@ -72,6 +80,21 @@ const restoreSnapshots = async (
72
80
  continue
73
81
  } catch {}
74
82
  await mkdir(dirname(snapshot.path), { recursive: true })
83
+ if (snapshot.vcs === "jj") {
84
+ await runGit([
85
+ "jj",
86
+ "-R",
87
+ snapshot.repositoryPath,
88
+ "workspace",
89
+ "add",
90
+ "--name",
91
+ snapshot.workspaceName!,
92
+ "-r",
93
+ snapshot.head,
94
+ snapshot.path,
95
+ ])
96
+ continue
97
+ }
75
98
  await runGit(
76
99
  snapshot.branch
77
100
  ? [
@@ -22,6 +22,18 @@ const git = async (args: string[], cwd?: string) => {
22
22
  }
23
23
  }
24
24
 
25
+ const jj = async (args: string[], cwd?: string) => {
26
+ const process = Bun.spawn(["jj", ...args], {
27
+ cwd,
28
+ stdout: "pipe",
29
+ stderr: "pipe",
30
+ })
31
+ await process.exited
32
+ if (process.exitCode !== 0) {
33
+ throw new Error(await new Response(process.stderr).text())
34
+ }
35
+ }
36
+
25
37
  describe("SyncService", () => {
26
38
  let root: string
27
39
  let originalPath: string | undefined
@@ -109,6 +121,61 @@ pr: null
109
121
  expect(await Bun.file(join(root, "repos/agency")).exists()).toBe(false)
110
122
  })
111
123
 
124
+ test("reconciles jj workspaces through the jj backend", async () => {
125
+ if (!Bun.which("jj")) return
126
+ const repository = join(root, "repos/agency")
127
+ await rm(repository, { recursive: true, force: true })
128
+ await git(["clone", join(root, "source"), repository])
129
+ await jj(["git", "init", "--colocate", repository])
130
+ await Bun.write(
131
+ join(root, "agency.json"),
132
+ JSON.stringify({ version: 2, vcs: "jj" }),
133
+ )
134
+ await runTestEffect(
135
+ TaskService.pipe(
136
+ Effect.flatMap((service) =>
137
+ service.create(
138
+ {
139
+ id: "jj-sync",
140
+ ticketUrl: null,
141
+ repo: "agency",
142
+ branch: "task/jj-sync",
143
+ base: "main",
144
+ },
145
+ root,
146
+ ),
147
+ ),
148
+ ),
149
+ )
150
+
151
+ const planned = await runTestEffect(
152
+ SyncService.pipe(
153
+ Effect.flatMap((service) => service.reconcile({ cwd: root })),
154
+ ),
155
+ )
156
+ expect(planned.changes).toContainEqual(
157
+ expect.objectContaining({
158
+ kind: "materialize-workspace",
159
+ target: "task:jj-sync",
160
+ status: "planned",
161
+ }),
162
+ )
163
+
164
+ const applied = await runTestEffect(
165
+ SyncService.pipe(
166
+ Effect.flatMap((service) =>
167
+ service.reconcile({ cwd: root, apply: true }),
168
+ ),
169
+ ),
170
+ )
171
+ expect(applied.executions[0]?.checkouts[0]).toMatchObject({
172
+ exists: true,
173
+ registered: true,
174
+ branch: "task/jj-sync",
175
+ dirty: false,
176
+ })
177
+ })
178
+
112
179
  test("observes drift without mutation and applies only safe transitions", async () => {
113
180
  await runTestEffect(
114
181
  TaskService.pipe(
@@ -25,6 +25,7 @@ import {
25
25
  RepositoryService,
26
26
  type RepositorySetupResult,
27
27
  } from "./RepositoryService"
28
+ import { VersionControlService } from "./VersionControlService"
28
29
 
29
30
  class SyncError extends Data.TaggedError("SyncError")<{
30
31
  readonly message: string
@@ -161,7 +162,9 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
161
162
  const worktrees = yield* WorktreeService
162
163
  const claims = yield* ClaimService
163
164
  const repositories = yield* RepositoryService
165
+ const versionControl = yield* VersionControlService
164
166
  const { root, config } = yield* workbase.loadConfig(options.cwd)
167
+ const backend = yield* versionControl.forWorkbase(root)
165
168
  const validation = yield* workbase.validate(root)
166
169
  if (!validation.valid) {
167
170
  return yield* new SyncError({
@@ -268,33 +271,27 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
268
271
  workspaceConflict = true
269
272
  continue
270
273
  }
271
- const listed = yield* fs.runCommand(
272
- [
273
- "git",
274
- "-C",
275
- repositoryPath,
276
- "worktree",
277
- "list",
278
- "--porcelain",
279
- "-z",
280
- ],
281
- { captureOutput: true },
274
+ const listed = yield* Effect.either(
275
+ backend.listWorkspaces(repositoryPath),
282
276
  )
283
- if (listed.exitCode !== 0) {
277
+ if (Either.isLeft(listed)) {
284
278
  unresolved.push({
285
279
  kind: "worktree-inspection-failed",
286
280
  target: record.key,
287
- message:
288
- listed.stderr.trim() || `Cannot inspect '${checkout.repo}'`,
281
+ message: `Cannot inspect '${checkout.repo}'`,
289
282
  })
290
283
  workspaceConflict = true
291
284
  continue
292
285
  }
293
286
  const exists = yield* fs.isDirectory(checkoutPath)
294
287
  const registered: RegisteredWorktree[] = []
295
- for (const item of parseWorktrees(listed.stdout)) {
288
+ for (const item of listed.right) {
296
289
  registered.push({
297
- ...item,
290
+ head: item.commit,
291
+ branch:
292
+ backend.kind === "jj" && "branch" in checkout
293
+ ? checkout.branch
294
+ : item.branch,
298
295
  path: (yield* fs.exists(item.path))
299
296
  ? yield* fs.realPath(item.path)
300
297
  : resolve(item.path),
@@ -306,39 +303,36 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
306
303
  ? join(yield* fs.realPath(codePath), checkout.repo)
307
304
  : resolve(checkoutPath)
308
305
  let atPath = registered.find((item) => item.path === expectedPath)
306
+ if (backend.kind === "jj" && atPath && exists) {
307
+ atPath = {
308
+ ...atPath,
309
+ head: yield* backend.workspaceHead(checkoutPath),
310
+ }
311
+ }
309
312
  const branchRef =
310
- "branch" in checkout ? `refs/heads/${checkout.branch}` : null
311
- const branchElsewhere = branchRef
312
- ? registered.find(
313
- (item) =>
314
- item.branch === branchRef && item.path !== expectedPath,
315
- )
316
- : undefined
313
+ "branch" in checkout
314
+ ? backend.kind === "jj"
315
+ ? checkout.branch
316
+ : `refs/heads/${checkout.branch}`
317
+ : null
318
+ const branchElsewhere =
319
+ branchRef && backend.kind !== "jj"
320
+ ? registered.find(
321
+ (item) =>
322
+ item.branch === branchRef && item.path !== expectedPath,
323
+ )
324
+ : undefined
317
325
  if ("branch" in checkout && !exists && !branchElsewhere) {
318
- const branch = yield* fs.runCommand(
319
- [
320
- "git",
321
- "-C",
322
- repositoryPath,
323
- "rev-parse",
324
- "--verify",
325
- `${checkout.branch}^{commit}`,
326
- ],
327
- { captureOutput: true },
326
+ const branch = yield* backend.resolveRevision(
327
+ repositoryPath,
328
+ checkout.branch,
328
329
  )
329
- if (branch.exitCode !== 0) {
330
- const base = yield* fs.runCommand(
331
- [
332
- "git",
333
- "-C",
334
- repositoryPath,
335
- "rev-parse",
336
- "--verify",
337
- `${data.base}^{commit}`,
338
- ],
339
- { captureOutput: true },
330
+ if (!branch) {
331
+ const base = yield* backend.resolveRevision(
332
+ repositoryPath,
333
+ data.base,
340
334
  )
341
- if (base.exitCode !== 0) {
335
+ if (!base) {
342
336
  unresolved.push({
343
337
  kind: "unresolved-base",
344
338
  target: record.key,
@@ -404,22 +398,10 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
404
398
  })
405
399
  }
406
400
  }
407
- const resolvedRef = resolvedCommit
408
- ? null
409
- : yield* fs.runCommand(
410
- [
411
- "git",
412
- "-C",
413
- repositoryPath,
414
- "rev-parse",
415
- "--verify",
416
- `${checkout.ref}^{commit}`,
417
- ],
418
- { captureOutput: true },
419
- )
420
- if (resolvedRef?.exitCode === 0) {
421
- resolvedCommit = resolvedRef.stdout.trim()
422
- }
401
+ resolvedCommit ??= yield* backend.resolveRevision(
402
+ repositoryPath,
403
+ checkout.ref,
404
+ )
423
405
  if (!resolvedCommit) {
424
406
  unresolved.push({
425
407
  kind: "unresolved-reference",
@@ -431,23 +413,15 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
431
413
  }
432
414
  }
433
415
 
434
- const dirtyResult =
416
+ const dirty =
435
417
  exists && atPath
436
- ? yield* fs.runCommand(
437
- ["git", "-C", checkoutPath, "status", "--porcelain"],
438
- { captureOutput: true },
439
- )
440
- : null
441
- const dirty = dirtyResult
442
- ? dirtyResult.exitCode === 0
443
- ? dirtyResult.stdout.length > 0
418
+ ? yield* backend.workspaceDirty(checkoutPath)
444
419
  : null
445
- : null
446
- if (dirtyResult && dirtyResult.exitCode !== 0) {
420
+ if (exists && atPath && dirty === null) {
447
421
  warnings.push({
448
422
  kind: "status-inspection-failed",
449
423
  target: record.key,
450
- message: `Could not inspect dirtiness for ${checkoutPath}: ${dirtyResult.stderr.trim()}`,
424
+ message: `Could not inspect dirtiness for ${checkoutPath}`,
451
425
  action: "Inspect the checkout manually before changing it",
452
426
  })
453
427
  }
@@ -607,15 +581,8 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
607
581
  let prConflict = false
608
582
  const repositoryPath = join(root, "repos", data.repo)
609
583
  const remoteName = config.delivery?.remote ?? "origin"
610
- const remote = yield* runExternal([
611
- "git",
612
- "-C",
613
- repositoryPath,
614
- "remote",
615
- "get-url",
616
- remoteName,
617
- ])
618
- const remoteRepository = remote.stdout
584
+ const remoteUrl = yield* backend.remoteUrl(repositoryPath, remoteName)
585
+ const remoteRepository = (remoteUrl ?? "")
619
586
  .trim()
620
587
  .replace(/^[a-z][a-z0-9+.-]*:\/\/(?:[^@/]+@)?[^/]+\//i, "")
621
588
  .replace(/^[^:]+:/, "")
@@ -635,11 +602,11 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
635
602
  })
636
603
  }
637
604
 
638
- if (config.delivery && remote.exitCode !== 0) {
605
+ if (config.delivery && !remoteUrl) {
639
606
  warnings.push({
640
607
  kind: "delivery-remote-unavailable",
641
608
  target: record.key,
642
- message: `Could not inspect delivery remote '${remoteName}': ${remote.stderr.trim()}`,
609
+ message: `Could not inspect delivery remote '${remoteName}'`,
643
610
  })
644
611
  } else if (config.delivery) {
645
612
  const resolved = resolveDeliveryCommand(config.delivery, "query", {
@@ -7,6 +7,13 @@ import { EpicService } from "./EpicService"
7
7
  import { TaskService } from "./TaskService"
8
8
  import { PhaseService } from "./PhaseService"
9
9
  import { PullRequestService } from "./PullRequestService"
10
+ import { WorktreeService } from "./WorktreeService"
11
+
12
+ const run = async (args: string[], cwd?: string) => {
13
+ const child = Bun.spawn(args, { cwd, stdout: "pipe", stderr: "pipe" })
14
+ const exitCode = await child.exited
15
+ if (exitCode !== 0) throw new Error(await new Response(child.stderr).text())
16
+ }
10
17
 
11
18
  describe("task and phase services", () => {
12
19
  let root: string
@@ -283,6 +290,79 @@ describe("task and phase services", () => {
283
290
  })
284
291
  })
285
292
 
293
+ test("recreates jj workspaces when converting a task to phases", async () => {
294
+ if (!Bun.which("jj")) return
295
+ const source = join(root, "source")
296
+ const repository = join(root, "repos/agency")
297
+ await rm(repository, { recursive: true, force: true })
298
+ await mkdir(source)
299
+ await run(["git", "init", "--initial-branch=main"], source)
300
+ await run(["git", "config", "user.email", "test@example.com"], source)
301
+ await run(["git", "config", "user.name", "Test"], source)
302
+ await Bun.write(join(source, "README.md"), "example\n")
303
+ await run(["git", "add", "README.md"], source)
304
+ await run(["git", "commit", "-m", "initial"], source)
305
+ await run(["git", "clone", source, repository])
306
+ await run(["jj", "git", "init", "--colocate", repository])
307
+ await Bun.write(
308
+ join(root, "agency.json"),
309
+ JSON.stringify({ version: 2, vcs: "jj" }),
310
+ )
311
+ await runTestEffect(
312
+ TaskService.pipe(
313
+ Effect.flatMap((service) =>
314
+ service.create(
315
+ {
316
+ id: "jj-single",
317
+ ticketUrl: null,
318
+ repo: "agency",
319
+ branch: "task/jj-single",
320
+ base: "main",
321
+ },
322
+ root,
323
+ ),
324
+ ),
325
+ ),
326
+ )
327
+ const workspace = await runTestEffect(
328
+ WorktreeService.pipe(
329
+ Effect.flatMap((service) =>
330
+ service.materialize("jj-single", undefined, root),
331
+ ),
332
+ ),
333
+ )
334
+
335
+ await runTestEffect(
336
+ PhaseService.pipe(
337
+ Effect.flatMap((service) =>
338
+ service.create(
339
+ {
340
+ taskId: "jj-single",
341
+ id: "extra",
342
+ firstPhase: "implementation",
343
+ repo: "agency",
344
+ branch: "task/extra",
345
+ base: "main",
346
+ },
347
+ root,
348
+ ),
349
+ ),
350
+ ),
351
+ )
352
+
353
+ const moved = join(
354
+ root,
355
+ "tasks/jj-single/phases/implementation/code/agency",
356
+ )
357
+ expect(await Bun.file(workspace.codePath).exists()).toBe(false)
358
+ expect(await Bun.file(join(moved, "README.md")).text()).toBe("example\n")
359
+ const listed = Bun.spawnSync(
360
+ ["jj", "-R", repository, "workspace", "list", "-T", 'root ++ "\\n"'],
361
+ { stdout: "pipe" },
362
+ )
363
+ expect(new TextDecoder().decode(listed.stdout)).toContain(moved)
364
+ })
365
+
286
366
  test("preserves non-PR completion when converting a task to phases", async () => {
287
367
  await runTestEffect(
288
368
  TaskService.pipe(