diff --git a/src/server/git/gitService.ts b/src/server/git/gitService.ts index 9dd2199..8bf33bb 100644 --- a/src/server/git/gitService.ts +++ b/src/server/git/gitService.ts @@ -1,5 +1,6 @@ import { createHash } from "node:crypto"; import { spawn } from "node:child_process"; +import { existsSync } from "node:fs"; import { join } from "node:path"; import type { GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse } from "../../shared/apiTypes.js"; import { normalizeRelativePath } from "../workspaces/pathSafety.js"; @@ -49,34 +50,18 @@ export async function gitStatus(cwd: string): Promise { * unchanged) is intentionally not surfaced as a pointer entry. */ async function expandSubmodules(cwd: string, parsed: ParsedStatus, topRaw: string): Promise { - const files: GitStatusFile[] = [...parsed.files]; - const submodulePaths: string[] = []; - let extraForHash = ""; + // Fan out concurrently — one `git status` per dirty submodule plus one + // `git rev-parse` per unstaged pointer move — then concatenate in input + // order so the file list and hash are identical to a serial pass. + const expanded = await Promise.all(parsed.submodules.map(async (sub) => ({ path: sub.path, ...(await expandSubmodule(cwd, sub)) }))); - for (const sub of parsed.submodules) { - submodulePaths.push(sub.path); - if (sub.commitChanged) { - files.push({ - path: sub.path, - index: sub.index, - workingTree: sub.workingTree, - submoduleFromCommit: short(sub.headOid), - submoduleToCommit: short(await resolveSubmoduleToCommit(cwd, sub)), - }); - } - if (sub.hasModifiedContent || sub.hasUntrackedContent) { - const inner = await runGit(join(cwd, sub.path), ["status", "--porcelain=v2", "--untracked-files=all", "-z"]); - if (inner.code !== 0) continue; // uninitialized / unreadable submodule: skip silently - extraForHash += `\0${sub.path}\0${inner.stdout}`; - const innerFiles = parseStatus(inner.stdout, { deferSubmodules: false }).files; - for (const file of innerFiles) { - files.push({ - ...file, - path: `${sub.path}/${file.path}`, - ...(file.oldPath === undefined ? {} : { oldPath: `${sub.path}/${file.oldPath}` }), - }); - } - } + const files: GitStatusFile[] = [...parsed.files]; + const dirtySubmodulePaths: string[] = []; + let extraForHash = ""; + for (const part of expanded) { + dirtySubmodulePaths.push(part.path); + files.push(...part.files); + extraForHash += part.extraForHash; } return { @@ -87,10 +72,41 @@ async function expandSubmodules(cwd: string, parsed: ParsedStatus, topRaw: strin ...(parsed.ahead === undefined ? {} : { ahead: parsed.ahead }), ...(parsed.behind === undefined ? {} : { behind: parsed.behind }), files, - submodules: submodulePaths, + submodules: dirtySubmodulePaths, }; } +/** Expand one dirty submodule: the pointer entry first, then its inner files. */ +async function expandSubmodule(cwd: string, sub: SubmoduleRecord): Promise<{ files: GitStatusFile[]; extraForHash: string }> { + const files: GitStatusFile[] = []; + let extraForHash = ""; + if (sub.commitChanged) { + files.push({ + path: sub.path, + index: sub.index, + workingTree: sub.workingTree, + submoduleFromCommit: short(sub.headOid), + submoduleToCommit: short(await resolveSubmoduleToCommit(cwd, sub)), + }); + } + if (sub.hasModifiedContent || sub.hasUntrackedContent) { + const inner = await runGit(join(cwd, sub.path), ["status", "--porcelain=v2", "--untracked-files=all", "-z"]); + if (inner.code === 0) { + extraForHash = `\0${sub.path}\0${inner.stdout}`; + const innerFiles = parseStatus(inner.stdout, { deferSubmodules: false }).files; + for (const file of innerFiles) { + files.push({ + ...file, + path: `${sub.path}/${file.path}`, + ...(file.oldPath === undefined ? {} : { oldPath: `${sub.path}/${file.oldPath}` }), + }); + } + } + // non-zero exit: uninitialized / unreadable submodule — skip silently + } + return { files, extraForHash }; +} + async function resolveSubmoduleToCommit(cwd: string, sub: SubmoduleRecord): Promise { // Staged pointer moves already expose the new commit as the index OID; an // unstaged move only records the old OID, so read the submodule's HEAD. @@ -154,7 +170,7 @@ async function isUntracked(cwd: string, path: string): Promise { } /** Configured direct-submodule paths (depth 1), read from `.gitmodules`. */ -async function submodulePaths(cwd: string): Promise { +async function configuredSubmodulePaths(cwd: string): Promise { // `-z` emits `\n\0` records; keys may themselves contain spaces // (`submodule.my sub.path`), so splitting lines at the first space mangles // paths with spaces in them. @@ -172,7 +188,12 @@ async function submodulePaths(cwd: string): Promise { /** The submodule that strictly contains `path`, if any (longest match wins). */ async function submoduleForPath(cwd: string, path: string): Promise { - const subs = await submodulePaths(cwd); + // Cheap bail-outs before spawning `git config`: a path strictly inside a + // submodule always contains `/`, and without `.gitmodules` there are no + // configured submodules to look up (every diff call used to pay this spawn). + if (!path.includes("/")) return undefined; + if (!existsSync(join(cwd, ".gitmodules"))) return undefined; + const subs = await configuredSubmodulePaths(cwd); let best: string | undefined; for (const sub of subs) { if (sub !== "" && path.startsWith(`${sub}/`) && (best === undefined || sub.length > best.length)) best = sub;