Skip to content

feat(adopt): batch operations — keep-all / use-all / auto-merge / per-file (#120)#131

Merged
breferrari merged 8 commits into
mainfrom
feat/120-adopt-batch-modes
Jun 1, 2026
Merged

feat(adopt): batch operations — keep-all / use-all / auto-merge / per-file (#120)#131
breferrari merged 8 commits into
mainfrom
feat/120-adopt-batch-modes

Conversation

@breferrari
Copy link
Copy Markdown
Owner

Closes #120.

Summary

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, plus a --mode flag for scripted runs.

Four modes: Keep all mine / Use all theirs / Auto-merge (best-effort) / Decide per file.

The auto-merge design decision (settled with the maintainer)

The issue framed "auto-merge non-conflicting" as a three-way merge, but adopt has no merge base — the vault was cloned without shardmind, so the original shard version that produced the user's bytes is unrecorded (differs carries only userContent + shardContent). A base-anchored three-way merge is genuinely impossible here.

We ship a best-effort two-way union merge (source/core/adopt-merge.ts) instead — the maintainer chose to include it rather than defer, with its limits surfaced, not hidden:

  • It keeps the user's bytes and therefore does not apply shard deletions (removed-upstream content looks "user-unique" and is retained).
  • "Non-conflicting" is a line-adjacency heuristic, so non-adjacent related edits can be unioned into duplicated content.
  • Overlapping replace-spans always fall through to the per-file prompt — never silently combined.
  • Newlines are never normalized, so a trailing-newline-only diff conservatively prompts.

Because of this it is labelled "best-effort" in the picker and docs, and the Summary lists every auto-merged file with a "review recommended" note. It's a convenience fast-path, not a correct merge.

How it works

  • AdoptModePicker shows once before the per-file loop when files differ and neither --mode nor --yes is set. --yes is now shorthand for keep-all-mine; --mode overrides it.
  • Executor gains a { kind:'merged', content, hash } resolution: writes union bytes as modified ownership at the merged hash, snapshots merged paths for rollback (alongside use_shard), buckets them in summary.adoptedMerged. A later update three-way-merges the result against the cached shard template (the proper base).
  • Machine: a mode-select phase + applyMode; the 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.

Quality gate

  • npm run typecheck — clean.
  • npm test1116 passed | 30 skipped (Layer 2 PTY skipped on Windows per E2E: bridge SIGINT delivery reliably on GH Actions Windows runner #57). Was 1093 pre-PR.
  • npm run build — clean.
  • New behavior has new tests (unit/integration/component/flow — see audit).
  • Adversarial cases enumerated + covered (harden audit below).
  • Copilot review requested + addressed (pending — will reply per comment).
  • Invariant 1 E2E still green (adopt --yes/--values path unchanged).
  • Issue acceptance criteria checked (below).
  • ROADMAP checkbox updated in this PR.

#120 acceptance criteria

  • AdoptDiffView surfaces a top-level mode picker before per-file iteration (via AdoptModePicker, shown ahead of the AdoptDiffView loop).
  • Layer 1 flow tests cover all four modes against a multi-file divergent fixture (scenarios 27-31).
  • shardmind adopt --mode=keep-all-mine|use-all-theirs|auto-merge|decide-per-file flag for non-interactive use.
  • AUTHORING.md / docs/ARCHITECTURE.md note the four modes (+ SHARD-LAYOUT §Adopt, IMPLEMENTATION §3.5 mermaid).

Invariant 1 proxy

The mode picker/--mode is interactive-flow only; --yes/--values adopt resolves in the machine and never reaches the picker, so the byte-equivalence suites (obsidian-mind-contract.test.ts, cli.test.ts adopt cases) exercise the unchanged classification/executor path and stay green.

Harden Audit

Reference bar: #11 (merge engine — fixtures-first + property tests) for the merge piece, #101 for the UI.

Rounds

  • Round 1 — /simplify (reuse + simplification + efficiency + altitude): efficiency clean; altitude confirmed the union shape / queue refactor / AdoptMode ownership / --mode-vs---yes modeling are right. Applied: Object.fromEntries for keep/use-all, enterDiffReview dedup, pickMode coupling comment. Skipped (noted): pre-existing formatSize dup (out of diff scope); plan field is consumed by executeAdopt, not dead.
  • Round 2 — /harden (adversarial + correctness + docs): 1 real-but-safe merge edge + an error-message gap + machine-deps clarity + two real doc drifts (SHARD-LAYOUT §Adopt still said "two choices, no third"; IMPLEMENTATION §3.5 mermaid omitted mode-select) + a missing flow-test branch.
  • Round 3 — re-audit (merge algorithm + mode machine, 6 probes): dry — run-grouping order-independence, byte-exact reconstruction, resolution carry-through, re-entrancy all hold.

Real issues found + fixed

  • SHARD-LAYOUT §Adopt drift — described per-file-only "two choices, no third"; rewritten for the four modes + three differs outcomes + best-effort limits.
  • IMPLEMENTATION §3.5 mermaid drift — added the mode-select + auto-merge nodes (+ merged in the apply/summary nodes).
  • ADOPT_WRITE_FAILED message — now lists the merged resolution.
  • Trailing-newline-only diff conflicts (safe, conservative) — pinned with a documenting test + a module note; the merge never normalizes newlines (would mask real diffs).
  • applyMode deps — tightened to [executeAdopt, finish] + clarifying comments on the closure-stability eslint-disables.

Verified sound (no action)

Union shape (Buffer+hash in the resolution), hash parity (recorded hash == bytes written), overwritesUserFile narrowing + snapshot inclusion of merged, AdoptApplyKind/AdoptResolution exhaustiveness, assertNever switch in adopt.tsx, firedRef (picker) + useOncePerKey (per-file) double-fire guards, module boundaries (core imports only diff; AdoptMode component-owned), zero any/@ts-ignore.

Tests added

  • Unit (adopt-merge): 11 — identical / pure-insertion each side / shard-deletion-kept / non-overlapping-both / overlapping-conflict / binary / empty / CRLF / trailing-newline, + determinism & common-line-survival properties (fast-check).
  • Integration: merged-resolution write + modified ownership + adoptedMerged bucket.
  • Component: AdoptModePicker (modes + firedRef) + AdoptSummary merged bucket.
  • Flow (Layer 1): scenarios 27-31 (keep-all / use-all / auto-merge mixed-conflict / --mode non-interactive / --yes --mode=auto-merge fallback); 20-21 re-driven through the picker; Layer 2 PTY picks decide-per-file.
  • Tests-before → after: 1093 → 1116.

Deferrals

None. (A future base-aware adopt — recording a base so a true three-way merge becomes possible — would supersede the union merge, but that's a separate, larger change.)

breferrari and others added 7 commits June 1, 2026 22:40
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…120)

- ARCHITECTURE §10.5: new mode-select step (3a) + the four modes; flags
  table gains --mode; a plain-spoken note that auto-merge is a best-effort
  two-way union merge (no base) that keeps user bytes, does NOT apply shard
  deletions, and can duplicate non-adjacent edits. Apply step snapshots
  differs+merged too; implementation-modules line gains adopt-merge +
  AdoptModePicker.
- AUTHORING: adopt description mentions the batch modes + --mode.
- CHANGELOG: [Unreleased] Added entry.
- CLAUDE.md: AdoptModePicker.tsx + adopt-merge.ts in the trees + module table.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
/simplify findings (efficiency clean; altitude confirmed the union shape,
queue refactor, AdoptMode ownership, and --mode/--yes modeling are right):
- keep-all-mine/use-all-theirs build resolutions via Object.fromEntries
  instead of an explicit loop.
- Extract a local enterDiffReview() to dedup the two diff-review setPhase
  calls (decide-per-file + auto-merge interactive).
- Document the coupling between pickMode's arrow-count map and the picker's
  option order.

Skipped (noted): formatSize duplication (CollisionReview/AdoptDiffView) is
pre-existing and out of this diff's scope; the diff-review phase's `plan`
field is consumed by executeAdopt, not dead.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…120)

/harden round 1 (adversarial + correctness + docs; correctness agent found
the union shape, queue refactor, hash parity, type narrowing, and switch
exhaustiveness all sound):

- Docs drift the agents caught: SHARD-LAYOUT §Adopt still said "two choices,
  no third" and described per-file-only resolution — rewritten for the four
  modes + the three differs outcomes (keep_mine/use_shard/merged) + the
  best-effort auto-merge limits. IMPLEMENTATION §3.5 adopt mermaid gains the
  mode-select + auto-merge nodes (it omitted them). Both also fix a leftover
  #104 "Wizard" reference.
- adopt-executor: the "missing resolution" error message now lists `merged`.
- use-adopt-machine: applyMode deps tightened to [executeAdopt, finish];
  clarifying comments on the two closure-stability eslint-disables.
- Tests: document the conservative trailing-newline-only conflict (diffLines
  aligns it as a replacement; merge never normalizes newlines — pinned so the
  behaviour is explicit); flow scenario 31 covers the untested
  --yes --mode=auto-merge non-interactive fallback (conflict → keep-mine).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings June 1, 2026 21:30
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds batch resolution modes to shardmind adopt so users can resolve many divergent files with one top-level choice (keep all mine / use all theirs / auto-merge best-effort / decide per file), including a --mode flag and an auto-merge path that union-merges non-conflicting changes and prompts only on conflicts.

Changes:

  • Introduces AdoptModePicker + new mode-select phase and refactors diff-review to iterate a queue (conflicts only for auto-merge).
  • Adds best-effort two-way union merge (twoWayUnionMerge) and a new executor resolution kind { kind: 'merged', content, hash }, plus summary bucketing for auto-merged files.
  • Expands docs/changelog and adds unit/integration/component/e2e coverage for the new modes and merged resolution.

Reviewed changes

Copilot reviewed 20 out of 20 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
tests/unit/adopt-merge.test.ts Unit + property tests for the two-way union merge behavior and edge cases.
tests/integration/adopt.test.ts Integration test validating executor handling of merged resolution (writes bytes, ownership, summary).
tests/e2e/tui/adopt-diff.test.ts PTY e2e flow updated to navigate the new mode picker before per-file prompts.
tests/component/flows/helpers.tsx Adds mode option typing to adopt flow test harness.
tests/component/flows/adopt-flow.test.tsx New Layer 1 flow scenarios covering all batch modes and --mode interactions.
tests/component/AdoptSummary.test.tsx Verifies summary renders the new auto-merged bucket with review note.
tests/component/AdoptModePicker.test.tsx Component tests for the new mode picker, including one-shot selection guard.
source/core/adopt-merge.ts Implements best-effort two-way union merge for adopt auto-merge mode.
source/core/adopt-executor.ts Adds merged resolution support, rollback snapshot inclusion, and summary bucket.
source/components/AdoptSummary.tsx Displays auto-merged count with “review recommended” messaging.
source/components/AdoptModePicker.tsx New UI component for selecting batch adopt mode across differing files.
source/commands/hooks/use-adopt-machine.ts Adds mode-select phase + applyMode logic, queue-based diff-review, and auto-merge pre-resolution.
source/commands/adopt.tsx Adds --mode CLI flag and renders AdoptModePicker during mode-select phase.
ROADMAP.md Marks #120 adopt batch operations as completed.
docs/SHARD-LAYOUT.md Updates adopt semantics to describe the four modes and merged outcome.
docs/IMPLEMENTATION.md Updates adopt flow diagram to include mode-select and auto-merge paths.
docs/AUTHORING.md Updates author-facing docs to mention adopt batch modes and --mode.
docs/ARCHITECTURE.md Updates architecture description of adopt flow, modes, and flags.
CLAUDE.md Updates repo map to include new adopt mode picker and merge module.
CHANGELOG.md Adds unreleased entry documenting adopt batch operations and testing/docs updates.

Comment on lines +50 to +52
<Text bold color="yellow">
{differsCount} file{differsCount === 1 ? '' : 's'} differ from the shard.
</Text>
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 7f0da50. Header now renders "1 file differs from the shard" / "N files differ from the shard" (subject-verb agreement). Updated the component test and the flow-test pickMode regex (it keyed on "differ from", which the singular "differs" would have broken).

Comment thread source/commands/adopt.tsx
Comment on lines +32 to +37
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)',
),
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 7f0da50. Reworded the --mode help: keep-all-mine/use-all-theirs/auto-merge are non-interactive (auto-merge still prompts on conflicts unless --yes), and decide-per-file is explicitly the per-file prompt loop.

