@afokapu/atdd-flow 0.3.65 → 0.3.67
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 +1 -1
- package/package.json +2 -2
- package/src/cli.ts +2 -2
- package/src/tasks.ts +9 -12
- package/src/vcs-migration.ts +154 -20
package/README.md
CHANGED
|
@@ -130,7 +130,7 @@ atdd-flow task review example-app runtime-rollout --by driver.runtime@example-ap
|
|
|
130
130
|
atdd-flow task done example-app runtime-rollout --by coordinator@example-app
|
|
131
131
|
```
|
|
132
132
|
|
|
133
|
-
Proof is a compact PR, CI run, report, commit range, deployment, or thread reference. Dependencies gate prerequisites; independent tasks are parallel-ready. A coordinator can staff an unassigned ready task with `atdd-flow task assign <project> <task-id> --assignee <address> --by <coordinator-address>`; assignment is allowed only once while the task is `todo`. Use `task block` only for a real external blocker, then checkpoint exact state and next action. Once the blocker is resolved, only that coordinator can clear it with `atdd-flow task unblock <project> <task-id> --by <coordinator-address>` while the task remains `in_progress`. For deliveries explicitly governed by the `workflow` ATDD Bun profile, `review → done` additionally requires a durable final behavioral-review result with decision `APPROVE` for the current clean delivery commit. For an idle driver’s final task, `task done ... --retire-assignee` delegates clean-and-merged worktree retirement to ATDD Bun. A driver whose assigned tasks are all already `done` is retired with `atdd-flow seat retire <address> --by <coordinator-or-main>`, using the same checks. When every assigned task is `done` and the recorded worktree is absent from disk and from `git worktree list`, `seat retire` records the retirement without running worktree finish, deleting the branch, or recreating the worktree; a missing worktree that Git still registers fails closed. Both paths fail closed on unfinished tasks or a dirty or unmerged worktree; on success they record `retired` and a checkpoint, then close the driver's Herdr workspace when `close_inactive` is enabled. `main@<project>` may likewise retire a coordinator seat (never main
|
|
133
|
+
Proof is a compact PR, CI run, report, commit range, deployment, or thread reference. Dependencies gate prerequisites; independent tasks are parallel-ready. A coordinator can staff an unassigned ready task with `atdd-flow task assign <project> <task-id> --assignee <address> --by <coordinator-address>`; assignment is allowed only once while the task is `todo`. Use `task block` only for a real external blocker, then checkpoint exact state and next action. Once the blocker is resolved, only that coordinator can clear it with `atdd-flow task unblock <project> <task-id> --by <coordinator-address>` while the task remains `in_progress`. For deliveries explicitly governed by the `workflow` ATDD Bun profile, `review → done` additionally requires a durable final behavioral-review result with decision `APPROVE` for the current clean delivery commit. For an idle driver’s final task, `task done ... --retire-assignee` delegates clean-and-merged worktree retirement to ATDD Bun. A driver whose assigned tasks are all already `done` is retired with `atdd-flow seat retire <address> --by <coordinator-or-main>`, using the same checks. When every assigned task is `done` and the recorded worktree is absent from disk and from `git worktree list`, `seat retire` records the retirement without running worktree finish, deleting the branch, or recreating the worktree; a missing worktree that Git still registers fails closed. Both paths fail closed on unfinished tasks or a dirty or unmerged worktree; on success they record `retired` and a checkpoint, then close the driver's Herdr workspace when `close_inactive` is enabled. `main@<project>` may likewise retire a coordinator seat (never main or the operator) once every task it coordinates or is assigned is `done`. A non-main coordinator sharing the primary checkout is retired as a Desk record without worktree finish; other coordinators use the same worktree-finish, absent-and-unregistered, and fail-closed paths. Retirement runs in the seat worktree with ATDD Bun from the primary checkout's `node_modules/.bin`, else the primary checkout's own source when it is the ATDD Bun package, else `PATH`; never the seat worktree's copy, which may pin an older toolkit.
|
|
134
134
|
|
|
135
135
|
At 80% context usage a seat writes a structured handover (template and process rules in the lifecycle convention), checks it with `atdd-flow handover <address> --check --file <path>`, and records it with `atdd-flow handover <address> --file <path>`. The seat keeps one durable `handover.yaml` beside its unchanged checkpoint. Recording rejects a missing header, Runtime line, or section, sections out of order, more than 150 lines, vague references such as `latest` or `that PR` without an exact ID, and a `supersedes` value that does not match the recorded handover id. A fresh session bound to the same seat reads it with `atdd-flow handover show <address>`.
|
|
136
136
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@afokapu/atdd-flow",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.67",
|
|
4
4
|
"description": "Filesystem-first Desk registry for projects, seats, tasks, threads, and handoffs.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"bun": ">=1.3.0"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
|
-
"@afokapu/atdd-bun": "0.10.
|
|
44
|
+
"@afokapu/atdd-bun": "0.10.101"
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
47
47
|
"@typesafe-ai/sdk": "^0.6.0",
|
package/src/cli.ts
CHANGED
|
@@ -33,7 +33,7 @@ Usage:
|
|
|
33
33
|
atdd-flow desk migrate
|
|
34
34
|
atdd-flow project init <project>
|
|
35
35
|
atdd-flow project configure <project> [--vcs <git|jj>] [--repository <primary-checkout>] [--worktree-root <directory>]
|
|
36
|
-
atdd-flow project migrate-vcs <project> --to jj [--dry-run]
|
|
36
|
+
atdd-flow project migrate-vcs <project> --to jj [--allow-dirty-primary] [--dry-run]
|
|
37
37
|
atdd-flow project migrate-named-coordinator-topology <project> --baseline <40-lowercase-hex-remote-main-sha>
|
|
38
38
|
atdd-flow spawn <project> <role> <name> [--worktree <path>] [--branch <branch>] [--agent <legacy-executable>]
|
|
39
39
|
atdd-flow bind <address> [--application <application>] --address <native-address> [--session <name>] [--agent <executable>] [--worktree <path>] [--wake host|native]
|
|
@@ -111,7 +111,7 @@ async function main() {
|
|
|
111
111
|
if (rest[0] === "configure") return configureProject(root, required(rest[1], "project"), rest.slice(2));
|
|
112
112
|
if (rest[0] === "migrate-vcs") return migrateProjectVcs(root, required(rest[1], "project"), rest.slice(2));
|
|
113
113
|
if (rest[0] === "migrate-named-coordinator-topology") return migrateNamedCoordinatorTopology(root, required(rest[1], "project"), rest.slice(2));
|
|
114
|
-
throw new Error("Use `atdd-flow project init <project>`, `atdd-flow project configure <project> [--vcs <git|jj>] [--repository <primary-checkout>] [--worktree-root <directory>]`, `atdd-flow project migrate-vcs <project> --to jj [--dry-run]`, or `atdd-flow project migrate-named-coordinator-topology <project> --baseline <40-lowercase-hex-remote-main-sha>`.");
|
|
114
|
+
throw new Error("Use `atdd-flow project init <project>`, `atdd-flow project configure <project> [--vcs <git|jj>] [--repository <primary-checkout>] [--worktree-root <directory>]`, `atdd-flow project migrate-vcs <project> --to jj [--allow-dirty-primary] [--dry-run]`, or `atdd-flow project migrate-named-coordinator-topology <project> --baseline <40-lowercase-hex-remote-main-sha>`.");
|
|
115
115
|
},
|
|
116
116
|
spawn: () => spawn(root, required(rest[0], "project"), required(rest[1], "role"), required(rest[2], "name"), rest.slice(3)),
|
|
117
117
|
bind: () => bind(root, required(rest[0], "address"), rest.slice(1)),
|
package/src/tasks.ts
CHANGED
|
@@ -242,7 +242,7 @@ async function retirementBun(root: string, projectName: string) {
|
|
|
242
242
|
return [required(Bun.which("atdd-bun") ?? undefined, "atdd-bun in the primary checkout node_modules/.bin or on PATH")];
|
|
243
243
|
}
|
|
244
244
|
|
|
245
|
-
async function retireDriver(root: string, projectName: string, assignee: string, id: string, allowAbsentWorktree = false) {
|
|
245
|
+
async function retireDriver(root: string, projectName: string, assignee: string, id: string, allowAbsentWorktree = false, allowPrimaryCheckout = false) {
|
|
246
246
|
const unfinished = (await allTasks(root, projectName))
|
|
247
247
|
.filter((entry) => entry.id !== id && (entry.task.assignee === assignee || entry.task.coordinator === assignee) && !terminal(entry.task))
|
|
248
248
|
.map((entry) => entry.id);
|
|
@@ -251,7 +251,10 @@ async function retireDriver(root: string, projectName: string, assignee: string,
|
|
|
251
251
|
const owner = await seat(root, assignee);
|
|
252
252
|
if (owner.retired) throw new Error(`Seat ${assignee} is already retired.`);
|
|
253
253
|
try {
|
|
254
|
-
const
|
|
254
|
+
const primary = (await project(root, projectName)).repository;
|
|
255
|
+
const primaryCheckout = allowPrimaryCheckout && primary && await canonicalPath(owner.worktree) === await canonicalPath(primary);
|
|
256
|
+
const summary = (primaryCheckout ? `Primary checkout ${owner.worktree} retained; retired without worktree finish.` : undefined)
|
|
257
|
+
?? (allowAbsentWorktree ? await absentUnregisteredWorktree(root, projectName, owner) : undefined)
|
|
255
258
|
?? await runOutput([...await retirementBun(root, projectName), "worktree", "finish", "--delete-branch", "--vcs", vcsMode(await project(root, projectName))], owner.worktree);
|
|
256
259
|
owner.retired = { task: `${projectName}/${id}`, completed_at: now(), summary };
|
|
257
260
|
await atomicYaml(paths(root).seatFile(assignee), owner);
|
|
@@ -276,18 +279,11 @@ async function retireDriver(root: string, projectName: string, assignee: string,
|
|
|
276
279
|
await closeRetiredDriverWorkspace(root, owner);
|
|
277
280
|
}
|
|
278
281
|
|
|
279
|
-
/**
|
|
280
|
-
* Only main retires a coordinator, and never one seated on the primary checkout; a driver may also be
|
|
281
|
-
* retired by the coordinator of every task assigned to it.
|
|
282
|
-
*/
|
|
282
|
+
/** Only main retires a coordinator; a driver may also be retired by the coordinator of every task assigned to it. */
|
|
283
283
|
async function assertRetireAuthority(root: string, owner: Seat, actor: string, owned: ListedTask[]) {
|
|
284
284
|
const main = `main@${owner.project}`;
|
|
285
285
|
if (owner.role === "coordinator") {
|
|
286
286
|
if (actor !== main) throw new Error(`Only ${main} may retire coordinator ${owner.address}.`);
|
|
287
|
-
const primary = (await project(root, owner.project)).repository;
|
|
288
|
-
if (primary && await canonicalPath(owner.worktree) === await canonicalPath(primary)) {
|
|
289
|
-
throw new Error(`Cannot retire ${owner.address}; its worktree is the primary checkout.`);
|
|
290
|
-
}
|
|
291
287
|
} else if (actor !== main && owned.some((entry) => entry.task.coordinator !== actor)) {
|
|
292
288
|
throw new Error(`Only ${main} or the coordinator of every task assigned to ${owner.address} may retire it.`);
|
|
293
289
|
}
|
|
@@ -295,7 +291,8 @@ async function assertRetireAuthority(root: string, owner: Seat, actor: string, o
|
|
|
295
291
|
|
|
296
292
|
/**
|
|
297
293
|
* Retires a driver whose assigned tasks are all terminal (done, superseded, or rejected), or, for main only, a coordinator
|
|
298
|
-
* (never main
|
|
294
|
+
* (never main) whose coordinated and assigned tasks are all terminal. A non-main coordinator on the
|
|
295
|
+
* primary checkout retires its record without invoking worktree finish.
|
|
299
296
|
*/
|
|
300
297
|
export async function retireSeat(root: string, address: string, args: string[]) {
|
|
301
298
|
const resolved = await canonicalAddress(root, address);
|
|
@@ -310,7 +307,7 @@ export async function retireSeat(root: string, address: string, args: string[])
|
|
|
310
307
|
const unfinished = owned.filter((entry) => !terminal(entry.task)).map((entry) => entry.id);
|
|
311
308
|
if (unfinished.length) throw new Error(`Cannot retire ${resolved}; it still owns unfinished tasks: ${unfinished.join(", ")}.`);
|
|
312
309
|
await assertRetireAuthority(root, owner, actor, owned);
|
|
313
|
-
await retireDriver(root, owner.project, resolved, owned.at(-1)!.id, true);
|
|
310
|
+
await retireDriver(root, owner.project, resolved, owned.at(-1)!.id, true, coordinator && resolved !== `main@${owner.project}`);
|
|
314
311
|
console.log(`${resolved} retired`);
|
|
315
312
|
}
|
|
316
313
|
|
package/src/vcs-migration.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// URN: component:flow:jj-vcs-migration:vcs-migration:backend:application
|
|
2
2
|
// Tested-By:
|
|
3
3
|
// - test:flow:jj-vcs-migration:E013-UNIT-001
|
|
4
|
+
// - test:flow:jj-vcs-migration:E014-UNIT-001
|
|
4
5
|
// Runtime: bun
|
|
5
6
|
// Purpose: Migrate one legacy Flow Git project to colocated JJ without losing lineage.
|
|
6
7
|
//
|
|
@@ -19,8 +20,9 @@
|
|
|
19
20
|
// Rollback uses the captured branch, commit, and path tuple rather than inferring
|
|
20
21
|
// state from a partial JJ conversion. The durable project VCS is written last.
|
|
21
22
|
|
|
22
|
-
import { readdir, realpath, rm } from "node:fs/promises";
|
|
23
|
+
import { lstat, readFile, readdir, readlink, realpath, rename, rm, writeFile } from "node:fs/promises";
|
|
23
24
|
import { join, resolve } from "node:path";
|
|
25
|
+
import { createHash } from "node:crypto";
|
|
24
26
|
import {
|
|
25
27
|
type Project, type Seat, atomicYaml, exists, paths, project, readYaml, required, yaml,
|
|
26
28
|
} from "./core";
|
|
@@ -32,6 +34,16 @@ type GitWorktree = { path: string; branch?: string };
|
|
|
32
34
|
type TaskLite = { assignee?: string; status?: string; governed_base?: { commit?: string } };
|
|
33
35
|
type ReviewsLite = { attempts?: Array<{ delivery_head?: string }> };
|
|
34
36
|
|
|
37
|
+
/** JJ silently skips new files above this size (snapshot.max-new-file-size default). */
|
|
38
|
+
const SNAPSHOT_MAX_NEW_FILE_BYTES = 1048576;
|
|
39
|
+
const DIRTY_PRIMARY_FLAG = "--allow-dirty-primary";
|
|
40
|
+
|
|
41
|
+
type DiskState =
|
|
42
|
+
| { kind: "absent" }
|
|
43
|
+
| { kind: "file"; sha256: string; exec: boolean }
|
|
44
|
+
| { kind: "symlink"; target: string };
|
|
45
|
+
type DirtyEntry = { path: string; status: string; disk: DiskState };
|
|
46
|
+
|
|
35
47
|
export type VcsMigrationPlan = {
|
|
36
48
|
schema: "atdd-flow/vcs-migration-plan/v1";
|
|
37
49
|
project: string;
|
|
@@ -40,6 +52,7 @@ export type VcsMigrationPlan = {
|
|
|
40
52
|
primary: SeatState;
|
|
41
53
|
workspaces: SeatState[];
|
|
42
54
|
lineage: { tasks_checked: number; reviews_checked: number };
|
|
55
|
+
dirty_primary?: { entries: Array<{ path: string; status: string }> };
|
|
43
56
|
};
|
|
44
57
|
|
|
45
58
|
type PreparedMigration = {
|
|
@@ -47,6 +60,8 @@ type PreparedMigration = {
|
|
|
47
60
|
repository: string;
|
|
48
61
|
states: SeatState[];
|
|
49
62
|
plan: VcsMigrationPlan;
|
|
63
|
+
dirty: DirtyEntry[];
|
|
64
|
+
index: Buffer | null;
|
|
50
65
|
};
|
|
51
66
|
|
|
52
67
|
/** Run one external VCS command without throwing so rollback can inspect failures. */
|
|
@@ -97,19 +112,58 @@ async function activeSeats(root: string, name: string) {
|
|
|
97
112
|
return records.filter(record => !record.retired);
|
|
98
113
|
}
|
|
99
114
|
|
|
115
|
+
/** Read one working-copy path exactly as JJ will snapshot it, without following symlinks. */
|
|
116
|
+
async function diskState(worktree: string, path: string): Promise<DiskState> {
|
|
117
|
+
const full = join(worktree, path);
|
|
118
|
+
const info = await lstat(full).catch(() => null);
|
|
119
|
+
if (!info) return { kind: "absent" };
|
|
120
|
+
if (info.isSymbolicLink()) return { kind: "symlink", target: await readlink(full) };
|
|
121
|
+
if (info.isDirectory()) throw new Error(`Dirty primary path ${path} is a nested repository or gitlink; JJ cannot retain it.`);
|
|
122
|
+
const sha256 = createHash("sha256").update(await readFile(full)).digest("hex");
|
|
123
|
+
return { kind: "file", sha256, exec: (info.mode & 0o111) !== 0 };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Capture the primary's uncommitted paths read-only, refusing anything JJ would drop. */
|
|
127
|
+
async function captureDirtyPrimary(worktree: string): Promise<DirtyEntry[]> {
|
|
128
|
+
// Raw, untrimmed: the first record's leading status space is significant.
|
|
129
|
+
const status = await Bun.spawn(["git", "status", "--porcelain=v1", "-z", "--untracked-files=all", "--no-renames"], { cwd: worktree, stdout: "pipe", stderr: "pipe" });
|
|
130
|
+
const [raw, failure, code] = await Promise.all([new Response(status.stdout).text(), new Response(status.stderr).text(), status.exited]);
|
|
131
|
+
if (code !== 0) throw new Error(`git status failed: ${failure.trim()}`);
|
|
132
|
+
const entries: DirtyEntry[] = [];
|
|
133
|
+
for (const record of raw.split("\0").filter(Boolean)) {
|
|
134
|
+
const flags = record.slice(0, 2), path = record.slice(3);
|
|
135
|
+
const disk = await diskState(worktree, path);
|
|
136
|
+
const isNew = flags.includes("?") || flags.includes("A");
|
|
137
|
+
if (isNew && disk.kind === "file") {
|
|
138
|
+
const size = (await lstat(join(worktree, path))).size;
|
|
139
|
+
if (size > SNAPSHOT_MAX_NEW_FILE_BYTES) {
|
|
140
|
+
throw new Error(`Dirty primary file ${path} (${size} bytes) exceeds the JJ snapshot size limit of ${SNAPSHOT_MAX_NEW_FILE_BYTES} bytes and would not be retained; commit it, move it, or ignore it first.`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
entries.push({ path, status: flags, disk });
|
|
144
|
+
}
|
|
145
|
+
return entries;
|
|
146
|
+
}
|
|
147
|
+
|
|
100
148
|
/** Capture the exact clean Git tuple that migration must preserve or parent. */
|
|
101
|
-
async function inspectSeat(repository: string, record: Seat): Promise<SeatState> {
|
|
149
|
+
async function inspectSeat(repository: string, record: Seat, allowDirtyPrimary: boolean): Promise<{ state: SeatState; dirty: DirtyEntry[] }> {
|
|
102
150
|
const worktree = await realpath(resolve(record.worktree)).catch(() => null);
|
|
103
151
|
if (!worktree) throw new Error(`Seat ${record.address} worktree does not exist: ${record.worktree}`);
|
|
104
152
|
const top = await realpath(await output(worktree, "git", ["rev-parse", "--show-toplevel"]));
|
|
105
153
|
if (top !== worktree) throw new Error(`Seat ${record.address} is not at a Git worktree root: ${worktree}`);
|
|
106
|
-
|
|
154
|
+
const primary = worktree === repository;
|
|
155
|
+
let dirty: DirtyEntry[] = [];
|
|
156
|
+
if (await output(worktree, "git", ["status", "--porcelain"])) {
|
|
157
|
+
if (!primary) throw new Error(`Seat ${record.address} worktree is dirty: ${worktree}`);
|
|
158
|
+
if (!allowDirtyPrimary) throw new Error(`Seat ${record.address} worktree is dirty: ${worktree}. Pass ${DIRTY_PRIMARY_FLAG} to retain the primary checkout's uncommitted changes as JJ working-copy state.`);
|
|
159
|
+
dirty = await captureDirtyPrimary(worktree);
|
|
160
|
+
}
|
|
107
161
|
const branch = await output(worktree, "git", ["branch", "--show-current"]);
|
|
108
162
|
if (branch !== record.branch) throw new Error(`Seat ${record.address} is checked out at ${branch || "detached HEAD"}, not ${record.branch}.`);
|
|
109
163
|
const commit = await output(worktree, "git", ["rev-parse", "HEAD"]);
|
|
110
164
|
const ref = await output(repository, "git", ["rev-parse", "--verify", `refs/heads/${record.branch}^{commit}`]);
|
|
111
165
|
if (commit !== ref) throw new Error(`Seat ${record.address} HEAD does not match branch ${record.branch}.`);
|
|
112
|
-
return { address: record.address, branch: record.branch, worktree, commit, primary
|
|
166
|
+
return { state: { address: record.address, branch: record.branch, worktree, commit, primary }, dirty };
|
|
113
167
|
}
|
|
114
168
|
|
|
115
169
|
/** Parse Git's porcelain registry rather than trusting directory existence alone. */
|
|
@@ -195,7 +249,7 @@ async function assertLineage(root: string, name: string, repository: string, sta
|
|
|
195
249
|
}
|
|
196
250
|
|
|
197
251
|
/** Build the complete deterministic migration plan before creating .jj metadata. */
|
|
198
|
-
async function prepare(root: string, name: string): Promise<PreparedMigration> {
|
|
252
|
+
async function prepare(root: string, name: string, allowDirtyPrimary: boolean): Promise<PreparedMigration> {
|
|
199
253
|
const config = await project(root, name);
|
|
200
254
|
if (vcsMode(config) !== "git") throw new Error(`Project ${name} is not a Git project.`);
|
|
201
255
|
const repository = await realpath(resolve(required(config.repository, `project ${name} repository`))).catch(() => null);
|
|
@@ -205,7 +259,9 @@ async function prepare(root: string, name: string): Promise<PreparedMigration> {
|
|
|
205
259
|
await assertToolVersions(repository);
|
|
206
260
|
const records = await activeSeats(root, name);
|
|
207
261
|
if (!records.length) throw new Error("Project has no active seats; configure vcs: jj directly before creating seats.");
|
|
208
|
-
const
|
|
262
|
+
const inspected = await Promise.all(records.map(record => inspectSeat(repository, record, allowDirtyPrimary)));
|
|
263
|
+
const states = inspected.map(entry => entry.state);
|
|
264
|
+
const dirty = inspected.flatMap(entry => entry.dirty);
|
|
209
265
|
const primary = states.filter(state => state.primary);
|
|
210
266
|
if (primary.length !== 1) throw new Error(`Migration requires exactly one active seat in the primary checkout; found ${primary.length}.`);
|
|
211
267
|
if (new Set(states.map(state => state.worktree)).size !== states.length) throw new Error("Active Flow seats must have distinct worktree paths before migration.");
|
|
@@ -213,15 +269,61 @@ async function prepare(root: string, name: string): Promise<PreparedMigration> {
|
|
|
213
269
|
await assertWorktreeRegistry(repository, states);
|
|
214
270
|
const lineage = await assertLineage(root, name, repository, states);
|
|
215
271
|
const workspaces = states.filter(state => !state.primary);
|
|
216
|
-
|
|
272
|
+
const index = await readIndex(repository);
|
|
273
|
+
const plan: VcsMigrationPlan = { schema: "atdd-flow/vcs-migration-plan/v1", project: name, from: "git", to: "jj", primary: primary[0]!, workspaces, lineage };
|
|
274
|
+
if (dirty.length) plan.dirty_primary = { entries: dirty.map(({ path, status }) => ({ path, status })) };
|
|
275
|
+
return { config, repository, states, plan, dirty, index };
|
|
217
276
|
}
|
|
218
277
|
|
|
219
278
|
/** The primary bookmark is intentionally not advanced during migration. */
|
|
220
|
-
async function verifyPrimary(state: SeatState) {
|
|
279
|
+
async function verifyPrimary(state: SeatState, dirty: DirtyEntry[]) {
|
|
280
|
+
if (dirty.length) return verifyDirtyPrimary(state, dirty);
|
|
221
281
|
const ready = await assertBranchReady("jj", state.worktree, state.branch);
|
|
222
282
|
if (ready !== state.commit) throw new Error(`Primary bookmark ${state.branch} changed during JJ migration.`);
|
|
223
283
|
}
|
|
224
284
|
|
|
285
|
+
/** Read the Git tree entry for one path in the JJ working-copy commit. */
|
|
286
|
+
async function treeEntry(state: SeatState, head: string, path: string) {
|
|
287
|
+
const listed = await output(state.worktree, "git", ["ls-tree", head, "--", path]);
|
|
288
|
+
if (!listed) return null;
|
|
289
|
+
const [meta] = listed.split("\t");
|
|
290
|
+
const [mode, , blob] = (meta ?? "").split(" ");
|
|
291
|
+
return { mode: mode!, blob: blob! };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** True when the tree entry is exactly the captured disk state (content and mode). */
|
|
295
|
+
async function entryMatches(state: SeatState, entry: DirtyEntry, found: { mode: string; blob: string }) {
|
|
296
|
+
const disk = entry.disk;
|
|
297
|
+
if (disk.kind === "symlink") {
|
|
298
|
+
return found.mode === "120000" && await output(state.worktree, "git", ["cat-file", "blob", found.blob]) === disk.target;
|
|
299
|
+
}
|
|
300
|
+
if (disk.kind !== "file") return false;
|
|
301
|
+
const hashed = await output(state.worktree, "git", ["hash-object", "--no-filters", "--", entry.path]);
|
|
302
|
+
return found.mode === (disk.exec ? "100755" : "100644") && found.blob === hashed;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** Compare one retained path in the JJ working-copy commit against the captured disk state. */
|
|
306
|
+
async function verifyDirtyPath(state: SeatState, head: string, entry: DirtyEntry) {
|
|
307
|
+
const now = await diskState(state.worktree, entry.path);
|
|
308
|
+
if (JSON.stringify(now) !== JSON.stringify(entry.disk)) throw new Error(`Primary path ${entry.path} changed on disk during JJ migration.`);
|
|
309
|
+
const found = await treeEntry(state, head, entry.path);
|
|
310
|
+
const retained = entry.disk.kind === "absent" ? found === null : found !== null && await entryMatches(state, entry, found);
|
|
311
|
+
if (!retained) throw new Error(`Primary path ${entry.path} was not retained exactly in the JJ working copy.`);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** A dirty primary must be exactly `main` plus the captured manifest, nothing more. */
|
|
315
|
+
async function verifyDirtyPrimary(state: SeatState, dirty: DirtyEntry[]) {
|
|
316
|
+
const bookmarked = await branchCommit("jj", state.worktree, state.branch);
|
|
317
|
+
const parent = await output(state.worktree, "jj", ["log", "-r", "@-", "--no-graph", "-T", "commit_id"]);
|
|
318
|
+
if (bookmarked !== state.commit || parent !== state.commit) throw new Error(`Primary bookmark ${state.branch} changed during JJ migration.`);
|
|
319
|
+
const head = await output(state.worktree, "jj", ["log", "-r", "@", "--no-graph", "-T", "commit_id"]);
|
|
320
|
+
const changed = (await output(state.worktree, "git", ["diff-tree", "-r", "-z", "--no-renames", "--name-only", state.commit, head])).split("\0").filter(Boolean);
|
|
321
|
+
const expected = new Set(dirty.map(entry => entry.path));
|
|
322
|
+
const extra = changed.filter(path => !expected.has(path));
|
|
323
|
+
if (extra.length) throw new Error(`JJ working copy contains paths outside the captured dirty manifest: ${extra.join(", ")}`);
|
|
324
|
+
for (const entry of dirty) await verifyDirtyPath(state, head, entry);
|
|
325
|
+
}
|
|
326
|
+
|
|
225
327
|
/** Secondary seats may gain one clean child; their legacy tip must be its parent. */
|
|
226
328
|
async function migratedHead(state: SeatState) {
|
|
227
329
|
const current = await output(state.worktree, "jj", ["log", "-r", "@", "--no-graph", "-T", "commit_id"]);
|
|
@@ -267,45 +369,77 @@ async function restoreGitWorkspace(repository: string, state: SeatState) {
|
|
|
267
369
|
return errors;
|
|
268
370
|
}
|
|
269
371
|
|
|
372
|
+
/** Locate the primary's Git index so JJ's colocation rewrite can be undone byte-for-byte. */
|
|
373
|
+
async function indexPath(repository: string) {
|
|
374
|
+
return resolve(repository, await output(repository, "git", ["rev-parse", "--git-path", "index"]));
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
async function readIndex(repository: string): Promise<Buffer | null> {
|
|
378
|
+
return readFile(await indexPath(repository)).catch(() => null);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** Restore (or remove) the captured index atomically. */
|
|
382
|
+
async function restoreIndex(repository: string, index: Buffer | null) {
|
|
383
|
+
const path = await indexPath(repository);
|
|
384
|
+
if (index === null) return rm(path, { force: true });
|
|
385
|
+
const temp = `${path}.migrate-restore`;
|
|
386
|
+
await writeFile(temp, index);
|
|
387
|
+
await rename(temp, path);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** JJ records kept commits under refs/jj; they exist only because this attempt initialized JJ. */
|
|
391
|
+
async function removeJjRefs(repository: string) {
|
|
392
|
+
const refs = (await output(repository, "git", ["for-each-ref", "--format=%(refname)", "refs/jj/"])).split("\n").filter(Boolean);
|
|
393
|
+
for (const ref of refs) await output(repository, "git", ["update-ref", "-d", ref]);
|
|
394
|
+
}
|
|
395
|
+
|
|
270
396
|
/** Best-effort rollback never changes project.vcs and reports every incomplete restore. */
|
|
271
|
-
async function rollback(repository: string, primary: SeatState, displaced: SeatState[]) {
|
|
397
|
+
async function rollback(repository: string, primary: SeatState, displaced: SeatState[], index: Buffer | null) {
|
|
272
398
|
const errors: string[] = [];
|
|
273
399
|
for (const state of [...displaced].reverse()) await removePartialWorkspace(repository, state);
|
|
274
400
|
await rm(join(repository, ".jj"), { recursive: true, force: true }).catch(error => errors.push(String(error)));
|
|
401
|
+
await removeJjRefs(repository).catch(error => errors.push(String(error)));
|
|
275
402
|
const switched = await command(repository, "git", ["switch", primary.branch]);
|
|
276
403
|
if (switched.code) errors.push(switched.err || switched.out);
|
|
277
404
|
for (const state of displaced) errors.push(...await restoreGitWorkspace(repository, state));
|
|
405
|
+
await restoreIndex(repository, index).catch(error => errors.push(String(error)));
|
|
278
406
|
return errors;
|
|
279
407
|
}
|
|
280
408
|
|
|
281
|
-
/**
|
|
282
|
-
|
|
409
|
+
/** Accept exactly `--to jj` plus at most one each of the known flags. */
|
|
410
|
+
function parseMigrationArgs(args: string[]) {
|
|
283
411
|
const toIndex = args.indexOf("--to"), to = toIndex < 0 ? undefined : args[toIndex + 1];
|
|
284
|
-
const
|
|
285
|
-
const
|
|
412
|
+
const flags = ["--dry-run", DIRTY_PRIMARY_FLAG];
|
|
413
|
+
const rest = args.filter((_arg, index) => index !== toIndex && index !== toIndex + 1);
|
|
286
414
|
const valid = toIndex >= 0 && to === "jj"
|
|
287
415
|
&& args.filter(arg => arg === "--to").length === 1
|
|
288
|
-
&&
|
|
289
|
-
&&
|
|
290
|
-
if (!valid) throw new Error("Use `atdd-flow project migrate-vcs <project> --to jj [--dry-run]`.");
|
|
291
|
-
|
|
416
|
+
&& rest.every(arg => flags.includes(arg))
|
|
417
|
+
&& new Set(rest).size === rest.length;
|
|
418
|
+
if (!valid) throw new Error("Use `atdd-flow project migrate-vcs <project> --to jj [--allow-dirty-primary] [--dry-run]`.");
|
|
419
|
+
return { dryRun: rest.includes("--dry-run"), allowDirtyPrimary: rest.includes(DIRTY_PRIMARY_FLAG) };
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** Execute the only supported legacy conversion: Git project to colocated JJ. */
|
|
423
|
+
export async function migrateProjectVcs(root: string, name: string, args: string[]) {
|
|
424
|
+
const { dryRun, allowDirtyPrimary } = parseMigrationArgs(args);
|
|
425
|
+
const prepared = await prepare(root, name, allowDirtyPrimary);
|
|
292
426
|
if (dryRun) { process.stdout.write(yaml.print(prepared.plan)); return; }
|
|
293
427
|
|
|
294
428
|
const displaced: SeatState[] = [];
|
|
295
429
|
try {
|
|
296
430
|
await output(prepared.repository, "jj", ["git", "init", "--colocate"]);
|
|
297
431
|
await assertRepository("jj", prepared.repository);
|
|
298
|
-
await verifyPrimary(prepared.plan.primary);
|
|
432
|
+
await verifyPrimary(prepared.plan.primary, prepared.dirty);
|
|
299
433
|
for (const state of prepared.plan.workspaces) {
|
|
300
434
|
displaced.push(state);
|
|
301
435
|
await convertWorkspace(prepared.repository, state);
|
|
302
436
|
}
|
|
303
|
-
await verifyPrimary(prepared.plan.primary);
|
|
437
|
+
await verifyPrimary(prepared.plan.primary, prepared.dirty);
|
|
304
438
|
for (const state of prepared.plan.workspaces) await verifyMigratedWorkspace(state);
|
|
305
439
|
await atomicYaml(paths(root).projectFile(name), { ...prepared.config, vcs: "jj" });
|
|
306
440
|
process.stdout.write(`${name} migrated git -> jj ${prepared.states.length} seats lineage preserved\n`);
|
|
307
441
|
} catch (error) {
|
|
308
|
-
const rollbackErrors = await rollback(prepared.repository, prepared.plan.primary, displaced);
|
|
442
|
+
const rollbackErrors = await rollback(prepared.repository, prepared.plan.primary, displaced, prepared.index);
|
|
309
443
|
const suffix = rollbackErrors.length ? ` Automatic rollback was incomplete: ${rollbackErrors.join("; ")}` : " Git worktrees were restored.";
|
|
310
444
|
throw new Error(`JJ migration failed before project.vcs changed: ${(error as Error).message}.${suffix}`);
|
|
311
445
|
}
|