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
@@ -1,3 +1,7 @@
import { execSync } from "child_process";
import { mkdtempSync, rmSync, writeFileSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";
import pino from "pino";
import { afterEach, describe, expect, test, vi } from "vitest";
@@ -139,6 +143,46 @@ describe("session checkout merge handling", () => {
});
});
describe("session branch validation", () => {
test("does not validate tags as branches", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "paseo-session-branch-validation-"));
const repoDir = join(tempDir, "repo");
try {
execSync(`git init -b main ${repoDir}`);
execSync("git config user.email 'test@test.com'", { cwd: repoDir });
execSync("git config user.name 'Test'", { cwd: repoDir });
writeFileSync(join(repoDir, "README.md"), "hello\n");
execSync("git add README.md", { cwd: repoDir });
execSync("git -c commit.gpgsign=false commit -m init", { cwd: repoDir });
execSync("git tag v1", { cwd: repoDir });
const messages: unknown[] = [];
const session = createSessionForTest({ messages });
await session.handleMessage({
type: "validate_branch_request",
cwd: repoDir,
branchName: "v1",
requestId: "request-validate-tag",
});
expect(messages).toContainEqual({
type: "validate_branch_response",
payload: {
exists: false,
resolvedRef: null,
isRemote: false,
error: null,
requestId: "request-validate-tag",
},
});
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
});
describe("session pull request timeline handling", () => {
test("passes request identity to GitHubService and emits timeline items", async () => {
const messages: unknown[] = [];
+58 -68
View File
@@ -167,6 +167,9 @@ import {
getCachedCheckoutShortstat,
getCheckoutStatus,
listBranchSuggestions,
checkoutResolvedBranch,
resolveBranchCheckout,
type CheckoutExistingBranchResult,
commitChanges,
mergeToBase,
mergeFromBase,
@@ -3504,24 +3507,20 @@ export class Session {
}
}
private async checkoutExistingBranch(cwd: string, branch: string): Promise<void> {
private async checkoutExistingBranch(
cwd: string,
branch: string,
): Promise<CheckoutExistingBranchResult> {
this.assertSafeGitRef(branch, "branch");
try {
await execCommand("git", ["rev-parse", "--verify", branch], { cwd });
} catch (error) {
const resolution = await resolveBranchCheckout(cwd, branch);
if (resolution.kind === "not-found") {
throw new Error(`Branch not found: ${branch}`);
}
const { stdout } = await execAsync("git rev-parse --abbrev-ref HEAD", {
cwd,
});
const current = stdout.trim();
if (current === branch) {
return;
}
await this.ensureCleanWorkingTree(cwd);
await execCommand("git", ["checkout", branch], { cwd });
return checkoutResolvedBranch({
cwd,
resolution,
});
}
private async createBranchFromBase(params: {
@@ -3557,7 +3556,7 @@ export class Session {
cwd,
});
return true;
} catch (error: any) {
} catch {
return false;
}
}
@@ -4034,59 +4033,49 @@ export class Session {
const resolvedCwd = expandTilde(cwd);
this.assertSafeGitRef(branchName, "branch");
// Try local branch first
try {
await execCommand("git", ["rev-parse", "--verify", branchName], {
cwd: resolvedCwd,
env: READ_ONLY_GIT_ENV,
});
this.emit({
type: "validate_branch_response",
payload: {
exists: true,
resolvedRef: branchName,
isRemote: false,
error: null,
requestId,
},
});
return;
} catch {
// Local branch doesn't exist, try remote
const resolution = await resolveBranchCheckout(resolvedCwd, branchName);
switch (resolution.kind) {
case "local":
this.emit({
type: "validate_branch_response",
payload: {
exists: true,
resolvedRef: resolution.name,
isRemote: false,
error: null,
requestId,
},
});
return;
case "remote-only":
this.emit({
type: "validate_branch_response",
payload: {
exists: true,
resolvedRef: resolution.remoteRef,
isRemote: true,
error: null,
requestId,
},
});
return;
case "not-found":
this.emit({
type: "validate_branch_response",
payload: {
exists: false,
resolvedRef: null,
isRemote: false,
error: null,
requestId,
},
});
return;
default: {
const exhaustiveCheck: never = resolution;
throw new Error(`Unhandled branch resolution: ${exhaustiveCheck}`);
}
}
// Try remote branch (origin/{branchName})
try {
await execCommand("git", ["rev-parse", "--verify", `origin/${branchName}`], {
cwd: resolvedCwd,
env: READ_ONLY_GIT_ENV,
});
this.emit({
type: "validate_branch_response",
payload: {
exists: true,
resolvedRef: `origin/${branchName}`,
isRemote: true,
error: null,
requestId,
},
});
return;
} catch {
// Remote branch doesn't exist either
}
// Branch not found anywhere
this.emit({
type: "validate_branch_response",
payload: {
exists: false,
resolvedRef: null,
isRemote: false,
error: null,
requestId,
},
});
} catch (error) {
this.emit({
type: "validate_branch_response",
@@ -4360,7 +4349,7 @@ export class Session {
const { cwd, branch, requestId } = msg;
try {
await this.checkoutExistingBranch(cwd, branch);
const checkoutResult = await this.checkoutExistingBranch(cwd, branch);
this.github.invalidate({ cwd });
this.checkoutDiffManager.scheduleRefreshForCwd(cwd);
@@ -4374,6 +4363,7 @@ export class Session {
cwd,
success: true,
branch,
source: checkoutResult.source,
error: null,
requestId,
},
@@ -1108,7 +1108,7 @@ describe("handleCreatePaseoWorktreeRequest", () => {
test("buildAgentSessionConfig invalidates GitHub cache after branch setup mutations", async () => {
const invalidate = vi.fn();
const createBranchFromBase = vi.fn(async () => {});
const checkoutExistingBranch = vi.fn(async () => {});
const checkoutExistingBranch = vi.fn(async () => ({ source: "local" as const }));
const createPaseoWorktree = vi.fn(async () => {
throw new Error("should not create worktree");
});
@@ -27,6 +27,7 @@ import type { ScriptRouteStore } from "./script-proxy.js";
import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
import type { GitHubService } from "../services/github-service.js";
import { getCheckoutStatus, resolveRepositoryDefaultBranch } from "../utils/checkout-git.js";
import type { CheckoutExistingBranchResult } from "../utils/checkout-git.js";
import { expandTilde } from "../utils/path.js";
import {
deletePaseoWorktree,
@@ -75,7 +76,7 @@ type BuildAgentSessionConfigDependencies = {
resolveRepositoryDefaultBranch?: (repoRoot: string) => Promise<string>;
},
) => Promise<CreatePaseoWorktreeResult>;
checkoutExistingBranch: (cwd: string, branch: string) => Promise<void>;
checkoutExistingBranch: (cwd: string, branch: string) => Promise<CheckoutExistingBranchResult>;
createBranchFromBase: (params: {
cwd: string;
baseBranch: string;
+3
View File
@@ -2640,6 +2640,7 @@ export const CheckoutSwitchBranchResponseSchema = z.object({
cwd: z.string(),
success: z.boolean(),
branch: z.string(),
source: z.enum(["local", "remote"]).optional(),
error: CheckoutErrorSchema.nullable(),
requestId: z.string(),
}),
@@ -2702,6 +2703,8 @@ export const BranchSuggestionsResponseSchema = z.object({
z.object({
name: z.string(),
committerDate: z.number(),
hasLocal: z.boolean().optional(),
hasRemote: z.boolean().optional(),
}),
)
.optional(),
+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 () => {
+186 -44
View File
@@ -130,6 +130,8 @@ interface GitRef {
export interface BranchSuggestion {
name: string;
committerDate: number;
hasLocal: boolean;
hasRemote: boolean;
}
async function listGitRefs(cwd: string, refPrefix: string): Promise<GitRef[]> {
@@ -154,9 +156,15 @@ async function listGitRefs(cwd: string, refPrefix: string): Promise<GitRef[]> {
.filter((ref): ref is GitRef => ref !== null);
}
interface BranchSuggestionMeta {
committerDate: number;
hasLocal: boolean;
hasRemote: boolean;
}
function sortBranchSuggestions(
branchNames: string[],
branchMeta: Map<string, { isLocal: boolean; committerDate: number }>,
branchMeta: Map<string, BranchSuggestionMeta>,
query: string,
): string[] {
const normalizedQuery = query.trim().toLowerCase();
@@ -197,14 +205,15 @@ export async function listBranchSuggestions(
listGitRefs(cwd, "refs/remotes/origin"),
]);
const branchMeta = new Map<string, { isLocal: boolean; committerDate: number }>();
const branchMeta = new Map<string, BranchSuggestionMeta>();
for (const ref of localRefs) {
const normalized = normalizeBranchSuggestionName(ref.name);
if (!normalized) continue;
const existing = branchMeta.get(normalized);
branchMeta.set(normalized, {
isLocal: true,
hasLocal: true,
hasRemote: existing?.hasRemote ?? false,
committerDate: Math.max(ref.committerDate, existing?.committerDate ?? 0),
});
}
@@ -214,10 +223,15 @@ export async function listBranchSuggestions(
if (!normalized) continue;
const existing = branchMeta.get(normalized);
if (!existing) {
branchMeta.set(normalized, { isLocal: false, committerDate: ref.committerDate });
branchMeta.set(normalized, {
hasLocal: false,
hasRemote: true,
committerDate: ref.committerDate,
});
} else {
branchMeta.set(normalized, {
...existing,
hasRemote: true,
committerDate: Math.max(ref.committerDate, existing.committerDate),
});
}
@@ -231,10 +245,110 @@ export async function listBranchSuggestions(
}
const ordered = sortBranchSuggestions(filteredNames, branchMeta, query);
return ordered.slice(0, limit).map((name) => ({
name,
committerDate: branchMeta.get(name)?.committerDate ?? 0,
}));
return ordered.slice(0, limit).map((name) => {
const meta = branchMeta.get(name);
return {
name,
committerDate: meta?.committerDate ?? 0,
hasLocal: meta?.hasLocal ?? false,
hasRemote: meta?.hasRemote ?? false,
};
});
}
export interface LocalBranchCheckoutResolution {
kind: "local";
name: string;
}
export interface RemoteOnlyBranchCheckoutResolution {
kind: "remote-only";
name: string;
remoteRef: string;
}
export interface NotFoundBranchCheckoutResolution {
kind: "not-found";
}
export type BranchCheckoutResolution =
| LocalBranchCheckoutResolution
| RemoteOnlyBranchCheckoutResolution
| NotFoundBranchCheckoutResolution;
export async function resolveBranchCheckout(
cwd: string,
name: string,
): Promise<BranchCheckoutResolution> {
await requireGitRepo(cwd);
const normalized = normalizeBranchSuggestionName(name);
if (!normalized) {
return { kind: "not-found" };
}
const localRef = `refs/heads/${normalized}`;
const localResult = await runGitCommand(["rev-parse", "--verify", "--quiet", localRef], {
cwd,
env: READ_ONLY_GIT_ENV,
acceptExitCodes: [0, 1],
});
const hasLocal = localResult.exitCode === 0;
if (hasLocal) {
return { kind: "local", name: normalized };
}
const remoteRef = `origin/${normalized}`;
const remoteRefPath = `refs/remotes/${remoteRef}`;
const remoteResult = await runGitCommand(["rev-parse", "--verify", "--quiet", remoteRefPath], {
cwd,
env: READ_ONLY_GIT_ENV,
acceptExitCodes: [0, 1],
});
const hasRemote = remoteResult.exitCode === 0;
if (hasRemote) {
return { kind: "remote-only", name: normalized, remoteRef };
}
return { kind: "not-found" };
}
export type BranchCheckoutSource = "local" | "remote";
export interface CheckoutExistingBranchResult {
source: BranchCheckoutSource;
}
export interface CheckoutResolvedBranchInput {
cwd: string;
resolution: BranchCheckoutResolution;
requestedBranch?: string;
}
export async function checkoutResolvedBranch(
input: CheckoutResolvedBranchInput,
): Promise<CheckoutExistingBranchResult> {
const { cwd, resolution } = input;
switch (resolution.kind) {
case "local": {
const { stdout } = await runGitCommand(["rev-parse", "--abbrev-ref", "HEAD"], { cwd });
const current = stdout.trim();
if (current === resolution.name) {
return { source: "local" };
}
await runGitCommand(["checkout", resolution.name], { cwd });
return { source: "local" };
}
case "remote-only":
await runGitCommand(["checkout", "-b", resolution.name, "--track", resolution.remoteRef], {
cwd,
});
return { source: "remote" };
case "not-found":
throw new Error(`Branch not found: ${input.requestedBranch ?? "unknown"}`);
}
}
async function listCheckoutFileChanges(
@@ -847,25 +961,59 @@ async function resolveBaseRef(repoRoot: string): Promise<string | null> {
}
function normalizeLocalBranchRefName(input: string): string {
return input.startsWith("origin/") ? input.slice("origin/".length) : input;
if (input.startsWith("refs/remotes/origin/")) {
return input.slice("refs/remotes/origin/".length);
}
if (input.startsWith("refs/heads/")) {
return input.slice("refs/heads/".length);
}
if (input.startsWith("origin/")) {
return input.slice("origin/".length);
}
return input;
}
interface ComparisonBaseRefName {
localName: string;
originRef: string;
}
function normalizeComparisonBaseRefName(input: string): ComparisonBaseRefName {
const localName = normalizeLocalBranchRefName(input);
return { localName, originRef: `origin/${localName}` };
}
async function doesGitRefExist(cwd: string, fullRef: string): Promise<boolean> {
try {
await runGitCommand(["show-ref", "--verify", "--quiet", fullRef], {
cwd,
env: READ_ONLY_GIT_ENV,
});
return true;
} catch {
return false;
}
const result = await runGitCommand(["show-ref", "--verify", "--quiet", fullRef], {
cwd,
env: READ_ONLY_GIT_ENV,
acceptExitCodes: [0, 1],
});
return result.exitCode === 0;
}
async function resolveBestComparisonBaseRef(
cwd: string,
normalizedBaseRef: string,
): Promise<string> {
async function resolveBestComparisonBaseRef(cwd: string, baseRef: string): Promise<string> {
const normalized = normalizeComparisonBaseRefName(baseRef);
const [hasLocal, hasOrigin] = await Promise.all([
doesGitRefExist(cwd, `refs/heads/${normalized.localName}`),
doesGitRefExist(cwd, `refs/remotes/origin/${normalized.localName}`),
]);
if (hasOrigin) {
return normalized.originRef;
}
if (hasLocal) {
return normalized.localName;
}
const refName =
baseRef.startsWith("origin/") || baseRef.startsWith("refs/remotes/origin/")
? normalized.originRef
: normalized.localName;
throw new Error(`Base branch not found locally or on origin: ${refName}`);
}
async function resolveMostAheadBaseRef(cwd: string, normalizedBaseRef: string): Promise<string> {
const [hasLocal, hasOrigin] = await Promise.all([
doesGitRefExist(cwd, `refs/heads/${normalizedBaseRef}`),
doesGitRefExist(cwd, `refs/remotes/origin/${normalizedBaseRef}`),
@@ -881,20 +1029,18 @@ async function resolveBestComparisonBaseRef(
throw new Error(`Base branch not found locally or on origin: ${normalizedBaseRef}`);
}
// Both exist: choose the ref with more unique commits compared to the other.
try {
const { stdout } = await runGitCommand(
["rev-list", "--left-right", "--count", `${normalizedBaseRef}...origin/${normalizedBaseRef}`],
{ cwd, env: READ_ONLY_GIT_ENV },
);
const [localOnlyRaw, originOnlyRaw] = stdout.trim().split(/\s+/);
const localOnly = Number.parseInt(localOnlyRaw ?? "0", 10);
const originOnly = Number.parseInt(originOnlyRaw ?? "0", 10);
if (!Number.isNaN(localOnly) && !Number.isNaN(originOnly) && originOnly > localOnly) {
return `origin/${normalizedBaseRef}`;
}
} catch {
// ignore and fall back to local
const { stdout } = await runGitCommand(
["rev-list", "--left-right", "--count", `${normalizedBaseRef}...origin/${normalizedBaseRef}`],
{ cwd, env: READ_ONLY_GIT_ENV },
);
const [localOnlyRaw, originOnlyRaw] = stdout.trim().split(/\s+/);
const localOnly = Number.parseInt(localOnlyRaw ?? "0", 10);
const originOnly = Number.parseInt(originOnlyRaw ?? "0", 10);
if (Number.isNaN(localOnly) || Number.isNaN(originOnly)) {
return normalizedBaseRef;
}
if (originOnly > localOnly) {
return `origin/${normalizedBaseRef}`;
}
return normalizedBaseRef;
@@ -909,7 +1055,7 @@ async function getAheadBehind(
if (!normalizedBaseRef || !currentBranch || normalizedBaseRef === currentBranch) {
return null;
}
const comparisonBaseRef = await resolveBestComparisonBaseRef(cwd, normalizedBaseRef);
const comparisonBaseRef = await resolveBestComparisonBaseRef(cwd, baseRef);
const { stdout } = await runGitCommand(
["rev-list", "--left-right", "--count", `${comparisonBaseRef}...${currentBranch}`],
{ cwd, env: READ_ONLY_GIT_ENV },
@@ -1189,10 +1335,7 @@ async function getCheckoutShortstatUncached(
if (currentBranch && localBaseRef && currentBranch !== localBaseRef) {
// Feature branch: diff against the merge-base with the base branch
const comparisonBaseRef = await resolveBestComparisonBaseRef(
cwd,
normalizeLocalBranchRefName(localBaseRef),
);
const comparisonBaseRef = await resolveBestComparisonBaseRef(cwd, localBaseRef);
try {
const { stdout } = await runGitCommand(["merge-base", "HEAD", comparisonBaseRef], {
@@ -1329,8 +1472,7 @@ export async function getCheckoutDiff(
throw new Error(`Base ref mismatch: expected ${baseRef}, got ${compare.baseRef}`);
}
const normalizedBaseRef = normalizeLocalBranchRefName(baseRef);
const bestBaseRef = await resolveBestComparisonBaseRef(cwd, normalizedBaseRef);
const bestBaseRef = await resolveBestComparisonBaseRef(cwd, baseRef);
refForDiff = (await tryResolveMergeBase(cwd, bestBaseRef)) ?? bestBaseRef;
}
@@ -1715,7 +1857,7 @@ export async function mergeFromBase(
}
const normalizedBaseRef = normalizeLocalBranchRefName(baseRef);
const bestBaseRef = await resolveBestComparisonBaseRef(cwd, normalizedBaseRef);
const bestBaseRef = await resolveMostAheadBaseRef(cwd, normalizedBaseRef);
if (bestBaseRef === currentBranch) {
return;
}