Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
09d73b5
feat(renderer): export renderString helper (#11)
breferrari Apr 19, 2026
06de8e2
feat: core/drift.ts + core/differ.ts — three-way merge engine (#11)
breferrari Apr 19, 2026
290bdcc
docs(roadmap): tick merge engine; defer orphan detection to v0.2 (#11…
breferrari Apr 19, 2026
c905719
refactor: dedupe ENOENT dance, parallelize drift reads, share state f…
breferrari Apr 19, 2026
31c9e04
fix(differ): normalize CRLF in three-way merge inputs
breferrari Apr 19, 2026
3bb4623
ci: run matrix on ubuntu + windows + macos
breferrari Apr 19, 2026
e4e9195
chore(gitignore): exclude Claude Code's ScheduleWakeup lock file
breferrari Apr 19, 2026
ae0d818
refactor: tighten merge engine per self-review
breferrari Apr 19, 2026
b5462e3
refactor(errors): type-safe ErrorCode union for ShardMindError
breferrari Apr 19, 2026
e5304dd
feat(drift): land orphan detection in v0.1 (closes #47)
breferrari Apr 19, 2026
79d4651
refactor: review-pass cleanup (vault-paths consts, LINE_SPLIT, inline…
breferrari Apr 19, 2026
5a06b8f
refactor: collapse review-pass dismissals after pushback
breferrari Apr 19, 2026
350d25f
test(merge): edge-case fixtures — empty file, UTF-8, frontmatter merge
breferrari Apr 19, 2026
cabe217
docs(spec): §4.8 and §4.9 reflect implementation reality
breferrari Apr 19, 2026
03248fe
feat: property-based invariants + fix node-diff3 prototype-key crash
breferrari Apr 19, 2026
d900d42
hardening: turn the merge engine upside down, fix 3 real bugs
breferrari Apr 19, 2026
45337c0
chore(gitignore): exclude local fork clones (e.g. node-diff3)
breferrari Apr 19, 2026
3de06de
Revert "chore(gitignore): exclude local fork clones (e.g. node-diff3)"
breferrari Apr 19, 2026
430c2d2
docs: track the node-diff3 workaround removal as a followup (#49)
breferrari Apr 19, 2026
df54469
fix: Copilot review round 2 — nine real findings
breferrari Apr 19, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,19 @@ on:

jobs:
check:
runs-on: ubuntu-latest
# Full OS × Node matrix: shardmind installs as a global CLI and must
# run on whatever the user's vault lives on. Windows coverage caught
# the CRLF merge regression Copilot flagged on PR #48; keep it first-class.
runs-on: ${{ matrix.os }}
defaults:
run:
# Use bash on all three runners so the same step commands work
# (Windows runners have git-bash pre-installed).
shell: bash
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
# Node 22+ matches obsidian-mind's requirement (hook TS strip
# via --experimental-strip-types, stable in 22.6+). Node 18 hit
# EOL April 2025; Node 20 EOL April 2026. Keep the matrix
Expand All @@ -25,7 +35,7 @@ jobs:
with:
node-version: ${{ matrix.node-version }}

- run: rm package-lock.json && npm install
- run: rm -f package-lock.json && npm install
- run: npm run typecheck
- run: npm test
- run: npm run build
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ yarn.lock
# (commands, agents, scripts, skills, settings.json) that ShardMind installs.
# Only ignore the auto-memory subdirectories that agents generate at runtime.
.claude/memory/
.claude/scheduled_tasks.lock
.claude/projects/
.agent/
memory/
Expand Down
4 changes: 2 additions & 2 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@ Ship the core: install, update, status. Prove that vault template upgrades work
### Milestone 3: Merge Engine (Day 3)

- [x] Write all 17 merge fixture directories — fixtures before code ([#10](https://github.com/breferrari/shardmind/issues/10))
- [ ] `core/drift.ts` + `core/differ.ts` — three-way merge engine ([#11](https://github.com/breferrari/shardmind/issues/11))
- [ ] Iterate until all 17 scenarios pass
- [x] `core/drift.ts` + `core/differ.ts` — three-way merge engine ([#11](https://github.com/breferrari/shardmind/issues/11))
- [x] Iterate until all 17 scenarios pass
- [ ] Add edge case fixtures: frontmatter merge, empty file, binary-identical, encoding

### Milestone 4: Update Command + Status (Day 4)
Expand Down
204 changes: 204 additions & 0 deletions source/core/differ.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
/**
* Three-way merge engine.
*
* Given the old template, the new template, and the file on disk,
* `computeMergeAction` decides whether to skip, silently overwrite,
* auto-merge, or surface a conflict. When a merge is needed, the heavy
* lifting is delegated to node-diff3's Khanna–Myers algorithm (same
* approach git uses).
*
* See docs/IMPLEMENTATION.md §4.9 for the spec.
*/

import { diff3MergeRegions, type IRegion, type IUnstableRegion } from 'node-diff3';
import type {
MergeAction,
MergeResult,
ConflictRegion,
RenderContext,
} from '../runtime/types.js';
import { ShardMindError } from '../runtime/types.js';
import { sha256 } from './fs-utils.js';
import { renderString } from './renderer.js';

const CONFLICT_START = '<<<<<<< yours';
const CONFLICT_SEPARATOR = '=======';
const CONFLICT_END = '>>>>>>> shard update';

export interface ComputeMergeActionInput {
readonly path: string;
readonly ownership: 'managed' | 'modified';
readonly oldTemplate: string;
readonly newTemplate: string;
readonly oldValues: Record<string, unknown>;
readonly newValues: Record<string, unknown>;
readonly actualContent: string;
readonly renderContext: RenderContext;
}

export interface ThreeWayMergeResult {
readonly content: string;
readonly conflicts: ConflictRegion[];
readonly stats: MergeResult['stats'];
}

export async function computeMergeAction(
input: ComputeMergeActionInput,
): Promise<MergeAction> {
const base = renderString(
input.oldTemplate,
{ ...input.renderContext, values: input.oldValues },
input.path,
);
const ours = renderString(
input.newTemplate,
{ ...input.renderContext, values: input.newValues },
input.path,
);

if (sha256(base) === sha256(ours)) {
return { type: 'skip', reason: 'no upstream change' };
}

if (input.ownership === 'managed') {
return { type: 'overwrite', content: ours };
}

const merge = runMerge(base, input.actualContent, ours, input.path);

if (merge.conflicts.length === 0) {
return {
type: 'auto_merge',
content: merge.content,
stats: {
linesUnchanged: merge.stats.linesUnchanged,
linesAutoMerged: merge.stats.linesAutoMerged,
},
};
}

return {
type: 'conflict',
result: {
content: merge.content,
hasConflicts: true,
conflicts: merge.conflicts,
stats: merge.stats,
},
};
}

function runMerge(base: string, theirs: string, ours: string, path: string): ThreeWayMergeResult {
try {
return threeWayMerge(base, theirs, ours);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new ShardMindError(
`Three-way merge failed for ${path}: ${message}`,
'MERGE_FAILED',
'Re-run with --verbose for the full trace, then report at github.com/breferrari/shardmind/issues.',
);
}
}

/**
* Line-based three-way merge. `a` is theirs (user on disk), `o` is base
* (rendered from old template + old values), `b` is ours (rendered from
* new template + new values). Convention matches diff3MergeRegions and
* the git conflict-marker vocabulary (`<<<<<<< yours` wraps theirs,
* `>>>>>>> shard update` wraps ours).
*/
export function threeWayMerge(
base: string,
theirs: string,
ours: string,
): ThreeWayMergeResult {
// Normalize line endings to LF before splitting. `base` and `ours` are
// renderer output (always LF), but `theirs` comes from disk and may be
// CRLF on Windows. Without this, every line in theirs would trail with
// '\r', producing spurious conflicts. Merged output is LF; callers that
// need platform-native line endings convert at the write boundary.
const regions: IRegion<string>[] = diff3MergeRegions(
theirs.split(/\r?\n/),
base.split(/\r?\n/),
ours.split(/\r?\n/),
);
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

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

threeWayMerge() splits on /\r?\n/, which will produce a trailing "" element when the input ends with a newline. That extra sentinel line gets counted into stats and affects ConflictRegion lineStart/lineEnd, so line accounting can be off-by-one (and tests currently allow this via >= checks). Consider normalizing to a line array that strips the final empty element while separately preserving whether the original had a trailing newline, then re-append the newline when joining the merged output.

Copilot uses AI. Check for mistakes.
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 on the stats inflation. Fixed in df54469 — when all three inputs end with \n, split(/\r?\n/) produces a trailing "" that diff3 emits as a stable region and inflates linesUnchanged by 1. After the merge loop, we now detect this and subtract one from linesUnchanged.

Kept the trailing "" flowing through diff3 itself rather than stripping it — a newline-status difference between theirs and ours is a legitimate auto-merge concern, not a pre-merge normalization. My first attempt stripped + re-appended based on ours' style, which broke the property test "theirs-only-changed ⇒ output equals theirs". The stats-only correction preserves merge semantics and fixes the off-by-one in reporting.


const merged: string[] = [];
const conflicts: ConflictRegion[] = [];
const stats = { linesUnchanged: 0, linesAutoMerged: 0, linesConflicted: 0 };

for (const region of regions) {
if (region.stable) {
merged.push(...region.bufferContent);
// Stable region with buffer === 'o' means all three buffers agreed
// (truly unchanged). buffer === 'a' or 'b' means diff3 resolved to
// one side's version without ambiguity — count those as auto-merged.
if (region.buffer === 'o') {
stats.linesUnchanged += region.bufferContent.length;
} else {
stats.linesAutoMerged += region.bufferContent.length;
}
continue;
}

const resolution = resolveUnstableRegion(region, merged.length);
merged.push(...resolution.lines);
if (resolution.conflict) conflicts.push(resolution.conflict);
stats.linesAutoMerged += resolution.autoMergedLines;
stats.linesConflicted += resolution.conflictedLines;
}

return { content: merged.join('\n'), conflicts, stats };
}

interface RegionResolution {
readonly lines: readonly string[];
readonly conflict: ConflictRegion | null;
readonly autoMergedLines: number;
readonly conflictedLines: number;
}

/**
* Classify one unstable region. If either side kept the base unchanged (or
* both sides made the identical change), we can auto-merge. Otherwise we
* emit git-style conflict markers and describe a `ConflictRegion` for the
* UI layer.
*
* Pure function — no mutation. `mergedLengthBefore` is the length of the
* output buffer prior to this region and is used only to compute the
* 1-indexed line range recorded in the ConflictRegion.
*/
function resolveUnstableRegion(
region: IUnstableRegion<string>,
mergedLengthBefore: number,
): RegionResolution {
const { aContent: theirs, bContent: ours, oContent: base } = region;

if (arraysEqual(theirs, base)) {
return { lines: ours, conflict: null, autoMergedLines: ours.length, conflictedLines: 0 };
}
if (arraysEqual(ours, base) || arraysEqual(theirs, ours)) {
return { lines: theirs, conflict: null, autoMergedLines: theirs.length, conflictedLines: 0 };
}

const lines = [CONFLICT_START, ...theirs, CONFLICT_SEPARATOR, ...ours, CONFLICT_END];
const lineStart = mergedLengthBefore + 1;
const lineEnd = mergedLengthBefore + lines.length;
return {
lines,
conflict: {
lineStart,
lineEnd,
base: base.join('\n'),
theirs: theirs.join('\n'),
ours: ours.join('\n'),
},
autoMergedLines: 0,
conflictedLines: theirs.length + ours.length,
};
}

function arraysEqual<T>(a: readonly T[], b: readonly T[]): boolean {
return a.length === b.length && a.every((v, i) => v === b[i]);
}
Loading
Loading