@markjaquith/agency 2.56.0 → 2.57.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.
package/README.md CHANGED
@@ -142,9 +142,17 @@ or credential:
142
142
 
143
143
  New workbases select `"jj"` when the `jj` executable is available and otherwise
144
144
  select `"git"`. Existing version 2 workbases without `vcs` remain Git workbases.
145
- The selected backend is a workbase-level invariant: jj workbases initialize
146
- managed clones with `jj git init` and create jj workspaces, while Git workbases
147
- continue to use Git worktrees.
145
+ The selected backend is a workbase-level invariant: jj workbases create
146
+ non-colocated managed clones and jj workspaces, while Git workbases continue to
147
+ use bare repositories and Git worktrees. Existing colocated jj repositories are
148
+ also supported.
149
+
150
+ Agency uses jj-native commands for repository validity, workspace registration,
151
+ working-copy state, revisions, bookmarks, ancestry, fetch, and push. Raw Git is
152
+ limited to backing-store plumbing such as durable review refs and integrations
153
+ that require Git, including GitHub CLI commit discovery. For those operations,
154
+ Agency obtains the backing repository with `jj git root` and supplies it as
155
+ `GIT_DIR`; it does not use raw Git to infer jj workspace state.
148
156
 
149
157
  Inspect the current backend and migration readiness with:
150
158
 
@@ -162,6 +170,9 @@ target backend, and updates `agency.json` last. Migration from jj to Git also
162
170
  blocks jj-only heads that are not preserved by a bookmark or workspace. Failed
163
171
  migrations restore the source repositories and workspaces when rollback is
164
172
  possible and report explicit manual recovery paths otherwise.
173
+ Converting a non-colocated jj workbase to Git is currently blocked before any
174
+ mutation because its backing Git object store is inside `.jj`; first convert the
175
+ repositories to colocated jj so the Git store survives metadata removal.
165
176
 
166
177
  Existing version 2 workbases without `repositories` remain valid. Run
167
178
  `agency repo setup` to preview deterministic adoption of legacy local aliases;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "2.56.0",
3
+ "version": "2.57.0",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
@@ -58,6 +58,7 @@
58
58
  "tag": "latest"
59
59
  },
