diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 1c7727ba7..af957616a 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,6 +1,9 @@ # Changelog ## [Unreleased] +### Fixed + +- Canonicalized bash executor working directories before handing them to brush so `pwd` stays aligned with canonical Git worktree paths in symlinked workspaces ### Added @@ -5789,4 +5792,4 @@ Initial public release. - Git branch display in footer - Message queueing during streaming responses - OAuth integration for Gmail and Google Calendar access -- HTML export with syntax highlighting and collapsible sections \ No newline at end of file +- HTML export with syntax highlighting and collapsible sections diff --git a/packages/coding-agent/src/exec/bash-executor.ts b/packages/coding-agent/src/exec/bash-executor.ts index 159e3e4cc..2c5633e25 100644 --- a/packages/coding-agent/src/exec/bash-executor.ts +++ b/packages/coding-agent/src/exec/bash-executor.ts @@ -3,6 +3,7 @@ * * Uses brush-core via native bindings for shell execution. */ +import * as fs from "node:fs/promises"; import { Shell } from "@oh-my-pi/pi-natives"; import { Settings } from "../config/settings"; import { OutputSink } from "../session/streaming-output"; @@ -39,10 +40,23 @@ const HARD_TIMEOUT_GRACE_MS = 5_000; const shellSessions = new Map(); +async function resolveShellCwd(cwd: string | undefined): Promise { + if (!cwd) return undefined; + + try { + // Brush preserves the working directory string verbatim, so resolve symlinks + // up front to keep `pwd` aligned with tools like `git worktree list`. + return await fs.realpath(cwd); + } catch { + return cwd; + } +} + export async function executeBash(command: string, options?: BashExecutorOptions): Promise { const settings = await Settings.init(); const { shell, env: shellEnv, prefix } = settings.getShellConfig(); const snapshotPath = shell.includes("bash") ? await getOrCreateSnapshot(shell, shellEnv) : null; + const commandCwd = await resolveShellCwd(options?.cwd); // Apply command prefix if configured const prefixedCommand = prefix ? `${prefix} ${command}` : command; @@ -97,7 +111,7 @@ export async function executeBash(command: string, options?: BashExecutorOptions const runPromise = shellSession.run( { command: finalCommand, - cwd: options?.cwd, + cwd: commandCwd, env: options?.env ? { ...NON_INTERACTIVE_ENV, ...options.env } : NON_INTERACTIVE_ENV, timeoutMs: options?.timeout, signal, diff --git a/packages/coding-agent/src/modes/prompt-action-autocomplete.ts b/packages/coding-agent/src/modes/prompt-action-autocomplete.ts index 2db64edf6..850c0f777 100644 --- a/packages/coding-agent/src/modes/prompt-action-autocomplete.ts +++ b/packages/coding-agent/src/modes/prompt-action-autocomplete.ts @@ -1,8 +1,8 @@ import { - CombinedAutocompleteProvider, - getEditorKeybindings, type AutocompleteItem, type AutocompleteProvider, + CombinedAutocompleteProvider, + getEditorKeybindings, type SlashCommand, } from "@oh-my-pi/pi-tui"; import { formatKeyHints, type KeybindingsManager } from "../config/keybindings"; diff --git a/packages/coding-agent/test/bash-executor.test.ts b/packages/coding-agent/test/bash-executor.test.ts index 538988c09..6ce40c571 100644 --- a/packages/coding-agent/test/bash-executor.test.ts +++ b/packages/coding-agent/test/bash-executor.test.ts @@ -39,6 +39,20 @@ describe("executeBash", () => { expect(result.output.trim()).toBe(tempDir); }); + it("canonicalizes symlinked cwd before execution", async () => { + if (process.platform === "win32") { + return; + } + + const realDir = path.join(tempDir, "real"); + const linkDir = path.join(tempDir, "link"); + fs.mkdirSync(realDir); + fs.symlinkSync(realDir, linkDir, "dir"); + + const result = await executeBash("pwd", { cwd: linkDir, timeout: 5000 }); + expect(result.output.trim()).toBe(fs.realpathSync(linkDir)); + }); + it("passes env vars", async () => { const result = await executeBash("echo $PI_TEST_ENV", { cwd: tempDir, diff --git a/packages/coding-agent/test/session-manager/signature-persistence.test.ts b/packages/coding-agent/test/session-manager/signature-persistence.test.ts index 391d05869..be5193265 100644 --- a/packages/coding-agent/test/session-manager/signature-persistence.test.ts +++ b/packages/coding-agent/test/session-manager/signature-persistence.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "bun:test"; -import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import type { AssistantMessage } from "@oh-my-pi/pi-ai"; +import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import { TempDir } from "@oh-my-pi/pi-utils"; describe("SessionManager signature persistence", () => { @@ -33,8 +33,11 @@ describe("SessionManager signature persistence", () => { await session.flush(); const reloaded = await SessionManager.open(session.getSessionFile()!); - const assistant = reloaded.getEntries().find(entry => entry.type === "message" && entry.message.role === "assistant") - ?.message as AssistantMessage | undefined; + const assistant = reloaded + .getEntries() + .find(entry => entry.type === "message" && entry.message.role === "assistant")?.message as + | AssistantMessage + | undefined; expect(assistant).toBeDefined(); expect(assistant?.content[0]).toMatchObject({ type: "thinking", thinking: "reasoning", thinkingSignature: "" });