comprehende 0.2.0 → 0.3.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/README.md CHANGED
@@ -30,7 +30,9 @@ pnpm dev -- --help
30
30
 
31
31
  `pnpm build` emits `dist/cli` and `dist/ui`. `prepack` runs that build, so `pnpm pack` / `pnpm publish` always ship the UI.
32
32
 
33
- `pnpm dev` and `pnpm exec` run with this package as cwd, so they only make sense when _this_ repo is the one under review. To review a different project from a checkout, `cd` into it and run `npx comprehende@0.2.0` (or `node /path/to/comprehende/dist/cli/main.js` after `pnpm build`).
33
+ `comprehende serve` and `comprehende export` share one UI and one git payload layer. Serve computes those payloads on each request. Export writes the same JSON (and image bytes) next to the UI so any static file server can host the review.
34
+
35
+ `pnpm dev` and `pnpm exec` run with this package as cwd, so they only make sense when _this_ repo is the one under review. To review a different project from a checkout, `cd` into it and run `npx comprehende@0.3.0` (or `node /path/to/comprehende/dist/cli/main.js` after `pnpm build`).
34
36
 
35
37
  ## Release
36
38
 
@@ -48,4 +50,16 @@ cd fixtures/repo
48
50
  node ../../dist/cli/main.js serve --data ../example/review.json
