@markjaquith/agency 2.50.0 → 2.52.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 +245 -0
  27. package/src/services/VcsMigrationService.ts +812 -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
@@ -0,0 +1,100 @@
1
+ import { afterEach, describe, expect, test } from "bun:test"
2
+ import { Effect } from "effect"
3
+ import { mkdir, realpath, stat } from "node:fs/promises"
4
+ import { join } from "node:path"
5
+ import { cleanupTempDir, createTempDir, runTestEffect } from "../test-utils"
6
+ import { VersionControlService } from "./VersionControlService"
7
+ import { preferredVersionControl } from "../workbase/version-control"
8
+
9
+ const run = async (args: string[], cwd?: string) => {
10
+ const child = Bun.spawn(args, { cwd, stdout: "pipe", stderr: "pipe" })
11
+ const exitCode = await child.exited
12
+ if (exitCode !== 0) throw new Error(await new Response(child.stderr).text())
13
+ }
14
+
15
+ describe("VersionControlService", () => {
16
+ const roots: string[] = []
17
+
18
+ afterEach(async () => {
19
+ await Promise.all(roots.splice(0).map(cleanupTempDir))
20
+ })
21
+
22
+ test("prefers jj when available and falls back to Git", () => {
23
+ expect(preferredVersionControl(() => "/usr/bin/jj")).toBe("jj")
24
+ expect(preferredVersionControl(() => null)).toBe("git")
25
+ })
26
+
27
+ test("selects the backend persisted by the workbase", async () => {
28
+ for (const kind of ["git", "jj"] as const) {
29
+ const root = await createTempDir()
30
+ roots.push(root)
31
+ await Bun.write(
32
+ join(root, "agency.json"),
33
+ JSON.stringify({ version: 2, vcs: kind }),
34
+ )
35
+ const selected = await runTestEffect(
36
+ VersionControlService.pipe(
37
+ Effect.flatMap((service) => service.forWorkbase(root)),
38
+ ),
39
+ )
40
+ expect(selected.kind).toBe(kind)
41
+ }
42
+ })
43
+
44
+ test("initializes and manages jj workspaces", async () => {
45
+ if (!Bun.which("jj")) return
46
+ const root = await createTempDir()
47
+ roots.push(root)
48
+ await Bun.write(
49
+ join(root, "agency.json"),
50
+ JSON.stringify({ version: 2, vcs: "jj" }),
51
+ )
52
+ const repository = join(root, "repository")
53
+ const workspace = join(root, "workspace")
54
+ await mkdir(repository)
55
+ await run(["git", "init", "--initial-branch=main"], repository)
56
+ await run(["git", "config", "user.email", "test@example.com"], repository)
57
+ await run(["git", "config", "user.name", "Test"], repository)
58
+ await Bun.write(join(repository, "README.md"), "example\n")
59
+ await run(["git", "add", "README.md"], repository)
60
+ await run(["git", "commit", "-m", "initial"], repository)
61
+
62
+ const backend = await runTestEffect(
63
+ VersionControlService.pipe(
64
+ Effect.flatMap((service) => service.forWorkbase(root)),
65
+ ),
66
+ )
67
+ await runTestEffect(backend.initializeRepository(repository))
68
+ expect((await stat(join(repository, ".jj"))).isDirectory()).toBe(true)
69
+
70
+ const revision = await runTestEffect(
71
+ backend.resolveRevision(repository, "main"),
72
+ )
73
+ expect(revision).toMatch(/^[0-9a-f]{40}$/)
74
+ await runTestEffect(
75
+ backend.createWorkspace({
76
+ repositoryPath: repository,
77
+ workspacePath: workspace,
78
+ workspaceName: "agency-test",
79
+ revision: revision!,
80
+ branch: "task/test",
81
+ }),
82
+ )
83
+ const canonicalWorkspace = await realpath(workspace)
84
+ expect(
85
+ (await runTestEffect(backend.listWorkspaces(repository))).some(
86
+ (item) =>
87
+ item.name === "agency-test" && item.path === canonicalWorkspace,
88
+ ),
89
+ ).toBe(true)
90
+
91
+ await runTestEffect(
92
+ backend.removeWorkspace({
93
+ repositoryPath: repository,
94
+ workspacePath: workspace,
95
+ workspaceName: "agency-test",
96
+ }),
97
+ )
98
+ expect(await Bun.file(workspace).exists()).toBe(false)
99
+ })
100
+ })
@@ -0,0 +1,479 @@
1
+ import { Data, Effect } from "effect"
2
+ import { FileSystemService } from "./FileSystemService"
3
+ import { WorkbaseService } from "./WorkbaseService"
4
+ import type { VersionControlKind } from "../workbase/version-control"
5
+
6
+ interface RegisteredWorkspace {
7
+ readonly name: string | null
8
+ readonly path: string
9
+ readonly commit: string | null
10
+ readonly branch: string | null
11
+ }
12
+
13
+ export interface VersionControlBackend {
14
+ readonly kind: VersionControlKind
15
+ readonly initializeRepository: (
16
+ path: string,
17
+ ) => Effect.Effect<void, unknown, any>
18
+ readonly listWorkspaces: (
19
+ repositoryPath: string,
20
+ ) => Effect.Effect<readonly RegisteredWorkspace[], unknown, any>
21
+ readonly resolveRevision: (
22
+ repositoryPath: string,
23
+ revision: string,
24
+ ) => Effect.Effect<string | null, unknown, any>
25
+ readonly workspaceHead: (
26
+ workspacePath: string,
27
+ ) => Effect.Effect<string | null, unknown, any>
28
+ readonly workspaceDirty: (
29
+ workspacePath: string,
30
+ ) => Effect.Effect<boolean | null, unknown, any>
31
+ readonly createWorkspace: (options: {
32
+ readonly repositoryPath: string
33
+ readonly workspacePath: string
34
+ readonly workspaceName: string
35
+ readonly revision: string
36
+ readonly branch?: string
37
+ }) => Effect.Effect<void, unknown, any>
38
+ readonly removeWorkspace: (options: {
39
+ readonly repositoryPath: string
40
+ readonly workspacePath: string
41
+ readonly workspaceName: string | null
42
+ }) => Effect.Effect<void, unknown, any>
43
+ readonly fetch: (
44
+ repositoryPath: string,
45
+ remote?: string,
46
+ branch?: string,
47
+ ) => Effect.Effect<void, unknown, any>
48
+ readonly push: (
49
+ workspacePath: string,
50
+ remote: string,
51
+ branch: string,
52
+ ) => Effect.Effect<void, unknown, any>
53
+ readonly remoteUrl: (
54
+ repositoryPath: string,
55
+ remote: string,
56
+ ) => Effect.Effect<string | null, unknown, any>
57
+ }
58
+
59
+ class VersionControlError extends Data.TaggedError("VersionControlError")<{
60
+ readonly message: string
61
+ }> {}
62
+
63
+ const requireSuccess = (
64
+ label: string,
65
+ effect: Effect.Effect<
66
+ {
67
+ readonly exitCode: number
68
+ readonly stdout: string
69
+ readonly stderr: string
70
+ },
71
+ unknown,
72
+ any
73
+ >,
74
+ ) =>
75
+ effect.pipe(
76
+ Effect.flatMap((result) =>
77
+ result.exitCode === 0
78
+ ? Effect.succeed(result)
79
+ : Effect.fail(
80
+ new VersionControlError({
81
+ message: `${label}: ${result.stderr.trim() || result.stdout.trim()}`,
82
+ }),
83
+ ),
84
+ ),
85
+ )
86
+
87
+ const parseGitWorktrees = (output: string): readonly RegisteredWorkspace[] => {
88
+ const workspaces: RegisteredWorkspace[] = []
89
+ let current: RegisteredWorkspace | null = null
90
+ for (const field of output.split("\0")) {
91
+ if (field.startsWith("worktree ")) {
92
+ if (current) workspaces.push(current)
93
+ current = {
94
+ name: null,
95
+ path: field.slice("worktree ".length),
96
+ commit: null,
97
+ branch: null,
98
+ }
99
+ } else if (current && field.startsWith("HEAD ")) {
100
+ const workspace: RegisteredWorkspace = current
101
+ current = { ...workspace, commit: field.slice("HEAD ".length) }
102
+ } else if (current && field.startsWith("branch ")) {
103
+ const workspace: RegisteredWorkspace = current
104
+ current = {
105
+ ...workspace,
106
+ branch: field.slice("branch ".length),
107
+ }
108
+ }
109
+ }
110
+ if (current) workspaces.push(current)
111
+ return workspaces
112
+ }
113
+
114
+ export class GitVersionControlService extends Effect.Service<GitVersionControlService>()(
115
+ "GitVersionControlService",
116
+ {
117
+ sync: () =>
118
+ ({
119
+ kind: "git",
120
+ initializeRepository: () => Effect.void,
121
+ listWorkspaces: (repositoryPath) =>
122
+ Effect.gen(function* () {
123
+ const fs = yield* FileSystemService
124
+ const result = yield* requireSuccess(
125
+ "Failed to inspect Git worktrees",
126
+ fs.runCommand(
127
+ [
128
+ "git",
129
+ "-C",
130
+ repositoryPath,
131
+ "worktree",
132
+ "list",
133
+ "--porcelain",
134
+ "-z",
135
+ ],
136
+ { captureOutput: true },
137
+ ),
138
+ )
139
+ return parseGitWorktrees(result.stdout)
140
+ }),
141
+ resolveRevision: (repositoryPath, revision) =>
142
+ Effect.gen(function* () {
143
+ const fs = yield* FileSystemService
144
+ const result = yield* fs.runCommand(
145
+ [
146
+ "git",
147
+ "-C",
148
+ repositoryPath,
149
+ "rev-parse",
150
+ "--verify",
151
+ `${revision}^{commit}`,
152
+ ],
153
+ { captureOutput: true },
154
+ )
155
+ return result.exitCode === 0 ? result.stdout.trim() : null
156
+ }),
157
+ workspaceHead: (workspacePath) =>
158
+ Effect.gen(function* () {
159
+ const fs = yield* FileSystemService
160
+ const result = yield* fs.runCommand(
161
+ ["git", "-C", workspacePath, "rev-parse", "HEAD"],
162
+ { captureOutput: true },
163
+ )
164
+ return result.exitCode === 0 ? result.stdout.trim() : null
165
+ }),
166
+ workspaceDirty: (workspacePath) =>
167
+ Effect.gen(function* () {
168
+ const fs = yield* FileSystemService
169
+ const result = yield* fs.runCommand(
170
+ ["git", "-C", workspacePath, "status", "--porcelain"],
171
+ { captureOutput: true },
172
+ )
173
+ return result.exitCode === 0 ? result.stdout.length > 0 : null
174
+ }),
175
+ createWorkspace: (options) =>
176
+ Effect.gen(function* () {
177
+ const fs = yield* FileSystemService
178
+ const command = options.branch
179
+ ? [
180
+ "git",
181
+ "-C",
182
+ options.repositoryPath,
183
+ "worktree",
184
+ "add",
185
+ "-b",
186
+ options.branch,
187
+ options.workspacePath,
188
+ options.revision,
189
+ ]
190
+ : [
191
+ "git",
192
+ "-C",
193
+ options.repositoryPath,
194
+ "worktree",
195
+ "add",
196
+ "--detach",
197
+ options.workspacePath,
198
+ options.revision,
199
+ ]
200
+ yield* requireSuccess(
201
+ "Failed to create Git worktree",
202
+ fs.runCommand(command, { captureOutput: true }),
203
+ )
204
+ }),
205
+ removeWorkspace: (options) =>
206
+ Effect.gen(function* () {
207
+ const fs = yield* FileSystemService
208
+ yield* requireSuccess(
209
+ "Failed to remove Git worktree",
210
+ fs.runCommand(
211
+ [
212
+ "git",
213
+ "-C",
214
+ options.repositoryPath,
215
+ "worktree",
216
+ "remove",
217
+ options.workspacePath,
218
+ ],
219
+ { captureOutput: true },
220
+ ),
221
+ )
222
+ }),
223
+ fetch: (repositoryPath, remote = "origin", branch) =>
224
+ Effect.gen(function* () {
225
+ const fs = yield* FileSystemService
226
+ yield* requireSuccess(
227
+ "Failed to fetch Git repository",
228
+ fs.runCommand(
229
+ [
230
+ "git",
231
+ "-C",
232
+ repositoryPath,
233
+ "fetch",
234
+ remote,
235
+ ...(branch ? [branch] : []),
236
+ ],
237
+ { captureOutput: true },
238
+ ),
239
+ )
240
+ }),
241
+ push: (workspacePath, remote, branch) =>
242
+ Effect.gen(function* () {
243
+ const fs = yield* FileSystemService
244
+ yield* requireSuccess(
245
+ "Failed to push branch",
246
+ fs.runCommand(
247
+ [
248
+ "git",
249
+ "-C",
250
+ workspacePath,
251
+ "push",
252
+ "--set-upstream",
253
+ remote,
254
+ branch,
255
+ ],
256
+ { captureOutput: true },
257
+ ),
258
+ )
259
+ }),
260
+ remoteUrl: (repositoryPath, remote) =>
261
+ Effect.gen(function* () {
262
+ const fs = yield* FileSystemService
263
+ const result = yield* fs.runCommand(
264
+ ["git", "-C", repositoryPath, "remote", "get-url", remote],
265
+ { captureOutput: true },
266
+ )
267
+ return result.exitCode === 0 ? result.stdout.trim() : null
268
+ }),
269
+ }) satisfies VersionControlBackend,
270
+ },
271
+ ) {}
272
+
273
+ const jjCommand = (repositoryPath: string, args: readonly string[]) => [
274
+ "jj",
275
+ "-R",
276
+ repositoryPath,
277
+ "--no-pager",
278
+ ...args,
279
+ ]
280
+
281
+ export class JjVersionControlService extends Effect.Service<JjVersionControlService>()(
282
+ "JjVersionControlService",
283
+ {
284
+ sync: () =>
285
+ ({
286
+ kind: "jj",
287
+ initializeRepository: (path) =>
288
+ Effect.gen(function* () {
289
+ const fs = yield* FileSystemService
290
+ if (yield* fs.exists(`${path}/.jj`)) return
291
+ yield* requireSuccess(
292
+ "Failed to initialize jj repository",
293
+ fs.runCommand(["jj", "git", "init", "--colocate", path], {
294
+ captureOutput: true,
295
+ }),
296
+ )
297
+ }),
298
+ listWorkspaces: (repositoryPath) =>
299
+ Effect.gen(function* () {
300
+ const fs = yield* FileSystemService
301
+ const result = yield* requireSuccess(
302
+ "Failed to inspect jj workspaces",
303
+ fs.runCommand(
304
+ jjCommand(repositoryPath, [
305
+ "workspace",
306
+ "list",
307
+ "-T",
308
+ 'name ++ "\\t" ++ root ++ "\\t" ++ target.commit_id() ++ "\\n"',
309
+ ]),
310
+ { captureOutput: true },
311
+ ),
312
+ )
313
+ return result.stdout
314
+ .trim()
315
+ .split("\n")
316
+ .filter(Boolean)
317
+ .map((line) => {
318
+ const [name, path, commit] = line.split("\t")
319
+ return {
320
+ name: name || null,
321
+ path: path!,
322
+ commit: commit || null,
323
+ branch: null,
324
+ }
325
+ })
326
+ }),
327
+ resolveRevision: (repositoryPath, revision) =>
328
+ Effect.gen(function* () {
329
+ const fs = yield* FileSystemService
330
+ const result = yield* fs.runCommand(
331
+ jjCommand(repositoryPath, [
332
+ "log",
333
+ "--ignore-working-copy",
334
+ "--no-graph",
335
+ "-r",
336
+ revision,
337
+ "-T",
338
+ 'commit_id ++ "\\n"',
339
+ ]),
340
+ { captureOutput: true },
341
+ )
342
+ return result.exitCode === 0 ? result.stdout.trim() || null : null
343
+ }),
344
+ workspaceHead: (workspacePath) =>
345
+ Effect.gen(function* () {
346
+ const fs = yield* FileSystemService
347
+ const result = yield* fs.runCommand(
348
+ jjCommand(workspacePath, [
349
+ "log",
350
+ "--ignore-working-copy",
351
+ "--no-graph",
352
+ "-r",
353
+ "@-",
354
+ "-T",
355
+ 'commit_id ++ "\\n"',
356
+ ]),
357
+ { captureOutput: true },
358
+ )
359
+ return result.exitCode === 0 ? result.stdout.trim() || null : null
360
+ }),
361
+ workspaceDirty: (workspacePath) =>
362
+ Effect.gen(function* () {
363
+ const fs = yield* FileSystemService
364
+ const result = yield* fs.runCommand(
365
+ jjCommand(workspacePath, ["diff", "--summary", "-r", "@"]),
366
+ { captureOutput: true },
367
+ )
368
+ return result.exitCode === 0 ? result.stdout.length > 0 : null
369
+ }),
370
+ createWorkspace: (options) =>
371
+ Effect.gen(function* () {
372
+ const fs = yield* FileSystemService
373
+ yield* requireSuccess(
374
+ "Failed to create jj workspace",
375
+ fs.runCommand(
376
+ jjCommand(options.repositoryPath, [
377
+ "workspace",
378
+ "add",
379
+ "--name",
380
+ options.workspaceName,
381
+ "-r",
382
+ options.revision,
383
+ options.workspacePath,
384
+ ]),
385
+ { captureOutput: true },
386
+ ),
387
+ )
388
+ }),
389
+ removeWorkspace: (options) =>
390
+ Effect.gen(function* () {
391
+ const fs = yield* FileSystemService
392
+ if (!options.workspaceName) {
393
+ return yield* new VersionControlError({
394
+ message: `Cannot remove unregistered jj workspace ${options.workspacePath}`,
395
+ })
396
+ }
397
+ yield* requireSuccess(
398
+ "Failed to forget jj workspace",
399
+ fs.runCommand(
400
+ jjCommand(options.repositoryPath, [
401
+ "workspace",
402
+ "forget",
403
+ options.workspaceName,
404
+ ]),
405
+ { captureOutput: true },
406
+ ),
407
+ )
408
+ yield* fs.deleteDirectory(options.workspacePath)
409
+ }),
410
+ fetch: (repositoryPath, remote = "origin", branch) =>
411
+ Effect.gen(function* () {
412
+ const fs = yield* FileSystemService
413
+ yield* requireSuccess(
414
+ "Failed to fetch jj repository",
415
+ fs.runCommand(
416
+ jjCommand(repositoryPath, [
417
+ "git",
418
+ "fetch",
419
+ "--remote",
420
+ remote,
421
+ ...(branch ? ["--branch", branch] : []),
422
+ ]),
423
+ { captureOutput: true },
424
+ ),
425
+ )
426
+ }),
427
+ push: (workspacePath, remote, branch) =>
428
+ Effect.gen(function* () {
429
+ const fs = yield* FileSystemService
430
+ yield* requireSuccess(
431
+ "Failed to push branch",
432
+ fs.runCommand(
433
+ jjCommand(workspacePath, [
434
+ "git",
435
+ "push",
436
+ "--remote",
437
+ remote,
438
+ "--named",
439
+ `${branch}=@-`,
440
+ ]),
441
+ { captureOutput: true },
442
+ ),
443
+ )
444
+ }),
445
+ remoteUrl: (repositoryPath, remote) =>
446
+ Effect.gen(function* () {
447
+ const fs = yield* FileSystemService
448
+ const result = yield* fs.runCommand(
449
+ jjCommand(repositoryPath, ["git", "remote", "list"]),
450
+ { captureOutput: true },
451
+ )
452
+ if (result.exitCode !== 0) return null
453
+ return (
454
+ result.stdout
455
+ .split("\n")
456
+ .find((line) => line.startsWith(`${remote} `))
457
+ ?.slice(remote.length + 1)
458
+ .trim() ?? null
459
+ )
460
+ }),
461
+ }) satisfies VersionControlBackend,
462
+ },
463
+ ) {}
464
+
465
+ export class VersionControlService extends Effect.Service<VersionControlService>()(
466
+ "VersionControlService",
467
+ {
468
+ sync: () => ({
469
+ forWorkbase: (root: string) =>
470
+ Effect.gen(function* () {
471
+ const workbase = yield* WorkbaseService
472
+ const git = yield* GitVersionControlService
473
+ const jj = yield* JjVersionControlService
474
+ const { config } = yield* workbase.loadConfig(root)
475
+ return config.vcs === "jj" ? jj : git
476
+ }),
477
+ }),
478
+ },
479
+ ) {}
@@ -23,6 +23,7 @@ import { validateWorktreeCreateCommand } from "../workbase/worktree-command"
23
23
  import { validateRunners } from "../workbase/runner-command"
