@markjaquith/agency 2.56.1 → 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.
@@ -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
  })
@@ -12,11 +12,35 @@ export interface RegisteredWorkspace {
12
12
  readonly dirty?: boolean
13
13
  }
14
14
 
15
+ interface PullRequestDefaults {
16
+ readonly title: string
17
+ readonly body: string
18
+ }
19
+
20
+ interface RepositoryInspection {
21
+ readonly kind: "bare" | "repository"
22
+ readonly remote: string | null
23
+ }
24
+
15
25
  export interface VersionControlBackend {
16
26
  readonly kind: VersionControlKind
27
+ readonly cloneRepository: (
28
+ source: string,
29
+ destination: string,
30
+ ) => Effect.Effect<void, unknown, any>
17
31
  readonly initializeRepository: (
18
32
  path: string,
19
33
  ) => Effect.Effect<void, unknown, any>
34
+ readonly inspectRepository: (
35
+ path: string,
36
+ ) => Effect.Effect<RepositoryInspection | null, unknown, any>
37
+ readonly gitEnvironment: (
38
+ path: string,
39
+ ) => Effect.Effect<Record<string, string>, unknown, any>
40
+ readonly pullRequestDefaults: (
41
+ workspacePath: string,
42
+ base: string,
43
+ ) => Effect.Effect<PullRequestDefaults | null, unknown, any>
20
44
  readonly listWorkspaces: (
21
45
  repositoryPath: string,
22
46
  ) => Effect.Effect<readonly RegisteredWorkspace[], unknown, any>
@@ -59,6 +83,11 @@ export interface VersionControlBackend {
59
83
  repositoryPath: string,
60
84
  remote: string,
61
85
  ) => Effect.Effect<string | null, unknown, any>
86
+ readonly setRemoteUrl: (
87
+ repositoryPath: string,
88
+ remote: string,
89
+ url: string | null,
90
+ ) => Effect.Effect<void, unknown, any>
62
91
  }
63
92
 
64
93
  class VersionControlError extends Data.TaggedError("VersionControlError")<{
@@ -122,7 +151,43 @@ export class GitVersionControlService extends Effect.Service<GitVersionControlSe
122
151
  sync: () =>
123
152
  ({
124
153
  kind: "git",
154
+ cloneRepository: (source, destination) =>
155
+ Effect.gen(function* () {
156
+ const fs = yield* FileSystemService
157
+ yield* requireSuccess(
158
+ "Failed to clone Git repository",
159
+ fs.runCommand(
160
+ ["git", "clone", "--bare", "--", source, destination],
161
+ {
162
+ captureOutput: true,
163
+ },
164
+ ),
165
+ )
166
+ }),
125
167
  initializeRepository: () => Effect.void,
168
+ inspectRepository: (path) =>
169
+ Effect.gen(function* () {
170
+ const fs = yield* FileSystemService
171
+ const valid = yield* fs.runCommand(
172
+ ["git", "-C", path, "rev-parse", "--git-dir"],
173
+ { captureOutput: true },
174
+ )
175
+ if (valid.exitCode !== 0) return null
176
+ const bare = yield* fs.runCommand(
177
+ ["git", "-C", path, "rev-parse", "--is-bare-repository"],
178
+ { captureOutput: true },
179
+ )
180
+ const remote = yield* fs.runCommand(
181
+ ["git", "-C", path, "remote", "get-url", "origin"],
182
+ { captureOutput: true },
183
+ )
184
+ return {
185
+ kind: bare.stdout.trim() === "true" ? "bare" : "repository",
186
+ remote: remote.exitCode === 0 ? remote.stdout.trim() : null,
187
+ } as const
188
+ }),
189
+ gitEnvironment: () => Effect.succeed({}),
190
+ pullRequestDefaults: () => Effect.succeed(null),
126
191
  listWorkspaces: (repositoryPath) =>
127
192
  Effect.gen(function* () {
128
193
  const fs = yield* FileSystemService
@@ -272,6 +337,30 @@ export class GitVersionControlService extends Effect.Service<GitVersionControlSe
272
337
  )
273
338
  return result.exitCode === 0 ? result.stdout.trim() : null
274
339
  }),
340
+ setRemoteUrl: (repositoryPath, remote, url) =>
341
+ Effect.gen(function* () {
342
+ const fs = yield* FileSystemService
343
+ const previous = yield* fs.runCommand(
344
+ ["git", "-C", repositoryPath, "remote", "get-url", remote],
345
+ { captureOutput: true },
346
+ )
347
+ const command =
348
+ url === null
349
+ ? ["git", "-C", repositoryPath, "remote", "remove", remote]
350
+ : [
351
+ "git",
352
+ "-C",
353
+ repositoryPath,
354
+ "remote",
355
+ previous.exitCode === 0 ? "set-url" : "add",
356
+ remote,
357
+ url,
358
+ ]
359
+ yield* requireSuccess(
360
+ `Failed to update Git remote '${remote}'`,
361
+ fs.runCommand(command, { captureOutput: true }),
362
+ )
363
+ }),
275
364
  }) satisfies VersionControlBackend,
