feat(git): Implement git diff

This commit is contained in:
Sam Atkins 2024-06-07 16:29:36 +01:00 committed by Eric Dubé
parent 49c2f16351
commit 622b6a9b92
6 changed files with 570 additions and 0 deletions

9
package-lock.json generated
View File

@ -12130,6 +12130,7 @@
"dependencies": {
"@pkgjs/parseargs": "^0.11.0",
"buffer": "^6.0.3",
"diff": "^5.2.0",
"isomorphic-git": "^1.25.10"
},
"devDependencies": {
@ -12164,6 +12165,14 @@
"ieee754": "^1.2.1"
}
},
"packages/git/node_modules/diff": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/diff/-/diff-5.2.0.tgz",
"integrity": "sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==",
"engines": {
"node": ">=0.3.1"
}
},
"packages/git/node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",

View File

@ -20,6 +20,7 @@
"dependencies": {
"@pkgjs/parseargs": "^0.11.0",
"buffer": "^6.0.3",
"diff": "^5.2.0",
"isomorphic-git": "^1.25.10"
}
}

128
packages/git/src/diff.js Normal file
View File

@ -0,0 +1,128 @@
/*
* Copyright (C) 2024 Puter Technologies Inc.
*
* This file is part of Puter's Git client.
*
* Puter's Git client is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import * as Diff from 'diff';
import git from 'isomorphic-git';
import path from 'path-browserify';
/**
* Produce an array of diffs from two git tree.
* @param fs
* @param dir
* @param gitdir
* @param cache
* @param env
* @param a_tree A walker object for the left comparison, usually a TREE(), STAGE() or WORKDIR()
* @param b_tree A walker object for the right comparison, usually a TREE(), STAGE() or WORKDIR()
* @param read_a Callback run to extract the data from each file in a
* @param read_b Callback run to extract the data from each file in b
* @param context_lines Number of context lines to include in diff
* @param path_filters Array of strings to filter which files to include
* @returns {Promise<any>} An array of diff objects, suitable for passing to format_diffs()
*/
export const diff_git_trees = ({
fs,
dir,
gitdir,
cache,
env,
a_tree,
b_tree,
read_a,
read_b,
context_lines = 3,
path_filters = [],
}) => {
return git.walk({
fs,
dir,
gitdir,
cache,
trees: [ a_tree, b_tree ],
map: async (filepath, [ a, b ]) => {
// Reject paths that don't match path_filters.
// Or if path_filters is empty, match everything.
const abs_filepath = path.resolve(env.PWD, filepath);
if (path_filters.length > 0 && !path_filters.some(abs_path =>
(filepath === '.') || (abs_filepath.startsWith(abs_path)) || (path.dirname(abs_filepath) === abs_path),
)) {
return null;
}
if (await git.isIgnored({ fs, dir, gitdir, filepath }))
return null;
const [ a_type, b_type ] = await Promise.all([ a?.type(), b?.type() ]);
// Exclude directories from results
if ((!a_type || a_type === 'tree') && (!b_type || b_type === 'tree'))
return;
const [
a_content,
a_oid,
a_mode,
b_content,
b_oid,
b_mode,
] = await Promise.all([
read_a(a),
a?.oid() ?? '00000000',
a?.mode(),
read_b(b),
b?.oid() ?? '00000000',
b?.mode(),
]);
const diff = Diff.structuredPatch(
a_content !== undefined ? filepath : '/dev/null',
b_content !== undefined ? filepath : '/dev/null',
a_content ?? '',
b_content ?? '',
undefined,
undefined,
{
context: context_lines,
newlineIsToken: true,
});
// Diffs with no changes lines, but a changed mode, still need to show up.
if (diff.hunks.length === 0 && a_mode === b_mode)
return;
const mode_string = (mode) => {
if (!mode)
return '000000';
return Number(mode).toString(8);
};
return {
a: {
oid: a_oid,
mode: mode_string(a_mode),
},
b: {
oid: b_oid,
mode: mode_string(b_mode),
},
diff,
};
},
});
};

View File

