diff --git a/packages/server/src/server/daemon-e2e/filesystem.e2e.test.ts b/packages/server/src/server/daemon-e2e/filesystem.e2e.test.ts index 7fee55e216..0c99744e62 100644 --- a/packages/server/src/server/daemon-e2e/filesystem.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/filesystem.e2e.test.ts @@ -31,6 +31,81 @@ afterEach(async () => { await ctx.cleanup(); }, 60000); +describe("checkout discard", () => { + test.each([".", "apps/web"])( + "discards selected Changes from the %s workspace without changing siblings", + async (workspacePath) => { + const cwd = tmpCwd(); + function git(args: string[]): string { + return execFileSync("git", args, { cwd, encoding: "utf8", stdio: "pipe" }); + } + try { + git(["init", "-b", "main"]); + git(["config", "user.name", "Paseo Test"]); + git(["config", "user.email", "test@getpaseo.local"]); + git(["config", "core.autocrlf", "false"]); + const nested = path.join(cwd, "apps", "web"); + mkdirSync(nested, { recursive: true }); + writeFileSync(path.join(nested, "target[ab].txt"), "target base\n"); + writeFileSync(path.join(nested, "targeta.txt"), "sibling base\n"); + writeFileSync(path.join(nested, "deleted.txt"), "deleted base\n"); + git(["add", "."]); + git(["-c", "commit.gpgsign=false", "commit", "-m", "initial"]); + const creation = await ctx.client.createWorkspace({ + source: { kind: "directory", path: path.join(cwd, workspacePath) }, + title: "Discard regression", + }); + const workspaceCwd = creation.workspace?.workspaceDirectory; + if (!workspaceCwd) { + throw new Error(creation.error ?? "Workspace creation failed"); + } + + writeFileSync(path.join(nested, "target[ab].txt"), "target edited\n"); + writeFileSync(path.join(nested, "targeta.txt"), "sibling edited\n"); + writeFileSync(path.join(nested, "staged.txt"), "staged new file\n"); + writeFileSync(path.join(nested, "scratch.txt"), "untracked file\n"); + git(["--literal-pathspecs", "add", "--", "apps/web/target[ab].txt", "apps/web/staged.txt"]); + git(["rm", "apps/web/deleted.txt"]); + + const subscription = ctx.client.observeCheckoutDiff(workspaceCwd, { mode: "uncommitted" }); + try { + const changes = await subscription.ready; + expect(changes.error).toBeNull(); + expect(changes.files.map((file) => file.path).sort()).toEqual([ + "apps/web/deleted.txt", + "apps/web/scratch.txt", + "apps/web/staged.txt", + "apps/web/target[ab].txt", + "apps/web/targeta.txt", + ]); + const selectedPaths = changes.files + .filter((file) => file.path !== "apps/web/targeta.txt") + .map((file) => file.path); + const discarded = await ctx.client.checkoutDiscardChanges(workspaceCwd, { + paths: selectedPaths, + }); + expect(discarded).toMatchObject({ success: true, error: null }); + + const target = await ctx.client.readFile(cwd, "apps/web/target[ab].txt"); + expect(new TextDecoder().decode(target.bytes)).toBe("target base\n"); + const restored = await ctx.client.readFile(cwd, "apps/web/deleted.txt"); + expect(new TextDecoder().decode(restored.bytes)).toBe("deleted base\n"); + const sibling = await ctx.client.readFile(cwd, "apps/web/targeta.txt"); + expect(new TextDecoder().decode(sibling.bytes)).toBe("sibling edited\n"); + await expect(ctx.client.readFile(cwd, "apps/web/staged.txt")).rejects.toThrow(/ENOENT/); + await expect(ctx.client.readFile(cwd, "apps/web/scratch.txt")).rejects.toThrow(/ENOENT/); + expect(git(["status", "--porcelain=v1", "-z"])).toBe(" M apps/web/targeta.txt\0"); + } finally { + await subscription.release(); + } + } finally { + // afterEach shuts down the daemon and its repository observers first. + onTestFinished(() => rmSync(cwd, { recursive: true, force: true })); + } + }, + ); +}); + describe("file explorer", () => { test.each(["..", "..."])( "workspace suggestions resolve exact paths starting with %s", diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index 0f34a1ea73..bda89506b5 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -3553,13 +3553,13 @@ export async function commitAll(cwd: string, message: string): Promise { } export async function discardChanges(cwd: string, pathspecs: string[]): Promise { - await requireGitRepo(cwd); + const worktreeRoot = await requireGitWorktreeRoot(cwd); if (pathspecs.length === 0) { return; } try { await runGitCommand(["--literal-pathspecs", "reset", "-q", "HEAD", "--", ...pathspecs], { - cwd, + cwd: worktreeRoot, timeout: DISCARD_CHANGES_TIMEOUT_MS, }); } catch { @@ -3567,7 +3567,7 @@ export async function discardChanges(cwd: string, pathspecs: string[]): Promise< await runGitCommand( ["--literal-pathspecs", "rm", "--cached", "-r", "-q", "--ignore-unmatch", "--", ...pathspecs], { - cwd, + cwd: worktreeRoot, timeout: DISCARD_CHANGES_TIMEOUT_MS, }, ); @@ -3579,7 +3579,7 @@ export async function discardChanges(cwd: string, pathspecs: string[]): Promise< const status = await runGitCommand( ["--literal-pathspecs", "status", "--porcelain=v1", "-z", "--", ...pathspecs], { - cwd, + cwd: worktreeRoot, timeout: DISCARD_CHANGES_TIMEOUT_MS, }, ); @@ -3605,13 +3605,13 @@ export async function discardChanges(cwd: string, pathspecs: string[]): Promise< } if (tracked.length > 0) { await runGitCommand(["--literal-pathspecs", "checkout", "-q", "--", ...tracked], { - cwd, + cwd: worktreeRoot, timeout: DISCARD_CHANGES_TIMEOUT_MS, }); } if (untracked.length > 0) { await runGitCommand(["--literal-pathspecs", "clean", "-fd", "-q", "--", ...untracked], { - cwd, + cwd: worktreeRoot, timeout: DISCARD_CHANGES_TIMEOUT_MS, }); }