Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 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
6 changes: 3 additions & 3 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,9 @@ 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
- [ ] Add edge case fixtures: frontmatter merge, empty file, binary-identical, encoding
- [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
- [x] Add edge case fixtures: empty file (18), UTF-8 non-ASCII (19), frontmatter merge on modified ownership (20). Hash-identical behavior already covered by scenarios 01 and 05 (both hit the `sha256(base) === sha256(ours)` shortcut)

### Milestone 4: Update Command + Status (Day 4)

Expand Down
31 changes: 19 additions & 12 deletions docs/IMPLEMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -487,14 +487,15 @@ interface DriftEntry {
```

**Algorithm**:
1. For each file in `state.files`:
a. If `ownership === 'volatile'` → add to `volatile`
b. Read file from disk. If not found → add to `missing`
c. Compute `sha256(file content)`
d. Compare against `state.files[path].rendered_hash`
e. If equal → `managed`
f. If different → `modified`
2. Return classified report
1. For each file in `state.files` (in parallel via `Promise.all`):
a. If `FileState.ownership === 'user'` (volatile at install time) → `DriftEntry` with `ownership: 'volatile'` → add to `volatile`. Never hashed; content may diverge by design.
b. Read file from disk. If ENOENT → add to `missing` (propagate state ownership).
c. Compute `sha256(file content)`.
d. Compare against `state.files[path].rendered_hash`. Equal → `managed`. Different → `modified`.
2. Orphan scan (runs in parallel with the classification): union of parent directories of every tracked path is the set of tracked directories. For each tracked directory, `readdir` non-recursively and report files not in `state.files` as orphans. Excludes engine-reserved files (`VALUES_FILE`) and never-scanned directories (`.shardmind`, `.git`, `.obsidian`). Subdirectories of a tracked directory are not auto-scanned — they only count if they themselves contain a tracked file.
3. Return classified report.

**Rationale for non-recursive orphan scan**: the shard only claims to manage what it tracks. A user's `brain/daily/2026-04-19.md` under an untracked subdirectory is their territory, not an orphan. But a `skills/my-extra.md` sibling of a tracked `skills/leadership.md` is an orphan because `skills/` is territory the shard already claims.

**Dependencies**: `node:fs`, `node:crypto`.

Expand Down Expand Up @@ -536,17 +537,23 @@ interface MergeStats {
4. If `sha256(base) === sha256(ours)` → no upstream change → `{ type: 'skip' }`
5. If ownership is `managed` (base === theirs) → `{ type: 'overwrite', content: ours }`
6. If ownership is `modified`:
a. Run `diff3Merge(theirs.split('\n'), base.split('\n'), ours.split('\n'))`
b. If no conflicts → `{ type: 'auto_merge', content: merged }`
c. If conflicts → `{ type: 'conflict', result: { content, hasConflicts, conflicts, stats } }`
a. Run `diff3MergeRegions(theirs.split(/\r?\n/), base.split(/\r?\n/), ours.split(/\r?\n/))` — not the flat `diff3Merge`; the regions variant exposes `buffer: 'a' | 'o' | 'b'` on stable regions and `aContent / oContent / bContent` on unstable ones, which is the only way to distinguish stable-unchanged (`buffer === 'o'`) from stable-auto-merged (`buffer === 'a' | 'b'`) lines. The `/\r?\n/` split tolerates CRLF on Windows-saved files; merged output is always LF.
b. For each stable region: emit `bufferContent`. For each unstable region: if `aContent === oContent` take `bContent`; if `bContent === oContent` take `aContent`; if `aContent === bContent` take either (false conflict); else emit git-style conflict markers and record a `ConflictRegion`.
c. No conflicts → `{ type: 'auto_merge', content, stats }`. Conflicts → `{ type: 'conflict', result: { content, hasConflicts: true, conflicts, stats } }`.

**`MergeResult`** (for conflicts):
```typescript
interface MergeStatsWithConflicts {
linesUnchanged: number;
linesAutoMerged: number;
linesConflicted: number;
}

interface MergeResult {
content: string; // Merged content with conflict markers
hasConflicts: boolean;
conflicts: ConflictRegion[];
stats: { linesUnchanged: number; linesAutoMerged: number; linesConflicted: number; };
stats: MergeStatsWithConflicts;
}

interface ConflictRegion {
Expand Down
206 changes: 206 additions & 0 deletions source/core/differ.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
/**
* 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,
MergeStatsWithConflicts,
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';

// Line splitter. LF is the engine's canonical line ending (renderer output
// is always LF); CR is tolerated so Windows-saved user files don't produce
// spurious conflicts against LF base/ours.
const LINE_SPLIT = /\r?\n/;

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: MergeStatsWithConflicts;
}

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 {
// Merged output is always LF — callers that need platform-native line
// endings convert at the write boundary.
const regions: IRegion<string>[] = diff3MergeRegions(
theirs.split(LINE_SPLIT),
base.split(LINE_SPLIT),
ours.split(LINE_SPLIT),
);

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