Archived
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:
@@ -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");
|
||||
});
|
||||
|
||||
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", () => {
|
||||
const textFile = {
|
||||
path: "README.md",
|
||||
|
||||
@@ -818,12 +818,12 @@ function optionalFileMediaType(value: unknown): FileContentResponse["mediaType"]
|
||||
|
||||
export function parseGitStatusResponse(value: unknown): GitStatusResponse {
|
||||
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 {
|
||||
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 {
|
||||
|
||||
@@ -2,16 +2,19 @@ import { css, html, LitElement, type PropertyValues, type TemplateResult } from
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import type { GitDiffResponse, GitStatusFile, GitStatusResponse } from "../api";
|
||||
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 type { WorkspacePanelContext } from "../plugins/types";
|
||||
import { workspacePanelStyles } from "./shared";
|
||||
|
||||
interface GitTreeState {
|
||||
interface GitViewState {
|
||||
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")
|
||||
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.selected { position: relative; z-index: 1; }
|
||||
.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
|
||||
// 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>();
|
||||
|
||||
protected override willUpdate(changedProperties: PropertyValues<this>): void {
|
||||
@@ -53,20 +58,20 @@ export class WorkspaceGitPanel extends LitElement {
|
||||
const context = this.context;
|
||||
if (context === undefined) return html`<p class="muted">Git unavailable.</p>`;
|
||||
const status = context.gitStatus;
|
||||
const treeState = this.computeTreeState(status);
|
||||
const viewState = this.computeViewState(status);
|
||||
return html`
|
||||
<section class="toolbar">
|
||||
<strong>Git</strong>
|
||||
${context.gitStale ? html`<span class="stale">stale</span>` : null}
|
||||
<div class="toolbar-actions">
|
||||
${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>
|
||||
</div>
|
||||
</section>
|
||||
<section class="split">
|
||||
<div class=${this.view === "tree" ? "list tree" : "list"}>
|
||||
${this.renderFileList(context, status, treeState.nodes)}
|
||||
${this.renderFileList(context, status, viewState)}
|
||||
</div>
|
||||
<div class="viewer">${renderDiffViewer(context)}</div>
|
||||
</section>
|
||||
@@ -89,66 +94,93 @@ export class WorkspaceGitPanel extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private renderExpandCollapseAll(directoryPaths: readonly string[]): TemplateResult {
|
||||
const allExpanded = directoryPaths.every((path) => this.expandedDirectories.has(path));
|
||||
private renderExpandCollapseAll(expandablePaths: readonly string[]): TemplateResult {
|
||||
const allExpanded = expandablePaths.every((path) => this.expandedDirectories.has(path));
|
||||
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.isGitRepo) return html`<p class="muted">Not a git repository.</p>`;
|
||||
const summary = html`<p class="summary">${gitSummary(status)}</p>`;
|
||||
if (status.files.length === 0) return html`${summary}<p class="muted">No changes.</p>`;
|
||||
const body = this.view === "tree"
|
||||
? nodes.map((node) => this.renderTreeNode(context, node, 0))
|
||||
: status.files.map((file) => this.renderFileRow(context, file));
|
||||
? viewState.nodes.map((node) => this.renderTreeNode(context, node, 0))
|
||||
: this.renderListBody(context, viewState.listModel);
|
||||
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 {
|
||||
if (node.kind === "directory") {
|
||||
const expanded = this.expandedDirectories.has(node.path);
|
||||
return html`
|
||||
<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>${node.name}</span>
|
||||
<span>${node.name}${node.isSubmodule === true ? submoduleBadge() : null}</span>
|
||||
</button>
|
||||
${expanded ? node.children.map((child) => this.renderTreeNode(context, child, depth + 1)) : null}
|
||||
`;
|
||||
}
|
||||
const selected = context.selectedDiffPath === node.path;
|
||||
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>
|
||||
`;
|
||||
return this.renderSelectableRow(context, node.path, node.name, node.file, depth);
|
||||
}
|
||||
|
||||
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`
|
||||
<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>${file.path}</span>
|
||||
<span>${label}</span>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
private computeTreeState(status: GitStatusResponse | undefined): GitTreeState {
|
||||
if (this.view !== "tree" || status === undefined || !status.isGitRepo || status.files.length === 0) return EMPTY_TREE_STATE;
|
||||
const nodes = buildGitFileTree(status.files);
|
||||
return { nodes, directoryPaths: collectGitFileTreeDirectoryPaths(nodes) };
|
||||
private computeViewState(status: GitStatusResponse | undefined): GitViewState {
|
||||
if (status === undefined || !status.isGitRepo || status.files.length === 0) return EMPTY_VIEW_STATE;
|
||||
if (this.view === "tree") {
|
||||
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 {
|
||||
if (this.view === view) return;
|
||||
this.view = view;
|
||||
writeGitFileView(view);
|
||||
// The tree always starts fully collapsed when entered.
|
||||
if (view === "tree") this.expandedDirectories = new Set();
|
||||
// Entering either view starts fully collapsed.
|
||||
this.expandedDirectories = new Set();
|
||||
}
|
||||
|
||||
private toggleDirectory(path: string): void {
|
||||
@@ -158,11 +190,15 @@ export class WorkspaceGitPanel extends LitElement {
|
||||
this.expandedDirectories = next;
|
||||
}
|
||||
|
||||
private toggleExpandAll(directoryPaths: readonly string[], allExpanded: boolean): void {
|
||||
this.expandedDirectories = allExpanded ? new Set() : new Set(directoryPaths);
|
||||
private toggleExpandAll(expandablePaths: readonly string[], allExpanded: boolean): void {
|
||||
this.expandedDirectories = allExpanded ? new Set() : new Set(expandablePaths);
|
||||
}
|
||||
}
|
||||
|
||||
function submoduleBadge(): TemplateResult {
|
||||
return html`<span class="submodule-badge">submodule</span>`;
|
||||
}
|
||||
|
||||
function renderDiffViewer(context: WorkspacePanelContext): TemplateResult {
|
||||
if (context.selectedDiffPath === undefined || context.selectedDiffPath === "") return html`<p class="muted">Select a changed file.</p>`;
|
||||
const unstaged = context.selectedDiff;
|
||||
|
||||
@@ -58,7 +58,7 @@ describe("MachineController", () => {
|
||||
selectedSession: session,
|
||||
fileTree: [{ name: "index.ts", path: "src/index.ts", type: "file" }],
|
||||
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,
|
||||
error: "stale error",
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -58,8 +58,50 @@ describe("buildGitFileTree", () => {
|
||||
const tree = buildGitFileTree([changed("src/b/c.ts"), changed("src/a.ts"), changed("docs/x.md")]);
|
||||
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 {
|
||||
return { path, index: "modified", workingTree: "modified" };
|
||||
}
|
||||
|
||||
@@ -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
|
||||
* 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 {
|
||||
readonly kind: "file";
|
||||
readonly name: string;
|
||||
readonly path: string;
|
||||
readonly file: GitStatusFile;
|
||||
readonly isSubmodulePointer?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* `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 {
|
||||
readonly kind: "directory";
|
||||
readonly name: string;
|
||||
readonly path: string;
|
||||
readonly isSubmodule?: boolean;
|
||||
readonly children: readonly GitFileTreeNode[];
|
||||
}
|
||||
|
||||
@@ -29,17 +35,34 @@ interface DirectoryAccumulator {
|
||||
readonly path: string;
|
||||
readonly directories: Map<string, DirectoryAccumulator>;
|
||||
readonly files: GitFileTreeFileNode[];
|
||||
isSubmodule: boolean;
|
||||
pointer?: GitFileTreeFileNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* 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 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) {
|
||||
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 name = segments[segments.length - 1];
|
||||
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) {
|
||||
const segment = segments[index];
|
||||
if (segment === undefined) continue;
|
||||
const childPath = directory.path.length === 0 ? segment : `${directory.path}/${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 = childDirectory(directory, segment);
|
||||
}
|
||||
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
|
||||
* 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[] {
|
||||
const paths: string[] = [];
|
||||
@@ -78,16 +93,37 @@ export function collectGitFileTreeDirectoryPaths(nodes: readonly GitFileTreeNode
|
||||
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 {
|
||||
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[] {
|
||||
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));
|
||||
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 {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -681,6 +681,10 @@ export interface GitStatusFile {
|
||||
oldPath?: string;
|
||||
index: 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 {
|
||||
@@ -691,6 +695,11 @@ export interface GitStatusResponse {
|
||||
ahead?: number;
|
||||
behind?: number;
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user