60
60
  "scripts": {
61
+ "benchmark:sync": "bun scripts/benchmark-sync.ts",
61
62
  "test": "find src \\( -name '*.test.ts' -o -name '*.test.tsx' \\) -print0 | xargs -0 -n 1 -P 4 bun test",
62
63
  "test:opencode": "AGENCY_TEST_OPENCODE=1 bun test src/cli.test.ts --test-name-pattern 'provides effective whole-workbase OpenCode access'",
63
64
  "format": "oxfmt",
@@ -1,6 +1,6 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from "bun:test"
2
2
  import { Effect } from "effect"
3
- import { chmod, mkdir, realpath } from "node:fs/promises"
3
+ import { chmod, mkdir, realpath, rm } from "node:fs/promises"
4
4
  import { dirname, join } from "node:path"
5
5
  import { PullRequestService } from "../services/PullRequestService"
6
6
  import {
@@ -31,6 +31,7 @@ describe("pr command", () => {
31
31
  join(bin, "gh"),
32
32
  `#!/bin/sh
33
33
  printf '%s\n' "$PWD" "$@" > "$GH_CAPTURE"
34
+ if [ -n "$GIT_DIR" ]; then printf 'GIT_DIR=%s\n' "$GIT_DIR" >> "$GH_CAPTURE"; fi
34
35
  exit "\${GH_EXIT:-0}"
35
36
  `,
36
37
  )
@@ -109,6 +110,41 @@ status: open
109
110
  # Build
110
111
  `,
111
112
  )
113
+ if (vcs === "jj") {
114
+ const repository = join(root, "repos/agency")
115
+ await rm(join(root, "tasks/single/code/agency"), { recursive: true })
116
+ await rm(join(root, "tasks/multi/phases/build/code/agency"), {
117
+ recursive: true,
118
+ })
119
+ const initialized = Bun.spawn(
120
+ ["jj", "git", "init", "--no-colocate", repository],
121
+ { stdout: "pipe", stderr: "pipe" },
122
+ )
123
+ if ((await initialized.exited) !== 0)
124
+ throw new Error(await new Response(initialized.stderr).text())
125
+ for (const [name, path] of [
126
+ ["single", join(root, "tasks/single/code/agency")],
127
+ ["build", join(root, "tasks/multi/phases/build/code/agency")],
128
+ ] as const) {
129
+ const workspace = Bun.spawn(
130
+ [
131
+ "jj",
132
+ "-R",
133
+ repository,
134
+ "workspace",
135
+ "add",
136
+ "--name",
137
+ name,
138
+ "-r",
139
+ "root()",
140
+ path,
141
+ ],
142
+ { stdout: "pipe", stderr: "pipe" },
143
+ )
144
+ if ((await workspace.exited) !== 0)
145
+ throw new Error(await new Response(workspace.stderr).text())
146
+ }
147
+ }
112
148
  return root
113
149
  }
114
150
 
@@ -187,6 +223,7 @@ status: open
187
223
  "--repo",
188
224
  "example/agency",
189
225
  "--web",
226
+ expect.stringMatching(/^GIT_DIR=.*\.jj/),
190
227
  ])
191
228
 
192
229
  expect(
@@ -202,6 +239,7 @@ status: open
202
239
  "example/agency",
203
240
  "--title",
204
241
  "Example",
242
+ expect.stringMatching(/^GIT_DIR=.*\.jj/),
205
243
  ])
206
244
  })
207
245
 
@@ -215,6 +253,7 @@ status: open
215
253
  await realpath(join(task, "code/agency")),
216
254
  "pr",
217
255
  ...args,
256
+ expect.stringMatching(/^GIT_DIR=.*\.jj/),
218
257
  ])
219
258
  })
220
259
 
@@ -3,6 +3,7 @@ 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 { VersionControlService } from "../services/VersionControlService"
6
7
  import { WorkbaseService } from "../services/WorkbaseService"
7
8
  import type { BaseCommandOptions } from "../utils/command"
8
9
  import { createLoggers } from "../utils/effect"
@@ -84,6 +85,7 @@ export const pr = (args: readonly string[], cwd: string = process.cwd()) =>
84
85
  const contexts = yield* ContextService
85
86
  const fs = yield* FileSystemService
86
87
  const workbase = yield* WorkbaseService
88
+ const versionControl = yield* VersionControlService
87
89
  const invocationCwd = resolve(cwd)
88
90
  const context = yield* contexts
89
91
  .get({ cwd: invocationCwd, target: ".", compact: true })
@@ -94,6 +96,7 @@ export const pr = (args: readonly string[], cwd: string = process.cwd()) =>
94
96
  : null
95
97
  const focusedCwd = writableCheckout ?? invocationCwd
96
98
  let forwardedArgs = args
99
+ let environment: Record<string, string> = {}
97
100
  if (
98
101
  writableCheckout &&
99
102
  context?.workbase.vcs === "jj" &&
@@ -111,10 +114,13 @@ export const pr = (args: readonly string[], cwd: string = process.cwd()) =>
111
114
  repositoryFromRemote(remote),
112
115
  )
113
116
  }
117
+ const backend = yield* versionControl.forWorkbase(context.workbase.root)
118
+ environment = yield* backend.gitEnvironment(writableCheckout)
114
119
  }
115
120
  const result = yield* fs.runCommand(["gh", "pr", ...forwardedArgs], {
116
121
  cwd: focusedCwd,
117
122
  passthrough: true,
123
+ env: environment,
118
124
  })
119
125
  return result.exitCode
120
126
  })
@@ -9,6 +9,7 @@ import {
9
9
  createTempDir,
10
10
  runTestEffect,
11
11
  } from "../test-utils"
12
+ import type { Progress } from "../utils/progress"
12
13
  import { sync } from "./sync"
13
14
 
14
15
  const git = async (args: string[], cwd: string) => {
@@ -97,4 +98,30 @@ describe("sync command", () => {
97
98
  ],
98
99
  })
99
100
  })
101
+
102
+ test("reports human-readable progress without polluting JSON output", async () => {
103
+ const updates: string[] = []
104
+ const progress: Progress = {
105
+ start: (message) => updates.push(`start:${message}`),
106
+ succeed: (message) => updates.push(`succeed:${message}`),
107
+ fail: (message) => updates.push(`fail:${message}`),
108
+ }
109
+
110
+ await captureLogs(() =>
111
+ runTestEffect(sync({ cwd: root, silent: false }, progress)),
112
+ )
113
+ expect(updates).toEqual([
114
+ "start:Validating workbase",
115
+ "start:Inspected 1 repositories",
116
+ "start:Queried pull requests 1/1 (task:example)",
117
+ "start:Reconciled execution units 1/1 (task:example)",
118
+ "succeed:Synchronized 1 execution units",
119
+ ])
120
+
121
+ updates.length = 0
122
+ await captureLogs(() =>
123
+ runTestEffect(sync({ cwd: root, dryRun: true, json: true }, progress)),
124
+ )
125
+ expect(updates).toEqual([])
126
+ })
100
127
  })
@@ -2,19 +2,54 @@ import { Effect } from "effect"
2
2
  import { SyncService } from "../services/SyncService"
3
3
  import type { BaseCommandOptions } from "../utils/command"
4
4
  import { createLoggers } from "../utils/effect"
5
+ import { createProgress, type Progress } from "../utils/progress"
5
6
 
6
7
  interface SyncCommandOptions extends BaseCommandOptions {
7
8
  readonly dryRun?: boolean
8
9
  }
9
10
 
10
- export const sync = (options: SyncCommandOptions = {}) =>
11
+ export const sync = (
12
+ options: SyncCommandOptions = {},
13
+ progress: Progress = createProgress({
14
+ silent: options.silent || options.json,
15
+ }),
16
+ ) =>
11
17
  Effect.gen(function* () {
12
18
  const service = yield* SyncService
13
19
  const { log } = createLoggers(options)
14
- const result = yield* service.reconcile({
15
- cwd: options.cwd,
16
- apply: options.dryRun !== true,
17
- })
20
+ const showProgress = !options.silent && !options.json
21
+ if (showProgress) progress.start("Validating workbase")
22
+ const result = yield* service
23
+ .reconcile({
24
+ cwd: options.cwd,
25
+ apply: options.dryRun !== true,
26
+ onProgress: showProgress
27
+ ? ({ stage, current, total, target }) => {
28
+ if (stage === "repositories") {
29
+ progress.start(`Inspected ${total} repositories`)
30
+ } else if (stage === "pull-requests") {
31
+ progress.start(
32
+ `Queried pull requests ${current}/${total}${target ? ` (${target})` : ""}`,
33
+ )
34
+ } else {
35
+ progress.start(
36
+ `Reconciled execution units ${current}/${total}${target ? ` (${target})` : ""}`,
37
+ )
38
+ }
39
+ }
40
+ : undefined,
41
+ })
42
+ .pipe(
43
+ Effect.tapError(() =>
44
+ Effect.sync(() => {
45
+ if (showProgress) progress.fail("Workbase sync failed")
46
+ }),
47
+ ),
48
+ )
49
+ if (showProgress)
50
+ progress.succeed(
51
+ `Synchronized ${result.executions.length} execution units`,
52
+ )
18
53
  if (options.json) return log(JSON.stringify(result, null, 2))
19
54
 
20
55
  for (const action of result.repositories.actions) {
@@ -973,17 +973,34 @@ export class ContextService extends Effect.Service<ContextService>()(
973
973
  ...(yield* inspectCheckout(repositoryPath, checkoutPath)),
974
974
  })
975
975
  }
976
- const reviewSourceCommit = reviewData
977
- ? yield* runGit(fs, join(root, "repos", reviewData.repo), [
978
- "ls-remote",
979
- "origin",
980
- reviewData.source.kind === "pull-request"
981
- ? reviewData.source.fetchRef
982
- : reviewData.source.ref
983
- .replace(/^refs\/remotes\/origin\//, "")
984
- .replace(/^origin\//, ""),
985
- ])
976
+ const reviewRepository = reviewData
977
+ ? repositories.get(reviewData.repo)
986
978
  : null
979
+ const reviewSourceCommit =
980
+ reviewData && reviewRepository?.remote
981
+ ? yield* fs
982
+ .runCommand(
983
+ [
984
+ "git",
985
+ "ls-remote",
986
+ reviewRepository.remote,
987
+ reviewData.source.kind === "pull-request"
988
+ ? reviewData.source.fetchRef
989
+ : reviewData.source.ref
990
+ .replace(/^refs\/remotes\/origin\//, "")
991
+ .replace(/^origin\//, ""),
992
+ ],
993
+ { captureOutput: true },
994
+ )
995
+ .pipe(
996
+ Effect.map((result) =>
997
+ result.exitCode === 0
998
+ ? result.stdout.trim() || null
999
+ : null,
1000
+ ),
1001
+ Effect.catchAll(() => Effect.succeed(null)),
1002
+ )
1003
+ : null
987
1004
 
988
1005
  const checkoutStates = [writable, ...referenceCheckouts].filter(
989
1006
  (value): value is NonNullable<typeof value> => value !== null,
@@ -396,17 +396,12 @@ export class DoctorService extends Effect.Service<DoctorService>()(
396
396
  for (const source of reviewSources.filter(
397
397
  (item) => item.repo === repository.alias,
398
398
  )) {
399
- const observed = yield* fs.runCommand(
400
- [
401
- "git",
402
- "-C",
403
- repository.path,
404
- "ls-remote",
405
- "origin",
406
- source.ref,
407
- ],
408
- { captureOutput: true },
409
- )
399
+ const observed = repository.remote
400
+ ? yield* fs.runCommand(
401
+ ["git", "ls-remote", repository.remote, source.ref],
402
+ { captureOutput: true },
403
+ )
404
+ : { exitCode: -1, stdout: "", stderr: "origin unavailable" }
410
405
  const available =
411
406
  observed.exitCode === 0 && Boolean(observed.stdout.trim())
412
407
  add({
@@ -33,6 +33,10 @@ import {
33
33
  type WorkStatus,
34
34
  } from "../workbase/schemas"
35
35
  import { FileSystemService } from "./FileSystemService"
36
+ import {
37
+ VersionControlService,
38
+ type VersionControlBackend,
39
+ } from "./VersionControlService"
36
40
  import { WorkbaseService, type ValidationReport } from "./WorkbaseService"
37
41
 
38
42
  class GraphError extends Data.TaggedError("GraphError")<{
@@ -68,6 +72,7 @@ export interface GraphOptions {
68
72
  readonly repositories?: readonly string[]
69
73
  readonly kinds?: readonly GraphNodeKind[]
70
74
  readonly include?: readonly GraphInclude[]
75
+ readonly backend?: VersionControlBackend
71
76
  }
72
77
 
73
78
  const epicNodeId = (id: string) => `epic:${id}`
@@ -127,6 +132,11 @@ export class GraphService extends Effect.Service<GraphService>()(
127
132
  const fs = yield* FileSystemService
128
133
  const workbase = yield* WorkbaseService
129
134
  const { root, config } = yield* workbase.loadConfig(options.cwd)
135
+ const backend =
136
+ options.backend ??
137
+ (yield* VersionControlService.pipe(
138
+ Effect.flatMap((service) => service.forWorkbase(root)),
139
+ ))
130
140
  const includes = [...new Set(options.include ?? [])].sort()
131
141
  const include = new Set(includes)
132
142
  const epics = new Map<string, Document<EpicData>>()
@@ -623,38 +633,20 @@ export class GraphService extends Effect.Service<GraphService>()(
623
633
 
624
634
  const inspectGit = (path: string) =>
625
635
  Effect.gen(function* () {
626
- const bare = yield* run(fs, [
627
- "git",
628
- "-C",
629
- path,
630
- "rev-parse",
631
- "--is-bare-repository",
632
- ])
636
+ const inspection = yield* backend.inspectRepository(path)
637
+ const workspaces = inspection
638
+ ? yield* backend
639
+ .listWorkspaces(path)
640
+ .pipe(Effect.catchAll(() => Effect.succeed([])))
641
+ : []
642
+ const primary = workspaces.find(
643
+ (workspace) => workspace.path === path,
644
+ )
633
645
  return {
634
- kind:
635
- bare === null
636
- ? null
637
- : bare === "true"
638
- ? "bare"
639
- : "repository",
640
- remote: yield* run(fs, [
641
- "git",
642
- "-C",
643
- path,
644
- "remote",
645
- "get-url",
646
- "origin",
647
- ]),
648
- head: yield* run(fs, ["git", "-C", path, "rev-parse", "HEAD"]),
649
- branch: yield* run(fs, [
650
- "git",
651
- "-C",
652
- path,
653
- "symbolic-ref",
654
- "--quiet",
655
- "--short",
656
- "HEAD",
657
- ]),
646
+ kind: inspection?.kind ?? null,
647
+ remote: inspection?.remote ?? null,
648
+ head: inspection ? yield* backend.workspaceHead(path) : null,
649
+ branch: primary?.branch?.replace(/^refs\/heads\//, "") ?? null,
658
650
  } satisfies GraphRepositoryGit
659
651
  })
660
652
 
@@ -678,6 +670,27 @@ export class GraphService extends Effect.Service<GraphService>()(
678
670
  }
679
671
  }
680
672
  if (include.has("git")) {
673
+ const remote = yield* backend.remoteUrl(
674
+ repositoryPath,
675
+ "origin",
676
+ )
677
+ const canonicalCheckoutPath = materialized
678
+ ? yield* fs.realPath(checkoutPath)
679
+ : checkoutPath
680
+ const actualBranch = materialized
681
+ ? yield* backend.listWorkspaces(repositoryPath).pipe(
682
+ Effect.map(
683
+ (workspaces) =>
684
+ workspaces
685
+ .find(
686
+ (workspace) =>
687
+ workspace.path === canonicalCheckoutPath,
688
+ )
689
+ ?.branch?.replace(/^refs\/heads\//, "") ?? null,
690
+ ),
691
+ Effect.catchAll(() => Effect.succeed(null)),
692
+ )
693
+ : null
681
694
  result.git =
682
695
  "review" in data
683
696
  ? {
@@ -686,10 +699,8 @@ export class GraphService extends Effect.Service<GraphService>()(
686
699
  value?.split(/\s+/)[0] ?? null)(
687
700
  yield* run(fs, [
688
701
  "git",
689
- "-C",
690
- repositoryPath,
691
702
  "ls-remote",
692
- "origin",
703
+ remote ?? "origin",
693
704
  data.review.source.kind === "pull-request"
694
705
  ? data.review.source.fetchRef
695
706
  : data.review.source.ref
@@ -698,86 +709,33 @@ export class GraphService extends Effect.Service<GraphService>()(
698
709
  ]),
699
710
  ),
700
711
  checkoutCommit: materialized
701
- ? yield* run(fs, [
702
- "git",
703
- "-C",
704
- checkoutPath,
705
- "rev-parse",
706
- "HEAD",
707
- ])
708
- : null,
709
- checkoutBranch: materialized
710
- ? yield* run(fs, [
711
- "git",
712
- "-C",
713
- checkoutPath,
714
- "symbolic-ref",
715
- "--quiet",
716
- "--short",
717
- "HEAD",
718
- ])
712
+ ? yield* backend.workspaceHead(checkoutPath)
719
713
  : null,
714
+ checkoutBranch: null,
720
715
  dirty: materialized
721
- ? ((status) =>
722
- status === null ? null : status.length > 0)(
723
- yield* runText(fs, [
724
- "git",
725
- "-C",
726
- checkoutPath,
727
- "status",
728
- "--porcelain",
729
- ]),
730
- )
716
+ ? yield* backend.workspaceDirty(checkoutPath)
731
717
  : null,
732
718
  }
733
719
  : {
734
720
  branch: data.branch,
735
721
  base: data.base,
736
- branchCommit: yield* run(fs, [
737
- "git",
738
- "-C",
722
+ branchCommit: yield* backend.resolveRevision(
739
723
  repositoryPath,
740
- "rev-parse",
741
- `${data.branch}^{commit}`,
742
- ]),
743
- baseCommit: yield* run(fs, [
744
- "git",
745
- "-C",
724
+ data.branch,
725
+ ),
726
+ baseCommit: yield* backend.resolveRevision(
746
727
  repositoryPath,
747
- "rev-parse",
748
- `${data.base}^{commit}`,
749
- ]),
728
+ data.base,
729
+ ),
750
730
  checkoutCommit: materialized
751
- ? yield* run(fs, [
752
- "git",
753
- "-C",
754
- checkoutPath,
755
- "rev-parse",
756
- "HEAD",
757
- ])
758
- : null,
759
- checkoutBranch: materialized
760
- ? yield* run(fs, [
761
- "git",
762
- "-C",
763
- checkoutPath,
764
- "symbolic-ref",
765
- "--quiet",
766
- "--short",
767
- "HEAD",
768
- ])
731
+ ? yield* backend.workspaceHead(checkoutPath)
769
732
  : null,
733
+ checkoutBranch:
734
+ backend.kind === "jj" && materialized
735
+ ? data.branch
736
+ : actualBranch,
770
737
  dirty: materialized
771
- ? ((status) =>
772
- status === null ? null : status.length > 0)(
773
- yield* runText(fs, [
774
- "git",
775
- "-C",
776
- checkoutPath,
777
- "status",
778
- "--porcelain",
779
- ]),
780
- )
738
+ ? yield* backend.workspaceDirty(checkoutPath)
781
739
  : null,
782
740
  }
783
741
  }
@@ -97,7 +97,7 @@ describe("PullRequestService", () => {
97
97
  await Bun.write(
98
98
  path,
99
99
  `#!/usr/bin/env bun
100
- await Bun.write(${JSON.stringify(ghCallPath)}, JSON.stringify({ args: Bun.argv.slice(2), cwd: process.cwd() }))
100
+ 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
101
  process.stdout.write(${JSON.stringify(stdout)})
102
102
  process.stderr.write(${JSON.stringify(stderr)})
103
103
  process.exit(${exitCode})
@@ -110,6 +110,7 @@ process.exit(${exitCode})
110
110
  (await Bun.file(ghCallPath).json()) as {
111
111
  args: string[]
112
112
  cwd: string
113
+ gitDir?: string
113
114
  }
114
115
 
115
116
  const expectRemoteBranch = async (branch: string, exists = true) => {
@@ -241,6 +242,49 @@ process.exit(${exitCode})
241
242
  expect(updated.endsWith(`${body}\n`)).toBe(true)
242
243
  })
243
244
 
245
+ test("creates a PR from a non-colocated jj workspace with backing Git context", async () => {
246
+ if (!Bun.which("jj")) return
247
+ await rm(join(root, "repos/agency"), { recursive: true, force: true })
248
+ await requireCommand([
249
+ "jj",
250
+ "git",
251
+ "clone",
252
+ "--no-colocate",
253
+ remotePath,
254
+ join(root, "repos/agency"),
255
+ ])
256
+ await requireCommand(
257
+ ["jj", "config", "set", "--repo", "user.name", "Agency Test"],
258
+ join(root, "repos/agency"),
259
+ )
260
+ await requireCommand(
261
+ ["jj", "config", "set", "--repo", "user.email", "agency@example.com"],
262
+ join(root, "repos/agency"),
263
+ )
264
+ await Bun.write(
265
+ join(root, "agency.json"),
266
+ JSON.stringify({ version: 2, vcs: "jj" }),
267
+ )
268
+ await createTask("jj-example", "task/jj-example")
269
+ const workspace = await materialize("jj-example")
270
+ await Bun.write(join(workspace.writablePath!, "CHANGE.md"), "jj change\n")
271
+ await requireCommand(
272
+ ["jj", "describe", "-m", "feat: add jj example"],
273
+ workspace.writablePath!,
274
+ )
275
+ await requireCommand(["jj", "new"], workspace.writablePath!)
276
+ const url = "https://github.com/example/agency/pull/44"
277
+ await writeFakeGh({ stdout: `${url}\n` })
278
+
279
+ expect(await createPullRequest("jj-example")).toBe(url)
280
+ await expectRemoteBranch("task/jj-example")
281
+ const ghCall = await readGhCall()
282
+ expect(ghCall.args).toContain("--head")
283
+ expect(ghCall.args).toContain("--repo")
284
+ expect(ghCall.gitDir).toContain(".jj")
285
+ expect(await Bun.file(join(root, "repos/agency/.git")).exists()).toBe(false)
286
+ })
287
+
244
288
  test("passes --draft to gh", async () => {
245
289
  await createTask()
246
290
  await writeFakeGh({
@@ -172,6 +172,19 @@ export class PullRequestService extends Effect.Service<PullRequestService>()(
172
172
  }
173
173
 
174
174
  yield* backend.push(workspace.writablePath, remote, execution.branch)
175
+ const defaults =
176
+ backend.kind === "jj"
177
+ ? yield* backend.pullRequestDefaults(
178
+ workspace.writablePath,
179
+ execution.base,
180
+ )
181
+ : null
182
+ if (backend.kind === "jj" && !defaults) {
183
+ return yield* new PullRequestError({
184
+ message:
185
+ "Failed to derive pull request title and body from jj commits",
186
+ })
187
+ }
175
188
 
176
189
  const remoteUrl = yield* backend.remoteUrl(
177
190
  workspace.writablePath,
@@ -198,11 +211,15 @@ export class PullRequestService extends Effect.Service<PullRequestService>()(
198
211
  repository,
199
212
  draft,
200
213
  vcs: config.vcs ?? "git",
214
+ ...(defaults ? { defaults } : {}),
201
215
  })
216
+ const gitEnvironment = config.delivery
217
+ ? {}
218
+ : yield* backend.gitEnvironment(workspace.writablePath)
202
219
  const created = yield* fs.runCommand(resolved.argv, {
203
220
  cwd: workspace.writablePath,
204
221
  captureOutput: true,
205
- env: resolved.environment,
222
+ env: { ...gitEnvironment, ...resolved.environment },
206
223
  })
207
224
  if (created.exitCode !== 0) {
208
225
  return yield* new PullRequestError({