@markjaquith/agency 2.54.4 → 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 +68 -1
- package/cli-main.ts +20 -0
- package/package.json +1 -1
- package/src/cli-parser.test.ts +9 -0
- package/src/cli-parser.ts +10 -0
- package/src/cli.test.ts +1 -0
- package/src/commands/push.ts +25 -0
- package/src/protocol.ts +1 -0
- package/src/services/DoctorService.ts +12 -0
- package/src/services/IntegrationService.test.ts +2 -0
- package/src/services/PushService.test.ts +516 -0
- package/src/services/PushService.ts +576 -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/test-utils.ts +2 -0
- package/src/workbase/AGENTS.md +5 -0
- 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
|
@@ -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(
|