calldiff
§ · Nested detail page

Git 快照与命令运行器 深度解析

这一页拆 git.ts 和 run.ts:calldiff 如何把 HEAD、worktree、commit、路径过滤和 blob 批量读取整理成统一快照。

§ 01 · SNAPSHOT

先把 git 参数归一化

▸diff defaults 与路径歧义

src/git.ts:33 定义 from/to 快照默认值,src/git.ts:71 的 resolveDiffSnapshotsAndPaths 处理“一个 positional 到底是 ref 还是 path”的歧义。src/git.ts:81 先判 commit,src/git.ts:85 再判磁盘路径,符合 README 里的 git-diff shaped 语义。

src/git.ts · L33-109TypeScript
33export function resolveSnapshots(
34  from: string | undefined,
35  to: string | undefined,
36): { from: Snapshot; to: Snapshot } {
37  // git-diff defaults: no args → HEAD vs worktree; one arg → that vs worktree
38  const left: Snapshot = {
39    kind: "commit",
40    ref: from ?? "HEAD",
41  };
42  const right: Snapshot =
43    to === undefined
44      ? { kind: "worktree", ref: "WORKTREE" }
45      : { kind: "commit", ref: to };
46  return { from: left, to: right };
47}
48
49function isCommitRef(cwd: string, ref: string): boolean {
50  try {
51    git(cwd, ["rev-parse", "--verify", `${ref}^{commit}`]);
52    return true;
53  } catch {
54    return false;
55  }
56}
57
58function isPathOnDisk(cwd: string, value: string): boolean {
59  return existsSync(resolve(cwd, value));
60}
61
62/**
63 * Resolve diff from/to/paths with git-diff defaults, treating on-disk path
64 * positionals as path filters when they are not valid git refs.
65 *
66 * Examples:
67 * - `diff main src` → main vs worktree, paths=[src]
68 * - `diff main feature src` → main vs feature, paths=[src]
69 * - `diff src` → HEAD vs worktree, paths=[src]
70 */
71export function resolveDiffSnapshotsAndPaths(
72  cwd: string,
73  from: string | undefined,
74  to: string | undefined,
75  paths: string[],
76): { from: Snapshot; to: Snapshot; paths: string[] } {
77  if (from === undefined && to === undefined) {
78    return { ...resolveSnapshots(undefined, undefined), paths };
79  }
80
81  if (from !== undefined && to === undefined) {
82    if (isCommitRef(cwd, from)) {
83      return { ...resolveSnapshots(from, undefined), paths };
84    }
85    if (isPathOnDisk(cwd, from)) {
86      return { ...resolveSnapshots(undefined, undefined), paths: [from, ...paths] };
87    }
88    throw new Error(`Unknown git ref: ${from}`);
89  }
90
91  if (from !== undefined && to !== undefined) {
92    if (!isCommitRef(cwd, from)) {
93      throw new Error(`Unknown git ref: ${from}`);
94    }
95    if (isCommitRef(cwd, to)) {
96      return { ...resolveSnapshots(from, to), paths };
97    }
98    if (isPathOnDisk(cwd, to)) {
99      return {
100        ...resolveSnapshots(from, undefined),
101        paths: [to, ...paths],
102      };
103    }
104    throw new Error(`Unknown git ref: ${to}`);
105  }
106
107  // to without from shouldn't happen via CLI positionals, but honor options.
108  return { ...resolveSnapshots(from, to), paths };
109}

▸tree/reach 只需要一个 snapshot

src/git.ts:111 之后是单快照解析;src/git.ts:124 允许 tree/reach 的第一个 positional 被解释成 ref,也允许不是 ref 但存在于磁盘时被解释成 path filter。这个细节让命令行体验和 diff 保持一致。

