Z ZPI 文档 v0.3.8
介绍页

源码

packages/desktop/src/main/git-review.ts

typescript275 行查看原文
import { stat } from "node:fs/promises";
import { relative, resolve } from "node:path";
import { readFileBounded } from "@zpi-dev/coding-agent";
import type { DiffItem } from "../shared/bridge.ts";
import type { ReviewDiffData, ReviewSnapshot, ReviewSource } from "../shared/review.ts";
import { isPathInside } from "./path-bounds.ts";
import { workspaceGitStatus, workspaceGitWatchPaths } from "./workspace-directory.ts";
import { runGit } from "./workspace-files.ts";

const previewBytes = 1024 * 1024;

async function comparison(cwd: string, source: ReviewSource): Promise<string[]> {
  if (source === "staged") return ["--cached"];
  if (source !== "branch") return [];
  try {
    // 与 ZCode 相同:比较上游分支的 merge-base 与 HEAD,不混入工作区改动。
    const upstream = (await runGit(cwd, ["rev-parse", "--abbrev-ref", "@{upstream}"])).trim();
    return [`${upstream}...HEAD`];
  } catch {
    return [];
  }
}

function parseStats(output: string, root: string): DiffItem[] {
  const records = output.split("\0");
  const entries: DiffItem[] = [];
  const statuses = new Map<string, DiffItem["status"]>();
  for (let i = 0; i < records.length; i++) {
    if (records[i].startsWith(":")) {
      const flag = records[i].split(" ").at(-1)?.[0];
      let path = records[++i];
      if (flag === "R" || flag === "C") path = records[++i];
      statuses.set(
        resolve(root, path),
        flag === "A"
          ? "created"
          : flag === "D"
            ? "deleted"
            : flag === "R" || flag === "C"
              ? "renamed"
              : "modified",
      );
      continue;
    }
    const match = /^(\d+|-)\t(\d+|-)\t([\s\S]*)$/.exec(records[i]);
    if (!match) continue;
    let path = match[3];
    let oldPath: string | undefined;
    if (!path) {
      oldPath = resolve(root, records[++i]);
      path = records[++i];
    }
    const binary = match[1] === "-";
    const added = Number(match[1]) || 0;
    const removed = Number(match[2]) || 0;
    entries.push({
      id: `file:${resolve(root, path)}`,
      path: resolve(root, path),
      oldPath,
      status: statuses.get(resolve(root, path)) ?? "modified",
      area: "",
      added,
      removed,
      patchAvailable: !binary,
      ...(binary ? { reason: "无法预览二进制 Diff" } : {}),
    });
  }
  return [...new Map(entries.map((entry) => [entry.path, entry])).values()];
}

export async function gitReview(
  cwd: string,
  source: ReviewSource,
  onlyPath?: string,
): Promise<ReviewSnapshot> {
  if (onlyPath && !isPathInside(resolve(cwd), resolve(onlyPath)))
    throw new Error("invalid_input: 文件不在当前工作区内");
  const status = await workspaceGitStatus(cwd);
  if (!status.summary.isRepository)
    return {
      entries: [],
      isRepository: false,
      ...(!status.summary.isGitAvailable ? { isGitAvailable: false } : {}),
    };
  const root = (await runGit(cwd, ["rev-parse", "--show-toplevel"])).trim();
  const watchPaths = await workspaceGitWatchPaths(cwd);
  const refs = await comparison(cwd, source);
  if (source === "branch" && !refs.length) return { entries: [], isRepository: true, watchPaths };
  let entries = parseStats(
    await runGit(cwd, [
      "--literal-pathspecs",
      "diff",
      "--no-ext-diff",
      "--no-textconv",
      "--no-color",
      "--raw",
      "--numstat",
      "-z",
      "--find-renames",
      ...refs,
      "--",
      ".",
    ]),
    root,
  );
  const changes = source === "staged" ? status.stagedChanges : status.unstagedChanges;
  const changesByPath = new Map(changes.map((change) => [change.path, change]));
  if (source === "staged") entries = entries.filter((entry) => !changesByPath.get(entry.path)?.isConflicted);
  for (const entry of entries) {
    const change = changesByPath.get(entry.path);
    if (source !== "branch" && change) {
      entry.area = change.isConflicted ? "conflicted" : source;
      if (change.isConflicted) {
        entry.added = 0;
        entry.removed = 0;
      }
    }
  }
  if (source === "unstaged") {
    for (const change of changes.filter(
      (change) => change.isUntracked && (!onlyPath || change.path === onlyPath),
    )) {
      // ZCode excludes untracked entries with no reviewable text (empty, binary, too large or unreadable).
      let text: string;
      try {
        const info = await stat(change.path);
        if (!info.isFile() || info.size > previewBytes) continue;
        const bytes = await readFileBounded(change.path, previewBytes);
        if (bytes.includes(0)) continue;
        text = bytes.toString("utf8");
        if (!text) continue;
      } catch {
        continue;
      }
      entries.push({
        id: `file:${change.path}`,
        path: change.path,
        status: "created",
        area: "untracked",
        added: text.split("\n").length - (text.endsWith("\n") ? 1 : 0),
        removed: 0,
        patchAvailable: true,
      });
    }
  }
  return {
    entries: entries
      .filter((entry) => !onlyPath || entry.path === onlyPath)
      .sort((a, b) => {
        const rank = (entry: DiffItem) =>
          entry.area === "conflicted" ? 2 : entry.area === "untracked" ? 1 : 0;
        return rank(a) - rank(b) || a.path.localeCompare(b.path);
      }),
    isRepository: true,
    watchPaths,
  };
}