Comment thread docs/ARCHITECTURE.md
Comment on lines 889 to 892
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 <keep-all-mine|use-all-theirs|auto-merge|decide-per-file>` (#120) — resolve the `differs` set non-interactively without the picker. Overrides `--yes`'s default. Combine with `--yes`/`--values` for a fully non-interactive run (the flag governs the diff set, not value collection).
- `--values <file>` — prefill value answers (same shape as `install --values`); shown on the gate's confirm page as `(from --values)`.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 7f0da50. Same reword applied to the ARCHITECTURE §10.5 flag description so the docs match the implementation (decide-per-file prompts; auto-merge prompts on conflicts unless --yes).

Comment on lines +77 to +80
/** 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');
}
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 7f0da50. Added a resolution != null guard before the typeof === 'object' branch (typeof null === "object") so a null map value can never reach .kind. The type excludes null, but the function runs on unvalidated resolutions[path] lookups, so the guard is cheap defense.

Comment on lines +54 to +60
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'));

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — fixed in 7f0da50. twoWayUnionMerge now checks both inputs are round-trippable UTF-8 (Buffer.from(text,"utf8").equals(buf)) and forces a conflict (prompt) when they are not, so a non-binary-but-non-UTF-8 file (e.g. Latin-1, no NUL) is never lossy-merged through U+FFFD. Added a unit test with a 0xFF byte.

Comment on lines +30 to +36
it('singularizes the count for one file', async () => {
const { lastFrame } = await mount(
<AdoptModePicker differsCount={1} onSelect={vi.fn()} />,
);
await waitFor(lastFrame, (f) => f.includes('Keep all mine'));
expect(lastFrame() ?? '').toMatch(/1 file differ(?!s)/);
});
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 7f0da50 alongside the header fix — the test now asserts "1 file differs from the shard".

Six Copilot comments, all valid:

1. AdoptModePicker header was "1 file differ" — fix subject/verb agreement
   to "1 file differs" / "N files differ"; update the component test and the
   flow-test pickMode regex (which keyed on "differ from").
2. twoWayUnionMerge round-trips through UTF-8 (decode → diff → re-encode). A
   non-binary file that isn't clean UTF-8 (Latin-1 bytes, no NUL) would have
   its bytes replaced by U+FFFD and silently corrupted. Detect
   non-round-trippable input and force a conflict (prompt) instead of merging.
   New unit test with a 0xFF byte.
3. overwritesUserFile: add a `!= null` guard before `typeof === 'object'`
   (typeof null === 'object') — defensive on the unvalidated resolutions map.
4. --mode help + ARCHITECTURE flag said "non-interactively", but
   decide-per-file still prompts and auto-merge prompts on conflicts. Reworded
   both to match actual behavior.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@breferrari breferrari merged commit 822bdf0 into main Jun 1, 2026
6 checks passed
@breferrari breferrari deleted the feat/120-adopt-batch-modes branch June 1, 2026 21:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

v0.2: Adopt batch operations (keep all mine / use all theirs / auto-merge non-conflicting)

2 participants