mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-27 11:42:52 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f040f93b5 | ||
|
|
a7575127c2 |
@@ -90,7 +90,6 @@ import {
|
||||
import { createAssistantImageOccurrenceKey } from "@/assistant-image/acquisition-cache";
|
||||
import { AssistantSelectionCopySurface } from "@/assistant-selection-copy/surface";
|
||||
import { AssistantFileLinkResolverProvider, createInlinePathOpener } from "@/assistant-file-links";
|
||||
import { useOpenOutsideWorkspaceFolder } from "@/workspace/open-in-file-manager/outside-folder";
|
||||
import {
|
||||
createWorkspaceFileTabTarget,
|
||||
type WorkspaceFileLocation,
|
||||
@@ -460,7 +459,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
view: "files",
|
||||
});
|
||||
});
|
||||
const openInlineOutsideDirectory = useOpenOutsideWorkspaceFolder(resolvedServerId);
|
||||
const inlinePathOpener = useMemo(
|
||||
() =>
|
||||
createInlinePathOpener({
|
||||
@@ -468,9 +466,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
client,
|
||||
openFile: openInlineFile,
|
||||
openDirectory: openInlineDirectory,
|
||||
openOutsideDirectory: openInlineOutsideDirectory,
|
||||
}),
|
||||
[workspaceRoot, client, openInlineFile, openInlineDirectory, openInlineOutsideDirectory],
|
||||
[workspaceRoot, client, openInlineFile, openInlineDirectory],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!isActive) inlinePathOpener.cancel();
|
||||
|
||||
@@ -71,9 +71,6 @@ it("ignores an older probe after a newer file link opens", async () => {
|
||||
openDirectory: (directory) => {
|
||||
opened.push(directory);
|
||||
},
|
||||
openOutsideDirectory: (directory) => {
|
||||
opened.push(directory);
|
||||
},
|
||||
});
|
||||
const first = opener.open({ raw: "./first.md", path: "/repo/first.md" }, "preferred");
|
||||
await opener.open({ raw: "./second.md:2", path: "/repo/second.md", lineStart: 2 }, "preferred");
|
||||
@@ -98,9 +95,6 @@ it("ignores a pending probe when its view is left", async () => {
|
||||
openDirectory: (directory) => {
|
||||
opened.push(directory);
|
||||
},
|
||||
openOutsideDirectory: (directory) => {
|
||||
opened.push(directory);
|
||||
},
|
||||
});
|
||||
const pending = opener.open({ raw: "./docs", path: "/repo/docs" }, "preferred");
|
||||
opener.cancel();
|
||||
@@ -108,25 +102,3 @@ it("ignores a pending probe when its view is left", async () => {
|
||||
await pending;
|
||||
expect(opened).toEqual([]);
|
||||
});
|
||||
|
||||
it("sends folders outside the workspace away from the workspace Files panel", async () => {
|
||||
const { createInlinePathOpener } = await import("./path-open");
|
||||
const workspaceFolders: string[] = [];
|
||||
const outsideFolders: string[] = [];
|
||||
const opener = createInlinePathOpener({
|
||||
workspaceRoot: "/Users/me/repo",
|
||||
client: null,
|
||||
openFile: () => {},
|
||||
openDirectory: (directory) => {
|
||||
workspaceFolders.push(directory);
|
||||
},
|
||||
openOutsideDirectory: (directory) => {
|
||||
outsideFolders.push(directory);
|
||||
},
|
||||
});
|
||||
for (const raw of ["~/Downloads/contracts/", "/tmp/reports/", "/Users/me/repo/docs/"]) {
|
||||
await opener.open({ raw, path: raw }, "preferred");
|
||||
}
|
||||
expect(outsideFolders).toEqual(["~/Downloads/contracts", "/tmp/reports"]);
|
||||
expect(workspaceFolders).toEqual(["docs"]);
|
||||
});
|
||||
|
||||
@@ -14,23 +14,12 @@ interface PathOpenInput {
|
||||
client: { listDirectory(root: string, path: string): Promise<unknown> } | null;
|
||||
}
|
||||
|
||||
type PathOpenTarget =
|
||||
| { kind: "file" }
|
||||
| { kind: "directory"; directoryPath: string }
|
||||
| { kind: "outsideDirectory"; directoryPath: string };
|
||||
|
||||
// Workspace paths arrive relative to the root; anything still absolute or
|
||||
// home-relative lives outside it, where the Files panel cannot list it.
|
||||
function isOutsideWorkspacePath(pathValue: string): boolean {
|
||||
return isAbsolutePath(pathValue) || pathValue === "~" || pathValue.startsWith("~/");
|
||||
}
|
||||
type PathOpenTarget = { kind: "file" } | { kind: "directory"; directoryPath: string };
|
||||
|
||||
/** Resolve workspace folders from filesystem facts, including names that contain dots. */
|
||||
export async function resolveInlinePathOpen(input: PathOpenInput): Promise<PathOpenTarget> {
|
||||
if (!input.file) {
|
||||
return isOutsideWorkspacePath(input.directory)
|
||||
? { kind: "outsideDirectory", directoryPath: input.directory }
|
||||
: { kind: "directory", directoryPath: input.directory };
|
||||
return { kind: "directory", directoryPath: input.directory };
|
||||
}
|
||||
if (
|
||||
input.lineStart !== undefined ||
|
||||
@@ -53,7 +42,6 @@ export function createInlinePathOpener(input: {
|
||||
client: PathOpenInput["client"];
|
||||
openFile: (location: WorkspaceFileLocation, disposition: OpenFileDisposition) => void;
|
||||
openDirectory: (path: string) => void;
|
||||
openOutsideDirectory: (path: string) => void;
|
||||
}) {
|
||||
let generation = 0;
|
||||
return {
|
||||
@@ -70,8 +58,6 @@ export function createInlinePathOpener(input: {
|
||||
if (ownGeneration !== generation) return;
|
||||
if (destination.kind === "directory") {
|
||||
input.openDirectory(destination.directoryPath);
|
||||
} else if (destination.kind === "outsideDirectory") {
|
||||
input.openOutsideDirectory(destination.directoryPath);
|
||||
} else if (normalized.file) {
|
||||
const location = normalizeWorkspaceFileLocation({
|
||||
path: normalized.file,
|
||||
|
||||
@@ -67,7 +67,6 @@ export const ar: TranslationResources = {
|
||||
daemonClientUnavailable: "عميل Daemon غير متوفر",
|
||||
daemonClientDisconnected: "تم قطع اتصال عميل Daemon",
|
||||
noFileFound: "لم يتم العثور على ملف لـ{{token}}",
|
||||
folderOutsideWorkspace: "{{path}} خارج مساحة العمل هذه",
|
||||
unexpectedDictationError: "حدث خطأ غير متوقع أثناء معالجة الإملاء.",
|
||||
},
|
||||
connectionStatus: {
|
||||
|
||||
@@ -63,7 +63,6 @@ export const en = {
|
||||
daemonClientUnavailable: "Daemon client unavailable",
|
||||
daemonClientDisconnected: "Daemon client is disconnected",
|
||||
noFileFound: "No file found for {{token}}",
|
||||
folderOutsideWorkspace: "{{path}} is outside this workspace",
|
||||
unexpectedDictationError: "An unexpected error occurred while handling dictation.",
|
||||
},
|
||||
connectionStatus: {
|
||||
|
||||
@@ -68,7 +68,6 @@ export const es: TranslationResources = {
|
||||
daemonClientUnavailable: "ClienteDaemonno disponible",
|
||||
daemonClientDisconnected: "El clienteDaemonestá desconectado",
|
||||
noFileFound: "No se encontró ningún archivo para{{token}}",
|
||||
folderOutsideWorkspace: "{{path}} está fuera de este espacio de trabajo",
|
||||
unexpectedDictationError: "Se produjo un error inesperado al manejar el dictado.",
|
||||
},
|
||||
connectionStatus: {
|
||||
|
||||
@@ -68,7 +68,6 @@ export const fr: TranslationResources = {
|
||||
daemonClientUnavailable: "Client du daemon indisponible",
|
||||
daemonClientDisconnected: "Le client du daemon est déconnecté",
|
||||
noFileFound: "Aucun fichier trouvé pour {{token}}",
|
||||
folderOutsideWorkspace: "{{path}} se trouve en dehors de cet espace de travail",
|
||||
unexpectedDictationError: "Une erreur inattendue s’est produite pendant la dictée.",
|
||||
},
|
||||
connectionStatus: {
|
||||
|
||||
@@ -68,7 +68,6 @@ export const ja: TranslationResources = {
|
||||
daemonClientUnavailable: "デーモンクライアントが利用できません",
|
||||
daemonClientDisconnected: "デーモンクライアントが切断されています",
|
||||
noFileFound: "{{token}}のファイルが見つかりません",
|
||||
folderOutsideWorkspace: "{{path}} はこのワークスペースの外にあります",
|
||||
unexpectedDictationError: "音声入力処理中に予期しないエラーが発生しました。",
|
||||
},
|
||||
connectionStatus: {
|
||||
|
||||
@@ -67,7 +67,6 @@ export const ko: TranslationResources = {
|
||||
daemonClientUnavailable: "데몬 클라이언트를 사용할 수 없습니다",
|
||||
daemonClientDisconnected: "데몬 클라이언트 연결이 끊어졌습니다",
|
||||
noFileFound: "{{token}}에 해당하는 파일을 찾을 수 없습니다",
|
||||
folderOutsideWorkspace: "{{path}}은(는) 이 워크스페이스 밖에 있습니다",
|
||||
unexpectedDictationError: "받아쓰기를 처리하는 중 예기치 않은 오류가 발생했습니다.",
|
||||
},
|
||||
connectionStatus: {
|
||||
|
||||
@@ -68,7 +68,6 @@ export const ptBR: TranslationResources = {
|
||||
daemonClientUnavailable: "Cliente do daemon indisponível",
|
||||
daemonClientDisconnected: "O cliente do daemon está desconectado",
|
||||
noFileFound: "Nenhum arquivo encontrado para {{token}}",
|
||||
folderOutsideWorkspace: "{{path}} está fora deste workspace",
|
||||
unexpectedDictationError: "Ocorreu um erro inesperado ao processar o ditado.",
|
||||
},
|
||||
connectionStatus: {
|
||||
|
||||
@@ -68,7 +68,6 @@ export const ru: TranslationResources = {
|
||||
daemonClientUnavailable: "Daemon клиента недоступен",
|
||||
daemonClientDisconnected: "Daemon клиента отключен",
|
||||
noFileFound: "Файл для {{token}} не найден",
|
||||
folderOutsideWorkspace: "{{path}} находится вне этого рабочего пространства",
|
||||
unexpectedDictationError: "При обработке диктовки произошла непредвиденная ошибка.",
|
||||
},
|
||||
connectionStatus: {
|
||||
|
||||
@@ -67,7 +67,6 @@ export const zhCN: TranslationResources = {
|
||||
daemonClientUnavailable: "Daemon client 不可用",
|
||||
daemonClientDisconnected: "Daemon client 已断开连接",
|
||||
noFileFound: "未找到 {{token}} 对应的文件",
|
||||
folderOutsideWorkspace: "{{path}} 不在此工作区内",
|
||||
unexpectedDictationError: "处理听写时发生意外错误。",
|
||||
},
|
||||
connectionStatus: {
|
||||
|
||||
@@ -64,7 +64,6 @@ import { WorkspaceScriptsButton } from "@/screens/workspace/scripts";
|
||||
import { ImportSessionSheet } from "@/components/import-session-sheet";
|
||||
import { useNavigateToImportedAgent } from "@/hooks/use-import-session";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useOpenOutsideWorkspaceFolder } from "@/workspace/open-in-file-manager/outside-folder";
|
||||
import { getOrCreateClientId } from "@/utils/client-id";
|
||||
import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store";
|
||||
import { toggleDesktopSidebarsWithCheckoutIntent } from "@/utils/desktop-sidebar-toggle";
|
||||
@@ -3683,7 +3682,6 @@ function WorkspaceScreenContent({
|
||||
view: "files",
|
||||
});
|
||||
});
|
||||
const openMarkdownOutsideDirectory = useOpenOutsideWorkspaceFolder(normalizedServerId);
|
||||
const markdownPathOpener = useMemo(
|
||||
() =>
|
||||
createInlinePathOpener({
|
||||
@@ -3691,15 +3689,8 @@ function WorkspaceScreenContent({
|
||||
client,
|
||||
openFile: openMarkdownFile,
|
||||
openDirectory: openMarkdownDirectory,
|
||||
openOutsideDirectory: openMarkdownOutsideDirectory,
|
||||
}),
|
||||
[
|
||||
workspaceDirectory,
|
||||
client,
|
||||
openMarkdownFile,
|
||||
openMarkdownDirectory,
|
||||
openMarkdownOutsideDirectory,
|
||||
],
|
||||
[workspaceDirectory, client, openMarkdownFile, openMarkdownDirectory],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!isRouteFocused) markdownPathOpener.cancel();
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
|
||||
import { openDesktopTarget, useDesktopOpenTargets } from "@/workspace/desktop-open-targets";
|
||||
|
||||
/**
|
||||
* Opens a folder outside the workspace in the system file manager. The Files
|
||||
* panel only lists the workspace, so hosts without a local file manager get a
|
||||
* toast instead.
|
||||
*/
|
||||
export function useOpenOutsideWorkspaceFolder(serverId: string): (folderPath: string) => void {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const isLocalExecution = useIsLocalDaemon(serverId);
|
||||
const { targets } = useDesktopOpenTargets({ isLocalExecution });
|
||||
const fileManagerTarget = targets.find((target) => target.kind === "file-manager");
|
||||
|
||||
return useCallback(
|
||||
(folderPath: string) => {
|
||||
const showOutsideWorkspace = () =>
|
||||
toast.show(t("common.errors.folderOutsideWorkspace", { path: folderPath }));
|
||||
if (!fileManagerTarget) {
|
||||
showOutsideWorkspace();
|
||||
return;
|
||||
}
|
||||
void openDesktopTarget({ editorId: fileManagerTarget.id, workspacePath: folderPath }).catch(
|
||||
(error: unknown) => {
|
||||
console.warn("[open-outside-workspace-folder] open failed", error);
|
||||
showOutsideWorkspace();
|
||||
},
|
||||
);
|
||||
},
|
||||
[fileManagerTarget, t, toast],
|
||||
);
|
||||
}
|
||||
@@ -86,10 +86,6 @@ class FakeEditorTargets implements EditorTargetRuntime {
|
||||
return this.paths.has(targetPath);
|
||||
}
|
||||
|
||||
expandHomePath(targetPath: string): string {
|
||||
return targetPath.startsWith("~/") ? `/home/me/${targetPath.slice(2)}` : targetPath;
|
||||
}
|
||||
|
||||
isAbsolutePath(targetPath: string): boolean {
|
||||
return targetPath.startsWith("/") || /^[A-Z]:\//u.test(targetPath);
|
||||
}
|
||||
@@ -517,19 +513,6 @@ describe("editor target registry", () => {
|
||||
expect(runtime.revealedPaths).toEqual(["C:/repo/src/app.ts"]);
|
||||
});
|
||||
|
||||
it("opens a home-relative folder in the file manager", async () => {
|
||||
const runtime = new FakeEditorTargets();
|
||||
runtime.addPath("/home/me/Downloads/contracts");
|
||||
|
||||
await openEditorTarget(
|
||||
{ editorId: "file-manager", workspacePath: "~/Downloads/contracts" },
|
||||
runtime,
|
||||
[fileManagerTarget],
|
||||
);
|
||||
|
||||
expect(runtime.openedPaths).toEqual(["/home/me/Downloads/contracts"]);
|
||||
});
|
||||
|
||||
it("keeps the platform file-manager ids used by stored preferences", async () => {
|
||||
const macTargets = await listAvailableEditorTargets(new FakeEditorTargets("darwin"), [
|
||||
finderTarget,
|
||||
|
||||
@@ -97,16 +97,10 @@ export function getEditorTarget(
|
||||
}
|
||||
|
||||
export async function openEditorTarget(
|
||||
request: EditorTargetLaunchInput & { editorId: string },
|
||||
input: EditorTargetLaunchInput & { editorId: string },
|
||||
runtime: EditorTargetRuntime,
|
||||
targets: readonly EditorTarget[] = EDITOR_TARGETS,
|
||||
): Promise<void> {
|
||||
// Assistant replies name folders like `~/Downloads/x`; expand them before the absolute-path guard.
|
||||
const input = {
|
||||
...request,
|
||||
workspacePath: runtime.expandHomePath(request.workspacePath),
|
||||
filePath: request.filePath === undefined ? undefined : runtime.expandHomePath(request.filePath),
|
||||
};
|
||||
if (!runtime.isAbsolutePath(input.workspacePath)) {
|
||||
throw new Error("Editor target workspace path must be an absolute local path");
|
||||
}
|
||||
|
||||
@@ -61,14 +61,6 @@ function isAbsolutePath(value: string, platform: NodeJS.Platform): boolean {
|
||||
return platform === "win32" ? win32.isAbsolute(value) : posix.isAbsolute(value);
|
||||
}
|
||||
|
||||
function expandHomePath(value: string, homeDirectory: string): string {
|
||||
if (value === "~") return homeDirectory;
|
||||
if (value.startsWith("~/") || value.startsWith("~\\")) {
|
||||
return path.join(homeDirectory, value.slice(2));
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function resolveExecutable(
|
||||
commands: readonly string[],
|
||||
input: {
|
||||
@@ -174,7 +166,6 @@ export function createEditorTargetRuntime(
|
||||
pathExists,
|
||||
isAbsolutePath: (targetPath) => isAbsolutePath(targetPath, platform),
|
||||
listDirectory,
|
||||
expandHomePath: (targetPath) => expandHomePath(targetPath, homeDirectory),
|
||||
resolveCommand: (commands) => resolveExecutable(commands, { env, pathExists, platform }),
|
||||
async spawnDetached({ command, args }) {
|
||||
const commandScript = isWindowsCommandScript(command, platform);
|
||||
|
||||
@@ -30,8 +30,6 @@ export interface EditorTargetRuntime {
|
||||
pathExists(path: string): boolean;
|
||||
isAbsolutePath(path: string): boolean;
|
||||
listDirectory(path: string): EditorTargetDirectoryEntry[];
|
||||
/** Resolves a leading `~` to the user's home directory. */
|
||||
expandHomePath(path: string): string;
|
||||
resolveCommand(commands: readonly string[]): string | null;
|
||||
spawnDetached(input: { command: string; args: readonly string[] }): Promise<void>;
|
||||
openPath(path: string): Promise<void>;
|
||||
|
||||
@@ -651,7 +651,8 @@ lines.on("line", (line) => {
|
||||
type: "timeline.item",
|
||||
item: expect.objectContaining({
|
||||
type: "assistant_message",
|
||||
id: "assistant-1",
|
||||
id: JSON.stringify(["agent_message_chunk", "assistant-1"]),
|
||||
messageId: "assistant-1",
|
||||
text: "ACP says hi",
|
||||
}),
|
||||
}),
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { createServer } from "node:http";
|
||||
import { mkdtemp, readFile, writeFile, rm, chmod } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import {
|
||||
diffWorkspaceGitRefs,
|
||||
@@ -6,14 +10,21 @@ import {
|
||||
fetchWorkspaceGitRemote,
|
||||
parseWorkspaceGitRefs,
|
||||
} from "./workspace-git-fetch.js";
|
||||
import type { RunGitCommand } from "../utils/run-git-command.js";
|
||||
import { runGitCommand, type RunGitCommand } from "../utils/run-git-command.js";
|
||||
|
||||
describe("workspace Git remote refs", () => {
|
||||
test("runs the complete fetch through the supplied Git runner", async () => {
|
||||
const commands: string[][] = [];
|
||||
let refRead = 0;
|
||||
const runGitCommand: RunGitCommand = async (args) => {
|
||||
const run: RunGitCommand = async (args, options) => {
|
||||
commands.push(args);
|
||||
if (args[0] === "fetch") {
|
||||
expect(options.envOverlay).toEqual({
|
||||
GIT_TERMINAL_PROMPT: "0",
|
||||
GIT_ASKPASS: "",
|
||||
GCM_INTERACTIVE: "0",
|
||||
});
|
||||
}
|
||||
const stdout =
|
||||
args[0] === "for-each-ref"
|
||||
? `refs/remotes/origin/main\0${refRead++ === 0 ? "aaaaaaaa" : "bbbbbbbb"}`
|
||||
@@ -22,7 +33,7 @@ describe("workspace Git remote refs", () => {
|
||||
};
|
||||
|
||||
await expect(
|
||||
fetchWorkspaceGitRemote("/repo", { onRefSnapshot() {} }, runGitCommand),
|
||||
fetchWorkspaceGitRemote("/repo", { onRefSnapshot() {} }, run),
|
||||
).resolves.toMatchObject({
|
||||
changes: [
|
||||
{
|
||||
@@ -109,3 +120,130 @@ describe("workspace Git remote refs", () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// Serve a real bare repository over Git's dumb HTTP transport, behind Basic authentication.
|
||||
// Both the authentication challenge and successful fetch exercise Git itself.
|
||||
async function withAuthenticatedRemote(
|
||||
verify: (fixture: {
|
||||
cwd: string;
|
||||
run: RunGitCommand;
|
||||
root: string;
|
||||
origin: string;
|
||||
expectedOid: string;
|
||||
}) => Promise<void>,
|
||||
): Promise<void> {
|
||||
const root = await mkdtemp(join(tmpdir(), "paseo-fetch-auth-"));
|
||||
const cwd = join(root, "checkout");
|
||||
const remote = join(root, "remote.git");
|
||||
const emptyConfig = join(root, "gitconfig");
|
||||
const askpass = join(root, "askpass.sh");
|
||||
const marker = join(root, "prompts");
|
||||
await writeFile(emptyConfig, "");
|
||||
await writeFile(
|
||||
askpass,
|
||||
`#!/bin/sh\nprintf 'prompt\\n' >> '${marker.replaceAll("'", "'\\''")}'\nexit 1\n`,
|
||||
);
|
||||
await chmod(askpass, 0o755);
|
||||
const askpassCommand = askpass;
|
||||
const run: RunGitCommand = (args, options) =>
|
||||
runGitCommand(args, {
|
||||
...options,
|
||||
env: {
|
||||
...options.env,
|
||||
GIT_CONFIG_GLOBAL: emptyConfig,
|
||||
GIT_CONFIG_NOSYSTEM: "1",
|
||||
GIT_ASKPASS: askpassCommand,
|
||||
SSH_ASKPASS: askpassCommand,
|
||||
},
|
||||
});
|
||||
const server = createServer(async (request, response) => {
|
||||
if (
|
||||
request.headers.authorization !== `Basic ${Buffer.from("fixture:secret").toString("base64")}`
|
||||
) {
|
||||
response.writeHead(401, { "WWW-Authenticate": 'Basic realm="git-test"' });
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
const pathname = new URL(request.url!, "http://localhost").pathname;
|
||||
try {
|
||||
const contents = await readFile(join(root, pathname));
|
||||
response.writeHead(200, { "Content-Type": "application/octet-stream" });
|
||||
response.end(contents);
|
||||
} catch {
|
||||
response.writeHead(404);
|
||||
response.end();
|
||||
}
|
||||
});
|
||||
try {
|
||||
await run(["init", "--initial-branch=main", cwd], { cwd: root });
|
||||
await run(
|
||||
[
|
||||
"-c",
|
||||
"user.name=Fixture",
|
||||
"-c",
|
||||
"user.email=fixture@example.invalid",
|
||||
"commit",
|
||||
"--allow-empty",
|
||||
"-m",
|
||||
"initial",
|
||||
],
|
||||
{ cwd },
|
||||
);
|
||||
await run(["clone", "--bare", cwd, remote], { cwd: root });
|
||||
await run(["update-server-info"], { cwd: remote });
|
||||
await run(["config", "credential.helper", ""], { cwd });
|
||||
await run(["config", "core.askPass", askpassCommand], { cwd });
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") throw new Error("Expected TCP server");
|
||||
const origin = `http://127.0.0.1:${address.port}/remote.git`;
|
||||
await run(["remote", "add", "origin", origin], { cwd });
|
||||
const expectedOid = (await run(["rev-parse", "HEAD"], { cwd })).stdout.trim();
|
||||
await verify({ cwd, run, root, origin, expectedOid });
|
||||
} finally {
|
||||
server.closeAllConnections();
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
await rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
}
|
||||
}
|
||||
|
||||
test.skipIf(process.platform === "win32")(
|
||||
"background fetches never invoke askpass when credentials are missing",
|
||||
async () => {
|
||||
await withAuthenticatedRemote(async ({ cwd, run, root }) => {
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
const result = await fetchWorkspaceGitRemote(cwd, { onRefSnapshot() {} }, run);
|
||||
expect(String(result.error)).toContain("terminal prompts disabled");
|
||||
}
|
||||
await expect(readFile(join(root, "prompts"), "utf8")).rejects.toMatchObject({
|
||||
code: "ENOENT",
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test.skipIf(process.platform === "win32")(
|
||||
"background fetches still use stored credentials",
|
||||
async () => {
|
||||
await withAuthenticatedRemote(async ({ cwd, run, root, origin, expectedOid }) => {
|
||||
const credentials = join(root, "credentials");
|
||||
await writeFile(credentials, origin.replace("http://", "http://fixture:secret@") + "\n");
|
||||
await run(["config", "credential.helper", `store --file="${credentials}"`], { cwd });
|
||||
|
||||
const result = await fetchWorkspaceGitRemote(cwd, { onRefSnapshot() {} }, run);
|
||||
|
||||
expect(result.error).toBeNull();
|
||||
expect(result.changes).toContainEqual({
|
||||
kind: "added",
|
||||
ref: "origin/main",
|
||||
afterOid: expectedOid,
|
||||
});
|
||||
expect((await run(["rev-parse", "refs/remotes/origin/main"], { cwd })).stdout.trim()).toBe(
|
||||
expectedOid,
|
||||
);
|
||||
await expect(readFile(join(root, "prompts"), "utf8")).rejects.toMatchObject({
|
||||
code: "ENOENT",
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -33,7 +33,9 @@ export async function fetchWorkspaceGitRemote(
|
||||
try {
|
||||
await run(["fetch", "origin", "--prune"], {
|
||||
cwd,
|
||||
envOverlay: { GIT_TERMINAL_PROMPT: "0" },
|
||||
// Terminal prompting and graphical credential prompting have separate controls.
|
||||
// Keep cached credentials available, but never start a login from a passive fetch.
|
||||
envOverlay: { GIT_TERMINAL_PROMPT: "0", GIT_ASKPASS: "", GCM_INTERACTIVE: "0" },
|
||||
timeout: 120_000,
|
||||
});
|
||||
} catch (caught) {
|
||||
|
||||
Reference in New Issue
Block a user