feat(Git Diff): Add an entity-level diff engine [1/5] (#10615)

* feat(Git Diff): add an entity-level diff engine for Insomnia YAML files

Parses both sides of a git-synced Insomnia file, matches requests, folders,
environments, mock routes, the MCP request, the cookie jar and the file-level
sections by id, and returns the added / removed / modified entities with their
field-level changes. Not wired into any UI yet.

Co-authored-by: James Gatz <jamesgatzos@gmail.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(Git Diff): tell same-named params and headers apart in the diff

diffByKey matched items by name only, so of two same-named items (eg. a
disabled default User-Agent header next to a custom one) the later hid the
earlier: a change to the first was invisible, and adding or removing one read
as a modification. Items sharing a name are now numbered by their order among
that name, as "User-Agent (1)" and "User-Agent (2)", on both sides.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: James Gatz <jamesgatzos@gmail.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-10-19 17:38:20 +08:00
1 parent a7eb49414c
commit 463f04abd1
2 files changed
+640

No files matched your search

@@ -0,0 +1,109 @@
import { describe, expect, it } from 'vitest';
import { stringify } from 'yaml';
import { computeFieldChanges, computeVisualDiff, diffByKey, ownFields } from './diff-engine';
const file = (collection: unknown[]) =>
stringify({ type: 'collection.insomnia.rest/5.0', name: 'C', meta: { id: 'wrk_1' }, collection });
describe('computeVisualDiff', () => {
it('is unparseable when one side has content that is not valid YAML (eg. conflict markers)', () => {
const before = file([{ name: 'r', url: 'https://a', meta: { id: 'req_1' } }]);
const conflicted = `${before}<<<<<<< HEAD\n - url: https://ours\n=======\n - url: https://theirs\n>>>>>>> main\n`;
expect(computeVisualDiff(before, conflicted).unparseable).toBe(true);
});
it('treats an empty side as a legitimately absent file (new or deleted), not unparseable', () => {
const after = file([{ name: 'r', url: 'https://a', meta: { id: 'req_1' } }]);
const result = computeVisualDiff('', after);
expect(result.unparseable).toBe(false);
expect(result.entities.map(e => [e.id, e.status])).toEqual([
['req_1', 'added'],
['file:workspace', 'added'],
]);
});
});
describe('ownFields', () => {
it('drops nested entities, which are shown as their own cards', () => {
const folder = { name: 'F', meta: { id: 'fld_1' }, children: [{ name: 'r', meta: { id: 'req_1' } }] };
const env = { name: 'Base', data: { a: 1 }, subEnvironments: [{ name: 'Sub' }] };
expect(ownFields(folder)).toEqual({ name: 'F', meta: { id: 'fld_1' } });
expect(ownFields(env)).toEqual({ name: 'Base', data: { a: 1 } });
});
});
describe('computeFieldChanges - meta', () => {
it('reports each changed meta field under its own path, ignoring volatile ones', () => {
const before = { meta: { id: 'req_1', modified: 1, description: 'same', isPrivate: false } };
const after = { meta: { id: 'req_1', modified: 2, description: 'same', isPrivate: true } };
expect(computeFieldChanges(before, after)).toEqual([
{ path: 'meta.isPrivate', label: 'Is Private', before: false, after: true },
]);
});
});
describe('computeVisualDiff - file-level sections', () => {
const mock = (extra: Record<string, unknown>) =>
stringify({ type: 'mock.insomnia.rest/5.0', name: 'Mock', meta: { id: 'wrk_m' }, ...extra });
it('diffs each file-level section as one whole entity', () => {
const before = mock({ server: { url: 'https://old', useInsomniaCloud: false } });
const after = mock({
name: 'Mock v2',
server: { url: 'https://new', useInsomniaCloud: false },
testSuites: [{ name: 'suite', meta: { id: 'uts_1' }, tests: [] }],
});
const entities = computeVisualDiff(before, after).entities;
expect(entities.map(e => [e.id, e.type, e.status])).toEqual([
['file:workspace', 'workspace', 'modified'],
['file:server', 'mock_server', 'modified'],
['file:testSuites', 'unit_tests', 'added'],
]);
expect(entities[1].fieldChanges.map(c => [c.path, c.before, c.after])).toEqual([
['url', 'https://old', 'https://new'],
]);
});
it('does not treat a non-Insomnia YAML file as a workspace', () => {
expect(
computeVisualDiff(stringify({ name: 'a', rules: {} }), stringify({ name: 'b', rules: {} })).entities,
).toEqual([]);
});
});
describe('diffByKey', () => {
const userAgent = { name: 'User-Agent', value: 'insomnia/12.1.0', disabled: false };
const customUserAgent = { name: 'User-Agent', value: '123', disabled: true };
const accept = { name: 'Accept', value: '*/*' };
const rows = (before: unknown[], after: unknown[]) =>
diffByKey(before as any[], after as any[], 'name').map(({ key, status }) => `${key}: ${status}`);
it('tells same-named items apart by their order, so a change to either one shows', () => {
const changedFirst = { ...userAgent, disabled: true };
const changedSecond = { ...customUserAgent, value: '456' };
expect(rows([userAgent, customUserAgent], [changedFirst, customUserAgent])).toEqual(['User-Agent (1): modified']);
expect(rows([userAgent, customUserAgent], [changedFirst, changedSecond])).toEqual([
'User-Agent (1): modified',
'User-Agent (2): modified',
]);
});
it('numbers a name repeated on only one side on both, so adding or removing the last one reads as such', () => {
expect(rows([userAgent, accept], [userAgent, accept, customUserAgent])).toEqual(['User-Agent (2): added']);
expect(rows([userAgent, customUserAgent], [userAgent])).toEqual(['User-Agent (2): removed']);
});
it('keeps the plain name for items whose name is not repeated', () => {
const before = [userAgent, customUserAgent, accept];
expect(rows(before, [userAgent, customUserAgent, { ...accept, value: 'a' }])).toEqual(['Accept: modified']);
});
});
@@ -0,0 +1,531 @@
import { parse as parseYaml } from 'yaml';
/**
* Visual diff card coverage tracker.
*
* A git-tracked file is a whole Insomnia v5 workspace export (see
* `common/import-v5-parser.ts`), containing many entities in one YAML blob.
* This module walks both sides of the diff, matches entities by `meta.id`,
* and produces one `EntityDiff` per changed/added/removed entity so the UI
* can render a dedicated card per entity instead of one big text diff.
*
* Entities without a dedicated card fall back to `GenericEntityDiffCard`
* (bullet list of raw field changes). Status of dedicated visual cards:
*
* - [x] Request (HTTP) -> RequestDiffCard
* - [x] Environment -> EnvironmentDiffCard
* - [x] Request Group (folder) -> RequestGroupDiffCard
* - [x] WebSocket Request -> WebSocketDiffCard
* - [x] gRPC Request -> GrpcDiffCard
* - [x] Socket.IO Request -> SocketIODiffCard
* - [x] MCP Request -> McpDiffCard
* - [x] Mock Route -> MockRouteDiffCard
* - [x] Cookie Jar -> CookieJarDiffCard
*
* File-level sections (see `FILE_SECTIONS`: workspace identity, mock server,
* API spec, unit tests, CA certificates) are each one whole entity and render
* via `GenericEntityDiffCard`.
*/
// TODO: Bind type from Insomnia v5 entity definitions (eg. `Request`, `Environment`, etc.) rather than hardcoding strings.
// TODO: What if the type changes for an existing entity? How should the diff engine handle it?
export type VisualDiffEntityType =
| 'request'
| 'grpc_request'
| 'websocket_request'
| 'socketio_request'
| 'request_group'
| 'environment'
| 'mock_route'
| 'mcp_request'
| 'cookie_jar'
| 'workspace'
| 'mock_server'
| 'api_spec'
| 'unit_tests'
| 'ca_certificates'
| 'unknown';
// Top-level parts of a file that aren't entity collections. Each is diffed — and
// staged/discarded — as one whole entity (eg. every test suite at once), keyed by
// a fixed id. Together with the entity collections they cover every top-level key.
export const FILE_SECTIONS: readonly { id: string; type: VisualDiffEntityType; keys: readonly string[] }[] = [
{ id: 'file:workspace', type: 'workspace', keys: ['type', 'schema_version', 'name', 'meta'] },
{ id: 'file:server', type: 'mock_server', keys: ['server'] },
{ id: 'file:spec', type: 'api_spec', keys: ['spec'] },
{ id: 'file:testSuites', type: 'unit_tests', keys: ['testSuites'] },
{ id: 'file:certificates', type: 'ca_certificates', keys: ['certificates'] },
];
export type EntityChangeStatus = 'added' | 'removed' | 'modified';
export interface FieldChange {
path: string;
label: string;
before: unknown;
after: unknown;
}
export interface EntityDiff {
id: string;
type: VisualDiffEntityType;
status: EntityChangeStatus;
name: string;
before: any;
after: any;
fieldChanges: FieldChange[];
}
export interface VisualDiffResult {
entities: EntityDiff[];
// True when neither side could be parsed as a recognizable Insomnia v5 file.
unparseable: boolean;
}
interface CollectedEntity {
type: VisualDiffEntityType;
name: string;
node: any;
}
// Recursively sorts object keys (and maps over arrays) so two structurally
// equal values serialize to the same JSON string regardless of key order.
function sortDeep(value: any): any {
if (Array.isArray(value)) {
return value.map(sortDeep);
}
if (value && typeof value === 'object') {
return Object.fromEntries(
Object.keys(value)
.sort()
.map(key => [key, sortDeep(value[key])]),
);
}
return value;
}
// undefined, null and empty string are treated as equivalent so schema defaults don't create noise
function emptyValueReplacer(_key: string, value: any) {
if (value === null || value === '') {
return;
}
return value;
}
// Deep-equality check used throughout the diff engine: key order doesn't
// matter (via sortDeep) and null/undefined/'' are treated as equivalent
// (via emptyValueReplacer).
export function valuesEqual(a: unknown, b: unknown): boolean {
return JSON.stringify(sortDeep(a), emptyValueReplacer) === JSON.stringify(sortDeep(b), emptyValueReplacer);
}
// Strips volatile/structural meta fields (modified/created/sortKey/id) before
// comparing two entities' meta blocks, so only user-visible fields (eg.
// description) are treated as a meaningful change.
function cleanMeta(meta: any) {
if (!meta || typeof meta !== 'object') {
return meta;
}
const { modified, created, sortKey, id, ...rest } = meta;
return rest;
}
// Keys that are structural (handled by entity matching itself) rather than displayable fields
const STRUCTURAL_KEYS = new Set(['children', 'subEnvironments']);
// An entity node minus its nested entities (eg. a folder's `children`), which get their own cards.
export function ownFields(node: any): any {
if (!node || typeof node !== 'object') {
return node;
}
return Object.fromEntries(Object.entries(node).filter(([key]) => !STRUCTURAL_KEYS.has(key)));
}
// Compares two entity nodes field-by-field and returns one FieldChange per
// differing key, skipping structural keys (handled by entity matching) and
// reporting `meta` per sub-field (eg. `meta.description`), minus volatile ones.
export function computeFieldChanges(before: any, after: any): FieldChange[] {
const keys = new Set([...Object.keys(before ?? {}), ...Object.keys(after ?? {})]);
const changes: FieldChange[] = [];
for (const key of keys) {
if (STRUCTURAL_KEYS.has(key)) {
continue;
}
if (key === 'meta') {
const beforeMeta = cleanMeta(before?.meta) ?? {};
const afterMeta = cleanMeta(after?.meta) ?? {};
for (const metaKey of new Set([...Object.keys(beforeMeta), ...Object.keys(afterMeta)])) {
if (!valuesEqual(beforeMeta[metaKey], afterMeta[metaKey])) {
changes.push({
path: `meta.${metaKey}`,
label: humanizeKey(metaKey),
before: beforeMeta[metaKey],
after: afterMeta[metaKey],
});
}
}
continue;
}
const beforeValue = before?.[key];
const afterValue = after?.[key];
if (!valuesEqual(beforeValue, afterValue)) {
changes.push({ path: key, label: humanizeKey(key), before: beforeValue, after: afterValue });
}
}
return changes;
}
// Turns a camelCase/snake_case/kebab-case field name into a human-readable
// label (eg. `pathParameters` -> "Path parameters") for display in a card.
export function humanizeKey(key: string): string {
return key
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.replace(/[_-]+/g, ' ')
.replace(/^./, c => c.toUpperCase());
}
export interface KeyedDiffRow {
key: string;
status: EntityChangeStatus;
before?: unknown;
after?: unknown;
}
// Single status representing a set of rows (eg. for a tab/chip badge): all-added
// or all-removed collapse to that status, anything mixed (or any modified row)
// reads as 'modified'.
export function dominantStatus(statuses: EntityChangeStatus[]): EntityChangeStatus {
const hasAdded = statuses.includes('added');
const hasRemoved = statuses.includes('removed');
const hasModified = statuses.includes('modified');
if (hasModified || (hasAdded && hasRemoved)) {
return 'modified';
}
if (hasAdded) {
return 'added';
}
if (hasRemoved) {
return 'removed';
}
return 'modified';
}
// Status for a single before/after block (eg. a request's body or auth config)
// based only on which side has content — content only on one side reads as
// added/removed even when the entity itself is 'modified'.
export function sideStatus(hasBefore: boolean, hasAfter: boolean): EntityChangeStatus {
if (!hasBefore && hasAfter) {
return 'added';
}
if (hasBefore && !hasAfter) {
return 'removed';
}
return 'modified';
}
// Diffs two arrays of objects by matching a key field (eg. header/parameter `name`) instead of position
export function diffByKey<T extends Record<string, any>>(
before: T[] = [],
after: T[] = [],
keyField: keyof T = 'name' as keyof T,
): KeyedDiffRow[] {
const rows: KeyedDiffRow[] = [];
const keyOf = (item: T, i: number) => String(item?.[keyField] ?? `#${i}`);
const repeatedKeys = new Set([...findRepeatedKeys(before, keyOf), ...findRepeatedKeys(after, keyOf)]);
const beforeMap = mapByKey(before, keyOf, repeatedKeys);
const afterMap = mapByKey(after, keyOf, repeatedKeys);
afterMap.forEach((afterItem, key) => {
const beforeItem = beforeMap.get(key);
if (!beforeItem) {
rows.push({ key, status: 'added', after: afterItem });
} else if (!valuesEqual(beforeItem, afterItem)) {
rows.push({ key, status: 'modified', before: beforeItem, after: afterItem });
}
});
beforeMap.forEach((beforeItem, key) => {
if (!afterMap.has(key)) {
rows.push({ key, status: 'removed', before: beforeItem });
}
});
return rows;
}
function findRepeatedKeys<T>(items: T[] = [], keyOf: (item: T, i: number) => string): Set<string> {
const seen = new Set<string>();
const repeated = new Set<string>();
(items ?? []).forEach((item, i) => {
const key = keyOf(item, i);
(seen.has(key) ? repeated : seen).add(key);
});
return repeated;
}
// Items sharing a key (eg. two `User-Agent` headers) are told apart by their order among that key,
// as "User-Agent (1)" and "User-Agent (2)" — otherwise the later one would hide the earlier one. A
// key repeated on either side is numbered on both, so the first still matches the first when a
// second one is added or removed.
function mapByKey<T>(
items: T[] = [],
keyOf: (item: T, i: number) => string,
repeatedKeys: ReadonlySet<string>,
): Map<string, T> {
const map = new Map<string, T>();
const occurrences = new Map<string, number>();
(items ?? []).forEach((item, i) => {
const key = keyOf(item, i);
if (!repeatedKeys.has(key)) {
map.set(key, item);
return;
}
const occurrence = (occurrences.get(key) ?? 0) + 1;
occurrences.set(key, occurrence);
map.set(`${key} (${occurrence})`, item);
});
return map;
}
// Diffs a plain key/value record (eg. environment `data`) by key
export function diffRecord(before: Record<string, any> = {}, after: Record<string, any> = {}): KeyedDiffRow[] {
const rows: KeyedDiffRow[] = [];
const keys = new Set([...Object.keys(before ?? {}), ...Object.keys(after ?? {})]);
keys.forEach(key => {
const hasBefore = before != null && Object.prototype.hasOwnProperty.call(before, key);
const hasAfter = after != null && Object.prototype.hasOwnProperty.call(after, key);
const beforeValue = before?.[key];
const afterValue = after?.[key];
if (!hasBefore) {
rows.push({ key, status: 'added', after: afterValue });
} else if (!hasAfter) {
rows.push({ key, status: 'removed', before: beforeValue });
} else if (!valuesEqual(beforeValue, afterValue)) {
rows.push({ key, status: 'modified', before: beforeValue, after: afterValue });
}
});
return rows;
}
// Determines a collection tree node's entity type from its `meta.id` prefix
// (eg. `req_` -> request, `fld_` -> folder), falling back to shape-sniffing
// (`children` array / `method` string) if the id is missing or unrecognized.
function classifyCollectionNode(node: any): VisualDiffEntityType {
const id: string = node?.meta?.id ?? '';
if (id.startsWith('ws-req')) {
return 'websocket_request';
}
if (id.startsWith('socketio-req')) {
return 'socketio_request';
}
if (id.startsWith('greq')) {
return 'grpc_request';
}
if (id.startsWith('fld')) {
return 'request_group';
}
if (id.startsWith('req')) {
return 'request';
}
// Fall back to structural shape in case an id is missing/unrecognized
if (Array.isArray(node?.children)) {
return 'request_group';
}
if (typeof node?.method === 'string') {
return 'request';
}
return 'unknown';
}
// Walks a parsed Insomnia v5 file (collection tree, environments, mock
// routes, MCP request, cookie jar) and flattens every entity it finds into a
// single `id -> CollectedEntity` map, so both sides of a diff can be matched
// up by id regardless of where in the tree they live.
function collectEntities(file: any): Map<string, CollectedEntity> {
const map = new Map<string, CollectedEntity>();
if (!file || typeof file !== 'object') {
return map;
}
// Recursively registers a collection-tree node (request/folder/etc.) and its children.
const addCollectionNode = (node: any) => {
if (!node || typeof node !== 'object') {
return;
}
const id = node.meta?.id;
if (id) {
map.set(id, { type: classifyCollectionNode(node), name: node.name || 'Untitled', node });
}
if (Array.isArray(node.children)) {
node.children.forEach(addCollectionNode);
}
};
// Registers a base environment and each of its sub-environments as separate entities.
const addEnvironmentTree = (env: any, fallbackName: string) => {
if (!env || typeof env !== 'object') {
return;
}
const id = env.meta?.id;
if (id) {
map.set(id, { type: 'environment', name: env.name || fallbackName, node: env });
}
(env.subEnvironments ?? []).forEach((sub: any, index: number) => {
const subId = sub?.meta?.id;
if (subId) {
map.set(subId, { type: 'environment', name: sub.name || `Environment ${index}`, node: sub });
}
});
};
if (Array.isArray(file.collection)) {
file.collection.forEach(addCollectionNode);
}
if (file.environments) {
addEnvironmentTree(file.environments, 'Base Environment');
}
if (Array.isArray(file.routes)) {
file.routes.forEach((route: any) => {
const id = route?.meta?.id;
if (id) {
map.set(id, { type: 'mock_route', name: route.name || route.pattern || 'Mock Route', node: route });
}
});
}
if (file.mcpRequest) {
const id = file.mcpRequest.meta?.id;
if (id) {
map.set(id, { type: 'mcp_request', name: file.mcpRequest.name || 'MCP Request', node: file.mcpRequest });
}
}
if (file.cookieJar) {
const id = file.cookieJar.meta?.id ?? 'cookie-jar';
map.set(id, { type: 'cookie_jar', name: file.cookieJar.name || 'Cookie Jar', node: file.cookieJar });
}
// Only real Insomnia files: any other YAML in the repo would otherwise surface as a "Workspace".
if (typeof file.type === 'string' && file.type.includes('insomnia')) {
const workspaceName = file.name || 'Untitled';
for (const section of FILE_SECTIONS) {
if (section.keys.every(key => file[key] === undefined)) {
continue;
}
// Object sections (eg. `server`) are shown field by field; arrays and multi-key sections are
// wrapped so the card reads "Test Suites: old -> new" rather than per-array-index rows.
const single = section.keys.length === 1 ? file[section.keys[0]] : undefined;
const node =
single && typeof single === 'object' && !Array.isArray(single)
? single
: Object.fromEntries(section.keys.filter(key => file[key] !== undefined).map(key => [key, file[key]]));
const name = section.type === 'mock_server' ? file.server?.url || 'Mock Server' : workspaceName;
map.set(section.id, { type: section.type, name, node });
}
}
// TODO: Handle more Insomnia v5 entities in the future
return map;
}
// Parses YAML text into a plain object, swallowing parse errors and
// returning `undefined` instead so a malformed/empty side of the diff
// degrades gracefully rather than throwing.
function safeParseYaml(text: string): any {
if (!text) {
return undefined;
}
try {
return parseYaml(text);
} catch {
return undefined;
}
}
// Entry point of the diff engine: parses both sides of a git-tracked file as
// YAML, collects every entity on each side, matches them up by id, and
// returns the added/removed/modified `EntityDiff` list the UI renders cards
// from.
export function computeVisualDiff(beforeText: string, afterText: string): VisualDiffResult {
const beforeFile = safeParseYaml(beforeText);
const afterFile = safeParseYaml(afterText);
const beforeEntities = collectEntities(beforeFile);
const afterEntities = collectEntities(afterFile);
const entities: EntityDiff[] = [];
const visited = new Set<string>();
afterEntities.forEach((afterEntity, id) => {
visited.add(id);
const beforeEntity = beforeEntities.get(id);
if (!beforeEntity) {
entities.push({
id,
type: afterEntity.type,
status: 'added',
name: afterEntity.name,
before: undefined,
after: afterEntity.node,
fieldChanges: [],
});
return;
}
const fieldChanges = computeFieldChanges(beforeEntity.node, afterEntity.node);
if (fieldChanges.length === 0) {
return;
}
entities.push({
id,
type: afterEntity.type,
status: 'modified',
name: afterEntity.name,
before: beforeEntity.node,
after: afterEntity.node,
fieldChanges,
});
});
beforeEntities.forEach((beforeEntity, id) => {
if (visited.has(id)) {
return;
}
entities.push({
id,
type: beforeEntity.type,
status: 'removed',
name: beforeEntity.name,
before: beforeEntity.node,
after: undefined,
fieldChanges: [],
});
});
// A side with content that doesn't parse (eg. merge-conflict markers) can't be
// reasoned about per entity — every entity would look added/removed, and
// acting on one would rewrite the whole file from a partial view.
const sideUnparseable = (text: string, file: unknown) => Boolean(text) && (file === undefined || file === null || typeof file !== 'object');
return {
entities,
unparseable:
(beforeFile === undefined && afterFile === undefined) ||
sideUnparseable(beforeText, beforeFile) ||
sideUnparseable(afterText, afterFile),
};
}