src/git.ts · L111-155TypeScript
111/** Single snapshot for `calldiff tree` / `reach` — no ref → working tree. */
112export function resolveSnapshot(ref: string | undefined): Snapshot {
113  if (ref === undefined) {
114    return { kind: "worktree", ref: "WORKTREE" };
115  }
116  return { kind: "commit", ref };
117}
118
119/**
120 * Resolve optional ref + path filters for tree/reach.
121 * A lone positional that isn't a git ref but exists on disk is treated as a
122 * path filter on the working tree (`calldiff tree -e foo src/lib`).
123 */
124export function resolveSnapshotAndPaths(
125  cwd: string,
126  ref: string | undefined,
127  paths: string[],
128): { snapshot: Snapshot; paths: string[] } {
129  if (ref === undefined) {
130    return { snapshot: resolveSnapshot(undefined), paths };
131  }
132
133  try {
134    git(cwd, ["rev-parse", "--verify", `${ref}^{commit}`]);
135    return { snapshot: resolveSnapshot(ref), paths };
136  } catch {
137    const abs = resolve(cwd, ref);
138    if (existsSync(abs)) {
139      return {
140        snapshot: resolveSnapshot(undefined),
141        paths: [ref, ...paths],
142      };
143    }
144    throw new Error(`Unknown git ref: ${ref}`);
145  }
146}
147
148export function verifyCommit(cwd: string, ref: string): void {
149  try {
150    git(cwd, ["rev-parse", "--verify", `${ref}^{commit}`]);
151  } catch {
152    throw new Error(`Unknown git ref: ${ref}`);
153  }
154}
155
§ 02 · FILES

文件枚举与 blob 读取

▸只纳入支持扩展名

src/git.ts:156 用 registry 暴露的扩展名集合判断 source file,src/git.ts:181 从工作区 git ls-files 读取 tracked + untracked 文件,src/git.ts:199 从 commit ls-tree 读取 blob metadata。这个选择确保 before/after 快照都走同一套过滤逻辑。

src/git.ts · L156-228TypeScript
156const SOURCE_EXT = new Set(listSupportedExtensions());
157
158function isSourceFile(path: string): boolean {
159  const lower = path.toLowerCase();
160  if (lower.endsWith(".d.ts")) return false;
161  const dot = lower.lastIndexOf(".");
162  if (dot < 0) return false;
163  return SOURCE_EXT.has(lower.slice(dot));
164}
165
166export interface SnapshotFile {
167  path: string;
168  /** Git blob metadata. Worktree files do not have it. */
169  oid?: string;
170  size?: number;
171}
172
173function isRegularFile(path: string): boolean {
174  try {
175    return lstatSync(path).isFile();
176  } catch {
177    return false;
178  }
179}
180
181function listWorktreeFiles(cwd: string): SnapshotFile[] {
182  const output = git(cwd, [
183    "ls-files",
184    "-z",
185    "--cached",
186    "--others",
187    "--exclude-standard",
188  ]);
189
190  return output
191    .split("\0")
192    .filter(
193      (path) =>
194        path && isSourceFile(path) && isRegularFile(resolve(cwd, path)),
195    )
196    .map((path) => ({ path }));
197}
198
199function listCommitFiles(cwd: string, ref: string): SnapshotFile[] {
200  const output = git(cwd, ["ls-tree", "-r", "-z", "-l", ref]);
201  const files: SnapshotFile[] = [];
202
203  for (const record of output.split("\0")) {
204    if (!record) continue;
205
206    const tab = record.indexOf("\t");
207    if (tab < 0) continue;
208
209    const [mode, type, oid, sizeText] = record
210      .slice(0, tab)
211      .trim()
212      .split(/\s+/);
213    const path = record.slice(tab + 1);
214    const regular = mode === "100644" || mode === "100755";
215    const size = Number(sizeText);
216    if (
217      regular &&
218      type === "blob" &&
219      oid &&
220      Number.isInteger(size) &&
221      isSourceFile(path)
222    ) {
223      files.push({ path, oid, size });
224    }
225  }
226
227  return files;
228}

▸commit blob 批量读取

src/git.ts:274 设置 32MB batch,src/git.ts:278 对 oid 去重分块,src/git.ts:302 用 git cat-file --batch 一次性读多个 blob。这比逐文件 git show 更适合大仓库和多语言扫描。

