From c560003b90561b26b140efe6cf79191167f7a40a Mon Sep 17 00:00:00 2001 From: CI Bot Date: Fri, 13 Feb 2026 19:00:39 -0800 Subject: [PATCH 1/5] feat(diff): add core graph diff computation (#203) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces src/diff.js with: - diffSnapshots() — pure comparison of two materialized graphs - computeDiff() — orchestrator (resolve epochs + materialize + diff) - parseDiffRefs() — CLI argument parser for range/two-arg/shorthand syntax - edgeKey(), compareEdge(), computeSummary() — internal helpers Three graph instances needed because materialize({ ceiling }) is destructive. System prefixes (decision, commit, epoch) excluded from diff output. Prefix filter requires both edge endpoints to match. --- src/diff.js | 342 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 342 insertions(+) create mode 100644 src/diff.js diff --git a/src/diff.js b/src/diff.js new file mode 100644 index 00000000..63ae44e9 --- /dev/null +++ b/src/diff.js @@ -0,0 +1,342 @@ +/** + * @module diff + * Graph diff computation between two historical points in time. + * + * Compares two materialized graph snapshots and reports node/edge changes. + * + * ## Edge identity invariant + * Edge uniqueness is `(source, target, type)` — guaranteed by git-warp's + * `addEdge` semantics (re-adding the same triple updates props, doesn't + * duplicate). The `edgeKey()` helper relies on this invariant. + * + * ## System prefix exclusion + * System-generated prefixes (`decision`, `commit`, `epoch`) are excluded + * from diff output, matching `src/export.js` behavior. + * + * ## Prefix filter edge inclusion rule + * When `prefix` option is specified, an edge is included **only if both + * endpoints pass the prefix filter**. No partial cross-prefix edges. + */ + +import { loadGraph } from './graph.js'; +import { getEpochForRef } from './epoch.js'; +import { extractPrefix } from './validators.js'; + +/** Node prefixes excluded from diff (system-generated). */ +const EXCLUDED_PREFIXES = new Set(['decision', 'commit', 'epoch']); + +/** + * @typedef {object} EdgeDiffEntry + * @property {string} source + * @property {string} target + * @property {string} type + */ + +/** + * @typedef {object} DiffEndpoint + * @property {string} ref - Original git ref + * @property {string} sha - Resolved commit SHA (short) + * @property {number} tick - Lamport tick used for materialization + * @property {boolean} nearest - Whether a nearest-epoch fallback was used + */ + +/** + * @typedef {object} DiffResult + * @property {number} schemaVersion + * @property {DiffEndpoint} from + * @property {DiffEndpoint} to + * @property {{ added: string[], removed: string[], total: { before: number, after: number } }} nodes + * @property {{ added: EdgeDiffEntry[], removed: EdgeDiffEntry[], total: { before: number, after: number } }} edges + * @property {{ nodesByPrefix: Record, edgesByType: Record }} summary + * @property {{ materializeMs: { a: number, b: number }, diffMs: number, nodeCount: { a: number, b: number }, edgeCount: { a: number, b: number } }} stats + */ + +/** + * Compute a unique key for an edge based on (source, target, type). + * Uses null byte separator to avoid collisions. + * + * @param {{ from: string, to: string, label: string }} edge + * @returns {string} + */ +export function edgeKey(edge) { + return `${edge.from}\0${edge.to}\0${edge.label}`; +} + +/** + * Stable comparator for edges: sort by (type, source, target). + * + * @param {EdgeDiffEntry} a + * @param {EdgeDiffEntry} b + * @returns {number} + */ +export function compareEdge(a, b) { + return a.type.localeCompare(b.type) + || a.source.localeCompare(b.source) + || a.target.localeCompare(b.target); +} + +/** + * Compute summary counts for nodes by prefix and edges by type. + * + * @param {string[]} nodesA + * @param {string[]} nodesB + * @param {Array<{from: string, to: string, label: string}>} edgesA + * @param {Array<{from: string, to: string, label: string}>} edgesB + * @returns {{ nodesByPrefix: Record, edgesByType: Record }} + */ +export function computeSummary(nodesA, nodesB, edgesA, edgesB) { + // Nodes by prefix + const prefixCountA = {}; + const prefixCountB = {}; + + for (const id of nodesA) { + const p = extractPrefix(id) ?? '(none)'; + prefixCountA[p] = (prefixCountA[p] ?? 0) + 1; + } + for (const id of nodesB) { + const p = extractPrefix(id) ?? '(none)'; + prefixCountB[p] = (prefixCountB[p] ?? 0) + 1; + } + + const allPrefixes = new Set([...Object.keys(prefixCountA), ...Object.keys(prefixCountB)]); + const nodesByPrefix = {}; + for (const p of [...allPrefixes].sort()) { + nodesByPrefix[p] = { before: prefixCountA[p] ?? 0, after: prefixCountB[p] ?? 0 }; + } + + // Edges by type + const typeCountA = {}; + const typeCountB = {}; + + for (const e of edgesA) { + typeCountA[e.label] = (typeCountA[e.label] ?? 0) + 1; + } + for (const e of edgesB) { + typeCountB[e.label] = (typeCountB[e.label] ?? 0) + 1; + } + + const allTypes = new Set([...Object.keys(typeCountA), ...Object.keys(typeCountB)]); + const edgesByType = {}; + for (const t of [...allTypes].sort()) { + edgesByType[t] = { before: typeCountA[t] ?? 0, after: typeCountB[t] ?? 0 }; + } + + return { nodesByPrefix, edgesByType }; +} + +/** + * Compare two materialized graph instances and return a diff. + * This is a pure comparison — both graphs must already be materialized. + * + * @param {import('@git-stunts/git-warp').default} graphA - "before" graph + * @param {import('@git-stunts/git-warp').default} graphB - "after" graph + * @param {{ prefix?: string }} [opts] + * @returns {Promise<{ nodes: { added: string[], removed: string[], total: { before: number, after: number } }, edges: { added: EdgeDiffEntry[], removed: EdgeDiffEntry[], total: { before: number, after: number } }, summary: object }>} + */ +export async function diffSnapshots(graphA, graphB, opts = {}) { + const prefixFilter = opts.prefix ?? null; + + const allNodesA = await graphA.getNodes(); + const allNodesB = await graphB.getNodes(); + + // Filter nodes: exclude system prefixes, apply prefix filter + const filterNode = (id) => { + const prefix = extractPrefix(id); + if (prefix && EXCLUDED_PREFIXES.has(prefix)) return false; + if (prefixFilter && prefix !== prefixFilter) return false; + return true; + }; + + const nodesA = allNodesA.filter(filterNode); + const nodesB = allNodesB.filter(filterNode); + + const setA = new Set(nodesA); + const setB = new Set(nodesB); + + const addedNodes = nodesB.filter(id => !setA.has(id)).sort(); + const removedNodes = nodesA.filter(id => !setB.has(id)).sort(); + + // Edges: only include edges where both endpoints are in the filtered node set + const allEdgesA = await graphA.getEdges(); + const allEdgesB = await graphB.getEdges(); + + const filterEdge = (edge, nodeSet) => { + return nodeSet.has(edge.from) && nodeSet.has(edge.to); + }; + + const edgesA = allEdgesA.filter(e => filterEdge(e, setA)); + const edgesB = allEdgesB.filter(e => filterEdge(e, setB)); + + const edgeMapA = new Set(edgesA.map(edgeKey)); + const edgeMapB = new Set(edgesB.map(edgeKey)); + + const addedEdges = edgesB + .filter(e => !edgeMapA.has(edgeKey(e))) + .map(e => ({ source: e.from, target: e.to, type: e.label })) + .sort(compareEdge); + + const removedEdges = edgesA + .filter(e => !edgeMapB.has(edgeKey(e))) + .map(e => ({ source: e.from, target: e.to, type: e.label })) + .sort(compareEdge); + + const summary = computeSummary(nodesA, nodesB, edgesA, edgesB); + + return { + nodes: { + added: addedNodes, + removed: removedNodes, + total: { before: nodesA.length, after: nodesB.length }, + }, + edges: { + added: addedEdges, + removed: removedEdges, + total: { before: edgesA.length, after: edgesB.length }, + }, + summary, + }; +} + +/** + * Parse diff ref arguments from CLI args. + * Supports: `A..B`, `A B`, `A` (shorthand for `A..HEAD`). + * Rejects: `A..B..C`, `..B`, `A..`, empty. + * + * @param {string[]} args - Non-flag arguments after the `diff` command + * @returns {{ refA: string, refB: string }} + */ +export function parseDiffRefs(args) { + if (!args || args.length === 0) { + throw new Error('Usage: git mind diff .. or git mind diff or git mind diff '); + } + + // Two-arg syntax: A B + if (args.length >= 2) { + return { refA: args[0], refB: args[1] }; + } + + // Single arg — might be range syntax or shorthand + const arg = args[0]; + + // Check for range syntax + const parts = arg.split('..'); + if (parts.length > 2) { + throw new Error(`Malformed range: "${arg}". Expected "ref-a..ref-b", got multiple ".." separators.`); + } + if (parts.length === 2) { + if (!parts[0]) { + throw new Error(`Malformed range: "${arg}". Left side of ".." is empty.`); + } + if (!parts[1]) { + throw new Error(`Malformed range: "${arg}". Right side of ".." is empty.`); + } + return { refA: parts[0], refB: parts[1] }; + } + + // Single ref — shorthand for ref..HEAD + return { refA: arg, refB: 'HEAD' }; +} + +/** + * Full diff orchestrator: resolve epochs, materialize graphs, compute diff. + * + * Opens three graph instances: + * 1. Resolver — unmaterialized, used to resolve both refs to epoch ticks + * 2. Graph A — materialized at tick A ("before") + * 3. Graph B — materialized at tick B ("after") + * + * Three instances are needed because `materialize({ ceiling })` is + * destructive (no unmaterialize). + * + * @param {string} cwd - Repository working directory + * @param {string} refA - "before" git ref + * @param {string} refB - "after" git ref + * @param {{ prefix?: string }} [opts] + * @returns {Promise} + */ +export async function computeDiff(cwd, refA, refB, opts = {}) { + // 1. Resolve both refs to epochs using a resolver graph + const resolver = await loadGraph(cwd, { writerId: 'diff-resolver' }); + + const resultA = await getEpochForRef(resolver, cwd, refA); + if (!resultA) { + throw new Error(`No epoch found for "${refA}" or its ancestors. Run "git mind process-commit" to record epoch markers.`); + } + + const resultB = await getEpochForRef(resolver, cwd, refB); + if (!resultB) { + throw new Error(`No epoch found for "${refB}" or its ancestors. Run "git mind process-commit" to record epoch markers.`); + } + + const tickA = resultA.epoch.tick; + const tickB = resultB.epoch.tick; + + // Same tick → empty diff + if (tickA === tickB) { + return { + schemaVersion: 1, + from: { + ref: refA, + sha: resultA.sha.slice(0, 8), + tick: tickA, + nearest: resultA.epoch.nearest ?? false, + }, + to: { + ref: refB, + sha: resultB.sha.slice(0, 8), + tick: tickB, + nearest: resultB.epoch.nearest ?? false, + }, + nodes: { added: [], removed: [], total: { before: 0, after: 0 } }, + edges: { added: [], removed: [], total: { before: 0, after: 0 } }, + summary: { nodesByPrefix: {}, edgesByType: {} }, + stats: { + materializeMs: { a: 0, b: 0 }, + diffMs: 0, + nodeCount: { a: 0, b: 0 }, + edgeCount: { a: 0, b: 0 }, + }, + }; + } + + // 2. Materialize two separate graph instances + const startA = Date.now(); + const graphA = await loadGraph(cwd, { writerId: 'diff-a' }); + await graphA.materialize({ ceiling: tickA }); + const msA = Date.now() - startA; + + const startB = Date.now(); + const graphB = await loadGraph(cwd, { writerId: 'diff-b' }); + await graphB.materialize({ ceiling: tickB }); + const msB = Date.now() - startB; + + // 3. Compute diff + const startDiff = Date.now(); + const diff = await diffSnapshots(graphA, graphB, { prefix: opts.prefix }); + const msDiff = Date.now() - startDiff; + + return { + schemaVersion: 1, + from: { + ref: refA, + sha: resultA.sha.slice(0, 8), + tick: tickA, + nearest: resultA.epoch.nearest ?? false, + }, + to: { + ref: refB, + sha: resultB.sha.slice(0, 8), + tick: tickB, + nearest: resultB.epoch.nearest ?? false, + }, + nodes: diff.nodes, + edges: diff.edges, + summary: diff.summary, + stats: { + materializeMs: { a: msA, b: msB }, + diffMs: msDiff, + nodeCount: { a: diff.nodes.total.before, b: diff.nodes.total.after }, + edgeCount: { a: diff.edges.total.before, b: diff.edges.total.after }, + }, + }; +} From 26d102ee0aee17c36ae287e5b2cc5e2c1bf38a90 Mon Sep 17 00:00:00 2001 From: CI Bot Date: Fri, 13 Feb 2026 19:04:18 -0800 Subject: [PATCH 2/5] test(diff): add diff test suite (#203) 25 tests covering: - diffSnapshots: node/edge detection, system node exclusion, prefix filtering, both-endpoint rule, summary computation - computeDiff: end-to-end epoch resolution, nearest fallback, same-tick empty diff, non-linear branch+merge history - parseDiffRefs: range syntax, two-arg, shorthand, error cases - compareEdge: deterministic sort ordering --- test/diff.test.js | 441 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 441 insertions(+) create mode 100644 test/diff.test.js diff --git a/test/diff.test.js b/test/diff.test.js new file mode 100644 index 00000000..ce2e7cfd --- /dev/null +++ b/test/diff.test.js @@ -0,0 +1,441 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { execSync } from 'node:child_process'; +import { initGraph } from '../src/graph.js'; +import { createEdge } from '../src/edges.js'; +import { recordEpoch, getCurrentTick } from '../src/epoch.js'; +import { diffSnapshots, computeDiff, parseDiffRefs, compareEdge } from '../src/diff.js'; + +/** + * Create two separate graph instances in separate temp repos. + * This is necessary because WarpGraph CRDTs merge all writers in a shared + * repo — separate repos ensure each graph only sees its own data. + */ +async function createTwoGraphs() { + const dirA = await mkdtemp(join(tmpdir(), 'gitmind-diff-a-')); + const dirB = await mkdtemp(join(tmpdir(), 'gitmind-diff-b-')); + + for (const dir of [dirA, dirB]) { + execSync('git init', { cwd: dir, stdio: 'ignore' }); + execSync('git config user.email "test@test.com"', { cwd: dir, stdio: 'ignore' }); + execSync('git config user.name "Test"', { cwd: dir, stdio: 'ignore' }); + } + + const graphA = await initGraph(dirA); + const graphB = await initGraph(dirB); + + return { dirA, dirB, graphA, graphB }; +} + +// ── diffSnapshots ───────────────────────────────────────────────── + +describe('diffSnapshots', () => { + let dirs = []; + + afterEach(async () => { + for (const d of dirs) { + await rm(d, { recursive: true, force: true }); + } + dirs = []; + }); + + it('detects added nodes', async () => { + const { dirA, dirB, graphA, graphB } = await createTwoGraphs(); + dirs.push(dirA, dirB); + + await createEdge(graphB, { source: 'task:new', target: 'spec:x', type: 'implements' }); + + const diff = await diffSnapshots(graphA, graphB); + + expect(diff.nodes.added).toContain('task:new'); + expect(diff.nodes.added).toContain('spec:x'); + expect(diff.nodes.removed).toHaveLength(0); + }); + + it('detects removed nodes', async () => { + const { dirA, dirB, graphA, graphB } = await createTwoGraphs(); + dirs.push(dirA, dirB); + + await createEdge(graphA, { source: 'task:old', target: 'spec:y', type: 'implements' }); + + const diff = await diffSnapshots(graphA, graphB); + + expect(diff.nodes.removed).toContain('task:old'); + expect(diff.nodes.removed).toContain('spec:y'); + expect(diff.nodes.added).toHaveLength(0); + }); + + it('detects added and removed simultaneously', async () => { + const { dirA, dirB, graphA, graphB } = await createTwoGraphs(); + dirs.push(dirA, dirB); + + await createEdge(graphA, { source: 'task:old', target: 'spec:shared', type: 'implements' }); + await createEdge(graphB, { source: 'task:new', target: 'spec:shared', type: 'implements' }); + + const diff = await diffSnapshots(graphA, graphB); + + expect(diff.nodes.added).toContain('task:new'); + expect(diff.nodes.removed).toContain('task:old'); + // spec:shared exists in both, so neither added nor removed + expect(diff.nodes.added).not.toContain('spec:shared'); + expect(diff.nodes.removed).not.toContain('spec:shared'); + }); + + it('returns empty diff for identical graphs', async () => { + const { dirA, dirB, graphA, graphB } = await createTwoGraphs(); + dirs.push(dirA, dirB); + + await createEdge(graphA, { source: 'task:a', target: 'spec:b', type: 'implements' }); + await createEdge(graphB, { source: 'task:a', target: 'spec:b', type: 'implements' }); + + const diff = await diffSnapshots(graphA, graphB); + + expect(diff.nodes.added).toHaveLength(0); + expect(diff.nodes.removed).toHaveLength(0); + expect(diff.edges.added).toHaveLength(0); + expect(diff.edges.removed).toHaveLength(0); + }); + + it('detects added edges', async () => { + const { dirA, dirB, graphA, graphB } = await createTwoGraphs(); + dirs.push(dirA, dirB); + + await createEdge(graphA, { source: 'task:a', target: 'spec:b', type: 'implements' }); + await createEdge(graphB, { source: 'task:a', target: 'spec:b', type: 'implements' }); + await createEdge(graphB, { source: 'task:a', target: 'spec:b', type: 'documents' }); + + const diff = await diffSnapshots(graphA, graphB); + + expect(diff.edges.added).toHaveLength(1); + expect(diff.edges.added[0]).toEqual({ + source: 'task:a', target: 'spec:b', type: 'documents', + }); + expect(diff.edges.removed).toHaveLength(0); + }); + + it('detects removed edges', async () => { + const { dirA, dirB, graphA, graphB } = await createTwoGraphs(); + dirs.push(dirA, dirB); + + await createEdge(graphA, { source: 'task:a', target: 'spec:b', type: 'implements' }); + await createEdge(graphA, { source: 'task:a', target: 'spec:b', type: 'documents' }); + await createEdge(graphB, { source: 'task:a', target: 'spec:b', type: 'implements' }); + + const diff = await diffSnapshots(graphA, graphB); + + expect(diff.edges.removed).toHaveLength(1); + expect(diff.edges.removed[0]).toEqual({ + source: 'task:a', target: 'spec:b', type: 'documents', + }); + expect(diff.edges.added).toHaveLength(0); + }); + + it('excludes system nodes (epoch, decision, commit)', async () => { + const { dirA, dirB, graphA, graphB } = await createTwoGraphs(); + dirs.push(dirA, dirB); + + // Add system nodes + one real node to A + const patchA = await graphA.createPatch(); + patchA.addNode('epoch:abc123def456'); + patchA.addNode('decision:d1'); + patchA.addNode('commit:c1'); + patchA.addNode('task:real'); + await patchA.commit(); + + // Add same system nodes + one real node + one new node to B + const patchB = await graphB.createPatch(); + patchB.addNode('epoch:abc123def456'); + patchB.addNode('decision:d1'); + patchB.addNode('commit:c1'); + patchB.addNode('task:real'); + patchB.addNode('task:new'); + await patchB.commit(); + + const diff = await diffSnapshots(graphA, graphB); + + // Only task:new should show as added — system nodes excluded + expect(diff.nodes.added).toEqual(['task:new']); + expect(diff.nodes.removed).toHaveLength(0); + }); + + it('filters by prefix option', async () => { + const { dirA, dirB, graphA, graphB } = await createTwoGraphs(); + dirs.push(dirA, dirB); + + await createEdge(graphA, { source: 'task:a', target: 'spec:b', type: 'implements' }); + await createEdge(graphB, { source: 'task:a', target: 'spec:b', type: 'implements' }); + await createEdge(graphB, { source: 'task:c', target: 'spec:d', type: 'implements' }); + await createEdge(graphB, { source: 'module:x', target: 'module:y', type: 'depends-on' }); + + const diff = await diffSnapshots(graphA, graphB, { prefix: 'task' }); + + // Only task-prefixed nodes should appear + expect(diff.nodes.added).toContain('task:c'); + expect(diff.nodes.added).not.toContain('spec:d'); + expect(diff.nodes.added).not.toContain('module:x'); + }); + + it('excludes edges when prefix filter removes an endpoint', async () => { + const { dirA, dirB, graphA, graphB } = await createTwoGraphs(); + dirs.push(dirA, dirB); + + await createEdge(graphA, { source: 'task:a', target: 'task:b', type: 'blocks' }); + await createEdge(graphB, { source: 'task:a', target: 'task:b', type: 'blocks' }); + await createEdge(graphB, { source: 'task:a', target: 'spec:x', type: 'implements' }); + + const diff = await diffSnapshots(graphA, graphB, { prefix: 'task' }); + + // The task:a -> spec:x edge should be excluded (spec:x doesn't pass filter) + expect(diff.edges.added).toHaveLength(0); + }); + + it('computes summary nodesByPrefix correctly', async () => { + const { dirA, dirB, graphA, graphB } = await createTwoGraphs(); + dirs.push(dirA, dirB); + + await createEdge(graphA, { source: 'task:a', target: 'spec:b', type: 'implements' }); + await createEdge(graphB, { source: 'task:a', target: 'spec:b', type: 'implements' }); + await createEdge(graphB, { source: 'task:c', target: 'spec:d', type: 'implements' }); + + const diff = await diffSnapshots(graphA, graphB); + + expect(diff.summary.nodesByPrefix.task).toEqual({ before: 1, after: 2 }); + expect(diff.summary.nodesByPrefix.spec).toEqual({ before: 1, after: 2 }); + }); + + it('computes summary edgesByType correctly', async () => { + const { dirA, dirB, graphA, graphB } = await createTwoGraphs(); + dirs.push(dirA, dirB); + + await createEdge(graphA, { source: 'task:a', target: 'spec:b', type: 'implements' }); + await createEdge(graphB, { source: 'task:a', target: 'spec:b', type: 'implements' }); + await createEdge(graphB, { source: 'task:a', target: 'spec:b', type: 'documents' }); + + const diff = await diffSnapshots(graphA, graphB); + + expect(diff.summary.edgesByType.implements).toEqual({ before: 1, after: 1 }); + expect(diff.summary.edgesByType.documents).toEqual({ before: 0, after: 1 }); + }); + + it('includes prefix/type in summary even when only in one snapshot', async () => { + const { dirA, dirB, graphA, graphB } = await createTwoGraphs(); + dirs.push(dirA, dirB); + + await createEdge(graphA, { source: 'module:x', target: 'module:y', type: 'depends-on' }); + await createEdge(graphB, { source: 'task:a', target: 'spec:b', type: 'implements' }); + + const diff = await diffSnapshots(graphA, graphB); + + // module prefix only in A, task/spec only in B + expect(diff.summary.nodesByPrefix.module).toEqual({ before: 2, after: 0 }); + expect(diff.summary.nodesByPrefix.task).toEqual({ before: 0, after: 1 }); + expect(diff.summary.nodesByPrefix.spec).toEqual({ before: 0, after: 1 }); + + // depends-on only in A, implements only in B + expect(diff.summary.edgesByType['depends-on']).toEqual({ before: 1, after: 0 }); + expect(diff.summary.edgesByType.implements).toEqual({ before: 0, after: 1 }); + }); +}); + +// ── computeDiff ─────────────────────────────────────────────────── + +describe('computeDiff', () => { + let tempDir; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), 'gitmind-diff-int-')); + execSync('git init', { cwd: tempDir, stdio: 'ignore' }); + execSync('git config user.email "test@test.com"', { cwd: tempDir, stdio: 'ignore' }); + execSync('git config user.name "Test"', { cwd: tempDir, stdio: 'ignore' }); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + it('computes diff between two epochs end-to-end', async () => { + const graph = await initGraph(tempDir); + + // Phase 1: create edges, record epoch + await createEdge(graph, { source: 'task:a', target: 'spec:b', type: 'implements' }); + const tick1 = await getCurrentTick(graph); + + await writeFile(join(tempDir, 'a.txt'), 'a'); + execSync('git add a.txt && git commit -m "c1"', { cwd: tempDir, stdio: 'ignore' }); + const sha1 = execSync('git rev-parse HEAD', { cwd: tempDir, encoding: 'utf-8' }).trim(); + await recordEpoch(graph, sha1, tick1); + + // Phase 2: add more edges, record epoch + await createEdge(graph, { source: 'task:c', target: 'spec:d', type: 'documents' }); + const tick2 = await getCurrentTick(graph); + + await writeFile(join(tempDir, 'b.txt'), 'b'); + execSync('git add b.txt && git commit -m "c2"', { cwd: tempDir, stdio: 'ignore' }); + const sha2 = execSync('git rev-parse HEAD', { cwd: tempDir, encoding: 'utf-8' }).trim(); + await recordEpoch(graph, sha2, tick2); + + const diff = await computeDiff(tempDir, sha1.slice(0, 8), sha2.slice(0, 8)); + + expect(diff.schemaVersion).toBe(1); + expect(diff.nodes.added).toContain('task:c'); + expect(diff.nodes.added).toContain('spec:d'); + expect(diff.edges.added).toHaveLength(1); + expect(diff.edges.added[0].type).toBe('documents'); + expect(diff.stats.materializeMs.a).toBeGreaterThanOrEqual(0); + expect(diff.stats.materializeMs.b).toBeGreaterThanOrEqual(0); + }); + + it('throws descriptive error for ref with no epoch', async () => { + await writeFile(join(tempDir, 'a.txt'), 'a'); + execSync('git add a.txt && git commit -m "c1"', { cwd: tempDir, stdio: 'ignore' }); + + await expect(computeDiff(tempDir, 'HEAD', 'HEAD')) + .rejects.toThrow(/No epoch found for "HEAD"/); + }); + + it('handles nearest-epoch fallback', async () => { + const graph = await initGraph(tempDir); + + // Create edge and epoch at c1 + await createEdge(graph, { source: 'task:a', target: 'spec:b', type: 'implements' }); + const tick1 = await getCurrentTick(graph); + + await writeFile(join(tempDir, 'a.txt'), 'a'); + execSync('git add a.txt && git commit -m "c1"', { cwd: tempDir, stdio: 'ignore' }); + const sha1 = execSync('git rev-parse HEAD', { cwd: tempDir, encoding: 'utf-8' }).trim(); + await recordEpoch(graph, sha1, tick1); + + // Add more edges and epoch at c2 + await createEdge(graph, { source: 'task:c', target: 'spec:d', type: 'documents' }); + const tick2 = await getCurrentTick(graph); + + await writeFile(join(tempDir, 'b.txt'), 'b'); + execSync('git add b.txt && git commit -m "c2"', { cwd: tempDir, stdio: 'ignore' }); + const sha2 = execSync('git rev-parse HEAD', { cwd: tempDir, encoding: 'utf-8' }).trim(); + await recordEpoch(graph, sha2, tick2); + + // Create c3 WITHOUT an epoch + await writeFile(join(tempDir, 'c.txt'), 'c'); + execSync('git add c.txt && git commit -m "c3"', { cwd: tempDir, stdio: 'ignore' }); + + // Diff using HEAD (c3, no direct epoch) — should fall back to c2's epoch + const diff = await computeDiff(tempDir, sha1.slice(0, 8), 'HEAD'); + + expect(diff.to.nearest).toBe(true); + expect(diff.nodes.added).toContain('task:c'); + }); + + it('returns empty diff when both refs resolve to same tick', async () => { + const graph = await initGraph(tempDir); + + await createEdge(graph, { source: 'task:a', target: 'spec:b', type: 'implements' }); + const tick = await getCurrentTick(graph); + + await writeFile(join(tempDir, 'a.txt'), 'a'); + execSync('git add a.txt && git commit -m "c1"', { cwd: tempDir, stdio: 'ignore' }); + const sha = execSync('git rev-parse HEAD', { cwd: tempDir, encoding: 'utf-8' }).trim(); + await recordEpoch(graph, sha, tick); + + // Both refs point to same commit → same tick → empty diff + const diff = await computeDiff(tempDir, 'HEAD', 'HEAD'); + + expect(diff.nodes.added).toHaveLength(0); + expect(diff.nodes.removed).toHaveLength(0); + expect(diff.edges.added).toHaveLength(0); + expect(diff.edges.removed).toHaveLength(0); + }); + + it('non-linear history: branch + merge with nearest fallback on one side', async () => { + const graph = await initGraph(tempDir); + + // c1 on main with epoch + await createEdge(graph, { source: 'task:a', target: 'spec:b', type: 'implements' }); + const tick1 = await getCurrentTick(graph); + + await writeFile(join(tempDir, 'a.txt'), 'a'); + execSync('git add a.txt && git commit -m "c1"', { cwd: tempDir, stdio: 'ignore' }); + const sha1 = execSync('git rev-parse HEAD', { cwd: tempDir, encoding: 'utf-8' }).trim(); + await recordEpoch(graph, sha1, tick1); + + // Branch off, add edges + execSync('git checkout -b feature', { cwd: tempDir, stdio: 'ignore' }); + + await createEdge(graph, { source: 'task:c', target: 'spec:d', type: 'documents' }); + const tick2 = await getCurrentTick(graph); + + await writeFile(join(tempDir, 'b.txt'), 'b'); + execSync('git add b.txt && git commit -m "c2-feature"', { cwd: tempDir, stdio: 'ignore' }); + const sha2 = execSync('git rev-parse HEAD', { cwd: tempDir, encoding: 'utf-8' }).trim(); + await recordEpoch(graph, sha2, tick2); + + // Go back to main, make a commit so merge is not fast-forward + execSync('git checkout -', { cwd: tempDir, stdio: 'ignore' }); + await writeFile(join(tempDir, 'c.txt'), 'c'); + execSync('git add c.txt && git commit -m "c3-main"', { cwd: tempDir, stdio: 'ignore' }); + + // Merge feature into main (--no-ff ensures merge commit) + execSync('git merge feature --no-edit', { cwd: tempDir, stdio: 'ignore' }); + + // HEAD is now the merge commit (no epoch) — should fall back to ancestor's epoch + const diff = await computeDiff(tempDir, sha1.slice(0, 8), 'HEAD'); + + expect(diff.to.nearest).toBe(true); + expect(diff.nodes.added).toContain('task:c'); + }); +}); + +// ── parseDiffRefs ───────────────────────────────────────────────── + +describe('parseDiffRefs', () => { + it('parses A..B range syntax', () => { + const result = parseDiffRefs(['HEAD~3..HEAD']); + expect(result).toEqual({ refA: 'HEAD~3', refB: 'HEAD' }); + }); + + it('parses A B two-arg syntax', () => { + const result = parseDiffRefs(['abc123', 'def456']); + expect(result).toEqual({ refA: 'abc123', refB: 'def456' }); + }); + + it('single ref A defaults to A..HEAD', () => { + const result = parseDiffRefs(['HEAD~5']); + expect(result).toEqual({ refA: 'HEAD~5', refB: 'HEAD' }); + }); + + it('rejects A..B..C (multiple "..")', () => { + expect(() => parseDiffRefs(['A..B..C'])).toThrow(/multiple "\.\." separators/); + }); + + it('rejects ..B (empty left side)', () => { + expect(() => parseDiffRefs(['..B'])).toThrow(/Left side.+empty/); + }); + + it('rejects A.. (empty right side)', () => { + expect(() => parseDiffRefs(['A..'])).toThrow(/Right side.+empty/); + }); + + it('rejects empty input', () => { + expect(() => parseDiffRefs([])).toThrow(/Usage/); + }); +}); + +// ── compareEdge ─────────────────────────────────────────────────── + +describe('compareEdge', () => { + it('sorts by type first, then source, then target', () => { + const edges = [ + { source: 'b:1', target: 'c:2', type: 'implements' }, + { source: 'a:1', target: 'c:2', type: 'documents' }, + { source: 'a:1', target: 'b:2', type: 'implements' }, + ]; + const sorted = [...edges].sort(compareEdge); + + expect(sorted[0].type).toBe('documents'); + expect(sorted[1].type).toBe('implements'); + expect(sorted[1].source).toBe('a:1'); + expect(sorted[2].type).toBe('implements'); + expect(sorted[2].source).toBe('b:1'); + }); +}); From 8154e6ab5b4d7d2cef33d027621cffc0c62695e4 Mon Sep 17 00:00:00 2001 From: CI Bot Date: Fri, 13 Feb 2026 19:05:45 -0800 Subject: [PATCH 3/5] feat(diff): add CLI command, formatter, and public API (#203) - formatDiff() + renderDiffTable() in format.js for TTY output - diff() command in commands.js following standard pattern - parseDiffRefs + case 'diff' in CLI entry point - computeDiff + diffSnapshots exported from public API - Usage text updated with diff command docs - GITMIND_DEBUG env var controls stats timing output --- bin/git-mind.js | 23 +++++++++- src/cli/commands.js | 25 ++++++++++- src/cli/format.js | 105 ++++++++++++++++++++++++++++++++++++++++++++ src/index.js | 1 + 4 files changed, 152 insertions(+), 2 deletions(-) diff --git a/bin/git-mind.js b/bin/git-mind.js index 052e8f27..d582954b 100755 --- a/bin/git-mind.js +++ b/bin/git-mind.js @@ -5,7 +5,8 @@ * Usage: git mind [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 } from '../src/diff.js'; const args = process.argv.slice(2); const command = args[0]; @@ -35,6 +36,9 @@ Commands: --json Output as JSON at Show graph at a historical point in time --json Output as JSON + diff .. Compare graph between two commits + --json Output as JSON + --prefix Scope to a single prefix import Import a YAML graph file --dry-run, --validate Validate without writing --json Output as JSON @@ -164,6 +168,23 @@ switch (command) { break; } + case 'diff': { + const diffFlags = parseFlags(args.slice(1)); + // Collect non-flag positional args + const diffPositionals = args.slice(1).filter(a => !a.startsWith('--')); + 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; diff --git a/src/cli/commands.js b/src/cli/commands.js index fd244dbe..5dee7e94 100644 --- a/src/cli/commands.js +++ b/src/cli/commands.js @@ -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. @@ -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; + } +} diff --git a/src/cli/format.js b/src/cli/format.js index a3e3fe80..2e73b22f 100644 --- a/src/cli/format.js +++ b/src/cli/format.js @@ -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} 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 diff --git a/src/index.js b/src/index.js index d32b4a84..b89e8094 100644 --- a/src/index.js +++ b/src/index.js @@ -41,3 +41,4 @@ export { getPendingSuggestions, acceptSuggestion, rejectSuggestion, adjustSuggestion, skipSuggestion, getReviewHistory, batchDecision, } from './review.js'; +export { computeDiff, diffSnapshots } from './diff.js'; From 5eea6744a6d86459892e8185f67e5f9c9501de21 Mon Sep 17 00:00:00 2001 From: CI Bot Date: Fri, 13 Feb 2026 19:06:35 -0800 Subject: [PATCH 4/5] docs(diff): add changelog and guide entries (#203) - v2.0.0-alpha.5 changelog section for git mind diff - New "Comparing graph snapshots" guide section with usage, prefix filtering, JSON versioning, and nearest-epoch fallback - CLI reference entry for git mind diff - Table of contents updated --- CHANGELOG.md | 13 ++++++++ GUIDE.md | 84 ++++++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 94 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c7a1b909..c9e808aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ 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) + +### Changed + +- **Test count** — 337 tests across 20 files (was 312 across 19) + ## [2.0.0-alpha.4] - 2026-02-13 ### Added @@ -203,6 +214,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 diff --git a/GUIDE.md b/GUIDE.md index a4bc2643..f75905f0 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -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) --- @@ -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 ` | 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. @@ -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. From 62b081f30f05fc632600e997be0d5caaf5d7a45e Mon Sep 17 00:00:00 2001 From: CI Bot Date: Fri, 13 Feb 2026 19:49:21 -0800 Subject: [PATCH 5/5] fix(diff): address round-1 review feedback (#203, #204) - Fix --prefix value leaking into positional args: flag values like `task` from `--prefix task` were incorrectly treated as ref arguments. Extracted collectDiffPositionals() helper that skips consumed values. - Add stats.sameTick flag to same-tick shortcut so JSON consumers can distinguish "unchanged graph" from "empty graph" (was returning 0/0). - 5 new regression tests (collectDiffPositionals + sameTick assertion). - CHANGELOG updated with fixes. --- CHANGELOG.md | 7 ++++++- bin/git-mind.js | 5 ++--- src/diff.js | 24 ++++++++++++++++++++++++ test/diff.test.js | 33 ++++++++++++++++++++++++++++++++- 4 files changed, 64 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c9e808aa..defbf6ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,9 +12,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`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** — 337 tests across 20 files (was 312 across 19) +- **Test count** — 342 tests across 20 files (was 312 across 19) ## [2.0.0-alpha.4] - 2026-02-13 diff --git a/bin/git-mind.js b/bin/git-mind.js index d582954b..b9998cfa 100755 --- a/bin/git-mind.js +++ b/bin/git-mind.js @@ -6,7 +6,7 @@ */ 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 } from '../src/diff.js'; +import { parseDiffRefs, collectDiffPositionals } from '../src/diff.js'; const args = process.argv.slice(2); const command = args[0]; @@ -170,8 +170,7 @@ switch (command) { case 'diff': { const diffFlags = parseFlags(args.slice(1)); - // Collect non-flag positional args - const diffPositionals = args.slice(1).filter(a => !a.startsWith('--')); + const diffPositionals = collectDiffPositionals(args.slice(1)); try { const { refA, refB } = parseDiffRefs(diffPositionals); await diff(cwd, refA, refB, { diff --git a/src/diff.js b/src/diff.js index 63ae44e9..280c71c1 100644 --- a/src/diff.js +++ b/src/diff.js @@ -237,6 +237,29 @@ export function parseDiffRefs(args) { return { refA: arg, refB: 'HEAD' }; } +/** Boolean flags that don't consume the next arg. */ +const DIFF_BOOLEAN_FLAGS = new Set(['json']); + +/** + * Collect positional args from a diff command's arg list, + * skipping --flag and their consumed values. + * + * @param {string[]} args - Args after the `diff` command word + * @returns {string[]} Positional (non-flag) arguments + */ +export function collectDiffPositionals(args) { + const positionals = []; + for (let i = 0; i < args.length; i++) { + if (args[i].startsWith('--')) { + const name = args[i].slice(2); + if (!DIFF_BOOLEAN_FLAGS.has(name)) i++; // skip the flag's value + } else { + positionals.push(args[i]); + } + } + return positionals; +} + /** * Full diff orchestrator: resolve epochs, materialize graphs, compute diff. * @@ -291,6 +314,7 @@ export async function computeDiff(cwd, refA, refB, opts = {}) { edges: { added: [], removed: [], total: { before: 0, after: 0 } }, summary: { nodesByPrefix: {}, edgesByType: {} }, stats: { + sameTick: true, materializeMs: { a: 0, b: 0 }, diffMs: 0, nodeCount: { a: 0, b: 0 }, diff --git a/test/diff.test.js b/test/diff.test.js index ce2e7cfd..d5e4b9e1 100644 --- a/test/diff.test.js +++ b/test/diff.test.js @@ -6,7 +6,7 @@ import { execSync } from 'node:child_process'; import { initGraph } from '../src/graph.js'; import { createEdge } from '../src/edges.js'; import { recordEpoch, getCurrentTick } from '../src/epoch.js'; -import { diffSnapshots, computeDiff, parseDiffRefs, compareEdge } from '../src/diff.js'; +import { diffSnapshots, computeDiff, parseDiffRefs, compareEdge, collectDiffPositionals } from '../src/diff.js'; /** * Create two separate graph instances in separate temp repos. @@ -345,6 +345,7 @@ describe('computeDiff', () => { expect(diff.nodes.removed).toHaveLength(0); expect(diff.edges.added).toHaveLength(0); expect(diff.edges.removed).toHaveLength(0); + expect(diff.stats.sameTick).toBe(true); }); it('non-linear history: branch + merge with nearest fallback on one side', async () => { @@ -419,6 +420,36 @@ describe('parseDiffRefs', () => { it('rejects empty input', () => { expect(() => parseDiffRefs([])).toThrow(/Usage/); }); + + it('ignores extra positional args beyond two', () => { + // Two-arg takes first two, ignores rest + const result = parseDiffRefs(['A', 'B', 'C']); + expect(result).toEqual({ refA: 'A', refB: 'B' }); + }); +}); + +// ── collectDiffPositionals ──────────────────────────────────────── + +describe('collectDiffPositionals', () => { + it('strips flag names and their values', () => { + const result = collectDiffPositionals(['HEAD~1..HEAD', '--prefix', 'task']); + expect(result).toEqual(['HEAD~1..HEAD']); + }); + + it('strips boolean flags without consuming next arg', () => { + const result = collectDiffPositionals(['HEAD~3..HEAD', '--json']); + expect(result).toEqual(['HEAD~3..HEAD']); + }); + + it('handles flag before positional args', () => { + const result = collectDiffPositionals(['--prefix', 'module', 'HEAD~5', 'HEAD']); + expect(result).toEqual(['HEAD~5', 'HEAD']); + }); + + it('returns all positionals when no flags present', () => { + const result = collectDiffPositionals(['abc123', 'def456']); + expect(result).toEqual(['abc123', 'def456']); + }); }); // ── compareEdge ───────────────────────────────────────────────────