@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
- return Promise.all(changes.map(async (change) => {
105
- const [oldFile, newFile] = await Promise.all([
106
- getFileAtRef(repoPath, oldRef, change.oldPath),
107
- getFileAtRef(repoPath, newRef, change.newPath)
108
- ]);
109
- return computeFileDiff(oldFile.content, newFile.content, {
110
- oldPath: change.oldPath,
111
- newPath: change.newPath,
112
- status: change.status,
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
- if (ref !== 'INDEX') {
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tequila99/githuman",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Review AI agent code changes before commit — Vue edition",
5
5
  "homepage": "https://github.com/tequila99/githuman#readme",
6
6
  "bugs": {