@markjaquith/agency 2.26.0 → 2.28.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.
@@ -1,6 +1,6 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from "bun:test"
2
2
  import { Effect } from "effect"
3
- import { mkdir, rm } from "node:fs/promises"
3
+ import { mkdir, realpath, rename, rm } from "node:fs/promises"
4
4
  import { join } from "node:path"
5
5
  import {
6
6
  captureErrors,
@@ -1106,7 +1106,8 @@ pr: null
1106
1106
  ),
1107
1107
  )
1108
1108
 
1109
- expect(removed).toEqual([])
1109
+ expect(removed).toHaveLength(1)
1110
+ expect(removed[0]).toEndWith("/tasks/stale/code/agency")
1110
1111
  const worktrees = Bun.spawnSync([
1111
1112
  "git",
1112
1113
  "-C",
@@ -1130,6 +1131,456 @@ pr: null
1130
1131
  ).toBe(0)
1131
1132
  })
1132
1133
 
1134
+ test("lists and inspects ownership, registration, commits, and dirtiness", async () => {
1135
+ const repository = join(root, "repos/agency")
1136
+ await git(["-C", repository, "branch", "task/inspected", "main"])
1137
+ const outside = join(root, "outside-inspected")
1138
+ await git(["-C", repository, "worktree", "add", outside, "task/inspected"])
1139
+ await Bun.write(join(outside, "dirty.txt"), "keep\n")
1140
+ await runTestEffect(
1141
+ TaskService.pipe(
1142
+ Effect.flatMap((service) =>
1143
+ service.create(
1144
+ {
1145
+ id: "inspected",
1146
+ ticketUrl: null,
1147
+ repo: "agency",
1148
+ branch: "task/inspected",
1149
+ base: "main",
1150
+ },
1151
+ root,
1152
+ ),
1153
+ ),
1154
+ ),
1155
+ )
1156
+
1157
+ const inspections = await runTestEffect(
1158
+ WorktreeService.pipe(Effect.flatMap((service) => service.list(root))),
1159
+ )
1160
+ const inspection = inspections.find(
1161
+ ({ owner }) => owner.taskId === "inspected",
1162
+ )!
1163
+ const checkout = inspection.checkouts[0]!
1164
+ expect(inspection.owner).toMatchObject({
1165
+ kind: "task",
1166
+ taskId: "inspected",
1167
+ documentPath: join(root, "tasks/inspected/TASK.md"),
1168
+ })
1169
+ const registeredOutside = await realpath(outside)
1170
+ expect(checkout).toMatchObject({
1171
+ registeredPath: registeredOutside,
1172
+ actualBranch: "task/inspected",
1173
+ actualCommit: expect.stringMatching(/^[0-9a-f]{40}$/),
1174
+ dirty: true,
1175
+ })
1176
+ expect(checkout.conflicts).toContainEqual(
1177
+ expect.objectContaining({
1178
+ kind: "branch-conflict",
1179
+ registeredPath: registeredOutside,
1180
+ branch: "task/inspected",
1181
+ commit: expect.stringMatching(/^[0-9a-f]{40}$/),
1182
+ dirty: true,
1183
+ }),
1184
+ )
1185
+ })
1186
+
1187
+ test("refuses to remove a clean checkout owned by the wrong branch", async () => {
1188
+ await runTestEffect(
1189
+ TaskService.pipe(
1190
+ Effect.flatMap((service) =>
1191
+ service.create(
1192
+ {
1193
+ id: "remove-wrong",
1194
+ ticketUrl: null,
1195
+ repo: "agency",
1196
+ branch: "task/expected-remove",
1197
+ base: "main",
1198
+ },
1199
+ root,
1200
+ ),
1201
+ ),
1202
+ ),
1203
+ )
1204
+ const repository = join(root, "repos/agency")
1205
+ await git(["-C", repository, "branch", "task/actual-remove", "main"])
1206
+ const checkout = join(root, "tasks/remove-wrong/code/agency")
1207
+ await mkdir(join(root, "tasks/remove-wrong/code"), { recursive: true })
1208
+ await git([
1209
+ "-C",
1210
+ repository,
1211
+ "worktree",
1212
+ "add",
1213
+ checkout,
1214
+ "task/actual-remove",
1215
+ ])
1216
+
1217
+ await expect(
1218
+ runTestEffect(
1219
+ WorktreeService.pipe(
1220
+ Effect.flatMap((service) =>
1221
+ service.remove("remove-wrong", undefined, root),
1222
+ ),
1223
+ ),
1224
+ ),
1225
+ ).rejects.toThrow("not branch 'task/expected-remove'")
1226
+ expect(await Bun.file(join(checkout, "README.md")).exists()).toBe(true)
1227
+ })
1228
+
1229
+ test("does not delete a non-worktree entry named for an expected alias", async () => {
1230
+ await runTestEffect(
1231
+ TaskService.pipe(
1232
+ Effect.flatMap((service) =>
1233
+ service.create(
1234
+ {
1235
+ id: "unexpected-file",
1236
+ ticketUrl: null,
1237
+ repo: "agency",
1238
+ branch: "task/unexpected-file",
1239
+ base: "main",
1240
+ },
1241
+ root,
1242
+ ),
1243
+ ),
1244
+ ),
1245
+ )
1246
+ const codePath = join(root, "tasks/unexpected-file/code")
1247
+ const unexpected = join(codePath, "agency")
1248
+ await mkdir(codePath, { recursive: true })
1249
+ await Bun.write(unexpected, "keep me\n")
1250
+
1251
+ await expect(
1252
+ runTestEffect(
1253
+ WorktreeService.pipe(
1254
+ Effect.flatMap((service) =>
1255
+ service.remove("unexpected-file", undefined, root),
1256
+ ),
1257
+ ),
1258
+ ),
1259
+ ).rejects.toThrow("is not a directory")
1260
+ expect(await Bun.file(unexpected).text()).toBe("keep me\n")
1261
+ })
1262
+
1263
+ test("refuses removal when a branch has duplicate Agency owners", async () => {
1264
+ await runTestEffect(
1265
+ TaskService.pipe(
1266
+ Effect.flatMap((service) =>
1267
+ service.create(
1268
+ {
1269
+ id: "owned-removal",
1270
+ ticketUrl: null,
1271
+ repo: "agency",
1272
+ branch: "task/owned-removal",
1273
+ base: "main",
1274
+ },
1275
+ root,
1276
+ ),
1277
+ ),
1278
+ ),
1279
+ )
1280
+ const workspace = await runTestEffect(
1281
+ WorktreeService.pipe(
1282
+ Effect.flatMap((service) =>
1283
+ service.materialize("owned-removal", undefined, root),
1284
+ ),
1285
+ ),
1286
+ )
1287
+ await runTestEffect(
1288
+ TaskService.pipe(
1289
+ Effect.flatMap((service) =>
1290
+ service.create(
1291
+ {
1292
+ id: "other-owner",
1293
+ ticketUrl: null,
1294
+ repo: "agency",
1295
+ branch: "task/owned-removal",
1296
+ base: "main",
1297
+ },
1298
+ root,
1299
+ ),
1300
+ ),
1301
+ ),
1302
+ )
1303
+
1304
+ await expect(
1305
+ runTestEffect(
1306
+ WorktreeService.pipe(
1307
+ Effect.flatMap((service) =>
1308
+ service.remove("owned-removal", undefined, root),
1309
+ ),
1310
+ ),
1311
+ ),
1312
+ ).rejects.toThrow("multiple Agency owners")
1313
+ expect(
1314
+ await Bun.file(join(workspace.writablePath, "README.md")).exists(),
1315
+ ).toBe(true)
1316
+ })
1317
+
1318
+ test("dry-runs and rebuilds every clean declared checkout", async () => {
1319
+ await runTestEffect(
1320
+ TaskService.pipe(
1321
+ Effect.flatMap((service) =>
1322
+ service.create(
1323
+ {
1324
+ id: "rebuilt",
1325
+ ticketUrl: null,
1326
+ repo: "agency",
1327
+ repos: [{ repo: "effect", ref: "main" }],
1328
+ branch: "task/rebuilt",
1329
+ base: "main",
1330
+ },
1331
+ root,
1332
+ ),
1333
+ ),
1334
+ ),
1335
+ )
1336
+ const original = await runTestEffect(
1337
+ WorktreeService.pipe(
1338
+ Effect.flatMap((service) =>
1339
+ service.materialize("rebuilt", undefined, root),
1340
+ ),
1341
+ ),
1342
+ )
1343
+ const plan = await runTestEffect(
1344
+ WorktreeService.pipe(
1345
+ Effect.flatMap((service) =>
1346
+ service.rebuild("rebuilt", undefined, root, { dryRun: true }),
1347
+ ),
1348
+ ),
1349
+ )
1350
+ expect(plan.actions).toEqual(
1351
+ expect.arrayContaining([
1352
+ `remove ${join(original.codePath, "agency")}`,
1353
+ `create ${join(original.codePath, "agency")}`,
1354
+ `remove ${join(original.codePath, "effect")}`,
1355
+ `create ${join(original.codePath, "effect")}`,
1356
+ ]),
1357
+ )
1358
+ expect(
1359
+ await Bun.file(join(original.writablePath, "README.md")).exists(),
1360
+ ).toBe(true)
1361
+
1362
+ const rebuilt = await runTestEffect(
1363
+ WorktreeService.pipe(
1364
+ Effect.flatMap((service) =>
1365
+ service.rebuild("rebuilt", undefined, root),
1366
+ ),
1367
+ ),
1368
+ )
1369
+ expect(rebuilt.inspection.conflicts).toEqual([])
1370
+ expect(
1371
+ await Bun.file(join(original.writablePath, "README.md")).exists(),
1372
+ ).toBe(true)
1373
+ })
1374
+
1375
+ test("restores removed worktrees when rebuild creation fails", async () => {
1376
+ await runTestEffect(
1377
+ TaskService.pipe(
1378
+ Effect.flatMap((service) =>
1379
+ service.create(
1380
+ {
1381
+ id: "rebuild-rollback",
1382
+ ticketUrl: null,
1383
+ repo: "agency",
1384
+ branch: "task/rebuild-rollback",
1385
+ base: "main",
1386
+ },
1387
+ root,
1388
+ ),
1389
+ ),
1390
+ ),
1391
+ )
1392
+ const workspace = await runTestEffect(
1393
+ WorktreeService.pipe(
1394
+ Effect.flatMap((service) =>
1395
+ service.materialize("rebuild-rollback", undefined, root),
1396
+ ),
1397
+ ),
1398
+ )
1399
+ await Bun.write(
1400
+ join(root, "agency.json"),
1401
+ JSON.stringify({
1402
+ version: 2,
1403
+ worktreeCreateCommand: [
1404
+ "sh",
1405
+ "-c",
1406
+ "echo rebuild-failed >&2; exit 7",
1407
+ "{repo}",
1408
+ "{worktree}",
1409
+ ],
1410
+ }),
1411
+ )
1412
+
1413
+ await expect(
1414
+ runTestEffect(
1415
+ WorktreeService.pipe(
1416
+ Effect.flatMap((service) =>
1417
+ service.rebuild("rebuild-rollback", undefined, root),
1418
+ ),
1419
+ ),
1420
+ ),
1421
+ ).rejects.toThrow("original worktrees were restored")
1422
+ expect(
1423
+ await Bun.file(join(workspace.writablePath, "README.md")).exists(),
1424
+ ).toBe(true)
1425
+ })
1426
+
1427
+ test("repairs a stale registration without deleting the writable branch", async () => {
1428
+ await runTestEffect(
1429
+ TaskService.pipe(
1430
+ Effect.flatMap((service) =>
1431
+ service.create(
1432
+ {
1433
+ id: "repaired",
1434
+ ticketUrl: null,
1435
+ repo: "agency",
1436
+ branch: "task/repaired",
1437
+ base: "main",
1438
+ },
1439
+ root,
1440
+ ),
1441
+ ),
1442
+ ),
1443
+ )
1444
+ const workspace = await runTestEffect(
1445
+ WorktreeService.pipe(
1446
+ Effect.flatMap((service) =>
1447
+ service.materialize("repaired", undefined, root),
1448
+ ),
1449
+ ),
1450
+ )
1451
+ await rm(workspace.codePath, { recursive: true })
1452
+
1453
+ const plan = await runTestEffect(
1454
+ WorktreeService.pipe(
1455
+ Effect.flatMap((service) =>
1456
+ service.repair("repaired", undefined, root, { dryRun: true }),
1457
+ ),
1458
+ ),
1459
+ )
1460
+ expect(plan.actions.join("\n")).toContain("worktree prune --expire now")
1461
+ expect(plan.actions).toContain(`prepare ${workspace.writablePath}`)
1462
+
1463
+ const repaired = await runTestEffect(
1464
+ WorktreeService.pipe(
1465
+ Effect.flatMap((service) =>
1466
+ service.repair("repaired", undefined, root),
1467
+ ),
1468
+ ),
1469
+ )
1470
+ expect(repaired.inspection.conflicts).toEqual([])
1471
+ expect(
1472
+ await Bun.file(join(workspace.writablePath, "README.md")).exists(),
1473
+ ).toBe(true)
1474
+ expect(
1475
+ Bun.spawnSync([
1476
+ "git",
1477
+ "-C",
1478
+ join(root, "repos/agency"),
1479
+ "show-ref",
1480
+ "--verify",
1481
+ "refs/heads/task/repaired",
1482
+ ]).exitCode,
1483
+ ).toBe(0)
1484
+ })
1485
+
1486
+ test("refuses to repair an unregistered checkout on the wrong branch", async () => {
1487
+ await runTestEffect(
1488
+ TaskService.pipe(
1489
+ Effect.flatMap((service) =>
1490
+ service.create(
1491
+ {
1492
+ id: "repair-wrong",
1493
+ ticketUrl: null,
1494
+ repo: "agency",
1495
+ branch: "task/repair-expected",
1496
+ base: "main",
1497
+ },
1498
+ root,
1499
+ ),
1500
+ ),
1501
+ ),
1502
+ )
1503
+ const repository = join(root, "repos/agency")
1504
+ await git(["-C", repository, "branch", "task/repair-actual", "main"])
1505
+ const oldPath = join(root, "repair-old-path")
1506
+ const checkout = join(root, "tasks/repair-wrong/code/agency")
1507
+ await git([
1508
+ "-C",
1509
+ repository,
1510
+ "worktree",
1511
+ "add",
1512
+ oldPath,
1513
+ "task/repair-actual",
1514
+ ])
1515
+ await mkdir(join(root, "tasks/repair-wrong/code"), { recursive: true })
1516
+ await rename(oldPath, checkout)
1517
+ await Bun.write(join(checkout, "uncommitted.txt"), "keep me\n")
1518
+
1519
+ await expect(
1520
+ runTestEffect(
1521
+ WorktreeService.pipe(
1522
+ Effect.flatMap((service) =>
1523
+ service.repair("repair-wrong", undefined, root),
1524
+ ),
1525
+ ),
1526
+ ),
1527
+ ).rejects.toThrow("not branch 'task/repair-expected'")
1528
+ expect(await Bun.file(join(checkout, "uncommitted.txt")).text()).toBe(
1529
+ "keep me\n",
1530
+ )
1531
+ })
1532
+
1533
+ test("repairs a moved checkout without changing its branch or dirty files", async () => {
1534
+ await runTestEffect(
1535
+ TaskService.pipe(
1536
+ Effect.flatMap((service) =>
1537
+ service.create(
1538
+ {
1539
+ id: "repair-moved",
1540
+ ticketUrl: null,
1541
+ repo: "agency",
1542
+ branch: "task/repair-moved",
1543
+ base: "main",
1544
+ },
1545
+ root,
1546
+ ),
1547
+ ),
1548
+ ),
1549
+ )
1550
+ const repository = join(root, "repos/agency")
1551
+ await git(["-C", repository, "branch", "task/repair-moved", "main"])
1552
+ const oldPath = join(root, "repair-moved-old")
1553
+ const checkout = join(root, "tasks/repair-moved/code/agency")
1554
+ await git([
1555
+ "-C",
1556
+ repository,
1557
+ "worktree",
1558
+ "add",
1559
+ oldPath,
1560
+ "task/repair-moved",
1561
+ ])
1562
+ await mkdir(join(root, "tasks/repair-moved/code"), { recursive: true })
1563
+ await rename(oldPath, checkout)
1564
+ await Bun.write(join(checkout, "uncommitted.txt"), "keep me\n")
1565
+
1566
+ const repaired = await runTestEffect(
1567
+ WorktreeService.pipe(
1568
+ Effect.flatMap((service) =>
1569
+ service.repair("repair-moved", undefined, root),
1570
+ ),
1571
+ ),
1572
+ )
1573
+ expect(repaired.inspection.conflicts).toEqual([])
1574
+ expect(repaired.inspection.checkouts[0]).toMatchObject({
1575
+ actualBranch: "task/repair-moved",
1576
+ dirty: true,
1577
+ registered: true,
1578
+ })
1579
+ expect(await Bun.file(join(checkout, "uncommitted.txt")).text()).toBe(
1580
+ "keep me\n",
1581
+ )
1582
+ })
1583
+
1133
1584
  test("supports Worktrunk as the configured command", async () => {
1134
1585
  if (Bun.spawnSync(["which", "wt"], { stdout: "ignore" }).exitCode !== 0) {
1135
1586
  return