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
+2 -2
View File
File diff suppressed because one or more lines are too long
+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();
+1
View File
@@ -195,6 +195,7 @@
"gitBar.switch": "Branch",
"gitBar.switchApply": "Switch branch",
"gitBar.switchCurrent": "current",
"gitBar.remoteBranches": "On the remote — switching creates the local branch",
"gitBar.switchDirty": "Commit or discard the uncommitted changes first",
"gitBar.switchHint": "Switch this worktree to another local branch",
"gitBar.switchLabel": "Switch branch…",
+1
View File
@@ -195,6 +195,7 @@
"gitBar.switch": "Nhánh",
"gitBar.switchApply": "Chuyển nhánh",
"gitBar.switchCurrent": "hiện tại",
"gitBar.remoteBranches": "Trên remote — switch sẽ tạo nhánh local",
"gitBar.switchDirty": "Hãy commit hoặc bỏ các thay đổi chưa commit trước",
"gitBar.switchHint": "Đổi worktree này sang nhánh local khác",
"gitBar.switchLabel": "Đổi nhánh…",
+2
View File
@@ -4320,6 +4320,8 @@ export const api = {
request<{
available: boolean;
branches: string[];
/** Fetched remote branches with no local one yet (`origin/feature`); absent from an older server. */
remotes?: string[];
current: string | null;
worktrees: LaneWorktree[];
}>(gitUrl(id, "/branches")),
+6 -3
View File
@@ -942,8 +942,11 @@ read the disk instead for a folder that is not a git repo (below).
GET /api/lanes/:id/git/branches
```
`{"available": true, "branches": [...], "current": "feat/x", "worktrees": [...]}`
— the local branches of the workspace's own repo, for the branch switcher. The repo
`{"available": true, "branches": [...], "remotes": ["origin/feat/y"], "current": "feat/x", "worktrees": [...]}`
— the local branches of the workspace's own repo, for the branch switcher, and
`remotes`: the fetched remote-tracking branches that have no local branch of
that name yet (the remotes' `HEAD` left out). The switcher lists them under
"On the remote" and re-reads the list after every fetch and pull. The repo
comes from the workspace row, never from the query (that is what `GET
/api/lanes/branches` is for, which takes a candidate path for a workspace that does
not exist yet; in server mode with `AGENTHUB_USERS_ROOT` set, a developer may name
@@ -1059,7 +1062,7 @@ cannot collide on `index.lock`.
| `stage-hunk` | `git apply --cached` of ONE hunk of `git diff -U<context> HEAD -- <path>` — or, with `block`, of only that hunk's `block`-th run of +/- lines (the other runs folded into context, as an edited `git add -p` hunk) | `{"path": "...", "hunk": "@@ -1,2 +1,3 @@", "block": 0, "context": 3}` |
| `discard-hunk` | `git apply -R` of that one hunk (or that one `block` of it), off the working tree | `{"path": "...", "hunk": "@@ ... @@", "block": 0, "context": 3, "confirm": true}` |
| `commit` | `git add -A` then `git commit -m <message>`, or the index as it stands with `only: "staged"`, or — with `paths` — `git commit --only -- <paths>`: exactly those files' working-tree content (an untracked one is added first, and taken back out if the commit fails), every other change left as it was | `{"message": "...", "only": "staged"}` / `{"message": "...", "paths": ["..."]}` |
| `switch` | `git switch [-c] <branch>` | `{"branch": "...", "create": false}` |
| `switch` | `git switch [-c] <branch>`; a branch only a remote has (`origin/feat/y`, or bare `feat/y` when exactly one remote has it — else `400 EAMBIGUOUS`) becomes `git switch -c feat/y --track refs/remotes/origin/feat/y`, the remote ref looked up among the fetched ones | `{"branch": "...", "create": false}` |
| `branch-create` | `git branch <branch> [<base>]` — never switches | `{"branch": "...", "base": "..."}` |
| `branch-delete` | `git branch -d <branch>` | `{"branch": "..."}` |
| `publish` | `git remote add origin -- <url>` then `git push -u origin HEAD` — only when the repo has NO remote | `{"url": "https://host/owner/repo.git"}` |
+3 -3
View File
@@ -12276,8 +12276,8 @@ paths:
get:
tags:
- Lanes
summary: The lane repo's own local branches
description: Feeds the lane's branch switcher. The repository comes from the lane row, never from the query — that is what GET /api/lanes/branches is for. Read-only, no same-origin guard.
summary: The lane repo's own local branches, and the remote ones not checked out yet
description: Feeds the lane's branch switcher. `branches` are the local branches; `remotes` the fetched remote-tracking branches with no local branch of that name yet (`origin/feature`, the remotes' HEAD left out) — `switch` to one creates its local tracking branch. The repository comes from the lane row, never from the query — that is what GET /api/lanes/branches is for. Read-only, no same-origin guard.
operationId: listLaneOwnBranches
parameters:
- name: id
@@ -12287,7 +12287,7 @@ paths:
type: integer
responses:
'200':
description: '{available, branches, current, worktrees}.'
description: '{available, branches, remotes, current, worktrees}.'
'404':
description: Lane not found.
/api/lanes/{id}/git/commit-message:
+47 -5
View File
@@ -138,7 +138,10 @@ describe("collectChanges — work range", () => {
const out = await collectChanges(LANE, { range: "work" });
// `--untracked-files=normal` folded these into one "docs/" row that
// opened nothing; the panel needs the files, like JetBrains and VS Code.
assert.deepEqual(out.files.map((f) => f.path).sort(), ["docs/deep/notes.txt", "docs/report.md"]);
assert.deepEqual(out.files.map((f) => f.path).sort(), [
"docs/deep/notes.txt",
"docs/report.md",
]);
assert.ok(out.files.every((f) => f.untracked && f.added === 1));
assert.equal((await gitFacts(LANE)).untracked, 2);
});
@@ -244,10 +247,7 @@ describe("collectChanges — branch range", () => {
it("falls back to the default branch without a base, and refuses a base that is gone", async () => {
// No recorded base (an adopted folder): what origin/HEAD names.
assert.equal(
(await collectChanges(LANE, { range: "branch", base: null })).base,
"development"
);
assert.equal((await collectChanges(LANE, { range: "branch", base: null })).base, "development");
await assert.rejects(() => collectChanges(LANE, { range: "branch", base: "nope" }), {
code: "ENOBASE",
});
@@ -374,6 +374,48 @@ describe("runGitAction", () => {
});
});
it("lists a branch only the remote has after a fetch, and switching tracks it", async () => {
const { listRemoteBranches } = require("../lib/worktree");
// Someone else pushes a new branch; this clone has never seen it.
gc(OTHER, "checkout", "-qb", "feat/remote-only");
write(OTHER, "r.txt", "remote\n");
gc(OTHER, "add", "-A");
gc(OTHER, "commit", "-qm", "remote work");
gc(OTHER, "push", "-q", "origin", "feat/remote-only");
assert.equal(
(await listRemoteBranches(LANE)).some((r) => r.name === "origin/feat/remote-only"),
false
);
await runGitAction(LANE, "fetch");
const remote = await listRemoteBranches(LANE);
assert.ok(remote.some((r) => r.name === "origin/feat/remote-only"));
// origin/HEAD and branches that already have a local one are left out.
assert.ok(!remote.some((r) => r.branch === "HEAD" || r.branch === "development"));
const result = await runGitAction(LANE, "switch", { branch: "origin/feat/remote-only" });
assert.equal(result.branch, "feat/remote-only");
assert.equal(g(LANE, "rev-parse", "--abbrev-ref", "HEAD").trim(), "feat/remote-only");
assert.equal(
g(LANE, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}").trim(),
"origin/feat/remote-only"
);
assert.ok(!(await listRemoteBranches(LANE)).some((r) => r.branch === "feat/remote-only"));
gc(LANE, "checkout", "-q", "development");
});
it("switches to a bare remote branch name when exactly one remote has it", async () => {
gc(OTHER, "checkout", "-qb", "feat/bare");
gc(OTHER, "push", "-q", "origin", "feat/bare");
await runGitAction(LANE, "fetch");
const result = await runGitAction(LANE, "switch", { branch: "feat/bare" });
assert.equal(result.branch, "feat/bare");
assert.equal(
g(LANE, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}").trim(),
"origin/feat/bare"
);
gc(LANE, "checkout", "-q", "development");
});
it("refuses a branch another worktree holds", async () => {
const wt = path.join(ROOT, "sibling");
g(LANE, "worktree", "add", "-q", wt, "development");
+1
View File
@@ -1122,6 +1122,7 @@ describe("the lane git surface: changes, branches, actions (issue #34)", () => {
const r = await request("GET", `/api/lanes/${laneId}/git/branches`);
assert.equal(r.status, 200);
assert.deepEqual(r.body.branches, ["feat/api-changes"]);
assert.deepEqual(r.body.remotes, []); // no remote at all
assert.equal(r.body.current, "feat/api-changes");
});
+26 -1
View File
@@ -55,6 +55,7 @@ const {
git: rawGit,
isValidBranchName,
listBranches,
listRemoteBranches,
branchCheckedOutAt,
statusCounts,
operationInProgress,
@@ -532,7 +533,31 @@ async function runGitAction(dir, action, body = {}) {
if (exists) throw bad("EBRANCHEXISTS", `branch ${branch} already exists`);
return done(action, await git(dir, ["switch", "-c", branch]), branch);
}
if (!exists) throw bad("ENOBRANCH", `branch ${branch} does not exist locally`);
if (!exists) {
// A branch only a remote has (fetched, never checked out here): its local
// tracking branch is created and switched to. Named either as the remote
// ref (`origin/feature`) or bare (`feature`, when exactly one remote has
// it). The remote ref is looked up among the fetched ones, never passed
// through from the request.
const remote = (await listRemoteBranches(dir)).filter(
(r) => r.name === branch || r.branch === branch
);
const pick = remote.find((r) => r.name === branch) || (remote.length === 1 ? remote[0] : null);
if (!pick) {
if (remote.length > 1) {
throw bad(
"EAMBIGUOUS",
`branch ${branch} is on several remotes (${remote.map((r) => r.name).join(", ")}): pick one`
);
}
throw bad("ENOBRANCH", `branch ${branch} does not exist locally or on a remote`);
}
return done(
action,
await git(dir, ["switch", "-c", pick.branch, "--track", `refs/remotes/${pick.name}`]),
pick.branch
);
}
const busyAt = await branchCheckedOutAt(dir, branch);
if (busyAt) throw bad("EBRANCHBUSY", `branch ${branch} is checked out at ${busyAt}`);
return done(action, await git(dir, ["switch", branch]), branch);
+34
View File
@@ -228,6 +228,39 @@ async function listBranches(sourceRepo) {
return { branches, current };
}
/**
* A repo's remote-tracking branches that have NO local branch of the same
* name yet — what a fetch brought in that `listBranches` cannot show.
* `[{name: "origin/feature", remote: "origin", branch: "feature"}]`, the
* remotes' symbolic `HEAD`s left out. The remote's name is matched against
* `git remote` (longest first), so a remote whose name holds a `/` splits right.
*/
async function listRemoteBranches(dir) {
const remotes = (await git(dir, ["remote"])).stdout
.split("\n")
.map((r) => r.trim())
.filter(Boolean)
.sort((a, b) => b.length - a.length);
if (!remotes.length) return [];
const refs = (
await git(dir, ["for-each-ref", "--format=%(refname:short)%00%(symref)", "refs/remotes"])
).stdout
.split("\n")
.filter(Boolean)
.map((l) => l.split("\0"));
const local = new Set((await listBranches(dir)).branches);
const out = [];
for (const [name, symref] of refs) {
if (symref) continue; // origin/HEAD → origin/main
const remote = remotes.find((r) => name.startsWith(`${r}/`));
if (!remote) continue;
const branch = name.slice(remote.length + 1);
if (!branch || branch === "HEAD" || local.has(branch)) continue;
out.push({ name, remote, branch });
}
return out;
}
/**
* Resolve the base branch: try origin/<wanted>, then <wanted>, then HEAD.
*/
@@ -978,6 +1011,7 @@ module.exports = {
isGitRepo,
isValidBranchName,
listBranches,
listRemoteBranches,
resolveBase,
slugify,
listWorktrees,
+3 -3
View File
@@ -854,13 +854,13 @@ const paths = {
"/api/lanes/{id}/git/branches": {
get: {
tags: ["Lanes"],
summary: "The lane repo's own local branches",
summary: "The lane repo's own local branches, and the remote ones not checked out yet",
description:
"Feeds the lane's branch switcher. The repository comes from the lane row, never from the query — that is what GET /api/lanes/branches is for. Read-only, no same-origin guard.",
"Feeds the lane's branch switcher. `branches` are the local branches; `remotes` the fetched remote-tracking branches with no local branch of that name yet (`origin/feature`, the remotes' HEAD left out) — `switch` to one creates its local tracking branch. The repository comes from the lane row, never from the query — that is what GET /api/lanes/branches is for. Read-only, no same-origin guard.",
operationId: "listLaneOwnBranches",
parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
responses: {
200: { description: "{available, branches, current, worktrees}." },
200: { description: "{available, branches, remotes, current, worktrees}." },
404: { description: "Lane not found." },
},
},
+8 -3
View File
@@ -48,6 +48,7 @@ const {
isGitRepo,
isValidBranchName,
listBranches,
listRemoteBranches,
listWorktrees,
resetWorktree,
resolveBase,
@@ -1913,7 +1914,9 @@ router.get("/:id/git/tree", async (req, res) => {
});
/**
* The lane's own local branches, for the branch switcher. Separate from
* The lane's own local branches, for the branch switcher, plus `remotes`: the
* fetched remote-tracking branches with no local branch yet (`origin/feature`
* — switching to one creates its tracking branch). Separate from
* `GET /branches` (which takes a candidate repo PATH from the query, for a
* lane that does not exist yet); this one resolves the repo from the lane row,
* so no path ever comes from the request.
@@ -1925,9 +1928,10 @@ router.get("/:id/git/branches", async (req, res) => {
try {
const { branches, current } = await listBranches(cwd);
const worktrees = await listWorktrees(cwd).catch(() => []);
res.json({ available: true, branches, current, worktrees });
const remotes = (await listRemoteBranches(cwd).catch(() => [])).map((r) => r.name);
res.json({ available: true, branches, remotes, current, worktrees });
} catch {
res.json({ available: false, branches: [], current: null, worktrees: [] });
res.json({ available: false, branches: [], remotes: [], current: null, worktrees: [] });
}
});
@@ -1987,6 +1991,7 @@ router.post("/:id/git/:action", sameOriginGuard, async (req, res) => {
"EBADMESSAGE",
"EBADBRANCH",
"ENOBRANCH",
"EAMBIGUOUS",
"EBRANCHEXISTS",
"EBRANCHBUSY",
"EDIRTY",