blob: 80d83ea0270e03f8e3652c3198593b7ebde76909 [file] [log] [blame] [edit]
/* This file is a part of @mdn/browser-compat-data
* See LICENSE file for more information. */
import chalk from 'chalk-template';
import deepDiff, { Diff } from 'deep-diff';
import esMain from 'es-main';
import yargs from 'yargs';
import { hideBin } from 'yargs/helpers';
import { query } from '../utils/index.js';
import { SupportStatement, Identifier, BrowserName } from '../types/types.js';
import { getMergeBase, getFileContent, getGitDiffStatuses } from './lib/git.js';
import mirror from './build/mirror.js';
interface Contents {
base: string;
head: string;
}
interface DiffItem {
name: string;
description: string;
}
/**
* Get contents from base and head commits
* Note: This does not detect renamed files
* @param baseCommit Base commit
* @param basePath Base path
* @param headCommit Head commit
* @param headPath Head path
* @returns The contents of both commits
*/
const getBaseAndHeadContents = (
baseCommit: string,
basePath: string,
headCommit: string,
headPath: string,
): Contents => {
const base = JSON.parse(getFileContent(baseCommit, basePath));
const head = JSON.parse(getFileContent(headCommit, headPath));
return { base, head };
};
/**
* Returns a formatted string of before-and-after changes
* @param lhs Left-hand (before) side
* @param rhs Right-hand (after) side
* @returns Formatted string
*/
const stringifyChange = (lhs: any, rhs: any): string =>
`${JSON.stringify(lhs)} → ${JSON.stringify(rhs)}`;
/**
* Perform mirroring on specified diff statement
* @param diff The diff to perform mirroring on
* @param diff.base The diff to perform mirroring on
* @param diff.head The diff to perform mirroring on
* @param contents The contents to mirror from
* @param contents.base The contents to mirror from
* @param contents.head The contents to mirror from
* @param path The feature path to mirror
* @param direction Whether to mirror 'base' or 'head'
*/
const doMirror = (
diff: { base: SupportStatement; head: SupportStatement },
contents: { base: Identifier; head: Identifier },
path: string[],
direction: 'base' | 'head',
): void => {
const browser = path[path.length - 1] as BrowserName;
const dataPath = path.slice(0, path.length - 3).join('.');
const data = contents[direction];
diff[direction] = mirror(browser, query(dataPath, data).__compat.support);
};
/**
* Describe the diff in text form (internal function)
* @param diffItem The diff to describe
* @param contents The contents of the diff
* @returns A human-readable diff description
*/
const describeByKind = (
diffItem: Diff<string, string>,
contents: Contents,
): string => {
const diff = { base: (diffItem as any).lhs, head: (diffItem as any).rhs };
// Handle mirroring
let doesMirror = '';
if (diff.base === 'mirror') {
doesMirror = 'No longer mirrors';
doMirror(
diff,
contents as any as { base: Identifier; head: Identifier },
diffItem.path as string[],
'base',
);
} else if (diff.head === 'mirror') {
doesMirror = 'Now mirrors';
doMirror(
diff,
contents as any as { base: Identifier; head: Identifier },
diffItem.path as string[],
'head',
);
}
switch (diffItem.kind) {
case 'N':
return 'added';
case 'D':
return 'deleted';
case 'A':
case 'E':
return `edited (${stringifyChange(diff.base, diff.head)})${
doesMirror && ` - ${doesMirror}`
}`;
default:
return '';
}
};
/**
* Describe the diff in text form
* @param diffItem The diff to describe
* @param contents The contents of the diff
* @returns A human-readable diff description
*/
const describeDiffItem = (
diffItem: Diff<string, string>,
contents: Contents,
): DiffItem => {
const path = (diffItem.path as string[]).join('.');
if (path.includes('.__compat.')) {
const [name, member] = path.split('.__compat.');
if (path.endsWith('.notes') && diffItem.kind === 'E') {
return { name, description: `${member} is edited (prose change)` };
}
return {
name,
description: `${member} is ${describeByKind(diffItem, contents)}`,
};
}
return {
name: (diffItem.path as string[]).slice(0, -1).join('.'),
description: `${path} is ${describeByKind(diffItem, contents)}`,
};
};
/**
* Merge diff together as a map
* @param items Diff items to merge
* @returns A map of the diff items
*/
const mergeAsMap = (items: DiffItem[]): Map<string, string> => {
const map = new Map();
for (const item of items) {
const descriptions = map.get(item.name) || [];
descriptions.push(item.description);
map.set(item.name, descriptions);
}
return map;
};
/**
* Get the diffs as a map
* @param base Base ref
* @param head Head ref
* @returns A map of the diff items
*/
const getDiffs = (base: string, head = ''): Map<string, string> => {
const namedDescriptions: { name: string; description: string }[] = [];
for (const status of getGitDiffStatuses(base, head)) {
if (!status.headPath.endsWith('.json') || !status.headPath.includes('/')) {
continue;
}
// Note that A means Added for git while it means Array for deep-diff
if (status.value === 'A' || status.value === 'D') {
namedDescriptions.push({
name: status.basePath.replace(/\//g, '.').slice(0, -5), // trim file extension
description: status.value === 'A' ? 'Newly added' : 'Entirely removed',
});
} else {
const contents = getBaseAndHeadContents(
base,
status.basePath,
head,
status.headPath,
);
const diff = deepDiff.diff(contents.base, contents.head);
if (diff) {
namedDescriptions.push(
...diff.map((item) => describeDiffItem(item, contents)),
);
}
}
}
return mergeAsMap(namedDescriptions);
};
if (esMain(import.meta)) {
const { argv } = yargs(hideBin(process.argv)).command(
'$0 [base] [head]',
'Print a formatted diff for changes between base and head commits',
(yargs) => {
yargs
.positional('base', {
describe:
'The base commit; may be commit hash or other git ref (e.g. "origin/main")',
type: 'string',
default: 'origin/main',
})
.positional('head', {
describe:
'The head commit that changes are applied to; may be commit hash or other git ref (e.g. "origin/main")',
type: 'string',
default: 'HEAD',
});
},
);
const { base, head } = argv as any;
for (const [key, values] of getDiffs(getMergeBase(base, head), head)) {
console.log(chalk`{bold ${key}}:`);
for (const value of values) {
console.log(` → ${value}`);
}
}
}