Archived
perf(git): parallelize submodule expansion and skip impossible submodule lookups
- expandSubmodules now fans out with Promise.all over the dirty submodules and concatenates results in input order, so the polled status endpoint no longer pays serial git status/rev-parse spawns (P1). - submoduleForPath bails out before spawning git config when the path contains no '/' or the repo has no .gitmodules, removing a spawn from every diff call in plain repos (P2). - Rename submodulePaths() to configuredSubmodulePaths() and the expandSubmodules local to dirtySubmodulePaths to disambiguate the two concepts (N2).
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import type { GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse } from "../../shared/apiTypes.js";
|
import type { GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse } from "../../shared/apiTypes.js";
|
||||||
import { normalizeRelativePath } from "../workspaces/pathSafety.js";
|
import { normalizeRelativePath } from "../workspaces/pathSafety.js";
|
||||||
@@ -49,12 +50,36 @@ export async function gitStatus(cwd: string): Promise<GitStatusResponse> {
|
|||||||
* unchanged) is intentionally not surfaced as a pointer entry.
|
* unchanged) is intentionally not surfaced as a pointer entry.
|
||||||
*/
|
*/
|
||||||
async function expandSubmodules(cwd: string, parsed: ParsedStatus, topRaw: string): Promise<GitStatusResponse> {
|
async function expandSubmodules(cwd: string, parsed: ParsedStatus, topRaw: string): Promise<GitStatusResponse> {
|
||||||
const files: GitStatusFile[] = [...parsed.files];
|
// Fan out concurrently — one `git status` per dirty submodule plus one
|
||||||
const submodulePaths: string[] = [];
|
// `git rev-parse` per unstaged pointer move — then concatenate in input
|
||||||
let extraForHash = "";
|
// 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) {
|
const files: GitStatusFile[] = [...parsed.files];
|
||||||
submodulePaths.push(sub.path);
|
const dirtySubmodulePaths: string[] = [];
|
||||||
|
let extraForHash = "";
|
||||||
|
for (const part of expanded) {
|
||||||
|
dirtySubmodulePaths.push(part.path);
|
||||||
|
files.push(...part.files);
|
||||||
|
extraForHash += part.extraForHash;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isGitRepo: true,
|
||||||
|
hash: hash(topRaw + extraForHash),
|
||||||
|
...(parsed.branch === undefined ? {} : { branch: parsed.branch }),
|
||||||
|
...(parsed.upstream === undefined ? {} : { upstream: parsed.upstream }),
|
||||||
|
...(parsed.ahead === undefined ? {} : { ahead: parsed.ahead }),
|
||||||
|
...(parsed.behind === undefined ? {} : { behind: parsed.behind }),
|
||||||
|
files,
|
||||||
|
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) {
|
if (sub.commitChanged) {
|
||||||
files.push({
|
files.push({
|
||||||
path: sub.path,
|
path: sub.path,
|
||||||
@@ -66,8 +91,8 @@ async function expandSubmodules(cwd: string, parsed: ParsedStatus, topRaw: strin
|
|||||||
}
|
}
|
||||||
if (sub.hasModifiedContent || sub.hasUntrackedContent) {
|
if (sub.hasModifiedContent || sub.hasUntrackedContent) {
|
||||||
const inner = await runGit(join(cwd, sub.path), ["status", "--porcelain=v2", "--untracked-files=all", "-z"]);
|
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
|
if (inner.code === 0) {
|
||||||
extraForHash += `\0${sub.path}\0${inner.stdout}`;
|
extraForHash = `\0${sub.path}\0${inner.stdout}`;
|
||||||
const innerFiles = parseStatus(inner.stdout, { deferSubmodules: false }).files;
|
const innerFiles = parseStatus(inner.stdout, { deferSubmodules: false }).files;
|
||||||
for (const file of innerFiles) {
|
for (const file of innerFiles) {
|
||||||
files.push({
|
files.push({
|
||||||
@@ -77,18 +102,9 @@ async function expandSubmodules(cwd: string, parsed: ParsedStatus, topRaw: strin
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// non-zero exit: uninitialized / unreadable submodule — skip silently
|
||||||
}
|
}
|
||||||
|
return { files, extraForHash };
|
||||||
return {
|
|
||||||
isGitRepo: true,
|
|
||||||
hash: hash(topRaw + extraForHash),
|
|
||||||
...(parsed.branch === undefined ? {} : { branch: parsed.branch }),
|
|
||||||
...(parsed.upstream === undefined ? {} : { upstream: parsed.upstream }),
|
|
||||||
...(parsed.ahead === undefined ? {} : { ahead: parsed.ahead }),
|
|
||||||
...(parsed.behind === undefined ? {} : { behind: parsed.behind }),
|
|
||||||
files,
|
|
||||||
submodules: submodulePaths,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveSubmoduleToCommit(cwd: string, sub: SubmoduleRecord): Promise<string> {
|
async function resolveSubmoduleToCommit(cwd: string, sub: SubmoduleRecord): Promise<string> {
|
||||||
@@ -154,7 +170,7 @@ async function isUntracked(cwd: string, path: string): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Configured direct-submodule paths (depth 1), read from `.gitmodules`. */
|
/** Configured direct-submodule paths (depth 1), read from `.gitmodules`. */
|
||||||
async function submodulePaths(cwd: string): Promise<string[]> {
|
async function configuredSubmodulePaths(cwd: string): Promise<string[]> {
|
||||||
// `-z` emits `<key>\n<value>\0` records; keys may themselves contain spaces
|
// `-z` emits `<key>\n<value>\0` records; keys may themselves contain spaces
|
||||||
// (`submodule.my sub.path`), so splitting lines at the first space mangles
|
// (`submodule.my sub.path`), so splitting lines at the first space mangles
|
||||||
// paths with spaces in them.
|
// paths with spaces in them.
|
||||||
@@ -172,7 +188,12 @@ async function submodulePaths(cwd: string): Promise<string[]> {
|
|||||||
|
|
||||||
/** The submodule that strictly contains `path`, if any (longest match wins). */
|
/** The submodule that strictly contains `path`, if any (longest match wins). */
|
||||||
async function submoduleForPath(cwd: string, path: string): Promise<string | undefined> {
|
async function submoduleForPath(cwd: string, path: string): Promise<string | undefined> {
|
||||||
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;
|
let best: string | undefined;
|
||||||
for (const sub of subs) {
|
for (const sub of subs) {
|
||||||
if (sub !== "" && path.startsWith(`${sub}/`) && (best === undefined || sub.length > best.length)) best = sub;
|
if (sub !== "" && path.startsWith(`${sub}/`) && (best === undefined || sub.length > best.length)) best = sub;
|
||||||
|
|||||||
Reference in New Issue
Block a user