Skip to content

Commit 622b6a9

Browse files
AtkinsSJKernelDeimos
authored andcommitted
feat(git): Implement git diff
1 parent 49c2f16 commit 622b6a9

File tree

6 files changed

+570
-0
lines changed

6 files changed

+570
-0
lines changed

package-lock.json

+9
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/git/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
"dependencies": {
2121
"@pkgjs/parseargs": "^0.11.0",
2222
"buffer": "^6.0.3",
23+
"diff": "^5.2.0",
2324
"isomorphic-git": "^1.25.10"
2425
}
2526
}

packages/git/src/diff.js

+128
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
/*
2+
* Copyright (C) 2024 Puter Technologies Inc.
3+
*
4+
* This file is part of Puter's Git client.
5+
*
6+
* Puter's Git client is free software: you can redistribute it and/or modify
7+
* it under the terms of the GNU Affero General Public License as published
8+
* by the Free Software Foundation, either version 3 of the License, or
9+
* (at your option) any later version.
10+
*
11+
* This program is distributed in the hope that it will be useful,
12+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14+
* GNU Affero General Public License for more details.
15+
*
16+
* You should have received a copy of the GNU Affero General Public License
17+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
18+
*/
19+
import * as Diff from 'diff';
20+
import git from 'isomorphic-git';
21+
import path from 'path-browserify';
22+
23+
/**
24+
* Produce an array of diffs from two git tree.
25+
* @param fs
26+
* @param dir
27+
* @param gitdir
28+
* @param cache
29+
* @param env
30+
* @param a_tree A walker object for the left comparison, usually a TREE(), STAGE() or WORKDIR()
31+
* @param b_tree A walker object for the right comparison, usually a TREE(), STAGE() or WORKDIR()
32+
* @param read_a Callback run to extract the data from each file in a
33+
* @param read_b Callback run to extract the data from each file in b
34+
* @param context_lines Number of context lines to include in diff
35+
* @param path_filters Array of strings to filter which files to include
36+
* @returns {Promise<any>} An array of diff objects, suitable for passing to format_diffs()
37+
*/
38+
export const diff_git_trees = ({
39+
fs,
40+
dir,
41+
gitdir,
42+
cache,
43+
env,
44+
a_tree,
45+
b_tree,
46+
read_a,
47+
read_b,
48+
context_lines = 3,
49+
path_filters = [],
50+
}) => {
51+
return git.walk({
52+
fs,
53+
dir,
54+
gitdir,
55+
cache,
56+
trees: [ a_tree, b_tree ],
57+
map: async (filepath, [ a, b ]) => {
58+
59+
// Reject paths that don't match path_filters.
60+
// Or if path_filters is empty, match everything.
61+
const abs_filepath = path.resolve(env.PWD, filepath);
62+
if (path_filters.length > 0 && !path_filters.some(abs_path =>
63+
(filepath === '.') || (abs_filepath.startsWith(abs_path)) || (path.dirname(abs_filepath) === abs_path),
64+
)) {
65+
return null;
66+
}
67+
68+
if (await git.isIgnored({ fs, dir, gitdir, filepath }))
69+
return null;
70+
71+
const [ a_type, b_type ] = await Promise.all([ a?.type(), b?.type() ]);
72+
73+
// Exclude directories from results
74+
if ((!a_type || a_type === 'tree') && (!b_type || b_type === 'tree'))
75+
return;
76+
77+
const [
78+
a_content,
79+
a_oid,
80+
a_mode,
81+
b_content,
82+
b_oid,
83+
b_mode,
84+
] = await Promise.all([
85+
read_a(a),
86+
a?.oid() ?? '00000000',
87+
a?.mode(),
88+
read_b(b),
89+
b?.oid() ?? '00000000',
90+
b?.mode(),
91+
]);
92+
93+
const diff = Diff.structuredPatch(
94+
a_content !== undefined ? filepath : '/dev/null',
95+
b_content !== undefined ? filepath : '/dev/null',
96+
a_content ?? '',
97+
b_content ?? '',
98+
undefined,
99+
undefined,
100+
{
101+
context: context_lines,
102+
newlineIsToken: true,
103+
});
104+
105+
// Diffs with no changes lines, but a changed mode, still need to show up.
106+
if (diff.hunks.length === 0 && a_mode === b_mode)
107+
return;
108+
109+
const mode_string = (mode) => {
110+
if (!mode)
111+
return '000000';
112+
return Number(mode).toString(8);
113+
};
114+
115+
return {
116+
a: {
117+
oid: a_oid,
118+
mode: mode_string(a_mode),
119+
},
120+
b: {
121+
oid: b_oid,
122+
mode: mode_string(b_mode),
123+
},
124+
diff,
125+
};
126+
},
127+
});
128+
};

