@markjaquith/agency 2.58.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/package.json
CHANGED
|
@@ -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,6 +22,17 @@ 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
|
|
27
38
|
const dropTask = (id: string) =>
|
|
@@ -265,6 +276,166 @@ describe("ArchiveService", () => {
|
|
|
265
276
|
).toBe(0)
|
|
266
277
|
})
|
|
267
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
|
+
|
|
268
439
|
test("archives a phase in the mirrored task hierarchy", async () => {
|
|
269
440
|
await runTestEffect(
|
|
270
441
|
TaskService.pipe(
|
|
@@ -770,6 +770,81 @@ const jjWorkspaceName = (
|
|
|
770
770
|
repo: string,
|
|
771
771
|
) => `agency-${taskId}-${phaseId ?? "task"}-${repo}`
|
|
772
772
|
|
|
773
|
+
// A process can stop after `jj workspace forget` but before checkout deletion.
|
|
774
|
+
// Only recover that residual when its repository and complete tree still match.
|
|
775
|
+
const inspectJjResidual = (
|
|
776
|
+
root: string,
|
|
777
|
+
repositoryPath: string,
|
|
778
|
+
workspacePath: string,
|
|
779
|
+
revision: string,
|
|
780
|
+
) =>
|
|
781
|
+
Effect.gen(function* () {
|
|
782
|
+
const fs = yield* FileSystemService
|
|
783
|
+
const repoPointer = join(workspacePath, ".jj", "repo")
|
|
784
|
+
if (!(yield* fs.exists(repoPointer))) return "unowned" as const
|
|
785
|
+
|
|
786
|
+
const pointer = (yield* fs.readFile(repoPointer)).trim()
|
|
787
|
+
if (!pointer) return "unowned" as const
|
|
788
|
+
const linkedRepository = resolve(dirname(repoPointer), pointer)
|
|
789
|
+
const expectedRepository = join(repositoryPath, ".jj", "repo")
|
|
790
|
+
if (
|
|
791
|
+
!(yield* fs.exists(linkedRepository)) ||
|
|
792
|
+
(yield* fs.realPath(linkedRepository)) !==
|
|
793
|
+
(yield* fs.realPath(expectedRepository))
|
|
794
|
+
)
|
|
795
|
+
return "unowned" as const
|
|
796
|
+
|
|
797
|
+
const comparisonPath = join(
|
|
798
|
+
root,
|
|
799
|
+
`.agency-jj-residual-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
|
800
|
+
)
|
|
801
|
+
const comparisonName = basename(comparisonPath)
|
|
802
|
+
return yield* Effect.gen(function* () {
|
|
803
|
+
const created = yield* fs.runCommand(
|
|
804
|
+
[
|
|
805
|
+
"jj",
|
|
806
|
+
"-R",
|
|
807
|
+
repositoryPath,
|
|
808
|
+
"workspace",
|
|
809
|
+
"add",
|
|
810
|
+
"--name",
|
|
811
|
+
comparisonName,
|
|
812
|
+
"-r",
|
|
813
|
+
revision,
|
|
814
|
+
comparisonPath,
|
|
815
|
+
],
|
|
816
|
+
{ captureOutput: true },
|
|
817
|
+
)
|
|
818
|
+
if (created.exitCode !== 0) {
|
|
819
|
+
return yield* new WorktreeError({
|
|
820
|
+
message: `Cannot verify unregistered jj checkout ${workspacePath}: ${created.stderr}`,
|
|
821
|
+
})
|
|
822
|
+
}
|
|
823
|
+
const comparison = yield* fs.runCommand(
|
|
824
|
+
["diff", "-qr", "-x", ".jj", workspacePath, comparisonPath],
|
|
825
|
+
{ captureOutput: true },
|
|
826
|
+
)
|
|
827
|
+
if (comparison.exitCode > 1) {
|
|
828
|
+
return yield* new WorktreeError({
|
|
829
|
+
message: `Cannot verify unregistered jj checkout ${workspacePath}: ${comparison.stderr}`,
|
|
830
|
+
})
|
|
831
|
+
}
|
|
832
|
+
return comparison.exitCode === 0
|
|
833
|
+
? ("clean" as const)
|
|
834
|
+
: ("modified" as const)
|
|
835
|
+
}).pipe(
|
|
836
|
+
Effect.ensuring(
|
|
837
|
+
Effect.gen(function* () {
|
|
838
|
+
yield* fs.runCommand(
|
|
839
|
+
["jj", "-R", repositoryPath, "workspace", "forget", comparisonName],
|
|
840
|
+
{ captureOutput: true },
|
|
841
|
+
)
|
|
842
|
+
yield* fs.deleteDirectory(comparisonPath)
|
|
843
|
+
}).pipe(Effect.ignore),
|
|
844
|
+
),
|
|
845
|
+
)
|
|
846
|
+
})
|
|
847
|
+
|
|
773
848
|
const buildInspectionContext = (
|
|
774
849
|
startPath: string,
|
|
775
850
|
skipWritableRevisionResolution: boolean,
|
|
@@ -1103,10 +1178,63 @@ const removeJj = (
|
|
|
1103
1178
|
Effect.gen(function* () {
|
|
1104
1179
|
const fs = yield* FileSystemService
|
|
1105
1180
|
const versionControl = yield* VersionControlService
|
|
1181
|
+
const tasks = yield* TaskService
|
|
1182
|
+
const phases = yield* PhaseService
|
|
1106
1183
|
const backend = yield* versionControl.forWorkbase(root)
|
|
1107
1184
|
const inspection = yield* inspectExecution(taskId, phaseId, root)
|
|
1108
|
-
const
|
|
1109
|
-
|
|
1185
|
+
const task = yield* tasks.show(taskId, root)
|
|
1186
|
+
const execution =
|
|
1187
|
+
"phases" in task.data && phaseId
|
|
1188
|
+
? (yield* phases.show(taskId, phaseId, root)).data
|
|
1189
|
+
: task.data
|
|
1190
|
+
const recoverable = new Map<string, string>()
|
|
1191
|
+
const modifiedResiduals = new Map<string, string>()
|
|
1192
|
+
for (const checkout of inspection.checkouts) {
|
|
1193
|
+
const recoveryRevision =
|
|
1194
|
+
checkout.expectedCommit ??
|
|
1195
|
+
(checkout.kind === "writable" && "base" in execution
|
|
1196
|
+
? yield* backend.resolveRevision(
|
|
1197
|
+
join(root, "repos", checkout.repo),
|
|
1198
|
+
execution.base,
|
|
1199
|
+
)
|
|
1200
|
+
: null)
|
|
1201
|
+
if (
|
|
1202
|
+
!checkout.conflicts.some(
|
|
1203
|
+
(conflict) => conflict.kind === "unregistered-checkout",
|
|
1204
|
+
) ||
|
|
1205
|
+
!recoveryRevision
|
|
1206
|
+
)
|
|
1207
|
+
continue
|
|
1208
|
+
const repositoryPath = join(root, "repos", checkout.repo)
|
|
1209
|
+
const residual = yield* inspectJjResidual(
|
|
1210
|
+
root,
|
|
1211
|
+
repositoryPath,
|
|
1212
|
+
checkout.path,
|
|
1213
|
+
recoveryRevision,
|
|
1214
|
+
)
|
|
1215
|
+
if (residual === "clean") recoverable.set(checkout.path, recoveryRevision)
|
|
1216
|
+
else if (residual === "modified")
|
|
1217
|
+
modifiedResiduals.set(checkout.path, recoveryRevision)
|
|
1218
|
+
}
|
|
1219
|
+
const blocking = inspection.checkouts.flatMap((checkout) =>
|
|
1220
|
+
checkout.conflicts
|
|
1221
|
+
.filter(
|
|
1222
|
+
(conflict) =>
|
|
1223
|
+
conflict.kind !== "stale-registration" &&
|
|
1224
|
+
!(
|
|
1225
|
+
conflict.kind === "unregistered-checkout" &&
|
|
1226
|
+
recoverable.has(checkout.path)
|
|
1227
|
+
),
|
|
1228
|
+
)
|
|
1229
|
+
.map((conflict) =>
|
|
1230
|
+
conflict.kind === "unregistered-checkout" &&
|
|
1231
|
+
modifiedResiduals.has(checkout.path)
|
|
1232
|
+
? {
|
|
1233
|
+
...conflict,
|
|
1234
|
+
message: `Cannot remove unregistered jj checkout ${checkout.path}; its contents do not match expected revision '${modifiedResiduals.get(checkout.path)}'`,
|
|
1235
|
+
}
|
|
1236
|
+
: conflict,
|
|
1237
|
+
),
|
|
1110
1238
|
)
|
|
1111
1239
|
if (blocking.length > 0) {
|
|
1112
1240
|
return yield* new WorktreeError({
|
|
@@ -1121,6 +1249,7 @@ const removeJj = (
|
|
|
1121
1249
|
workspaceName: string
|
|
1122
1250
|
head: string
|
|
1123
1251
|
branch: string | null
|
|
1252
|
+
registered: boolean
|
|
1124
1253
|
}[] = []
|
|
1125
1254
|
for (const checkout of inspection.checkouts) {
|
|
1126
1255
|
if (checkout.dirty) {
|
|
@@ -1128,8 +1257,20 @@ const removeJj = (
|
|
|
1128
1257
|
message: `Failed to remove workspace for '${checkout.repo}': checkout has uncommitted changes`,
|
|
1129
1258
|
})
|
|
1130
1259
|
}
|
|
1131
|
-
if (!checkout.registeredPath) continue
|
|
1132
1260
|
const repositoryPath = join(root, "repos", checkout.repo)
|
|
1261
|
+
if (!checkout.registeredPath) {
|
|
1262
|
+
const recoveryRevision = recoverable.get(checkout.path)
|
|
1263
|
+
if (!recoveryRevision) continue
|
|
1264
|
+
plans.push({
|
|
1265
|
+
repositoryPath,
|
|
1266
|
+
workspacePath: checkout.path,
|
|
1267
|
+
workspaceName: jjWorkspaceName(taskId, phaseId, checkout.repo),
|
|
1268
|
+
head: recoveryRevision,
|
|
1269
|
+
branch: checkout.actualBranch,
|
|
1270
|
+
registered: false,
|
|
1271
|
+
})
|
|
1272
|
+
continue
|
|
1273
|
+
}
|
|
1133
1274
|
const registered = (yield* backend.listWorkspaces(repositoryPath)).find(
|
|
1134
1275
|
(workspace) => workspace.path === checkout.registeredPath,
|
|
1135
1276
|
)
|
|
@@ -1144,6 +1285,7 @@ const removeJj = (
|
|
|
1144
1285
|
workspaceName: registered.name,
|
|
1145
1286
|
head: checkout.actualCommit,
|
|
1146
1287
|
branch: checkout.actualBranch,
|
|
1288
|
+
registered: true,
|
|
1147
1289
|
})
|
|
1148
1290
|
}
|
|
1149
1291
|
|
|
@@ -1162,11 +1304,15 @@ const removeJj = (
|
|
|
1162
1304
|
const completed: typeof plans = []
|
|
1163
1305
|
return yield* Effect.gen(function* () {
|
|
1164
1306
|
for (const plan of plans) {
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1307
|
+
if (plan.registered) {
|
|
1308
|
+
yield* backend.removeWorkspace({
|
|
1309
|
+
repositoryPath: plan.repositoryPath,
|
|
1310
|
+
workspacePath: plan.workspacePath,
|
|
1311
|
+
workspaceName: plan.workspaceName,
|
|
1312
|
+
})
|
|
1313
|
+
} else {
|
|
1314
|
+
yield* fs.deleteDirectory(plan.workspacePath)
|
|
1315
|
+
}
|
|
1170
1316
|
completed.push(plan)
|
|
1171
1317
|
}
|
|
1172
1318
|
yield* fs.deleteDirectoryIfEmpty(inspection.codePath)
|