Merge pull request #3205 from openchamber/fix/branch-selector-remote-checkout
fix(git): check out a local tracking branch when a remote branch is picked
This commit is contained in:
@@ -1347,8 +1347,10 @@ export const GitView: React.FC<GitViewProps> = ({ 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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -763,24 +763,82 @@ async function getGitBranchesRaw(directory: string): Promise<GitBranchResult> {
|
||||
return { all, current, branches };
|
||||
}
|
||||
|
||||
const gitRefExists = async (directory: string, ref: string): Promise<boolean> => {
|
||||
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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user