@markjaquith/agency 2.57.0 → 2.58.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -6
- package/package.json +1 -1
- package/src/cli-parser.test.ts +8 -0
- package/src/cli-parser.ts +7 -1
- package/src/commands/archive.test.ts +39 -1
- package/src/commands/archive.ts +25 -2
- package/src/commands/restore.test.ts +8 -0
- package/src/services/ArchiveBulkService.test.ts +485 -0
- package/src/services/ArchiveService.test.ts +336 -2
- package/src/services/ArchiveService.ts +447 -29
- package/src/services/WorktreeService.test.ts +4 -0
- package/src/services/WorktreeService.ts +155 -13
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
|
2
2
|
import { Effect } from "effect"
|
|
3
|
-
import { mkdir } from "node:fs/promises"
|
|
3
|
+
import { mkdir, rm } from "node:fs/promises"
|
|
4
4
|
import { join } from "node:path"
|
|
5
5
|
import { cleanupTempDir, createTempDir, runTestEffect } from "../test-utils"
|
|
6
6
|
import { ArchiveService } from "./ArchiveService"
|
|
@@ -22,8 +22,33 @@ const git = async (args: string[], cwd?: string) => {
|
|
|
22
22
|
}
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
const jj = async (args: string[]) => {
|
|
26
|
+
const process = Bun.spawn(["jj", ...args], {
|
|
27
|
+
stdout: "pipe",
|
|
28
|
+
stderr: "pipe",
|
|
29
|
+
})
|
|
30
|
+
await process.exited
|
|
31
|
+
if (process.exitCode !== 0) {
|
|
32
|
+
throw new Error(await new Response(process.stderr).text())
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
25
36
|
describe("ArchiveService", () => {
|
|
26
37
|
let root: string
|
|
38
|
+
const dropTask = (id: string) =>
|
|
39
|
+
runTestEffect(
|
|
40
|
+
TaskService.pipe(
|
|
41
|
+
Effect.flatMap((service) => service.setStatus(id, "dropped", root)),
|
|
42
|
+
),
|
|
43
|
+
)
|
|
44
|
+
const dropPhase = (taskId: string, id: string) =>
|
|
45
|
+
runTestEffect(
|
|
46
|
+
PhaseService.pipe(
|
|
47
|
+
Effect.flatMap((service) =>
|
|
48
|
+
service.setStatus(taskId, id, "dropped", root),
|
|
49
|
+
),
|
|
50
|
+
),
|
|
51
|
+
)
|
|
27
52
|
|
|
28
53
|
beforeEach(async () => {
|
|
29
54
|
root = await createTempDir()
|
|
@@ -42,6 +67,142 @@ describe("ArchiveService", () => {
|
|
|
42
67
|
|
|
43
68
|
afterEach(async () => cleanupTempDir(root))
|
|
44
69
|
|
|
70
|
+
test("requires terminal effective status for singular task archival", async () => {
|
|
71
|
+
await runTestEffect(
|
|
72
|
+
TaskService.pipe(
|
|
73
|
+
Effect.flatMap((service) =>
|
|
74
|
+
service.create(
|
|
75
|
+
{
|
|
76
|
+
id: "status-check",
|
|
77
|
+
ticketUrl: null,
|
|
78
|
+
repo: "agency",
|
|
79
|
+
branch: "task/status-check",
|
|
80
|
+
base: "main",
|
|
81
|
+
},
|
|
82
|
+
root,
|
|
83
|
+
),
|
|
84
|
+
),
|
|
85
|
+
),
|
|
86
|
+
)
|
|
87
|
+
await expect(
|
|
88
|
+
runTestEffect(
|
|
89
|
+
ArchiveService.pipe(
|
|
90
|
+
Effect.flatMap((service) =>
|
|
91
|
+
service.archiveTask("status-check", root),
|
|
92
|
+
),
|
|
93
|
+
),
|
|
94
|
+
),
|
|
95
|
+
).rejects.toThrow("status=open")
|
|
96
|
+
await runTestEffect(
|
|
97
|
+
TaskService.pipe(
|
|
98
|
+
Effect.flatMap((service) =>
|
|
99
|
+
service.setStatus("status-check", "working", root),
|
|
100
|
+
),
|
|
101
|
+
),
|
|
102
|
+
)
|
|
103
|
+
await expect(
|
|
104
|
+
runTestEffect(
|
|
105
|
+
ArchiveService.pipe(
|
|
106
|
+
Effect.flatMap((service) =>
|
|
107
|
+
service.archiveTask("status-check", root),
|
|
108
|
+
),
|
|
109
|
+
),
|
|
110
|
+
),
|
|
111
|
+
).rejects.toThrow("status=working")
|
|
112
|
+
await runTestEffect(
|
|
113
|
+
TaskService.pipe(
|
|
114
|
+
Effect.flatMap((service) =>
|
|
115
|
+
service.setStatus("status-check", "dropped", root),
|
|
116
|
+
),
|
|
117
|
+
),
|
|
118
|
+
)
|
|
119
|
+
const result = await runTestEffect(
|
|
120
|
+
ArchiveService.pipe(
|
|
121
|
+
Effect.flatMap((service) =>
|
|
122
|
+
service.archiveTask("status-check", root, { dryRun: true }),
|
|
123
|
+
),
|
|
124
|
+
),
|
|
125
|
+
)
|
|
126
|
+
expect(result.dryRun).toBe(true)
|
|
127
|
+
|
|
128
|
+
await runTestEffect(
|
|
129
|
+
TaskService.pipe(
|
|
130
|
+
Effect.flatMap((service) =>
|
|
131
|
+
service.create(
|
|
132
|
+
{
|
|
133
|
+
id: "done-check",
|
|
134
|
+
ticketUrl: null,
|
|
135
|
+
repo: "agency",
|
|
136
|
+
branch: "task/done-check",
|
|
137
|
+
base: "main",
|
|
138
|
+
},
|
|
139
|
+
root,
|
|
140
|
+
),
|
|
141
|
+
),
|
|
142
|
+
),
|
|
143
|
+
)
|
|
144
|
+
const donePath = join(root, "tasks/done-check/TASK.md")
|
|
145
|
+
await Bun.write(
|
|
146
|
+
donePath,
|
|
147
|
+
(await Bun.file(donePath).text()).replace("status: open", "status: done"),
|
|
148
|
+
)
|
|
149
|
+
const doneResult = await runTestEffect(
|
|
150
|
+
ArchiveService.pipe(
|
|
151
|
+
Effect.flatMap((service) =>
|
|
152
|
+
service.archiveTask("done-check", root, { dryRun: true }),
|
|
153
|
+
),
|
|
154
|
+
),
|
|
155
|
+
)
|
|
156
|
+
expect(doneResult.dryRun).toBe(true)
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
test("rejects empty and partially terminal multi-phase tasks in singular archival", async () => {
|
|
160
|
+
await runTestEffect(
|
|
161
|
+
TaskService.pipe(
|
|
162
|
+
Effect.flatMap((service) =>
|
|
163
|
+
service.create(
|
|
164
|
+
{ id: "empty", ticketUrl: null, multiPhase: true },
|
|
165
|
+
root,
|
|
166
|
+
),
|
|
167
|
+
),
|
|
168
|
+
),
|
|
169
|
+
)
|
|
170
|
+
await expect(
|
|
171
|
+
runTestEffect(
|
|
172
|
+
ArchiveService.pipe(
|
|
173
|
+
Effect.flatMap((service) => service.archiveTask("empty", root)),
|
|
174
|
+
),
|
|
175
|
+
),
|
|
176
|
+
).rejects.toThrow("has no phases")
|
|
177
|
+
|
|
178
|
+
for (const id of ["terminal", "open"]) {
|
|
179
|
+
await runTestEffect(
|
|
180
|
+
PhaseService.pipe(
|
|
181
|
+
Effect.flatMap((service) =>
|
|
182
|
+
service.create(
|
|
183
|
+
{
|
|
184
|
+
taskId: "empty",
|
|
185
|
+
id,
|
|
186
|
+
repo: "agency",
|
|
187
|
+
branch: `task/empty-${id}`,
|
|
188
|
+
base: "main",
|
|
189
|
+
},
|
|
190
|
+
root,
|
|
191
|
+
),
|
|
192
|
+
),
|
|
193
|
+
),
|
|
194
|
+
)
|
|
195
|
+
}
|
|
196
|
+
await dropPhase("empty", "terminal")
|
|
197
|
+
await expect(
|
|
198
|
+
runTestEffect(
|
|
199
|
+
ArchiveService.pipe(
|
|
200
|
+
Effect.flatMap((service) => service.archiveTask("empty", root)),
|
|
201
|
+
),
|
|
202
|
+
),
|
|
203
|
+
).rejects.toThrow("phase:open:status=open")
|
|
204
|
+
})
|
|
205
|
+
|
|
45
206
|
test("archives a task after removing its worktree and preserves its branch", async () => {
|
|
46
207
|
await runTestEffect(
|
|
47
208
|
EpicService.pipe(
|
|
@@ -79,6 +240,7 @@ describe("ArchiveService", () => {
|
|
|
79
240
|
),
|
|
80
241
|
),
|
|
81
242
|
)
|
|
243
|
+
await dropTask("child")
|
|
82
244
|
|
|
83
245
|
const result = await runTestEffect(
|
|
84
246
|
ArchiveService.pipe(
|
|
@@ -114,6 +276,166 @@ describe("ArchiveService", () => {
|
|
|
114
276
|
).toBe(0)
|
|
115
277
|
})
|
|
116
278
|
|
|
279
|
+
test("retries archive after a jj workspace was forgotten but not deleted", async () => {
|
|
280
|
+
if (!Bun.which("jj")) return
|
|
281
|
+
const repository = join(root, "repos/agency")
|
|
282
|
+
await rm(repository, { recursive: true, force: true })
|
|
283
|
+
await git(["clone", join(root, "source"), repository])
|
|
284
|
+
await jj(["git", "init", "--colocate", repository])
|
|
285
|
+
await Bun.write(
|
|
286
|
+
join(root, "agency.json"),
|
|
287
|
+
JSON.stringify({ version: 2, vcs: "jj" }),
|
|
288
|
+
)
|
|
289
|
+
await runTestEffect(
|
|
290
|
+
TaskService.pipe(
|
|
291
|
+
Effect.flatMap((service) =>
|
|
292
|
+
service.create(
|
|
293
|
+
{
|
|
294
|
+
id: "interrupted",
|
|
295
|
+
ticketUrl: null,
|
|
296
|
+
repo: "agency",
|
|
297
|
+
branch: "task/interrupted",
|
|
298
|
+
base: "main",
|
|
299
|
+
},
|
|
300
|
+
root,
|
|
301
|
+
),
|
|
302
|
+
),
|
|
303
|
+
),
|
|
304
|
+
)
|
|
305
|
+
await runTestEffect(
|
|
306
|
+
TaskService.pipe(
|
|
307
|
+
Effect.flatMap((service) =>
|
|
308
|
+
service.setStatus("interrupted", "dropped", root),
|
|
309
|
+
),
|
|
310
|
+
),
|
|
311
|
+
)
|
|
312
|
+
const workspace = await runTestEffect(
|
|
313
|
+
WorktreeService.pipe(
|
|
314
|
+
Effect.flatMap((service) =>
|
|
315
|
+
service.materialize("interrupted", undefined, root),
|
|
316
|
+
),
|
|
317
|
+
),
|
|
318
|
+
)
|
|
319
|
+
await jj([
|
|
320
|
+
"-R",
|
|
321
|
+
repository,
|
|
322
|
+
"workspace",
|
|
323
|
+
"forget",
|
|
324
|
+
"agency-interrupted-task-agency",
|
|
325
|
+
])
|
|
326
|
+
|
|
327
|
+
const preview = await runTestEffect(
|
|
328
|
+
ArchiveService.pipe(
|
|
329
|
+
Effect.flatMap((service) =>
|
|
330
|
+
service.archiveTask("interrupted", root, { dryRun: true }),
|
|
331
|
+
),
|
|
332
|
+
),
|
|
333
|
+
)
|
|
334
|
+
expect(preview.removedWorktrees).toEqual([workspace.writablePath!])
|
|
335
|
+
|
|
336
|
+
await runTestEffect(
|
|
337
|
+
ArchiveService.pipe(
|
|
338
|
+
Effect.flatMap((service) => service.archiveTask("interrupted", root)),
|
|
339
|
+
),
|
|
340
|
+
)
|
|
341
|
+
expect(await Bun.file(workspace.writablePath!).exists()).toBe(false)
|
|
342
|
+
expect(
|
|
343
|
+
await Bun.file(join(root, "archive/tasks/interrupted/TASK.md")).exists(),
|
|
344
|
+
).toBe(true)
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
test("refuses a modified residual jj checkout after workspace removal", async () => {
|
|
348
|
+
if (!Bun.which("jj")) return
|
|
349
|
+
const repository = join(root, "repos/agency")
|
|
350
|
+
await rm(repository, { recursive: true, force: true })
|
|
351
|
+
await git(["clone", join(root, "source"), repository])
|
|
352
|
+
await jj(["git", "init", "--colocate", repository])
|
|
353
|
+
await Bun.write(
|
|
354
|
+
join(root, "agency.json"),
|
|
355
|
+
JSON.stringify({ version: 2, vcs: "jj" }),
|
|
356
|
+
)
|
|
357
|
+
await runTestEffect(
|
|
358
|
+
TaskService.pipe(
|
|
359
|
+
Effect.flatMap((service) =>
|
|
360
|
+
service.create(
|
|
361
|
+
{
|
|
362
|
+
id: "modified-residual",
|
|
363
|
+
ticketUrl: null,
|
|
364
|
+
repo: "agency",
|
|
365
|
+
branch: "task/modified-residual",
|
|
366
|
+
base: "main",
|
|
367
|
+
},
|
|
368
|
+
root,
|
|
369
|
+
),
|
|
370
|
+
),
|
|
371
|
+
),
|
|
372
|
+
)
|
|
373
|
+
await runTestEffect(
|
|
374
|
+
TaskService.pipe(
|
|
375
|
+
Effect.flatMap((service) =>
|
|
376
|
+
service.setStatus("modified-residual", "dropped", root),
|
|
377
|
+
),
|
|
378
|
+
),
|
|
379
|
+
)
|
|
380
|
+
const workspace = await runTestEffect(
|
|
381
|
+
WorktreeService.pipe(
|
|
382
|
+
Effect.flatMap((service) =>
|
|
383
|
+
service.materialize("modified-residual", undefined, root),
|
|
384
|
+
),
|
|
385
|
+
),
|
|
386
|
+
)
|
|
387
|
+
await jj([
|
|
388
|
+
"-R",
|
|
389
|
+
repository,
|
|
390
|
+
"workspace",
|
|
391
|
+
"forget",
|
|
392
|
+
"agency-modified-residual-task-agency",
|
|
393
|
+
])
|
|
394
|
+
await Bun.write(join(workspace.writablePath!, "keep.txt"), "keep me\n")
|
|
395
|
+
|
|
396
|
+
await expect(
|
|
397
|
+
runTestEffect(
|
|
398
|
+
ArchiveService.pipe(
|
|
399
|
+
Effect.flatMap((service) =>
|
|
400
|
+
service.archiveTask("modified-residual", root),
|
|
401
|
+
),
|
|
402
|
+
),
|
|
403
|
+
),
|
|
404
|
+
).rejects.toThrow("contents do not match expected revision")
|
|
405
|
+
expect(
|
|
406
|
+
await Bun.file(join(workspace.writablePath!, "keep.txt")).text(),
|
|
407
|
+
).toBe("keep me\n")
|
|
408
|
+
expect(
|
|
409
|
+
await Bun.file(join(root, "tasks/modified-residual/TASK.md")).exists(),
|
|
410
|
+
).toBe(true)
|
|
411
|
+
expect(
|
|
412
|
+
await Bun.file(
|
|
413
|
+
join(
|
|
414
|
+
root,
|
|
415
|
+
`.agency-worktree-${Buffer.from("modified-residual:task").toString("hex")}.lock`,
|
|
416
|
+
),
|
|
417
|
+
).exists(),
|
|
418
|
+
).toBe(false)
|
|
419
|
+
|
|
420
|
+
await rm(join(workspace.writablePath!, "keep.txt"))
|
|
421
|
+
await rm(join(workspace.writablePath!, ".jj"), {
|
|
422
|
+
recursive: true,
|
|
423
|
+
force: true,
|
|
424
|
+
})
|
|
425
|
+
await expect(
|
|
426
|
+
runTestEffect(
|
|
427
|
+
ArchiveService.pipe(
|
|
428
|
+
Effect.flatMap((service) =>
|
|
429
|
+
service.archiveTask("modified-residual", root),
|
|
430
|
+
),
|
|
431
|
+
),
|
|
432
|
+
),
|
|
433
|
+
).rejects.toThrow("is not registered as a jj workspace")
|
|
434
|
+
expect(
|
|
435
|
+
await Bun.file(join(workspace.writablePath!, "README.md")).exists(),
|
|
436
|
+
).toBe(true)
|
|
437
|
+
})
|
|
438
|
+
|
|
117
439
|
test("archives a phase in the mirrored task hierarchy", async () => {
|
|
118
440
|
await runTestEffect(
|
|
119
441
|
TaskService.pipe(
|
|
@@ -316,6 +638,7 @@ describe("ArchiveService", () => {
|
|
|
316
638
|
),
|
|
317
639
|
)
|
|
318
640
|
await Bun.write(join(workspace.writablePath!, "dirty.txt"), "keep me\n")
|
|
641
|
+
await dropTask("dirty")
|
|
319
642
|
|
|
320
643
|
await expect(
|
|
321
644
|
runTestEffect(
|
|
@@ -349,6 +672,7 @@ describe("ArchiveService", () => {
|
|
|
349
672
|
),
|
|
350
673
|
),
|
|
351
674
|
)
|
|
675
|
+
await dropTask("preview")
|
|
352
676
|
|
|
353
677
|
const archivePreview = await runTestEffect(
|
|
354
678
|
ArchiveService.pipe(
|
|
@@ -416,6 +740,7 @@ describe("ArchiveService", () => {
|
|
|
416
740
|
),
|
|
417
741
|
),
|
|
418
742
|
)
|
|
743
|
+
await dropTask("child")
|
|
419
744
|
await runTestEffect(
|
|
420
745
|
ArchiveService.pipe(
|
|
421
746
|
Effect.flatMap((service) => service.archiveTask("child", root)),
|
|
@@ -426,7 +751,11 @@ describe("ArchiveService", () => {
|
|
|
426
751
|
ArchiveService.pipe(
|
|
427
752
|
Effect.flatMap((service) =>
|
|
428
753
|
service.list(
|
|
429
|
-
{
|
|
754
|
+
{
|
|
755
|
+
kinds: ["task"],
|
|
756
|
+
repositories: ["agency"],
|
|
757
|
+
statuses: ["dropped"],
|
|
758
|
+
},
|
|
430
759
|
root,
|
|
431
760
|
),
|
|
432
761
|
),
|
|
@@ -688,6 +1017,7 @@ describe("ArchiveService", () => {
|
|
|
688
1017
|
),
|
|
689
1018
|
),
|
|
690
1019
|
)
|
|
1020
|
+
await dropTask("reserved")
|
|
691
1021
|
await runTestEffect(
|
|
692
1022
|
ArchiveService.pipe(
|
|
693
1023
|
Effect.flatMap((service) => service.archiveTask("reserved", root)),
|
|
@@ -737,6 +1067,7 @@ describe("ArchiveService", () => {
|
|
|
737
1067
|
),
|
|
738
1068
|
),
|
|
739
1069
|
)
|
|
1070
|
+
await dropTask("locked")
|
|
740
1071
|
await Bun.write(join(root, ".agency-archive.lock"), "held\n")
|
|
741
1072
|
|
|
742
1073
|
await expect(
|
|
@@ -784,6 +1115,7 @@ describe("ArchiveService", () => {
|
|
|
784
1115
|
),
|
|
785
1116
|
),
|
|
786
1117
|
)
|
|
1118
|
+
await dropTask("child")
|
|
787
1119
|
await runTestEffect(
|
|
788
1120
|
ArchiveService.pipe(
|
|
789
1121
|
Effect.flatMap((service) => service.archiveTask("child", root)),
|
|
@@ -895,6 +1227,8 @@ describe("ArchiveService", () => {
|
|
|
895
1227
|
join(root, "tasks/multi-dirty/phases/dirty/code/agency/dirty.txt"),
|
|
896
1228
|
"keep me\n",
|
|
897
1229
|
)
|
|
1230
|
+
await dropPhase("multi-dirty", "clean")
|
|
1231
|
+
await dropPhase("multi-dirty", "dirty")
|
|
898
1232
|
|
|
899
1233
|
await expect(
|
|
900
1234
|
runTestEffect(
|