@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
@@ -30,6 +30,7 @@ import {
30
30
  buildNonPrCompletion,
31
31
  type NonPrCompletionInput,
32
32
  } from "../workbase/completion"
33
+ import { VersionControlService } from "./VersionControlService"
33
34
 
34
35
  class PhaseError extends Data.TaggedError("PhaseError")<{
35
36
  readonly message: string
@@ -92,8 +93,10 @@ export class PhaseService extends Effect.Service<PhaseService>()(
92
93
  Effect.gen(function* () {
93
94
  const fs = yield* FileSystemService
94
95
  const workbase = yield* WorkbaseService
96
+ const versionControl = yield* VersionControlService
95
97
  const tasks = yield* TaskService
96
98
  const root = yield* workbase.discover(startPath)
99
+ const backend = yield* versionControl.forWorkbase(root)
97
100
  const taskId = yield* decodeId(input.taskId, "task")
98
101
  const id = yield* decodeId(input.id, "phase")
99
102
  const task = yield* tasks.show(taskId, root)
@@ -268,95 +271,213 @@ export class PhaseService extends Effect.Service<PhaseService>()(
268
271
  firstData.repo,
269
272
  ...(firstData.repos ?? []).map((reference) => reference.repo),
270
273
  ]
271
- const repair = async (basePath: string) => {
272
- for (const alias of checkoutAliases) {
273
- const checkoutPath = join(basePath, alias)
274
- try {
275
- await lstat(checkoutPath)
276
- } catch {
277
- continue
278
- }
279
- const result = Bun.spawnSync([
280
- "git",
281
- "-C",
282
- join(root, "repos", alias),
283
- "worktree",
284
- "repair",
285
- checkoutPath,
286
- ])
287
- if (result.exitCode !== 0) {
288
- throw new Error(
289
- `Failed to repair moved worktree for '${alias}': ${new TextDecoder().decode(result.stderr)}`,
290
- )
291
- }
292
- }
293
- }
294
- steps.push({
295
- label: `move and repair code for ${taskId}/${firstPhaseId}`,
296
- preflight: async () => {
297
- for (const entry of await readdir(oldCodePath)) {
298
- if (!checkoutAliases.includes(entry))
299
- throw new Error(
300
- `Cannot convert task '${taskId}'; code contains unmanaged entry '${entry}'`,
274
+ if (backend.kind === "jj") {
275
+ const workspaces: {
276
+ alias: string
277
+ name: string
278
+ head: string
279
+ oldPath: string
280
+ newPath: string
281
+ }[] = []
282
+ const runBackend = <A>(
283
+ effect: Effect.Effect<A, unknown, any>,
284
+ ) =>
285
+ Effect.runPromise(
286
+ effect.pipe(
287
+ Effect.provideService(FileSystemService, fs),
288
+ ) as Effect.Effect<A, unknown, never>,
289
+ )
290
+ steps.push({
291
+ label: `move jj workspaces for ${taskId}/${firstPhaseId}`,
292
+ preflight: async () => {
293
+ for (const entry of await readdir(oldCodePath)) {
294
+ if (!checkoutAliases.includes(entry))
295
+ throw new Error(
296
+ `Cannot convert task '${taskId}'; code contains unmanaged entry '${entry}'`,
297
+ )
298
+ }
299
+ for (const alias of checkoutAliases) {
300
+ const oldPath = join(oldCodePath, alias)
301
+ try {
302
+ await lstat(oldPath)
303
+ } catch {
304
+ continue
305
+ }
306
+ const expected = await realpath(oldPath)
307
+ const registered = (
308
+ await runBackend(
309
+ backend.listWorkspaces(join(root, "repos", alias)),
310
+ )
311
+ ).find((workspace) => workspace.path === expected)
312
+ if (!registered?.name)
313
+ throw new Error(
314
+ `Cannot convert task '${taskId}'; checkout '${alias}' is not registered as a jj workspace`,
315
+ )
316
+ if (await runBackend(backend.workspaceDirty(oldPath)))
317
+ throw new Error(
318
+ `Cannot convert task '${taskId}'; jj workspace '${alias}' has uncommitted changes`,
319
+ )
320
+ const head = await runBackend(
321
+ backend.workspaceHead(oldPath),
301
322
  )
302
- }
323
+ if (!head)
324
+ throw new Error(
325
+ `Cannot resolve jj workspace '${alias}' before conversion`,
326
+ )
327
+ workspaces.push({
328
+ alias,
329
+ name: registered.name,
330
+ head,
331
+ oldPath,
332
+ newPath: join(firstCodePath, alias),
333
+ })
334
+ }
335
+ },
336
+ apply: async () => {
337
+ await mkdir(firstCodePath, { recursive: true })
338
+ for (const workspace of workspaces) {
339
+ const repositoryPath = join(
340
+ root,
341
+ "repos",
342
+ workspace.alias,
343
+ )
344
+ await runBackend(
345
+ backend.removeWorkspace({
346
+ repositoryPath,
347
+ workspacePath: workspace.oldPath,
348
+ workspaceName: workspace.name,
349
+ }),
350
+ )
351
+ await runBackend(
352
+ backend.createWorkspace({
353
+ repositoryPath,
354
+ workspacePath: workspace.newPath,
355
+ workspaceName: workspace.name,
356
+ revision: workspace.head,
357
+ }),
358
+ )
359
+ }
360
+ await rm(oldCodePath, { recursive: true, force: true })
361
+ },
362
+ rollback: async () => {
363
+ await mkdir(oldCodePath, { recursive: true })
364
+ for (const workspace of [...workspaces].reverse()) {
365
+ const repositoryPath = join(
366
+ root,
367
+ "repos",
368
+ workspace.alias,
369
+ )
370
+ await runBackend(
371
+ backend.removeWorkspace({
372
+ repositoryPath,
373
+ workspacePath: workspace.newPath,
374
+ workspaceName: workspace.name,
375
+ }),
376
+ )
377
+ await runBackend(
378
+ backend.createWorkspace({
379
+ repositoryPath,
380
+ workspacePath: workspace.oldPath,
381
+ workspaceName: workspace.name,
382
+ revision: workspace.head,
383
+ }),
384
+ )
385
+ }
386
+ await rm(firstDirectory, { recursive: true, force: true })
387
+ },
388
+ manualRecovery: `Restore jj workspaces from ${firstCodePath} to ${oldCodePath}`,
389
+ })
390
+ } else {
391
+ const repair = async (basePath: string) => {
303
392
  for (const alias of checkoutAliases) {
304
- const checkoutPath = join(oldCodePath, alias)
393
+ const checkoutPath = join(basePath, alias)
305
394
  try {
306
395
  await lstat(checkoutPath)
307
396
  } catch {
308
397
  continue
309
398
  }
310
- const listed = Bun.spawnSync([
399
+ const result = Bun.spawnSync([
311
400
  "git",
312
401
  "-C",
313
402
  join(root, "repos", alias),
314
403
  "worktree",
315
- "list",
316
- "--porcelain",
404
+ "repair",
405
+ checkoutPath,
317
406
  ])
318
- if (listed.exitCode !== 0)
407
+ if (result.exitCode !== 0) {
319
408
  throw new Error(
320
- `Failed to inspect worktrees for '${alias}'`,
409
+ `Failed to repair moved worktree for '${alias}': ${new TextDecoder().decode(result.stderr)}`,
321
410
  )
322
- const expected = await realpath(checkoutPath)
323
- let registered = false
324
- for (const line of new TextDecoder()
325
- .decode(listed.stdout)
326
- .split("\n")) {
327
- if (!line.startsWith("worktree ")) continue
328
- try {
329
- if ((await realpath(line.slice(9))) === expected) {
330
- registered = true
331
- break
332
- }
333
- } catch {}
334
411
  }
335
- if (!registered)
336
- throw new Error(
337
- `Cannot convert task '${taskId}'; checkout '${alias}' is not registered as a Git worktree`,
338
- )
339
412
  }
340
- },
341
- apply: async () => {
342
- await mkdir(firstDirectory, { recursive: true })
343
- await rename(oldCodePath, firstCodePath)
344
- try {
345
- await repair(firstCodePath)
346
- } catch (cause) {
413
+ }
414
+ steps.push({
415
+ label: `move and repair code for ${taskId}/${firstPhaseId}`,
416
+ preflight: async () => {
417
+ for (const entry of await readdir(oldCodePath)) {
418
+ if (!checkoutAliases.includes(entry))
419
+ throw new Error(
420
+ `Cannot convert task '${taskId}'; code contains unmanaged entry '${entry}'`,
421
+ )
422
+ }
423
+ for (const alias of checkoutAliases) {
424
+ const checkoutPath = join(oldCodePath, alias)
425
+ try {
426
+ await lstat(checkoutPath)
427
+ } catch {
428
+ continue
429
+ }
430
+ const listed = Bun.spawnSync([
431
+ "git",
432
+ "-C",
433
+ join(root, "repos", alias),
434
+ "worktree",
435
+ "list",
436
+ "--porcelain",
437
+ ])
438
+ if (listed.exitCode !== 0)
439
+ throw new Error(
440
+ `Failed to inspect worktrees for '${alias}'`,
441
+ )
442
+ const expected = await realpath(checkoutPath)
443
+ let registered = false
444
+ for (const line of new TextDecoder()
445
+ .decode(listed.stdout)
446
+ .split("\n")) {
447
+ if (!line.startsWith("worktree ")) continue
448
+ try {
449
+ if ((await realpath(line.slice(9))) === expected) {
450
+ registered = true
451
+ break
452
+ }
453
+ } catch {}
454
+ }
455
+ if (!registered)
456
+ throw new Error(
457
+ `Cannot convert task '${taskId}'; checkout '${alias}' is not registered as a Git worktree`,
458
+ )
459
+ }
460
+ },
461
+ apply: async () => {
462
+ await mkdir(firstDirectory, { recursive: true })
463
+ await rename(oldCodePath, firstCodePath)
464
+ try {
465
+ await repair(firstCodePath)
466
+ } catch (cause) {
467
+ await rename(firstCodePath, oldCodePath)
468
+ await repair(oldCodePath)
469
+ await rm(firstDirectory, { recursive: true, force: true })
470
+ throw cause
471
+ }
472
+ },
473
+ rollback: async () => {
347
474
  await rename(firstCodePath, oldCodePath)
348
475
  await repair(oldCodePath)
349
476
  await rm(firstDirectory, { recursive: true, force: true })
350
- throw cause
351
- }
352
- },
353
- rollback: async () => {
354
- await rename(firstCodePath, oldCodePath)
355
- await repair(oldCodePath)
356
- await rm(firstDirectory, { recursive: true, force: true })
357
- },
358
- manualRecovery: `Move ${firstCodePath} back to ${oldCodePath} and run git worktree repair`,
359
- })
477
+ },
478
+ manualRecovery: `Move ${firstCodePath} back to ${oldCodePath} and run git worktree repair`,
479
+ })
480
+ }
360
481
  }
361
482
  steps.push(
362
483
  documentWriteStep(root, [
@@ -6,6 +6,7 @@ import type { BaseCommandOptions } from "../utils/command"
6
6
  import { TaskService } from "./TaskService"
7
7
  import { PhaseService } from "./PhaseService"
8
8
  import { ReadinessService } from "./ReadinessService"
9
+ import { VersionControlService } from "./VersionControlService"
9
10
  import {
10
11
  formatMarkdownDocument,
11
12
  parseFrontmatter,
@@ -110,6 +111,7 @@ export class PullRequestService extends Effect.Service<PullRequestService>()(
110
111
  const worktrees = yield* WorktreeService
111
112
  const readiness = yield* ReadinessService
112
113
  const workbase = yield* WorkbaseService
114
+ const versionControl = yield* VersionControlService
113
115
  const requestedTask = yield* tasks.show(taskId, startPath)
114
116
  if ("review" in requestedTask.data) {
115
117
  return yield* new PullRequestError({
@@ -154,6 +156,7 @@ export class PullRequestService extends Effect.Service<PullRequestService>()(
154
156
  ? (yield* phases.show(taskId, phaseId!, workspace.root)).data
155
157
  : workspaceTask.data
156
158
  const { config } = yield* workbase.loadConfig(workspace.root)
159
+ const backend = yield* versionControl.forWorkbase(workspace.root)
157
160
  if (!workspace.writablePath) {
158
161
  return yield* new PullRequestError({
159
162
  message: `Task '${taskId}' has no writable checkout`,
@@ -161,49 +164,30 @@ export class PullRequestService extends Effect.Service<PullRequestService>()(
161
164
  }
162
165
  const remote = config.delivery?.remote ?? "origin"
163
166
 
164
- const status = yield* fs.runCommand(
165
- ["git", "-C", workspace.writablePath, "status", "--porcelain"],
166
- { captureOutput: true },
167
- )
168
- if (status.exitCode !== 0) {
167
+ const dirty = yield* backend.workspaceDirty(workspace.writablePath)
168
+ if (dirty === null) {
169
169
  return yield* new PullRequestError({
170
- message: `Failed to inspect worktree status: ${status.stderr}`,
170
+ message: "Failed to inspect worktree status",
171
171
  })
172
172
  }
173
- if (status.stdout) {
173
+ if (dirty) {
174
174
  return yield* new PullRequestError({
175
175
  message: "Cannot create a PR with a dirty worktree",
176
176
  })
177
177
  }
178
178
 
179
- const push = yield* fs.runCommand(
180
- [
181
- "git",
182
- "-C",
183
- workspace.writablePath,
184
- "push",
185
- "--set-upstream",
186
- remote,
187
- execution.branch,
188
- ],
189
- { captureOutput: true },
190
- )
191
- if (push.exitCode !== 0) {
192
- return yield* new PullRequestError({
193
- message: `Failed to push branch: ${push.stderr}`,
194
- })
195
- }
179
+ yield* backend.push(workspace.writablePath, remote, execution.branch)
196
180
 
197
- const remoteResult = yield* fs.runCommand(
198
- ["git", "-C", workspace.writablePath, "remote", "get-url", remote],
199
- { captureOutput: true },
181
+ const remoteUrl = yield* backend.remoteUrl(
182
+ workspace.writablePath,
183
+ remote,
200
184
  )
201
- if (remoteResult.exitCode !== 0) {
185
+ if (!remoteUrl) {
202
186
  return yield* new PullRequestError({
203
- message: `Failed to inspect delivery remote '${remote}': ${remoteResult.stderr}`,
187
+ message: `Failed to inspect delivery remote '${remote}'`,
204
188
  })
205
189
  }
206
- const repository = repositoryFromRemote(remoteResult.stdout.trim())
190
+ const repository = repositoryFromRemote(remoteUrl)
207
191
  const resolved = config.delivery
208
192
  ? resolveDeliveryCommand(config.delivery, "create", {
209
193
  repository,
@@ -1,6 +1,6 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from "bun:test"
2
2
  import { Effect } from "effect"
3
- import { mkdir } from "node:fs/promises"
3
+ import { mkdir, stat } from "node:fs/promises"
4
4
  import { dirname, join } from "node:path"
5
5
  import { cleanupTempDir, createTempDir, runTestEffect } from "../test-utils"
6
6
  import { RepositoryService } from "./RepositoryService"
@@ -98,6 +98,36 @@ describe("RepositoryService", () => {
98
98
  ])
99
99
  })
100
100
 
101
+ test("initializes a managed clone with jj for a jj workbase", async () => {
102
+ if (!Bun.which("jj")) return
103
+ await Bun.write(
104
+ join(root, "agency.json"),
105
+ JSON.stringify({ version: 2, vcs: "jj" }),
106
+ )
107
+ const source = join(root, "source")
108
+ await runGit(["init", "--initial-branch=main", source])
109
+ await runGit(["-C", source, "config", "user.email", "test@example.com"])
110
+ await runGit(["-C", source, "config", "user.name", "Test"])
111
+ await Bun.write(join(source, "README.md"), "example\n")
112
+ await runGit(["-C", source, "add", "README.md"])
113
+ await runGit(["-C", source, "commit", "-m", "initial"])
114
+ await setPortableOrigin(source, "jj-agency")
115
+
116
+ const destination = await runTestEffect(
117
+ RepositoryService.pipe(
118
+ Effect.flatMap((service) => service.add("agency", source, root)),
119
+ ),
120
+ )
121
+ expect((await stat(join(destination, ".jj"))).isDirectory()).toBe(true)
122
+ expect(
123
+ await runTestEffect(
124
+ RepositoryService.pipe(
125
+ Effect.flatMap((service) => service.show("agency", root)),
126
+ ),
127
+ ),
128
+ ).toMatchObject({ kind: "repository" })
129
+ })
130
+
101
131
  test("links an existing repository", async () => {
102
132
  const target = join(root, "linked-repository")
103
133
  await mkdir(target, { recursive: true })