src/git.ts · L274-344TypeScript
274const BATCH_BYTES = 32 * 1024 * 1024;
275
276type Blob = { oid: string; size: number };
277
278function chunkBlobs(files: SnapshotFile[]): Blob[][] {
279  const unique = new Map<string, number>();
280  for (const file of files) {
281    if (file.oid && file.size !== undefined && !unique.has(file.oid)) {
282      unique.set(file.oid, file.size);
283    }
284  }
285
286  const chunks: Blob[][] = [];
287  let chunk: Blob[] = [];
288  let bytes = 0;
289  for (const [oid, size] of unique) {
290    if (chunk.length > 0 && bytes + size > BATCH_BYTES) {
291      chunks.push(chunk);
292      chunk = [];
293      bytes = 0;
294    }
295    chunk.push({ oid, size });
296    bytes += size;
297  }
298  if (chunk.length > 0) chunks.push(chunk);
299  return chunks;
300}
301
302function readBlobBatch(cwd: string, batch: Blob[]): Map<string, string> {
303  const input = Buffer.from(`${batch.map((blob) => blob.oid).join("\n")}\n`);
304  const bytes = batch.reduce((total, blob) => total + blob.size, 0);
305  const maxBuffer = bytes + batch.length * 128 + 1024;
306  const output = gitBuffer(cwd, ["cat-file", "--batch"], input, maxBuffer);
307  const blobs = new Map<string, string>();
308  let offset = 0;
309
310  while (offset < output.length) {
311    const headerEnd = output.indexOf(0x0a, offset);
312    if (headerEnd < 0) throw new Error("Invalid git cat-file response");
313
314    const header = output.subarray(offset, headerEnd).toString("ascii");
315    const [oid, type, sizeText] = header.split(" ");
316    const size = Number(sizeText);
317    if (!oid || type !== "blob" || !Number.isInteger(size)) {
318      throw new Error(`Invalid git cat-file header: ${header}`);
319    }
320
321    const contentStart = headerEnd + 1;
322    const contentEnd = contentStart + size;
323    if (contentEnd >= output.length) {
324      throw new Error(`Truncated git blob: ${oid}`);
325    }
326
327    blobs.set(oid, output.subarray(contentStart, contentEnd).toString("utf8"));
328    offset = contentEnd + 1;
329  }
330
331  return blobs;
332}
333
334export function visitCommitBlobs(
335  cwd: string,
336  files: SnapshotFile[],
337  visit: (oid: string, source: string) => void,
338): void {
339  for (const batch of chunkBlobs(files)) {
340    for (const [oid, source] of readBlobBatch(cwd, batch)) {
341      visit(oid, source);
342    }
343  }
344}
§ 03 · RUNNER

run.ts 如何复用快照能力

▸loadIndex 是唯一入口

src/run.ts:64 的 loadIndex 先调用 src/git.ts:242 获取 SnapshotFile,再按 snapshot.kind 分派到 src/git.ts:346 的工作区读取或 src/git.ts:334 的 commit blob 访问。调用方 diff/tree/reach 都只拿 FunctionIndex,不直接碰 git。

src/run.ts · L64-103TypeScript
64function loadIndex(
65  cwd: string,
66  snapshot: Snapshot,
67  pathFilters: string[],
68  cache: ExtractionCache = new Map(),
69): FunctionIndex {
70  const files = listSnapshotFiles(cwd, snapshot, pathFilters);
71  const extracted = new Map<string, FunctionInfo[]>();
72  const extract = (file: SnapshotFile, source: string): void => {
73    try {
74      extracted.set(file.path, extractCached(file.path, source, cache));
75    } catch (error) {
76      const message = error instanceof Error ? error.message : String(error);
77      console.error(
78        `warn: failed to parse ${file.path} @ ${snapshot.ref}: ${message}`,
79      );
80    }
81  };
82
83  if (snapshot.kind === "worktree") {
84    visitWorktreeFiles(cwd, files, extract);
85  } else {
86    const filesByOid = new Map<string, SnapshotFile[]>();
87    for (const file of files) {
88      if (!file.oid) continue;
89      const matches = filesByOid.get(file.oid) ?? [];
90      matches.push(file);
91      filesByOid.set(file.oid, matches);
92    }
93    visitCommitBlobs(cwd, files, (oid, source) => {
94      for (const file of filesByOid.get(oid) ?? []) extract(file, source);
95    });
96  }
97
98  const functions: FunctionInfo[] = [];
99  for (const file of files) {
100    functions.push(...(extracted.get(file.path) ?? []));
101  }
102  return buildIndex(functions);
103}

▸CLI 错误边界在命令层

src/cli.ts:175 的 diff command 捕获 runDiff 异常并包装成 DIFF_FAILED,src/cli.ts:238 的 tree command 也在命令层处理 MISSING_ENTRY 和 TREE_FAILED。run 层抛具体错误,CLI 层负责用户可读的 exit code。

