@paperclipai/plugin-workspace-diff 0.1.0
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.
- package/dist/contracts.d.ts +634 -0
- package/dist/contracts.d.ts.map +1 -0
- package/dist/contracts.js +122 -0
- package/dist/contracts.js.map +1 -0
- package/dist/diff-model.d.ts +46 -0
- package/dist/diff-model.d.ts.map +1 -0
- package/dist/diff-model.js +87 -0
- package/dist/diff-model.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/manifest.d.ts +4 -0
- package/dist/manifest.d.ts.map +1 -0
- package/dist/manifest.js +34 -0
- package/dist/manifest.js.map +1 -0
- package/dist/ui/index.d.ts +7 -0
- package/dist/ui/index.d.ts.map +1 -0
- package/dist/ui/index.js +25431 -0
- package/dist/ui/index.js.map +7 -0
- package/dist/worker.d.ts +8 -0
- package/dist/worker.d.ts.map +1 -0
- package/dist/worker.js +77 -0
- package/dist/worker.js.map +1 -0
- package/dist/workspace-diff.d.ts +9 -0
- package/dist/workspace-diff.d.ts.map +1 -0
- package/dist/workspace-diff.js +663 -0
- package/dist/workspace-diff.js.map +1 -0
- package/package.json +53 -0
|
@@ -0,0 +1,663 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { constants as fsConstants } from "node:fs";
|
|
3
|
+
import fs from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
const execFileAsync = promisify(execFile);
|
|
7
|
+
export const WORKSPACE_DIFF_CAPS = {
|
|
8
|
+
maxFiles: 200,
|
|
9
|
+
maxFileBytes: 512 * 1024,
|
|
10
|
+
maxPatchBytes: 256 * 1024,
|
|
11
|
+
maxTotalPatchBytes: 1024 * 1024,
|
|
12
|
+
};
|
|
13
|
+
const GIT_TIMEOUT_MS = 10_000;
|
|
14
|
+
const GIT_LIST_MAX_BUFFER = 2 * 1024 * 1024;
|
|
15
|
+
const OPEN_NOFOLLOW = fsConstants.O_NOFOLLOW ?? 0;
|
|
16
|
+
function warning(code, message, filePath = null) {
|
|
17
|
+
return { code, message, path: filePath };
|
|
18
|
+
}
|
|
19
|
+
function workspaceDiffError(code, message, details = {}) {
|
|
20
|
+
const error = new Error(message);
|
|
21
|
+
Object.assign(error, { code, status: 422, details: { code, ...details } });
|
|
22
|
+
return error;
|
|
23
|
+
}
|
|
24
|
+
function toErrorMessage(error) {
|
|
25
|
+
if (error instanceof Error)
|
|
26
|
+
return error.message;
|
|
27
|
+
return String(error);
|
|
28
|
+
}
|
|
29
|
+
async function runGit(cwd, args, maxBuffer = GIT_LIST_MAX_BUFFER) {
|
|
30
|
+
try {
|
|
31
|
+
return await execFileAsync("git", ["-C", cwd, ...args], {
|
|
32
|
+
cwd,
|
|
33
|
+
timeout: GIT_TIMEOUT_MS,
|
|
34
|
+
maxBuffer,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
const stderr = typeof error.stderr === "string"
|
|
39
|
+
? String(error.stderr).trim()
|
|
40
|
+
: "";
|
|
41
|
+
const message = stderr || toErrorMessage(error);
|
|
42
|
+
throw workspaceDiffError("git_command_failed", message, { args });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
async function realDirectory(value, code) {
|
|
46
|
+
if (!path.isAbsolute(value)) {
|
|
47
|
+
throw workspaceDiffError(code, "Execution workspace path must be absolute", { cwd: value });
|
|
48
|
+
}
|
|
49
|
+
let stat;
|
|
50
|
+
try {
|
|
51
|
+
stat = await fs.stat(value);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
throw workspaceDiffError(code, "Execution workspace path does not exist", { cwd: value });
|
|
55
|
+
}
|
|
56
|
+
if (!stat.isDirectory()) {
|
|
57
|
+
throw workspaceDiffError(code, "Execution workspace path is not a directory", { cwd: value });
|
|
58
|
+
}
|
|
59
|
+
return await fs.realpath(value);
|
|
60
|
+
}
|
|
61
|
+
function isWithinDirectory(childPath, parentPath) {
|
|
62
|
+
const relative = path.relative(parentPath, childPath);
|
|
63
|
+
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
|
64
|
+
}
|
|
65
|
+
async function resolveWorkspacePaths(workspace) {
|
|
66
|
+
if (!workspace.cwd?.trim()) {
|
|
67
|
+
throw workspaceDiffError("missing_cwd", "Execution workspace needs a local path before Paperclip can inspect diffs", { workspaceId: workspace.id });
|
|
68
|
+
}
|
|
69
|
+
const cwd = await realDirectory(workspace.cwd.trim(), "workspace_path_invalid");
|
|
70
|
+
let repoRoot;
|
|
71
|
+
try {
|
|
72
|
+
repoRoot = (await runGit(cwd, ["rev-parse", "--show-toplevel"])).stdout.trim();
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
throw workspaceDiffError("non_git_workspace", "Execution workspace path is not inside a git repository", { workspaceId: workspace.id, cwd });
|
|
76
|
+
}
|
|
77
|
+
const repoRootReal = await realDirectory(repoRoot, "non_git_workspace");
|
|
78
|
+
if (!isWithinDirectory(cwd, repoRootReal)) {
|
|
79
|
+
throw workspaceDiffError("workspace_path_invalid", "Execution workspace path resolved outside its git repository", { workspaceId: workspace.id, cwd, repoRoot: repoRootReal });
|
|
80
|
+
}
|
|
81
|
+
return { cwd, repoRoot: repoRootReal };
|
|
82
|
+
}
|
|
83
|
+
function normalizePathFilter(rawPath) {
|
|
84
|
+
const value = rawPath.trim().replaceAll("\\", "/");
|
|
85
|
+
if (!value || value === ".")
|
|
86
|
+
return null;
|
|
87
|
+
if (value.includes("\0") || value.startsWith("/")) {
|
|
88
|
+
throw workspaceDiffError("path_filter_invalid", "Path filters must be relative workspace paths", { path: rawPath });
|
|
89
|
+
}
|
|
90
|
+
const normalized = path.posix.normalize(value);
|
|
91
|
+
if (normalized === "." ||
|
|
92
|
+
normalized === ".." ||
|
|
93
|
+
normalized.startsWith("../") ||
|
|
94
|
+
normalized.includes("/../")) {
|
|
95
|
+
throw workspaceDiffError("path_filter_invalid", "Path filters must not contain traversal segments", { path: rawPath });
|
|
96
|
+
}
|
|
97
|
+
return normalized;
|
|
98
|
+
}
|
|
99
|
+
function normalizePathFilters(paths) {
|
|
100
|
+
return Array.from(new Set(paths.map(normalizePathFilter).filter((value) => Boolean(value))));
|
|
101
|
+
}
|
|
102
|
+
function statusFromGitStatus(status) {
|
|
103
|
+
if (status.startsWith("R"))
|
|
104
|
+
return "renamed";
|
|
105
|
+
if (status.startsWith("C"))
|
|
106
|
+
return "copied";
|
|
107
|
+
switch (status[0]) {
|
|
108
|
+
case "A":
|
|
109
|
+
return "added";
|
|
110
|
+
case "D":
|
|
111
|
+
return "deleted";
|
|
112
|
+
case "M":
|
|
113
|
+
return "modified";
|
|
114
|
+
case "T":
|
|
115
|
+
return "type_changed";
|
|
116
|
+
default:
|
|
117
|
+
return "unknown";
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
function parseNameStatus(output) {
|
|
121
|
+
const tokens = output.split("\0").filter(Boolean);
|
|
122
|
+
const entries = [];
|
|
123
|
+
let index = 0;
|
|
124
|
+
while (index < tokens.length) {
|
|
125
|
+
const statusCode = tokens[index++] ?? "";
|
|
126
|
+
if (!statusCode)
|
|
127
|
+
continue;
|
|
128
|
+
if (statusCode.startsWith("R") || statusCode.startsWith("C")) {
|
|
129
|
+
const oldPath = tokens[index++] ?? "";
|
|
130
|
+
const newPath = tokens[index++] ?? "";
|
|
131
|
+
if (newPath) {
|
|
132
|
+
entries.push({
|
|
133
|
+
status: statusFromGitStatus(statusCode),
|
|
134
|
+
path: newPath,
|
|
135
|
+
oldPath: oldPath || null,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
const filePath = tokens[index++] ?? "";
|
|
141
|
+
if (filePath) {
|
|
142
|
+
entries.push({
|
|
143
|
+
status: statusFromGitStatus(statusCode),
|
|
144
|
+
path: filePath,
|
|
145
|
+
oldPath: null,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return entries;
|
|
150
|
+
}
|
|
151
|
+
async function readDiffNameStatus(cwd, scopeArgs, paths) {
|
|
152
|
+
const result = await runGit(cwd, [
|
|
153
|
+
"diff",
|
|
154
|
+
"--name-status",
|
|
155
|
+
"-z",
|
|
156
|
+
"--no-ext-diff",
|
|
157
|
+
"--find-renames",
|
|
158
|
+
...scopeArgs,
|
|
159
|
+
"--",
|
|
160
|
+
...paths,
|
|
161
|
+
]);
|
|
162
|
+
return parseNameStatus(result.stdout);
|
|
163
|
+
}
|
|
164
|
+
async function readUntrackedPaths(cwd, paths) {
|
|
165
|
+
const result = await runGit(cwd, ["ls-files", "--others", "--exclude-standard", "-z", "--", ...paths]);
|
|
166
|
+
return result.stdout.split("\0").filter(Boolean);
|
|
167
|
+
}
|
|
168
|
+
function ensureFile(files, filePath, status, oldPath) {
|
|
169
|
+
const existing = files.get(filePath);
|
|
170
|
+
if (existing) {
|
|
171
|
+
if (existing.status === "unknown" || status === "renamed" || status === "copied") {
|
|
172
|
+
existing.status = status;
|
|
173
|
+
}
|
|
174
|
+
if (!existing.oldPath && oldPath)
|
|
175
|
+
existing.oldPath = oldPath;
|
|
176
|
+
return existing;
|
|
177
|
+
}
|
|
178
|
+
const file = {
|
|
179
|
+
path: filePath,
|
|
180
|
+
oldPath,
|
|
181
|
+
status,
|
|
182
|
+
staged: false,
|
|
183
|
+
unstaged: false,
|
|
184
|
+
untracked: false,
|
|
185
|
+
binary: false,
|
|
186
|
+
oversized: false,
|
|
187
|
+
truncated: false,
|
|
188
|
+
additions: 0,
|
|
189
|
+
deletions: 0,
|
|
190
|
+
sizeBytes: null,
|
|
191
|
+
patches: [],
|
|
192
|
+
warnings: [],
|
|
193
|
+
patchScopes: [],
|
|
194
|
+
};
|
|
195
|
+
files.set(filePath, file);
|
|
196
|
+
return file;
|
|
197
|
+
}
|
|
198
|
+
function addStatusEntries(files, entries, scope) {
|
|
199
|
+
for (const entry of entries) {
|
|
200
|
+
const file = ensureFile(files, entry.path, entry.status, entry.oldPath);
|
|
201
|
+
if (scope === "staged")
|
|
202
|
+
file.staged = true;
|
|
203
|
+
else if (scope === "unstaged")
|
|
204
|
+
file.unstaged = true;
|
|
205
|
+
if (!file.patchScopes.includes(scope))
|
|
206
|
+
file.patchScopes.push(scope);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
function parseNumstat(output) {
|
|
210
|
+
const line = output.split(/\r?\n/).find(Boolean);
|
|
211
|
+
if (!line)
|
|
212
|
+
return { additions: 0, deletions: 0, binary: false };
|
|
213
|
+
const [additionsRaw, deletionsRaw] = line.split(/\t/);
|
|
214
|
+
if (additionsRaw === "-" || deletionsRaw === "-") {
|
|
215
|
+
return { additions: 0, deletions: 0, binary: true };
|
|
216
|
+
}
|
|
217
|
+
return {
|
|
218
|
+
additions: Number.parseInt(additionsRaw ?? "0", 10) || 0,
|
|
219
|
+
deletions: Number.parseInt(deletionsRaw ?? "0", 10) || 0,
|
|
220
|
+
binary: false,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
async function readNumstat(cwd, scopeArgs, filePath) {
|
|
224
|
+
const result = await runGit(cwd, [
|
|
225
|
+
"diff",
|
|
226
|
+
"--numstat",
|
|
227
|
+
"--no-ext-diff",
|
|
228
|
+
"--find-renames",
|
|
229
|
+
...scopeArgs,
|
|
230
|
+
"--",
|
|
231
|
+
filePath,
|
|
232
|
+
], 128 * 1024);
|
|
233
|
+
return parseNumstat(result.stdout);
|
|
234
|
+
}
|
|
235
|
+
async function statWorkspaceFile(repoRoot, filePath) {
|
|
236
|
+
const resolved = await resolveWorkspaceFilePath(repoRoot, filePath);
|
|
237
|
+
if (resolved.status !== "ok")
|
|
238
|
+
return null;
|
|
239
|
+
let handle;
|
|
240
|
+
try {
|
|
241
|
+
handle = await fs.open(resolved.realPath, fsConstants.O_RDONLY | OPEN_NOFOLLOW);
|
|
242
|
+
}
|
|
243
|
+
catch {
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
try {
|
|
247
|
+
const stat = await handle.stat();
|
|
248
|
+
return stat.isFile() ? stat.size : null;
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
finally {
|
|
254
|
+
await handle.close();
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
async function resolveWorkspaceFilePath(repoRoot, filePath) {
|
|
258
|
+
const target = path.resolve(repoRoot, filePath);
|
|
259
|
+
if (!isWithinDirectory(target, repoRoot))
|
|
260
|
+
return { status: "outside_workspace" };
|
|
261
|
+
try {
|
|
262
|
+
const realPath = await fs.realpath(target);
|
|
263
|
+
if (!isWithinDirectory(realPath, repoRoot))
|
|
264
|
+
return { status: "outside_workspace" };
|
|
265
|
+
return { status: "ok", realPath };
|
|
266
|
+
}
|
|
267
|
+
catch {
|
|
268
|
+
return { status: "missing" };
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
function isMaxBufferError(error) {
|
|
272
|
+
return typeof error === "object"
|
|
273
|
+
&& error !== null
|
|
274
|
+
&& "code" in error
|
|
275
|
+
&& error.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER";
|
|
276
|
+
}
|
|
277
|
+
async function readPatchOutput(cwd, args) {
|
|
278
|
+
try {
|
|
279
|
+
return await execFileAsync("git", ["-C", cwd, ...args], {
|
|
280
|
+
cwd,
|
|
281
|
+
timeout: GIT_TIMEOUT_MS,
|
|
282
|
+
maxBuffer: WORKSPACE_DIFF_CAPS.maxPatchBytes + 64 * 1024,
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
catch (error) {
|
|
286
|
+
if (isMaxBufferError(error)) {
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
const stderr = typeof error.stderr === "string"
|
|
290
|
+
? String(error.stderr).trim()
|
|
291
|
+
: "";
|
|
292
|
+
throw workspaceDiffError("git_command_failed", stderr || toErrorMessage(error), { args });
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
function reservePatchBytes(patch, budget, filePath, warnings) {
|
|
296
|
+
const patchBytes = Buffer.byteLength(patch, "utf8");
|
|
297
|
+
if (patchBytes > WORKSPACE_DIFF_CAPS.maxPatchBytes) {
|
|
298
|
+
warnings.push(warning("patch_truncated", "File patch exceeded the per-file diff cap.", filePath));
|
|
299
|
+
return null;
|
|
300
|
+
}
|
|
301
|
+
if (budget.totalPatchBytes + patchBytes > WORKSPACE_DIFF_CAPS.maxTotalPatchBytes) {
|
|
302
|
+
warnings.push(warning("patch_truncated", "Workspace diff exceeded the total patch cap.", filePath));
|
|
303
|
+
return null;
|
|
304
|
+
}
|
|
305
|
+
budget.totalPatchBytes += patchBytes;
|
|
306
|
+
return patch;
|
|
307
|
+
}
|
|
308
|
+
async function buildTrackedPatch(input) {
|
|
309
|
+
const warnings = [];
|
|
310
|
+
const numstat = await readNumstat(input.cwd, input.scopeArgs, input.filePath);
|
|
311
|
+
const sizeBytes = await statWorkspaceFile(input.repoRoot, input.filePath);
|
|
312
|
+
if (numstat.binary) {
|
|
313
|
+
warnings.push(warning("binary_file", "Binary files are summarized without a text patch.", input.filePath));
|
|
314
|
+
return {
|
|
315
|
+
kind: input.kind,
|
|
316
|
+
patch: null,
|
|
317
|
+
additions: 0,
|
|
318
|
+
deletions: 0,
|
|
319
|
+
binary: true,
|
|
320
|
+
oversized: false,
|
|
321
|
+
truncated: false,
|
|
322
|
+
warnings,
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
if (sizeBytes !== null && sizeBytes > WORKSPACE_DIFF_CAPS.maxFileBytes) {
|
|
326
|
+
warnings.push(warning("file_oversized", "File is too large to include a text patch.", input.filePath));
|
|
327
|
+
return {
|
|
328
|
+
kind: input.kind,
|
|
329
|
+
patch: null,
|
|
330
|
+
additions: numstat.additions,
|
|
331
|
+
deletions: numstat.deletions,
|
|
332
|
+
binary: false,
|
|
333
|
+
oversized: true,
|
|
334
|
+
truncated: false,
|
|
335
|
+
warnings,
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
const patchOutput = await readPatchOutput(input.cwd, [
|
|
339
|
+
"diff",
|
|
340
|
+
"--no-ext-diff",
|
|
341
|
+
"--find-renames",
|
|
342
|
+
"--unified=3",
|
|
343
|
+
...input.scopeArgs,
|
|
344
|
+
"--",
|
|
345
|
+
input.filePath,
|
|
346
|
+
]);
|
|
347
|
+
if (!patchOutput) {
|
|
348
|
+
warnings.push(warning("patch_truncated", "File patch exceeded the per-file diff cap.", input.filePath));
|
|
349
|
+
return {
|
|
350
|
+
kind: input.kind,
|
|
351
|
+
patch: null,
|
|
352
|
+
additions: numstat.additions,
|
|
353
|
+
deletions: numstat.deletions,
|
|
354
|
+
binary: false,
|
|
355
|
+
oversized: false,
|
|
356
|
+
truncated: true,
|
|
357
|
+
warnings,
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
const patch = reservePatchBytes(patchOutput.stdout, input.budget, input.filePath, warnings);
|
|
361
|
+
return {
|
|
362
|
+
kind: input.kind,
|
|
363
|
+
patch,
|
|
364
|
+
additions: numstat.additions,
|
|
365
|
+
deletions: numstat.deletions,
|
|
366
|
+
binary: false,
|
|
367
|
+
oversized: false,
|
|
368
|
+
truncated: patch === null,
|
|
369
|
+
warnings,
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
function isProbablyBinary(buffer) {
|
|
373
|
+
return buffer.subarray(0, Math.min(buffer.length, 8_000)).includes(0);
|
|
374
|
+
}
|
|
375
|
+
function countAddedLines(content) {
|
|
376
|
+
if (content.length === 0)
|
|
377
|
+
return 0;
|
|
378
|
+
return content.endsWith("\n") ? content.split("\n").length - 1 : content.split("\n").length;
|
|
379
|
+
}
|
|
380
|
+
function buildUntrackedPatch(filePath, content) {
|
|
381
|
+
const lines = content.length === 0 ? [] : content.split("\n");
|
|
382
|
+
if (lines.length > 0 && lines[lines.length - 1] === "")
|
|
383
|
+
lines.pop();
|
|
384
|
+
const lineCount = countAddedLines(content);
|
|
385
|
+
const header = [
|
|
386
|
+
`diff --git a/${filePath} b/${filePath}`,
|
|
387
|
+
"new file mode 100644",
|
|
388
|
+
"--- /dev/null",
|
|
389
|
+
`+++ b/${filePath}`,
|
|
390
|
+
];
|
|
391
|
+
if (lineCount === 0)
|
|
392
|
+
return `${header.join("\n")}\n`;
|
|
393
|
+
const hunkLines = lines.map((line) => `+${line}`).join("\n");
|
|
394
|
+
return [...header, `@@ -0,0 +1,${lineCount} @@`, hunkLines, ""].join("\n");
|
|
395
|
+
}
|
|
396
|
+
async function buildUntrackedFilePatch(input) {
|
|
397
|
+
const warnings = [];
|
|
398
|
+
const resolved = await resolveWorkspaceFilePath(input.repoRoot, input.filePath);
|
|
399
|
+
if (resolved.status === "outside_workspace") {
|
|
400
|
+
warnings.push(warning("symlink_target_outside_workspace", "Untracked file resolves outside the workspace and is summarized without reading target bytes.", input.filePath));
|
|
401
|
+
return {
|
|
402
|
+
kind: "untracked",
|
|
403
|
+
patch: null,
|
|
404
|
+
additions: 0,
|
|
405
|
+
deletions: 0,
|
|
406
|
+
binary: false,
|
|
407
|
+
oversized: false,
|
|
408
|
+
truncated: false,
|
|
409
|
+
warnings,
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
if (resolved.status === "missing") {
|
|
413
|
+
return {
|
|
414
|
+
kind: "untracked",
|
|
415
|
+
patch: null,
|
|
416
|
+
additions: 0,
|
|
417
|
+
deletions: 0,
|
|
418
|
+
binary: false,
|
|
419
|
+
oversized: false,
|
|
420
|
+
truncated: false,
|
|
421
|
+
warnings,
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
let handle;
|
|
425
|
+
try {
|
|
426
|
+
handle = await fs.open(resolved.realPath, fsConstants.O_RDONLY | OPEN_NOFOLLOW);
|
|
427
|
+
}
|
|
428
|
+
catch {
|
|
429
|
+
return {
|
|
430
|
+
kind: "untracked",
|
|
431
|
+
patch: null,
|
|
432
|
+
additions: 0,
|
|
433
|
+
deletions: 0,
|
|
434
|
+
binary: false,
|
|
435
|
+
oversized: false,
|
|
436
|
+
truncated: false,
|
|
437
|
+
warnings,
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
let sizeBytes;
|
|
441
|
+
let buffer = null;
|
|
442
|
+
try {
|
|
443
|
+
const stat = await handle.stat();
|
|
444
|
+
if (!stat.isFile()) {
|
|
445
|
+
return {
|
|
446
|
+
kind: "untracked",
|
|
447
|
+
patch: null,
|
|
448
|
+
additions: 0,
|
|
449
|
+
deletions: 0,
|
|
450
|
+
binary: false,
|
|
451
|
+
oversized: false,
|
|
452
|
+
truncated: false,
|
|
453
|
+
warnings,
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
sizeBytes = stat.size;
|
|
457
|
+
if (sizeBytes <= WORKSPACE_DIFF_CAPS.maxFileBytes) {
|
|
458
|
+
buffer = await handle.readFile();
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
finally {
|
|
462
|
+
await handle.close();
|
|
463
|
+
}
|
|
464
|
+
if (sizeBytes > WORKSPACE_DIFF_CAPS.maxFileBytes) {
|
|
465
|
+
warnings.push(warning("file_oversized", "Untracked file is too large to include a text patch.", input.filePath));
|
|
466
|
+
return {
|
|
467
|
+
kind: "untracked",
|
|
468
|
+
patch: null,
|
|
469
|
+
additions: 0,
|
|
470
|
+
deletions: 0,
|
|
471
|
+
binary: false,
|
|
472
|
+
oversized: true,
|
|
473
|
+
truncated: false,
|
|
474
|
+
warnings,
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
if (!buffer) {
|
|
478
|
+
return {
|
|
479
|
+
kind: "untracked",
|
|
480
|
+
patch: null,
|
|
481
|
+
additions: 0,
|
|
482
|
+
deletions: 0,
|
|
483
|
+
binary: false,
|
|
484
|
+
oversized: false,
|
|
485
|
+
truncated: false,
|
|
486
|
+
warnings,
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
if (isProbablyBinary(buffer)) {
|
|
490
|
+
warnings.push(warning("binary_file", "Binary files are summarized without a text patch.", input.filePath));
|
|
491
|
+
return {
|
|
492
|
+
kind: "untracked",
|
|
493
|
+
patch: null,
|
|
494
|
+
additions: 0,
|
|
495
|
+
deletions: 0,
|
|
496
|
+
binary: true,
|
|
497
|
+
oversized: false,
|
|
498
|
+
truncated: false,
|
|
499
|
+
warnings,
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
const content = buffer.toString("utf8");
|
|
503
|
+
const patch = reservePatchBytes(buildUntrackedPatch(input.filePath, content), input.budget, input.filePath, warnings);
|
|
504
|
+
return {
|
|
505
|
+
kind: "untracked",
|
|
506
|
+
patch,
|
|
507
|
+
additions: countAddedLines(content),
|
|
508
|
+
deletions: 0,
|
|
509
|
+
binary: false,
|
|
510
|
+
oversized: false,
|
|
511
|
+
truncated: patch === null,
|
|
512
|
+
warnings,
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
function applyPatchToFile(file, patch, sizeBytes) {
|
|
516
|
+
file.patches.push(patch);
|
|
517
|
+
file.additions += patch.additions;
|
|
518
|
+
file.deletions += patch.deletions;
|
|
519
|
+
file.binary = file.binary || patch.binary;
|
|
520
|
+
file.oversized = file.oversized || patch.oversized;
|
|
521
|
+
file.truncated = file.truncated || patch.truncated;
|
|
522
|
+
file.warnings.push(...patch.warnings);
|
|
523
|
+
if (file.sizeBytes === null && sizeBytes !== null)
|
|
524
|
+
file.sizeBytes = sizeBytes;
|
|
525
|
+
}
|
|
526
|
+
function finalizeStats(files) {
|
|
527
|
+
return {
|
|
528
|
+
fileCount: files.length,
|
|
529
|
+
stagedFileCount: files.filter((file) => file.staged).length,
|
|
530
|
+
unstagedFileCount: files.filter((file) => file.unstaged).length,
|
|
531
|
+
untrackedFileCount: files.filter((file) => file.untracked).length,
|
|
532
|
+
binaryFileCount: files.filter((file) => file.binary).length,
|
|
533
|
+
oversizedFileCount: files.filter((file) => file.oversized).length,
|
|
534
|
+
truncatedFileCount: files.filter((file) => file.truncated).length,
|
|
535
|
+
additions: files.reduce((sum, file) => sum + file.additions, 0),
|
|
536
|
+
deletions: files.reduce((sum, file) => sum + file.deletions, 0),
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
async function resolveHeadSha(cwd) {
|
|
540
|
+
try {
|
|
541
|
+
return (await runGit(cwd, ["rev-parse", "HEAD"], 128 * 1024)).stdout.trim() || null;
|
|
542
|
+
}
|
|
543
|
+
catch {
|
|
544
|
+
return null;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
async function resolveBaseRef(cwd, baseRef, workspace) {
|
|
548
|
+
const resolvedBaseRef = baseRef ?? workspace.baseRef ?? null;
|
|
549
|
+
if (!resolvedBaseRef) {
|
|
550
|
+
throw workspaceDiffError("base_ref_missing", "A baseRef query parameter or execution workspace baseRef is required for head diffs", { workspaceId: workspace.id });
|
|
551
|
+
}
|
|
552
|
+
try {
|
|
553
|
+
await execFileAsync("git", ["-C", cwd, "rev-parse", "--verify", "--quiet", `${resolvedBaseRef}^{commit}`], {
|
|
554
|
+
cwd,
|
|
555
|
+
timeout: GIT_TIMEOUT_MS,
|
|
556
|
+
maxBuffer: 128 * 1024,
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
catch {
|
|
560
|
+
throw workspaceDiffError("base_ref_invalid", `Could not resolve baseRef "${resolvedBaseRef}" in this workspace`, { workspaceId: workspace.id, baseRef: resolvedBaseRef });
|
|
561
|
+
}
|
|
562
|
+
return resolvedBaseRef;
|
|
563
|
+
}
|
|
564
|
+
async function collectFiles(input) {
|
|
565
|
+
const files = new Map();
|
|
566
|
+
let baseRef = null;
|
|
567
|
+
if (input.query.view === "head") {
|
|
568
|
+
baseRef = await resolveBaseRef(input.cwd, input.query.baseRef, input.workspace);
|
|
569
|
+
addStatusEntries(files, await readDiffNameStatus(input.cwd, [`${baseRef}...HEAD`], input.paths), "head");
|
|
570
|
+
}
|
|
571
|
+
else {
|
|
572
|
+
addStatusEntries(files, await readDiffNameStatus(input.cwd, ["--cached"], input.paths), "staged");
|
|
573
|
+
addStatusEntries(files, await readDiffNameStatus(input.cwd, [], input.paths), "unstaged");
|
|
574
|
+
if (input.query.includeUntracked) {
|
|
575
|
+
for (const untrackedPath of await readUntrackedPaths(input.cwd, input.paths)) {
|
|
576
|
+
const file = ensureFile(files, untrackedPath, "untracked", null);
|
|
577
|
+
file.untracked = true;
|
|
578
|
+
if (!file.patchScopes.includes("unstaged"))
|
|
579
|
+
file.patchScopes.push("unstaged");
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
return { files, baseRef };
|
|
584
|
+
}
|
|
585
|
+
export function workspaceDiffService() {
|
|
586
|
+
return {
|
|
587
|
+
async getDiff(workspace, query) {
|
|
588
|
+
const { cwd, repoRoot } = await resolveWorkspacePaths(workspace);
|
|
589
|
+
const paths = normalizePathFilters(query.paths);
|
|
590
|
+
const warnings = [];
|
|
591
|
+
const { files: filesByPath, baseRef } = await collectFiles({ cwd, workspace, query, paths });
|
|
592
|
+
const allFiles = Array.from(filesByPath.values()).sort((left, right) => left.path.localeCompare(right.path));
|
|
593
|
+
const cappedFiles = allFiles.slice(0, WORKSPACE_DIFF_CAPS.maxFiles);
|
|
594
|
+
if (allFiles.length > cappedFiles.length) {
|
|
595
|
+
warnings.push(warning("file_count_truncated", `Workspace diff includes ${allFiles.length} files, so only the first ${WORKSPACE_DIFF_CAPS.maxFiles} are returned.`));
|
|
596
|
+
}
|
|
597
|
+
const patchBudget = { totalPatchBytes: 0 };
|
|
598
|
+
for (const file of cappedFiles) {
|
|
599
|
+
if (query.view === "head") {
|
|
600
|
+
const patch = await buildTrackedPatch({
|
|
601
|
+
cwd,
|
|
602
|
+
repoRoot,
|
|
603
|
+
filePath: file.path,
|
|
604
|
+
kind: "head",
|
|
605
|
+
scopeArgs: [`${baseRef}...HEAD`],
|
|
606
|
+
budget: patchBudget,
|
|
607
|
+
});
|
|
608
|
+
applyPatchToFile(file, patch, await statWorkspaceFile(repoRoot, file.path));
|
|
609
|
+
continue;
|
|
610
|
+
}
|
|
611
|
+
if (file.staged) {
|
|
612
|
+
const patch = await buildTrackedPatch({
|
|
613
|
+
cwd,
|
|
614
|
+
repoRoot,
|
|
615
|
+
filePath: file.path,
|
|
616
|
+
kind: "staged",
|
|
617
|
+
scopeArgs: ["--cached"],
|
|
618
|
+
budget: patchBudget,
|
|
619
|
+
});
|
|
620
|
+
applyPatchToFile(file, patch, await statWorkspaceFile(repoRoot, file.path));
|
|
621
|
+
}
|
|
622
|
+
if (file.unstaged) {
|
|
623
|
+
const patch = await buildTrackedPatch({
|
|
624
|
+
cwd,
|
|
625
|
+
repoRoot,
|
|
626
|
+
filePath: file.path,
|
|
627
|
+
kind: "unstaged",
|
|
628
|
+
scopeArgs: [],
|
|
629
|
+
budget: patchBudget,
|
|
630
|
+
});
|
|
631
|
+
applyPatchToFile(file, patch, await statWorkspaceFile(repoRoot, file.path));
|
|
632
|
+
}
|
|
633
|
+
if (file.untracked) {
|
|
634
|
+
const patch = await buildUntrackedFilePatch({
|
|
635
|
+
repoRoot,
|
|
636
|
+
filePath: file.path,
|
|
637
|
+
budget: patchBudget,
|
|
638
|
+
});
|
|
639
|
+
applyPatchToFile(file, patch, await statWorkspaceFile(repoRoot, file.path));
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
const files = cappedFiles.map(({ patchScopes: _patchScopes, ...file }) => file);
|
|
643
|
+
const patchWarnings = files.flatMap((file) => file.warnings);
|
|
644
|
+
return {
|
|
645
|
+
workspaceId: workspace.id,
|
|
646
|
+
companyId: workspace.companyId,
|
|
647
|
+
view: query.view,
|
|
648
|
+
baseRef,
|
|
649
|
+
defaultBaseRef: workspace.baseRef,
|
|
650
|
+
headSha: await resolveHeadSha(cwd),
|
|
651
|
+
includeUntracked: query.includeUntracked,
|
|
652
|
+
paths,
|
|
653
|
+
files,
|
|
654
|
+
stats: finalizeStats(files),
|
|
655
|
+
warnings: [...warnings, ...patchWarnings],
|
|
656
|
+
caps: WORKSPACE_DIFF_CAPS,
|
|
657
|
+
truncated: warnings.some((item) => item.code === "file_count_truncated")
|
|
658
|
+
|| files.some((file) => file.truncated),
|
|
659
|
+
};
|
|
660
|
+
},
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
//# sourceMappingURL=workspace-diff.js.map
|