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
+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");
});