fix(git): the branch switcher lists and checks out branches only the remote has

GET /api/lanes/:id/git/branches read only refs/heads, so a branch someone
else pushed never showed after a fetch or pull, and `switch` refused it
(ENOBRANCH). It now also returns `remotes`: fetched remote-tracking branches
with no local branch of that name (origin/HEAD left out), listed under
"On the remote"; switching to one runs `git switch -c <b> --track
refs/remotes/<remote>/<b>` with the remote ref looked up, never passed
through (bare name only when exactly one remote has it, else EAMBIGUOUS).
The bar also re-reads the branch list after a fetch or pull instead of
keeping the list it loaded first.
This commit is contained in:
2026-10-28 14:57:32 +07:00
parent fa27b299e7
commit 14e979837e
14 changed files with 197 additions and 24 deletions
+24 -3
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, create/delete a branch, publish a repo
* fetch, pull, push, switch branch (a local one, or one only a remote has —
* listed under "On the remote" and checked out as a new tracking branch; the
* list is re-read after every fetch and pull), create/delete a branch, publish a repo
* that has no remote yet (a URL, then `origin` + `push -u`), and start a merge
* (the picked branch, or the upstream once diverged; resolving it is the
* Changes panel's job). Committing is
@@ -114,6 +116,8 @@ export function LaneGitBar({
const [error, setError] = useState<string | null>(null);
const [switching, setSwitching] = useState(false);
const [branches, setBranches] = useState<string[] | null>(null);
// Fetched remote branches with no local one yet (`origin/feature`).
const [remoteBranches, setRemoteBranches] = useState<string[]>([]);
const [worktrees, setWorktrees] = useState<LaneWorktree[]>([]);
const [picked, setPicked] = useState("");
const [newBranch, setNewBranch] = useState("");
@@ -138,6 +142,7 @@ export function LaneGitBar({
setError(null);
setSwitching(false);
setBranches(null);
setRemoteBranches([]);
setWorktrees([]);
setPicked("");
setNewBranch("");
@@ -152,6 +157,9 @@ export function LaneGitBar({
if (!git.available) return null;
const dirty = git.dirty + git.untracked;
// A remote branch: switching creates its local tracking branch; it cannot
// be deleted here or given a sibling worktree before that.
const pickedRemote = remoteBranches.includes(picked);
const merging = !!git.merging;
const run = async (action: Action, body?: RunBody) => {
@@ -184,6 +192,9 @@ export function LaneGitBar({
setPicked("");
setBranches(null);
await refreshBranches();
} else if ((action === "fetch" || action === "pull") && branches !== null) {
// A fetch may have brought branches the list does not show yet.
await refreshBranches();
}
onChanged();
} catch (err) {
@@ -217,6 +228,7 @@ export function LaneGitBar({
const r = await api.lanes.gitBranches(gitTarget(laneId, session, source));
if (laneNow.current !== laneId) return;
setBranches(r.branches);
setRemoteBranches(r.remotes || []);
setWorktrees(r.worktrees || []);
} catch {
if (laneNow.current === laneId) setBranches([]);
@@ -446,6 +458,15 @@ export function LaneGitBar({
</option>
);
})}
{remoteBranches.length > 0 && (
<optgroup label={t("gitBar.remoteBranches")}>
{remoteBranches.map((b) => (
<option key={`remote:${b}`} value={b}>
{b}
</option>
))}
</optgroup>
)}
</select>
<Btn
label={t("gitBar.switchApply")}
@@ -476,7 +497,7 @@ export function LaneGitBar({
})
: t("gitBar.newLaneHint")
}
disabled={!picked || heldBy.has(picked) || picked === git.branch}
disabled={!picked || heldBy.has(picked) || picked === git.branch || pickedRemote}
// The sibling lane is created through the normal Add-lane flow,
// prefilled — one provisioning path, not a second one here.
onClick={() => onNewLane(picked)}
@@ -486,7 +507,7 @@ export function LaneGitBar({
label={t("gitBar.branchDelete")}
icon={<Trash2 className="h-3 w-3" />}
busy={busy === "branch-delete"}
disabled={!picked || picked === git.branch || !!heldBy.get(picked)}
disabled={!picked || picked === git.branch || !!heldBy.get(picked) || pickedRemote}
title={t("gitBar.branchDeleteHint")}
onClick={() =>
void confirmRun(
@@ -4,7 +4,8 @@
* 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
* refuses it too), that branches only the remote has are offered (and the list
* is re-read after a fetch), 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>
*/
@@ -236,6 +237,43 @@ describe("LaneGitBar", () => {
await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledTimes(2));
});
it("lists branches only the remote has, switches to one, and re-reads the list after a fetch", async () => {
vi.mocked(api.lanes.gitBranches).mockResolvedValue({
available: true,
branches: ["development", "feat/thing"],
remotes: ["origin/feat/new"],
current: "feat/thing",
worktrees: [],
});
const user = userEvent.setup();
render(
<LaneGitBar
laneId={3}
git={facts({ dirty: 0, untracked: 0 })}
onChanged={vi.fn()}
onNewLane={vi.fn()}
/>
);
await user.click(screen.getByRole("button", { name: /Branch/ }));
const select = await screen.findByLabelText("Switch branch…");
expect(
screen.getByRole("group", { name: "On the remote — switching creates the local branch" })
).toBeInTheDocument();
await user.selectOptions(select, "origin/feat/new");
// Not a local branch yet: nothing to delete, no worktree to start on it.
expect(screen.getByRole("button", { name: "Delete" })).toBeDisabled();
expect(screen.getByRole("button", { name: /New workspace here/ })).toBeDisabled();
await user.click(screen.getByRole("button", { name: "Switch branch" }));
await waitFor(() =>
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "switch", { branch: "origin/feat/new" })
);
vi.mocked(api.lanes.gitBranches).mockClear();
await user.click(screen.getByRole("button", { name: /Fetch/ }));
await waitFor(() => expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "fetch", {}));
await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledTimes(1));
});
it("creates a branch without switching, then offers a sibling lane on it", async () => {
const user = userEvent.setup();
const onNewLane = vi.fn();