From 01b3e3346ff72d9e2a1691667edf074c07fbb44c Mon Sep 17 00:00:00 2001 From: Iuliia Ivashko Date: Fri, 28 Aug 2026 15:29:44 +0300 Subject: [PATCH] fix(git): check out a local tracking branch when a remote branch is picked --- CHANGELOG.md | 1 + packages/ui/src/components/views/GitView.tsx | 6 +- packages/vscode/CHANGELOG.md | 1 + packages/vscode/src/gitService.ts | 68 ++++++++++++++++++-- packages/web/server/lib/git/DOCUMENTATION.md | 2 +- packages/web/server/lib/git/service.js | 61 +++++++++++++++++- packages/web/server/lib/git/service.test.js | 61 ++++++++++++++++++ 7 files changed, 190 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 869cbd6f..eb4cc429 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ All notable changes to this project will be documented in this file. - Files: opening a file over 5,000 lines is no longer blocked — the line-count guard now allows up to 20,000 lines, letting large files reach the virtualized full-file preview instead of being rejected at the open step (thanks @gaojunran). - Usage: GitHub Copilot now shows a single AI Credits window, matching Copilot's token-based quota, in place of the old Chat Requests and Completions windows (thanks to @jakoss). - Settings: fixed the Cloudflare Tunnel download link shown when cloudflared is not installed (thanks to @AyoubAchour). +- Git: picking a remote branch such as `origin/main` in the branch selector now switches you to that branch instead of leaving the repository on a detached `HEAD` with no branch name. - Desktop: "Restart to Update" no longer looks dead when the update cannot be installed — the update window now shows the reason, including when the running copy was not installed from an official signed release, and the button stays available to retry. ## [1.21.0] - 2026-08-26 diff --git a/packages/ui/src/components/views/GitView.tsx b/packages/ui/src/components/views/GitView.tsx index 008550e6..eb455d39 100644 --- a/packages/ui/src/components/views/GitView.tsx +++ b/packages/ui/src/components/views/GitView.tsx @@ -1347,8 +1347,10 @@ export const GitView: React.FC = ({ isActive }) => { } try { - await git.checkoutBranch(currentDirectory, normalized); - toast.success(t('gitView.toast.checkedOut', { name: normalized })); + // Picking a remote-tracking branch checks out the local branch that + // tracks it, so report the branch the repository actually landed on. + const result = await git.checkoutBranch(currentDirectory, normalized); + toast.success(t('gitView.toast.checkedOut', { name: result?.branch || normalized })); await refreshStatusAndBranches(); await refreshLog(); } catch (err) { diff --git a/packages/vscode/CHANGELOG.md b/packages/vscode/CHANGELOG.md index 32ab4e1f..2d079e7a 100644 --- a/packages/vscode/CHANGELOG.md +++ b/packages/vscode/CHANGELOG.md @@ -1,5 +1,6 @@ ## [Unreleased] +- Picking a remote branch such as `origin/main` in the Git branch selector now switches you to that branch instead of leaving the repository on a detached `HEAD` with no branch name. - GitHub Copilot usage now shows a single AI Credits window, matching Copilot's token-based quota, in place of the old Chat Requests and Completions windows (thanks to @jakoss). - The context usage readout now reports the session cost including everything its subagents spent, matching the work status panel instead of showing a lower figure. diff --git a/packages/vscode/src/gitService.ts b/packages/vscode/src/gitService.ts index 97b3a4d1..fc758a7f 100644 --- a/packages/vscode/src/gitService.ts +++ b/packages/vscode/src/gitService.ts @@ -763,24 +763,82 @@ async function getGitBranchesRaw(directory: string): Promise { return { all, current, branches }; } +const gitRefExists = async (directory: string, ref: string): Promise => { + const result = await execGit(['show-ref', '--verify', '--quiet', ref], directory); + return result.exitCode === 0; +}; + +/** + * The branch selector lists remote-tracking branches beside local ones, so + * picking `origin/main` means "work on main", not "detach HEAD at the remote's + * commit" — which is what a literal checkout of a remote-tracking ref does. + * Resolve such a pick to the local branch, creating it with tracking when it + * does not exist yet. Anything we cannot resolve is checked out as requested, + * leaving git's own DWIM behavior intact. + */ +const resolveBranchCheckoutTarget = async ( + directory: string, + branch: string +): Promise<{ branch: string; remoteRef: string | null }> => { + const requested = String(branch || '').trim(); + const asRequested = { branch: requested, remoteRef: null }; + if (!requested) { + return asRequested; + } + + if (await gitRefExists(directory, `refs/heads/${requested}`)) { + return asRequested; + } + + const remoteRef = requested.replace(/^remotes\//, ''); + if (!(await gitRefExists(directory, `refs/remotes/${remoteRef}`))) { + return asRequested; + } + + const remotesResult = await execGit(['remote'], directory); + const remotes = remotesResult.exitCode === 0 + ? remotesResult.stdout.split('\n').map((line) => line.trim()).filter(Boolean) + : []; + const remote = remotes.find((name) => remoteRef.startsWith(`${name}/`)); + if (!remote) { + return asRequested; + } + + const localBranch = remoteRef.slice(remote.length + 1); + // `origin/HEAD` names no branch of its own; it is a pointer to one. + if (!localBranch || localBranch === 'HEAD') { + return asRequested; + } + + const localExists = await gitRefExists(directory, `refs/heads/${localBranch}`); + return { branch: localBranch, remoteRef: localExists ? null : remoteRef }; +}; + /** * Checkout a branch */ export async function checkoutBranch(directory: string, branch: string): Promise<{ success: boolean; branch: string }> { + const target = await resolveBranchCheckoutTarget(directory, branch); + + if (target.remoteRef) { + const tracked = await execGit(['checkout', '-b', target.branch, '--track', target.remoteRef], directory); + return { success: tracked.exitCode === 0, branch: target.branch }; + } + const repo = await getRepository(directory); - + if (repo) { try { - await repo.checkout(branch); - return { success: true, branch }; + await repo.checkout(target.branch); + return { success: true, branch: target.branch }; } catch (error) { console.error('[GitService] Failed to checkout branch:', error); } } // Fallback to raw git - const result = await execGit(['checkout', branch], directory); - return { success: result.exitCode === 0, branch }; + const result = await execGit(['checkout', target.branch], directory); + return { success: result.exitCode === 0, branch: target.branch }; } /** diff --git a/packages/web/server/lib/git/DOCUMENTATION.md b/packages/web/server/lib/git/DOCUMENTATION.md index 3488bc5f..818d71ad 100644 --- a/packages/web/server/lib/git/DOCUMENTATION.md +++ b/packages/web/server/lib/git/DOCUMENTATION.md @@ -40,7 +40,7 @@ The following functions are exported and used by the web server: ### Branch Operations - `getBranches(directory)`: Get list of local and remote branches (filtered to active remote branches). - `createBranch(directory, branchName, options)`: Create and checkout a new branch. -- `checkoutBranch(directory, branchName)`: Checkout an existing branch. +- `checkoutBranch(directory, branchName)`: Checkout an existing branch. A remote-tracking name (`origin/main`, or the `remotes/`-prefixed form) resolves to the local branch of that name, created with `--track` when it does not exist yet, because the branch selector offers remote branches as places to work rather than commits to inspect — a literal checkout of the remote ref would detach HEAD. A local branch whose own name looks like a remote ref wins over that resolution, and anything unresolvable is checked out as requested. The returned `branch` is the branch that was actually checked out, which callers should report instead of the requested name. - `deleteBranch(directory, branch, options)`: Delete a branch (supports force flag). - `renameBranch(directory, oldName, newName)`: Rename a branch and preserve upstream tracking. - `getRemotes(directory)`: Get list of configured remotes. diff --git a/packages/web/server/lib/git/service.js b/packages/web/server/lib/git/service.js index 633e34c3..f22f4ba3 100644 --- a/packages/web/server/lib/git/service.js +++ b/packages/web/server/lib/git/service.js @@ -3773,12 +3773,69 @@ export async function createBranch(directory, branchName, options = {}) { } } +// Deliberately not `--quiet`: simple-git resolves a quiet non-zero exit as +// success, so the ref itself has to be echoed for the answer to mean anything. +const gitRefExists = async (git, ref) => { + try { + const output = await git.raw(['show-ref', '--verify', ref]); + return String(output).trim().length > 0; + } catch { + return false; + } +}; + +/** + * The branch selector lists remote-tracking branches beside local ones, so + * picking `origin/main` means "work on main", not "detach HEAD at the remote's + * commit" — which is what a literal checkout of a remote-tracking ref does. + * Resolve such a pick to the local branch, creating it with tracking when it + * does not exist yet. Anything we cannot resolve is checked out as requested, + * leaving git's own DWIM behavior intact. + */ +const resolveBranchCheckoutTarget = async (git, branchName) => { + const requested = String(branchName || '').trim(); + if (!requested) { + throw new Error('Branch name is required'); + } + + const asRequested = { branch: requested, remoteRef: null }; + + if (await gitRefExists(git, `refs/heads/${requested}`)) { + return asRequested; + } + + const remoteRef = requested.replace(/^remotes\//, ''); + if (!(await gitRefExists(git, `refs/remotes/${remoteRef}`))) { + return asRequested; + } + + const remotes = await git.getRemotes(); + const remote = remotes.find((entry) => entry?.name && remoteRef.startsWith(`${entry.name}/`)); + if (!remote) { + return asRequested; + } + + const localBranch = remoteRef.slice(remote.name.length + 1); + // `origin/HEAD` names no branch of its own; it is a pointer to one. + if (!localBranch || localBranch === 'HEAD') { + return asRequested; + } + + const localExists = await gitRefExists(git, `refs/heads/${localBranch}`); + return { branch: localBranch, remoteRef: localExists ? null : remoteRef }; +}; + export async function checkoutBranch(directory, branchName) { const { git } = await createRepositoryGitContext(directory); try { - await git.checkout(branchName); - return { success: true, branch: branchName }; + const target = await resolveBranchCheckoutTarget(git, branchName); + if (target.remoteRef) { + await git.raw(['checkout', '-b', target.branch, '--track', target.remoteRef]); + } else { + await git.checkout(target.branch); + } + return { success: true, branch: target.branch }; } catch (error) { console.error('Failed to checkout branch:', error); throw error; diff --git a/packages/web/server/lib/git/service.test.js b/packages/web/server/lib/git/service.test.js index 9443fcb6..4df73b3a 100644 --- a/packages/web/server/lib/git/service.test.js +++ b/packages/web/server/lib/git/service.test.js @@ -6,6 +6,7 @@ import { afterAll, afterEach, describe, expect, it, vi } from 'vitest'; import simpleGit from 'simple-git'; import { + checkoutBranch, checkoutCommit, cherryPick, createWorktree, @@ -1052,6 +1053,66 @@ describe('checkoutCommit', () => { }); }); +// --------------------------------------------------------------------------- +// checkoutBranch +// --------------------------------------------------------------------------- + +describe('checkoutBranch', () => { + it('checks out a local branch by name', async () => { + const { repository } = createRepositoryWithRemote(); + runGit(repository, ['branch', 'feature']); + + const result = await checkoutBranch(repository, 'feature'); + + expect(result).toEqual({ success: true, branch: 'feature' }); + expect(runGit(repository, ['rev-parse', '--abbrev-ref', 'HEAD']).trim()).toBe('feature'); + }); + + it('creates a tracking local branch instead of detaching HEAD on a remote branch', async () => { + const { repository } = createRepositoryWithRemote({ defaultBranch: 'react' }); + + const result = await checkoutBranch(repository, 'origin/react'); + + expect(result).toEqual({ success: true, branch: 'react' }); + expect(runGit(repository, ['rev-parse', '--abbrev-ref', 'HEAD']).trim()).toBe('react'); + expect(runGit(repository, ['rev-parse', '--abbrev-ref', 'react@{upstream}']).trim()).toBe('origin/react'); + }); + + it('checks out the existing local branch when a remote branch is picked', async () => { + const { repository } = createRepositoryWithRemote({ defaultBranch: 'react' }); + runGit(repository, ['branch', 'react', 'origin/react']); + + const result = await checkoutBranch(repository, 'origin/react'); + + expect(result).toEqual({ success: true, branch: 'react' }); + expect(runGit(repository, ['rev-parse', '--abbrev-ref', 'HEAD']).trim()).toBe('react'); + }); + + it('accepts the remotes/ prefixed form of a remote branch', async () => { + const { repository } = createRepositoryWithRemote({ defaultBranch: 'react' }); + + const result = await checkoutBranch(repository, 'remotes/origin/react'); + + expect(result).toEqual({ success: true, branch: 'react' }); + expect(runGit(repository, ['rev-parse', '--abbrev-ref', 'HEAD']).trim()).toBe('react'); + }); + + it('prefers a local branch whose name looks like a remote ref', async () => { + const { repository } = createRepositoryWithRemote({ defaultBranch: 'react' }); + runGit(repository, ['branch', 'origin/react']); + + const result = await checkoutBranch(repository, 'origin/react'); + + expect(result).toEqual({ success: true, branch: 'origin/react' }); + expect(runGit(repository, ['symbolic-ref', 'HEAD']).trim()).toBe('refs/heads/origin/react'); + }); + + it('rejects an unknown branch', async () => { + const { repository } = createRepositoryWithRemote(); + await expect(checkoutBranch(repository, 'does-not-exist')).rejects.toThrow(); + }); +}); + // --------------------------------------------------------------------------- // cherryPick // ---------------------------------------------------------------------------