Skip to content

Commit

Permalink
feat(git): Implement git diff
Browse files Browse the repository at this point in the history
  • Loading branch information
AtkinsSJ authored and KernelDeimos committed Jun 28, 2024
1 parent 49c2f16 commit 622b6a9
Show file tree
Hide file tree
Showing 6 changed files with 570 additions and 0 deletions.
9 changes: 9 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/git/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"dependencies": {
"@pkgjs/parseargs": "^0.11.0",
"buffer": "^6.0.3",
"diff": "^5.2.0",
"isomorphic-git": "^1.25.10"
}
}
128 changes: 128 additions & 0 deletions packages/git/src/diff.js
Original file line number Diff line number Diff line change
@@ -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,
};
},
});
};
234 changes: 234 additions & 0 deletions packages/git/src/format.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Loading

0 comments on commit 622b6a9

Please sign in to comment.