@markjaquith/agency 2.54.1 → 2.54.3

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.
package/README.md CHANGED
@@ -857,7 +857,8 @@ including readiness checks and durable PR recording. Other `agency pr`
857
857
  invocations forward every argument to `gh pr`. From an execution task or phase
858
858
  directory, including descendants, passthrough runs in that execution unit's
859
859
  authoritative writable checkout. Otherwise it runs in the caller's current
860
- directory.
860
+ directory. In jj workbases, Agency also supplies the repository and the work
861
+ item's declared branch to subcommands that would otherwise infer Git context.
861
862
 
862
863
  ### Status and Validation
863
864
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "2.54.1",
3
+ "version": "2.54.3",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
@@ -50,9 +50,19 @@ exit "\${GH_EXIT:-0}"
50
50
  const captured = async () =>
51
51
  (await Bun.file(capturePath).text()).trim().split("\n")
52
52
 
53
- const createExecutionWorkbase = async () => {
53
+ const createExecutionWorkbase = async (vcs: "git" | "jj" = "git") => {
54
54
  const root = join(tempRoot, "workbase")
55
- await write(root, "agency.json", '{"version":2}\n')
55
+ await write(
56
+ root,
57
+ "agency.json",
58
+ `${JSON.stringify({
59
+ version: 2,
60
+ vcs,
61
+ repositories: {
62
+ agency: { remote: "https://github.com/example/agency.git" },
63
+ },
64
+ })}\n`,
65
+ )
56
66
  await mkdir(join(root, "repos/agency"), { recursive: true })
57
67
  await mkdir(join(root, "tasks/single/code/agency"), { recursive: true })
58
68
  await write(
@@ -163,6 +173,51 @@ status: open
163
173
  ])
164
174
  })
165
175
 
