From 2d9cbb04530750de69955ee845a6b6a2f53bcc9e Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 19 Apr 2026 14:26:47 +0700 Subject: [PATCH] fix(server): resolve remote-only branches in switcher checkout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/`. Explicit `origin/` 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/` → ``, 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 `. Remote-only → `git checkout -b --track origin/`. Not-found → throws the existing `Branch not found: ` 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. --- packages/server/src/server/session.test.ts | 44 +++ packages/server/src/server/session.ts | 126 ++++---- .../src/server/worktree-session.test.ts | 2 +- .../server/src/server/worktree-session.ts | 3 +- packages/server/src/shared/messages.ts | 3 + .../server/src/utils/checkout-git.test.ts | 280 +++++++++++++++++- packages/server/src/utils/checkout-git.ts | 230 +++++++++++--- 7 files changed, 568 insertions(+), 120 deletions(-) diff --git a/packages/server/src/server/session.test.ts b/packages/server/src/server/session.test.ts index 53a18b69d..74202e891 100644 --- a/packages/server/src/server/session.test.ts +++ b/packages/server/src/server/session.test.ts @@ -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[] = []; diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index ee3e5dc5a..fc6358e08 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -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 { + private async checkoutExistingBranch( + cwd: string, + branch: string, + ): Promise { 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, }, diff --git a/packages/server/src/server/worktree-session.test.ts b/packages/server/src/server/worktree-session.test.ts index be99375aa..63353b623 100644 --- a/packages/server/src/server/worktree-session.test.ts +++ b/packages/server/src/server/worktree-session.test.ts @@ -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"); }); diff --git a/packages/server/src/server/worktree-session.ts b/packages/server/src/server/worktree-session.ts index 971cadd60..bd585bd75 100644 --- a/packages/server/src/server/worktree-session.ts +++ b/packages/server/src/server/worktree-session.ts @@ -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; }, ) => Promise; - checkoutExistingBranch: (cwd: string, branch: string) => Promise; + checkoutExistingBranch: (cwd: string, branch: string) => Promise; createBranchFromBase: (params: { cwd: string; baseBranch: string; diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index edb6bdeeb..e5e6f46df 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -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(), diff --git a/packages/server/src/utils/checkout-git.test.ts b/packages/server/src/utils/checkout-git.test.ts index 23a27e050..a5e5ce943 100644 --- a/packages/server/src/utils/checkout-git.test.ts +++ b/packages/server/src/utils/checkout-git.test.ts @@ -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 () => { diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index cadc61258..fc388a7f1 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -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 { @@ -154,9 +156,15 @@ async function listGitRefs(cwd: string, refPrefix: string): Promise { .filter((ref): ref is GitRef => ref !== null); } +interface BranchSuggestionMeta { + committerDate: number; + hasLocal: boolean; + hasRemote: boolean; +} + function sortBranchSuggestions( branchNames: string[], - branchMeta: Map, + branchMeta: Map, 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(); + const branchMeta = new Map(); 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 { + 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 { + 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 { } 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 { - 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 { +async function resolveBestComparisonBaseRef(cwd: string, baseRef: string): Promise { + 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 { 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; }