@afokapu/atdd-flow 0.3.70 → 0.3.72

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/src/tasks.ts CHANGED
@@ -29,6 +29,19 @@ export type CoordinatorTransfer = {
29
29
  };
30
30
  export type TerminalDisposition = { kind: "superseded" | "rejected"; by: string; at: string; reason: string };
31
31
  export type DeferredDisposition = { by: string; at: string; reason: string; owner: string; trigger: string; review_at: string };
32
+ export type DispatchAlert = { key: string; message: string; observed_at: string };
33
+ /** Persisted evidence for an immediate assignment; each field is written only by its own typed command. */
34
+ export type ImmediateDispatch = {
35
+ thread: string;
36
+ message: string;
37
+ priority: string;
38
+ mutation_boundary: string;
39
+ assignee: string;
40
+ status: "dispatched";
41
+ acknowledgement?: { dispatch: string; by: string; acknowledged_at: string };
42
+ start?: { by: string; transitioned_at: string };
43
+ alerts?: { missing_acknowledgement?: DispatchAlert; missing_start?: DispatchAlert };
44
+ };
32
45
  export type Task = {
33
46
  schema: "atdd-workflow/task/v1";
34
47
  title: string;
@@ -51,6 +64,8 @@ export type Task = {
51
64
  disposition?: TerminalDisposition;
52
65
  /** Bounded hold with an accountable owner, trigger, and review point. */
53
66
  deferred?: DeferredDisposition;
67
+ /** Absent unless assigned with `--immediate`. */
68
+ immediate_dispatch?: ImmediateDispatch;
54
69
  done_when: DoneWhen[];
55
70
  };
56
71
 
@@ -346,11 +361,95 @@ export async function assign(root: string, projectName: string, id: string, args
346
361
  if (task.status !== "todo") throw new Error(`Task ${id} can only be assigned while todo.`);
347
362
  if (task.assignee) throw new Error(`Task ${id} is already assigned to ${task.assignee}.`);
348
363
  const assignee = await canonicalAddress(root, required(words(args, "--assignee"), "--assignee"));
349
- const base = await evidenceBase(root, projectName, task.coordinator, await eligibleAssignee(root, projectName, assignee));
364
+ const target = await eligibleAssignee(root, projectName, assignee);
365
+ const base = await evidenceBase(root, projectName, task.coordinator, target);
366
+ const immediate = args.includes("--immediate") ? await dispatchImmediately(root, projectName, id, task, target, args) : undefined;
350
367
  task.assignee = assignee;
351
368
  if (base) task.governed_base = base;
369
+ if (immediate) task.immediate_dispatch = immediate;
370
+ await writeTask(root, projectName, id, task);
371
+ console.log(`${id} assigned ${assignee}${immediate ? " dispatched" : ""}`);
372
+ }
373
+
374
+ /** The assignee's todo -> in_progress transition is the only writer of start evidence. */
375
+ function recordDispatchStart(task: Task, next: TaskStatus, actor: string) {
376
+ if (next === "in_progress" && task.status === "todo" && task.immediate_dispatch) {
377
+ task.immediate_dispatch.start = { by: actor, transitioned_at: now() };
378
+ }
379
+ }
380
+
381
+ async function quietly<T>(run: () => Promise<T>) {
382
+ const log = console.log;
383
+ console.log = () => {};
384
+ try { return await run(); }
385
+ finally { console.log = log; }
386
+ }
387
+
388
+ /**
389
+ * Fails closed before any write when the assignee has no native queue, then persists one task-linked
390
+ * dispatch through the existing native mail path. The task write that claims "dispatched" happens last.
391
+ */
392
+ async function dispatchImmediately(root: string, projectName: string, id: string, task: Task, target: Seat, args: string[]): Promise<ImmediateDispatch> {
393
+ const priority = required(words(args, "--priority"), "--priority");
394
+ const boundary = required(words(args, "--mutation-boundary"), "--mutation-boundary");
395
+ const threads = await import("./threads");
396
+ if (!threads.hasNativeQueue(target)) throw new Error(`Assignee ${target.address} has no native queue; immediate assignment needs a native Flow inbox.`);
397
+ const thread = await quietly(() => threads.startThread(root, [
398
+ "--with", `${task.coordinator},${target.address}`, "--subject", `Immediate assignment ${projectName}/${id}`, "--task", `${projectName}/${id}`,
399
+ ]));
400
+ const body = [
401
+ `Immediate assignment: ${projectName}/${id}`, `Priority: ${priority}`, `Mutation boundary: ${boundary}`,
402
+ `Acknowledge (typed, not prose): atdd-flow task acknowledge ${projectName} ${id} --dispatch <this message id> --by ${target.address}`,
403
+ `Start: atdd-flow task start ${projectName} ${id} --by ${target.address}`,
404
+ ].join("\n");
405
+ const message = await quietly(() => threads.post(root, thread, ["--from", task.coordinator, "--to", target.address, "--label", "dispatch", "--expects-result", "--body", body]));
406
+ return { thread, message, priority, mutation_boundary: boundary, assignee: target.address, status: "dispatched" };
407
+ }
408
+
409
+ /** Binds an acknowledgement to the exact stored dispatch and assignee; it never changes task status. */
410
+ export async function acknowledge(root: string, projectName: string, id: string, args: string[]) {
411
+ const task = await readTask(root, projectName, taskId(id));
412
+ const actor = await canonicalAddress(root, required(words(args, "--by"), "--by"));
413
+ const dispatch = required(words(args, "--dispatch"), "--dispatch");
414
+ const stored = task.immediate_dispatch;
415
+ if (!stored) throw new Error(`Task ${id} has no immediate dispatch.`);
416
+ if (dispatch !== stored.message) throw new Error(`${dispatch} is not the dispatch for task ${id}.`);
417
+ if (actor !== stored.assignee || actor !== task.assignee) throw new Error(`Only ${stored.assignee} may acknowledge task ${id}.`);
418
+ if (terminal(task)) throw new Error(`Task ${id} is already ${task.status}.`);
419
+ if (!stored.acknowledgement) {
420
+ stored.acknowledgement = { dispatch, by: actor, acknowledged_at: now() };
421
+ await writeTask(root, projectName, id, task);
422
+ }
423
+ console.log(`${id} acknowledged ${actor}`);
424
+ }
425
+
426
+ function missingSignal(task: Task): "missing_acknowledgement" | "missing_start" | undefined {
427
+ const stored = task.immediate_dispatch;
428
+ if (!stored || terminal(task)) return undefined;
429
+ if (!stored.acknowledgement) return "missing_acknowledgement";
430
+ return task.status === "todo" ? "missing_start" : undefined;
431
+ }
432
+
433
+ /** On demand and bounded: posts at most one alert per missing state, then returns the stored one. */
434
+ export async function observeAssignment(root: string, projectName: string, id: string, args: string[]) {
435
+ const task = await readTask(root, projectName, taskId(id));
436
+ const actor = await canonicalAddress(root, required(words(args, "--by"), "--by"));
437
+ if (actor !== task.coordinator) throw new Error(`Only ${task.coordinator} may observe the assignment of task ${id}.`);
438
+ const stored = task.immediate_dispatch;
439
+ if (!stored) throw new Error(`Task ${id} has no immediate dispatch.`);
440
+ const state = missingSignal(task);
441
+ if (!state) { console.log(`${id} no missing signal`); return; }
442
+ const existing = stored.alerts?.[state];
443
+ if (existing) { console.log(`${id} ${state} ${existing.message}`); return; }
444
+ const action = state === "missing_acknowledgement"
445
+ ? `atdd-flow task acknowledge ${projectName} ${id} --dispatch ${stored.message} --by ${stored.assignee}`
446
+ : `atdd-flow task start ${projectName} ${id} --by ${stored.assignee}`;
447
+ const { post } = await import("./threads");
448
+ const message = await quietly(() => post(root, stored.thread, ["--from", task.coordinator, "--to", stored.assignee, "--label", state, "--expects-result", "--body",
449
+ `Alert ${state} for ${projectName}/${id} (dispatch ${stored.message}). Run: ${action}`]));
450
+ stored.alerts = { ...stored.alerts, [state]: { key: `${stored.message}/${state}`, message, observed_at: now() } };
352
451
  await writeTask(root, projectName, id, task);
353
- console.log(`${id} assigned ${assignee}`);
452
+ console.log(`${id} ${state} ${message}`);
354
453
  }
355
454
 
356
455
  type TransferAuthorization = {
@@ -512,6 +611,7 @@ async function transition(root: string, projectName: string, id: string, next: T
512
611
  await assertAcceptedBehavioralReview(root, projectName, id, task);
513
612
  if (retire) await retireAssignee(root, projectName, id, task);
514
613
  }
614
+ recordDispatchStart(task, next, actor);
515
615
  task.status = next;
516
616
  if (next !== "in_progress") { delete task.blocker; delete task.deferred; }
517
617
  await writeTask(root, projectName, id, task);
package/src/threads.ts CHANGED
@@ -234,9 +234,13 @@ export async function publishNativeMail(root: string, address: string, _segment:
234
234
  });
235
235
  }
