Skip to content

Retire KDiff3; make DiffPlexMergeEngine the sole merge engine - #9

Merged
TheValiantOne merged 4 commits into
mainfrom
feature/retire-kdiff3
Aug 7, 2026
Merged

Retire KDiff3; make DiffPlexMergeEngine the sole merge engine#9
TheValiantOne merged 4 commits into
mainfrom
feature/retire-kdiff3

Conversation

@TheValiantOne

@TheValiantOne TheValiantOne commented Aug 7, 2026

Copy link
Copy Markdown
Owner

Summary

  • Deletes Tools/KDiff3.cs, Tools/KDiff3MergeEngine.cs, and the IMergeEngine interface that used to sit between FileMerger and the merge engine. DiffPlexMergeEngine (in-process, DiffPlex-based, no external binary) is now the sole text-merge engine — there's no more engine-selection step at startup, and the MergeEngine/KDiff3Path/ReviewEachMerge/ShowPathsInKDiff3 App.config settings are gone.
  • New conflict-resolution UX for both the GUI's interactive path and the CLI/MCP headless path (same underlying code either way, since DiffPlexMergeEngine has no UI of its own): a genuine conflict that needs manual resolution writes a git/diff3-style conflict-marker sidecar file and opens it in the OS's default editor for that file type (Tools/FileOpener.cs, Process.Start with UseShellExecute = true), instead of opening KDiff3's merge-editor window.
  • docs/decisions/kdiff3-retirement.md (new) is a full decision record: the rationale for retiring KDiff3, and the empirical KDiff3 process-behavior findings (window-title polling, the load-bearing 250ms poll interval, five failed window-suppression techniques, unverified foreground-focus restoration) preserved now that the motivating code is gone — this was previously only written up in this fork's local, gitignored HANDOFF.md.
  • CLAUDE.md, CONTRIBUTING.md, README.md updated throughout to reflect KDiff3 being gone.

