mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-30 13:13:25 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8938a305ca | ||
|
|
cafa04d3fb | ||
|
|
938c4e2632 | ||
|
|
d57096a456 | ||
|
|
ada7ba43f0 |
@@ -223,6 +223,7 @@ test.describe("Add Project command-center flow", () => {
|
||||
|
||||
await page.keyboard.press("Enter");
|
||||
await expectAddProjectPage(page, "directory-search");
|
||||
await expect(addProjectFlowInput(page)).toBeFocused();
|
||||
await page.keyboard.type(projectPickerFixture.fuzzyQuery);
|
||||
await expect(addProjectFlow(page)).toContainText(projectPickerFixture.projectName, {
|
||||
timeout: 30_000,
|
||||
|
||||
@@ -116,6 +116,7 @@ for (const version of ["0.2.5", "0.7.2", "0.8.0"]) {
|
||||
);
|
||||
expect(workspaces).toHaveLength(1);
|
||||
gate.release();
|
||||
await gate.waitForCreated();
|
||||
await expect.poll(async () => (await client.fetchAgents()).entries.length).toBe(1);
|
||||
const first = (await client.fetchAgents()).entries[0]!.agent;
|
||||
await client.waitForFinish(first.id, 20_000);
|
||||
@@ -128,6 +129,7 @@ for (const version of ["0.2.5", "0.7.2", "0.8.0"]) {
|
||||
const secondPrompt = "Create another agent: emit 1 coalesced agent stream updates";
|
||||
await fillComposerDraft(page, secondPrompt);
|
||||
await pressSubmitBeforeTheNextRender(page, "Send message");
|
||||
await gate.waitForCreated();
|
||||
await expect.poll(async () => (await client.fetchAgents()).entries.length).toBe(2);
|
||||
const second = (await client.fetchAgents()).entries.find(
|
||||
({ agent }) => agent.id !== first.id,
|
||||
@@ -206,21 +208,40 @@ for (const version of ["0.2.5", "0.7.2", "0.8.0"]) {
|
||||
async function holdLegacyAgentCreation(page: Page, port: number) {
|
||||
const readers = await loadSessionMessageReaders();
|
||||
const arrived = Promise.withResolvers<void>();
|
||||
let created = Promise.withResolvers<void>();
|
||||
let createRequestId: string | undefined;
|
||||
let release = () => {};
|
||||
let holding = true;
|
||||
await page.routeWebSocket(new RegExp(`:${port}/ws`), (browser) => {
|
||||
const server = browser.connectToServer();
|
||||
browser.onMessage((frame) => {
|
||||
if (holding && readers.client(frame)?.type === "create_agent_request") {
|
||||
const message = readers.client(frame);
|
||||
if (message?.type === "create_agent_request") createRequestId = message.requestId;
|
||||
if (holding && message?.type === "create_agent_request") {
|
||||
holding = false;
|
||||
release = () => server.send(frame);
|
||||
arrived.resolve();
|
||||
} else server.send(frame);
|
||||
});
|
||||
server.onMessage((frame) => browser.send(frame));
|
||||
server.onMessage((frame) => {
|
||||
const message = readers.server(frame);
|
||||
if (
|
||||
message?.type === "status" &&
|
||||
message.payload.status === "agent_created" &&
|
||||
message.payload.requestId === createRequestId
|
||||
) {
|
||||
created.resolve();
|
||||
}
|
||||
browser.send(frame);
|
||||
});
|
||||
});
|
||||
return {
|
||||
waitForRequest: () => arrived.promise,
|
||||
async waitForCreated() {
|
||||
// Agent-list membership includes initialization, before the initial prompt starts.
|
||||
await created.promise;
|
||||
created = Promise.withResolvers<void>();
|
||||
},
|
||||
release() {
|
||||
const send = release;
|
||||
release = () => {};
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import {
|
||||
addCustomModel,
|
||||
expectCustomModelRetained,
|
||||
openCustomModels,
|
||||
removeCustomModel,
|
||||
} from "../support/helpers/custom-models";
|
||||
|
||||
const longModelId = "custom-model-with-a-long-name-".repeat(6);
|
||||
const shortModelId = "custom-short";
|
||||
|
||||
for (const width of [1200, 390]) {
|
||||
test(`removes a long custom model at ${width}px without removing another model`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 1200, height: 1000 });
|
||||
await openCustomModels(page);
|
||||
await addCustomModel(page, shortModelId);
|
||||
await addCustomModel(page, longModelId);
|
||||
|
||||
await test.step("remove the long model at the target width", async () => {
|
||||
await page.setViewportSize({ width, height: 1000 });
|
||||
await removeCustomModel(page, longModelId);
|
||||
await expectCustomModelRetained(page, shortModelId);
|
||||
});
|
||||
|
||||
await removeCustomModel(page, shortModelId);
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { gotoAppShell, openSettings } from "./app";
|
||||
import { openSettingsHostSection } from "./settings";
|
||||
import { getServerId } from "./server-id";
|
||||
|
||||
export async function openCustomModels(page: Page): Promise<void> {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsHostSection(page, getServerId(), "providers");
|
||||
await page.getByRole("button", { name: "Claude provider details", exact: true }).click();
|
||||
await expect(page.getByRole("textbox", { name: "Search models" })).toBeVisible();
|
||||
}
|
||||
|
||||
export async function addCustomModel(page: Page, id: string): Promise<void> {
|
||||
await page.getByRole("button", { name: "Add model", exact: true }).click();
|
||||
await page.getByPlaceholder("e.g. openai/gpt-5").fill(id);
|
||||
await page
|
||||
.getByTestId("add-custom-model-sheet")
|
||||
.getByRole("button", { name: "Add", exact: true })
|
||||
.click();
|
||||
await expect(page.getByRole("button", { name: `Remove ${id}`, exact: true })).toHaveCount(1);
|
||||
}
|
||||
|
||||
export async function removeCustomModel(page: Page, id: string): Promise<void> {
|
||||
await page.getByRole("textbox", { name: "Search models" }).fill(id);
|
||||
const remove = page.getByRole("button", { name: `Remove ${id}`, exact: true });
|
||||
await expect(remove).toBeInViewport({ ratio: 1 });
|
||||
await remove.click();
|
||||
await expect(remove).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function expectCustomModelRetained(page: Page, id: string): Promise<void> {
|
||||
await page.getByRole("textbox", { name: "Search models" }).fill(id);
|
||||
await expect(page.getByRole("button", { name: `Remove ${id}`, exact: true })).toBeInViewport({
|
||||
ratio: 1,
|
||||
});
|
||||
}
|
||||
@@ -95,11 +95,11 @@ function CustomModelRow({
|
||||
|
||||
return (
|
||||
<View style={sheetStyles.modelRow}>
|
||||
<Text style={sheetStyles.modelTitle} numberOfLines={1}>
|
||||
<Text style={[sheetStyles.modelTitle, sheetStyles.customModelText]} numberOfLines={1}>
|
||||
{model.label}
|
||||
</Text>
|
||||
<Text
|
||||
style={sheetStyles.monoHint}
|
||||
style={[sheetStyles.monoHint, sheetStyles.customModelText]}
|
||||
numberOfLines={1}
|
||||
selectable
|
||||
dataSet={CODE_SURFACE_DATASET}
|
||||
@@ -762,6 +762,7 @@ const sheetStyles = StyleSheet.create((theme) => ({
|
||||
iconButton: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
flexShrink: 0,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
@@ -807,6 +808,10 @@ const sheetStyles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.base,
|
||||
flexShrink: 0,
|
||||
},
|
||||
customModelText: {
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
modelRowFiller: {
|
||||
flex: 1,
|
||||
},
|
||||
|
||||
@@ -2,6 +2,23 @@ import { describe, expect, it } from "vitest";
|
||||
import { detectTerminalLocalLinks } from "./terminal-local-link-parsing";
|
||||
|
||||
describe("detectTerminalLocalLinks", () => {
|
||||
it.each([
|
||||
["user@host:~/projects$ ls", 10, "~/projects"],
|
||||
["root@host:/etc/nginx# ", 10, "/etc/nginx"],
|
||||
["user@host ~/src% ", 10, "~/src"],
|
||||
])("excludes the prompt marker in %s", (line, index, text) => {
|
||||
expect(detectTerminalLocalLinks(line)).toMatchObject([
|
||||
{ path: { index, text }, suffix: undefined },
|
||||
]);
|
||||
});
|
||||
|
||||
it.each(["$", "#", "%"])("preserves a filename ending in %s", (marker) => {
|
||||
const path = `/tmp/report${marker}`;
|
||||
expect(detectTerminalLocalLinks(`cat ${path}`)).toMatchObject([
|
||||
{ path: { index: 4, text: path }, suffix: undefined },
|
||||
]);
|
||||
});
|
||||
|
||||
it("detects VS Code-style filename and line suffixes", () => {
|
||||
expect(detectTerminalLocalLinks("file.ts:42")).toMatchObject([
|
||||
{
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -773,8 +773,12 @@ describe("Hub relationship", () => {
|
||||
const second = await relationship.ownedCreateResult("second-create");
|
||||
|
||||
expect(relationship.relationshipFile()?.relationship.daemonId).toBe(daemonId);
|
||||
expect(first).toMatchObject({ payload: { success: true, executionId: "first-execution" } });
|
||||
expect(second).toMatchObject({ payload: { success: true, executionId: "second-execution" } });
|
||||
expect(first).toMatchObject({
|
||||
payload: { success: true, executionId: "first-execution", error: null },
|
||||
});
|
||||
expect(second).toMatchObject({
|
||||
payload: { success: true, executionId: "second-execution", error: null },
|
||||
});
|
||||
expect(await relationship.durableOwnedAgentIds()).toHaveLength(2);
|
||||
});
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -33,7 +33,7 @@ describe("ensureWorkspaceServicePortPlan", () => {
|
||||
expect(allocatedServices).toEqual(["api", "web", "worker"]);
|
||||
});
|
||||
|
||||
it("returns the existing plan without calling the allocator on later calls", async () => {
|
||||
it("extends the existing plan with newly declared services without reallocating peers", async () => {
|
||||
let allocationCount = 0;
|
||||
|
||||
const firstPlan = await ensureWorkspaceServicePortPlan({
|
||||
@@ -54,9 +54,11 @@ describe("ensureWorkspaceServicePortPlan", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(Array.from(secondPlan.entries())).toEqual(Array.from(firstPlan.entries()));
|
||||
expect(secondPlan.has("new-service")).toBe(false);
|
||||
expect(allocationCount).toBe(2);
|
||||
expect(Array.from(secondPlan.entries())).toEqual([
|
||||
...firstPlan.entries(),
|
||||
["new-service", 4300],
|
||||
]);
|
||||
expect(allocationCount).toBe(3);
|
||||
});
|
||||
|
||||
it("shares one first-plan build across concurrent callers", async () => {
|
||||
@@ -101,6 +103,103 @@ describe("ensureWorkspaceServicePortPlan", () => {
|
||||
expect(allocationCount).toBe(2);
|
||||
});
|
||||
|
||||
it("extends a pending plan for a concurrent caller with another service", async () => {
|
||||
const allocation = createDeferredPort();
|
||||
const workspaceId = "registry-concurrent-extension-workspace";
|
||||
const firstPlan = ensureWorkspaceServicePortPlan({
|
||||
workspaceId,
|
||||
services: [{ scriptName: "backend-dev" }],
|
||||
allocatePort: async () => allocation.promise,
|
||||
});
|
||||
let allocations = 0;
|
||||
const secondPlan = ensureWorkspaceServicePortPlan({
|
||||
workspaceId,
|
||||
services: [{ scriptName: "backend-dev" }, { scriptName: "web" }],
|
||||
allocatePort: async () => {
|
||||
allocations += 1;
|
||||
return 6101;
|
||||
},
|
||||
});
|
||||
allocation.resolve(6100);
|
||||
expect(Array.from((await firstPlan).entries())).toEqual([["backend-dev", 6100]]);
|
||||
expect(Array.from((await secondPlan).entries())).toEqual([
|
||||
["backend-dev", 6100],
|
||||
["web", 6101],
|
||||
]);
|
||||
expect(allocations).toBe(1);
|
||||
releaseWorkspaceServicePortPlan(workspaceId);
|
||||
});
|
||||
|
||||
it("keeps the previous plan and reservations when an extension fails", async () => {
|
||||
const workspaceId = "registry-failed-extension-workspace";
|
||||
await ensureWorkspaceServicePortPlan({
|
||||
workspaceId,
|
||||
services: [{ scriptName: "backend-dev" }],
|
||||
allocatePort: async () => 6200,
|
||||
});
|
||||
let allocations = 0;
|
||||
await expect(
|
||||
ensureWorkspaceServicePortPlan({
|
||||
workspaceId,
|
||||
services: [{ scriptName: "backend-dev" }, { scriptName: "web" }, { scriptName: "worker" }],
|
||||
allocatePort: async () => {
|
||||
allocations += 1;
|
||||
if (allocations === 1) return 6201;
|
||||
throw new Error("Allocation failed");
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow("Allocation failed");
|
||||
const priorPlan = await ensureWorkspaceServicePortPlan({
|
||||
workspaceId,
|
||||
services: [{ scriptName: "backend-dev" }],
|
||||
allocatePort: async () => {
|
||||
throw new Error("Existing port must survive");
|
||||
},
|
||||
});
|
||||
expect(Array.from(priorPlan.entries())).toEqual([["backend-dev", 6200]]);
|
||||
const otherWorkspace = "registry-failed-extension-other-workspace";
|
||||
const reusedPlan = await ensureWorkspaceServicePortPlan({
|
||||
workspaceId: otherWorkspace,
|
||||
services: [{ scriptName: "web" }],
|
||||
allocatePort: async ({ reservedPorts }) => {
|
||||
expect(reservedPorts.has(6200)).toBe(true);
|
||||
expect(reservedPorts.has(6201)).toBe(false);
|
||||
return 6201;
|
||||
},
|
||||
});
|
||||
expect(reusedPlan.get("web")).toBe(6201);
|
||||
releaseWorkspaceServicePortPlan(workspaceId);
|
||||
releaseWorkspaceServicePortPlan(otherWorkspace);
|
||||
});
|
||||
|
||||
it("rejects a new explicit port that collides with an existing service", async () => {
|
||||
const workspaceId = "registry-extension-explicit-collision-workspace";
|
||||
await ensureWorkspaceServicePortPlan({
|
||||
workspaceId,
|
||||
services: [{ scriptName: "backend-dev" }],
|
||||
allocatePort: async () => 6300,
|
||||
});
|
||||
await expect(
|
||||
ensureWorkspaceServicePortPlan({
|
||||
workspaceId,
|
||||
services: [{ scriptName: "backend-dev" }, { scriptName: "web", port: 6300 }],
|
||||
allocatePort: async () => 6301,
|
||||
}),
|
||||
).rejects.toThrow("Service 'web' has a duplicate port 6300");
|
||||
const plan = await ensureWorkspaceServicePortPlan({
|
||||
workspaceId,
|
||||
services: [{ scriptName: "backend-dev" }, { scriptName: "web", port: 6301 }],
|
||||
allocatePort: async () => {
|
||||
throw new Error("No allocation needed");
|
||||
},
|
||||
});
|
||||
expect(Array.from(plan.entries())).toEqual([
|
||||
["backend-dev", 6300],
|
||||
["web", 6301],
|
||||
]);
|
||||
releaseWorkspaceServicePortPlan(workspaceId);
|
||||
});
|
||||
|
||||
it("uses explicit configured ports without calling the allocator", async () => {
|
||||
let allocationCount = 0;
|
||||
|
||||
@@ -312,6 +411,123 @@ describe("refreshWorkspaceServicePort", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves a concurrent service addition while refreshing a stopped service", async () => {
|
||||
const workspaceId = "registry-refresh-and-extend-workspace";
|
||||
await ensureWorkspaceServicePortPlan({
|
||||
workspaceId,
|
||||
services: [{ scriptName: "backend-dev" }],
|
||||
allocatePort: async () => 6400,
|
||||
});
|
||||
const allocation = createDeferredPort();
|
||||
const refreshing = refreshWorkspaceServicePort({
|
||||
workspaceId,
|
||||
service: { scriptName: "backend-dev" },
|
||||
allocatePort: async () => allocation.promise,
|
||||
});
|
||||
const extending = ensureWorkspaceServicePortPlan({
|
||||
workspaceId,
|
||||
services: [{ scriptName: "backend-dev" }, { scriptName: "web", port: 6402 }],
|
||||
allocatePort: async () => {
|
||||
throw new Error("Explicit addition needs no allocation");
|
||||
},
|
||||
});
|
||||
allocation.resolve(6401);
|
||||
expect(await refreshing).toBe(6401);
|
||||
await extending;
|
||||
const plan = await ensureWorkspaceServicePortPlan({
|
||||
workspaceId,
|
||||
services: [],
|
||||
allocatePort: async () => {
|
||||
throw new Error("No allocation needed");
|
||||
},
|
||||
});
|
||||
expect(Array.from(plan.entries())).toEqual([
|
||||
["backend-dev", 6401],
|
||||
["web", 6402],
|
||||
]);
|
||||
releaseWorkspaceServicePortPlan(workspaceId);
|
||||
});
|
||||
|
||||
it.each(["reused port", "allocation failure"])(
|
||||
"cancels a pending refresh and its waiter after workspace release (%s)",
|
||||
async (outcome) => {
|
||||
const workspaceId = "registry-cancelled-refresh-workspace";
|
||||
await ensureWorkspaceServicePortPlan({
|
||||
workspaceId,
|
||||
services: [{ scriptName: "api" }],
|
||||
allocatePort: async () => 6500,
|
||||
});
|
||||
const allocation = createDeferredPort();
|
||||
const refreshing = refreshWorkspaceServicePort({
|
||||
workspaceId,
|
||||
service: { scriptName: "api" },
|
||||
allocatePort: async () => {
|
||||
const port = await allocation.promise;
|
||||
if (outcome === "allocation failure") throw new Error("Allocation failed");
|
||||
return port;
|
||||
},
|
||||
});
|
||||
const waiting = refreshWorkspaceServicePort({
|
||||
workspaceId,
|
||||
service: { scriptName: "web", port: 6501 },
|
||||
allocatePort: async () => 6501,
|
||||
});
|
||||
const assertions = Promise.all([
|
||||
expect(refreshing).rejects.toThrow("Workspace service port plan was released"),
|
||||
expect(waiting).rejects.toThrow("Workspace service port plan was released"),
|
||||
]);
|
||||
releaseWorkspaceServicePortPlan(workspaceId);
|
||||
allocation.resolve(6500);
|
||||
await assertions;
|
||||
const reusedPlan = await ensureWorkspaceServicePortPlan({
|
||||
workspaceId: "registry-after-cancelled-refresh-workspace",
|
||||
services: [{ scriptName: "api" }],
|
||||
allocatePort: async () => 6500,
|
||||
});
|
||||
expect(reusedPlan.get("api")).toBe(6500);
|
||||
releaseWorkspaceServicePortPlan("registry-after-cancelled-refresh-workspace");
|
||||
},
|
||||
);
|
||||
|
||||
it("lets a waiting service restart allocate after another service's restart fails", async () => {
|
||||
const workspaceId = "registry-independent-refresh-failure-workspace";
|
||||
await ensureWorkspaceServicePortPlan({
|
||||
workspaceId,
|
||||
services: [{ scriptName: "api" }, { scriptName: "web" }],
|
||||
allocatePort: createSequentialPortAllocator(6600),
|
||||
});
|
||||
const allocation = createDeferredPort();
|
||||
const failedRefresh = refreshWorkspaceServicePort({
|
||||
workspaceId,
|
||||
service: { scriptName: "api" },
|
||||
allocatePort: async () => {
|
||||
await allocation.promise;
|
||||
throw new Error("API port script failed");
|
||||
},
|
||||
});
|
||||
const failedAssertion = expect(failedRefresh).rejects.toThrow("API port script failed");
|
||||
const waitingRefresh = refreshWorkspaceServicePort({
|
||||
workspaceId,
|
||||
service: { scriptName: "web" },
|
||||
allocatePort: async () => 6602,
|
||||
});
|
||||
allocation.resolve(0);
|
||||
await failedAssertion;
|
||||
expect(await waitingRefresh).toBe(6602);
|
||||
const plan = await ensureWorkspaceServicePortPlan({
|
||||
workspaceId,
|
||||
services: [],
|
||||
allocatePort: async () => {
|
||||
throw new Error("No allocation needed");
|
||||
},
|
||||
});
|
||||
expect(Array.from(plan.entries())).toEqual([
|
||||
["api", 6600],
|
||||
["web", 6602],
|
||||
]);
|
||||
releaseWorkspaceServicePortPlan(workspaceId);
|
||||
});
|
||||
|
||||
it("uses an explicit configured port without calling the allocator", async () => {
|
||||
let allocationCount = 0;
|
||||
|
||||
|
||||
@@ -24,6 +24,24 @@ interface PendingWorkspaceServicePortPlanToken {
|
||||
isReleased: boolean;
|
||||
}
|
||||
|
||||
class WorkspaceServicePortPlanReleasedError extends Error {
|
||||
constructor(workspaceId: string) {
|
||||
super(`Workspace service port plan was released while being created for '${workspaceId}'`);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForWorkspaceServicePortPlanUpdate(
|
||||
pendingPlan: Promise<Map<string, number>>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await pendingPlan;
|
||||
} catch (error) {
|
||||
// Allocation failure belongs to the original request. A waiting request
|
||||
// retries against the rolled-back plan; workspace archival cancels both.
|
||||
if (error instanceof WorkspaceServicePortPlanReleasedError) throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const workspaceServicePortPlans = new Map<string, Map<string, number>>();
|
||||
const pendingWorkspaceServicePortPlans = new Map<string, Promise<Map<string, number>>>();
|
||||
const pendingWorkspaceServicePortPlanTokens = new Map<
|
||||
@@ -37,25 +55,19 @@ const MAX_DYNAMIC_PORT_ALLOCATION_ATTEMPTS = 10;
|
||||
export async function ensureWorkspaceServicePortPlan(
|
||||
options: EnsureWorkspaceServicePortPlanOptions,
|
||||
): Promise<ReadonlyMap<string, number>> {
|
||||
const existingPlan = workspaceServicePortPlans.get(options.workspaceId);
|
||||
if (existingPlan) {
|
||||
const pendingPlan = pendingWorkspaceServicePortPlans.get(options.workspaceId);
|
||||
if (pendingPlan) {
|
||||
await waitForWorkspaceServicePortPlanUpdate(pendingPlan);
|
||||
return ensureWorkspaceServicePortPlan(options);
|
||||
}
|
||||
|
||||
const existingPlan =
|
||||
workspaceServicePortPlans.get(options.workspaceId) ?? new Map<string, number>();
|
||||
if (options.services.every((service) => existingPlan.has(service.scriptName))) {
|
||||
return new Map(existingPlan);
|
||||
}
|
||||
|
||||
let pendingPlan = pendingWorkspaceServicePortPlans.get(options.workspaceId);
|
||||
if (!pendingPlan) {
|
||||
const token: PendingWorkspaceServicePortPlanToken = { isReleased: false };
|
||||
pendingPlan = createPendingWorkspaceServicePortPlan({
|
||||
workspaceId: options.workspaceId,
|
||||
services: options.services,
|
||||
allocatePort: options.allocatePort,
|
||||
token,
|
||||
});
|
||||
pendingWorkspaceServicePortPlans.set(options.workspaceId, pendingPlan);
|
||||
pendingWorkspaceServicePortPlanTokens.set(options.workspaceId, token);
|
||||
}
|
||||
|
||||
return new Map(await pendingPlan);
|
||||
return new Map(await startWorkspaceServicePortPlanUpdate({ ...options, existingPlan }));
|
||||
}
|
||||
|
||||
export function requirePlannedWorkspaceServicePort(
|
||||
@@ -82,27 +94,62 @@ export function releaseWorkspaceServicePortPlan(workspaceId: string): void {
|
||||
dynamicPortsByWorkspace.delete(workspaceId);
|
||||
}
|
||||
|
||||
async function createPendingWorkspaceServicePortPlan(options: {
|
||||
workspaceId: string;
|
||||
services: readonly WorkspaceServicePortDeclaration[];
|
||||
allocatePort: (request: WorkspaceServicePortAllocationRequest) => Promise<number>;
|
||||
token: PendingWorkspaceServicePortPlanToken;
|
||||
}): Promise<Map<string, number>> {
|
||||
interface WorkspaceServicePortPlanUpdateOptions extends EnsureWorkspaceServicePortPlanOptions {
|
||||
existingPlan: ReadonlyMap<string, number>;
|
||||
refreshScriptName?: string;
|
||||
}
|
||||
|
||||
function startWorkspaceServicePortPlanUpdate(
|
||||
options: WorkspaceServicePortPlanUpdateOptions,
|
||||
): Promise<Map<string, number>> {
|
||||
const token: PendingWorkspaceServicePortPlanToken = { isReleased: false };
|
||||
const plan = createPendingWorkspaceServicePortPlan({ ...options, token });
|
||||
pendingWorkspaceServicePortPlans.set(options.workspaceId, plan);
|
||||
pendingWorkspaceServicePortPlanTokens.set(options.workspaceId, token);
|
||||
return plan;
|
||||
}
|
||||
|
||||
async function createPendingWorkspaceServicePortPlan(
|
||||
options: WorkspaceServicePortPlanUpdateOptions & { token: PendingWorkspaceServicePortPlanToken },
|
||||
): Promise<Map<string, number>> {
|
||||
const previousDynamicPorts = new Map(dynamicPortsByWorkspace.get(options.workspaceId));
|
||||
const retainedPlan = new Map(options.existingPlan);
|
||||
if (options.refreshScriptName !== undefined) retainedPlan.delete(options.refreshScriptName);
|
||||
try {
|
||||
const plan = await buildWorkspaceServicePortPlan({
|
||||
const updatedPlan = await buildWorkspaceServicePortPlan({
|
||||
workspaceId: options.workspaceId,
|
||||
services: options.services,
|
||||
allocatePort: options.allocatePort,
|
||||
existingPlan: retainedPlan,
|
||||
});
|
||||
const plan = new Map(options.existingPlan);
|
||||
for (const [scriptName, port] of updatedPlan) plan.set(scriptName, port);
|
||||
if (options.token.isReleased) {
|
||||
throw new Error(
|
||||
`Workspace service port plan was released while being created for '${options.workspaceId}'`,
|
||||
);
|
||||
throw new WorkspaceServicePortPlanReleasedError(options.workspaceId);
|
||||
}
|
||||
for (const [scriptName, port] of previousDynamicPorts) {
|
||||
if (plan.get(scriptName) !== port) {
|
||||
releaseDynamicPort({ workspaceId: options.workspaceId, scriptName, port });
|
||||
}
|
||||
}
|
||||
workspaceServicePortPlans.set(options.workspaceId, plan);
|
||||
return plan;
|
||||
} catch (error) {
|
||||
releaseWorkspaceServicePortPlan(options.workspaceId);
|
||||
const dynamicPorts = dynamicPortsByWorkspace.get(options.workspaceId);
|
||||
if (dynamicPorts) {
|
||||
for (const [scriptName, port] of dynamicPorts) {
|
||||
if (options.token.isReleased || previousDynamicPorts.get(scriptName) !== port) {
|
||||
releaseDynamicPort({ workspaceId: options.workspaceId, scriptName, port });
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!options.token.isReleased) {
|
||||
for (const [scriptName, port] of previousDynamicPorts) {
|
||||
reserveDynamicPort({ workspaceId: options.workspaceId, scriptName, port });
|
||||
}
|
||||
}
|
||||
if (options.token.isReleased)
|
||||
throw new WorkspaceServicePortPlanReleasedError(options.workspaceId);
|
||||
throw error;
|
||||
} finally {
|
||||
pendingWorkspaceServicePortPlans.delete(options.workspaceId);
|
||||
@@ -111,12 +158,16 @@ async function createPendingWorkspaceServicePortPlan(options: {
|
||||
}
|
||||
|
||||
async function buildWorkspaceServicePortPlan(options: {
|
||||
existingPlan: ReadonlyMap<string, number>;
|
||||
workspaceId: string;
|
||||
services: readonly WorkspaceServicePortDeclaration[];
|
||||
allocatePort: (request: WorkspaceServicePortAllocationRequest) => Promise<number>;
|
||||
}): Promise<Map<string, number>> {
|
||||
const explicitPortOwners = new Map<number, string>();
|
||||
const explicitPortOwners = new Map<number, string>(
|
||||
Array.from(options.existingPlan, ([scriptName, port]) => [port, scriptName]),
|
||||
);
|
||||
for (const service of options.services) {
|
||||
if (options.existingPlan.has(service.scriptName)) continue;
|
||||
if (service.port === undefined) continue;
|
||||
if (explicitPortOwners.has(service.port)) {
|
||||
throw new Error(`Service '${service.scriptName}' has a duplicate port ${service.port}`);
|
||||
@@ -124,8 +175,9 @@ async function buildWorkspaceServicePortPlan(options: {
|
||||
explicitPortOwners.set(service.port, service.scriptName);
|
||||
}
|
||||
|
||||
const plan = new Map<string, number>();
|
||||
const plan = new Map(options.existingPlan);
|
||||
for (const service of options.services) {
|
||||
if (plan.has(service.scriptName)) continue;
|
||||
if (service.port !== undefined) {
|
||||
plan.set(service.scriptName, service.port);
|
||||
continue;
|
||||
@@ -148,32 +200,20 @@ async function buildWorkspaceServicePortPlan(options: {
|
||||
export async function refreshWorkspaceServicePort(
|
||||
options: RefreshWorkspaceServicePortOptions,
|
||||
): Promise<number> {
|
||||
const plan = workspaceServicePortPlans.get(options.workspaceId) ?? new Map<string, number>();
|
||||
const pendingPlan = pendingWorkspaceServicePortPlans.get(options.workspaceId);
|
||||
if (pendingPlan) {
|
||||
await waitForWorkspaceServicePortPlanUpdate(pendingPlan);
|
||||
return refreshWorkspaceServicePort(options);
|
||||
}
|
||||
|
||||
const reservedPorts = new Set(plan.values());
|
||||
const previousPort = plan.get(options.service.scriptName);
|
||||
if (previousPort !== undefined) {
|
||||
reservedPorts.delete(previousPort);
|
||||
}
|
||||
const oldDynamicPort = dynamicPortsByWorkspace
|
||||
.get(options.workspaceId)
|
||||
?.get(options.service.scriptName);
|
||||
const port = await resolveServicePort({
|
||||
service: options.service,
|
||||
const plan = await startWorkspaceServicePortPlanUpdate({
|
||||
workspaceId: options.workspaceId,
|
||||
services: [options.service],
|
||||
allocatePort: options.allocatePort,
|
||||
reservedPorts,
|
||||
existingPlan: workspaceServicePortPlans.get(options.workspaceId) ?? new Map<string, number>(),
|
||||
refreshScriptName: options.service.scriptName,
|
||||
});
|
||||
if (oldDynamicPort !== undefined && oldDynamicPort !== port) {
|
||||
releaseDynamicPort({
|
||||
workspaceId: options.workspaceId,
|
||||
scriptName: options.service.scriptName,
|
||||
port: oldDynamicPort,
|
||||
});
|
||||
}
|
||||
plan.set(options.service.scriptName, port);
|
||||
workspaceServicePortPlans.set(options.workspaceId, plan);
|
||||
return port;
|
||||
return requirePlannedWorkspaceServicePort(plan, options.service.scriptName);
|
||||
}
|
||||
|
||||
async function resolveServicePort(options: {
|
||||
|
||||
@@ -837,6 +837,54 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("starts a service added to paseo.json without restarting the daemon", async () => {
|
||||
const backend = {
|
||||
type: "service" as const,
|
||||
command: "uv run uvicorn app.main:app --reload --port $PASEO_PORT",
|
||||
};
|
||||
commitPaseoScripts({ "backend-dev": backend });
|
||||
|
||||
const serviceProxy = new ScriptRouteStore();
|
||||
const runtimeStore = new WorkspaceScriptRuntimeStore();
|
||||
const createTerminalCalls: CreateTerminalCall[] = [];
|
||||
const terminalRecords: StubTerminalRecord[] = [];
|
||||
const options = {
|
||||
repoRoot: repoDir,
|
||||
workspaceId: repoDir,
|
||||
projectSlug: "repo",
|
||||
branchName: "feature-added-service",
|
||||
daemonPort: 6767,
|
||||
serviceProxy,
|
||||
runtimeStore,
|
||||
terminalManager: createStubTerminalManager(createTerminalCalls, terminalRecords),
|
||||
};
|
||||
const backendResult = await spawnWorkspaceScript({ ...options, scriptName: "backend-dev" });
|
||||
|
||||
writeFileSync(
|
||||
join(repoDir, "paseo.json"),
|
||||
JSON.stringify({
|
||||
scripts: {
|
||||
"backend-dev": backend,
|
||||
web: { type: "service", command: "pnpm run dev -- --port $PASEO_PORT" },
|
||||
},
|
||||
}),
|
||||
);
|
||||
const webResult = await spawnWorkspaceScript({ ...options, scriptName: "web" });
|
||||
|
||||
expect(webResult.port).toEqual(expect.any(Number));
|
||||
expect(webResult.port).not.toBe(backendResult.port);
|
||||
expect(terminalRecords[1]?.sentInputs).toEqual(["pnpm run dev -- --port $PASEO_PORT\r"]);
|
||||
expect(createTerminalCalls[1]?.env?.PASEO_PORT).toBe(String(webResult.port));
|
||||
expect(createTerminalCalls[1]?.env?.PASEO_SERVICE_BACKEND_DEV_PORT).toBe(
|
||||
String(backendResult.port),
|
||||
);
|
||||
expect(serviceProxy.listRoutes()).toHaveLength(2);
|
||||
expect(runtimeStore.get({ workspaceId: repoDir, scriptName: "backend-dev" })).toMatchObject({
|
||||
lifecycle: "running",
|
||||
terminalId: backendResult.terminalId,
|
||||
});
|
||||
});
|
||||
|
||||
it("spawns services with public aliases and public service URLs", async () => {
|
||||
commitPaseoScripts(
|
||||
{
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user