src/cli.ts · L175-260TypeScript
175    run(c) {
176      const entries = entriesFromOption(c.options.entry);
177      let result: DiffResult;
178      try {
179        result = runDiff({
180          from: c.options.from ?? c.args.from,
181          to: c.options.to ?? c.args.to,
182          entries,
183          paths: c.args.paths,
184          maxDepth: c.options.maxDepth,
185          locs: c.options.locs,
186          color: !c.formatExplicit && !c.agent,
187        });
188      } catch (error) {
189        return c.error({
190          code: "DIFF_FAILED",
191          message: error instanceof Error ? error.message : String(error),
192          exitCode: 1,
193        });
194      }
195
196      const cta =
197        result.trees.length > 0
198          ? {
199              cta: {
200                commands: result.trees.slice(0, 3).map((t) => ({
201                  command: "tree",
202                  options: { entry: t.entry },
203                  description: `View full call tree for ${t.entry}`,
204                })),
205              },
206            }
207          : undefined;
208
209      return emitAsciiOrData(c, result, cta);
210    },
211  })
212  .command("tree", {
213    description: "View a call tree (no diff) for one or more entrypoints",
214    args: z.object({
215      ref: z
216        .string()
217        .optional()
218        .describe("Git ref (default: working tree)"),
219      paths: pathsArg,
220    }),
221    options: z.object({
222      entry: entryOption,
223      maxDepth: maxDepthOption,
224      locs: locsOption,
225    }),
226    alias: { entry: "e" },
227    examples: [
228      {
229        description: "Tree from working tree",
230        options: { entry: "createAgentSession" },
231      },
232      {
233        description: "Tree from a commit",
234        args: { ref: "HEAD" },
235        options: { entry: "PiService.createAgentSession" },
236      },
237    ],
238    run(c) {
239      const entries = entriesFromOption(c.options.entry) ?? [];
240      if (entries.length === 0) {
241        return c.error({
242          code: "MISSING_ENTRY",
243          message: "calldiff tree requires --entry / -e",
244          exitCode: 2,
245        });
246      }
247
248      try {
249        const result = runTree({
250          ref: c.args.ref,
251          entries,
252          paths: c.args.paths,
253          maxDepth: c.options.maxDepth,
254          locs: c.options.locs,
255          color: !c.formatExplicit && !c.agent,
256        });
257        return emitAsciiOrData(c, result);
258      } catch (error) {
259        return c.error({
260          code: "TREE_FAILED",
§ 04 · OUTPUT

输出策略照顾人和 Agent

▸ASCII fast path 与 structured format

src/cli.ts:73 的 emitAsciiOrData 让默认输出直接写 stdout,避免 incur 在附 CTA 时包一层对象;但 src/cli.ts:89 又在 explicit format 时返回结构化结果。这个细节让人类终端和 agent JSON 消费都能用同一套 command。

src/cli.ts · L73-98TypeScript
73/**
74 * Default: classic ASCII (TTY + pipes) with optional CTAs.
75 * `--format` / `--json`: structured envelope for agents.
76 *
77 * The ASCII path writes to stdout itself so that piped output stays raw text
78 * (incur wraps a scalar payload as `{ data, cta }` once a CTA is attached).
79 * `--token-count` / `--token-limit` / `--token-offset` act on what a command
80 * *returns*, though, so under those flags we hand the ASCII back to incur and
81 * let it do the counting and slicing. The CTA is dropped there on purpose: it
82 * keeps the counted output exactly the tree the user asked about.
83 */
84function emitAsciiOrData(
85  c: EmitContext,
86  result: DiffResult | TreeResult | ReachResult,
87  cta?: CtaMeta,
88): unknown {
89  if (!c.formatExplicit) {
90    if (tokenFlagActive) return result.ascii;
91    process.stdout.write(
92      result.ascii.endsWith("\n") ? result.ascii : `${result.ascii}\n`,
93    );
94    if (cta) return c.ok({}, cta);
95    return;
96  }
97  return result;
98}
§ 05 · RISKS

运行器边界

▸依赖本地 git 与语法包

src/git.ts:7 的 gitBuffer 依赖本机 git,src/languages/grammars.ts:132 在缺 grammar 时还会 npm install。离线环境需要提前准备 grammar cache,否则首次运行多语言项目会把网络和 native module 问题暴露给用户。

src/git.ts · L7-31TypeScript
7function gitBuffer(
8  cwd: string,
9  args: string[],
10  input?: Buffer,
11  maxBuffer = 64 * 1024 * 1024,
12): Buffer {
13  return execFileSync("git", args, {
14    cwd,
15    input,
16    maxBuffer,
17    stdio: ["pipe", "pipe", "pipe"],
18  });
19}
20
21function git(cwd: string, args: string[]): string {
22  return gitBuffer(cwd, args).toString("utf8");
23}
24
25export function assertGitRepo(cwd: string): void {
26  try {
27    git(cwd, ["rev-parse", "--is-inside-work-tree"]);
28  } catch {
29    throw new Error(`Not a git repository: ${cwd}`);
30  }
31}
Generated · 2026-08-11Source · tanishqkancharla/calldiff