@ -212,3 +212,237 @@ export const format_tag = (tag, options = {}) => {
}
return s;
}
export const diff_formatting_options = {
'patch': {
description: 'Generate a patch.',
type: 'boolean',
short: 'p',
},
'no-patch': {
description: 'Suppress patch output. Useful for commands that output a patch by default.',
type: 'boolean',
short: 's',
},
'raw': {
description: 'Generate diff in raw format.',
type: 'boolean',
},
'patch-with-raw': {
description: 'Alias for --patch --raw.',
type: 'boolean',
},
'numstat': {
description: 'Generate a diffstat in a machine-friendly format.',
type: 'boolean',
},
'summary': {
description: 'List newly added, deleted, or moved files.',
type: 'boolean',
},
'unified': {
description: 'Generate patches with N lines of context. Implies --patch.',
type: 'string',
short: 'U',
},
'src-prefix': {
description: 'Show the given source prefix instead of "a/".',
type: 'string',
},
'dst-prefix': {
description: 'Show the given destination prefix instead of "b/".',
type: 'string',
},
'no-prefix': {
description: 'Do not show source or destination prefixes.',
type: 'boolean',
},
'default-prefix': {
description: 'Use default "a/" and "b/" source and destination prefixes.',
type: 'boolean',
},
};
/**
* Process command-line options related to diff formatting, and return an options object to pass to format_diff().
* @param options Parsed command-line options.
* @returns {{raw: boolean, numstat: boolean, summary: boolean, patch: boolean, context_lines: number, no_patch: boolean, source_prefix: string, dest_prefix: string }}
*/
export const process_diff_formatting_options = (options) => {
const result = {
raw: false,
numstat: false,
summary: false,
patch: false,
context_lines: 3,
no_patch: false,
source_prefix: 'a/',
dest_prefix: 'b/',
};
if (options['raw'])
result.raw = true;
if (options['numstat'])
result.numstat = true;
if (options['summary'])
result.summary = true;
if (options['patch'])
result.patch = true;
if (options['patch-with-raw']) {
result.patch = true;
result.raw = true;
}
if (options['unified'] !== undefined) {
result.patch = true;
result.context_lines = options['unified'];
}
// Prefixes
if (options['src-prefix'])
result.source_prefix = options['src-prefix'];
if (options['dst-prefix'])
result.dest_prefix = options['dst-prefix'];
if (options['default-prefix']) {
result.source_prefix = 'a/';
result.dest_prefix = 'b/';
}
if (options['no-prefix']) {
result.source_prefix = '';
result.dest_prefix = '';
}
// If nothing is specified, default to --patch
if (!result.raw && !result.numstat && !result.summary && !result.patch)
result.patch = true;
// --no-patch overrides the others
if (options['no-patch'])
result.no_patch = true;
return result;
}
/**
* Produce a string representation of the given diffs.
* @param diffs A single object, or array of them, in the format:
* {
* a: { mode, oid },
* b: { mode, oid },
* diff: object returned by Diff.structuredPatch() - see https://www.npmjs.com/package/diff
* }
* @param options Object returned by process_diff_formatting_options()
* @returns {string}
*/
export const format_diffs = (diffs, options) => {
if (!(diffs instanceof Array))
diffs = [diffs];
let s = '';
if (options.raw) {
// https://git-scm.com/docs/diff-format#_raw_output_format
for (const { a, b, diff } of diffs) {
s += `:${a.mode} ${b.mode} ${shorten_hash(a.oid)} ${shorten_hash(b.oid)} `;
// Status. For now, we just support A/D/M
if (a.mode === '000000') {
s += 'A'; // Added
} else if (b.mode === '000000') {
s += 'D'; // Deleted
} else {
s += 'M'; // Modified
}
// TODO: -z option
s += `\t${diff.oldFileName}\n`;
}
s += '\n';
}
if (options.numstat) {
// https://git-scm.com/docs/diff-format#_other_diff_formats
for (const { a, b, diff } of diffs) {
const { added_lines, deleted_lines } = diff.hunks.reduce((acc, hunk) => {
const first_char_counts = hunk.lines.reduce((acc, line) => {
acc[line[0]] = (acc[line[0]] || 0) + 1;
return acc;
}, {});
acc.added_lines += first_char_counts['+'] || 0;
acc.deleted_lines += first_char_counts['-'] || 0;
return acc;
}, { added_lines: 0, deleted_lines: 0 });
// TODO: -z option
s += `${added_lines}\t${deleted_lines}\t`;
if (diff.oldFileName === diff.newFileName) {
s += `${diff.oldFileName}\n`;
} else {
s += `${diff.oldFileName} => ${diff.newFileName}\n`;
}
}
}
// TODO: --stat / --compact-summary
if (options.summary) {
// https://git-scm.com/docs/diff-format#_other_diff_formats
for (const { a, b, diff } of diffs) {
if (diff.oldFileName === diff.newFileName)
continue;
if (diff.oldFileName === '/dev/null') {
s += `create mode ${b.mode} ${diff.newFileName}\n`;
} else if (diff.newFileName === '/dev/null') {
s += `delete mode ${a.mode} ${diff.oldFileName}\n`;
} else {
// TODO: Abbreviate shared parts of path - see git manual link above.
s += `rename ${diff.oldFileName} => ${diff.newFileName}\n`;
}
}
}
if (options.patch) {
for (const { a, b, diff } of diffs) {
const a_path = diff.oldFileName.startsWith('/') ? diff.oldFileName : `${options.source_prefix}${diff.oldFileName}`;
const b_path = diff.newFileName.startsWith('/') ? diff.newFileName : `${options.dest_prefix}${diff.newFileName}`;
// NOTE: This first line shows `a/$newFileName` for files that are new, not `/dev/null`.
const first_line_a_path = a_path !== '/dev/null' ? a_path : `${options.source_prefix}${diff.newFileName}`;
s += `diff --git ${first_line_a_path} ${b_path}\n`;
if (a.mode === b.mode) {
s += `index ${shorten_hash(a.oid)}..${shorten_hash(b.oid)} ${a.mode}`;
} else {
if (a.mode === '000000') {
s += `new file mode ${b.mode}\n`;
} else {
s += `old mode ${a.mode}\n`;
s += `new mode ${b.mode}\n`;
}
s += `index ${shorten_hash(a.oid)}..${shorten_hash(b.oid)}\n`;
}
if (!diff.hunks.length)
continue;
s += `--- ${a_path}\n`;
s += `+++ ${b_path}\n`;
for (const hunk of diff.hunks) {
s += `\x1b[36;1m@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@\x1b[0m\n`;
for (const line of hunk.lines) {
switch (line[0]) {
case '+':
s += `\x1b[32;1m${line}\x1b[0m\n`;
break;
case '-':
s += `\x1b[31;1m${line}\x1b[0m\n`;
break;
default:
s += `${line}\n`;
break;
}
}
}
}
}
return s;
}

