fix(server): resolve remote-only branches in switcher checkout

Branch switcher listed remote-only branches as if they were local, then
threw "Branch not found" when clicked because the checkout path only
verified `refs/heads/<name>`. Explicit `origin/<name>` input went the
other way — verify passed, but checkout produced a detached HEAD.

Split the concerns into three layers:

- Enumerate: `listBranchSuggestions` returns provenance (`hasLocal`,
  `hasRemote`) so the wire payload no longer loses origin-only info.
  Fields are optional on `branchDetails` for backward compat.
- Resolve: new `resolveBranchCheckout` normalizes `origin/<name>` →
  `<name>`, verifies explicit `refs/heads/` and `refs/remotes/origin/`
  paths (no tag-DWIM), returns a tagged
  `{ local | remote-only | not-found }` result. Policy (local wins,
  origin fallback) lives here.
- Act: `checkoutExistingBranch` no longer does implicit DWIM. Local →
  `git checkout <name>`. Remote-only → `git checkout -b <name> --track
  origin/<name>`. Not-found → throws the existing
  `Branch not found: <name>` string. Response carries optional
  `source: "local" | "remote"`.

`validateBranch` now routes through the same resolver so both paths
agree on what a branch is.

Also fixes a related stale-base diff bug: worktrees created off
`origin/main` could show phantom "added" commits when the local `main`
lagged. `resolveBestComparisonBaseRef` is now origin-first for
diff/status. Merge still uses the "most-ahead" helper so unpublished
local commits aren't lost. `doesGitRefExist` tightened to only swallow
exit 1 (missing ref), propagating other git failures.
This commit is contained in:
Mohamed Boudra
2026-04-19 14:26:52 +07:00
parent e373c33108
commit 2d9cbb0453
7 changed files with 568 additions and 120 deletions
+274 -6
View File
@@ -14,6 +14,7 @@ import {
getCheckoutShortstat,
getPullRequestStatus,
getCheckoutStatus,
checkoutResolvedBranch,
listBranchSuggestions,
mergeToBase,
mergeFromBase,
@@ -22,6 +23,7 @@ import {
NotGitRepoError,
pullCurrentBranch,
pushCurrentBranch,
resolveBranchCheckout,
resolveRepositoryDefaultBranch,
parseWorktreeList,
isPaseoWorktreePath,
@@ -344,6 +346,96 @@ const x = 1;
expect(shortstat).toEqual({ additions: 1, deletions: 0 });
});
it("does not count origin base commits as feature changes when local main is stale", async () => {
const remoteDir = join(tempDir, "remote.git");
const otherClone = join(tempDir, "other-clone");
execSync(`git init --bare -b main ${remoteDir}`);
execSync(`git remote add origin ${remoteDir}`, { cwd: repoDir });
execSync("git push -u origin main", { cwd: repoDir });
execSync(`git clone ${remoteDir} ${otherClone}`);
execSync("git config user.email 'test@test.com'", { cwd: otherClone });
execSync("git config user.name 'Test'", { cwd: otherClone });
writeFileSync(join(otherClone, "already-on-origin.txt"), "origin\n");
execSync("git add already-on-origin.txt", { cwd: otherClone });
execSync("git -c commit.gpgsign=false commit -m 'origin base commit'", { cwd: otherClone });
execSync("git push", { cwd: otherClone });
writeFileSync(join(repoDir, "local-only-base.txt"), "local\n");
execSync("git add local-only-base.txt", { cwd: repoDir });
execSync("git -c commit.gpgsign=false commit -m 'local base drift'", { cwd: repoDir });
execSync("git fetch origin", { cwd: repoDir });
execSync("git checkout -b feature origin/main", { cwd: repoDir });
const shortstat = await getCheckoutShortstat(repoDir);
expect(shortstat).toBeNull();
const diff = await getCheckoutDiff(repoDir, {
mode: "base",
baseRef: "main",
includeStructured: true,
});
expect(diff.diff).toBe("");
expect(diff.structured).toEqual([]);
});
it("falls back to the local base branch when origin is absent", async () => {
execSync("git checkout -b feature", { cwd: repoDir });
writeFileSync(join(repoDir, "local-feature.txt"), "feature\n");
execSync("git add local-feature.txt", { cwd: repoDir });
execSync("git -c commit.gpgsign=false commit -m 'local feature'", { cwd: repoDir });
const shortstat = await getCheckoutShortstat(repoDir);
expect(shortstat).toEqual({ additions: 1, deletions: 0 });
const diff = await getCheckoutDiff(repoDir, { mode: "base", baseRef: "main" });
expect(diff.diff).toContain("local-feature.txt");
});
it("keeps an explicit origin base ref instead of stripping it to a stale local branch", async () => {
const remoteDir = join(tempDir, "remote.git");
const otherClone = join(tempDir, "other-clone");
execSync(`git init --bare -b main ${remoteDir}`);
execSync(`git remote add origin ${remoteDir}`, { cwd: repoDir });
execSync("git push -u origin main", { cwd: repoDir });
execSync(`git clone ${remoteDir} ${otherClone}`);
execSync("git config user.email 'test@test.com'", { cwd: otherClone });
execSync("git config user.name 'Test'", { cwd: otherClone });
writeFileSync(join(otherClone, "origin-base.txt"), "origin\n");
execSync("git add origin-base.txt", { cwd: otherClone });
execSync("git -c commit.gpgsign=false commit -m 'origin base'", { cwd: otherClone });
execSync("git push", { cwd: otherClone });
writeFileSync(join(repoDir, "local-drift.txt"), "local\n");
execSync("git add local-drift.txt", { cwd: repoDir });
execSync("git -c commit.gpgsign=false commit -m 'local drift'", { cwd: repoDir });
execSync("git fetch origin", { cwd: repoDir });
execSync("git checkout -b feature origin/main", { cwd: repoDir });
const diff = await getCheckoutDiff(repoDir, { mode: "base", baseRef: "origin/main" });
expect(diff.diff).toBe("");
});
it("shows feature commits when the local and origin base branches are up to date", async () => {
const remoteDir = join(tempDir, "remote.git");
execSync(`git init --bare -b main ${remoteDir}`);
execSync(`git remote add origin ${remoteDir}`, { cwd: repoDir });
execSync("git push -u origin main", { cwd: repoDir });
execSync("git fetch origin", { cwd: repoDir });
execSync("git checkout -b feature", { cwd: repoDir });
writeFileSync(join(repoDir, "feature.txt"), "feature\n");
execSync("git add feature.txt", { cwd: repoDir });
execSync("git -c commit.gpgsign=false commit -m 'feature commit'", { cwd: repoDir });
const shortstat = await getCheckoutShortstat(repoDir);
expect(shortstat).toEqual({ additions: 1, deletions: 0 });
const diff = await getCheckoutDiff(repoDir, { mode: "base", baseRef: "main" });
expect(diff.diff).toContain("feature.txt");
});
it("warms shortstat cache in the background without blocking listing callers", async () => {
expect(getCachedCheckoutShortstat(repoDir)).toBeUndefined();
@@ -790,31 +882,38 @@ const x = 1;
execSync(`git --git-dir ${remoteDir} show-ref --verify refs/heads/feature`);
});
it("lists merged local and remote branch suggestions without duplicates", async () => {
it("lists merged local and remote branch suggestions with provenance", async () => {
const remoteDir = join(tempDir, "remote.git");
execSync(`git init --bare -b main ${remoteDir}`);
execSync(`git remote add origin ${remoteDir}`, { cwd: repoDir });
execSync("git push -u origin main", { cwd: repoDir });
execSync("git checkout -b local-only", { cwd: repoDir });
execSync("git checkout -b feature/local-only", { cwd: repoDir });
execSync("git checkout main", { cwd: repoDir });
execSync("git checkout -b feature/shared", { cwd: repoDir });
writeFileSync(join(repoDir, "shared.txt"), "shared\n");
execSync("git add shared.txt", { cwd: repoDir });
execSync("git -c commit.gpgsign=false commit -m 'shared branch'", { cwd: repoDir });
execSync("git push -u origin feature/shared", { cwd: repoDir });
execSync("git checkout main", { cwd: repoDir });
const otherClone = join(tempDir, "other-clone");
execSync(`git clone ${remoteDir} ${otherClone}`);
execSync("git config user.email 'test@test.com'", { cwd: otherClone });
execSync("git config user.name 'Test'", { cwd: otherClone });
execSync("git checkout -b remote-only", { cwd: otherClone });
execSync("git checkout -b feature/remote-only", { cwd: otherClone });
writeFileSync(join(otherClone, "remote-only.txt"), "remote-only\n");
execSync("git add remote-only.txt", { cwd: otherClone });
execSync("git -c commit.gpgsign=false commit -m 'remote only branch'", { cwd: otherClone });
execSync("git push -u origin remote-only", { cwd: otherClone });
execSync("git push -u origin feature/remote-only", { cwd: otherClone });
execSync("git fetch origin", { cwd: repoDir });
const branches = await listBranchSuggestions(repoDir, { limit: 50 });
const branchNames = branches.map((branch) => branch.name);
expect(branchNames).toContain("main");
expect(branchNames).toContain("local-only");
expect(branchNames).toContain("remote-only");
expect(branchNames).toContain("feature/local-only");
expect(branchNames).toContain("feature/remote-only");
expect(branchNames).toContain("feature/shared");
expect(branchNames.filter((name) => name === "main")).toHaveLength(1);
expect(branchNames).not.toContain("HEAD");
expect(branchNames.some((name) => name.startsWith("origin/"))).toBe(false);
@@ -826,6 +925,175 @@ const x = 1;
}),
]),
);
expect(branches.find((branch) => branch.name === "feature/local-only")).toMatchObject({
hasLocal: true,
hasRemote: false,
});
expect(branches.find((branch) => branch.name === "feature/remote-only")).toMatchObject({
hasLocal: false,
hasRemote: true,
});
expect(branches.find((branch) => branch.name === "feature/shared")).toMatchObject({
hasLocal: true,
hasRemote: true,
});
});
it("resolves branch checkout targets with local precedence and origin normalization", async () => {
const remoteDir = join(tempDir, "remote.git");
execSync(`git init --bare -b main ${remoteDir}`);
execSync(`git remote add origin ${remoteDir}`, { cwd: repoDir });
execSync("git push -u origin main", { cwd: repoDir });
execSync("git checkout -b feature/local", { cwd: repoDir });
execSync("git checkout main", { cwd: repoDir });
execSync("git checkout -b feature/shared", { cwd: repoDir });
writeFileSync(join(repoDir, "shared.txt"), "shared\n");
execSync("git add shared.txt", { cwd: repoDir });
execSync("git -c commit.gpgsign=false commit -m 'shared branch'", { cwd: repoDir });
execSync("git push -u origin feature/shared", { cwd: repoDir });
execSync("git checkout main", { cwd: repoDir });
const otherClone = join(tempDir, "other-clone");
execSync(`git clone ${remoteDir} ${otherClone}`);
execSync("git config user.email 'test@test.com'", { cwd: otherClone });
execSync("git config user.name 'Test'", { cwd: otherClone });
execSync("git checkout -b feature/remote-only", { cwd: otherClone });
writeFileSync(join(otherClone, "remote-only.txt"), "remote-only\n");
execSync("git add remote-only.txt", { cwd: otherClone });
execSync("git -c commit.gpgsign=false commit -m 'remote only branch'", { cwd: otherClone });
execSync("git push -u origin feature/remote-only", { cwd: otherClone });
execSync("git fetch origin", { cwd: repoDir });
await expect(resolveBranchCheckout(repoDir, "feature/local")).resolves.toEqual({
kind: "local",
name: "feature/local",
});
await expect(resolveBranchCheckout(repoDir, "feature/remote-only")).resolves.toEqual({
kind: "remote-only",
name: "feature/remote-only",
remoteRef: "origin/feature/remote-only",
});
await expect(resolveBranchCheckout(repoDir, "origin/feature/remote-only")).resolves.toEqual({
kind: "remote-only",
name: "feature/remote-only",
remoteRef: "origin/feature/remote-only",
});
await expect(resolveBranchCheckout(repoDir, "feature/shared")).resolves.toEqual({
kind: "local",
name: "feature/shared",
});
await expect(resolveBranchCheckout(repoDir, "feature/unknown")).resolves.toEqual({
kind: "not-found",
});
});
it("does not resolve tags as branch checkout targets", async () => {
execSync("git checkout -b feature/a", { cwd: repoDir });
execSync("git checkout main", { cwd: repoDir });
execSync("git tag v1", { cwd: repoDir });
await expect(resolveBranchCheckout(repoDir, "v1")).resolves.toEqual({
kind: "not-found",
});
});
it("checks out a remote-only branch as a local tracking branch", async () => {
const remoteDir = join(tempDir, "remote.git");
execSync(`git init --bare -b main ${remoteDir}`);
execSync(`git remote add origin ${remoteDir}`, { cwd: repoDir });
execSync("git push -u origin main", { cwd: repoDir });
const otherClone = join(tempDir, "other-clone");
execSync(`git clone ${remoteDir} ${otherClone}`);
execSync("git config user.email 'test@test.com'", { cwd: otherClone });
execSync("git config user.name 'Test'", { cwd: otherClone });
execSync("git checkout -b feature/remote-only", { cwd: otherClone });
writeFileSync(join(otherClone, "remote-only.txt"), "remote-only\n");
execSync("git add remote-only.txt", { cwd: otherClone });
execSync("git -c commit.gpgsign=false commit -m 'remote only branch'", { cwd: otherClone });
execSync("git push -u origin feature/remote-only", { cwd: otherClone });
execSync("git fetch origin", { cwd: repoDir });
const resolution = await resolveBranchCheckout(repoDir, "feature/remote-only");
await expect(checkoutResolvedBranch({ cwd: repoDir, resolution })).resolves.toEqual({
source: "remote",
});
expect(execSync("git symbolic-ref --short HEAD", { cwd: repoDir }).toString().trim()).toBe(
"feature/remote-only",
);
execSync("git symbolic-ref -q HEAD", { cwd: repoDir });
expect(
execSync("git rev-parse --abbrev-ref --symbolic-full-name @{u}", { cwd: repoDir })
.toString()
.trim(),
).toBe("origin/feature/remote-only");
});
it("normalizes explicit origin input when checking out a remote-only branch", async () => {
const remoteDir = join(tempDir, "remote.git");
execSync(`git init --bare -b main ${remoteDir}`);
execSync(`git remote add origin ${remoteDir}`, { cwd: repoDir });
execSync("git push -u origin main", { cwd: repoDir });
const otherClone = join(tempDir, "other-clone");
execSync(`git clone ${remoteDir} ${otherClone}`);
execSync("git config user.email 'test@test.com'", { cwd: otherClone });
execSync("git config user.name 'Test'", { cwd: otherClone });
execSync("git checkout -b feature/remote-only", { cwd: otherClone });
writeFileSync(join(otherClone, "remote-only.txt"), "remote-only\n");
execSync("git add remote-only.txt", { cwd: otherClone });
execSync("git -c commit.gpgsign=false commit -m 'remote only branch'", { cwd: otherClone });
execSync("git push -u origin feature/remote-only", { cwd: otherClone });
execSync("git fetch origin", { cwd: repoDir });
const resolution = await resolveBranchCheckout(repoDir, "origin/feature/remote-only");
await expect(checkoutResolvedBranch({ cwd: repoDir, resolution })).resolves.toEqual({
source: "remote",
});
expect(execSync("git symbolic-ref --short HEAD", { cwd: repoDir }).toString().trim()).toBe(
"feature/remote-only",
);
execSync("git symbolic-ref -q HEAD", { cwd: repoDir });
expect(
execSync("git rev-parse --abbrev-ref --symbolic-full-name @{u}", { cwd: repoDir })
.toString()
.trim(),
).toBe("origin/feature/remote-only");
});
it("checks out the local branch when local and remote branches share a name", async () => {
const remoteDir = join(tempDir, "remote.git");
execSync(`git init --bare -b main ${remoteDir}`);
execSync(`git remote add origin ${remoteDir}`, { cwd: repoDir });
execSync("git push -u origin main", { cwd: repoDir });
execSync("git checkout -b feature/shared", { cwd: repoDir });
writeFileSync(join(repoDir, "shared.txt"), "shared\n");
execSync("git add shared.txt", { cwd: repoDir });
execSync("git -c commit.gpgsign=false commit -m 'shared branch'", { cwd: repoDir });
execSync("git push -u origin feature/shared", { cwd: repoDir });
execSync("git checkout main", { cwd: repoDir });
const resolution = await resolveBranchCheckout(repoDir, "feature/shared");
await expect(checkoutResolvedBranch({ cwd: repoDir, resolution })).resolves.toEqual({
source: "local",
});
expect(execSync("git symbolic-ref --short HEAD", { cwd: repoDir }).toString().trim()).toBe(
"feature/shared",
);
});
it("throws the existing branch-not-found message for unknown checkout targets", async () => {
await expect(
checkoutResolvedBranch({
cwd: repoDir,
resolution: { kind: "not-found" },
requestedBranch: "missing-branch",
}),
).rejects.toThrow(/^Branch not found: missing-branch$/);
});
it("filters branch suggestions by query and enforces result limit", async () => {