Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [2.0.0-alpha.5] - 2026-02-13

### Added

- **`git mind diff` command** — Compare the knowledge graph between two historical commits. Resolves git refs to epoch markers, materializes both snapshots, and reports node/edge additions and removals with summary tables. Supports range syntax (`A..B`), two-arg syntax (`A B`), and shorthand (`A` for `A..HEAD`). `--json` output includes `schemaVersion: 1` for forward compatibility. `--prefix` scopes the diff to a single node prefix (#203)
- **Diff API** — `computeDiff(cwd, refA, refB, opts)` for full orchestration, `diffSnapshots(graphA, graphB, opts)` for pure snapshot comparison in `src/diff.js`. Both exported from public API (#203)

### Fixed

- **`--prefix` value leaks into positional args** — `git mind diff HEAD~1..HEAD --prefix task` incorrectly treated `task` as a second ref argument. Extracted `collectDiffPositionals()` helper that skips flag values consumed by non-boolean flags (#203)
- **Same-tick shortcut reports zero totals** — When both refs resolve to the same Lamport tick, `computeDiff` returned `total: { before: 0, after: 0 }` which misrepresents an unchanged graph as empty. Now includes `stats.sameTick: true` so JSON consumers can distinguish "unchanged" from "empty graph" (#203)

### Changed

- **Test count** — 342 tests across 20 files (was 312 across 19)

## [2.0.0-alpha.4] - 2026-02-13

### Added
Expand Down Expand Up @@ -203,6 +219,8 @@ Complete rewrite from C23 to Node.js on `@git-stunts/git-warp`.
- Docker-based CI/CD
- All C-specific documentation

[2.0.0-alpha.5]: https://github.com/neuroglyph/git-mind/releases/tag/v2.0.0-alpha.5
[2.0.0-alpha.4]: https://github.com/neuroglyph/git-mind/releases/tag/v2.0.0-alpha.4
[2.0.0-alpha.3]: https://github.com/neuroglyph/git-mind/releases/tag/v2.0.0-alpha.3
[2.0.0-alpha.2]: https://github.com/neuroglyph/git-mind/releases/tag/v2.0.0-alpha.2
[2.0.0-alpha.0]: https://github.com/neuroglyph/git-mind/releases/tag/v2.0.0-alpha.0
84 changes: 81 additions & 3 deletions GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,10 @@ Everything you need to know — from zero to power user.
7. [Importing graphs from YAML](#importing-graphs-from-yaml)
8. [Commit directives](#commit-directives)
9. [Time-travel with `git mind at`](#time-travel-with-git-mind-at)
10. [Using git-mind as a library](#using-git-mind-as-a-library)
11. [Appendix A: How it works under the hood](#appendix-a-how-it-works-under-the-hood)
12. [Appendix B: Edge types reference](#appendix-b-edge-types-reference)
10. [Comparing graph snapshots](#comparing-graph-snapshots)
11. [Using git-mind as a library](#using-git-mind-as-a-library)
12. [Appendix A: How it works under the hood](#appendix-a-how-it-works-under-the-hood)
13. [Appendix B: Edge types reference](#appendix-b-edge-types-reference)

---

Expand Down Expand Up @@ -317,6 +318,27 @@ Resolves the ref to a commit SHA, finds the epoch marker (or nearest ancestor),
|------|-------------|
| `--json` | Output as JSON (includes epoch metadata) |

### `git mind diff`

Compare the knowledge graph between two commits.

```bash
git mind diff HEAD~10..HEAD # range syntax
git mind diff abc1234 def5678 # two-arg syntax
git mind diff HEAD~10 # shorthand for HEAD~10..HEAD
git mind diff HEAD~5..HEAD --prefix task # scope to task: nodes
git mind diff HEAD~5..HEAD --json # structured output
```

**Flags:**

| Flag | Description |
|------|-------------|
| `--json` | Output as JSON (includes `schemaVersion` for compatibility) |
| `--prefix <prefix>` | Only include nodes with this prefix (edges must have both endpoints matching) |

See [Comparing graph snapshots](#comparing-graph-snapshots) for details.

### `git mind suggest`

Generate AI-powered edge suggestions based on recent code changes.
Expand Down Expand Up @@ -559,6 +581,62 @@ if (result) {

---

## Comparing graph snapshots

`git mind diff` shows what changed in your knowledge graph between two commits. It resolves each ref to an epoch marker, materializes the graph at both points in time, and reports the delta.

### Usage

```bash
# Range syntax
git mind diff HEAD~10..HEAD

# Two-arg syntax
git mind diff abc1234 def5678

# Shorthand: ref..HEAD
git mind diff HEAD~10

# Scope to a single prefix
git mind diff HEAD~10..HEAD --prefix task

# JSON output (includes schemaVersion for compatibility)
git mind diff HEAD~10..HEAD --json
```

### How it works

1. Both refs are resolved to commit SHAs
2. Each SHA is looked up in the epoch markers (or the nearest ancestor's epoch)
3. Two separate graph instances are materialized at those Lamport ticks
4. The diff engine compares nodes and edges, reporting additions and removals

System-generated nodes (`epoch:`, `decision:`, `commit:`) are excluded from the diff, matching export behavior.

### Prefix filtering

When `--prefix` is specified, only nodes with that prefix are included. Edges are included **only if both endpoints pass the prefix filter** — no partial cross-prefix edges appear in the output.

```bash
git mind diff HEAD~5..HEAD --prefix module
# Only shows module:* node changes and edges between module:* nodes
```

### JSON output and versioning

The `--json` output includes a `schemaVersion` field (currently `1`). Breaking changes to the JSON structure will increment this version, so downstream tools can detect incompatible output.

```bash
git mind diff HEAD~5..HEAD --json | jq '.schemaVersion'
# 1
```

### Nearest-epoch fallback

If a ref doesn't have an exact epoch marker, the diff engine walks up the commit ancestry to find the nearest one. When this happens, the TTY output shows a warning icon next to the endpoint.

---

## Using git-mind as a library

git-mind exports its core modules for use in scripts and integrations.
Expand Down
22 changes: 21 additions & 1 deletion bin/git-mind.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
* Usage: git mind <command> [options]
*/

import { init, link, view, list, remove, nodes, status, at, importCmd, importMarkdownCmd, exportCmd, mergeCmd, installHooks, processCommitCmd, doctor, suggest, review } from '../src/cli/commands.js';
import { init, link, view, list, remove, nodes, status, at, importCmd, importMarkdownCmd, exportCmd, mergeCmd, installHooks, processCommitCmd, doctor, suggest, review, diff } from '../src/cli/commands.js';
import { parseDiffRefs, collectDiffPositionals } from '../src/diff.js';

const args = process.argv.slice(2);
const command = args[0];
Expand Down Expand Up @@ -35,6 +36,9 @@ Commands:
--json Output as JSON
at <ref> Show graph at a historical point in time
--json Output as JSON
diff <ref-a>..<ref-b> Compare graph between two commits
--json Output as JSON
--prefix <prefix> Scope to a single prefix
import <file> Import a YAML graph file
--dry-run, --validate Validate without writing
--json Output as JSON
Expand Down Expand Up @@ -164,6 +168,22 @@ switch (command) {
break;
}

case 'diff': {
const diffFlags = parseFlags(args.slice(1));
const diffPositionals = collectDiffPositionals(args.slice(1));
try {
const { refA, refB } = parseDiffRefs(diffPositionals);
await diff(cwd, refA, refB, {
json: diffFlags.json ?? false,
prefix: diffFlags.prefix,
});
} catch (err) {
console.error(err.message);
process.exitCode = 1;
}
break;
}

case 'import': {
const importFlags = parseFlags(args.slice(1));
const dryRun = importFlags['dry-run'] === true || importFlags['validate'] === true;
Expand Down
25 changes: 24 additions & 1 deletion src/cli/commands.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ import { getEpochForRef } from '../epoch.js';
import { runDoctor, fixIssues } from '../doctor.js';
import { generateSuggestions } from '../suggest.js';
import { getPendingSuggestions, acceptSuggestion, rejectSuggestion, skipSuggestion, batchDecision } from '../review.js';
import { success, error, info, warning, formatEdge, formatView, formatNode, formatNodeList, formatStatus, formatExportResult, formatImportResult, formatDoctorResult, formatSuggestions, formatReviewItem, formatDecisionSummary, formatAtStatus } from './format.js';
import { computeDiff } from '../diff.js';
import { success, error, info, warning, formatEdge, formatView, formatNode, formatNodeList, formatStatus, formatExportResult, formatImportResult, formatDoctorResult, formatSuggestions, formatReviewItem, formatDecisionSummary, formatAtStatus, formatDiff } from './format.js';

/**
* Initialize a git-mind graph in the current repo.
Expand Down Expand Up @@ -594,3 +595,25 @@ export async function review(cwd, opts = {}) {
process.exitCode = 1;
}
}

/**
* Show graph diff between two commits.
* @param {string} cwd
* @param {string} refA
* @param {string} refB
* @param {{ json?: boolean, prefix?: string }} opts
*/
export async function diff(cwd, refA, refB, opts = {}) {
try {
const result = await computeDiff(cwd, refA, refB, { prefix: opts.prefix });

if (opts.json) {
console.log(JSON.stringify(result, null, 2));
} else {
console.log(formatDiff(result));
}
} catch (err) {
console.error(error(err.message));
process.exitCode = 1;
}
}
105 changes: 105 additions & 0 deletions src/cli/format.js
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,111 @@ export function formatAtStatus(ref, sha, epoch, status) {
return lines.join('\n');
}

/**
* Render a summary diff table with before/after/delta columns.
* @param {Record<string, {before: number, after: number}>} entries
* @returns {string[]} Lines of formatted output
*/
function renderDiffTable(entries) {
const lines = [];
const sorted = Object.entries(entries).sort(([, a], [, b]) => {
const deltaB = Math.abs(b.after - b.before);
const deltaA = Math.abs(a.after - a.before);
return deltaB - deltaA;
});
for (const [key, { before, after }] of sorted) {
const delta = after - before;
const sign = delta > 0 ? '+' : delta < 0 ? '' : ' ';
const deltaStr = delta !== 0
? chalk.dim(` (${sign}${delta})`)
: '';
lines.push(` ${chalk.yellow(key.padEnd(14))} ${String(before).padStart(3)} ${figures.arrowRight} ${String(after).padStart(3)}${deltaStr}`);
}
return lines;
}

/**
* Format a diff result for terminal display.
* @param {import('../diff.js').DiffResult} diff
* @returns {string}
*/
export function formatDiff(diff) {
const lines = [];

// Header
lines.push(chalk.bold(`Graph Diff: ${diff.from.sha}..${diff.to.sha}`));
lines.push(chalk.dim('═'.repeat(40)));

// Endpoints
const fmtEndpoint = (label, ep) => {
const shaStr = `commit ${chalk.cyan(ep.sha)}`;
const tickStr = `tick ${chalk.yellow(String(ep.tick))}`;
const nearestStr = ep.nearest
? ` ${chalk.yellow(figures.warning)} nearest from ${chalk.dim(ep.ref)}`
: '';
return `${label} ${shaStr} ${tickStr}${nearestStr}`;
};
lines.push(fmtEndpoint('from', diff.from));
lines.push(fmtEndpoint(' to', diff.to));
lines.push('');

// Nodes
const na = diff.nodes.total.before;
const nb = diff.nodes.total.after;
const nAdded = diff.nodes.added.length;
const nRemoved = diff.nodes.removed.length;
lines.push(`${chalk.bold('Nodes:')} ${na} ${figures.arrowRight} ${nb} (+${nAdded}, -${nRemoved})`);

for (const id of diff.nodes.added) {
lines.push(` ${chalk.green('+')} ${chalk.cyan(id)}`);
}
for (const id of diff.nodes.removed) {
lines.push(` ${chalk.red('-')} ${chalk.cyan(id)}`);
}
if (nAdded === 0 && nRemoved === 0) {
lines.push(chalk.dim(' (no changes)'));
}
lines.push('');

// Edges
const ea = diff.edges.total.before;
const eb = diff.edges.total.after;
const eAdded = diff.edges.added.length;
const eRemoved = diff.edges.removed.length;
lines.push(`${chalk.bold('Edges:')} ${ea} ${figures.arrowRight} ${eb} (+${eAdded}, -${eRemoved})`);

for (const e of diff.edges.added) {
lines.push(` ${chalk.green('+')} ${chalk.cyan(e.source)} ${chalk.dim('--[')}${chalk.yellow(e.type)}${chalk.dim(']-->')} ${chalk.cyan(e.target)}`);
}
for (const e of diff.edges.removed) {
lines.push(` ${chalk.red('-')} ${chalk.cyan(e.source)} ${chalk.dim('--[')}${chalk.yellow(e.type)}${chalk.dim(']-->')} ${chalk.cyan(e.target)}`);
}
if (eAdded === 0 && eRemoved === 0) {
lines.push(chalk.dim(' (no changes)'));
}

// Summary tables
if (Object.keys(diff.summary.nodesByPrefix).length > 0) {
lines.push('');
lines.push(chalk.bold('By Prefix'));
lines.push(...renderDiffTable(diff.summary.nodesByPrefix));
}

if (Object.keys(diff.summary.edgesByType).length > 0) {
lines.push('');
lines.push(chalk.bold('By Type'));
lines.push(...renderDiffTable(diff.summary.edgesByType));
}

// Timing stats (debug only)
if (process.env.GITMIND_DEBUG) {
lines.push('');
lines.push(chalk.dim(`materialize: ${diff.stats.materializeMs.a}ms + ${diff.stats.materializeMs.b}ms diff: ${diff.stats.diffMs}ms`));
}

return lines.join('\n');
}

/**
* Format an import result for terminal display.
* @param {import('../import.js').ImportResult} result
Expand Down
Loading