276
365
  },
277
366
  ) {}
@@ -290,6 +379,25 @@ export class JjVersionControlService extends Effect.Service<JjVersionControlServ
290
379
  sync: () =>
291
380
  ({
292
381
  kind: "jj",
382
+ cloneRepository: (source, destination) =>
383
+ Effect.gen(function* () {
384
+ const fs = yield* FileSystemService
385
+ yield* requireSuccess(
386
+ "Failed to clone jj repository",
387
+ fs.runCommand(
388
+ [
389
+ "jj",
390
+ "git",
391
+ "clone",
392
+ "--no-colocate",
393
+ "--",
394
+ source,
395
+ destination,
396
+ ],
397
+ { captureOutput: true },
398
+ ),
399
+ )
400
+ }),
293
401
  initializeRepository: (path) =>
294
402
  Effect.gen(function* () {
295
403
  const fs = yield* FileSystemService
@@ -301,6 +409,72 @@ export class JjVersionControlService extends Effect.Service<JjVersionControlServ
301
409
  }),
302
410
  )
303
411
  }),
412
+ inspectRepository: (path) =>
413
+ Effect.gen(function* () {
414
+ const fs = yield* FileSystemService
415
+ const root = yield* fs.runCommand(jjCommand(path, ["root"]), {
416
+ captureOutput: true,
417
+ })
418
+ if (root.exitCode !== 0) return null
419
+ const remote = yield* fs.runCommand(
420
+ jjCommand(path, ["git", "remote", "list"]),
421
+ { captureOutput: true },
422
+ )
423
+ return {
424
+ kind: "repository",
425
+ remote:
426
+ remote.exitCode === 0
427
+ ? (remote.stdout
428
+ .split("\n")
429
+ .find((line) => line.startsWith("origin "))
430
+ ?.slice("origin ".length)
431
+ .trim() ?? null)
432
+ : null,
433
+ } as const
434
+ }),
435
+ gitEnvironment: (path) =>
436
+ Effect.gen(function* () {
437
+ const fs = yield* FileSystemService
438
+ const result = yield* requireSuccess(
439
+ "Failed to locate jj backing Git repository",
440
+ fs.runCommand(jjCommand(path, ["git", "root"]), {
441
+ captureOutput: true,
442
+ }),
443
+ )
444
+ return { GIT_DIR: result.stdout.trim() }
445
+ }),
446
+ pullRequestDefaults: (workspacePath, base) =>
447
+ Effect.gen(function* () {
448
+ const fs = yield* FileSystemService
449
+ const result = yield* fs.runCommand(
450
+ jjCommand(workspacePath, [
451
+ "log",
452
+ "--ignore-working-copy",
453
+ "--no-graph",
454
+ "-r",
455
+ `${base}..@-`,
456
+ "-T",
457
+ 'description.first_line() ++ "\\n"',
458
+ ]),
459
+ { captureOutput: true },
460
+ )
461
+ if (result.exitCode !== 0) return null
462
+ const commits = result.stdout
463
+ .split("\n")
464
+ .map((line) => line.trim())
465
+ .filter(Boolean)
466
+ if (commits.length === 0) return null
467
+ return {
468
+ title: commits.at(-1)!,
469
+ body:
470
+ commits.length === 1
471
+ ? ""
472
+ : `Commits:\n${commits
473
+ .toReversed()
474
+ .map((commit) => `- ${commit}`)
475
+ .join("\n")}`,
476
+ } satisfies PullRequestDefaults
477
+ }),
304
478
  listWorkspaces: (repositoryPath) =>
