diff --git a/packages/cli/src/context.ts b/packages/cli/src/context.ts index 651376b5c..59aaad3ff 100644 --- a/packages/cli/src/context.ts +++ b/packages/cli/src/context.ts @@ -6,11 +6,15 @@ */ import { homedir } from "node:os"; +import { isAbsolute } from "node:path"; import type { CommandContext } from "@stricli/core"; import { getConfigDir } from "./lib/db/index.js"; +import { logger } from "./lib/logger.js"; import { type Span, setCommandSpanName } from "./lib/telemetry.js"; import type { Writer } from "./types/index.js"; +const log = logger.withTag("context"); + export interface SentryContext extends CommandContext { readonly process: NodeJS.Process; readonly env: NodeJS.ProcessEnv; @@ -30,6 +34,28 @@ export interface SentryContext extends CommandContext { readonly commandPrefix?: readonly string[]; } +/** + * Resolve the working directory, tolerating one that has been deleted. + * + * `process.cwd()` throws ENOENT (`uv_cwd`) once the directory the CLI was + * started in is removed (e.g. an agent's git worktree cleaned up under it). + * The shell's logical `PWD` still names that directory, so use it instead: + * lookups under the missing path find nothing, and commands that don't need + * local files keep working. + */ +function resolveCwd(process: NodeJS.Process): string { + try { + return process.cwd(); + } catch (error) { + const pwd = process.env.PWD; + if (!(pwd && isAbsolute(pwd))) { + throw error; + } + log.debug(`Working directory is unavailable, using PWD (${pwd})`, error); + return pwd; + } +} + /** * Build a dynamic context that uses forCommand to set telemetry tags. * @@ -43,7 +69,7 @@ export function buildContext(process: NodeJS.Process, span?: Span) { const baseContext: SentryContext = { process, env: process.env, - cwd: process.cwd(), + cwd: resolveCwd(process), homeDir: homedir(), configDir: getConfigDir(), stdout: process.stdout, diff --git a/packages/cli/test/lib/context.test.ts b/packages/cli/test/lib/context.test.ts new file mode 100644 index 000000000..2baf37202 --- /dev/null +++ b/packages/cli/test/lib/context.test.ts @@ -0,0 +1,46 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; +import { buildContext } from "../../src/context.js"; + +/** Mirrors the error Node throws when the working directory was removed. */ +function deletedCwdError(): NodeJS.ErrnoException { + return Object.assign( + new Error( + "ENOENT: process.cwd failed with error no such file or directory, the current working directory was likely removed without changing the working directory, uv_cwd" + ), + { code: "ENOENT", errno: -2, syscall: "uv_cwd" } + ); +} + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); +}); + +describe("buildContext cwd", () => { + test("falls back to PWD when the working directory was deleted", () => { + vi.spyOn(process, "cwd").mockImplementation(() => { + throw deletedCwdError(); + }); + vi.stubEnv("PWD", "/tmp/removed-worktree"); + + const context = buildContext(process); + + expect(context.cwd).toBe("/tmp/removed-worktree"); + expect(context.forCommand({ prefix: ["sentry"] }).cwd).toBe( + "/tmp/removed-worktree" + ); + }); + + test("rethrows when PWD cannot stand in for the working directory", () => { + const error = deletedCwdError(); + vi.spyOn(process, "cwd").mockImplementation(() => { + throw error; + }); + + vi.stubEnv("PWD", ""); + expect(() => buildContext(process)).toThrow(error); + + vi.stubEnv("PWD", "relative/dir"); + expect(() => buildContext(process)).toThrow(error); + }); +});