- {visibleBranches.map((b, index) => (
-
{
- optionRefs.current[index] = el;
- }}
- id={`${listboxId}-option-${index}`}
- role="option"
- aria-selected={b === branchLabel}
- onMouseMove={() => setActiveBranchIndex(index)}
- onClick={() => pickBranch(b)}
- className={cn(
- 'cursor-pointer rounded-[8px] px-3 py-1.5 text-13 text-foreground',
- index === clampedActiveIndex
- ? 'bg-accent text-accent-foreground'
- : b === branchLabel && 'bg-accent/60',
- )}
- >
- {b}
-
- ))}
+ {['remote', 'local'].map((group) => {
+ const values = visibleBranches.filter(
+ (b) => (b === remoteDefaultBranch) === (group === 'remote'),
+ );
+ if (!values.length) return null;
+ return (
+
+ {(checked || branchOnly) && remoteDefaultBranch && (
+
+ {t(`newChat.branchSync.${group}Group`)}
+
+ )}
+ {values.map((b) => {
+ const index = visibleBranches.indexOf(b);
+ return (
+
{
+ optionRefs.current[index] = el;
+ }}
+ id={`${listboxId}-option-${index}`}
+ role="option"
+ aria-selected={b === branchLabel}
+ onMouseMove={() => setActiveBranchIndex(index)}
+ onClick={() => pickBranch(b)}
+ className={cn(
+ 'cursor-pointer rounded-[8px] px-3 py-1.5 text-13 text-foreground',
+ index === clampedActiveIndex
+ ? 'bg-accent text-accent-foreground'
+ : b === branchLabel && 'bg-accent/60',
+ )}
+ >
+ {displayBranch(b)}
+ {b === branchLabel && sync.details && (
+
{sync.details}
+ )}
+
+ );
+ })}
+
+ );
+ })}
{/* 常驻播报区域,让过滤为空时的文本更新可被读屏感知;列表关联始终保留。 */}
{t('orca.workerProject.label')}
@@ -272,6 +279,12 @@ export function WorkerProjectField({
branchOnly
compact
branchLabel={branch}
+ baseRepo={base}
+ remoteDefaultBranch={
+ branchSnapshot?.base === base
+ ? branchSnapshot.remoteDefaultBranch
+ : undefined
+ }
branchDisplayLabel={sourceBranchLabel}
branches={branchSnapshot?.base === base ? branchSnapshot.values : []}
branchesLoading={branchLoading}
diff --git a/apps/desktop/src/renderer/features/cc-agent/__tests__/CreateWorkerPopover.test.tsx b/apps/desktop/src/renderer/features/cc-agent/__tests__/CreateWorkerPopover.test.tsx
index 5022c0f658e..232fb0285d4 100644
--- a/apps/desktop/src/renderer/features/cc-agent/__tests__/CreateWorkerPopover.test.tsx
+++ b/apps/desktop/src/renderer/features/cc-agent/__tests__/CreateWorkerPopover.test.tsx
@@ -6,6 +6,7 @@ import { setDataOwnerGeneration } from '@/contexts/dataOwnerGeneration';
import { act, cleanup, fireEvent, render, renderHook, screen, waitFor } from '@testing-library/react';
import { useState } from 'react';
+import { Tooltip } from '@/components/ui/tooltip';
import userEvent from '@testing-library/user-event';
import { renderToString } from 'react-dom/server';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -2219,7 +2220,7 @@ describe('local Worker project selection', () => {
beforeEach(() => {
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', { configurable: true, value: vi.fn() });
projectContext.mockReset().mockResolvedValue(context);
- (window as unknown as {electronAPI: unknown}).electronAPI = {localDb: {orcaWorkflows: {projectContext}}};
+ (window as unknown as {electronAPI: unknown}).electronAPI = {localDb: {orcaWorkflows: {projectContext}}, worktreeListBranches: vi.fn().mockResolvedValue({branches: ['main']})};
});
afterEach(() => {cleanup(); Reflect.deleteProperty(HTMLElement.prototype, 'scrollIntoView'); delete (window as unknown as {electronAPI?: unknown}).electronAPI;});
// Native picker cancellation does not select a project. Rejected IPC must also leave
@@ -2272,7 +2273,7 @@ describe('local Worker project selection', () => {
const missing = {...project, directory: '/gone', baseRepo: null, branch: null, exists: false};
projectContext.mockResolvedValue({...context, selected: missing});
const onCreate = vi.fn();
- render(
);
+ render(
);
await screen.findByText('orca.workerProject.missingDirectory');
expect(screen.queryByText('orca.workerProject.notGit /gone')).toBeNull();
expect(screen.queryByRole('checkbox')).toBeNull();
@@ -2285,7 +2286,7 @@ describe('local Worker project selection', () => {
const empty = {...project, branch: null, headState: 'unborn'};
projectContext.mockResolvedValue({...context, inherited: empty, selected: empty});
const onCreate = vi.fn();
- render(
);
+ render(
);
await screen.findByText('orca.workerProject.emptyRepo /repo');
expect(screen.queryByRole('checkbox')).toBeNull();
fireEvent.click(screen.getByRole('button', {name: 'Create'}));
@@ -2297,7 +2298,7 @@ describe('local Worker project selection', () => {
projectContext.mockResolvedValue({...context, selected: detached});
window.electronAPI.worktreeListBranches = vi.fn().mockResolvedValue({branches: ['main']});
const onCreate = vi.fn();
- render(
);
+ render(
);
await screen.findByText('orca.workerProject.directDetached other orca.workerProject.detachedHead abcdef01');
fireEvent.click(screen.getByRole('checkbox'));
const branchButton = screen.getByRole('button', {name: 'newChat.branchChip.label'});
@@ -2311,6 +2312,24 @@ describe('local Worker project selection', () => {
expect(onCreate).toHaveBeenLastCalledWith(expect.objectContaining({worktree: {sourceBranch: 'main'}}));
expect(branchButton.textContent).toBe('main');
});
+ it('displays the remote source as origin/main but submits its full ref without fetching', async () => {
+ window.electronAPI.worktreeListBranches = vi.fn().mockResolvedValue({branches: ['main'], remoteDefaultBranch: 'refs/remotes/origin/main'});
+ window.electronAPI.worktreeBranchSyncStatus = vi.fn().mockResolvedValue({branch: 'main', remote: 'origin', remoteBranch: 'main', remoteOnly: false, ahead: 0, behind: 0, dirtyFiles: 0});
+ window.electronAPI.worktreeSyncBranch = vi.fn();
+ const onCreate = vi.fn();
+ render(
);
+ await screen.findByRole('checkbox');
+ fireEvent.click(screen.getByRole('checkbox'));
+ const branchButton = screen.getByRole('button', {name: 'newChat.branchChip.label'});
+ expect(window.electronAPI.worktreeListBranches).not.toHaveBeenCalled();
+ fireEvent.click(branchButton);
+ fireEvent.click(await screen.findByRole('option', {name: 'origin/main'}));
+ expect(branchButton.textContent).toBe('origin/main');
+ expect(screen.getByText('orca.workerProject.splitWarning origin/main')).toBeTruthy();
+ fireEvent.click(screen.getByRole('button', {name: 'Create'}));
+ expect(onCreate).toHaveBeenLastCalledWith({worktree: {sourceBranch: 'refs/remotes/origin/main'}});
+ expect(window.electronAPI.worktreeSyncBranch).not.toHaveBeenCalled();
+ });
it('omits workingDir for unchanged inheritance and resets worktree choice on reopening', async () => {
const {result, rerender} = renderHook(({open}) => useWorkerProjectSelection(open, 'lead', true), {initialProps: {open: true}});
await waitFor(() => expect(result.current.ready).toBe(true));
@@ -2374,7 +2393,7 @@ describe('local Worker project selection', () => {
? {...project, directory: dir, branch: 'selected-branch'} : opening);
window.electronAPI.showOpenDirectoryDialog = vi.fn().mockResolvedValue({canceled: false, path: directory});
const onCreate = vi.fn();
- render(
);
+ render(
);
fireEvent.click(await screen.findByRole('button', {name: 'orca.workerProject.current'}));
fireEvent.click(screen.getByRole('button', {name: 'newChat.folderPicker.browseProjectFolder'}));
await waitFor(() => expect(projectContext).toHaveBeenLastCalledWith('lead', directory));
diff --git a/apps/desktop/src/renderer/hooks/useBranchSync.ts b/apps/desktop/src/renderer/hooks/useBranchSync.ts
new file mode 100644
index 00000000000..c57583c5e94
--- /dev/null
+++ b/apps/desktop/src/renderer/hooks/useBranchSync.ts
@@ -0,0 +1,204 @@
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import {
+ BRANCH_SYNC_ERROR_PREFIX,
+ type BranchSyncStatus,
+ type BranchSyncResult,
+} from '../../shared/worktreeBranchSync';
+import { extractIpcError } from '@/utils/ipcError';
+import { toast } from '@/lib/toast';
+
+/** One state source for the capsule action and the selected branch's status row. */
+export function useBranchSync(
+ baseRepo: string | undefined,
+ branch: string,
+ deviceId?: string | null,
+) {
+ const { t } = useTranslation();
+ const key = JSON.stringify([baseRepo, branch, deviceId]);
+ const currentKey = useRef(key);
+ currentKey.current = key;
+ const [snapshot, setSnapshot] = useState<{
+ key: string;
+ status?: BranchSyncStatus;
+ busy?: boolean;
+ result?: BranchSyncResult;
+ error?: string;
+ missingRemote?: boolean;
+ unsupported?: boolean;
+ }>({ key });
+ const request = useRef(0);
+ const busy = useRef(false);
+ const hoverTimer = useRef
| undefined>(undefined);
+ const doneTimer = useRef | undefined>(undefined);
+ const invoke = useCallback(
+ (sync: boolean) => {
+ const req = { baseRepo: baseRepo!, branch };
+ if (deviceId)
+ return window.electronAPI.deviceLink.invoke(
+ deviceId,
+ sync ? 'worktree:sync-branch' : 'worktree:branch-sync-status',
+ [req],
+ );
+ return sync
+ ? window.electronAPI.worktreeSyncBranch(req)
+ : window.electronAPI.worktreeBranchSyncStatus(req);
+ },
+ [baseRepo, branch, deviceId],
+ );
+ const refresh = useCallback(async () => {
+ if (!baseRepo || busy.current) return;
+ const id = ++request.current;
+ try {
+ const status = (await invoke(false)) as BranchSyncStatus;
+ if (currentKey.current === key && id === request.current)
+ setSnapshot((old) => ({
+ ...old,
+ key,
+ status:
+ old.key === key && old.missingRemote
+ ? { ...status, blocked: 'missing-remote' }
+ : status,
+ }));
+ } catch (error) {
+ if (
+ currentKey.current === key &&
+ id === request.current &&
+ deviceId &&
+ extractIpcError(error)?.code === 'DEVICE_LINK_CHANNEL_NOT_ALLOWED'
+ ) {
+ setSnapshot({ key, unsupported: true });
+ return;
+ }
+ if (currentKey.current === key && id === request.current)
+ setSnapshot((old) =>
+ old.key === key && old.missingRemote
+ ? old
+ : {
+ key,
+ error:
+ extractIpcError(error)?.message ??
+ (error instanceof Error ? error.message : String(error)),
+ },
+ );
+ }
+ }, [baseRepo, invoke, key, deviceId]);
+ useEffect(() => {
+ busy.current = false;
+ setSnapshot({ key });
+ void refresh();
+ return () => {
+ ++request.current;
+ clearTimeout(hoverTimer.current);
+ clearTimeout(doneTimer.current);
+ };
+ }, [key, refresh]);
+ const blockedHint = (status: BranchSyncStatus) =>
+ t(`newChat.branchSync.${status.blocked}`, {
+ branch,
+ remote: status.remote ?? 'origin',
+ remoteBranch: status.remoteBranch ?? branch,
+ });
+ const sync = async () => {
+ if (!baseRepo || busy.current) return;
+ busy.current = true;
+ const id = ++request.current;
+ setSnapshot((old) => ({ ...old, key, busy: true, error: undefined, result: undefined }));
+ try {
+ const result = (await invoke(true)) as BranchSyncResult;
+ if (currentKey.current !== key || id !== request.current) return;
+ if (result.status.blocked) toast.warning(blockedHint(result.status));
+ setSnapshot({
+ key,
+ status: result.status,
+ result,
+ missingRemote: result.status.blocked === 'missing-remote',
+ });
+ clearTimeout(doneTimer.current);
+ doneTimer.current = setTimeout(() => {
+ if (currentKey.current === key) setSnapshot((old) => ({ ...old, result: undefined }));
+ }, 2_000);
+ } catch (error) {
+ if (currentKey.current !== key || id !== request.current) return;
+ const message =
+ extractIpcError(error)?.message ?? (error instanceof Error ? error.message : String(error));
+ setSnapshot((old) => ({ ...old, busy: false, error: message }));
+ toast.error(
+ t('newChat.branchSync.failed', {
+ reason: message.startsWith(BRANCH_SYNC_ERROR_PREFIX)
+ ? t(`newChat.branchSync.errors.${message.slice(BRANCH_SYNC_ERROR_PREFIX.length)}`)
+ : message,
+ }),
+ );
+ } finally {
+ if (currentKey.current === key && id === request.current) busy.current = false;
+ }
+ };
+ const state = snapshot.key === key ? snapshot : { key };
+ const status = state.status;
+ const vars = {
+ branch,
+ remote: status?.remote ?? 'origin',
+ remoteBranch: status?.remoteBranch ?? branch,
+ };
+ const errorText = state.error?.startsWith(BRANCH_SYNC_ERROR_PREFIX)
+ ? t(`newChat.branchSync.errors.${state.error.slice(BRANCH_SYNC_ERROR_PREFIX.length)}`)
+ : state.error;
+ let hint = t('newChat.branchSync.action', vars);
+ if (status?.remoteOnly) hint = t('newChat.branchSync.remoteAction', vars);
+ if (status?.checkoutPath) hint += `\n${t('newChat.branchSync.checkedOut')}`;
+ if (status?.blocked) hint = blockedHint(status);
+ if (state.result && !status?.blocked)
+ hint = state.result.updated
+ ? t('newChat.branchSync.updated', { count: state.result.updated })
+ : status?.ahead
+ ? t('newChat.branchSync.keptAhead', { count: status.ahead })
+ : t('newChat.branchSync.current');
+ if (state.error) hint = t('newChat.branchSync.failed', { reason: errorText });
+ if (state.busy) hint = t('newChat.branchSync.fetching', vars);
+ const ahead = status?.ahead ?? 0;
+ const behind = status?.behind ?? 0;
+ const diverging = ahead > 0 && behind > 0;
+ const details = [
+ diverging
+ ? t('newChat.branchSync.diverging', {
+ remote: `${vars.remote}/${vars.remoteBranch}`,
+ ahead,
+ behind,
+ count: ahead,
+ })
+ : status?.ahead
+ ? t('newChat.branchSync.ahead', {
+ ...vars,
+ remote: `${vars.remote}/${vars.remoteBranch}`,
+ count: status.ahead,
+ })
+ : '',
+ !diverging && status?.behind
+ ? t('newChat.branchSync.behind', {
+ ...vars,
+ remote: `${vars.remote}/${vars.remoteBranch}`,
+ count: status.behind,
+ })
+ : '',
+ status?.checkoutPath && status.dirtyFiles
+ ? t('newChat.branchSync.dirty', { count: status.dirtyFiles })
+ : '',
+ ]
+ .filter(Boolean)
+ .join(' · ');
+ return {
+ visible: !!baseRepo && !state.unsupported && status?.blocked !== 'not-local',
+ disabled: !!state.busy || !!status?.blocked || (!status && !state.error),
+ busy: !!state.busy,
+ done: !!state.result && !status?.blocked,
+ hint,
+ details,
+ sync,
+ refresh,
+ onHover: () => {
+ clearTimeout(hoverTimer.current);
+ hoverTimer.current = setTimeout(() => void refresh(), 250);
+ },
+ };
+}
diff --git a/apps/desktop/src/renderer/hooks/useWorktreeQueries.ts b/apps/desktop/src/renderer/hooks/useWorktreeQueries.ts
index f30904829f7..a95215fff09 100644
--- a/apps/desktop/src/renderer/hooks/useWorktreeQueries.ts
+++ b/apps/desktop/src/renderer/hooks/useWorktreeQueries.ts
@@ -144,6 +144,7 @@ export function useDetectCwd(
}
interface BranchesState {
+ remoteDefaultBranch?: string;
branches: string[];
current: string | null;
loading: boolean;
@@ -176,8 +177,8 @@ export function branchesStateForTarget(
) {
return { branches: [], current: null, loading: true, failed: false };
}
- const { branches, current, loading, failed } = snapshot;
- return { branches, current, loading, failed };
+ const { branches, current, loading, failed, remoteDefaultBranch } = snapshot;
+ return { branches, current, loading, failed, remoteDefaultBranch };
}
/**
@@ -232,6 +233,7 @@ export function useBranches(
if (cancelled) return;
setSnapshot({
target: requestTarget,
+ remoteDefaultBranch: res.remoteDefaultBranch,
branches: res.branches ?? [],
current: res.current ?? null,
loading: false,
diff --git a/apps/desktop/src/renderer/i18n/locales/en/common.json b/apps/desktop/src/renderer/i18n/locales/en/common.json
index eb03ab5c979..40e9771f6c1 100644
--- a/apps/desktop/src/renderer/i18n/locales/en/common.json
+++ b/apps/desktop/src/renderer/i18n/locales/en/common.json
@@ -11365,6 +11365,44 @@
"prompt": "Draft a reply to this email, taking the conversation and my thoughts into account. Don't send it yet."
}
}
+ },
+ "branchSync": {
+ "multiple-checkouts": "{{branch}} is open in multiple directories. Cannot update",
+ "changed": "The branch state changed. Please retry.",
+ "action": "Pull the latest commits from {{remote}}/{{remoteBranch}}",
+ "remoteAction": "Pull the latest commits from {{remote}}/{{remoteBranch}}",
+ "checkedOut": "Also updates committed files on this branch; uncommitted changes are kept",
+ "fetching": "Pulling…",
+ "updated_one": "Pulled {{count}} new commit",
+ "updated_other": "Pulled {{count}} new commits",
+ "current": "Already up to date",
+ "keptAhead_one": "{{count}} unpushed local commit. No update needed",
+ "keptAhead_other": "{{count}} unpushed local commits. No update needed",
+ "failed": "Pull failed: {{reason}}",
+ "diverged": "Local and {{remote}} have diverged. Cannot update automatically",
+ "conflict": "Uncommitted changes conflict with remote updates. Commit or stash first",
+ "missing-remote": "{{remoteBranch}} does not exist on {{remote}}",
+ "no-remote": "No remote repository configured",
+ "not-local": "Select a local branch or the remote default branch",
+ "ahead_one": "{{count}} commit ahead of {{remote}}",
+ "ahead_other": "{{count}} commits ahead of {{remote}}",
+ "behind_one": "{{count}} commit behind {{remote}}",
+ "behind_other": "{{count}} commits behind {{remote}}",
+ "dirty_one": "{{count}} uncommitted file",
+ "dirty_other": "{{count}} uncommitted files",
+ "diverging_one": "{{ahead}} ahead, {{behind}} behind {{remote}}",
+ "diverging_other": "{{ahead}} ahead, {{behind}} behind {{remote}}",
+ "remoteGroup": "Remote",
+ "localGroup": "Local branches",
+ "errors": {
+ "git": "Git could not update the branch. Check repository access and retry.",
+ "timeout": "Git timed out. Check the connection and retry.",
+ "auth": "Git authentication failed. Check remote credentials.",
+ "network": "Could not connect to the remote repository. Check the connection.",
+ "missing": "The remote branch no longer exists.",
+ "busy": "Repository is busy. Try again later",
+ "changed": "The checked-out branch changed. Retry."
+ }
}
},
"onboarding": {
diff --git a/apps/desktop/src/renderer/i18n/locales/ja/common.json b/apps/desktop/src/renderer/i18n/locales/ja/common.json
index a53ff7f5199..0531a9d281d 100644
--- a/apps/desktop/src/renderer/i18n/locales/ja/common.json
+++ b/apps/desktop/src/renderer/i18n/locales/ja/common.json
@@ -11330,6 +11330,38 @@
"prompt": "このメールへの返信の下書きを作って。これまでのやり取りと私の考えを踏まえて、まだ送信しないで。"
}
}
+ },
+ "branchSync": {
+ "multiple-checkouts": "{{branch}} が複数のディレクトリで開かれているため、更新できません",
+ "changed": "ブランチの状態が変わりました。再試行してください",
+ "action": "{{remote}}/{{remoteBranch}} の最新コミットを取得",
+ "remoteAction": "{{remote}}/{{remoteBranch}} の最新コミットを取得",
+ "checkedOut": "現在のブランチのコミット済みファイルも更新します。未コミットの変更は保持されます",
+ "fetching": "取得中…",
+ "updated_other": "{{count}} 件のコミットを取り込みました",
+ "current": "すでに最新です",
+ "keptAhead_other": "未プッシュのコミットが {{count}} 件あります。更新は不要です",
+ "failed": "取得失敗:{{reason}}",
+ "diverged": "ローカルと {{remote}} の両方に新しいコミットがあり、自動更新できません",
+ "conflict": "未コミットの変更がリモートの更新と競合しています。先にコミットまたは stash してください",
+ "missing-remote": "{{remote}} に {{remoteBranch}} がありません",
+ "no-remote": "リモートリポジトリが未設定です",
+ "not-local": "ローカルブランチまたはリモートの既定ブランチを選択してください",
+ "ahead_other": "{{remote}} より {{count}} コミット先行",
+ "behind_other": "{{remote}} より {{count}} コミット遅れ",
+ "dirty_other": "未コミットのファイル {{count}} 件",
+ "diverging_other": "{{ahead}} コミット先行、{{behind}} コミット遅れ({{remote}})",
+ "remoteGroup": "リモート",
+ "localGroup": "ローカルブランチ",
+ "errors": {
+ "git": "Git でブランチを更新できません。リポジトリへのアクセスを確認して再試行してください。",
+ "timeout": "Git がタイムアウトしました。接続を確認して再試行してください。",
+ "auth": "Git の認証に失敗しました。リモートの認証情報を確認してください。",
+ "network": "リモートリポジトリに接続できません。接続を確認してください。",
+ "missing": "リモートブランチは存在しません。",
+ "busy": "リポジトリは使用中です。後で再試行してください",
+ "changed": "チェックアウト中のブランチが変更されました。再試行してください。"
+ }
}
},
"onboarding": {
diff --git a/apps/desktop/src/renderer/i18n/locales/ko/common.json b/apps/desktop/src/renderer/i18n/locales/ko/common.json
index b926f5632e9..6e06b44b5dd 100644
--- a/apps/desktop/src/renderer/i18n/locales/ko/common.json
+++ b/apps/desktop/src/renderer/i18n/locales/ko/common.json
@@ -11330,6 +11330,38 @@
"prompt": "이 메일의 답장 초안을 써 줘. 주고받은 메일과 내 생각을 반영하고, 아직 보내지는 마."
}
}
+ },
+ "branchSync": {
+ "multiple-checkouts": "{{branch}}이(가) 여러 디렉터리에 열려 있어 업데이트할 수 없습니다",
+ "changed": "브랜치 상태가 변경되었습니다. 다시 시도해 주세요",
+ "action": "{{remote}}/{{remoteBranch}}의 최신 커밋 가져오기",
+ "remoteAction": "{{remote}}/{{remoteBranch}}의 최신 커밋 가져오기",
+ "checkedOut": "현재 브랜치의 커밋된 파일도 업데이트됩니다. 커밋하지 않은 변경 사항은 유지됩니다",
+ "fetching": "가져오는 중…",
+ "updated_other": "커밋 {{count}}개 업데이트됨",
+ "current": "이미 최신 상태입니다",
+ "keptAhead_other": "푸시하지 않은 로컬 커밋 {{count}}개. 업데이트가 필요 없습니다",
+ "failed": "가져오기 실패: {{reason}}",
+ "diverged": "로컬과 {{remote}} 양쪽에 새 커밋이 있어 자동 업데이트할 수 없습니다",
+ "conflict": "커밋하지 않은 변경 사항이 원격 업데이트와 충돌합니다. 먼저 커밋하거나 stash 하세요",
+ "missing-remote": "{{remote}}에 {{remoteBranch}}이(가) 없습니다",
+ "no-remote": "원격 저장소가 설정되지 않았습니다",
+ "not-local": "로컬 브랜치 또는 원격 기본 브랜치를 선택하세요",
+ "ahead_other": "{{remote}}보다 커밋 {{count}}개 앞섬",
+ "behind_other": "{{remote}}보다 커밋 {{count}}개 뒤처짐",
+ "dirty_other": "커밋하지 않은 파일 {{count}}개",
+ "diverging_other": "{{ahead}}개 커밋 앞섬, {{behind}}개 커밋 뒤처짐({{remote}})",
+ "remoteGroup": "원격",
+ "localGroup": "로컬 브랜치",
+ "errors": {
+ "git": "Git이 브랜치를 업데이트하지 못했습니다. 저장소 접근 권한을 확인하고 다시 시도하세요.",
+ "timeout": "Git 작업 시간이 초과되었습니다. 연결을 확인하고 다시 시도하세요.",
+ "auth": "Git 인증에 실패했습니다. 원격 인증 정보를 확인하세요.",
+ "network": "원격 저장소에 연결할 수 없습니다. 연결을 확인하세요.",
+ "missing": "원격 브랜치가 더 이상 존재하지 않습니다.",
+ "busy": "저장소가 사용 중입니다. 잠시 후 다시 시도하세요",
+ "changed": "체크아웃된 브랜치가 변경되었습니다. 다시 시도하세요."
+ }
}
},
"onboarding": {
diff --git a/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json b/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json
index 215d56cdcb8..6d02d356354 100644
--- a/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json
+++ b/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json
@@ -11330,6 +11330,38 @@
"prompt": "帮我为这封邮件写一份回复草稿,结合邮件往来和我的想法,先不发送。"
}
}
+ },
+ "branchSync": {
+ "multiple-checkouts": "{{branch}} 在多个目录中打开,无法更新",
+ "changed": "分支状态已变化,请重试",
+ "action": "拉取 {{remote}}/{{remoteBranch}} 的最新提交",
+ "remoteAction": "拉取 {{remote}}/{{remoteBranch}} 的最新提交",
+ "checkedOut": "会同步更新当前分支已提交的文件,未提交的改动会保留",
+ "fetching": "正在拉取…",
+ "updated_other": "已更新 {{count}} 个提交",
+ "current": "已是最新",
+ "keptAhead_other": "本地有 {{count}} 个未推送的提交,无需更新",
+ "failed": "拉取失败:{{reason}}",
+ "diverged": "本地与 {{remote}} 各有新提交,无法自动更新",
+ "conflict": "未提交的改动与远程更新冲突,请先提交或 stash",
+ "missing-remote": "{{remote}} 上没有 {{remoteBranch}}",
+ "no-remote": "未设置远程仓库",
+ "not-local": "请先选择本地分支或远程主分支",
+ "ahead_other": "领先 {{remote}} {{count}} 个提交",
+ "behind_other": "落后 {{remote}} {{count}} 个提交",
+ "dirty_other": "未提交 {{count}} 个文件",
+ "diverging_other": "领先 {{ahead}}、落后 {{behind}} 个提交({{remote}})",
+ "remoteGroup": "远程",
+ "localGroup": "本地分支",
+ "errors": {
+ "git": "Git 无法更新分支,请检查仓库访问权限后重试",
+ "timeout": "Git 操作超时,请检查网络连接后重试",
+ "auth": "Git 认证失败,请检查远程仓库凭证",
+ "network": "无法连接远程仓库,请检查网络连接",
+ "missing": "远程分支已不存在",
+ "busy": "仓库正忙,请稍后重试",
+ "changed": "检出的分支已变化,请重试"
+ }
}
},
"onboarding": {
diff --git a/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json b/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json
index 0e823f9a279..091b4d8a5f5 100644
--- a/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json
+++ b/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json
@@ -11330,6 +11330,38 @@
"prompt": "幫我為這封郵件寫一份回覆草稿,結合郵件往來和我的想法,先不寄送。"
}
}
+ },
+ "branchSync": {
+ "multiple-checkouts": "{{branch}} 在多個目錄中開啟,無法更新",
+ "changed": "分支狀態已變更,請重試",
+ "action": "取得 {{remote}}/{{remoteBranch}} 的最新提交",
+ "remoteAction": "取得 {{remote}}/{{remoteBranch}} 的最新提交",
+ "checkedOut": "會同步更新目前分支已提交的檔案,未提交的變更會保留",
+ "fetching": "正在取得更新…",
+ "updated_other": "已更新 {{count}} 個提交",
+ "current": "已是最新",
+ "keptAhead_other": "本機有 {{count}} 個尚未推送的提交,無需更新",
+ "failed": "取得更新失敗:{{reason}}",
+ "diverged": "本機與 {{remote}} 各有新提交,無法自動更新",
+ "conflict": "尚未提交的變更與遠端更新衝突,請先提交或 stash",
+ "missing-remote": "{{remote}} 上沒有 {{remoteBranch}}",
+ "no-remote": "未設定遠端儲存庫",
+ "not-local": "請先選擇本機分支或遠端主分支",
+ "ahead_other": "領先 {{remote}} {{count}} 個提交",
+ "behind_other": "落後 {{remote}} {{count}} 個提交",
+ "dirty_other": "尚未提交 {{count}} 個檔案",
+ "diverging_other": "領先 {{ahead}}、落後 {{behind}} 個提交({{remote}})",
+ "remoteGroup": "遠端",
+ "localGroup": "本機分支",
+ "errors": {
+ "git": "Git 無法更新分支,請檢查儲存庫存取權限後重試",
+ "timeout": "Git 操作逾時,請檢查網路連線後重試",
+ "auth": "Git 認證失敗,請檢查遠端儲存庫憑證",
+ "network": "無法連線至遠端儲存庫,請檢查網路連線",
+ "missing": "遠端分支已不存在",
+ "busy": "儲存庫忙碌中,請稍後重試",
+ "changed": "檢出的分支已變更,請重試"
+ }
}
},
"onboarding": {
diff --git a/apps/desktop/src/renderer/lib/worktree.types.ts b/apps/desktop/src/renderer/lib/worktree.types.ts
index 3e7dc1b65bc..311318224f0 100644
--- a/apps/desktop/src/renderer/lib/worktree.types.ts
+++ b/apps/desktop/src/renderer/lib/worktree.types.ts
@@ -69,6 +69,8 @@ export interface DetectCwdResp {
}
export interface ListBranchesResp {
+ /** Full ref, never a local branch named origin/main. */
+ remoteDefaultBranch?: string;
branches: string[];
/** 当前 HEAD 所在分支名(用于默认值)。 */
current: string;
diff --git a/apps/desktop/src/renderer/vite-env.d.ts b/apps/desktop/src/renderer/vite-env.d.ts
index fe0f3298e83..58176516ef6 100644
--- a/apps/desktop/src/renderer/vite-env.d.ts
+++ b/apps/desktop/src/renderer/vite-env.d.ts
@@ -4409,6 +4409,8 @@ interface ElectronAPI {
worktreeSuggestName: (req: {
baseRepo: string;
}) => Promise;
+ worktreeBranchSyncStatus: (req: import('../shared/worktreeBranchSync').BranchSyncRequest) => Promise;
+ worktreeSyncBranch: (req: import('../shared/worktreeBranchSync').BranchSyncRequest) => Promise;
worktreeListBranches: (req: {
baseRepo: string;
}) => Promise;
diff --git a/apps/desktop/src/shared/worktreeBranchSync.ts b/apps/desktop/src/shared/worktreeBranchSync.ts
new file mode 100644
index 00000000000..df067dce337
--- /dev/null
+++ b/apps/desktop/src/shared/worktreeBranchSync.ts
@@ -0,0 +1,32 @@
+/** Append-only desktop/device-link contract; creation never waits for branch sync. */
+export interface BranchSyncRequest {
+ baseRepo: string;
+ branch: string;
+}
+export interface BranchSyncStatus {
+ branch: string;
+ remote?: string;
+ remoteBranch?: string;
+ remoteRef?: string;
+ remoteOnly: boolean;
+ ahead: number;
+ behind: number;
+ dirtyFiles: number;
+ checkoutPath?: string;
+ blocked?:
+ | 'no-remote'
+ | 'missing-remote'
+ | 'diverged'
+ | 'conflict'
+ | 'not-local'
+ | 'multiple-checkouts'
+ | 'changed';
+}
+export interface BranchSyncResult {
+ status: BranchSyncStatus;
+ updated: number;
+}
+
+export const BRANCH_SYNC_ERROR_PREFIX = 'branch-sync:';
+export type BranchSyncFailureReason =
+ 'git' | 'timeout' | 'auth' | 'network' | 'missing' | 'busy' | 'changed';
diff --git a/docs/dev-rules/protocol-compatibility.md b/docs/dev-rules/protocol-compatibility.md
index b705b4b59ce..71c91e7abf1 100644
--- a/docs/dev-rules/protocol-compatibility.md
+++ b/docs/dev-rules/protocol-compatibility.md
@@ -1,5 +1,22 @@
# 跨端协议兼容
+## 手动对齐 worktree 源分支
+
+`worktree:list-branches` 的 IPC 响应可选追加 `remoteDefaultBranch`
+(完整的 `refs/remotes/origin/*` 引用);仅 UI 查询补充,失败不影响本地分支列表,
+创建使用的 `listBranches()` 保持原样。列表仍按原有约定懒加载。
+本地支持刷新;控制端查询 `worktree:branch-sync-status`,旧被控端返回
+`DEVICE_LINK_CHANNEL_NOT_ALLOWED` 时隐藏刷新按钮,缺少可选字段时不显示远程分组,
+本地分支选择与创建保持原行为。新被控端继续接受旧请求。
+状态查询只读本地 Git;缺本地 tracking ref 不代表远程分支不存在,仍允许拉取。
+仅拉取确认远程分支不存在后,在本次胶囊选择内置灰;换分支或重新挂载即重新判断。
+同步仅由用户点击触发,按配置的 tracking remote/ref(否则 origin 同名分支)拉取一个显式
+refspec,再按需快进本地分支。
+设备互联对单次同步预留 90 秒(网络 Git 30 秒及清理、本地探测),失败不重放写请求、
+不重置 peer 或 relay;创建和发送不等待同步,不改用其它源引用。手机本轮不提供入口。
+实现见 `apps/desktop/src/main/worktree/branchSync.ts`;真实 Git 回归在
+`branchSync.git-integration.test.ts`。
+
## IM 状态表情由客户端声明
官方 Slack / Telegram 的 `hello` 新增可选 `statusReactions`:`queued`、`error` 为
diff --git a/packages/device-link/src/__tests__/allowlist.test.ts b/packages/device-link/src/__tests__/allowlist.test.ts
index 75e30e4faf9..60178c070ef 100644
--- a/packages/device-link/src/__tests__/allowlist.test.ts
+++ b/packages/device-link/src/__tests__/allowlist.test.ts
@@ -281,6 +281,8 @@ describe('REMOTE_INVOKE_ALLOWLIST', () => {
for (const ch of [
'worktree:detect-cwd',
'worktree:list-branches',
+ 'worktree:branch-sync-status',
+ 'worktree:sync-branch',
'worktree:suggest-name',
'worktree:create',
'worktree:discard-precreated',
diff --git a/packages/device-link/src/allowlist.ts b/packages/device-link/src/allowlist.ts
index 015c2b2f449..0d07d0e22d4 100644
--- a/packages/device-link/src/allowlist.ts
+++ b/packages/device-link/src/allowlist.ts
@@ -678,6 +678,10 @@ const EXTENDED_INVOKE_CHANNELS: readonly string[] = [
// 老被控端无这些 channel → CHANNEL_NOT_ALLOWED → 控制端按对应能力降级。
'worktree:detect-cwd',
'worktree:list-branches',
+ // Sync status is local-only; sync mutates only by explicit fetch + fast-forward.
+ // sync-branch baseRepo also passes dispatch PATH_GUARDED_CHANNELS.
+ 'worktree:branch-sync-status',
+ 'worktree:sync-branch',
'worktree:suggest-name',
'worktree:create',
'worktree:discard-precreated',
@@ -863,6 +867,8 @@ export const INVOKE_TIMEOUT_OVERRIDES_MS: Readonly> = {
// 被控端 worktree:create 含 git worktree add(--no-checkout)+ 白名单文件选择性
// checkout + .claude/.sivi 拷贝;大仓库 / 慢盘上可能超默认 30s,给足执行预算 + 回程余量。
'worktree:create': 60_000,
+ // User-triggered fetch (30s) plus local probes and process cleanup. Never replay.
+ 'worktree:sync-branch': 90_000,
// 可能先等待同 sessionId 的晚到 create 释放互斥锁,再执行 git worktree remove。
'worktree:discard-precreated': 60_000,
'worktree:cancel-precreated': 60_000,