24
24
  import { findDependencyCycles } from "../workbase/dependency-graph"
25
25
  import { validateDelivery } from "../workbase/delivery-command"
26
+ import { preferredVersionControl } from "../workbase/version-control"
26
27
 
27
28
  class WorkbaseNotFoundError extends Data.TaggedError("WorkbaseNotFoundError")<{
28
29
  readonly message: string
@@ -189,7 +190,10 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
189
190
  }
190
191
 
191
192
  yield* fs.createDirectory(root)
192
- yield* fs.writeJSON(configPath, { version: 2 })
193
+ yield* fs.writeJSON(configPath, {
194
+ version: 2,
195
+ vcs: preferredVersionControl(),
196
+ })
193
197
  for (const directory of ["repos", "epics", "tasks"]) {
194
198
  yield* fs.createDirectory(join(root, directory))
195
199
  }
@@ -1,6 +1,6 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from "bun:test"
2
2
  import { Effect } from "effect"
3
- import { mkdir, realpath, rename, rm } from "node:fs/promises"
3
+ import { mkdir, realpath, rename, rm, stat } from "node:fs/promises"
4
4
  import { join } from "node:path"
5
5
  import {
6
6
  captureErrors,
@@ -23,6 +23,17 @@ const git = async (args: string[], cwd?: string) => {
23
23
  throw new Error(await new Response(process.stderr).text())
24
24
  }
25
25
 
26
+ const jj = async (args: string[], cwd?: string) => {
27
+ const process = Bun.spawn(["jj", ...args], {
28
+ cwd,
29
+ stdout: "pipe",
30
+ stderr: "pipe",
31
+ })
32
+ await process.exited
33
+ if (process.exitCode !== 0)
34
+ throw new Error(await new Response(process.stderr).text())
35
+ }
36
+
26
37
  describe("WorktreeService", () => {
27
38
  let root: string
28
39
  let source: string
@@ -93,6 +104,66 @@ describe("WorktreeService", () => {
93
104
  expect(new TextDecoder().decode(branch.stdout).trim()).toBe("task/example")
94
105
  })
95
106
 
107
+ test("materializes and removes a jj workspace for a jj workbase", async () => {
108
+ if (!Bun.which("jj")) return
109
+ const repository = join(root, "repos/agency")
110
+ await rm(repository, { recursive: true, force: true })
111
+ await git(["clone", source, repository])
112
+ await jj(["git", "init", "--colocate", repository])
113
+ await Bun.write(
114
+ join(root, "agency.json"),
115
+ JSON.stringify({ version: 2, vcs: "jj" }),
116
+ )
117
+ await runTestEffect(
118
+ TaskService.pipe(
119
+ Effect.flatMap((service) =>
120
+ service.create(
121
+ {
122
+ id: "jj-example",
123
+ ticketUrl: null,
124
+ repo: "agency",
125
+ branch: "task/jj-example",
126
+ base: "main",
127
+ },
128
+ root,
129
+ ),
130
+ ),
131
+ ),
132
+ )
133
+
134
+ const workspace = await runTestEffect(
135
+ WorktreeService.pipe(
136
+ Effect.flatMap((service) =>
137
+ service.materialize("jj-example", undefined, root),
138
+ ),
139
+ ),
140
+ )
141
+ const jjMetadata = await stat(join(workspace.writablePath!, ".jj"))
142
+ expect(jjMetadata.isFile() || jjMetadata.isDirectory()).toBe(true)
143
+ const inspection = await runTestEffect(
144
+ WorktreeService.pipe(
145
+ Effect.flatMap((service) =>
146
+ service.inspect("jj-example", undefined, root),
147
+ ),
148
+ ),
149
+ )
150
+ expect(inspection.conflicts).toEqual([])
151
+ expect(inspection.checkouts[0]).toMatchObject({
152
+ exists: true,
153
+ registered: true,
154
+ actualBranch: "task/jj-example",
155
+ })
156
+
157
+ await runTestEffect(
158
+ WorktreeService.pipe(
159
+ Effect.flatMap((service) =>
160
+ service.remove("jj-example", undefined, root),
161
+ ),
162
+ ),
163
+ )
164
+ expect(await Bun.file(workspace.writablePath!).exists()).toBe(false)
165
+ })
166
+
96
167
  test("does not fetch the origin for an existing writable worktree", async () => {
97
168
  await runTestEffect(
98
169
  TaskService.pipe(