From 7fe7446b560ea79d1fd89426b484509710702b76 Mon Sep 17 00:00:00 2001 From: Jan Jaap Date: Tue, 16 Jun 2026 19:58:27 +0200 Subject: [PATCH 1/3] fix(settings): disable auto-open of task sidebar by default (#2421) Co-authored-by: Claude Haiku 4.5 (cherry picked from commit 4b7382733454ce582d4b27fdc0c14f3ffb519bc3) --- packages/contracts/src/settings.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 461b6fd519d1..437fc4bb7e4b 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -40,7 +40,7 @@ export type SidebarThreadPreviewCount = typeof SidebarThreadPreviewCount.Type; export const DEFAULT_SIDEBAR_THREAD_PREVIEW_COUNT: SidebarThreadPreviewCount = 6; export const ClientSettingsSchema = Schema.Struct({ - autoOpenPlanSidebar: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + autoOpenPlanSidebar: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), dismissedProviderUpdateNotificationKeys: Schema.Array(TrimmedNonEmptyString).pipe( From 8b8965c4823b3fb74ccf989ccad5dcc85a166fe1 Mon Sep 17 00:00:00 2001 From: Icarus Wings <10465470+TheIcarusWings@users.noreply.github.com> Date: Tue, 16 Jun 2026 19:04:47 +0100 Subject: [PATCH 2/3] Double-click a sidebar thread row to rename (#3064) Co-authored-by: Claude Opus 4.8 (cherry picked from commit 60b546cf0111f0a041e9fbac025f4c904961ad75) --- .../components/Sidebar.dblclick.browser.tsx | 255 ++++++++++++++++++ apps/web/src/components/Sidebar.logic.test.ts | 19 ++ apps/web/src/components/Sidebar.logic.ts | 9 + apps/web/src/components/Sidebar.tsx | 52 +++- 4 files changed, 331 insertions(+), 4 deletions(-) create mode 100644 apps/web/src/components/Sidebar.dblclick.browser.tsx diff --git a/apps/web/src/components/Sidebar.dblclick.browser.tsx b/apps/web/src/components/Sidebar.dblclick.browser.tsx new file mode 100644 index 000000000000..71d744be1947 --- /dev/null +++ b/apps/web/src/components/Sidebar.dblclick.browser.tsx @@ -0,0 +1,255 @@ +import "../index.css"; + +import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; +import { useCallback, useRef, useState } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { page, userEvent } from "vite-plus/test/browser"; +import { cleanup, render } from "vitest-browser-react"; + +import { AppAtomRegistryProvider } from "../rpc/atomRegistry"; +import { DEFAULT_INTERACTION_MODE } from "../types"; +import type { SidebarThreadSummary } from "../types"; +import { SidebarThreadRow } from "./Sidebar"; + +// Double-click-to-rename is a desktop affordance; force the non-mobile path so +// the rename input is reachable regardless of the test browser viewport. +vi.mock("~/hooks/useMediaQuery", () => ({ + useIsMobile: () => false, + useMediaQuery: () => false, +})); + +const THREAD_ID = ThreadId.make("thread-1"); +const ENVIRONMENT_ID = EnvironmentId.make("environment-local"); +const PROJECT_ID = ProjectId.make("project-1"); +const INITIAL_TITLE = "Original title"; + +const ROW_TESTID = `thread-row-${THREAD_ID}`; +const TITLE_TESTID = `thread-title-${THREAD_ID}`; + +// Spies live at module scope so their call history survives the row's +// re-renders; reset between tests. +const spies = { + handleThreadClick: vi.fn(), + startThreadRename: vi.fn(), + navigateToThread: vi.fn(), + handleMultiSelectContextMenu: vi.fn(async () => {}), + handleThreadContextMenu: vi.fn(async () => {}), + clearSelection: vi.fn(), + commitRename: vi.fn(), + attemptArchiveThread: vi.fn(async () => {}), + openPrLink: vi.fn(), +}; + +function buildThread(title: string): SidebarThreadSummary { + return { + id: THREAD_ID, + environmentId: ENVIRONMENT_ID, + projectId: PROJECT_ID, + title, + interactionMode: DEFAULT_INTERACTION_MODE, + session: null, + createdAt: "2024-01-01T00:00:00.000Z", + archivedAt: null, + updatedAt: undefined, + latestTurn: null, + branch: null, + worktreePath: null, + latestUserMessageAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + }; +} + +// Mirrors the real parent (`SidebarProjectItem`): holds the rename state, wires +// `startThreadRename`, and commits by clearing the rename state and persisting +// the new title back onto the thread so the row re-renders with it. +function Harness() { + const [title, setTitle] = useState(INITIAL_TITLE); + const [renamingThreadKey, setRenamingThreadKey] = useState(null); + const [renamingTitle, setRenamingTitle] = useState(""); + const [confirmingArchiveThreadKey, setConfirmingArchiveThreadKey] = useState(null); + const renamingInputRef = useRef(null); + const renamingCommittedRef = useRef(false); + const confirmArchiveButtonRefs = useRef(new Map()); + + const startThreadRename = useCallback((threadKey: string, nextTitle: string) => { + spies.startThreadRename(threadKey, nextTitle); + setRenamingThreadKey(threadKey); + setRenamingTitle(nextTitle); + renamingCommittedRef.current = false; + }, []); + + const commitRename = useCallback( + async (threadRef: unknown, newTitle: string, originalTitle: string) => { + spies.commitRename(threadRef, newTitle, originalTitle); + const trimmed = newTitle.trim(); + if (trimmed.length > 0) { + setTitle(trimmed); + } + setRenamingThreadKey(null); + renamingInputRef.current = null; + }, + [], + ); + + const cancelRename = useCallback(() => { + setRenamingThreadKey(null); + renamingInputRef.current = null; + }, []); + + return ( + +
    + +
+
+ ); +} + +describe("SidebarThreadRow double-click rename", () => { + beforeEach(() => { + for (const spy of Object.values(spies)) spy.mockClear(); + }); + + afterEach(() => { + cleanup(); + }); + + it("double-clicking a row starts the inline rename, focused with text selected", async () => { + render(); + + await expect.element(page.getByTestId(TITLE_TESTID)).toBeVisible(); + + await userEvent.dblClick(page.getByTestId(ROW_TESTID)); + + const input = page.getByRole("textbox"); + await expect.element(input).toBeVisible(); + + const element = input.element() as HTMLInputElement; + expect(element.value).toBe(INITIAL_TITLE); + // The existing rename-input ref focuses + selects the whole title. + expect(document.activeElement).toBe(element); + expect(element.selectionStart).toBe(0); + expect(element.selectionEnd).toBe(INITIAL_TITLE.length); + }); + + it("Enter commits the rename and the new title persists on the row", async () => { + render(); + + await userEvent.dblClick(page.getByTestId(ROW_TESTID)); + const input = page.getByRole("textbox"); + await expect.element(input).toBeVisible(); + + await userEvent.fill(input, "Renamed thread"); + await userEvent.keyboard("{Enter}"); + + // commitRename was invoked with (threadRef, newTitle, originalTitle). + expect(spies.commitRename).toHaveBeenCalledTimes(1); + expect(spies.commitRename).toHaveBeenCalledWith( + expect.anything(), + "Renamed thread", + INITIAL_TITLE, + ); + + // Input is gone and the row now shows the persisted title. + const title = page.getByTestId(TITLE_TESTID); + await expect.element(title).toBeVisible(); + await expect.element(title).toHaveTextContent("Renamed thread"); + }); + + it("Escape cancels the rename without committing", async () => { + render(); + + await userEvent.dblClick(page.getByTestId(ROW_TESTID)); + await expect.element(page.getByRole("textbox")).toBeVisible(); + + await userEvent.keyboard("{Escape}"); + + expect(spies.commitRename).not.toHaveBeenCalled(); + const title = page.getByTestId(TITLE_TESTID); + await expect.element(title).toBeVisible(); + await expect.element(title).toHaveTextContent(INITIAL_TITLE); + }); + + it("double-clicking inside the rename input keeps the edit (does not reset to the title)", async () => { + render(); + + await userEvent.dblClick(page.getByTestId(ROW_TESTID)); + const input = page.getByRole("textbox"); + await expect.element(input).toBeVisible(); + + await userEvent.fill(input, "Edited but not committed"); + // Double-clicking inside the input (e.g. to select a word) must not bubble + // to the row and restart the rename, which would wipe the edit. + await userEvent.dblClick(input); + + expect((input.element() as HTMLInputElement).value).toBe("Edited but not committed"); + expect(spies.commitRename).not.toHaveBeenCalled(); + }); + + it("double-clicking the row chrome while already renaming does not restart/reset it", async () => { + render(); + + await userEvent.dblClick(page.getByTestId(ROW_TESTID)); + const input = page.getByRole("textbox"); + await expect.element(input).toBeVisible(); + await userEvent.fill(input, "Edited"); + expect(spies.startThreadRename).toHaveBeenCalledTimes(1); + + // Double-click the row element itself (chrome, not the input). + const rowEl = page.getByTestId(ROW_TESTID).element(); + rowEl.dispatchEvent(new MouseEvent("dblclick", { bubbles: true, cancelable: true, detail: 2 })); + + // Guard short-circuits: rename is not restarted and the edit is preserved. + expect(spies.startThreadRename).toHaveBeenCalledTimes(1); + expect((input.element() as HTMLInputElement).value).toBe("Edited"); + }); + + it("modifier double-click is multi-select intent and does not start a rename", async () => { + render(); + + await userEvent.keyboard("{Shift>}"); + await userEvent.dblClick(page.getByTestId(ROW_TESTID)); + await userEvent.keyboard("{/Shift}"); + + await expect.element(page.getByTestId(TITLE_TESTID)).toBeVisible(); + expect(page.getByRole("textbox").elements()).toHaveLength(0); + }); + + it("single click routes through the navigation handler and does not start a rename", async () => { + render(); + + await userEvent.click(page.getByTestId(ROW_TESTID)); + + expect(spies.handleThreadClick).toHaveBeenCalledTimes(1); + // No rename input: the title span is still shown. + await expect.element(page.getByTestId(TITLE_TESTID)).toBeVisible(); + expect(page.getByRole("textbox").elements()).toHaveLength(0); + }); +}); diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index bdbbf6f84914..fc6cbd1c0ed4 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -11,6 +11,7 @@ import { getProjectSortTimestamp, hasUnseenCompletion, isContextMenuPointerDown, + isTrailingDoubleClick, orderItemsByPreferredIds, resolveProjectStatusIndicator, resolveSidebarNewThreadSeedContext, @@ -171,6 +172,24 @@ describe("shouldClearThreadSelectionOnMouseDown", () => { }); }); +describe("isTrailingDoubleClick", () => { + it("treats a single click as a normal activation", () => { + expect(isTrailingDoubleClick(1)).toBe(false); + }); + + it("treats synthetic/keyboard activations (detail 0) as a normal activation", () => { + expect(isTrailingDoubleClick(0)).toBe(false); + }); + + it("ignores the second click of a double-click so it does not navigate", () => { + expect(isTrailingDoubleClick(2)).toBe(true); + }); + + it("ignores further clicks of a triple-click", () => { + expect(isTrailingDoubleClick(3)).toBe(true); + }); +}); + describe("resolveSidebarNewThreadEnvMode", () => { it("uses the app default when the caller does not request a specific mode", () => { expect( diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index b9dd27dfb039..41f4e39bb73c 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -160,6 +160,15 @@ export function shouldClearThreadSelectionOnMouseDown(target: HTMLElement | null return !target.closest(THREAD_SELECTION_SAFE_SELECTOR); } +// A double-click dispatches two `click` events before `dblclick`: the first has +// `detail === 1`, the second `detail === 2`. The second click must not run the +// row's single-click navigation, otherwise double-click-to-rename would also +// navigate. `MouseEvent.detail` is 0 for synthetic/keyboard activations, which +// still count as a normal single activation. +export function isTrailingDoubleClick(detail: number): boolean { + return detail > 1; +} + export function resolveSidebarNewThreadEnvMode(input: { requestedEnvMode?: SidebarNewThreadEnvMode; defaultEnvMode: SidebarNewThreadEnvMode; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 3b33efec201e..aea59dc40b47 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -163,6 +163,7 @@ import { getSidebarThreadIdsToPrewarm, resolveAdjacentThreadId, isContextMenuPointerDown, + isTrailingDoubleClick, resolveProjectStatusIndicator, resolveSidebarNewThreadSeedContext, resolveSidebarNewThreadEnvMode, @@ -177,6 +178,7 @@ import { import { sortThreads } from "../lib/threadSort"; import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; +import { useIsMobile } from "~/hooks/useMediaQuery"; import { CommandDialogTrigger } from "./ui/command"; import RateLimitsPanel from "./RateLimitsPanel"; import { readEnvironmentApi } from "../environmentApi"; @@ -291,6 +293,7 @@ interface SidebarThreadRowProps { renamingThreadKey: string | null; renamingTitle: string; setRenamingTitle: (title: string) => void; + startThreadRename: (threadKey: string, title: string) => void; renamingInputRef: React.RefObject; renamingCommittedRef: React.RefObject; confirmingArchiveThreadKey: string | null; @@ -318,7 +321,7 @@ interface SidebarThreadRowProps { openPrLink: (event: React.MouseEvent, prUrl: string) => void; } -const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowProps) { +export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowProps) { const { orderedProjectThreadKeys, isActive, @@ -327,6 +330,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP renamingThreadKey, renamingTitle, setRenamingTitle, + startThreadRename, renamingInputRef, renamingCommittedRef, confirmingArchiveThreadKey, @@ -351,6 +355,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP environmentId: thread.environmentId, threadId: thread.id, }); + const isMobile = useIsMobile(); const primaryEnvironmentId = usePrimaryEnvironmentId(); const isRemoteThread = primaryEnvironmentId !== null && thread.environmentId !== primaryEnvironmentId; @@ -421,6 +426,24 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP }, [handleThreadClick, orderedProjectThreadKeys, threadRef], ); + const handleRowDoubleClick = useCallback( + (event: React.MouseEvent) => { + // Already renaming this row: a double-click on the row chrome (outside the + // input) must not restart and discard the in-progress edit. + if (renamingThreadKey === threadKey) return; + // On mobile the first tap navigates and closes the sidebar sheet, so the + // inline rename can't be shown. Renaming there stays on the context menu. + if (isMobile) return; + // cmd/ctrl/shift double-clicks are multi-select intent, not rename. + if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; + // Ignore double-clicks bubbling from nested controls (PR status, port, + // archive buttons) — only the row body should enter inline rename. + if ((event.target as HTMLElement).closest("button, a")) return; + event.preventDefault(); + startThreadRename(threadKey, thread.title); + }, + [isMobile, renamingThreadKey, startThreadRename, threadKey, thread.title], + ); const handleRowKeyDown = useCallback( (event: React.KeyboardEvent) => { if (event.key !== "Enter" && event.key !== " ") return; @@ -494,6 +517,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP void commitRename(threadRef, renamingTitle, thread.title); } }, [commitRename, renamingCommittedRef, renamingTitle, thread.title, threadRef]); + // Keep clicks/double-clicks inside the rename input from bubbling to the row. + // Without stopping `dblclick`, double-clicking to select a word would re-fire + // the row's rename handler and reset the in-progress edit back to the title. const handleRenameInputClick = useCallback((event: React.MouseEvent) => { event.stopPropagation(); }, []); @@ -560,6 +586,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP isSelected, })} relative isolate`} onClick={handleRowClick} + onDoubleClick={handleRowDoubleClick} onKeyDown={handleRowKeyDown} onContextMenu={handleRowContextMenu} > @@ -591,6 +618,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP onKeyDown={handleRenameInputKeyDown} onBlur={handleRenameInputBlur} onClick={handleRenameInputClick} + onDoubleClick={handleRenameInputClick} /> ) : ( @@ -753,6 +781,7 @@ interface SidebarProjectThreadListProps { renamingThreadKey: string | null; renamingTitle: string; setRenamingTitle: (title: string) => void; + startThreadRename: (threadKey: string, title: string) => void; renamingInputRef: React.RefObject; renamingCommittedRef: React.RefObject; confirmingArchiveThreadKey: string | null; @@ -803,6 +832,7 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( renamingThreadKey, renamingTitle, setRenamingTitle, + startThreadRename, renamingInputRef, renamingCommittedRef, confirmingArchiveThreadKey, @@ -854,6 +884,7 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( renamingThreadKey={renamingThreadKey} renamingTitle={renamingTitle} setRenamingTitle={setRenamingTitle} + startThreadRename={startThreadRename} renamingInputRef={renamingInputRef} renamingCommittedRef={renamingCommittedRef} confirmingArchiveThreadKey={confirmingArchiveThreadKey} @@ -1590,6 +1621,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec return; } + // Ignore the trailing click of a plain double-click so it doesn't navigate + // while a double-click is starting an inline rename. Placed after the + // modifier branches so cmd/shift selection still processes every click. + if (isTrailingDoubleClick(event.detail)) { + return; + } + if (currentSelectionCount > 0) { clearSelection(); } @@ -1784,6 +1822,12 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec renamingInputRef.current = null; }, []); + const startThreadRename = useCallback((threadKey: string, title: string) => { + setRenamingThreadKey(threadKey); + setRenamingTitle(title); + renamingCommittedRef.current = false; + }, []); + const commitRename = useCallback( async (threadRef: ScopedThreadRef, newTitle: string, originalTitle: string) => { const threadKey = scopedThreadKey(threadRef); @@ -1943,9 +1987,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec ); if (clicked === "rename") { - setRenamingThreadKey(threadKey); - setRenamingTitle(thread.title); - renamingCommittedRef.current = false; + startThreadRename(threadKey, thread.title); return; } @@ -1993,6 +2035,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec markThreadUnread, memberProjectByScopedKey, project.cwd, + startThreadRename, ], ); @@ -2115,6 +2158,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec renamingThreadKey={renamingThreadKey} renamingTitle={renamingTitle} setRenamingTitle={setRenamingTitle} + startThreadRename={startThreadRename} renamingInputRef={renamingInputRef} renamingCommittedRef={renamingCommittedRef} confirmingArchiveThreadKey={confirmingArchiveThreadKey} From ec9a03507f7b462b15ae9980c034e4b3eddd427e Mon Sep 17 00:00:00 2001 From: Andrew Forster <76947376+Andrew-Forster@users.noreply.github.com> Date: Wed, 17 Jun 2026 15:49:09 -0700 Subject: [PATCH 3/3] [codex] Make background VCS fetch non-interactive (#3133) (cherry picked from commit 75b3b3c48aa51345f712f979a670cc17f2b41b26) --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 54 ++++++++++++-------- apps/server/src/vcs/GitVcsDriverCore.ts | 4 ++ 2 files changed, 38 insertions(+), 20 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index c0e0f1876c46..173d7649bd18 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -216,7 +216,7 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }), ); - it.effect("disables SSH askpass for background upstream status fetches", () => + it.effect("makes background upstream status fetches non-interactive", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); const tempDir = yield* makeTmpDir("git-vcs-driver-ssh-env-"); @@ -225,15 +225,26 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { const pathService = yield* Path.Path; const sshLogPath = pathService.join(tempDir, "ssh-env.txt"); const sshWrapperPath = pathService.join(tempDir, "ssh-wrapper.sh"); - const previousGitSsh = process.env.GIT_SSH; - const previousAskpassRequire = process.env.SSH_ASKPASS_REQUIRE; - const previousAskpassLog = process.env.T3_TEST_SSH_ASKPASS_LOG; + const envKeys = [ + "GCM_INTERACTIVE", + "GIT_ASKPASS", + "GIT_SSH", + "GIT_TERMINAL_PROMPT", + "SSH_ASKPASS", + "SSH_ASKPASS_REQUIRE", + "T3_TEST_SSH_ASKPASS_LOG", + ] as const; + const previousEnv = new Map(envKeys.map((key) => [key, process.env[key]])); yield* fileSystem.writeFileString( sshWrapperPath, [ "#!/bin/sh", - 'printf "%s\\n" "${SSH_ASKPASS_REQUIRE:-}" > "$T3_TEST_SSH_ASKPASS_LOG"', + 'printf "GCM_INTERACTIVE=%s\\n" "${GCM_INTERACTIVE:-}" > "$T3_TEST_SSH_ASKPASS_LOG"', + 'printf "GIT_ASKPASS=%s\\n" "${GIT_ASKPASS:-}" >> "$T3_TEST_SSH_ASKPASS_LOG"', + 'printf "GIT_TERMINAL_PROMPT=%s\\n" "${GIT_TERMINAL_PROMPT:-}" >> "$T3_TEST_SSH_ASKPASS_LOG"', + 'printf "SSH_ASKPASS=%s\\n" "${SSH_ASKPASS:-}" >> "$T3_TEST_SSH_ASKPASS_LOG"', + 'printf "SSH_ASKPASS_REQUIRE=%s\\n" "${SSH_ASKPASS_REQUIRE:-}" >> "$T3_TEST_SSH_ASKPASS_LOG"', "exit 1", "", ].join("\n"), @@ -245,29 +256,32 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { yield* Effect.gen(function* () { process.env.GIT_SSH = sshWrapperPath; + process.env.GCM_INTERACTIVE = "always"; + process.env.GIT_ASKPASS = "git-askpass"; + process.env.GIT_TERMINAL_PROMPT = "1"; + process.env.SSH_ASKPASS = "ssh-askpass"; process.env.SSH_ASKPASS_REQUIRE = "force"; process.env.T3_TEST_SSH_ASKPASS_LOG = sshLogPath; yield* (yield* GitVcsDriver.GitVcsDriver).statusDetails(cwd); - assert.equal((yield* fileSystem.readFileString(sshLogPath)).trim(), "never"); + assert.deepEqual((yield* fileSystem.readFileString(sshLogPath)).trim().split(/\r?\n/), [ + "GCM_INTERACTIVE=never", + "GIT_ASKPASS=", + "GIT_TERMINAL_PROMPT=0", + "SSH_ASKPASS=", + "SSH_ASKPASS_REQUIRE=never", + ]); }).pipe( Effect.ensuring( Effect.sync(() => { - if (previousGitSsh === undefined) { - delete process.env.GIT_SSH; - } else { - process.env.GIT_SSH = previousGitSsh; - } - if (previousAskpassRequire === undefined) { - delete process.env.SSH_ASKPASS_REQUIRE; - } else { - process.env.SSH_ASKPASS_REQUIRE = previousAskpassRequire; - } - if (previousAskpassLog === undefined) { - delete process.env.T3_TEST_SSH_ASKPASS_LOG; - } else { - process.env.T3_TEST_SSH_ASKPASS_LOG = previousAskpassLog; + for (const key of envKeys) { + const previous = previousEnv.get(key); + if (previous === undefined) { + delete process.env[key]; + } else { + process.env[key] = previous; + } } }), ), diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index ffa688d254c7..fe1877e62002 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -55,6 +55,10 @@ const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5); const STATUS_UPSTREAM_REFRESH_FAILURE_COOLDOWN = Duration.seconds(5); const STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY = 2_048; const STATUS_UPSTREAM_REFRESH_ENV = Object.freeze({ + GCM_INTERACTIVE: "never", + GIT_ASKPASS: "", + GIT_TERMINAL_PROMPT: "0", + SSH_ASKPASS: "", SSH_ASKPASS_REQUIRE: "never", } satisfies NodeJS.ProcessEnv); const DEFAULT_BASE_BRANCH_CANDIDATES = ["main", "master"] as const;