@markjaquith/agency 2.55.0 → 2.56.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
@@ -235,6 +235,56 @@ Supplemental read-only repositories remain detached Git worktrees at their
235
235
  declared refs so they do not acquire writable branches. Jj workbases always use
236
236
  jj workspaces and ignore this Git-specific customization.
237
237
 
238
+ ### Post-checkout Commands
239
+
240
+ Each repository declaration may provide a VCS-neutral `postCheckoutCommand` argv
241
+ template for repository-specific setup. Agency invokes it directly, without a
242
+ shell, with the new checkout as its working directory:
243
+
244
+ ```json
245
+ {
246
+ "version": 2,
247
+ "repositories": {
248
+ "frontend": {
249
+ "remote": "git@example.com:team/frontend.git",
250
+ "postCheckoutCommand": ["bun", "install", "--frozen-lockfile"]
251
+ }
252
+ }
253
+ }
254
+ ```
255
+
256
+ The hook runs for each newly created managed checkout, including writable and
257
+ reference checkouts, after Git worktree or jj workspace creation has completed
258
+ and Agency has validated the checkout. It does not run for a reused checkout or
259
+ for inspection-only commands. A custom `worktreeCreateCommand` completes and is
260
+ validated before this hook runs.
261
+
262
+ Available placeholders and matching environment variables are:
263
+
264
+ | Placeholder | Environment | Value |
265
+ | ------------------ | ------------------------ | ---------------------------------------------- |
266
+ | `{repoAlias}` | `AGENCY_REPO_ALIAS` | Repository alias |
267
+ | `{repositoryPath}` | `AGENCY_REPOSITORY_PATH` | Absolute source repository path under `repos/` |
268
+ | `{checkoutPath}` | `AGENCY_CHECKOUT_PATH` | Absolute managed checkout path |
269
+ | `{checkoutKind}` | `AGENCY_CHECKOUT_KIND` | `writable` or `reference` |
270
+ | `{requestedRef}` | `AGENCY_REQUESTED_REF` | Requested branch, reference, or review commit |
271
+ | `{base}` | `AGENCY_BASE` | Configured execution base |
272
+ | `{vcs}` | `AGENCY_VCS` | `git` or `jj` |
273
+ | `{workbaseRoot}` | `AGENCY_WORKBASE_ROOT` | Absolute workbase root |
274
+ | `{taskId}` | `AGENCY_TASK_ID` | Task ID |
275
+ | `{phaseId}` | `AGENCY_PHASE_ID` | Phase ID |
276
+
277
+ `{base}` and `{phaseId}` and their environment variables are empty strings when
278
+ they do not apply. Dry runs report a planned `post-checkout` operation but never
279
+ execute it. Verbose output identifies the repository and expanded command.
280
+
281
+ Hook success is part of checkout creation. A non-zero exit or failure to start
282
+ rolls back the checkout and any branch created by the same operation; if cleanup
283
+ also fails, Agency reports the exact manual recovery action. A later command
284
+ retries checkout creation and the hook rather than reusing an uninitialized
285
+ checkout. Hook commands should be idempotent so a retry is safe after any
286
+ external effects the failed invocation may have completed.
287
+
238
288
  ### Agent Runners
239
289
 
240
290
  OpenCode and Claude Code are built-in runner presets. Select either preset or a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "2.55.0",
3
+ "version": "2.56.0",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
@@ -173,6 +173,18 @@ export class DoctorService extends Effect.Service<DoctorService>()(
173
173
  ] as const,
174
174
  ]
175
175
  : []),
176
+ ...Object.entries(config.repositories ?? {}).flatMap(
177
+ ([alias, repository]) =>
178
+ repository.postCheckoutCommand
179
+ ? [
180
+ [
181
+ `integration.repository.${alias}.post-checkout`,
182
+ repository.postCheckoutCommand,
183
+ `Repository '${alias}' post-checkout hook`,
184
+ ] as const,
185
+ ]
186
+ : [],
187
+ ),
176
188
  ...Object.entries(config.runners ?? {}).map(
177
189
  ([name, runner]) =>
178
190
  [
@@ -371,6 +371,30 @@ status: done
371
371
  ).rejects.toThrow("{worktree}")
372
372
  })
373
373
 
374
+ test("rejects an unknown post-checkout command placeholder", async () => {
375
+ await write(
376
+ root,
377
+ "agency.json",
378
+ JSON.stringify({
379
+ version: 2,
380
+ repositories: {
381
+ agency: {
382
+ remote: "https://example.com/agency.git",
383
+ postCheckoutCommand: ["tool", "{unknown}"],
384
+ },
385
+ },
386
+ }),
387
+ )
388
+
389
+ await expect(
390
+ runTestEffect(
391
+ WorkbaseService.pipe(
392
+ Effect.flatMap((service) => service.discover(root)),
393
+ ),
394
+ ),
395
+ ).rejects.toThrow("Repository 'agency'")
396
+ })
397
+
374
398
  test("rejects an unknown runner command placeholder", async () => {
375
399
  await write(
376
400
  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 { validatePostCheckoutCommand } from "../workbase/checkout-command"
23
24
  import { validateRunners } from "../workbase/runner-command"
24
25
  import { findDependencyCycles } from "../workbase/dependency-graph"
25
26
  import { validateDelivery } from "../workbase/delivery-command"
@@ -274,6 +275,23 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
274
275
  })
275
276
  }
