@markjaquith/agency 2.25.0 → 2.27.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.
@@ -146,6 +146,31 @@ const writeRegistry = (
146
146
  yield* fs.writeJSON(path, registry)
147
147
  })
148
148
 
149
+ const findRegistration = (
150
+ selector: string,
151
+ configDirectory?: string,
152
+ basePath: string = process.cwd(),
153
+ ) =>
154
+ Effect.gen(function* () {
155
+ const fs = yield* FileSystemService
156
+ const { path, registry } = yield* readRegistry(configDirectory)
157
+ const candidatePath = resolve(basePath, selector)
158
+ const canonicalCandidate = (yield* fs.exists(candidatePath))
159
+ ? yield* fs.realPath(candidatePath)
160
+ : candidatePath
161
+ const entry =
162
+ registry.workbases.find((item) => item.id === selector) ??
163
+ registry.workbases.find((item) => item.name === selector) ??
164
+ registry.workbases.find((item) => item.path === canonicalCandidate)
165
+ if (!entry) {
166
+ return yield* new WorkbaseRegistryError({
167
+ path,
168
+ message: `Unknown workbase selector '${selector}'`,
169
+ })
170
+ }
171
+ return { path, registry, entry }
172
+ })
173
+
149
174
  export class WorkbaseService extends Effect.Service<WorkbaseService>()(
150
175
  "WorkbaseService",
151
176
  {
@@ -401,22 +426,11 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
401
426
  basePath: string = process.cwd(),
402
427
  ) =>
403
428
  Effect.gen(function* () {
404
- const fs = yield* FileSystemService
405
- const { path, registry } = yield* readRegistry(configDirectory)
406
- const candidatePath = resolve(basePath, selector)
407
- const canonicalCandidate = (yield* fs.exists(candidatePath))
408
- ? yield* fs.realPath(candidatePath)
409
- : candidatePath
410
- const entry =
411
- registry.workbases.find((item) => item.id === selector) ??
412
- registry.workbases.find((item) => item.name === selector) ??
413
- registry.workbases.find((item) => item.path === canonicalCandidate)
414
- if (!entry) {
415
- return yield* new WorkbaseRegistryError({
416
- path,
417
- message: `Unknown workbase selector '${selector}'`,
418
- })
419
- }
429
+ const { path, registry, entry } = yield* findRegistration(
430
+ selector,
431
+ configDirectory,
432
+ basePath,
433
+ )
420
434
  const workbases = registry.workbases.filter(
421
435
  (item) => item.id !== entry.id,
422
436
  )
@@ -431,6 +445,61 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
431
445
  return entry
432
446
  }),
433
447
 
448
+ showRegistered: (
449
+ selector: string,
450
+ configDirectory?: string,
451
+ basePath: string = process.cwd(),
452
+ ) =>
453
+ findRegistration(selector, configDirectory, basePath).pipe(
454
+ Effect.map(({ entry }) => entry),
455
+ ),
456
+
457
+ nameRegistered: (
458
+ selector: string,
459
+ name: string | null,
460
+ configDirectory?: string,
461
+ basePath: string = process.cwd(),
462
+ ) =>
463
+ Effect.gen(function* () {
464
+ const { path, registry, entry } = yield* findRegistration(
465
+ selector,
466
+ configDirectory,
467
+ basePath,
468
+ )
469
+ if (name !== null) {
470
+ const decodedName = decode(EntityId, name)
471
+ if (!decodedName.success) {
472
+ return yield* new WorkbaseRegistryError({
473
+ path,
474
+ message: `Invalid workbase name '${name}': names must contain only letters, numbers, dots, underscores, and hyphens`,
475
+ })
476
+ }
477
+ const collision = registry.workbases.find(
478
+ (item) =>
479
+ item.id !== entry.id &&
480
+ (item.id === name || item.name === name),
481
+ )
482
+ if (collision) {
483
+ return yield* new WorkbaseRegistryError({
484
+ path,
485
+ message: `Workbase name '${name}' is already registered for ${collision.path}`,
486
+ })
487
+ }
488
+ }
489
+ const updated = {
490
+ id: entry.id,
491
+ ...(name === null ? {} : { name }),
492
+ path: entry.path,
493
+ }
494
+ yield* writeRegistry(path, {
495
+ ...registry,
496
+ workbases: registry.workbases.map((item) =>
497
+ item.id === entry.id ? updated : item,
498
+ ),
499
+ })
500
+ return updated
501
+ }),
502
+
434
503
  pruneRegistered: (configDirectory?: string) =>
435
504
  Effect.gen(function* () {
436
505
  const fs = yield* FileSystemService
@@ -455,7 +524,11 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
455
524
  return removed
456
525
  }),
457
526
 
458
- setDefault: (selector: string | null, configDirectory?: string) =>
527
+ setDefault: (
528
+ selector: string | null,
529
+ configDirectory?: string,
530
+ basePath: string = process.cwd(),
531
+ ) =>
459
532
  Effect.gen(function* () {
460
533
  const { path, registry } = yield* readRegistry(configDirectory)
461
534
  if (selector === null) {
@@ -465,15 +538,11 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
465
538
  })
466
539
  return null
467
540
  }
468
- const entry = registry.workbases.find(
469
- (item) => item.id === selector || item.name === selector,
541
+ const { entry } = yield* findRegistration(
542
+ selector,
543
+ configDirectory,
544
+ basePath,
470
545
  )
471
- if (!entry) {
472
- return yield* new WorkbaseRegistryError({
473
- path,
474
- message: `Unknown registered workbase selector '${selector}'`,
475
- })
476
- }
477
546
  yield* writeRegistry(path, {
478
547
  version: 2,
479
548
  workbases: registry.workbases,
@@ -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