49
51
  ```
50
52
 
51
- `pnpm fixture` writes a tiny git repo to `fixtures/repo` (gitignored) and a refs-only `fixtures/example/review.json`. Serve with cwd set to `fixtures/repo`.
53
+ Export a static copy (cwd still `fixtures/repo`):
54
+
55
+ ```sh
56
+ node ../../dist/cli/main.js export --data ../example/review.json --out ../../fixtures/site
57
+ ```
58
+
59
+ The folder has the UI plus frozen `api/*.json` payloads and image bytes. There is no git in that folder. Host it with any static file server:
60
+
61
+ ```sh
62
+ python3 -m http.server --directory ../../fixtures/site 8080
63
+ ```
64
+
65
+ `pnpm fixture` writes a tiny git repo to `fixtures/repo` (gitignored) and a refs-only `fixtures/example/review.json`. Serve or export with cwd set to `fixtures/repo`.
@@ -0,0 +1,8 @@
1
+ export class ApiError extends Error {
2
+ status;
3
+ constructor(status, message) {
4
+ super(message);
5
+ this.name = "ApiError";
6
+ this.status = status;
7
+ }
8
+ }
@@ -0,0 +1,249 @@
1
+ import { loadDocument } from "../cli/commands.js";
2
+ import { blameFile } from "../git/blame.js";
3
+ import { readImageBlob } from "../git/blob.js";
4
+ import { fileLanguage, filePatchFromGit, resolveSource, toHunkRef } from "../git/diff.js";
5
+ import { GitError } from "../git/exec.js";
6
+ import { listCommits } from "../git/log.js";
7
+ import { mergeBase } from "../git/repo.js";
8
+ import { showFile } from "../git/show.js";
9
+ import { coverReview } from "../review/coverage.js";
10
+ import { ApiError } from "./error.js";
11
+ export async function openReview(cwd, dataPath) {
12
+ const document = await loadDocument(dataPath);
13
+ const resolvedRefs = await resolveSource(cwd, document.source.baseRef, document.source.headRef);
14
+ const { files, coverage } = await coverReview(cwd, document);
15
+ const [mergeBaseSha, commits] = await Promise.all([
16
+ mergeBase(cwd, document.source.baseRef, document.source.headRef),
17
+ listCommits(cwd, document.source.baseRef, document.source.headRef),
18
+ ]);
19
+ return {
20
+ cwd,
21
+ document,
22
+ resolved: {
23
+ baseRef: document.source.baseRef,
24
+ headRef: document.source.headRef,
25
+ range: document.source.range ?? `${document.source.baseRef}...${document.source.headRef}`,
26
+ baseSha: resolvedRefs.baseSha,
27
+ headSha: resolvedRefs.headSha,
28
+ },
29
+ files,
30
+ coverage,
31
+ mergeBaseSha,
32
+ commits,
33
+ };
34
+ }
35
+ export function reviewPayload(ctx) {
36
+ const { document, resolved, files, coverage, commits } = ctx;
37
+ return {
38
+ document,
39
+ resolved,
40
+ coverage: {
41
+ totalHunks: coverage.totalHunks,
42
+ assignedHunks: coverage.assignedHunks,
43
+ unassignedCount: coverage.unassigned.length,
44
+ staleCount: coverage.stale.length,
45
+ },
46
+ groups: coverage.groups
47
+ .slice()
48
+ .sort((a, b) => a.group.suggestedOrder - b.group.suggestedOrder || a.group.id.localeCompare(b.group.id))
49
+ .map((group) => ({
50
+ id: group.group.id,
51
+ title: group.group.title,
52
+ summary: group.group.summary,
53
+ lookFor: group.group.lookFor ?? [],
54
+ dependsOn: group.group.dependsOn ?? [],
55
+ part: group.group.part,
56
+ suggestedOrder: group.group.suggestedOrder,
57
+ hunkCount: group.hunks.length,
58
+ staleCount: group.stale.length,
59
+ files: uniquePaths(group.hunks),
60
+ })),
61
+ unassigned: {
62
+ hunkCount: coverage.unassigned.length,
63
+ files: uniquePaths(coverage.unassigned),
64
+ },
65
+ stale: coverage.stale,
66
+ files: files.map((file) => {
67
+ const entry = {
68
+ path: file.path,
69
+ status: file.status,
70
+ binary: file.binary,
71
+ image: file.image,
72
+ hunkCount: file.hunks.length,
73
+ };
74
+ if (file.oldPath !== undefined) {
75
+ entry.oldPath = file.oldPath;
76
+ }
77
+ return entry;
78
+ }),
79
+ skipped: files.filter((file) => file.binary && !file.image).map((file) => ({ path: file.path, reason: "binary" })),
80
+ commits,
81
+ };
82
+ }
83
+ export function hunksPayload(ctx, groupId) {
84
+ if (groupId === "") {
85
+ throw new ApiError(400, "missing group");
86
+ }
87
+ if (groupId === "unassigned") {
88
+ return serializeLayer(ctx.files, ctx.coverage.unassigned);
89
+ }
90
+ const group = ctx.coverage.groups.find((item) => item.group.id === groupId);
91
+ if (group === undefined) {
92
+ throw new ApiError(404, `unknown group "${groupId}"`);
93
+ }
94
+ return serializeLayer(ctx.files, group.hunks);
95
+ }
96
+ export async function filePayload(ctx, path, side) {
97
+ const file = findFile(ctx.files, path);
98
+ const lookup = side === "old" ? (file.oldPath ?? file.path) : file.path;
99
+ assertSideExists(file, side);
100
+ const ref = side === "old" ? ctx.mergeBaseSha : ctx.document.source.headRef;
101
+ try {
102
+ const content = await showFile(ctx.cwd, ref, lookup);
103
+ return { path: lookup, ref, side, content, language: fileLanguage(lookup) };
104
+ }
105
+ catch (error) {
106
+ throw new ApiError(404, error instanceof Error ? error.message : "file not found");
107
+ }
108
+ }
109
+ export async function blamePayload(ctx, path, side) {
110
+ const file = findFile(ctx.files, path);
111
+ const lookup = side === "old" ? (file.oldPath ?? file.path) : file.path;
112
+ assertSideExists(file, side);
113
+ const ref = side === "old" ? ctx.mergeBaseSha : ctx.document.source.headRef;
114
+ try {
115
+ const lines = await blameFile(ctx.cwd, ref, lookup);
116
+ return { path: lookup, ref, side, lines };
117
+ }
118
+ catch (error) {
119
+ throw new ApiError(404, error instanceof Error ? error.message : "blame not available");
120
+ }
121
+ }
122
+ export async function imagePayload(ctx, path, side) {
123
+ const file = findFile(ctx.files, path);
124
+ if (!file.image) {
125
+ throw new ApiError(404, "path is not an image in the live diff");
126
+ }
127
+ const lookup = side === "old" ? (file.oldPath ?? file.path) : file.path;
128
+ assertSideExists(file, side);
129
+ const ref = side === "old" ? ctx.mergeBaseSha : ctx.document.source.headRef;
130
+ try {
131
+ const blob = await readImageBlob(ctx.cwd, ref, lookup);
132
+ if (!blob.ok) {
133
+ throw new ApiError(404, `Git LFS object sha256:${blob.oid} is not in this clone`);
134
+ }
135
+ return { encoding: "bytes", mediaType: blob.mediaType, body: blob.bytes };
136
+ }
137
+ catch (error) {
138
+ if (error instanceof ApiError) {
139
+ throw error;
140
+ }
141
+ throw new ApiError(404, error instanceof Error ? error.message : "image not found");
142
+ }
143
+ }
144
+ export async function renderResource(ctx, resource) {
145
+ switch (resource.kind) {
146
+ case "review":
147
+ return { encoding: "json", body: reviewPayload(ctx) };
148
+ case "hunks":
149
+ return { encoding: "json", body: hunksPayload(ctx, resource.group) };
150
+ case "file":
151
+ return { encoding: "json", body: await filePayload(ctx, resource.path, resource.side) };
152
+ case "blame":
153
+ return { encoding: "json", body: await blamePayload(ctx, resource.path, resource.side) };
154
+ case "image":
155
+ return imagePayload(ctx, resource.path, resource.side);
156
+ }
157
+ }
158
+ export function listResources(ctx) {
159
+ const resources = [{ kind: "review" }, { kind: "hunks", group: "unassigned" }];
160
+ for (const group of ctx.document.groups) {
161
+ resources.push({ kind: "hunks", group: group.id });
162
+ }
163
+ for (const file of ctx.files) {
164
+ if (file.image) {
165
+ for (const side of sidesFor(file)) {
166
+ resources.push({ kind: "image", path: file.path, side });
167
+ }
168
+ continue;
169
+ }
170
+ for (const side of sidesFor(file)) {
171
+ resources.push({ kind: "file", path: file.path, side });
172
+ resources.push({ kind: "blame", path: file.path, side });
173
+ }
174
+ }
175
+ return resources;
176
+ }
177
+ function sidesFor(file) {
178
+ if (file.status === "added") {
179
+ return ["new"];
180
+ }
181
+ if (file.status === "deleted") {
182
+ return ["old"];
183
+ }
184
+ return ["old", "new"];
185
+ }
186
+ function serializeLayer(files, hunks) {
187
+ const groups = [];
188
+ for (const hunk of hunks) {
189
+ const existing = groups.find((group) => group.file.path === hunk.path);
190
+ if (existing !== undefined) {
191
+ existing.hunks.push(hunk);
192
+ continue;
193
+ }
194
+ const file = files.find((item) => item.path === hunk.path);
195
+ if (file === undefined) {
196
+ continue;
197
+ }
198
+ groups.push({ file, hunks: [hunk] });
199
+ }
200
+ const serializedFiles = groups.map(({ file, hunks: fileHunks }) => {
201
+ const next = {
202
+ path: file.path,
203
+ kind: file.image ? "image" : "text",
204
+ status: file.status,
205
+ patch: file.image ? file.headerPatch : filePatchFromGit(file, fileHunks),
206
+ hunks: fileHunks.map(serializeHunk),
207
+ };
208
+ if (file.oldPath !== undefined) {
209
+ next.oldPath = file.oldPath;
210
+ }
211
+ return next;
212
+ });
213
+ return { hunks: hunks.map(serializeHunk), files: serializedFiles };
214
+ }
215
+ function serializeHunk(hunk) {
216
+ return {
217
+ ...toHunkRef(hunk),
218
+ header: hunk.header,
219
+ language: fileLanguage(hunk.path),
220
+ lines: hunk.lines,
221
+ };
222
+ }
223
+ function findFile(files, path) {
224
+ const file = files.find((item) => item.path === path || item.oldPath === path);
225
+ if (file === undefined) {
226
+ throw new ApiError(404, `path is not in the live diff: ${path}`);
227
+ }
228
+ return file;
229
+ }
230
+ function assertSideExists(file, side) {
231
+ if (side === "old" && file.status === "added") {
232
+ throw new ApiError(404, "file did not exist on the base side");
233
+ }
234
+ if (side === "new" && file.status === "deleted") {
235
+ throw new ApiError(404, "file does not exist on the head side");
236
+ }
237
+ }
238
+ function uniquePaths(hunks) {
239
+ return [...new Set(hunks.map((hunk) => hunk.path))];
240
+ }
241
+ export function snapshotJson(body) {
242
+ return `${JSON.stringify(body)}\n`;
243
+ }
244
+ export function isUnavailableSnapshot(error) {
245
+ if (error instanceof ApiError) {
246
+ return error.status === 404 || error.status === 400;
247
+ }
248
+ return error instanceof GitError;
249
+ }
@@ -0,0 +1,96 @@
1
+ export function apiHref(resource) {
2
+ switch (resource.kind) {
3
+ case "review":
4
+ return "api/review.json";
5
+ case "hunks":
6
+ return `api/hunks/${encodeURIComponent(resource.group)}.json`;
7
+ case "file":
8
+ return `api/files/${resource.side}/${encodeFilePath(resource.path)}.json`;
9
+ case "blame":
10
+ return `api/blame/${resource.side}/${encodeFilePath(resource.path)}.json`;
11
+ case "image":
12
+ return `api/images/${resource.side}/${encodeFilePath(resource.path)}`;
13
+ }
14
+ }
15
+ /** Decoded relative path under the site root. Static hosts map encoded request URLs onto this. */
16
+ export function apiFsRel(resource) {
17
+ switch (resource.kind) {
18
+ case "review":
19
+ return "api/review.json";
20
+ case "hunks":
21
+ return `api/hunks/${encodeURIComponent(resource.group)}.json`;
22
+ case "file":
23
+ return `api/files/${resource.side}/${resource.path}.json`;
24
+ case "blame":
25
+ return `api/blame/${resource.side}/${resource.path}.json`;
26
+ case "image":
27
+ return `api/images/${resource.side}/${resource.path}`;
28
+ }
29
+ }
30
+ export function parseApiPath(pathname) {
31
+ const parts = pathname.split("/").filter((part) => part !== "").map(decodeSegment);
32
+ if (parts[0] !== "api" || parts[1] === undefined) {
33
+ return undefined;
34
+ }
35
+ if (parts[1] === "review.json" && parts.length === 2) {
36
+ return { kind: "review" };
37
+ }
38
+ if (parts[1] === "hunks" && parts.length === 3 && parts[2] !== undefined) {
39
+ const group = jsonStem(parts[2]);
40
+ if (group === undefined) {
41
+ return undefined;
42
+ }
43
+ return { kind: "hunks", group };
44
+ }
45
+ if (parts[1] === "images" && parts.length >= 4 && parts[2] !== undefined) {
46
+ const side = parts[2];
47
+ if (side !== "old" && side !== "new") {
48
+ return undefined;
49
+ }
50
+ const path = parts.slice(3).join("/");
51
+ if (!isRepoPath(path)) {
52
+ return undefined;
53
+ }
54
+ return { kind: "image", path, side };
55
+ }
56
+ if ((parts[1] === "files" || parts[1] === "blame") && parts.length >= 4 && parts[2] !== undefined) {
57
+ const side = parts[2];
58
+ if (side !== "old" && side !== "new") {
59
+ return undefined;
60
+ }
61
+ const rest = parts.slice(3);
62
+ const last = rest.at(-1);
63
+ const stem = last === undefined ? undefined : jsonStem(last);
64
+ if (stem === undefined) {
65
+ return undefined;
66
+ }
67
+ rest[rest.length - 1] = stem;
68
+ const path = rest.join("/");
69
+ if (!isRepoPath(path)) {
70
+ return undefined;
71
+ }
72
+ return { kind: parts[1] === "files" ? "file" : "blame", path, side };
73
+ }
74
+ return undefined;
75
+ }
76
+ function encodeFilePath(path) {
77
+ return path.split("/").map(encodeURIComponent).join("/");
78
+ }
79
+ function decodeSegment(segment) {
80
+ try {
81
+ return decodeURIComponent(segment);
82
+ }
83
+ catch {
84
+ return segment;
85
+ }
86
+ }
87
+ function jsonStem(file) {
88
+ if (!file.endsWith(".json")) {
89
+ return undefined;
90
+ }
91
+ const stem = file.slice(0, -".json".length);
92
+ return stem === "" ? undefined : stem;
93
+ }
94
+ function isRepoPath(path) {
95
+ return path !== "" && !path.startsWith("/") && !path.includes("\0") && !path.split("/").includes("..");
96
+ }
@@ -0,0 +1,45 @@
1
+ import { cp, mkdir, rm, writeFile } from "node:fs/promises";
2
+ import { existsSync } from "node:fs";
3
+ import { dirname, join, resolve } from "node:path";
4
+ import { findPackageRoot } from "../package-root.js";
5
+ import { isUnavailableSnapshot, listResources, openReview, renderResource, snapshotJson, } from "./live.js";
6
+ import { apiFsRel } from "./paths.js";
7
+ export async function exportStaticSite(opts) {
8
+ const outDir = resolve(opts.outDir);
9
+ const uiRoot = opts.uiRoot ?? join(findPackageRoot(), "dist/ui");
10
+ if (!existsSync(uiRoot)) {
11
+ throw new Error("UI is missing from this install. Reinstall comprehende from npm, or from a git checkout run `pnpm build`.");
12
+ }
13
+ if (existsSync(join(outDir, ".git"))) {
14
+ throw new Error(`refusing to write export into a git repository: ${outDir}`);
15
+ }
16
+ const ctx = opts.ctx ?? (await openReview(opts.cwd, opts.dataPath));
17
+ await mkdir(outDir, { recursive: true });
18
+ await rm(join(outDir, "assets"), { recursive: true, force: true });
19
+ await rm(join(outDir, "api"), { recursive: true, force: true });
20
+ await cp(uiRoot, outDir, { recursive: true });
21
+ const apiFiles = [];
22
+ for (const resource of listResources(ctx)) {
23
+ let body;
24
+ try {
25
+ body = await renderResource(ctx, resource);
26
+ }
27
+ catch (error) {
28
+ if (isUnavailableSnapshot(error)) {
29
+ continue;
30
+ }
31
+ throw error;
32
+ }
33
+ const rel = apiFsRel(resource);
34
+ const abs = join(outDir, ...rel.split("/"));
35
+ await mkdir(dirname(abs), { recursive: true });
36
+ if (body.encoding === "json") {
37
+ await writeFile(abs, snapshotJson(body.body));
38
+ }
39
+ else {
40
+ await writeFile(abs, Buffer.from(body.body));
41
+ }
42
+ apiFiles.push(rel);
43
+ }
44
+ return { outDir, apiFiles };
45
+ }
@@ -0,0 +1 @@
1
+ export {};
package/dist/cli/args.js CHANGED
@@ -1,4 +1,4 @@
1
- const COMMANDS = new Set(["index", "validate", "serve"]);
1
+ const COMMANDS = new Set(["index", "validate", "serve", "export"]);
2
2
  export const USAGE = `Usage: comprehende <command> [options]
3
3
 
4
4
  Run inside the git repository under review. Cwd is the repo.
@@ -13,16 +13,21 @@ Commands:
13
13
  serve --data <review.json> [--port <n>] [--open]
14
14
  Serve the local UI on 127.0.0.1 (re-reads git on each request)
15
15
 
16
+ export --data <review.json> --out <dir>
17
+ Write a static site (same UI + frozen git payloads). No server after that.
18
+
16
19
  Options:
17
20
  --base <ref> Base ref (default: origin/HEAD or main/master)
18
21
  --head <ref> Head ref (default: HEAD)
19
22
  --data <path> Review document path
23
+ --out <dir> Output directory for export
20
24
  --port <n> Listen port (default: 4567, 0 for ephemeral)
21
25
  --open Open the UI in a browser
22
26
  -h, --help Show this help
23
27
  -v, --version Show version
24
28
 
25
29
  Diffs always come from git in cwd. The review document is interpretation only.
30
+ Export is a point-in-time copy. Rebase or new commits need a new export.
26
31
  `;
