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
+5
View File
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---
Expand a changed submodule in the Git panel to see the work inside it. Tree view nests the submodule's own modified and untracked files (keeping their folder structure) and list view flattens them into one group, with a moved commit pointer shown as `<old> → <new>` when it changed. Selecting any inner file shows its real diff instead of the bare `Subproject commit` line.
+22
View File
@@ -505,6 +505,28 @@ describe("API parsers", () => {
expect(() => parseGitStatusResponse({ isGitRepo: true, hash: "h", files: [{ path: "a", index: "weird", workingTree: "modified" }] })).toThrow("Invalid git file state"); expect(() => parseGitStatusResponse({ isGitRepo: true, hash: "h", files: [{ path: "a", index: "weird", workingTree: "modified" }] })).toThrow("Invalid git file state");
}); });
it("parses submodule paths and pointer commit fields", () => {
const parsed = parseGitStatusResponse({
isGitRepo: true,
hash: "h",
branch: "main",
files: [
{ path: "HARL", index: "unmodified", workingTree: "modified", submoduleFromCommit: "1111111", submoduleToCommit: "2222222" },
{ path: "HARL/inner.txt", index: "modified", workingTree: "modified" },
],
submodules: ["HARL"],
});
expect(parsed.submodules).toEqual(["HARL"]);
expect(parsed.files[0]?.submoduleFromCommit).toBe("1111111");
expect(parsed.files[0]?.submoduleToCommit).toBe("2222222");
expect(parsed.files[1]?.submoduleFromCommit).toBeUndefined();
});
it("defaults submodules to an empty list when absent", () => {
const parsed = parseGitStatusResponse({ isGitRepo: true, hash: "h", files: [] });
expect(parsed.submodules).toEqual([]);
});
it("validates file content responses", () => { it("validates file content responses", () => {
const textFile = { const textFile = {
path: "README.md", path: "README.md",
+2 -2
View File
@@ -818,12 +818,12 @@ function optionalFileMediaType(value: unknown): FileContentResponse["mediaType"]
export function parseGitStatusResponse(value: unknown): GitStatusResponse { export function parseGitStatusResponse(value: unknown): GitStatusResponse {
const record = requireRecord(value); const record = requireRecord(value);
return { isGitRepo: requireBoolean(record, "isGitRepo"), hash: requireString(record, "hash"), ...optionalField("branch", optionalString(record, "branch")), ...optionalField("upstream", optionalString(record, "upstream")), ...optionalField("ahead", optionalNumber(record, "ahead")), ...optionalField("behind", optionalNumber(record, "behind")), files: arrayOf(parseGitStatusFile)(record["files"]) }; return { isGitRepo: requireBoolean(record, "isGitRepo"), hash: requireString(record, "hash"), ...optionalField("branch", optionalString(record, "branch")), ...optionalField("upstream", optionalString(record, "upstream")), ...optionalField("ahead", optionalNumber(record, "ahead")), ...optionalField("behind", optionalNumber(record, "behind")), files: arrayOf(parseGitStatusFile)(record["files"]), submodules: record["submodules"] === undefined ? [] : arrayOfString(record["submodules"], "submodules") };
} }
function parseGitStatusFile(value: unknown): GitStatusFile { function parseGitStatusFile(value: unknown): GitStatusFile {
const record = requireRecord(value); const record = requireRecord(value);
return { path: requireString(record, "path"), ...optionalField("oldPath", optionalString(record, "oldPath")), index: parseGitFileState(record["index"]), workingTree: parseGitFileState(record["workingTree"]) }; return { path: requireString(record, "path"), ...optionalField("oldPath", optionalString(record, "oldPath")), index: parseGitFileState(record["index"]), workingTree: parseGitFileState(record["workingTree"]), ...optionalField("submoduleFromCommit", optionalString(record, "submoduleFromCommit")), ...optionalField("submoduleToCommit", optionalString(record, "submoduleToCommit")) };
} }
function parseGitFileState(value: unknown): GitFileState { function parseGitFileState(value: unknown): GitFileState {
+68 -32
View File
@@ -2,16 +2,19 @@ import { css, html, LitElement, type PropertyValues, type TemplateResult } from
import { customElement, property, state } from "lit/decorators.js"; import { customElement, property, state } from "lit/decorators.js";
import type { GitDiffResponse, GitStatusFile, GitStatusResponse } from "../api"; import type { GitDiffResponse, GitStatusFile, GitStatusResponse } from "../api";
import { buildGitFileTree, collectGitFileTreeDirectoryPaths, type GitFileTreeNode } from "../gitFileTree"; import { buildGitFileTree, collectGitFileTreeDirectoryPaths, type GitFileTreeNode } from "../gitFileTree";
import { buildGitFileList, type GitFileListModel, type GitFileListSubmoduleFile, type GitFileListSubmoduleGroup } from "../gitFileList";
import { readGitFileView, writeGitFileView, type GitFileView } from "../gitFileViewPreference"; import { readGitFileView, writeGitFileView, type GitFileView } from "../gitFileViewPreference";
import type { WorkspacePanelContext } from "../plugins/types"; import type { WorkspacePanelContext } from "../plugins/types";
import { workspacePanelStyles } from "./shared"; import { workspacePanelStyles } from "./shared";
interface GitTreeState { interface GitViewState {
readonly nodes: readonly GitFileTreeNode[]; readonly nodes: readonly GitFileTreeNode[];
readonly directoryPaths: readonly string[]; readonly listModel: GitFileListModel;
readonly expandablePaths: readonly string[];
} }
const EMPTY_TREE_STATE: GitTreeState = { nodes: [], directoryPaths: [] }; const EMPTY_LIST_MODEL: GitFileListModel = { submodules: [], files: [] };
const EMPTY_VIEW_STATE: GitViewState = { nodes: [], listModel: EMPTY_LIST_MODEL, expandablePaths: [] };
@customElement("workspace-git-panel") @customElement("workspace-git-panel")
export class WorkspaceGitPanel extends LitElement { export class WorkspaceGitPanel extends LitElement {
@@ -27,6 +30,7 @@ export class WorkspaceGitPanel extends LitElement {
.view-toggle button:last-child { border-top-right-radius: 7px; border-bottom-right-radius: 7px; margin-left: -1px; } .view-toggle button:last-child { border-top-right-radius: 7px; border-bottom-right-radius: 7px; margin-left: -1px; }
.view-toggle button.selected { position: relative; z-index: 1; } .view-toggle button.selected { position: relative; z-index: 1; }
.row .twisty { color: var(--pi-dim, var(--pi-muted)); } .row .twisty { color: var(--pi-dim, var(--pi-muted)); }
.submodule-badge { display: inline-block; margin-left: 6px; border: 1px solid var(--pi-border); border-radius: 999px; color: var(--pi-muted); padding: 0 5px; font-size: 11px; font-weight: 400; vertical-align: baseline; }
`, `,
]; ];
@@ -37,7 +41,8 @@ export class WorkspaceGitPanel extends LitElement {
// Ephemeral by design: the tree always opens fully collapsed, and expand // Ephemeral by design: the tree always opens fully collapsed, and expand
// state is intentionally not persisted. Reassigned (never mutated in place) // state is intentionally not persisted. Reassigned (never mutated in place)
// so Lit observes the change. // so Lit observes the change. Shared by tree directories and, in list view,
// submodule groups, both keyed by their path.
@state() private expandedDirectories = new Set<string>(); @state() private expandedDirectories = new Set<string>();
protected override willUpdate(changedProperties: PropertyValues<this>): void { protected override willUpdate(changedProperties: PropertyValues<this>): void {
@@ -53,20 +58,20 @@ export class WorkspaceGitPanel extends LitElement {
const context = this.context; const context = this.context;
if (context === undefined) return html`<p class="muted">Git unavailable.</p>`; if (context === undefined) return html`<p class="muted">Git unavailable.</p>`;
const status = context.gitStatus; const status = context.gitStatus;
const treeState = this.computeTreeState(status); const viewState = this.computeViewState(status);
return html` return html`
<section class="toolbar"> <section class="toolbar">
<strong>Git</strong> <strong>Git</strong>
${context.gitStale ? html`<span class="stale">stale</span>` : null} ${context.gitStale ? html`<span class="stale">stale</span>` : null}
<div class="toolbar-actions"> <div class="toolbar-actions">
${this.renderViewToggle()} ${this.renderViewToggle()}
${this.view === "tree" && treeState.directoryPaths.length > 0 ? this.renderExpandCollapseAll(treeState.directoryPaths) : null} ${viewState.expandablePaths.length > 0 ? this.renderExpandCollapseAll(viewState.expandablePaths) : null}
<button type="button" @click=${context.onRefreshGit}>Refresh</button> <button type="button" @click=${context.onRefreshGit}>Refresh</button>
</div> </div>
</section> </section>
<section class="split"> <section class="split">
<div class=${this.view === "tree" ? "list tree" : "list"}> <div class=${this.view === "tree" ? "list tree" : "list"}>
${this.renderFileList(context, status, treeState.nodes)} ${this.renderFileList(context, status, viewState)}
</div> </div>
<div class="viewer">${renderDiffViewer(context)}</div> <div class="viewer">${renderDiffViewer(context)}</div>
</section> </section>
@@ -89,66 +94,93 @@ export class WorkspaceGitPanel extends LitElement {
`; `;
} }
private renderExpandCollapseAll(directoryPaths: readonly string[]): TemplateResult { private renderExpandCollapseAll(expandablePaths: readonly string[]): TemplateResult {
const allExpanded = directoryPaths.every((path) => this.expandedDirectories.has(path)); const allExpanded = expandablePaths.every((path) => this.expandedDirectories.has(path));
return html` return html`
<button type="button" @click=${() => { this.toggleExpandAll(directoryPaths, allExpanded); }}>${allExpanded ? "Collapse all" : "Expand all"}</button> <button type="button" @click=${() => { this.toggleExpandAll(expandablePaths, allExpanded); }}>${allExpanded ? "Collapse all" : "Expand all"}</button>
`; `;
} }
private renderFileList(context: WorkspacePanelContext, status: GitStatusResponse | undefined, nodes: readonly GitFileTreeNode[]): TemplateResult { private renderFileList(context: WorkspacePanelContext, status: GitStatusResponse | undefined, viewState: GitViewState): TemplateResult {
if (status === undefined) return html`<p class="muted">No status loaded.</p>`; if (status === undefined) return html`<p class="muted">No status loaded.</p>`;
if (!status.isGitRepo) return html`<p class="muted">Not a git repository.</p>`; if (!status.isGitRepo) return html`<p class="muted">Not a git repository.</p>`;
const summary = html`<p class="summary">${gitSummary(status)}</p>`; const summary = html`<p class="summary">${gitSummary(status)}</p>`;
if (status.files.length === 0) return html`${summary}<p class="muted">No changes.</p>`; if (status.files.length === 0) return html`${summary}<p class="muted">No changes.</p>`;
const body = this.view === "tree" const body = this.view === "tree"
? nodes.map((node) => this.renderTreeNode(context, node, 0)) ? viewState.nodes.map((node) => this.renderTreeNode(context, node, 0))
: status.files.map((file) => this.renderFileRow(context, file)); : this.renderListBody(context, viewState.listModel);
return html`${summary}${body}`; return html`${summary}${body}`;
} }
private renderListBody(context: WorkspacePanelContext, model: GitFileListModel): TemplateResult {
return html`
${model.submodules.map((group) => this.renderSubmoduleGroup(context, group))}
${model.files.map((file) => this.renderFileRow(context, file))}
`;
}
private renderSubmoduleGroup(context: WorkspacePanelContext, group: GitFileListSubmoduleGroup): TemplateResult {
const expanded = this.expandedDirectories.has(group.path);
return html`
<button type="button" class="row" style="--depth:0" aria-expanded=${expanded ? "true" : "false"} @click=${() => { this.toggleDirectory(group.path); }}>
<span class="twisty">${expanded ? "▾" : "▸"}</span>
<span>${group.name}${submoduleBadge()}</span>
</button>
${expanded ? html`
${group.pointer === undefined ? null : this.renderSelectableRow(context, group.path, group.pointer.name, group.pointer.file, 1)}
${group.files.map((entry) => this.renderSubmoduleFileRow(context, entry))}
` : null}
`;
}
private renderSubmoduleFileRow(context: WorkspacePanelContext, entry: GitFileListSubmoduleFile): TemplateResult {
return this.renderSelectableRow(context, entry.path, entry.relativePath, entry.file, 1);
}
private renderTreeNode(context: WorkspacePanelContext, node: GitFileTreeNode, depth: number): TemplateResult { private renderTreeNode(context: WorkspacePanelContext, node: GitFileTreeNode, depth: number): TemplateResult {
if (node.kind === "directory") { if (node.kind === "directory") {
const expanded = this.expandedDirectories.has(node.path); const expanded = this.expandedDirectories.has(node.path);
return html` return html`
<button type="button" class="row" style=${`--depth:${String(depth)}`} aria-expanded=${expanded ? "true" : "false"} @click=${() => { this.toggleDirectory(node.path); }}> <button type="button" class="row" style=${`--depth:${String(depth)}`} aria-expanded=${expanded ? "true" : "false"} @click=${() => { this.toggleDirectory(node.path); }}>
<span class="twisty">${expanded ? "▾" : "▸"}</span> <span class="twisty">${expanded ? "▾" : "▸"}</span>
<span>${node.name}</span> <span>${node.name}${node.isSubmodule === true ? submoduleBadge() : null}</span>
</button> </button>
${expanded ? node.children.map((child) => this.renderTreeNode(context, child, depth + 1)) : null} ${expanded ? node.children.map((child) => this.renderTreeNode(context, child, depth + 1)) : null}
`; `;
} }
const selected = context.selectedDiffPath === node.path; return this.renderSelectableRow(context, node.path, node.name, node.file, depth);
return html`
<button type="button" class=${selected ? "row selected" : "row"} style=${`--depth:${String(depth)}`} @click=${() => { context.onSelectDiff(node.path); }}>
<span>${stateLabel(node.file.index, node.file.workingTree)}</span>
<span>${node.name}</span>
</button>
`;
} }
private renderFileRow(context: WorkspacePanelContext, file: GitStatusFile): TemplateResult { private renderFileRow(context: WorkspacePanelContext, file: GitStatusFile): TemplateResult {
const selected = context.selectedDiffPath === file.path; return this.renderSelectableRow(context, file.path, file.path, file, 0);
}
private renderSelectableRow(context: WorkspacePanelContext, path: string, label: string, file: GitStatusFile, depth: number): TemplateResult {
const selected = context.selectedDiffPath === path;
return html` return html`
<button type="button" class=${selected ? "row selected" : "row"} @click=${() => { context.onSelectDiff(file.path); }}> <button type="button" class=${selected ? "row selected" : "row"} style=${`--depth:${String(depth)}`} @click=${() => { context.onSelectDiff(path); }}>
<span>${stateLabel(file.index, file.workingTree)}</span> <span>${stateLabel(file.index, file.workingTree)}</span>
<span>${file.path}</span> <span>${label}</span>
</button> </button>
`; `;
} }
private computeTreeState(status: GitStatusResponse | undefined): GitTreeState { private computeViewState(status: GitStatusResponse | undefined): GitViewState {
if (this.view !== "tree" || status === undefined || !status.isGitRepo || status.files.length === 0) return EMPTY_TREE_STATE; if (status === undefined || !status.isGitRepo || status.files.length === 0) return EMPTY_VIEW_STATE;
const nodes = buildGitFileTree(status.files); if (this.view === "tree") {
return { nodes, directoryPaths: collectGitFileTreeDirectoryPaths(nodes) }; const nodes = buildGitFileTree(status.files, status.submodules);
return { nodes, listModel: EMPTY_LIST_MODEL, expandablePaths: collectGitFileTreeDirectoryPaths(nodes) };
}
const listModel = buildGitFileList(status.files, status.submodules);
return { nodes: [], listModel, expandablePaths: listModel.submodules.map((group) => group.path) };
} }
private setView(view: GitFileView): void { private setView(view: GitFileView): void {
if (this.view === view) return; if (this.view === view) return;
this.view = view; this.view = view;
writeGitFileView(view); writeGitFileView(view);
// The tree always starts fully collapsed when entered. // Entering either view starts fully collapsed.
if (view === "tree") this.expandedDirectories = new Set(); this.expandedDirectories = new Set();
} }
private toggleDirectory(path: string): void { private toggleDirectory(path: string): void {
@@ -158,11 +190,15 @@ export class WorkspaceGitPanel extends LitElement {
this.expandedDirectories = next; this.expandedDirectories = next;
} }
private toggleExpandAll(directoryPaths: readonly string[], allExpanded: boolean): void { private toggleExpandAll(expandablePaths: readonly string[], allExpanded: boolean): void {
this.expandedDirectories = allExpanded ? new Set() : new Set(directoryPaths); this.expandedDirectories = allExpanded ? new Set() : new Set(expandablePaths);
} }
} }
function submoduleBadge(): TemplateResult {
return html`<span class="submodule-badge">submodule</span>`;
}
function renderDiffViewer(context: WorkspacePanelContext): TemplateResult { function renderDiffViewer(context: WorkspacePanelContext): TemplateResult {
if (context.selectedDiffPath === undefined || context.selectedDiffPath === "") return html`<p class="muted">Select a changed file.</p>`; if (context.selectedDiffPath === undefined || context.selectedDiffPath === "") return html`<p class="muted">Select a changed file.</p>`;
const unstaged = context.selectedDiff; const unstaged = context.selectedDiff;
@@ -58,7 +58,7 @@ describe("MachineController", () => {
selectedSession: session, selectedSession: session,
fileTree: [{ name: "index.ts", path: "src/index.ts", type: "file" }], fileTree: [{ name: "index.ts", path: "src/index.ts", type: "file" }],
selectedFilePath: "src/index.ts", selectedFilePath: "src/index.ts",
gitStatus: { isGitRepo: true, hash: "abc123", branch: "main", files: [{ path: "src/index.ts", index: "modified", workingTree: "modified" }] }, gitStatus: { isGitRepo: true, hash: "abc123", branch: "main", files: [{ path: "src/index.ts", index: "modified", workingTree: "modified" }], submodules: [] },
activeTerminalCount: 2, activeTerminalCount: 2,
error: "stale error", error: "stale error",
}; };
+47
View File
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import type { GitStatusFile } from "./api";
import { buildGitFileList } from "./gitFileList";
describe("buildGitFileList", () => {
it("leaves non-submodule files flat in their original order", () => {
const model = buildGitFileList([changed("src/b.ts"), changed("a.txt"), changed("src/a.ts")], []);
expect(model.submodules).toEqual([]);
expect(model.files.map((file) => file.path)).toEqual(["src/b.ts", "a.txt", "src/a.ts"]);
});
it("groups submodule files flat with submodule-relative paths and the pointer first", () => {
const pointer = submodulePointer("HARL", "1111111", "2222222");
const model = buildGitFileList([changed("README.md"), pointer, changed("HARL/src/foo.ts"), changed("HARL/a.txt"), changed("HARL/b.txt")], ["HARL"]);
expect(model.files.map((file) => file.path)).toEqual(["README.md"]);
expect(model.submodules).toHaveLength(1);
const group = model.submodules[0];
expect(group?.path).toBe("HARL");
expect(group?.name).toBe("HARL");
expect(group?.pointer?.name).toBe("1111111 → 2222222");
expect(group?.pointer?.file).toBe(pointer);
// Flattened (no nesting) and sorted by relative path.
expect(group?.files.map((entry) => entry.relativePath)).toEqual(["a.txt", "b.txt", "src/foo.ts"]);
expect(group?.files.map((entry) => entry.path)).toEqual(["HARL/a.txt", "HARL/b.txt", "HARL/src/foo.ts"]);
});
it("places submodule groups first, sorted by name, ahead of the flat files", () => {
const model = buildGitFileList([changed("root.txt"), changed("Zsub/x"), changed("Asub/y")], ["Zsub", "Asub"]);
expect(model.submodules.map((group) => group.name)).toEqual(["Asub", "Zsub"]);
expect(model.files.map((file) => file.path)).toEqual(["root.txt"]);
});
it("omits the pointer for a dirty submodule whose commit did not move", () => {
const model = buildGitFileList([changed("HARL/x.txt")], ["HARL"]);
expect(model.submodules[0]?.pointer).toBeUndefined();
expect(model.submodules[0]?.files.map((entry) => entry.relativePath)).toEqual(["x.txt"]);
});
});
function changed(path: string): GitStatusFile {
return { path, index: "modified", workingTree: "modified" };
}
function submodulePointer(path: string, from: string, to: string): GitStatusFile {
return { path, index: "unmodified", workingTree: "modified", submoduleFromCommit: from, submoduleToCommit: to };
}
+84
View File
@@ -0,0 +1,84 @@
import type { GitStatusFile } from "./api";
/** A changed file inside a submodule, shown flat in list view. `path` is the
* full superproject-relative path (the diff key); `relativePath` is shown in
* the row and is relative to the submodule root (may still contain slashes). */
export interface GitFileListSubmoduleFile {
readonly path: string;
readonly relativePath: string;
readonly file: GitStatusFile;
}
/** An expandable submodule group in list view. Children are flat (no nesting),
* with the pointer row pinned first when present. The pointer is the
* submodule's own moved commit, keyed by the submodule path; its `name` is the
* `<old> → <new>` short-SHA summary. */
export interface GitFileListSubmoduleGroup {
readonly path: string;
readonly name: string;
readonly pointer?: { readonly name: string; readonly file: GitStatusFile };
readonly files: readonly GitFileListSubmoduleFile[];
}
/** List-view model: submodule groups first (alphabetical), then non-submodule
* files in their original Git status order. */
export interface GitFileListModel {
readonly submodules: readonly GitFileListSubmoduleGroup[];
readonly files: readonly GitStatusFile[];
}
/**
* Group the flat changed-file list for list view. Unlike the tree, submodule
* contents are flattened: each submodule becomes one expandable group holding
* its pointer row and its changed files, and everything else stays a flat list.
*/
export function buildGitFileList(files: readonly GitStatusFile[], submodules: readonly string[] = []): GitFileListModel {
const submoduleSet = new Set(submodules);
const pointers = new Map<string, { readonly name: string; readonly file: GitStatusFile }>();
const grouped = new Map<string, GitFileListSubmoduleFile[]>();
for (const submodule of submodules) grouped.set(submodule, []);
const flat: GitStatusFile[] = [];
for (const file of files) {
if (submoduleSet.has(file.path)) {
pointers.set(file.path, { name: pointerName(file), file });
continue;
}
const owner = ownerSubmodule(file.path, submodules);
if (owner !== undefined) {
grouped.get(owner)?.push({ path: file.path, relativePath: file.path.slice(owner.length + 1), file });
continue;
}
flat.push(file);
}
const groups: GitFileListSubmoduleGroup[] = submodules
.map((submodule): GitFileListSubmoduleGroup => {
const pointer = pointers.get(submodule);
const inner = grouped.get(submodule) ?? [];
return { path: submodule, name: segmentName(submodule), ...(pointer === undefined ? {} : { pointer }), files: [...inner].sort((left, right) => left.relativePath.localeCompare(right.relativePath)) };
})
.sort((left, right) => left.name.localeCompare(right.name));
return { submodules: groups, files: flat };
}
function ownerSubmodule(path: string, submodules: readonly string[]): string | undefined {
let best: string | undefined;
for (const submodule of submodules) {
if (submodule !== "" && path.startsWith(`${submodule}/`) && (best === undefined || submodule.length > best.length)) best = submodule;
}
return best;
}
function pointerName(file: GitStatusFile): string {
const from = file.submoduleFromCommit;
const to = file.submoduleToCommit;
return from !== undefined && to !== undefined ? `${from}${to}` : "commit";
}
function segmentName(path: string): string {
const segments = path.split("/");
const last = segments[segments.length - 1];
return last ?? path;
}
+42
View File
@@ -58,8 +58,50 @@ describe("buildGitFileTree", () => {
const tree = buildGitFileTree([changed("src/b/c.ts"), changed("src/a.ts"), changed("docs/x.md")]); const tree = buildGitFileTree([changed("src/b/c.ts"), changed("src/a.ts"), changed("docs/x.md")]);
expect(collectGitFileTreeDirectoryPaths(tree)).toEqual(["docs", "src", "src/b"]); expect(collectGitFileTreeDirectoryPaths(tree)).toEqual(["docs", "src", "src/b"]);
}); });
it("marks a submodule directory and pins its pointer as the first child", () => {
const pointer = submodulePointer("HARL", "1111111", "2222222");
const tree = buildGitFileTree([pointer, changed("HARL/tracked.txt"), changed("HARL/src/foo.ts"), changed("README.md")], ["HARL"]);
expect(tree.map((node) => node.name)).toEqual(["HARL", "README.md"]);
const harl = expectDirectory(tree[0]);
expect(harl.isSubmodule).toBe(true);
// Pointer first, then directories, then files (submodule-relative nesting kept).
const pointerRow = expectFile(harl.children[0]);
expect(pointerRow.isSubmodulePointer).toBe(true);
expect(pointerRow.name).toBe("1111111 → 2222222");
expect(pointerRow.path).toBe("HARL");
expect(pointerRow.file).toBe(pointer);
const src = expectDirectory(harl.children[1]);
expect(src.path).toBe("HARL/src");
expect(expectFile(src.children[0]).path).toBe("HARL/src/foo.ts");
expect(expectFile(harl.children[2]).path).toBe("HARL/tracked.txt");
}); });
it("creates a submodule node for a pointer-only change with no inner files", () => {
const tree = buildGitFileTree([submodulePointer("HARL", "aaaaaaa", "bbbbbbb")], ["HARL"]);
const harl = expectDirectory(tree[0]);
expect(harl.isSubmodule).toBe(true);
expect(harl.children).toHaveLength(1);
expect(expectFile(harl.children[0]).isSubmodulePointer).toBe(true);
expect(collectGitFileTreeDirectoryPaths(tree)).toEqual(["HARL"]);
});
it("groups a dirty submodule without a moved pointer and emits no pointer row", () => {
const tree = buildGitFileTree([changed("HARL/x.txt"), changed("HARL/nested/y.txt")], ["HARL"]);
const harl = expectDirectory(tree[0]);
expect(harl.isSubmodule).toBe(true);
expect(harl.children.every((node) => node.kind !== "file" || node.isSubmodulePointer !== true)).toBe(true);
expect(collectGitFileTreeDirectoryPaths(tree)).toEqual(["HARL", "HARL/nested"]);
});
});
function submodulePointer(path: string, from: string, to: string): GitStatusFile {
return { path, index: "unmodified", workingTree: "modified", submoduleFromCommit: from, submoduleToCommit: to };
}
function changed(path: string): GitStatusFile { function changed(path: string): GitStatusFile {
return { path, index: "modified", workingTree: "modified" }; return { path, index: "modified", workingTree: "modified" };
} }
+52 -16
View File
@@ -3,23 +3,29 @@ import type { GitStatusFile } from "./api";
/** /**
* A changed file placed at a leaf of the Git file tree. `path` is the full * A changed file placed at a leaf of the Git file tree. `path` is the full
* repository-relative path (used to load its diff); `name` is just the final * repository-relative path (used to load its diff); `name` is just the final
* segment shown in the tree row. * segment shown in the tree row. A submodule commit-pointer row reuses this
* shape: `isSubmodulePointer` is set, `path` is the submodule path, and `name`
* is the `<old> → <new>` short-SHA summary.
*/ */
export interface GitFileTreeFileNode { export interface GitFileTreeFileNode {
readonly kind: "file"; readonly kind: "file";
readonly name: string; readonly name: string;
readonly path: string; readonly path: string;
readonly file: GitStatusFile; readonly file: GitStatusFile;
readonly isSubmodulePointer?: boolean;
} }
/** /**
* A directory grouping in the Git file tree. `path` is the full directory path * A directory grouping in the Git file tree. `path` is the full directory path
* and doubles as the stable key used to track expand/collapse state. * and doubles as the stable key used to track expand/collapse state.
* `isSubmodule` marks a directory that is actually a submodule root so the UI
* can label it and treat its contents as the submodule's own changes.
*/ */
export interface GitFileTreeDirectoryNode { export interface GitFileTreeDirectoryNode {
readonly kind: "directory"; readonly kind: "directory";
readonly name: string; readonly name: string;
readonly path: string; readonly path: string;
readonly isSubmodule?: boolean;
readonly children: readonly GitFileTreeNode[]; readonly children: readonly GitFileTreeNode[];
} }
@@ -29,17 +35,34 @@ interface DirectoryAccumulator {
readonly path: string; readonly path: string;
readonly directories: Map<string, DirectoryAccumulator>; readonly directories: Map<string, DirectoryAccumulator>;
readonly files: GitFileTreeFileNode[]; readonly files: GitFileTreeFileNode[];
isSubmodule: boolean;
pointer?: GitFileTreeFileNode;
} }
/** /**
* Build a nested directory/file tree from Git's flat changed-file list. The * Build a nested directory/file tree from Git's flat changed-file list. The
* status response already carries every changed path, so this is a pure * status response already carries every changed path, so this is a pure
* client-side transform (no lazy per-directory loading like the Files tab). * client-side transform (no lazy per-directory loading like the Files tab).
* Directories sort before files, and both sort alphabetically within a level. *
* `submodules` lists submodule roots: a directory matching one is marked as a
* submodule, a file whose path equals one becomes that submodule's pinned
* commit-pointer row, and files below one nest inside it. Directories sort
* before files (pointer row first within a submodule), both alphabetically.
*/ */
export function buildGitFileTree(files: readonly GitStatusFile[]): GitFileTreeNode[] { export function buildGitFileTree(files: readonly GitStatusFile[], submodules: readonly string[] = []): GitFileTreeNode[] {
const root = createDirectoryAccumulator(""); const root = createDirectoryAccumulator("");
const submoduleSet = new Set(submodules);
// Ensure a node exists (and is marked) for every submodule, so a submodule
// whose only change is a moved pointer still renders as an expandable root.
for (const submodule of submodules) ensureDirectory(root, submodule).isSubmodule = true;
for (const file of files) { for (const file of files) {
if (submoduleSet.has(file.path)) {
const directory = ensureDirectory(root, file.path);
directory.isSubmodule = true;
directory.pointer = { kind: "file", name: pointerName(file), path: file.path, file, isSubmodulePointer: true };
continue;
}
const segments = file.path.split("/").filter((segment) => segment.length > 0); const segments = file.path.split("/").filter((segment) => segment.length > 0);
const name = segments[segments.length - 1]; const name = segments[segments.length - 1];
if (name === undefined) continue; if (name === undefined) continue;
@@ -47,15 +70,7 @@ export function buildGitFileTree(files: readonly GitStatusFile[]): GitFileTreeNo
for (let index = 0; index < segments.length - 1; index += 1) { for (let index = 0; index < segments.length - 1; index += 1) {
const segment = segments[index]; const segment = segments[index];
if (segment === undefined) continue; if (segment === undefined) continue;
const childPath = directory.path.length === 0 ? segment : `${directory.path}/${segment}`; directory = childDirectory(directory, segment);
const existing = directory.directories.get(childPath);
if (existing === undefined) {
const created = createDirectoryAccumulator(childPath);
directory.directories.set(childPath, created);
directory = created;
} else {
directory = existing;
}
} }
directory.files.push({ kind: "file", name, path: file.path, file }); directory.files.push({ kind: "file", name, path: file.path, file });
} }
@@ -65,7 +80,7 @@ export function buildGitFileTree(files: readonly GitStatusFile[]): GitFileTreeNo
/** /**
* Every directory path in the tree, in a stable order. Used to drive the * Every directory path in the tree, in a stable order. Used to drive the
* expand-all / collapse-all control and to decide whether the tree is fully * expand-all / collapse-all control and to decide whether the tree is fully
* expanded. * expanded. Submodule roots are directories and are included.
*/ */
export function collectGitFileTreeDirectoryPaths(nodes: readonly GitFileTreeNode[]): string[] { export function collectGitFileTreeDirectoryPaths(nodes: readonly GitFileTreeNode[]): string[] {
const paths: string[] = []; const paths: string[] = [];
@@ -78,16 +93,37 @@ export function collectGitFileTreeDirectoryPaths(nodes: readonly GitFileTreeNode
return paths; return paths;
} }
function pointerName(file: GitStatusFile): string {
const from = file.submoduleFromCommit;
const to = file.submoduleToCommit;
return from !== undefined && to !== undefined ? `${from}${to}` : "commit";
}
function createDirectoryAccumulator(path: string): DirectoryAccumulator { function createDirectoryAccumulator(path: string): DirectoryAccumulator {
return { path, directories: new Map(), files: [] }; return { path, directories: new Map(), files: [], isSubmodule: false };
}
function childDirectory(parent: DirectoryAccumulator, segment: string): DirectoryAccumulator {
const childPath = parent.path.length === 0 ? segment : `${parent.path}/${segment}`;
const existing = parent.directories.get(childPath);
if (existing !== undefined) return existing;
const created = createDirectoryAccumulator(childPath);
parent.directories.set(childPath, created);
return created;
}
function ensureDirectory(root: DirectoryAccumulator, path: string): DirectoryAccumulator {
let directory = root;
for (const segment of path.split("/").filter((part) => part.length > 0)) directory = childDirectory(directory, segment);
return directory;
} }
function finalizeChildren(directory: DirectoryAccumulator): GitFileTreeNode[] { function finalizeChildren(directory: DirectoryAccumulator): GitFileTreeNode[] {
const directories: GitFileTreeDirectoryNode[] = [...directory.directories.values()] const directories: GitFileTreeDirectoryNode[] = [...directory.directories.values()]
.map((child): GitFileTreeDirectoryNode => ({ kind: "directory", name: segmentName(child.path), path: child.path, children: finalizeChildren(child) })) .map((child): GitFileTreeDirectoryNode => ({ kind: "directory", name: segmentName(child.path), path: child.path, ...(child.isSubmodule ? { isSubmodule: true } : {}), children: finalizeChildren(child) }))
.sort((left, right) => left.name.localeCompare(right.name)); .sort((left, right) => left.name.localeCompare(right.name));
const files = [...directory.files].sort((left, right) => left.name.localeCompare(right.name)); const files = [...directory.files].sort((left, right) => left.name.localeCompare(right.name));
return [...directories, ...files]; return [...(directory.pointer === undefined ? [] : [directory.pointer]), ...directories, ...files];
} }
function segmentName(path: string): string { function segmentName(path: string): string {
+111
View File
@@ -0,0 +1,111 @@
import { execFileSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync, renameSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterAll, describe, expect, it } from "vitest";
import { gitDiff, gitStatus } from "./gitService.js";
// Isolate from any global/system git config and force a deterministic identity;
// `protocol.file.allow` is required for `submodule add` from a local path.
const GIT_FLAGS = ["-c", "user.name=Test", "-c", "[email protected]", "-c", "protocol.file.allow=always", "-c", "commit.gpgsign=false"];
const GIT_ENV = { ...process.env, GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_SYSTEM: "/dev/null", GIT_TERMINAL_PROMPT: "0" };
const created: string[] = [];
afterAll(() => { for (const dir of created) rmSync(dir, { recursive: true, force: true }); });
function git(cwd: string, args: string[]): string {
return execFileSync("git", [...GIT_FLAGS, ...args], { cwd, encoding: "utf8", env: GIT_ENV });
}
/** Superproject at `dir` with a submodule `HARL` recorded at commit `c2`; the
* submodule origin has two commits `c1` (a.txt=v1) then `c2` (a.txt=v2). */
function createFixture(): { dir: string; c1: string; c2: string } {
const base = mkdtempSync(join(tmpdir(), "pi-web-sub-"));
created.push(base);
const origin = join(base, "origin");
const sup = join(base, "sup");
git(base, ["init", "-b", "main", origin]);
writeFileSync(join(origin, "a.txt"), "v1\n");
git(origin, ["add", "-A"]);
git(origin, ["commit", "-m", "c1"]);
const c1 = git(origin, ["rev-parse", "HEAD"]).trim();
writeFileSync(join(origin, "a.txt"), "v2\n");
git(origin, ["add", "-A"]);
git(origin, ["commit", "-m", "c2"]);
const c2 = git(origin, ["rev-parse", "HEAD"]).trim();
git(base, ["init", "-b", "main", sup]);
git(sup, ["submodule", "add", origin, "HARL"]);
writeFileSync(join(sup, "root.txt"), "root\n");
git(sup, ["add", "-A"]);
git(sup, ["commit", "-m", "init"]);
return { dir: sup, c1, c2 };
}
describe("gitStatus with submodules", () => {
it("surfaces a moved commit pointer with short SHAs and no inner files", async () => {
const { dir, c1, c2 } = createFixture();
git(join(dir, "HARL"), ["checkout", c1]); // move the pointer, leave the tree clean
const status = await gitStatus(dir);
expect(status.submodules).toContain("HARL");
const pointer = status.files.find((file) => file.path === "HARL");
expect(pointer?.submoduleFromCommit).toBe(c2.slice(0, 7));
expect(pointer?.submoduleToCommit).toBe(c1.slice(0, 7));
expect(status.files.some((file) => file.path.startsWith("HARL/"))).toBe(false);
});
it("lists modified and untracked inner files and omits the pointer when the commit is unchanged", async () => {
const { dir } = createFixture();
writeFileSync(join(dir, "HARL", "a.txt"), "v2\nchanged\n");
writeFileSync(join(dir, "HARL", "new.txt"), "brand-new\n");
const status = await gitStatus(dir);
expect(status.submodules).toContain("HARL");
expect(status.files.find((file) => file.path === "HARL")).toBeUndefined();
const inner = status.files.filter((file) => file.path.startsWith("HARL/")).map((file) => file.path);
expect(inner).toContain("HARL/a.txt");
expect(inner).toContain("HARL/new.txt");
});
it("skips inner recursion without throwing when the submodule repo is unreadable", async () => {
const { dir } = createFixture();
writeFileSync(join(dir, "HARL", "new.txt"), "brand-new\n"); // untracked → would trigger recursion
renameSync(join(dir, "HARL", ".git"), join(dir, "HARL", ".git.bak")); // break the inner repo
const status = await gitStatus(dir);
expect(status.isGitRepo).toBe(true);
expect(status.files.some((file) => file.path.startsWith("HARL/"))).toBe(false);
});
});
describe("gitDiff routing into submodules", () => {
it("returns real content for a tracked file inside the submodule", async () => {
const { dir } = createFixture();
writeFileSync(join(dir, "HARL", "a.txt"), "v2\nchanged\n");
const diff = await gitDiff(dir, { path: "HARL/a.txt" });
expect(diff.path).toBe("HARL/a.txt");
expect(diff.diff).toContain("@@");
expect(diff.diff).toContain("changed");
});
it("produces an untracked-file diff inside the submodule via --no-index", async () => {
const { dir } = createFixture();
writeFileSync(join(dir, "HARL", "new.txt"), "brand-new\n");
const diff = await gitDiff(dir, { path: "HARL/new.txt" });
expect(diff.path).toBe("HARL/new.txt");
expect(diff.diff).toContain("brand-new");
});
it("diffs the submodule path itself against the superproject pointer", async () => {
const { dir, c1 } = createFixture();
git(join(dir, "HARL"), ["checkout", c1]);
const diff = await gitDiff(dir, { path: "HARL" });
expect(diff.path).toBe("HARL");
expect(diff.diff).toContain("Subproject commit");
});
});
+167 -5
View File
@@ -1,15 +1,103 @@
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import { spawn } from "node:child_process"; import { spawn } from "node:child_process";
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";
import { sanitizedGitEnv } from "./gitEnv.js"; import { sanitizedGitEnv } from "./gitEnv.js";
const MAX_OUTPUT = 2 * 1024 * 1024; 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> { export async function gitStatus(cwd: string): Promise<GitStatusResponse> {
const result = await runGit(cwd, ["status", "--porcelain=v2", "--branch", "--untracked-files=all", "-z"]); 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: [] }; if (result.code !== 0) return { isGitRepo: false, hash: hash(result.stdout + result.stderr), files: [], submodules: [] };
return parseStatus(result.stdout); 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> { 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; let path: string | undefined;
if (options.path !== undefined && options.path !== "") path = normalizeRelativePath(options.path); 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"]; const args = ["diff", "--no-ext-diff", "--color=never"];
if (staged) args.push("--cached"); if (staged) args.push("--cached");
if (path !== undefined) args.push("--", path); 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 }; 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> { async function isUntracked(cwd: string, path: string): Promise<boolean> {
const result = await runGit(cwd, ["ls-files", "--others", "--exclude-standard", "-z", "--", path]); const result = await runGit(cwd, ["ls-files", "--others", "--exclude-standard", "-z", "--", path]);
return result.code === 0 && result.stdout.split("\0").includes(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 records = raw.split("\0").filter((record) => record !== "");
const files: GitStatusFile[] = []; const files: GitStatusFile[] = [];
const submodules: SubmoduleRecord[] = [];
let branch: string | undefined; let branch: string | undefined;
let upstream: string | undefined; let upstream: string | undefined;
let ahead: number | 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("! ")) files.push({ path: record.slice(2), index: "ignored", workingTree: "ignored" });
else if (record.startsWith("1 ")) { else if (record.startsWith("1 ")) {
const parts = record.split(" "); 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 ")) { } else if (record.startsWith("2 ")) {
const parts = record.split(" "); const parts = record.split(" ");
const path = parts.slice(9).join(" "); 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 { function stateFor(code: string | undefined): GitFileState {
@@ -90,6 +248,10 @@ function normalizeBranch(value: string): string | undefined {
return value === "(detached)" ? undefined : value; return value === "(detached)" ? undefined : value;
} }
function short(oid: string): string {
return oid.slice(0, 7);
}
function hash(value: string): string { function hash(value: string): string {
return createHash("sha1").update(value).digest("hex"); return createHash("sha1").update(value).digest("hex");
} }
+9
View File
@@ -681,6 +681,10 @@ export interface GitStatusFile {
oldPath?: string; oldPath?: string;
index: GitFileState; index: GitFileState;
workingTree: GitFileState; workingTree: GitFileState;
// Set only on a submodule commit-pointer entry (path equals the submodule's
// superproject-relative path). Short SHAs of the recorded and current commit.
submoduleFromCommit?: string;
submoduleToCommit?: string;
} }
export interface GitStatusResponse { export interface GitStatusResponse {
@@ -691,6 +695,11 @@ export interface GitStatusResponse {
ahead?: number; ahead?: number;
behind?: number; behind?: number;
files: GitStatusFile[]; files: GitStatusFile[];
// Superproject-relative paths of submodules that carry a change. Files inside
// a submodule appear in `files` under `<submodule>/<inner path>`; the client
// uses this list to group and label them and to distinguish a submodule root
// from an ordinary directory with the same name.
submodules: string[];
} }
export interface GitDiffResponse { export interface GitDiffResponse {