aff79a787c
What broke: a lane on a fresh `git init` repo had no working git surface.
`statusCounts` ran `rev-parse --short HEAD` unguarded, so `gitFacts` threw
and `GET /api/lanes/:id/git` answered `{available:false}` — the Changes
panel showed no header, and `commit`/`switch` 500'd through `isDirty`.
Add lane in Worktree mode accepted such a repo and left a `failed` row
behind, and a repo with commits but no remote had a permanently disabled
Push with no way to give it one short of a terminal.
What changed:
- worktree.js: an unborn HEAD is a normal state. `statusCounts` reports
`head: null`; `gitFacts` reads the branch via `symbolic-ref`, an empty
subject, and a new `unborn` field.
- git-ops.js: an 11th write, `publish` — `remote add origin -- <url>` then
`push -u origin HEAD`, only with a commit (ENOCOMMITS) and NO remote at
all (EREMOTEEXISTS, so it never re-points one), the url allowlisted by
`validateRemoteUrl` (https without userinfo, ssh://, scp-like; refuses
local paths, file://, ext::, fd::, git://, http://, leading `-`:
EBADURL), and `origin` removed again when the push fails.
- POST /api/lanes/worktree answers 400 EEMPTYREPO for a repo with no
commits instead of creating a row that can only fail.
- LaneGitBar: unborn repos show "no commits yet" and a commit-first hint;
no remote offers Publish… with an inline URL box; a remote but no
upstream relabels Push as "Publish branch". AddLaneModal disables Add
on an empty repo and offers "Use as a Repo lane".
- The two tests that used an unborn repo to force a provisioning failure
now use a `feat` ref clash, since that repo is refused up front.
- CLAUDE.md rule reworded to "the boring eleven" with publish's guards;
docs/API.md, docs/LANES.md, ARCHITECTURE.md, server/README.md, openapi.
407 lines
16 KiB
TypeScript
407 lines
16 KiB
TypeScript
/**
|
|
* @file LaneGitBar.test.tsx
|
|
* @description Pins the lane git action row: the ahead/behind readout, which
|
|
* buttons a repo with no remote may press (and that it may publish to a typed URL),
|
|
* what an unborn repo and a branch with no upstream show, that it offers no commit (the
|
|
* Changes panel's staged-only box owns that), that a dirty tree blocks the branch switch client-side (the server
|
|
* refuses it too), and that a failed action shows git's own words rather than
|
|
* a generic sentence.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import { render, screen, waitFor } from "@testing-library/react";
|
|
import userEvent from "@testing-library/user-event";
|
|
import { LaneGitBar } from "../LaneGitBar";
|
|
import { api } from "../../../lib/api";
|
|
import type { LaneGitFacts } from "../../../lib/types";
|
|
|
|
vi.mock("../../../lib/api", () => ({
|
|
api: { lanes: { gitAction: vi.fn(), gitBranches: vi.fn() } },
|
|
}));
|
|
|
|
function facts(over: Partial<Extract<LaneGitFacts, { available: true }>> = {}): LaneGitFacts {
|
|
return {
|
|
available: true,
|
|
branch: "feat/thing",
|
|
head: "9b3e74a",
|
|
unborn: false,
|
|
subject: "wip",
|
|
dirty: 2,
|
|
untracked: 1,
|
|
files: [],
|
|
hasRemote: true,
|
|
upstream: "origin/feat/thing",
|
|
ahead: 2,
|
|
behind: 1,
|
|
...over,
|
|
};
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.mocked(api.lanes.gitAction).mockReset().mockResolvedValue({
|
|
action: "fetch",
|
|
ok: true,
|
|
output: "",
|
|
});
|
|
vi.mocked(api.lanes.gitBranches)
|
|
.mockReset()
|
|
.mockResolvedValue({
|
|
available: true,
|
|
branches: ["development", "feat/thing"],
|
|
current: "feat/thing",
|
|
worktrees: [],
|
|
});
|
|
});
|
|
|
|
describe("LaneGitBar", () => {
|
|
it("shows both sides of the upstream comparison", () => {
|
|
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
|
|
const tracking = screen.getByTestId("lane-git-tracking");
|
|
expect(tracking).toHaveTextContent("2");
|
|
expect(tracking).toHaveTextContent("1");
|
|
});
|
|
|
|
it("disables the network actions when the repo has no remote", () => {
|
|
render(
|
|
<LaneGitBar
|
|
laneId={3}
|
|
git={facts({ hasRemote: false, upstream: null })}
|
|
onChanged={vi.fn()}
|
|
/>
|
|
);
|
|
expect(screen.getByRole("button", { name: /Fetch/ })).toBeDisabled();
|
|
expect(screen.getByRole("button", { name: /Push/ })).toBeDisabled();
|
|
// Local actions stay available — a repo with no remote still branches.
|
|
expect(screen.getByRole("button", { name: /Branch/ })).toBeEnabled();
|
|
});
|
|
|
|
it("offers no commit at all — that is the Changes panel's staged-only box", () => {
|
|
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
|
|
expect(screen.queryByRole("button", { name: /Commit/ })).toBeNull();
|
|
});
|
|
|
|
it("shows an unborn repo's branch and asks for a first commit instead of a push", () => {
|
|
render(
|
|
<LaneGitBar
|
|
laneId={3}
|
|
git={facts({
|
|
branch: "main",
|
|
head: null,
|
|
unborn: true,
|
|
subject: "",
|
|
hasRemote: false,
|
|
upstream: null,
|
|
ahead: null,
|
|
behind: null,
|
|
})}
|
|
onChanged={vi.fn()}
|
|
/>
|
|
);
|
|
expect(screen.getByTestId("lane-git-bar")).toHaveTextContent("no commits yet");
|
|
expect(screen.getByText(/Commit first, then publish/)).toBeInTheDocument();
|
|
expect(screen.queryByRole("button", { name: /Fetch|Pull|Push|Publish/ })).toBeNull();
|
|
});
|
|
|
|
it("publishes a repo with no remote to a typed URL", async () => {
|
|
const user = userEvent.setup();
|
|
const onChanged = vi.fn();
|
|
render(
|
|
<LaneGitBar
|
|
laneId={3}
|
|
git={facts({ hasRemote: false, upstream: null, ahead: null, behind: null })}
|
|
onChanged={onChanged}
|
|
/>
|
|
);
|
|
|
|
await user.click(screen.getByRole("button", { name: "Publish…" }));
|
|
await user.type(screen.getByLabelText(/Remote URL/), "git@host:owner/repo.git");
|
|
await user.click(screen.getByRole("button", { name: "Publish" }));
|
|
|
|
await waitFor(() =>
|
|
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "publish", {
|
|
url: "git@host:owner/repo.git",
|
|
})
|
|
);
|
|
expect(onChanged).toHaveBeenCalled();
|
|
});
|
|
|
|
it("calls a first push of a branch with no upstream 'Publish branch'", async () => {
|
|
const user = userEvent.setup();
|
|
render(
|
|
<LaneGitBar
|
|
laneId={3}
|
|
git={facts({ upstream: null, ahead: null, behind: null })}
|
|
onChanged={vi.fn()}
|
|
/>
|
|
);
|
|
expect(screen.queryByRole("button", { name: /^Push$/ })).toBeNull();
|
|
await user.click(screen.getByRole("button", { name: "Publish branch" }));
|
|
await waitFor(() => expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "push", {}));
|
|
});
|
|
|
|
it("loads the branch list on demand and refuses to switch while dirty", async () => {
|
|
const user = userEvent.setup();
|
|
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
|
|
|
|
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
|
await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledWith(3));
|
|
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
|
|
// Picking is harmless; applying is what a dirty tree blocks.
|
|
expect(screen.getByRole("button", { name: "Switch branch" })).toBeDisabled();
|
|
expect(api.lanes.gitAction).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("switches branch on a clean tree", async () => {
|
|
const user = userEvent.setup();
|
|
render(<LaneGitBar laneId={3} git={facts({ dirty: 0, untracked: 0 })} onChanged={vi.fn()} />);
|
|
|
|
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
|
const select = await screen.findByLabelText("Switch branch…");
|
|
await user.selectOptions(select, "development");
|
|
await user.click(screen.getByRole("button", { name: "Switch branch" }));
|
|
|
|
await waitFor(() =>
|
|
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "switch", { branch: "development" })
|
|
);
|
|
});
|
|
|
|
it("re-reads the branch list after a switch, so the branch just left is not held", async () => {
|
|
const user = userEvent.setup();
|
|
render(<LaneGitBar laneId={3} git={facts({ dirty: 0, untracked: 0 })} onChanged={vi.fn()} />);
|
|
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
|
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
|
|
await user.click(screen.getByRole("button", { name: "Switch branch" }));
|
|
await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledTimes(2));
|
|
});
|
|
|
|
it("creates a branch without switching, then offers a sibling lane on it", async () => {
|
|
const user = userEvent.setup();
|
|
const onNewLane = vi.fn();
|
|
render(
|
|
<LaneGitBar
|
|
laneId={3}
|
|
git={facts({ dirty: 0, untracked: 0 })}
|
|
onChanged={vi.fn()}
|
|
onNewLane={onNewLane}
|
|
/>
|
|
);
|
|
|
|
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
|
await user.type(await screen.findByLabelText("New branch name…"), "feat/sibling");
|
|
await user.click(screen.getByRole("button", { name: "Create" }));
|
|
await waitFor(() =>
|
|
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "branch-create", {
|
|
branch: "feat/sibling",
|
|
})
|
|
);
|
|
|
|
await user.selectOptions(screen.getByLabelText("Switch branch…"), "development");
|
|
await user.click(screen.getByRole("button", { name: "New lane here" }));
|
|
expect(onNewLane).toHaveBeenCalledWith("development");
|
|
});
|
|
|
|
it("offers a sibling lane only on a branch no worktree holds — the server refuses the rest", async () => {
|
|
vi.mocked(api.lanes.gitBranches).mockResolvedValue({
|
|
available: true,
|
|
branches: ["development", "feat/thing", "feat/free"],
|
|
current: "feat/thing",
|
|
worktrees: [
|
|
{ path: "/repo/lane3", branch: "feat/thing" },
|
|
{ path: "/repo/dev", branch: "development" },
|
|
],
|
|
});
|
|
const user = userEvent.setup();
|
|
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} onNewLane={vi.fn()} />);
|
|
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
|
const select = await screen.findByLabelText("Switch branch…");
|
|
const newLane = () => screen.getByRole("button", { name: "New lane here" });
|
|
|
|
// This lane's own branch: its worktree is this lane.
|
|
await user.selectOptions(select, "feat/thing");
|
|
expect(newLane()).toBeDisabled();
|
|
// Held by another worktree: git allows one worktree per branch (EBRANCHBUSY).
|
|
await user.selectOptions(select, "development");
|
|
expect(newLane()).toBeDisabled();
|
|
expect(newLane()).toHaveAttribute("title", expect.stringContaining("/repo/dev"));
|
|
|
|
await user.selectOptions(select, "feat/free");
|
|
expect(newLane()).toBeEnabled();
|
|
});
|
|
|
|
it("prints git's own rejection text when an action fails", async () => {
|
|
const user = userEvent.setup();
|
|
vi.mocked(api.lanes.gitAction).mockRejectedValue(
|
|
new Error("git push failed\n! [rejected] main -> main (non-fast-forward)")
|
|
);
|
|
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
|
|
|
|
await user.click(screen.getByRole("button", { name: /Push/ }));
|
|
expect(await screen.findByTestId("lane-git-bar-error")).toHaveTextContent("non-fast-forward");
|
|
});
|
|
|
|
it("re-reads the branch list for the new lane instead of showing the old one's", async () => {
|
|
const user = userEvent.setup();
|
|
const { rerender } = render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
|
|
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
|
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
|
|
|
|
rerender(<LaneGitBar laneId={4} git={facts()} onChanged={vi.fn()} />);
|
|
expect(screen.queryByLabelText("Switch branch…")).toBeNull();
|
|
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
|
await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledWith(4));
|
|
expect(await screen.findByLabelText("Switch branch…")).toHaveValue("");
|
|
});
|
|
|
|
it("sends one branch-create for a double Enter", async () => {
|
|
vi.mocked(api.lanes.gitAction).mockReturnValue(new Promise(() => {}));
|
|
const user = userEvent.setup();
|
|
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
|
|
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
|
await user.type(await screen.findByLabelText(/New branch/), "feat/once{Enter}{Enter}");
|
|
expect(api.lanes.gitAction).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("forgets which branches the previous lane's worktrees held once the lane changes", async () => {
|
|
let resolveOld: (v: Awaited<ReturnType<typeof api.lanes.gitBranches>>) => void = () => {};
|
|
vi.mocked(api.lanes.gitBranches).mockReturnValueOnce(
|
|
new Promise((r) => {
|
|
resolveOld = r;
|
|
})
|
|
);
|
|
const user = userEvent.setup();
|
|
const { rerender } = render(
|
|
<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} onNewLane={vi.fn()} />
|
|
);
|
|
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
|
|
|
rerender(<LaneGitBar laneId={4} git={facts()} onChanged={vi.fn()} onNewLane={vi.fn()} />);
|
|
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
|
const select = await screen.findByLabelText("Switch branch…");
|
|
await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledWith(4));
|
|
// Lane 3's answer lands late: its worktree held `development`.
|
|
resolveOld({
|
|
available: true,
|
|
branches: ["development", "feat/thing"],
|
|
current: "feat/thing",
|
|
worktrees: [{ path: "/repo/dev", branch: "development" }],
|
|
});
|
|
await user.selectOptions(select, "development");
|
|
const newLane = screen.getByRole("button", { name: "New lane here" });
|
|
expect(newLane).toBeEnabled();
|
|
expect(newLane).not.toHaveAttribute("title", expect.stringContaining("/repo/dev"));
|
|
});
|
|
|
|
it("claims no sync state when the upstream comparison could not be counted", () => {
|
|
render(
|
|
<LaneGitBar
|
|
laneId={3}
|
|
git={facts({ upstream: "origin/gone", ahead: null, behind: null })}
|
|
onChanged={vi.fn()}
|
|
/>
|
|
);
|
|
expect(screen.queryByText("in sync")).toBeNull();
|
|
expect(screen.queryByTestId("lane-git-tracking")).toBeNull();
|
|
});
|
|
|
|
it("explains a push with nowhere to go instead of the server's generic text", async () => {
|
|
const user = userEvent.setup();
|
|
vi.mocked(api.lanes.gitAction).mockRejectedValue(
|
|
Object.assign(new Error("this repository has no remote to push to"), { code: "ENOREMOTE" })
|
|
);
|
|
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
|
|
await user.click(screen.getByRole("button", { name: /Push/ }));
|
|
expect(await screen.findByTestId("lane-git-bar-error")).toHaveTextContent(
|
|
"No remote to push to or pull from"
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("git login (issue #45)", () => {
|
|
const authErr = (rejected = false) =>
|
|
Object.assign(new Error("git.smartgift.io.vn needs a username and password or access token"), {
|
|
code: "EGITAUTH",
|
|
auth: { protocol: "https", host: "git.smartgift.io.vn", username: "vi", rejected },
|
|
});
|
|
|
|
it("asks for a login when git needs one, then resends the same action with it", async () => {
|
|
vi.mocked(api.lanes.gitAction).mockRejectedValueOnce(authErr());
|
|
const user = userEvent.setup();
|
|
const onChanged = vi.fn();
|
|
render(<LaneGitBar laneId={3} git={facts()} onChanged={onChanged} />);
|
|
|
|
await user.click(screen.getByRole("button", { name: /Push/ }));
|
|
const form = await screen.findByTestId("lane-git-login");
|
|
expect(form).toHaveTextContent("git.smartgift.io.vn");
|
|
expect(screen.getByLabelText("Username")).toHaveValue("vi");
|
|
expect(screen.getByLabelText(/Remember for everyone/)).toBeChecked();
|
|
await user.type(screen.getByLabelText("Password or access token"), "s3cret");
|
|
await user.click(screen.getByRole("button", { name: "Log in and retry" }));
|
|
|
|
await waitFor(() =>
|
|
expect(api.lanes.gitAction).toHaveBeenLastCalledWith(3, "push", {
|
|
credentials: {
|
|
protocol: "https",
|
|
host: "git.smartgift.io.vn",
|
|
username: "vi",
|
|
password: "s3cret",
|
|
remember: true,
|
|
},
|
|
})
|
|
);
|
|
await waitFor(() => expect(screen.queryByTestId("lane-git-login")).toBeNull());
|
|
expect(onChanged).toHaveBeenCalled();
|
|
expect(document.body.textContent).not.toContain("s3cret");
|
|
});
|
|
|
|
it("says the login was wrong when the host rejected it, and keeps the form", async () => {
|
|
vi.mocked(api.lanes.gitAction)
|
|
.mockRejectedValueOnce(authErr())
|
|
.mockRejectedValueOnce(authErr(true));
|
|
const user = userEvent.setup();
|
|
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
|
|
await user.click(screen.getByRole("button", { name: /Fetch/ }));
|
|
await user.type(await screen.findByLabelText("Password or access token"), "bad");
|
|
await user.click(screen.getByRole("button", { name: "Log in and retry" }));
|
|
expect(await screen.findByTestId("lane-git-login")).toHaveTextContent(/rejected/i);
|
|
expect(screen.getByLabelText("Password or access token")).toHaveValue("");
|
|
});
|
|
});
|
|
|
|
describe("merge (issue #45)", () => {
|
|
it("merges the picked branch into the current one after a confirm", async () => {
|
|
const confirm = vi.spyOn(window, "confirm").mockReturnValue(true);
|
|
const user = userEvent.setup();
|
|
render(<LaneGitBar laneId={3} git={facts({ dirty: 0, untracked: 0 })} onChanged={vi.fn()} />);
|
|
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
|
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
|
|
await user.click(screen.getByRole("button", { name: "Merge into current" }));
|
|
await waitFor(() =>
|
|
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "merge", { branch: "development" })
|
|
);
|
|
confirm.mockRestore();
|
|
});
|
|
|
|
it("offers to merge the upstream when the branch has diverged", async () => {
|
|
vi.spyOn(window, "confirm").mockReturnValue(true);
|
|
const user = userEvent.setup();
|
|
render(<LaneGitBar laneId={3} git={facts({ dirty: 0, untracked: 0 })} onChanged={vi.fn()} />);
|
|
await user.click(screen.getByRole("button", { name: "Merge origin/feat/thing" }));
|
|
await waitFor(() =>
|
|
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "merge", { branch: "origin/feat/thing" })
|
|
);
|
|
});
|
|
|
|
it("disables pull and switch while a merge is in progress", () => {
|
|
render(
|
|
<LaneGitBar
|
|
laneId={3}
|
|
git={{ ...facts(), merging: true, conflicts: 1 } as LaneGitFacts}
|
|
onChanged={vi.fn()}
|
|
/>
|
|
);
|
|
expect(screen.getByRole("button", { name: /Pull/ })).toBeDisabled();
|
|
expect(screen.getByRole("button", { name: /Branch/ })).toBeDisabled();
|
|
});
|
|
});
|