@tequila99/githuman 0.2.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { diffLines } from 'diff';
|
|
2
|
-
import { assertSafeRef, getFileAtRef, listChangedPaths, listUntrackedPaths } from "./git.service.js";
|
|
2
|
+
import { assertSafeRef, getFileAtRef, getFilesAtRefBatch, listChangedPaths, listUntrackedPaths } from "./git.service.js";
|
|
3
3
|
function splitLines(text) {
|
|
4
4
|
if (text === '')
|
|
5
5
|
return [];
|
|
@@ -99,19 +99,37 @@ export function computeFileDiff(oldContent, newContent, meta) {
|
|
|
99
99
|
const hunks = windowHunks(lines);
|
|
100
100
|
return { ...base, additions, deletions, hunks };
|
|
101
101
|
}
|
|
102
|
+
/**
|
|
103
|
+
* Reads one diff side's content for every changed path, batching all
|
|
104
|
+
* git-ref reads into a single `git cat-file --batch` process instead of one
|
|
105
|
+
* `git show` per file (see ADR 0019) — `WORKTREE` still reads straight off
|
|
106
|
+
* disk (already process-free) since it isn't a git object. Falls back to
|
|
107
|
+
* per-file `getFileAtRef` if the batch process fails to spawn or crashes
|
|
108
|
+
* mid-read, so a batching problem degrades performance rather than
|
|
109
|
+
* correctness.
|
|
110
|
+
*/
|
|
111
|
+
async function readDiffSide(repoPath, ref, paths) {
|
|
112
|
+
if (ref === 'WORKTREE' || paths.length === 0) {
|
|
113
|
+
return Promise.all(paths.map(path => getFileAtRef(repoPath, ref, path)));
|
|
114
|
+
}
|
|
115
|
+
try {
|
|
116
|
+
return await getFilesAtRefBatch(repoPath, paths.map(path => ({ ref, path })));
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return Promise.all(paths.map(path => getFileAtRef(repoPath, ref, path)));
|
|
120
|
+
}
|
|
121
|
+
}
|
|
102
122
|
async function buildDiffFiles(repoPath, diffArgs, oldRef, newRef) {
|
|
103
123
|
const changes = await listChangedPaths(repoPath, diffArgs);
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
isBinary: oldFile.isBinary || newFile.isBinary
|
|
114
|
-
});
|
|
124
|
+
const [oldFiles, newFiles] = await Promise.all([
|
|
125
|
+
readDiffSide(repoPath, oldRef, changes.map(change => change.oldPath)),
|
|
126
|
+
readDiffSide(repoPath, newRef, changes.map(change => change.newPath))
|
|
127
|
+
]);
|
|
128
|
+
return changes.map((change, i) => computeFileDiff(oldFiles[i].content, newFiles[i].content, {
|
|
129
|
+
oldPath: change.oldPath,
|
|
130
|
+
newPath: change.newPath,
|
|
131
|
+
status: change.status,
|
|
132
|
+
isBinary: oldFiles[i].isBinary || newFiles[i].isBinary
|
|
115
133
|
}));
|
|
116
134
|
}
|
|
117
135
|
export async function getStagedDiff(repoPath) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { execFile } from 'node:child_process';
|
|
1
|
+
import { execFile, spawn } from 'node:child_process';
|
|
2
2
|
import { promisify } from 'node:util';
|
|
3
3
|
import { readFile } from 'node:fs/promises';
|
|
4
4
|
import { basename, resolve, sep } from 'node:path';
|
|
@@ -30,6 +30,23 @@ export function assertSafeRef(ref) {
|
|
|
30
30
|
throw new Error(`Invalid git ref "${ref}": refs may not start with "-"`);
|
|
31
31
|
}
|
|
32
32
|
}
|
|
33
|
+
/**
|
|
34
|
+
* Builds the `git show`/`cat-file` object spec for a file at a ref (e.g.
|
|
35
|
+
* `HEAD:src/foo.ts`, or `:src/foo.ts` for the INDEX/staged form). Shared by
|
|
36
|
+
* `getFileAtRef` and `getFilesAtRefBatch` so both single-file and batched
|
|
37
|
+
* reads resolve refs identically.
|
|
38
|
+
*/
|
|
39
|
+
function objectSpecFor(ref, path) {
|
|
40
|
+
if (ref !== 'INDEX') {
|
|
41
|
+
assertSafeRef(ref);
|
|
42
|
+
}
|
|
43
|
+
// No `--` before the spec: for the INDEX form (`:path`) it would make git
|
|
44
|
+
// treat the pathspec-looking `:path` as a plain path instead of the
|
|
45
|
+
// special "file staged in the index" syntax, silently changing meaning
|
|
46
|
+
// (falls back to showing HEAD). assertSafeRef above already rules out
|
|
47
|
+
// the injection this would otherwise guard against.
|
|
48
|
+
return ref === 'INDEX' ? `:${path}` : `${ref}:${path}`;
|
|
49
|
+
}
|
|
33
50
|
/**
|
|
34
51
|
* Reads a file's content at a given git ref.
|
|
35
52
|
*
|
|
@@ -54,16 +71,8 @@ export async function getFileAtRef(repoPath, ref, path) {
|
|
|
54
71
|
return { content: '', isBinary: false };
|
|
55
72
|
}
|
|
56
73
|
}
|
|
57
|
-
|
|
58
|
-
assertSafeRef(ref);
|
|
59
|
-
}
|
|
60
|
-
const gitRef = ref === 'INDEX' ? `:${path}` : `${ref}:${path}`;
|
|
74
|
+
const gitRef = objectSpecFor(ref, path);
|
|
61
75
|
try {
|
|
62
|
-
// No `--` before gitRef: for the INDEX form (`:path`) it would make git
|
|
63
|
-
// treat the pathspec-looking `:path` as a plain path instead of the
|
|
64
|
-
// special "file staged in the index" syntax, silently changing meaning
|
|
65
|
-
// (falls back to showing HEAD). assertSafeRef above already rules out
|
|
66
|
-
// the injection this would otherwise guard against.
|
|
67
76
|
const { stdout } = await execFileAsync('git', ['show', gitRef], {
|
|
68
77
|
cwd: repoPath,
|
|
69
78
|
encoding: 'buffer',
|
|
@@ -79,6 +88,89 @@ export async function getFileAtRef(repoPath, ref, path) {
|
|
|
79
88
|
return { content: '', isBinary: false };
|
|
80
89
|
}
|
|
81
90
|
}
|
|
91
|
+
/**
|
|
92
|
+
* Parses `git cat-file --batch`'s output for `count` requested objects, in
|
|
93
|
+
* request order. Each entry is either `<sha> SP <type> SP <size> LF`
|
|
94
|
+
* followed by exactly `size` bytes of content and a trailing LF, or
|
|
95
|
+
* `<object> SP missing LF` (or another non-numeric-size error line, e.g.
|
|
96
|
+
* `ambiguous`/`notdir`) for a request that didn't resolve — treated the
|
|
97
|
+
* same as a missing file, matching `getFileAtRef`'s catch-all behavior.
|
|
98
|
+
*
|
|
99
|
+
* Parses by the announced byte size, not line-by-line — file content can
|
|
100
|
+
* itself contain arbitrary bytes, including `\n`.
|
|
101
|
+
*/
|
|
102
|
+
function parseCatFileBatchOutput(buffer, count) {
|
|
103
|
+
const results = [];
|
|
104
|
+
let offset = 0;
|
|
105
|
+
for (let i = 0; i < count; i++) {
|
|
106
|
+
const headerEnd = buffer.indexOf(0x0a, offset);
|
|
107
|
+
if (headerEnd === -1) {
|
|
108
|
+
throw new Error('git cat-file --batch: truncated output (missing header)');
|
|
109
|
+
}
|
|
110
|
+
const header = buffer.toString('utf-8', offset, headerEnd);
|
|
111
|
+
offset = headerEnd + 1;
|
|
112
|
+
const size = Number(header.slice(header.lastIndexOf(' ') + 1));
|
|
113
|
+
if (!Number.isInteger(size) || size < 0) {
|
|
114
|
+
results.push({ content: '', isBinary: false });
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
const content = buffer.subarray(offset, offset + size);
|
|
118
|
+
if (content.length < size) {
|
|
119
|
+
throw new Error('git cat-file --batch: truncated output (short content)');
|
|
120
|
+
}
|
|
121
|
+
offset += size + 1; // skip content + its trailing LF
|
|
122
|
+
const isBinary = content.includes(0);
|
|
123
|
+
results.push({
|
|
124
|
+
content: isBinary ? '' : content.toString('utf-8'),
|
|
125
|
+
isBinary
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
return results;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Reads many files' content at git refs in one `git cat-file --batch`
|
|
132
|
+
* process, instead of one `git show` process per file (see ADR 0019) —
|
|
133
|
+
* used by `buildDiffFiles` to avoid spawning `2 × changed files` processes
|
|
134
|
+
* for a single diff. Results come back in the same order as `requests`.
|
|
135
|
+
*
|
|
136
|
+
* Does not accept `ref: 'WORKTREE'` — worktree content isn't a git object;
|
|
137
|
+
* callers read it directly off disk instead (as `getFileAtRef` already
|
|
138
|
+
* does for that ref).
|
|
139
|
+
*/
|
|
140
|
+
export async function getFilesAtRefBatch(repoPath, requests) {
|
|
141
|
+
if (requests.length === 0)
|
|
142
|
+
return [];
|
|
143
|
+
const specs = requests.map(({ ref, path }) => {
|
|
144
|
+
if (ref === 'WORKTREE') {
|
|
145
|
+
throw new Error("getFilesAtRefBatch does not support ref 'WORKTREE' — read worktree files directly instead");
|
|
146
|
+
}
|
|
147
|
+
return objectSpecFor(ref, path);
|
|
148
|
+
});
|
|
149
|
+
return new Promise((resolvePromise, reject) => {
|
|
150
|
+
const child = spawn('git', ['cat-file', '--batch'], { cwd: repoPath });
|
|
151
|
+
const chunks = [];
|
|
152
|
+
let stderr = '';
|
|
153
|
+
child.stdout.on('data', (chunk) => chunks.push(chunk));
|
|
154
|
+
child.stderr.on('data', (chunk) => {
|
|
155
|
+
stderr += chunk.toString('utf-8');
|
|
156
|
+
});
|
|
157
|
+
child.on('error', reject);
|
|
158
|
+
child.on('close', code => {
|
|
159
|
+
if (code !== 0) {
|
|
160
|
+
reject(new Error(`git cat-file --batch exited with code ${code}: ${stderr}`));
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
try {
|
|
164
|
+
resolvePromise(parseCatFileBatchOutput(Buffer.concat(chunks), requests.length));
|
|
165
|
+
}
|
|
166
|
+
catch (err) {
|
|
167
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
child.stdin.write(specs.join('\n') + '\n');
|
|
171
|
+
child.stdin.end();
|
|
172
|
+
});
|
|
173
|
+
}
|
|
82
174
|
/**
|
|
83
175
|
* Lists every file path visible at a git ref, for browsing the whole
|
|
84
176
|
* codebase rather than just a diff. `'WORKTREE'` lists the working directory
|