@afokapu/atdd-flow 0.3.66 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@afokapu/atdd-flow",
3
- "version": "0.3.66",
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": {
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)),
@@ -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
- if (await output(worktree, "git", ["status", "--porcelain"])) throw new Error(`Seat ${record.address} worktree is dirty: ${worktree}`);
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: worktree === repository };
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 states = await Promise.all(records.map(record => inspectSeat(repository, record)));
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
- return { config, repository, states, plan: { schema: "atdd-flow/vcs-migration-plan/v1", project: name, from: "git", to: "jj", primary: primary[0]!, workspaces, lineage } };
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
- /** Execute the only supported legacy conversion: Git project to colocated JJ. */
282
- export async function migrateProjectVcs(root: string, name: string, args: string[]) {
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 dryRun = args.includes("--dry-run");
285
- const consumed = new Set([toIndex, toIndex + 1, ...(dryRun ? [args.indexOf("--dry-run")] : [])]);
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
- && args.filter(arg => arg === "--dry-run").length <= 1
289
- && args.every((_arg, index) => consumed.has(index));
290
- if (!valid) throw new Error("Use `atdd-flow project migrate-vcs <project> --to jj [--dry-run]`.");
291
- const prepared = await prepare(root, name);
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
  }