feat(lanes): git login from the dashboard, merge and conflict resolution (#45)

Part A — a fetch/pull/push against a remote that needs a login no longer
dies on "terminal prompts disabled": the server answers 409 EGITAUTH with
the host, the git bar asks for a username + password/access token and
resends the same action. AgentHub keeps its OWN credential store
(<CLAUDE_HOME>/agenthub-git-credentials/, 0700/0600), one login per host,
shared by every user of the install, managed from Settings -> Git
credentials. A login is only sent to one of the repo's own remote hosts,
saved only after git accepted it, never returned by any route, and reaches
git through an exclusive credential helper (never argv).

Part B — merge a branch (or the diverged upstream) from the git bar; a
merge that stops on conflicts lists them in the Changes panel with Keep
mine / Take theirs / Mark resolved, then Complete merge or Abort merge
(confirm gate). Clean tree, repo-root lane and an existing ref only;
pull/switch/hunk actions are blocked mid-merge; staging a conflicted file
with markers left is refused. Rebase/reset/stash stay out.
This commit is contained in:
2026-09-25 10:44:40 +07:00
parent 605ca9d65e
commit f514602697
30 changed files with 2005 additions and 59 deletions
@@ -21,6 +21,11 @@
* `POST /api/lanes/:id/git/:action`, and `discard` is asked for twice here
* before the server is told `confirm: true` — it is the one action in this
* panel git cannot undo.
*
* During a merge (issue #45) a banner offers **Complete merge** (only once no
* file is conflicted) and **Abort merge** (two clicks, `confirm: true`), and
* conflicted files get their own group with Keep mine / Take theirs / Mark
* resolved instead of stage — staging one blind would mark it resolved.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -32,9 +37,12 @@ import {
ChevronRight,
ChevronsDownUp,
ChevronsUpDown,
Check,
Columns2,
FileText,
FoldVertical,
ArrowLeftToLine,
ArrowRightToLine,
Folder,
Minus,
Pencil,
@@ -298,6 +306,46 @@ function FileActions({
);
}
type Resolve = (path: string, side: "ours" | "theirs" | "done") => void;
/** A conflicted file's three ways out of the conflict. */
function ConflictActions({
f,
busy,
onResolve,
}: {
f: LaneChangeFile;
busy: boolean;
onResolve: Resolve;
}) {
const { t } = useTranslation(["lanes"]);
return (
<span className="flex shrink-0 items-center gap-0.5">
<IconButton
label={t("changes.takeOurs")}
disabled={busy}
onClick={() => onResolve(f.path, "ours")}
>
<ArrowLeftToLine className="h-3 w-3" />
</IconButton>
<IconButton
label={t("changes.takeTheirs")}
disabled={busy}
onClick={() => onResolve(f.path, "theirs")}
>
<ArrowRightToLine className="h-3 w-3" />
</IconButton>
<IconButton
label={t("changes.markResolved")}
disabled={busy}
onClick={() => onResolve(f.path, "done")}
>
<Check className="h-3 w-3" />
</IconButton>
</span>
);
}
/** One overview row. At module scope on purpose: declared inside the panel it
* was a new component type every render, so each poll and each keystroke in
* the commit box remounted every row (focus dropped to <body>). */
@@ -309,9 +357,11 @@ function ChangeRow({
onOpen,
onAct,
onArm,
onResolve,
}: {
f: LaneChangeFile;
range: LaneChangeRange;
onResolve: Resolve;
busy: boolean;
/** This row's discard has been clicked once and awaits the second click. */
armed: boolean;
@@ -346,9 +396,12 @@ function ChangeRow({
)}
<Delta added={f.added} deleted={f.deleted} binary={f.binary} />
</button>
{range === "work" && (
<FileActions f={f} busy={busy} armed={armed} onAct={onAct} onArm={onArm} />
)}
{range === "work" &&
(f.conflicted ? (
<ConflictActions f={f} busy={busy} onResolve={onResolve} />
) : (
<FileActions f={f} busy={busy} armed={armed} onAct={onAct} onArm={onArm} />
))}
</li>
);
}
@@ -409,6 +462,7 @@ export function LaneChangesPanel({
const [loading, setLoading] = useState(false);
const [busy, setBusy] = useState(false);
const [confirmDiscard, setConfirmDiscard] = useState<string | null>(null);
const [armedAbort, setArmedAbort] = useState(false);
// The hunk whose Undo was clicked once: `${path}\n${header}`.
const [armedHunk, setArmedHunk] = useState<string | null>(null);
// Files folded shut in review mode. A change-set is read file by file, and
@@ -694,6 +748,28 @@ export function LaneChangesPanel({
}
};
// resolve / merge-commit / merge-abort: same shape as `act`, other body.
const mergeAct = async (
action: "resolve" | "merge-commit" | "merge-abort",
body: Parameters<typeof api.lanes.gitAction>[2] = {}
) => {
const lane = laneId;
setBusy(true);
setError(null);
try {
await api.lanes.gitAction(lane, action, body);
if (laneNow.current !== lane) return;
setArmedAbort(false);
refreshGit();
await load();
} catch (err) {
if (laneNow.current === lane) setError(err instanceof Error ? err.message : String(err));
} finally {
if (laneNow.current === lane) setBusy(false);
}
};
const resolve: Resolve = (path, side) => void mergeAct("resolve", { path, side });
// One hunk, matched by its `@@` line against a fresh diff at the context
// this diff was read with — so the server never applies a stale patch.
const actHunk = async (action: "stage-hunk" | "discard-hunk", path: string, hunk: string) => {
@@ -805,9 +881,11 @@ export function LaneChangesPanel({
const files = data?.available ? data.files : [];
const allCollapsed = files.length > 0 && files.every((f) => collapsed.has(f.path));
const stagedCount = range === "work" ? files.filter((f) => f.staged).length : 0;
const stagedCount = range === "work" ? files.filter((f) => f.staged && !f.conflicted).length : 0;
// What "Stage all" sends: every listed file with a change the index lacks.
const toStage = range === "work" ? files.filter((f) => f.untracked || f.unstaged) : [];
// Never a conflicted one — staging it would mark it resolved.
const toStage =
range === "work" ? files.filter((f) => (f.untracked || f.unstaged) && !f.conflicted) : [];
const current = mode === "file" ? files.find((f) => f.path === openFile) : undefined;
// The armed path left the list (committed, reverted, lane switched): disarm,
@@ -1079,6 +1157,38 @@ export function LaneChangesPanel({
</p>
)}
{git?.available && git.merging && (
<div
data-testid="lane-merge-banner"
className="mb-2 flex flex-wrap items-center gap-1.5 rounded border border-status-warning/40 bg-status-warning/10 px-2 py-1 text-[11px] text-status-warning"
>
<span className="min-w-0 flex-1">
{git.conflicts
? t("changes.mergeBanner", { count: git.conflicts })
: t("changes.mergeReady")}
</span>
<button
type="button"
disabled={busy || !!git.conflicts}
onClick={() => void mergeAct("merge-commit")}
className="shrink-0 rounded border border-accent/40 bg-accent/15 px-1.5 py-0.5 text-accent hover:bg-accent/25 disabled:cursor-not-allowed disabled:opacity-40"
>
{t("changes.mergeCommit")}
</button>
<button
type="button"
disabled={busy}
// Two clicks, like discard: aborting drops every resolution made.
onClick={() =>
armedAbort ? void mergeAct("merge-abort", { confirm: true }) : setArmedAbort(true)
}
className="shrink-0 rounded border border-status-danger/40 px-1.5 py-0.5 text-status-danger hover:bg-status-danger/10 disabled:opacity-40"
>
{armedAbort ? t("changes.mergeAbortConfirm") : t("changes.mergeAbort")}
</button>
</div>
)}
{/* In single-file mode the DIFF owns the scrolling, so its horizontal
bar sits at the bottom of the visible box instead of at the bottom of
a 2000-line container nobody has scrolled to yet. Every other mode is
@@ -1207,16 +1317,17 @@ export function LaneChangesPanel({
<div data-testid="lane-changes-overview">
{(range === "work"
? [
{ key: "conflicts", rows: files.filter((f) => f.conflicted) },
{
key: "staged",
rows: files
.filter((f) => f.staged)
.filter((f) => f.staged && !f.conflicted)
.map((f) => ({ ...f, unstaged: false, untracked: false })),
},
{
key: "unstaged",
rows: files
.filter((f) => f.unstaged || f.untracked)
.filter((f) => (f.unstaged || f.untracked) && !f.conflicted)
.map((f) => ({ ...f, staged: false })),
},
]
@@ -1224,7 +1335,11 @@ export function LaneChangesPanel({
).map(
(g) =>
g.rows.length > 0 && (
<div key={g.key} className="mb-1.5">
<div
key={g.key}
className="mb-1.5"
data-testid={g.key === "conflicts" ? "lane-changes-conflicts" : undefined}
>
{g.key !== "all" && (
<div className="flex items-center gap-1.5 px-1 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-fg-secondary">
{t(`changes.${g.key}Group`)}
@@ -1244,6 +1359,7 @@ export function LaneChangesPanel({
onOpen={show}
onAct={(...a) => void act(...a)}
onArm={setConfirmDiscard}
onResolve={resolve}
/>
))}
</ul>
+150 -12
View File
@@ -1,7 +1,9 @@
/**
* @file The lane's git action row: ahead/behind against the upstream, and the
* writes `POST /api/lanes/:id/git/:action` offers that are not per-file —
* fetch, pull, push, switch branch, and create/delete a branch. Committing is
* fetch, pull, push, switch branch, create/delete a branch, and start a merge
* (the picked branch, or the upstream once diverged; resolving it is the
* Changes panel's job). Committing is
* NOT here: it is the Changes panel's staged-only commit box, beside the
* per-file stage/unstage/discard it commits — one way to commit, and it only
* ever takes what the user staged.
@@ -12,17 +14,32 @@
* Nothing here decides what an action may do; the server does (pull is always
* --ff-only, switch refuses a dirty tree), and this only reports what it said.
* Failures print git's own text, because "! [rejected] non-fast-forward" tells
* the user more than any sentence this file could write.
* the user more than any sentence this file could write. The one exception
* is `EGITAUTH` (issue #45): the dashboard's git has no terminal to prompt
* in, so a remote wanting a login opens a form here and the SAME action is
* resent with the typed credential — the token is never rendered back.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { ArrowDown, ArrowUp, GitBranch, Plus, RefreshCw, Trash2, Upload } from "lucide-react";
import { api } from "../../lib/api";
import {
ArrowDown,
ArrowUp,
GitBranch,
GitMerge,
Plus,
RefreshCw,
Trash2,
Upload,
} from "lucide-react";
import { api, type GitAuthNeeded } from "../../lib/api";
import type { LaneGitFacts, LaneWorktree } from "../../lib/types";
type Action = "fetch" | "pull" | "push" | "switch" | "branch-create" | "branch-delete";
type Action = "fetch" | "pull" | "push" | "switch" | "branch-create" | "branch-delete" | "merge";
type RunBody = Parameters<typeof api.lanes.gitAction>[2];
const LOCAL_HOST = /^(localhost|127\.|\[::1\])/;
function Btn({
label,
@@ -85,6 +102,11 @@ export function LaneGitBar({
const [worktrees, setWorktrees] = useState<LaneWorktree[]>([]);
const [picked, setPicked] = useState("");
const [newBranch, setNewBranch] = useState("");
// Set when git needed a login for `action`; the form resends that action.
const [login, setLogin] = useState<{ action: Action; auth: GitAuthNeeded } | null>(null);
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [remember, setRemember] = useState(true);
// The Changes panel is not keyed by lane, so this bar outlives a lane switch.
// All of the state above describes ONE lane — a branch picked for lane A,
// applied after switching, would move B's HEAD — so it is reset during
@@ -102,6 +124,8 @@ export function LaneGitBar({
setWorktrees([]);
setPicked("");
setNewBranch("");
setLogin(null);
setPassword("");
inFlight.current = false;
}
const laneNow = useRef(laneId);
@@ -109,18 +133,25 @@ export function LaneGitBar({
if (!git.available) return null;
const dirty = git.dirty + git.untracked;
const merging = !!git.merging;
const run = async (action: Action, body?: { branch?: string }) => {
const run = async (action: Action, body?: RunBody) => {
if (inFlight.current) return;
inFlight.current = true;
const lane = laneId;
setBusy(action);
setError(null);
try {
await api.lanes.gitAction(lane, action, body || {});
const res = await api.lanes.gitAction(lane, action, body || {});
// Landed after a lane switch: every write below belongs to the lane
// no longer shown.
if (laneNow.current !== lane) return;
// A merge that stopped on conflicts still succeeded in starting; the
// Changes panel below lists the files to resolve.
if (res?.conflicts?.length)
setError(t("gitBar.mergeConflicts", { count: res.conflicts.length }));
setLogin(null);
setPassword("");
// The branch list this bar is showing is stale the moment a branch is
// created, deleted or switched to, so it is re-read rather than patched.
if (action === "switch" || action.startsWith("branch-")) {
@@ -136,7 +167,12 @@ export function LaneGitBar({
// rejection text verbatim — hence the <pre> below rather than a line.
// ENOREMOTE is the one refusal with no git text to show: the server's
// sentence is English and says less than a translated one.
if (laneNow.current === lane)
const auth = (err as { code?: string; auth?: GitAuthNeeded })?.auth;
if (laneNow.current === lane && (err as { code?: string })?.code === "EGITAUTH" && auth) {
setLogin({ action, auth });
setUsername((u) => u || auth.username || "");
setPassword("");
} else if (laneNow.current === lane)
setError(
(err as { code?: string })?.code === "ENOREMOTE"
? t("gitBar.errNoRemote")
@@ -170,6 +206,11 @@ export function LaneGitBar({
await refreshBranches();
};
const merge = (branch: string) => {
if (!window.confirm(t("gitBar.mergeConfirm", { branch, current: git.branch }))) return;
void run("merge", { branch });
};
const heldBy = new Map(
worktrees.filter((w) => w.branch).map((w) => [w.branch as string, w.path])
);
@@ -211,6 +252,18 @@ export function LaneGitBar({
)
)}
{/* Diverged: a --ff-only pull can only refuse, so offer the merge. */}
{git.upstream && (git.ahead ?? 0) > 0 && (git.behind ?? 0) > 0 && !merging && (
<Btn
label={t("gitBar.mergeUpstream", { upstream: git.upstream })}
icon={<GitMerge className="h-3 w-3" />}
busy={busy === "merge"}
disabled={!!busy || dirty > 0}
title={dirty > 0 ? t("gitBar.switchDirty") : t("gitBar.mergeUpstreamHint")}
onClick={() => merge(git.upstream as string)}
/>
)}
<div className="ml-auto flex shrink-0 items-center">
<Btn
compact
@@ -226,8 +279,8 @@ export function LaneGitBar({
label={t("gitBar.pull")}
icon={<ArrowDown className="h-3.5 w-3.5" />}
busy={busy === "pull"}
disabled={!git.hasRemote || !!busy}
title={t("gitBar.pullHint")}
disabled={!git.hasRemote || !!busy || merging}
title={merging ? t("gitBar.merging") : t("gitBar.pullHint")}
onClick={() => run("pull")}
/>
<Btn
@@ -243,10 +296,16 @@ export function LaneGitBar({
compact
label={t("gitBar.switch")}
icon={<GitBranch className="h-3.5 w-3.5" />}
disabled={!!busy}
disabled={!!busy || merging}
// A dirty tree is refused server-side; saying so before the click
// beats a red box after it.
title={dirty > 0 ? t("gitBar.switchDirty") : t("gitBar.switchHint")}
title={
merging
? t("gitBar.merging")
: dirty > 0
? t("gitBar.switchDirty")
: t("gitBar.switchHint")
}
onClick={openSwitch}
/>
</div>
@@ -281,6 +340,14 @@ export function LaneGitBar({
title={dirty > 0 ? t("gitBar.switchDirty") : t("gitBar.switchHint")}
onClick={() => void run("switch", { branch: picked })}
/>
<Btn
label={t("gitBar.merge")}
icon={<GitMerge className="h-3 w-3" />}
busy={busy === "merge"}
disabled={!picked || picked === git.branch || dirty > 0 || !!busy}
title={dirty > 0 ? t("gitBar.switchDirty") : t("gitBar.mergeHint")}
onClick={() => merge(picked)}
/>
{onNewLane && (
<Btn
label={t("gitBar.newLane")}
@@ -339,6 +406,77 @@ export function LaneGitBar({
</div>
)}
{login && (
<form
data-testid="lane-git-login"
className="space-y-1.5 rounded border border-border-light bg-surface-1 p-2 text-[11px]"
onSubmit={(e) => {
e.preventDefault();
const { protocol, host } = login.auth;
void run(login.action, {
credentials: { protocol, host, username: username.trim(), password, remember },
});
}}
>
<p className="font-medium text-fg-primary">
{t("gitBar.loginTitle", { host: login.auth.host })}
</p>
{login.auth.rejected && (
<p role="alert" className="text-status-danger">
{t("gitBar.loginRejected")}
</p>
)}
<div className="flex gap-1.5">
<input
aria-label={t("gitBar.loginUser")}
placeholder={t("gitBar.loginUser")}
autoComplete="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="min-w-0 flex-1 rounded border border-border-light bg-surface-0 px-2 py-0.5 text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
/>
<input
type="password"
aria-label={t("gitBar.loginPassword")}
placeholder={t("gitBar.loginPassword")}
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="min-w-0 flex-1 rounded border border-border-light bg-surface-0 px-2 py-0.5 text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
/>
</div>
<label className="flex items-center gap-1.5 text-fg-secondary">
<input
type="checkbox"
checked={remember}
onChange={(e) => setRemember(e.target.checked)}
/>
{t("gitBar.loginRemember")}
</label>
<p className="text-[10px] text-fg-muted">{t("gitBar.loginTokenHint")}</p>
{/* The token crosses the network in the clear on a plain-http LAN address. */}
{location.protocol === "http:" && !LOCAL_HOST.test(location.hostname) && (
<p className="text-[10px] text-status-warning">{t("gitBar.loginInsecure")}</p>
)}
<div className="flex gap-1.5">
<button
type="submit"
disabled={!username.trim() || !password || !!busy}
className="rounded border border-accent/40 bg-accent/15 px-2 py-0.5 text-accent hover:bg-accent/25 disabled:cursor-not-allowed disabled:opacity-40"
>
{t("gitBar.loginSubmit")}
</button>
<Btn
label={t("gitBar.loginCancel")}
onClick={() => {
setLogin(null);
setPassword("");
}}
/>
</div>
</form>
)}
{error && (
<pre
role="alert"
@@ -1245,3 +1245,82 @@ describe("LaneChangesPanel", () => {
expect(screen.getByRole("button", { name: "Expand all folders" })).toBeEnabled();
});
});
describe("a merge in progress (issue #45)", () => {
const merging = (conflicts: number) => ({
available: true as const,
branch: "main",
head: "abc1234",
subject: "main",
dirty: 1,
untracked: 0,
files: [],
hasRemote: false,
upstream: null,
ahead: null,
behind: null,
merging: true,
conflicts,
});
const conflicted = changes({
files: [
{
path: "a.txt",
status: "UU",
untracked: false,
staged: true,
unstaged: true,
conflicted: true,
added: 3,
deleted: 0,
binary: false,
},
{
path: "b.txt",
status: "A ",
untracked: false,
staged: true,
added: 1,
deleted: 0,
binary: false,
},
],
});
it("lists conflicts on their own, resolves one side, and blocks the commit until none are left", async () => {
vi.mocked(api.lanes.git).mockResolvedValue(merging(1));
vi.mocked(api.lanes.changes).mockResolvedValue(conflicted);
const user = userEvent.setup();
render(<LaneChangesPanel laneId={3} />);
const group = await screen.findByTestId("lane-changes-conflicts");
expect(within(group).getByText("a.txt")).toBeInTheDocument();
// Not offered as an ordinary stage: that would mark it resolved blind.
expect(screen.getAllByText("a.txt")).toHaveLength(1);
expect(screen.getByRole("button", { name: "Complete merge" })).toBeDisabled();
await user.click(within(group).getByRole("button", { name: "Take theirs" }));
await waitFor(() =>
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "resolve", {
path: "a.txt",
side: "theirs",
})
);
});
it("completes the merge once nothing is conflicted, and aborts only on a second click", async () => {
vi.mocked(api.lanes.git).mockResolvedValue(merging(0));
const user = userEvent.setup();
render(<LaneChangesPanel laneId={3} />);
await user.click(await screen.findByRole("button", { name: "Complete merge" }));
await waitFor(() => expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "merge-commit", {}));
await user.click(screen.getByRole("button", { name: "Abort merge" }));
expect(api.lanes.gitAction).not.toHaveBeenCalledWith(3, "merge-abort", expect.anything());
await user.click(screen.getByRole("button", { name: "Click again to abort the merge" }));
await waitFor(() =>
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "merge-abort", { confirm: true })
);
});
});
@@ -255,3 +255,91 @@ describe("LaneGitBar", () => {
);
});
});
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();
});
});