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
@@ -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