/** Reuse the entry already validated by this request; do not rescan the repository for each layer. */
async function reviewPatch(root: string, refs: string[], entry: DiffItem): Promise<string> {
  const path = entry.path;
  const file = relative(root, path);
  if (entry.area === "untracked") {
    const info = await stat(path);
    if (!info.isFile() || info.size > previewBytes) throw new Error("invalid_input: 文件过大");
    const bytes = await readFileBounded(path, previewBytes);
    if (bytes.includes(0)) return "";
    const text = bytes.toString("utf8").replaceAll("\r\n", "\n");
    const lines = text ? text.split("\n") : [];
    if (text.endsWith("\n")) lines.pop();
    const patch = ["--- /dev/null", `+++ b/${file.replaceAll("\\", "/")}`];
    if (lines.length) {
      patch.push(`@@ -0,0 +1,${lines.length} @@`, ...lines.map((line) => `+${line}`));
      if (!text.endsWith("\n")) patch.push("\\ No newline at end of file");
    }
    return `${patch.join("\n")}\n`;
  }
  return runGit(root, [
    "--literal-pathspecs",
    "diff",
    "--no-ext-diff",
    "--no-textconv",
    "--no-color",
    "--find-renames",
    ...refs,
    "--",
    file,
    ...(entry.oldPath ? [relative(root, entry.oldPath)] : []),
  ]);
}

async function gitBlob(cwd: string, ref: string, path: string): Promise<string | null> {
  try {
    const text = await runGit(cwd, ["show", `${ref}:${path}`]);
    return Buffer.byteLength(text) <= previewBytes && !text.includes("\0") ? text : null;
  } catch {
    return null;
  }
}
async function workingContent(path: string): Promise<string | null> {
  try {
    const info = await stat(path);
    if (!info.isFile() || info.size > previewBytes) return null;
    const text = (await readFileBounded(path, previewBytes)).toString("utf8");
    return text.includes("\0") ? null : text;
  } catch {
    return null;
  }
}
export async function gitReviewDiff(
  cwd: string,
  source: ReviewSource,
  path: string,
): Promise<ReviewDiffData> {
  const entry = (await gitReview(cwd, source, path)).entries.find((entry) => entry.path === path);
  const empty: ReviewDiffData = {
    path,
    availability: "unavailable",
    patch: null,
    beforeContent: null,
    afterContent: null,
    summary: entry?.reason ?? null,
  };
  if (!entry) return empty;
  if (entry.reason)
    return {
      ...empty,
      availability:
        entry.reason === "Diff 已裁剪"
          ? "truncated"
          : entry.reason.includes("二进制")
            ? "binary"
            : "unavailable",
    };
  let patch: string;
  let root: string;
  let refs: string[];
  try {
    root = (await runGit(cwd, ["rev-parse", "--show-toplevel"])).trim();
    refs = await comparison(cwd, source);
    if (source === "branch" && !refs.length) throw new Error("not_found: 上游分支不可用");
    patch = await reviewPatch(root, refs, entry);
  } catch (error) {
    if ((error as NodeJS.ErrnoException).code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER")
      return { ...empty, availability: "truncated" };
    return { ...empty, summary: "无法读取此文件的 Git diff" };
  }
  if (!patch) return empty;
  if (Buffer.byteLength(patch) > previewBytes) return { ...empty, availability: "truncated" };
  const file = relative(root, path).replaceAll("\\", "/");
  const beforeFile = relative(root, entry.oldPath ?? path).replaceAll("\\", "/");
  let before: string | null;
  let after: string | null;
  if (source === "branch") {
    const base = (await runGit(cwd, ["merge-base", "@{upstream}", "HEAD"])).trim();
    [before, after] = await Promise.all([gitBlob(root, base, beforeFile), gitBlob(root, "HEAD", file)]);
  } else if (source === "staged") {
    [before, after] = await Promise.all([gitBlob(root, "HEAD", beforeFile), gitBlob(root, "", file)]);
  } else {
    [before, after] = await Promise.all([
      entry.area === "untracked" ? Promise.resolve("") : gitBlob(root, "", beforeFile),
      workingContent(path),
    ]);
  }
  // ZCode 只有两侧全文都可用时才启用 MultiFileDiff;缺一侧保留正确 patch,不补造空文件。
  const complete = before !== null && after !== null;
  return {
    ...empty,
    availability: "patch",
    patch,
    beforeContent: complete ? before : null,
    afterContent: complete ? after : null,
  };
}