View File

@ -23,6 +23,7 @@ import module_checkout from './checkout.js'
import module_clone from './clone.js'
import module_commit from './commit.js'
import module_config from './config.js'
import module_diff from './diff.js'
import module_fetch from './fetch.js'
import module_help from './help.js'
import module_init from './init.js'
@ -40,6 +41,7 @@ export default {
"clone": module_clone,
"commit": module_commit,
"config": module_config,
"diff": module_diff,
"fetch": module_fetch,
"help": module_help,
"init": module_init,

View File

@ -0,0 +1,196 @@
/*
* Copyright (C) 2024 Puter Technologies Inc.
*
* This file is part of Puter's Git client.
*
* Puter's Git client is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import git, { STAGE, TREE, WORKDIR } from 'isomorphic-git';
import { find_repo_root, group_positional_arguments } from '../git-helpers.js';
import { SHOW_USAGE } from '../help.js';
import * as Diff from 'diff';
import path from 'path-browserify';
import { diff_formatting_options, format_diffs, process_diff_formatting_options } from '../format.js';
import { diff_git_trees } from '../diff.js';
export default {
name: 'diff',
usage: [
'git diff [<options>] [--] [<path>...]',
'git diff [<options>] --cached [--] [<path>...]',
'git diff [<options>] <commit> [--] [<path>...]',
'git diff [<options>] <commit> <commit> [--] [<path>...]',
'git diff [<options>] --no-index [--] <path> <path>',
],
description: `Show changes between commits, the working tree, and elsewhere.`,
args: {
allowPositionals: true,
tokens: true,
options: {
...diff_formatting_options,
'cached': {
description: 'Show changes staged for commit.',
type: 'boolean',
},
'exit-code': {
description: 'Exit with 1 if there are differences, or 0 if there are no differences.',
type: 'boolean',
},
'staged': {
description: 'Alias for --cached.',
type: 'boolean',
},
'no-index': {
description: 'Compare files, ignoring git.',
type: 'boolean',
},
},
},
execute: async (ctx) => {
const { io, fs, env, args } = ctx;
const { stdout, stderr } = io;
const { options, positionals, tokens } = args;
const cache = {};
const diff_options = process_diff_formatting_options(options);
if (diff_options.no_patch && !options['exit-code'])
return;
if (options['staged']) {
options['cached'] = true;
delete options['staged'];
}
if (options['cached'] && options['no-index']) {
stderr('error: --cached and --no-index are mutually exclusive.');
throw SHOW_USAGE;
}
if (options['no-index']) {
if (positionals.length !== 2) {
stderr('error: git diff --no-index expects exactly 2 file paths to compare.');
throw SHOW_USAGE;
}
const [ a_rel_path, b_rel_path ] = positionals;
const a_path = path.resolve(env.PWD, a_rel_path);
const b_path = path.resolve(env.PWD, b_rel_path);
const [ a_source, b_source, a_stat, b_stat ] = await Promise.all([
fs.promises.readFile(a_path, { encoding: 'utf8' }),
fs.promises.readFile(b_path, { encoding: 'utf8' }),
fs.promises.stat(a_path),
fs.promises.stat(b_path),
]);
const diff = Diff.structuredPatch(a_rel_path, b_rel_path, a_source, b_source, undefined, undefined, {
context: diff_options.context_lines,
newlineIsToken: true,
});
// Git mode format is, in octal:
// 2 digits for the type of file
// a 0
// 3 digits for the permissions
const mode_string = (stat) => {
return (stat.isSymbolicLink() ? '12' : '10') + '0' + Number(a_stat.mode).toString(8);
}
const a = { path: a_rel_path, oid: '00000000', mode: mode_string(a_stat) };
const b = { path: b_rel_path, oid: '00000000', mode: mode_string(b_stat) };
stdout(format_diffs({ a, b, diff }, diff_options));
return;
}
const { dir, gitdir } = await find_repo_root(fs, env.PWD);
// TODO: Canonical git is more permissive about requiring `--` before the file paths, when it's unambiguous.
const { before: commit_args, after: path_args } = group_positional_arguments(tokens);
// Ensure all commit_args are commit references
const resolved_commit_refs = await Promise.allSettled(commit_args.map(commit_arg => {
return git.resolveRef({ fs, dir, gitdir, ref: commit_arg });
}));
for (const [i, ref] of resolved_commit_refs.entries()) {
if (ref.status === 'rejected')
throw new Error(`bad revision '${commit_args[i]}'`);
}
const path_filters = path_args.map(it => path.resolve(env.PWD, it));
const diff_ctx = {
fs, dir, gitdir, cache, env,
context_lines: diff_options.context_lines,
path_filters,
};
const read_tree = walker => walker?.content()?.then(it => new TextDecoder().decode(it));
const read_staged = walker => walker?.oid()
?.then(oid => git.readBlob({ fs, dir, gitdir, oid, cache }))
.then(it => new TextDecoder().decode(it.blob));
let diffs = [];
if (options['cached']) {
if (commit_args.length > 1) {
stderr('error: Too many <commit>s passed to `git diff --cached`. Up to 1 is allowed.');
throw SHOW_USAGE;
}
// Show staged changes
diffs = await diff_git_trees({
...diff_ctx,
a_tree: TREE({ ref: commit_args[0] ?? 'HEAD' }),
b_tree: STAGE(),
read_a: read_tree,
read_b: read_staged,
});
} else if (commit_args.length === 0) {
// Show unstaged changes
diffs = await diff_git_trees({
...diff_ctx,
a_tree: STAGE(),
b_tree: WORKDIR(),
read_a: read_staged,
read_b: read_tree,
});
} else if (commit_args.length === 1) {
// Changes from commit to workdir
diffs = await diff_git_trees({
...diff_ctx,
a_tree: TREE({ ref: commit_args[0] }),
b_tree: WORKDIR(),
read_a: read_tree,
read_b: read_tree,
});
} else if (commit_args.length === 2) {
// Changes from one commit to another
diffs = await diff_git_trees({
...diff_ctx,
a_tree: TREE({ ref: commit_args[0] }),
b_tree: TREE({ ref: commit_args[1] }),
read_a: read_tree,
read_b: read_tree,
});
} else {
// TODO: Canonical git supports >2 <commit>s for merge commits.
stderr('error: Too many <commit>s passed to `git diff`. Up to 2 are supported.');
throw SHOW_USAGE;
}
if (!diff_options.no_patch)
stdout(format_diffs(diffs, diff_options));
if (options['exit-code'])
return diffs.length > 0 ? 1 : 0;
}
}