@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.
@@ -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(