From 3450b0b878d9daf57ca058070eb797e9a03e15b4 Mon Sep 17 00:00:00 2001 From: Atharvsinh-codez Date: Fri, 16 Oct 2026 06:25:57 +0000 Subject: [PATCH 1/5] fix(server): changed files keep their real paths for renames and non-ASCII names Status read `git status --porcelain=2` and `git diff-index --numstat` in their display form. Numstat writes a rename inside a folder as `src/{old.ts => new.ts}`, which became `new.ts}`, and porcelain quotes names with non-ASCII bytes, which the whitespace split turned into `notes.md"`. Committing a selection that included either path failed with "pathspec did not match". Read both commands with -z so paths arrive verbatim, reuse the existing NUL numstat parser, and list a rename's original path so its deletion can still be selected. --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 29 ++++- apps/server/src/vcs/GitVcsDriverCore.ts | 106 +++++++------------ 2 files changed, 66 insertions(+), 69 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index ea3f4bb2c1af..008eff4a1666 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -332,7 +332,7 @@ it.effect("uses stable diagnostics for every parsed non-repository command", () assert.deepStrictEqual(commands, [ { args: ["rev-parse", "--git-path", "index"], lcAll: "C" }, - { args: ["status", "--porcelain=2", "--branch"], lcAll: "C" }, + { args: ["status", "--porcelain=2", "--branch", "-z"], lcAll: "C" }, { args: ["rev-parse", "--abbrev-ref", "HEAD"], lcAll: "C" }, { args: ["rev-parse", "--git-common-dir"], lcAll: "C" }, ]); @@ -2445,6 +2445,33 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); + it.effect("reports renamed and non-ASCII files by paths that can be committed", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + yield* writeTextFile(cwd, "src/old.ts", "one\ntwo\nthree\n"); + yield* writeTextFile(cwd, "café notes.md", "draft\n"); + yield* git(cwd, ["add", "."]); + yield* git(cwd, ["commit", "-m", "add files"]); + yield* git(cwd, ["mv", "src/old.ts", "src/new.ts"]); + yield* writeTextFile(cwd, "src/new.ts", "one\ntwo\nthree\nfour\n"); + yield* writeTextFile(cwd, "café notes.md", "draft\nedit\n"); + const driver = yield* GitVcsDriver.GitVcsDriver; + + const status = yield* driver.statusDetails(cwd); + + assert.deepEqual(status.workingTree.files, [ + { path: "café notes.md", insertions: 1, deletions: 0 }, + { path: "src/new.ts", insertions: 1, deletions: 0 }, + { path: "src/old.ts", insertions: 0, deletions: 0 }, + ]); + yield* driver.commit(cwd, "Commit selection", "", { + stage: { filePaths: status.workingTree.files.map((file) => file.path) }, + }); + assert.equal(yield* git(cwd, ["status", "--porcelain"]), ""); + }), + ); + it.effect("reports default-branch delta separately from upstream delta", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index db56c68b48eb..6bc1d786de30 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -184,30 +184,6 @@ function parseBranchAb(value: string): { ahead: number; behind: number } { }; } -function parseNumstatEntries( - stdout: string, -): Array<{ path: string; insertions: number; deletions: number }> { - const entries: Array<{ path: string; insertions: number; deletions: number }> = []; - for (const line of stdout.split(/\r?\n/g)) { - if (line.trim().length === 0) continue; - const [addedRaw, deletedRaw, ...pathParts] = line.split("\t"); - const rawPath = - pathParts.length > 1 ? (pathParts.at(-1) ?? "").trim() : pathParts.join("\t").trim(); - if (rawPath.length === 0) continue; - const added = Number.parseInt(addedRaw ?? "0", 10); - const deleted = Number.parseInt(deletedRaw ?? "0", 10); - const renameArrowIndex = rawPath.indexOf(" => "); - const normalizedPath = - renameArrowIndex >= 0 ? rawPath.slice(renameArrowIndex + " => ".length).trim() : rawPath; - entries.push({ - path: normalizedPath.length > 0 ? normalizedPath : rawPath, - insertions: Number.isFinite(added) ? added : 0, - deletions: Number.isFinite(deleted) ? deleted : 0, - }); - } - return entries; -} - function parseReviewNumstat(stdout: string): ReviewDiffFileStat[] { const fields = stdout.split("\0"); const files: ReviewDiffFileStat[] = []; @@ -227,25 +203,19 @@ function parseReviewNumstat(stdout: string): ReviewDiffFileStat[] { return files; } -function parsePorcelainPath(line: string): string | null { - if (line.startsWith("? ") || line.startsWith("! ")) { - const simple = line.slice(2).trim(); - return simple.length > 0 ? simple : null; - } - - if (!(line.startsWith("1 ") || line.startsWith("2 ") || line.startsWith("u "))) { - return null; - } - - const tabIndex = line.indexOf("\t"); - if (tabIndex >= 0) { - const fromTab = line.slice(tabIndex + 1); - const [filePath] = fromTab.split("\t"); - return filePath?.trim().length ? filePath.trim() : null; - } - - const parts = line.trim().split(/\s+/g); - const filePath = parts.at(-1) ?? ""; +// The path may contain spaces, so it is everything after the record type's fixed fields. +const PORCELAIN_V2_FIELDS_BEFORE_PATH = new Map([ + ["1", 8], + ["2", 9], + ["u", 10], + ["?", 1], + ["!", 1], +]); + +function parsePorcelainPath(record: string): string | null { + const fieldsBeforePath = PORCELAIN_V2_FIELDS_BEFORE_PATH.get(record.split(" ", 1)[0] ?? ""); + if (fieldsBeforePath === undefined) return null; + const filePath = record.split(" ").slice(fieldsBeforePath).join(" "); return filePath.length > 0 ? filePath : null; } @@ -1852,6 +1822,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* "status", "--porcelain=2", "--branch", + "-z", ...(includeDivergence ? [] : ["--no-ahead-behind"]), ]; const indexResult = yield* executeGitWithStableDiagnostics( @@ -1929,48 +1900,37 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* Effect.catchTags({ GitCommandError: () => Effect.succeed(null) }), ); const statusCacheKey = repositoryPaths?.gitCommonDir; - const [numstatStdout, defaultBranch, hasPrimaryRemote] = yield* Effect.all( + const [numstatEntries, defaultBranch, hasPrimaryRemote] = yield* Effect.all( [ // Plumbing, because porcelain `git diff` rewrites the index even with // GIT_OPTIONAL_LOCKS=0. -M keeps porcelain's rename detection. executeGitWithStableDiagnostics( "GitVcsDriver.statusDetails.numstat", cwd, - ["diff-index", "-M", "--numstat", "HEAD", "--"], + ["diff-index", "-M", "--numstat", "-z", "HEAD", "--"], { allowNonZeroExit: true }, ).pipe( Effect.flatMap((result) => { - if (result.exitCode === 0) return Effect.succeed(result.stdout); + if (result.exitCode === 0) return Effect.succeed(parseReviewNumstat(result.stdout)); if (isUnbornHeadStderr(result.stderr)) { return Effect.map( Effect.all([ runGitStdout("GitVcsDriver.statusDetails.numstat.unborn", cwd, [ "diff-files", "--numstat", + "-z", ]), runGitStdout("GitVcsDriver.statusDetails.numstat.unborn.staged", cwd, [ "diff", "--cached", "--numstat", + "-z", ]), ]), - ([unstagedStdout, stagedStdout]) => { - const staged = parseNumstatEntries(stagedStdout); - const unstaged = parseNumstatEntries(unstagedStdout); - const map = new Map(); - for (const entry of [...staged, ...unstaged]) { - const existing = map.get(entry.path) ?? { - insertions: 0, - deletions: 0, - }; - existing.insertions += entry.insertions; - existing.deletions += entry.deletions; - map.set(entry.path, existing); - } - return Array.from(map.entries()) - .map(([p, s]) => `${s.insertions}\t${s.deletions}\t${p}`) - .join("\n"); - }, + ([unstagedStdout, stagedStdout]) => [ + ...parseReviewNumstat(stagedStdout), + ...parseReviewNumstat(unstagedStdout), + ], ); } return Effect.fail( @@ -1978,7 +1938,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ...gitCommandContext({ operation: "GitVcsDriver.statusDetails.numstat", cwd, - args: ["diff-index", "-M", "--numstat", "HEAD", "--"], + args: ["diff-index", "-M", "--numstat", "-z", "HEAD", "--"], }), detail: "git diff-index HEAD --numstat failed.", exitCode: result.exitCode, @@ -2007,7 +1967,9 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* let hasWorkingTreeChanges = false; const changedFilesWithoutNumstat = new Set(); - for (const line of statusStdout.split(/\r?\n/g)) { + const statusRecords = statusStdout.split("\0"); + for (let index = 0; index < statusRecords.length; index++) { + const line = statusRecords[index]!; if (line.startsWith("# branch.head ")) { const value = line.slice("# branch.head ".length).trim(); refName = value.startsWith("(") ? null : value; @@ -2025,10 +1987,16 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* behindCount = parsed.behind; continue; } - if (line.trim().length > 0 && !line.startsWith("#")) { + if (line.length > 0 && !line.startsWith("#")) { hasWorkingTreeChanges = true; const pathValue = parsePorcelainPath(line); if (pathValue) changedFilesWithoutNumstat.add(pathValue); + // A rename record is followed by its original path as a separate record. Listing it + // keeps the deletion selectable when committing only some files. + if (line.startsWith("2 ")) { + const originalPath = statusRecords[++index]; + if (originalPath) changedFilesWithoutNumstat.add(originalPath); + } } } @@ -2053,10 +2021,12 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* : yield* computeAheadCountAgainstBase(cwd, refName).pipe(Effect.orElseSucceed(() => 0)); } - const numstatEntries = parseNumstatEntries(numstatStdout); const fileStatMap = new Map(); for (const entry of numstatEntries) { - fileStatMap.set(entry.path, { insertions: entry.insertions, deletions: entry.deletions }); + const stat = fileStatMap.get(entry.path) ?? { insertions: 0, deletions: 0 }; + stat.insertions += entry.additions; + stat.deletions += entry.deletions; + fileStatMap.set(entry.path, stat); } let insertions = 0; From f21194c121b09ebd8d59bac5ff973b7340be0740 Mon Sep 17 00:00:00 2001 From: Atharvsinh-codez Date: Fri, 16 Oct 2026 12:37:13 +0000 Subject: [PATCH 2/5] fix(server): stage selected files from the repository root With -z, status reports paths relative to the repository root, but selected files were staged relative to cwd. In a project that is a subdirectory of its repository, committing only some files resolved `nested/new.txt` to `nested/nested/new.txt` and failed. Anchor the pathspecs at the top, the same way checkpoint diffs already do. --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 27 ++++++++++++++++++++ apps/server/src/vcs/GitVcsDriverCore.ts | 3 ++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 008eff4a1666..d1354f145bd7 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -2472,6 +2472,33 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); + it.effect("commits selected files from a nested project directory", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + yield* writeTextFile(cwd, "nested/tracked.txt", "one\n"); + yield* git(cwd, ["add", "."]); + yield* git(cwd, ["commit", "-m", "add nested file"]); + yield* writeTextFile(cwd, "nested/tracked.txt", "one\ntwo\n"); + yield* writeTextFile(cwd, "nested/new.txt", "new\n"); + yield* writeTextFile(cwd, "README.md", "# test\nleft out\n"); + const nestedCwd = (yield* Path.Path).join(cwd, "nested"); + const driver = yield* GitVcsDriver.GitVcsDriver; + + const status = yield* driver.statusDetails(nestedCwd); + + assert.deepEqual(status.workingTree.files, [ + { path: "nested/new.txt", insertions: 0, deletions: 0 }, + { path: "nested/tracked.txt", insertions: 1, deletions: 0 }, + { path: "README.md", insertions: 1, deletions: 0 }, + ]); + yield* driver.commit(nestedCwd, "Commit nested selection", "", { + stage: { filePaths: ["nested/new.txt", "nested/tracked.txt"] }, + }); + assert.equal(yield* git(cwd, ["status", "--porcelain"]), "M README.md"); + }), + ); + it.effect("reports default-branch delta separately from upstream delta", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 6bc1d786de30..03d8a71e975c 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -2152,7 +2152,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* yield* runGit( "GitVcsDriver.commit.addSelected", cwd, - [...config, "--literal-pathspecs", "add", "-A", "--", ...filePaths], + // Status paths are repository-relative, while cwd can be a subdirectory. + [...config, "add", "-A", "--", ...filePaths.map((file) => `:(top,literal)${file}`)], env ? { env } : {}, ); } else { From 3a6fd158799119839ef6fdaff2e21f1bdfa46f33 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:41:59 -0700 Subject: [PATCH 3/5] refactor(server): simplify porcelain record lookup --- apps/server/src/vcs/GitVcsDriverCore.ts | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 42ca66e2e428..43d239bd2a94 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -204,17 +204,18 @@ function parseReviewNumstat(stdout: string): ReviewDiffFileStat[] { return files; } -// The path may contain spaces, so it is everything after the record type's fixed fields. -const PORCELAIN_V2_FIELDS_BEFORE_PATH = new Map([ - ["1", 8], - ["2", 9], - ["u", 10], - ["?", 1], - ["!", 1], -]); +// Space-separated fields precede the path in each porcelain v2 record type. The path itself +// may contain spaces, so it is everything after them. +const PORCELAIN_V2_FIELDS_BEFORE_PATH: Record = { + "1": 8, + "2": 9, + u: 10, + "?": 1, + "!": 1, +}; function parsePorcelainPath(record: string): string | null { - const fieldsBeforePath = PORCELAIN_V2_FIELDS_BEFORE_PATH.get(record.split(" ", 1)[0] ?? ""); + const fieldsBeforePath = PORCELAIN_V2_FIELDS_BEFORE_PATH[record[0] ?? ""]; if (fieldsBeforePath === undefined) return null; const filePath = record.split(" ").slice(fieldsBeforePath).join(" "); return filePath.length > 0 ? filePath : null; From f7de77f24eec7fc890aaa70eb39a2846b1413019 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Sun, 18 Oct 2026 22:18:51 -0700 Subject: [PATCH 4/5] fix(web): open status files from the repository root --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 32 ++++++ apps/server/src/vcs/GitVcsDriverCore.ts | 1 + .../GitActionsControl.browser.test.tsx | 99 ++++++++++++++++--- apps/web/src/components/GitActionsControl.tsx | 9 +- packages/contracts/src/git.ts | 3 + 5 files changed, 125 insertions(+), 19 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index c582cb094d91..fff203b7a0b1 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -2690,6 +2690,11 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { { path: "nested/tracked.txt", insertions: 1, deletions: 0 }, { path: "README.md", insertions: 1, deletions: 0 }, ]); + // The root is what the paths above are relative to, not the nested cwd. + assert.equal( + NodeFS.realpathSync.native(status.workingTree.rootPath!), + NodeFS.realpathSync.native(cwd), + ); yield* driver.commit(nestedCwd, "Commit nested selection", "", { stage: { filePaths: ["nested/new.txt", "nested/tracked.txt"] }, }); @@ -2697,6 +2702,33 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); + it.effect("reports the linked worktree as the root of a nested directory in it", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const worktreePath = yield* makeTmpDir("git-vcs-driver-linked-"); + yield* initRepoWithCommit(cwd); + yield* writeTextFile(cwd, "nested/tracked.txt", "one\n"); + yield* git(cwd, ["add", "."]); + yield* git(cwd, ["commit", "-m", "add nested file"]); + yield* git(cwd, ["worktree", "add", "-b", "feature/linked", worktreePath]); + yield* writeTextFile(worktreePath, "nested/tracked.txt", "one\ntwo\n"); + yield* writeTextFile(worktreePath, "nested/new.txt", "new\n"); + const nestedCwd = (yield* Path.Path).join(worktreePath, "nested"); + const driver = yield* GitVcsDriver.GitVcsDriver; + + const status = yield* driver.statusDetails(nestedCwd); + + assert.deepEqual(status.workingTree.files, [ + { path: "nested/new.txt", insertions: 0, deletions: 0 }, + { path: "nested/tracked.txt", insertions: 1, deletions: 0 }, + ]); + assert.equal( + NodeFS.realpathSync.native(status.workingTree.rootPath!), + NodeFS.realpathSync.native(worktreePath), + ); + }), + ); + it.effect("reports default-branch delta separately from upstream delta", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 43d239bd2a94..7c504c35d893 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -2099,6 +2099,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* upstreamRef, hasWorkingTreeChanges, workingTree: { + ...(repositoryPaths?.worktreeRoot ? { rootPath: repositoryPaths.worktreeRoot } : {}), files, insertions, deletions, diff --git a/apps/web/src/components/GitActionsControl.browser.test.tsx b/apps/web/src/components/GitActionsControl.browser.test.tsx index e4641b21700b..5904d57f54df 100644 --- a/apps/web/src/components/GitActionsControl.browser.test.tsx +++ b/apps/web/src/components/GitActionsControl.browser.test.tsx @@ -29,6 +29,9 @@ const state = vi.hoisted(() => ({ metadataRequests: [] as { environmentId: string; branch: string }[], toasts: [] as Toast[], afterGitAction: undefined as (() => void) | undefined, + workingTreeRoot: undefined as string | undefined, + files: [{ path: "file.ts", insertions: 1, deletions: 0 }], + editorOpens: [] as { environmentId: string; input: { cwd: string; editor: string } }[], })); vi.mock(import("~/state/session"), async (original) => { @@ -61,7 +64,8 @@ vi.mock(import("~/state/query"), async (original) => ({ hasPrimaryRemote: true, hasWorkingTreeChanges: true, workingTree: { - files: [{ path: "file.ts", insertions: 1, deletions: 0 }], + ...(state.workingTreeRoot === undefined ? {} : { rootPath: state.workingTreeRoot }), + files: state.files, insertions: 1, deletions: 0, }, @@ -73,21 +77,45 @@ vi.mock(import("~/state/query"), async (original) => ({ error: null, })) as never, })); -vi.mock(import("~/state/use-atom-command"), async (original) => ({ - ...(await original()), - // The only command whose result matters here is the thread's branch metadata update. - useAtomCommand: ((_command: unknown, options: unknown) => - options === "thread branch metadata update" - ? async ({ environmentId, input }: { environmentId: string; input: { branch: string } }) => { - state.metadataRequests.push({ environmentId, branch: input.branch }); - if (!state.scopes.has(AuthOrchestrationOperateScope)) - return AsyncResult.failure(Cause.fail(new Error("Task denied"))); - if (state.shell) state.shell = { branch: input.branch }; - if (state.detail) state.detail = { branch: input.branch }; - return AsyncResult.success(undefined); - } - : async () => AsyncResult.success(undefined)) as never, -})); +vi.mock(import("~/state/use-atom-command"), async (original) => { + const { shellEnvironment } = await import("~/state/shell"); + return { + ...(await original()), + // Only the thread's branch metadata update and editor opens are observed here. + useAtomCommand: ((command: unknown, options: unknown) => + options === "thread branch metadata update" + ? async ({ + environmentId, + input, + }: { + environmentId: string; + input: { branch: string }; + }) => { + state.metadataRequests.push({ environmentId, branch: input.branch }); + if (!state.scopes.has(AuthOrchestrationOperateScope)) + return AsyncResult.failure(Cause.fail(new Error("Task denied"))); + if (state.shell) state.shell = { branch: input.branch }; + if (state.detail) state.detail = { branch: input.branch }; + return AsyncResult.success(undefined); + } + : command === shellEnvironment.openInEditor + ? async (request: (typeof state.editorOpens)[number]) => { + state.editorOpens.push(request); + return AsyncResult.success(undefined); + } + : async () => AsyncResult.success(undefined)) as never, + }; +}); +// The preferred-editor flow needs one editor the environment reports as installed. +vi.mock(import("~/state/server"), async (original) => { + const actual = await original(); + const { Atom } = await import("effect/reactivity"); + const config = Atom.make(() => ({ availableEditors: ["cursor"] })); + return { + ...actual, + serverEnvironment: { ...actual.serverEnvironment, configValueAtom: (() => config) as never }, + }; +}); vi.mock(import("~/lib/sourceControlActions"), async (original) => ({ ...(await original()), useSourceControlActionRunning: () => false, @@ -180,6 +208,9 @@ beforeEach(() => { state.metadataRequests = []; state.toasts = []; state.afterGitAction = undefined; + state.workingTreeRoot = undefined; + state.files = [{ path: "file.ts", insertions: 1, deletions: 0 }]; + state.editorOpens = []; useComposerDraftStore.setState({ getDraftSession: () => state.draft as never, getDraftThreadByRef: () => state.draft as never, @@ -312,6 +343,42 @@ describe("Git actions while thread details load", () => { }); }); +describe("Changed-file editor targets", () => { + beforeEach(() => { + state.scopes.add(AuthOrchestrationOperateScope); + state.files = [ + { path: "nested/new.txt", insertions: 0, deletions: 0 }, + { path: "nested/tracked.txt", insertions: 1, deletions: 0 }, + ]; + }); + + it("resolves repository-relative paths against the status root, not the project cwd", async () => { + state.workingTreeRoot = "/repo"; + await render("thread", "/repo/nested"); + await openCommitDialog(); + + await userEvent.click(button("Open nested/new.txt in editor")); + await userEvent.click(button("Open nested/tracked.txt in editor")); + + await vi.waitFor(() => expect(state.editorOpens).toHaveLength(2)); + expect(state.editorOpens).toEqual([ + { environmentId: "environment", input: { cwd: "/repo/nested/new.txt", editor: "cursor" } }, + { + environmentId: "environment", + input: { cwd: "/repo/nested/tracked.txt", editor: "cursor" }, + }, + ]); + }); + + it("does not guess a file location when the server reports no root", async () => { + await render("thread", "/repo/nested"); + await openCommitDialog(); + + await expect.element(button("Open nested/new.txt in editor")).toBeDisabled(); + expect(state.editorOpens).toEqual([]); + }); +}); + describe("Default branch prompt", () => { beforeEach(() => { state.isDefaultRef = true; diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 85b16e67f3e6..ad6b4ff12cf9 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -1091,6 +1091,9 @@ export default function GitActionsControl({ const changesTotals = gitStatusForActions?.branchChanges ?? gitStatusForActions?.workingTree; const allFiles = gitStatusForActions?.workingTree.files ?? []; + // Changed-file paths are repository-relative, so they must resolve against + // the worktree root from the same status snapshot, not the project cwd. + const workingTreeRoot = gitStatusForActions?.workingTree.rootPath ?? null; const selectedFiles = allFiles.filter((f) => !excludedFiles.has(f.path)); const allSelected = excludedFiles.size === 0; const noneSelected = selectedFiles.length === 0; @@ -1498,7 +1501,7 @@ export default function GitActionsControl({ }; const openChangedFileInEditor = (filePath: string) => { - if (!gitCwd) { + if (workingTreeRoot === null) { toastManager.add({ type: "error", title: "Editor opening is unavailable.", @@ -1506,7 +1509,7 @@ export default function GitActionsControl({ }); return; } - const target = resolvePathLinkTarget(filePath, gitCwd); + const target = resolvePathLinkTarget(filePath, workingTreeRoot); void (async () => { const result = await openInPreferredEditor(target); if (result._tag === "Success" || isAtomCommandInterrupted(result)) { @@ -1944,7 +1947,7 @@ export default function GitActionsControl({