@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 +50 -0
- package/package.json +1 -1
- package/src/services/DoctorService.ts +12 -0
- package/src/services/WorkbaseService.test.ts +24 -0
- package/src/services/WorkbaseService.ts +18 -0
- package/src/services/WorktreeService.test.ts +383 -1
- package/src/services/WorktreeService.ts +232 -4
- package/src/workbase/checkout-command.test.ts +62 -0
- package/src/workbase/checkout-command.ts +66 -0
- package/src/workbase/schemas.test.ts +34 -0
- package/src/workbase/schemas.ts +1 -0
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
|
@@ -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({
|
|
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
|
|
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(
|
|
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*
|
|
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)({
|
package/src/workbase/schemas.ts
CHANGED
|
@@ -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({
|