дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 19:25:54 +02:00
Deleting a branch checked out in another worktree failed with git's `used by worktree` error and offered no way forward. The delete flow now detects the holding worktree and offers to remove both the worktree and the branch in one confirmed step; a dirty worktree goes to Force Remove with git's own error shown. A worktree that is the open project or contains it is never offered, and the main worktree keeps git's message. Worktrees below a project opened on a parent folder are still offered. Sibling covered: multi-repository branch deletion passes the target repository explicitly instead of relying on the focused one, which a refresh after a worktree removal could change, so each branch is deleted in its own repository and focus stays where it was. Co-authored-by: muutot <[email protected]>
196 рядки
7.3 KiB
TypeScript
196 рядки
7.3 KiB
TypeScript
import { beforeEach, expect, it, vi } from "vitest";
|
|
import type { Branch } from "@/bindings";
|
|
|
|
const mocks = vi.hoisted(() => ({
|
|
confirm: vi.fn(),
|
|
local: vi.fn(),
|
|
remote: vi.fn(),
|
|
focus: vi.fn(),
|
|
notify: vi.fn(),
|
|
range: vi.fn(),
|
|
deleteBranch: vi.fn(),
|
|
branches: vi.fn(),
|
|
}));
|
|
vi.mock("@/platform/ipc", async (original) => {
|
|
const real = await original<typeof import("@/platform/ipc")>();
|
|
return {
|
|
...real,
|
|
commands: { ...real.commands, vcsDeleteBranch: mocks.deleteBranch, vcsBranches: mocks.branches },
|
|
};
|
|
});
|
|
vi.mock("../dialogs", async (original) => ({
|
|
...(await original<typeof import("../dialogs")>()),
|
|
confirmDialog: mocks.confirm,
|
|
}));
|
|
vi.mock("../ops", async (original) => ({
|
|
...(await original<typeof import("../ops")>()),
|
|
runOp: mocks.local,
|
|
runRemote: mocks.remote,
|
|
}));
|
|
vi.mock("../store", async (original) => ({
|
|
...(await original<typeof import("../store")>()),
|
|
focusRepo: mocks.focus,
|
|
}));
|
|
|
|
vi.mock("@/workbench/contributions", async (original) => ({
|
|
...(await original<typeof import("@/workbench/contributions")>()),
|
|
notifications: () => ({ notify: mocks.notify }),
|
|
}));
|
|
|
|
vi.mock("../CompareBranches", async (original) => ({
|
|
...(await original<typeof import("../CompareBranches")>()),
|
|
showCommitRange: mocks.range,
|
|
}));
|
|
|
|
import { canDeleteBranch, deleteBranches } from "../branchOps";
|
|
import { branchMenu } from "../branchMenu";
|
|
import { registerLogPaneActions, useBranchesPane } from "../logPaneActions";
|
|
import { getAction } from "@/platform/actions";
|
|
import { useVcs } from "../store";
|
|
|
|
const branch = (name: string, opts: Partial<Branch> = {}): Branch => ({
|
|
name,
|
|
remote: false,
|
|
current: false,
|
|
upstream: null,
|
|
ahead: 0,
|
|
behind: 0,
|
|
lastCommitMs: null,
|
|
...opts,
|
|
});
|
|
const target = (name: string, repo = "/p/a", opts: Partial<Branch> = {}) => ({
|
|
ref: name,
|
|
repo,
|
|
branch: branch(name, opts),
|
|
});
|
|
|
|
beforeEach(() => {
|
|
vi.resetAllMocks();
|
|
useVcs.setState({ root: "/p", repo: "/p/a", branches: [], branchesLoaded: true });
|
|
useBranchesPane.setState({ selected: [] });
|
|
mocks.confirm.mockResolvedValue(true);
|
|
mocks.branches.mockResolvedValue({ status: "ok", data: [] });
|
|
mocks.focus.mockImplementation(async (repo: string) => {
|
|
useVcs.setState({ repo });
|
|
});
|
|
});
|
|
|
|
it("deletes across repositories without changing focus", async () => {
|
|
await deleteBranches([target("one", "/p/b"), target("two", "/p/a"), target("three", "/p/b")]);
|
|
expect(mocks.focus).not.toHaveBeenCalled();
|
|
expect(useVcs.getState().repo).toBe("/p/a");
|
|
});
|
|
|
|
it("keeps deletion targets when an in-flight refresh overwrites focus between deletions", async () => {
|
|
mocks.local.mockImplementation(async (_title, run) => {
|
|
const result = await run();
|
|
useVcs.setState({ repo: "/p/b" });
|
|
return result;
|
|
});
|
|
mocks.focus.mockImplementation(async () => {
|
|
// A refresh started in B finishes after the attempt to focus A.
|
|
useVcs.setState({ repo: "/p/b" });
|
|
});
|
|
mocks.deleteBranch.mockResolvedValue({ status: "ok", data: null });
|
|
await deleteBranches([target("one", "/p/b"), target("two", "/p/a")]);
|
|
expect(mocks.deleteBranch.mock.calls).toEqual([
|
|
["/p/b", "one"],
|
|
["/p/a", "two"],
|
|
]);
|
|
expect(mocks.focus).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("leaves focus untouched when deletion throws", async () => {
|
|
mocks.local.mockRejectedValue(new Error("failed"));
|
|
await expect(deleteBranches([target("one", "/p/b")])).rejects.toThrow("failed");
|
|
expect(mocks.focus).not.toHaveBeenCalled();
|
|
expect(useVcs.getState().repo).toBe("/p/a");
|
|
});
|
|
|
|
it("skips tags, groups and each repository's current branch", async () => {
|
|
const selected = [
|
|
target("main", "/p/a", { current: true }),
|
|
target("other", "/p/b", { current: true }),
|
|
{ ref: "v1", repo: "/p/a" },
|
|
{},
|
|
target("other", "/p/a"),
|
|
target("main", "/p/b"),
|
|
];
|
|
expect(selected.map((t) => canDeleteBranch("ref" in t ? t : null))).toEqual([false, false, false, false, true, true]);
|
|
await deleteBranches(selected);
|
|
expect(mocks.local.mock.calls.map(([title]) => title)).toEqual(["Delete branch other", "Delete branch main"]);
|
|
});
|
|
|
|
it("does nothing for only current branches or non-branch rows", async () => {
|
|
await deleteBranches([target("main", "/p/a", { current: true }), { ref: "v1" }, {}]);
|
|
expect(mocks.local).not.toHaveBeenCalled();
|
|
expect(mocks.remote).not.toHaveBeenCalled();
|
|
expect(mocks.confirm).not.toHaveBeenCalled();
|
|
expect(mocks.focus).not.toHaveBeenCalled();
|
|
expect(branchMenu(branch("main", { current: true }), "main").some((i) => "label" in i && i.label === "Delete")).toBe(
|
|
false,
|
|
);
|
|
});
|
|
|
|
it.each([1, 2])("cancelling %s remote branches leaves the whole mixed selection untouched", async (count) => {
|
|
mocks.confirm.mockResolvedValue(false);
|
|
await deleteBranches([
|
|
target("local"),
|
|
...Array.from({ length: count }, (_, i) => target(`origin/r${i}`, "/p/b", { remote: true })),
|
|
]);
|
|
expect(mocks.confirm).toHaveBeenCalledTimes(1);
|
|
expect(mocks.local).not.toHaveBeenCalled();
|
|
expect(mocks.remote).not.toHaveBeenCalled();
|
|
expect(mocks.focus).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("confirms once for remote branches across repositories and deletes in the right repository", async () => {
|
|
mocks.remote.mockResolvedValue(true);
|
|
await deleteBranches([
|
|
target("origin/one", "/p/a", { remote: true }),
|
|
target("origin/two", "/p/b", { remote: true }),
|
|
]);
|
|
expect(mocks.confirm).toHaveBeenCalledTimes(1);
|
|
expect(mocks.remote.mock.calls.map(([op]) => op.branch)).toEqual(["origin/one", "origin/two"]);
|
|
expect(mocks.remote.mock.calls.map(([, , repos]) => repos)).toEqual([["/p/a"], ["/p/b"]]);
|
|
expect(mocks.focus).not.toHaveBeenCalled();
|
|
expect(useVcs.getState().repo).toBe("/p/a");
|
|
});
|
|
|
|
it("the Delete action uses the whole pane selection without an explicit argument", async () => {
|
|
const dispose = registerLogPaneActions();
|
|
try {
|
|
useBranchesPane.setState({ selected: [target("one"), target("two")] });
|
|
await getAction("vcs.log.branches.delete")!.run({ ctx: {} });
|
|
await vi.waitFor(() => expect(mocks.local).toHaveBeenCalledTimes(2));
|
|
} finally {
|
|
dispose();
|
|
}
|
|
});
|
|
|
|
it("Delete Tracked Branch keeps the repository captured without changing focus", async () => {
|
|
mocks.local.mockResolvedValue({ name: "one", tip: "abc", upstream: "origin/one", unmerged: false });
|
|
await deleteBranches([target("one", "/p/b")]);
|
|
expect(useVcs.getState().repo).toBe("/p/a");
|
|
const notification = mocks.notify.mock.calls[0]![0];
|
|
notification.actions.find((a: { label: string }) => a.label === "Delete Tracked Branch").run();
|
|
await vi.waitFor(() =>
|
|
expect(mocks.remote).toHaveBeenCalledWith({ op: "deleteBranch", branch: "origin/one" }, "origin/one", ["/p/b"]),
|
|
);
|
|
});
|
|
|
|
it("View Commits keeps the deleted branch's repository without changing focus", async () => {
|
|
mocks.local.mockResolvedValue({ name: "one", tip: "abc", upstream: null, unmerged: true });
|
|
await deleteBranches([target("one", "/p/b")]);
|
|
const notification = mocks.notify.mock.calls[0]![0];
|
|
notification.actions.find((a: { label: string }) => a.label === "View Commits").run();
|
|
expect(mocks.range).toHaveBeenCalledWith("Commits of one", "HEAD", "abc", "/p/b");
|
|
});
|
|
|
|
it("keeps the primary repository focused implicitly", async () => {
|
|
useVcs.setState({ repo: null, roots: ["/p/a", "/p/b"] });
|
|
await deleteBranches([target("one", "/p/b")]);
|
|
expect(useVcs.getState().repo).toBeNull();
|
|
expect(mocks.focus).not.toHaveBeenCalled();
|
|
});
|