2cc492bb88
GET /git/refs lists local branches (upstream, ahead/behind, last commit, merged, held by a worktree, protected), remote branches grouped by remote and tags (target, annotated message/tagger/date). New writes in server/lib/git-refs.js, dispatched from git-ops.js: branch-rename, branch-upstream (set/unset), tag-create (light/annotated), tag-delete (confirm), tag-push (one tag to origin, no --tags, no force), and branch-create with a looked-up from + switch on a clean tree. Every ref is looked up, never passed through; repo-wide writes are repo-root only (ESUBDIR) and refused during any operation (EMERGING). Client: git/BranchManager + TagManager dialog opened from the git bar's tags icon, en+vi (gitRefs namespace), keyboard navigable. # Conflicts: # CLAUDE.md # client/src/components/lanes/LaneGitBar.tsx # docs/API.md # server/__tests__/lanes-api.test.js # server/routes/lanes.js
297 lines
11 KiB
TypeScript
297 lines
11 KiB
TypeScript
/**
|
|
* @file BranchManager.test.tsx
|
|
* @description Pins the git bar's Branches panel: local branches with the
|
|
* current one marked and ahead/behind shown, remote branches grouped by
|
|
* remote, the search box, arrow-key walking, and each action's request —
|
|
* rename, new branch from a remote branch (switch only offered on a clean
|
|
* tree), set/unset upstream, merge into current, delete refused with its
|
|
* reason — plus the Tags tab: annotated create, push one tag, delete with
|
|
* `confirm: true` after the themed confirm, and git's error shown verbatim.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import { render, screen, waitFor, within } from "@testing-library/react";
|
|
import userEvent from "@testing-library/user-event";
|
|
import { BranchManager } from "../git/BranchManager";
|
|
import { api, type GitRefs } from "../../../lib/api";
|
|
import { confirmDialog } from "../../../lib/confirm";
|
|
|
|
vi.mock("../../../lib/confirm", () => ({ confirmDialog: vi.fn(async () => true) }));
|
|
vi.mock("../../../lib/toast", () => ({
|
|
toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() },
|
|
}));
|
|
vi.mock("../../../lib/api", () => ({
|
|
gitTarget: (lane: number) => lane,
|
|
api: { lanes: { gitRefs: vi.fn(), gitRefAction: vi.fn() } },
|
|
}));
|
|
|
|
const REFS: GitRefs = {
|
|
available: true,
|
|
current: "main",
|
|
head: "aaaaaaa",
|
|
truncated: false,
|
|
local: [
|
|
{
|
|
name: "main",
|
|
sha: "a1",
|
|
current: true,
|
|
upstream: "origin/main",
|
|
ahead: 2,
|
|
behind: 1,
|
|
gone: false,
|
|
subject: "latest work",
|
|
date: null,
|
|
merged: true,
|
|
worktree: null,
|
|
protected: true,
|
|
},
|
|
{
|
|
name: "feature/very-long-branch-name",
|
|
sha: "b2",
|
|
current: false,
|
|
upstream: null,
|
|
ahead: null,
|
|
behind: null,
|
|
gone: false,
|
|
subject: "feature commit",
|
|
date: null,
|
|
merged: false,
|
|
worktree: null,
|
|
protected: false,
|
|
},
|
|
{
|
|
name: "held",
|
|
sha: "c3",
|
|
current: false,
|
|
upstream: null,
|
|
ahead: null,
|
|
behind: null,
|
|
gone: false,
|
|
subject: "x",
|
|
date: null,
|
|
merged: true,
|
|
worktree: "/tmp/other",
|
|
protected: false,
|
|
},
|
|
],
|
|
remotes: [
|
|
{
|
|
remote: "origin",
|
|
branches: [
|
|
{ name: "origin/main", branch: "main", sha: "a1", subject: "s", date: null },
|
|
{ name: "origin/remote-only", branch: "remote-only", sha: "d4", subject: "r", date: null },
|
|
],
|
|
},
|
|
],
|
|
tags: [
|
|
{
|
|
name: "v1",
|
|
sha: "a1b2c3d4",
|
|
annotated: true,
|
|
message: "release one",
|
|
tagger: "T",
|
|
date: null,
|
|
},
|
|
],
|
|
};
|
|
|
|
function setup(over: { dirty?: number } = {}) {
|
|
const onChanged = vi.fn();
|
|
render(
|
|
<BranchManager
|
|
laneId={3}
|
|
dirty={over.dirty ?? 0}
|
|
merging={false}
|
|
onClose={vi.fn()}
|
|
onChanged={onChanged}
|
|
/>
|
|
);
|
|
return { onChanged };
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.mocked(api.lanes.gitRefs).mockReset().mockResolvedValue(REFS);
|
|
vi.mocked(api.lanes.gitRefAction)
|
|
.mockReset()
|
|
.mockResolvedValue({ action: "x", ok: true, output: "" });
|
|
vi.mocked(confirmDialog).mockClear();
|
|
});
|
|
|
|
const row = (name: RegExp) => screen.getByRole("button", { name });
|
|
|
|
describe("BranchManager", () => {
|
|
it("lists local branches with the current marked and remote ones grouped", async () => {
|
|
setup();
|
|
await screen.findByText("feature/very-long-branch-name");
|
|
const main = row(/^main/);
|
|
expect(within(main).getByText("current")).toBeInTheDocument();
|
|
expect(main).toHaveTextContent("2");
|
|
expect(main).toHaveTextContent("1");
|
|
expect(screen.getByText("Remote · origin")).toBeInTheDocument();
|
|
expect(screen.getByText("origin/remote-only")).toBeInTheDocument();
|
|
// A long name truncates with its full text as the tooltip.
|
|
expect(screen.getByText("feature/very-long-branch-name")).toHaveAttribute(
|
|
"title",
|
|
"feature/very-long-branch-name"
|
|
);
|
|
});
|
|
|
|
it("filters by the search box and walks rows with the arrow keys", async () => {
|
|
setup();
|
|
await screen.findByText("held");
|
|
await userEvent.type(screen.getByRole("textbox", { name: "Search branches" }), "remote");
|
|
expect(screen.queryByText("held")).toBeNull();
|
|
expect(screen.getByText("origin/remote-only")).toBeInTheDocument();
|
|
await userEvent.clear(screen.getByRole("textbox", { name: "Search branches" }));
|
|
await userEvent.keyboard("{ArrowDown}");
|
|
expect(row(/^main/)).toHaveFocus();
|
|
await userEvent.keyboard("{ArrowDown}");
|
|
expect(row(/^feature\/very-long/)).toHaveFocus();
|
|
});
|
|
|
|
it("renames a branch and re-reads the list", async () => {
|
|
const { onChanged } = setup();
|
|
await userEvent.click(await screen.findByText("feature/very-long-branch-name"));
|
|
await userEvent.click(screen.getByRole("button", { name: /^Rename$/ }));
|
|
const input = screen.getByRole("textbox", { name: "New name" });
|
|
await userEvent.clear(input);
|
|
await userEvent.type(input, "feature/short{Enter}");
|
|
expect(api.lanes.gitRefAction).toHaveBeenCalledWith(3, "branch-rename", {
|
|
branch: "feature/very-long-branch-name",
|
|
name: "feature/short",
|
|
});
|
|
await waitFor(() => expect(onChanged).toHaveBeenCalled());
|
|
expect(api.lanes.gitRefs).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("refuses rename and delete of a protected current branch, with the reason", async () => {
|
|
setup();
|
|
await userEvent.click(await screen.findByText("main"));
|
|
expect(screen.getByRole("button", { name: /^Rename$/ })).toBeDisabled();
|
|
expect(screen.getByRole("button", { name: /^Delete$/ })).toBeDisabled();
|
|
expect(screen.getByText(/You are on this branch/)).toBeInTheDocument();
|
|
});
|
|
|
|
it("explains a branch another worktree holds", async () => {
|
|
setup();
|
|
await userEvent.click(await screen.findByText("held"));
|
|
expect(screen.getByRole("button", { name: /^Delete$/ })).toBeDisabled();
|
|
expect(screen.getByText(/another worktree: \/tmp\/other/)).toBeInTheDocument();
|
|
});
|
|
|
|
it("deletes an unmerged branch only after the confirm warns", async () => {
|
|
setup();
|
|
await userEvent.click(await screen.findByText("feature/very-long-branch-name"));
|
|
await userEvent.click(screen.getByRole("button", { name: /^Delete$/ }));
|
|
expect(vi.mocked(confirmDialog).mock.calls[0]?.[0].message).toMatch(/not merged/);
|
|
expect(api.lanes.gitRefAction).toHaveBeenCalledWith(3, "branch-delete", {
|
|
branch: "feature/very-long-branch-name",
|
|
});
|
|
});
|
|
|
|
it("creates a branch from a remote branch, switch offered only on a clean tree", async () => {
|
|
setup({ dirty: 2 });
|
|
await userEvent.click(await screen.findByText("origin/remote-only"));
|
|
await userEvent.click(screen.getByRole("button", { name: /New branch from here/ }));
|
|
expect(screen.getByRole("checkbox", { name: "Switch to it" })).toBeDisabled();
|
|
const input = screen.getByRole("textbox", { name: "New branch name" });
|
|
expect(input).toHaveValue("remote-only");
|
|
await userEvent.type(input, "{Enter}");
|
|
expect(api.lanes.gitRefAction).toHaveBeenCalledWith(3, "branch-create", {
|
|
branch: "remote-only",
|
|
from: "origin/remote-only",
|
|
});
|
|
});
|
|
|
|
it("sets and unsets an upstream, and merges into the current branch", async () => {
|
|
setup();
|
|
await userEvent.click(await screen.findByText("feature/very-long-branch-name"));
|
|
await userEvent.click(screen.getByRole("button", { name: /Set upstream/ }));
|
|
await userEvent.selectOptions(screen.getByRole("combobox"), "origin/remote-only");
|
|
await userEvent.click(screen.getByRole("button", { name: /Apply/ }));
|
|
expect(api.lanes.gitRefAction).toHaveBeenCalledWith(3, "branch-upstream", {
|
|
branch: "feature/very-long-branch-name",
|
|
upstream: "origin/remote-only",
|
|
});
|
|
await userEvent.click(screen.getByRole("button", { name: /Merge into main/ }));
|
|
expect(api.lanes.gitRefAction).toHaveBeenCalledWith(3, "merge", {
|
|
branch: "feature/very-long-branch-name",
|
|
});
|
|
await userEvent.click(screen.getByText("main"));
|
|
await userEvent.click(screen.getByRole("button", { name: /Unset upstream/ }));
|
|
expect(api.lanes.gitRefAction).toHaveBeenCalledWith(3, "branch-upstream", { branch: "main" });
|
|
});
|
|
|
|
it("shows git's own words when a write is refused", async () => {
|
|
vi.mocked(api.lanes.gitRefAction).mockRejectedValueOnce(
|
|
Object.assign(new Error("branch x already exists"), { code: "EBRANCHEXISTS" })
|
|
);
|
|
setup();
|
|
await userEvent.click(await screen.findByText("feature/very-long-branch-name"));
|
|
await userEvent.click(screen.getByRole("button", { name: /^Rename$/ }));
|
|
await userEvent.type(screen.getByRole("textbox", { name: "New name" }), "x{Enter}");
|
|
expect(await screen.findByTestId("branch-manager-error")).toHaveTextContent(
|
|
"branch x already exists"
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("TagManager (Tags tab)", () => {
|
|
const openTags = async () => {
|
|
setup();
|
|
await screen.findByText("main");
|
|
await userEvent.click(screen.getByRole("tab", { name: /Tags/ }));
|
|
};
|
|
|
|
it("lists tags with their message and creates an annotated one", async () => {
|
|
await openTags();
|
|
expect(screen.getByText("v1")).toBeInTheDocument();
|
|
expect(screen.getByText(/release one/)).toBeInTheDocument();
|
|
await userEvent.click(screen.getByRole("button", { name: /New tag/ }));
|
|
await userEvent.type(screen.getByRole("textbox", { name: /Tag name/ }), "v2");
|
|
await userEvent.type(
|
|
screen.getByRole("textbox", { name: /^On / }),
|
|
"feature/very-long-branch-name"
|
|
);
|
|
await userEvent.type(screen.getByRole("textbox", { name: /Message/ }), "notes");
|
|
await userEvent.click(screen.getByRole("button", { name: "Create annotated tag" }));
|
|
expect(api.lanes.gitRefAction).toHaveBeenCalledWith(3, "tag-create", {
|
|
tag: "v2",
|
|
message: "notes",
|
|
from: "feature/very-long-branch-name",
|
|
});
|
|
});
|
|
|
|
it("pushes one tag and deletes one only with confirm: true", async () => {
|
|
await openTags();
|
|
await userEvent.click(screen.getByText("v1"));
|
|
await userEvent.click(screen.getByRole("button", { name: /Push to origin/ }));
|
|
expect(api.lanes.gitRefAction).toHaveBeenCalledWith(3, "tag-push", { tag: "v1" });
|
|
await userEvent.click(screen.getByRole("button", { name: /Delete tag/ }));
|
|
expect(vi.mocked(confirmDialog).mock.lastCall?.[0].destructive).toBe(true);
|
|
expect(api.lanes.gitRefAction).toHaveBeenCalledWith(3, "tag-delete", {
|
|
tag: "v1",
|
|
confirm: true,
|
|
});
|
|
});
|
|
|
|
it("opens from the git bar's button and closes on Escape", async () => {
|
|
const { BranchesButton } = await import("../git/BranchesButton");
|
|
render(<BranchesButton laneId={3} dirty={0} merging={false} onChanged={vi.fn()} />);
|
|
await userEvent.click(screen.getByRole("button", { name: "Manage branches & tags" }));
|
|
expect(await screen.findByTestId("branch-manager")).toBeInTheDocument();
|
|
await screen.findByText("main");
|
|
await userEvent.keyboard("{Escape}");
|
|
expect(screen.queryByTestId("branch-manager")).toBeNull();
|
|
});
|
|
|
|
it("does not delete when the confirm is cancelled", async () => {
|
|
vi.mocked(confirmDialog).mockResolvedValueOnce(false);
|
|
await openTags();
|
|
await userEvent.click(screen.getByText("v1"));
|
|
await userEvent.click(screen.getByRole("button", { name: /Delete tag/ }));
|
|
expect(api.lanes.gitRefAction).not.toHaveBeenCalled();
|
|
});
|
|
});
|