@markjaquith/agency 2.74.0 → 3.0.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 (57) hide show
  1. package/README.md +23 -121
  2. package/cli-main.ts +0 -26
  3. package/cli.ts +1 -17
  4. package/package.json +1 -2
  5. package/schemas/agency-graph-v1.schema.json +1 -1
  6. package/src/cli-parser.test.ts +0 -19
  7. package/src/cli-parser.ts +0 -23
  8. package/src/cli.test.ts +3 -3
  9. package/src/commands/context.test.ts +0 -67
  10. package/src/commands/init.test.ts +1 -2
  11. package/src/commands/pr.test.ts +2 -87
  12. package/src/commands/pr.ts +2 -79
  13. package/src/commands/push.test.ts +1 -2
  14. package/src/graph-schema.ts +1 -1
  15. package/src/services/ArchiveBulkService.test.ts +1 -142
  16. package/src/services/ArchiveService.test.ts +1 -172
  17. package/src/services/ArchiveService.ts +0 -26
  18. package/src/services/ContextService.ts +3 -47
  19. package/src/services/DoctorService.ts +8 -24
  20. package/src/services/GraphService.test.ts +1 -0
  21. package/src/services/GraphService.ts +2 -5
  22. package/src/services/PhaseService.ts +70 -191
  23. package/src/services/PullRequestService.test.ts +1 -45
  24. package/src/services/PullRequestService.ts +1 -22
  25. package/src/services/PushService.test.ts +29 -295
  26. package/src/services/PushService.ts +10 -239
  27. package/src/services/RepositoryService.test.ts +1 -61
  28. package/src/services/RepositoryService.ts +2 -13
  29. package/src/services/ReviewService.test.ts +1 -84
  30. package/src/services/ReviewService.ts +9 -48
  31. package/src/services/SyncService.test.ts +0 -81
  32. package/src/services/SyncService.ts +12 -39
  33. package/src/services/TaskPhaseService.test.ts +0 -80
  34. package/src/services/TaskService.ts +1 -9
  35. package/src/services/VersionControlService.test.ts +4 -117
  36. package/src/services/VersionControlService.ts +3 -426
  37. package/src/services/WorkbaseService.test.ts +0 -20
  38. package/src/services/WorkbaseService.ts +1 -18
  39. package/src/services/WorktreeService.test.ts +1 -705
  40. package/src/services/WorktreeService.ts +395 -1604
  41. package/src/services/push-validation.ts +0 -7
  42. package/src/test-utils.ts +0 -4
  43. package/src/workbase/AGENTS.md +4 -5
  44. package/src/workbase/checkout-command.test.ts +1 -1
  45. package/src/workbase/checkout-command.ts +1 -1
  46. package/src/workbase/delivery-command.test.ts +4 -35
  47. package/src/workbase/delivery-command.ts +2 -15
  48. package/src/workbase/schemas.test.ts +0 -19
  49. package/src/workbase/schemas.ts +1 -2
  50. package/src/commands/vcs.test.ts +0 -75
  51. package/src/commands/vcs.ts +0 -72
  52. package/src/services/VcsMigrationService.test.ts +0 -348
  53. package/src/services/VcsMigrationService.ts +0 -857
  54. package/src/vcs-status-fast.ts +0 -310
  55. package/src/workbase/version-control.ts +0 -5
  56. package/src/workbase/workspace-command.test.ts +0 -70
  57. package/src/workbase/workspace-command.ts +0 -63
@@ -125,20 +125,12 @@ export class DoctorService extends Effect.Service<DoctorService>()(
125
125
  return available
126
126
  })
127
127
 
