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:
@@ -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");
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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." },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user