236
236
 
237
+ /** The single predicate for a durable native inbox queue: an explicit native wake or a Pi-designated seat. */
238
+ export const hasNativeQueue = (target: { agent?: string; runtime?: { wake?: string } }) =>
239
+ target.runtime?.wake === "native" || target.agent === "pi";
240
+
237
241
  async function prepareNativeMail(root: string, address: string, message: Message, threadId: string) {
238
242
  const target = await seat(root, address);
239
- if (target.runtime?.wake !== "native" && target.agent !== "pi") return;
243
+ if (!hasNativeQueue(target)) return;
240
244
  const segment = await enqueueNativeMail(root, address, { thread: threadId, message: message.id, created_at: message.created_at });
241
245
  return { address, segment };
242
246
  }
@@ -244,7 +248,7 @@ async function prepareNativeMail(root: string, address: string, message: Message
244
248
  async function inject(root: string, address: string, message: Message, threadId: string, queued = false) {
245
249
  const target = await seat(root, address);
246
250
  const runtime = target.runtime;
247
- if (runtime?.wake === "native" || target.agent === "pi") {
251
+ if (hasNativeQueue(target)) {
248
252
  if (!queued) await enqueueNativeMail(root, address, { thread: threadId, message: message.id, created_at: message.created_at, published: true });
249
253
  if (!runtime || runtime.wake === "native") return;
250
254
  }
@@ -2,6 +2,7 @@
2
2
  // Tested-By:
3
3
  // - test:flow:jj-vcs-migration:E013-UNIT-001
4
4
  // - test:flow:jj-vcs-migration:E014-UNIT-001
5
+ // - test:flow:jj-vcs-migration:E017-UNIT-001
5
6
  // Runtime: bun
6
7
  // Purpose: Migrate one legacy Flow Git project to colocated JJ without losing lineage.
7
8
  //
@@ -21,7 +22,7 @@
21
22
  // state from a partial JJ conversion. The durable project VCS is written last.
22
23
 
23
24
  import { lstat, readFile, readdir, readlink, realpath, rename, rm, writeFile } from "node:fs/promises";
24
- import { join, resolve } from "node:path";
25
+ import { isAbsolute, join, normalize, resolve } from "node:path";
25
26
  import { createHash } from "node:crypto";
26
27
  import {
27
28
  type Project, type Seat, atomicYaml, exists, paths, project, readYaml, required, yaml,
@@ -37,12 +38,19 @@ type ReviewsLite = { attempts?: Array<{ delivery_head?: string }> };
37
38
  /** JJ silently skips new files above this size (snapshot.max-new-file-size default). */
38
39
  const SNAPSHOT_MAX_NEW_FILE_BYTES = 1048576;
39
40
  const DIRTY_PRIMARY_FLAG = "--allow-dirty-primary";
41
+ const RETAIN_LARGE_FLAG = "--retain-large-primary-file";
42
+ /** Plain literals (no interpolation): angle-bracket placeholders must never look like an HTML fragment. */
43
+ const SPEC_FORMAT = "<path>:<sha256>:<bytes>:<mode>";
44
+ const USAGE = "Use `atdd-flow project migrate-vcs <project> --to jj [--allow-dirty-primary [--retain-large-primary-file <path>:<sha256>:<bytes>:<mode> ...]] [--dry-run]`.";
45
+
46
+ /** One operator-authorized over-limit primary file, matched against disk exactly. */
47
+ type LargeFile = { path: string; sha256: string; bytes: number; mode: string };
40
48
 
41
49
  type DiskState =
42
50
  | { kind: "absent" }
43
51
  | { kind: "file"; sha256: string; exec: boolean }
44
52
  | { kind: "symlink"; target: string };
45
- type DirtyEntry = { path: string; status: string; disk: DiskState };
53
+ type DirtyEntry = { path: string; status: string; disk: DiskState; retained?: LargeFile };
46
54
 
47
55
  export type VcsMigrationPlan = {
48
56
  schema: "atdd-flow/vcs-migration-plan/v1";
@@ -53,6 +61,7 @@ export type VcsMigrationPlan = {
53
61
  workspaces: SeatState[];
54
62
  lineage: { tasks_checked: number; reviews_checked: number };
55
63
  dirty_primary?: { entries: Array<{ path: string; status: string }> };
64
+ large_primary?: LargeFile[];
56
65
  };
57
66
 
58
67
  type PreparedMigration = {
@@ -64,6 +73,31 @@ type PreparedMigration = {
64
73
  index: Buffer | null;
65
74
  };
66
75
 
76
+ /** Parse `<path>:<sha256>:<bytes>:<mode>` from the right so the path alone may contain colons. */
77
+ function parseLargeFile(spec: string): LargeFile {
78
+ const match = spec.match(/^(.+):([0-9a-f]{64}):(\d+):([0-7]{4})$/);
79
+ if (!match) throw new Error(`${RETAIN_LARGE_FLAG} expects ${SPEC_FORMAT} (lowercase 64-hex sha256, decimal bytes, four-digit octal mode); got ${spec}.`);
80
+ const [, path, sha256, bytes, mode] = match as unknown as [string, string, string, string, string];
81
+ if (isAbsolute(path) || path.split("/").includes("..") || normalize(path) !== path || path.endsWith("/")) {
82
+ throw new Error(`${RETAIN_LARGE_FLAG} path must be a normalized repository-relative path with no '..' (outside the primary checkout is refused): ${path}`);
83
+ }
84
+ return { path, sha256, bytes: Number(bytes), mode };
85
+ }
86
+
87
+ /** Every authorization must name a path that is actually dirty in the primary checkout. */
88
+ function assertAuthorizationsDirty(authorized: LargeFile[], dirty: DirtyEntry[]) {
89
+ for (const auth of authorized) {
90
+ if (!dirty.some(entry => entry.path === auth.path)) throw new Error(`Authorized large file ${auth.path} is not a dirty primary path (it does not exist or is unchanged).`);
91
+ }
92
+ }
93
+
94
+ /** Compare one over-limit new file with its authorization; every field must match exactly. */
95
+ function assertAuthorizedLarge(auth: LargeFile, size: number, mode: string, sha256: string) {
96
+ if (size !== auth.bytes) throw new Error(`Authorized large file ${auth.path} is ${size} bytes on disk, not the authorized ${auth.bytes} bytes.`);
97
+ if (sha256 !== auth.sha256) throw new Error(`Authorized large file ${auth.path} sha256 ${sha256} does not match the authorized sha256 ${auth.sha256}.`);
98
+ if (mode !== auth.mode) throw new Error(`Authorized large file ${auth.path} has mode ${mode} on disk, not the authorized mode ${auth.mode}.`);
99
+ }
100
+
67
101
  /** Run one external VCS command without throwing so rollback can inspect failures. */
68
102
  async function command(cwd: string, executable: string, args: string[]): Promise<CommandResult> {
69
103
  const child = Bun.spawn([executable, ...args], { cwd, stdout: "pipe", stderr: "pipe" });
@@ -123,30 +157,48 @@ async function diskState(worktree: string, path: string): Promise<DiskState> {
123
157
  return { kind: "file", sha256, exec: (info.mode & 0o111) !== 0 };
124
158
  }
125
159
 
160
+ /** Classify one dirty path: retained exactly, plain, or an unauthorized over-limit refusal. */
161
+ async function classifyDirty(worktree: string, flags: string, path: string, authorized: LargeFile[]) {
162
+ const disk = await diskState(worktree, path);
163
+ const auth = authorized.find(item => item.path === path);
164
+ const info = disk.kind === "file" ? await lstat(join(worktree, path)) : null;
165
+ const isNew = flags.includes("?") || flags.includes("A");
166
+ const overLimit = isNew && info !== null && info.size > SNAPSHOT_MAX_NEW_FILE_BYTES;
167
+ const entry: DirtyEntry = { path, status: flags, disk };
168
+ if (auth) {
169
+ if (!overLimit || disk.kind !== "file" || !info) {
170
+ throw new Error(`Authorized file ${path} does not exceed the JJ snapshot size limit of ${SNAPSHOT_MAX_NEW_FILE_BYTES} bytes as a new regular file; the authorization is refused.`);
171
+ }
172
+ assertAuthorizedLarge(auth, info.size, (info.mode & 0o777).toString(8).padStart(4, "0"), disk.sha256);
173
+ entry.retained = auth;
174
+ }
175
+ const refusal = overLimit && !auth
176
+ ? `Dirty primary file ${path} (${info!.size} bytes) exceeds the JJ snapshot size limit of ${SNAPSHOT_MAX_NEW_FILE_BYTES} bytes and would not be retained; commit it, move it, ignore it, or authorize it exactly with ${RETAIN_LARGE_FLAG}.`
177
+ : undefined;
178
+ return { entry, refusal };
179
+ }
180
+
126
181
  /** Capture the primary's uncommitted paths read-only, refusing anything JJ would drop. */
127
- async function captureDirtyPrimary(worktree: string): Promise<DirtyEntry[]> {
182
+ async function captureDirtyPrimary(worktree: string, authorized: LargeFile[]): Promise<DirtyEntry[]> {
128
183
  // Raw, untrimmed: the first record's leading status space is significant.
129
184
  const status = await Bun.spawn(["git", "status", "--porcelain=v1", "-z", "--untracked-files=all", "--no-renames"], { cwd: worktree, stdout: "pipe", stderr: "pipe" });
130
185
  const [raw, failure, code] = await Promise.all([new Response(status.stdout).text(), new Response(status.stderr).text(), status.exited]);
131
186
  if (code !== 0) throw new Error(`git status failed: ${failure.trim()}`);
132
187
  const entries: DirtyEntry[] = [];
188
+ const refused: string[] = [];
133
189
  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 });
190
+ const { entry, refusal } = await classifyDirty(worktree, record.slice(0, 2), record.slice(3), authorized);
191
+ entries.push(entry);
192
+ if (refusal) refused.push(refusal);
144
193
  }
194
+ // Report a stale authorization before an unauthorized file so the operator fixes the spec first.
195
+ assertAuthorizationsDirty(authorized, entries);
196
+ if (refused.length) throw new Error(refused[0]!);
145
197
  return entries;
146
198
  }
147
199
 
148
200
  /** Capture the exact clean Git tuple that migration must preserve or parent. */
149
- async function inspectSeat(repository: string, record: Seat, allowDirtyPrimary: boolean): Promise<{ state: SeatState; dirty: DirtyEntry[] }> {
201
+ async function inspectSeat(repository: string, record: Seat, allowDirtyPrimary: boolean, authorized: LargeFile[]): Promise<{ state: SeatState; dirty: DirtyEntry[] }> {
150
202
  const worktree = await realpath(resolve(record.worktree)).catch(() => null);
151
203
  if (!worktree) throw new Error(`Seat ${record.address} worktree does not exist: ${record.worktree}`);
152
204
  const top = await realpath(await output(worktree, "git", ["rev-parse", "--show-toplevel"]));
@@ -156,7 +208,7 @@ async function inspectSeat(repository: string, record: Seat, allowDirtyPrimary:
156
208
  if (await output(worktree, "git", ["status", "--porcelain"])) {
157
209
  if (!primary) throw new Error(`Seat ${record.address} worktree is dirty: ${worktree}`);
158
210
  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);
211
+ dirty = await captureDirtyPrimary(worktree, authorized);
160
212
  }
161
213
  const branch = await output(worktree, "git", ["branch", "--show-current"]);
162
214
  if (branch !== record.branch) throw new Error(`Seat ${record.address} is checked out at ${branch || "detached HEAD"}, not ${record.branch}.`);
@@ -249,7 +301,7 @@ async function assertLineage(root: string, name: string, repository: string, sta
249
301
  }
250
302
 
251
303
  /** Build the complete deterministic migration plan before creating .jj metadata. */
252
- async function prepare(root: string, name: string, allowDirtyPrimary: boolean): Promise<PreparedMigration> {
304
+ async function prepare(root: string, name: string, allowDirtyPrimary: boolean, authorized: LargeFile[]): Promise<PreparedMigration> {
253
305
  const config = await project(root, name);
254
306
  if (vcsMode(config) !== "git") throw new Error(`Project ${name} is not a Git project.`);
255
307
  const repository = await realpath(resolve(required(config.repository, `project ${name} repository`))).catch(() => null);
@@ -259,9 +311,10 @@ async function prepare(root: string, name: string, allowDirtyPrimary: boolean):
259
311
  await assertToolVersions(repository);
260
312
  const records = await activeSeats(root, name);
261
313
  if (!records.length) throw new Error("Project has no active seats; configure vcs: jj directly before creating seats.");
262
- const inspected = await Promise.all(records.map(record => inspectSeat(repository, record, allowDirtyPrimary)));
314
+ const inspected = await Promise.all(records.map(record => inspectSeat(repository, record, allowDirtyPrimary, authorized)));
263
315
  const states = inspected.map(entry => entry.state);
264
316
  const dirty = inspected.flatMap(entry => entry.dirty);
317
+ assertAuthorizationsDirty(authorized, dirty);
265
318
  const primary = states.filter(state => state.primary);
266
319
  if (primary.length !== 1) throw new Error(`Migration requires exactly one active seat in the primary checkout; found ${primary.length}.`);
267
320
  if (new Set(states.map(state => state.worktree)).size !== states.length) throw new Error("Active Flow seats must have distinct worktree paths before migration.");
@@ -272,6 +325,8 @@ async function prepare(root: string, name: string, allowDirtyPrimary: boolean):
272
325
  const index = await readIndex(repository);
273
326
  const plan: VcsMigrationPlan = { schema: "atdd-flow/vcs-migration-plan/v1", project: name, from: "git", to: "jj", primary: primary[0]!, workspaces, lineage };
274
327
  if (dirty.length) plan.dirty_primary = { entries: dirty.map(({ path, status }) => ({ path, status })) };
328
+ const large = dirty.flatMap(entry => entry.retained ? [entry.retained] : []);
329
+ if (large.length) plan.large_primary = large;
275
330
  return { config, repository, states, plan, dirty, index };
276
331
  }
277
332
 
@@ -309,6 +364,17 @@ async function verifyDirtyPath(state: SeatState, head: string, entry: DirtyEntry
309
364
  const found = await treeEntry(state, head, entry.path);
310
365
  const retained = entry.disk.kind === "absent" ? found === null : found !== null && await entryMatches(state, entry, found);
311
366
  if (!retained) throw new Error(`Primary path ${entry.path} was not retained exactly in the JJ working copy.`);
367
+ if (entry.retained) await verifyLargeBlob(state, head, entry.retained);
368
+ }
369
+
370
+ /** Hash-preservation: the bytes stored in the JJ working-copy commit equal the authorization. */
371
+ async function verifyLargeBlob(state: SeatState, head: string, auth: LargeFile) {
372
+ const child = Bun.spawn(["git", "cat-file", "blob", `${head}:${auth.path}`], { cwd: state.worktree, stdout: "pipe", stderr: "pipe" });
373
+ const [bytes, code] = await Promise.all([new Response(child.stdout).arrayBuffer(), child.exited]);
374
+ const digest = createHash("sha256").update(new Uint8Array(bytes)).digest("hex");
375
+ if (code !== 0 || bytes.byteLength !== auth.bytes || digest !== auth.sha256) {
376
+ throw new Error(`Authorized large file ${auth.path} in the JJ working copy does not match the authorized sha256 ${auth.sha256} and ${auth.bytes} bytes.`);
377
+ }
312
378
  }
313
379
 
314
380
  /** A dirty primary must be exactly `main` plus the captured manifest, nothing more. */
@@ -399,35 +465,63 @@ async function rollback(repository: string, primary: SeatState, displaced: SeatS
399
465
  for (const state of [...displaced].reverse()) await removePartialWorkspace(repository, state);
400
466
  await rm(join(repository, ".jj"), { recursive: true, force: true }).catch(error => errors.push(String(error)));
401
467
  await removeJjRefs(repository).catch(error => errors.push(String(error)));
402
- const switched = await command(repository, "git", ["switch", primary.branch]);
403
- if (switched.code) errors.push(switched.err || switched.out);
468
+ // Skip the switch when HEAD already names the branch so rollback adds no HEAD reflog entry.
469
+ const onBranch = await command(repository, "git", ["symbolic-ref", "-q", "HEAD"]);
470
+ if (onBranch.out !== `refs/heads/${primary.branch}`) {
471
+ const switched = await command(repository, "git", ["switch", primary.branch]);
472
+ if (switched.code) errors.push(switched.err || switched.out);
473
+ }
404
474
  for (const state of displaced) errors.push(...await restoreGitWorkspace(repository, state));
405
475
  await restoreIndex(repository, index).catch(error => errors.push(String(error)));
406
476
  return errors;
407
477
  }
408
478
 
409
- /** Accept exactly `--to jj` plus at most one each of the known flags. */
479
+ /** Require exactly one `--to jj` pair and return the remaining arguments. */
480
+ function withoutTarget(args: string[]) {
481
+ const toIndex = args.indexOf("--to");
482
+ const valid = toIndex >= 0 && args[toIndex + 1] === "jj" && args.filter(arg => arg === "--to").length === 1;
483
+ if (!valid) throw new Error(USAGE);
484
+ return args.filter((_arg, index) => index !== toIndex && index !== toIndex + 1);
485
+ }
486
+
487
+ /** Parse the value after one `--retain-large-primary-file`, refusing duplicates. */
488
+ function parseRetainValue(value: string | undefined, authorized: LargeFile[]) {
489
+ if (value === undefined || value.startsWith("--")) throw new Error(USAGE);
490
+ const file = parseLargeFile(value);
491
+ if (authorized.some(item => item.path === file.path)) throw new Error(`${RETAIN_LARGE_FLAG} names ${file.path} more than once (duplicate authorization).`);
492
+ return file;
493
+ }
494
+
495
+ /** Accept exactly `--to jj`, at most one each of the known flags, and any number of distinct file authorizations. */
410
496
  function parseMigrationArgs(args: string[]) {
411
- const toIndex = args.indexOf("--to"), to = toIndex < 0 ? undefined : args[toIndex + 1];
412
- const flags = ["--dry-run", DIRTY_PRIMARY_FLAG];
413
- const rest = args.filter((_arg, index) => index !== toIndex && index !== toIndex + 1);
414
- const valid = toIndex >= 0 && to === "jj"
415
- && args.filter(arg => arg === "--to").length === 1
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) };
497
+ const rest = withoutTarget(args);
498
+ const flags = new Set<string>();
499
+ const authorized: LargeFile[] = [];
500
+ for (let index = 0; index < rest.length; index += 1) {
501
+ const arg = rest[index]!;
502
+ if (arg === RETAIN_LARGE_FLAG) {
503
+ authorized.push(parseRetainValue(rest[index + 1], authorized));
504
+ index += 1;
505
+ } else if (["--dry-run", DIRTY_PRIMARY_FLAG].includes(arg) && !flags.has(arg)) flags.add(arg);
506
+ else throw new Error(USAGE);
507
+ }
508
+ if (authorized.length && !flags.has(DIRTY_PRIMARY_FLAG)) throw new Error(`${RETAIN_LARGE_FLAG} requires ${DIRTY_PRIMARY_FLAG}.`);
509
+ return { dryRun: flags.has("--dry-run"), allowDirtyPrimary: flags.has(DIRTY_PRIMARY_FLAG), authorized };
420
510
  }
421
511
 
422
512
  /** Execute the only supported legacy conversion: Git project to colocated JJ. */
423
513
  export async function migrateProjectVcs(root: string, name: string, args: string[]) {
424
- const { dryRun, allowDirtyPrimary } = parseMigrationArgs(args);
425
- const prepared = await prepare(root, name, allowDirtyPrimary);
514
+ const { dryRun, allowDirtyPrimary, authorized } = parseMigrationArgs(args);
515
+ const prepared = await prepare(root, name, allowDirtyPrimary, authorized);
426
516
  if (dryRun) { process.stdout.write(yaml.print(prepared.plan)); return; }
427
517
 
428
518
  const displaced: SeatState[] = [];
429
519
  try {
430
- await output(prepared.repository, "jj", ["git", "init", "--colocate"]);
520
+ // The raised snapshot limit is scoped to this one command and equals the largest authorized file;
521
+ // preflight proved every over-limit new file is authorized and verification rejects any extra path.
522
+ const limit = Math.max(0, ...(prepared.plan.large_primary ?? []).map(file => file.bytes));
523
+ const config = limit > SNAPSHOT_MAX_NEW_FILE_BYTES ? [`--config=snapshot.max-new-file-size=${limit}`] : [];
524
+ await output(prepared.repository, "jj", [...config, "git", "init", "--colocate"]);
431
525
  await assertRepository("jj", prepared.repository);
432
526
  await verifyPrimary(prepared.plan.primary, prepared.dirty);
433
527
  for (const state of prepared.plan.workspaces) {