128
- const [gitAvailable, jjAvailable] = yield* Effect.all(
129
- [
130
- tool("tool.git", "git", "error", "Git"),
131
- tool(
132
- "tool.jj",
133
- "jj",
134
- config.vcs === "jj" ? "error" : "optional",
135
- "Jujutsu",
136
- ),
137
- ],
138
- { concurrency: "unbounded", batching: true },
128
+ const versionControlAvailable = yield* tool(
129
+ "tool.git",
130
+ "git",
131
+ "error",
132
+ "Git",
139
133
  )
140
- const versionControlAvailable =
141
- gitAvailable && (config.vcs !== "jj" || jjAvailable)
142
134
  yield* Effect.all(
143
135
  [
144
136
  tool(
@@ -180,15 +172,6 @@ export class DoctorService extends Effect.Service<DoctorService>()(
180
172
  ] as const,
181
173
  ]
182
174
  : []),
183
- ...(config.workspaceCreateCommand
184
- ? [
185
- [
186
- "integration.workspace-create",
187
- config.workspaceCreateCommand,
188
- "Workspace creator",
189
- ] as const,
190
- ]
191
- : []),
192
175
  ...Object.entries(config.repositories ?? {}).flatMap(
193
176
  ([alias, repository]) =>
194
177
  repository.postCheckoutCommand
@@ -384,8 +367,9 @@ export class DoctorService extends Effect.Service<DoctorService>()(
384
367
  category: "repository",
385
368
  level: "warning",
386
369
  status: "fail",
387
- message: `Repository, ref, remote, and workspace checks were skipped because the ${config.vcs ?? "git"} backend is unavailable`,
388
- remediation: `Install '${config.vcs === "jj" ? "jj" : "git"}' and rerun 'agency doctor'.`,
370
+ message:
371
+ "Repository, ref, remote, and workspace checks were skipped because Git is unavailable",
372
+ remediation: "Install 'git' and rerun 'agency doctor'.",
389
373
  })
390
374
  }
391
375
  for (const repository of repositoryList) {
@@ -121,6 +121,7 @@ describe("GraphService", () => {
121
121
  const graph = await getGraph(root)
122
122
 
123
123
  expect(Schema.decodeUnknownSync(AgencyGraph)(graph)).toEqual(graph)
124
+ expect(graph.workbase.vcs).toBe("git")
124
125
  expect(graph.nodes.map((node) => node.id)).toEqual([
125
126
  "epic:delivery",
126
127
  "execution-unit:phase/ship/implement",
@@ -821,10 +821,7 @@ export class GraphService extends Effect.Service<GraphService>()(
821
821
  checkoutCommit: materialized
822
822
  ? yield* backend.workspaceHead(checkoutPath)
823
823
  : null,
824
- checkoutBranch:
825
- backend.kind === "jj" && materialized
826
- ? data.branch
827
- : actualBranch,
824
+ checkoutBranch: actualBranch,
828
825
  dirty: materialized
829
826
  ? yield* backend.workspaceDirty(checkoutPath)
830
827
  : null,
@@ -1022,7 +1019,7 @@ export class GraphService extends Effect.Service<GraphService>()(
1022
1019
  version: GRAPH_VERSION,
1023
1020
  workbase: {
1024
1021
  version: config.version,
1025
- vcs: config.vcs ?? "git",
1022
+ vcs: "git",
1026
1023
  ...(include.has("workspace") ? { root } : {}),
1027
1024
  },
1028
1025
  filters,
@@ -30,7 +30,6 @@ import {
30
30
  buildNonPrCompletion,
31
31
  type NonPrCompletionInput,
32
32
  } from "../workbase/completion"
33
- import { VersionControlService } from "./VersionControlService"
34
33
 
35
34
  class PhaseError extends Data.TaggedError("PhaseError")<{
36
35
  readonly message: string
@@ -93,10 +92,8 @@ export class PhaseService extends Effect.Service<PhaseService>()(
93
92
  Effect.gen(function* () {
94
93
  const fs = yield* FileSystemService
95
94
  const workbase = yield* WorkbaseService
96
- const versionControl = yield* VersionControlService
97
95
  const tasks = yield* TaskService
98
96
  const root = yield* workbase.discover(startPath)
99
- const backend = yield* versionControl.forWorkbase(root)
100
97
  const taskId = yield* decodeId(input.taskId, "task")
101
98
  const id = yield* decodeId(input.id, "phase")
102
99
  const task = yield* tasks.show(taskId, root)
@@ -274,213 +271,95 @@ export class PhaseService extends Effect.Service<PhaseService>()(
274
271
  firstData.repo,
275
272
  ...(firstData.repos ?? []).map((reference) => reference.repo),
276
273
  ]
277
- if (backend.kind === "jj") {
278
- const workspaces: {
279
- alias: string
280
- name: string
281
- head: string
282
- oldPath: string
283
- newPath: string
284
- }[] = []
285
- const runBackend = <A>(
286
- effect: Effect.Effect<A, unknown, any>,
287
- ) =>
288
- Effect.runPromise(
289
- effect.pipe(
290
- Effect.provideService(FileSystemService, fs),
291
- ) as Effect.Effect<A, unknown, never>,
292
- )
293
- steps.push({
294
- label: `move jj workspaces for ${taskId}/${firstPhaseId}`,
295
- preflight: async () => {
296
- for (const entry of await readdir(oldCodePath)) {
297
- if (!checkoutAliases.includes(entry))
298
- throw new Error(
299
- `Cannot convert task '${taskId}'; code contains unmanaged entry '${entry}'`,
300
- )
301
- }
302
- for (const alias of checkoutAliases) {
303
- const oldPath = join(oldCodePath, alias)
304
- try {
305
- await lstat(oldPath)
306
- } catch {
307
- continue
308
- }
309
- const expected = await realpath(oldPath)
310
- const registered = (
311
- await runBackend(
312
- backend.listWorkspaces(join(root, "repos", alias)),
313
- )
314
- ).find((workspace) => workspace.path === expected)
315
- if (!registered?.name)
316
- throw new Error(
317
- `Cannot convert task '${taskId}'; checkout '${alias}' is not registered as a jj workspace`,
318
- )
319
- if (await runBackend(backend.workspaceDirty(oldPath)))
320
- throw new Error(
321
- `Cannot convert task '${taskId}'; jj workspace '${alias}' has uncommitted changes`,
322
- )
323
- const head = await runBackend(
324
- backend.workspaceHead(oldPath),
325
- )
326
- if (!head)
327
- throw new Error(
328
- `Cannot resolve jj workspace '${alias}' before conversion`,
329
- )
330
- workspaces.push({
331
- alias,
332
- name: registered.name,
333
- head,
334
- oldPath,
335
- newPath: join(firstCodePath, alias),
336
- })
337
- }
338
- },
339
- apply: async () => {
340
- await mkdir(firstCodePath, { recursive: true })
341
- for (const workspace of workspaces) {
342
- const repositoryPath = join(
343
- root,
344
- "repos",
345
- workspace.alias,
346
- )
347
- await runBackend(
348
- backend.removeWorkspace({
349
- repositoryPath,
350
- workspacePath: workspace.oldPath,
351
- workspaceName: workspace.name,
352
- }),
353
- )
354
- await runBackend(
355
- backend.createWorkspace({
356
- repositoryPath,
357
- workspacePath: workspace.newPath,
358
- workspaceName: workspace.name,
359
- revision: workspace.head,
360
- }),
361
- )
362
- }
363
- await rm(oldCodePath, { recursive: true, force: true })
364
- },
365
- rollback: async () => {
366
- await mkdir(oldCodePath, { recursive: true })
367
- for (const workspace of [...workspaces].reverse()) {
368
- const repositoryPath = join(
369
- root,
370
- "repos",
371
- workspace.alias,
372
- )
373
- await runBackend(
374
- backend.removeWorkspace({
375
- repositoryPath,
376
- workspacePath: workspace.newPath,
377
- workspaceName: workspace.name,
378
- }),
379
- )
380
- await runBackend(
381
- backend.createWorkspace({
382
- repositoryPath,
383
- workspacePath: workspace.oldPath,
384
- workspaceName: workspace.name,
385
- revision: workspace.head,
386
- }),
274
+ const repair = async (basePath: string) => {
275
+ for (const alias of checkoutAliases) {
276
+ const checkoutPath = join(basePath, alias)
277
+ try {
278
+ await lstat(checkoutPath)
279
+ } catch {
280
+ continue
281
+ }
282
+ const result = Bun.spawnSync([
283
+ "git",
284
+ "-C",
285
+ join(root, "repos", alias),
286
+ "worktree",
287
+ "repair",
288
+ checkoutPath,
289
+ ])
290
+ if (result.exitCode !== 0) {
291
+ throw new Error(
292
+ `Failed to repair moved worktree for '${alias}': ${new TextDecoder().decode(result.stderr)}`,
293
+ )
294
+ }
295
+ }
296
+ }
297
+ steps.push({
298
+ label: `move and repair code for ${taskId}/${firstPhaseId}`,
299
+ preflight: async () => {
300
+ for (const entry of await readdir(oldCodePath)) {
301
+ if (!checkoutAliases.includes(entry))
302
+ throw new Error(
303
+ `Cannot convert task '${taskId}'; code contains unmanaged entry '${entry}'`,
387
304
  )
388
- }
389
- await rm(firstDirectory, { recursive: true, force: true })
390
- },
391
- manualRecovery: `Restore jj workspaces from ${firstCodePath} to ${oldCodePath}`,
392
- })
393
- } else {
394
- const repair = async (basePath: string) => {
305
+ }
395
306
  for (const alias of checkoutAliases) {
396
- const checkoutPath = join(basePath, alias)
307
+ const checkoutPath = join(oldCodePath, alias)
397
308
  try {
398
309
  await lstat(checkoutPath)
399
310
  } catch {
400
311
  continue
401
312
  }
402
- const result = Bun.spawnSync([
313
+ const listed = Bun.spawnSync([
403
314
  "git",
404
315
  "-C",
405
316
  join(root, "repos", alias),
406
317
  "worktree",
407
- "repair",
408
- checkoutPath,
318
+ "list",
319
+ "--porcelain",
409
320
  ])
410
- if (result.exitCode !== 0) {
321
+ if (listed.exitCode !== 0)
411
322
  throw new Error(
412
- `Failed to repair moved worktree for '${alias}': ${new TextDecoder().decode(result.stderr)}`,
323
+ `Failed to inspect worktrees for '${alias}'`,
413
324
  )
414
- }
415
- }
416
- }
417
- steps.push({
418
- label: `move and repair code for ${taskId}/${firstPhaseId}`,
419
- preflight: async () => {
420
- for (const entry of await readdir(oldCodePath)) {
421
- if (!checkoutAliases.includes(entry))
422
- throw new Error(
423
- `Cannot convert task '${taskId}'; code contains unmanaged entry '${entry}'`,
424
- )
425
- }
426
- for (const alias of checkoutAliases) {
427
- const checkoutPath = join(oldCodePath, alias)
325
+ const expected = await realpath(checkoutPath)
326
+ let registered = false
327
+ for (const line of new TextDecoder()
328
+ .decode(listed.stdout)
329
+ .split("\n")) {
330
+ if (!line.startsWith("worktree ")) continue
428
331
  try {
429
- await lstat(checkoutPath)
430
- } catch {
431
- continue
432
- }
433
- const listed = Bun.spawnSync([
434
- "git",
435
- "-C",
436
- join(root, "repos", alias),
437
- "worktree",
438
- "list",
439
- "--porcelain",
440
- ])
441
- if (listed.exitCode !== 0)
442
- throw new Error(
443
- `Failed to inspect worktrees for '${alias}'`,
444
- )
445
- const expected = await realpath(checkoutPath)
446
- let registered = false
447
- for (const line of new TextDecoder()
448
- .decode(listed.stdout)
449
- .split("\n")) {
450
- if (!line.startsWith("worktree ")) continue
451
- try {
452
- if ((await realpath(line.slice(9))) === expected) {
453
- registered = true
454
- break
455
- }
456
- } catch {}
457
- }
458
- if (!registered)
459
- throw new Error(
460
- `Cannot convert task '${taskId}'; checkout '${alias}' is not registered as a Git worktree`,
461
- )
462
- }
463
- },
464
- apply: async () => {
465
- await mkdir(firstDirectory, { recursive: true })
466
- await rename(oldCodePath, firstCodePath)
467
- try {
468
- await repair(firstCodePath)
469
- } catch (cause) {
470
- await rename(firstCodePath, oldCodePath)
471
- await repair(oldCodePath)
472
- await rm(firstDirectory, { recursive: true, force: true })
473
- throw cause
332
+ if ((await realpath(line.slice(9))) === expected) {
333
+ registered = true
334
+ break
335
+ }
336
+ } catch {}
474
337
  }
475
- },
476
- rollback: async () => {
338
+ if (!registered)
339
+ throw new Error(
340
+ `Cannot convert task '${taskId}'; checkout '${alias}' is not registered as a Git worktree`,
341
+ )
342
+ }
343
+ },
344
+ apply: async () => {
345
+ await mkdir(firstDirectory, { recursive: true })
346
+ await rename(oldCodePath, firstCodePath)
347
+ try {
348
+ await repair(firstCodePath)
349
+ } catch (cause) {
477
350
  await rename(firstCodePath, oldCodePath)
478
351
  await repair(oldCodePath)
479
352
  await rm(firstDirectory, { recursive: true, force: true })
480
- },
481
- manualRecovery: `Move ${firstCodePath} back to ${oldCodePath} and run git worktree repair`,
482
- })
483
- }
353
+ throw cause
354
+ }
355
+ },
356
+ rollback: async () => {
357
+ await rename(firstCodePath, oldCodePath)
358
+ await repair(oldCodePath)
359
+ await rm(firstDirectory, { recursive: true, force: true })
360
+ },
361
+ manualRecovery: `Move ${firstCodePath} back to ${oldCodePath} and run git worktree repair`,
362
+ })
484
363
  }
485
364
  steps.push(
486
365
  documentWriteStep(root, [
@@ -98,7 +98,7 @@ describe("PullRequestService", () => {
98
98
  await Bun.write(
99
99
  path,
100
100
  `#!/usr/bin/env bun
101
- 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
+ await Bun.write(${JSON.stringify(ghCallPath)}, JSON.stringify({ args: Bun.argv.slice(2), cwd: process.cwd() }))
102
102
  process.stdout.write(${JSON.stringify(stdout)})
103
103
  process.stderr.write(${JSON.stringify(stderr)})
104
104
  process.exit(${exitCode})
@@ -111,7 +111,6 @@ process.exit(${exitCode})
111
111
  (await Bun.file(ghCallPath).json()) as {
112
112
  args: string[]
113
113
  cwd: string
114
- gitDir?: string
115
114
  }
116
115
 
117
116
  const expectRemoteBranch = async (branch: string, exists = true) => {
@@ -243,49 +242,6 @@ process.exit(${exitCode})
243
242
  expect(updated.endsWith(`${body}\n`)).toBe(true)
244
243
  })
245
244
 
246
- test("creates a PR from a non-colocated jj workspace with backing Git context", async () => {
247
- if (!Bun.which("jj")) return
248
- await rm(join(root, "repos/agency"), { recursive: true, force: true })
249
- await requireCommand([
250
- "jj",
251
- "git",
252
- "clone",
253
- "--no-colocate",
254
- remotePath,
255
- join(root, "repos/agency"),
256
- ])
257
- await requireCommand(
258
- ["jj", "config", "set", "--repo", "user.name", "Agency Test"],
259
- join(root, "repos/agency"),
260
- )
261
- await requireCommand(
262
- ["jj", "config", "set", "--repo", "user.email", "agency@example.com"],
263
- join(root, "repos/agency"),
264
- )
265
- await Bun.write(
266
- join(root, "agency.json"),
267
- JSON.stringify({ version: 2, vcs: "jj" }),
268
- )
269
- await createTask("jj-example", "task/jj-example")
270
- const workspace = await materialize("jj-example")
271
- await Bun.write(join(workspace.writablePath!, "CHANGE.md"), "jj change\n")
272
- await requireCommand(
273
- ["jj", "describe", "-m", "feat: add jj example"],
274
- workspace.writablePath!,
275
- )
276
- await requireCommand(["jj", "new"], workspace.writablePath!)
277
- const url = "https://github.com/example/agency/pull/44"
278
- await writeFakeGh({ stdout: `${url}\n` })
279
-
280
- expect(await createPullRequest("jj-example")).toBe(url)
281
- await expectRemoteBranch("task/jj-example")
282
- const ghCall = await readGhCall()
283
- expect(ghCall.args).toContain("--head")
284
- expect(ghCall.args).toContain("--repo")
285
- expect(ghCall.gitDir).toContain(".jj")
286
- expect(await Bun.file(join(root, "repos/agency/.git")).exists()).toBe(false)
287
- })
288
-
289
245
  test("passes --draft to gh", async () => {
290
246
  await createTask()
291
247
  await writeFakeGh({
@@ -195,20 +195,6 @@ export class PullRequestService extends Effect.Service<PullRequestService>()(
195
195
  }
196
196
 
197
197
  yield* backend.push(workspace.writablePath, remote, execution.branch)
198
- const defaults =
199
- backend.kind === "jj"
200
- ? yield* backend.pullRequestDefaults(
201
- workspace.writablePath,
202
- execution.base,
203
- )
204
- : null
205
- if (backend.kind === "jj" && !defaults) {
206
- return yield* new PullRequestError({
207
- message:
208
- "Failed to derive pull request title and body from jj commits",
209
- })
210
- }
211
-
212
198
  const remoteUrl = yield* backend.remoteUrl(
213
199
  workspace.writablePath,
214
200
  remote,
@@ -230,22 +216,15 @@ export class PullRequestService extends Effect.Service<PullRequestService>()(
230
216
  })
231
217
  : resolveGitHubCreateCommand({
232
218
  base: execution.base,
233
- branch: execution.branch,
234
- repository,
235
219
  draft,
236
- vcs: config.vcs ?? "git",
237
220
  title: options.title,
238
221
  head: options.head,
239
222
  labels: options.labels,
240
- ...(defaults ? { defaults } : {}),
241
223
  })
242
- const gitEnvironment = config.delivery
243
- ? {}
244
- : yield* backend.gitEnvironment(workspace.writablePath)
245
224
  const created = yield* fs.runCommand(resolved.argv, {
246
225
  cwd: workspace.writablePath,
247
226
  captureOutput: true,
248
- env: { ...gitEnvironment, ...resolved.environment },
227
+ env: resolved.environment,
249
228
  })
250
229
  if (created.exitCode !== 0) {
251
230
  return yield* new PullRequestError({