From 804335bc00bff52f2652f5de9e6227f013322948 Mon Sep 17 00:00:00 2001 From: Elowen <73627805+Mar-garet@users.noreply.github.com> Date: Sun, 11 Oct 2026 19:53:25 +0800 Subject: [PATCH] fix(files): rename untracked bracket paths without matching siblings (#6573) * fix(files): use literal paths when checking rename tracking * fix(files): preserve Git activity labels for rename lookups * fix(files): clear inherited literal mode for rename lookups * test(files): cover tracked renames with inherited literal mode --- .../src/server/file-explorer/service.test.ts | 127 +++++++++++++++++- .../src/server/file-explorer/service.ts | 6 +- 2 files changed, 130 insertions(+), 3 deletions(-) diff --git a/packages/server/src/server/file-explorer/service.test.ts b/packages/server/src/server/file-explorer/service.test.ts index ea22ac24b9..a82e1a8f80 100644 --- a/packages/server/src/server/file-explorer/service.test.ts +++ b/packages/server/src/server/file-explorer/service.test.ts @@ -12,8 +12,8 @@ import { } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { describe, expect, it } from "vitest"; -import { runGitCommand } from "../../utils/run-git-command.js"; +import { describe, expect, it, vi } from "vitest"; +import { runGitCommand, snapshotGitCommandRuntimeMetrics } from "../../utils/run-git-command.js"; import { createExplorerEntry, deleteExplorerEntry, @@ -517,6 +517,129 @@ describe("file explorer service", () => { } }); + it("renames an untracked bracketed filename without matching a tracked sibling", async () => { + const root = await createTempDir("paseo-entry-literal-rename-"); + try { + await runGitCommand(["init"], { cwd: root }); + await writeFile(path.join(root, "i.tsx"), "tracked\n", "utf8"); + await runGitCommand(["add", "i.tsx"], { cwd: root }); + await writeFile(path.join(root, "[id].tsx"), "untracked\n", "utf8"); + + snapshotGitCommandRuntimeMetrics(); + const result = await renameExplorerEntry({ + root, + relativePath: "[id].tsx", + name: "renamed.tsx", + }); + + expect(result).toEqual({ status: "ok", path: "renamed.tsx" }); + expect(snapshotGitCommandRuntimeMetrics().operationsTop).toEqual([ + ["ls-files", 1], + ["rev-parse", 1], + ]); + expect(await readFile(path.join(root, "renamed.tsx"), "utf8")).toBe("untracked\n"); + await expect(stat(path.join(root, "[id].tsx"))).rejects.toMatchObject({ code: "ENOENT" }); + expect(await readFile(path.join(root, "i.tsx"), "utf8")).toBe("tracked\n"); + expect((await runGitCommand(["ls-files", "-z"], { cwd: root })).stdout).toBe("i.tsx\0"); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("renames an untracked bracketed folder without matching a tracked sibling", async () => { + const root = await createTempDir("paseo-folder-literal-rename-"); + try { + await runGitCommand(["init"], { cwd: root }); + await mkdir(path.join(root, "i")); + await writeFile(path.join(root, "i", "route.tsx"), "tracked\n", "utf8"); + await runGitCommand(["add", "i"], { cwd: root }); + await mkdir(path.join(root, "[id]")); + await writeFile(path.join(root, "[id]", "route.tsx"), "untracked\n", "utf8"); + + const result = await renameExplorerEntry({ root, relativePath: "[id]", name: "route" }); + + expect(result).toEqual({ status: "ok", path: "route" }); + expect(await readFile(path.join(root, "route", "route.tsx"), "utf8")).toBe("untracked\n"); + await expect(stat(path.join(root, "[id]"))).rejects.toMatchObject({ code: "ENOENT" }); + expect(await readFile(path.join(root, "i", "route.tsx"), "utf8")).toBe("tracked\n"); + expect((await runGitCommand(["ls-files", "-z"], { cwd: root })).stdout).toBe("i/route.tsx\0"); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("uses Git to rename a tracked bracketed filename and preserves its tracked sibling", async () => { + const root = await createTempDir("paseo-tracked-literal-rename-"); + try { + await runGitCommand(["init"], { cwd: root }); + await writeFile(path.join(root, "[id].tsx"), "tracked route\n", "utf8"); + await writeFile(path.join(root, "i.tsx"), "tracked sibling\n", "utf8"); + await runGitCommand(["add", "--", "[id].tsx", "i.tsx"], { cwd: root }); + + const result = await renameExplorerEntry({ + root, + relativePath: "[id].tsx", + name: "renamed.tsx", + }); + + expect(result).toEqual({ status: "ok", path: "renamed.tsx" }); + expect(await readFile(path.join(root, "renamed.tsx"), "utf8")).toBe("tracked route\n"); + await expect(stat(path.join(root, "[id].tsx"))).rejects.toMatchObject({ code: "ENOENT" }); + expect(await readFile(path.join(root, "i.tsx"), "utf8")).toBe("tracked sibling\n"); + expect((await runGitCommand(["ls-files", "-z"], { cwd: root })).stdout).toBe( + "i.tsx\0renamed.tsx\0", + ); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it.each([ + { + sourcePath: "[id].tsx", + sourceFile: "[id].tsx", + targetName: "renamed.tsx", + targetFile: "renamed.tsx", + }, + { + sourcePath: "[id]", + sourceFile: "[id]/route.tsx", + targetName: "route", + targetFile: "route/route.tsx", + }, + ])( + "keeps tracked renames in the index with inherited literal pathspecs ($sourcePath)", + async ({ sourcePath, sourceFile, targetName, targetFile }) => { + const root = await createTempDir("paseo-inherited-literal-rename-"); + try { + vi.stubEnv("GIT_LITERAL_PATHSPECS", "1"); + await runGitCommand(["init"], { cwd: root }); + await mkdir(path.dirname(path.join(root, sourceFile)), { recursive: true }); + await writeFile(path.join(root, sourceFile), "tracked route\n", "utf8"); + await writeFile(path.join(root, "i.tsx"), "tracked sibling\n", "utf8"); + await runGitCommand(["add", "."], { cwd: root }); + + const result = await renameExplorerEntry({ + root, + relativePath: sourcePath, + name: targetName, + }); + + expect(result).toEqual({ status: "ok", path: targetName }); + expect(await readFile(path.join(root, targetFile), "utf8")).toBe("tracked route\n"); + await expect(stat(path.join(root, sourcePath))).rejects.toMatchObject({ code: "ENOENT" }); + expect(await readFile(path.join(root, "i.tsx"), "utf8")).toBe("tracked sibling\n"); + expect((await runGitCommand(["ls-files", "-z"], { cwd: root })).stdout).toBe( + `i.tsx\0${targetFile}\0`, + ); + expect(process.env.GIT_LITERAL_PATHSPECS).toBe("1"); + } finally { + vi.unstubAllEnvs(); + await rm(root, { recursive: true, force: true }); + } + }, + ); + it("supports case-only renames for tracked and untracked files", async () => { const root = await createTempDir("paseo-entry-case-rename-"); try { diff --git a/packages/server/src/server/file-explorer/service.ts b/packages/server/src/server/file-explorer/service.ts index 5aa4efe974..fdbfc697cf 100644 --- a/packages/server/src/server/file-explorer/service.ts +++ b/packages/server/src/server/file-explorer/service.ts @@ -724,7 +724,11 @@ export async function renameExplorerEntry({ }); const tracked = repository.exitCode === 0 - ? await runGitCommand(["ls-files", "-z", "--", sourcePath], { cwd: realRoot }) + ? await runGitCommand(["ls-files", "-z", "--", `:(literal)${sourcePath}`], { + cwd: realRoot, + // Inherited literal mode disables the :(literal) pathspec magic. + envOverlay: { GIT_LITERAL_PATHSPECS: undefined }, + }) : null; if (tracked?.stdout) { await runGitCommand(["mv", "--", sourcePath, renamedPath], { cwd: realRoot });