Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions src/supervisor/git/statusService.parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
import { describe, expect, it } from "vitest";
import {
buildGitStatusResultFromOutputs,
buildGitStatusSummaryFromOutput,
expandUntrackedEntries,
parseDiffNumstat,
parseStatusPorcelainV2,
} from "./statusService";
Expand Down Expand Up @@ -252,3 +254,71 @@ describe("buildGitStatusResultFromOutputs", () => {
expect(result.remoteInfo?.owner).toBe("owner");
});
});

describe("expandUntrackedEntries", () => {
it("replaces a collapsed `? dir/` entry with one entry per ls-files path", () => {
const parsed = parseStatusPorcelainV2(["# branch.head main", "? src/widget/"].join("\n"));
expandUntrackedEntries(parsed, "src/widget/a.ts\0src/widget/b.ts\0src/widget/c.css\0");
expect(parsed.unstaged.map((f) => f.path)).toEqual([
"src/widget/a.ts",
"src/widget/b.ts",
"src/widget/c.css",
]);
// Counts stay 0 — the summary path leaves them for mergeSummaryStatus to backfill.
expect(parsed.unstaged.every((f) => f.status === "?" && !f.staged)).toBe(true);
expect(parsed.unstaged.every((f) => f.insertions === 0 && f.deletions === 0)).toBe(true);
});

it("keeps tracked unstaged entries and drops only collapsed `?` rows", () => {
const parsed = parseStatusPorcelainV2(
[
"# branch.head main",
"1 .M N... 100644 100644 100644 bbb bbb src/tracked.ts",
"? src/new/",
].join("\n"),
);
expandUntrackedEntries(parsed, "src/new/x.ts\0");
expect(parsed.unstaged.map((f) => f.path)).toEqual(["src/tracked.ts", "src/new/x.ts"]);
});

it("is a no-op when there are no untracked entries", () => {
const parsed = parseStatusPorcelainV2(
["# branch.head main", "1 .M N... 100644 100644 100644 bbb bbb src/tracked.ts"].join("\n"),
);
expandUntrackedEntries(parsed, "src/ignored.ts\0");
expect(parsed.unstaged.map((f) => f.path)).toEqual(["src/tracked.ts"]);
});

it("leaves the collapsed entry intact when ls-files output is empty", () => {
const parsed = parseStatusPorcelainV2(["# branch.head main", "? src/new/"].join("\n"));
expandUntrackedEntries(parsed, "");
expect(parsed.unstaged.map((f) => f.path)).toEqual(["src/new/"]);
});
});

describe("buildGitStatusSummaryFromOutput", () => {
it("keeps untracked directories collapsed when ls-files output is empty", () => {
const result = buildGitStatusSummaryFromOutput(
["# branch.head main", "? src/new/"].join("\n"),
"",
);
expect(result.detail).toBe("summary");
expect(result.unstaged.map((f) => f.path)).toEqual(["src/new/"]);
});

it("expands untracked directories so the summary file list matches the full path", () => {
const result = buildGitStatusSummaryFromOutput(
["# branch.head main", "? src/new/"].join("\n"),
"src/new/a.ts\0src/new/b.ts\0",
);
// Same per-file granularity the full/enriched path produces, with status "?"
// and counts 0 — keys line up so mergeSummaryStatus can backfill insertions.
expect(
result.unstaged.map((f) => ({ path: f.path, status: f.status, staged: f.staged })),
).toEqual([
{ path: "src/new/a.ts", status: "?", staged: false },
{ path: "src/new/b.ts", status: "?", staged: false },
]);
expect(result.totalInsertions).toBe(0);
});
});
120 changes: 80 additions & 40 deletions src/supervisor/git/statusService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ function parseUntrackedPaths(output: string): string[] {
.map((path) => toForwardSlash(path));
}

/** Lists untracked, non-ignored files NUL-separated — one path per file. */
const LS_FILES_UNTRACKED_ARGS = ["ls-files", "--others", "--exclude-standard", "-z"];

