diff --git a/packages/server/package.json b/packages/server/package.json index 541cf5eba1..beffa3b4ce 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -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", diff --git a/packages/server/src/server/git-filename-diffs.e2e.test.ts b/packages/server/src/server/git-filename-diffs.e2e.test.ts new file mode 100644 index 0000000000..343e1c81c8 --- /dev/null +++ b/packages/server/src/server/git-filename-diffs.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 }); + } + }, +); diff --git a/packages/server/src/server/utils/diff-highlighter.test.ts b/packages/server/src/server/utils/diff-highlighter.test.ts index 5ca1177799..fda98d6cbf 100644 --- a/packages/server/src/server/utils/diff-highlighter.test.ts +++ b/packages/server/src/server/utils/diff-highlighter.test.ts @@ -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); diff --git a/packages/server/src/server/utils/diff-highlighter.ts b/packages/server/src/server/utils/diff-highlighter.ts index 97d68d1fcd..3c2df279e2 100644 --- a/packages/server/src/server/utils/diff-highlighter.ts +++ b/packages/server/src/server/utils/diff-highlighter.ts @@ -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) { diff --git a/packages/server/src/utils/checkout-git.patch-format.test.ts b/packages/server/src/utils/checkout-git.patch-format.test.ts index d30ae1d7ce..61e864a401 100644 --- a/packages/server/src/utils/checkout-git.patch-format.test.ts +++ b/packages/server/src/utils/checkout-git.patch-format.test.ts @@ -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) => { diff --git a/packages/server/src/utils/checkout-git.test.ts b/packages/server/src/utils/checkout-git.test.ts index 21791fa49e..e361c41921 100644 --- a/packages/server/src/utils/checkout-git.test.ts +++ b/packages/server/src/utils/checkout-git.test.ts @@ -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", diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index 7a53c753b9..0f34a1ea73 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -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 (.*)\}(.*)$/); 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(); - 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\t\t` (renames use `old => new`, binary @@ -2738,16 +2736,13 @@ async function countUntrackedAdditions( ): Promise { 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)) { diff --git a/packages/server/src/utils/git-quoted-path.ts b/packages/server/src/utils/git-quoted-path.ts new file mode 100644 index 0000000000..cb5a9d45eb --- /dev/null +++ b/packages/server/src/utils/git-quoted-path.ts @@ -0,0 +1,34 @@ +const GIT_PATH_ESCAPE_BYTES: Record = { + 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"); +}