27
32
  export function parseArgv(argv, cwd = process.cwd()) {
28
33
  const args = argv[0] === "--" ? argv.slice(1) : [...argv];
@@ -52,6 +57,7 @@ export function parseArgv(argv, cwd = process.cwd()) {
52
57
  base: flag(rest, "--base"),
53
58
  head: flag(rest, "--head"),
54
59
  data: flag(rest, "--data"),
60
+ out: flag(rest, "--out"),
55
61
  port,
56
62
  open: rest.includes("--open"),
57
63
  };
@@ -28,6 +28,12 @@ export function resolveDataPath(data, cwd) {
28
28
  }
29
29
  return isAbsolute(data) ? data : resolve(cwd, data);
30
30
  }
31
+ export function resolveOutPath(out, cwd) {
32
+ if (out === undefined) {
33
+ throw new Error("missing --out <dir>");
34
+ }
35
+ return isAbsolute(out) ? out : resolve(cwd, out);
36
+ }
31
37
  export async function cmdValidate(cwd, dataPath) {
32
38
  const document = await loadDocument(dataPath);
33
39
  await resolveSource(cwd, document.source.baseRef, document.source.headRef);
package/dist/cli/main.js CHANGED
@@ -4,9 +4,10 @@ import { realpathSync } from "node:fs";
4
4
  import { resolve } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { parseArgv, USAGE } from "./args.js";
7
- import { cmdIndex, cmdValidate, loadDocument, resolveDataPath } from "./commands.js";
8
- import { resolveSource } from "../git/diff.js";
9
- import { coverReview, coverageErrors } from "../review/coverage.js";
7
+ import { cmdIndex, cmdValidate, resolveDataPath, resolveOutPath } from "./commands.js";
8
+ import { exportStaticSite } from "../api/snapshot.js";
9
+ import { openReview } from "../api/live.js";
10
+ import { coverageErrors } from "../review/coverage.js";
10
11
  import { assertWorkTree } from "../git/repo.js";
11
12
  import { readPackageVersion } from "../package-root.js";
12
13
  import { startServer } from "../server/http.js";
@@ -41,13 +42,8 @@ export async function run(argv) {
41
42
  }
42
43
  case "serve": {
43
44
  const dataPath = resolveDataPath(request.data, request.cwd);
44
- const document = await loadDocument(dataPath);
45
- await resolveSource(request.cwd, document.source.baseRef, document.source.headRef);
46
- const { coverage } = await coverReview(request.cwd, document);
47
- const problems = coverageErrors(coverage);
48
- if (problems.length > 0) {
49
- console.error(`coverage issues (serve continues; git wins, unassigned/stale are visible):\n${problems.join("\n\n")}`);
50
- }
45
+ const ctx = await openReview(request.cwd, dataPath);
46
+ warnCoverage(ctx.coverage, "serve continues; git wins, unassigned/stale are visible");
51
47
  const running = await startServer({ cwd: request.cwd, dataPath, port: request.port });
52
48
  console.log(running.url);
53
49
  console.error(`serving ${dataPath} cwd=${request.cwd} localhost only`);
@@ -57,6 +53,16 @@ export async function run(argv) {
57
53
  await waitForClose(running.server);
58
54
  return 0;
59
55
  }
56
+ case "export": {
57
+ const dataPath = resolveDataPath(request.data, request.cwd);
58
+ const outDir = resolveOutPath(request.out, request.cwd);
59
+ const ctx = await openReview(request.cwd, dataPath);
60
+ warnCoverage(ctx.coverage, "export continues; git wins, unassigned/stale are visible");
61
+ const result = await exportStaticSite({ cwd: request.cwd, dataPath, outDir, ctx });
62
+ console.log(result.outDir);
63
+ console.error(`exported ${dataPath} ${result.apiFiles.length} api files no git in the folder`);
64
+ return 0;
65
+ }
60
66
  }
61
67
  }
