Git 快照与命令运行器 深度解析
这一页拆 git.ts 和 run.ts:calldiff 如何把 HEAD、worktree、commit、路径过滤和 blob 批量读取整理成统一快照。
先把 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 语义。
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 保持一致。
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
文件枚举与 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 快照都走同一套过滤逻辑。
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 更适合大仓库和多语言扫描。
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}
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。
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。
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",
输出策略照顾人和 Agent
▸ASCII fast path 与 structured format
src/cli.ts:73 的 emitAsciiOrData 让默认输出直接写 stdout,避免 incur 在附 CTA 时包一层对象;但 src/cli.ts:89 又在 explicit format 时返回结构化结果。这个细节让人类终端和 agent JSON 消费都能用同一套 command。
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}
运行器边界
▸依赖本地 git 与语法包
src/git.ts:7 的 gitBuffer 依赖本机 git,src/languages/grammars.ts:132 在缺 grammar 时还会 npm install。离线环境需要提前准备 grammar cache,否则首次运行多语言项目会把网络和 native module 问题暴露给用户。
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}