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:
@@ -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.
|
||||
@@ -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