62
68
  catch (error) {
@@ -64,6 +70,12 @@ export async function run(argv) {
64
70
  return 1;
65
71
  }
66
72
  }
73
+ function warnCoverage(coverage, note) {
74
+ const problems = coverageErrors(coverage);
75
+ if (problems.length > 0) {
76
+ console.error(`coverage issues (${note}):\n${problems.join("\n\n")}`);
77
+ }
78
+ }
67
79
  function waitForClose(server) {
68
80
  return new Promise((resolve) => {
69
81
  const shutdown = () => {
@@ -0,0 +1,21 @@
1
+ import { imageMediaType } from "../schema/image.js";
2
+ import { gitBuffer } from "./exec.js";
3
+ import { parseLfsPointer, readLfsObject } from "./lfs.js";
4
+ import { assertSafePath, assertSafeRef } from "./repo.js";
5
+ export async function readBlob(cwd, ref, path) {
6
+ assertSafeRef(ref);
7
+ assertSafePath(path);
8
+ return gitBuffer(cwd, ["cat-file", "blob", `${ref}:${path}`]);
9
+ }
10
+ export async function readImageBlob(cwd, ref, path) {
11
+ const blob = await readBlob(cwd, ref, path);
12
+ const pointer = parseLfsPointer(blob);
13
+ if (pointer === undefined) {
14
+ return { ok: true, bytes: blob, mediaType: imageMediaType(path), lfs: false };
15
+ }
16
+ const object = await readLfsObject(cwd, pointer);
17
+ if (object === undefined) {
18
+ return { ok: false, oid: pointer.oid };
19
+ }
20
+ return { ok: true, bytes: object, mediaType: imageMediaType(path), lfs: true };
21
+ }
package/dist/git/diff.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { isImagePath, isLfsPointerText } from "../schema/image.js";
1
2
  import { git } from "./exec.js";
2
3
  import { rangeLabel, resolveCommit } from "./repo.js";
3
4
  const HUNK_HEADER = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/;
@@ -23,7 +24,7 @@ export async function readDiff(cwd, baseRef, headRef) {
23
24
  "--end-of-options",
24
25
  `${baseRef}...${headRef}`,
25
26
  ]);
26
- return parseUnifiedDiff(stdout);
27
+ return classifyDiffFiles(parseUnifiedDiff(stdout));
27
28
  }
28
29
  export async function readHunkIndex(cwd, baseRef, headRef) {
29
30
  const { source } = await resolveSource(cwd, baseRef, headRef);
@@ -31,6 +32,12 @@ export async function readHunkIndex(cwd, baseRef, headRef) {
31
32
  const hunks = [];
32
33
  const skipped = [];
33
34
  for (const file of files) {
35
+ if (file.image) {
36
+ for (const hunk of file.hunks) {
37
+ hunks.push(toHunkRef(hunk));
38
+ }
39
+ continue;
40
+ }
34
41
  if (file.binary) {
35
42
  skipped.push({ path: file.path, reason: "binary" });
36
43
  continue;
@@ -196,6 +203,7 @@ class FileBuilder {
196
203
  path,
197
204
  status,
198
205
  binary: this.binary,
206
+ image: false,
199
207
  headerPatch: this.headerPatch,
200
208
  patch: this.patch,
201
209
  hunks,
@@ -282,14 +290,48 @@ function stripDiffPath(raw) {
282
290
  export function flattenHunks(files) {
283
291
  const hunks = [];
284
292
  for (const file of files) {
285
- if (file.binary) {
293
+ if (file.binary && !file.image) {
286
294
  continue;
287
295
  }
288
296
  hunks.push(...file.hunks);
289
297
  }
290
298
  return hunks;
291
299
  }
300
+ export function classifyDiffFiles(files) {
301
+ return files.map(classifyDiffFile);
302
+ }
303
+ function classifyDiffFile(file) {
304
+ if (!isImagePath(file.path) && (file.oldPath === undefined || !isImagePath(file.oldPath))) {
305
+ return file;
306
+ }
307
+ if (file.binary || isLfsPointerText(file.patch)) {
308
+ return asImageFile(file);
309
+ }
310
+ return file;
311
+ }
312
+ function asImageFile(file) {
313
+ return { ...file, image: true, hunks: [imageLiveHunk(file.path, file.oldPath)] };
314
+ }
315
+ export function imageLiveHunk(path, oldPath) {
316
+ const hunk = {
317
+ path,
318
+ oldStart: 0,
319
+ oldLines: 0,
320
+ newStart: 0,
321
+ newLines: 0,
322
+ header: "image",
323
+ lines: [],
324
+ patch: "",
325
+ };
326
+ if (oldPath !== undefined) {
327
+ hunk.oldPath = oldPath;
328
+ }
329
+ return hunk;
330
+ }
292
331
  export function fileLanguage(path) {
332
+ if (isImagePath(path)) {
333
+ return "image";
334
+ }
293
335
  const ext = path.split(".").pop()?.toLowerCase();
294
336
  switch (ext) {
295
337
  case "ts":