@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.
- package/README.md +24 -2
- package/cli.ts +21 -0
- package/package.json +1 -1
- package/skills/agency/SKILL.md +10 -1
- package/src/cli-parser.test.ts +47 -0
- package/src/cli-parser.ts +110 -2
- package/src/cli.test.ts +34 -0
- package/src/commands/repo.test.ts +44 -0
- package/src/commands/repo.ts +107 -1
- package/src/commands/workbase.test.ts +47 -0
- package/src/commands/workbase.ts +52 -1
- package/src/commands/worktree.ts +138 -0
- package/src/services/FileSystemService.ts +26 -0
- package/src/services/RepositoryService.test.ts +129 -1
- package/src/services/RepositoryService.ts +181 -0
- package/src/services/WorkbaseService.test.ts +46 -0
- package/src/services/WorkbaseService.ts +94 -25
- package/src/services/WorktreeService.test.ts +453 -2
- package/src/services/WorktreeService.ts +1510 -655
|
@@ -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
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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: (
|
|
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 =
|
|
469
|
-
|
|
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).
|
|
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
|