export function parseStatusPorcelainV2(output: string): ParsedPorcelainStatus {
let branch = "";
let tracking = "";
Expand Down Expand Up @@ -274,8 +277,40 @@ export function buildGitStatusResultFromOutputs(args: {
};
}

function buildGitStatusSummaryFromOutput(statusOutput: string): GitStatusResult {
/**
* Expand the single collapsed `? dir/` porcelain entries into one entry per
* untracked file, using the paths from `git ls-files --others --exclude-standard
* -z`. Insertion/deletion counts are left at 0 so the summary path stays cheap
* (no file reads); {@link mergeSummaryStatus} in the renderer backfills the
* counts from the prior full refresh by matching `path`/`status` keys.
*
* The point is to keep the summary file list structurally identical to the full
* path's expanded list. Without this, the cheap summary (poll/fetch) returns one
* collapsed directory row while the full refresh (watcher/initial) returns one
* row per file — so the Changes panel visibly flips between a collapsed and an
* expanded view of the same working tree, and the key-based count backfill fails.
*/
export function expandUntrackedEntries(parsed: ParsedPorcelainStatus, lsFilesOutput: string): void {
if (!parsed.unstaged.some((file) => file.status === "?")) return;
const untrackedPaths = parseUntrackedPaths(lsFilesOutput);
if (untrackedPaths.length === 0) return;
const trackedUnstaged = parsed.unstaged.filter((file) => file.status !== "?");
const untracked: GitFileChange[] = untrackedPaths.map((path) => ({
path,
status: "?",
staged: false,
insertions: 0,
deletions: 0,
}));
parsed.unstaged = [...trackedUnstaged, ...untracked];
}

export function buildGitStatusSummaryFromOutput(
statusOutput: string,
untrackedOutput: string,
): GitStatusResult {
const parsed = parseStatusPorcelainV2(statusOutput);
expandUntrackedEntries(parsed, untrackedOutput);
return {
detail: "summary",
isRepo: true,
Expand Down Expand Up @@ -425,18 +460,25 @@ export class GitStatusService {
if (location.kind === "wsl") {
const results = await execGitBatchWslBridge(
location,
[{ cwd: location.linuxPath, args: ["status", "--porcelain=v2", "-b"] }],
[
{ cwd: location.linuxPath, args: ["status", "--porcelain=v2", "-b"] },
{ cwd: location.linuxPath, args: LS_FILES_UNTRACKED_ARGS },
],
GIT_STATUS_TIMEOUT,
);
const result = results[0];
return result?.ok ? buildGitStatusSummaryFromOutput(result.stdout) : nonRepoSummaryStatus();
const untracked = results[1];
return result?.ok
? buildGitStatusSummaryFromOutput(result.stdout, untracked?.ok ? untracked.stdout : "")
: nonRepoSummaryStatus();
}

try {
const statusOutput = await execGit(location, ["status", "--porcelain=v2", "-b"], {
timeout: GIT_STATUS_TIMEOUT,
});
return buildGitStatusSummaryFromOutput(statusOutput);
const [statusOutput, untrackedOutput] = await Promise.all([
execGit(location, ["status", "--porcelain=v2", "-b"], { timeout: GIT_STATUS_TIMEOUT }),
execGit(location, LS_FILES_UNTRACKED_ARGS, { timeout: GIT_STATUS_TIMEOUT }).catch(() => ""),
]);
return buildGitStatusSummaryFromOutput(statusOutput, untrackedOutput);
} catch {
return nonRepoSummaryStatus();
}
Expand Down Expand Up @@ -566,18 +608,27 @@ export class GitStatusService {
): Promise<Record<string, GitStatusResult>> {
if (worktreePaths.length === 0) return {};

const bridgeCommands = worktreePaths.map((cwd) => ({
cwd,
args: ["status", "--porcelain=v2", "-b"],
}));
const PER_WORKTREE_CMDS = 2;
const bridgeCommands: { cwd: string; args: string[] }[] = [];
for (const cwd of worktreePaths) {
bridgeCommands.push(
{ cwd, args: ["status", "--porcelain=v2", "-b"] },
{ cwd, args: LS_FILES_UNTRACKED_ARGS },
);
}
const results = await execGitBatchWslBridge(location, bridgeCommands, GIT_STATUS_TIMEOUT);

const out: Record<string, GitStatusResult> = {};
for (let i = 0; i < worktreePaths.length; i += 1) {
const result = results[i];
if (!result) continue;
out[worktreePaths[i]!] = result.ok
? buildGitStatusSummaryFromOutput(result.stdout)
const off = i * PER_WORKTREE_CMDS;
const statusResult = results[off];
if (!statusResult) continue;
const untrackedResult = results[off + 1];
out[worktreePaths[i]!] = statusResult.ok
? buildGitStatusSummaryFromOutput(
statusResult.stdout,
untrackedResult?.ok ? untrackedResult.stdout : "",
)
: nonRepoSummaryStatus();
}
return out;
Expand Down Expand Up @@ -739,34 +790,23 @@ export class GitStatusService {
location: ProjectLocation,
parsed: ParsedPorcelainStatus,
): Promise<void> {
const hasUntracked = parsed.unstaged.some((file) => file.status === "?");
if (!hasUntracked) {
return;
}
if (!parsed.unstaged.some((file) => file.status === "?")) return;

const lsFilesOutput = await execGit(
location,
["ls-files", "--others", "--exclude-standard", "-z"],
{
timeout: GIT_STATUS_TIMEOUT,
},
).catch(() => "");
const actualUntrackedPaths = parseUntrackedPaths(lsFilesOutput);
if (actualUntrackedPaths.length === 0) {
return;
}
const lsFilesOutput = await execGit(location, LS_FILES_UNTRACKED_ARGS, {
timeout: GIT_STATUS_TIMEOUT,
}).catch(() => "");
// Share the row-shaping with the summary path so the two never disagree on
// untracked-entry shape (the key the renderer's count backfill matches on).
expandUntrackedEntries(parsed, lsFilesOutput);

const trackedUnstaged = parsed.unstaged.filter((file) => file.status !== "?");
const untracked = await Promise.all(
actualUntrackedPaths.map(async (path) => ({
path,
status: "?",
staged: false,
insertions: await this.readUntrackedInsertions(location, path),
deletions: 0,
})),
// The full path additionally counts insertions per untracked file; the
// expanded rows arrive with counts at 0, so fill them in here.
await Promise.all(
parsed.unstaged.map(async (file) => {
if (file.status !== "?") return;
file.insertions = await this.readUntrackedInsertions(location, file.path);
}),
);
parsed.unstaged = [...trackedUnstaged, ...untracked];
}

private async readUntrackedInsertions(
Expand Down