feat(git): view submodule working-tree changes in the Git panel

Recurse into dirty submodules when building the Git status so their
modified and untracked files appear as full-path entries, and add a
commit-pointer entry (with short SHAs) only when the recorded commit
actually moved. Route diffs whose path falls inside a submodule to run
in that submodule's working tree so real per-file diffs are shown.

The changed-file list groups these under the submodule: tree view keeps
the nested structure and marks the submodule root with a badge, list
view flattens them into one expandable group pinned above the ordinary
files. Depth 1 only; ignored files are excluded; the panel stays
read-only.

Covered by client tree/list-grouping tests, parser tests, and a
server test that drives a real temporary repository and submodule.
This commit is contained in:
lzr
2026-07-22 21:35:06 +08:00
parent 8a5aaf93a1
commit dd435cb1b0
12 changed files with 610 additions and 56 deletions
+167 -5
View File
@@ -1,15 +1,103 @@
import { createHash } from "node:crypto";
import { spawn } from "node:child_process";
import { join } from "node:path";
import type { GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse } from "../../shared/apiTypes.js";
import { normalizeRelativePath } from "../workspaces/pathSafety.js";
import { sanitizedGitEnv } from "./gitEnv.js";
const MAX_OUTPUT = 2 * 1024 * 1024;
/**
* A submodule row parsed from the superproject status. `git status` reports a
* submodule as a single path with an `S<c><m><u>` flag field (commit changed /
* modified tracked content / untracked content) but never lists the files that
* changed inside it, so we recurse in `expandSubmodules`.
*/
interface SubmoduleRecord {
path: string;
index: GitFileState;
workingTree: GitFileState;
commitChanged: boolean;
hasModifiedContent: boolean;
hasUntrackedContent: boolean;
headOid: string;
indexOid: string;
}
interface ParsedStatus {
isGitRepo: true;
branch?: string;
upstream?: string;
ahead?: number;
behind?: number;
files: GitStatusFile[];
submodules: SubmoduleRecord[];
}
export async function gitStatus(cwd: string): Promise<GitStatusResponse> {
const result = await runGit(cwd, ["status", "--porcelain=v2", "--branch", "--untracked-files=all", "-z"]);
if (result.code !== 0) return { isGitRepo: false, hash: hash(result.stdout + result.stderr), files: [] };
return parseStatus(result.stdout);
if (result.code !== 0) return { isGitRepo: false, hash: hash(result.stdout + result.stderr), files: [], submodules: [] };
const parsed = parseStatus(result.stdout, { deferSubmodules: true });
return expandSubmodules(cwd, parsed, result.stdout);
}
/**
* Merge each dirty submodule's own changes into the flat file list. A moved
* commit pointer becomes a single entry keyed by the submodule path (carrying
* the short SHAs for display); modified/untracked content is listed as regular
* entries under `<submodule>/<inner path>`. A plain `-dirty` pointer (commit
* unchanged) is intentionally not surfaced as a pointer entry.
*/
async function expandSubmodules(cwd: string, parsed: ParsedStatus, topRaw: string): Promise<GitStatusResponse> {
const files: GitStatusFile[] = [...parsed.files];
const submodulePaths: string[] = [];
let extraForHash = "";
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}` }),
});
}
}
}
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> {
// 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.
if (sub.indexOid !== sub.headOid) return sub.indexOid;
const head = await runGit(join(cwd, sub.path), ["rev-parse", "HEAD"]);
const resolved = head.stdout.trim();
return head.code === 0 && resolved !== "" ? resolved : sub.indexOid;
}
export async function gitDiff(cwd: string, options: { path?: string; staged?: boolean }): Promise<GitDiffResponse> {
@@ -17,6 +105,11 @@ export async function gitDiff(cwd: string, options: { path?: string; staged?: bo
let path: string | undefined;
if (options.path !== undefined && options.path !== "") path = normalizeRelativePath(options.path);
if (path !== undefined) {
const owner = await submoduleForPath(cwd, path);
if (owner !== undefined) return submoduleDiff(cwd, owner, path, staged);
}
const args = ["diff", "--no-ext-diff", "--color=never"];
if (staged) args.push("--cached");
if (path !== undefined) args.push("--", path);
@@ -31,14 +124,64 @@ export async function gitDiff(cwd: string, options: { path?: string; staged?: bo
return { ...(path === undefined ? {} : { path }), staged, hash: hash(result.stdout), diff: result.stdout, truncated: result.truncated };
}
/**
* Run the diff inside the owning submodule's working tree, since `git diff` at
* the superproject root never shows content changes below a submodule boundary.
* The response path stays the full superproject-relative path so the viewer and
* the selected row line up.
*/
async function submoduleDiff(cwd: string, owner: string, path: string, staged: boolean): Promise<GitDiffResponse> {
const subCwd = join(cwd, owner);
const rel = normalizeRelativePath(path.slice(owner.length + 1));
const args = ["diff", "--no-ext-diff", "--color=never"];
if (staged) args.push("--cached");
args.push("--", rel);
const result = await runGit(subCwd, args);
if (result.code !== 0) throw new Error(result.stderr.trim() || "git diff failed");
if (!staged && result.stdout === "" && await isUntracked(subCwd, rel)) {
const untracked = await runGit(subCwd, ["diff", "--no-ext-diff", "--color=never", "--no-index", "/dev/null", "--", rel]);
if (untracked.code !== 0 && untracked.code !== 1) throw new Error(untracked.stderr.trim() || "git diff failed");
return { path, staged, hash: hash(untracked.stdout), diff: untracked.stdout, truncated: untracked.truncated };
}
return { path, staged, hash: hash(result.stdout), diff: result.stdout, truncated: result.truncated };
}
async function isUntracked(cwd: string, path: string): Promise<boolean> {
const result = await runGit(cwd, ["ls-files", "--others", "--exclude-standard", "-z", "--", path]);
return result.code === 0 && result.stdout.split("\0").includes(path);
}
function parseStatus(raw: string): GitStatusResponse {
/** Configured direct-submodule paths (depth 1), read from `.gitmodules`. */
async function submodulePaths(cwd: string): Promise<string[]> {
const result = await runGit(cwd, ["config", "--file", ".gitmodules", "--get-regexp", "^submodule\\..+\\.path$"]);
if (result.code !== 0) return [];
const paths: string[] = [];
for (const line of result.stdout.split("\n")) {
const trimmed = line.trim();
if (trimmed === "") continue;
const spaceAt = trimmed.indexOf(" ");
if (spaceAt === -1) continue;
paths.push(trimmed.slice(spaceAt + 1));
}
return paths;
}
/** The submodule that strictly contains `path`, if any (longest match wins). */
async function submoduleForPath(cwd: string, path: string): Promise<string | undefined> {
const subs = await submodulePaths(cwd);
let best: string | undefined;
for (const sub of subs) {
if (sub !== "" && path.startsWith(`${sub}/`) && (best === undefined || sub.length > best.length)) best = sub;
}
return best;
}
function parseStatus(raw: string, options: { deferSubmodules: boolean }): ParsedStatus {
const records = raw.split("\0").filter((record) => record !== "");
const files: GitStatusFile[] = [];
const submodules: SubmoduleRecord[] = [];
let branch: string | undefined;
let upstream: string | undefined;
let ahead: number | undefined;
@@ -56,7 +199,22 @@ function parseStatus(raw: string): GitStatusResponse {
else if (record.startsWith("! ")) files.push({ path: record.slice(2), index: "ignored", workingTree: "ignored" });
else if (record.startsWith("1 ")) {
const parts = record.split(" ");
files.push({ path: parts.slice(8).join(" "), index: stateFor(parts[1]?.[0]), workingTree: stateFor(parts[1]?.[1]) });
const sub = parts[2];
const path = parts.slice(8).join(" ");
if (options.deferSubmodules && sub?.startsWith("S") === true) {
submodules.push({
path,
index: stateFor(parts[1]?.[0]),
workingTree: stateFor(parts[1]?.[1]),
commitChanged: sub[1] === "C",
hasModifiedContent: sub[2] === "M",
hasUntrackedContent: sub[3] === "U",
headOid: parts[6] ?? "",
indexOid: parts[7] ?? "",
});
} else {
files.push({ path, index: stateFor(parts[1]?.[0]), workingTree: stateFor(parts[1]?.[1]) });
}
} else if (record.startsWith("2 ")) {
const parts = record.split(" ");
const path = parts.slice(9).join(" ");
@@ -69,7 +227,7 @@ function parseStatus(raw: string): GitStatusResponse {
}
}
return { isGitRepo: true, hash: hash(raw), ...(branch === undefined ? {} : { branch }), ...(upstream === undefined ? {} : { upstream }), ...(ahead === undefined ? {} : { ahead }), ...(behind === undefined ? {} : { behind }), files };
return { isGitRepo: true, ...(branch === undefined ? {} : { branch }), ...(upstream === undefined ? {} : { upstream }), ...(ahead === undefined ? {} : { ahead }), ...(behind === undefined ? {} : { behind }), files, submodules };
}
function stateFor(code: string | undefined): GitFileState {
@@ -90,6 +248,10 @@ function normalizeBranch(value: string): string | undefined {
return value === "(detached)" ? undefined : value;
}
function short(oid: string): string {
return oid.slice(0, 7);
}
function hash(value: string): string {
return createHash("sha1").update(value).digest("hex");
}