diff --git a/src/main/git/branch-switch-target-real-git.test.ts b/src/main/git/branch-switch-target-real-git.test.ts new file mode 100644 index 00000000000..eeb93ab2435 --- /dev/null +++ b/src/main/git/branch-switch-target-real-git.test.ts @@ -0,0 +1,229 @@ +import { chmod, mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { runProcess } from '@orca/process-host' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import type { BranchSwitchTarget } from '../../shared/git/branch-switch-target' +import { installRealWorkerThreadChildProcesses } from '../worker-thread-child-process/worker-thread-child-process-real.test-fixtures' +import { BranchSwitchLandedWithWarningError, switchBranchGuarded } from './branch-switch' +import { BranchSwitchCollisionError } from './branch-switch-target' +import { checkoutBranch, listLocalBranches } from './checkout' +import { getStatus } from './source-control/status-read' +import { searchRemoteRefPage } from './repo-remote-ref-search' + +let directory: string +async function git(...args: string[]): Promise { + const result = await runProcess({ + program: 'git', + args, + cwd: directory, + env: { + ...process.env, + GIT_AUTHOR_DATE: '2024-01-01T00:00:00Z', + GIT_COMMITTER_DATE: '2024-01-01T00:00:00Z' + } + }) + if (result.code !== 0) { + throw new Error(result.stderr) + } + return result.stdout.trim() +} +function switchTo(target: BranchSwitchTarget, blocker: string | null = null, onSwitched = vi.fn()) { + return switchBranchGuarded( + directory, + target, + {}, + { + getStatus: () => getStatus(directory, { includeLineStats: false }), + checkout: (branch, creation) => checkoutBranch(directory, branch, {}, creation), + getAgentBlocker: async () => blocker, + onSwitched + } + ) +} +async function remote(name: string) { + await git('remote', 'add', name, './unused') + await git('update-ref', `refs/remotes/${name}/topic`, 'HEAD') +} +beforeEach(async () => { + directory = await realpath(await mkdtemp(join(tmpdir(), 'orca-branch-target-'))) + await git('init', '-q') + await git('symbolic-ref', 'HEAD', 'refs/heads/main') + await git('config', 'user.name', 'Test') + await git('config', 'user.email', 'test@example.test') + await git('config', 'commit.gpgsign', 'false') + await git('config', 'core.hooksPath', '') + await writeFile(join(directory, 'file.txt'), 'base\n') + await git('add', 'file.txt') + await git('commit', '-qm', 'base') +}) +afterEach(async () => { + await rm(directory, { recursive: true, force: true }) +}) + +it('switches an exact local target and records it', async () => { + await git('branch', 'feature') + const recorded = vi.fn() + expect(await switchTo({ kind: 'local', name: 'feature' }, null, recorded)).toEqual({ + branch: 'feature', + previousBranch: 'main' + }) + expect(await git('symbolic-ref', '--short', 'HEAD')).toBe('feature') + expect(recorded).toHaveBeenCalledOnce() +}) +it.each(['team/upstream', 'origin'])( + 'resolves %s explicitly even with a slash-named local', + async (name) => { + await remote(name) + await git('branch', `${name}/topic`) + await switchTo({ kind: 'remote', name: `${name}/topic` }) + expect(await git('symbolic-ref', '--short', 'HEAD')).toBe('topic') + expect(await git('rev-parse', '--symbolic-full-name', '@{upstream}')).toBe( + `refs/remotes/${name}/topic` + ) + expect(await git('rev-parse', `refs/heads/${name}/topic`)).toBe(await git('rev-parse', 'main')) + } +) +it('reuses a differently named local tracker', async () => { + await remote('origin') + await git('branch', '--track', 'my-topic', 'refs/remotes/origin/topic') + expect(await switchTo({ kind: 'remote', name: 'origin/topic' })).toEqual({ + branch: 'my-topic', + previousBranch: 'main' + }) + expect(await git('symbolic-ref', '--short', 'HEAD')).toBe('my-topic') + expect((await listLocalBranches(directory)).branches).not.toContain('topic') +}) +it('creates from HEAD without tracking despite autoSetupMerge and an exact remote match', async () => { + await remote('origin') + await git('config', 'branch.autoSetupMerge', 'always') + await switchTo({ kind: 'new', name: 'topic' }) + expect(await git('rev-parse', 'HEAD')).toBe(await git('rev-parse', 'main')) + expect(await git('for-each-ref', '--format=%(upstream)', 'refs/heads/topic')).toBe('') +}) +it('refusals leave no new branch or tracking config behind', async () => { + await remote('origin') + await writeFile(join(directory, 'draft.txt'), 'draft') + await expect(switchTo({ kind: 'remote', name: 'origin/topic' })).rejects.toThrow( + 'Commit or discard' + ) + await expect( + switchTo({ kind: 'new', name: 'draft' }, 'Codex is working in this workspace.') + ).rejects.toThrow('Codex') + expect( + await git('for-each-ref', '--format=%(refname)', 'refs/heads/draft', 'refs/heads/topic') + ).toBe('') + expect(await git('symbolic-ref', '--short', 'HEAD')).toBe('main') +}) +it('carries uncommitted changes onto a new branch but refuses an existing branch', async () => { + const commit = await git('rev-parse', 'HEAD') + await git('branch', 'feature') + await writeFile(join(directory, 'file.txt'), 'work in progress\n') + await writeFile(join(directory, 'draft.txt'), 'draft\n') + await expect(switchTo({ kind: 'local', name: 'feature' })).rejects.toThrow('Commit or discard') + expect(await git('symbolic-ref', '--short', 'HEAD')).toBe('main') + await switchTo({ kind: 'new', name: 'draft' }) + expect(await git('symbolic-ref', '--short', 'HEAD')).toBe('draft') + expect(await git('rev-parse', 'HEAD')).toBe(commit) + expect(await git('diff', '--name-only')).toBe('file.txt') + expect(await readFile(join(directory, 'file.txt'), 'utf8')).toBe('work in progress\n') + expect(await git('ls-files', '--others', '--exclude-standard')).toBe('draft.txt') + expect(await readFile(join(directory, 'draft.txt'), 'utf8')).toBe('draft\n') +}) +it('offers direct local collision recovery without relying on discovery', async () => { + await git('branch', 'topic') + await expect(switchTo({ kind: 'new', name: 'topic' })).rejects.toMatchObject({ + data: { existingBranch: 'topic' } + }) + await remote('origin') + await expect(switchTo({ kind: 'remote', name: 'origin/topic' })).rejects.toBeInstanceOf( + BranchSwitchCollisionError + ) + await switchTo({ kind: 'local', name: 'topic' }) + expect(await git('symbolic-ref', '--short', 'HEAD')).toBe('topic') +}) +it('probes the resolved local name when a remote checkout lands with a hook failure', async () => { + await remote('team/upstream') + await mkdir(join(directory, '.git', 'hooks-test')) + const hook = join(directory, '.git', 'hooks-test', 'post-checkout') + await writeFile(hook, '#!/bin/sh\necho "hook failed" >&2\necho logout >&2\nexit 1\n') + await chmod(hook, 0o755) + await git('config', 'core.hooksPath', '.git/hooks-test') + const recorded = vi.fn() + await expect( + switchTo({ kind: 'remote', name: 'team/upstream/topic' }, null, recorded) + ).rejects.toThrow( + new BranchSwitchLandedWithWarningError( + 'Switched to topic. A git hook in this repository failed afterwards and said: hook failed. The switch itself worked.', + 'topic' + ) + ) + expect(await git('symbolic-ref', '--short', 'HEAD')).toBe('topic') + expect(recorded).toHaveBeenCalledOnce() +}) +it("quotes git's own error without blaming a hook when a switch lands", async () => { + await git('branch', 'topic') + await expect( + switchBranchGuarded( + directory, + { kind: 'local', name: 'topic' }, + {}, + { + getStatus: () => getStatus(directory, { includeLineStats: false }), + checkout: async (branch) => { + await git('checkout', '-q', branch) + throw Object.assign(new Error('checkout failed'), { + stderr: 'error: unable to create file app.css: Permission denied\n' + }) + }, + getAgentBlocker: async () => null + } + ) + ).rejects.toThrow( + new BranchSwitchLandedWithWarningError( + 'Switched to topic, but git reported: error: unable to create file app.css: Permission denied', + 'topic' + ) + ) +}) +it('maps a remote not covered by its fetch settings to tracking recovery', async () => { + await remote('origin') + await git('config', 'remote.origin.fetch', '+refs/heads/other:refs/remotes/origin/other') + await expect(switchTo({ kind: 'remote', name: 'origin/topic' })).rejects.toThrow( + "isn't included in the remote's fetch settings" + ) + expect(await git('symbolic-ref', '--short', 'HEAD')).toBe('main') +}) +it('reads live worktree occupancy and discovers remotes despite 50 local matches', async () => { + await remote('origin') + const newer = await runProcess({ + program: 'git', + args: ['commit-tree', 'HEAD^{tree}', '-p', 'HEAD', '-m', 'newer local refs'], + cwd: directory, + env: { + ...process.env, + GIT_AUTHOR_DATE: '2024-01-02T00:00:00Z', + GIT_COMMITTER_DATE: '2024-01-02T00:00:00Z' + } + }) + expect(newer.code).toBe(0) + const oid = newer.stdout.trim() + const edits = Array.from({ length: 50 }, (_, n) => `create refs/heads/topic-${n} ${oid}`).join( + '\n' + ) + const transaction = await runProcess({ + program: 'git', + args: ['update-ref', '--stdin'], + cwd: directory, + input: `${edits}\n` + }) + expect(transaction.code).toBe(0) + const page = await searchRemoteRefPage(directory, 'topic', 50) + expect(page.refDetails).toEqual([{ refName: 'origin/topic', localBranchName: 'topic' }]) + const occupied = join(directory, '.git', 'checkout-test') + await git('worktree', 'add', '-q', occupied, 'topic-0') + expect((await listLocalBranches(directory)).worktreePaths?.['topic-0']).toBe(occupied) + await git('worktree', 'remove', occupied) + expect((await listLocalBranches(directory)).worktreePaths?.['topic-0']).toBeUndefined() +}) +installRealWorkerThreadChildProcesses() diff --git a/src/main/git/branch-switch-target.test.ts b/src/main/git/branch-switch-target.test.ts new file mode 100644 index 00000000000..6bd2b63a1d4 --- /dev/null +++ b/src/main/git/branch-switch-target.test.ts @@ -0,0 +1,15 @@ +import { beforeEach, expect, it, vi } from 'vitest' +import { resolveBranchSwitchTarget } from './branch-switch-target' +import { gitExecFileAsync } from './runner' +vi.mock('./runner', () => ({ gitExecFileAsync: vi.fn() })) +beforeEach(() => vi.mocked(gitExecFileAsync).mockReset()) +it('explains how to recover when remote settings cannot be read', async () => { + vi.mocked(gitExecFileAsync) + .mockResolvedValueOnce({ stdout: 'refs/remotes/origin/topic\t\t\n', stderr: '' }) + .mockRejectedValueOnce(new Error('raw remote command failure')) + await expect( + resolveBranchSwitchTarget('/repo', { kind: 'remote', name: 'origin/topic' }, {}) + ).rejects.toThrow( + "Orca couldn't read this repository's remotes. Check its remote settings in a terminal, then try again." + ) +}) diff --git a/src/main/git/branch-switch-target.ts b/src/main/git/branch-switch-target.ts new file mode 100644 index 00000000000..925cf864062 --- /dev/null +++ b/src/main/git/branch-switch-target.ts @@ -0,0 +1,120 @@ +import type { + BranchSwitchTarget, + BranchSwitchCreation +} from '../../shared/git/branch-switch-target' +import { isBranchNameValid } from '../../shared/git/branch-switch-target' +import { gitExecFileAsync } from './runner' +import { gitOptionsForWorktree, type GitRuntimeOptions } from './git-runtime-options' +import { readGitCommandFailureStderr } from '../../shared/git/commands/git-command-failure-text' + +export class BranchSwitchCollisionError extends Error { + constructor(readonly branch: string) { + super(`A local branch named ${branch} already exists. Switch to that branch instead.`) + } + get data() { + return { existingBranch: this.branch } + } +} + +export async function resolveBranchSwitchTarget( + path: string, + target: BranchSwitchTarget, + options: GitRuntimeOptions +): Promise<{ branch: string; creation?: BranchSwitchCreation }> { + const execution = gitOptionsForWorktree(path, { ...options, admissionTier: 'interactive' }) + const run = (args: string[]) => gitExecFileAsync(args, execution) + if (!isBranchNameValid(target.name)) { + throw new Error('This branch name is invalid. Choose a different name.') + } + if (target.kind === 'new') { + await run(['check-ref-format', '--branch', target.name]).catch(() => { + throw new Error('This branch name is invalid. Choose a different name.') + }) + return { branch: target.name, creation: { kind: 'new' } } + } + const full = + target.kind === 'local' + ? `refs/heads/${target.name}` + : `refs/remotes/${target.name.replace(/^refs\/remotes\//, '')}` + const { stdout } = await run([ + 'for-each-ref', + '--format=%(refname)%09%(upstream)%09%(symref)', + target.kind === 'remote' ? 'refs/heads/' : full, + ...(target.kind === 'remote' ? [full] : []) + ]) + const rows = stdout + .trim() + .split('\n') + .map((line) => line.split('\t')) + const selected = rows.find((row) => row[0] === full) + if (!selected || selected[2]) { + throw new Error( + 'This branch is no longer available. Refresh the branch list and choose another branch.' + ) + } + if (target.kind === 'local') { + return { branch: target.name } + } + const tracker = rows.find((row) => row[0]?.startsWith('refs/heads/') && row[1] === full) + if (tracker?.[0]) { + return { branch: tracker[0].slice('refs/heads/'.length) } + } + const remotes = ( + await run(['remote']).catch((error) => { + if (options.signal?.aborted || (error instanceof Error && error.name === 'AbortError')) { + throw error + } + throw new Error( + "Orca couldn't read this repository's remotes. Check its remote settings in a terminal, then try again." + ) + }) + ).stdout + .trim() + .split('\n') + .sort((a, b) => b.length - a.length) + const remotePath = full.slice('refs/remotes/'.length) + const remote = remotes.find((name) => name && remotePath.startsWith(`${name}/`)) + if (!remote) { + throw new Error( + 'This remote is no longer configured. Fetch from a configured remote, then try again.' + ) + } + const branch = remotePath.slice(remote.length + 1) + if (rows.some((row) => row[0] === `refs/heads/${branch}`)) { + throw new BranchSwitchCollisionError(branch) + } + return { branch, creation: { kind: 'remote', startPoint: full } } +} + +export function branchSwitchFailure(error: unknown, branch: string): Error { + if (error instanceof BranchSwitchCollisionError) { + return error + } + const text = readGitCommandFailureStderr(error) ?? (error instanceof Error ? error.message : '') + if (/already exists/.test(text)) { + return new BranchSwitchCollisionError(branch) + } + const occupied = /(?:already checked out at|already used by worktree at) ['"](.+?)['"]/.exec(text) + if (occupied) { + return new Error( + `Checked out in ${occupied[1]}. Open that workspace or switch its branch in a terminal, then try again.` + ) + } + const tracking = branchSwitchTrackingFailure(text, branch) + if (tracking) { + return new Error(tracking) + } + return new Error( + "Orca couldn't switch branches. Check this repository in a terminal, then try again." + ) +} + +export function branchSwitchTrackingFailure(text: string, branch: string): string | null { + if (/cannot set up tracking information/.test(text)) { + return `Orca couldn't set up tracking for ${branch} because this remote branch isn't included in the remote's fetch settings. Update that remote's fetch settings, fetch the branch, then try again.` + } + if (/could not lock config|config.*lock|config.lock/.test(text)) { + return "Orca couldn't set up tracking because another Git action is updating this repository. Wait for it to finish, then try again." + } + return null +} diff --git a/src/main/git/branch-switch.ts b/src/main/git/branch-switch.ts index f53a7d0dd2c..e0d8d01f7b4 100644 --- a/src/main/git/branch-switch.ts +++ b/src/main/git/branch-switch.ts @@ -1,3 +1,12 @@ +import type { + BranchSwitchTarget, + BranchSwitchCreation +} from '../../shared/git/branch-switch-target' +import { + resolveBranchSwitchTarget, + branchSwitchFailure, + branchSwitchTrackingFailure +} from './branch-switch-target' import { access } from 'node:fs/promises' import * as path from 'node:path' import type { GitStatusResult } from '../../shared/git/status/git-status-types' @@ -19,7 +28,10 @@ export class BranchSwitchRefusedError extends Error { /** The switch landed, but git reported a problem (files left behind, a failed hook); shown as-is. */ export class BranchSwitchLandedWithWarningError extends Error { - constructor(message: string) { + constructor( + message: string, + readonly branch: string + ) { super(message) this.name = 'BranchSwitchLandedWithWarningError' } @@ -40,7 +52,8 @@ function gitFailureLine(stderr: string): string | null { const lines = stderr .split('\n') .map((line) => line.trim()) - .filter((line) => line && !GIT_CHECKOUT_PROGRESS.test(line)) + // WSL git runs in an interactive login shell, which writes `logout` to stderr as it exits. + .filter((line) => line && line !== 'logout' && !GIT_CHECKOUT_PROGRESS.test(line)) return lines.find((line) => /^(error|fatal):/.test(line)) ?? lines.at(-1) ?? null } @@ -63,7 +76,7 @@ export type BranchSwitchDeps = { /** Status as the panel reads it, so Orca's own shared-path symlinks don't count as changes. */ getStatus: () => Promise /** The provider's checkout, so the user's switch keeps the interactive git queue. */ - checkout: () => Promise<{ skippedPaths: string[] }> + checkout: (branch: string, creation?: BranchSwitchCreation) => Promise<{ skippedPaths: string[] }> /** A user-facing reason when an agent is working in this checkout, else null. */ getAgentBlocker: () => Promise /** Record the user's choice before the lock lets a queued first-work rename look at the branch. */ @@ -164,6 +177,7 @@ async function firstHiddenSubmoduleEdit( async function readRefusal( worktreePath: string, branch: string, + creating: boolean, options: GitRuntimeOptions, deps: BranchSwitchDeps ): Promise<{ refusal: string | null; previousBranch: string | null }> { @@ -201,55 +215,81 @@ async function readRefusal( 'Another git action is still running in this checkout. Try again when it finishes.' ) } - if (status.entries.length > 0) { - return refuse(changedFilesReason(status)) - } - const hiddenSubmodule = await firstHiddenSubmoduleEdit(worktreePath, options) - if (hiddenSubmodule) { - return refuse( - `Commit, stash or clean the changes inside submodule ${hiddenSubmodule} before switching branches.` - ) + if (!creating) { + if (status.entries.length > 0) { + return refuse(changedFilesReason(status)) + } + const hiddenSubmodule = await firstHiddenSubmoduleEdit(worktreePath, options) + if (hiddenSubmodule) { + return refuse( + `Commit, stash or clean the changes inside submodule ${hiddenSubmodule} before switching branches.` + ) + } } return refuse(await deps.getAgentBlocker()) } -/** Switch an existing local branch in place, refusing when the checkout is not safe to switch. */ +/** Switch or create a branch in place, refusing when the checkout is not safe to switch. */ export async function switchBranchGuarded( worktreePath: string, - branch: string, + target: string | BranchSwitchTarget, options: GitRuntimeOptions, deps: BranchSwitchDeps ): Promise { return withCheckoutLock(worktreePath, async () => { - const { refusal, previousBranch } = await readRefusal(worktreePath, branch, options, deps) + const resolved: { branch: string; creation?: BranchSwitchCreation } = + typeof target === 'string' + ? { branch: target } + : await resolveBranchSwitchTarget(worktreePath, target, options) + const { branch } = resolved + const creating = typeof target !== 'string' && target.kind === 'new' + const { refusal, previousBranch } = await readRefusal( + worktreePath, + branch, + creating, + options, + deps + ) if (refusal !== null) { throw new BranchSwitchRefusedError(refusal) } - if (previousBranch === null) { + if (previousBranch === null && !creating) { return { branch, previousBranch } } let skippedPaths: string[] try { - ;({ skippedPaths } = await deps.checkout()) + ;({ skippedPaths } = await deps.checkout(branch, resolved.creation)) } catch (error) { // Git can exit non-zero after HEAD already moved (a file it could not replace, a failing // post-checkout hook); that switch landed and must be recorded. - if ((await readCurrentBranch(worktreePath, options)) !== branch) { - throw error + if (previousBranch === null || (await readCurrentBranch(worktreePath, options)) !== branch) { + throw typeof target === 'string' ? error : branchSwitchFailure(error, branch) } deps.onSwitched?.() const stderr = readGitCommandFailureStderr(error) ?? '' const failed = parseCheckoutSkippedPaths(stderr) - const detail = gitFailureLine(stderr) ?? 'a post-checkout hook or git step failed' + const tracking = + typeof target === 'string' ? null : branchSwitchTrackingFailure(stderr, branch) + const detail = gitFailureLine(stderr) throw new BranchSwitchLandedWithWarningError( failed.length > 0 ? filesLeftBehindMessage(branch, failed) - : `Switched to ${branch}, but git reported an error: ${detail}` + : tracking + ? `Switched to ${branch}, but ${tracking}` + : detail && /^(error|fatal):/.test(detail) + ? `Switched to ${branch}, but git reported: ${detail}` + : detail + ? `Switched to ${branch}. A git hook in this repository failed afterwards and said: ${detail}. The switch itself worked.` + : `Switched to ${branch}. A git hook or git step in this repository failed afterwards. The switch itself worked.`, + branch ) } deps.onSwitched?.() if (skippedPaths.length > 0) { - throw new BranchSwitchLandedWithWarningError(filesLeftBehindMessage(branch, skippedPaths)) + throw new BranchSwitchLandedWithWarningError( + filesLeftBehindMessage(branch, skippedPaths), + branch + ) } return { branch, previousBranch } }) diff --git a/src/main/git/checkout-fallback.test.ts b/src/main/git/checkout-fallback.test.ts new file mode 100644 index 00000000000..49c6b8f8dd9 --- /dev/null +++ b/src/main/git/checkout-fallback.test.ts @@ -0,0 +1,58 @@ +import { beforeEach, expect, it, vi } from 'vitest' +import { gitExecFileAsync } from './runner' +import { listLocalBranches } from './checkout' +vi.mock('./runner', () => ({ gitExecFileAsync: vi.fn() })) +vi.mock('./source-control/git-read-cache-invalidation', () => ({ + runWithGitReadCacheInvalidation: vi.fn() +})) +beforeEach(() => vi.mocked(gitExecFileAsync).mockReset()) +it.each(['unknown field: worktreepath', 'format failed on this host'])( + 'retries the original listing once after %s', + async (message) => { + vi.mocked(gitExecFileAsync) + .mockRejectedValueOnce(new Error(message)) + .mockResolvedValueOnce({ stdout: '*\tmain\n \tfeature\n', stderr: '' }) + expect(await listLocalBranches('/repo')).toEqual({ + current: 'main', + branches: ['main', 'feature'] + }) + expect(vi.mocked(gitExecFileAsync).mock.calls[1]?.[0]).toEqual([ + 'for-each-ref', + '--format=%(HEAD)%09%(refname:lstrip=2)', + 'refs/heads/' + ]) + expect(gitExecFileAsync).toHaveBeenCalledTimes(2) + } +) +it('propagates the retry error without a third attempt', async () => { + const failure = new Error('repository unreadable') + vi.mocked(gitExecFileAsync) + .mockRejectedValueOnce(new Error('first')) + .mockRejectedValueOnce(failure) + await expect(listLocalBranches('/repo')).rejects.toBe(failure) + expect(gitExecFileAsync).toHaveBeenCalledTimes(2) +}) +it('does not retry cancellation', async () => { + const abort = new Error('cancelled') + abort.name = 'AbortError' + vi.mocked(gitExecFileAsync).mockRejectedValueOnce(abort) + await expect(listLocalBranches('/repo')).rejects.toBe(abort) + expect(gitExecFileAsync).toHaveBeenCalledOnce() +}) + +it('translates occupied WSL branches into the owning workspace path namespace', async () => { + const platform = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + try { + vi.mocked(gitExecFileAsync).mockResolvedValueOnce({ + stdout: ' \tfeature\t1700000000\t/home/alice/feature\n', + stderr: '' + }) + const result = await listLocalBranches(String.raw`\\wsl.localhost\Ubuntu\home\alice\repo`) + expect(result.worktreePaths).toEqual({ + feature: String.raw`\\wsl.localhost\Ubuntu\home\alice\feature` + }) + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: platform }) + } +}) diff --git a/src/main/git/checkout.ts b/src/main/git/checkout.ts index ed97a3efe49..c560190ef7d 100644 --- a/src/main/git/checkout.ts +++ b/src/main/git/checkout.ts @@ -1,7 +1,10 @@ +import type { RuntimeGitLocalBranches } from '../../shared/runtime-worktree-contracts' +import type { BranchSwitchCreation } from '../../shared/git/branch-switch-target' import type { GitRuntimeOptions } from './git-runtime-options' import { gitOptionsForWorktree } from './git-runtime-options' import { gitExecFileAsync } from './runner' import { gitEnvWithoutYesNoPrompt } from './command-runner/git-no-prompt-env' +import { translateWorktreePath } from './worktrees/catalog/worktree-path-comparison' import { parseCheckoutSkippedPaths } from './checkout-skipped-paths' import { runWithGitReadCacheInvalidation } from './source-control/git-read-cache-invalidation' @@ -16,11 +19,18 @@ export function assertValidBranchName(branch: string): void { export async function checkoutBranch( worktreePath: string, branch: string, - options: GitRuntimeOptions = {} + options: GitRuntimeOptions = {}, + creation?: BranchSwitchCreation ): Promise<{ skippedPaths: string[] }> { assertValidBranchName(branch) + const args = + creation?.kind === 'new' + ? ['checkout', '--no-track', '-b', branch, '--'] + : creation?.kind === 'remote' + ? ['checkout', '--track', '-b', branch, creation.startPoint, '--'] + : ['checkout', branch, '--'] const { stderr } = await runWithGitReadCacheInvalidation(() => - gitExecFileAsync(['checkout', branch, '--'], { + gitExecFileAsync(args, { ...gitOptionsForWorktree(worktreePath, options), // A locked file must fail the checkout, not open a prompt while the switch lock is held. ...gitEnvWithoutYesNoPrompt() @@ -33,18 +43,35 @@ export async function checkoutBranch( export async function listLocalBranches( worktreePath: string, options: GitRuntimeOptions = {} -): Promise<{ current: string | null; branches: string[] }> { +): Promise { + const execution = gitOptionsForWorktree(worktreePath, options) + let extended = true const { stdout } = await gitExecFileAsync( - ['for-each-ref', '--format=%(HEAD)%09%(refname:lstrip=2)', 'refs/heads/'], - gitOptionsForWorktree(worktreePath, options) - ) + [ + 'for-each-ref', + '--format=%(HEAD)%09%(refname:lstrip=2)%09%(committerdate:unix)%09%(worktreepath)', + 'refs/heads/' + ], + execution + ).catch((error) => { + if (options.signal?.aborted || (error instanceof Error && error.name === 'AbortError')) { + throw error + } + extended = false + return gitExecFileAsync( + ['for-each-ref', '--format=%(HEAD)%09%(refname:lstrip=2)', 'refs/heads/'], + execution + ) + }) let current: string | null = null const branches: string[] = [] + const committedAt: Record = Object.create(null) + const worktreePaths: Record = Object.create(null) for (const line of stdout.split('\n')) { if (line.length === 0) { continue } - const [marker, name] = line.split('\t') + const [marker, name, timestamp, occupiedPath] = line.split('\t') if (!name) { continue } @@ -52,6 +79,12 @@ export async function listLocalBranches( current = name } branches.push(name) + if (timestamp && Number.isFinite(Number(timestamp))) { + committedAt[name] = Number(timestamp) * 1000 + } + if (occupiedPath) { + worktreePaths[name] = translateWorktreePath(occupiedPath, worktreePath, options) + } } branches.sort((a, b) => { if (a === current) { @@ -62,5 +95,5 @@ export async function listLocalBranches( } return 0 }) - return { current, branches } + return { current, branches, ...(extended ? { committedAt, worktreePaths } : {}) } } diff --git a/src/main/git/repo-base-ref-search.ts b/src/main/git/repo-base-ref-search.ts index 4bb50c3f2cc..b4fc7d43f87 100644 --- a/src/main/git/repo-base-ref-search.ts +++ b/src/main/git/repo-base-ref-search.ts @@ -1,141 +1,21 @@ import type { BaseRefSearchResult } from '../../shared/repo-types' -import { isForEachRefExcludeUnsupportedError } from '../../shared/git/refs/git-ref-command-capabilities' import { clampRepoSearchRefsLimit, clampRepoSearchRefsScanLimit, REPO_SEARCH_REFS_DEFAULT_LIMIT, - isRepoSearchRefsRequestLimit, - isRepoSearchRefsScanLimit + isRepoSearchRefsRequestLimit } from '../../shared/repo-search-limits' import { isSafeGitRefName } from '../../shared/git/status/git-status-upstream-ref' import { isRemoteHeadRef } from '../../shared/source-control/hosted-review/hosted-review-refs' -import { getLocalGitCapabilityCache } from './command-runner/git-capability-state' +import { runSearchBaseRefsGit } from './repo-ref-search-command' +export { buildSearchBaseRefsArgv } from './repo-ref-search-command' import { gitExecOptions, type LocalGitExecOptions } from './repo-default-base-ref' import { gitExecFileAsync } from './runner' import { isQualifiedBaseRef, resolveBaseRefSearchSelector } from './base-ref-search-selector' -const REF_SEARCH_CANDIDATE_MULTIPLIER = 4 -const REF_SEARCH_LEGACY_HEADROOM = 100 - type RefSearchPatternGroup = 'all' | 'segmented' | 'branchRoot' - -function getRefSearchTokens(normalizedQuery: string): string[] { - return normalizedQuery.split('/').filter((token) => token.length > 0) -} - -function getRefSearchCandidateCount(limit: number, excludesRemoteHead: boolean): number { - if (!isRepoSearchRefsScanLimit(limit)) { - throw new Error('invalid_limit') - } - const baseCount = limit * REF_SEARCH_CANDIDATE_MULTIPLIER - return excludesRemoteHead ? baseCount : baseCount + REF_SEARCH_LEGACY_HEADROOM -} - -/** Exclude direct remote HEAD slots while preserving nested branch names ending in HEAD. */ -function getRemoteHeadExcludes(remoteNames: readonly string[] | undefined): string[] { - if (remoteNames && remoteNames.length > 0) { - // Ordinary remotes share a wildcard; slash-containing remotes need exact excludes. - const slashRemotes = [...new Set(remoteNames)].filter( - (remote) => remote.includes('/') && isSafeGitRefName(`refs/remotes/${remote}/HEAD`) - ) - return [ - '--exclude=refs/remotes/*/HEAD', - ...slashRemotes.map((remote) => `--exclude=refs/remotes/${remote}/HEAD`) - ] - } - // * does not cross slashes, so unknown nested remotes remain eligible. - return ['--exclude=refs/remotes/*/HEAD'] -} - -/** Build the bounded `for-each-ref` argv shared by local and remote searches. */ -export function buildSearchBaseRefsArgv( - normalizedQuery: string, - limit: number, - options: { - excludeRemoteHead?: boolean - remoteNames?: readonly string[] - patternGroup?: RefSearchPatternGroup - } = {} -): string[] { - const excludeRemoteHead = options.excludeRemoteHead ?? true - // Bound the Git scan even when the caller asks for more retained rows. - const boundedScanLimit = clampRepoSearchRefsScanLimit(limit) - const candidateCount = getRefSearchCandidateCount(boundedScanLimit, excludeRemoteHead) - const base = [ - 'for-each-ref', - '--format=%(refname)%00%(refname:short)', - '--sort=-committerdate', - ...(excludeRemoteHead ? getRemoteHeadExcludes(options.remoteNames) : []), - `--count=${candidateCount}` - ] - const tokens = getRefSearchTokens(normalizedQuery) - if (tokens.length <= 1) { - const query = tokens[0] ?? '' - return [ - ...base, - `refs/heads/**/*${query}*`, - `refs/heads/**/*${query}*/**`, - `refs/remotes/**/*${query}*`, - `refs/remotes/**/*${query}*/**` - ] - } - - const segmented = tokens.map((token) => `*${token}*`).join('/') - const substringQuery = tokens.join('/') - const remoteBranchRootPatterns = - options.remoteNames && options.remoteNames.length > 0 - ? options.remoteNames.flatMap((remote) => [ - `refs/remotes/${remote}/${substringQuery}*`, - `refs/remotes/${remote}/${substringQuery}*/**` - ]) - : [`refs/remotes/*/${substringQuery}*`, `refs/remotes/*/${substringQuery}*/**`] - const segmentedPatterns = [`refs/remotes/${segmented}`, `refs/heads/${segmented}`] - const branchRootPatterns = [ - `refs/heads/${substringQuery}*`, - `refs/heads/${substringQuery}*/**`, - ...remoteBranchRootPatterns - ] - const patterns = - options.patternGroup === 'segmented' - ? segmentedPatterns - : options.patternGroup === 'branchRoot' - ? branchRootPatterns - : [...segmentedPatterns, ...branchRootPatterns] - return [...base, ...patterns] -} - -async function runSearchBaseRefsGit( - path: string, - normalizedQuery: string, - limit: number, - options: { - remoteNames: readonly string[] - patternGroup?: RefSearchPatternGroup - git?: LocalGitExecOptions - } -): Promise<{ stdout: string }> { - const execOptions = gitExecOptions(path, options.git) - return getLocalGitCapabilityCache(execOptions).runWithFallback( - 'for-each-ref-exclude', - () => - gitExecFileAsync( - buildSearchBaseRefsArgv(normalizedQuery, limit, { - remoteNames: options.remoteNames, - patternGroup: options.patternGroup - }), - execOptions - ), - () => - gitExecFileAsync( - buildSearchBaseRefsArgv(normalizedQuery, limit, { - excludeRemoteHead: false, - remoteNames: options.remoteNames, - patternGroup: options.patternGroup - }), - execOptions - ), - isForEachRefExcludeUnsupportedError - ) +function getRefSearchTokens(query: string) { + return query.split('/').filter(Boolean) } export function mergeBaseRefSearchResultGroups( diff --git a/src/main/git/repo-ref-search-command.ts b/src/main/git/repo-ref-search-command.ts new file mode 100644 index 00000000000..da3f2cb9527 --- /dev/null +++ b/src/main/git/repo-ref-search-command.ts @@ -0,0 +1,144 @@ +import { isForEachRefExcludeUnsupportedError } from '../../shared/git/refs/git-ref-command-capabilities' +import { + clampRepoSearchRefsScanLimit, + isRepoSearchRefsScanLimit +} from '../../shared/repo-search-limits' +import { isSafeGitRefName } from '../../shared/git/status/git-status-upstream-ref' +import { getLocalGitCapabilityCache } from './command-runner/git-capability-state' +import { gitExecOptions, type LocalGitExecOptions } from './repo-default-base-ref' +import { gitExecFileAsync } from './runner' + +const REF_SEARCH_CANDIDATE_MULTIPLIER = 4 +const REF_SEARCH_LEGACY_HEADROOM = 100 + +type RefSearchPatternGroup = 'all' | 'segmented' | 'branchRoot' + +function getRefSearchTokens(normalizedQuery: string): string[] { + return normalizedQuery.split('/').filter((token) => token.length > 0) +} + +function getRefSearchCandidateCount(limit: number, excludesRemoteHead: boolean): number { + if (!isRepoSearchRefsScanLimit(limit)) { + throw new Error('invalid_limit') + } + const baseCount = limit * REF_SEARCH_CANDIDATE_MULTIPLIER + return excludesRemoteHead ? baseCount : baseCount + REF_SEARCH_LEGACY_HEADROOM +} + +/** Exclude direct remote HEAD slots while preserving nested branch names ending in HEAD. */ +function getRemoteHeadExcludes(remoteNames: readonly string[] | undefined): string[] { + if (remoteNames && remoteNames.length > 0) { + // Ordinary remotes share a wildcard; slash-containing remotes need exact excludes. + const slashRemotes = [...new Set(remoteNames)].filter( + (remote) => remote.includes('/') && isSafeGitRefName(`refs/remotes/${remote}/HEAD`) + ) + return [ + '--exclude=refs/remotes/*/HEAD', + ...slashRemotes.map((remote) => `--exclude=refs/remotes/${remote}/HEAD`) + ] + } + // * does not cross slashes, so unknown nested remotes remain eligible. + return ['--exclude=refs/remotes/*/HEAD'] +} + +/** Build the bounded `for-each-ref` argv shared by local and remote searches. */ +export function buildSearchBaseRefsArgv( + normalizedQuery: string, + limit: number, + options: { + excludeRemoteHead?: boolean + remoteNames?: readonly string[] + namespace?: 'remote' + patternGroup?: RefSearchPatternGroup + } = {} +): string[] { + const excludeRemoteHead = options.excludeRemoteHead ?? true + // Bound the Git scan even when the caller asks for more retained rows. + const boundedScanLimit = clampRepoSearchRefsScanLimit(limit) + const candidateCount = getRefSearchCandidateCount(boundedScanLimit, excludeRemoteHead) + const base = [ + 'for-each-ref', + '--format=%(refname)%00%(refname:short)', + '--sort=-committerdate', + ...(excludeRemoteHead ? getRemoteHeadExcludes(options.remoteNames) : []), + `--count=${candidateCount}` + ] + const tokens = getRefSearchTokens(normalizedQuery) + if (tokens.length <= 1) { + const query = tokens[0] ?? '' + return [ + ...base, + ...[ + `refs/heads/**/*${query}*`, + `refs/heads/**/*${query}*/**`, + `refs/remotes/**/*${query}*`, + `refs/remotes/**/*${query}*/**` + ].filter((pattern) => options.namespace !== 'remote' || pattern.startsWith('refs/remotes/')) + ] + } + + const segmented = tokens.map((token) => `*${token}*`).join('/') + const substringQuery = tokens.join('/') + const remoteBranchRootPatterns = + options.remoteNames && options.remoteNames.length > 0 + ? options.remoteNames.flatMap((remote) => [ + `refs/remotes/${remote}/${substringQuery}*`, + `refs/remotes/${remote}/${substringQuery}*/**` + ]) + : [`refs/remotes/*/${substringQuery}*`, `refs/remotes/*/${substringQuery}*/**`] + const segmentedPatterns = [`refs/remotes/${segmented}`, `refs/heads/${segmented}`] + const branchRootPatterns = [ + `refs/heads/${substringQuery}*`, + `refs/heads/${substringQuery}*/**`, + ...remoteBranchRootPatterns + ] + const patterns = + options.patternGroup === 'segmented' + ? segmentedPatterns + : options.patternGroup === 'branchRoot' + ? branchRootPatterns + : [...segmentedPatterns, ...branchRootPatterns] + return [ + ...base, + ...patterns.filter( + (pattern) => options.namespace !== 'remote' || pattern.startsWith('refs/remotes/') + ) + ] +} + +export async function runSearchBaseRefsGit( + path: string, + normalizedQuery: string, + limit: number, + options: { + remoteNames: readonly string[] + namespace?: 'remote' + patternGroup?: RefSearchPatternGroup + git?: LocalGitExecOptions + } +): Promise<{ stdout: string }> { + const execOptions = gitExecOptions(path, options.git) + return getLocalGitCapabilityCache(execOptions).runWithFallback( + 'for-each-ref-exclude', + () => + gitExecFileAsync( + buildSearchBaseRefsArgv(normalizedQuery, limit, { + namespace: options.namespace, + remoteNames: options.remoteNames, + patternGroup: options.patternGroup + }), + execOptions + ), + () => + gitExecFileAsync( + buildSearchBaseRefsArgv(normalizedQuery, limit, { + excludeRemoteHead: false, + namespace: options.namespace, + remoteNames: options.remoteNames, + patternGroup: options.patternGroup + }), + execOptions + ), + isForEachRefExcludeUnsupportedError + ) +} diff --git a/src/main/git/repo-remote-ref-search.test.ts b/src/main/git/repo-remote-ref-search.test.ts new file mode 100644 index 00000000000..991869ea097 --- /dev/null +++ b/src/main/git/repo-remote-ref-search.test.ts @@ -0,0 +1,60 @@ +import { beforeEach, expect, it, vi } from 'vitest' +import { buildSearchBaseRefsArgv } from './repo-ref-search-command' +import { searchRemoteRefPage } from './repo-remote-ref-search' +import { gitExecFileAsync } from './runner' +import { clearGitCapabilityStateForTests } from './command-runner/git-capability-state' +vi.mock('./runner', () => ({ gitExecFileAsync: vi.fn() })) +beforeEach(() => { + vi.mocked(gitExecFileAsync).mockReset() + clearGitCapabilityStateForTests() +}) +it('applies the remote namespace before both modern and legacy candidate caps', () => { + for (const excludeRemoteHead of [true, false]) { + const args = buildSearchBaseRefsArgv('topic', 51, { namespace: 'remote', excludeRemoteHead }) + expect(args.filter((arg) => arg.startsWith('refs/'))).toEqual([ + 'refs/remotes/**/*topic*', + 'refs/remotes/**/*topic*/**' + ]) + expect(args).toContain(excludeRemoteHead ? '--count=204' : '--count=304') + } +}) +it.each([0, 1])( + 'propagates failure from read %i rather than returning empty discovery', + async (read) => { + const failure = new Error('read failed') + if (read === 1) { + vi.mocked(gitExecFileAsync).mockResolvedValueOnce({ stdout: 'origin\n', stderr: '' }) + } + vi.mocked(gitExecFileAsync).mockRejectedValueOnce(failure) + await expect(searchRemoteRefPage('/repo', 'topic', 50)).rejects.toBe(failure) + } +) +it('uses a sentinel and reports a saturated scan even after HEAD filtering', async () => { + vi.mocked(gitExecFileAsync) + .mockResolvedValueOnce({ stdout: 'origin\n', stderr: '' }) + .mockResolvedValueOnce({ + stdout: Array.from({ length: 204 }, () => 'refs/remotes/origin/HEAD\0origin/HEAD').join('\n'), + stderr: '' + }) + expect(await searchRemoteRefPage('/repo', '', 50)).toEqual({ refDetails: [], truncated: true }) +}) +it.each([100, 1000])( + 'retains only the requested 50 remote rows from %i candidates', + async (size) => { + vi.mocked(gitExecFileAsync) + .mockResolvedValueOnce({ stdout: 'origin\n', stderr: '' }) + .mockResolvedValueOnce({ + stdout: Array.from( + { length: size }, + (_, n) => `refs/remotes/origin/topic-${n}\0origin/topic-${n}` + ).join('\n'), + stderr: '' + }) + const page = await searchRemoteRefPage('/repo', 'topic', 50) + expect(page.refDetails).toHaveLength(50) + expect(page.truncated).toBe(true) + // The caller needs 50 choices plus a sentinel; Git bounds candidates before returning them. + expect(vi.mocked(gitExecFileAsync).mock.calls[1]?.[0]).toContain('--count=204') + expect(gitExecFileAsync).toHaveBeenCalledTimes(2) + } +) diff --git a/src/main/git/repo-remote-ref-search.ts b/src/main/git/repo-remote-ref-search.ts new file mode 100644 index 00000000000..8df0340fd6d --- /dev/null +++ b/src/main/git/repo-remote-ref-search.ts @@ -0,0 +1,39 @@ +import type { BaseRefSearchResult } from '../../shared/repo-types' +import { gitExecFileAsync } from './runner' +import { runSearchBaseRefsGit } from './repo-ref-search-command' +import { + normalizeRefSearchQuery, + parseAndFilterSearchRefDetails, + mergeBaseRefSearchResultGroups +} from './repo-base-ref-search' + +export async function searchRemoteRefPage( + path: string, + query: string, + limit: number +): Promise<{ refDetails: BaseRefSearchResult[]; truncated: boolean }> { + const { stdout } = await gitExecFileAsync(['remote'], { cwd: path }) + const remoteNames = stdout.split('\n').filter(Boolean) + const normalized = normalizeRefSearchQuery(query) + const groups: ('segmented' | 'branchRoot' | 'all')[] = + normalized.split('/').filter(Boolean).length > 1 ? ['segmented', 'branchRoot'] : ['all'] + const results = await Promise.all( + groups.map((patternGroup) => + runSearchBaseRefsGit(path, normalized, limit + 1, { + namespace: 'remote', + remoteNames, + patternGroup + }) + ) + ) + const candidates = mergeBaseRefSearchResultGroups( + results.map((result) => parseAndFilterSearchRefDetails(result.stdout, limit + 1, remoteNames)), + limit + 1 + ) + return { + refDetails: candidates.slice(0, limit), + truncated: + candidates.length > limit || + results.some((result) => result.stdout.split('\n').filter(Boolean).length >= (limit + 1) * 4) + } +} diff --git a/src/main/providers/git-provider-contract.ts b/src/main/providers/git-provider-contract.ts index a5907dd7900..d1153c69506 100644 --- a/src/main/providers/git-provider-contract.ts +++ b/src/main/providers/git-provider-contract.ts @@ -24,6 +24,8 @@ import type { import type { CommitMessageDraftContext } from '../../shared/source-control/ai/generation/commit-message-generation' import type { ConflictResolution } from '../../shared/source-control/listing/source-control-entry-actions' import type { GitProviderStatusOptions } from './git-provider-status-options' +import type { BranchSwitchCreation } from '../../shared/git/branch-switch-target' +import type { RuntimeGitLocalBranches } from '../../shared/runtime-worktree-contracts' import type { GitAdmissionTier } from '../git/command-runner/git-exec-options' export type { GitProviderStatusOptions } from './git-provider-status-options' @@ -67,8 +69,12 @@ export type IGitProvider = { abortMerge(worktreePath: string): Promise abortRebase(worktreePath: string): Promise /** `skippedPaths`: files git could not replace (held open on Windows) though the switch landed. */ - checkoutBranch(worktreePath: string, branch: string): Promise<{ skippedPaths: string[] }> - listLocalBranches(worktreePath: string): Promise<{ current: string | null; branches: string[] }> + checkoutBranch( + worktreePath: string, + branch: string, + creation?: BranchSwitchCreation + ): Promise<{ skippedPaths: string[] }> + listLocalBranches(worktreePath: string): Promise getBranchCompare( worktreePath: string, baseRef: string, diff --git a/src/main/providers/local-git-provider.ts b/src/main/providers/local-git-provider.ts index 5a0b8b61f68..11017f9bd90 100644 --- a/src/main/providers/local-git-provider.ts +++ b/src/main/providers/local-git-provider.ts @@ -104,7 +104,10 @@ export function createLocalGitProvider(options: LocalGitProviderOptions = {}): L detectConflictOperation: (worktreePath) => detectConflictOperation(worktreePath, base), abortMerge: (worktreePath) => abortMerge(worktreePath, interactive), abortRebase: (worktreePath) => abortRebase(worktreePath, interactive), - checkoutBranch: (worktreePath, branch) => checkoutBranch(worktreePath, branch, interactive), + checkoutBranch: (worktreePath, branch, creation) => + creation + ? checkoutBranch(worktreePath, branch, interactive, creation) + : checkoutBranch(worktreePath, branch, interactive), listLocalBranches: (worktreePath) => listLocalBranches(worktreePath, base), getBranchCompare: (worktreePath, baseRef, compareOptions) => getBranchCompare(worktreePath, baseRef, { diff --git a/src/main/runtime/integrations/git/orca-runtime-git.ts b/src/main/runtime/integrations/git/orca-runtime-git.ts index 666f9e9f58c..08a0cfb407a 100644 --- a/src/main/runtime/integrations/git/orca-runtime-git.ts +++ b/src/main/runtime/integrations/git/orca-runtime-git.ts @@ -13,6 +13,7 @@ export class RuntimeGitCommands { readonly getRuntimeGitHistory: RuntimeGitStatusCommands['getRuntimeGitHistory'] readonly getRuntimeGitConflictOperation: RuntimeGitStatusCommands['getRuntimeGitConflictOperation'] readonly getRuntimeGitMutationInFlight: RuntimeGitStatusCommands['getRuntimeGitMutationInFlight'] + readonly switchRuntimeGitBranch: RuntimeGitStatusCommands['switchRuntimeGitBranch'] readonly checkoutRuntimeGitBranch: RuntimeGitStatusCommands['checkoutRuntimeGitBranch'] readonly listRuntimeGitLocalBranches: RuntimeGitStatusCommands['listRuntimeGitLocalBranches'] readonly getRuntimeGitDiff: RuntimeGitDiffCommands['getRuntimeGitDiff'] @@ -60,6 +61,7 @@ export class RuntimeGitCommands { this.getRuntimeGitHistory = status.getRuntimeGitHistory.bind(status) this.getRuntimeGitConflictOperation = status.getRuntimeGitConflictOperation.bind(status) this.getRuntimeGitMutationInFlight = status.getRuntimeGitMutationInFlight.bind(status) + this.switchRuntimeGitBranch = status.switchRuntimeGitBranch.bind(status) this.checkoutRuntimeGitBranch = status.checkoutRuntimeGitBranch.bind(status) this.listRuntimeGitLocalBranches = status.listRuntimeGitLocalBranches.bind(status) this.getRuntimeGitDiff = diff.getRuntimeGitDiff.bind(diff) diff --git a/src/main/runtime/integrations/git/runtime-git-command-surface.ts b/src/main/runtime/integrations/git/runtime-git-command-surface.ts index 52997e92452..90da686120f 100644 --- a/src/main/runtime/integrations/git/runtime-git-command-surface.ts +++ b/src/main/runtime/integrations/git/runtime-git-command-surface.ts @@ -10,6 +10,7 @@ type RuntimeGitCommandName = | 'getRuntimeGitMutationInFlight' | 'abortRuntimeGitMerge' | 'abortRuntimeGitRebase' + | 'switchRuntimeGitBranch' | 'checkoutRuntimeGitBranch' | 'listRuntimeGitLocalBranches' | 'getRuntimeGitDiff' @@ -57,6 +58,7 @@ export function installRuntimeGitCommandSurface( getRuntimeGitMutationInFlight: commands.getRuntimeGitMutationInFlight.bind(commands), abortRuntimeGitMerge: commands.abortRuntimeGitMerge.bind(commands), abortRuntimeGitRebase: commands.abortRuntimeGitRebase.bind(commands), + switchRuntimeGitBranch: commands.switchRuntimeGitBranch.bind(commands), checkoutRuntimeGitBranch: commands.checkoutRuntimeGitBranch.bind(commands), listRuntimeGitLocalBranches: commands.listRuntimeGitLocalBranches.bind(commands), getRuntimeGitDiff: commands.getRuntimeGitDiff.bind(commands), diff --git a/src/main/runtime/integrations/git/runtime-git-status-commands.ts b/src/main/runtime/integrations/git/runtime-git-status-commands.ts index d7ef1944338..e76d4cc6141 100644 --- a/src/main/runtime/integrations/git/runtime-git-status-commands.ts +++ b/src/main/runtime/integrations/git/runtime-git-status-commands.ts @@ -4,13 +4,15 @@ import type { } from '../../../../shared/git/history/git-history-types' import type { GitMutationInFlight } from '../../../../shared/git/commands/git-mutation-in-flight' import { isGitWorktreeMutationInFlight } from '../../../git/command-runner/git-worktree-mutation-state' -import { switchBranchGuarded } from '../../../git/branch-switch' +import type { BranchSwitchTarget } from '../../../../shared/git/branch-switch-target' +import { BranchSwitchLandedWithWarningError, switchBranchGuarded } from '../../../git/branch-switch' import type { GitConflictOperation, GitStagingArea, GitStatusResult } from '../../../../shared/git/status/git-status-types' import type { + RuntimeGitSwitchResult, RuntimeGitCheckoutResult, RuntimeGitLocalBranches } from '../../../../shared/runtime-worktree-contracts' @@ -124,6 +126,38 @@ export class RuntimeGitStatusCommands { return { ok: true, branch: result.branch } } + async switchRuntimeGitBranch( + worktreeSelector: string, + branchTarget: BranchSwitchTarget + ): Promise { + const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) + const provider = requireRuntimeGitProvider(target) + const path = target.worktree.path + try { + const result = await switchBranchGuarded( + path, + branchTarget, + localGitOptionsForTarget(target), + { + getStatus: () => + provider.getStatus(path, { admissionTier: 'interactive', includeLineStats: false }), + checkout: (branch, creation) => + creation + ? provider.checkoutBranch(path, branch, creation) + : provider.checkoutBranch(path, branch), + getAgentBlocker: async () => this.host.getAgentSwitchBlocker?.(path) ?? null, + onSwitched: () => this.host.recordBranchSwitched?.(target.worktree) + } + ) + return { ok: true, branch: result.branch } + } catch (error) { + if (error instanceof BranchSwitchLandedWithWarningError) { + return { ok: true, branch: error.branch, warning: error.message } + } + throw error + } + } + async listRuntimeGitLocalBranches(worktreeSelector: string): Promise { const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) return requireRuntimeGitProvider(target).listLocalBranches(target.worktree.path) diff --git a/src/main/runtime/repositories/runtime-repository-ref-queries.ts b/src/main/runtime/repositories/runtime-repository-ref-queries.ts index a4af0b5da3b..8182acd72e6 100644 --- a/src/main/runtime/repositories/runtime-repository-ref-queries.ts +++ b/src/main/runtime/repositories/runtime-repository-ref-queries.ts @@ -8,6 +8,7 @@ import { } from '../../../shared/repo-search-limits' import { getBaseRefDefault } from '../../git/repo-default-base-ref' import { getRemoteCount } from '../../git/repo' +import { searchRemoteRefPage } from '../../git/repo-remote-ref-search' import { searchBaseRefDetails } from '../../git/repo-base-ref-search' import { getRepoRoutingConnectionId } from '../../../shared/execution-host' import { blockedWslDistroForPath } from '../../wsl/filesystem/wsl-unc-access-gate' @@ -23,7 +24,8 @@ export class RuntimeRepositoryRefQueries { repoSelector: string, query: string, limit: number, - includeQualifiedRefs = true + includeQualifiedRefs = true, + namespace?: 'remote' ): Promise { if (!isRepoSearchRefsRequestLimit(limit)) { throw new Error('invalid_limit') @@ -34,6 +36,13 @@ export class RuntimeRepositoryRefQueries { if (isFolderRepo(repo)) { return { refs: [], truncated: false } } + if (namespace === 'remote') { + if (getRepoRoutingConnectionId(repo)) { + throw new Error('This project is not served by this host.') + } + const page = await searchRemoteRefPage(repo.path, query, effectiveLimit) + return { ...page, refs: page.refDetails.map((row) => row.refName), namespace } + } // Unserved SSH hosts report no refs; never search local files for them. const refDetails = getRepoRoutingConnectionId(repo) ? [] diff --git a/src/main/runtime/rpc/errors.ts b/src/main/runtime/rpc/errors.ts index 2d1ec6b6e29..7cd1c1a4e98 100644 --- a/src/main/runtime/rpc/errors.ts +++ b/src/main/runtime/rpc/errors.ts @@ -1,3 +1,4 @@ +import { BranchSwitchCollisionError } from '../../git/branch-switch-target' import { RECIPE_VM_REFUSAL_CODES, RecipeVmWakeUnavailableError @@ -201,6 +202,7 @@ export function mapRuntimeError(id: string, meta: RpcEnvelopeMeta, error: unknow return errorResponse(id, meta, error.code, message) } if ( + error instanceof BranchSwitchCollisionError || error instanceof TerminalSplitOutcomeError || error instanceof WslHostWindowsDriveRefusalError ) { diff --git a/src/main/runtime/rpc/methods/projects/repo.ts b/src/main/runtime/rpc/methods/projects/repo.ts index 72283cc9816..a745f852106 100644 --- a/src/main/runtime/rpc/methods/projects/repo.ts +++ b/src/main/runtime/rpc/methods/projects/repo.ts @@ -236,16 +236,21 @@ export const REPO_METHODS = [ name: 'repo.searchRefs', permission: 'workspace', params: RepoSearchRefs, - handler: async (params, { runtime, clientCapabilities }) => - projectRepoSearchRefsForClient( - await runtime.searchRepoRefs( - params.repo, - params.query, - params.limit, - includesQualifiedSearchRefs(clientCapabilities) - ), + handler: async (params, { runtime, clientCapabilities }) => { + const searchArgs: Parameters = [ + params.repo, + params.query, + params.limit, + includesQualifiedSearchRefs(clientCapabilities) + ] + if (params.namespace !== undefined) { + searchArgs.push(params.namespace) + } + return projectRepoSearchRefsForClient( + await runtime.searchRepoRefs(...searchArgs), clientCapabilities ) + } }), defineMethod({ name: 'repo.hooks', diff --git a/src/main/runtime/rpc/methods/source-control/git/git-branch-methods.ts b/src/main/runtime/rpc/methods/source-control/git/git-branch-methods.ts new file mode 100644 index 00000000000..0da82cde707 --- /dev/null +++ b/src/main/runtime/rpc/methods/source-control/git/git-branch-methods.ts @@ -0,0 +1,12 @@ +import { defineMethod } from '../../../core' +import { GitSwitchBranch } from '../../../../../../shared/rpc-contract/git-params' + +export const GIT_BRANCH_METHODS = [ + defineMethod({ + name: 'git.switchBranch', + permission: 'workspace', + params: GitSwitchBranch, + handler: async (params, { runtime }) => + runtime.switchRuntimeGitBranch(params.worktree, params.target) + }) +] diff --git a/src/main/runtime/rpc/methods/source-control/git/git.ts b/src/main/runtime/rpc/methods/source-control/git/git.ts index 8580f6853ba..ffa48ddaf6a 100644 --- a/src/main/runtime/rpc/methods/source-control/git/git.ts +++ b/src/main/runtime/rpc/methods/source-control/git/git.ts @@ -1,5 +1,6 @@ import { defineMethod } from '../../../core' import { GIT_COMMIT_MESSAGE_GENERATION_METHODS } from './git-commit-message-generation-methods' +import { GIT_BRANCH_METHODS } from './git-branch-methods' import { GIT_DIFF_METHODS } from './git-diff-methods' import { GitBranchCompare, @@ -138,6 +139,7 @@ export const GIT_METHODS = [ params: WorktreeSelector, handler: async (params, { runtime }) => runtime.listRuntimeGitLocalBranches(params.worktree) }), + ...GIT_BRANCH_METHODS, ...GIT_DIFF_METHODS, defineMethod({ name: 'git.branchCompare', diff --git a/src/main/runtime/session-tabs/structured-session-tab-restoration.ts b/src/main/runtime/session-tabs/structured-session-tab-restoration.ts index 8f25af5126b..362414736cc 100644 --- a/src/main/runtime/session-tabs/structured-session-tab-restoration.ts +++ b/src/main/runtime/session-tabs/structured-session-tab-restoration.ts @@ -213,9 +213,16 @@ export abstract class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce exten repoSelector: string, query: string, limit = DEFAULT_REPO_SEARCH_REFS_LIMIT, - includeQualifiedRefs = true + includeQualifiedRefs = true, + namespace?: 'remote' ): Promise { - return this.repositoryRefQueries.search(repoSelector, query, limit, includeQualifiedRefs) + return this.repositoryRefQueries.search( + repoSelector, + query, + limit, + includeQualifiedRefs, + namespace + ) } protected async resolveHostedReviewTarget(args: { diff --git a/src/renderer/src/components/right-sidebar/source-control/branches/BranchPicker.test.tsx b/src/renderer/src/components/right-sidebar/source-control/branches/BranchPicker.test.tsx new file mode 100644 index 00000000000..321edd90048 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/source-control/branches/BranchPicker.test.tsx @@ -0,0 +1,283 @@ +// @vitest-environment happy-dom +import type { ReactNode } from 'react' +import type * as BranchClient from '@/runtime/source-control/git/runtime-git-branch-client' +import { act } from 'react' +import { createRoot } from 'react-dom/client' +import { fireEvent } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' +import { toast } from 'sonner' +import { BranchPicker, type BranchPickerProps } from './BranchPicker' +import { i18n } from '@/i18n/i18n' +import { RuntimeRpcCallError } from '@/runtime/rpc/calls/runtime-rpc-result' +import { + listRuntimeGitBranches, + searchRuntimeRemoteBranches, + switchRuntimeGitBranch +} from '@/runtime/source-control/git/runtime-git-branch-client' + +vi.mock('@/runtime/source-control/git/runtime-git-branch-client', async (importOriginal) => ({ + ...(await importOriginal()), + listRuntimeGitBranches: vi.fn(), + searchRuntimeRemoteBranches: vi.fn(), + switchRuntimeGitBranch: vi.fn() +})) +vi.mock('sonner', () => ({ toast: { error: vi.fn() } })) +vi.mock('@/components/ui/popover', () => ({ + Popover: ({ + children, + onOpenChange + }: { + children: ReactNode + onOpenChange: (open: boolean) => void + }) => ( + <> + + {children} + + ), + PopoverTrigger: ({ children }: { children: ReactNode }) => <>{children}, + PopoverContent: ({ children }: { children: ReactNode }) =>
{children}
+})) +vi.mock('@/components/ui/command', () => ({ + Command: ({ children }: { children: ReactNode }) => <>{children}, + CommandList: ({ children }: { children: ReactNode }) =>
{children}
, + CommandInput: ({ + value, + onValueChange + }: { + value: string + onValueChange: (value: string) => void + }) => onValueChange(event.target.value)} />, + CommandItem: ({ + children, + disabled, + onSelect + }: { + children: ReactNode + disabled: boolean + onSelect: () => void + }) => ( + + ) +})) +Reflect.set(globalThis, 'IS_REACT_ACT_ENVIRONMENT', true) +const container = document.createElement('div') +let root = createRoot(container) +afterEach(async () => { + await act(async () => root.unmount()) + root = createRoot(container) + vi.clearAllMocks() +}) + +it('keeps disk blockers authoritative and offers the existing branch after a collision', async () => { + vi.mocked(listRuntimeGitBranches).mockResolvedValue({ + current: 'main', + branches: ['main', 'feature'] + }) + vi.mocked(searchRuntimeRemoteBranches).mockResolvedValue({ refs: [], truncated: false }) + vi.mocked(switchRuntimeGitBranch).mockResolvedValue({ ok: true, branch: 'feature' }) + const props: BranchPickerProps = { + branch: 'main', + blocker: 'Commit or discard 2 changed files before switching.', + dirtyOnlyBlocker: true, + context: { target: { kind: 'local' }, worktreeId: 'repo::/repo', worktreePath: '/repo' }, + repoId: 'repo', + hostId: 'local', + hostLabel: 'This computer', + workspaceLabel: 'repo', + workspaces: new Map(), + onOpenWorkspace: vi.fn(), + onSwitched: vi.fn() + } + await act(async () => root.render()) + await act(async () => container.querySelector('button')?.click()) + const feature = Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent === 'feature' + ) + expect(feature?.disabled).toBe(true) + await act(async () => feature?.click()) + expect(switchRuntimeGitBranch).not.toHaveBeenCalled() + expect(container.textContent).toContain('Commit or discard 2 changed files') + await act(async () => root.render()) + expect(feature?.disabled).toBe(false) + await act(async () => feature?.click()) + expect(switchRuntimeGitBranch).toHaveBeenCalledWith( + props.context, + { kind: 'local', name: 'feature' }, + 'This computer' + ) + expect(props.onSwitched).toHaveBeenCalledOnce() + vi.mocked(switchRuntimeGitBranch).mockRejectedValueOnce( + new RuntimeRpcCallError({ + id: 'collision', + ok: false, + error: { + code: 'runtime_error', + message: 'A local branch exists.', + data: { existingBranch: 'hidden' } + } + }) + ) + await act(async () => container.querySelector('button')?.click()) + await act(async () => + Array.from(container.querySelectorAll('button')) + .find((button) => button.textContent === 'feature') + ?.click() + ) + const recovery = vi.mocked(toast.error).mock.lastCall?.[1]?.action + expect(recovery).toMatchObject({ label: 'Switch to hidden' }) + if (!recovery || typeof recovery !== 'object' || !('onClick' in recovery)) { + throw new Error('Collision toast has no recovery action') + } + const toastHost = document.createElement('div') + const toastRoot = createRoot(toastHost) + await act(async () => toastRoot.render()) + await act(async () => toastHost.querySelector('button')?.click()) + await act(async () => toastRoot.unmount()) + expect(switchRuntimeGitBranch).toHaveBeenLastCalledWith( + props.context, + { kind: 'local', name: 'hidden' }, + 'This computer' + ) + await act(async () => root.render()) + await act(async () => container.querySelector('button')?.click()) + const input = container.querySelector('input') + if (!input) { + throw new Error('Branch search input missing') + } + await act(async () => fireEvent.change(input, { target: { value: 'draft' } })) + const create = Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent === 'Create branch draft from main' + ) + expect(create?.disabled).toBe(false) + expect(container.textContent).toContain('Commit or discard 2 changed files') + await act(async () => + root.render() + ) + expect(create?.disabled).toBe(true) + await act(async () => root.render()) + await act(async () => create?.click()) + expect(switchRuntimeGitBranch).toHaveBeenLastCalledWith( + props.context, + { kind: 'new', name: 'draft' }, + 'This computer' + ) +}) + +it('delivers the original workspace outcome after the picker unmounts', async () => { + vi.mocked(listRuntimeGitBranches).mockResolvedValue({ current: 'main', branches: ['feature'] }) + let finish: ((result: { ok: true; branch: string; warning: string }) => void) | undefined + vi.mocked(switchRuntimeGitBranch).mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve + }) + ) + const onSwitched = vi.fn() + await act(async () => + root.render( + + ) + ) + await act(async () => container.querySelector('button')?.click()) + await act(async () => + Array.from(container.querySelectorAll('button')) + .find((button) => button.textContent === 'feature') + ?.click() + ) + await act(async () => root.render(

Another workspace

)) + await act(async () => finish?.({ ok: true, branch: 'feature', warning: 'Files left behind.' })) + expect(onSwitched).toHaveBeenCalledWith('Files left behind.') +}) + +it('renders a translated local-list recovery message', async () => { + i18n.addResourceBundle( + 'de', + 'translation', + { + sourceControl: { branchPicker: { listFailed: 'Lokale Zweige konnten nicht geladen werden.' } } + }, + true, + true + ) + await i18n.changeLanguage('de') + vi.mocked(listRuntimeGitBranches).mockRejectedValueOnce(new Error('unreadable')) + try { + await act(async () => + root.render( + + ) + ) + await act(async () => container.querySelector('button')?.click()) + expect(container.querySelector('[role="status"]')?.textContent).toBe( + 'Lokale Zweige konnten nicht geladen werden.' + ) + } finally { + await i18n.changeLanguage('en') + } +}) + +it('updates an open memoized row when the UI language changes', async () => { + i18n.addResourceBundle( + 'de', + 'translation', + { sourceControl: { branchPicker: { open: 'Arbeitsbereich öffnen' } } }, + true, + true + ) + vi.mocked(listRuntimeGitBranches).mockResolvedValueOnce({ + current: 'main', + branches: ['held'], + worktreePaths: { held: '/held' } + }) + vi.mocked(searchRuntimeRemoteBranches).mockResolvedValue({ refs: [], truncated: false }) + try { + await act(async () => + root.render( + + ) + ) + await act(async () => container.querySelector('button')?.click()) + expect(container.textContent).toContain('Open workspace') + await act(async () => i18n.changeLanguage('de')) + expect(container.textContent).toContain('Arbeitsbereich öffnen') + expect(container.textContent).not.toContain('Open workspace') + } finally { + await act(async () => i18n.changeLanguage('en')) + } +}) diff --git a/src/renderer/src/components/right-sidebar/source-control/branches/BranchPicker.tsx b/src/renderer/src/components/right-sidebar/source-control/branches/BranchPicker.tsx new file mode 100644 index 00000000000..5aefba9b338 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/source-control/branches/BranchPicker.tsx @@ -0,0 +1,137 @@ +import { memo } from 'react' +import { Check, ChevronDown, GitBranch, Info } from 'lucide-react' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { Command, CommandInput, CommandItem, CommandList } from '@/components/ui/command' +import { useIntlLocale, useTranslate } from '@/i18n/use-translate' +import { useUiLanguageRevision } from '@/i18n/use-ui-language-revision' +import { formatUiRelativeTime } from '@/i18n/relative-time-format' +import type { BranchPickerRow } from './branch-picker-rows' +import { useBranchPicker, type BranchPickerOptions } from './use-branch-picker' + +export type BranchPickerProps = BranchPickerOptions & { + branch: string + blocker: string | null + dirtyOnlyBlocker?: boolean +} +export function BranchPicker({ + branch, + blocker, + dirtyOnlyBlocker = false, + ...options +}: BranchPickerProps) { + const t = useTranslate() + useUiLanguageRevision() + const picker = useBranchPicker(options) + const disabled = blocker !== null || picker.pending + return ( + + + + + + + + {[blocker, picker.error ?? picker.discoveryError].map( + (reason) => + reason && ( +

+ + {reason} +

+ ) + )} + + {picker.rows.map((row) => ( + + ))} + +
+
+
+ ) +} + +const BranchRow = memo(function BranchRow({ + row, + branch, + openedAt, + pending, + disabled, + onSelect +}: { + row: BranchPickerRow + branch: string + openedAt: number + pending: boolean + disabled: boolean + onSelect: (row: BranchPickerRow) => Promise +}) { + const t = useTranslate() + const intlLocale = useIntlLocale() + useUiLanguageRevision() + return ( + void onSelect(row)} + > + + {row.target.kind === 'new' + ? t('sourceControl.branchPicker.create', 'Create branch {{name}} from {{branch}}', { + name: row.label, + branch + }) + : row.label} + + {row.current ? ( + + ) : row.workspace ? ( + + {t('sourceControl.branchPicker.open', 'Open workspace')} + + ) : row.occupiedPath ? ( + + {t('sourceControl.branchPicker.occupied', 'Checked out in {{path}}', { + path: row.occupiedPath + })} + + ) : row.target.kind === 'remote' ? ( + + {t('sourceControl.branchPicker.remote', 'remote')} + + ) : row.committedAt ? ( + + {formatUiRelativeTime(row.committedAt - openedAt, intlLocale)} + + ) : null} + + ) +}) diff --git a/src/renderer/src/components/right-sidebar/source-control/branches/SourceControlBranchPicker.tsx b/src/renderer/src/components/right-sidebar/source-control/branches/SourceControlBranchPicker.tsx new file mode 100644 index 00000000000..54a6d86d4c9 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/source-control/branches/SourceControlBranchPicker.tsx @@ -0,0 +1,147 @@ +import { useCallback, useMemo } from 'react' +import { toast } from 'sonner' +import { basename } from '@/lib/path' +import { isWorkspaceSelected } from '@/lib/active-workspace-host' +import { translate } from '@/i18n/i18n' +import { useTranslate } from '@/i18n/use-translate' +import { useAppStore } from '@/store' +import { selectWorktreesByRepo } from '@/store/workspaces/catalog/workspace-record-fields' +import { getIndexedWorktreeMap } from '@/store/slices/worktrees/listing/worktree-catalog-index' +import { getExecutionHostLabel } from '../../../../../../shared/execution-host' +import { selectWorktreeAgentActivitySummary } from '@/components/sidebar/workspace-card/activity/worktree-agent-activity-summary' +import type { BranchWorkspace } from './branch-picker-rows' +import { BranchPicker } from './BranchPicker' +import type { SourceControlPanelModel } from '../panel/use-panel-model' +import { requireGitOwnerTarget } from '../sync/worktree-git-owner-target' + +export function SourceControlBranchPicker({ model }: { model: SourceControlPanelModel }) { + const t = useTranslate() + const { + activeWorktree, + activeWorktreeId, + activeRepoTarget, + activeRepo, + branchName, + entries, + conflictOperation, + statusReadError, + recordBranchSwitchOutcome, + refreshActiveGitStatusAfterMutation + } = model + const statusKnown = useAppStore((state) => + activeWorktreeId ? state.gitStatusHeadByWorktree?.[activeWorktreeId] !== undefined : false + ) + const hostLabel = useAppStore((state) => + activeRepoTarget?.kind === 'environment' + ? state.runtimeEnvironments.find((host) => host.id === activeRepoTarget.environmentId)?.name + : undefined + ) + // The panel's map holds only the owner inside a folder section; workspace rows need every worktree. + const worktreeMap = useAppStore((state) => getIndexedWorktreeMap(selectWorktreesByRepo(state))) + const agentWorking = useAppStore((state) => + activeWorktreeId + ? selectWorktreeAgentActivitySummary(state, activeWorktreeId).hasLiveWorking + : false + ) + const showDirtyBlocker = + statusKnown && !statusReadError && conflictOperation === 'unknown' && entries.length > 0 + const changedCount = useMemo( + () => (showDirtyBlocker ? new Set(entries.map((entry) => entry.path)).size : 0), + [entries, showDirtyBlocker] + ) + const onOpenWorkspace = useCallback( + (workspace: BranchWorkspace) => + useAppStore.getState().setActiveWorktree(workspace.id, workspace.hostId), + [] + ) + const selectedHostId = useAppStore((state) => state.activeWorkspaceExecutionHostId ?? null) + const resolvedHostLabel = hostLabel ?? getExecutionHostLabel(activeWorktree?.hostId ?? 'local') + // Worktree ids repeat across hosts, so a remote copy is named with its host. + const workspaceLabel = activeWorktree + ? (activeWorktree.hostId ?? 'local') === 'local' + ? basename(activeWorktree.path) + : `${basename(activeWorktree.path)} (${resolvedHostLabel})` + : '' + const onSwitched = useCallback( + (message?: string) => { + if (message) { + // The switch can land after the user moved on; name the workspace it happened in. + const selection = { worktreeId: activeWorktreeId, executionHostId: selectedHostId } + if (isWorkspaceSelected(useAppStore.getState(), selection)) { + toast.warning(message, { duration: 15000 }) + } else { + toast.warning( + translate('sourceControl.branchPicker.switchedIn', 'In {{workspace}}', { + workspace: workspaceLabel + }), + { description: message, duration: 15000 } + ) + } + } + recordBranchSwitchOutcome(refreshActiveGitStatusAfterMutation) + }, + [ + activeWorktreeId, + selectedHostId, + workspaceLabel, + recordBranchSwitchOutcome, + refreshActiveGitStatusAfterMutation + ] + ) + if (!activeWorktree || !activeWorktreeId || !activeRepo || !activeRepoTarget) { + return null + } + // Checking out moves HEAD under the agent, so creating a branch waits too; the host re-checks on switch. + const blocker = agentWorking + ? t( + 'sourceControl.branchPicker.agentWorking', + 'An agent is working in this workspace. Switch branches after it finishes, or close its tab if it has stopped.' + ) + : statusReadError + ? t( + 'sourceControl.branchPicker.statusFailed', + "Orca couldn't read this checkout's changes. Refresh Source Control before switching." + ) + : !statusKnown + ? t( + 'sourceControl.branchPicker.statusUnknown', + 'Read this checkout’s changes before switching.' + ) + : conflictOperation !== 'unknown' + ? t( + 'sourceControl.branchPicker.conflict', + 'Finish or abort the {{operation}} before switching branches.', + { operation: conflictOperation } + ) + : entries.length + ? t( + 'sourceControl.branchPicker.dirty', + 'Commit or discard {{count}} changed files before switching branches.', + { + count: changedCount, + defaultValue_one: + 'Commit or discard {{count}} changed file before switching branches.' + } + ) + : null + return ( + + ) +} diff --git a/src/renderer/src/components/right-sidebar/source-control/branches/branch-picker-performance.test.tsx b/src/renderer/src/components/right-sidebar/source-control/branches/branch-picker-performance.test.tsx new file mode 100644 index 00000000000..3bc0e9d59be --- /dev/null +++ b/src/renderer/src/components/right-sidebar/source-control/branches/branch-picker-performance.test.tsx @@ -0,0 +1,240 @@ +// @vitest-environment happy-dom +import { act, type ReactNode } from 'react' +import { createRoot } from 'react-dom/client' +import { expect, it, vi } from 'vitest' +import { BranchPicker, type BranchPickerProps } from './BranchPicker' +import type * as Rows from './branch-picker-rows' +import type { BranchWorkspace } from './branch-picker-rows' +import { callRuntimeRpc } from '@/runtime/rpc/calls/runtime-rpc-call' + +const counts = vi.hoisted(() => ({ renders: 0, builds: 0, iterations: 0 })) +vi.mock('@/runtime/rpc/calls/runtime-rpc-call', () => ({ callRuntimeRpc: vi.fn() })) +vi.mock('./branch-picker-rows', async (original) => { + const actual = await original() + class ObservedIndex extends Map { + override *keys(): MapIterator { + for (const key of super.keys()) { + counts.iterations++ + yield key + } + return undefined + } + override *values(): MapIterator { + for (const value of super.values()) { + counts.iterations++ + yield value + } + return undefined + } + override *entries(): MapIterator<[string, BranchWorkspace]> { + for (const entry of super.entries()) { + counts.iterations++ + yield entry + } + return undefined + } + override [Symbol.iterator]() { + return this.entries() + } + override forEach( + callback: (value: BranchWorkspace, key: string, map: Map) => void + ) { + for (const [key, value] of this) { + callback(value, key, this) + } + } + } + return { + ...actual, + indexBranchWorkspaces: (...args: Parameters) => { + const index = actual.indexBranchWorkspaces(...args) + if (!index) { + return null + } + counts.builds++ + return new ObservedIndex(index) + } + } +}) +vi.mock('@/components/ui/popover', () => ({ + Popover: ({ + children, + onOpenChange + }: { + children: ReactNode + onOpenChange: (open: boolean) => void + }) => ( + <> + + + {children} + + ), + PopoverTrigger: ({ children }: { children: ReactNode }) => <>{children}, + PopoverContent: ({ children }: { children: ReactNode }) =>
{children}
+})) +vi.mock('@/components/ui/command', () => ({ + Command: ({ children }: { children: ReactNode }) => <>{children}, + CommandList: ({ children }: { children: ReactNode }) =>
{children}
, + CommandInput: ({ onValueChange }: { onValueChange: (query: string) => void }) => ( + <> + + + + ), + CommandItem: ({ children }: { children: ReactNode }) => { + counts.renders++ + return
{children}
+ } +})) +Reflect.set(globalThis, 'IS_REACT_ACT_ENVIRONMENT', true) +it.each([100, 1000])('bounds rendered picker work for %i branches and workspaces', async (size) => { + vi.useFakeTimers() + vi.clearAllMocks() + counts.renders = counts.builds = counts.iterations = 0 + let workspaceVisits = 0 + let branchVisits = 0 + const makeWorkspaces = () => + new Map( + Array.from({ length: size }, (_, n): [string, BranchWorkspace] => [ + String(n), + { + id: String(n), + repoId: 'repo', + get path() { + workspaceVisits++ + return `/workspace/${n}` + } + } + ]) + ) + const branches = Array.from({ length: size }, (_, n) => `branch-${n}`) + vi.mocked(callRuntimeRpc) + .mockResolvedValue({ + refs: [], + refDetails: Array.from({ length: 200 }, (_, n) => ({ + refName: `origin/remote-${n}`, + localBranchName: `remote-${n}` + })), + truncated: true + }) + .mockResolvedValueOnce({ + current: 'branch-0', + branches, + worktreePaths: new Proxy>( + Object.fromEntries(branches.map((name, n) => [name, `/workspace/${n}`])), + { + get(target, key) { + branchVisits++ + return typeof key === 'string' ? target[key] : undefined + } + } + ) + }) + const props: BranchPickerProps = { + branch: 'branch-0', + blocker: null, + context: { target: { kind: 'local' }, worktreeId: 'repo::/repo', worktreePath: '/repo' }, + repoId: 'repo', + hostId: 'local', + hostLabel: 'This computer', + workspaceLabel: 'repo', + workspaces: makeWorkspaces(), + onOpenWorkspace: vi.fn(), + onSwitched: vi.fn() + } + const container = document.createElement('div') + const root = createRoot(container) + const click = (selector: string) => { + container.querySelector(selector)?.click() + } + try { + await act(async () => root.render()) + expect(counts.builds).toBe(0) + expect(workspaceVisits).toBe(0) + await act(async () => click('[data-open]')) + expect(counts.builds).toBe(1) + expect(workspaceVisits).toBe(size) + expect(branchVisits).toBe(size) + // The open window shows 50 local matches, regardless of catalog size. + expect(container.querySelectorAll('[data-row]')).toHaveLength(50) + counts.renders = 0 + await act(async () => root.render()) + expect(counts.renders).toBe(0) + expect(counts.builds).toBe(1) + const next = makeWorkspaces() + await act(async () => + root.render() + ) + expect(counts.builds).toBe(2) + expect(workspaceVisits).toBe(size * 2) + expect(branchVisits).toBe(size * 2) + counts.renders = 0 + await act(async () => + root.render() + ) + expect(counts.renders).toBe(0) + expect(counts.builds).toBe(2) + expect(counts.iterations).toBe(0) + await act(async () => vi.advanceTimersByTimeAsync(200)) + counts.renders = 0 + await act(async () => + root.render() + ) + expect(counts.renders).toBe(0) + let resolveStale: ((value: unknown) => void) | undefined + vi.mocked(callRuntimeRpc).mockReturnValueOnce( + new Promise((resolve) => { + resolveStale = resolve + }) + ) + await act(async () => click('[data-stale]')) + await act(async () => vi.advanceTimersByTimeAsync(200)) + counts.renders = 0 + await act(async () => click('[data-query]')) + // A keystroke can replace at most the 100 visible local/remote/create rows. + expect(counts.renders).toBeLessThanOrEqual(100) + expect(container.querySelectorAll('[data-row]').length).toBeLessThanOrEqual(100) + await act(async () => vi.advanceTimersByTimeAsync(200)) + expect(counts.renders).toBeLessThanOrEqual(100) + expect(callRuntimeRpc).toHaveBeenCalledTimes(4) + expect(container.querySelectorAll('[data-row]').length).toBeLessThanOrEqual(100) + // Exercise the production remote client: one remote-only bounded request per debounce. + expect(callRuntimeRpc).toHaveBeenLastCalledWith(props.context.target, 'repo.searchRefs', { + repo: 'repo', + query: 'branch-9', + namespace: 'remote', + limit: 50 + }) + await act(async () => + resolveStale?.({ + refs: ['origin/stale'], + refDetails: [{ refName: 'origin/stale', localBranchName: 'stale' }], + truncated: false + }) + ) + expect(container.textContent).not.toContain('origin/stale') + expect(counts.iterations).toBe(0) + expect(branchVisits).toBe(size * 2) + await act(async () => click('[data-close]')) + await act(async () => + root.render( + + ) + ) + expect(counts.builds).toBe(2) + expect(workspaceVisits).toBe(size * 2) + expect(branchVisits).toBe(size * 2) + } finally { + await act(async () => root.unmount()) + vi.useRealTimers() + } +}) diff --git a/src/renderer/src/components/right-sidebar/source-control/branches/branch-picker-rows.test.ts b/src/renderer/src/components/right-sidebar/source-control/branches/branch-picker-rows.test.ts new file mode 100644 index 00000000000..de0bb725b8b --- /dev/null +++ b/src/renderer/src/components/right-sidebar/source-control/branches/branch-picker-rows.test.ts @@ -0,0 +1,154 @@ +import { expect, it } from 'vitest' +import { branchPickerRows, indexBranchWorkspaces, indexLocalBranchRows } from './branch-picker-rows' + +it('uses Git occupancy, then maps navigation by host, repo and path', () => { + const workspaces = [ + { id: 'current', repoId: 'repo', path: '/repo' }, + { id: 'other', repoId: 'repo', path: '/other' }, + { id: 'stale', repoId: 'repo', path: '/stale' }, + { id: 'twin', repoId: 'different-repo', path: '/other' } + ] + const index = indexBranchWorkspaces(true, workspaces, 'repo', 'local') + const rows = indexLocalBranchRows( + { + current: 'main', + branches: ['main', 'held', 'external', 'released'], + worktreePaths: { main: '/repo', held: '/other', external: '/external' } + }, + '/repo', + index + ) + expect(rows.map((row) => [row.label, row.occupiedPath, row.workspace?.id])).toEqual([ + ['main', undefined, undefined], + ['held', '/other', 'other'], + ['external', '/external', undefined], + ['released', undefined, undefined] + ]) +}) +it('offers valid creation with failed, pending, old-host or capped discovery; only exact known matches suppress it', () => { + const localRows = indexLocalBranchRows( + { current: 'main', branches: ['main', 'feat-existing'] }, + '/repo', + null + ) + const names = new Set(['main', 'feat-existing']) + for (const page of [ + null, + { refs: [], truncated: false }, + { + refs: ['origin/feat-other'], + refDetails: [{ refName: 'origin/feat-other', localBranchName: 'feat-other' }], + truncated: true + } + ]) { + expect( + branchPickerRows(localRows, names, page, 'feat').map((row) => row.target) + ).toContainEqual({ kind: 'new', name: 'feat' }) + } + const exact = { + refs: ['origin/feat'], + refDetails: [{ refName: 'origin/feat', localBranchName: 'feat' }], + truncated: true + } + expect(branchPickerRows(localRows, names, exact, 'feat').map((row) => row.target)).toEqual([ + { kind: 'local', name: 'main' }, + { kind: 'local', name: 'feat-existing' }, + { kind: 'remote', name: 'origin/feat' } + ]) + expect( + branchPickerRows(localRows, names, null, 'feat-existing').some( + (row) => row.target.kind === 'new' + ) + ).toBe(false) + expect(branchPickerRows(localRows, names, null, 'bad..name').map((row) => row.label)).toEqual([ + 'main' + ]) +}) +it('drops tracking duplicates without confusing qualified remote refs and slash-named locals', () => { + const page = { + refs: [], + truncated: false, + refDetails: [ + { refName: 'origin/main', localBranchName: 'main' }, + { refName: 'refs/remotes/origin/topic', localBranchName: 'topic' }, + { refName: 'team/upstream/feature', localBranchName: 'feature' } + ] + } + expect( + branchPickerRows([], new Set(['main', 'origin/topic']), page, '').map((row) => row.target) + ).toEqual([ + { kind: 'remote', name: 'origin/topic' }, + { kind: 'remote', name: 'team/upstream/feature' } + ]) +}) + +it('maps owner-translated WSL occupancy to its workspace', () => { + const path = String.raw`\\wsl.localhost\Ubuntu\home\alice\feature` + const index = indexBranchWorkspaces(true, [{ id: 'held', repoId: 'repo', path }], 'repo', 'local') + expect( + indexLocalBranchRows( + { current: 'main', branches: ['feature'], worktreePaths: { feature: path } }, + String.raw`\\wsl.localhost\Ubuntu\home\alice\repo`, + index + )[0]?.workspace?.id + ).toBe('held') +}) +it('orders the current branch before recent local commits', () => { + const local = indexLocalBranchRows( + { + current: 'old', + branches: ['old', 'new', 'middle'], + committedAt: { old: 1, new: 3, middle: 2 } + }, + '/repo', + null + ) + expect( + branchPickerRows(local, new Set(['old', 'new', 'middle']), null, '').map((row) => row.label) + ).toEqual(['old', 'new', 'middle']) +}) + +it('keeps an older exact local match reachable ahead of newer prefix and substring matches', () => { + const newer = Array.from({ length: 60 }, (_, n) => `feature-${n}`) + const branches = ['main', 'feature', 'other-feature', ...newer] + const local = indexLocalBranchRows( + { + current: 'main', + branches, + committedAt: { + feature: 1, + 'other-feature': 1000, + ...Object.fromEntries(newer.map((name, n) => [name, n + 10])) + } + }, + '/repo', + null + ) + const rows = branchPickerRows(local, new Set(branches), null, 'feature') + expect(rows).toHaveLength(50) + expect(rows.slice(0, 4).map((row) => row.label)).toEqual([ + 'main', + 'feature', + 'feature-59', + 'feature-58' + ]) + expect(rows.some((row) => row.label === 'other-feature')).toBe(false) +}) + +it('shows an exact remote at the end of a full local and remote window instead of hiding both actions', () => { + const branches = Array.from({ length: 50 }, (_, n) => `feat-${n}`) + const local = indexLocalBranchRows({ current: null, branches }, '/repo', null) + const refDetails = Array.from({ length: 50 }, (_, n) => ({ + refName: `origin/${n === 49 ? 'feat' : `remote-feat-${n}`}`, + localBranchName: n === 49 ? 'feat' : `remote-feat-${n}` + })) + const rows = branchPickerRows( + local, + new Set(branches), + { refs: [], refDetails, truncated: false }, + 'feat' + ) + expect(rows).toHaveLength(100) + expect(rows.at(-1)?.target).toEqual({ kind: 'remote', name: 'origin/feat' }) + expect(rows.some((row) => row.target.kind === 'new')).toBe(false) +}) diff --git a/src/renderer/src/components/right-sidebar/source-control/branches/branch-picker-rows.ts b/src/renderer/src/components/right-sidebar/source-control/branches/branch-picker-rows.ts new file mode 100644 index 00000000000..5624792a900 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/source-control/branches/branch-picker-rows.ts @@ -0,0 +1,119 @@ +import type { + RuntimeGitLocalBranches, + RuntimeRepoSearchRefs +} from '../../../../../../shared/runtime-worktree-contracts' +import type { BranchSwitchTarget } from '../../../../../../shared/git/branch-switch-target' +import { isBranchNameValid } from '../../../../../../shared/git/branch-switch-target' +import { normalizeRuntimePathForComparison } from '../../../../../../shared/cross-platform-path' +import type { ExecutionHostId } from '../../../../../../shared/execution-host' + +export const LOCAL_BRANCH_WINDOW = 50 +export const BRANCH_PICKER_ROW_LIMIT = 100 + +export type BranchWorkspace = { id: string; path: string; repoId: string; hostId?: ExecutionHostId } +export type BranchPickerRow = { + target: BranchSwitchTarget + label: string + current?: boolean + committedAt?: number + occupiedPath?: string + workspace?: BranchWorkspace +} +export function indexBranchWorkspaces( + open: boolean, + workspaces: Iterable, + repoId: string, + hostId: ExecutionHostId +): Map | null { + if (!open) { + return null + } + const map = new Map() + for (const workspace of workspaces) { + if (workspace.repoId === repoId && (workspace.hostId ?? 'local') === hostId) { + map.set(normalizeRuntimePathForComparison(workspace.path), workspace) + } + } + return map +} +export function indexLocalBranchRows( + local: RuntimeGitLocalBranches, + currentPath: string, + workspaces: Map | null +): BranchPickerRow[] { + const normalizedCurrent = normalizeRuntimePathForComparison(currentPath) + return local.branches + .map((name) => { + const path = + local.worktreePaths && Object.hasOwn(local.worktreePaths, name) + ? local.worktreePaths[name] + : undefined + const occupiedPath = + path && normalizeRuntimePathForComparison(path) !== normalizedCurrent ? path : undefined + return { + target: { kind: 'local', name }, + label: name, + current: name === local.current, + committedAt: + local.committedAt && Object.hasOwn(local.committedAt, name) + ? local.committedAt[name] + : undefined, + occupiedPath, + workspace: occupiedPath + ? workspaces?.get(normalizeRuntimePathForComparison(occupiedPath)) + : undefined + } + }) + .sort( + (a, b) => + Number(Boolean(b.current)) - Number(Boolean(a.current)) || + (b.committedAt ?? 0) - (a.committedAt ?? 0) + ) +} +export function branchPickerRows( + localRows: BranchPickerRow[], + localNames: Set, + remote: RuntimeRepoSearchRefs | null, + query: string +): BranchPickerRow[] { + const name = query.trim() + const normalized = name.toLowerCase() + const current: BranchPickerRow[] = [] + const exact: BranchPickerRow[] = [] + const prefix: BranchPickerRow[] = [] + const rest: BranchPickerRow[] = [] + for (const row of localRows) { + const label = row.label.toLowerCase() + if (row.current) { + current.push(row) + } else if (row.label === name) { + exact.push(row) + } else if (label.startsWith(normalized)) { + prefix.push(row) + } else if (label.includes(normalized)) { + rest.push(row) + } + } + const rows = [...current, ...exact, ...prefix, ...rest].slice(0, LOCAL_BRANCH_WINDOW) + const remoteRows = remote?.refDetails ?? [] + const canCreate = + isBranchNameValid(name) && + !localNames.has(name) && + !remoteRows.some((ref) => ref.localBranchName === name || ref.refName === name) + for (const ref of remoteRows) { + if (ref.refName.startsWith('refs/heads/') || localNames.has(ref.localBranchName)) { + continue + } + if (rows.length >= BRANCH_PICKER_ROW_LIMIT - Number(canCreate)) { + break + } + rows.push({ + target: { kind: 'remote', name: ref.refName.replace(/^refs\/remotes\//, '') }, + label: ref.refName.replace(/^refs\/remotes\//, '') + }) + } + if (canCreate) { + rows.push({ target: { kind: 'new', name }, label: name }) + } + return rows +} diff --git a/src/renderer/src/components/right-sidebar/source-control/branches/use-branch-picker.ts b/src/renderer/src/components/right-sidebar/source-control/branches/use-branch-picker.ts new file mode 100644 index 00000000000..c353b452b7b --- /dev/null +++ b/src/renderer/src/components/right-sidebar/source-control/branches/use-branch-picker.ts @@ -0,0 +1,224 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { toast } from 'sonner' +import { translate } from '@/i18n/i18n' +import type { RuntimeGitContext } from '@/runtime/source-control/git/runtime-git-client-context' +import { + listRuntimeGitBranches, + searchRuntimeRemoteBranches, + switchRuntimeGitBranch, + branchSwitchExistingBranch +} from '@/runtime/source-control/git/runtime-git-branch-client' +import type { + RuntimeGitLocalBranches, + RuntimeRepoSearchRefs +} from '../../../../../../shared/runtime-worktree-contracts' +import type { ExecutionHostId } from '../../../../../../shared/execution-host' +import { + branchPickerRows, + indexBranchWorkspaces, + indexLocalBranchRows, + type BranchWorkspace, + type BranchPickerRow +} from './branch-picker-rows' + +const EMPTY_LOCAL: RuntimeGitLocalBranches = { current: null, branches: [] } +export type BranchPickerOptions = { + context: RuntimeGitContext + repoId: string + hostId: ExecutionHostId + hostLabel: string + /** Names the workspace in a result that arrives after the user moved on. */ + workspaceLabel: string + workspaces: ReadonlyMap + onOpenWorkspace: (workspace: BranchWorkspace) => void + onSwitched: (warning?: string) => void +} +export function useBranchPicker(options: BranchPickerOptions) { + const [open, updateOpen] = useState(false) + const [openedAt, setOpenedAt] = useState(0) + const setOpen = useCallback((next: boolean) => { + if (next) { + setOpenedAt(Date.now()) + } + updateOpen(next) + }, []) + const [query, setQuery] = useState('') + const [local, setLocal] = useState(EMPTY_LOCAL) + const [remote, setRemote] = useState<{ query: string; page: RuntimeRepoSearchRefs } | null>(null) + const [error, setError] = useState(null) + const [discoveryError, setDiscoveryError] = useState(null) + const [pending, setPending] = useState(false) + const inFlight = useRef(false) + const generation = useRef(0) + const { context, repoId, hostId, workspaces } = options + const { target, worktreeId, worktreePath } = context + const route = useMemo( + () => ({ target, worktreeId, worktreePath }), + [target, worktreeId, worktreePath] + ) + useEffect(() => { + if (!open) { + setLocal(EMPTY_LOCAL) + setRemote(null) + return + } + let stale = false + setError(null) + setQuery('') + void listRuntimeGitBranches(route) + .then((result) => { + if (!stale) { + setLocal(result) + } + }) + .catch(() => { + if (!stale) { + setError( + translate( + 'sourceControl.branchPicker.listFailed', + "Orca couldn't list local branches. Try reopening the picker." + ) + ) + } + }) + return () => { + stale = true + } + }, [open, route]) + useEffect(() => { + if (!open) { + return + } + let stale = false + setDiscoveryError(null) + const timer = setTimeout(() => { + void searchRuntimeRemoteBranches(route, repoId, query) + .then((page) => { + if (!stale) { + setRemote({ query, page }) + } + }) + .catch(() => { + if (!stale) { + setDiscoveryError( + translate( + 'sourceControl.branchPicker.discoveryFailed', + "Orca couldn't find remote branches. You can still switch local branches or create a branch." + ) + ) + } + }) + }, 200) + return () => { + stale = true + clearTimeout(timer) + } + }, [open, query, repoId, route]) + useEffect( + () => () => { + generation.current += 1 + }, + [context.worktreeId, context.target] + ) + const workspaceIndex = useMemo( + () => indexBranchWorkspaces(open, workspaces.values(), repoId, hostId), + [open, workspaces, repoId, hostId] + ) + const localRows = useMemo( + () => (open ? indexLocalBranchRows(local, context.worktreePath, workspaceIndex) : []), + [open, local, context.worktreePath, workspaceIndex] + ) + const localNames = useMemo(() => new Set(local.branches), [local]) + const currentRemote = remote?.query === query ? remote.page : null + const rows = useMemo( + () => branchPickerRows(localRows, localNames, currentRemote, query), + [localRows, localNames, currentRemote, query] + ) + const { onOpenWorkspace, onSwitched, hostLabel, workspaceLabel } = options + const selectRef = useRef<(row: BranchPickerRow) => Promise>(async () => {}) + const select = useCallback( + async (row: BranchPickerRow) => { + if (row.workspace) { + onOpenWorkspace(row.workspace) + setOpen(false) + return + } + if (row.current || row.occupiedPath || inFlight.current) { + return + } + inFlight.current = true + const ownGeneration = generation.current + setPending(true) + try { + const result = await switchRuntimeGitBranch(route, row.target, hostLabel) + onSwitched(result.warning) + if (generation.current === ownGeneration) { + setOpen(false) + } + } catch (failure) { + // Report a failure even after the user left; only the recovery needs this picker. + const here = generation.current === ownGeneration + if (here) { + setOpen(false) + } + const existingBranch = here ? branchSwitchExistingBranch(failure) : null + toast.error( + here + ? translate('sourceControl.branchPicker.switchFailedTitle', "Couldn't switch branches") + : translate( + 'sourceControl.branchPicker.switchFailedIn', + "Couldn't switch branches in {{workspace}}", + { workspace: workspaceLabel } + ), + { + description: + failure instanceof Error + ? failure.message + : translate( + 'sourceControl.branchPicker.switchFailed', + "Orca couldn't switch branches. Try again." + ), + // Matches the landed warning so the reason and its recovery stay readable. + duration: 15000, + action: existingBranch + ? { + label: translate('sourceControl.branchPicker.recover', 'Switch to {{branch}}', { + branch: existingBranch + }), + onClick: () => { + // A toast outlives the picker; never switch a workspace the user has left. + if (generation.current === ownGeneration) { + void selectRef.current({ + target: { kind: 'local', name: existingBranch }, + label: existingBranch + }) + } + } + } + : undefined + } + ) + } + inFlight.current = false + if (generation.current === ownGeneration) { + setPending(false) + } + }, + [route, hostLabel, workspaceLabel, onOpenWorkspace, onSwitched, setOpen] + ) + useEffect(() => { + selectRef.current = select + }, [select]) + return { + open, + openedAt, + setOpen, + query, + setQuery, + rows, + error, + discoveryError, + pending, + select + } +} diff --git a/src/renderer/src/components/right-sidebar/source-control/panel/branch-compare-flow.tsx b/src/renderer/src/components/right-sidebar/source-control/panel/branch-compare-flow.tsx new file mode 100644 index 00000000000..9f43fb22182 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/source-control/panel/branch-compare-flow.tsx @@ -0,0 +1,143 @@ +import type React from 'react' +import { useTranslate } from '@/i18n/use-translate' +import type { WorktreeGitIdentityDisplay } from '../../../../../../shared/worktree/git-identity-display' +import { HeadIdentity } from './head-identity' + +function BaseRefButton({ + baseRef, + displayLabel, + onClick, + title +}: { + baseRef: string + displayLabel: string + onClick: () => void + title: string +}): React.JSX.Element { + const t = useTranslate() + const accessibleName = t( + 'auto.components.right.sidebar.SourceControl.c7d4e2f801', + 'Change base ref: {{value0}}', + { value0: displayLabel } + ) + return ( + + ) +} + +function BaseLine({ + baseRef, + baseLabel, + onChangeBaseRef, + changeBaseTitle, + showArrow, + leading, + trailing +}: { + baseRef: string + baseLabel: string + onChangeBaseRef: () => void + changeBaseTitle: string + showArrow: boolean + leading?: React.ReactNode + trailing?: React.ReactNode +}): React.JSX.Element { + const t = useTranslate() + return ( +
+ {leading} + {showArrow ? ( + + ) : ( + + {t('auto.components.right.sidebar.SourceControl.e8a1c4b203', 'vs')} + + )} + + + + {trailing} +
+ ) +} + +// Stack head and base so narrow sidebars do not truncate both refs on one line. +export function StackedCompareFlow({ + headDisplay, + headControl, + baseRef, + baseLabel, + onChangeBaseRef, + changeBaseTitle, + leading, + trailing, + headTrailing +}: { + headDisplay: WorktreeGitIdentityDisplay | null + headControl?: React.ReactNode + baseRef: string + baseLabel: string + onChangeBaseRef: () => void + changeBaseTitle: string + leading?: React.ReactNode + trailing?: React.ReactNode + headTrailing?: React.ReactNode +}): React.JSX.Element { + if (!headDisplay) { + return ( + + {headTrailing} + {trailing} + + } + /> + ) + } + + return ( +
+ {/* Place branch line totals beside HEAD; base-line commit counts measure comparison state. */} + {/* Use gap-2 so colored counts do not read as part of an ellipsized branch name. */} +
+ + {headControl ?? } + + {headTrailing} +
+ {/* Why: spinner/actions sit on the base line — they describe compare state, not HEAD. */} + +
+ ) +} diff --git a/src/renderer/src/components/right-sidebar/source-control/panel/branch-context-row.tsx b/src/renderer/src/components/right-sidebar/source-control/panel/branch-context-row.tsx index 2ef68c50228..e5a13c28d09 100644 --- a/src/renderer/src/components/right-sidebar/source-control/panel/branch-context-row.tsx +++ b/src/renderer/src/components/right-sidebar/source-control/panel/branch-context-row.tsx @@ -5,7 +5,8 @@ import type { GitBranchLineTotal } from '../../../../../../shared/git/status/git import { cn } from '@/lib/utils' import { useTranslate } from '@/i18n/use-translate' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { DetachedHeadBadge } from '@/components/DetachedHeadBadge' +import { HeadIdentity } from './head-identity' +import { StackedCompareFlow } from './branch-compare-flow' import type { WorktreeGitIdentityDisplay } from '../../../../../../shared/worktree/git-identity-display' import { SourceControlHeaderIconButton } from './header-icon-button' import { SourceControlBranchLineTotalChip } from './branch-line-total-chip' @@ -17,36 +18,6 @@ import { } from './branch-context-stats' import { openAppHttpLink } from '@/lib/browser/navigation/app-http-link-open' -function BaseRefButton({ - baseRef, - displayLabel, - onClick, - title -}: { - baseRef: string - displayLabel: string - onClick: () => void - title: string -}): React.JSX.Element { - const t = useTranslate() - const accessibleName = t( - 'auto.components.right.sidebar.SourceControl.c7d4e2f801', - 'Change base ref: {{value0}}', - { value0: displayLabel } - ) - return ( - - ) -} - function ContextStat({ stat }: { stat: SourceControlBranchContextStat }): React.JSX.Element { // Make stats focusable so screen readers announce their base and keyboard users can open the tooltip. return ( @@ -102,46 +73,6 @@ function resolveHeadFlowLabel( return null } -function HeadIdentity({ display }: { display: WorktreeGitIdentityDisplay }): React.JSX.Element { - const t = useTranslate() - if (display.kind === 'detached') { - return ( - - ) - } - - const branchAriaLabel = t( - 'auto.components.right.sidebar.SourceControl.a4e93c21d7', - 'Current branch: {{value0}}', - { value0: display.branchName } - ) - - // Keep full refs keyboard-discoverable; block layout lets truncate clip long names before they overlap totals. - return ( - - - - {display.branchName} - - - - {display.branchName} - - - ) -} - function CompareFlowGroup({ flowLabel, busy, @@ -165,117 +96,11 @@ function CompareFlowGroup({ ) } -function BaseLine({ - baseRef, - baseLabel, - onChangeBaseRef, - changeBaseTitle, - showArrow, - leading, - trailing -}: { - baseRef: string - baseLabel: string - onChangeBaseRef: () => void - changeBaseTitle: string - showArrow: boolean - leading?: React.ReactNode - trailing?: React.ReactNode -}): React.JSX.Element { - const t = useTranslate() - return ( -
- {leading} - {showArrow ? ( - - ) : ( - - {t('auto.components.right.sidebar.SourceControl.e8a1c4b203', 'vs')} - - )} - - - - {trailing} -
- ) -} - -// Stack head and base so narrow sidebars do not truncate both refs on one line. -function StackedCompareFlow({ - headDisplay, - baseRef, - baseLabel, - onChangeBaseRef, - changeBaseTitle, - leading, - trailing, - headTrailing -}: { - headDisplay: WorktreeGitIdentityDisplay | null - baseRef: string - baseLabel: string - onChangeBaseRef: () => void - changeBaseTitle: string - leading?: React.ReactNode - trailing?: React.ReactNode - headTrailing?: React.ReactNode -}): React.JSX.Element { - if (!headDisplay) { - return ( - - {headTrailing} - {trailing} - - } - /> - ) - } - - return ( -
- {/* Place branch line totals beside HEAD; base-line commit counts measure comparison state. */} - {/* Use gap-2 so colored counts do not read as part of an ellipsized branch name. */} -
- - - - {headTrailing} -
- {/* Why: spinner/actions sit on the base line — they describe compare state, not HEAD. */} - -
- ) -} - export function SourceControlBranchContextRow({ summary, compareBaseRef, headDisplay = null, + headControl, manualReviewUrl, branchLineTotal, onChangeBaseRef, @@ -284,6 +109,7 @@ export function SourceControlBranchContextRow({ summary: GitBranchCompareSummary | null compareBaseRef: string | null headDisplay?: WorktreeGitIdentityDisplay | null + headControl?: React.ReactNode manualReviewUrl?: string | null branchLineTotal?: GitBranchLineTotal | null onChangeBaseRef: () => void @@ -299,7 +125,7 @@ export function SourceControlBranchContextRow({ } return (
- + {headControl ?? }
) } @@ -327,6 +153,7 @@ export function SourceControlBranchContextRow({ > + ) + } + + const branchAriaLabel = t( + 'auto.components.right.sidebar.SourceControl.a4e93c21d7', + 'Current branch: {{value0}}', + { value0: display.branchName } + ) + + // Keep full refs keyboard-discoverable; block layout lets truncate clip long names before they overlap totals. + return ( + + + + {display.branchName} + + + + {display.branchName} + + + ) +} diff --git a/src/renderer/src/components/right-sidebar/source-control/panel/header-review-actions.tsx b/src/renderer/src/components/right-sidebar/source-control/panel/header-review-actions.tsx new file mode 100644 index 00000000000..5a6f0ca5fd1 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/source-control/panel/header-review-actions.tsx @@ -0,0 +1,88 @@ +import type React from 'react' +import { GitPullRequestArrow, Loader2 } from 'lucide-react' +import type { HostedReviewInfo } from '../../../../../../shared/source-control/hosted-review/hosted-review-contracts' +import type { PrimaryAction } from '../review/source-control-primary-action-types' +import { Button } from '@/components/ui/button' +import { cn } from '@/lib/utils' +import { useTranslate } from '@/i18n/use-translate' +import { PrimaryActionTooltip } from '../review/source-control-primary-action-tooltip' +import { HostedReviewHeaderLink, HostedReviewIcon } from '../review/hosted-review-header-chrome' + +export function HostedReviewToolbarLink({ + review, + onOpenHostedReviewInChecks, + compact +}: { + review: HostedReviewInfo + onOpenHostedReviewInChecks: () => void + compact?: boolean +}): React.JSX.Element { + return ( +
+ + +
+ ) +} + +export function CreatePrHeaderButton({ + action, + isCreatePrIntentInFlight, + isCreatingPr, + onClick +}: { + action: PrimaryAction + isCreatePrIntentInFlight: boolean + isCreatingPr: boolean + onClick: () => void +}): React.JSX.Element { + return ( + + + + + + ) +} + +export function SuppressedGitHubPRToolbar({ + number, + onRelink +}: { + number: number + onRelink: () => void +}): React.JSX.Element { + const t = useTranslate() + return ( +
+ + {t('sourceControl.unlinkedPr.status', 'PR #{{number}} unlinked', { number })} + + +
+ ) +} diff --git a/src/renderer/src/components/right-sidebar/source-control/panel/header-toolbar.tsx b/src/renderer/src/components/right-sidebar/source-control/panel/header-toolbar.tsx index b83a3b6e65c..7914a964d56 100644 --- a/src/renderer/src/components/right-sidebar/source-control/panel/header-toolbar.tsx +++ b/src/renderer/src/components/right-sidebar/source-control/panel/header-toolbar.tsx @@ -1,6 +1,6 @@ import { ImeInput } from '@/lib/ime-text-field' import React, { useCallback, useEffect, useRef } from 'react' -import { GitPullRequestArrow, Loader2, Search, X } from 'lucide-react' +import { Search, X } from 'lucide-react' import type { GitBranchCompareSummary } from '../../../../../../shared/git/diff/git-diff-compare-types' import type { GitBranchLineTotal } from '../../../../../../shared/git/status/git-status-types' import type { SourceControlViewMode } from '../../../../../../shared/ui-chrome-types' @@ -8,10 +8,13 @@ import type { HostedReviewInfo } from '../../../../../../shared/source-control/h import type { PrimaryAction } from '../review/source-control-primary-action-types' import { Button } from '@/components/ui/button' import { cn } from '@/lib/utils' -import { PrimaryActionTooltip } from '../review/source-control-primary-action-tooltip' import { useTranslate } from '@/i18n/use-translate' import type { WorktreeGitIdentityDisplay } from '../../../../../../shared/worktree/git-identity-display' -import { HostedReviewHeaderLink, HostedReviewIcon } from '../review/hosted-review-header-chrome' +import { + HostedReviewToolbarLink, + CreatePrHeaderButton, + SuppressedGitHubPRToolbar +} from './header-review-actions' import { SourceControlBranchContextRow } from './branch-context-row' import { shouldShowSourceControlBranchContextChrome } from './branch-context-stats' import { SourceControlHeaderOverflowMenu } from './header-overflow-menu' @@ -39,90 +42,12 @@ type SourceControlHeaderToolbarProps = { onExpandNotes: () => void branchSummary: GitBranchCompareSummary | null compareBaseRef: string | null + headControl?: React.ReactNode headDisplay?: WorktreeGitIdentityDisplay | null manualReviewUrl?: string | null branchLineTotal?: GitBranchLineTotal | null } -function HostedReviewToolbarLink({ - review, - onOpenHostedReviewInChecks, - compact -}: { - review: HostedReviewInfo - onOpenHostedReviewInChecks: () => void - compact?: boolean -}): React.JSX.Element { - return ( -
- - -
- ) -} - -function CreatePrHeaderButton({ - action, - isCreatePrIntentInFlight, - isCreatingPr, - onClick -}: { - action: PrimaryAction - isCreatePrIntentInFlight: boolean - isCreatingPr: boolean - onClick: () => void -}): React.JSX.Element { - return ( - - - - - - ) -} - -function SuppressedGitHubPRToolbar({ - number, - onRelink -}: { - number: number - onRelink: () => void -}): React.JSX.Element { - const t = useTranslate() - return ( -
- - {t('sourceControl.unlinkedPr.status', 'PR #{{number}} unlinked', { number })} - - -
- ) -} - function renderOverflowMenu( props: Pick< SourceControlHeaderToolbarProps, @@ -163,6 +88,7 @@ export function SourceControlHeaderToolbar({ branchSummary, compareBaseRef, headDisplay = null, + headControl, manualReviewUrl, branchLineTotal }: SourceControlHeaderToolbarProps): React.JSX.Element { @@ -314,6 +240,7 @@ export function SourceControlHeaderToolbar({ summary={branchSummary} compareBaseRef={compareBaseRef} headDisplay={headDisplay} + headControl={headControl} manualReviewUrl={manualReviewUrl} branchLineTotal={branchLineTotal} onChangeBaseRef={onChangeBaseRef} diff --git a/src/renderer/src/components/right-sidebar/source-control/panel/panel-ready.tsx b/src/renderer/src/components/right-sidebar/source-control/panel/panel-ready.tsx index ee3694f8088..69745b6f3ea 100644 --- a/src/renderer/src/components/right-sidebar/source-control/panel/panel-ready.tsx +++ b/src/renderer/src/components/right-sidebar/source-control/panel/panel-ready.tsx @@ -1,3 +1,4 @@ +import { SourceControlBranchPicker } from '../branches/SourceControlBranchPicker' import { BulkActionBar } from '../commit/bulk-action-bar' import { SourceControlHeaderToolbar } from './header-toolbar' import { SourceControlNotesShelf } from '../notes/notes-shelf' @@ -66,6 +67,11 @@ export function SourceControlPanelReady(props: SourceControlPanelReadyProps) { onKeyDown={handleSourceControlKeyDown} > + ) : undefined + } filterQuery={filterQuery} filterExpanded={filterExpanded} onFilterQueryChange={setFilterQuery} diff --git a/src/renderer/src/components/right-sidebar/source-control/panel/use-worktree-operation-state.ts b/src/renderer/src/components/right-sidebar/source-control/panel/use-worktree-operation-state.ts index 4d98cafb724..e28703ec7df 100644 --- a/src/renderer/src/components/right-sidebar/source-control/panel/use-worktree-operation-state.ts +++ b/src/renderer/src/components/right-sidebar/source-control/panel/use-worktree-operation-state.ts @@ -48,6 +48,16 @@ export function useSourceControlWorktreeOperationState({ writeWorktreeCommitDraft(operationStore, update), [operationStore] ) + // A landed switch clears the last action error, which belonged to the previous branch. + const recordBranchSwitchOutcome = useCallback( + (refresh?: () => Promise): void => { + if (operationStore.getState().remoteActionError) { + operationStore.setState({ remoteActionError: null }) + } + void refresh?.().catch(() => {}) + }, + [operationStore] + ) useEffect(() => { // Terminal conflict resolution must clear stale failure banners once status observes the operation ending. @@ -75,6 +85,7 @@ export function useSourceControlWorktreeOperationState({ isCreatePrIntentInFlight, isCreatingPr, operationStore, + recordBranchSwitchOutcome, remoteActionError, updateCommitDraft } diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index 97d159d9f06..cbef9709a4c 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2320,6 +2320,10 @@ } }, "sourceControl": { + "branchPicker": { + "dirty_one": "Commit or discard {{count}} changed file before switching branches.", + "dirty_other": "Commit or discard {{count}} changed files before switching branches." + }, "createReviewFailure": { "stacked": { "stackConflict": "The pull request already belongs to a different GitHub stack.", diff --git a/src/renderer/src/i18n/locales/de.json b/src/renderer/src/i18n/locales/de.json index 7a3a80dca40..f1e374949e5 100644 --- a/src/renderer/src/i18n/locales/de.json +++ b/src/renderer/src/i18n/locales/de.json @@ -21281,6 +21281,29 @@ "loading": "Submodul-Änderungen werden geladen…", "empty": "Keine Änderungen im Submodul", "loadFailed": "Submodul-Änderungen konnten nicht geladen werden" + }, + "branchPicker": { + "switch": "Branch wechseln: {{branch}}", + "search": "Branches suchen", + "recover": "Zu {{branch}} wechseln", + "create": "Branch {{name}} aus {{branch}} erstellen", + "open": "Arbeitsbereich öffnen", + "occupied": "Ausgecheckt in {{path}}", + "remote": "remote", + "statusFailed": "Orca konnte die Änderungen in diesem Checkout nicht lesen. Aktualisiere die Quellcodeverwaltung, bevor du wechselst.", + "statusUnknown": "Lies die Änderungen in diesem Checkout, bevor du wechselst.", + "conflict": "Schließe den {{operation}} ab oder brich ihn ab, bevor du den Branch wechselst.", + "dirty_one": "Committe oder verwirf {{count}} geänderte Datei, bevor du den Branch wechselst.", + "dirty_other": "Committe oder verwirf {{count}} geänderte Dateien, bevor du den Branch wechselst.", + "agentWorking": "Ein Agent arbeitet in diesem Arbeitsbereich. Wechsle den Branch, wenn er fertig ist, oder schließe seinen Tab, falls er angehalten hat.", + "listFailed": "Orca konnte die lokalen Branches nicht auflisten. Öffne die Auswahl erneut.", + "discoveryFailed": "Orca konnte keine Remote-Branches finden. Du kannst trotzdem zu lokalen Branches wechseln oder einen Branch erstellen.", + "switchFailed": "Orca konnte den Branch nicht wechseln. Versuche es erneut.", + "switchFailedTitle": "Branch konnte nicht gewechselt werden", + "switchFailedIn": "Branch in {{workspace}} konnte nicht gewechselt werden", + "switchedIn": "In {{workspace}}", + "thisComputer": "diesem Computer", + "updateHost": "Aktualisiere Orca auf {{host}}, um von hier aus Branches zu wechseln." } }, "agentsSidebarIntro": { diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index e0c302e3258..13173dc92b5 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -21967,6 +21967,29 @@ "loading": "Loading submodule changes…", "empty": "No changes in submodule", "loadFailed": "Failed to load submodule changes" + }, + "branchPicker": { + "switch": "Switch branch: {{branch}}", + "search": "Search branches", + "recover": "Switch to {{branch}}", + "create": "Create branch {{name}} from {{branch}}", + "open": "Open workspace", + "occupied": "Checked out in {{path}}", + "remote": "remote", + "statusFailed": "Orca couldn't read this checkout's changes. Refresh Source Control before switching.", + "statusUnknown": "Read this checkout’s changes before switching.", + "conflict": "Finish or abort the {{operation}} before switching branches.", + "dirty_one": "Commit or discard {{count}} changed file before switching branches.", + "dirty_other": "Commit or discard {{count}} changed files before switching branches.", + "agentWorking": "An agent is working in this workspace. Switch branches after it finishes, or close its tab if it has stopped.", + "listFailed": "Orca couldn't list local branches. Try reopening the picker.", + "discoveryFailed": "Orca couldn't find remote branches. You can still switch local branches or create a branch.", + "switchFailed": "Orca couldn't switch branches. Try again.", + "switchFailedTitle": "Couldn't switch branches", + "switchFailedIn": "Couldn't switch branches in {{workspace}}", + "switchedIn": "In {{workspace}}", + "thisComputer": "this computer", + "updateHost": "Update Orca on {{host}} to switch branches from here." } }, "agentsSidebarIntro": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index ab7776b95d2..164818789c6 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -22088,6 +22088,30 @@ "loading": "Cargando cambios del submódulo…", "empty": "No hay cambios en el submódulo", "loadFailed": "No se pudieron cargar los cambios del submódulo" + }, + "branchPicker": { + "switch": "Cambiar de rama: {{branch}}", + "search": "Buscar ramas", + "recover": "Cambiar a {{branch}}", + "create": "Crear la rama {{name}} desde {{branch}}", + "open": "Abrir espacio de trabajo", + "occupied": "Activa en {{path}}", + "remote": "remota", + "statusFailed": "Orca no pudo leer los cambios de esta copia de trabajo. Actualiza Control de código fuente antes de cambiar.", + "statusUnknown": "Lee los cambios de esta copia de trabajo antes de cambiar.", + "conflict": "Termina o cancela el {{operation}} antes de cambiar de rama.", + "dirty_one": "Confirma o descarta {{count}} archivo modificado antes de cambiar de rama.", + "dirty_other": "Confirma o descarta {{count}} archivos modificados antes de cambiar de rama.", + "dirty_many": "Confirma o descarta {{count}} archivos modificados antes de cambiar de rama.", + "agentWorking": "Un agente está trabajando en este espacio de trabajo. Cambia de rama cuando termine, o cierra su pestaña si se ha detenido.", + "listFailed": "Orca no pudo listar las ramas locales. Vuelve a abrir el selector.", + "discoveryFailed": "Orca no encontró ramas remotas. Aún puedes cambiar a ramas locales o crear una rama.", + "switchFailed": "Orca no pudo cambiar de rama. Inténtalo de nuevo.", + "switchFailedTitle": "No se pudo cambiar de rama", + "switchFailedIn": "No se pudo cambiar de rama en {{workspace}}", + "switchedIn": "En {{workspace}}", + "thisComputer": "este equipo", + "updateHost": "Actualiza Orca en {{host}} para cambiar de rama desde aquí." } }, "aiVault": { diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 64060531f42..0815ff26a3d 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -21632,6 +21632,30 @@ "loading": "Chargement des modifications du sous-module…", "empty": "Aucune modification dans le sous-module", "loadFailed": "Impossible de charger les modifications du sous-module" + }, + "branchPicker": { + "switch": "Changer de branche : {{branch}}", + "search": "Rechercher des branches", + "recover": "Passer à {{branch}}", + "create": "Créer la branche {{name}} à partir de {{branch}}", + "open": "Ouvrir l’espace de travail", + "occupied": "Extraite dans {{path}}", + "remote": "distante", + "statusFailed": "Orca n’a pas pu lire les modifications de cette copie de travail. Actualisez le contrôle de code source avant de changer.", + "statusUnknown": "Lisez les modifications de cette copie de travail avant de changer.", + "conflict": "Terminez ou annulez le {{operation}} avant de changer de branche.", + "dirty_one": "Validez ou abandonnez {{count}} fichier modifié avant de changer de branche.", + "dirty_other": "Validez ou abandonnez {{count}} fichiers modifiés avant de changer de branche.", + "dirty_many": "Validez ou abandonnez {{count}} fichiers modifiés avant de changer de branche.", + "agentWorking": "Un agent travaille dans cet espace de travail. Changez de branche une fois qu’il a terminé, ou fermez son onglet s’il s’est arrêté.", + "listFailed": "Orca n’a pas pu lister les branches locales. Rouvrez le sélecteur.", + "discoveryFailed": "Orca n’a trouvé aucune branche distante. Vous pouvez toujours passer à une branche locale ou créer une branche.", + "switchFailed": "Orca n’a pas pu changer de branche. Réessayez.", + "switchFailedTitle": "Impossible de changer de branche", + "switchFailedIn": "Impossible de changer de branche dans {{workspace}}", + "switchedIn": "Dans {{workspace}}", + "thisComputer": "cet ordinateur", + "updateHost": "Mettez à jour Orca sur {{host}} pour changer de branche d’ici." } }, "agentsSidebarIntro": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 6152b194a25..c7969b8dc1d 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -20993,6 +20993,29 @@ "loading": "サブモジュールの変更を読み込み中…", "empty": "サブモジュールに変更はありません", "loadFailed": "サブモジュールの変更を読み込めませんでした" + }, + "branchPicker": { + "switch": "ブランチを切り替え: {{branch}}", + "search": "ブランチを検索", + "recover": "{{branch}} に切り替え", + "create": "{{branch}} からブランチ {{name}} を作成", + "open": "ワークスペースを開く", + "occupied": "{{path}} でチェックアウト済み", + "remote": "リモート", + "statusFailed": "このチェックアウトの変更を読み取れませんでした。切り替える前にソース管理を更新してください。", + "statusUnknown": "切り替える前に、このチェックアウトの変更を読み取ってください。", + "conflict": "ブランチを切り替える前に {{operation}} を完了または中止してください。", + "dirty_one": "ブランチを切り替える前に、変更された {{count}} 個のファイルをコミットまたは破棄してください。", + "dirty_other": "ブランチを切り替える前に、変更された {{count}} 個のファイルをコミットまたは破棄してください。", + "agentWorking": "このワークスペースで Agent が作業中です。完了してからブランチを切り替えるか、停止している場合はそのタブを閉じてください。", + "listFailed": "ローカルブランチを一覧表示できませんでした。ピッカーを開き直してください。", + "discoveryFailed": "リモートブランチが見つかりませんでした。ローカルブランチへの切り替えやブランチの作成は引き続き可能です。", + "switchFailed": "ブランチを切り替えられませんでした。もう一度お試しください。", + "switchFailedTitle": "ブランチを切り替えられませんでした", + "switchFailedIn": "{{workspace}} でブランチを切り替えられませんでした", + "switchedIn": "{{workspace}} で", + "thisComputer": "このコンピューター", + "updateHost": "ここからブランチを切り替えるには、{{host}} の Orca を更新してください。" } }, "rendererRecovery": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index eef300630e9..bc336e9def8 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -20993,6 +20993,29 @@ "loading": "하위 모듈 변경 사항을 불러오는 중…", "empty": "하위 모듈에 변경 사항 없음", "loadFailed": "하위 모듈 변경 사항을 불러오지 못했습니다" + }, + "branchPicker": { + "switch": "브랜치 전환: {{branch}}", + "search": "브랜치 검색", + "recover": "{{branch}}(으)로 전환", + "create": "{{branch}}에서 {{name}} 브랜치 만들기", + "open": "작업 공간 열기", + "occupied": "{{path}}에서 체크아웃됨", + "remote": "원격", + "statusFailed": "Orca가 이 체크아웃의 변경 사항을 읽지 못했습니다. 전환하기 전에 소스 제어를 새로 고치세요.", + "statusUnknown": "전환하기 전에 이 체크아웃의 변경 사항을 읽으세요.", + "conflict": "브랜치를 전환하기 전에 {{operation}}을(를) 완료하거나 중단하세요.", + "dirty_one": "브랜치를 전환하기 전에 변경된 파일 {{count}}개를 커밋하거나 버리세요.", + "dirty_other": "브랜치를 전환하기 전에 변경된 파일 {{count}}개를 커밋하거나 버리세요.", + "agentWorking": "이 워크스페이스에서 에이전트가 작업 중입니다. 작업이 끝난 후 브랜치를 전환하거나, 멈춘 경우 해당 탭을 닫으세요.", + "listFailed": "Orca가 로컬 브랜치를 나열하지 못했습니다. 선택기를 다시 열어 보세요.", + "discoveryFailed": "Orca가 원격 브랜치를 찾지 못했습니다. 로컬 브랜치로 전환하거나 브랜치를 만들 수는 있습니다.", + "switchFailed": "Orca가 브랜치를 전환하지 못했습니다. 다시 시도하세요.", + "switchFailedTitle": "브랜치를 전환하지 못했습니다", + "switchFailedIn": "{{workspace}}에서 브랜치를 전환하지 못했습니다", + "switchedIn": "{{workspace}}에서", + "thisComputer": "이 컴퓨터", + "updateHost": "여기서 브랜치를 전환하려면 {{host}}의 Orca를 업데이트하세요." } }, "rendererRecovery": { diff --git a/src/renderer/src/i18n/locales/pt.json b/src/renderer/src/i18n/locales/pt.json index f0191f35dd0..7ceccbd3b88 100644 --- a/src/renderer/src/i18n/locales/pt.json +++ b/src/renderer/src/i18n/locales/pt.json @@ -290,6 +290,30 @@ "loading": "Carregando alterações do submódulo…", "empty": "Nenhuma alteração no submódulo", "loadFailed": "Não foi possível carregar as alterações do submódulo" + }, + "branchPicker": { + "switch": "Trocar de branch: {{branch}}", + "search": "Pesquisar branches", + "recover": "Trocar para {{branch}}", + "create": "Criar branch {{name}} a partir de {{branch}}", + "open": "Abrir espaço de trabalho", + "occupied": "Em uso em {{path}}", + "remote": "remoto", + "statusFailed": "O Orca não conseguiu ler as alterações desta cópia de trabalho. Atualize o Controle de código-fonte antes de trocar.", + "statusUnknown": "Leia as alterações desta cópia de trabalho antes de trocar.", + "conflict": "Conclua ou cancele o {{operation}} antes de trocar de branch.", + "dirty_one": "Faça commit ou descarte {{count}} arquivo alterado antes de trocar de branch.", + "dirty_other": "Faça commit ou descarte {{count}} arquivos alterados antes de trocar de branch.", + "dirty_many": "Faça commit ou descarte {{count}} arquivos alterados antes de trocar de branch.", + "agentWorking": "Um agente está trabalhando neste workspace. Troque de branch quando ele terminar ou feche a aba dele se ele tiver parado.", + "listFailed": "O Orca não conseguiu listar os branches locais. Tente reabrir o seletor.", + "discoveryFailed": "O Orca não encontrou branches remotos. Você ainda pode trocar para branches locais ou criar um branch.", + "switchFailed": "O Orca não conseguiu trocar de branch. Tente novamente.", + "switchFailedTitle": "Não foi possível trocar de branch", + "switchFailedIn": "Não foi possível trocar de branch em {{workspace}}", + "switchedIn": "Em {{workspace}}", + "thisComputer": "este computador", + "updateHost": "Atualize o Orca em {{host}} para trocar de branch daqui." } }, "githubChecks": { diff --git a/src/renderer/src/i18n/locales/ru.json b/src/renderer/src/i18n/locales/ru.json index dec7d71ba9b..e16fc1460b9 100644 --- a/src/renderer/src/i18n/locales/ru.json +++ b/src/renderer/src/i18n/locales/ru.json @@ -21977,6 +21977,31 @@ "loading": "Загрузка изменений подмодуля…", "empty": "В подмодуле нет изменений", "loadFailed": "Не удалось загрузить изменения подмодуля" + }, + "branchPicker": { + "switch": "Сменить ветку: {{branch}}", + "search": "Поиск веток", + "recover": "Переключиться на {{branch}}", + "create": "Создать ветку {{name}} из {{branch}}", + "open": "Открыть рабочее пространство", + "occupied": "Извлечена в {{path}}", + "remote": "удалённая", + "statusFailed": "Orca не удалось прочитать изменения в этой рабочей копии. Обновите систему управления версиями перед переключением.", + "statusUnknown": "Прочитайте изменения в этой рабочей копии перед переключением.", + "conflict": "Завершите или прервите {{operation}} перед сменой ветки.", + "dirty_one": "Зафиксируйте или отмените {{count}} изменённый файл перед сменой ветки.", + "dirty_few": "Зафиксируйте или отмените {{count}} изменённых файла перед сменой ветки.", + "dirty_many": "Зафиксируйте или отмените {{count}} изменённых файлов перед сменой ветки.", + "dirty_other": "Зафиксируйте или отмените {{count}} изменённых файлов перед сменой ветки.", + "agentWorking": "В этом рабочем пространстве работает агент. Смените ветку, когда он закончит, или закройте его вкладку, если он остановился.", + "listFailed": "Orca не удалось получить список локальных веток. Откройте выбор заново.", + "discoveryFailed": "Orca не нашла удалённых веток. Вы всё равно можете переключиться на локальную ветку или создать новую.", + "switchFailed": "Orca не удалось сменить ветку. Попробуйте ещё раз.", + "switchFailedTitle": "Не удалось сменить ветку", + "switchFailedIn": "Не удалось сменить ветку в {{workspace}}", + "switchedIn": "В {{workspace}}", + "thisComputer": "этот компьютер", + "updateHost": "Обновите Orca на {{host}}, чтобы менять ветки отсюда." } }, "agentsSidebarIntro": { diff --git a/src/renderer/src/i18n/locales/tr.json b/src/renderer/src/i18n/locales/tr.json index 61be6f9bbc3..d9d05c44b05 100644 --- a/src/renderer/src/i18n/locales/tr.json +++ b/src/renderer/src/i18n/locales/tr.json @@ -21262,6 +21262,29 @@ "loading": "Alt modül değişiklikleri yükleniyor…", "empty": "Alt modülde değişiklik yok", "loadFailed": "Alt modül değişiklikleri yüklenemedi" + }, + "branchPicker": { + "switch": "Dal değiştir: {{branch}}", + "search": "Dallarda ara", + "recover": "{{branch}} dalına geç", + "create": "{{branch}} dalından {{name}} dalını oluştur", + "open": "Çalışma alanını aç", + "occupied": "{{path}} konumunda kullanımda", + "remote": "uzak", + "statusFailed": "Orca bu çalışma kopyasındaki değişiklikleri okuyamadı. Değiştirmeden önce Kaynak Denetimi'ni yenileyin.", + "statusUnknown": "Değiştirmeden önce bu çalışma kopyasındaki değişiklikleri okuyun.", + "conflict": "Dal değiştirmeden önce {{operation}} işlemini bitirin veya iptal edin.", + "dirty_one": "Dal değiştirmeden önce {{count}} değişmiş dosyayı commit edin veya atın.", + "dirty_other": "Dal değiştirmeden önce {{count}} değişmiş dosyayı commit edin veya atın.", + "agentWorking": "Bu çalışma alanında bir ajan çalışıyor. Bitirdiğinde dal değiştirin veya durduysa sekmesini kapatın.", + "listFailed": "Orca yerel dalları listeleyemedi. Seçiciyi yeniden açmayı deneyin.", + "discoveryFailed": "Orca uzak dal bulamadı. Yine de yerel dallara geçebilir veya bir dal oluşturabilirsiniz.", + "switchFailed": "Orca dal değiştiremedi. Tekrar deneyin.", + "switchFailedTitle": "Dal değiştirilemedi", + "switchFailedIn": "{{workspace}} içinde dal değiştirilemedi", + "switchedIn": "{{workspace}} içinde", + "thisComputer": "bu bilgisayar", + "updateHost": "Buradan dal değiştirmek için {{host}} üzerindeki Orca'yı güncelleyin." } }, "agentsSidebarIntro": { diff --git a/src/renderer/src/i18n/locales/zh-Hant.json b/src/renderer/src/i18n/locales/zh-Hant.json index 119825efb3a..d3f01461e73 100644 --- a/src/renderer/src/i18n/locales/zh-Hant.json +++ b/src/renderer/src/i18n/locales/zh-Hant.json @@ -20962,6 +20962,29 @@ "loading": "正在載入子模組變更…", "empty": "子模組中沒有變更", "loadFailed": "無法載入子模組變更" + }, + "branchPicker": { + "switch": "切換分支:{{branch}}", + "search": "搜尋分支", + "recover": "切換到 {{branch}}", + "create": "從 {{branch}} 建立分支 {{name}}", + "open": "開啟工作區", + "occupied": "已在 {{path}} 中簽出", + "remote": "遠端", + "statusFailed": "Orca 無法讀取此簽出的變更。請先重新整理原始碼控制再切換。", + "statusUnknown": "切換前請先讀取此簽出的變更。", + "conflict": "切換分支前,請完成或中止 {{operation}}。", + "dirty_one": "切換分支前,請提交或捨棄 {{count}} 個已變更的檔案。", + "dirty_other": "切換分支前,請提交或捨棄 {{count}} 個已變更的檔案。", + "agentWorking": "有代理正在此工作區中工作。請在其完成後再切換分支;如果它已停止,請關閉其分頁。", + "listFailed": "Orca 無法列出本機分支。請嘗試重新開啟選擇器。", + "discoveryFailed": "Orca 找不到遠端分支。你仍然可以切換本機分支或建立分支。", + "switchFailed": "Orca 無法切換分支。請再試一次。", + "switchFailedTitle": "無法切換分支", + "switchFailedIn": "無法在 {{workspace}} 中切換分支", + "switchedIn": "在 {{workspace}} 中", + "thisComputer": "這台電腦", + "updateHost": "請在 {{host}} 上更新 Orca,以便從這裡切換分支。" } }, "agentsSidebarIntro": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index fb9e06de1e1..c7471b43eec 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -20993,6 +20993,29 @@ "loading": "正在加载子模块更改…", "empty": "子模块中没有更改", "loadFailed": "无法加载子模块更改" + }, + "branchPicker": { + "switch": "切换分支:{{branch}}", + "search": "搜索分支", + "recover": "切换到 {{branch}}", + "create": "从 {{branch}} 创建分支 {{name}}", + "open": "打开工作区", + "occupied": "已在 {{path}} 中检出", + "remote": "远程", + "statusFailed": "Orca 无法读取此检出的更改。请先刷新源代码管理再切换。", + "statusUnknown": "切换前请先读取此检出的更改。", + "conflict": "切换分支前,请完成或中止 {{operation}}。", + "dirty_one": "切换分支前,请提交或放弃 {{count}} 个已更改的文件。", + "dirty_other": "切换分支前,请提交或放弃 {{count}} 个已更改的文件。", + "agentWorking": "有代理正在此工作区中工作。请在其完成后再切换分支;如果它已停止,请关闭其标签页。", + "listFailed": "Orca 无法列出本地分支。请尝试重新打开选择器。", + "discoveryFailed": "Orca 找不到远程分支。你仍然可以切换本地分支或创建分支。", + "switchFailed": "Orca 无法切换分支。请重试。", + "switchFailedTitle": "无法切换分支", + "switchFailedIn": "无法在 {{workspace}} 中切换分支", + "switchedIn": "在 {{workspace}} 中", + "thisComputer": "此电脑", + "updateHost": "请在 {{host}} 上更新 Orca,以便从这里切换分支。" } }, "rendererRecovery": { diff --git a/src/renderer/src/runtime/source-control/git/runtime-git-branch-client.test.ts b/src/renderer/src/runtime/source-control/git/runtime-git-branch-client.test.ts new file mode 100644 index 00000000000..805b6d897c8 --- /dev/null +++ b/src/renderer/src/runtime/source-control/git/runtime-git-branch-client.test.ts @@ -0,0 +1,62 @@ +import { expect, it, vi } from 'vitest' +import type { RuntimeGitContext } from './runtime-git-client-context' +import { callRuntimeRpc } from '../../rpc/calls/runtime-rpc-call' +import { RuntimeRpcCallError } from '../../rpc/calls/runtime-rpc-result' +import { + branchSwitchExistingBranch, + searchRuntimeRemoteBranches, + switchRuntimeGitBranch +} from './runtime-git-branch-client' +vi.mock('../../rpc/calls/runtime-rpc-call', () => ({ callRuntimeRpc: vi.fn() })) +const context: RuntimeGitContext = { + target: { kind: 'environment', environmentId: 'owner' }, + worktreeId: 'repo::/repo', + worktreePath: '/repo' +} +it('keeps creation available on old hosts and explains the missing operation', async () => { + vi.mocked(callRuntimeRpc).mockRejectedValueOnce( + new RuntimeRpcCallError({ + id: 'old', + ok: false, + error: { code: 'method_not_found', message: 'missing' } + }) + ) + await expect( + switchRuntimeGitBranch(context, { kind: 'new', name: 'feature' }, 'Build Mac') + ).rejects.toThrow('Update Orca on Build Mac to switch branches from here.') + expect(callRuntimeRpc).toHaveBeenLastCalledWith(context.target, 'git.switchBranch', { + worktree: 'id:repo::/repo', + target: { kind: 'new', name: 'feature' } + }) +}) +it('preserves landed warnings and direct collision recovery data', async () => { + vi.mocked(callRuntimeRpc).mockResolvedValueOnce({ + ok: true, + branch: 'feature', + warning: 'Switched to feature, but a hook failed.' + }) + expect( + await switchRuntimeGitBranch(context, { kind: 'local', name: 'feature' }, 'Build Mac') + ).toEqual({ ok: true, branch: 'feature', warning: 'Switched to feature, but a hook failed.' }) + const failure = new RuntimeRpcCallError({ + id: 'collision', + ok: false, + error: { + code: 'runtime_error', + message: 'Switch to the existing branch.', + data: { existingBranch: 'feature' } + } + }) + expect(branchSwitchExistingBranch(failure)).toBe('feature') +}) +it('propagates remote discovery failure instead of a false empty page', async () => { + const failure = new Error('read failed') + vi.mocked(callRuntimeRpc).mockRejectedValueOnce(failure) + await expect(searchRuntimeRemoteBranches(context, 'repo', 'topic')).rejects.toBe(failure) + expect(callRuntimeRpc).toHaveBeenLastCalledWith(context.target, 'repo.searchRefs', { + repo: 'repo', + query: 'topic', + limit: 50, + namespace: 'remote' + }) +}) diff --git a/src/renderer/src/runtime/source-control/git/runtime-git-branch-client.ts b/src/renderer/src/runtime/source-control/git/runtime-git-branch-client.ts new file mode 100644 index 00000000000..f55562d4750 --- /dev/null +++ b/src/renderer/src/runtime/source-control/git/runtime-git-branch-client.ts @@ -0,0 +1,68 @@ +import { translate } from '@/i18n/i18n' +import type { BranchSwitchTarget } from '../../../../../shared/git/branch-switch-target' +import type { + RuntimeGitLocalBranches, + RuntimeGitSwitchResult, + RuntimeRepoSearchRefs +} from '../../../../../shared/runtime-worktree-contracts' +import type { RuntimeGitContext } from './runtime-git-client-context' +import { callRuntimeRpc } from '../../rpc/calls/runtime-rpc-call' +import { RuntimeRpcCallError } from '../../rpc/calls/runtime-rpc-result' +import { toRuntimeWorktreeSelector } from '../../runtime-worktree-selector' + +export async function switchRuntimeGitBranch( + context: RuntimeGitContext, + target: BranchSwitchTarget, + hostLabel = context.target.kind === 'environment' + ? context.target.environmentId + : translate('sourceControl.branchPicker.thisComputer', 'this computer') +): Promise { + try { + return await callRuntimeRpc(context.target, 'git.switchBranch', { + worktree: toRuntimeWorktreeSelector(context.worktreeId ?? ''), + target + }) + } catch (error) { + if (error instanceof RuntimeRpcCallError && error.code === 'method_not_found') { + throw new Error( + translate( + 'sourceControl.branchPicker.updateHost', + 'Update Orca on {{host}} to switch branches from here.', + { host: hostLabel } + ) + ) + } + throw error + } +} +export function listRuntimeGitBranches( + context: RuntimeGitContext +): Promise { + return callRuntimeRpc(context.target, 'git.localBranches', { + worktree: toRuntimeWorktreeSelector(context.worktreeId ?? '') + }) +} +export function searchRuntimeRemoteBranches( + context: RuntimeGitContext, + repoId: string, + query: string +): Promise { + return callRuntimeRpc(context.target, 'repo.searchRefs', { + repo: repoId, + query, + limit: 50, + namespace: 'remote' + }) +} +export function branchSwitchExistingBranch(error: unknown): string | null { + if (!(error instanceof RuntimeRpcCallError)) { + return null + } + const data = error.response.error.data + return typeof data === 'object' && + data !== null && + 'existingBranch' in data && + typeof data.existingBranch === 'string' + ? data.existingBranch + : null +} diff --git a/src/shared/git/branch-switch-target.ts b/src/shared/git/branch-switch-target.ts new file mode 100644 index 00000000000..642ffbec5df --- /dev/null +++ b/src/shared/git/branch-switch-target.ts @@ -0,0 +1,20 @@ +export type BranchSwitchTarget = { kind: 'local' | 'remote' | 'new'; name: string } +export type BranchSwitchCreation = { kind: 'new' } | { kind: 'remote'; startPoint: string } + +export function isBranchNameValid(name: string): boolean { + return ( + name.length > 0 && + name !== '@' && + !name.startsWith('-') && + !name.startsWith('/') && + !name.endsWith('/') && + !name.endsWith('.') && + !/[\s~^:?*[\\]/.test(name) && + !Array.from(name).some( + (character) => character.charCodeAt(0) < 32 || character.charCodeAt(0) === 127 + ) && + !name.includes('..') && + !name.includes('@{') && + !name.split('/').some((part) => !part || part.startsWith('.') || part.endsWith('.lock')) + ) +} diff --git a/src/shared/rpc-contract/git-params.ts b/src/shared/rpc-contract/git-params.ts index 1880cef41c1..542dad33c55 100644 --- a/src/shared/rpc-contract/git-params.ts +++ b/src/shared/rpc-contract/git-params.ts @@ -249,6 +249,10 @@ export const GitRebaseFromBase = WorktreeSelector.extend({ ) }) +export const GitSwitchBranch = WorktreeSelector.extend({ + target: z.object({ kind: z.enum(['local', 'remote', 'new']), name: z.string().min(1) }) +}) + export const GitCheckout = WorktreeSelector.extend({ branch: z .unknown() diff --git a/src/shared/rpc-contract/repo-params.ts b/src/shared/rpc-contract/repo-params.ts index 09043409cf1..bb87e3dd8ac 100644 --- a/src/shared/rpc-contract/repo-params.ts +++ b/src/shared/rpc-contract/repo-params.ts @@ -44,6 +44,7 @@ export const RepoHooksCheck = RepoSelector.extend({ }) export const RepoSearchRefs = z.object({ + namespace: z.literal('remote').optional(), repo: requiredString('Missing repo selector'), query: z .unknown() diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts index ac1a25bb497..d578eeac3eb 100644 --- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts +++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts @@ -292,6 +292,7 @@ import { GitResolveConflict, GitStatusParams, GitSubmoduleStatus, + GitSwitchBranch, GitTargetedRemote, WorktreeSelector as WorktreeSelectorOfGitParams } from './git-params' @@ -1108,6 +1109,7 @@ export const RPC_PARAMS_BY_METHOD = { 'git.stage': GitFilePath, 'git.status': GitStatusParams, 'git.submoduleStatus': GitSubmoduleStatus, + 'git.switchBranch': GitSwitchBranch, 'git.unstage': GitFilePath, 'git.upstreamStatus': GitTargetedRemote, 'github.addIssueComment': IssueComment, diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts index d4e32766128..a8699e2d38b 100644 --- a/src/shared/runtime-types.ts +++ b/src/shared/runtime-types.ts @@ -190,6 +190,7 @@ export type { } from './runtime-terminal-contracts' export type { RuntimeGitCheckoutResult, + RuntimeGitSwitchResult, RuntimeGitLocalBranches, RuntimeRepoList, RuntimeRepoSearchRefs, diff --git a/src/shared/runtime-worktree-contracts.ts b/src/shared/runtime-worktree-contracts.ts index a59c78b6f95..047902d56da 100644 --- a/src/shared/runtime-worktree-contracts.ts +++ b/src/shared/runtime-worktree-contracts.ts @@ -148,6 +148,8 @@ export type RuntimeWorktreePsSummary = { export type RuntimeGitLocalBranches = { current: string | null branches: string[] + committedAt?: Record + worktreePaths?: Record } export type RuntimeSpeechModelSummary = { @@ -167,6 +169,8 @@ export type RuntimeSpeechSetupState = { models: RuntimeSpeechModelSummary[] } +export type RuntimeGitSwitchResult = { ok: true; branch: string; warning?: string } + export type RuntimeGitCheckoutResult = { ok: true branch: string @@ -214,6 +218,7 @@ export type RuntimeWorktreePsResult = { export type RuntimeRepoList = { repos: Repo[] } export type RuntimeRepoSearchRefs = { + namespace?: 'remote' refs: string[] refDetails?: BaseRefSearchResult[] truncated: boolean