User-facing behavior changes

  • No more KDiff3 popup. A genuine conflict no longer opens a merge-editor window for manual resolution. Instead: a conflict-marker sidecar (<<<<<<</|||||||/=======/>>>>>>>, labeled with real mod names) is written under a new DiffPlexConflicts folder and opened in your default editor for that file type. There is no merge UI anymore — resolving a conflict means editing the sidecar by hand (or comparing the source files yourself) and re-running the merge.
  • dryRun writes but does not open. The MCP merge_conflicts tool's dryRun option (and the equivalent CLI path) still writes a conflict's sidecar for inspection, but does not launch an editor for it — a preview must not have that kind of side effect. (This was caught and fixed during this change's own review — an earlier version of the dry-run path would have opened one editor window per genuine conflict on every preview call.)
  • A small, measured, non-zero chance a real conflict is now reported as "needs manual resolution" that KDiff3 might have auto-solved. DiffPlexMergeEngine has a confirmed upstream DiffPlex bug it has to defend against, with measured failure rates from ~0.35% (realistic single-edit-per-side density) up to double digits on adversarial dense-edit cases — see CLAUDE.md's Compatibility constraints and docs/decisions/kdiff3-retirement.md for the full numbers. This never produces silently wrong output (the engine detects the failure and refuses rather than risk corrupted merge output), but it does mean more conflicts now need the manual sidecar-editing workflow than would have under KDiff3. This is a deliberate, disclosed tradeoff, not a regression — see the decision doc for why it was accepted.
  • No more vanilla-less 2-way fallback. If no vanilla version of a file can be found, the merge is refused outright rather than attempting a degraded 2-way diff (KDiff3 always attempted one; DiffPlex's ThreeWayDiffer as used here has no equivalent).
  • ReviewEachMerge and ShowPathsInKDiff3 settings are gone — there's no merge UI left for either to apply to.
  • One fewer external binary to source separately — a fresh checkout now only needs QuickBMS and wcc_lite, not KDiff3.

Known adjacent bug, not fixed here

Program.TryOpenFile (host project, used by several existing "open merged/vanilla file" GUI actions) has the identical missing-UseShellExecute = true bug that this change's new Tools/FileOpener.cs was written to avoid — confirmed via code inspection, documented in FileOpener.cs's own header comment. Left unfixed: it's pre-existing, unrelated to KDiff3, affects 7 GUI call sites that can't be exercised end-to-end in this development environment (no display), and fixing it would expand an already large, high-stakes diff into changing behavior that can't be verified here. Worth a follow-up fix/ branch.

Process note

While preparing this PR, gh pr create (run without --repo/--base) silently defaulted to opening it against the upstream AnotherSymbiote/WitcherScriptMerger repo's master branch instead of this fork's main — a stale line in CLAUDE.md claiming no separate fork existed yet contributed to missing this. That PR (AnotherSymbiote/WitcherScriptMerger#9) was closed immediately with an explanatory comment once caught, and this PR was opened correctly with --repo/--base specified explicitly. CLAUDE.md's Project overview now documents the correct remote/branch setup and calls out the mistake so it isn't repeated.

Verification

  • dotnet build WitcherScriptMerger.sln --configuration Release: 0 errors, 5 pre-existing CA1823 warnings only (unrelated to this change — down from main's 10, since KDiff3.cs's own 5 unused-P/Invoke-field warnings are gone along with the file).
  • dotnet test WitcherScriptMerger.sln: 31/31 passing, including new regression coverage for the dry-run/FileOpener interaction and a real NBSP-vs-whitespace classification bug caught during this change's own review (see below).
  • dotnet format whitespace WitcherScriptMerger.sln --verify-no-changes: clean.
  • End-to-end smoke tests against a scratch game/mods tree, for both an auto-solvable conflict (verified correct UTF-16LE+BOM output) and a genuine conflict (verified the sidecar was written and actually opened — confirmed via a real, non-mocked OpenWith.exe process spawn matching the run's timestamp, since visual confirmation isn't possible in this environment) — exercised through both the GUI-triggered interactive path (a purpose-built harness calling FileMerger.MergeFilesInteractive directly) and the CLI's headless merge verb.
  • grep -ri kdiff3 across the repo: only matches the new decision doc and legitimate historical/pointer references in comments and other docs.
  • A dedicated code-review pass (multi-angle, high effort) was run against the full diff. All 11 findings were triaged; the substantive ones were fixed in a second commit on this branch:
    • DiffPlexMergeEngine.NormalizeWhitespace used string.Trim() (Unicode-aware) instead of the class's own documented ASCII-only whitespace set, which could silently discard a genuine NBSP-vs-plain-text content difference at a piece boundary as if it were whitespace-only. Fixed, with a regression test verified to fail against the pre-fix code.
    • The interactive path's "Merge N of M was canceled" prompt now fires for every automatic engine refusal, not just true user cancellations (there's no more UI to cancel out of) — renamed/reworded to "was skipped" for accuracy.
    • The "attempting to open it now for review" message was unconditional even though the sidecar's .conflict extension has no default OS file association and the open can silently fail — reordered so the message reflects the real outcome.
    • OptionsForm.Designer.cs's layout cascade was incomplete after removing two checkboxes, leaving a 57px dead-space gap instead of the original 11px.
    • A fabricated citation (a comment and CLAUDE.md both claimed CONTRIBUTING.md documents a "no premature abstraction" convention that justified deleting IMergeEngine — it doesn't) was corrected to state the reasoning on its own terms.
    • A stale comment, a documentation gap in Mcp/CLAUDE.md's permissions section (missing the DiffPlexConflicts filesystem root and the process-spawning behavior), and an overclaiming test-suite comment about xunit's parallelization defaults were also fixed.
    • Two findings (the Program.TryOpenFile bug above, and the disclosed DiffPlex reliability gap) were deliberately left as-is — see their sections above.
  • A second, independent pass (an advisor review of the reordered FileOpener.Open/message logic) caught two more claims-vs-code mismatches, fixed in a third commit: CLAUDE.md's prose description of the sidecar-open behavior still described the pre-reorder sequencing and didn't mention dryRun's suppression of it; and a code comment claimed FileOpener.Open's return value was "exactly" a signal that an editor opened, when the E2E run's own OpenWith.exe observation shows it only distinguishes "the launch call succeeded" from "it threw" — the picker case is a real, observed possibility, not a hypothetical.
  • That same pass also caught a stale, actually-consequential claim in CLAUDE.md's Project overview: it said origin was still the upstream AnotherSymbiote/WitcherScriptMerger remote and no separate fork existed. False, and its false-ness produced a real mistake while preparing this PR — see below.

AI assistance disclosure

This PR was substantially produced by an AI coding agent (Claude Code), including the architecture decisions, the decision-record doc, and the end-to-end verification described above, working from an explicit task specification. All findings from the code-review pass were reviewed and fixed by the same process before this PR was opened.

Chris Knight and others added 4 commits August 7, 2026 18:21
KDiff3 (Tools/KDiff3.cs, Tools/KDiff3MergeEngine.cs) and the IMergeEngine
interface that used to sit in front of it are deleted. DiffPlexMergeEngine
is now the only text-merge engine, called directly by FileMerger - no more
engine-selection step at startup, no more MergeEngine/KDiff3Path/
ReviewEachMerge/ShowPathsInKDiff3 App.config settings.

User-facing behavior change: a genuine conflict that needs manual resolution
no longer opens KDiff3's merge-editor window. Instead it writes a
git/diff3-style conflict-marker sidecar file and opens it in the OS's
default editor for that file type (Tools/FileOpener.cs, Core-side,
Process.Start with UseShellExecute=true) - in both the GUI's interactive
path and the CLI/MCP headless path, since they're now the same code path
underneath. A dry-run merge_conflicts call still writes the sidecar for a
would-be conflict but does not open it, since a preview must not have that
kind of side effect.

This is a deliberate tradeoff, not a strict improvement: DiffPlexMergeEngine
has a measured non-zero failure rate on dense multi-edit conflicts and no
vanilla-less 2-way fallback, both of which KDiff3 handled. See
docs/decisions/kdiff3-retirement.md for the full rationale and for the
empirical KDiff3 process-behavior findings (window-title polling, the
250ms-poll-interval constraint, failed window-suppression attempts,
unverified focus restoration) preserved now that the motivating code is
gone - previously this only lived in the local, gitignored HANDOFF.md.

CLAUDE.md, CONTRIBUTING.md, README.md updated throughout to reflect KDiff3
being gone.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXAuGMLB44T5Zv5o5ZzKah
- DiffPlexMergeEngine.NormalizeWhitespace used the parameterless string.Trim(),
  which trims the full Unicode whitespace category (including NBSP) at the
  edges of the joined comparison text, silently undoing this class's own
  documented reason for using a narrow ASCII-only regex elsewhere in the same
  method. A trailing-NBSP-vs-plain-text conflict could be misclassified as
  whitespace-only and silently discarded. Fixed via Trim(WhitespaceChars), the
  same explicit ASCII set as the regex; regression test added, and manually
  verified to fail against the pre-fix code before being restored.

- The interactive path's "Merge N of M was canceled" prompt is now shown for
  every automatic engine refusal (genuine conflict, missing vanilla, outdated
  hash), not just true user cancellations - DiffPlexMergeEngine's interactive
  Merge() has no UI to cancel out of at all, unlike the retired KDiff3 engine.
  Renamed to ConfirmContinueAfterSkippedMerge and reworded to "was skipped",
  which is accurate for both this method's call sites.

- FileOpener.Open's return value was discarded, so the notifier message
  unconditionally claimed "attempting to open it now for review" even when
  the sidecar's .conflict extension has no OS file association and the open
  silently failed. Reordered so the open happens first and the message
  reflects what actually happened.

- OptionsForm.Designer.cs: removing two checkboxes shrank grpMerging.Size but
  left the button row and ClientSize unchanged, opening a 57px dead-space gap
  where the original had an 11px one. Cascaded the 46px reduction through the
  button row and ClientSize to restore the original gap.

- Removed a fabricated citation: a comment and CLAUDE.md both claimed
  CONTRIBUTING.md documents a "no premature abstraction" convention that
  justified deleting IMergeEngine. It doesn't - grep confirms CONTRIBUTING.md
  never mentions abstraction, interfaces, or "premature". Reworded to state
  this as the deletion's own reasoning instead of a false citation.

- Fixed a stale comment in BuildMerge still pointing at a Program.cs
  engine-selection switch this same change deleted.

- Mcp/CLAUDE.md's "Minimal required permissions" section didn't mention that
  merge_conflicts can launch external processes (FileOpener.Open, once per
  genuine conflict, uncapped) or that DiffPlexConflictsDirectory is a
  filesystem root the process writes to - both real omissions in a doc whose
  stated purpose is "exactly what the process touches, and at what privilege
  level".

- Tightened an overclaiming test-suite comment about xunit's parallelization
  defaults: sequential execution is only guaranteed within one collection
  (implicitly one per class), not across classes - a future test class also
  exercising the shared FileOpener.Open static could race this one.

Two findings from the same review were evaluated and deliberately not acted
on: Program.TryOpenFile has the identical missing-UseShellExecute bug this
change's FileOpener.cs documents but doesn't fix (pre-existing, affects 7
untestable GUI call sites, out of scope for this diff); and DiffPlexMergeEngine
becoming the sole engine without new verification to close its already-
disclosed reliability gap is this change's own stated premise, not an
oversight (see docs/decisions/kdiff3-retirement.md).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXAuGMLB44T5Zv5o5ZzKah
- CLAUDE.md's "Interactive vs. headless split" section still described the
  sidecar-open behavior in its pre-reorder sequencing (open after the
  notifier message) and never mentioned dryRun's suppression of it. Updated
  to match the actual current order and document openConflictMarkers.

- The reordering's own code comment claimed FileOpener.Open's return value is
  "exactly" a signal that an editor opened. It isn't - it only distinguishes
  Process.Start succeeding from throwing. This change's own end-to-end
  verification observed a real OpenWith.exe spawn (the OS's file-open picker,
  since the test machine has no association for ".conflict"), meaning
  "opened" can be true while what the user sees is a picker, not an editor.
  Reworded to say what the value actually distinguishes, using that same
  observed case as a concrete example instead of an unqualified claim.

- CLAUDE.md's Project overview claimed origin was still the upstream
  AnotherSymbiote/WitcherScriptMerger remote and no separate fork existed.
  False - a separate fork (TheValiantOne/WitcherScriptMerger, default branch
  main) is the actual origin; upstream (AnotherSymbiote, default branch
  master) is a second remote kept for reference. This stale claim caused a
  real mistake: `gh pr create` run without --repo/--base for this same change
  defaulted to opening a PR against the upstream repo's master branch instead
  of this fork's main (closed immediately once caught). Corrected, with the
  mistake called out explicitly so it isn't repeated.

- Minor: a code comment's NBSP example used a literal U+00A0 character
  (visually indistinguishable from a space), rather than an explicit \u00A0
  escape - fixed for the same reason the regression test itself avoids a
  literal character.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXAuGMLB44T5Zv5o5ZzKah
# Conflicts:
#	CLAUDE.md
#	WitcherScriptMerger.Core/Mcp/WsmMcpTools.cs
#	WitcherScriptMerger.Core/Paths.cs
@TheValiantOne
TheValiantOne merged commit 6bbdf62 into main Aug 7, 2026
1 check passed
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.

1 participant