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:
@@ -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[] = [];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user