From dc1a72b94e6f1f82141bb1373d4171eda19c8303 Mon Sep 17 00:00:00 2001 From: Brenno Ferrari Date: Mon, 1 Jun 2026 22:40:58 +0200 Subject: [PATCH 1/8] feat(core): two-way union merge for adopt auto-merge mode (#120) Adopt compares user-vault vs rendered-shard with no merge base, so a three-way merge (differ.ts) is impossible. twoWayUnionMerge does a best-effort union via diffLines: keep common lines, union each side's unique lines, and report a conflict when both sides replaced the same span (caller prompts on those). Documented limitations (inherent to having no base): keeps user bytes so it cannot apply shard deletions, and can duplicate non-adjacent related edits. content is only valid when hasConflict is false. Binary inputs always conflict. Tests: identical / pure-insertion each side / shard-deletion-kept / non-overlapping-both / overlapping-replacement-conflict / binary / empty / CRLF, plus determinism and common-line-survival properties (fast-check). Co-Authored-By: Claude Opus 4.8 (1M context) --- source/core/adopt-merge.ts | 96 ++++++++++++++++++++++++++++ tests/unit/adopt-merge.test.ts | 111 +++++++++++++++++++++++++++++++++ 2 files changed, 207 insertions(+) create mode 100644 source/core/adopt-merge.ts create mode 100644 tests/unit/adopt-merge.test.ts diff --git a/source/core/adopt-merge.ts b/source/core/adopt-merge.ts new file mode 100644 index 0000000..e86e0e9 --- /dev/null +++ b/source/core/adopt-merge.ts @@ -0,0 +1,96 @@ +/** + * Two-way union merge for `shardmind adopt --mode=auto-merge` (#120). + * + * Adopt compares the user's vault file against the rendered shard file with + * **no common ancestor** — the vault was cloned without shardmind, so the + * original shard version that produced the user's bytes is unrecorded. A + * base-anchored three-way merge (`core/differ.ts`, used by `update`) is + * therefore impossible here. + * + * What we can do instead is a *union* merge: keep every line the two sides + * share, keep each side's unique lines, and treat a region where BOTH sides + * replaced the same span as a conflict that the caller resolves by prompting. + * + * This is a deliberately weaker, best-effort operation — surfaced as such in + * the mode picker and docs. Its limitations are inherent to having no base: + * + * - It **keeps the user's bytes** and therefore **does not apply shard + * deletions**: content the shard removed looks "user-unique" and is + * retained. + * - "Non-conflicting" is a line-*adjacency* heuristic. Two edits to the + * same idea that `diffLines` does not align as one replacement run are + * both kept (union), which can duplicate content. Overlapping (adjacent + * removed+added) runs are always reported as conflicts, never combined. + * + * `content` is the union and is only meaningful when `hasConflict === false`; + * the caller sends conflicting files to the per-file prompt instead. + */ + +import { diffLines } from 'diff'; + +export interface TwoWayMergeResult { + /** The union-merged bytes. Only valid when `hasConflict === false`. */ + content: Buffer; + /** True when some region needs a human decision (or the input is binary). */ + hasConflict: boolean; +} + +/** + * Union-merge `userContent` and `shardContent`. + * + * Binary inputs can't be line-merged, so they always conflict (the caller + * prompts). For text, walk `diffLines` and group each maximal run of + * non-common parts: a run touching only one side is unioned in; a run + * touching both sides (a replacement) marks a conflict. + */ +export function twoWayUnionMerge( + userContent: Buffer, + shardContent: Buffer, + isBinary: boolean, +): TwoWayMergeResult { + if (isBinary) { + // No meaningful line merge — force a prompt. `content` is unused. + return { content: userContent, hasConflict: true }; + } + + const parts = diffLines(userContent.toString('utf8'), shardContent.toString('utf8')); + + let hasConflict = false; + let out = ''; + + let i = 0; + while (i < parts.length) { + const part = parts[i]!; + if (!part.added && !part.removed) { + // Common to both sides — emit verbatim (raw value preserves the + // exact bytes, including any CRLF / trailing newline). + out += part.value; + i++; + continue; + } + + // Gather the maximal contiguous run of changed parts. + let runHasUser = false; // removed === present in user, absent in shard + let runHasShard = false; // added === present in shard, absent in user + let runValue = ''; + let j = i; + while (j < parts.length && (parts[j]!.added || parts[j]!.removed)) { + if (parts[j]!.removed) runHasUser = true; + if (parts[j]!.added) runHasShard = true; + runValue += parts[j]!.value; + j++; + } + + if (runHasUser && runHasShard) { + // Both sides replaced the same span — a real conflict. Leave it for + // the per-file prompt; the union `content` is no longer usable. + hasConflict = true; + } else { + // Pure one-side change (user-unique OR shard-unique lines) — union it. + out += runValue; + } + i = j; + } + + return { content: Buffer.from(out, 'utf8'), hasConflict }; +} diff --git a/tests/unit/adopt-merge.test.ts b/tests/unit/adopt-merge.test.ts new file mode 100644 index 0000000..23ed93f --- /dev/null +++ b/tests/unit/adopt-merge.test.ts @@ -0,0 +1,111 @@ +import { describe, it, expect } from 'vitest'; +import * as fc from 'fast-check'; +import { twoWayUnionMerge } from '../../source/core/adopt-merge.js'; + +const buf = (s: string) => Buffer.from(s, 'utf8'); + +describe('twoWayUnionMerge', () => { + it('identical content → no conflict, content unchanged', () => { + const s = 'line1\nline2\n'; + const r = twoWayUnionMerge(buf(s), buf(s), false); + expect(r.hasConflict).toBe(false); + expect(r.content.toString('utf8')).toBe(s); + }); + + it('pure shard insertion → union takes the shard line, no conflict', () => { + const user = 'line1\nline2\n'; + const shard = 'line1\ninserted\nline2\n'; + const r = twoWayUnionMerge(buf(user), buf(shard), false); + expect(r.hasConflict).toBe(false); + expect(r.content.toString('utf8')).toBe(shard); + }); + + it('pure user-only line (shard deleted it) → union KEEPS the user line, no conflict', () => { + // Demonstrates the documented limitation: without a base, a shard + // deletion is indistinguishable from a user-unique line, so it is kept. + const user = 'line1\nmine\nline2\n'; + const shard = 'line1\nline2\n'; + const r = twoWayUnionMerge(buf(user), buf(shard), false); + expect(r.hasConflict).toBe(false); + expect(r.content.toString('utf8')).toBe(user); + }); + + it('non-overlapping edits on both sides → union of both, no conflict', () => { + const user = 'header\nmine\nfooter\n'; + const shard = 'header\nfooter\ntheirs\n'; + const r = twoWayUnionMerge(buf(user), buf(shard), false); + expect(r.hasConflict).toBe(false); + const out = r.content.toString('utf8'); + expect(out).toContain('mine'); + expect(out).toContain('theirs'); + expect(out).toContain('header'); + expect(out).toContain('footer'); + }); + + it('overlapping replacement of the same line → conflict', () => { + const user = 'line1\nMINE\nline3\n'; + const shard = 'line1\nTHEIRS\nline3\n'; + const r = twoWayUnionMerge(buf(user), buf(shard), false); + expect(r.hasConflict).toBe(true); + }); + + it('binary input → always conflict (no line merge attempted)', () => { + const user = '\x00\x01mine'; + const shard = '\x00\x01theirs'; + const r = twoWayUnionMerge(buf(user), buf(shard), true); + expect(r.hasConflict).toBe(true); + }); + + it('empty user file, shard has content → union is the shard content', () => { + const r = twoWayUnionMerge(buf(''), buf('a\nb\n'), false); + expect(r.hasConflict).toBe(false); + expect(r.content.toString('utf8')).toBe('a\nb\n'); + }); + + it('preserves CRLF bytes in unioned regions', () => { + const user = 'a\r\nb\r\n'; + const shard = 'a\r\ninserted\r\nb\r\n'; + const r = twoWayUnionMerge(buf(user), buf(shard), false); + expect(r.hasConflict).toBe(false); + expect(r.content.toString('utf8')).toBe(shard); + }); + + // ── Properties ────────────────────────────────────────────────────── + + it('property: deterministic — same inputs produce the same result', () => { + fc.assert( + fc.property(fc.string(), fc.string(), (u, s) => { + const a = twoWayUnionMerge(buf(u), buf(s), false); + const b = twoWayUnionMerge(buf(u), buf(s), false); + return ( + a.hasConflict === b.hasConflict && + a.content.equals(b.content) + ); + }), + ); + }); + + it('property: when no conflict, every common line survives in the union', () => { + // Build user/shard from a shared anchor list with side-unique insertions + // so the merge is a clean union; assert the anchors all appear. + fc.assert( + fc.property( + fc.uniqueArray(fc.stringMatching(/^[a-z]{1,8}$/), { minLength: 1, maxLength: 6 }), + fc.stringMatching(/^[A-Z]{1,8}$/), + fc.stringMatching(/^[0-9]{1,8}$/), + (anchors, userExtra, shardExtra) => { + // user = anchors with userExtra appended; shard = anchors with + // shardExtra appended. The trailing insertions are on different + // sides at the same tail position, so they may or may not conflict; + // we only assert the invariant on the no-conflict branch. + const user = [...anchors, userExtra].join('\n') + '\n'; + const shard = [...anchors, shardExtra].join('\n') + '\n'; + const r = twoWayUnionMerge(buf(user), buf(shard), false); + if (r.hasConflict) return true; // precondition not met + const out = r.content.toString('utf8'); + return anchors.every((a) => out.includes(a)); + }, + ), + ); + }); +}); From fe5cc2e38a903c595d822518bc94c8036103b6ae Mon Sep 17 00:00:00 2001 From: Brenno Ferrari Date: Mon, 1 Jun 2026 22:45:49 +0200 Subject: [PATCH 2/8] feat(core): executor support for merged adopt resolutions (#120) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend AdoptResolution with a { kind:'merged', content, hash } variant so the auto-merge mode can hand the executor pre-merged union bytes. The executor writes them as `modified` ownership at the merged hash (it's user-customized content), buckets them in a new summary.adoptedMerged, adds the 'differs-merged' AdoptApplyKind, and snapshots merged paths for rollback alongside use_shard (both overwrite the user's file). A future `update` three-way-merges the result against the cached shard template. labelForAction gains the merged glyph so its exhaustive switch typechecks. Integration test: differs + merged resolution → union bytes on disk, `modified` ownership at the merged hash, adoptedMerged bucket. Co-Authored-By: Claude Opus 4.8 (1M context) --- source/commands/hooks/use-adopt-machine.ts | 2 + source/core/adopt-executor.ts | 53 ++++++++++++++++++---- tests/integration/adopt.test.ts | 53 ++++++++++++++++++++++ 3 files changed, 99 insertions(+), 9 deletions(-) diff --git a/source/commands/hooks/use-adopt-machine.ts b/source/commands/hooks/use-adopt-machine.ts index 2a608b8..5d109fc 100644 --- a/source/commands/hooks/use-adopt-machine.ts +++ b/source/commands/hooks/use-adopt-machine.ts @@ -505,5 +505,7 @@ function labelForAction(kind: AdoptApplyKind): string { return '→'; case 'differs-use-shard': return '↻'; + case 'differs-merged': + return '⊕'; } } diff --git a/source/core/adopt-executor.ts b/source/core/adopt-executor.ts index 13aa830..62ff8f0 100644 --- a/source/core/adopt-executor.ts +++ b/source/core/adopt-executor.ts @@ -60,11 +60,25 @@ const SNAPSHOT_CONCURRENCY = 16; * One decision per `differs` entry. The diff UI returns this shape; the * executor reads it. Two values mirror the spec's two-choice prompt. */ -export type AdoptResolution = 'keep_mine' | 'use_shard'; +/** + * One decision per `differs` entry. `keep_mine` / `use_shard` mirror the + * per-file prompt. `merged` carries the bytes produced by the auto-merge + * mode's two-way union merge (#120) so the executor can write them without + * re-running the merge; the machine computes them and supplies the hash. + */ +export type AdoptResolution = + | 'keep_mine' + | 'use_shard' + | { kind: 'merged'; content: Buffer; hash: string }; /** Map vault-relative path → resolution for every `plan.differs[]` entry. */ export type AdoptResolutions = Record; +/** A resolution that overwrites the user's file → needs a rollback snapshot. */ +function overwritesUserFile(resolution: AdoptResolution | undefined): boolean { + return resolution === 'use_shard' || (typeof resolution === 'object' && resolution.kind === 'merged'); +} + export interface AdoptRunnerOptions { vaultRoot: string; manifest: ShardManifest; @@ -100,7 +114,8 @@ export type AdoptApplyKind = | 'matches' | 'shard-only' | 'differs-keep-mine' - | 'differs-use-shard'; + | 'differs-use-shard' + | 'differs-merged'; export type AdoptProgressEvent = | { kind: 'start'; total: number } @@ -124,6 +139,8 @@ export interface AdoptSummary { matchedAuto: string[]; adoptedMine: string[]; adoptedShard: string[]; + /** Files written by the auto-merge mode's union merge (#120). */ + adoptedMerged: string[]; installedFresh: string[]; totalManaged: number; } @@ -215,6 +232,7 @@ export async function runAdopt(opts: AdoptRunnerOptions): Promise { matchedAuto: [], adoptedMine: [], adoptedShard: [], + adoptedMerged: [], installedFresh: [], totalManaged: 0, }; @@ -279,7 +297,11 @@ export async function runAdopt(opts: AdoptRunnerOptions): Promise { ); } const action: AdoptApplyKind = - resolution === 'keep_mine' ? 'differs-keep-mine' : 'differs-use-shard'; + resolution === 'keep_mine' + ? 'differs-keep-mine' + : resolution === 'use_shard' + ? 'differs-use-shard' + : 'differs-merged'; onProgress?.({ kind: 'file', index, @@ -292,13 +314,25 @@ export async function runAdopt(opts: AdoptRunnerOptions): Promise { fileStates[c.path] = buildFileState(c, c.userHash, 'modified'); onFileTouched?.(c.path, false); summary.adoptedMine.push(c.path); - } else { + } else if (resolution === 'use_shard') { if (!dryRun) { await writeVaultFileBuffer(vaultRoot, c.path, c.shardContent); } fileStates[c.path] = buildFileState(c, c.shardHash, 'managed'); onFileTouched?.(c.path, false); summary.adoptedShard.push(c.path); + } else { + // Auto-merge (#120): write the union-merged bytes. The result is + // user-customized content (it contains the user's lines), so it is + // recorded as `modified` ownership at the merged hash — exactly like + // a kept-but-edited managed file. A future `update` three-way-merges + // it against the cached shard template, which is the proper base. + if (!dryRun) { + await writeVaultFileBuffer(vaultRoot, c.path, resolution.content); + } + fileStates[c.path] = buildFileState(c, resolution.hash, 'modified'); + onFileTouched?.(c.path, false); + summary.adoptedMerged.push(c.path); } } @@ -363,10 +397,11 @@ async function createBackupDir(vaultRoot: string, now: Date): Promise { } /** - * Snapshot every path the apply phase will overwrite. Only `differs + - * use_shard` triggers a snapshot — `matches` writes nothing, - * `differs + keep_mine` writes nothing, and `shard-only` writes to a - * path that doesn't exist yet (rollback erases via `addedPaths` instead). + * Snapshot every path the apply phase will overwrite — `differs + use_shard` + * and `differs + merged` (auto-merge) both replace existing user bytes. + * `matches` writes nothing, `differs + keep_mine` writes nothing, and + * `shard-only` writes to a path that doesn't exist yet (rollback erases via + * `addedPaths` instead). * * Tolerates ENOENT defensively: a `differs-use-shard` path whose user * file vanished between plan-time and execute-time is unusual but not @@ -383,7 +418,7 @@ async function snapshotForRollback( await fsp.mkdir(filesBackupDir, { recursive: true }); const toSnapshot = plan.differs - .filter((c) => resolutions[c.path] === 'use_shard') + .filter((c) => overwritesUserFile(resolutions[c.path])) .map((c) => c.path); await mapConcurrent(toSnapshot, SNAPSHOT_CONCURRENCY, async (rel) => { diff --git a/tests/integration/adopt.test.ts b/tests/integration/adopt.test.ts index cc21e1b..ba812b0 100644 --- a/tests/integration/adopt.test.ts +++ b/tests/integration/adopt.test.ts @@ -299,6 +299,59 @@ describe('adopt pipeline (against examples/minimal-shard)', () => { expect(result.summary.installedFresh).toContain('brain/North Star.md'); }); + it('differs + merged resolution: writes the union bytes as `modified`, buckets as adoptedMerged (#120)', async () => { + // The executor does not re-run the merge — it writes whatever bytes the + // `merged` resolution carries (the machine computes them via + // twoWayUnionMerge). Feed explicit merged bytes to isolate the executor's + // merged branch from the merge algorithm (unit-tested separately). + const { manifest, schema } = await loadShard(); + const selections = defaultModuleSelections(schema); + const validator = buildValuesValidator(schema); + const values = validator.parse(resolveComputedDefaults(schema, VALUES)); + + await runInstall({ + vaultRoot: vault, + manifest, + schema, + tempDir: MINIMAL_SHARD, + resolved: RESOLVED, + tarballSha256: 'deadbeef', + values: values as Record, + selections, + }); + await fsp.rm(path.join(vault, '.shardmind'), { recursive: true, force: true }); + await fsp.rm(path.join(vault, 'shard-values.yaml'), { force: true }); + + // Mutate Home.md so it classifies as `differs`. + await fsp.writeFile(path.join(vault, 'Home.md'), '# Home — my preferred shape\n', 'utf-8'); + + const adoptPlan = await plan(vault); + expect(adoptPlan.differs.map((c) => c.path)).toContain('Home.md'); + + const mergedBytes = Buffer.from('# Home — union of mine + shard\n', 'utf-8'); + const mergedHash = sha256(mergedBytes); + // Resolve every differs path (minimal-shard has a time-varying template + // that also classifies as `differs`); only Home.md uses `merged`. + const resolutions: AdoptResolutions = {}; + for (const c of adoptPlan.differs) resolutions[c.path] = 'keep_mine'; + resolutions['Home.md'] = { kind: 'merged', content: mergedBytes, hash: mergedHash }; + + const { result } = await adopt(vault, resolutions); + + // Merged bytes are on disk. + const homeDisk = await fsp.readFile(path.join(vault, 'Home.md'), 'utf-8'); + expect(homeDisk).toBe('# Home — union of mine + shard\n'); + + // Recorded as user-customized (modified) at the merged hash. + const state = (await readState(vault)) as ShardState; + expect(state.files['Home.md']?.ownership).toBe('modified'); + expect(state.files['Home.md']?.rendered_hash).toBe(mergedHash); + + expect(result.summary.adoptedMerged).toContain('Home.md'); + expect(result.summary.adoptedMine).not.toContain('Home.md'); + expect(result.summary.adoptedShard).not.toContain('Home.md'); + }); + it('rejects adopt when .shardmind/state.json already exists', async () => { // Simulate a previously-installed vault. const { manifest, schema } = await loadShard(); From 363c30ee3bd377a12cffaba0f03c9ce3992433ce Mon Sep 17 00:00:00 2001 From: Brenno Ferrari Date: Mon, 1 Jun 2026 23:06:26 +0200 Subject: [PATCH 3/8] feat(adopt): batch mode picker + --mode flag for the differs set (#120) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a top-level AdoptModePicker shown once (when files differ and no --mode/--yes) before the per-file loop, plus a --mode flag for non-interactive runs: - keep-all-mine / use-all-theirs resolve every divergent file one way. - auto-merge two-way-unions the non-conflicting files (writing merged bytes) and sends only the conflicting ones to the per-file prompt; non-interactive auto-merge falls those back to keep_mine. - decide-per-file is the existing per-file loop. Machine: new mode-select phase + applyMode; the diff-review phase now iterates a `queue` (the files needing a prompt) with pre-seeded resolutions, so per-file and auto-merge share one loop. --yes is shorthand for keep-all-mine; --mode overrides it. AdoptMode is component-owned (AdoptModePicker) and imported by the machine, mirroring AdoptDiffAction — components never import from commands. UI: AdoptSummary renders the new adoptedMerged bucket with a "review recommended" note. The auto-merge option is labelled best-effort. Tests: AdoptModePicker component test (modes + firedRef guard); Layer 1 scenarios 27-30 (keep-all / use-all / auto-merge mixed conflict / --mode non-interactive); scenarios 20-21 updated to pass through the picker; Layer 2 PTY scenario 20 picks decide-per-file; AdoptSummary merged-bucket test + fixture. Co-Authored-By: Claude Opus 4.8 (1M context) --- source/commands/adopt.tsx | 24 +++- source/commands/hooks/use-adopt-machine.ts | 145 ++++++++++++++++++--- source/components/AdoptModePicker.tsx | 75 +++++++++++ source/components/AdoptSummary.tsx | 8 ++ tests/component/AdoptModePicker.test.tsx | 76 +++++++++++ tests/component/AdoptSummary.test.tsx | 12 ++ tests/component/flows/adopt-flow.test.tsx | 138 ++++++++++++++++++++ tests/component/flows/helpers.tsx | 1 + tests/e2e/tui/adopt-diff.test.ts | 14 ++ 9 files changed, 470 insertions(+), 23 deletions(-) create mode 100644 source/components/AdoptModePicker.tsx create mode 100644 tests/component/AdoptModePicker.test.tsx diff --git a/source/commands/adopt.tsx b/source/commands/adopt.tsx index 44db3fb..9cf2ee8 100644 --- a/source/commands/adopt.tsx +++ b/source/commands/adopt.tsx @@ -5,6 +5,7 @@ import { ShardMindError, assertNever } from '../runtime/types.js'; import { Spinner, StatusMessage, Alert } from '../components/ui.js'; import AdoptValuesGate from '../components/AdoptValuesGate.js'; +import AdoptModePicker from '../components/AdoptModePicker.js'; import AdoptDiffView from '../components/AdoptDiffView.js'; import AdoptSummary from '../components/AdoptSummary.js'; import CommandFrame from '../components/CommandFrame.js'; @@ -28,6 +29,12 @@ export const options = zod.object({ .boolean() .default(false) .describe('Skip prompts; auto-keep your version on every differs decision'), + mode: zod + .enum(['keep-all-mine', 'use-all-theirs', 'auto-merge', 'decide-per-file']) + .optional() + .describe( + 'Resolve all divergent files non-interactively. auto-merge is best-effort (keeps your bytes, ignores shard deletions, may duplicate — review after)', + ), verbose: zod.boolean().default(false).describe('Show per-file action history during adopt'), dryRun: zod .boolean() @@ -46,18 +53,20 @@ type Props = { export default function Adopt({ args, options }: Props) { const [shardRef] = args; - const { values: valuesFile, yes, verbose, dryRun, noUpdateCheck } = options; + const { values: valuesFile, yes, mode, verbose, dryRun, noUpdateCheck } = options; const { phase, onWizardComplete, onWizardCancel, onWizardError, + onModeSelect, onDiffChoice, } = useAdoptMachine({ shardRef: shardRef!, valuesFile, yes, + mode, verbose, dryRun, vaultRoot: process.cwd(), @@ -108,15 +117,24 @@ export default function Adopt({ args, options }: Props) { ); + case 'mode-select': + return ( + + + + ); case 'diff-review': { - const target = phase.plan.differs[phase.currentIndex]; + const target = phase.queue[phase.currentIndex]; if (!target || target.kind !== 'differs') return null; return ( void; onWizardCancel: () => void; onWizardError: (err: Error) => void; + onModeSelect: (mode: AdoptMode) => void; onDiffChoice: (action: AdoptDiffAction) => void; } export function useAdoptMachine(input: UseAdoptMachineInput): UseAdoptMachineOutput { - const { shardRef, valuesFile, yes, verbose, dryRun, vaultRoot } = input; + const { shardRef, valuesFile, yes, mode, verbose, dryRun, vaultRoot } = input; const { exit } = useApp(); const [phase, setPhase] = useState({ kind: 'booting' }); @@ -285,31 +302,28 @@ export function useAdoptMachine(input: UseAdoptMachineInput): UseAdoptMachineOut selections: validatedResult.selections, }); - if (plan.differs.length === 0 || yes) { - // --yes auto-resolves every differs as `keep_mine` — preserves - // the user's bytes, which is the safe default for retroactive - // adoption (the user opted into keeping the vault they already - // had). They can re-run with explicit decisions if they want. - const resolutions: AdoptResolutions = {}; - for (const c of plan.differs) resolutions[c.path] = 'keep_mine'; - await executeAdopt(ctx, validatedResult, plan, resolutions); + if (plan.differs.length === 0) { + await executeAdopt(ctx, validatedResult, plan, {}); return; } - setPhase({ - kind: 'diff-review', - ctx, - result: validatedResult, - plan, - currentIndex: 0, - resolutions: {}, - }); + // `--mode` overrides the picker; `--yes` is shorthand for + // keep-all-mine (preserve the user's bytes — the safe default for + // retroactive adoption). With neither, prompt for the mode. + const effectiveMode: AdoptMode | undefined = + mode ?? (yes ? 'keep-all-mine' : undefined); + if (effectiveMode) { + await applyMode(effectiveMode, ctx, validatedResult, plan, false); + return; + } + + setPhase({ kind: 'mode-select', ctx, result: validatedResult, plan }); } catch (err) { finish({ kind: 'error', error: err as Error }); } }, // eslint-disable-next-line react-hooks/exhaustive-deps - [vaultRoot, yes, finish], + [vaultRoot, yes, mode, finish], ); const executeAdopt = useCallback( @@ -439,6 +453,87 @@ export function useAdoptMachine(input: UseAdoptMachineInput): UseAdoptMachineOut [vaultRoot, dryRun, verbose, finish], ); + // Resolve the `differs` set according to a batch mode, then either execute + // directly or drop into the per-file prompt for whatever's left. + const applyMode = useCallback( + async ( + selected: AdoptMode, + ctx: PreparedContext, + result: WizardResult, + plan: AdoptPlan, + interactive: boolean, + ) => { + try { + if (selected === 'keep-all-mine' || selected === 'use-all-theirs') { + const decision = selected === 'keep-all-mine' ? 'keep_mine' : 'use_shard'; + const resolutions: AdoptResolutions = {}; + for (const c of plan.differs) resolutions[c.path] = decision; + await executeAdopt(ctx, result, plan, resolutions); + return; + } + + if (selected === 'decide-per-file') { + setPhase({ + kind: 'diff-review', + ctx, + result, + plan, + queue: plan.differs, + currentIndex: 0, + resolutions: {}, + }); + return; + } + + // auto-merge: two-way-union each differs file; non-conflicting files + // resolve to their merged bytes, conflicting files queue for a prompt. + const resolutions: AdoptResolutions = {}; + const queue: AdoptClassification[] = []; + for (const c of plan.differs) { + if (c.kind !== 'differs') continue; + const merged = twoWayUnionMerge(c.userContent, c.shardContent, c.isBinary); + if (merged.hasConflict) { + queue.push(c); + } else { + resolutions[c.path] = { + kind: 'merged', + content: merged.content, + hash: sha256(merged.content), + }; + } + } + + if (queue.length === 0) { + await executeAdopt(ctx, result, plan, resolutions); + return; + } + + if (interactive) { + setPhase({ + kind: 'diff-review', + ctx, + result, + plan, + queue, + currentIndex: 0, + resolutions, + }); + return; + } + + // Non-interactive auto-merge (`--mode=auto-merge` with no prompts): + // conflicting files fall back to keep_mine (preserve the user's + // bytes). They still surface in the summary's adoptedMine bucket. + for (const c of queue) resolutions[c.path] = 'keep_mine'; + await executeAdopt(ctx, result, plan, resolutions); + } catch (err) { + finish({ kind: 'error', error: err as Error }); + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [finish], + ); + const onWizardComplete = useCallback( (result: WizardResult) => { const current = phaseRef.current; @@ -458,15 +553,24 @@ export function useAdoptMachine(input: UseAdoptMachineInput): UseAdoptMachineOut [finish], ); + const onModeSelect = useCallback( + (selected: AdoptMode) => { + const current = phaseRef.current; + if (current.kind !== 'mode-select') return; + void applyMode(selected, current.ctx, current.result, current.plan, true); + }, + [applyMode], + ); + const onDiffChoice = useCallback( (action: AdoptDiffAction) => { const current = phaseRef.current; if (current.kind !== 'diff-review') return; - const target = current.plan.differs[current.currentIndex]; + const target = current.queue[current.currentIndex]; if (!target) return; const next = { ...current.resolutions, [target.path]: action }; const nextIndex = current.currentIndex + 1; - if (nextIndex < current.plan.differs.length) { + if (nextIndex < current.queue.length) { setPhase({ ...current, currentIndex: nextIndex, resolutions: next }); return; } @@ -480,6 +584,7 @@ export function useAdoptMachine(input: UseAdoptMachineInput): UseAdoptMachineOut onWizardComplete, onWizardCancel, onWizardError, + onModeSelect, onDiffChoice, }; } diff --git a/source/components/AdoptModePicker.tsx b/source/components/AdoptModePicker.tsx new file mode 100644 index 0000000..57a3cf4 --- /dev/null +++ b/source/components/AdoptModePicker.tsx @@ -0,0 +1,75 @@ +import { useRef } from 'react'; +import { Box, Text } from 'ink'; +import { Select } from './ui.js'; + +/** + * Batch resolution mode for the `differs` set (#120). `keep-all-mine` / + * `use-all-theirs` resolve every file one way; `auto-merge` two-way-unions + * the non-conflicting files and prompts on the rest; `decide-per-file` is the + * per-file prompt loop. Selected via `--mode` or this picker. Defined here + * (component-owned) so the command machine imports it the same way it imports + * `AdoptDiffAction` / `WizardResult` — components never import from commands. + */ +export type AdoptMode = + | 'keep-all-mine' + | 'use-all-theirs' + | 'auto-merge' + | 'decide-per-file'; + +interface AdoptModePickerProps { + /** Number of files that differ from the shard (drives the header count). */ + differsCount: number; + onSelect: (mode: AdoptMode) => void; +} + +const MODE_VALUES = new Set([ + 'keep-all-mine', + 'use-all-theirs', + 'auto-merge', + 'decide-per-file', +]); + +/** + * Top-level batch resolution picker shown once before the per-file diff loop + * (#120). Deciding individually across dozens of divergent files is poor UX; + * this lets the user resolve them all at once, with per-file prompting kept + * as a mode. + * + * The auto-merge label is deliberately honest: it is a best-effort two-way + * union merge (no merge base exists for adopt), so it keeps the user's bytes, + * does NOT apply shard deletions, and can duplicate non-adjacent edits — the + * user should review merged files. See `core/adopt-merge.ts`. + */ +export default function AdoptModePicker({ differsCount, onSelect }: AdoptModePickerProps) { + // `Select` can fire onChange more than once if Ink re-focuses the instance + // (see CollisionReview / DiffView). One-shot decision → guard it. + const firedRef = useRef(false); + + return ( + + + {differsCount} file{differsCount === 1 ? '' : 's'} differ from the shard. + + How should I resolve them? + +