2b5d7268ed
Server: git-rewrite.js (rebase + continue/skip/abort, rebase-interactive with no editor, reset soft/mixed/keep/hard, undo-rewrite, force-push with lease, remote-branch-delete, remote add/rename/remove) and git-conflicts.js (conflict stages read + conflict-save, reflog), dispatched from runGitAction; operationInProgress now reports a rebase and only ALLOWED_WHILE_REBASING writes run during one. New reads: /git/conflict, /git/reflog, /git/remotes, /git/rewrite-plan. Client: History tools menu, MergeEditor, ReflogView, RebaseDialog, InteractiveRebase, ResetDialog, ForcePushDialog, RemotesDialog, rebase banner Skip. CLAUDE.md git-writes rule rewritten for the new actions.
321 lines
11 KiB
TypeScript
321 lines
11 KiB
TypeScript
/**
|
|
* @file Tests for the Changes panel's history tools: the 3-way merge editor
|
|
* (three panes, per-block accept/both/edit, "N left", Save only when every
|
|
* block is chosen and it sends the composed result with the opened sha), the
|
|
* rebase banner's Skip, the interactive-rebase list (pushed warning + tick,
|
|
* reorder, squash/reword/drop in the todo, two-click run, Undo after) and the
|
|
* reset dialog (mode, hard needs a clean tree and the typed branch name).
|
|
* @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 { MergeEditor, composeResult } from "../MergeEditor";
|
|
import { InteractiveRebase, todoProblem } from "../InteractiveRebase";
|
|
import { ResetDialog } from "../ResetDialog";
|
|
import { RebaseSkipButton } from "../GitTools";
|
|
import { gitRewrite } from "../../../../lib/gitRewrite";
|
|
import { parseConflicts } from "../../../../lib/conflicts";
|
|
|
|
vi.mock("../../../../lib/gitRewrite", async (orig) => {
|
|
const real = await orig<typeof import("../../../../lib/gitRewrite")>();
|
|
return {
|
|
...real,
|
|
gitRewrite: {
|
|
conflict: vi.fn(),
|
|
planInteractive: vi.fn(),
|
|
planReset: vi.fn(),
|
|
run: vi.fn(),
|
|
},
|
|
};
|
|
});
|
|
vi.mock("../../../../lib/toast", () => ({
|
|
toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() },
|
|
}));
|
|
|
|
const WORKING = [
|
|
"top",
|
|
"<<<<<<< HEAD",
|
|
"mine",
|
|
"=======",
|
|
"theirs",
|
|
">>>>>>> feat",
|
|
"middle",
|
|
"<<<<<<< HEAD",
|
|
"a",
|
|
"=======",
|
|
"b",
|
|
">>>>>>> feat",
|
|
"",
|
|
].join("\n");
|
|
|
|
const HEAD = "a".repeat(40);
|
|
const commit = (n: number, pushed = false) => ({
|
|
sha: String(n).repeat(40),
|
|
short: String(n).repeat(7),
|
|
subject: `c${n}`,
|
|
message: `c${n}`,
|
|
pushed,
|
|
});
|
|
|
|
beforeEach(() => {
|
|
vi.mocked(gitRewrite.conflict)
|
|
.mockReset()
|
|
.mockResolvedValue({
|
|
available: true,
|
|
path: "a.txt",
|
|
operation: "merge",
|
|
branch: "main",
|
|
incoming: { sha: "f".repeat(40), short: "fffffff", subject: "feat work" },
|
|
base: "top\nbase\n",
|
|
ours: "top\nmine\n",
|
|
theirs: "top\ntheirs\n",
|
|
working: WORKING,
|
|
sha: "s1",
|
|
usable: true,
|
|
binary: false,
|
|
tooBig: false,
|
|
});
|
|
vi.mocked(gitRewrite.run).mockReset().mockResolvedValue({ action: "x", ok: true, output: "" });
|
|
});
|
|
|
|
describe("MergeEditor", () => {
|
|
it("shows three panes and saves the composed result once every block is chosen", async () => {
|
|
const user = userEvent.setup();
|
|
const onResolved = vi.fn();
|
|
render(<MergeEditor target={3} path="a.txt" onClose={vi.fn()} onResolved={onResolved} />);
|
|
const incoming = await screen.findByTestId("merge-incoming");
|
|
expect(incoming).toHaveTextContent("Incoming: fffffff feat work");
|
|
expect(incoming).toHaveTextContent("theirs");
|
|
expect(screen.getByTestId("merge-current")).toHaveTextContent("Current: main");
|
|
expect(screen.getByTestId("merge-left")).toHaveTextContent("2 of 2 conflicts left");
|
|
const save = screen.getByTestId("merge-save");
|
|
expect(save).toBeDisabled();
|
|
|
|
const [first, second] = screen.getAllByTestId("merge-block") as [HTMLElement, HTMLElement];
|
|
await user.click(within(first).getByTestId("merge-accept-incoming"));
|
|
expect(screen.getByTestId("merge-left")).toHaveTextContent("1 of 2 conflicts left");
|
|
await user.click(within(second).getByTestId("merge-both-ic"));
|
|
expect(screen.getByTestId("merge-left")).toHaveTextContent("Every conflict has a choice");
|
|
|
|
await user.click(save);
|
|
await waitFor(() =>
|
|
expect(gitRewrite.run).toHaveBeenCalledWith(3, "conflict-save", {
|
|
path: "a.txt",
|
|
content: "top\ntheirs\nmiddle\nb\na\n",
|
|
baseSha: "s1",
|
|
})
|
|
);
|
|
expect(onResolved).toHaveBeenCalled();
|
|
});
|
|
|
|
it("edits one block by hand, and the base pane is one click away", async () => {
|
|
const user = userEvent.setup();
|
|
render(<MergeEditor target={3} path="a.txt" onClose={vi.fn()} onResolved={vi.fn()} />);
|
|
const [first] = (await screen.findAllByTestId("merge-block")) as [HTMLElement];
|
|
await user.click(within(first).getByTestId("merge-edit-block"));
|
|
const box = within(first).getByTestId("merge-block-text");
|
|
await user.clear(box);
|
|
await user.type(box, "hand");
|
|
await user.click(within(first).getByRole("button", { name: "Done" }));
|
|
expect(first).toHaveTextContent("hand");
|
|
expect(screen.queryByTestId("merge-base")).toBeNull();
|
|
await user.click(screen.getByLabelText("Show base"));
|
|
expect(screen.getByTestId("merge-base")).toHaveTextContent("base");
|
|
});
|
|
|
|
it("labels the sides for a rebase and explains a refused save", async () => {
|
|
vi.mocked(gitRewrite.conflict).mockResolvedValueOnce({
|
|
...(await vi.mocked(gitRewrite.conflict).getMockImplementation()!(3, "a.txt")),
|
|
operation: "rebase",
|
|
});
|
|
vi.mocked(gitRewrite.run).mockRejectedValueOnce(
|
|
Object.assign(new Error("x"), { code: "EMARKERS" })
|
|
);
|
|
const user = userEvent.setup();
|
|
render(<MergeEditor target={3} path="a.txt" onClose={vi.fn()} onResolved={vi.fn()} />);
|
|
expect(await screen.findByTestId("merge-current")).toHaveTextContent(
|
|
"the branch you are rebasing onto"
|
|
);
|
|
await user.click(screen.getByTestId("merge-whole-toggle"));
|
|
await user.click(screen.getByTestId("merge-save"));
|
|
expect(await screen.findByRole("alert")).toHaveTextContent("still has conflict markers");
|
|
});
|
|
|
|
it("composeResult keeps markers on an unresolved block", () => {
|
|
const parts = parseConflicts(WORKING.split("\n"))!;
|
|
expect(composeResult(parts, [{ kind: "ours" }])).toBe(
|
|
"top\nmine\nmiddle\n<<<<<<< HEAD\na\n=======\nb\n>>>>>>> feat\n"
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("RebaseSkipButton", () => {
|
|
it("skips the stopped commit and re-reads", async () => {
|
|
const user = userEvent.setup();
|
|
const onChanged = vi.fn();
|
|
render(<RebaseSkipButton target={3} onChanged={onChanged} />);
|
|
await user.click(screen.getByTestId("rebase-skip"));
|
|
await waitFor(() => expect(onChanged).toHaveBeenCalled());
|
|
expect(gitRewrite.run).toHaveBeenCalledWith(3, "rebase-skip", {});
|
|
});
|
|
});
|
|
|
|
describe("InteractiveRebase", () => {
|
|
beforeEach(() => {
|
|
vi.mocked(gitRewrite.planInteractive)
|
|
.mockReset()
|
|
.mockResolvedValue({
|
|
available: true,
|
|
kind: "interactive",
|
|
head: HEAD,
|
|
branch: "feat",
|
|
subdir: false,
|
|
operation: null,
|
|
dirty: false,
|
|
base: "b".repeat(40),
|
|
merges: false,
|
|
commits: [commit(1, true), commit(2), commit(3)],
|
|
pushed: 1,
|
|
});
|
|
});
|
|
|
|
it("warns about pushed commits, needs the tick, and sends the edited todo", async () => {
|
|
const user = userEvent.setup();
|
|
vi.mocked(gitRewrite.run).mockResolvedValueOnce({
|
|
action: "rebase-interactive",
|
|
ok: true,
|
|
output: "",
|
|
before: HEAD,
|
|
after: "c".repeat(40),
|
|
conflicts: [],
|
|
stopped: false,
|
|
needsForcePush: true,
|
|
});
|
|
render(<InteractiveRebase target={3} onClose={vi.fn()} onChanged={vi.fn()} />);
|
|
expect(await screen.findByTestId("interactive-summary")).toHaveTextContent(
|
|
"This rewrites 3 commits; 1 are already pushed, so a force-push will be needed."
|
|
);
|
|
const run = screen.getByTestId("interactive-run");
|
|
expect(run).toBeDisabled(); // unchanged + pushed not acknowledged
|
|
|
|
const rows = screen.getAllByTestId("interactive-row") as HTMLElement[];
|
|
await user.selectOptions(within(rows[1]!).getByTestId("interactive-action"), "squash");
|
|
await user.selectOptions(within(rows[2]!).getByTestId("interactive-action"), "reword");
|
|
const msg = within(rows[2]!).getByTestId("interactive-message");
|
|
await user.clear(msg);
|
|
await user.type(msg, "new words");
|
|
await user.click(within(rows[2]!).getByRole("button", { name: "Move up" }));
|
|
expect(run).toBeDisabled();
|
|
await user.click(screen.getByTestId("interactive-force"));
|
|
expect(run).toBeEnabled();
|
|
|
|
await user.click(run);
|
|
expect(gitRewrite.run).not.toHaveBeenCalled(); // armed only
|
|
await user.click(screen.getByRole("button", { name: "Click again to rewrite" }));
|
|
await waitFor(() =>
|
|
expect(gitRewrite.run).toHaveBeenCalledWith(3, "rebase-interactive", {
|
|
head: HEAD,
|
|
confirm: true,
|
|
force: true,
|
|
todo: [
|
|
{ sha: commit(1).sha, action: "pick" },
|
|
{ sha: commit(3).sha, action: "reword", message: "new words" },
|
|
{ sha: commit(2).sha, action: "squash" },
|
|
],
|
|
})
|
|
);
|
|
expect(await screen.findByTestId("git-rewrite-done")).toHaveTextContent(
|
|
"Force push (with lease)"
|
|
);
|
|
expect(screen.getByTestId("git-rewrite-undo")).toBeInTheDocument();
|
|
});
|
|
|
|
it("todoProblem refuses a squash with nothing above and an empty reword", () => {
|
|
const c = commit(1);
|
|
expect(todoProblem([{ commit: c, action: "squash", message: "" }])).toBe("first");
|
|
expect(todoProblem([{ commit: c, action: "drop", message: "" }])).toBe("allDropped");
|
|
expect(todoProblem([{ commit: c, action: "reword", message: " " }])).toBe("reword");
|
|
expect(todoProblem([{ commit: c, action: "pick", message: "" }])).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("ResetDialog", () => {
|
|
const plan = (over = {}) => ({
|
|
available: true,
|
|
kind: "reset" as const,
|
|
head: HEAD,
|
|
branch: "feat",
|
|
subdir: false,
|
|
operation: null,
|
|
dirty: false,
|
|
target: "d".repeat(40),
|
|
related: true,
|
|
dropped: 2,
|
|
added: 0,
|
|
pushed: 0,
|
|
...over,
|
|
});
|
|
|
|
it("hard needs the branch name typed back", async () => {
|
|
vi.mocked(gitRewrite.planReset).mockReset().mockResolvedValue(plan());
|
|
vi.mocked(gitRewrite.run).mockResolvedValueOnce({
|
|
action: "reset",
|
|
ok: true,
|
|
output: "",
|
|
before: HEAD,
|
|
after: "d".repeat(40),
|
|
});
|
|
const user = userEvent.setup();
|
|
render(
|
|
<ResetDialog
|
|
target={3}
|
|
commit={{ sha: "d".repeat(40), short: "ddddddd", subject: "old" }}
|
|
onClose={vi.fn()}
|
|
onChanged={vi.fn()}
|
|
/>
|
|
);
|
|
expect(await screen.findByTestId("reset-summary")).toHaveTextContent("2 commit(s) leave feat");
|
|
await user.click(screen.getByTestId("reset-mode-hard"));
|
|
const run = screen.getByTestId("reset-run");
|
|
expect(run).toBeDisabled();
|
|
await user.type(screen.getByTestId("reset-confirm-branch"), "feat");
|
|
expect(run).toBeEnabled();
|
|
await user.click(run);
|
|
await waitFor(() =>
|
|
expect(gitRewrite.run).toHaveBeenCalledWith(3, "reset", {
|
|
target: "d".repeat(40),
|
|
mode: "hard",
|
|
head: HEAD,
|
|
confirm: true,
|
|
confirmBranch: "feat",
|
|
})
|
|
);
|
|
expect(await screen.findByTestId("git-rewrite-undo")).toBeInTheDocument();
|
|
});
|
|
|
|
it("refuses hard on a dirty tree and asks for the tick on pushed commits", async () => {
|
|
vi.mocked(gitRewrite.planReset)
|
|
.mockReset()
|
|
.mockResolvedValue(plan({ dirty: true, pushed: 1 }));
|
|
const user = userEvent.setup();
|
|
render(
|
|
<ResetDialog
|
|
target={3}
|
|
commit={{ sha: "d".repeat(40) }}
|
|
onClose={vi.fn()}
|
|
onChanged={vi.fn()}
|
|
/>
|
|
);
|
|
await screen.findByTestId("reset-pushed");
|
|
const run = screen.getByTestId("reset-run");
|
|
expect(run).toBeDisabled();
|
|
await user.click(screen.getByTestId("reset-force"));
|
|
expect(run).toBeEnabled(); // mixed works on a dirty tree
|
|
await user.click(screen.getByTestId("reset-mode-hard"));
|
|
expect(screen.getByTestId("reset-hard-dirty")).toBeInTheDocument();
|
|
expect(run).toBeDisabled();
|
|
});
|
|
});
|