feat: add file manager context actions (#3027)
* feat(app): add file manager context actions * fix: reshape file context actions --------- Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
This commit is contained in:
co-authored by
Mohamed Boudra
parent
a98d154b6b
commit
ca2b633286
@@ -1,10 +1,25 @@
|
||||
import { appendFile, chmod, mkdtemp, rm, stat, truncate, writeFile } from "node:fs/promises";
|
||||
import {
|
||||
appendFile,
|
||||
chmod,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
rm,
|
||||
stat,
|
||||
truncate,
|
||||
writeFile,
|
||||
} 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 {
|
||||
createExplorerEntry,
|
||||
deleteExplorerEntry,
|
||||
duplicateExplorerEntry,
|
||||
getExplorerFileVersion,
|
||||
readExplorerFile,
|
||||
renameExplorerEntry,
|
||||
streamExplorerFile,
|
||||
writeExplorerFile,
|
||||
} from "./service.js";
|
||||
@@ -373,4 +388,202 @@ describe("file explorer service", () => {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("creates files and directories, refusing duplicates and separator names", async () => {
|
||||
const root = await createTempDir("paseo-entry-create-");
|
||||
try {
|
||||
const file = await createExplorerEntry({
|
||||
root,
|
||||
parentPath: ".",
|
||||
name: "notes.txt",
|
||||
kind: "file",
|
||||
});
|
||||
expect(file).toEqual({ status: "ok", path: "notes.txt" });
|
||||
expect((await stat(path.join(root, "notes.txt"))).isFile()).toBe(true);
|
||||
|
||||
const dir = await createExplorerEntry({
|
||||
root,
|
||||
parentPath: ".",
|
||||
name: "docs",
|
||||
kind: "directory",
|
||||
});
|
||||
expect(dir).toEqual({ status: "ok", path: "docs" });
|
||||
const nested = await createExplorerEntry({
|
||||
root,
|
||||
parentPath: "docs",
|
||||
name: "guide.md",
|
||||
kind: "file",
|
||||
});
|
||||
expect(nested).toEqual({ status: "ok", path: "docs/guide.md" });
|
||||
|
||||
const duplicate = await createExplorerEntry({
|
||||
root,
|
||||
parentPath: ".",
|
||||
name: "notes.txt",
|
||||
kind: "file",
|
||||
});
|
||||
expect(duplicate.status).toBe("error");
|
||||
|
||||
const traversal = await createExplorerEntry({
|
||||
root,
|
||||
parentPath: ".",
|
||||
name: "../escape",
|
||||
kind: "directory",
|
||||
});
|
||||
expect(traversal.status).toBe("error");
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("duplicates files and folders with collision-free sibling names", async () => {
|
||||
const root = await createTempDir("paseo-entry-duplicate-");
|
||||
try {
|
||||
await writeFile(path.join(root, "notes.txt"), "original", "utf8");
|
||||
const firstFileCopy = await duplicateExplorerEntry({
|
||||
root,
|
||||
relativePath: "notes.txt",
|
||||
});
|
||||
expect(firstFileCopy).toEqual({ status: "ok", path: "notes copy.txt" });
|
||||
expect(await readFile(path.join(root, "notes copy.txt"), "utf8")).toBe("original");
|
||||
|
||||
const secondFileCopy = await duplicateExplorerEntry({
|
||||
root,
|
||||
relativePath: "notes.txt",
|
||||
});
|
||||
expect(secondFileCopy).toEqual({ status: "ok", path: "notes copy 2.txt" });
|
||||
|
||||
await mkdir(path.join(root, "docs"));
|
||||
await writeFile(path.join(root, "docs", "guide.md"), "guide", "utf8");
|
||||
const folderCopy = await duplicateExplorerEntry({ root, relativePath: "docs" });
|
||||
expect(folderCopy).toEqual({ status: "ok", path: "docs copy" });
|
||||
expect(await readFile(path.join(root, "docs copy", "guide.md"), "utf8")).toBe("guide");
|
||||
|
||||
await expect(duplicateExplorerEntry({ root, relativePath: "." })).resolves.toMatchObject({
|
||||
status: "error",
|
||||
});
|
||||
await expect(duplicateExplorerEntry({ root, relativePath: "missing.txt" })).resolves.toEqual({
|
||||
status: "error",
|
||||
error: "File or folder no longer exists",
|
||||
});
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("renames tracked entries with Git and untracked entries on the filesystem", async () => {
|
||||
const root = await createTempDir("paseo-entry-rename-");
|
||||
try {
|
||||
await runGitCommand(["init"], { cwd: root });
|
||||
await writeFile(path.join(root, "tracked.txt"), "tracked", "utf8");
|
||||
await mkdir(path.join(root, "tracked-folder"));
|
||||
await writeFile(path.join(root, "tracked-folder", "inside.txt"), "tracked", "utf8");
|
||||
await runGitCommand(["add", "."], { cwd: root });
|
||||
await runGitCommand(
|
||||
["-c", "user.name=Paseo Test", "-c", "user.email=test@paseo.local", "commit", "-m", "base"],
|
||||
{ cwd: root },
|
||||
);
|
||||
|
||||
const tracked = await renameExplorerEntry({
|
||||
root,
|
||||
relativePath: "tracked.txt",
|
||||
name: "renamed.txt",
|
||||
});
|
||||
expect(tracked).toEqual({ status: "ok", path: "renamed.txt" });
|
||||
expect((await runGitCommand(["status", "--short"], { cwd: root })).stdout.trim()).toBe(
|
||||
"R tracked.txt -> renamed.txt",
|
||||
);
|
||||
|
||||
const trackedFolder = await renameExplorerEntry({
|
||||
root,
|
||||
relativePath: "tracked-folder",
|
||||
name: "renamed-folder",
|
||||
});
|
||||
expect(trackedFolder).toEqual({ status: "ok", path: "renamed-folder" });
|
||||
const gitStatus = (await runGitCommand(["status", "--short"], { cwd: root })).stdout;
|
||||
expect(gitStatus).toContain("tracked-folder/inside.txt -> renamed-folder/inside.txt");
|
||||
|
||||
await writeFile(path.join(root, "untracked.txt"), "untracked", "utf8");
|
||||
const untracked = await renameExplorerEntry({
|
||||
root,
|
||||
relativePath: "untracked.txt",
|
||||
name: "moved.txt",
|
||||
});
|
||||
expect(untracked).toEqual({ status: "ok", path: "moved.txt" });
|
||||
expect((await stat(path.join(root, "moved.txt"))).isFile()).toBe(true);
|
||||
} finally {
|
||||
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 {
|
||||
await runGitCommand(["init"], { cwd: root });
|
||||
await writeFile(path.join(root, "Tracked.txt"), "tracked", "utf8");
|
||||
await writeFile(path.join(root, "Loose.txt"), "untracked", "utf8");
|
||||
await runGitCommand(["add", "Tracked.txt"], { cwd: root });
|
||||
await runGitCommand(
|
||||
["-c", "user.name=Paseo Test", "-c", "user.email=test@paseo.local", "commit", "-m", "base"],
|
||||
{ cwd: root },
|
||||
);
|
||||
|
||||
await expect(
|
||||
renameExplorerEntry({ root, relativePath: "Tracked.txt", name: "tracked.txt" }),
|
||||
).resolves.toEqual({ status: "ok", path: "tracked.txt" });
|
||||
expect((await stat(path.join(root, "tracked.txt"))).isFile()).toBe(true);
|
||||
|
||||
await expect(
|
||||
renameExplorerEntry({ root, relativePath: "Loose.txt", name: "loose.txt" }),
|
||||
).resolves.toEqual({ status: "ok", path: "loose.txt" });
|
||||
expect((await stat(path.join(root, "loose.txt"))).isFile()).toBe(true);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses invalid renames, existing targets, and the workspace root", async () => {
|
||||
const root = await createTempDir("paseo-entry-rename-errors-");
|
||||
try {
|
||||
await writeFile(path.join(root, "source.txt"), "source", "utf8");
|
||||
await writeFile(path.join(root, "existing.txt"), "existing", "utf8");
|
||||
|
||||
await expect(
|
||||
renameExplorerEntry({ root, relativePath: "source.txt", name: "existing.txt" }),
|
||||
).resolves.toEqual({ status: "error", error: '"existing.txt" already exists' });
|
||||
await expect(
|
||||
renameExplorerEntry({ root, relativePath: "source.txt", name: "../outside.txt" }),
|
||||
).resolves.toEqual({ status: "error", error: "Name cannot contain path separators" });
|
||||
await expect(
|
||||
renameExplorerEntry({ root, relativePath: ".", name: "renamed-root" }),
|
||||
).resolves.toEqual({ status: "error", error: "Cannot rename the workspace root" });
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("deletes files and directories but never the workspace root or outside paths", async () => {
|
||||
const root = await createTempDir("paseo-entry-delete-");
|
||||
try {
|
||||
await writeFile(path.join(root, "doomed.txt"), "bye", "utf8");
|
||||
const removedFile = await deleteExplorerEntry({ root, relativePath: "doomed.txt" });
|
||||
expect(removedFile).toEqual({ status: "ok", path: "doomed.txt" });
|
||||
await expect(stat(path.join(root, "doomed.txt"))).rejects.toThrow();
|
||||
|
||||
await createExplorerEntry({ root, parentPath: ".", name: "nested", kind: "directory" });
|
||||
await writeFile(path.join(root, "nested", "inner.txt"), "hi", "utf8");
|
||||
const removedDir = await deleteExplorerEntry({ root, relativePath: "nested" });
|
||||
expect(removedDir).toEqual({ status: "ok", path: "nested" });
|
||||
await expect(stat(path.join(root, "nested"))).rejects.toThrow();
|
||||
|
||||
const rootDelete = await deleteExplorerEntry({ root, relativePath: "." });
|
||||
expect(rootDelete.status).toBe("error");
|
||||
|
||||
await expect(
|
||||
deleteExplorerEntry({ root, relativePath: "../outside" }),
|
||||
).resolves.toMatchObject({ status: "error" });
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { FileHandle } from "fs/promises";
|
||||
import path from "path";
|
||||
import { randomUUID } from "crypto";
|
||||
import { expandUserPath, resolvePathFromBase } from "../path-utils.js";
|
||||
import { runGitCommand } from "../../utils/run-git-command.js";
|
||||
|
||||
export type ExplorerEntryKind = "file" | "directory";
|
||||
export type ExplorerFileKind = "text" | "image" | "binary";
|
||||
@@ -572,6 +573,215 @@ export async function getDownloadableFileInfo({ root, relativePath }: ReadFilePa
|
||||
}
|
||||
}
|
||||
|
||||
export interface ExplorerCreateEntryParams {
|
||||
root: string;
|
||||
parentPath: string;
|
||||
name: string;
|
||||
kind: ExplorerEntryKind;
|
||||
}
|
||||
|
||||
export interface ExplorerRenameEntryParams {
|
||||
root: string;
|
||||
relativePath: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export type ExplorerEntryMutationResult =
|
||||
| { status: "ok"; path: string }
|
||||
| { status: "error"; error: string };
|
||||
|
||||
export async function createExplorerEntry({
|
||||
root,
|
||||
parentPath,
|
||||
name,
|
||||
kind,
|
||||
}: ExplorerCreateEntryParams): Promise<ExplorerEntryMutationResult> {
|
||||
const trimmedName = name.trim();
|
||||
if (!trimmedName || trimmedName === "." || trimmedName === "..") {
|
||||
return { status: "error", error: "Invalid name" };
|
||||
}
|
||||
if (trimmedName.includes("/") || trimmedName.includes("\\")) {
|
||||
return { status: "error", error: "Name cannot contain path separators" };
|
||||
}
|
||||
|
||||
try {
|
||||
const parent = await resolveScopedPath({ root, relativePath: parentPath });
|
||||
const parentStats = await fs.stat(parent.resolvedPath);
|
||||
if (!parentStats.isDirectory()) {
|
||||
return { status: "error", error: "Parent path is not a directory" };
|
||||
}
|
||||
const targetPath = path.join(parent.resolvedPath, trimmedName);
|
||||
if (kind === "directory") {
|
||||
await fs.mkdir(targetPath);
|
||||
} else {
|
||||
const handle = await fs.open(targetPath, "wx", 0o644);
|
||||
await handle.close();
|
||||
}
|
||||
return {
|
||||
status: "ok",
|
||||
path: normalizeRelativePath({
|
||||
root,
|
||||
targetPath: path.join(parent.requestedPath, trimmedName),
|
||||
}),
|
||||
};
|
||||
} catch (error) {
|
||||
if (isEntryExistsError(error)) {
|
||||
return { status: "error", error: `"${trimmedName}" already exists` };
|
||||
}
|
||||
return { status: "error", error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function duplicateExplorerEntry({
|
||||
root,
|
||||
relativePath,
|
||||
}: ReadFileParams): Promise<ExplorerEntryMutationResult> {
|
||||
try {
|
||||
const source = await resolveScopedPath({ root, relativePath });
|
||||
const realRoot = await fs.realpath(expandUserPath(root));
|
||||
if (source.resolvedPath === realRoot) {
|
||||
return { status: "error", error: "Cannot duplicate the workspace root" };
|
||||
}
|
||||
|
||||
const stats = await fs.lstat(source.requestedPath);
|
||||
const sourceName = path.basename(source.requestedPath);
|
||||
const extension = stats.isDirectory() ? "" : path.extname(sourceName);
|
||||
const baseName = extension ? sourceName.slice(0, -extension.length) : sourceName;
|
||||
let targetPath = "";
|
||||
for (let copyNumber = 1; ; copyNumber += 1) {
|
||||
const suffix = copyNumber === 1 ? " copy" : ` copy ${copyNumber}`;
|
||||
targetPath = path.join(
|
||||
path.dirname(source.requestedPath),
|
||||
`${baseName}${suffix}${extension}`,
|
||||
);
|
||||
try {
|
||||
await fs.lstat(targetPath);
|
||||
} catch (error) {
|
||||
if (isMissingEntryError(error)) {
|
||||
break;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
await fs.cp(source.requestedPath, targetPath, {
|
||||
recursive: stats.isDirectory(),
|
||||
errorOnExist: true,
|
||||
force: false,
|
||||
preserveTimestamps: true,
|
||||
});
|
||||
return { status: "ok", path: normalizeRelativePath({ root, targetPath }) };
|
||||
} catch (error) {
|
||||
if (isMissingEntryError(error)) {
|
||||
return { status: "error", error: "File or folder no longer exists" };
|
||||
}
|
||||
return { status: "error", error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function renameExplorerEntry({
|
||||
root,
|
||||
relativePath,
|
||||
name,
|
||||
}: ExplorerRenameEntryParams): Promise<ExplorerEntryMutationResult> {
|
||||
const trimmedName = name.trim();
|
||||
if (!trimmedName || trimmedName === "." || trimmedName === "..") {
|
||||
return { status: "error", error: "Invalid name" };
|
||||
}
|
||||
if (trimmedName.includes("/") || trimmedName.includes("\\")) {
|
||||
return { status: "error", error: "Name cannot contain path separators" };
|
||||
}
|
||||
|
||||
try {
|
||||
const source = await resolveScopedPath({ root, relativePath });
|
||||
const realRoot = await fs.realpath(expandUserPath(root));
|
||||
if (source.resolvedPath === realRoot) {
|
||||
return { status: "error", error: "Cannot rename the workspace root" };
|
||||
}
|
||||
|
||||
const targetPath = path.join(path.dirname(source.requestedPath), trimmedName);
|
||||
const sourceStats = await fs.lstat(source.requestedPath);
|
||||
const targetStats = await fs.lstat(targetPath).catch((error: unknown) => {
|
||||
if (isMissingEntryError(error)) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
if (
|
||||
targetStats &&
|
||||
(targetStats.dev !== sourceStats.dev || targetStats.ino !== sourceStats.ino)
|
||||
) {
|
||||
return { status: "error", error: `"${trimmedName}" already exists` };
|
||||
}
|
||||
|
||||
const sourcePath = normalizeRelativePath({ root, targetPath: source.requestedPath });
|
||||
const renamedPath = normalizeRelativePath({ root, targetPath });
|
||||
if (sourcePath === renamedPath) {
|
||||
return { status: "ok", path: renamedPath };
|
||||
}
|
||||
const repository = await runGitCommand(["rev-parse", "--is-inside-work-tree"], {
|
||||
cwd: realRoot,
|
||||
acceptExitCodes: [0, 128],
|
||||
});
|
||||
const tracked =
|
||||
repository.exitCode === 0
|
||||
? await runGitCommand(["ls-files", "-z", "--", sourcePath], { cwd: realRoot })
|
||||
: null;
|
||||
if (tracked?.stdout) {
|
||||
await runGitCommand(["mv", "--", sourcePath, renamedPath], { cwd: realRoot });
|
||||
} else {
|
||||
await fs.rename(source.requestedPath, targetPath);
|
||||
}
|
||||
return { status: "ok", path: renamedPath };
|
||||
} catch (error) {
|
||||
if (isEntryExistsError(error)) {
|
||||
return { status: "error", error: `"${trimmedName}" already exists` };
|
||||
}
|
||||
if (isMissingEntryError(error)) {
|
||||
return { status: "error", error: "File or folder no longer exists" };
|
||||
}
|
||||
return { status: "error", error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteExplorerEntry({
|
||||
root,
|
||||
relativePath,
|
||||
}: ReadFileParams): Promise<ExplorerEntryMutationResult> {
|
||||
try {
|
||||
const scoped = await resolveScopedPath({ root, relativePath });
|
||||
const realRoot = await fs.realpath(expandUserPath(root));
|
||||
if (scoped.resolvedPath === realRoot) {
|
||||
return { status: "error", error: "Cannot delete the workspace root" };
|
||||
}
|
||||
// Remove the requested path, not the realpath: deleting a symlink must
|
||||
// remove the link, never its target. lstat keeps the same guarantee.
|
||||
const stats = await fs.lstat(scoped.requestedPath);
|
||||
await fs.rm(scoped.requestedPath, {
|
||||
recursive: stats.isDirectory(),
|
||||
force: false,
|
||||
});
|
||||
return {
|
||||
status: "ok",
|
||||
path: normalizeRelativePath({ root, targetPath: scoped.requestedPath }),
|
||||
};
|
||||
} catch (error) {
|
||||
if (isMissingEntryError(error)) {
|
||||
return { status: "error", error: "File or folder no longer exists" };
|
||||
}
|
||||
return { status: "error", error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
function isEntryExistsError(error: unknown): boolean {
|
||||
return (
|
||||
typeof error === "object" &&
|
||||
error !== null &&
|
||||
"code" in error &&
|
||||
(error as NodeJS.ErrnoException).code === "EEXIST"
|
||||
);
|
||||
}
|
||||
|
||||
async function resolveScopedPath({
|
||||
root,
|
||||
relativePath = ".",
|
||||
|
||||
@@ -2106,6 +2106,8 @@ export class Session {
|
||||
return this.checkoutSession.handleCheckoutPushRequest(msg);
|
||||
case "checkout.refresh.request":
|
||||
return this.checkoutSession.handleRefreshRequest(msg);
|
||||
case "checkout.discard_changes.request":
|
||||
return this.checkoutSession.handleCheckoutDiscardChangesRequest(msg);
|
||||
case "checkout_pr_create_request":
|
||||
return this.checkoutSession.handleCheckoutPrCreateRequest(msg);
|
||||
case "checkout_pr_merge_request":
|
||||
@@ -2196,6 +2198,14 @@ export class Session {
|
||||
return undefined;
|
||||
case "fs.file.write.request":
|
||||
return this.workspaceFilesSession.handleFileWriteRequest(msg);
|
||||
case "fs.entry.create.request":
|
||||
return this.workspaceFilesSession.handleFileEntryCreateRequest(msg);
|
||||
case "fs.entry.rename.request":
|
||||
return this.workspaceFilesSession.handleFileEntryRenameRequest(msg);
|
||||
case "fs.entry.duplicate.request":
|
||||
return this.workspaceFilesSession.handleFileEntryDuplicateRequest(msg);
|
||||
case "fs.entry.delete.request":
|
||||
return this.workspaceFilesSession.handleFileEntryDeleteRequest(msg);
|
||||
case "project_icon_request":
|
||||
return this.workspaceFilesSession.handleProjectIconRequest(msg);
|
||||
case "project.icon.get.request":
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import pino from "pino";
|
||||
import {
|
||||
@@ -439,6 +443,88 @@ describe("CheckoutSession", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("discard changes", () => {
|
||||
it("discards the paths, notifies git mutation, refreshes diffs, and confirms success", async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "checkout-session-discard-"));
|
||||
const cwd = realpathSync(tempDir);
|
||||
try {
|
||||
execFileSync("git", ["init", "-q"], { cwd });
|
||||
execFileSync("git", ["config", "user.email", "test@example.com"], { cwd });
|
||||
execFileSync("git", ["config", "user.name", "Test User"], { cwd });
|
||||
writeFileSync(join(cwd, "file.txt"), "original\n");
|
||||
execFileSync("git", ["add", "file.txt"], { cwd });
|
||||
execFileSync("git", ["commit", "-qm", "initial"], { cwd });
|
||||
writeFileSync(join(cwd, "file.txt"), "changed\n");
|
||||
|
||||
const { subscriber, refreshedCwds } = createFakeDiffSubscriber({
|
||||
cwd: "",
|
||||
files: [],
|
||||
error: null,
|
||||
});
|
||||
const { checkout, emitted, gitMutationCalls } = makeCheckoutSession({ diff: subscriber });
|
||||
|
||||
await checkout.handleCheckoutDiscardChangesRequest({
|
||||
type: "checkout.discard_changes.request",
|
||||
cwd,
|
||||
paths: ["file.txt"],
|
||||
requestId: "discard-1",
|
||||
});
|
||||
|
||||
expect(readFileSync(join(cwd, "file.txt"), "utf8").replaceAll("\r\n", "\n")).toBe(
|
||||
"original\n",
|
||||
);
|
||||
expect(gitMutationCalls.notifyGitMutation).toEqual([
|
||||
{ cwd, reason: "discard-changes", options: undefined },
|
||||
]);
|
||||
expect(refreshedCwds).toEqual([cwd]);
|
||||
expect(emitted).toEqual([
|
||||
{
|
||||
type: "checkout.discard_changes.response",
|
||||
payload: { cwd, success: true, error: null, requestId: "discard-1" },
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("returns the checkout error without notifying git or refreshing diffs", async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "checkout-session-discard-error-"));
|
||||
const cwd = realpathSync(tempDir);
|
||||
try {
|
||||
const { subscriber, refreshedCwds } = createFakeDiffSubscriber({
|
||||
cwd: "",
|
||||
files: [],
|
||||
error: null,
|
||||
});
|
||||
const { checkout, emitted, gitMutationCalls } = makeCheckoutSession({ diff: subscriber });
|
||||
|
||||
await checkout.handleCheckoutDiscardChangesRequest({
|
||||
type: "checkout.discard_changes.request",
|
||||
cwd,
|
||||
paths: ["file.txt"],
|
||||
requestId: "discard-error",
|
||||
});
|
||||
|
||||
expect(gitMutationCalls.notifyGitMutation).toEqual([]);
|
||||
expect(refreshedCwds).toEqual([]);
|
||||
expect(emitted).toEqual([
|
||||
{
|
||||
type: "checkout.discard_changes.response",
|
||||
payload: {
|
||||
cwd,
|
||||
success: false,
|
||||
error: { code: "NOT_GIT_REPO", message: `Not a git repository: ${cwd}` },
|
||||
requestId: "discard-error",
|
||||
},
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("diff subscriptions", () => {
|
||||
it("opens a subscription, streams updates tagged with the id, and tears down on unsubscribe", async () => {
|
||||
const { subscriber, subscriptions } = createFakeDiffSubscriber({
|
||||
|
||||
@@ -42,6 +42,7 @@ import type {
|
||||
import {
|
||||
commitChanges,
|
||||
createPullRequest,
|
||||
discardChanges,
|
||||
forgeAuthStateFromError,
|
||||
isForgeAuthError,
|
||||
mergeFromBase,
|
||||
@@ -605,6 +606,26 @@ export class CheckoutSession {
|
||||
}
|
||||
}
|
||||
|
||||
async handleCheckoutDiscardChangesRequest(
|
||||
msg: Extract<SessionInboundMessage, { type: "checkout.discard_changes.request" }>,
|
||||
): Promise<void> {
|
||||
const { cwd, paths, requestId } = msg;
|
||||
try {
|
||||
await discardChanges(cwd, paths);
|
||||
await this.gitMutation.notifyGitMutation(cwd, "discard-changes");
|
||||
this.scheduleDiffRefresh(cwd);
|
||||
this.host.emit({
|
||||
type: "checkout.discard_changes.response",
|
||||
payload: { cwd, success: true, error: null, requestId },
|
||||
});
|
||||
} catch (error) {
|
||||
this.host.emit({
|
||||
type: "checkout.discard_changes.response",
|
||||
payload: { cwd, success: false, error: toCheckoutError(error), requestId },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async handleStashSaveRequest(
|
||||
msg: Extract<SessionInboundMessage, { type: "stash_save_request" }>,
|
||||
): Promise<void> {
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
||||
import {
|
||||
existsSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
realpathSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
@@ -74,6 +81,227 @@ function uploadFrame(args: Parameters<typeof encodeFileTransferFrame>[0]): FileT
|
||||
}
|
||||
|
||||
describe("WorkspaceFilesSession", () => {
|
||||
test("creates an entry and emits the complete success response", async () => {
|
||||
const cwd = makeDir("workspace-files-create-");
|
||||
const { subsystem, emitted } = makeSubsystem();
|
||||
|
||||
await subsystem.handleFileEntryCreateRequest({
|
||||
type: "fs.entry.create.request",
|
||||
cwd,
|
||||
parentPath: ".",
|
||||
name: "notes.txt",
|
||||
kind: "file",
|
||||
requestId: "req-create",
|
||||
});
|
||||
|
||||
expect(existsSync(join(cwd, "notes.txt"))).toBe(true);
|
||||
expect(emitted).toEqual([
|
||||
{
|
||||
type: "fs.entry.create.response",
|
||||
payload: {
|
||||
cwd,
|
||||
parentPath: ".",
|
||||
path: "notes.txt",
|
||||
success: true,
|
||||
error: null,
|
||||
requestId: "req-create",
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("passes entry creation errors through in the response", async () => {
|
||||
const cwd = makeDir("workspace-files-create-error-");
|
||||
writeFileSync(join(cwd, "notes.txt"), "existing");
|
||||
const { subsystem, emitted } = makeSubsystem();
|
||||
|
||||
await subsystem.handleFileEntryCreateRequest({
|
||||
type: "fs.entry.create.request",
|
||||
cwd,
|
||||
parentPath: ".",
|
||||
name: "notes.txt",
|
||||
kind: "file",
|
||||
requestId: "req-create-error",
|
||||
});
|
||||
|
||||
expect(emitted).toEqual([
|
||||
{
|
||||
type: "fs.entry.create.response",
|
||||
payload: {
|
||||
cwd,
|
||||
parentPath: ".",
|
||||
path: null,
|
||||
success: false,
|
||||
error: '"notes.txt" already exists',
|
||||
requestId: "req-create-error",
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("renames an entry and emits the resulting path", async () => {
|
||||
const cwd = makeDir("workspace-files-rename-");
|
||||
writeFileSync(join(cwd, "notes.txt"), "rename me");
|
||||
const { subsystem, emitted } = makeSubsystem();
|
||||
|
||||
await subsystem.handleFileEntryRenameRequest({
|
||||
type: "fs.entry.rename.request",
|
||||
cwd,
|
||||
path: "notes.txt",
|
||||
name: "renamed.txt",
|
||||
requestId: "req-rename",
|
||||
});
|
||||
|
||||
expect(existsSync(join(cwd, "notes.txt"))).toBe(false);
|
||||
expect(existsSync(join(cwd, "renamed.txt"))).toBe(true);
|
||||
expect(emitted).toEqual([
|
||||
{
|
||||
type: "fs.entry.rename.response",
|
||||
payload: {
|
||||
cwd,
|
||||
path: "notes.txt",
|
||||
renamedPath: "renamed.txt",
|
||||
success: true,
|
||||
error: null,
|
||||
requestId: "req-rename",
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("passes entry rename errors through in the response", async () => {
|
||||
const cwd = makeDir("workspace-files-rename-error-");
|
||||
const { subsystem, emitted } = makeSubsystem();
|
||||
|
||||
await subsystem.handleFileEntryRenameRequest({
|
||||
type: "fs.entry.rename.request",
|
||||
cwd,
|
||||
path: "missing.txt",
|
||||
name: "renamed.txt",
|
||||
requestId: "req-rename-error",
|
||||
});
|
||||
|
||||
expect(emitted).toEqual([
|
||||
{
|
||||
type: "fs.entry.rename.response",
|
||||
payload: {
|
||||
cwd,
|
||||
path: "missing.txt",
|
||||
renamedPath: null,
|
||||
success: false,
|
||||
error: "File or folder no longer exists",
|
||||
requestId: "req-rename-error",
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("duplicates an entry and emits the resulting path", async () => {
|
||||
const cwd = makeDir("workspace-files-duplicate-");
|
||||
writeFileSync(join(cwd, "notes.txt"), "duplicate me");
|
||||
const { subsystem, emitted } = makeSubsystem();
|
||||
|
||||
await subsystem.handleFileEntryDuplicateRequest({
|
||||
type: "fs.entry.duplicate.request",
|
||||
cwd,
|
||||
path: "notes.txt",
|
||||
requestId: "req-duplicate",
|
||||
});
|
||||
|
||||
expect(readFileSync(join(cwd, "notes copy.txt"), "utf8")).toBe("duplicate me");
|
||||
expect(emitted).toEqual([
|
||||
{
|
||||
type: "fs.entry.duplicate.response",
|
||||
payload: {
|
||||
cwd,
|
||||
path: "notes.txt",
|
||||
duplicatedPath: "notes copy.txt",
|
||||
success: true,
|
||||
error: null,
|
||||
requestId: "req-duplicate",
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("passes entry duplication errors through in the response", async () => {
|
||||
const cwd = makeDir("workspace-files-duplicate-error-");
|
||||
const { subsystem, emitted } = makeSubsystem();
|
||||
|
||||
await subsystem.handleFileEntryDuplicateRequest({
|
||||
type: "fs.entry.duplicate.request",
|
||||
cwd,
|
||||
path: "missing.txt",
|
||||
requestId: "req-duplicate-error",
|
||||
});
|
||||
|
||||
expect(emitted).toEqual([
|
||||
{
|
||||
type: "fs.entry.duplicate.response",
|
||||
payload: {
|
||||
cwd,
|
||||
path: "missing.txt",
|
||||
duplicatedPath: null,
|
||||
success: false,
|
||||
error: "File or folder no longer exists",
|
||||
requestId: "req-duplicate-error",
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("deletes an entry and emits the complete success response", async () => {
|
||||
const cwd = makeDir("workspace-files-delete-");
|
||||
writeFileSync(join(cwd, "notes.txt"), "delete me");
|
||||
const { subsystem, emitted } = makeSubsystem();
|
||||
|
||||
await subsystem.handleFileEntryDeleteRequest({
|
||||
type: "fs.entry.delete.request",
|
||||
cwd,
|
||||
path: "notes.txt",
|
||||
requestId: "req-delete",
|
||||
});
|
||||
|
||||
expect(existsSync(join(cwd, "notes.txt"))).toBe(false);
|
||||
expect(emitted).toEqual([
|
||||
{
|
||||
type: "fs.entry.delete.response",
|
||||
payload: {
|
||||
cwd,
|
||||
path: "notes.txt",
|
||||
success: true,
|
||||
error: null,
|
||||
requestId: "req-delete",
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("passes entry deletion errors through in the response", async () => {
|
||||
const cwd = makeDir("workspace-files-delete-error-");
|
||||
const { subsystem, emitted } = makeSubsystem();
|
||||
|
||||
await subsystem.handleFileEntryDeleteRequest({
|
||||
type: "fs.entry.delete.request",
|
||||
cwd,
|
||||
path: "missing.txt",
|
||||
requestId: "req-delete-error",
|
||||
});
|
||||
|
||||
expect(emitted).toEqual([
|
||||
{
|
||||
type: "fs.entry.delete.response",
|
||||
payload: {
|
||||
cwd,
|
||||
path: "missing.txt",
|
||||
success: false,
|
||||
error: "File or folder no longer exists",
|
||||
requestId: "req-delete-error",
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("lists directory entries", async () => {
|
||||
const cwd = makeDir("workspace-files-list-");
|
||||
writeFileSync(join(cwd, "a.txt"), "alpha");
|
||||
|
||||
@@ -7,6 +7,10 @@ import {
|
||||
} from "@getpaseo/protocol/binary-frames/index";
|
||||
import type {
|
||||
FileDownloadTokenRequest,
|
||||
FileEntryCreateRequest,
|
||||
FileEntryDeleteRequest,
|
||||
FileEntryDuplicateRequest,
|
||||
FileEntryRenameRequest,
|
||||
FileExplorerRequest,
|
||||
FileUploadRequest,
|
||||
FileSubscribeRequest,
|
||||
@@ -18,9 +22,13 @@ import type {
|
||||
import { FileUploadStore } from "../../file-upload/index.js";
|
||||
import type { DownloadTokenStore } from "../../file-download/token-store.js";
|
||||
import {
|
||||
createExplorerEntry,
|
||||
deleteExplorerEntry,
|
||||
duplicateExplorerEntry,
|
||||
getDownloadableFileInfo,
|
||||
listDirectoryEntries,
|
||||
readExplorerFile,
|
||||
renameExplorerEntry,
|
||||
streamExplorerFile,
|
||||
writeExplorerFile,
|
||||
} from "../../file-explorer/service.js";
|
||||
@@ -131,6 +139,80 @@ export class WorkspaceFilesSession {
|
||||
});
|
||||
}
|
||||
|
||||
async handleFileEntryCreateRequest(request: FileEntryCreateRequest): Promise<void> {
|
||||
const result = await createExplorerEntry({
|
||||
root: request.cwd,
|
||||
parentPath: request.parentPath,
|
||||
name: request.name,
|
||||
kind: request.kind,
|
||||
});
|
||||
this.host.emit({
|
||||
type: "fs.entry.create.response",
|
||||
payload: {
|
||||
cwd: request.cwd,
|
||||
parentPath: request.parentPath,
|
||||
path: result.status === "ok" ? result.path : null,
|
||||
success: result.status === "ok",
|
||||
error: result.status === "ok" ? null : result.error,
|
||||
requestId: request.requestId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async handleFileEntryRenameRequest(request: FileEntryRenameRequest): Promise<void> {
|
||||
const result = await renameExplorerEntry({
|
||||
root: request.cwd,
|
||||
relativePath: request.path,
|
||||
name: request.name,
|
||||
});
|
||||
this.host.emit({
|
||||
type: "fs.entry.rename.response",
|
||||
payload: {
|
||||
cwd: request.cwd,
|
||||
path: request.path,
|
||||
renamedPath: result.status === "ok" ? result.path : null,
|
||||
success: result.status === "ok",
|
||||
error: result.status === "ok" ? null : result.error,
|
||||
requestId: request.requestId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async handleFileEntryDuplicateRequest(request: FileEntryDuplicateRequest): Promise<void> {
|
||||
const result = await duplicateExplorerEntry({
|
||||
root: request.cwd,
|
||||
relativePath: request.path,
|
||||
});
|
||||
this.host.emit({
|
||||
type: "fs.entry.duplicate.response",
|
||||
payload: {
|
||||
cwd: request.cwd,
|
||||
path: request.path,
|
||||
duplicatedPath: result.status === "ok" ? result.path : null,
|
||||
success: result.status === "ok",
|
||||
error: result.status === "ok" ? null : result.error,
|
||||
requestId: request.requestId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async handleFileEntryDeleteRequest(request: FileEntryDeleteRequest): Promise<void> {
|
||||
const result = await deleteExplorerEntry({
|
||||
root: request.cwd,
|
||||
relativePath: request.path,
|
||||
});
|
||||
this.host.emit({
|
||||
type: "fs.entry.delete.response",
|
||||
payload: {
|
||||
cwd: request.cwd,
|
||||
path: request.path,
|
||||
success: result.status === "ok",
|
||||
error: result.status === "ok" ? null : result.error,
|
||||
requestId: request.requestId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const unsubscribe of this.fileSubscriptions.values()) unsubscribe();
|
||||
this.fileSubscriptions.clear();
|
||||
|
||||
@@ -1638,6 +1638,12 @@ export class VoiceAssistantWebSocketServer {
|
||||
workspaceScriptManagement: true,
|
||||
// COMPAT(projectCustomIcon): added in v0.2.0, remove after 2027-01-20.
|
||||
projectCustomIcon: true,
|
||||
// COMPAT(fsEntryOps): added in v0.3.0, remove gate after 2027-02-08.
|
||||
fsEntryOps: true,
|
||||
// COMPAT(fsEntryDuplicate): added in v0.3.0, remove gate after 2027-02-09.
|
||||
fsEntryDuplicate: true,
|
||||
// COMPAT(checkoutDiscardChanges): added in v0.3.0, remove gate after 2027-02-08.
|
||||
checkoutDiscardChanges: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
__resetPullRequestStatusCacheForTests,
|
||||
__setPullRequestStatusCacheTtlForTests,
|
||||
commitAll,
|
||||
discardChanges,
|
||||
CHECKOUT_DIFF_MAX_STRUCTURED_BYTES,
|
||||
createPullRequest,
|
||||
getCachedCheckoutShortstat,
|
||||
@@ -108,6 +109,10 @@ function initRepo(): { tempDir: string; repoDir: string } {
|
||||
return { tempDir, repoDir };
|
||||
}
|
||||
|
||||
function readTextFile(path: string): string {
|
||||
return readFileSync(path, "utf8").replaceAll("\r\n", "\n");
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
@@ -516,6 +521,19 @@ describe("checkout git utilities", () => {
|
||||
expect(message).toBe("update file");
|
||||
});
|
||||
|
||||
it("includes both paths for a staged rename in structured diffs", async () => {
|
||||
execFileSync("git", ["mv", "file.txt", "renamed.txt"], { cwd: repoDir });
|
||||
|
||||
const diff = await getCheckoutDiff(repoDir, {
|
||||
mode: "uncommitted",
|
||||
includeStructured: true,
|
||||
});
|
||||
|
||||
expect(diff.structured).toContainEqual(
|
||||
expect.objectContaining({ path: "renamed.txt", oldPath: "file.txt" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("reads the origin URL once when collecting facts for an origin-tracking branch", async () => {
|
||||
setupRemoteTrackingMain(repoDir, tempDir);
|
||||
|
||||
@@ -3698,3 +3716,132 @@ const x = 1;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("discardChanges", () => {
|
||||
it("discards staged and unstaged modifications, deletions, and untracked files", async () => {
|
||||
const { tempDir, repoDir } = initRepo();
|
||||
try {
|
||||
writeFileSync(join(repoDir, "file.txt"), "changed\n");
|
||||
writeFileSync(join(repoDir, "staged.txt"), "staged\n");
|
||||
execFileSync("git", ["add", "staged.txt"], { cwd: repoDir });
|
||||
mkdirSync(join(repoDir, "junk"), { recursive: true });
|
||||
writeFileSync(join(repoDir, "junk", "scratch.txt"), "scratch\n");
|
||||
|
||||
await discardChanges(repoDir, ["file.txt", "staged.txt", "junk"]);
|
||||
|
||||
expect(readTextFile(join(repoDir, "file.txt"))).toBe("hello\n");
|
||||
expect(existsSync(join(repoDir, "staged.txt"))).toBe(false);
|
||||
expect(existsSync(join(repoDir, "junk"))).toBe(false);
|
||||
const status = execFileSync("git", ["status", "--porcelain"], { cwd: repoDir })
|
||||
.toString()
|
||||
.trim();
|
||||
expect(status).toBe("");
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("restores both sides of a staged rename", async () => {
|
||||
const { tempDir, repoDir } = initRepo();
|
||||
try {
|
||||
execFileSync("git", ["mv", "file.txt", "renamed.txt"], { cwd: repoDir });
|
||||
|
||||
await discardChanges(repoDir, ["file.txt", "renamed.txt"]);
|
||||
|
||||
expect(readTextFile(join(repoDir, "file.txt"))).toBe("hello\n");
|
||||
expect(existsSync(join(repoDir, "renamed.txt"))).toBe(false);
|
||||
expect(execFileSync("git", ["status", "--porcelain"], { cwd: repoDir }).toString()).toBe("");
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("unstages and removes files in a repository with an unborn HEAD", async () => {
|
||||
const tempDir = realpathSync.native(mkdtempSync(join(tmpdir(), "checkout-git-unborn-")));
|
||||
const repoDir = join(tempDir, "repo");
|
||||
mkdirSync(repoDir, { recursive: true });
|
||||
try {
|
||||
execFileSync("git", ["init", "-b", "main"], { cwd: repoDir });
|
||||
writeFileSync(join(repoDir, "new.txt"), "new\n");
|
||||
execFileSync("git", ["add", "new.txt"], { cwd: repoDir });
|
||||
|
||||
await discardChanges(repoDir, ["new.txt"]);
|
||||
|
||||
expect(existsSync(join(repoDir, "new.txt"))).toBe(false);
|
||||
expect(execFileSync("git", ["status", "--porcelain"], { cwd: repoDir }).toString()).toBe("");
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("discards a nested folder pathspec without touching sibling changes", async () => {
|
||||
const { tempDir, repoDir } = initRepo();
|
||||
try {
|
||||
mkdirSync(join(repoDir, "nested"));
|
||||
writeFileSync(join(repoDir, "nested", "tracked.txt"), "original\n");
|
||||
execFileSync("git", ["add", "nested/tracked.txt"], { cwd: repoDir });
|
||||
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "add nested"], {
|
||||
cwd: repoDir,
|
||||
});
|
||||
writeFileSync(join(repoDir, "nested", "tracked.txt"), "changed\n");
|
||||
writeFileSync(join(repoDir, "nested", "untracked.txt"), "remove\n");
|
||||
writeFileSync(join(repoDir, "outside.txt"), "keep\n");
|
||||
|
||||
await discardChanges(repoDir, ["nested"]);
|
||||
|
||||
expect(readTextFile(join(repoDir, "nested", "tracked.txt"))).toBe("original\n");
|
||||
expect(existsSync(join(repoDir, "nested", "untracked.txt"))).toBe(false);
|
||||
expect(readFileSync(join(repoDir, "outside.txt"), "utf8")).toBe("keep\n");
|
||||
expect(execFileSync("git", ["status", "--porcelain"], { cwd: repoDir }).toString()).toBe(
|
||||
"?? outside.txt\n",
|
||||
);
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("restores a staged deletion", async () => {
|
||||
const { tempDir, repoDir } = initRepo();
|
||||
try {
|
||||
execFileSync("git", ["rm", "file.txt"], { cwd: repoDir });
|
||||
await discardChanges(repoDir, ["file.txt"]);
|
||||
expect(readTextFile(join(repoDir, "file.txt"))).toBe("hello\n");
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves files outside the given pathspecs untouched", async () => {
|
||||
const { tempDir, repoDir } = initRepo();
|
||||
try {
|
||||
writeFileSync(join(repoDir, "file.txt"), "changed\n");
|
||||
writeFileSync(join(repoDir, "other.txt"), "keep\n");
|
||||
await discardChanges(repoDir, ["file.txt"]);
|
||||
expect(readTextFile(join(repoDir, "file.txt"))).toBe("hello\n");
|
||||
expect(readFileSync(join(repoDir, "other.txt"), "utf8")).toBe("keep\n");
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("treats requested filenames as literal pathspecs", async () => {
|
||||
const { tempDir, repoDir } = initRepo();
|
||||
try {
|
||||
writeFileSync(join(repoDir, "foo[ab].txt"), "literal original\n");
|
||||
writeFileSync(join(repoDir, "fooa.txt"), "sibling original\n");
|
||||
execFileSync("git", ["add", "foo[ab].txt", "fooa.txt"], { cwd: repoDir });
|
||||
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "add pathspec files"], {
|
||||
cwd: repoDir,
|
||||
});
|
||||
writeFileSync(join(repoDir, "foo[ab].txt"), "literal changed\n");
|
||||
writeFileSync(join(repoDir, "fooa.txt"), "sibling changed\n");
|
||||
|
||||
await discardChanges(repoDir, ["foo[ab].txt"]);
|
||||
|
||||
expect(readTextFile(join(repoDir, "foo[ab].txt"))).toBe("literal original\n");
|
||||
expect(readTextFile(join(repoDir, "fooa.txt"))).toBe("sibling changed\n");
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -61,8 +61,10 @@ export type GitMutationRefreshReason =
|
||||
| "create-branch"
|
||||
| "stash-push"
|
||||
| "stash-pop"
|
||||
| "discard-changes"
|
||||
| "create-worktree";
|
||||
|
||||
const DISCARD_CHANGES_TIMEOUT_MS = 120_000;
|
||||
const DEFAULT_PULL_REQUEST_STATUS_CACHE_TTL_MS = 30_000;
|
||||
const PULL_REQUEST_STATUS_CACHE_MAX = 1_000;
|
||||
const DEFAULT_SHORTSTAT_CACHE_TTL_MS = 15_000;
|
||||
@@ -2084,6 +2086,7 @@ function buildPlaceholderParsedDiffFile(
|
||||
): ParsedDiffFile {
|
||||
return {
|
||||
path: change.path,
|
||||
...(change.oldPath ? { oldPath: change.oldPath } : {}),
|
||||
isNew: change.isNew,
|
||||
isDeleted: change.isDeleted,
|
||||
additions: options.stat?.additions ?? 0,
|
||||
@@ -2918,6 +2921,7 @@ async function buildHighlightedTrackedDiffFile(input: {
|
||||
return {
|
||||
...highlightedFile,
|
||||
path: change.path,
|
||||
...(change.oldPath ? { oldPath: change.oldPath } : {}),
|
||||
isNew: change.isNew,
|
||||
isDeleted: change.isDeleted,
|
||||
status: "ok",
|
||||
@@ -2993,6 +2997,7 @@ async function appendStructuredTrackedDiffs(
|
||||
|
||||
const file = {
|
||||
path: change.path,
|
||||
...(change.oldPath ? { oldPath: change.oldPath } : {}),
|
||||
isNew: change.isNew,
|
||||
isDeleted: change.isDeleted,
|
||||
additions: stat?.additions ?? 0,
|
||||
@@ -3321,6 +3326,71 @@ export async function commitAll(cwd: string, message: string): Promise<void> {
|
||||
await commitChanges(cwd, { message, addAll: true });
|
||||
}
|
||||
|
||||
export async function discardChanges(cwd: string, pathspecs: string[]): Promise<void> {
|
||||
await requireGitRepo(cwd);
|
||||
if (pathspecs.length === 0) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await runGitCommand(["--literal-pathspecs", "reset", "-q", "HEAD", "--", ...pathspecs], {
|
||||
cwd,
|
||||
timeout: DISCARD_CHANGES_TIMEOUT_MS,
|
||||
});
|
||||
} catch {
|
||||
// Why: unborn HEAD has no commit for reset, so remove the paths directly from the index.
|
||||
await runGitCommand(
|
||||
["--literal-pathspecs", "rm", "--cached", "-r", "-q", "--ignore-unmatch", "--", ...pathspecs],
|
||||
{
|
||||
cwd,
|
||||
timeout: DISCARD_CHANGES_TIMEOUT_MS,
|
||||
},
|
||||
);
|
||||
}
|
||||
// With everything unstaged, the remaining state is only worktree
|
||||
// modifications/deletions (restore from the index) and untracked files
|
||||
// (clean). Classify from porcelain so each path gets the command that
|
||||
// actually applies to it.
|
||||
const status = await runGitCommand(
|
||||
["--literal-pathspecs", "status", "--porcelain=v1", "-z", "--", ...pathspecs],
|
||||
{
|
||||
cwd,
|
||||
timeout: DISCARD_CHANGES_TIMEOUT_MS,
|
||||
},
|
||||
);
|
||||
const tracked: string[] = [];
|
||||
const untracked: string[] = [];
|
||||
const tokens = status.stdout.split("\0");
|
||||
for (let index = 0; index < tokens.length; index++) {
|
||||
const token = tokens[index];
|
||||
if (token.length < 4) {
|
||||
continue;
|
||||
}
|
||||
const state = token.slice(0, 2);
|
||||
const filePath = token.slice(3);
|
||||
if (state.startsWith("R") || state.startsWith("C")) {
|
||||
// Rename/copy entries carry the source path as the next NUL token.
|
||||
index += 1;
|
||||
}
|
||||
if (state === "??") {
|
||||
untracked.push(filePath);
|
||||
continue;
|
||||
}
|
||||
tracked.push(filePath);
|
||||
}
|
||||
if (tracked.length > 0) {
|
||||
await runGitCommand(["--literal-pathspecs", "checkout", "-q", "--", ...tracked], {
|
||||
cwd,
|
||||
timeout: DISCARD_CHANGES_TIMEOUT_MS,
|
||||
});
|
||||
}
|
||||
if (untracked.length > 0) {
|
||||
await runGitCommand(["--literal-pathspecs", "clean", "-fd", "-q", "--", ...untracked], {
|
||||
cwd,
|
||||
timeout: DISCARD_CHANGES_TIMEOUT_MS,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
interface DetectMergeToBaseConflictInput {
|
||||
operationCwd: string;
|
||||
error: unknown;
|
||||
|
||||
Reference in New Issue
Block a user