diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e95c11..fbabfe1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,18 @@ Between releases: see `git log` for merged work and [`ROADMAP.md`](ROADMAP.md) f ## [Unreleased] +### Added (adopt batch operations — #120) + +Closes [#120](https://github.com/breferrari/shardmind/issues/120). `shardmind adopt` against a divergent vault asked for a decision on *every* differing file (the flagship obsidian-mind run had 35). A top-level mode picker now resolves the whole set at once, with per-file prompting kept as a mode. + +- **Mode picker** (`source/components/AdoptModePicker.tsx`) shown once before the per-file loop when files differ and neither `--mode` nor `--yes` is set: **Keep all mine** / **Use all theirs** / **Auto-merge (best-effort)** / **Decide per file**. A `--mode=keep-all-mine|use-all-theirs|auto-merge|decide-per-file` flag drives it non-interactively (overrides `--yes`, which is now shorthand for keep-all-mine). + +- **Auto-merge is a best-effort two-way *union* merge** (`source/core/adopt-merge.ts`), not a three-way merge: adopt has no merge base (the vault was cloned without shardmind), so a base-anchored merge is impossible. It keeps common lines, unions each side's unique lines, and sends files where both sides replaced the same span to the per-file prompt. **Its limits are surfaced, not hidden:** it keeps the user's bytes so it **does not apply shard deletions**, and can duplicate non-adjacent edits. Merged files are written as `modified` ownership and flagged **"review recommended"** in the Summary. Non-interactive auto-merge keeps-mine on conflicts. + +- **Executor** gains a `{ kind:'merged', content, hash }` resolution (`adopt-executor.ts`): writes the union bytes, records `modified` ownership at the merged hash, snapshots merged paths for rollback, and buckets them in `summary.adoptedMerged`. A subsequent `update` three-way-merges the result against the cached shard template (the proper base). The machine's `diff-review` phase now iterates a `queue` (only the files needing a prompt) with pre-seeded resolutions, so per-file and auto-merge share one loop. + +- **Tests**: `adopt-merge` unit matrix + determinism/union properties (fast-check); executor merged-resolution integration test; `AdoptModePicker` component test (modes + firedRef guard); Layer 1 flow scenarios 27-30 (keep-all / use-all / auto-merge mixed conflict / `--mode` non-interactive); Layer 2 PTY scenario picks decide-per-file; `AdoptSummary` merged-bucket test. **Docs**: `docs/ARCHITECTURE.md §10.5` (modes + best-effort note), `docs/AUTHORING.md`, `CLAUDE.md`. + ### Changed (hook lifecycle split — #102) — **breaking, shard-author-facing** Part of [#102](https://github.com/breferrari/shardmind/issues/102) (the engine + fixture + docs; the issue stays open until the release-window items below land). Splits the single `post-install` hook into three named slots with engine-enforced contracts, turning yesterday's comment-checked conventions into machine-checked signals. Ships in the same release as [#121](https://github.com/breferrari/shardmind/issues/121) (engine version-compatibility check), which is the guard that stops a pre-0.2 engine from silently ignoring the new slots; obsidian-mind's hook migration ([`obsidian-mind#75`](https://github.com/breferrari/obsidian-mind/issues/75)) ships in the same window. diff --git a/CLAUDE.md b/CLAUDE.md index 42fb2cf..735879b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -146,6 +146,7 @@ shardmind/ │ │ ├── ExistingInstallGate.tsx # Install: existing-install disambiguation │ │ ├── DiffView.tsx # Three-way diff + conflict resolution │ │ ├── AdoptValuesGate.tsx # Adopt: values confirm-or-override page (#104) +│ │ ├── AdoptModePicker.tsx # Adopt: batch mode picker (keep-all / use-all / auto-merge / per-file) (#120) │ │ ├── AdoptDiffView.tsx # Adopt: 2-way diff (no merge base) + per-file prompt │ │ ├── AdoptSummary.tsx # Final adopt report (matched / mine / shard / fresh) │ │ ├── NewValuesPrompt.tsx # Update: prompt for newly required values @@ -180,6 +181,7 @@ shardmind/ │ │ ├── install-executor.ts # Apply install plan with rollback │ │ ├── adopt-planner.ts # Classify user vault vs shard (matches/differs/shard-only) │ │ ├── adopt-executor.ts # Apply adopt plan with snapshot-rollback +│ │ ├── adopt-merge.ts # Two-way union merge for adopt --mode=auto-merge (#120) │ │ ├── values-io.ts # Shared YAML load for shard-values.yaml │ │ ├── values-defaults.ts # `valuesAreDefaults(values, schema)` — Invariant 2 helper │ │ ├── update-check.ts # 24h cached latest-version lookup (status + update) @@ -338,6 +340,7 @@ Each file in `source/core/` maps 1:1 to a section in `docs/IMPLEMENTATION.md`: | `update-executor.ts` | §4.12 | Apply update plan with snapshot-based rollback | | `adopt-planner.ts` | §4.17 | Classify user vault vs shard outputs (matches / differs / shard-only) | | `adopt-executor.ts` | §4.18 | Apply adopt plan with snapshot-based rollback | +| `adopt-merge.ts` | SHARD-LAYOUT.md §Adopt semantics | Two-way union merge for `adopt --mode=auto-merge` (#120) | | `values-io.ts` | §4.13 | Shared YAML load for shard-values.yaml (install + update) | | `values-defaults.ts` | §4.16 (HookContext extensions) | `valuesAreDefaults(values, schema)` — deep-equal pure fn for Invariant 2 | | `status.ts` | §4.14 | Pure StatusReport builder for the `shardmind` (status) command | diff --git a/ROADMAP.md b/ROADMAP.md index 5547f1b..3496f21 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -127,7 +127,7 @@ UX gaps surfaced during real obsidian-mind v6 install + adopt runs. None block t - [x] Wizard scroll indicator + boolean prompt consistency (Y/n typed input → selectable Yes/No) ([#100](https://github.com/breferrari/shardmind/issues/100)) — smallest item, recommended starting point. - [x] `multiselect` value type for module-set questions ([#101](https://github.com/breferrari/shardmind/issues/101)) — pairs with #100 (shortens module list). First-class value type + scrollable widget + per-option `default` + min/max; value→module gating stays #80. - [x] Adopt: replace step-by-step install wizard with confirm-or-override values flow ([#104](https://github.com/breferrari/shardmind/issues/104)) -- [ ] Adopt batch operations (keep all mine / use all theirs / auto-merge non-conflicting) ([#120](https://github.com/breferrari/shardmind/issues/120)) — pairs with #104. +- [x] Adopt batch operations (keep all mine / use all theirs / auto-merge non-conflicting) ([#120](https://github.com/breferrari/shardmind/issues/120)) — pairs with #104. - [ ] Hook stderr presentation in Summary (truncate, dim, label as non-fatal) ([#105](https://github.com/breferrari/shardmind/issues/105)) ### 0.1.x — Done gate diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1b419d5..dbbf472 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -875,15 +875,20 @@ Phase ordering (logical; UI may interleave loading messages — see IMPLEMENTATI 2. **Values gate** (`source/components/AdoptValuesGate.tsx`, #104) — adopt's user already has a populated vault, so it opens on a single confirm page that surfaces the values that will drive classification (resolved defaults, computed defaults, `--values` prefill, each labelled with its provenance) plus the module default, with **Use these values / Override individually / Cancel**. "Override individually" drops into the same `InstallWizard` install uses (per-value + module editing). Required values with no default and no prefill open straight into the wizard (no confident set to confirm). This step runs **before** classification because `.njk` templates need values to render before their output bytes can be hashed. 3. **Classify** (`source/core/adopt-planner.ts::classifyAdoption`) — for every shard output, render or read, hash, stat the user's vault, and assign one of four buckets: - **matches** — byte-identical post-render → managed silently. - - **differs** — bytes differ → 2-way diff prompt (`AdoptDiffView`); user picks `keep_mine` (record user's hash, ownership=`modified`) or `use_shard` (overwrite with shard bytes, ownership=`managed`). + - **differs** — bytes differ → resolved per the chosen batch mode (below); `keep_mine` records the user's hash (ownership=`modified`), `use_shard` overwrites with shard bytes (ownership=`managed`), `merged` writes union bytes (ownership=`modified`). - **shard-only** — user doesn't have the path → install fresh, managed. - Implicit **user-only** — paths in vault but not in shard → never enumerated, left untouched. -4. **Apply** (`source/core/adopt-executor.ts::runAdopt`) — snapshot any `differs+use_shard` user file before overwriting, write shard-only fresh installs, write engine metadata (`state.json`, cached manifest+schema, templates cache, vault-root `shard-values.yaml`). Snapshot-then-restore rollback on any failure between snapshot and final state-write. +3a. **Mode select** (`source/components/AdoptModePicker.tsx`, #120) — when there is at least one `differs` file and neither `--mode` nor `--yes` is set, adopt prompts once for how to resolve the whole set rather than file-by-file. Four modes: + - **Keep all mine** / **Use all theirs** — resolve every `differs` one way. + - **Auto-merge (best-effort)** — two-way-union each file (`source/core/adopt-merge.ts`): non-conflicting files resolve to their merged bytes; files where both sides replaced the same span fall through to the per-file prompt. **This is a union merge with no merge base** (adopt has no record of the original shard version): it keeps the user's bytes, **does not apply shard deletions**, and can duplicate non-adjacent edits — merged files are flagged "review recommended" in the Summary. Non-interactive auto-merge (`--mode=auto-merge` under `--yes`) keeps-mine on conflicts. + - **Decide per file** — the per-file `AdoptDiffView` prompt (`keep_mine` / `use_shard`). +4. **Apply** (`source/core/adopt-executor.ts::runAdopt`) — snapshot any user file an apply will overwrite (`differs+use_shard` and `differs+merged`), write shard-only fresh installs, write engine metadata (`state.json`, cached manifest+schema, templates cache, vault-root `shard-values.yaml`). Snapshot-then-restore rollback on any failure between snapshot and final state-write. 5. **Hooks** — run the install-side slots via the orchestrator: `bootstrap` (unmanaged setup), then `personalize` (managed edits) unless `valuesAreDefaults` (engine-skipped, Invariant 2). `newFiles=summary.installedFresh`. Non-fatal (Helm semantics, §9.3). 6. **Re-hash** — recompute managed-file hashes per `state.ts::rehashManagedFiles` so any hook edits to managed paths land in the recorded state. Flags: -- `--yes` — skip the values gate + auto-pick `keep_mine` on every `differs`. Preserves the user's bytes on every divergence; safe default for retroactive adoption. +- `--yes` — skip the values gate + the mode picker; resolve every `differs` as `keep_mine` (shorthand for `--mode=keep-all-mine`). Preserves the user's bytes on every divergence; safe default for retroactive adoption. +- `--mode ` (#120) — choose how to resolve the `differs` set, skipping the picker. `keep-all-mine`/`use-all-theirs`/`auto-merge` are non-interactive (`auto-merge` still prompts on conflicts unless `--yes` is also set, which keeps-mine on them); `decide-per-file` is the per-file prompt loop. Overrides `--yes`'s default. The flag governs the diff set, not value collection — combine with `--yes`/`--values` for a fully non-interactive run. - `--values ` — prefill value answers (same shape as `install --values`); shown on the gate's confirm page as `(from --values)`. - `--verbose` — show per-file action history during the apply phase. - `--dry-run` — run the full pipeline (fetch, values gate, classify) without touching the vault. Summary reports what *would* happen. @@ -892,7 +897,7 @@ Volatile templates (`{# shardmind: volatile #}`) skip the differs prompt entirel Excluded modules' files in the user's vault are not classified — adopt mirrors install's "module excluded → file not installed" rule, so user content at those paths stays user-content without any prompt. -Implementation modules: `source/core/adopt-planner.ts` (IMPLEMENTATION §4.17), `source/core/adopt-executor.ts` (§4.18). Orchestration lives in `source/commands/hooks/use-adopt-machine.ts`; UI components are `source/components/AdoptValuesGate.tsx` (values confirm-or-override), `source/components/AdoptDiffView.tsx` + `source/components/AdoptSummary.tsx`. +Implementation modules: `source/core/adopt-planner.ts` (IMPLEMENTATION §4.17), `source/core/adopt-executor.ts` (§4.18), `source/core/adopt-merge.ts` (two-way union merge for auto-merge mode). Orchestration lives in `source/commands/hooks/use-adopt-machine.ts`; UI components are `source/components/AdoptValuesGate.tsx` (values confirm-or-override), `source/components/AdoptModePicker.tsx` (batch mode picker), `source/components/AdoptDiffView.tsx` + `source/components/AdoptSummary.tsx`. ### 10.6 Install Location diff --git a/docs/AUTHORING.md b/docs/AUTHORING.md index c3d49ea..8994b87 100644 --- a/docs/AUTHORING.md +++ b/docs/AUTHORING.md @@ -15,7 +15,7 @@ A shard ships: Users run `shardmind install /`. The engine downloads the tarball, walks the shard root applying Tier 1 exclusions + `.shardmindignore` + symlink rejection, prompts for values, lets the user opt out of removable modules, renders + copies + caches, writes `state.json`, and runs your hook. Users never edit your shard directly — they edit their vault, and the next `shardmind update` merges upstream changes into their customizations via three-way merge. -A user who already cloned your repo (typically before shardmind support existed) can run `shardmind adopt /` instead. Adopt walks the existing vault, classifies each shard-output path against what the install would have produced, and asks the user — for any file that differs — whether to keep their version (recorded as `ownership: 'modified'`) or accept the shard's. Files matching exactly are auto-managed; files only the shard ships are installed fresh. After adopt, `shardmind update` works normally — the cache made at adopt time becomes the merge base. See `docs/SHARD-LAYOUT.md §Adopt semantics` for the full contract. +A user who already cloned your repo (typically before shardmind support existed) can run `shardmind adopt /` instead. Adopt walks the existing vault, classifies each shard-output path against what the install would have produced, and — for any file that differs — lets the user resolve the whole set at once (keep all mine / use all theirs / best-effort auto-merge / decide per file, or the `--mode` flag) before recording each file's `ownership` (`modified` for kept/merged, `managed` for use-shard). Files matching exactly are auto-managed; files only the shard ships are installed fresh. After adopt, `shardmind update` works normally — the cache made at adopt time becomes the merge base. See `docs/SHARD-LAYOUT.md §Adopt semantics` for the full contract. ## 2. File layout diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index d151d1f..8d26005 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -163,17 +163,23 @@ graph TD E["adopt-planner.ts::classifyAdoption
resolveModules walks shard, render or read each output
per-output sha256 vs sha256(user vault path)
→ matches | differs | shard-only buckets"] --> F1 F1{Any
differs?} - F1 -->|Yes, --yes| F2["auto-resolve every differs as keep_mine"] - F1 -->|Yes, interactive| F3["diff-review loop
AdoptDiffView per file → keep_mine / use_shard"] + F1 -->|"Yes, --mode / --yes"| F2["applyMode (non-interactive)
keep-all-mine / use-all-theirs /
auto-merge (conflicts→keep_mine) / —"] + F1 -->|Yes, interactive| FM["mode-select — AdoptModePicker (#120)
keep-all-mine · use-all-theirs ·
auto-merge · decide-per-file"] F1 -->|No| G + FM -->|keep-all / use-all| G + FM -->|"auto-merge"| FA["adopt-merge.ts::twoWayUnionMerge
non-conflicting → merged bytes
conflicting → queue"] + FM -->|decide-per-file| F3 + FA -->|conflicts| F3 + FA -->|no conflicts| G + F3["diff-review loop (queue)
AdoptDiffView per file → keep_mine / use_shard"] --> G + F2 --> G - F3 --> G - G["adopt-executor.ts::runAdopt
① Snapshot every differs+use_shard user file
② Apply per classification + resolution
③ Cache templates + manifest + schema
④ Write shard-values.yaml + state.json"] --> H + G["adopt-executor.ts::runAdopt
① Snapshot every differs+use_shard / differs+merged user file
② Apply per classification + resolution (keep_mine / use_shard / merged)
③ Cache templates + manifest + schema
④ Write shard-values.yaml + state.json"] --> H H["hooks (orchestrator)
bootstrap → personalize (non-fatal)
newFiles = summary.installedFresh"] --> I - I["summary — AdoptSummary
Counts: matched-auto / kept-mine / use-shard / fresh
+ hook output"] + I["summary — AdoptSummary
Counts: matched-auto / kept-mine / use-shard / merged / fresh
+ hook output"] G -->|Any failure| R["rollbackAdopt
Restore snapshot + erase added paths
+ drop .shardmind/ + shard-values.yaml"] diff --git a/docs/SHARD-LAYOUT.md b/docs/SHARD-LAYOUT.md index e3fcb0b..9911a1f 100644 --- a/docs/SHARD-LAYOUT.md +++ b/docs/SHARD-LAYOUT.md @@ -214,22 +214,22 @@ Pre-conditions enforced before any walk: Phases (logical order; UI may interleave loading messages): 1. Fetch shard at target version into a temp directory. -2. Wizard collects values (same component / pipeline as install) and module selections. Wizard runs **before** classification because `.njk` templates need values to render before their output bytes can be hashed. +2. Collect values via the `AdoptValuesGate` confirm-or-override page (#104) and module selections — same value pipeline as install. Runs **before** classification because `.njk` templates need values to render before their output bytes can be hashed. 3. Classify each file the shard would install at the chosen module selections, comparing the rendered (or copied) bytes against the user's vault: - **Matches shard content exactly** → record hash, mark managed automatically. "Exactly" means byte-for-byte equality after the standard render pipeline (frontmatter normalized via `parseYaml → stringifyYaml`, see `renderer.ts`). A pristine clone with default values + clean YAML lands here for every file; non-default vaults legitimately produce `differs` for any rendered output the user's bytes don't post-render-equal. This is the same equality `drift.ts` enforces on update. - - **Differs from shard content** → 2-way diff UI (no merge base); user marks "my modification" (record user's content hash as managed) or "use shard's version" (overwrite, record shard hash as managed). Two choices, no third "leave untracked" — adopt is the moment the file becomes managed; the user can later modify it freely and the merge engine handles it on update. + - **Differs from shard content** → resolved per a batch mode (#120). When ≥1 file differs and neither `--mode` nor `--yes` is set, `AdoptModePicker` prompts once for the whole set: **keep all mine** / **use all theirs** / **auto-merge (best-effort)** / **decide per file**. Each `differs` file ends as one of: `keep_mine` (record user's hash, ownership `modified`), `use_shard` (overwrite, ownership `managed`), or `merged` (write union bytes, ownership `modified`). There is no "leave untracked" outcome — adopt is the moment the file becomes managed; the merge engine handles later edits on update. **Auto-merge** is a two-way *union* merge (`core/adopt-merge.ts`): adopt has no merge base, so it keeps common + each side's unique lines, sends overlapping replacements to the per-file prompt, **does not apply shard deletions**, and can duplicate non-adjacent edits — merged files are flagged "review recommended". Non-interactive auto-merge keeps-mine on conflicts. - **Volatile templates** (carry `{# shardmind: volatile #}`) skip the prompt: user's bytes are recorded as managed without a differs comparison (volatile content is never expected to match across renders, so a prompt would be meaningless). Symmetric with install, which records volatile-template outputs the same way. - **Excluded modules' files** are not classified. If the user's vault contains them, they stay as user content. - User has the path but it's not a shard output → user-only, left unmanaged (not in `state.files`). - Shard has the path but the user's vault doesn't → shard-only, installed fresh and recorded as managed. -4. For every `differs` decision, apply: write shard bytes for "use shard's", leave user bytes for "my modification". `keep mine` paths still become `state.files` entries hashed at the user's bytes — adopt is the entry point into management. +4. For every `differs` decision, apply: write shard bytes for `use_shard`, union bytes for `merged`, leave user bytes for `keep_mine`. All three become `state.files` entries (hashed at the user's / merged / shard bytes respectively) — adopt is the entry point into management. 5. Write `.shardmind/state.json` + cached `.shardmind/shard.yaml` + cached `.shardmind/shard-schema.yaml` + vault-root `shard-values.yaml`; cache the shard source under `.shardmind/templates/` so future `update` runs have a merge base. 6. Run the install-side hook slots via the orchestrator: `bootstrap` (always), then `personalize` (managed edits) unless the engine skips it because values are defaults (Invariant 2). `newFiles` = paths classified shard-only and freshly installed, `removedFiles` = []. 7. Re-hash managed files per the usual post-hook semantics. Future `shardmind update` calls work normally — merge base is the adopt-time cache. -Reuses: drift detection (`core/drift.ts`), install-executor, value collection (`AdoptValuesGate` confirm page → `InstallWizard` on override, #104), hook runtime. New surfaces: 2-way diff UI component (`AdoptDiffView`), adopt-planner, adopt-executor. +Reuses: drift detection (`core/drift.ts`), install-executor, value collection (`AdoptValuesGate` confirm page → `InstallWizard` on override, #104), hook runtime. New surfaces: batch mode picker (`AdoptModePicker`, #120), two-way union merge (`core/adopt-merge.ts`, #120), 2-way diff UI component (`AdoptDiffView`), adopt-planner, adopt-executor. ## Naming decisions diff --git a/source/commands/adopt.tsx b/source/commands/adopt.tsx index 44db3fb..2259271 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 divergent files in bulk, skipping the mode picker. keep-all-mine/use-all-theirs/auto-merge are non-interactive (auto-merge still prompts on conflicts unless --yes); decide-per-file is the per-file prompt. 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,32 @@ 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 }); } }, + // References `applyMode` + `executeAdopt` via closure (defined below); + // both are stable across a session (their own deps — vaultRoot, dryRun, + // verbose, finish — are fixed CLI flags / process.cwd), so a captured + // binding is never stale. Listing them here would be a TDZ ref. // eslint-disable-next-line react-hooks/exhaustive-deps - [vaultRoot, yes, finish], + [vaultRoot, yes, mode, finish], ); const executeAdopt = useCallback( @@ -439,6 +457,84 @@ 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, + ) => { + const enterDiffReview = ( + queue: AdoptClassification[], + resolutions: AdoptResolutions, + ) => + setPhase({ kind: 'diff-review', ctx, result, plan, queue, currentIndex: 0, resolutions }); + + try { + if (selected === 'keep-all-mine' || selected === 'use-all-theirs') { + const decision = selected === 'keep-all-mine' ? 'keep_mine' : 'use_shard'; + await executeAdopt( + ctx, + result, + plan, + Object.fromEntries(plan.differs.map((c) => [c.path, decision])), + ); + return; + } + + if (selected === 'decide-per-file') { + enterDiffReview(plan.differs, {}); + 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) { + enterDiffReview(queue, 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 }); + } + }, + // `executeAdopt` + `finish` are the only non-stable values applyMode + // calls. `setPhase` is a stable useState setter; `twoWayUnionMerge` / + // `sha256` are module imports. `ctx`/`result`/`plan` are arguments. The + // deps cover the closure; the lint rule can't see callbacks-as-args. + // eslint-disable-next-line react-hooks/exhaustive-deps + [executeAdopt, finish], + ); + const onWizardComplete = useCallback( (result: WizardResult) => { const current = phaseRef.current; @@ -458,15 +554,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 +585,7 @@ export function useAdoptMachine(input: UseAdoptMachineInput): UseAdoptMachineOut onWizardComplete, onWizardCancel, onWizardError, + onModeSelect, onDiffChoice, }; } @@ -505,5 +611,7 @@ function labelForAction(kind: AdoptApplyKind): string { return '→'; case 'differs-use-shard': return '↻'; + case 'differs-merged': + return '⊕'; } } diff --git a/source/components/AdoptModePicker.tsx b/source/components/AdoptModePicker.tsx new file mode 100644 index 0000000..e1d29af --- /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 ? ' differs' : 's differ'} from the shard. + + How should I resolve them? + +