@markjaquith/agency 2.64.0 → 2.65.1
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 +51 -2
- package/package.json +1 -1
- package/src/services/ArchiveBulkService.test.ts +99 -0
- package/src/services/DoctorService.ts +9 -0
- package/src/services/WorkbaseService.test.ts +20 -0
- package/src/services/WorkbaseService.ts +16 -0
- package/src/services/WorktreeService.test.ts +192 -0
- package/src/services/WorktreeService.ts +120 -23
- package/src/workbase/schemas.test.ts +19 -0
- package/src/workbase/schemas.ts +1 -0
- package/src/workbase/workspace-command.test.ts +70 -0
- package/src/workbase/workspace-command.ts +63 -0
package/README.md
CHANGED
|
@@ -246,6 +246,55 @@ Supplemental read-only repositories remain detached Git worktrees at their
|
|
|
246
246
|
declared refs so they do not acquire writable branches. Jj workbases always use
|
|
247
247
|
jj workspaces and ignore this Git-specific customization.
|
|
248
248
|
|
|
249
|
+
### Custom Jj Workspace Command
|
|
250
|
+
|
|
251
|
+
Jj workbases normally create managed workspaces with `jj workspace add`. Set
|
|
252
|
+
`workspaceCreateCommand` to an argv template when another tool should create or
|
|
253
|
+
adopt a prepared workspace directly at Agency's destination:
|
|
254
|
+
|
|
255
|
+
```json
|
|
256
|
+
{
|
|
257
|
+
"version": 2,
|
|
258
|
+
"vcs": "jj",
|
|
259
|
+
"workspaceCreateCommand": [
|
|
260
|
+
"my-prewarm-tool",
|
|
261
|
+
"adopt",
|
|
262
|
+
"--repo",
|
|
263
|
+
"{repo}",
|
|
264
|
+
"--destination",
|
|
265
|
+
"{workspace}",
|
|
266
|
+
"--name",
|
|
267
|
+
"{name}",
|
|
268
|
+
"--revision",
|
|
269
|
+
"{revision}"
|
|
270
|
+
]
|
|
271
|
+
}
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
Available placeholders are:
|
|
275
|
+
|
|
276
|
+
- `{repo}`: absolute repository alias path under `repos/`
|
|
277
|
+
- `{workspace}`: absolute managed workspace path Agency requires
|
|
278
|
+
- `{name}`: unique jj workspace name Agency requires
|
|
279
|
+
- `{revision}`: exact commit the new working copy must be based on
|
|
280
|
+
- `{kind}`: `writable` or `reference`
|
|
281
|
+
- `{requestedRef}`: configured branch, reference, or review commit
|
|
282
|
+
|
|
283
|
+
`{repo}`, `{workspace}`, `{name}`, and `{revision}` are required. Agency invokes
|
|
284
|
+
the command directly without a shell and sets matching `AGENCY_REPO`,
|
|
285
|
+
`AGENCY_WORKSPACE`, `AGENCY_WORKSPACE_NAME`, `AGENCY_REVISION`,
|
|
286
|
+
`AGENCY_CHECKOUT_KIND`, and `AGENCY_REQUESTED_REF` environment variables. The
|
|
287
|
+
command applies to each new jj checkout and must leave `{workspace}` registered
|
|
288
|
+
under `{name}` with its working-copy parent at `{revision}`. This lets a prewarm
|
|
289
|
+
tool move or adopt a prepared workspace without first paying for Agency's normal
|
|
290
|
+
full checkout.
|
|
291
|
+
|
|
292
|
+
Agency validates the registration, name, path, and revision before running any
|
|
293
|
+
`postCheckoutCommand`. A failed command or validation removes a partially
|
|
294
|
+
created workspace when possible and otherwise reports manual recovery. Resume
|
|
295
|
+
restoration continues to use Agency's built-in exact-target recovery path.
|
|
296
|
+
Git workbases ignore this jj-specific customization.
|
|
297
|
+
|
|
249
298
|
### Post-checkout Commands
|
|
250
299
|
|
|
251
300
|
Each repository declaration may provide a VCS-neutral `postCheckoutCommand` argv
|
|
@@ -267,8 +316,8 @@ shell, with the new checkout as its working directory:
|
|
|
267
316
|
The hook runs for each newly created managed checkout, including writable and
|
|
268
317
|
reference checkouts, after Git worktree or jj workspace creation has completed
|
|
269
318
|
and Agency has validated the checkout. It does not run for a reused checkout or
|
|
270
|
-
for inspection-only commands. A custom `worktreeCreateCommand`
|
|
271
|
-
validated before this hook runs.
|
|
319
|
+
for inspection-only commands. A custom `worktreeCreateCommand` or
|
|
320
|
+
`workspaceCreateCommand` completes and is validated before this hook runs.
|
|
272
321
|
|
|
273
322
|
Available placeholders and matching environment variables are:
|
|
274
323
|
|
package/package.json
CHANGED
|
@@ -17,6 +17,13 @@ const git = (args: readonly string[], cwd?: string) => {
|
|
|
17
17
|
}
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
const jj = (args: readonly string[], cwd?: string) => {
|
|
21
|
+
const result = Bun.spawnSync(["jj", ...args], { cwd })
|
|
22
|
+
if (result.exitCode !== 0) {
|
|
23
|
+
throw new Error(new TextDecoder().decode(result.stderr))
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
20
27
|
describe("ArchiveService bulk task archive", () => {
|
|
21
28
|
let root: string
|
|
22
29
|
let source: string
|
|
@@ -436,6 +443,98 @@ claim:
|
|
|
436
443
|
).toBe(true)
|
|
437
444
|
})
|
|
438
445
|
|
|
446
|
+
test("archives a jj working-copy commit preserved by its task bookmark", async () => {
|
|
447
|
+
if (!Bun.which("jj")) return
|
|
448
|
+
const repository = join(root, "repos/agency")
|
|
449
|
+
await rm(repository, { recursive: true, force: true })
|
|
450
|
+
git(["clone", source, repository])
|
|
451
|
+
jj(["git", "init", "--colocate", repository])
|
|
452
|
+
await Bun.write(
|
|
453
|
+
join(root, "agency.json"),
|
|
454
|
+
JSON.stringify({ version: 2, vcs: "jj" }),
|
|
455
|
+
)
|
|
456
|
+
await createTask("jj-bookmarked")
|
|
457
|
+
await dropTask("jj-bookmarked")
|
|
458
|
+
const workspace = await runTestEffect(
|
|
459
|
+
WorktreeService.pipe(
|
|
460
|
+
Effect.flatMap((service) =>
|
|
461
|
+
service.materialize("jj-bookmarked", undefined, root),
|
|
462
|
+
),
|
|
463
|
+
),
|
|
464
|
+
)
|
|
465
|
+
await Bun.write(join(workspace.writablePath!, "preserved.txt"), "keep\n")
|
|
466
|
+
jj(
|
|
467
|
+
["bookmark", "set", "task/jj-bookmarked", "-r", "@"],
|
|
468
|
+
workspace.writablePath!,
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
const preview = await archiveTasks(true)
|
|
472
|
+
expect(preview.tasks[0]).toMatchObject({
|
|
473
|
+
id: "jj-bookmarked",
|
|
474
|
+
disposition: "planned",
|
|
475
|
+
removedWorktrees: [workspace.writablePath!],
|
|
476
|
+
})
|
|
477
|
+
expect(
|
|
478
|
+
await Bun.file(join(workspace.writablePath!, "preserved.txt")).text(),
|
|
479
|
+
).toBe("keep\n")
|
|
480
|
+
|
|
481
|
+
const result = await archiveTasks()
|
|
482
|
+
expect(result.tasks[0]).toMatchObject({
|
|
483
|
+
id: "jj-bookmarked",
|
|
484
|
+
disposition: "archived",
|
|
485
|
+
})
|
|
486
|
+
expect(await Bun.file(workspace.writablePath!).exists()).toBe(false)
|
|
487
|
+
const preserved = Bun.spawnSync([
|
|
488
|
+
"jj",
|
|
489
|
+
"-R",
|
|
490
|
+
repository,
|
|
491
|
+
"file",
|
|
492
|
+
"show",
|
|
493
|
+
"-r",
|
|
494
|
+
"task/jj-bookmarked",
|
|
495
|
+
'root:"preserved.txt"',
|
|
496
|
+
])
|
|
497
|
+
if (preserved.exitCode !== 0) {
|
|
498
|
+
throw new Error(preserved.stderr.toString())
|
|
499
|
+
}
|
|
500
|
+
expect(preserved.stdout.toString()).toBe("keep\n")
|
|
501
|
+
})
|
|
502
|
+
|
|
503
|
+
test("archives a forgotten jj workspace preserved by its task bookmark", async () => {
|
|
504
|
+
if (!Bun.which("jj")) return
|
|
505
|
+
const repository = join(root, "repos/agency")
|
|
506
|
+
await rm(repository, { recursive: true, force: true })
|
|
507
|
+
git(["clone", source, repository])
|
|
508
|
+
jj(["git", "init", "--colocate", repository])
|
|
509
|
+
await Bun.write(
|
|
510
|
+
join(root, "agency.json"),
|
|
511
|
+
JSON.stringify({ version: 2, vcs: "jj" }),
|
|
512
|
+
)
|
|
513
|
+
await createTask("jj-stale")
|
|
514
|
+
await dropTask("jj-stale")
|
|
515
|
+
const workspace = await runTestEffect(
|
|
516
|
+
WorktreeService.pipe(
|
|
517
|
+
Effect.flatMap((service) =>
|
|
518
|
+
service.materialize("jj-stale", undefined, root),
|
|
519
|
+
),
|
|
520
|
+
),
|
|
521
|
+
)
|
|
522
|
+
await Bun.write(join(workspace.writablePath!, "preserved.txt"), "keep\n")
|
|
523
|
+
jj(["bookmark", "set", "task/jj-stale", "-r", "@"], workspace.writablePath!)
|
|
524
|
+
jj(["-R", repository, "workspace", "forget", "agency-jj-stale-task-agency"])
|
|
525
|
+
|
|
526
|
+
const result = await archiveTasks(true)
|
|
527
|
+
|
|
528
|
+
expect(result.tasks[0]).toMatchObject({
|
|
529
|
+
id: "jj-stale",
|
|
530
|
+
disposition: "planned",
|
|
531
|
+
removedWorktrees: [workspace.writablePath!],
|
|
532
|
+
})
|
|
533
|
+
expect(
|
|
534
|
+
await Bun.file(join(workspace.writablePath!, "preserved.txt")).text(),
|
|
535
|
+
).toBe("keep\n")
|
|
536
|
+
})
|
|
537
|
+
|
|
439
538
|
test("rolls back the entire cohort when application fails", async () => {
|
|
440
539
|
await runTestEffect(
|
|
441
540
|
EpicService.pipe(
|
|
@@ -173,6 +173,15 @@ export class DoctorService extends Effect.Service<DoctorService>()(
|
|
|
173
173
|
] as const,
|
|
174
174
|
]
|
|
175
175
|
: []),
|
|
176
|
+
...(config.workspaceCreateCommand
|
|
177
|
+
? [
|
|
178
|
+
[
|
|
179
|
+
"integration.workspace-create",
|
|
180
|
+
config.workspaceCreateCommand,
|
|
181
|
+
"Workspace creator",
|
|
182
|
+
] as const,
|
|
183
|
+
]
|
|
184
|
+
: []),
|
|
176
185
|
...Object.entries(config.repositories ?? {}).flatMap(
|
|
177
186
|
([alias, repository]) =>
|
|
178
187
|
repository.postCheckoutCommand
|
|
@@ -371,6 +371,26 @@ status: done
|
|
|
371
371
|
).rejects.toThrow("{worktree}")
|
|
372
372
|
})
|
|
373
373
|
|
|
374
|
+
test("rejects an invalid workspace command template", async () => {
|
|
375
|
+
await write(
|
|
376
|
+
root,
|
|
377
|
+
"agency.json",
|
|
378
|
+
JSON.stringify({
|
|
379
|
+
version: 2,
|
|
380
|
+
vcs: "jj",
|
|
381
|
+
workspaceCreateCommand: ["tool", "{repo}", "{workspace}", "{name}"],
|
|
382
|
+
}),
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
await expect(
|
|
386
|
+
runTestEffect(
|
|
387
|
+
WorkbaseService.pipe(
|
|
388
|
+
Effect.flatMap((service) => service.discover(root)),
|
|
389
|
+
),
|
|
390
|
+
),
|
|
391
|
+
).rejects.toThrow("{revision}")
|
|
392
|
+
})
|
|
393
|
+
|
|
374
394
|
test("rejects an unknown post-checkout command placeholder", async () => {
|
|
375
395
|
await write(
|
|
376
396
|
root,
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
type WorkbaseRegistration,
|
|
21
21
|
} from "../workbase/schemas"
|
|
22
22
|
import { validateWorktreeCreateCommand } from "../workbase/worktree-command"
|
|
23
|
+
import { validateWorkspaceCreateCommand } from "../workbase/workspace-command"
|
|
23
24
|
import { validatePostCheckoutCommand } from "../workbase/checkout-command"
|
|
24
25
|
import { validateRunners } from "../workbase/runner-command"
|
|
25
26
|
import { findDependencyCycles } from "../workbase/dependency-graph"
|
|
@@ -275,6 +276,21 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
|
|
|
275
276
|
})
|
|
276
277
|
}
|
|
277
278
|
}
|
|
279
|
+
if (decoded.value.workspaceCreateCommand) {
|
|
280
|
+
try {
|
|
281
|
+
validateWorkspaceCreateCommand(
|
|
282
|
+
decoded.value.workspaceCreateCommand,
|
|
283
|
+
)
|
|
284
|
+
} catch (cause) {
|
|
285
|
+
return yield* new WorkbaseConfigError({
|
|
286
|
+
path: configPath,
|
|
287
|
+
message:
|
|
288
|
+
cause instanceof Error
|
|
289
|
+
? cause.message
|
|
290
|
+
: "Invalid workspaceCreateCommand",
|
|
291
|
+
})
|
|
292
|
+
}
|
|
293
|
+
}
|
|
278
294
|
for (const [alias, repository] of Object.entries(
|
|
279
295
|
decoded.value.repositories ?? {},
|
|
280
296
|
)) {
|
|
@@ -319,6 +319,198 @@ describe("WorktreeService", () => {
|
|
|
319
319
|
expect(await Bun.file(workspace.writablePath!).exists()).toBe(false)
|
|
320
320
|
})
|
|
321
321
|
|
|
322
|
+
test("uses a custom jj workspace creator before the post-checkout hook", async () => {
|
|
323
|
+
if (!Bun.which("jj")) return
|
|
324
|
+
const repository = join(root, "repos/agency")
|
|
325
|
+
await rm(repository, { recursive: true, force: true })
|
|
326
|
+
await git(["clone", source, repository])
|
|
327
|
+
await jj(["git", "init", "--colocate", repository])
|
|
328
|
+
await Bun.write(
|
|
329
|
+
join(root, "agency.json"),
|
|
330
|
+
JSON.stringify({
|
|
331
|
+
version: 2,
|
|
332
|
+
vcs: "jj",
|
|
333
|
+
workspaceCreateCommand: [
|
|
334
|
+
"sh",
|
|
335
|
+
"-c",
|
|
336
|
+
'jj -R "$1" workspace add --name "$3" -r "$4" "$2" && printf "%s\\n%s\\n" "$AGENCY_CHECKOUT_KIND" "$AGENCY_REQUESTED_REF" > "$2/creator-finished"',
|
|
337
|
+
"workspace-creator",
|
|
338
|
+
"{repo}",
|
|
339
|
+
"{workspace}",
|
|
340
|
+
"{name}",
|
|
341
|
+
"{revision}",
|
|
342
|
+
"{kind}",
|
|
343
|
+
"{requestedRef}",
|
|
344
|
+
],
|
|
345
|
+
repositories: {
|
|
346
|
+
agency: {
|
|
347
|
+
remote: "https://example.com/agency.git",
|
|
348
|
+
postCheckoutCommand: [
|
|
349
|
+
"sh",
|
|
350
|
+
"-c",
|
|
351
|
+
'test -f creator-finished && printf "post-checkout" > hook-finished',
|
|
352
|
+
],
|
|
353
|
+
},
|
|
354
|
+
},
|
|
355
|
+
}),
|
|
356
|
+
)
|
|
357
|
+
await runTestEffect(
|
|
358
|
+
TaskService.pipe(
|
|
359
|
+
Effect.flatMap((service) =>
|
|
360
|
+
service.create(
|
|
361
|
+
{
|
|
362
|
+
id: "jj-custom",
|
|
363
|
+
ticketUrl: null,
|
|
364
|
+
repo: "agency",
|
|
365
|
+
branch: "task/jj-custom",
|
|
366
|
+
base: "main",
|
|
367
|
+
},
|
|
368
|
+
root,
|
|
369
|
+
),
|
|
370
|
+
),
|
|
371
|
+
),
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
const workspace = await runTestEffect(
|
|
375
|
+
WorktreeService.pipe(
|
|
376
|
+
Effect.flatMap((service) =>
|
|
377
|
+
service.materialize("jj-custom", undefined, root),
|
|
378
|
+
),
|
|
379
|
+
),
|
|
380
|
+
)
|
|
381
|
+
expect(
|
|
382
|
+
await Bun.file(join(workspace.writablePath!, "creator-finished")).text(),
|
|
383
|
+
).toBe("writable\ntask/jj-custom\n")
|
|
384
|
+
expect(
|
|
385
|
+
await Bun.file(join(workspace.writablePath!, "hook-finished")).text(),
|
|
386
|
+
).toBe("post-checkout")
|
|
387
|
+
expect(workspace.operations[0]).toMatchObject({
|
|
388
|
+
action: "create-workspace",
|
|
389
|
+
command: expect.arrayContaining(["writable", "task/jj-custom"]),
|
|
390
|
+
status: "completed",
|
|
391
|
+
})
|
|
392
|
+
})
|
|
393
|
+
|
|
394
|
+
test("rolls back a jj workspace partially created by a custom command", async () => {
|
|
395
|
+
if (!Bun.which("jj")) return
|
|
396
|
+
const repository = join(root, "repos/agency")
|
|
397
|
+
await rm(repository, { recursive: true, force: true })
|
|
398
|
+
await git(["clone", source, repository])
|
|
399
|
+
await jj(["git", "init", "--colocate", repository])
|
|
400
|
+
await Bun.write(
|
|
401
|
+
join(root, "agency.json"),
|
|
402
|
+
JSON.stringify({
|
|
403
|
+
version: 2,
|
|
404
|
+
vcs: "jj",
|
|
405
|
+
workspaceCreateCommand: [
|
|
406
|
+
"sh",
|
|
407
|
+
"-c",
|
|
408
|
+
'jj -R "$1" workspace add --name "$3" -r "$4" "$2" && echo adoption-failed >&2; exit 7',
|
|
409
|
+
"workspace-creator",
|
|
410
|
+
"{repo}",
|
|
411
|
+
"{workspace}",
|
|
412
|
+
"{name}",
|
|
413
|
+
"{revision}",
|
|
414
|
+
],
|
|
415
|
+
}),
|
|
416
|
+
)
|
|
417
|
+
await runTestEffect(
|
|
418
|
+
TaskService.pipe(
|
|
419
|
+
Effect.flatMap((service) =>
|
|
420
|
+
service.create(
|
|
421
|
+
{
|
|
422
|
+
id: "jj-custom-failure",
|
|
423
|
+
ticketUrl: null,
|
|
424
|
+
repo: "agency",
|
|
425
|
+
branch: "task/jj-custom-failure",
|
|
426
|
+
base: "main",
|
|
427
|
+
},
|
|
428
|
+
root,
|
|
429
|
+
),
|
|
430
|
+
),
|
|
431
|
+
),
|
|
432
|
+
)
|
|
433
|
+
const workspacePath = join(root, "tasks/jj-custom-failure/code/agency")
|
|
434
|
+
|
|
435
|
+
await expect(
|
|
436
|
+
runTestEffect(
|
|
437
|
+
WorktreeService.pipe(
|
|
438
|
+
Effect.flatMap((service) =>
|
|
439
|
+
service.materialize("jj-custom-failure", undefined, root),
|
|
440
|
+
),
|
|
441
|
+
),
|
|
442
|
+
),
|
|
443
|
+
).rejects.toThrow("adoption-failed")
|
|
444
|
+
expect(await Bun.file(workspacePath).exists()).toBe(false)
|
|
445
|
+
expect(
|
|
446
|
+
await jjOutput(["workspace", "list", "-T", 'name ++ "\\n"'], repository),
|
|
447
|
+
).not.toContain("agency-jj-custom-failure-task-agency")
|
|
448
|
+
})
|
|
449
|
+
|
|
450
|
+
test("rejects and rolls back a custom jj workspace at the wrong revision", async () => {
|
|
451
|
+
if (!Bun.which("jj")) return
|
|
452
|
+
const repository = join(root, "repos/agency")
|
|
453
|
+
await rm(repository, { recursive: true, force: true })
|
|
454
|
+
await git(["clone", source, repository])
|
|
455
|
+
await jj(["git", "init", "--colocate", repository])
|
|
456
|
+
const hookMarker = join(root, "wrong-revision-hook")
|
|
457
|
+
await Bun.write(
|
|
458
|
+
join(root, "agency.json"),
|
|
459
|
+
JSON.stringify({
|
|
460
|
+
version: 2,
|
|
461
|
+
vcs: "jj",
|
|
462
|
+
workspaceCreateCommand: [
|
|
463
|
+
"sh",
|
|
464
|
+
"-c",
|
|
465
|
+
'jj -R "$1" workspace add --name "$3" -r "root()" "$2"',
|
|
466
|
+
"workspace-creator",
|
|
467
|
+
"{repo}",
|
|
468
|
+
"{workspace}",
|
|
469
|
+
"{name}",
|
|
470
|
+
"{revision}",
|
|
471
|
+
],
|
|
472
|
+
repositories: {
|
|
473
|
+
agency: {
|
|
474
|
+
remote: "https://example.com/agency.git",
|
|
475
|
+
postCheckoutCommand: ["sh", "-c", 'touch "$1"', "hook", hookMarker],
|
|
476
|
+
},
|
|
477
|
+
},
|
|
478
|
+
}),
|
|
479
|
+
)
|
|
480
|
+
await runTestEffect(
|
|
481
|
+
TaskService.pipe(
|
|
482
|
+
Effect.flatMap((service) =>
|
|
483
|
+
service.create(
|
|
484
|
+
{
|
|
485
|
+
id: "jj-custom-wrong-revision",
|
|
486
|
+
ticketUrl: null,
|
|
487
|
+
repo: "agency",
|
|
488
|
+
branch: "task/jj-custom-wrong-revision",
|
|
489
|
+
base: "main",
|
|
490
|
+
},
|
|
491
|
+
root,
|
|
492
|
+
),
|
|
493
|
+
),
|
|
494
|
+
),
|
|
495
|
+
)
|
|
496
|
+
const workspacePath = join(
|
|
497
|
+
root,
|
|
498
|
+
"tasks/jj-custom-wrong-revision/code/agency",
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
await expect(
|
|
502
|
+
runTestEffect(
|
|
503
|
+
WorktreeService.pipe(
|
|
504
|
+
Effect.flatMap((service) =>
|
|
505
|
+
service.materialize("jj-custom-wrong-revision", undefined, root),
|
|
506
|
+
),
|
|
507
|
+
),
|
|
508
|
+
),
|
|
509
|
+
).rejects.toThrow("failed validation")
|
|
510
|
+
expect(await Bun.file(workspacePath).exists()).toBe(false)
|
|
511
|
+
expect(await Bun.file(hookMarker).exists()).toBe(false)
|
|
512
|
+
})
|
|
513
|
+
|
|
322
514
|
test("suspends and resumes the exact jj working-copy target", async () => {
|
|
323
515
|
if (!Bun.which("jj")) return
|
|
324
516
|
const repository = join(root, "repos/agency")
|
|
@@ -8,6 +8,10 @@ import {
|
|
|
8
8
|
expandWorktreeCreateCommand,
|
|
9
9
|
worktreeCommandEnvironment,
|
|
10
10
|
} from "../workbase/worktree-command"
|
|
11
|
+
import {
|
|
12
|
+
expandWorkspaceCreateCommand,
|
|
13
|
+
workspaceCommandEnvironment,
|
|
14
|
+
} from "../workbase/workspace-command"
|
|
11
15
|
import {
|
|
12
16
|
expandPostCheckoutCommand,
|
|
13
17
|
postCheckoutCommandEnvironment,
|
|
@@ -470,7 +474,9 @@ const inspectExecution = (
|
|
|
470
474
|
: yield* backend.workspaceHead(checkoutPath)
|
|
471
475
|
: null
|
|
472
476
|
const dirty =
|
|
473
|
-
exists && atPath
|
|
477
|
+
exists && atPath
|
|
478
|
+
? yield* backend.workspaceDirty(checkoutPath)
|
|
479
|
+
: (atPath?.dirty ?? null)
|
|
474
480
|
const expectedCommit =
|
|
475
481
|
"branch" in checkout && context?.skipWritableRevisionResolution
|
|
476
482
|
? actualCommit
|
|
@@ -1157,7 +1163,7 @@ const materializeJj = (options: {
|
|
|
1157
1163
|
const created: {
|
|
1158
1164
|
repositoryPath: string
|
|
1159
1165
|
workspacePath: string
|
|
1160
|
-
workspaceName: string
|
|
1166
|
+
workspaceName: string | null
|
|
1161
1167
|
}[] = []
|
|
1162
1168
|
const resumePath = jjResumePath(options.taskPath, options.phasePath)
|
|
1163
1169
|
const resume = yield* readJjResumeState(resumePath)
|
|
@@ -1227,9 +1233,14 @@ const materializeJj = (options: {
|
|
|
1227
1233
|
const canonicalPath = exists
|
|
1228
1234
|
? yield* fs.realPath(workspacePath)
|
|
1229
1235
|
: resolve(workspacePath)
|
|
1230
|
-
const
|
|
1236
|
+
const registeredWorkspaces =
|
|
1237
|
+
yield* backend.listWorkspaces(repositoryPath)
|
|
1238
|
+
const registered = registeredWorkspaces.find(
|
|
1231
1239
|
(workspace) => workspace.path === canonicalPath,
|
|
1232
1240
|
)
|
|
1241
|
+
const registeredByName = registeredWorkspaces.find(
|
|
1242
|
+
(workspace) => workspace.name === workspaceName,
|
|
1243
|
+
)
|
|
1233
1244
|
if (exists && !registered && resumeCheckout) {
|
|
1234
1245
|
const residual = yield* inspectJjResidual(
|
|
1235
1246
|
options.root,
|
|
@@ -1256,6 +1267,11 @@ const materializeJj = (options: {
|
|
|
1256
1267
|
message: `Workspace registry contains a missing checkout at ${workspacePath}`,
|
|
1257
1268
|
})
|
|
1258
1269
|
}
|
|
1270
|
+
if (!exists && registeredByName) {
|
|
1271
|
+
return yield* new WorktreeError({
|
|
1272
|
+
message: `Jj workspace name '${workspaceName}' is already registered at ${registeredByName.path}`,
|
|
1273
|
+
})
|
|
1274
|
+
}
|
|
1259
1275
|
if (exists && registered) {
|
|
1260
1276
|
const actualCommit = resumeCheckout
|
|
1261
1277
|
? ((yield* jjIdentity(workspacePath, "@"))?.commitId ?? null)
|
|
@@ -1313,7 +1329,7 @@ const materializeJj = (options: {
|
|
|
1313
1329
|
})
|
|
1314
1330
|
}
|
|
1315
1331
|
|
|
1316
|
-
const
|
|
1332
|
+
const defaultCommand = [
|
|
1317
1333
|
"jj",
|
|
1318
1334
|
"-R",
|
|
1319
1335
|
repositoryPath,
|
|
@@ -1325,6 +1341,36 @@ const materializeJj = (options: {
|
|
|
1325
1341
|
revision,
|
|
1326
1342
|
workspacePath,
|
|
1327
1343
|
]
|
|
1344
|
+
const workspaceVariables = {
|
|
1345
|
+
repo: repositoryPath,
|
|
1346
|
+
workspace: workspacePath,
|
|
1347
|
+
name: workspaceName,
|
|
1348
|
+
revision,
|
|
1349
|
+
kind:
|
|
1350
|
+
"branch" in checkout
|
|
1351
|
+
? ("writable" as const)
|
|
1352
|
+
: ("reference" as const),
|
|
1353
|
+
requestedRef: requestedRevision,
|
|
1354
|
+
}
|
|
1355
|
+
let command = defaultCommand
|
|
1356
|
+
let commandEnvironment: Record<string, string> | undefined
|
|
1357
|
+
if (options.config.workspaceCreateCommand && !resumeCheckout) {
|
|
1358
|
+
try {
|
|
1359
|
+
command = expandWorkspaceCreateCommand(
|
|
1360
|
+
options.config.workspaceCreateCommand,
|
|
1361
|
+
workspaceVariables,
|
|
1362
|
+
)
|
|
1363
|
+
commandEnvironment = workspaceCommandEnvironment(workspaceVariables)
|
|
1364
|
+
} catch (cause) {
|
|
1365
|
+
return yield* new WorktreeError({
|
|
1366
|
+
message:
|
|
1367
|
+
cause instanceof Error
|
|
1368
|
+
? cause.message
|
|
1369
|
+
: "Invalid workspaceCreateCommand",
|
|
1370
|
+
cause,
|
|
1371
|
+
})
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1328
1374
|
operations.push({
|
|
1329
1375
|
action: "create-workspace",
|
|
1330
1376
|
repo: checkout.repo,
|
|
@@ -1353,6 +1399,38 @@ const materializeJj = (options: {
|
|
|
1353
1399
|
workspaceName,
|
|
1354
1400
|
commitId: resumeCheckout.commitId,
|
|
1355
1401
|
})
|
|
1402
|
+
} else if (options.config.workspaceCreateCommand) {
|
|
1403
|
+
verboseLog(`Running workspace command: ${formatCommand(command)}`)
|
|
1404
|
+
const result = yield* fs.runCommand(command, {
|
|
1405
|
+
cwd: repositoryPath,
|
|
1406
|
+
captureOutput: true,
|
|
1407
|
+
forwardOutput: forwardCommandOutput,
|
|
1408
|
+
env: commandEnvironment,
|
|
1409
|
+
})
|
|
1410
|
+
const createdPath = yield* fs.isDirectory(workspacePath)
|
|
1411
|
+
const canonicalCreatedPath = createdPath
|
|
1412
|
+
? yield* fs.realPath(workspacePath)
|
|
1413
|
+
: resolve(workspacePath)
|
|
1414
|
+
const registeredAfterCommand = (yield* backend.listWorkspaces(
|
|
1415
|
+
repositoryPath,
|
|
1416
|
+
)).find((workspace) => workspace.path === canonicalCreatedPath)
|
|
1417
|
+
if (createdPath || registeredAfterCommand) {
|
|
1418
|
+
created.push({
|
|
1419
|
+
repositoryPath,
|
|
1420
|
+
workspacePath,
|
|
1421
|
+
workspaceName: registeredAfterCommand?.name ?? null,
|
|
1422
|
+
})
|
|
1423
|
+
}
|
|
1424
|
+
if (result.exitCode !== 0) {
|
|
1425
|
+
return yield* new WorktreeError({
|
|
1426
|
+
message: `Failed to create jj workspace for '${checkout.repo}': ${result.stderr.trim() || result.stdout.trim()}`,
|
|
1427
|
+
})
|
|
1428
|
+
}
|
|
1429
|
+
if (!createdPath) {
|
|
1430
|
+
return yield* new WorktreeError({
|
|
1431
|
+
message: `Workspace command did not create ${workspacePath}`,
|
|
1432
|
+
})
|
|
1433
|
+
}
|
|
1356
1434
|
} else {
|
|
1357
1435
|
yield* backend.createWorkspace({
|
|
1358
1436
|
repositoryPath,
|
|
@@ -1362,7 +1440,9 @@ const materializeJj = (options: {
|
|
|
1362
1440
|
...("branch" in checkout ? { branch: checkout.branch } : {}),
|
|
1363
1441
|
})
|
|
1364
1442
|
}
|
|
1365
|
-
|
|
1443
|
+
if (!options.config.workspaceCreateCommand || resumeCheckout) {
|
|
1444
|
+
created.push({ repositoryPath, workspacePath, workspaceName })
|
|
1445
|
+
}
|
|
1366
1446
|
const canonicalWorkspacePath = yield* fs.realPath(workspacePath)
|
|
1367
1447
|
const registeredAfterCreate = (yield* backend.listWorkspaces(
|
|
1368
1448
|
repositoryPath,
|
|
@@ -1370,7 +1450,10 @@ const materializeJj = (options: {
|
|
|
1370
1450
|
const head = resumeCheckout
|
|
1371
1451
|
? ((yield* jjIdentity(workspacePath, "@"))?.commitId ?? null)
|
|
1372
1452
|
: yield* backend.workspaceHead(workspacePath)
|
|
1373
|
-
if (
|
|
1453
|
+
if (
|
|
1454
|
+
registeredAfterCreate?.name !== workspaceName ||
|
|
1455
|
+
head !== revision
|
|
1456
|
+
) {
|
|
1374
1457
|
return yield* new WorktreeError({
|
|
1375
1458
|
message: `Created jj workspace for '${checkout.repo}' failed validation`,
|
|
1376
1459
|
})
|
|
@@ -1445,16 +1528,18 @@ const materializeJj = (options: {
|
|
|
1445
1528
|
const rolledBack: string[] = []
|
|
1446
1529
|
const manualRecovery: string[] = []
|
|
1447
1530
|
for (const workspace of [...created].reverse()) {
|
|
1448
|
-
const removed = yield*
|
|
1449
|
-
.
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
)
|
|
1531
|
+
const removed = yield* (
|
|
1532
|
+
workspace.workspaceName
|
|
1533
|
+
? backend.removeWorkspace({
|
|
1534
|
+
repositoryPath: workspace.repositoryPath,
|
|
1535
|
+
workspacePath: workspace.workspacePath,
|
|
1536
|
+
workspaceName: workspace.workspaceName,
|
|
1537
|
+
})
|
|
1538
|
+
: fs.deleteDirectory(workspace.workspacePath)
|
|
1539
|
+
).pipe(
|
|
1540
|
+
Effect.as(true),
|
|
1541
|
+
Effect.catchAll(() => Effect.succeed(false)),
|
|
1542
|
+
)
|
|
1458
1543
|
if (removed)
|
|
1459
1544
|
rolledBack.push(`create-workspace ${workspace.workspaceName}`)
|
|
1460
1545
|
else
|
|
@@ -1576,10 +1661,26 @@ const removeJj = (
|
|
|
1576
1661
|
bookmark: string
|
|
1577
1662
|
}[] = []
|
|
1578
1663
|
for (const checkout of inspection.checkouts) {
|
|
1664
|
+
const repositoryPath = join(root, "repos", checkout.repo)
|
|
1665
|
+
const registered = checkout.registeredPath
|
|
1666
|
+
? (yield* backend.listWorkspaces(repositoryPath)).find(
|
|
1667
|
+
(workspace) => workspace.path === checkout.registeredPath,
|
|
1668
|
+
)
|
|
1669
|
+
: undefined
|
|
1579
1670
|
if (checkout.dirty === true && options.persistResume === false) {
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1671
|
+
const preservedByBookmark =
|
|
1672
|
+
checkout.kind === "writable" &&
|
|
1673
|
+
registered?.commit !== null &&
|
|
1674
|
+
registered?.commit !== undefined &&
|
|
1675
|
+
(yield* backend.resolveRevision(
|
|
1676
|
+
repositoryPath,
|
|
1677
|
+
checkout.requestedRef,
|
|
1678
|
+
)) === registered.commit
|
|
1679
|
+
if (!preservedByBookmark) {
|
|
1680
|
+
return yield* new WorktreeError({
|
|
1681
|
+
message: `Failed to remove workspace for '${checkout.repo}': checkout has uncommitted changes`,
|
|
1682
|
+
})
|
|
1683
|
+
}
|
|
1583
1684
|
}
|
|
1584
1685
|
if (
|
|
1585
1686
|
checkout.exists &&
|
|
@@ -1590,7 +1691,6 @@ const removeJj = (
|
|
|
1590
1691
|
message: `Failed to remove workspace for '${checkout.repo}': checkout cleanliness could not be verified`,
|
|
1591
1692
|
})
|
|
1592
1693
|
}
|
|
1593
|
-
const repositoryPath = join(root, "repos", checkout.repo)
|
|
1594
1694
|
if (!checkout.registeredPath) {
|
|
1595
1695
|
const recoveryRevision = recoverable.get(checkout.path)
|
|
1596
1696
|
if (!recoveryRevision) continue
|
|
@@ -1613,9 +1713,6 @@ const removeJj = (
|
|
|
1613
1713
|
})
|
|
1614
1714
|
continue
|
|
1615
1715
|
}
|
|
1616
|
-
const registered = (yield* backend.listWorkspaces(repositoryPath)).find(
|
|
1617
|
-
(workspace) => workspace.path === checkout.registeredPath,
|
|
1618
|
-
)
|
|
1619
1716
|
if (!registered?.name || !checkout.actualCommit || !registered.commit) {
|
|
1620
1717
|
return yield* new WorktreeError({
|
|
1621
1718
|
message: `Cannot identify jj workspace at ${checkout.registeredPath}`,
|
|
@@ -257,6 +257,25 @@ describe("repository post-checkout configuration", () => {
|
|
|
257
257
|
})
|
|
258
258
|
})
|
|
259
259
|
|
|
260
|
+
describe("workspace creation configuration", () => {
|
|
261
|
+
test("accepts a jj workspace argv command", () => {
|
|
262
|
+
const config = Schema.decodeUnknownSync(WorkbaseConfig)({
|
|
263
|
+
version: 2,
|
|
264
|
+
vcs: "jj",
|
|
265
|
+
workspaceCreateCommand: [
|
|
266
|
+
"prewarm",
|
|
267
|
+
"adopt",
|
|
268
|
+
"{repo}",
|
|
269
|
+
"{workspace}",
|
|
270
|
+
"{name}",
|
|
271
|
+
"{revision}",
|
|
272
|
+
],
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
expect(config.workspaceCreateCommand?.[0]).toBe("prewarm")
|
|
276
|
+
})
|
|
277
|
+
})
|
|
278
|
+
|
|
260
279
|
describe("runner configuration", () => {
|
|
261
280
|
test("accepts named argv commands with resume commands and environment", () => {
|
|
262
281
|
const config = Schema.decodeUnknownSync(WorkbaseConfig)({
|
package/src/workbase/schemas.ts
CHANGED
|
@@ -109,6 +109,7 @@ export const WorkbaseConfig = Schema.Struct({
|
|
|
109
109
|
),
|
|
110
110
|
chooserCommand: Schema.optional(Schema.NonEmptyArray(NonEmptyString)),
|
|
111
111
|
worktreeCreateCommand: Schema.optional(Schema.NonEmptyArray(NonEmptyString)),
|
|
112
|
+
workspaceCreateCommand: Schema.optional(Schema.NonEmptyArray(NonEmptyString)),
|
|
112
113
|
runners: Schema.optional(
|
|
113
114
|
Schema.Record({
|
|
114
115
|
key: EntityId,
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import {
|
|
3
|
+
expandWorkspaceCreateCommand,
|
|
4
|
+
workspaceCommandEnvironment,
|
|
5
|
+
} from "./workspace-command"
|
|
6
|
+
|
|
7
|
+
const variables = {
|
|
8
|
+
repo: "/work/repos/app",
|
|
9
|
+
workspace: "/work/tasks/example/code/app",
|
|
10
|
+
name: "agency-example-task-app",
|
|
11
|
+
revision: "0123456789abcdef",
|
|
12
|
+
kind: "writable" as const,
|
|
13
|
+
requestedRef: "task/example",
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe("workspace command templates", () => {
|
|
17
|
+
test("expands argv placeholders without shell interpolation", () => {
|
|
18
|
+
expect(
|
|
19
|
+
expandWorkspaceCreateCommand(
|
|
20
|
+
[
|
|
21
|
+
"tool",
|
|
22
|
+
"--repo={repo}",
|
|
23
|
+
"--workspace={workspace}",
|
|
24
|
+
"--name={name}",
|
|
25
|
+
"--revision={revision}",
|
|
26
|
+
"{kind}",
|
|
27
|
+
"{requestedRef}",
|
|
28
|
+
],
|
|
29
|
+
variables,
|
|
30
|
+
),
|
|
31
|
+
).toEqual([
|
|
32
|
+
"tool",
|
|
33
|
+
"--repo=/work/repos/app",
|
|
34
|
+
"--workspace=/work/tasks/example/code/app",
|
|
35
|
+
"--name=agency-example-task-app",
|
|
36
|
+
"--revision=0123456789abcdef",
|
|
37
|
+
"writable",
|
|
38
|
+
"task/example",
|
|
39
|
+
])
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test("requires creation identity placeholders", () => {
|
|
43
|
+
expect(() =>
|
|
44
|
+
expandWorkspaceCreateCommand(
|
|
45
|
+
["tool", "{repo}", "{workspace}", "{name}"],
|
|
46
|
+
variables,
|
|
47
|
+
),
|
|
48
|
+
).toThrow("{revision}")
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test("rejects unknown placeholders", () => {
|
|
52
|
+
expect(() =>
|
|
53
|
+
expandWorkspaceCreateCommand(
|
|
54
|
+
["tool", "{repo}", "{workspace}", "{name}", "{revision}", "{base}"],
|
|
55
|
+
variables,
|
|
56
|
+
),
|
|
57
|
+
).toThrow("{base}")
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test("provides equivalent environment variables", () => {
|
|
61
|
+
expect(workspaceCommandEnvironment(variables)).toEqual({
|
|
62
|
+
AGENCY_REPO: variables.repo,
|
|
63
|
+
AGENCY_WORKSPACE: variables.workspace,
|
|
64
|
+
AGENCY_WORKSPACE_NAME: variables.name,
|
|
65
|
+
AGENCY_REVISION: variables.revision,
|
|
66
|
+
AGENCY_CHECKOUT_KIND: variables.kind,
|
|
67
|
+
AGENCY_REQUESTED_REF: variables.requestedRef,
|
|
68
|
+
})
|
|
69
|
+
})
|
|
70
|
+
})
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
interface WorkspaceCommandVariables {
|
|
2
|
+
readonly repo: string
|
|
3
|
+
readonly workspace: string
|
|
4
|
+
readonly name: string
|
|
5
|
+
readonly revision: string
|
|
6
|
+
readonly kind: "writable" | "reference"
|
|
7
|
+
readonly requestedRef: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const REQUIRED_PLACEHOLDERS = ["repo", "workspace", "name", "revision"] as const
|
|
11
|
+
const PLACEHOLDERS = new Set([
|
|
12
|
+
"repo",
|
|
13
|
+
"workspace",
|
|
14
|
+
"name",
|
|
15
|
+
"revision",
|
|
16
|
+
"kind",
|
|
17
|
+
"requestedRef",
|
|
18
|
+
])
|
|
19
|
+
|
|
20
|
+
export const validateWorkspaceCreateCommand = (command: readonly string[]) => {
|
|
21
|
+
const template = command.join("\u0000")
|
|
22
|
+
for (const placeholder of REQUIRED_PLACEHOLDERS) {
|
|
23
|
+
if (!template.includes(`{${placeholder}}`)) {
|
|
24
|
+
throw new Error(
|
|
25
|
+
`workspaceCreateCommand must include the {${placeholder}} placeholder`,
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
for (const argument of command) {
|
|
30
|
+
for (const match of argument.matchAll(/\{([^{}]+)\}/g)) {
|
|
31
|
+
const placeholder = match[1]!
|
|
32
|
+
if (!PLACEHOLDERS.has(placeholder)) {
|
|
33
|
+
throw new Error(
|
|
34
|
+
`Unknown workspaceCreateCommand placeholder: {${placeholder}}`,
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const expandWorkspaceCreateCommand = (
|
|
42
|
+
command: readonly string[],
|
|
43
|
+
variables: WorkspaceCommandVariables,
|
|
44
|
+
): string[] => {
|
|
45
|
+
validateWorkspaceCreateCommand(command)
|
|
46
|
+
|
|
47
|
+
return command.map((argument) =>
|
|
48
|
+
argument.replaceAll(/\{([^{}]+)\}/g, (match, placeholder: string) => {
|
|
49
|
+
return variables[placeholder as keyof WorkspaceCommandVariables] ?? match
|
|
50
|
+
}),
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const workspaceCommandEnvironment = (
|
|
55
|
+
variables: WorkspaceCommandVariables,
|
|
56
|
+
): Record<string, string> => ({
|
|
57
|
+
AGENCY_REPO: variables.repo,
|
|
58
|
+
AGENCY_WORKSPACE: variables.workspace,
|
|
59
|
+
AGENCY_WORKSPACE_NAME: variables.name,
|
|
60
|
+
AGENCY_REVISION: variables.revision,
|
|
61
|
+
AGENCY_CHECKOUT_KIND: variables.kind,
|
|
62
|
+
AGENCY_REQUESTED_REF: variables.requestedRef,
|
|
63
|
+
})
|