Compare commits

...
Author SHA1 Message Date
paseo-bot[bot] 605c3f3832 fix(desktop): keep running when launcher console pipes close
Desktop logging reaches inherited stdout and stderr before the CLI guards run. A launcher that closes its output reader raised an uncaught EPIPE dialog. Handle closed console pipes at desktop startup while preserving console logging and surfacing other stream errors.
2026-10-14 14:43:06 +02:00
paseo-bot[bot]andRaisal P Wardana cafa04d3fb fix(app): traverse workspaces beyond nine (#6586)
Relative navigation shared the list capped for digit shortcuts, so it wrapped at the ninth workspace. Keep all sidebar targets and cap only numbered badges. Carry forward #4128 with keyboard-routing and browser regressions.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Raisal P Wardana <IEatCodeDaily@users.noreply.github.com>
2026-10-14 13:14:08 +02:00
paseo-bot[bot]and见安 938c4e2632 Show Git diffs for filenames with quotes and control characters (#6583)
* fix(diff): decode quoted Git patch paths

* fix(server): preserve Git paths in diff and commit listings

* refactor(diff): make quoted-path conversions explicit

* test(server): verify quoted filename diffs through daemon RPC

Keep the checkout and commit-file regression on the public client interface, and align the large-file command assertion with NUL-delimited statistics.

Co-authored-by: 见安 <73627805+Mar-garet@users.noreply.github.com>

* test(server): run filename RPC regression in integration checks

---------

Co-authored-by: 见安 <73627805+Mar-garet@users.noreply.github.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-14 12:09:47 +02:00
14 changed files with 649 additions and 48 deletions
@@ -0,0 +1,74 @@
import type { Page } from "@playwright/test";
import { test, expect } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { seedWorkspace } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
import {
expectSidebarWorkspaceSelected,
switchWorkspaceViaSidebar,
} from "../support/helpers/workspace-ui";
async function readVisibleWorkspaceOrder(page: Page): Promise<string[]> {
const rows = page.getByRole("button", { name: /^Workspace \d{2}$/ });
await expect(rows).toHaveCount(12);
return rows.evaluateAll(
(elements, prefix) =>
elements.map((element) => element.getAttribute("data-testid")!.slice(prefix.length)),
`sidebar-workspace-row-${getServerId()}:`,
);
}
async function navigateWorkspace(
page: Page,
direction: "next" | "previous",
workspaceId: string,
): Promise<void> {
await page.keyboard.press(direction === "next" ? "Alt+]" : "Alt+[");
await expectSidebarWorkspaceSelected({ page, serverId: getServerId(), workspaceId });
await expect(page).toHaveURL(new RegExp(`/workspace/${workspaceId}$`));
}
test("previous and next traverse every visible workspace while digit shortcuts stop at nine", async ({
page,
}) => {
test.setTimeout(120_000);
const project = await seedWorkspace({
repoPrefix: "relative-navigation-",
title: "Workspace 01",
});
try {
for (let index = 2; index <= 12; index += 1) {
const created = await project.client.createWorkspace({
source: { kind: "directory", path: project.repoPath },
title: `Workspace ${String(index).padStart(2, "0")}`,
});
expect(created.error).toBeNull();
}
await page.setViewportSize({ width: 1440, height: 1100 });
await gotoAppShell(page);
const order = await readVisibleWorkspaceOrder(page);
await test.step("next crosses the ninth workspace and previous returns to it", async () => {
await switchWorkspaceViaSidebar({ page, serverId: getServerId(), workspaceId: order[8] });
await navigateWorkspace(page, "next", order[9]);
await navigateWorkspace(page, "previous", order[8]);
});
await test.step("wrapping uses the true first and last visible workspace", async () => {
await switchWorkspaceViaSidebar({ page, serverId: getServerId(), workspaceId: order[0] });
await navigateWorkspace(page, "previous", order[11]);
await navigateWorkspace(page, "next", order[0]);
});
await test.step("the ninth digit shortcut still selects the ninth workspace", async () => {
await page.keyboard.press("Alt+9");
await expectSidebarWorkspaceSelected({
page,
serverId: getServerId(),
workspaceId: order[8],
});
});
} finally {
await project.cleanup();
}
});
@@ -5,6 +5,7 @@ import type {
SidebarWorkspaceEntry,
} from "@/hooks/use-sidebar-workspaces-list";
import { buildStatusGroups } from "@/hooks/sidebar-status-view-model";
import { routeKeyboardShortcut } from "@/keyboard/route-shortcut";
import {
buildSidebarShortcutModel,
@@ -65,6 +66,46 @@ function project(projectKey: string, workspaces: SidebarWorkspaceEntry[]): Sideb
}
describe("buildSidebarShortcutModel", () => {
it.each([
[9, 1, 10],
[10, -1, 9],
[12, 1, 1],
[1, -1, 12],
] as const)(
"navigates from workspace %i by %i to workspace %i across the full visible sidebar",
(current, delta, next) => {
const workspaces = Array.from({ length: 12 }, (_, index) =>
workspace({
serverId: "s",
workspaceId: `ws-${index + 1}`,
workspaceDirectory: `/repo/w${index + 1}`,
name: `w${index + 1}`,
}),
);
const model = buildSidebarShortcutModel({
projects: [project("p", workspaces)],
collapsedProjectKeys: new Set<string>(),
});
expect(
routeKeyboardShortcut(
{ action: "workspace.navigate.relative", payload: { delta } },
{
pathname: `/h/s/workspace/ws-${current}`,
isMobile: false,
sidebarShortcutTargets: model.shortcutTargets,
navigationActiveWorkspace: null,
commandCenterOpen: false,
shortcutsDialogOpen: false,
},
),
).toEqual({ kind: "navigate-workspace", serverId: "s", workspaceId: `ws-${next}` });
expect(model.shortcutIndexByWorkspaceKey.size).toBe(9);
expect(model.shortcutIndexByWorkspaceKey.get("s:ws-9")).toBe(9);
expect(model.shortcutIndexByWorkspaceKey.has("s:ws-10")).toBe(false);
},
);
it("builds shortcut targets in visual order and excludes collapsed projects", () => {
const projects = [
project("p1", [
@@ -111,7 +152,7 @@ describe("buildSidebarShortcutModel", () => {
expect(model.shortcutIndexByWorkspaceKey.get("s1:ws-repo2-main")).toBeUndefined();
});
it("limits shortcuts to 9", () => {
it("keeps all traversal targets while numbering only the first 9", () => {
const workspaces = Array.from({ length: 20 }, (_, index) =>
workspace({
serverId: "s",
@@ -127,9 +168,11 @@ describe("buildSidebarShortcutModel", () => {
collapsedProjectKeys: new Set<string>(),
});
expect(model.shortcutTargets).toHaveLength(9);
expect(model.shortcutTargets[0]).toEqual({ serverId: "s", workspaceId: "ws-1" });
expect(model.shortcutTargets).toHaveLength(20);
expect(model.shortcutTargets[8]).toEqual({ serverId: "s", workspaceId: "ws-9" });
expect(model.shortcutTargets[9]).toEqual({ serverId: "s", workspaceId: "ws-10" });
expect(model.shortcutIndexByWorkspaceKey.get("s:ws-9")).toBe(9);
expect(model.shortcutIndexByWorkspaceKey.get("s:ws-10")).toBeUndefined();
});
it("excludes a collapsed project's workspaces regardless of project kind", () => {
+3 -6
View File
@@ -70,13 +70,10 @@ export function buildSidebarShortcutSections(input: {
}
for (const workspace of section.workspaces) {
if (shortcutTargets.length >= maxShortcuts) {
break;
}
const shortcutNumber = shortcutTargets.length + 1;
shortcutTargets.push(createShortcutTarget(workspace));
shortcutIndexByWorkspaceKey.set(workspace.workspaceKey, shortcutNumber);
if (shortcutTargets.length <= maxShortcuts) {
shortcutIndexByWorkspaceKey.set(workspace.workspaceKey, shortcutTargets.length);
}
}
}
@@ -0,0 +1,88 @@
import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
const modulePath = fileURLToPath(new URL("./console-streams.ts", import.meta.url));
type Output = "stdout" | "stderr";
async function writeToConsole(input: { closed?: Output; invalidStdout?: boolean } = {}) {
const child = spawn(
process.execPath,
[
"--import",
"tsx",
"-e",
`
const { protectConsoleStreams } = require(${JSON.stringify(modulePath)});
protectConsoleStreams();
process.send("ready");
process.once("message", () => {
if (${JSON.stringify(input.invalidStdout)}) require("node:fs").closeSync(1);
console.info("desktop info");
console.warn("desktop warning");
setImmediate(() => { process.send("alive"); process.disconnect(); });
});
`,
],
{
cwd: fileURLToPath(new URL(".", import.meta.url)),
stdio: ["ignore", "pipe", "pipe", "ipc"],
env: Object.fromEntries(Object.entries(process.env).filter(([key]) => key !== "FORCE_COLOR")),
},
);
let stdout = "";
let stderr = "";
let alive = false;
child.stdout.setEncoding("utf8").on("data", (text: string) => {
stdout += text;
});
child.stderr.setEncoding("utf8").on("data", (text: string) => {
stderr += text;
});
child.on("message", (message) => {
if (message === "ready") {
if (input.closed) child[input.closed].destroy();
child.send("write");
}
if (message === "alive") alive = true;
});
const code = await new Promise<number | null>((resolve, reject) => {
child.once("close", resolve);
child.once("error", reject);
});
return { code, stdout, stderr, alive };
}
describe("desktop console streams", () => {
for (const stream of ["stdout", "stderr"] as const) {
it(`keeps running when the launcher closes ${stream}`, async () => {
const result = await writeToConsole({ closed: stream });
expect(result.code, result.stderr).toBe(0);
expect(result.alive).toBe(true);
expect(result[stream === "stdout" ? "stderr" : "stdout"]).toBe(
stream === "stdout" ? "desktop warning\n" : "desktop info\n",
);
});
}
// Windows stdout uses a native handle rather than the POSIX fd closed here.
it.skipIf(process.platform === "win32")(
"does not suppress a console failure other than a closed pipe",
async () => {
const result = await writeToConsole({ invalidStdout: true });
expect(result.code).toBe(1);
expect(result.alive).toBe(false);
expect(result.stderr).toContain("EBADF");
},
);
it("preserves logging while the console has readers", async () => {
expect(await writeToConsole()).toEqual({
code: 0,
stdout: "desktop info\n",
stderr: "desktop warning\n",
alive: true,
});
});
});
@@ -0,0 +1,11 @@
function ignoreClosedConsole(error: NodeJS.ErrnoException): void {
if (error.code !== "EPIPE") {
throw error;
}
}
/** Keep desktop logging independent of the program that launched its console. */
export function protectConsoleStreams(): void {
process.stdout.on("error", ignoreClosedConsole);
process.stderr.on("error", ignoreClosedConsole);
}
+3
View File
@@ -1,3 +1,6 @@
import { protectConsoleStreams } from "./diagnostics/console-streams.js";
protectConsoleStreams();
process.emitWarning = (() => {}) as typeof process.emitWarning;
import log from "electron-log/main";
+1 -1
View File
@@ -89,7 +89,7 @@
"speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts",
"test": "npm run test:unit && npm run test:integration",
"test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"",
"test:integration": "vitest run --maxWorkers=1 src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts && vitest run --maxWorkers=1 src/server/daemon-e2e/git-operations.e2e.test.ts -t \"merges into\" && vitest run --maxWorkers=1 src/server/session.create-agent-worktree-autoarchive.e2e.test.ts -t \"creates a worktree and auto-archives both\" && vitest run --maxWorkers=1 src/server/agent/providers/opencode-bridge.local.e2e.test.ts -t \"versioned runtime\" && vitest run --maxWorkers=1 src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"oversized attachments\"",
"test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts && vitest run --maxWorkers=1 src/server/daemon-e2e/git-operations.e2e.test.ts -t \"merges into\" && vitest run --maxWorkers=1 src/server/session.create-agent-worktree-autoarchive.e2e.test.ts -t \"creates a worktree and auto-archives both\" && vitest run --maxWorkers=1 src/server/agent/providers/opencode-bridge.local.e2e.test.ts -t \"versioned runtime\" && vitest run --maxWorkers=1 src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"oversized attachments\"",
"test:hub-cli-contract": "vitest run --maxWorkers=1 src/server/hub/hub-cli-contract.test.ts",
"test:integration:all": "npm run test:e2e",
"test:integration:real": "vitest run real.e2e.test.ts",
@@ -0,0 +1,62 @@
import { test, expect } from "vitest";
import { execFileSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createTestPaseoDaemon, DaemonClient } from "./test-utils/index.js";
test.skipIf(process.platform === "win32")(
"checkout and commit diffs preserve filenames that Git quotes",
async () => {
const cwd = mkdtempSync(join(tmpdir(), "git-filename-diffs-"));
const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
const names = [
'report "draft".txt',
"back\\slash.txt",
"tab\tname.txt",
"normal.txt",
"with spaces.txt",
];
const editLines = expect.arrayContaining([
expect.objectContaining({ type: "remove", content: "before" }),
expect.objectContaining({ type: "add", content: "after" }),
]);
const daemon = await createTestPaseoDaemon({ agentClients: {} });
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
try {
git("init", "-b", "main");
git("config", "user.name", "Test");
git("config", "user.email", "test@example.com");
git("config", "core.quotepath", "false");
for (const name of names) writeFileSync(join(cwd, name), "before\n");
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "initial");
for (const name of names) writeFileSync(join(cwd, name), "after\n");
await client.connect();
const checkout = await client.getCheckoutDiff(cwd, { mode: "uncommitted" });
expect(checkout.error).toBeNull();
expect(checkout.files.map((file) => file.path).sort()).toEqual([...names].sort());
for (const file of checkout.files) {
expect(file).toMatchObject({ status: "ok", additions: 1, deletions: 1 });
expect(file.hunks.flatMap((hunk) => hunk.lines)).toEqual(editLines);
}
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "edit");
const sha = git("rev-parse", "HEAD");
for (const name of names) {
const commit = await client.getCommitFileDiff(cwd, sha, name);
expect(commit.file).toMatchObject({ path: name, additions: 1, deletions: 1 });
expect(commit.file?.hunks.flatMap((hunk) => hunk.lines)).toEqual(editLines);
const file = await client.readFile(cwd, name);
expect(file.kind).toBe("text");
expect(new TextDecoder().decode(file.bytes)).toBe("after\n");
}
} finally {
await client.close();
await daemon.close();
rmSync(cwd, { recursive: true, force: true });
}
},
);
@@ -218,6 +218,85 @@ index 1234567..abcdefg 100644
},
);
it("decodes quoted Git patch paths before matching files", () => {
const files = parseDiff(
[
'diff --git "a/report \\"draft\\".txt" "b/report \\"draft\\".txt"',
"index 1234567..abcdefg 100644",
'--- "a/report \\"draft\\".txt"',
'+++ "b/report \\"draft\\".txt"',
"@@ -1 +1 @@",
"-before",
"+after",
"",
].join("\n"),
);
expect(files).toEqual([
{
path: 'report "draft".txt',
isNew: false,
isDeleted: false,
additions: 1,
deletions: 1,
hunks: [
{
oldStart: 1,
oldCount: 1,
newStart: 1,
newCount: 1,
lines: [
{ type: "header", content: "@@ -1 +1 @@" },
{ type: "remove", content: "before" },
{ type: "add", content: "after" },
],
},
],
},
]);
});
it.each([
["tab\\tname.txt", "tab\tname.txt"],
["line\\nname.txt", "line\nname.txt"],
["back\\\\slash.txt", "back\\slash.txt"],
["bell\\aname.txt", "bell\u0007name.txt"],
["vertical\\vname.txt", "vertical\u000bname.txt"],
["\\346\\265\\213\\350\\257\\225.txt", "测试.txt"],
['测试 \\"draft\\".txt', '测试 "draft".txt'],
])("decodes C-quoted path %s without changing its diff lines", (encoded, path) => {
const files = parseDiff(
[
'diff --git "a/' + encoded + '" "b/' + encoded + '"',
"index 1234567..abcdefg 100644",
'--- "a/' + encoded + '"',
'+++ "b/' + encoded + '"',
"@@ -1 +1 @@",
"-before",
"+after",
"",
].join("\n"),
);
expect(files).toHaveLength(1);
expect(files[0]).toMatchObject({ path, additions: 1, deletions: 1 });
expect(files[0].hunks[0].lines).toEqual([
{ type: "header", content: "@@ -1 +1 @@" },
{ type: "remove", content: "before" },
{ type: "add", content: "after" },
]);
});
it.each([
['"a/old \\"draft\\".txt" b/new with spaces.txt', "new with spaces.txt"],
['a/old with spaces.txt "b/new \\"draft\\".txt"', 'new "draft".txt'],
['"report \\"draft\\".txt" "report \\"draft\\".txt"', 'report "draft".txt'],
])("reads quoted paths from header-only patches: %s", (header, path) => {
expect(parseDiff("diff --git " + header + "\nsimilarity index 100%\n")).toEqual([
{ path, isNew: false, isDeleted: false, additions: 0, deletions: 0, hunks: [] },
]);
});
it("parses a simple diff with one hunk", () => {
const files = parseDiff(SIMPLE_DIFF);
@@ -1,6 +1,7 @@
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { highlightCode, isLanguageSupported, type HighlightToken } from "@getpaseo/highlight";
import { unquoteGitPath } from "../../utils/git-quoted-path.js";
const MAX_DIFF_HIGHLIGHT_LINE_CHARS = 10_000;
@@ -53,7 +54,7 @@ function extractPathFromMetadata(lines: string[], prefix: "--- " | "+++ "): stri
return null;
}
const path = line.slice(prefix.length).replace(/\t.*$/, "").trimEnd();
const path = unquoteGitPath(line.slice(prefix.length).replace(/\t.*$/, ""));
return path === "/dev/null" ? null : path;
}
@@ -64,6 +65,17 @@ function extractPathFromDiffHeader(lines: string[]): string {
return prefixedPathMatch[2];
}
const quotedPathMatch =
firstLine.match(/^("(?:\\.|[^"\\])*") (.+)$/) ?? firstLine.match(/^(.+) ("(?:\\.|[^"\\])*")$/);
if (quotedPathMatch) {
const oldPath = unquoteGitPath(quotedPathMatch[1]);
const newPath = unquoteGitPath(quotedPathMatch[2]);
if (usesDiffPathPrefixes(oldPath, newPath)) {
return newPath.slice(2);
}
return newPath;
}
const metadataPath =
extractPathFromMetadata(lines, "+++ ") ?? extractPathFromMetadata(lines, "--- ");
if (metadataPath) {
@@ -3,15 +3,218 @@ import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { getCheckoutDiff, getCommitFileDiff } from "./checkout-git.js";
import {
getCheckoutDiff,
getCheckoutShortstat,
getCommitFileDiff,
listCheckoutCommits,
} from "./checkout-git.js";
const repos: string[] = [];
const EDITED_TEXT_DIFF = {
isNew: false,
isDeleted: false,
additions: 1,
deletions: 1,
hunks: [
{
oldStart: 1,
oldCount: 1,
newStart: 1,
newCount: 1,
lines: [
{ type: "header", content: "@@ -1 +1 @@" },
{ type: "remove", content: "before" },
{ type: "add", content: "after" },
],
},
],
};
afterEach(() => {
vi.unstubAllEnvs();
for (const repo of repos.splice(0)) rmSync(repo, { recursive: true, force: true });
});
it.skipIf(process.platform === "win32")(
"returns a quoted filename and its uncommitted diff using its actual path",
async () => {
const cwd = createChangedRepo("core.quotepath=false");
const name = 'report "draft".txt';
writeFileSync(join(cwd, "file.txt"), "before\n");
writeFileSync(join(cwd, name), "before\n");
git(cwd, "add", "--", name);
git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "add quoted filename");
writeFileSync(join(cwd, name), "after\n");
const result = await getCheckoutDiff(cwd, { mode: "uncommitted", includeStructured: true });
expect(result.structured).toHaveLength(1);
expect(result.structured?.[0]).toMatchObject({
path: name,
status: "ok",
...EDITED_TEXT_DIFF,
});
},
);
describe.skipIf(process.platform === "win32")("quoted paths from real Git", () => {
const names = [
"normal.txt",
"with spaces.txt",
'report "draft".txt',
"back\\slash.txt",
"tab\tname.txt",
"line\nname.txt",
" leading and trailing .txt ",
'report "old => draft".txt',
];
function createQuotedPathsRepo(): string {
const cwd = createChangedRepo("core.quotepath=false");
writeFileSync(join(cwd, "file.txt"), "before\n");
for (const name of names) writeFileSync(join(cwd, name), "before\n");
git(cwd, "add", "--", ...names);
git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "add filenames");
git(cwd, "checkout", "-b", "feature");
for (const name of names) writeFileSync(join(cwd, name), "after\n");
return cwd;
}
it("preserves tracked and untracked paths, diff lines and summary counts", async () => {
const cwd = createQuotedPathsRepo();
const untrackedNames = names.map((name) => "untracked-" + name);
for (const name of untrackedNames) writeFileSync(join(cwd, name), "new file\n");
const result = await getCheckoutDiff(cwd, { mode: "uncommitted", includeStructured: true });
expect(result.structured?.map((file) => file.path).sort()).toEqual(
[...names, ...untrackedNames].sort(),
);
for (const name of names) {
expect(result.structured?.find((file) => file.path === name)).toMatchObject({
path: name,
status: "ok",
...EDITED_TEXT_DIFF,
});
}
for (const name of untrackedNames) {
expect(result.structured?.find((file) => file.path === name)).toMatchObject({
path: name,
status: "ok",
isNew: true,
isDeleted: false,
additions: 1,
deletions: 0,
hunks: [
{
oldStart: 0,
oldCount: 0,
newStart: 1,
newCount: 1,
lines: [
{ type: "header", content: "@@ -0,0 +1 @@" },
{ type: "add", content: "new file" },
],
},
],
});
}
expect(await getCheckoutShortstat(cwd)).toEqual({
additions: names.length + untrackedNames.length,
deletions: names.length,
});
});
it("opens base and commit diffs using the actual paths from the commit list", async () => {
const cwd = createQuotedPathsRepo();
git(cwd, "add", "--", ...names);
git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "edit filenames");
const sha = git(cwd, "rev-parse", "HEAD").trim();
const base = await getCheckoutDiff(cwd, {
mode: "base",
baseRef: "main",
includeStructured: true,
});
expect(base.structured?.map((file) => file.path).sort()).toEqual([...names].sort());
for (const name of names) {
expect(base.structured?.find((file) => file.path === name)).toMatchObject({
path: name,
status: "ok",
...EDITED_TEXT_DIFF,
});
}
const history = await listCheckoutCommits({ cwd });
const commit = history.commits[0];
expect(commit.sha).toBe(sha);
expect(commit.files).toEqual(
[...names].sort().map((name) => ({
path: name,
additions: 1,
deletions: 1,
status: "modified",
})),
);
for (const file of commit.files) {
expect(await getCommitFileDiff({ cwd, sha, path: file.path })).toMatchObject({
path: file.path,
...EDITED_TEXT_DIFF,
});
}
});
it("preserves quoted rename destinations and binary paths in diff and commit lists", async () => {
const cwd = createChangedRepo("core.quotepath=false");
const oldPath = 'old "draft".txt';
const newPath = 'new "draft".txt';
const binaryPath = "image\tdata.bin";
writeFileSync(join(cwd, "file.txt"), "before\n");
writeFileSync(join(cwd, oldPath), "unchanged\n");
writeFileSync(join(cwd, binaryPath), Buffer.from([0, 1, 2]));
git(cwd, "add", "--", oldPath, binaryPath);
git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "add rename and binary files");
git(cwd, "checkout", "-b", "feature");
git(cwd, "mv", "--", oldPath, newPath);
writeFileSync(join(cwd, binaryPath), Buffer.from([0, 3, 4]));
const result = await getCheckoutDiff(cwd, { mode: "uncommitted", includeStructured: true });
expect(
result.structured?.map(({ path, oldPath: sourcePath, status }) => ({
path,
oldPath: sourcePath,
status,
})),
).toEqual([
{ path: binaryPath, oldPath: undefined, status: "binary" },
{
path: newPath,
oldPath,
status: "ok",
},
]);
expect(result.structured?.[0]).toEqual({
path: binaryPath,
isNew: false,
isDeleted: false,
additions: 0,
deletions: 0,
hunks: [],
status: "binary",
});
git(cwd, "add", "--", binaryPath);
git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "rename file and edit binary");
const history = await listCheckoutCommits({ cwd });
expect(history.commits[0].files).toEqual([
{ path: binaryPath, additions: 0, deletions: 0, status: "modified" },
{ path: newPath, additions: 0, deletions: 0, status: "renamed" },
]);
});
});
it.each(["diff.mnemonicPrefix=true", "color.diff=always", "diff.noprefix=true"])(
"preserves batched paths, spaces and literal prefix directories with %s",
async (config) => {
@@ -1628,7 +1628,7 @@ const x = 1;
expect(diff.diff).toContain(`-export const value = "old";`);
expect(diff.diff).toContain(`+export const value = "new";`);
expect(commands).toContain(
"diff --no-ext-diff --no-color --src-prefix=a/ --dst-prefix=b/ --numstat HEAD",
"diff --no-ext-diff --no-color --src-prefix=a/ --dst-prefix=b/ -z --numstat HEAD",
);
expect(commands).toContain(
"diff --no-ext-diff --no-color --src-prefix=a/ --dst-prefix=b/ HEAD -- :(literal)generated.js :(literal)small.ts",
+30 -35
View File
@@ -32,6 +32,7 @@ import {
import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js";
import { runGitCommand, type RunGitCommand } from "./run-git-command.js";
import { readGitFileContents } from "./git-file-contents.js";
import { unquoteGitPath } from "./git-quoted-path.js";
import { isPaseoOwnedWorktreeCwd, resolvePaseoWorktreesBaseRoot } from "./worktree.js";
import {
branchNameFromRef,
@@ -515,22 +516,18 @@ async function listCheckoutFileChanges(
const { stdout: nameStatusOut } = await runGitCommand(
buildGitDiffArgs({
ignoreWhitespace,
extra: ["--name-status", ...getCheckoutDiffRefArgs(refs)],
extra: ["-z", "--name-status", ...getCheckoutDiffRefArgs(refs)],
}),
{ cwd, envOverlay: READ_ONLY_GIT_ENV },
);
for (const line of nameStatusOut
.split("\n")
.map((l) => l.trim())
.filter(Boolean)) {
// `--name-status` uses TAB separators, which preserves filenames with spaces.
const tabParts = line.split("\t");
const rawStatus = (tabParts[0] ?? "").trim();
const nameStatusFields = nameStatusOut.split("\0");
for (let index = 0; index < nameStatusFields.length; ) {
const rawStatus = nameStatusFields[index++];
if (!rawStatus) continue;
if (rawStatus.startsWith("R") || rawStatus.startsWith("C")) {
const oldPath = tabParts[1];
const newPath = tabParts[2];
const oldPath = nameStatusFields[index++];
const newPath = nameStatusFields[index++];
if (newPath) {
changes.push({
path: newPath,
@@ -543,7 +540,7 @@ async function listCheckoutFileChanges(
continue;
}
const path = tabParts[1];
const path = nameStatusFields[index++];
if (!path) continue;
const code = rawStatus[0];
changes.push({
@@ -556,16 +553,13 @@ async function listCheckoutFileChanges(
if (refs.includeUntracked) {
const { stdout: untrackedOut } = await runGitCommand(
["ls-files", "--others", "--exclude-standard"],
["ls-files", "-z", "--others", "--exclude-standard"],
{
cwd,
envOverlay: READ_ONLY_GIT_ENV,
},
);
for (const file of untrackedOut
.split("\n")
.map((l) => l.trim())
.filter(Boolean)) {
for (const file of untrackedOut.split("\0").filter(Boolean)) {
changes.push({
path: file,
status: "U",
@@ -623,18 +617,22 @@ async function tryResolveMergeBase(cwd: string, baseRef: string): Promise<string
type FileStat = { additions: number; deletions: number; isBinary: boolean } | null;
function normalizeNumstatPath(pathField: string): string {
if (/^"(?:\\.|[^"\\])*"$/.test(pathField)) {
return unquoteGitPath(pathField);
}
const braceRenameMatch = pathField.match(/^(.*)\{(.*) => (.*)\}(.*)$/);
if (braceRenameMatch) {
const [, prefix, , renamed, suffix] = braceRenameMatch;
return `${prefix}${renamed}${suffix}`;
return unquoteGitPath(`${prefix}${renamed}${suffix}`);
}
const inlineRenameMatch = pathField.match(/^(.*) => (.*)$/);
if (inlineRenameMatch) {
return inlineRenameMatch[2] ?? pathField;
return unquoteGitPath(inlineRenameMatch[2] ?? pathField);
}
return pathField;
return unquoteGitPath(pathField);
}
// Parsed patches need a stable wire format regardless of terminal presentation settings.
@@ -669,7 +667,7 @@ async function getTrackedNumstatByPath(
const result = await runGitCommand(
buildGitDiffArgs({
ignoreWhitespace,
extra: ["--numstat", ...getCheckoutDiffRefArgs(refs)],
extra: ["-z", "--numstat", ...getCheckoutDiffRefArgs(refs)],
}),
{
cwd,
@@ -680,21 +678,21 @@ async function getTrackedNumstatByPath(
);
const stats = new Map<string, FileStat>();
const lines = result.stdout
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
for (const line of lines) {
const parts = line.split("\t");
const records = result.stdout.split("\0");
for (let index = 0; index < records.length; index += 1) {
const parts = records[index].split("\t");
if (parts.length < 3) {
continue;
}
const additionsField = parts[0] ?? "";
const deletionsField = parts[1] ?? "";
const rawPath = parts.slice(2).join("\t");
const path = normalizeNumstatPath(rawPath);
let path = parts.slice(2).join("\t");
if (!path) {
// A rename has an empty path here, followed by separate old/new NUL fields.
path = records[index + 2];
index += 2;
}
if (!path) {
continue;
@@ -2424,7 +2422,7 @@ function parseRawStatusLine(line: string, statuses: Map<string, CheckoutCommitFi
if (!path) {
return;
}
statuses.set(path, status);
statuses.set(unquoteGitPath(path), status);
}
// A `--numstat` line: `<adds>\t<dels>\t<path>` (renames use `old => new`, binary
@@ -2738,16 +2736,13 @@ async function countUntrackedAdditions(
): Promise<number> {
try {
const { stdout } = await getRunGitCommand(context)(
["ls-files", "--others", "--exclude-standard"],
["ls-files", "-z", "--others", "--exclude-standard"],
{
cwd,
envOverlay: READ_ONLY_GIT_ENV,
},
);
const files = stdout
.split("\n")
.map((l) => l.trim())
.filter(Boolean);
const files = stdout.split("\0").filter(Boolean);
let additions = 0;
for (const file of files.slice(0, UNTRACKED_SHORTSTAT_MAX_FILES)) {
@@ -0,0 +1,34 @@
const GIT_PATH_ESCAPE_BYTES: Record<string, number> = {
a: 7,
b: 8,
t: 9,
n: 10,
v: 11,
f: 12,
r: 13,
'"': 34,
"\\": 92,
};
// core.quotepath=false still quotes control characters, double quotes and backslashes.
export function unquoteGitPath(value: string): string {
if (!value.startsWith('"') || !value.endsWith('"')) return value;
const encoded = value.slice(1, -1);
const parts: Buffer[] = [];
let offset = 0;
for (const match of encoded.matchAll(/\\([0-7]{1,3}|[abfnrtv"\\])/g)) {
parts.push(Buffer.from(encoded.slice(offset, match.index)));
const escape = match[1];
let byte: number;
if (/^[0-7]/.test(escape)) {
byte = Number.parseInt(escape, 8);
} else {
byte = GIT_PATH_ESCAPE_BYTES[escape];
}
parts.push(Buffer.from([byte]));
offset = match.index + match[0].length;
}
parts.push(Buffer.from(encoded.slice(offset)));
return Buffer.concat(parts).toString("utf8");
}