176
+ test("injects the execution branch and repository for jj targets", async () => {
177
+ const root = await createExecutionWorkbase("jj")
178
+ const task = join(root, "tasks/single")
179
+ const phase = join(root, "tasks/multi/phases/build")
180
+
181
+ expect(await runTestEffect(pr(["view", "--web"], task))).toBe(0)
182
+ expect(await captured()).toEqual([
183
+ await realpath(join(task, "code/agency")),
184
+ "pr",
185
+ "view",
186
+ "feat/single",
187
+ "--repo",
188
+ "example/agency",
189
+ "--web",
190
+ ])
191
+
192
+ expect(
193
+ await runTestEffect(pr(["create", "--title", "Example"], phase)),
194
+ ).toBe(0)
195
+ expect(await captured()).toEqual([
196
+ await realpath(join(phase, "code/agency")),
197
+ "pr",
198
+ "create",
199
+ "--head",
200
+ "feat/build",
201
+ "--repo",
202
+ "example/agency",
203
+ "--title",
204
+ "Example",
205
+ ])
206
+ })
207
+
208
+ test("preserves explicit jj PR and repository targets", async () => {
209
+ const root = await createExecutionWorkbase("jj")
210
+ const task = join(root, "tasks/single")
211
+ const args = ["view", "123", "--repo", "other/repository"]
212
+
213
+ expect(await runTestEffect(pr(args, task))).toBe(0)
214
+ expect(await captured()).toEqual([
215
+ await realpath(join(task, "code/agency")),
216
+ "pr",
217
+ ...args,
218
+ ])
219
+ })
220
+
166
221
  test("falls back to the invocation directory without execution authority", async () => {
167
222
  const root = await createExecutionWorkbase()
168
223
  const orchestration = join(root, "tasks/multi")
@@ -3,8 +3,10 @@ import { resolve } from "node:path"
3
3
  import { ContextService } from "../services/ContextService"
4
4
  import { FileSystemService } from "../services/FileSystemService"
5
5
  import { PullRequestService } from "../services/PullRequestService"
6
+ import { WorkbaseService } from "../services/WorkbaseService"
6
7
  import type { BaseCommandOptions } from "../utils/command"
7
8
  import { createLoggers } from "../utils/effect"
9
+ import { repositoryFromRemote } from "../workbase/delivery-command"
8
10
 
9
11
  interface PrCreateOptions extends BaseCommandOptions {
10
12
  readonly taskId: string
@@ -13,6 +15,56 @@ interface PrCreateOptions extends BaseCommandOptions {
13
15
  readonly force?: boolean
14
16
  }
15
17
 
18
+ const branchTargetCommands = new Set([
19
+ "checkout",
20
+ "checks",
21
+ "close",
22
+ "comment",
23
+ "diff",
24
+ "edit",
25
+ "lock",
26
+ "merge",
27
+ "ready",
28
+ "reopen",
29
+ "revert",
30
+ "review",
31
+ "unlock",
32
+ "update-branch",
33
+ "view",
34
+ ])
35
+
36
+ const hasOption = (args: readonly string[], short: string, long: string) =>
37
+ args.some(
38
+ (argument) =>
39
+ argument === short ||
40
+ argument.startsWith(`${short}=`) ||
41
+ argument === long ||
42
+ argument.startsWith(`${long}=`),
43
+ )
44
+
45
+ const withJjContext = (
46
+ args: readonly string[],
47
+ branch: string,
48
+ repository: string,
49
+ ) => {
50
+ const [command, ...rest] = args
51
+ if (!command) return args
52
+
53
+ const branchArgs =
54
+ (command === "create" || command === "new") &&
55
+ !hasOption(rest, "-H", "--head")
56
+ ? ["--head", branch]
57
+ : branchTargetCommands.has(command) &&
58
+ (rest.length === 0 || rest[0]?.startsWith("-"))
59
+ ? [branch]
60
+ : []
61
+ const repositoryArgs = hasOption(rest, "-R", "--repo")
62
+ ? []
63
+ : ["--repo", repository]
64
+
65
+ return [command, ...branchArgs, ...repositoryArgs, ...rest]
66
+ }
67
+
16
68
  export const prCreate = (options: PrCreateOptions) =>
17
69
  Effect.gen(function* () {
18
70
  const pullRequests = yield* PullRequestService
@@ -31,6 +83,7 @@ export const pr = (args: readonly string[], cwd: string = process.cwd()) =>
31
83
  Effect.gen(function* () {
32
84
  const contexts = yield* ContextService
33
85
  const fs = yield* FileSystemService
86
+ const workbase = yield* WorkbaseService
34
87
  const invocationCwd = resolve(cwd)
35
88
  const context = yield* contexts
36
89
  .get({ cwd: invocationCwd, target: ".", compact: true })
@@ -40,7 +93,26 @@ export const pr = (args: readonly string[], cwd: string = process.cwd()) =>
40
93
  ? context.authority.writable?.checkoutPath
41
94
  : null
42
95
  const focusedCwd = writableCheckout ?? invocationCwd
43
- const result = yield* fs.runCommand(["gh", "pr", ...args], {
96
+ let forwardedArgs = args
97
+ if (
98
+ writableCheckout &&
99
+ context?.workbase.vcs === "jj" &&
100
+ context.authority.writable
101
+ ) {
102
+ const execution =
103
+ context.documents?.phase?.data ?? context.documents?.task?.data
104
+ const { config } = yield* workbase.loadConfig(context.workbase.root)
105
+ const remote =
106
+ config.repositories?.[context.authority.writable.repo]?.remote
107
+ if (execution && "branch" in execution && remote) {
108
+ forwardedArgs = withJjContext(
109
+ args,
110
+ execution.branch,
111
+ repositoryFromRemote(remote),
112
+ )
113
+ }
114
+ }
115
+ const result = yield* fs.runCommand(["gh", "pr", ...forwardedArgs], {
44
116
  cwd: focusedCwd,
45
117
  passthrough: true,
46
118
  })
@@ -53,5 +125,6 @@ Usage: agency pr create <task-id> [phase-id] [--draft] [--force] [--json]
53
125
 
54
126
  Create records a pull request for an Agency execution unit. Other invocations
55
127
  run gh pr unchanged, focusing the writable repository checkout when invoked from
56
- an Agency execution task or phase.
128
+ an Agency execution task or phase. In jj workbases, Agency supplies the declared
129
+ branch and repository to gh subcommands that would otherwise infer Git context.
57
130
  `
@@ -10,6 +10,7 @@ import { RepositoryService } from "./RepositoryService"
10
10
  import { TaskService } from "./TaskService"
11
11
  import { WorkbaseService } from "./WorkbaseService"
12
12
  import { WorktreeService } from "./WorktreeService"
13
+ import { VersionControlService } from "./VersionControlService"
13
14
 
14
15
  type DoctorCheckLevel = "error" | "warning" | "optional"
15
16
 
@@ -87,7 +88,9 @@ export class DoctorService extends Effect.Service<DoctorService>()(
87
88
  const tasks = yield* TaskService
88
89
  const workbases = yield* WorkbaseService
89
90
  const worktrees = yield* WorktreeService
91
+ const versionControl = yield* VersionControlService
90
92
  const { root, config } = yield* workbases.loadConfig(startPath)
93
+ const backend = yield* versionControl.forWorkbase(root)
91
94
  const checks: DoctorCheck[] = []
92
95
  const add = (
93
96
  check: Omit<DoctorCheck, "remediation"> & {
@@ -409,32 +412,8 @@ export class DoctorService extends Effect.Service<DoctorService>()(
409
412
  }
410
413
 
411
414
  for (const ref of [...(refs.get(repository.alias) ?? [])].sort()) {
412
- const local = yield* fs.runCommand(
413
- [
414
- "git",
415
- "-C",
416
- repository.path,
417
- "rev-parse",
418
- "--verify",
419
- `${ref}^{commit}`,
420
- ],
421
- { captureOutput: true },
422
- )
423
- const remote =
424
- local.exitCode === 0
425
- ? local
426
- : yield* fs.runCommand(
427
- [
428
- "git",
429
- "-C",
430
- repository.path,
431
- "rev-parse",
432
- "--verify",
433
- `origin/${ref}^{commit}`,
434
- ],
435
- { captureOutput: true },
436
- )
437
- const found = local.exitCode === 0 || remote.exitCode === 0
415
+ const found =
416
+ (yield* backend.resolveRevision(repository.path, ref)) !== null
438
417
  add({
439
418
  id: `ref.${repository.alias}.${ref}`,
440
419
  category: "ref",
@@ -16,20 +16,15 @@ import {
16
16
  normalizePullRequestRecord,
17
17
  parsePullRequestRecord,
18
18
  recordFromGitHubUrl,
19
+ repositoryFromRemote,
19
20
  resolveDeliveryCommand,
21
+ resolveGitHubCreateCommand,
20
22
  } from "../workbase/delivery-command"
21
23
 
22
24
  class PullRequestError extends Data.TaggedError("PullRequestError")<{
23
25
  readonly message: string
24
26
  }> {}
25
27
 
26
- const repositoryFromRemote = (remote: string) =>
27
- remote
28
- .replace(/^[a-z][a-z0-9+.-]*:\/\/(?:[^@/]+@)?[^/]+\//i, "")
29
- .replace(/^[^:]+:/, "")
30
- .replace(/\.git\/?$/, "")
31
- .replace(/\/$/, "")
32
-
33
28
  interface PullRequestOptions extends BaseCommandOptions {
34
29
  readonly force?: boolean
35
30
  }
@@ -197,18 +192,13 @@ export class PullRequestService extends Effect.Service<PullRequestService>()(
197
192
  url: "",
198
193
  identifier: "",
199
194
  })
200
- : {
201
- argv: [
202
- "gh",
203
- "pr",
204
- "create",
205
- "--fill",
206
- "--base",
207
- execution.base,
208
- ...(draft ? ["--draft"] : []),
209
- ],
210
- environment: {},
211
- }
195
+ : resolveGitHubCreateCommand({
196
+ base: execution.base,
197
+ branch: execution.branch,
198
+ repository,
199
+ draft,
200
+ vcs: config.vcs ?? "git",
201
+ })
212
202
  const created = yield* fs.runCommand(resolved.argv, {
213
203
  cwd: workspace.writablePath,
214
204
  captureOutput: true,
@@ -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, rm } from "node:fs/promises"
4
4
  import { join } from "node:path"
5
5
  import { cleanupTempDir, createTempDir, runTestEffect } from "../test-utils"
6
6
  import { TaskService } from "./TaskService"
@@ -13,6 +13,7 @@ import { PhaseService } from "./PhaseService"
13
13
  import { ArchiveService } from "./ArchiveService"
14
14
  import { ClaimService } from "./ClaimService"
15
15
  import { SyncService } from "./SyncService"
16
+ import { DoctorService } from "./DoctorService"
16
17
  import { task as taskCommand } from "../commands/task"
17
18
 
18
19
  const git = async (args: string[], cwd?: string) => {
@@ -26,6 +27,16 @@ const git = async (args: string[], cwd?: string) => {
26
27
  throw new Error(await new Response(child.stderr).text())
27
28
  }
28
29
 
30
+ const jj = async (args: string[]) => {
31
+ const child = Bun.spawn(["jj", ...args], {
32
+ stdout: "pipe",
33
+ stderr: "pipe",
34
+ })
35
+ await child.exited
36
+ if (child.exitCode !== 0)
37
+ throw new Error(await new Response(child.stderr).text())
38
+ }
39
+
29
40
  describe("ReviewService", () => {
30
41
  let root: string
31
42
  let source: string
@@ -151,6 +162,78 @@ describe("ReviewService", () => {
151
162
  expect(new TextDecoder().decode(oldPins.stdout).trim()).toBe("")
152
163
  })
153
164
 
165
+ test("imports jj reviews for creation, diagnostics, materialization, and refresh", async () => {
166
+ if (!Bun.which("jj")) return
167
+ const repository = join(root, "repos/agency")
168
+ await rm(repository, { recursive: true, force: true })
169
+ await git(["clone", source, repository])
170
+ await git(["switch", "-c", "jj-review"], source)
171
+ await Bun.write(join(source, "README.md"), "jj review one\n")
172
+ await git(["commit", "-am", "jj review one"], source)
173
+ await jj(["git", "init", "--colocate", repository])
174
+ await Bun.write(
175
+ join(root, "agency.json"),
176
+ JSON.stringify({ version: 2, vcs: "jj" }),
177
+ )
178
+
179
+ await runTestEffect(
180
+ taskCommand({
181
+ subcommand: "create",
182
+ args: ["jj-review"],
183
+ review: "agency",
184
+ ref: "jj-review",
185
+ cwd: root,
186
+ silent: true,
187
+ }),
188
+ )
189
+ const created = await runTestEffect(
190
+ TaskService.pipe(
191
+ Effect.flatMap((service) => service.show("jj-review", root)),
192
+ ),
193
+ )
194
+ const original = "review" in created.data ? created.data.review.commit : ""
195
+
196
+ const context = await runTestEffect(
197
+ ContextService.pipe(
198
+ Effect.flatMap((service) =>
199
+ service.get({ target: "jj-review", cwd: root }),
200
+ ),
201
+ ),
202
+ )
203
+ expect(context.workspace!.warnings).toEqual([])
204
+ expect(context.review?.checkout?.resolvedCommit).toBe(original)
205
+
206
+ const doctor = await runTestEffect(
207
+ DoctorService.pipe(Effect.flatMap((service) => service.inspect(root))),
208
+ )
209
+ expect(
210
+ doctor.checks.find((check) => check.id === `ref.agency.${original}`),
211
+ ).toMatchObject({ status: "pass" })
212
+
213
+ const workspace = await runTestEffect(
214
+ WorktreeService.pipe(
215
+ Effect.flatMap((service) =>
216
+ service.materialize("jj-review", undefined, root),
217
+ ),
218
+ ),
219
+ )
220
+ expect(
221
+ await Bun.file(join(workspace.reviewPath!, "README.md")).text(),
222
+ ).toBe("jj review one\n")
223
+
224
+ await Bun.write(join(source, "README.md"), "jj review two\n")
225
+ await git(["commit", "-am", "jj review two"], source)
226
+ const refreshed = await runTestEffect(
227
+ ReviewService.pipe(
228
+ Effect.flatMap((service) => service.refresh("jj-review", root)),
229
+ ),
230
+ )
231
+ expect(refreshed.changed).toBe(true)
232
+ expect(
233
+ await Bun.file(join(workspace.reviewPath!, "README.md")).text(),
234
+ ).toBe("jj review two\n")
235
+ })
236
+
154
237
  test("rejects delivery and phase operations even when forced", async () => {
155
238
  await createReview()
156
239
  await expect(
@@ -1,4 +1,4 @@
1
- import { Data, Effect, Layer } from "effect"
1
+ import { Data, Effect, Either, Layer } from "effect"
2
2
  import { randomUUID } from "node:crypto"
3
3
  import { lstat, mkdir } from "node:fs/promises"
4
4
  import { dirname } from "node:path"
@@ -15,6 +15,7 @@ import {
15
15
  GitVersionControlService,
16
16
  JjVersionControlService,
17
17
  VersionControlService,
18
+ type VersionControlBackend,
18
19
  } from "./VersionControlService"
19
20
  import { withWorktreeLocks } from "./WorktreeLock"
20
21
  import {
@@ -160,7 +161,11 @@ const normalizeBranch = (input: string, repositoryPath: string) =>
160
161
  return `refs/heads/${name}`
161
162
  })
162
163
 
163
- const fetchCommit = (repoPath: string, sourceRef: string) =>
164
+ const fetchCommit = (
165
+ repoPath: string,
166
+ sourceRef: string,
167
+ backend: VersionControlBackend,
168
+ ) =>
164
169
  Effect.gen(function* () {
165
170
  const fs = yield* FileSystemService
166
171
  const temporaryRef = `refs/agency/review-fetch/${process.pid}-${randomUUID()}`
@@ -196,6 +201,17 @@ const fetchCommit = (repoPath: string, sourceRef: string) =>
196
201
  ],
197
202
  { captureOutput: true },
198
203
  )
204
+ const commit = resolved.stdout.trim()
205
+ if (resolved.exitCode !== 0 || !/^[a-f0-9]{40}$/.test(commit)) {
206
+ yield* fs.runCommand(
207
+ ["git", "-C", repoPath, "update-ref", "-d", temporaryRef],
208
+ { captureOutput: true },
209
+ )
210
+ return yield* new ReviewError({
211
+ message: `Review source '${sourceRef}' did not resolve to a commit`,
212
+ })
213
+ }
214
+ const imported = yield* Effect.either(backend.importGitRefs(repoPath))
199
215
  const cleanup = yield* fs.runCommand(
200
216
  ["git", "-C", repoPath, "update-ref", "-d", temporaryRef],
201
217
  { captureOutput: true },
@@ -205,10 +221,10 @@ const fetchCommit = (repoPath: string, sourceRef: string) =>
205
221
  message: `Failed to remove temporary review fetch ref: ${cleanup.stderr.trim()}`,
206
222
  })
207
223
  }
208
- const commit = resolved.stdout.trim()
209
- if (resolved.exitCode !== 0 || !/^[a-f0-9]{40}$/.test(commit)) {
224
+ if (Either.isLeft(imported)) {
210
225
  return yield* new ReviewError({
211
- message: `Review source '${sourceRef}' did not resolve to a commit`,
226
+ message: `Review source '${sourceRef}' was fetched but could not be imported into ${backend.kind}`,
227
+ cause: imported.left,
212
228
  })
213
229
  }
214
230
  return commit
@@ -225,6 +241,8 @@ export class ReviewService extends Effect.Service<ReviewService>()(
225
241
  ) =>
226
242
  Effect.gen(function* () {
227
243
  const repositories = yield* RepositoryService
244
+ const versionControl = yield* VersionControlService
245
+ const backend = yield* versionControl.forWorkbase(startPath)
228
246
  const repository = yield* repositories.show(repo, startPath)
229
247
  if (!repository.remote || repository.states.includes("missing")) {
230
248
  return yield* new ReviewError({
@@ -273,7 +291,7 @@ export class ReviewService extends Effect.Service<ReviewService>()(
273
291
  message: "Exactly one review source is required",
274
292
  })
275
293
  }
276
- const commit = yield* fetchCommit(repository.path, sourceRef)
294
+ const commit = yield* fetchCommit(repository.path, sourceRef, backend)
277
295
  return {
278
296
  repo,
279
297
  source,
@@ -47,6 +47,9 @@ export interface VersionControlBackend {
47
47
  remote?: string,
48
48
  branch?: string,
49
49
  ) => Effect.Effect<void, unknown, any>
50
+ readonly importGitRefs: (
51
+ repositoryPath: string,
52
+ ) => Effect.Effect<void, unknown, any>
50
53
  readonly push: (
51
54
  workspacePath: string,
52
55
  remote: string,
@@ -240,6 +243,7 @@ export class GitVersionControlService extends Effect.Service<GitVersionControlSe
240
243
  ),
241
244
  )
242
245
  }),
246
+ importGitRefs: () => Effect.void,
243
247
  push: (workspacePath, remote, branch) =>
244
248
  Effect.gen(function* () {
245
249
  const fs = yield* FileSystemService
@@ -333,18 +337,27 @@ export class JjVersionControlService extends Effect.Service<JjVersionControlServ
333
337
  resolveRevision: (repositoryPath, revision) =>
334
338
  Effect.gen(function* () {
335
339
  const fs = yield* FileSystemService
336
- const result = yield* fs.runCommand(
337
- jjCommand(repositoryPath, [
338
- "log",
339
- "--ignore-working-copy",
340
- "--no-graph",
341
- "-r",
342
- revision,
343
- "-T",
344
- 'commit_id ++ "\\n"',
345
- ]),
346
- { captureOutput: true },
347
- )
340
+ const resolve = () =>
341
+ fs.runCommand(
342
+ jjCommand(repositoryPath, [
343
+ "log",
344
+ "--ignore-working-copy",
345
+ "--no-graph",
346
+ "-r",
347
+ revision,
348
+ "-T",
349
+ 'commit_id ++ "\\n"',
350
+ ]),
351
+ { captureOutput: true },
352
+ )
353
+ let result = yield* resolve()
354
+ if (result.exitCode !== 0 || !result.stdout.trim()) {
355
+ const imported = yield* fs.runCommand(
356
+ jjCommand(repositoryPath, ["git", "import"]),
357
+ { captureOutput: true },
358
+ )
359
+ if (imported.exitCode === 0) result = yield* resolve()
360
+ }
348
361
  return result.exitCode === 0 ? result.stdout.trim() || null : null
349
362
  }),
350
363
  workspaceHead: (workspacePath) =>
@@ -430,6 +443,16 @@ export class JjVersionControlService extends Effect.Service<JjVersionControlServ
430
443
  ),
431
444
  )
432
445
  }),
446
+ importGitRefs: (repositoryPath) =>
447
+ Effect.gen(function* () {
448
+ const fs = yield* FileSystemService
449
+ yield* requireSuccess(
450
+ "Failed to import Git refs into jj",
451
+ fs.runCommand(jjCommand(repositoryPath, ["git", "import"]), {
452
+ captureOutput: true,
453
+ }),
454
+ )
455
+ }),
433
456
  push: (workspacePath, remote, branch) =>
434
457
  Effect.gen(function* () {
435
458
  const fs = yield* FileSystemService
@@ -164,6 +164,44 @@ describe("WorktreeService", () => {
164
164
  expect(await Bun.file(workspace.writablePath!).exists()).toBe(false)
165
165
  })
166
166
 
167
+ test("recommends a repository fetch when jj cannot resolve a base", async () => {
168
+ if (!Bun.which("jj")) return
169
+ const repository = join(root, "repos/agency")
170
+ await rm(repository, { recursive: true, force: true })
171
+ await git(["clone", source, repository])
172
+ await jj(["git", "init", "--colocate", repository])
173
+ await Bun.write(
174
+ join(root, "agency.json"),
175
+ JSON.stringify({ version: 2, vcs: "jj" }),
176
+ )
177
+ await runTestEffect(
178
+ TaskService.pipe(
179
+ Effect.flatMap((service) =>
180
+ service.create(
181
+ {
182
+ id: "jj-missing",
183
+ ticketUrl: null,
184
+ repo: "agency",
185
+ branch: "task/jj-missing",
186
+ base: "absent-base",
187
+ },
188
+ root,
189
+ ),
190
+ ),
191
+ ),
192
+ )
193
+
194
+ await expect(
195
+ runTestEffect(
196
+ WorktreeService.pipe(
197
+ Effect.flatMap((service) =>
198
+ service.materialize("jj-missing", undefined, root),
199
+ ),
200
+ ),
201
+ ),
202
+ ).rejects.toThrow("run 'agency repo fetch agency' and retry")
203
+ })
204
+
167
205
  test("does not fetch the origin for an existing writable worktree", async () => {
168
206
  await runTestEffect(
169
207
  TaskService.pipe(
@@ -884,8 +884,12 @@ const materializeJj = (options: {
884
884
  revision = yield* backend.resolveRevision(repositoryPath, base)
885
885
  }
886
886
  if (!revision) {
887
+ const recovery =
888
+ backend.kind === "jj"
889
+ ? `; run 'agency repo fetch ${checkout.repo}' and retry`
890
+ : ""
887
891
  return yield* new WorktreeError({
888
- message: `${"branch" in checkout ? "Base" : "Reference"} '${"branch" in checkout ? base : checkout.ref}' for repository '${checkout.repo}' does not resolve to a commit`,
892
+ message: `${"branch" in checkout ? "Base" : "Reference"} '${"branch" in checkout ? base : checkout.ref}' for repository '${checkout.repo}' does not resolve to a commit${recovery}`,
889
893
  })
890
894
  }
891
895
 
@@ -3,6 +3,7 @@ import {
3
3
  parsePullRequestRecord,
4
4
  recordFromGitHubJson,
5
5
  resolveDeliveryCommand,
6
+ resolveGitHubCreateCommand,
6
7
  validateDelivery,
7
8
  } from "./delivery-command"
8
9
 
@@ -31,6 +32,34 @@ describe("delivery commands", () => {
31
32
  })
32
33
  })
33
34
 
35
+ test("adds explicit jj context to the default GitHub create command", () => {
36
+ const input = {
37
+ base: "main",
38
+ branch: "feat/example",
39
+ repository: "example/agency",
40
+ draft: false,
41
+ } as const
42
+ expect(resolveGitHubCreateCommand({ ...input, vcs: "jj" })).toEqual({
43
+ argv: [
44
+ "gh",
45
+ "pr",
46
+ "create",
47
+ "--fill",
48
+ "--base",
49
+ "main",
50
+ "--head",
51
+ "feat/example",
52
+ "--repo",
53
+ "example/agency",
54
+ ],
55
+ environment: {},
56
+ })
57
+ expect(resolveGitHubCreateCommand({ ...input, vcs: "git" })).toEqual({
58
+ argv: ["gh", "pr", "create", "--fill", "--base", "main"],
59
+ environment: {},
60
+ })
61
+ })
62
+
34
63
  test("rejects unknown placeholders", () => {
35
64
  expect(() =>
36
65
  validateDelivery({ ...delivery, queryCommand: ["forge", "{unknown}"] }),
@@ -11,6 +11,39 @@ export interface DeliveryCommandVariables {
11
11
  readonly identifier: string
12
12
  }
13
13
 
14
+ export const repositoryFromRemote = (remote: string) =>
15
+ remote
16
+ .replace(/^[a-z][a-z0-9+.-]*:\/\/(?:[^@/]+@)?[^/]+\//i, "")
17
+ .replace(/^[^:]+:/, "")
18
+ .replace(/\.git\/?$/, "")
19
+ .replace(/\/$/, "")
20
+
21
+ export const resolveGitHubCreateCommand = ({
22
+ base,
23
+ branch,
24
+ repository,
25
+ draft,
26
+ vcs,
27
+ }: {
28
+ readonly base: string
29
+ readonly branch: string
30
+ readonly repository: string
31
+ readonly draft: boolean
32
+ readonly vcs: "git" | "jj"
33
+ }) => ({
34
+ argv: [
35
+ "gh",
36
+ "pr",
37
+ "create",
38
+ "--fill",
39
+ "--base",
40
+ base,
41
+ ...(vcs === "jj" ? ["--head", branch, "--repo", repository] : []),
42
+ ...(draft ? ["--draft"] : []),
43
+ ],
44
+ environment: {},
45
+ })
46
+
14
47
  const PLACEHOLDERS = new Set<keyof DeliveryCommandVariables>([
15
48
  "repository",
16
49
  "branch",