@markjaquith/agency 2.58.0 → 2.58.2
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(
|
|
@@ -1882,6 +1882,111 @@ pr: null
|
|
|
1882
1882
|
).toBe(true)
|
|
1883
1883
|
})
|
|
1884
1884
|
|
|
1885
|
+
test("rebuilds clean drifted jj references without discarding changes", async () => {
|
|
1886
|
+
if (!Bun.which("jj")) return
|
|
1887
|
+
const first = new TextDecoder()
|
|
1888
|
+
.decode(
|
|
1889
|
+
Bun.spawnSync(["git", "-C", source, "rev-parse", "HEAD"], {
|
|
1890
|
+
stdout: "pipe",
|
|
1891
|
+
}).stdout,
|
|
1892
|
+
)
|
|
1893
|
+
.trim()
|
|
1894
|
+
await Bun.write(join(source, "README.md"), "updated\n")
|
|
1895
|
+
await git(["add", "README.md"], source)
|
|
1896
|
+
await git(["-c", "commit.gpgsign=false", "commit", "-m", "update"], source)
|
|
1897
|
+
const second = new TextDecoder()
|
|
1898
|
+
.decode(
|
|
1899
|
+
Bun.spawnSync(["git", "-C", source, "rev-parse", "HEAD"], {
|
|
1900
|
+
stdout: "pipe",
|
|
1901
|
+
}).stdout,
|
|
1902
|
+
)
|
|
1903
|
+
.trim()
|
|
1904
|
+
const repository = join(root, "repos/agency")
|
|
1905
|
+
await rm(repository, { recursive: true, force: true })
|
|
1906
|
+
await jj(["git", "clone", "--no-colocate", source, repository])
|
|
1907
|
+
await Bun.write(
|
|
1908
|
+
join(root, "agency.json"),
|
|
1909
|
+
JSON.stringify({ version: 2, vcs: "jj" }),
|
|
1910
|
+
)
|
|
1911
|
+
const createDriftedReview = async (id: string) => {
|
|
1912
|
+
const task = await runTestEffect(
|
|
1913
|
+
TaskService.pipe(
|
|
1914
|
+
Effect.flatMap((service) =>
|
|
1915
|
+
service.create(
|
|
1916
|
+
{
|
|
1917
|
+
id,
|
|
1918
|
+
ticketUrl: null,
|
|
1919
|
+
review: {
|
|
1920
|
+
repo: "agency",
|
|
1921
|
+
source: {
|
|
1922
|
+
kind: "branch",
|
|
1923
|
+
ref: "refs/heads/main",
|
|
1924
|
+
},
|
|
1925
|
+
commit: first,
|
|
1926
|
+
refreshedAt: "2026-08-10T00:00:00.000Z",
|
|
1927
|
+
},
|
|
1928
|
+
},
|
|
1929
|
+
root,
|
|
1930
|
+
),
|
|
1931
|
+
),
|
|
1932
|
+
),
|
|
1933
|
+
)
|
|
1934
|
+
const workspace = await runTestEffect(
|
|
1935
|
+
WorktreeService.pipe(
|
|
1936
|
+
Effect.flatMap((service) => service.materialize(id, undefined, root)),
|
|
1937
|
+
),
|
|
1938
|
+
)
|
|
1939
|
+
await Bun.write(task.path, task.content.replace(first, second))
|
|
1940
|
+
return workspace
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1943
|
+
const workspace = await createDriftedReview("drifted-review")
|
|
1944
|
+
|
|
1945
|
+
await expect(
|
|
1946
|
+
runTestEffect(
|
|
1947
|
+
WorktreeService.pipe(
|
|
1948
|
+
Effect.flatMap((service) =>
|
|
1949
|
+
service.materialize("drifted-review", undefined, root),
|
|
1950
|
+
),
|
|
1951
|
+
),
|
|
1952
|
+
),
|
|
1953
|
+
).rejects.toThrow("does not match reference")
|
|
1954
|
+
const inspection = await runTestEffect(
|
|
1955
|
+
WorktreeService.pipe(
|
|
1956
|
+
Effect.flatMap((service) =>
|
|
1957
|
+
service.inspect("drifted-review", undefined, root),
|
|
1958
|
+
),
|
|
1959
|
+
),
|
|
1960
|
+
)
|
|
1961
|
+
expect(inspection.conflicts).toEqual([
|
|
1962
|
+
expect.objectContaining({ kind: "reference-drift" }),
|
|
1963
|
+
])
|
|
1964
|
+
await runTestEffect(
|
|
1965
|
+
WorktreeService.pipe(
|
|
1966
|
+
Effect.flatMap((service) =>
|
|
1967
|
+
service.rebuild("drifted-review", undefined, root),
|
|
1968
|
+
),
|
|
1969
|
+
),
|
|
1970
|
+
)
|
|
1971
|
+
expect(
|
|
1972
|
+
await Bun.file(join(workspace.reviewPath!, "README.md")).text(),
|
|
1973
|
+
).toBe("updated\n")
|
|
1974
|
+
|
|
1975
|
+
const dirtyWorkspace = await createDriftedReview("dirty-drifted-review")
|
|
1976
|
+
const dirtyPath = join(dirtyWorkspace.reviewPath!, "dirty.txt")
|
|
1977
|
+
await Bun.write(dirtyPath, "preserve me\n")
|
|
1978
|
+
await expect(
|
|
1979
|
+
runTestEffect(
|
|
1980
|
+
WorktreeService.pipe(
|
|
1981
|
+
Effect.flatMap((service) =>
|
|
1982
|
+
service.rebuild("dirty-drifted-review", undefined, root),
|
|
1983
|
+
),
|
|
1984
|
+
),
|
|
1985
|
+
),
|
|
1986
|
+
).rejects.toThrow("checkout has uncommitted changes")
|
|
1987
|
+
expect(await Bun.file(dirtyPath).text()).toBe("preserve me\n")
|
|
1988
|
+
})
|
|
1989
|
+
|
|
1885
1990
|
test("restores removed worktrees when rebuild creation fails", async () => {
|
|
1886
1991
|
await runTestEffect(
|
|
1887
1992
|
TaskService.pipe(
|
|
@@ -254,11 +254,13 @@ const originRef = (ref: string) =>
|
|
|
254
254
|
interface MaterializeOptions extends BaseCommandOptions {
|
|
255
255
|
readonly force?: boolean
|
|
256
256
|
readonly lockHeld?: boolean
|
|
257
|
+
readonly allowReferenceDrift?: boolean
|
|
257
258
|
}
|
|
258
259
|
|
|
259
260
|
interface RemoveOptions extends BaseCommandOptions {
|
|
260
261
|
readonly snapshots?: WorktreeRemovalSnapshot[]
|
|
261
262
|
readonly lockHeld?: boolean
|
|
263
|
+
readonly allowReferenceDrift?: boolean
|
|
262
264
|
}
|
|
263
265
|
|
|
264
266
|
interface LifecycleOptions extends BaseCommandOptions {
|
|
@@ -770,6 +772,81 @@ const jjWorkspaceName = (
|
|
|
770
772
|
repo: string,
|
|
771
773
|
) => `agency-${taskId}-${phaseId ?? "task"}-${repo}`
|
|
772
774
|
|
|
775
|
+
// A process can stop after `jj workspace forget` but before checkout deletion.
|
|
776
|
+
// Only recover that residual when its repository and complete tree still match.
|
|
777
|
+
const inspectJjResidual = (
|
|
778
|
+
root: string,
|
|
779
|
+
repositoryPath: string,
|
|
780
|
+
workspacePath: string,
|
|
781
|
+
revision: string,
|
|
782
|
+
) =>
|
|
783
|
+
Effect.gen(function* () {
|
|
784
|
+
const fs = yield* FileSystemService
|
|
785
|
+
const repoPointer = join(workspacePath, ".jj", "repo")
|
|
786
|
+
if (!(yield* fs.exists(repoPointer))) return "unowned" as const
|
|
787
|
+
|
|
788
|
+
const pointer = (yield* fs.readFile(repoPointer)).trim()
|
|
789
|
+
if (!pointer) return "unowned" as const
|
|
790
|
+
const linkedRepository = resolve(dirname(repoPointer), pointer)
|
|
791
|
+
const expectedRepository = join(repositoryPath, ".jj", "repo")
|
|
792
|
+
if (
|
|
793
|
+
!(yield* fs.exists(linkedRepository)) ||
|
|
794
|
+
(yield* fs.realPath(linkedRepository)) !==
|
|
795
|
+
(yield* fs.realPath(expectedRepository))
|
|
796
|
+
)
|
|
797
|
+
return "unowned" as const
|
|
798
|
+
|
|
799
|
+
const comparisonPath = join(
|
|
800
|
+
root,
|
|
801
|
+
`.agency-jj-residual-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
|
802
|
+
)
|
|
803
|
+
const comparisonName = basename(comparisonPath)
|
|
804
|
+
return yield* Effect.gen(function* () {
|
|
805
|
+
const created = yield* fs.runCommand(
|
|
806
|
+
[
|
|
807
|
+
"jj",
|
|
808
|
+
"-R",
|
|
809
|
+
repositoryPath,
|
|
810
|
+
"workspace",
|
|
811
|
+
"add",
|
|
812
|
+
"--name",
|
|
813
|
+
comparisonName,
|
|
814
|
+
"-r",
|
|
815
|
+
revision,
|
|
816
|
+
comparisonPath,
|
|
817
|
+
],
|
|
818
|
+
{ captureOutput: true },
|
|
819
|
+
)
|
|
820
|
+
if (created.exitCode !== 0) {
|
|
821
|
+
return yield* new WorktreeError({
|
|
822
|
+
message: `Cannot verify unregistered jj checkout ${workspacePath}: ${created.stderr}`,
|
|
823
|
+
})
|
|
824
|
+
}
|
|
825
|
+
const comparison = yield* fs.runCommand(
|
|
826
|
+
["diff", "-qr", "-x", ".jj", workspacePath, comparisonPath],
|
|
827
|
+
{ captureOutput: true },
|
|
828
|
+
)
|
|
829
|
+
if (comparison.exitCode > 1) {
|
|
830
|
+
return yield* new WorktreeError({
|
|
831
|
+
message: `Cannot verify unregistered jj checkout ${workspacePath}: ${comparison.stderr}`,
|
|
832
|
+
})
|
|
833
|
+
}
|
|
834
|
+
return comparison.exitCode === 0
|
|
835
|
+
? ("clean" as const)
|
|
836
|
+
: ("modified" as const)
|
|
837
|
+
}).pipe(
|
|
838
|
+
Effect.ensuring(
|
|
839
|
+
Effect.gen(function* () {
|
|
840
|
+
yield* fs.runCommand(
|
|
841
|
+
["jj", "-R", repositoryPath, "workspace", "forget", comparisonName],
|
|
842
|
+
{ captureOutput: true },
|
|
843
|
+
)
|
|
844
|
+
yield* fs.deleteDirectory(comparisonPath)
|
|
845
|
+
}).pipe(Effect.ignore),
|
|
846
|
+
),
|
|
847
|
+
)
|
|
848
|
+
})
|
|
849
|
+
|
|
773
850
|
const buildInspectionContext = (
|
|
774
851
|
startPath: string,
|
|
775
852
|
skipWritableRevisionResolution: boolean,
|
|
@@ -935,12 +1012,32 @@ const materializeJj = (options: {
|
|
|
935
1012
|
})
|
|
936
1013
|
}
|
|
937
1014
|
if (exists && registered) {
|
|
1015
|
+
const actualCommit = yield* backend.workspaceHead(workspacePath)
|
|
1016
|
+
if ("ref" in checkout) {
|
|
1017
|
+
const expectedCommit = yield* backend.resolveRevision(
|
|
1018
|
+
repositoryPath,
|
|
1019
|
+
checkout.ref,
|
|
1020
|
+
)
|
|
1021
|
+
if (!expectedCommit) {
|
|
1022
|
+
return yield* new WorktreeError({
|
|
1023
|
+
message: `Reference '${checkout.ref}' for repository '${checkout.repo}' does not resolve to a commit; run 'agency repo fetch ${checkout.repo}' and retry`,
|
|
1024
|
+
})
|
|
1025
|
+
}
|
|
1026
|
+
if (
|
|
1027
|
+
actualCommit !== expectedCommit &&
|
|
1028
|
+
!options.commandOptions.allowReferenceDrift
|
|
1029
|
+
) {
|
|
1030
|
+
return yield* new WorktreeError({
|
|
1031
|
+
message: `Existing checkout ${workspacePath} does not match reference '${checkout.ref}' (${expectedCommit})`,
|
|
1032
|
+
})
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
938
1035
|
reports.push({
|
|
939
1036
|
repo: checkout.repo,
|
|
940
1037
|
kind: "branch" in checkout ? "writable" : "reference",
|
|
941
1038
|
path: workspacePath,
|
|
942
1039
|
requestedRef: "branch" in checkout ? checkout.branch : checkout.ref,
|
|
943
|
-
resolvedCommit:
|
|
1040
|
+
resolvedCommit: actualCommit,
|
|
944
1041
|
action: "reused",
|
|
945
1042
|
})
|
|
946
1043
|
continue
|
|
@@ -1103,10 +1200,66 @@ const removeJj = (
|
|
|
1103
1200
|
Effect.gen(function* () {
|
|
1104
1201
|
const fs = yield* FileSystemService
|
|
1105
1202
|
const versionControl = yield* VersionControlService
|
|
1203
|
+
const tasks = yield* TaskService
|
|
1204
|
+
const phases = yield* PhaseService
|
|
1106
1205
|
const backend = yield* versionControl.forWorkbase(root)
|
|
1107
1206
|
const inspection = yield* inspectExecution(taskId, phaseId, root)
|
|
1108
|
-
const
|
|
1109
|
-
|
|
1207
|
+
const task = yield* tasks.show(taskId, root)
|
|
1208
|
+
const execution =
|
|
1209
|
+
"phases" in task.data && phaseId
|
|
1210
|
+
? (yield* phases.show(taskId, phaseId, root)).data
|
|
1211
|
+
: task.data
|
|
1212
|
+
const recoverable = new Map<string, string>()
|
|
1213
|
+
const modifiedResiduals = new Map<string, string>()
|
|
1214
|
+
for (const checkout of inspection.checkouts) {
|
|
1215
|
+
const recoveryRevision =
|
|
1216
|
+
checkout.expectedCommit ??
|
|
1217
|
+
(checkout.kind === "writable" && "base" in execution
|
|
1218
|
+
? yield* backend.resolveRevision(
|
|
1219
|
+
join(root, "repos", checkout.repo),
|
|
1220
|
+
execution.base,
|
|
1221
|
+
)
|
|
1222
|
+
: null)
|
|
1223
|
+
if (
|
|
1224
|
+
!checkout.conflicts.some(
|
|
1225
|
+
(conflict) => conflict.kind === "unregistered-checkout",
|
|
1226
|
+
) ||
|
|
1227
|
+
!recoveryRevision
|
|
1228
|
+
)
|
|
1229
|
+
continue
|
|
1230
|
+
const repositoryPath = join(root, "repos", checkout.repo)
|
|
1231
|
+
const residual = yield* inspectJjResidual(
|
|
1232
|
+
root,
|
|
1233
|
+
repositoryPath,
|
|
1234
|
+
checkout.path,
|
|
1235
|
+
recoveryRevision,
|
|
1236
|
+
)
|
|
1237
|
+
if (residual === "clean") recoverable.set(checkout.path, recoveryRevision)
|
|
1238
|
+
else if (residual === "modified")
|
|
1239
|
+
modifiedResiduals.set(checkout.path, recoveryRevision)
|
|
1240
|
+
}
|
|
1241
|
+
const blocking = inspection.checkouts.flatMap((checkout) =>
|
|
1242
|
+
checkout.conflicts
|
|
1243
|
+
.filter(
|
|
1244
|
+
(conflict) =>
|
|
1245
|
+
conflict.kind !== "stale-registration" &&
|
|
1246
|
+
!(
|
|
1247
|
+
options.allowReferenceDrift && conflict.kind === "reference-drift"
|
|
1248
|
+
) &&
|
|
1249
|
+
!(
|
|
1250
|
+
conflict.kind === "unregistered-checkout" &&
|
|
1251
|
+
recoverable.has(checkout.path)
|
|
1252
|
+
),
|
|
1253
|
+
)
|
|
1254
|
+
.map((conflict) =>
|
|
1255
|
+
conflict.kind === "unregistered-checkout" &&
|
|
1256
|
+
modifiedResiduals.has(checkout.path)
|
|
1257
|
+
? {
|
|
1258
|
+
...conflict,
|
|
1259
|
+
message: `Cannot remove unregistered jj checkout ${checkout.path}; its contents do not match expected revision '${modifiedResiduals.get(checkout.path)}'`,
|
|
1260
|
+
}
|
|
1261
|
+
: conflict,
|
|
1262
|
+
),
|
|
1110
1263
|
)
|
|
1111
1264
|
if (blocking.length > 0) {
|
|
1112
1265
|
return yield* new WorktreeError({
|
|
@@ -1121,6 +1274,7 @@ const removeJj = (
|
|
|
1121
1274
|
workspaceName: string
|
|
1122
1275
|
head: string
|
|
1123
1276
|
branch: string | null
|
|
1277
|
+
registered: boolean
|
|
1124
1278
|
}[] = []
|
|
1125
1279
|
for (const checkout of inspection.checkouts) {
|
|
1126
1280
|
if (checkout.dirty) {
|
|
@@ -1128,8 +1282,20 @@ const removeJj = (
|
|
|
1128
1282
|
message: `Failed to remove workspace for '${checkout.repo}': checkout has uncommitted changes`,
|
|
1129
1283
|
})
|
|
1130
1284
|
}
|
|
1131
|
-
if (!checkout.registeredPath) continue
|
|
1132
1285
|
const repositoryPath = join(root, "repos", checkout.repo)
|
|
1286
|
+
if (!checkout.registeredPath) {
|
|
1287
|
+
const recoveryRevision = recoverable.get(checkout.path)
|
|
1288
|
+
if (!recoveryRevision) continue
|
|
1289
|
+
plans.push({
|
|
1290
|
+
repositoryPath,
|
|
1291
|
+
workspacePath: checkout.path,
|
|
1292
|
+
workspaceName: jjWorkspaceName(taskId, phaseId, checkout.repo),
|
|
1293
|
+
head: recoveryRevision,
|
|
1294
|
+
branch: checkout.actualBranch,
|
|
1295
|
+
registered: false,
|
|
1296
|
+
})
|
|
1297
|
+
continue
|
|
1298
|
+
}
|
|
1133
1299
|
const registered = (yield* backend.listWorkspaces(repositoryPath)).find(
|
|
1134
1300
|
(workspace) => workspace.path === checkout.registeredPath,
|
|
1135
1301
|
)
|
|
@@ -1144,6 +1310,7 @@ const removeJj = (
|
|
|
1144
1310
|
workspaceName: registered.name,
|
|
1145
1311
|
head: checkout.actualCommit,
|
|
1146
1312
|
branch: checkout.actualBranch,
|
|
1313
|
+
registered: true,
|
|
1147
1314
|
})
|
|
1148
1315
|
}
|
|
1149
1316
|
|
|
@@ -1162,11 +1329,15 @@ const removeJj = (
|
|
|
1162
1329
|
const completed: typeof plans = []
|
|
1163
1330
|
return yield* Effect.gen(function* () {
|
|
1164
1331
|
for (const plan of plans) {
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1332
|
+
if (plan.registered) {
|
|
1333
|
+
yield* backend.removeWorkspace({
|
|
1334
|
+
repositoryPath: plan.repositoryPath,
|
|
1335
|
+
workspacePath: plan.workspacePath,
|
|
1336
|
+
workspaceName: plan.workspaceName,
|
|
1337
|
+
})
|
|
1338
|
+
} else {
|
|
1339
|
+
yield* fs.deleteDirectory(plan.workspacePath)
|
|
1340
|
+
}
|
|
1170
1341
|
completed.push(plan)
|
|
1171
1342
|
}
|
|
1172
1343
|
yield* fs.deleteDirectoryIfEmpty(inspection.codePath)
|
|
@@ -2246,7 +2417,12 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
|
|
|
2246
2417
|
root,
|
|
2247
2418
|
)
|
|
2248
2419
|
const blockingConflicts = inspection.conflicts.filter(
|
|
2249
|
-
(conflict) =>
|
|
2420
|
+
(conflict) =>
|
|
2421
|
+
conflict.kind !== "stale-registration" &&
|
|
2422
|
+
!(
|
|
2423
|
+
options.allowReferenceDrift &&
|
|
2424
|
+
conflict.kind === "reference-drift"
|
|
2425
|
+
),
|
|
2250
2426
|
)
|
|
2251
2427
|
if (blockingConflicts.length > 0) {
|
|
2252
2428
|
return yield* new WorktreeError({
|
|
@@ -2577,21 +2753,29 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
|
|
|
2577
2753
|
)
|
|
2578
2754
|
}
|
|
2579
2755
|
const inspection = yield* inspectExecution(taskId, phaseId, root)
|
|
2580
|
-
const
|
|
2581
|
-
(checkout) => checkout.dirty
|
|
2756
|
+
const notProvablyClean = inspection.checkouts.filter(
|
|
2757
|
+
(checkout) => checkout.exists && checkout.dirty !== false,
|
|
2582
2758
|
)
|
|
2583
|
-
if (
|
|
2759
|
+
if (notProvablyClean.length > 0) {
|
|
2760
|
+
const unknown = notProvablyClean.some(
|
|
2761
|
+
(checkout) => checkout.dirty === null,
|
|
2762
|
+
)
|
|
2584
2763
|
return yield* new WorktreeError({
|
|
2585
|
-
message: `Cannot rebuild ${
|
|
2586
|
-
conflicts:
|
|
2764
|
+
message: `Cannot rebuild ${notProvablyClean.map((checkout) => checkout.path).join(", ")}; ${unknown ? "checkout cleanliness could not be verified" : "checkout has uncommitted changes"}`,
|
|
2765
|
+
conflicts: notProvablyClean.flatMap(
|
|
2766
|
+
(checkout) => checkout.conflicts,
|
|
2767
|
+
),
|
|
2587
2768
|
})
|
|
2588
2769
|
}
|
|
2589
|
-
|
|
2770
|
+
const blockingConflicts = inspection.conflicts.filter(
|
|
2771
|
+
(conflict) => conflict.kind !== "reference-drift",
|
|
2772
|
+
)
|
|
2773
|
+
if (blockingConflicts.length > 0) {
|
|
2590
2774
|
return yield* new WorktreeError({
|
|
2591
|
-
message:
|
|
2775
|
+
message: blockingConflicts
|
|
2592
2776
|
.map(({ message }) => message)
|
|
2593
2777
|
.join("\n"),
|
|
2594
|
-
conflicts:
|
|
2778
|
+
conflicts: blockingConflicts,
|
|
2595
2779
|
})
|
|
2596
2780
|
}
|
|
2597
2781
|
yield* service.materialize(taskId, phaseId, inspection.root, {
|
|
@@ -2599,6 +2783,7 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
|
|
|
2599
2783
|
dryRun: true,
|
|
2600
2784
|
silent: true,
|
|
2601
2785
|
lockHeld: true,
|
|
2786
|
+
allowReferenceDrift: true,
|
|
2602
2787
|
})
|
|
2603
2788
|
if (options.dryRun) {
|
|
2604
2789
|
return {
|
|
@@ -2622,6 +2807,7 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
|
|
|
2622
2807
|
...options,
|
|
2623
2808
|
snapshots,
|
|
2624
2809
|
lockHeld: true,
|
|
2810
|
+
allowReferenceDrift: true,
|
|
2625
2811
|
},
|
|
2626
2812
|
)
|
|
2627
2813
|
const workspace = yield* service
|