@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
@@ -166,11 +166,11 @@ describe("ReviewService", () => {
166
166
  if (!Bun.which("jj")) return
167
167
  const repository = join(root, "repos/agency")
168
168
  await rm(repository, { recursive: true, force: true })
169
- await git(["clone", source, repository])
170
169
  await git(["switch", "-c", "jj-review"], source)
171
170
  await Bun.write(join(source, "README.md"), "jj review one\n")
172
171
  await git(["commit", "-am", "jj review one"], source)
173
- await jj(["git", "init", "--colocate", repository])
172
+ await jj(["git", "clone", "--no-colocate", source, repository])
173
+ expect(await Bun.file(join(repository, ".git")).exists()).toBe(false)
174
174
  await Bun.write(
175
175
  join(root, "agency.json"),
176
176
  JSON.stringify({ version: 2, vcs: "jj" }),
@@ -45,8 +45,15 @@ const githubRepository = (remote: string) => {
45
45
  const pinRef = (taskId: string) =>
46
46
  `refs/agency/reviews/${Buffer.from(taskId).toString("hex")}`
47
47
 
48
- const runGit = async (args: readonly string[]) => {
49
- const child = Bun.spawn([...args], { stdout: "pipe", stderr: "pipe" })
48
+ const runGit = async (
49
+ args: readonly string[],
50
+ environment: Record<string, string> = {},
51
+ ) => {
52
+ const child = Bun.spawn([...args], {
53
+ stdout: "pipe",
54
+ stderr: "pipe",
55
+ env: { ...process.env, ...environment },
56
+ })
50
57
  const [exitCode, stdout, stderr] = await Promise.all([
51
58
  child.exited,
52
59
  new Response(child.stdout).text(),
@@ -121,7 +128,7 @@ const restoreSnapshots = async (
121
128
  }
122
129
  }
123
130
 
124
- const normalizeBranch = (input: string, repositoryPath: string) =>
131
+ const normalizeBranch = (input: string) =>
125
132
  Effect.gen(function* () {
126
133
  const fs = yield* FileSystemService
127
134
  if (
@@ -150,7 +157,7 @@ const normalizeBranch = (input: string, repositoryPath: string) =>
150
157
  })
151
158
  }
152
159
  const checked = yield* fs.runCommand(
153
- ["git", "-C", repositoryPath, "check-ref-format", "--branch", name],
160
+ ["git", "check-ref-format", "--branch", name],
154
161
  { captureOutput: true },
155
162
  )
156
163
  if (checked.exitCode !== 0) {
@@ -168,6 +175,7 @@ const fetchCommit = (
168
175
  ) =>
169
176
  Effect.gen(function* () {
170
177
  const fs = yield* FileSystemService
178
+ const environment = yield* backend.gitEnvironment(repoPath)
171
179
  const temporaryRef = `refs/agency/review-fetch/${process.pid}-${randomUUID()}`
172
180
  const fetched = yield* fs.runCommand(
173
181
  [
@@ -179,12 +187,12 @@ const fetchCommit = (
179
187
  "origin",
180
188
  `+${sourceRef}:${temporaryRef}`,
181
189
  ],
182
- { captureOutput: true },
190
+ { captureOutput: true, env: environment },
183
191
  )
184
192
  if (fetched.exitCode !== 0) {
185
193
  const cleanup = yield* fs.runCommand(
186
194
  ["git", "-C", repoPath, "update-ref", "-d", temporaryRef],
187
- { captureOutput: true },
195
+ { captureOutput: true, env: environment },
188
196
  )
189
197
  return yield* new ReviewError({
190
198
  message: `Review source '${sourceRef}' could not be fetched: ${fetched.stderr.trim()}${cleanup.exitCode === 0 ? "" : `; temporary ref cleanup failed: ${cleanup.stderr.trim()}`}`,
@@ -199,13 +207,13 @@ const fetchCommit = (
199
207
  "--verify",
200
208
  `${temporaryRef}^{commit}`,
201
209
  ],
202
- { captureOutput: true },
210
+ { captureOutput: true, env: environment },
203
211
  )
204
212
  const commit = resolved.stdout.trim()
205
213
  if (resolved.exitCode !== 0 || !/^[a-f0-9]{40}$/.test(commit)) {
206
214
  yield* fs.runCommand(
207
215
  ["git", "-C", repoPath, "update-ref", "-d", temporaryRef],
208
- { captureOutput: true },
216
+ { captureOutput: true, env: environment },
209
217
  )
210
218
  return yield* new ReviewError({
211
219
  message: `Review source '${sourceRef}' did not resolve to a commit`,
@@ -214,7 +222,7 @@ const fetchCommit = (
214
222
  const imported = yield* Effect.either(backend.importGitRefs(repoPath))
215
223
  const cleanup = yield* fs.runCommand(
216
224
  ["git", "-C", repoPath, "update-ref", "-d", temporaryRef],
217
- { captureOutput: true },
225
+ { captureOutput: true, env: environment },
218
226
  )
219
227
  if (cleanup.exitCode !== 0) {
220
228
  return yield* new ReviewError({
@@ -284,7 +292,7 @@ export class ReviewService extends Effect.Service<ReviewService>()(
284
292
  fetchRef: sourceRef,
285
293
  }
286
294
  } else if (input.ref) {
287
- sourceRef = yield* normalizeBranch(input.ref, repository.path)
295
+ sourceRef = yield* normalizeBranch(input.ref)
288
296
  source = { kind: "branch", ref: sourceRef }
289
297
  } else {
290
298
  return yield* new ReviewError({
@@ -366,6 +374,11 @@ export class ReviewService extends Effect.Service<ReviewService>()(
366
374
  task.data.review.repo,
367
375
  root,
368
376
  )
377
+ const versionControl = yield* VersionControlService
378
+ const backend = yield* versionControl.forWorkbase(root)
379
+ const gitEnvironment = yield* backend.gitEnvironment(
380
+ repository.path,
381
+ )
369
382
  const hadCheckout = inspection.checkouts.some(
370
383
  (checkout) => checkout.exists || checkout.registered,
371
384
  )
@@ -391,25 +404,31 @@ export class ReviewService extends Effect.Service<ReviewService>()(
391
404
  steps.push({
392
405
  label: `advance review pin for ${taskId}`,
393
406
  apply: () =>
394
- runGit([
395
- "git",
396
- "-C",
397
- repository.path,
398
- "update-ref",
399
- pinRef(taskId),
400
- latest.commit,
401
- previousReview.commit,
402
- ]).then(() => undefined),
407
+ runGit(
408
+ [
409
+ "git",
410
+ "-C",
411
+ repository.path,
412
+ "update-ref",
413
+ pinRef(taskId),
414
+ latest.commit,
415
+ previousReview.commit,
416
+ ],
417
+ gitEnvironment,
418
+ ).then(() => undefined),
403
419
  rollback: () =>
404
- runGit([
405
- "git",
406
- "-C",
407
- repository.path,
408
- "update-ref",
409
- pinRef(taskId),
410
- previousReview.commit,
411
- latest.commit,
412
- ]).then(() => undefined),
420
+ runGit(
421
+ [
422
+ "git",
423
+ "-C",
424
+ repository.path,
425
+ "update-ref",
426
+ pinRef(taskId),
427
+ previousReview.commit,
428
+ latest.commit,
429
+ ],
430
+ gitEnvironment,
431
+ ).then(() => undefined),
413
432
  manualRecovery: `Reset ${pinRef(taskId)} to ${previousReview.commit}`,
414
433
  })
415
434
  if (hadCheckout) {
@@ -59,6 +59,7 @@ describe("SyncService", () => {
59
59
  await Bun.write(
60
60
  gh,
61
61
  `#!/bin/sh
62
+ if [ -n "$GH_CAPTURE" ]; then printf '%s\n' "$@" "GIT_DIR=$GIT_DIR" > "$GH_CAPTURE"; fi
62
63
  case "$*" in
63
64
  *mergeable*) ;;
64
65
  *) echo "mergeable field was not requested" >&2; exit 2 ;;
@@ -82,6 +83,7 @@ JSON
82
83
  afterEach(async () => {
83
84
  if (originalPath === undefined) delete process.env.PATH
84
85
  else process.env.PATH = originalPath
86
+ delete process.env.GH_CAPTURE
85
87
  await cleanupTempDir(root)
86
88
  })
87
89
 
@@ -125,8 +127,14 @@ pr: null
125
127
  if (!Bun.which("jj")) return
126
128
  const repository = join(root, "repos/agency")
127
129
  await rm(repository, { recursive: true, force: true })
128
- await git(["clone", join(root, "source"), repository])
129
- await jj(["git", "init", "--colocate", repository])
130
+ await jj([
131
+ "git",
132
+ "clone",
133
+ "--no-colocate",
134
+ join(root, "source"),
135
+ repository,
136
+ ])
137
+ expect(await Bun.file(join(repository, ".git")).exists()).toBe(false)
130
138
  await Bun.write(
131
139
  join(root, "agency.json"),
132
140
  JSON.stringify({ version: 2, vcs: "jj" }),
@@ -147,6 +155,8 @@ pr: null
147
155
  ),
148
156
  ),
149
157
  )
158
+ const capture = join(root, "gh-capture")
159
+ process.env.GH_CAPTURE = capture
150
160
 
151
161
  const planned = await runTestEffect(
152
162
  SyncService.pipe(
@@ -160,6 +170,10 @@ pr: null
160
170
  status: "planned",
161
171
  }),
162
172
  )
173
+ const invocation = await Bun.file(capture).text()
174
+ expect(invocation).toContain("--repo\n")
175
+ expect(invocation).toContain("GIT_DIR=")
176
+ expect(invocation).toContain(".jj")
163
177
 
164
178
  const applied = await runTestEffect(
165
179
  SyncService.pipe(
@@ -174,6 +188,7 @@ pr: null
174
188
  branch: "task/jj-sync",
175
189
  dirty: false,
176
190
  })
191
+ delete process.env.GH_CAPTURE
177
192
  })
178
193
 
179
194
  test("observes drift without mutation and applies only safe transitions", async () => {
@@ -339,20 +339,34 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
339
339
  env: resolved.environment,
340
340
  })
341
341
  } else if (!config.delivery && existing) {
342
- result = yield* runExternal([
343
- "gh",
344
- "pr",
345
- "view",
346
- existing.url,
347
- "--json",
348
- "number,state,title,isDraft,headRefName,baseRefName,url,mergedAt,mergeCommit,mergeable",
349
- ])
342
+ const environment = yield* Effect.either(
343
+ backend.gitEnvironment(repositoryPath),
344
+ )
345
+ result = yield* runExternal(
346
+ [
347
+ "gh",
348
+ "pr",
349
+ "view",
350
+ existing.url,
351
+ "--json",
352
+ "number,state,title,isDraft,headRefName,baseRefName,url,mergedAt,mergeCommit,mergeable",
353
+ ],
354
+ {
355
+ cwd: repositoryPath,
356
+ env: Either.isRight(environment) ? environment.right : {},
357
+ },
358
+ )
350
359
  } else if (!config.delivery) {
360
+ const environment = yield* Effect.either(
361
+ backend.gitEnvironment(repositoryPath),
362
+ )
351
363
  result = yield* runExternal(
352
364
  [
353
365
  "gh",
354
366
  "pr",
355
367
  "list",
368
+ "--repo",
369
+ remoteRepository,
356
370
  "--head",
357
371
  data.branch,
358
372
  "--state",
@@ -360,7 +374,10 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
360
374
  "--json",
361
375
  "number,state,title,isDraft,headRefName,baseRefName,url,mergedAt,mergeCommit,mergeable",
362
376
  ],
363
- { cwd: repositoryPath },
377
+ {
378
+ cwd: repositoryPath,
379
+ env: Either.isRight(environment) ? environment.right : {},
380
+ },
364
381
  )
365
382
  }
366
383
  return [
@@ -531,14 +548,18 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
531
548
  let resolvedCommit: string | null = null
532
549
  if ("ref" in checkout) {
533
550
  if (!isCommitId(checkout.ref)) {
534
- const remoteRef = yield* runExternal([
535
- "git",
536
- "-C",
551
+ const remote = yield* backend.remoteUrl(
537
552
  repositoryPath,
538
- "ls-remote",
539
553
  "origin",
540
- originRef(checkout.ref),
541
- ])
554
+ )
555
+ const remoteRef = remote
556
+ ? yield* runExternal([
557
+ "git",
558
+ "ls-remote",
559
+ remote,
560
+ originRef(checkout.ref),
561
+ ])
562
+ : { exitCode: -1, stdout: "", stderr: "origin unavailable" }
542
563
  resolvedCommit =
543
564
  remoteRef.stdout.match(/^([0-9a-f]{40,64})\s/m)?.[1] ?? null
544
565
  if (!resolvedCommit) {
@@ -998,12 +1019,14 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
998
1019
  })
999
1020
  }
1000
1021
  const repositoryPath = join(root, "repos", data.review.repo)
1022
+ const reviewRemote = yield* backend.remoteUrl(
1023
+ repositoryPath,
1024
+ "origin",
1025
+ )
1001
1026
  const source = yield* runExternal([
1002
1027
  "git",
1003
- "-C",
1004
- repositoryPath,
1005
1028
  "ls-remote",
1006
- "origin",
1029
+ reviewRemote ?? "origin",
1007
1030
  data.review.source.kind === "pull-request"
1008
1031
  ? data.review.source.fetchRef
1009
1032
  : originRef(data.review.source.ref),
@@ -3,6 +3,7 @@ import { Data, Effect, Either } from "effect"
3
3
  import { join } from "node:path"
4
4
  import { FileSystemService } from "./FileSystemService"
5
5
  import { WorkbaseService } from "./WorkbaseService"
6
+ import { VersionControlService } from "./VersionControlService"
6
7
  import { EpicService, type EpicRecord } from "./EpicService"
7
8
  import {
8
9
  EntityId,
@@ -88,11 +89,16 @@ const reviewPinStep = (
88
89
  root: string,
89
90
  taskId: string,
90
91
  review: ReviewRecord,
92
+ environment: Record<string, string>,
91
93
  ): TransactionStep => {
92
94
  const repositoryPath = join(root, "repos", review.repo)
93
95
  const ref = reviewPinRef(taskId)
94
96
  const run = async (args: readonly string[]) => {
95
- const child = Bun.spawn([...args], { stdout: "pipe", stderr: "pipe" })
97
+ const child = Bun.spawn([...args], {
98
+ stdout: "pipe",
99
+ stderr: "pipe",
100
+ env: { ...process.env, ...environment },
101
+ })
96
102
  const [exitCode, stderr] = await Promise.all([
97
103
  child.exited,
98
104
  new Response(child.stderr).text(),
@@ -132,6 +138,7 @@ export class TaskService extends Effect.Service<TaskService>()("TaskService", {
132
138
  const fs = yield* FileSystemService
133
139
  const workbase = yield* WorkbaseService
134
140
  const epics = yield* EpicService
141
+ const versionControl = yield* VersionControlService
135
142
  const root = yield* workbase.discover(startPath)
136
143
  const id = yield* decodeId(input.id)
137
144
  const directory = join(root, "tasks", id)
@@ -257,13 +264,22 @@ export class TaskService extends Effect.Service<TaskService>()("TaskService", {
257
264
  const updated = formatMarkdownDocument(epicData, parsed.body)
258
265
  writes.push({ path: parentEpic.path, content: updated })
259
266
  }
267
+ let reviewEnvironment: Record<string, string> = {}
268
+ if (input.review) {
269
+ const backend = yield* versionControl.forWorkbase(root)
270
+ reviewEnvironment = yield* backend.gitEnvironment(
271
+ join(root, "repos", input.review.repo),
272
+ )
273
+ }
260
274
  yield* runLifecycleTransaction({
261
275
  root,
262
276
  preconditions: parentEpic
263
277
  ? [{ path: parentEpic.path, revision: parentEpic.revision }]
264
278
  : [],
265
279
  steps: [
266
- ...(input.review ? [reviewPinStep(root, id, input.review)] : []),
280
+ ...(input.review
281
+ ? [reviewPinStep(root, id, input.review, reviewEnvironment)]
282
+ : []),
267
283
  documentWriteStep(root, writes),
268
284
  ],
269
285
  })
@@ -1,6 +1,6 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from "bun:test"
2
2
  import { Effect } from "effect"
3
- import { lstat, mkdir } from "node:fs/promises"
3
+ import { lstat, mkdir, rm } from "node:fs/promises"
4
4
  import { join } from "node:path"
5
5
  import { cleanupTempDir, createTempDir, runTestEffect } from "../test-utils"
6
6
  import { ClaimService } from "./ClaimService"
@@ -196,6 +196,63 @@ describe("VcsMigrationService", () => {
196
196
  ).toEqual([])
197
197
  })
198
198
 
199
+ test("blocks non-colocated jj to Git migration before mutation", async () => {
200
+ if (!Bun.which("jj")) return
201
+ await runTestEffect(
202
+ WorktreeService.pipe(
203
+ Effect.flatMap((service) => service.remove("example", undefined, root)),
204
+ ),
205
+ )
206
+ for (const alias of ["agency", "effect"]) {
207
+ const repository = join(root, "repos", alias)
208
+ await rm(repository, { recursive: true, force: true })
209
+ await run([
210
+ "jj",
211
+ "git",
212
+ "clone",
213
+ "--no-colocate",
214
+ join(root, "source"),
215
+ repository,
216
+ ])
217
+ expect(await exists(join(repository, ".git"))).toBe(false)
218
+ }
219
+ await Bun.write(
220
+ join(root, "agency.json"),
221
+ JSON.stringify({ version: 2, vcs: "jj" }),
222
+ )
223
+
224
+ const planned = await runTestEffect(
225
+ VcsMigrationService.pipe(
226
+ Effect.flatMap((service) => service.migrate("git", root)),
227
+ ),
228
+ )
229
+ expect(planned.blockers).toEqual(
230
+ expect.arrayContaining([
231
+ expect.objectContaining({
232
+ target: "repository:agency",
233
+ message: expect.stringContaining("non-colocated"),
234
+ }),
235
+ expect.objectContaining({
236
+ target: "repository:effect",
237
+ message: expect.stringContaining("non-colocated"),
238
+ }),
239
+ ]),
240
+ )
241
+ expect((await Bun.file(join(root, "agency.json")).json()).vcs).toBe("jj")
242
+ expect(await exists(join(root, "repos/agency/.jj"))).toBe(true)
243
+ await expect(
244
+ runTestEffect(
245
+ VcsMigrationService.pipe(
246
+ Effect.flatMap((service) =>
247
+ service.migrate("git", root, { apply: true }),
248
+ ),
249
+ ),
250
+ ),
251
+ ).rejects.toThrow("non-colocated")
252
+ expect((await Bun.file(join(root, "agency.json")).json()).vcs).toBe("jj")
253
+ expect(await exists(join(root, "repos/agency/.jj"))).toBe(true)
254
+ })
255
+
199
256
  test("allows clean unclaimed working workspaces", async () => {
200
257
  if (!Bun.which("jj")) return
201
258
  await runTestEffect(
@@ -309,6 +309,15 @@ const inspectMigration = (startPath: string, requestedTarget?: VcsKind) =>
309
309
  remote: repository.declaredRemote ?? repository.remote,
310
310
  })
311
311
  if (source === "jj" && target === "git" && initialized) {
312
+ const gitEnvironment: Record<string, string> =
313
+ yield* sourceBackend.gitEnvironment(targetPath)
314
+ if (gitEnvironment.GIT_DIR?.includes(`${join(targetPath, ".jj")}/`)) {
315
+ blockers.push({
316
+ kind: "repository",
317
+ target: `repository:${repository.alias}`,
318
+ message: `Repository '${repository.alias}' is non-colocated; migrate it to colocated jj before converting the workbase to Git`,
319
+ })
320
+ }
312
321
  const dirty = yield* sourceBackend.workspaceDirty(targetPath)
313
322
  if (dirty !== false) {
314
323
  blockers.push({
@@ -97,4 +97,42 @@ describe("VersionControlService", () => {
97
97
  )
98
98
  expect(await Bun.file(workspace).exists()).toBe(false)
99
99
  })
100
+
101
+ test("clones non-colocated jj repositories and exposes their backing Git directory", async () => {
102
+ if (!Bun.which("jj")) return
103
+ const root = await createTempDir()
104
+ roots.push(root)
105
+ await Bun.write(
106
+ join(root, "agency.json"),
107
+ JSON.stringify({ version: 2, vcs: "jj" }),
108
+ )
109
+ const source = join(root, "source")
110
+ const repository = join(root, "repository")
111
+ await mkdir(source)
112
+ await run(["git", "init", "--initial-branch=main"], source)
113
+ await run(["git", "config", "user.email", "test@example.com"], source)
114
+ await run(["git", "config", "user.name", "Test"], source)
115
+ await Bun.write(join(source, "README.md"), "example\n")
116
+ await run(["git", "add", "README.md"], source)
117
+ await run(["git", "commit", "-m", "initial"], source)
118
+
119
+ const backend = await runTestEffect(
120
+ VersionControlService.pipe(
121
+ Effect.flatMap((service) => service.forWorkbase(root)),
122
+ ),
123
+ )
124
+ await runTestEffect(backend.cloneRepository(source, repository))
125
+
126
+ expect((await stat(join(repository, ".jj"))).isDirectory()).toBe(true)
127
+ expect(await Bun.file(join(repository, ".git")).exists()).toBe(false)
128
+ const environment: Record<string, string> = await runTestEffect(
129
+ backend.gitEnvironment(repository),
130
+ )
131
+ expect(environment.GIT_DIR).toContain(join(repository, ".jj"))
132
+ expect((await stat(environment.GIT_DIR!)).isDirectory()).toBe(true)
133
+ expect(await runTestEffect(backend.inspectRepository(repository))).toEqual({
134
+ kind: "repository",
135
+ remote: await realpath(source),
136
+ })
137
+ })
100
138
  })