305
479
  Effect.gen(function* () {
306
480
  const fs = yield* FileSystemService
@@ -456,6 +630,20 @@ export class JjVersionControlService extends Effect.Service<JjVersionControlServ
456
630
  push: (workspacePath, remote, branch) =>
457
631
  Effect.gen(function* () {
458
632
  const fs = yield* FileSystemService
633
+ yield* requireSuccess(
634
+ "Failed to set jj delivery bookmark",
635
+ fs.runCommand(
636
+ jjCommand(workspacePath, [
637
+ "bookmark",
638
+ "set",
639
+ "--allow-backwards",
640
+ branch,
641
+ "-r",
642
+ "@-",
643
+ ]),
644
+ { captureOutput: true },
645
+ ),
646
+ )
459
647
  yield* requireSuccess(
460
648
  "Failed to push branch",
461
649
  fs.runCommand(
@@ -464,8 +652,8 @@ export class JjVersionControlService extends Effect.Service<JjVersionControlServ
464
652
  "push",
465
653
  "--remote",
466
654
  remote,
467
- "--named",
468
- `${branch}=@-`,
655
+ "--bookmark",
656
+ branch,
469
657
  ]),
470
658
  { captureOutput: true },
471
659
  ),
@@ -487,6 +675,27 @@ export class JjVersionControlService extends Effect.Service<JjVersionControlServ
487
675
  .trim() ?? null
488
676
  )
489
677
  }),
678
+ setRemoteUrl: (repositoryPath, remote, url) =>
679
+ Effect.gen(function* () {
680
+ const fs = yield* FileSystemService
681
+ const previous = yield* fs.runCommand(
682
+ jjCommand(repositoryPath, ["git", "remote", "list"]),
683
+ { captureOutput: true },
684
+ )
685
+ const exists = previous.stdout
686
+ .split("\n")
687
+ .some((line) => line.startsWith(`${remote} `))
688
+ const args =
689
+ url === null
690
+ ? ["git", "remote", "remove", remote]
691
+ : ["git", "remote", exists ? "set-url" : "add", remote, url]
692
+ yield* requireSuccess(
693
+ `Failed to update jj Git remote '${remote}'`,
694
+ fs.runCommand(jjCommand(repositoryPath, args), {
695
+ captureOutput: true,
696
+ }),
697
+ )
698
+ }),
490
699
  }) satisfies VersionControlBackend,
491
700
  },
492
701
  ) {}
@@ -132,19 +132,14 @@ const inspectRepository = async (root: string, alias: string) => {
132
132
  return null
133
133
  }
134
134
  if (!stats.isDirectory() && !stats.isSymbolicLink()) return null
135
- const [git, bare] = await Promise.all([
136
- run(["git", "-C", path, "rev-parse", "--git-dir"]),
137
- run(["git", "-C", path, "rev-parse", "--is-bare-repository"]),
138
- ])
139
- if (git.exitCode !== 0 || bare.exitCode !== 0) return null
135
+ const jj = await run(["jj", "-R", path, "root"])
136
+ if (jj.exitCode !== 0) return null
140
137
  return {
141
138
  alias,
142
139
  path,
143
140
  kind: stats.isSymbolicLink()
144
141
  ? ("symlink" as const)
145
- : bare.stdout === "true"
146
- ? ("bare" as const)
147
- : ("repository" as const),
142
+ : ("repository" as const),
148
143
  initialized: await directoryExists(join(path, ".jj")),
149
144
  }
150
145
  }
@@ -39,12 +39,21 @@ describe("delivery commands", () => {
39
39
  repository: "example/agency",
40
40
  draft: false,
41
41
  } as const
42
- expect(resolveGitHubCreateCommand({ ...input, vcs: "jj" })).toEqual({
42
+ expect(
43
+ resolveGitHubCreateCommand({
44
+ ...input,
45
+ vcs: "jj",
46
+ defaults: { title: "Add example", body: "Details" },
47
+ }),
48
+ ).toEqual({
43
49
  argv: [
44
50
  "gh",
45
51
  "pr",
46
52
  "create",
47
- "--fill",
53
+ "--title",
54
+ "Add example",
55
+ "--body",
56
+ "Details",
48
57
  "--base",
49
58
  "main",
50
59
  "--head",
@@ -24,18 +24,22 @@ export const resolveGitHubCreateCommand = ({
24
24
  repository,
25
25
  draft,
26
26
  vcs,
27
+ defaults,
27
28
  }: {
28
29
  readonly base: string
29
30
  readonly branch: string
30
31
  readonly repository: string
31
32
  readonly draft: boolean
32
33
  readonly vcs: "git" | "jj"
34
+ readonly defaults?: { readonly title: string; readonly body: string }
33
35
  }) => ({
34
36
  argv: [
35
37
  "gh",
36
38
  "pr",
37
39
  "create",
38
- "--fill",
40
+ ...(defaults
41
+ ? ["--title", defaults.title, "--body", defaults.body]
42
+ : ["--fill"]),
39
43
  "--base",
40
44
  base,
41
45
  ...(vcs === "jj" ? ["--head", branch, "--repo", repository] : []),