276
277
  }
278
+ for (const [alias, repository] of Object.entries(
279
+ decoded.value.repositories ?? {},
280
+ )) {
281
+ if (!repository.postCheckoutCommand) continue
282
+ try {
283
+ validatePostCheckoutCommand(repository.postCheckoutCommand)
284
+ } catch (cause) {
285
+ return yield* new WorkbaseConfigError({
286
+ path: configPath,
287
+ message: `Repository '${alias}': ${
288
+ cause instanceof Error
289
+ ? cause.message
290
+ : "Invalid postCheckoutCommand"
291
+ }`,
292
+ })
293
+ }
294
+ }
277
295
  try {
278
296
  validateRunners(decoded.value.runners)
279
297
  validateDelivery(decoded.value.delivery)
@@ -104,6 +104,126 @@ describe("WorktreeService", () => {
104
104
  expect(new TextDecoder().decode(branch.stdout).trim()).toBe("task/example")
105
105
  })
106
106
 
107
+ test("runs repository hooks for new writable and reference checkouts", async () => {
108
+ await ensureEffectRepository()
109
+ const hook = [
110
+ "sh",
111
+ "-c",
112
+ 'printf "%s\\n" "$@" > post-checkout-argv; env | grep "^AGENCY_" | sort > post-checkout-env; printf x >> post-checkout-count',
113
+ "post-checkout",
114
+ "{repoAlias}",
115
+ "{repositoryPath}",
116
+ "{checkoutPath}",
117
+ "{checkoutKind}",
118
+ "{requestedRef}",
119
+ "{base}",
120
+ "{vcs}",
121
+ "{workbaseRoot}",
122
+ "{taskId}",
123
+ "{phaseId}",
124
+ ]
125
+ await Bun.write(
126
+ join(root, "agency.json"),
127
+ JSON.stringify({
128
+ version: 2,
129
+ repositories: {
130
+ agency: {
131
+ remote: "https://example.com/agency.git",
132
+ postCheckoutCommand: hook,
133
+ },
134
+ effect: {
135
+ remote: "https://example.com/effect.git",
136
+ postCheckoutCommand: hook,
137
+ },
138
+ },
139
+ }),
140
+ )
141
+ await runTestEffect(
142
+ TaskService.pipe(
143
+ Effect.flatMap((service) =>
144
+ service.create(
145
+ {
146
+ id: "hooked",
147
+ ticketUrl: null,
148
+ repo: "agency",
149
+ repos: [{ repo: "effect", ref: "main" }],
150
+ branch: "task/hooked",
151
+ base: "main",
152
+ },
153
+ root,
154
+ ),
155
+ ),
156
+ ),
157
+ )
158
+
159
+ const workspace = await runTestEffect(
160
+ WorktreeService.pipe(
161
+ Effect.flatMap((service) =>
162
+ service.materialize("hooked", undefined, root),
163
+ ),
164
+ ),
165
+ )
166
+ const writable = workspace.writablePath!
167
+ const reference = join(workspace.codePath, "effect")
168
+ expect(await Bun.file(join(writable, "post-checkout-argv")).text()).toBe(
169
+ [
170
+ "agency",
171
+ join(root, "repos/agency"),
172
+ writable,
173
+ "writable",
174
+ "task/hooked",
175
+ "main",
176
+ "git",
177
+ root,
178
+ "hooked",
179
+ "",
180
+ "",
181
+ ].join("\n"),
182
+ )
183
+ expect(
184
+ await Bun.file(join(reference, "post-checkout-argv")).text(),
185
+ ).toContain("reference\nmain\nmain\ngit")
186
+ const environment = await Bun.file(
187
+ join(writable, "post-checkout-env"),
188
+ ).text()
189
+ expect(environment).toContain(`AGENCY_CHECKOUT_PATH=${writable}\n`)
190
+ expect(environment).toContain("AGENCY_CHECKOUT_KIND=writable\n")
191
+ expect(environment).toContain("AGENCY_PHASE_ID=\n")
192
+ expect(workspace.operations).toEqual(
193
+ expect.arrayContaining([
194
+ expect.objectContaining({
195
+ action: "post-checkout",
196
+ repo: "agency",
197
+ status: "completed",
198
+ }),
199
+ expect.objectContaining({
200
+ action: "post-checkout",
201
+ repo: "effect",
202
+ status: "completed",
203
+ }),
204
+ ]),
205
+ )
206
+
207
+ const reused = await runTestEffect(
208
+ WorktreeService.pipe(
209
+ Effect.flatMap((service) =>
210
+ service.materialize("hooked", undefined, root),
211
+ ),
212
+ ),
213
+ )
214
+ expect(
215
+ reused.operations.filter(
216
+ (operation) => operation.action === "post-checkout",
217
+ ),
218
+ ).toEqual([])
219
+ expect(await Bun.file(join(writable, "post-checkout-count")).text()).toBe(
220
+ "x",
221
+ )
222
+ expect(await Bun.file(join(reference, "post-checkout-count")).text()).toBe(
223
+ "x",
224
+ )
225
+ })
226
+
107
227
  test("materializes and removes a jj workspace for a jj workbase", async () => {
108
228
  if (!Bun.which("jj")) return
109
229
  const repository = join(root, "repos/agency")
@@ -112,7 +232,20 @@ describe("WorktreeService", () => {
112
232
  await jj(["git", "init", "--colocate", repository])
113
233
  await Bun.write(
114
234
  join(root, "agency.json"),
115
- JSON.stringify({ version: 2, vcs: "jj" }),
235
+ JSON.stringify({
236
+ version: 2,
237
+ vcs: "jj",
238
+ repositories: {
239
+ agency: {
240
+ remote: "https://example.com/agency.git",
241
+ postCheckoutCommand: [
242
+ "sh",
243
+ "-c",
244
+ 'printf "%s:%s" "$AGENCY_VCS" "$AGENCY_CHECKOUT_KIND" > post-checkout',
245
+ ],
246
+ },
247
+ },
248
+ }),
116
249
  )
117
250
  await runTestEffect(
118
251
  TaskService.pipe(
@@ -140,6 +273,9 @@ describe("WorktreeService", () => {
140
273
  )
141
274
  const jjMetadata = await stat(join(workspace.writablePath!, ".jj"))
142
275
  expect(jjMetadata.isFile() || jjMetadata.isDirectory()).toBe(true)
276
+ expect(
277
+ await Bun.file(join(workspace.writablePath!, "post-checkout")).text(),
278
+ ).toBe("jj:writable")
143
279
  const inspection = await runTestEffect(
144
280
  WorktreeService.pipe(
145
281
  Effect.flatMap((service) =>
@@ -440,6 +576,22 @@ describe("WorktreeService", () => {
440
576
  })
441
577
 
442
578
  test("selects phases and rejects missing or unexpected phase IDs", async () => {
579
+ await Bun.write(
580
+ join(root, "agency.json"),
581
+ JSON.stringify({
582
+ version: 2,
583
+ repositories: {
584
+ agency: {
585
+ remote: "https://example.com/agency.git",
586
+ postCheckoutCommand: [
587
+ "sh",
588
+ "-c",
589
+ 'printf "%s:%s" "$AGENCY_TASK_ID" "$AGENCY_PHASE_ID" > post-checkout-owner',
590
+ ],
591
+ },
592
+ },
593
+ }),
594
+ )
443
595
  await runTestEffect(
444
596
  TaskService.pipe(
445
597
  Effect.flatMap((service) =>
@@ -503,6 +655,11 @@ describe("WorktreeService", () => {
503
655
  expect(workspace.writablePath).toBe(
504
656
  join(root, "tasks/multi/phases/selected/code/agency"),
505
657
  )
658
+ expect(
659
+ await Bun.file(
660
+ join(workspace.writablePath!, "post-checkout-owner"),
661
+ ).text(),
662
+ ).toBe("multi:selected")
506
663
 
507
664
  await runTestEffect(
508
665
  TaskService.pipe(
@@ -663,6 +820,231 @@ pr: null
663
820
  ).not.toBe(0)
664
821
  })
665
822
 
823
+ test("reports a planned hook in dry runs without executing it", async () => {
824
+ const marker = join(root, "dry-run-hook")
825
+ await Bun.write(
826
+ join(root, "agency.json"),
827
+ JSON.stringify({
828
+ version: 2,
829
+ repositories: {
830
+ agency: {
831
+ remote: "https://example.com/agency.git",
832
+ postCheckoutCommand: ["sh", "-c", 'touch "$1"', "hook", marker],
833
+ },
834
+ },
835
+ }),
836
+ )
837
+ await runTestEffect(
838
+ TaskService.pipe(
839
+ Effect.flatMap((service) =>
840
+ service.create(
841
+ {
842
+ id: "dry-hook",
843
+ ticketUrl: null,
844
+ repo: "agency",
845
+ branch: "task/dry-hook",
846
+ base: "main",
847
+ },
848
+ root,
849
+ ),
850
+ ),
851
+ ),
852
+ )
853
+
854
+ const materializeDry = () =>
855
+ runTestEffect(
856
+ WorktreeService.pipe(
857
+ Effect.flatMap((service) =>
858
+ service.materialize("dry-hook", undefined, root, {
859
+ dryRun: true,
860
+ verbose: true,
861
+ }),
862
+ ),
863
+ ),
864
+ )
865
+ let workspace!: Awaited<ReturnType<typeof materializeDry>>
866
+ const logs = await captureErrors(async () => {
867
+ workspace = await materializeDry()
868
+ })
869
+ expect(workspace.operations).toContainEqual({
870
+ action: "post-checkout",
871
+ repo: "agency",
872
+ command: ["sh", "-c", 'touch "$1"', "hook", marker],
873
+ status: "planned",
874
+ })
875
+ expect(await Bun.file(marker).exists()).toBe(false)
876
+ expect(await Bun.file(workspace.writablePath!).exists()).toBe(false)
877
+ expect(logs).toEqual([
878
+ expect.stringContaining("Planning post-checkout command for 'agency':"),
879
+ ])
880
+ })
881
+
882
+ test("rolls back a failed hook and runs it again on retry", async () => {
883
+ const retryMarker = join(root, "retry-hook")
884
+ await Bun.write(
885
+ join(root, "agency.json"),
886
+ JSON.stringify({
887
+ version: 2,
888
+ repositories: {
889
+ agency: {
890
+ remote: "https://example.com/agency.git",
891
+ postCheckoutCommand: [
892
+ "sh",
893
+ "-c",
894
+ 'if [ ! -f "$1" ]; then touch partial-bootstrap "$1"; echo bootstrap-failed >&2; exit 7; fi',
895
+ "hook",
896
+ retryMarker,
897
+ ],
898
+ },
899
+ },
900
+ }),
901
+ )
902
+ await runTestEffect(
903
+ TaskService.pipe(
904
+ Effect.flatMap((service) =>
905
+ service.create(
906
+ {
907
+ id: "retry-hook",
908
+ ticketUrl: null,
909
+ repo: "agency",
910
+ branch: "task/retry-hook",
911
+ base: "main",
912
+ },
913
+ root,
914
+ ),
915
+ ),
916
+ ),
917
+ )
918
+ const checkoutPath = join(root, "tasks/retry-hook/code/agency")
919
+
920
+ await expect(
921
+ runTestEffect(
922
+ WorktreeService.pipe(
923
+ Effect.flatMap((service) =>
924
+ service.materialize("retry-hook", undefined, root),
925
+ ),
926
+ ),
927
+ ),
928
+ ).rejects.toThrow("bootstrap-failed")
929
+ expect(await Bun.file(checkoutPath).exists()).toBe(false)
930
+ expect(
931
+ Bun.spawnSync([
932
+ "git",
933
+ "-C",
934
+ join(root, "repos/agency"),
935
+ "show-ref",
936
+ "--verify",
937
+ "refs/heads/task/retry-hook",
938
+ ]).exitCode,
939
+ ).not.toBe(0)
940
+
941
+ await expect(
942
+ runTestEffect(
943
+ WorktreeService.pipe(
944
+ Effect.flatMap((service) =>
945
+ service.materialize("retry-hook", undefined, root),
946
+ ),
947
+ ),
948
+ ),
949
+ ).resolves.toMatchObject({ writablePath: checkoutPath })
950
+ })
951
+
952
+ test("does not run a hook when checkout validation fails", async () => {
953
+ const marker = join(root, "invalid-checkout-hook")
954
+ await Bun.write(
955
+ join(root, "agency.json"),
956
+ JSON.stringify({
957
+ version: 2,
958
+ worktreeCreateCommand: [
959
+ "git",
960
+ "-C",
961
+ "{repo}",
962
+ "worktree",
963
+ "add",
964
+ "--detach",
965
+ "{worktree}",
966
+ "{base}",
967
+ ],
968
+ repositories: {
969
+ agency: {
970
+ remote: "https://example.com/agency.git",
971
+ postCheckoutCommand: ["sh", "-c", 'touch "$1"', "hook", marker],
972
+ },
973
+ },
974
+ }),
975
+ )
976
+ await runTestEffect(
977
+ TaskService.pipe(
978
+ Effect.flatMap((service) =>
979
+ service.create(
980
+ {
981
+ id: "invalid-hook-order",
982
+ ticketUrl: null,
983
+ repo: "agency",
984
+ branch: "task/invalid-hook-order",
985
+ base: "main",
986
+ },
987
+ root,
988
+ ),
989
+ ),
990
+ ),
991
+ )
992
+
993
+ await expect(
994
+ runTestEffect(
995
+ WorktreeService.pipe(
996
+ Effect.flatMap((service) =>
997
+ service.materialize("invalid-hook-order", undefined, root),
998
+ ),
999
+ ),
1000
+ ),
1001
+ ).rejects.toThrow("failed validation")
1002
+ expect(await Bun.file(marker).exists()).toBe(false)
1003
+ })
1004
+
1005
+ test("rolls back when a hook executable cannot be started", async () => {
1006
+ await Bun.write(
1007
+ join(root, "agency.json"),
1008
+ JSON.stringify({
1009
+ version: 2,
1010
+ repositories: {
1011
+ agency: {
1012
+ remote: "https://example.com/agency.git",
1013
+ postCheckoutCommand: ["agency-missing-hook-executable"],
1014
+ },
1015
+ },
1016
+ }),
1017
+ )
1018
+ await runTestEffect(
1019
+ TaskService.pipe(
1020
+ Effect.flatMap((service) =>
1021
+ service.create(
1022
+ {
1023
+ id: "missing-hook-command",
1024
+ ticketUrl: null,
1025
+ repo: "agency",
1026
+ branch: "task/missing-hook-command",
1027
+ base: "main",
1028
+ },
1029
+ root,
1030
+ ),
1031
+ ),
1032
+ ),
1033
+ )
1034
+ const checkoutPath = join(root, "tasks/missing-hook-command/code/agency")
1035
+
1036
+ await expect(
1037
+ runTestEffect(
1038
+ WorktreeService.pipe(
1039
+ Effect.flatMap((service) =>
1040
+ service.materialize("missing-hook-command", undefined, root),
1041
+ ),
1042
+ ),
1043
+ ),
1044
+ ).rejects.toThrow("Failed to start post-checkout command for 'agency'")
1045
+ expect(await Bun.file(checkoutPath).exists()).toBe(false)
1046
+ })
1047
+
666
1048
  test("moves and repairs an existing worktree when converting a task", async () => {
667
1049
  await runTestEffect(
668
1050
  TaskService.pipe(
@@ -8,7 +8,12 @@ import {
8
8
  expandWorktreeCreateCommand,
9
9
  worktreeCommandEnvironment,
10
10
  } from "../workbase/worktree-command"
11
- import type { RepositoryReference } from "../workbase/schemas"
11
+ import {
12
+ expandPostCheckoutCommand,
13
+ postCheckoutCommandEnvironment,
14
+ type CheckoutCommandVariables,
15
+ } from "../workbase/checkout-command"
16
+ import type { RepositoryReference, WorkbaseConfig } from "../workbase/schemas"
12
17
  import type { BaseCommandOptions } from "../utils/command"
13
18
  import { createLoggers } from "../utils/effect"
14
19
  import { withWorktreeLocks } from "./WorktreeLock"
@@ -35,6 +40,7 @@ interface WorkspaceOperation {
35
40
  | "create-branch"
36
41
  | "create-worktree"
37
42
  | "create-workspace"
43
+ | "post-checkout"
38
44
  readonly repo: string
39
45
  readonly command: readonly string[]
40
46
  readonly status: "planned" | "completed"
@@ -176,6 +182,70 @@ const formatCommand = (args: readonly string[]) =>
176
182
  )
177
183
  .join(" ")
178
184
 
185
+ const runPostCheckoutHook = (options: {
186
+ readonly command: readonly string[] | undefined
187
+ readonly variables: CheckoutCommandVariables
188
+ readonly dryRun: boolean
189
+ readonly forwardOutput: boolean
190
+ readonly verboseLog: (...args: unknown[]) => void
191
+ readonly operations: WorkspaceOperation[]
192
+ }) =>
193
+ Effect.gen(function* () {
194
+ if (!options.command) return
195
+ let command: string[]
196
+ try {
197
+ command = expandPostCheckoutCommand(options.command, options.variables)
198
+ } catch (cause) {
199
+ return yield* new WorktreeError({
200
+ message:
201
+ cause instanceof Error
202
+ ? cause.message
203
+ : "Invalid postCheckoutCommand",
204
+ cause,
205
+ })
206
+ }
207
+ options.verboseLog(
208
+ `${options.dryRun ? "Planning" : "Running"} post-checkout command for '${options.variables.repoAlias}': ${formatCommand(command)}`,
209
+ )
210
+ if (options.dryRun) {
211
+ options.operations.push({
212
+ action: "post-checkout",
213
+ repo: options.variables.repoAlias,
214
+ command,
215
+ status: "planned",
216
+ })
217
+ return
218
+ }
219
+ const result = yield* FileSystemService.pipe(
220
+ Effect.flatMap((fs) =>
221
+ fs.runCommand(command, {
222
+ cwd: options.variables.checkoutPath,
223
+ captureOutput: true,
224
+ forwardOutput: options.forwardOutput,
225
+ env: postCheckoutCommandEnvironment(options.variables),
226
+ }),
227
+ ),
228
+ Effect.mapError(
229
+ (cause) =>
230
+ new WorktreeError({
231
+ message: `Failed to start post-checkout command for '${options.variables.repoAlias}': ${cause.message}`,
232
+ cause,
233
+ }),
234
+ ),
235
+ )
236
+ if (result.exitCode !== 0) {
237
+ return yield* new WorktreeError({
238
+ message: `Post-checkout command failed for '${options.variables.repoAlias}' with exit code ${result.exitCode}${result.stderr ? `: ${result.stderr}` : ""}`,
239
+ })
240
+ }
241
+ options.operations.push({
242
+ action: "post-checkout",
243
+ repo: options.variables.repoAlias,
244
+ command,
245
+ status: "completed",
246
+ })
247
+ })
248
+
179
249
  const isCommitId = (ref: string) => /^[0-9a-f]{40,64}$/i.test(ref)
180
250
 
181
251
  const originRef = (ref: string) =>
@@ -812,6 +882,7 @@ const materializeJj = (options: {
812
882
  | { readonly repo: string; readonly branch: string }
813
883
  | RepositoryReference
814
884
  )[]
885
+ readonly config: WorkbaseConfig
815
886
  readonly commandOptions: MaterializeOptions
816
887
  }) =>
817
888
  Effect.gen(function* () {
@@ -826,6 +897,11 @@ const materializeJj = (options: {
826
897
  workspaceName: string
827
898
  }[] = []
828
899
  const base = "base" in options.execution ? options.execution.base : null
900
+ const { verboseLog } = createLoggers(options.commandOptions)
901
+ const forwardCommandOutput =
902
+ options.commandOptions.verbose === true &&
903
+ !options.commandOptions.silent &&
904
+ !options.commandOptions.json
829
905
 
830
906
  if (!options.commandOptions.dryRun)
831
907
  yield* fs.createDirectory(options.codePath)
@@ -920,7 +996,37 @@ const materializeJj = (options: {
920
996
  ...("branch" in checkout ? { branch: checkout.branch } : {}),
921
997
  })
922
998
  created.push({ repositoryPath, workspacePath, workspaceName })
999
+ const canonicalWorkspacePath = yield* fs.realPath(workspacePath)
1000
+ const registeredAfterCreate = (yield* backend.listWorkspaces(
1001
+ repositoryPath,
1002
+ )).find((workspace) => workspace.path === canonicalWorkspacePath)
1003
+ const head = yield* backend.workspaceHead(workspacePath)
1004
+ if (!registeredAfterCreate || head !== revision) {
1005
+ return yield* new WorktreeError({
1006
+ message: `Created jj workspace for '${checkout.repo}' failed validation`,
1007
+ })
1008
+ }
923
1009
  }
1010
+ yield* runPostCheckoutHook({
1011
+ command:
1012
+ options.config.repositories?.[checkout.repo]?.postCheckoutCommand,
1013
+ variables: {
1014
+ repoAlias: checkout.repo,
1015
+ repositoryPath,
1016
+ checkoutPath: workspacePath,
1017
+ checkoutKind: "branch" in checkout ? "writable" : "reference",
1018
+ requestedRef: requestedRevision,
1019
+ base: base ?? "",
1020
+ vcs: "jj",
1021
+ workbaseRoot: options.root,
1022
+ taskId: options.taskId,
1023
+ phaseId: options.phaseId ?? "",
1024
+ },
1025
+ dryRun: options.commandOptions.dryRun === true,
1026
+ forwardOutput: forwardCommandOutput,
1027
+ verboseLog,
1028
+ operations,
1029
+ })
924
1030
  reports.push({
925
1031
  repo: checkout.repo,
926
1032
  kind: "branch" in checkout ? "writable" : "reference",
@@ -957,16 +1063,36 @@ const materializeJj = (options: {
957
1063
  }).pipe(
958
1064
  Effect.catchAll((cause) =>
959
1065
  Effect.gen(function* () {
1066
+ const rolledBack: string[] = []
1067
+ const manualRecovery: string[] = []
960
1068
  for (const workspace of [...created].reverse()) {
961
- yield* backend
1069
+ const removed = yield* backend
962
1070
  .removeWorkspace({
963
1071
  repositoryPath: workspace.repositoryPath,
964
1072
  workspacePath: workspace.workspacePath,
965
1073
  workspaceName: workspace.workspaceName,
966
1074
  })
967
- .pipe(Effect.ignore)
1075
+ .pipe(
1076
+ Effect.as(true),
1077
+ Effect.catchAll(() => Effect.succeed(false)),
1078
+ )
1079
+ if (removed)
1080
+ rolledBack.push(`create-workspace ${workspace.workspaceName}`)
1081
+ else
1082
+ manualRecovery.push(
1083
+ `Remove jj workspace ${workspace.workspacePath}`,
1084
+ )
968
1085
  }
969
- return yield* Effect.fail(cause)
1086
+ return yield* new WorktreeError({
1087
+ message: `${cause instanceof Error ? cause.message : "Workspace creation failed"}. ${
1088
+ manualRecovery.length
1089
+ ? "Some effects require manual recovery"
1090
+ : "Created jj workspaces were rolled back"
1091
+ }`,
1092
+ rolledBack,
1093
+ manualRecovery,
1094
+ cause,
1095
+ })
970
1096
  }),
971
1097
  ),
972
1098
  )
@@ -1245,6 +1371,7 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
1245
1371
  codePath,
1246
1372
  execution,
1247
1373
  requestedCheckouts,
1374
+ config,
1248
1375
  commandOptions: options,
1249
1376
  })
1250
1377
  }
@@ -1695,6 +1822,26 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
1695
1822
  { captureOutput: true },
1696
1823
  )
1697
1824
  }
1825
+ yield* runPostCheckoutHook({
1826
+ command:
1827
+ config.repositories?.[alias]?.postCheckoutCommand,
1828
+ variables: {
1829
+ repoAlias: alias,
1830
+ repositoryPath,
1831
+ checkoutPath,
1832
+ checkoutKind: "writable",
1833
+ requestedRef: checkout.branch,
1834
+ base: executionBase,
1835
+ vcs: "git",
1836
+ workbaseRoot: root,
1837
+ taskId,
1838
+ phaseId: phaseId ?? "",
1839
+ },
1840
+ dryRun: true,
1841
+ forwardOutput: false,
1842
+ verboseLog,
1843
+ operations,
1844
+ })
1698
1845
  checkoutReports.push({
1699
1846
  repo: alias,
1700
1847
  kind: "writable",
@@ -1739,6 +1886,38 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
1739
1886
  ["git", "-C", checkoutPath, "rev-parse", "HEAD"],
1740
1887
  { captureOutput: true },
1741
1888
  )
1889
+ const currentBranch = yield* fs.runCommand(
1890
+ ["git", "-C", checkoutPath, "branch", "--show-current"],
1891
+ { captureOutput: true },
1892
+ )
1893
+ if (
1894
+ head.exitCode !== 0 ||
1895
+ currentBranch.exitCode !== 0 ||
1896
+ currentBranch.stdout.trim() !== checkout.branch
1897
+ ) {
1898
+ return yield* new WorktreeError({
1899
+ message: `Created worktree for '${alias}' failed validation for branch '${checkout.branch}'`,
1900
+ })
1901
+ }
1902
+ yield* runPostCheckoutHook({
1903
+ command: config.repositories?.[alias]?.postCheckoutCommand,
1904
+ variables: {
1905
+ repoAlias: alias,
1906
+ repositoryPath,
1907
+ checkoutPath,
1908
+ checkoutKind: "writable",
1909
+ requestedRef: checkout.branch,
1910
+ base: executionBase,
1911
+ vcs: "git",
1912
+ workbaseRoot: root,
1913
+ taskId,
1914
+ phaseId: phaseId ?? "",
1915
+ },
1916
+ dryRun: false,
1917
+ forwardOutput: forwardCommandOutput,
1918
+ verboseLog,
1919
+ operations,
1920
+ })
1742
1921
  checkoutReports.push({
1743
1922
  repo: alias,
1744
1923
  kind: "writable",
@@ -1831,6 +2010,26 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
1831
2010
  command,
1832
2011
  status: "planned",
1833
2012
  })
2013
+ yield* runPostCheckoutHook({
2014
+ command:
2015
+ config.repositories?.[alias]?.postCheckoutCommand,
2016
+ variables: {
2017
+ repoAlias: alias,
2018
+ repositoryPath,
2019
+ checkoutPath,
2020
+ checkoutKind: "reference",
2021
+ requestedRef: checkout.ref,
2022
+ base: executionBase,
2023
+ vcs: "git",
2024
+ workbaseRoot: root,
2025
+ taskId,
2026
+ phaseId: phaseId ?? "",
2027
+ },
2028
+ dryRun: true,
2029
+ forwardOutput: false,
2030
+ verboseLog,
2031
+ operations,
2032
+ })
1834
2033
  checkoutReports.push({
1835
2034
  repo: alias,
1836
2035
  kind: "reference",
@@ -1855,6 +2054,34 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
1855
2054
  command,
1856
2055
  status: "completed",
1857
2056
  })
2057
+ const head = yield* fs.runCommand(
2058
+ ["git", "-C", checkoutPath, "rev-parse", "HEAD"],
2059
+ { captureOutput: true },
2060
+ )
2061
+ if (head.exitCode !== 0 || head.stdout.trim() !== commit) {
2062
+ return yield* new WorktreeError({
2063
+ message: `Created reference checkout for '${alias}' failed validation for '${checkout.ref}'`,
2064
+ })
2065
+ }
2066
+ yield* runPostCheckoutHook({
2067
+ command: config.repositories?.[alias]?.postCheckoutCommand,
2068
+ variables: {
2069
+ repoAlias: alias,
2070
+ repositoryPath,
2071
+ checkoutPath,
2072
+ checkoutKind: "reference",
2073
+ requestedRef: checkout.ref,
2074
+ base: executionBase,
2075
+ vcs: "git",
2076
+ workbaseRoot: root,
2077
+ taskId,
2078
+ phaseId: phaseId ?? "",
2079
+ },
2080
+ dryRun: false,
2081
+ forwardOutput: forwardCommandOutput,
2082
+ verboseLog,
2083
+ operations,
2084
+ })
1858
2085
  checkoutReports.push({
1859
2086
  repo: alias,
1860
2087
  kind: "reference",
@@ -1918,6 +2145,7 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
1918
2145
  join(root, "repos", checkout.repo),
1919
2146
  "worktree",
1920
2147
  "remove",
2148
+ "--force",
1921
2149
  checkoutPath,
1922
2150
  ],
1923
2151
  { captureOutput: true },
@@ -0,0 +1,62 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import {
3
+ expandPostCheckoutCommand,
4
+ postCheckoutCommandEnvironment,
5
+ } from "./checkout-command"
6
+
7
+ const variables = {
8
+ repoAlias: "app",
9
+ repositoryPath: "/work/repos/app",
10
+ checkoutPath: "/work/tasks/example/code/app",
11
+ checkoutKind: "reference" as const,
12
+ requestedRef: "main",
13
+ base: "",
14
+ vcs: "jj" as const,
15
+ workbaseRoot: "/work",
16
+ taskId: "example",
17
+ phaseId: "",
18
+ }
19
+
20
+ describe("post-checkout command templates", () => {
21
+ test("expands all context placeholders without shell interpolation", () => {
22
+ expect(
23
+ expandPostCheckoutCommand(
24
+ [
25
+ "tool",
26
+ "{repoAlias}",
27
+ "{repositoryPath}",
28
+ "{checkoutPath}",
29
+ "{checkoutKind}",
30
+ "{requestedRef}",
31
+ "{base}",
32
+ "{vcs}",
33
+ "{workbaseRoot}",
34
+ "{taskId}",
35
+ "{phaseId}",
36
+ ],
37
+ variables,
38
+ ),
39
+ ).toEqual(["tool", ...Object.values(variables)])
40
+ })
41
+
42
+ test("rejects unknown placeholders", () => {
43
+ expect(() =>
44
+ expandPostCheckoutCommand(["tool", "{unknown}"], variables),
45
+ ).toThrow("{unknown}")
46
+ })
47
+
48
+ test("provides matching environment variables with empty optional values", () => {
49
+ expect(postCheckoutCommandEnvironment(variables)).toEqual({
50
+ AGENCY_REPO_ALIAS: variables.repoAlias,
51
+ AGENCY_REPOSITORY_PATH: variables.repositoryPath,
52
+ AGENCY_CHECKOUT_PATH: variables.checkoutPath,
53
+ AGENCY_CHECKOUT_KIND: variables.checkoutKind,
54
+ AGENCY_REQUESTED_REF: variables.requestedRef,
55
+ AGENCY_BASE: "",
56
+ AGENCY_VCS: variables.vcs,
57
+ AGENCY_WORKBASE_ROOT: variables.workbaseRoot,
58
+ AGENCY_TASK_ID: variables.taskId,
59
+ AGENCY_PHASE_ID: "",
60
+ })
61
+ })
62
+ })
@@ -0,0 +1,66 @@
1
+ export interface CheckoutCommandVariables {
2
+ readonly repoAlias: string
3
+ readonly repositoryPath: string
4
+ readonly checkoutPath: string
5
+ readonly checkoutKind: "writable" | "reference"
6
+ readonly requestedRef: string
7
+ readonly base: string
8
+ readonly vcs: "git" | "jj"
9
+ readonly workbaseRoot: string
10
+ readonly taskId: string
11
+ readonly phaseId: string
12
+ }
13
+
14
+ const PLACEHOLDERS = new Set<keyof CheckoutCommandVariables>([
15
+ "repoAlias",
16
+ "repositoryPath",
17
+ "checkoutPath",
18
+ "checkoutKind",
19
+ "requestedRef",
20
+ "base",
21
+ "vcs",
22
+ "workbaseRoot",
23
+ "taskId",
24
+ "phaseId",
25
+ ])
26
+
27
+ export const validatePostCheckoutCommand = (command: readonly string[]) => {
28
+ for (const argument of command) {
29
+ for (const match of argument.matchAll(/\{([^{}]+)\}/g)) {
30
+ const placeholder = match[1]!
31
+ if (!PLACEHOLDERS.has(placeholder as keyof CheckoutCommandVariables)) {
32
+ throw new Error(
33
+ `Unknown postCheckoutCommand placeholder: {${placeholder}}`,
34
+ )
35
+ }
36
+ }
37
+ }
38
+ }
39
+
40
+ export const expandPostCheckoutCommand = (
41
+ command: readonly string[],
42
+ variables: CheckoutCommandVariables,
43
+ ): string[] => {
44
+ validatePostCheckoutCommand(command)
45
+
46
+ return command.map((argument) =>
47
+ argument.replaceAll(/\{([^{}]+)\}/g, (match, placeholder: string) => {
48
+ return variables[placeholder as keyof CheckoutCommandVariables] ?? match
49
+ }),
50
+ )
51
+ }
52
+
53
+ export const postCheckoutCommandEnvironment = (
54
+ variables: CheckoutCommandVariables,
55
+ ): Record<string, string> => ({
56
+ AGENCY_REPO_ALIAS: variables.repoAlias,
57
+ AGENCY_REPOSITORY_PATH: variables.repositoryPath,
58
+ AGENCY_CHECKOUT_PATH: variables.checkoutPath,
59
+ AGENCY_CHECKOUT_KIND: variables.checkoutKind,
60
+ AGENCY_REQUESTED_REF: variables.requestedRef,
61
+ AGENCY_BASE: variables.base,
62
+ AGENCY_VCS: variables.vcs,
63
+ AGENCY_WORKBASE_ROOT: variables.workbaseRoot,
64
+ AGENCY_TASK_ID: variables.taskId,
65
+ AGENCY_PHASE_ID: variables.phaseId,
66
+ })
@@ -187,6 +187,40 @@ describe("body-of-work descriptions", () => {
187
187
  })
188
188
  })
189
189
 
190
+ describe("repository post-checkout configuration", () => {
191
+ test("accepts a per-repository argv command", () => {
192
+ const config = Schema.decodeUnknownSync(WorkbaseConfig)({
193
+ version: 2,
194
+ repositories: {
195
+ agency: {
196
+ remote: "https://example.com/agency.git",
197
+ postCheckoutCommand: ["bun", "install", "--frozen-lockfile"],
198
+ },
199
+ },
200
+ })
201
+
202
+ expect(config.repositories?.agency?.postCheckoutCommand).toEqual([
203
+ "bun",
204
+ "install",
205
+ "--frozen-lockfile",
206
+ ])
207
+ })
208
+
209
+ test("rejects shell strings in place of argv arrays", () => {
210
+ expect(() =>
211
+ Schema.decodeUnknownSync(WorkbaseConfig)({
212
+ version: 2,
213
+ repositories: {
214
+ agency: {
215
+ remote: "https://example.com/agency.git",
216
+ postCheckoutCommand: "bun install",
217
+ },
218
+ },
219
+ }),
220
+ ).toThrow()
221
+ })
222
+ })
223
+
190
224
  describe("runner configuration", () => {
191
225
  test("accepts named argv commands with resume commands and environment", () => {
192
226
  const config = Schema.decodeUnknownSync(WorkbaseConfig)({
@@ -24,6 +24,7 @@ export const RepositoryRemote = NonEmptyString.pipe(
24
24
 
25
25
  export const RepositoryDeclaration = Schema.Struct({
26
26
  remote: RepositoryRemote,
27
+ postCheckoutCommand: Schema.optional(Schema.NonEmptyArray(NonEmptyString)),
27
28
  })
28
29
 
29
30
  export const RepositoryReference = Schema.Struct({