packages/git/src/format.js

+234
Original file line numberDiff line numberDiff line change
@@ -212,3 +212,237 @@ export const format_tag = (tag, options = {}) => {
212212
}
213213
return s;
214214
}
215+
216+
export const diff_formatting_options = {
217+
'patch': {
218+
description: 'Generate a patch.',
219+
type: 'boolean',
220+
short: 'p',
221+
},
222+
'no-patch': {
223+
description: 'Suppress patch output. Useful for commands that output a patch by default.',
224+
type: 'boolean',
225+
short: 's',
226+
},
227+
'raw': {
228+
description: 'Generate diff in raw format.',
229+
type: 'boolean',
230+
},
231+
'patch-with-raw': {
232+
description: 'Alias for --patch --raw.',
233+
type: 'boolean',
234+
},
235+
'numstat': {
236+
description: 'Generate a diffstat in a machine-friendly format.',
237+
type: 'boolean',
238+
},
239+
'summary': {
240+
description: 'List newly added, deleted, or moved files.',
241+
type: 'boolean',
242+
},
243+
'unified': {
244+
description: 'Generate patches with N lines of context. Implies --patch.',
245+
type: 'string',
246+
short: 'U',
247+
},
248+
'src-prefix': {
249+
description: 'Show the given source prefix instead of "a/".',
250+
type: 'string',
251+
},
252+
'dst-prefix': {
253+
description: 'Show the given destination prefix instead of "b/".',
254+
type: 'string',
255+
},
256+
'no-prefix': {
257+
description: 'Do not show source or destination prefixes.',
258+
type: 'boolean',
259+
},
260+
'default-prefix': {
261+
description: 'Use default "a/" and "b/" source and destination prefixes.',
262+
type: 'boolean',
263+
},
264+
};
265+
266+
/**
267+
* Process command-line options related to diff formatting, and return an options object to pass to format_diff().
268+
* @param options Parsed command-line options.
269+
* @returns {{raw: boolean, numstat: boolean, summary: boolean, patch: boolean, context_lines: number, no_patch: boolean, source_prefix: string, dest_prefix: string }}
270+
*/
271+
export const process_diff_formatting_options = (options) => {
272+
const result = {
273+
raw: false,
274+
numstat: false,
275+
summary: false,
276+
patch: false,
277+
context_lines: 3,
278+
no_patch: false,
279+
source_prefix: 'a/',
280+
dest_prefix: 'b/',
281+
};
282+
283+
if (options['raw'])
284+
result.raw = true;
285+
if (options['numstat'])
286+
result.numstat = true;
287+
if (options['summary'])
288+
result.summary = true;
289+
if (options['patch'])
290+
result.patch = true;
291+
if (options['patch-with-raw']) {
292+
result.patch = true;
293+
result.raw = true;
294+
}
295+
if (options['unified'] !== undefined) {
296+
result.patch = true;
297+
result.context_lines = options['unified'];
298+
}
299+
300+
// Prefixes
301+
if (options['src-prefix'])
302+
result.source_prefix = options['src-prefix'];
303+
if (options['dst-prefix'])
304+
result.dest_prefix = options['dst-prefix'];
305+
if (options['default-prefix']) {
306+
result.source_prefix = 'a/';
307+
result.dest_prefix = 'b/';
308+
}
309+
if (options['no-prefix']) {
310+
result.source_prefix = '';
311+
result.dest_prefix = '';
312+
}
313+
314+
// If nothing is specified, default to --patch
315+
if (!result.raw && !result.numstat && !result.summary && !result.patch)
316+
result.patch = true;
317+
318+
// --no-patch overrides the others
319+
if (options['no-patch'])
320+
result.no_patch = true;
321+
322+
return result;
323+
}
324+
325+
/**
326+
* Produce a string representation of the given diffs.
327+
* @param diffs A single object, or array of them, in the format:
328+
* {
329+
* a: { mode, oid },
330+
* b: { mode, oid },
331+
* diff: object returned by Diff.structuredPatch() - see https://www.npmjs.com/package/diff
332+
* }
333+
* @param options Object returned by process_diff_formatting_options()
334+
* @returns {string}
335+
*/
336+
export const format_diffs = (diffs, options) => {
337+
if (!(diffs instanceof Array))
338+
diffs = [diffs];
339+
340+
let s = '';
341+
if (options.raw) {
342+
// https://git-scm.com/docs/diff-format#_raw_output_format
343+
for (const { a, b, diff } of diffs) {
344+
s += `:${a.mode} ${b.mode} ${shorten_hash(a.oid)} ${shorten_hash(b.oid)} `;
345+
// Status. For now, we just support A/D/M
346+
if (a.mode === '000000') {
347+
s += 'A'; // Added
348+
} else if (b.mode === '000000') {
349+
s += 'D'; // Deleted
350+
} else {
351+
s += 'M'; // Modified
352+
}
353+
// TODO: -z option
354+
s += `\t${diff.oldFileName}\n`;
355+
}
356+
s += '\n';
357+
}
358+
359+
if (options.numstat) {
360+
// https://git-scm.com/docs/diff-format#_other_diff_formats
361+
for (const { a, b, diff } of diffs) {
362+
const { added_lines, deleted_lines } = diff.hunks.reduce((acc, hunk) => {
363+
const first_char_counts = hunk.lines.reduce((acc, line) => {
364+
acc[line[0]] = (acc[line[0]] || 0) + 1;
365+
return acc;
366+
}, {});
367+
acc.added_lines += first_char_counts['+'] || 0;
368+
acc.deleted_lines += first_char_counts['-'] || 0;
369+
return acc;
370+
}, { added_lines: 0, deleted_lines: 0 });
371+
372+
// TODO: -z option
373+
s += `${added_lines}\t${deleted_lines}\t`;
374+
if (diff.oldFileName === diff.newFileName) {
375+
s += `${diff.oldFileName}\n`;
376+
} else {
377+
s += `${diff.oldFileName} => ${diff.newFileName}\n`;
378+
}
379+
}
380+
}
381+
382+
// TODO: --stat / --compact-summary
383+
384+
if (options.summary) {
385+
// https://git-scm.com/docs/diff-format#_other_diff_formats
386+
for (const { a, b, diff } of diffs) {
387+
if (diff.oldFileName === diff.newFileName)
388+
continue;
389+
390+
if (diff.oldFileName === '/dev/null') {
391+
s += `create mode ${b.mode} ${diff.newFileName}\n`;
392+
} else if (diff.newFileName === '/dev/null') {
393+
s += `delete mode ${a.mode} ${diff.oldFileName}\n`;
394+
} else {
395+
// TODO: Abbreviate shared parts of path - see git manual link above.
396+
s += `rename ${diff.oldFileName} => ${diff.newFileName}\n`;
397+
}
398+
}
399+
}
400+
401+
if (options.patch) {
402+
for (const { a, b, diff } of diffs) {
403+
const a_path = diff.oldFileName.startsWith('/') ? diff.oldFileName : `${options.source_prefix}${diff.oldFileName}`;
404+
const b_path = diff.newFileName.startsWith('/') ? diff.newFileName : `${options.dest_prefix}${diff.newFileName}`;
405+
406+
// NOTE: This first line shows `a/$newFileName` for files that are new, not `/dev/null`.
407+
const first_line_a_path = a_path !== '/dev/null' ? a_path : `${options.source_prefix}${diff.newFileName}`;
408+
s += `diff --git ${first_line_a_path} ${b_path}\n`;
409+
if (a.mode === b.mode) {
410+
s += `index ${shorten_hash(a.oid)}..${shorten_hash(b.oid)} ${a.mode}`;
411+
} else {
412+
if (a.mode === '000000') {
413+
s += `new file mode ${b.mode}\n`;
414+
} else {
415+
s += `old mode ${a.mode}\n`;
416+
s += `new mode ${b.mode}\n`;
417+
}
418+
s += `index ${shorten_hash(a.oid)}..${shorten_hash(b.oid)}\n`;
419+
}
420+
if (!diff.hunks.length)
421+
continue;
422+
423+
s += `--- ${a_path}\n`;
424+
s += `+++ ${b_path}\n`;
425+
426+
for (const hunk of diff.hunks) {
427+
s += `\x1b[36;1m@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@\x1b[0m\n`;
428+
429+
for (const line of hunk.lines) {
430+
switch (line[0]) {
431+
case '+':
432+
s += `\x1b[32;1m${line}\x1b[0m\n`;
433+
break;
434+
case '-':
435+
s += `\x1b[31;1m${line}\x1b[0m\n`;
436+
break;
437+
default:
438+
s += `${line}\n`;
439+
break;
440+
}
441+
}
442+
}
443+
}
444+
}
445+
446+
447+
return s;
448+
}

0 commit comments

Comments
 (0)