From c07afaf078b53552a722f35acb4efd96b978ba48 Mon Sep 17 00:00:00 2001 From: fmfsaisai Date: Sat, 24 Oct 2026 18:39:11 +0800 Subject: [PATCH 01/11] =?UTF-8?q?feat(worktree):=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E6=89=8B=E5=8A=A8=E5=AF=B9=E9=BD=90=E8=BF=9C=E7=A8=8B=E5=88=86?= =?UTF-8?q?=E6=94=AF=E4=B8=8E=E9=80=89=E6=8B=A9=E8=BF=9C=E7=A8=8B=E4=B8=BB?= =?UTF-8?q?=E5=88=86=E6=94=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 主会话和 Worker 共用分支刷新按钮、状态提示与远程分组;仅执行显式拉取和快进。保留创建路径及分支列表懒加载,通过状态 IPC 识别旧被控端,并补充真实 Git 和界面回归测试。 Signed-off-by: fmfsaisai --- .../branch-sync/branch-sync-fixture.tsx | 59 +++++ .../scripts/fixtures/branch-sync/ui-page.mjs | 70 ++++++ .../worker-project-fixture.tsx | 3 + .../__tests__/deviceLinkHostDispatch.test.ts | 12 +- .../src/main/__tests__/worktreeReveal.test.ts | 28 ++- apps/desktop/src/main/device-link/dispatch.ts | 1 + .../branchSync.git-integration.test.ts | 216 +++++++++++++++++ .../__tests__/branchSyncHandlers.test.ts | 59 +++++ apps/desktop/src/main/worktree/branchSync.ts | 222 ++++++++++++++++++ .../src/main/worktree/branchSyncHandlers.ts | 67 ++++++ apps/desktop/src/main/worktree/index.ts | 17 +- apps/desktop/src/main/worktree/types.ts | 2 + apps/desktop/src/preload/preload.ts | 2 + .../components/new-chat/WorktreeChipsRow.tsx | 179 ++++++++++---- .../__tests__/BranchWorktreeChip.test.tsx | 214 +++++++++++++++++ .../features/cc-agent/WorkerProjectField.tsx | 17 +- .../__tests__/CreateWorkerPopover.test.tsx | 29 ++- .../src/renderer/hooks/useBranchSync.ts | 188 +++++++++++++++ .../src/renderer/hooks/useWorktreeQueries.ts | 6 +- .../src/renderer/i18n/locales/en/common.json | 29 +++ .../src/renderer/i18n/locales/ja/common.json | 29 +++ .../src/renderer/i18n/locales/ko/common.json | 29 +++ .../renderer/i18n/locales/zh-CN/common.json | 29 +++ .../renderer/i18n/locales/zh-TW/common.json | 29 +++ .../src/renderer/lib/worktree.types.ts | 2 + apps/desktop/src/renderer/vite-env.d.ts | 2 + apps/desktop/src/shared/worktreeBranchSync.ts | 25 ++ docs/dev-rules/protocol-compatibility.md | 17 ++ .../src/__tests__/allowlist.test.ts | 2 + packages/device-link/src/allowlist.ts | 6 + 30 files changed, 1528 insertions(+), 62 deletions(-) create mode 100644 apps/desktop/scripts/fixtures/branch-sync/branch-sync-fixture.tsx create mode 100644 apps/desktop/scripts/fixtures/branch-sync/ui-page.mjs create mode 100644 apps/desktop/src/main/worktree/__tests__/branchSync.git-integration.test.ts create mode 100644 apps/desktop/src/main/worktree/__tests__/branchSyncHandlers.test.ts create mode 100644 apps/desktop/src/main/worktree/branchSync.ts create mode 100644 apps/desktop/src/main/worktree/branchSyncHandlers.ts create mode 100644 apps/desktop/src/renderer/components/new-chat/__tests__/BranchWorktreeChip.test.tsx create mode 100644 apps/desktop/src/renderer/hooks/useBranchSync.ts create mode 100644 apps/desktop/src/shared/worktreeBranchSync.ts diff --git a/apps/desktop/scripts/fixtures/branch-sync/branch-sync-fixture.tsx b/apps/desktop/scripts/fixtures/branch-sync/branch-sync-fixture.tsx new file mode 100644 index 00000000000..65157cf2bdf --- /dev/null +++ b/apps/desktop/scripts/fixtures/branch-sync/branch-sync-fixture.tsx @@ -0,0 +1,59 @@ +import { useState } from 'react'; +import { BranchWorktreeChip } from '../../../src/renderer/components/new-chat/WorktreeChipsRow'; +import { mountPage, type UiPageContext } from '../../ui-page/harness'; +import type { BranchSyncRequest, BranchSyncStatus } from '../../../src/shared/worktreeBranchSync'; +const branches = ['main', 'feature/search', 'origin/main']; +function Fixture({ ctx }: { ctx: UiPageContext }) { + const [branch, setBranch] = useState('main'); + const [checked, setChecked] = useState(ctx.case !== 'unchecked'); + return ( +
+ {}} + checked={checked} + branchSourceSelected + branchInteractive + onPick={setBranch} + onOpenRequested={() => {}} + onToggle={setChecked} + compact={ctx.case === 'worker'} + branchOnly={ctx.case === 'worker'} + /> +
+ ); +} +mountPage({ + host: (ctx) => { + const read = (req: BranchSyncRequest): BranchSyncStatus => ({ + branch: req.branch, + remote: 'origin', + remoteBranch: 'main', + remoteOnly: req.branch.startsWith('refs/'), + ahead: ctx.case === 'diverged' ? 2 : 0, + behind: 3, + dirtyFiles: 3, + checkoutPath: '/projects/cindy', + ...(ctx.case === 'diverged' || ctx.case === 'conflict' ? { blocked: ctx.case } : {}), + }); + return { + deviceLink: { + invoke: async () => { + throw new Error('[DEVICE_LINK_CHANNEL_NOT_ALLOWED] Old host'); + }, + }, + worktreeBranchSyncStatus: async (req: BranchSyncRequest) => read(req), + worktreeSyncBranch: async (req: BranchSyncRequest) => ({ + status: { ...read(req), behind: 0 }, + updated: 3, + }), + }; + }, + render: (ctx) => , +}); diff --git a/apps/desktop/scripts/fixtures/branch-sync/ui-page.mjs b/apps/desktop/scripts/fixtures/branch-sync/ui-page.mjs new file mode 100644 index 00000000000..29bfe529800 --- /dev/null +++ b/apps/desktop/scripts/fixtures/branch-sync/ui-page.mjs @@ -0,0 +1,70 @@ +import assert from 'node:assert/strict'; +export default { + name: 'branch-sync', + entry: './branch-sync-fixture.tsx', + viewport: { width: 560, height: 400 }, + real: 'BranchWorktreeChip, sync hook, grouped combobox, tooltip, themes and locale strings', + notCovered: + 'Git processes, device-link transport and native task creation; covered by unit and Git integration tests', + cases: { + default: { + about: 'Main-task capsule, selected-branch details, remote group and successful fetch', + }, + worker: { about: 'Compact Worker branch-only capsule', viewport: { width: 360, height: 400 } }, + unchecked: { about: 'Unchecked main-task worktree excludes remote sources' }, + conflict: { about: 'Disabled sync remains focusable and explains overlapping changes' }, + diverged: { about: 'Diverged branches keep local source and explain why sync is unavailable' }, + 'old-host': { about: 'Old controlled host has neither refresh button nor remote group' }, + }, + async check(page, ui) { + const trigger = page.getByRole('button', { + name: await ui.t('newChat.branchChip.label'), + exact: true, + }); + await trigger.waitFor(); + await ui.capture('capsule'); + if (ui.case === 'conflict' || ui.case === 'diverged') { + const action = page.getByRole('button', { + name: await ui.t(`newChat.branchSync.${ui.case}`, { + branch: 'main', + remote: 'origin', + remoteBranch: 'main', + }), + exact: true, + }); + assert.equal(await action.isDisabled(), true); + await action.locator('..').hover(); + await page.getByRole('tooltip').waitFor(); + await ui.capture('disabled-reason'); + } + await trigger.click(); + await page.getByRole('combobox').waitFor(); + const remote = page.getByRole('group', { + name: await ui.t('newChat.branchSync.remoteGroup'), + exact: true, + }); + assert.equal(await remote.count(), ['unchecked', 'old-host'].includes(ui.case) ? 0 : 1); + await ui.capture('branches'); + if (ui.case === 'default' || ui.case === 'worker') { + await page.getByRole('combobox').fill('origin/main'); + assert.equal(await page.getByRole('option').count(), 2); + await page.keyboard.press('Enter'); + assert.match(await trigger.textContent(), /origin\/main/); + const action = page.getByRole('button', { + name: await ui.t('newChat.branchSync.remoteAction', { + remote: 'origin', + remoteBranch: 'main', + }), + exact: false, + }); + await action.click(); + await page + .getByRole('button', { + name: await ui.t('newChat.branchSync.updated', { count: 3 }), + exact: true, + }) + .waitFor(); + await ui.capture('updated'); + } + }, +}; diff --git a/apps/desktop/scripts/fixtures/orca-worker-project/worker-project-fixture.tsx b/apps/desktop/scripts/fixtures/orca-worker-project/worker-project-fixture.tsx index 6d7c3a899f6..bd6a7ed347b 100644 --- a/apps/desktop/scripts/fixtures/orca-worker-project/worker-project-fixture.tsx +++ b/apps/desktop/scripts/fixtures/orca-worker-project/worker-project-fixture.tsx @@ -191,7 +191,10 @@ void mountPage({ }, }, }, + worktreeBranchSyncStatus: async ({ branch }: { branch: string }) => ({ branch, remote: 'origin', remoteBranch: branch, remoteOnly: false, ahead: 0, behind: 3, dirtyFiles: 2, checkoutPath: inherited.directory }), + worktreeSyncBranch: async ({ branch }: { branch: string }) => ({ updated: 3, status: { branch, remote: 'origin', remoteBranch: branch, remoteOnly: false, ahead: 0, behind: 0, dirtyFiles: 2 } }), worktreeListBranches: async () => ({ + remoteDefaultBranch: 'refs/remotes/origin/main', branches: ['main', 'feat/worker-project-picker', 'release/v2'], }), }), diff --git a/apps/desktop/src/main/__tests__/deviceLinkHostDispatch.test.ts b/apps/desktop/src/main/__tests__/deviceLinkHostDispatch.test.ts index 9721143eed3..5aa9b295668 100644 --- a/apps/desktop/src/main/__tests__/deviceLinkHostDispatch.test.ts +++ b/apps/desktop/src/main/__tests__/deviceLinkHostDispatch.test.ts @@ -176,20 +176,20 @@ describe('device-link host dispatch (runInvoke) — real gate + async fs guard + // 远程 worktree:worktree:create 的 baseRepo 与 create-session 的 workingDir 同口径收敛 // (决定在被控端哪个仓库下跑 git worktree add)。 - it('worktree:create 的 baseRepo 是真实存在目录 → 放行到 handler', async () => { - const handler = registerHandler('worktree:create', () => ({ ok: true })); + it.each(['worktree:create', 'worktree:sync-branch'])('%s 的 baseRepo 是真实存在目录 → 放行到 handler', async (channel) => { + const handler = registerHandler(channel, () => ({ ok: true })); const res = (await runInvoke(SRC, { - channel: 'worktree:create', + channel, args: [{ sessionId: 's-wt', baseRepo: TMP_DIR, name: 'auto-x1', sourceBranch: 'main' }], })) as Extract; expect(res.ok).toBe(true); expect(handler).toHaveBeenCalledTimes(1); }); - it('worktree:create 的 baseRepo 不存在 → 结构化拒绝,不落到 handler', async () => { - const handler = registerHandler('worktree:create', () => ({ ok: true })); + it.each(['worktree:create', 'worktree:sync-branch'])('%s 的 baseRepo 不存在 → 结构化拒绝,不落到 handler', async (channel) => { + const handler = registerHandler(channel, () => ({ ok: true })); const res = (await runInvoke(SRC, { - channel: 'worktree:create', + channel, args: [{ sessionId: 's-wt', baseRepo: '/definitely/not/a/real/repo/xyz123', name: 'auto-x1', sourceBranch: 'main' }], })) as Extract; expect(res.ok).toBe(false); diff --git a/apps/desktop/src/main/__tests__/worktreeReveal.test.ts b/apps/desktop/src/main/__tests__/worktreeReveal.test.ts index bae3131cbdc..3c029eea6eb 100644 --- a/apps/desktop/src/main/__tests__/worktreeReveal.test.ts +++ b/apps/desktop/src/main/__tests__/worktreeReveal.test.ts @@ -14,6 +14,13 @@ import { extractIpcError } from '../../renderer/utils/ipcError'; const showItemInFolderMock = vi.fn(); const suggestNameMock = vi.fn(); const detectCwdMock = vi.fn(); +const listBranchesMock = vi.fn(); +const readOriginDefaultBranchMock = vi.fn(); +vi.mock('../worktree/branchSync', () => ({ + readOriginDefaultBranch: (...args: unknown[]) => readOriginDefaultBranchMock(...args), + readBranchSyncStatus: vi.fn(), + syncBranch: vi.fn(), +})); type Handler = (event: unknown, req: unknown) => unknown | Promise; const handlers = new Map(); const ipcMainMock = { @@ -46,7 +53,7 @@ vi.mock('../worktree/WorktreeManager', () => ({ getForSession: vi.fn(), listAll: vi.fn(), suggestName: (...args: unknown[]) => suggestNameMock(...args), - listBranches: vi.fn(), + listBranches: (...args: unknown[]) => listBranchesMock(...args), })); let registerWorktreeIpc: typeof import('../worktree/index').registerWorktreeIpc; @@ -55,6 +62,8 @@ beforeEach(async () => { showItemInFolderMock.mockReset(); suggestNameMock.mockReset(); detectCwdMock.mockReset(); + listBranchesMock.mockReset(); + readOriginDefaultBranchMock.mockReset(); getAllPathsMock.mockReset(); handlers.clear(); if (!registerWorktreeIpc) { @@ -163,3 +172,20 @@ describe('worktree:reveal whitelist (I-3 fix)', () => { expect(showItemInFolderMock).not.toHaveBeenCalled(); }); }); + +// F1 regression (unit): optional remote grouping must not make the existing branch +// list fail. Existing reveal/probe cases never exercise this enrichment; the +// minimal failure is an origin lookup timeout after a successful local list. +describe('worktree:list-branches optional remote group', () => { + it.each([false, true])('preserves local branches when remote lookup fails: %s', async (fails) => { + const baseRepo = path.resolve('/repo'); + const local = { branches: ['main'], current: 'main' }; + listBranchesMock.mockResolvedValue(local); + if (fails) readOriginDefaultBranchMock.mockRejectedValue(new Error('Git timed out')); + else readOriginDefaultBranchMock.mockResolvedValue('refs/remotes/origin/main'); + const result = await handlers.get('worktree:list-branches')!({}, { baseRepo }); + expect(result).toEqual({ ...local, remoteDefaultBranch: fails ? undefined : 'refs/remotes/origin/main' }); + expect(listBranchesMock).toHaveBeenCalledExactlyOnceWith(baseRepo); + expect(readOriginDefaultBranchMock).toHaveBeenCalledExactlyOnceWith(baseRepo); + }); +}); diff --git a/apps/desktop/src/main/device-link/dispatch.ts b/apps/desktop/src/main/device-link/dispatch.ts index 7baebfc7e12..c131fac60c0 100644 --- a/apps/desktop/src/main/device-link/dispatch.ts +++ b/apps/desktop/src/main/device-link/dispatch.ts @@ -356,6 +356,7 @@ const PATH_GUARDED_CHANNELS: ReadonlyMap = ne // 协同远端 Worker 指定本机目录时同口径收敛;不指定则由本机分配任务目录。 [ORCA_REMOTE_WORKER_OPEN_CHANNEL, 'workingDir'], ['worktree:create', 'baseRepo'], + ['worktree:sync-branch', 'baseRepo'], ]); type RemoteWorkingDirGuardValue = boolean | RemoteWorkingDirCheckResult; diff --git a/apps/desktop/src/main/worktree/__tests__/branchSync.git-integration.test.ts b/apps/desktop/src/main/worktree/__tests__/branchSync.git-integration.test.ts new file mode 100644 index 00000000000..db79ce42745 --- /dev/null +++ b/apps/desktop/src/main/worktree/__tests__/branchSync.git-integration.test.ts @@ -0,0 +1,216 @@ +/** Test value: real branchSync entry points must fetch one ref and only fast-forward. + * No previous suite covers this manual action. Minimal inputs: one bare remote, + * two diverging commits / an overlapping dirty file / a branch in another checkout. + * Tier: git-integration (real Git ref/index/worktree semantics, no network). + */ +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterAll, beforeAll, expect, it, vi } from 'vitest'; +import { readBranchSyncStatus, readOriginDefaultBranch, syncBranch } from '../branchSync'; +import * as gitModule from '../gitExec'; +vi.mock('../../logger', () => ({ + createLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }), +})); +let root: string; +let remote: string; +let seed: string; +let sequence = 0; +function git(cwd: string, ...args: string[]) { + return execFileSync( + 'git', + [ + '-c', + 'user.name=Cindy Test', + '-c', + 'user.email=test@example.invalid', + '-c', + 'commit.gpgsign=false', + ...args, + ], + { + cwd, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + env: { ...process.env, GIT_CONFIG_GLOBAL: os.devNull, GIT_CONFIG_NOSYSTEM: '1' }, + }, + ).trim(); +} +function commit(cwd: string, file: string, content: string) { + fs.writeFileSync(path.join(cwd, file), content); + git(cwd, 'add', '--', file); + git(cwd, 'commit', '-m', file); + return git(cwd, 'rev-parse', 'HEAD'); +} +beforeAll(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cindy-branch-sync-')); + seed = path.join(root, 'seed'); + remote = path.join(root, 'remote.git'); + fs.mkdirSync(seed); + git(seed, 'init', '-b', 'main'); + commit(seed, 'base', 'base'); + git(root, 'clone', '--bare', seed, remote); + git(seed, 'remote', 'add', 'origin', remote); +}); +afterAll(() => fs.rmSync(root, { recursive: true, force: true })); +function fixture() { + const branch = `case-${++sequence}`; + const repo = path.join(root, branch); + git(root, 'clone', remote, repo); + git(repo, 'checkout', '-b', branch); + git(repo, 'push', '-u', 'origin', branch); + git(seed, 'checkout', '-B', branch, 'main'); + return { + repo, + branch, + req: { baseRepo: repo, branch }, + advance: (file = 'base') => { + const sha = commit(seed, file, `remote-${branch}`); + git(seed, 'push', 'origin', branch); + return sha; + }, + }; +} +it('fetches and fast-forwards the checked-out branch while preserving unrelated dirty files', async () => { + const f = fixture(); + const sha = f.advance(); + fs.writeFileSync(path.join(f.repo, 'keep'), 'uncommitted'); + const result = await syncBranch(f.req); + expect(result.updated).toBe(1); + expect(git(f.repo, 'rev-parse', 'HEAD')).toBe(sha); + expect(fs.readFileSync(path.join(f.repo, 'base'), 'utf8')).toBe(`remote-${f.branch}`); + expect(fs.readFileSync(path.join(f.repo, 'keep'), 'utf8')).toBe('uncommitted'); + expect(result.status.dirtyFiles).toBe(1); +}); +it('updates an unchecked local ref without changing the current checkout', async () => { + const f = fixture(); + const sha = f.advance(); + git(f.repo, 'checkout', 'main'); + const before = git(f.repo, 'rev-parse', 'HEAD'); + expect((await syncBranch(f.req)).updated).toBe(1); + expect(git(f.repo, 'rev-parse', f.branch)).toBe(sha); + expect(git(f.repo, 'rev-parse', 'HEAD')).toBe(before); +}); +it('fast-forwards the directory where the selected branch is checked out, including linked worktrees', async () => { + const f = fixture(); + git(f.repo, 'checkout', 'main'); + const checkout = path.join(root, 'linked'); + git(f.repo, 'worktree', 'add', checkout, f.branch); + const sha = f.advance(); + const result = await syncBranch(f.req); + expect(result.status.checkoutPath).toBe(fs.realpathSync(checkout).replaceAll('\\', '/')); + expect(git(checkout, 'rev-parse', 'HEAD')).toBe(sha); + expect(fs.readFileSync(path.join(checkout, 'base'), 'utf8')).toBe(`remote-${f.branch}`); +}); +it('leaves divergent branches unchanged', async () => { + const f = fixture(); + const local = commit(f.repo, 'local', 'local'); + f.advance(); + const result = await syncBranch(f.req); + expect(result.status).toMatchObject({ blocked: 'diverged', ahead: 1, behind: 1 }); + expect(git(f.repo, 'rev-parse', 'HEAD')).toBe(local); +}); +it.each(['tracked', 'untracked'])( + 'blocks overlapping %s changes and keeps their bytes', + async (kind) => { + const f = fixture(); + const file = kind === 'tracked' ? 'base' : 'new-file'; + f.advance(file); + fs.writeFileSync(path.join(f.repo, file), 'user changes'); + const before = git(f.repo, 'rev-parse', 'HEAD'); + expect((await syncBranch(f.req)).status.blocked).toBe('conflict'); + expect(git(f.repo, 'rev-parse', 'HEAD')).toBe(before); + expect(fs.readFileSync(path.join(f.repo, file), 'utf8')).toBe('user changes'); + }, +); +it('retains unpushed local commits and reports already-current on the next fetch', async () => { + const f = fixture(); + const local = commit(f.repo, 'local', 'local'); + expect(await syncBranch(f.req)).toMatchObject({ updated: 0, status: { ahead: 1, behind: 0 } }); + expect(git(f.repo, 'rev-parse', 'HEAD')).toBe(local); + git(f.repo, 'push'); + expect(await syncBranch(f.req)).toMatchObject({ updated: 0, status: { ahead: 0, behind: 0 } }); +}); +it('fetches a remote-only source without touching a same-named local branch', async () => { + const f = fixture(); + const before = git(f.repo, 'rev-parse', 'HEAD'); + git(f.repo, 'branch', `origin/${f.branch}`); + const sha = f.advance(); + const result = await syncBranch({ baseRepo: f.repo, branch: `refs/remotes/origin/${f.branch}` }); + expect(result.updated).toBe(1); + expect(git(f.repo, 'rev-parse', `refs/remotes/origin/${f.branch}`)).toBe(sha); + expect(git(f.repo, 'rev-parse', `refs/heads/origin/${f.branch}`)).toBe(before); + expect(git(f.repo, 'rev-parse', 'HEAD')).toBe(before); +}); +it('honors a configured tracking remote and branch rather than origin/same-name', async () => { + const f = fixture(); + const sha = f.advance(); + git(f.repo, 'remote', 'rename', 'origin', 'upstream'); + git(f.repo, 'branch', '-m', 'renamed'); + const result = await syncBranch({ baseRepo: f.repo, branch: 'renamed' }); + expect(result.status).toMatchObject({ remote: 'upstream', remoteBranch: f.branch }); + expect(git(f.repo, 'rev-parse', 'HEAD')).toBe(sha); +}); +it('falls back to origin/same-name without upstream configuration; disables missing refs and remotes', async () => { + const f = fixture(); + git(f.repo, 'branch', '--unset-upstream'); + expect(await readBranchSyncStatus(f.req)).toMatchObject({ + remote: 'origin', + remoteBranch: f.branch, + }); + git(f.repo, 'update-ref', '-d', `refs/remotes/origin/${f.branch}`); + expect((await readBranchSyncStatus(f.req)).blocked).toBeUndefined(); + expect((await syncBranch(f.req)).status.blocked).toBeUndefined(); + git(f.repo, 'push', 'origin', '--delete', f.branch); + expect((await syncBranch(f.req)).status.blocked).toBe('missing-remote'); + git(f.repo, 'remote', 'remove', 'origin'); + fs.writeFileSync(path.join(f.repo, 'dirty-without-remote'), 'user changes'); + expect((await syncBranch(f.req)).status).toMatchObject({ blocked: 'no-remote', dirtyFiles: 1 }); +}); +it('uses a valid origin/HEAD, otherwise main then master, and never upstream', async () => { + const f = fixture(); + git(f.repo, 'symbolic-ref', 'refs/remotes/origin/HEAD', `refs/remotes/origin/${f.branch}`); + expect(await readOriginDefaultBranch(f.repo)).toBe(`refs/remotes/origin/${f.branch}`); + git(f.repo, 'symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/missing'); + expect(await readOriginDefaultBranch(f.repo)).toBe('refs/remotes/origin/main'); + git(f.repo, 'symbolic-ref', '--delete', 'refs/remotes/origin/HEAD'); + expect(await readOriginDefaultBranch(f.repo)).toBe('refs/remotes/origin/main'); + git(f.repo, 'update-ref', 'refs/remotes/origin/master', 'HEAD'); + git(f.repo, 'update-ref', '-d', 'refs/remotes/origin/main'); + expect(await readOriginDefaultBranch(f.repo)).toBe('refs/remotes/origin/master'); + git(f.repo, 'update-ref', '-d', 'refs/remotes/origin/master'); + git(f.repo, 'update-ref', 'refs/remotes/upstream/main', 'HEAD'); + expect(await readOriginDefaultBranch(f.repo)).toBeUndefined(); +}); + +it('keeps HEAD, staged content and dirty bytes when Git discovers an overlap after the preflight', async () => { + const f = fixture(); + f.advance(); + const before = git(f.repo, 'rev-parse', 'HEAD'); + fs.writeFileSync(path.join(f.repo, 'staged'), 'staged change'); + git(f.repo, 'add', 'staged'); + git(f.repo, 'config', 'merge.autostash', 'true'); + const index = git(f.repo, 'ls-files', '--stage'); + const realGit = gitModule.gitExec; + const spy = vi.spyOn(gitModule, 'gitExec').mockImplementation(async (args, cwd, opts) => { + if (args[0] === 'merge') fs.writeFileSync(path.join(f.repo, 'base'), 'arrived during fetch'); + return realGit(args, cwd, opts); + }); + try { + expect((await syncBranch(f.req)).status.blocked).toBe('conflict'); + expect(git(f.repo, 'rev-parse', 'HEAD')).toBe(before); + expect(git(f.repo, 'ls-files', '--stage')).toBe(index); + expect(fs.readFileSync(path.join(f.repo, 'base'), 'utf8')).toBe('arrived during fetch'); + expect(fs.readFileSync(path.join(f.repo, 'staged'), 'utf8')).toBe('staged change'); + expect(git(f.repo, 'stash', 'list')).toBe(''); + } finally { + spy.mockRestore(); + } +}); +it('disables a branch with no upstream when only a non-origin remote is configured', async () => { + const f = fixture(); + git(f.repo, 'branch', '--unset-upstream'); + git(f.repo, 'remote', 'rename', 'origin', 'upstream'); + expect((await readBranchSyncStatus(f.req)).blocked).toBe('no-remote'); +}); diff --git a/apps/desktop/src/main/worktree/__tests__/branchSyncHandlers.test.ts b/apps/desktop/src/main/worktree/__tests__/branchSyncHandlers.test.ts new file mode 100644 index 00000000000..db878986e76 --- /dev/null +++ b/apps/desktop/src/main/worktree/__tests__/branchSyncHandlers.test.ts @@ -0,0 +1,59 @@ +/** Test value: new privileged IPC endpoints must reject untrusted frames and invalid + * payloads before invoking Git, and never leak credential-bearing Git stderr. + * No existing handler covers this capability. Minimal inputs: untrusted event, + * option-shaped ref, credential URL in failure. Tier: unit, injected identity. + */ +import path from 'node:path'; +import { beforeEach, expect, it, vi } from 'vitest'; +import { createBranchSyncHandlers } from '../branchSyncHandlers'; +import { GitExecError } from '../gitExec'; +const calls = vi.hoisted(() => ({ status: vi.fn(), sync: vi.fn() })); +vi.mock('../branchSync', () => ({ readBranchSyncStatus: calls.status, syncBranch: calls.sync })); +const assertSender = vi.fn(); +const remote = vi.fn(); +const handlers = createBranchSyncHandlers({ + assertTrustedAppRendererEvent: assertSender, + isDeviceLinkInvoke: remote, +}); +const req = { baseRepo: path.resolve('/repo'), branch: 'main' }; +beforeEach(() => { + vi.resetAllMocks(); + remote.mockReturnValue(false); +}); +it('rejects an untrusted frame before reading or mutating any repository', () => { + assertSender.mockImplementation(() => { + throw new Error('[PERMISSION_DENIED] Untrusted frame'); + }); + for (const handler of [handlers.status, handlers.sync]) + expect(() => handler({}, req)).toThrow('PERMISSION_DENIED'); + expect(calls.status).not.toHaveBeenCalled(); + expect(calls.sync).not.toHaveBeenCalled(); +}); +it.each([ + { baseRepo: 'relative', branch: 'main' }, + { ...req, branch: '--help' }, + { ...req, branch: 'main:other' }, + null, +])('rejects malformed payloads without side effects: %j', (body) => { + expect(() => handlers.sync({}, body)).toThrow('INVALID_PARAMS'); + expect(calls.sync).not.toHaveBeenCalled(); +}); +it('accepts the authenticated device-link context without treating its synthetic event as a local frame', async () => { + remote.mockReturnValue(true); + calls.sync.mockResolvedValue({ updated: 2 }); + expect(await handlers.sync({}, req)).toEqual({ updated: 2 }); + expect(assertSender).not.toHaveBeenCalled(); + expect(calls.sync).toHaveBeenCalledWith(req); +}); +it('returns a readable reason without leaking Git credentials or output', async () => { + calls.sync.mockRejectedValue( + new GitExecError({ + args: ['fetch'], + exitCode: 128, + stdout: '', + stderr: 'Authentication failed for https://name:secret@example.com/repo', + }), + ); + await expect(handlers.sync({}, req)).rejects.toThrow('branch-sync:auth'); + await expect(handlers.sync({}, req)).rejects.not.toThrow('secret'); +}); diff --git a/apps/desktop/src/main/worktree/branchSync.ts b/apps/desktop/src/main/worktree/branchSync.ts new file mode 100644 index 00000000000..50cbc576896 --- /dev/null +++ b/apps/desktop/src/main/worktree/branchSync.ts @@ -0,0 +1,222 @@ +import path from 'node:path'; +import { gitExec, GitExecError } from './gitExec'; +import { boundedNetworkGitOpts } from './freshBase'; +import type { + BranchSyncRequest, + BranchSyncResult, + BranchSyncStatus, +} from '../../shared/worktreeBranchSync'; + +const localOpts = { timeoutMs: 5_000, extraEnv: { LC_ALL: 'C' } }; +async function git(cwd: string, ...args: string[]): Promise { + return (await gitExec(args, cwd, localOpts)).stdout.trim(); +} +async function optional(cwd: string, ...args: string[]): Promise { + try { + return await git(cwd, ...args); + } catch (error) { + if (error instanceof GitExecError && !error.timedOut && error.exitCode !== null) + return undefined; + throw error; + } +} + +/** Local refs only: a dangling origin/HEAD must not hide the main/master fallback. */ +export async function readOriginDefaultBranch(cwd: string): Promise { + const symbolic = await optional(cwd, 'symbolic-ref', 'refs/remotes/origin/HEAD'); + for (const ref of [symbolic, 'refs/remotes/origin/main', 'refs/remotes/origin/master']) { + if ( + ref?.startsWith('refs/remotes/origin/') && + (await optional(cwd, 'rev-parse', '--verify', `${ref}^{commit}`)) + ) + return ref; + } + return undefined; +} + +function dirtyPaths(porcelain: string): { paths: string[]; files: number } { + const fields = porcelain.split('\0'); + const paths: string[] = []; + let files = 0; + for (let i = 0; i < fields.length; i++) { + const row = fields[i]; + if (!row) continue; + files++; + paths.push(row.slice(3)); + if (/[RC]/.test(row.slice(0, 2)) && fields[i + 1]) paths.push(fields[++i]); + } + return { paths, files }; +} +function overlaps(a: string, b: string): boolean { + return a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`); +} + +/** Validate refs even for direct main callers; never pass an option-shaped ref to Git. */ +async function validate(req: BranchSyncRequest): Promise { + if ( + !req || + typeof req.baseRepo !== 'string' || + !path.isAbsolute(req.baseRepo) || + typeof req.branch !== 'string' || + req.branch.length > 1024 || + req.branch.startsWith('-') + ) { + throw new Error('Invalid branch sync request'); + } + await git( + req.baseRepo, + 'check-ref-format', + req.branch.startsWith('refs/remotes/origin/') ? req.branch : `refs/heads/${req.branch}`, + ); +} + +export async function readBranchSyncStatus(req: BranchSyncRequest): Promise { + await validate(req); + const { baseRepo, branch } = req; + const remoteOnly = branch.startsWith('refs/remotes/origin/'); + const status: BranchSyncStatus = { branch, remoteOnly, ahead: 0, behind: 0, dirtyFiles: 0 }; + const remotes = (await git(baseRepo, 'remote')).split(/\r?\n/).filter(Boolean); + let remote = 'origin'; + let remoteBranch = remoteOnly ? branch.slice('refs/remotes/origin/'.length) : branch; + const localRef = `refs/heads/${branch}`; + if (!remoteOnly) { + if (!(await optional(baseRepo, 'rev-parse', '--verify', `${localRef}^{commit}`))) + return { ...status, blocked: 'not-local' }; + const configuredRemote = await optional(baseRepo, 'config', '--get', `branch.${branch}.remote`); + const configuredRef = await optional(baseRepo, 'config', '--get', `branch.${branch}.merge`); + if (configuredRemote && configuredRef?.startsWith('refs/heads/')) { + remote = configuredRemote; + remoteBranch = configuredRef.slice('refs/heads/'.length); + } + // -z preserves checkout paths containing newlines and non-ASCII characters. + const worktrees = ( + await gitExec(['worktree', 'list', '--porcelain', '-z'], baseRepo, localOpts) + ).stdout; + for (const block of worktrees.split('\0\0')) { + const fields = block.split('\0'); + if (fields.includes(`branch ${localRef}`)) { + status.checkoutPath = fields.find((s) => s.startsWith('worktree '))?.slice(9); + break; + } + } + } + const dirty = status.checkoutPath + ? dirtyPaths( + ( + await gitExec( + ['status', '--porcelain', '-z', '--untracked-files=all'], + status.checkoutPath, + localOpts, + ) + ).stdout, + ) + : { paths: [], files: 0 }; + status.dirtyFiles = dirty.files; + if (!remotes.includes(remote)) return { ...status, blocked: 'no-remote' }; + Object.assign(status, { + remote, + remoteBranch, + remoteRef: `refs/remotes/${remote}/${remoteBranch}`, + }); + // A missing local tracking ref is unknown, not evidence the remote branch is absent. + if (!(await optional(baseRepo, 'rev-parse', '--verify', `${status.remoteRef}^{commit}`))) + return status; + if (remoteOnly) return status; + const counts = ( + await git(baseRepo, 'rev-list', '--left-right', '--count', `${localRef}...${status.remoteRef}`) + ) + .split(/\s+/) + .map(Number); + [status.ahead, status.behind] = counts; + if (status.ahead && status.behind) return { ...status, blocked: 'diverged' }; + if (status.behind && dirty.paths.length) { + const changes = ( + await gitExec(['diff', '--name-only', '-z', localRef, status.remoteRef!], baseRepo, localOpts) + ).stdout + .split('\0') + .filter(Boolean); + if (dirty.paths.some((p) => changes.some((other) => overlaps(p, other)))) + return { ...status, blocked: 'conflict' }; + } + return status; +} + +// Serialize this UI's mutations per repository; no coupling to task creation or sends. +const syncing = new Map>(); +export async function syncBranch(req: BranchSyncRequest): Promise { + await validate(req); + const key = await git(req.baseRepo, 'rev-parse', '--path-format=absolute', '--git-common-dir'); + if (syncing.has(key)) throw new Error('A branch update is already running'); + const operation = syncBranchOnce(req); + syncing.set(key, operation); + try { + return await operation; + } finally { + syncing.delete(key); + } +} +async function syncBranchOnce(req: BranchSyncRequest): Promise { + let status = await readBranchSyncStatus(req); + if (status.blocked) return { status, updated: 0 }; + const { remote, remoteBranch, remoteRef } = status; + const oldRemote = await optional(req.baseRepo, 'rev-parse', '--verify', `${remoteRef}^{commit}`); + try { + await gitExec( + [ + 'fetch', + '--no-tags', + '--recurse-submodules=no', + '--', + remote!, + `+refs/heads/${remoteBranch}:${remoteRef}`, + ], + req.baseRepo, + { + ...boundedNetworkGitOpts(30_000), + extraEnv: { ...boundedNetworkGitOpts(30_000).extraEnv, LC_ALL: 'C' }, + }, + ); + } catch (error) { + if ( + error instanceof GitExecError && + /couldn't find remote ref|could not find remote ref/i.test(error.stderr) + ) { + return { status: { ...status, blocked: 'missing-remote' }, updated: 0 }; + } + throw error; + } + status = await readBranchSyncStatus(req); + if (status.blocked) return { status, updated: 0 }; + if (status.remoteOnly) { + const updated = Number( + await git( + req.baseRepo, + 'rev-list', + '--count', + oldRemote ? `${oldRemote}..${remoteRef}` : remoteRef!, + ), + ); + return { status, updated }; + } + if (!status.behind) return { status, updated: 0 }; + const old = await git(req.baseRepo, 'rev-parse', '--verify', `refs/heads/${req.branch}^{commit}`); + const next = await git(req.baseRepo, 'rev-parse', '--verify', `${remoteRef}^{commit}`); + await git(req.baseRepo, 'merge-base', '--is-ancestor', old, next); + const updated = status.behind; + if (status.checkoutPath) { + if ((await git(status.checkoutPath, 'symbolic-ref', 'HEAD')) !== `refs/heads/${req.branch}`) + throw new Error('The checked-out branch changed; retry'); + try { + await git(status.checkoutPath, 'merge', '--ff-only', '--no-autostash', next); + } catch (error) { + const refreshed = await readBranchSyncStatus(req); + if (refreshed.blocked) return { status: refreshed, updated: 0 }; + if (error instanceof GitExecError && /would be overwritten|local changes/i.test(error.stderr)) + return { status: { ...refreshed, blocked: 'conflict' }, updated: 0 }; + throw error; + } + } else { + await git(req.baseRepo, 'update-ref', `refs/heads/${req.branch}`, next, old); + } + return { status: await readBranchSyncStatus(req), updated }; +} diff --git a/apps/desktop/src/main/worktree/branchSyncHandlers.ts b/apps/desktop/src/main/worktree/branchSyncHandlers.ts new file mode 100644 index 00000000000..517dea179aa --- /dev/null +++ b/apps/desktop/src/main/worktree/branchSyncHandlers.ts @@ -0,0 +1,67 @@ +import path from 'node:path'; +import { readBranchSyncStatus, syncBranch } from './branchSync'; +import { GitExecError } from './gitExec'; +import { requireObject, requireString, throwIpcError } from '../utils/ipcValidate'; +import { + BRANCH_SYNC_ERROR_PREFIX, + type BranchSyncRequest, + type BranchSyncFailureReason, +} from '../../shared/worktreeBranchSync'; + +/** Small IPC boundary, testable without loading Electron or the WorktreeManager. */ +export function createBranchSyncHandlers(deps: { + assertTrustedAppRendererEvent(event: Event): void; + isDeviceLinkInvoke(): boolean; +}) { + function request(event: Event, input: unknown): BranchSyncRequest { + // Remote invokes already passed the same-account device-link allowlist. + if (!deps.isDeviceLinkInvoke()) deps.assertTrustedAppRendererEvent(event); + const body = requireObject(input); + const baseRepo = requireString(body.baseRepo, 'baseRepo'); + const branch = requireString(body.branch, 'branch'); + if ( + !path.isAbsolute(baseRepo) || + baseRepo.length > 32768 || + baseRepo.includes('\0') || + branch.length > 1024 || + /[\x00-\x20~^:?*\\\[]/.test(branch) || + branch.startsWith('-') || + branch.includes('..') + ) { + throwIpcError('INVALID_PARAMS', 'Invalid repository or branch'); + } + return { baseRepo, branch }; + } + async function run(operation: () => Promise): Promise { + try { + return await operation(); + } catch (error) { + // Do not return raw Git output: remote URLs may contain credentials. + let reason: BranchSyncFailureReason = 'git'; + if (error instanceof GitExecError) { + if (error.timedOut) reason = 'timeout'; + else if (/auth|credential|permission denied|could not read Username/i.test(error.stderr)) + reason = 'auth'; + else if (/could not resolve|unable to access|connect|network/i.test(error.stderr)) + reason = 'network'; + else if (/couldn.t find remote ref/i.test(error.stderr)) reason = 'missing'; + else if (/lock|expected/i.test(error.stderr)) reason = 'busy'; + } + if (error instanceof Error && error.message === 'A branch update is already running') + reason = 'busy'; + if (error instanceof Error && error.message === 'The checked-out branch changed; retry') + reason = 'changed'; + throwIpcError('INTERNAL', BRANCH_SYNC_ERROR_PREFIX + reason); + } + } + return { + status: (event: Event, input: unknown) => { + const req = request(event, input); + return run(() => readBranchSyncStatus(req)); + }, + sync: (event: Event, input: unknown) => { + const req = request(event, input); + return run(() => syncBranch(req)); + }, + }; +} diff --git a/apps/desktop/src/main/worktree/index.ts b/apps/desktop/src/main/worktree/index.ts index 73ea840a370..a8b3e0c8edd 100644 --- a/apps/desktop/src/main/worktree/index.ts +++ b/apps/desktop/src/main/worktree/index.ts @@ -7,6 +7,10 @@ */ import path from 'node:path'; +import { readOriginDefaultBranch } from './branchSync'; +import { createBranchSyncHandlers } from './branchSyncHandlers'; +import { assertTrustedAppRendererEvent } from '../security/trustedAppRenderer'; +import { isDeviceLinkInvoke } from '../device-link/invoke-context'; import { ipcMain as ipcMainType, type IpcMain, shell } from 'electron'; import * as WorktreeManager from './WorktreeManager'; @@ -49,6 +53,10 @@ export * from './types'; * 调用方保证只调一次。 */ export function registerWorktreeIpc(ipcMain: IpcMain = ipcMainType): void { + const branchSync = createBranchSyncHandlers({ assertTrustedAppRendererEvent, isDeviceLinkInvoke }); + ipcMain.handle('worktree:branch-sync-status', branchSync.status); + ipcMain.handle('worktree:sync-branch', branchSync.sync); + ipcMain.handle('worktree:create', (_e, req: CreateWorktreeReq) => WorktreeManager.createWorktree(req), ); @@ -110,9 +118,12 @@ export function registerWorktreeIpc(ipcMain: IpcMain = ipcMainType): void { name: await WorktreeManager.suggestName(req.baseRepo), })); - ipcMain.handle('worktree:list-branches', (_e, req: ListBranchesReq) => - WorktreeManager.listBranches(req.baseRepo), - ); + ipcMain.handle('worktree:list-branches', async (_e, req: ListBranchesReq) => { + const branches = await WorktreeManager.listBranches(req.baseRepo); + // Optional UI enrichment must never affect creation or the local branch list. + const remoteDefaultBranch = await readOriginDefaultBranch(req.baseRepo).catch(() => undefined); + return { ...branches, remoteDefaultBranch }; + }); // ── P1: 删除确认预检 + 回收后恢复 ────────────────────────────────────────── // removal-preview 是只读查询,允许 device-link 控制端在被控端执行;restore 仍是 diff --git a/apps/desktop/src/main/worktree/types.ts b/apps/desktop/src/main/worktree/types.ts index d9bcd0d7894..a1b73df0e75 100644 --- a/apps/desktop/src/main/worktree/types.ts +++ b/apps/desktop/src/main/worktree/types.ts @@ -99,6 +99,8 @@ export interface ListBranchesReq { } export interface ListBranchesResp { + /** Full ref, never a local branch named origin/main. */ + remoteDefaultBranch?: string; branches: string[]; current: string; } diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index ace0a54e984..c6f7212af24 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -5250,6 +5250,8 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.invoke('worktree:reveal', req), worktreeSuggestName: (req: { baseRepo: string }): Promise => ipcRenderer.invoke('worktree:suggest-name', req), + worktreeBranchSyncStatus: (req: import('../shared/worktreeBranchSync').BranchSyncRequest): Promise => ipcRenderer.invoke('worktree:branch-sync-status', req), + worktreeSyncBranch: (req: import('../shared/worktreeBranchSync').BranchSyncRequest): Promise => ipcRenderer.invoke('worktree:sync-branch', req), worktreeListBranches: (req: { baseRepo: string }): Promise => ipcRenderer.invoke('worktree:list-branches', req), // P1: 删除/归档确认预检(有无 worktree、是否有未提交更改) diff --git a/apps/desktop/src/renderer/components/new-chat/WorktreeChipsRow.tsx b/apps/desktop/src/renderer/components/new-chat/WorktreeChipsRow.tsx index 5be10ddf4f6..3bc4fc3f223 100644 --- a/apps/desktop/src/renderer/components/new-chat/WorktreeChipsRow.tsx +++ b/apps/desktop/src/renderer/components/new-chat/WorktreeChipsRow.tsx @@ -36,7 +36,15 @@ import { useId, type KeyboardEvent, } from 'react'; -import { GitBranch, ChevronDown, Folder, MessageCircle, Search } from 'lucide-react'; +import { + GitBranch, + ChevronDown, + Folder, + MessageCircle, + Search, + RefreshCw, + Check, +} from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { cn } from '@/lib/utils'; @@ -48,6 +56,7 @@ import { type FolderPickerOption, type FolderPickerSelectSource, } from './FolderPickerPopover'; +import { useBranchSync } from '@/hooks/useBranchSync'; import { resolveBranchPick } from './branchPick'; import { useBranches, useDetectCwd, useSuggestName } from '@/hooks/useWorktreeQueries'; import { getProjectPickerDisplayName } from '@/hooks/useProjectPickerOptions'; @@ -248,9 +257,7 @@ export function WorktreeChipsRow({ // 确认不合格(2026-08-07 裁决)→ 整条控件隐藏,勾选记忆保留、发送侧放行普通会话; // 探测中/失败(confirmedIneligible === null)时已 ON 仍显示,由上层 fail closed。 const showBranchChip = - !advancedHidden - && confirmedIneligible !== true - && (enabled || !!detect.data?.isGitRepo); + !advancedHidden && confirmedIneligible !== true && (enabled || !!detect.data?.isGitRepo); // 分支选择与 checkbox 是两条独立轴;仅环境不具备 worktree 资格或创建在途时禁用。 const branchInteractive = !(disabled || branchDisabled || environmentDisabled) && baseRepo !== null; @@ -268,6 +275,9 @@ export function WorktreeChipsRow({ const branchWorktree = showBranchChip ? ( + (checked || branchOnly) && remoteDefaultBranch + ? [remoteDefaultBranch, ...branches] + : branches, + [checked, branchOnly, remoteDefaultBranch, branches], + ); + const displayBranch = (value: string) => + value === remoteDefaultBranch ? value.replace(/^refs\/remotes\//, '') : value; + // 分支菜单内搜索词(2026-08-20 用户裁决:对齐 Codex 分支选择器,菜单顶部加搜索框)。 // 菜单关闭即清空——搜索只是本次挑选的临时过滤,不是需要记忆的偏好。 const [branchMenuOpen, setBranchMenuOpen] = useState(false); const [branchQuery, setBranchQuery] = useState(''); const normalizedBranchQuery = branchQuery.trim().toLowerCase(); const visibleBranches = normalizedBranchQuery - ? branches.filter((b) => b.toLowerCase().includes(normalizedBranchQuery)) - : branches; + ? allBranches.filter((b) => displayBranch(b).toLowerCase().includes(normalizedBranchQuery)) + : allBranches; // 组合框键盘导航(2026-09-08 Codex review:改用 aria-activedescendant 组合框结构): // 焦点始终留在输入框,方向键只移动高亮项,Enter 选中高亮项——不再依赖菜单 @@ -490,9 +517,9 @@ export function BranchWorktreeChip({ // 输入搜索词时接管,避免打断已经在筛选/用方向键挑选的操作。 useEffect(() => { if (!branchMenuOpen || normalizedBranchQuery) return; - const current = branches.indexOf(branchLabel); + const current = allBranches.indexOf(branchLabel); setActiveBranchIndex(current >= 0 ? current : 0); - }, [branchMenuOpen, branches, branchLabel, normalizedBranchQuery]); + }, [branchMenuOpen, allBranches, branchLabel, normalizedBranchQuery]); const pickBranch = (branch: string) => { onPick(branch); @@ -557,7 +584,9 @@ export function BranchWorktreeChip({ aria-label={t('newChat.branchChip.label')} > - {branchDisplayLabel} + + {branchDisplayLabel} + {branchInteractive && ( {t('newChat.branchChip.loading')} - ) : branchesFailed || branches.length === 0 ? ( + ) : branchesFailed || allBranches.length === 0 ? ( /* 失败与空列表都给重试入口(空列表也可能是隧道/瞬时问题); 点击不关闭弹层,重试期间留在原地显示 loading。 */ + + + + {sync.hint} + + + )} + {!branchOnly && ( + <> + {/* 悬停激活任一半区时分隔线隐去,让 hover 填充看起来是一体的(对齐 Claude Code)。 */} + + {checkboxArea} + + )} ); } diff --git a/apps/desktop/src/renderer/components/new-chat/__tests__/BranchWorktreeChip.test.tsx b/apps/desktop/src/renderer/components/new-chat/__tests__/BranchWorktreeChip.test.tsx new file mode 100644 index 00000000000..d29c8c98116 --- /dev/null +++ b/apps/desktop/src/renderer/components/new-chat/__tests__/BranchWorktreeChip.test.tsx @@ -0,0 +1,214 @@ +// @vitest-environment jsdom +/** Test value: shared capsule is a new interactive protocol consumer. Verify real + * component selection values, stale responses, retry and old-host fallback. + * Previously only source-text guards covered the capsule. Minimal inputs: one + * remote ref and a local origin/main namesake, an unresolved fetch, missing caps. + * Tier: unit; mocked bridge only, no Git or real timers. + */ +import React from 'react'; +import { + act, + cleanup, + fireEvent, + render as rtlRender, + screen, + waitFor, +} from '@testing-library/react'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { BranchWorktreeChip } from '../WorktreeChipsRow'; +import { Tooltip } from '@/components/ui/tooltip'; +const render = (element: React.ReactElement) => + rtlRender(element, { + wrapper: ({ children }) => {children}, + }); +const mocks = vi.hoisted(() => ({ + toast: vi.fn(), + t: (key: string, args?: object) => key + (args ? JSON.stringify(args) : ''), +})); +vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: mocks.t }) })); +vi.mock('@/lib/toast', () => ({ toast: { error: mocks.toast } })); +vi.mock('../FolderPickerPopover', () => ({ + FolderPickerPopover: () => null, + addRecentFolder: vi.fn(), +})); +vi.mock('@/hooks/useProjectPickerOptions', () => ({ getProjectPickerDisplayName: () => '' })); +const baseStatus = { + branch: 'main', + remote: 'origin', + remoteBranch: 'main', + remoteOnly: false, + ahead: 0, + behind: 3, + dirtyFiles: 2, + checkoutPath: '/repo', +}; +const status = vi.fn(); +const sync = vi.fn(); +const invoke = vi.fn(); +const props = { + baseRepo: '/repo', + branchLabel: 'main', + branches: ['main', 'origin/main'], + branchesLoading: false, + branchesFailed: false, + onRetryBranches: vi.fn(), + checked: true, + branchSourceSelected: true, + branchInteractive: true, + onPick: vi.fn(), + onOpenRequested: vi.fn(), + onToggle: vi.fn(), + remoteDefaultBranch: 'refs/remotes/origin/main', +}; +beforeEach(() => { + vi.clearAllMocks(); + status.mockResolvedValue(baseStatus); + sync.mockResolvedValue({ status: { ...baseStatus, behind: 0 }, updated: 3 }); + Object.assign(window, { + electronAPI: { + worktreeBranchSyncStatus: status, + worktreeSyncBranch: sync, + deviceLink: { invoke }, + }, + }); + Element.prototype.scrollIntoView = vi.fn(); +}); +afterEach(() => { + cleanup(); + vi.useRealTimers(); +}); +const branchButton = () => screen.getByRole('button', { name: 'newChat.branchChip.label' }); +const refreshButton = () => screen.getByRole('button', { name: /newChat.branchSync.action/ }); +it('shares counts with the selected row and keeps full remote refs distinct during keyboard search', async () => { + render(); + await waitFor(() => expect(status).toHaveBeenCalledOnce()); + await act(async () => { + fireEvent.click(branchButton()); + }); + expect(screen.getByRole('group', { name: 'newChat.branchSync.remoteGroup' })).toBeTruthy(); + expect(screen.getByText(/newChat.branchSync.behind/).textContent).toContain( + 'newChat.branchSync.dirty', + ); + const input = screen.getByRole('combobox'); + fireEvent.change(input, { target: { value: 'origin/main' } }); + expect(screen.getAllByRole('option')).toHaveLength(2); + fireEvent.keyDown(input, { key: 'Enter' }); + expect(props.onPick).toHaveBeenCalledWith('refs/remotes/origin/main'); + await act(async () => { + fireEvent.click(branchButton()); + }); + fireEvent.change(screen.getByRole('combobox'), { target: { value: 'origin/main' } }); + fireEvent.keyDown(screen.getByRole('combobox'), { key: 'ArrowDown' }); + fireEvent.keyDown(screen.getByRole('combobox'), { key: 'Enter' }); + expect(props.onPick).toHaveBeenLastCalledWith('origin/main'); +}); +it('shows remote sources only when worktree is checked or branchOnly is used', async () => { + const view = render(); + await waitFor(() => expect(refreshButton().hasAttribute('disabled')).toBe(false)); + await act(async () => { + fireEvent.click(branchButton()); + }); + expect(screen.queryByRole('group', { name: 'newChat.branchSync.remoteGroup' })).toBeNull(); + view.rerender(); + expect(screen.getByRole('group', { name: 'newChat.branchSync.remoteGroup' })).toBeTruthy(); +}); +it('hides the action when an old controlled host rejects the status channel', async () => { + invoke.mockRejectedValue(new Error('[DEVICE_LINK_CHANNEL_NOT_ALLOWED] Old host')); + render(); + await waitFor(() => + expect(screen.queryByRole('button', { name: /newChat.branchSync/ })).toBeNull(), + ); + expect(status).not.toHaveBeenCalled(); + expect(invoke).toHaveBeenCalledWith('old-pc', 'worktree:branch-sync-status', [ + { baseRepo: '/repo', branch: 'main' }, + ]); + expect(sync).not.toHaveBeenCalled(); +}); +it('blocks duplicate refresh clicks while leaving branch selection usable', async () => { + let resolve!: (value: unknown) => void; + sync.mockReturnValue( + new Promise((r) => { + resolve = r; + }), + ); + render(); + await waitFor(() => expect(refreshButton().hasAttribute('disabled')).toBe(false)); + fireEvent.click(refreshButton()); + fireEvent.click(screen.getByRole('button', { name: /newChat.branchSync.fetching/ })); + await act(async () => { + fireEvent.click(branchButton()); + }); + expect(screen.getByRole('combobox')).toBeTruthy(); + expect(sync).toHaveBeenCalledOnce(); + await act(async () => resolve({ status: baseStatus, updated: 3 })); + expect(screen.getByRole('button', { name: /newChat.branchSync.updated/ })).toBeTruthy(); +}); +it('explains a disabled conflict, then rechecks locally on hover', async () => { + status.mockResolvedValueOnce({ ...baseStatus, blocked: 'conflict' }); + render(); + const button = await screen.findByRole('button', { name: /newChat.branchSync.conflict/ }); + expect(button.getAttribute('aria-disabled')).toBe('true'); + expect(button.parentElement?.tabIndex).toBe(0); + vi.useFakeTimers(); + fireEvent.mouseEnter(button.parentElement!); + await act(async () => vi.advanceTimersByTime(250)); + expect(status).toHaveBeenCalledTimes(2); + expect(refreshButton().hasAttribute('disabled')).toBe(false); +}); +it('reports failed fetches in a toast and permits retry without selecting anything', async () => { + sync.mockRejectedValueOnce(new Error('[INTERNAL] Authentication failed')); + render(); + await waitFor(() => expect(refreshButton().hasAttribute('disabled')).toBe(false)); + fireEvent.click(refreshButton()); + const retry = await screen.findByRole('button', { name: /newChat.branchSync.failed/ }); + expect(mocks.toast).toHaveBeenCalledOnce(); + expect(retry.hasAttribute('disabled')).toBe(false); + fireEvent.click(retry); + await screen.findByRole('button', { name: /newChat.branchSync.updated/ }); + expect(props.onPick).not.toHaveBeenCalled(); +}); +it('routes to the selected device and discards a late status from the previous branch', async () => { + let resolve!: (value: unknown) => void; + invoke + .mockReturnValueOnce( + new Promise((r) => { + resolve = r; + }), + ) + .mockResolvedValue({ ...baseStatus, branch: 'next' }); + const view = render(); + view.rerender(); + await waitFor(() => + expect(invoke).toHaveBeenLastCalledWith('pc', 'worktree:branch-sync-status', [ + { baseRepo: '/repo', branch: 'next' }, + ]), + ); + await act(async () => resolve({ ...baseStatus, blocked: 'conflict' })); + expect(screen.queryByRole('button', { name: /newChat.branchSync.conflict/ })).toBeNull(); + fireEvent.click(refreshButton()); + await waitFor(() => + expect(invoke).toHaveBeenLastCalledWith('pc', 'worktree:sync-branch', [ + { baseRepo: '/repo', branch: 'next' }, + ]), + ); + expect(status).not.toHaveBeenCalled(); +}); + +it('keeps a fetched missing-remote result disabled across local rechecks, but clears it after changing branch', async () => { + sync.mockResolvedValue({ status: { ...baseStatus, blocked: 'missing-remote' }, updated: 0 }); + const view = render(); + await waitFor(() => expect(refreshButton().hasAttribute('disabled')).toBe(false)); + fireEvent.click(refreshButton()); + await screen.findByRole('button', { name: /newChat.branchSync.missing-remote/ }); + await act(async () => { + fireEvent.click(branchButton()); + }); + await waitFor(() => expect(status).toHaveBeenCalledTimes(2)); + expect( + screen + .getByRole('button', { name: /newChat.branchSync.missing-remote/ }) + .hasAttribute('disabled'), + ).toBe(true); + view.rerender(); + await waitFor(() => expect(refreshButton().hasAttribute('disabled')).toBe(false)); +}); diff --git a/apps/desktop/src/renderer/features/cc-agent/WorkerProjectField.tsx b/apps/desktop/src/renderer/features/cc-agent/WorkerProjectField.tsx index 29dd3d5ec79..01e262f3f7d 100644 --- a/apps/desktop/src/renderer/features/cc-agent/WorkerProjectField.tsx +++ b/apps/desktop/src/renderer/features/cc-agent/WorkerProjectField.tsx @@ -135,6 +135,7 @@ export function WorkerProjectField({ const [branchSnapshot, setBranchSnapshot] = useState<{ base: string; values: string[]; + remoteDefaultBranch?: string; failed?: boolean; }>(); const [branchLoading, setBranchLoading] = useState(false); @@ -155,7 +156,11 @@ export function WorkerProjectField({ .then( (value) => { if (request === branchRequest.current) - setBranchSnapshot({ base, values: value.branches }); + setBranchSnapshot({ + base, + values: value.branches, + remoteDefaultBranch: value.remoteDefaultBranch, + }); }, () => { if (request === branchRequest.current) @@ -177,7 +182,9 @@ export function WorkerProjectField({ ? t('orca.workerProject.detachedHead', { sha: context.selected.branch?.slice(0, 8) }) : (context?.selected.branch ?? '—'); const sourceBranchLabel = - detached && branch === context?.selected.branch ? currentBranchLabel : branch; + detached && branch === context?.selected.branch + ? currentBranchLabel + : branch.replace(/^refs\/remotes\//, ''); return (
{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..1188bebbd09 --- /dev/null +++ b/apps/desktop/src/renderer/hooks/useBranchSync.ts @@ -0,0 +1,188 @@ +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 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; + 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', { ...vars, directory: status.checkoutPath.split(/[\\/]/).filter(Boolean).at(-1) })}`; + if (status?.blocked) hint = t(`newChat.branchSync.${status.blocked}`, vars); + 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 details = [ + status?.ahead + ? t('newChat.branchSync.ahead', { + ...vars, + remote: `${vars.remote}/${vars.remoteBranch}`, + count: status.ahead, + }) + : '', + 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..492ffdda8d4 100644 --- a/apps/desktop/src/renderer/i18n/locales/en/common.json +++ b/apps/desktop/src/renderer/i18n/locales/en/common.json @@ -11365,6 +11365,35 @@ "prompt": "Draft a reply to this email, taking the conversation and my thoughts into account. Don't send it yet." } } + }, + "branchSync": { + "action": "Fetch the latest commits for {{remoteBranch}} from {{remote}} and update local {{branch}}", + "remoteAction": "Fetch the latest commits for {{remoteBranch}} from {{remote}}", + "checkedOut": "{{branch}} is checked out in “{{directory}}”. Its files will also update; your uncommitted changes will be preserved.", + "fetching": "Fetching from {{remote}}…", + "updated": "Updated by {{count}} commits", + "current": "Already up to date", + "keptAhead": "Keeping the local version with {{count}} unpushed commits", + "failed": "Last fetch failed: {{reason}}. Click to retry.", + "diverged": "Local {{branch}} and {{remote}}/{{remoteBranch}} have diverged and cannot update automatically. The new worktree will use local {{branch}}.", + "conflict": "Your uncommitted changes overlap the remote updates. Commit them or use git stash before updating.", + "missing-remote": "{{remoteBranch}} does not exist on {{remote}}", + "no-remote": "This project has no remote repository", + "not-local": "Select a local branch or the remote default branch", + "ahead": "{{count}} commits ahead of {{remote}}", + "behind": "{{count}} commits behind {{remote}}", + "dirty": "{{count}} uncommitted files", + "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": "The repository changed or is busy. Retry after the other Git operation finishes.", + "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..4a22b0644bf 100644 --- a/apps/desktop/src/renderer/i18n/locales/ja/common.json +++ b/apps/desktop/src/renderer/i18n/locales/ja/common.json @@ -11330,6 +11330,35 @@ "prompt": "このメールへの返信の下書きを作って。これまでのやり取りと私の考えを踏まえて、まだ送信しないで。" } } + }, + "branchSync": { + "action": "{{remote}} から {{remoteBranch}} の最新コミットを取得し、ローカルの {{branch}} を更新", + "remoteAction": "{{remote}} から {{remoteBranch}} の最新コミットを取得", + "checkedOut": "{{branch}} は「{{directory}}」で使用中です。そのファイルも更新されます。未コミットの変更は保持されます。", + "fetching": "{{remote}} から取得中…", + "updated": "{{count}} コミット更新しました", + "current": "すでに最新です", + "keptAhead": "未プッシュの {{count}} コミットを含むローカル版を保持します", + "failed": "前回の取得に失敗:{{reason}}。クリックして再試行。", + "diverged": "ローカルの {{branch}} と {{remote}}/{{remoteBranch}} は分岐しており、自動更新できません。新しい worktree はローカルの {{branch}} を使用します。", + "conflict": "未コミットの変更とリモートの更新が同じファイルにあります。先にコミットするか、git stash で変更を退避してください。", + "missing-remote": "{{remote}} に {{remoteBranch}} がありません", + "no-remote": "このプロジェクトにリモートリポジトリはありません", + "not-local": "ローカルブランチまたはリモートの既定ブランチを選択してください", + "ahead": "{{remote}} より {{count}} コミット先行", + "behind": "{{remote}} より {{count}} コミット遅れ", + "dirty": "未コミットのファイル {{count}} 件", + "remoteGroup": "リモート", + "localGroup": "ローカルブランチ", + "errors": { + "git": "Git でブランチを更新できません。リポジトリへのアクセスを確認して再試行してください。", + "timeout": "Git がタイムアウトしました。接続を確認して再試行してください。", + "auth": "Git の認証に失敗しました。リモートの認証情報を確認してください。", + "network": "リモートリポジトリに接続できません。接続を確認してください。", + "missing": "リモートブランチは存在しません。", + "busy": "リポジトリが変更されたか使用中です。他の Git 操作の完了後に再試行してください。", + "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..93e3e33218a 100644 --- a/apps/desktop/src/renderer/i18n/locales/ko/common.json +++ b/apps/desktop/src/renderer/i18n/locales/ko/common.json @@ -11330,6 +11330,35 @@ "prompt": "이 메일의 답장 초안을 써 줘. 주고받은 메일과 내 생각을 반영하고, 아직 보내지는 마." } } + }, + "branchSync": { + "action": "{{remote}}에서 {{remoteBranch}}의 최신 커밋을 가져와 로컬 {{branch}} 업데이트", + "remoteAction": "{{remote}}에서 {{remoteBranch}}의 최신 커밋 가져오기", + "checkedOut": "{{branch}}은(는) “{{directory}}”에서 사용 중입니다. 해당 파일도 업데이트되며 커밋하지 않은 변경 사항은 유지됩니다.", + "fetching": "{{remote}}에서 가져오는 중…", + "updated": "커밋 {{count}}개 업데이트됨", + "current": "이미 최신 상태입니다", + "keptAhead": "푸시하지 않은 커밋 {{count}}개가 있는 로컬 버전을 유지합니다", + "failed": "마지막 가져오기 실패: {{reason}}. 클릭하여 다시 시도하세요.", + "diverged": "로컬 {{branch}}와 {{remote}}/{{remoteBranch}} 양쪽에 새 커밋이 있어 자동 업데이트할 수 없습니다. 새 worktree는 로컬 {{branch}}을(를) 사용합니다.", + "conflict": "커밋하지 않은 변경 사항과 원격 업데이트가 같은 파일에 있습니다. 먼저 커밋하거나 git stash로 변경 사항을 보관하세요.", + "missing-remote": "{{remote}}에 {{remoteBranch}}이(가) 없습니다", + "no-remote": "이 프로젝트에 원격 저장소가 설정되지 않았습니다", + "not-local": "로컬 브랜치 또는 원격 기본 브랜치를 선택하세요", + "ahead": "{{remote}}보다 커밋 {{count}}개 앞섬", + "behind": "{{remote}}보다 커밋 {{count}}개 뒤처짐", + "dirty": "커밋하지 않은 파일 {{count}}개", + "remoteGroup": "원격", + "localGroup": "로컬 브랜치", + "errors": { + "git": "Git이 브랜치를 업데이트하지 못했습니다. 저장소 접근 권한을 확인하고 다시 시도하세요.", + "timeout": "Git 작업 시간이 초과되었습니다. 연결을 확인하고 다시 시도하세요.", + "auth": "Git 인증에 실패했습니다. 원격 인증 정보를 확인하세요.", + "network": "원격 저장소에 연결할 수 없습니다. 연결을 확인하세요.", + "missing": "원격 브랜치가 더 이상 존재하지 않습니다.", + "busy": "저장소가 변경되었거나 사용 중입니다. 다른 Git 작업이 끝난 후 다시 시도하세요.", + "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..c8c818d7bdd 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,35 @@ "prompt": "帮我为这封邮件写一份回复草稿,结合邮件往来和我的想法,先不发送。" } } + }, + "branchSync": { + "action": "从 {{remote}} 拉取 {{remoteBranch}} 的最新提交,并更新本地 {{branch}}", + "remoteAction": "从 {{remote}} 拉取 {{remoteBranch}} 的最新提交", + "checkedOut": "{{branch}} 正在「{{directory}}」里使用,那里的文件会一起更新,你没提交的改动不受影响", + "fetching": "正在从 {{remote}} 拉取…", + "updated": "已更新 {{count}} 个提交", + "current": "已是最新", + "keptAhead": "本地有 {{count}} 个提交还没推送,保留本地版本", + "failed": "上次拉取失败:{{reason}},点击重试", + "diverged": "本地 {{branch}} 和 {{remote}} 上的 {{remoteBranch}} 各有新提交,没法自动更新。新 worktree 会用本地 {{branch}}", + "conflict": "你还没提交的改动和远程的更新涉及同一批文件,没法更新。可以先提交,或者用 git stash 把改动收起来", + "missing-remote": "{{remote}} 上没有 {{remoteBranch}}", + "no-remote": "这个项目没有设置远程仓库", + "not-local": "请先选择本地分支或远程主分支", + "ahead": "领先 {{remote}} {{count}} 个提交", + "behind": "落后 {{remote}} {{count}} 个提交", + "dirty": "未提交 {{count}} 个文件", + "remoteGroup": "远程", + "localGroup": "本地分支", + "errors": { + "git": "Git 无法更新分支,请检查仓库访问权限后重试", + "timeout": "Git 操作超时,请检查网络连接后重试", + "auth": "Git 认证失败,请检查远程仓库凭证", + "network": "无法连接远程仓库,请检查网络连接", + "missing": "远程分支已不存在", + "busy": "仓库正在更新或已发生变化,请等其它 Git 操作完成后重试", + "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..a3937013be3 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,35 @@ "prompt": "幫我為這封郵件寫一份回覆草稿,結合郵件往來和我的想法,先不寄送。" } } + }, + "branchSync": { + "action": "從 {{remote}} 取得 {{remoteBranch}} 的最新提交,並更新本機 {{branch}}", + "remoteAction": "從 {{remote}} 取得 {{remoteBranch}} 的最新提交", + "checkedOut": "{{branch}} 正在「{{directory}}」中使用,那裡的檔案會一起更新,你尚未提交的變更不受影響", + "fetching": "正在從 {{remote}} 取得更新…", + "updated": "已更新 {{count}} 個提交", + "current": "已是最新", + "keptAhead": "本機有 {{count}} 個提交尚未推送,保留本機版本", + "failed": "上次取得更新失敗:{{reason}},點擊重試", + "diverged": "本機 {{branch}} 和 {{remote}} 上的 {{remoteBranch}} 各有新提交,無法自動更新。新 worktree 會使用本機 {{branch}}", + "conflict": "尚未提交的變更與遠端更新涉及相同檔案,無法更新。請先提交,或使用 git stash 收起變更", + "missing-remote": "{{remote}} 上沒有 {{remoteBranch}}", + "no-remote": "這個專案沒有設定遠端儲存庫", + "not-local": "請先選擇本機分支或遠端主分支", + "ahead": "領先 {{remote}} {{count}} 個提交", + "behind": "落後 {{remote}} {{count}} 個提交", + "dirty": "尚未提交 {{count}} 個檔案", + "remoteGroup": "遠端", + "localGroup": "本機分支", + "errors": { + "git": "Git 無法更新分支,請檢查儲存庫存取權限後重試", + "timeout": "Git 操作逾時,請檢查網路連線後重試", + "auth": "Git 認證失敗,請檢查遠端儲存庫憑證", + "network": "無法連線至遠端儲存庫,請檢查網路連線", + "missing": "遠端分支已不存在", + "busy": "儲存庫正在更新或已變更,請等其他 Git 操作完成後重試", + "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..ba8a9747790 --- /dev/null +++ b/apps/desktop/src/shared/worktreeBranchSync.ts @@ -0,0 +1,25 @@ +/** 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'; +} +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, From bce8ecf5edf5308aa69f381966d74de0d911c8a9 Mon Sep 17 00:00:00 2001 From: fmfsaisai Date: Sat, 24 Oct 2026 20:02:49 +0800 Subject: [PATCH 02/11] =?UTF-8?q?fix(worktree):=20=E6=89=8B=E5=8A=A8?= =?UTF-8?q?=E5=88=B7=E6=96=B0=E8=A2=AB=E6=8B=92=E6=97=B6=E6=98=BE=E7=A4=BA?= =?UTF-8?q?=E5=8E=9F=E5=9B=A0=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: fmfsaisai --- .../__tests__/BranchWorktreeChip.test.tsx | 28 +++++++++++++++++-- .../src/renderer/hooks/useBranchSync.ts | 9 +++++- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/renderer/components/new-chat/__tests__/BranchWorktreeChip.test.tsx b/apps/desktop/src/renderer/components/new-chat/__tests__/BranchWorktreeChip.test.tsx index d29c8c98116..955ce4c01ce 100644 --- a/apps/desktop/src/renderer/components/new-chat/__tests__/BranchWorktreeChip.test.tsx +++ b/apps/desktop/src/renderer/components/new-chat/__tests__/BranchWorktreeChip.test.tsx @@ -23,10 +23,11 @@ const render = (element: React.ReactElement) => }); const mocks = vi.hoisted(() => ({ toast: vi.fn(), + warning: vi.fn(), t: (key: string, args?: object) => key + (args ? JSON.stringify(args) : ''), })); vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: mocks.t }) })); -vi.mock('@/lib/toast', () => ({ toast: { error: mocks.toast } })); +vi.mock('@/lib/toast', () => ({ toast: { error: mocks.toast, warning: mocks.warning } })); vi.mock('../FolderPickerPopover', () => ({ FolderPickerPopover: () => null, addRecentFolder: vi.fn(), @@ -149,11 +150,13 @@ it('explains a disabled conflict, then rechecks locally on hover', async () => { const button = await screen.findByRole('button', { name: /newChat.branchSync.conflict/ }); expect(button.getAttribute('aria-disabled')).toBe('true'); expect(button.parentElement?.tabIndex).toBe(0); + expect(mocks.warning).not.toHaveBeenCalled(); vi.useFakeTimers(); fireEvent.mouseEnter(button.parentElement!); await act(async () => vi.advanceTimersByTime(250)); expect(status).toHaveBeenCalledTimes(2); expect(refreshButton().hasAttribute('disabled')).toBe(false); + expect(mocks.warning).not.toHaveBeenCalled(); }); it('reports failed fetches in a toast and permits retry without selecting anything', async () => { sync.mockRejectedValueOnce(new Error('[INTERNAL] Authentication failed')); @@ -199,11 +202,14 @@ it('keeps a fetched missing-remote result disabled across local rechecks, but cl const view = render(); await waitFor(() => expect(refreshButton().hasAttribute('disabled')).toBe(false)); fireEvent.click(refreshButton()); - await screen.findByRole('button', { name: /newChat.branchSync.missing-remote/ }); + const blocked = await screen.findByRole('button', { name: /newChat.branchSync.missing-remote/ }); + expect(mocks.warning).toHaveBeenCalledExactlyOnceWith(blocked.getAttribute('aria-label')); + expect(mocks.toast).not.toHaveBeenCalled(); await act(async () => { fireEvent.click(branchButton()); }); await waitFor(() => expect(status).toHaveBeenCalledTimes(2)); + expect(mocks.warning).toHaveBeenCalledOnce(); expect( screen .getByRole('button', { name: /newChat.branchSync.missing-remote/ }) @@ -212,3 +218,21 @@ it('keeps a fetched missing-remote result disabled across local rechecks, but cl view.rerender(); await waitFor(() => expect(refreshButton().hasAttribute('disabled')).toBe(false)); }); + +// F01: a rejected user action should notify only its current target. Existing +// stale-read coverage cannot catch a delayed mutation response causing a toast. +it('does not warn for a blocked click result after changing branch or project', async () => { + let resolve!: (value: unknown) => void; + sync.mockReturnValue( + new Promise((done) => { + resolve = done; + }), + ); + const view = render(); + await waitFor(() => expect(refreshButton().hasAttribute('disabled')).toBe(false)); + fireEvent.click(refreshButton()); + view.rerender(); + await act(async () => resolve({ status: { ...baseStatus, blocked: 'conflict' }, updated: 0 })); + expect(mocks.warning).not.toHaveBeenCalled(); + expect(mocks.toast).not.toHaveBeenCalled(); +}); diff --git a/apps/desktop/src/renderer/hooks/useBranchSync.ts b/apps/desktop/src/renderer/hooks/useBranchSync.ts index 1188bebbd09..cdb3aef829d 100644 --- a/apps/desktop/src/renderer/hooks/useBranchSync.ts +++ b/apps/desktop/src/renderer/hooks/useBranchSync.ts @@ -93,6 +93,12 @@ export function useBranchSync( 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; @@ -101,6 +107,7 @@ export function useBranchSync( 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, @@ -141,7 +148,7 @@ export function useBranchSync( if (status?.remoteOnly) hint = t('newChat.branchSync.remoteAction', vars); if (status?.checkoutPath) hint += `\n${t('newChat.branchSync.checkedOut', { ...vars, directory: status.checkoutPath.split(/[\\/]/).filter(Boolean).at(-1) })}`; - if (status?.blocked) hint = t(`newChat.branchSync.${status.blocked}`, vars); + if (status?.blocked) hint = blockedHint(status); if (state.result && !status?.blocked) hint = state.result.updated ? t('newChat.branchSync.updated', { count: state.result.updated }) From c5766f953182d2c5dece4c59e7bbc7844e81f867 Mon Sep 17 00:00:00 2001 From: fmfsaisai Date: Sat, 24 Oct 2026 20:50:26 +0800 Subject: [PATCH 03/11] =?UTF-8?q?fix(worktree):=20=E9=98=B2=E6=AD=A2?= =?UTF-8?q?=E5=88=86=E6=94=AF=E5=88=B7=E6=96=B0=E7=A0=B4=E5=9D=8F=E5=85=B6?= =?UTF-8?q?=E4=BB=96=E6=A3=80=E5=87=BA=E7=9B=AE=E5=BD=95=E4=B8=8E=E5=BF=BD?= =?UTF-8?q?=E7=95=A5=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: fmfsaisai --- .../branchSync.git-integration.test.ts | 69 +++++++++++++++++++ apps/desktop/src/main/worktree/branchSync.ts | 35 ++++++++-- .../src/renderer/i18n/locales/en/common.json | 2 + .../src/renderer/i18n/locales/ja/common.json | 2 + .../src/renderer/i18n/locales/ko/common.json | 2 + .../renderer/i18n/locales/zh-CN/common.json | 2 + .../renderer/i18n/locales/zh-TW/common.json | 2 + apps/desktop/src/shared/worktreeBranchSync.ts | 9 ++- 8 files changed, 118 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/main/worktree/__tests__/branchSync.git-integration.test.ts b/apps/desktop/src/main/worktree/__tests__/branchSync.git-integration.test.ts index db79ce42745..1558b8a9dca 100644 --- a/apps/desktop/src/main/worktree/__tests__/branchSync.git-integration.test.ts +++ b/apps/desktop/src/main/worktree/__tests__/branchSync.git-integration.test.ts @@ -214,3 +214,72 @@ it('disables a branch with no upstream when only a non-origin remote is configur git(f.repo, 'remote', 'rename', 'origin', 'upstream'); expect((await readBranchSyncStatus(f.req)).blocked).toBe('no-remote'); }); + +// PR #7059 regressions, git-integration tier: preflight checks alone cannot +// protect duplicate checkouts, ignored local files, or a checkout before writing. +// Existing clean/dirty fast-forward cases do not exercise these Git behaviors. +it('refuses a branch forced into multiple worktrees without changing either checkout', async () => { + const f = fixture(); + const checkout = path.join(root, 'duplicate-checkout'); + git(f.repo, 'worktree', 'add', '--force', checkout, f.branch); + f.advance(); + const snapshot = (cwd: string) => ({ + head: git(cwd, 'rev-parse', 'HEAD'), + index: git(cwd, 'ls-files', '--stage'), + bytes: fs.readFileSync(path.join(cwd, 'base'), 'utf8'), + }); + const before = [snapshot(f.repo), snapshot(checkout)]; + expect((await readBranchSyncStatus(f.req)).blocked).toBe('multiple-checkouts'); + expect(await syncBranch(f.req)).toMatchObject({ + updated: 0, + status: { blocked: 'multiple-checkouts' }, + }); + expect([snapshot(f.repo), snapshot(checkout)]).toEqual(before); + expect(git(f.repo, 'rev-parse', f.branch)).toBe(before[0].head); +}); + +it('refuses to overwrite an ignored file with a remotely tracked file', async () => { + const f = fixture(); + f.advance('config.json'); + fs.appendFileSync( + path.resolve(f.repo, git(f.repo, 'rev-parse', '--git-path', 'info/exclude')), + '\nconfig.json\n', + ); + fs.writeFileSync(path.join(f.repo, 'config.json'), 'local ignored configuration'); + const before = git(f.repo, 'rev-parse', 'HEAD'); + const index = git(f.repo, 'ls-files', '--stage'); + expect(git(f.repo, 'status', '--porcelain')).toBe(''); + expect(await syncBranch(f.req)).toMatchObject({ updated: 0, status: { blocked: 'conflict' } }); + expect(git(f.repo, 'rev-parse', 'HEAD')).toBe(before); + expect(git(f.repo, 'ls-files', '--stage')).toBe(index); + expect(fs.readFileSync(path.join(f.repo, 'config.json'), 'utf8')).toBe( + 'local ignored configuration', + ); +}); + +it('refuses an unchecked-branch write if another worktree checks it out after preflight', async () => { + const f = fixture(); + f.advance(); + const before = git(f.repo, 'rev-parse', f.branch); + git(f.repo, 'checkout', 'main'); + const checkout = path.join(root, 'late-checkout'); + let index: string | undefined; + const realGit = gitModule.gitExec; + const spy = vi.spyOn(gitModule, 'gitExec').mockImplementation(async (args, cwd, opts) => { + if (args[0] === 'fetch' && args.includes('.')) { + git(f.repo, 'worktree', 'add', checkout, f.branch); + index = git(checkout, 'ls-files', '--stage'); + } + return realGit(args, cwd, opts); + }); + try { + expect(await syncBranch(f.req)).toMatchObject({ updated: 0, status: { blocked: 'changed' } }); + expect(index).toBeDefined(); + expect(git(f.repo, 'rev-parse', f.branch)).toBe(before); + expect(git(checkout, 'rev-parse', 'HEAD')).toBe(before); + expect(git(checkout, 'ls-files', '--stage')).toBe(index); + expect(fs.readFileSync(path.join(checkout, 'base'), 'utf8')).toBe('base'); + } finally { + spy.mockRestore(); + } +}); diff --git a/apps/desktop/src/main/worktree/branchSync.ts b/apps/desktop/src/main/worktree/branchSync.ts index 50cbc576896..042ac098065 100644 --- a/apps/desktop/src/main/worktree/branchSync.ts +++ b/apps/desktop/src/main/worktree/branchSync.ts @@ -92,13 +92,16 @@ export async function readBranchSyncStatus(req: BranchSyncRequest): Promise s.startsWith('worktree '))?.slice(9); - break; + const checkout = fields.find((s) => s.startsWith('worktree '))?.slice(9); + if (checkout) checkouts.push(checkout); } } + if (checkouts.length > 1) return { ...status, blocked: 'multiple-checkouts' }; + status.checkoutPath = checkouts[0]; } const dirty = status.checkoutPath ? dirtyPaths( @@ -207,7 +210,14 @@ async function syncBranchOnce(req: BranchSyncRequest): Promise if ((await git(status.checkoutPath, 'symbolic-ref', 'HEAD')) !== `refs/heads/${req.branch}`) throw new Error('The checked-out branch changed; retry'); try { - await git(status.checkoutPath, 'merge', '--ff-only', '--no-autostash', next); + await git( + status.checkoutPath, + 'merge', + '--ff-only', + '--no-autostash', + '--no-overwrite-ignore', + next, + ); } catch (error) { const refreshed = await readBranchSyncStatus(req); if (refreshed.blocked) return { status: refreshed, updated: 0 }; @@ -216,7 +226,24 @@ async function syncBranchOnce(req: BranchSyncRequest): Promise throw error; } } else { - await git(req.baseRepo, 'update-ref', `refs/heads/${req.branch}`, next, old); + try { + // Local fetch refuses non-fast-forwards and branches checked out in any + // worktree at write time. update-ref has no checkout guard. + await git( + req.baseRepo, + 'fetch', + '--no-tags', + '--recurse-submodules=no', + '.', + `${next}:refs/heads/${req.branch}`, + ); + } catch (error) { + const refreshed = await readBranchSyncStatus(req); + if (refreshed.blocked) return { status: refreshed, updated: 0 }; + if (refreshed.checkoutPath) + return { status: { ...refreshed, blocked: 'changed' }, updated: 0 }; + throw error; + } } return { status: await readBranchSyncStatus(req), updated }; } diff --git a/apps/desktop/src/renderer/i18n/locales/en/common.json b/apps/desktop/src/renderer/i18n/locales/en/common.json index 492ffdda8d4..fd17bb85412 100644 --- a/apps/desktop/src/renderer/i18n/locales/en/common.json +++ b/apps/desktop/src/renderer/i18n/locales/en/common.json @@ -11367,6 +11367,8 @@ } }, "branchSync": { + "multiple-checkouts": "{{branch}} is checked out in multiple directories and cannot be updated safely", + "changed": "The branch state changed. Please retry.", "action": "Fetch the latest commits for {{remoteBranch}} from {{remote}} and update local {{branch}}", "remoteAction": "Fetch the latest commits for {{remoteBranch}} from {{remote}}", "checkedOut": "{{branch}} is checked out in “{{directory}}”. Its files will also update; your uncommitted changes will be preserved.", diff --git a/apps/desktop/src/renderer/i18n/locales/ja/common.json b/apps/desktop/src/renderer/i18n/locales/ja/common.json index 4a22b0644bf..ad22c3bd581 100644 --- a/apps/desktop/src/renderer/i18n/locales/ja/common.json +++ b/apps/desktop/src/renderer/i18n/locales/ja/common.json @@ -11332,6 +11332,8 @@ } }, "branchSync": { + "multiple-checkouts": "{{branch}} は複数のディレクトリで開かれているため、安全に更新できません", + "changed": "ブランチの状態が変わりました。再試行してください", "action": "{{remote}} から {{remoteBranch}} の最新コミットを取得し、ローカルの {{branch}} を更新", "remoteAction": "{{remote}} から {{remoteBranch}} の最新コミットを取得", "checkedOut": "{{branch}} は「{{directory}}」で使用中です。そのファイルも更新されます。未コミットの変更は保持されます。", diff --git a/apps/desktop/src/renderer/i18n/locales/ko/common.json b/apps/desktop/src/renderer/i18n/locales/ko/common.json index 93e3e33218a..90e8a6a942b 100644 --- a/apps/desktop/src/renderer/i18n/locales/ko/common.json +++ b/apps/desktop/src/renderer/i18n/locales/ko/common.json @@ -11332,6 +11332,8 @@ } }, "branchSync": { + "multiple-checkouts": "{{branch}} 브랜치가 여러 디렉터리에 열려 있어 안전하게 업데이트할 수 없습니다", + "changed": "브랜치 상태가 변경되었습니다. 다시 시도해 주세요", "action": "{{remote}}에서 {{remoteBranch}}의 최신 커밋을 가져와 로컬 {{branch}} 업데이트", "remoteAction": "{{remote}}에서 {{remoteBranch}}의 최신 커밋 가져오기", "checkedOut": "{{branch}}은(는) “{{directory}}”에서 사용 중입니다. 해당 파일도 업데이트되며 커밋하지 않은 변경 사항은 유지됩니다.", 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 c8c818d7bdd..a7f3677ffb4 100644 --- a/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json +++ b/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json @@ -11332,6 +11332,8 @@ } }, "branchSync": { + "multiple-checkouts": "{{branch}} 同时在多个目录里打开,没法安全更新", + "changed": "分支状态已变化,请重试", "action": "从 {{remote}} 拉取 {{remoteBranch}} 的最新提交,并更新本地 {{branch}}", "remoteAction": "从 {{remote}} 拉取 {{remoteBranch}} 的最新提交", "checkedOut": "{{branch}} 正在「{{directory}}」里使用,那里的文件会一起更新,你没提交的改动不受影响", 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 a3937013be3..e7ac8939d42 100644 --- a/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json +++ b/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json @@ -11332,6 +11332,8 @@ } }, "branchSync": { + "multiple-checkouts": "{{branch}} 同時在多個目錄中開啟,無法安全更新", + "changed": "分支狀態已變更,請重試", "action": "從 {{remote}} 取得 {{remoteBranch}} 的最新提交,並更新本機 {{branch}}", "remoteAction": "從 {{remote}} 取得 {{remoteBranch}} 的最新提交", "checkedOut": "{{branch}} 正在「{{directory}}」中使用,那裡的檔案會一起更新,你尚未提交的變更不受影響", diff --git a/apps/desktop/src/shared/worktreeBranchSync.ts b/apps/desktop/src/shared/worktreeBranchSync.ts index ba8a9747790..df067dce337 100644 --- a/apps/desktop/src/shared/worktreeBranchSync.ts +++ b/apps/desktop/src/shared/worktreeBranchSync.ts @@ -13,7 +13,14 @@ export interface BranchSyncStatus { behind: number; dirtyFiles: number; checkoutPath?: string; - blocked?: 'no-remote' | 'missing-remote' | 'diverged' | 'conflict' | 'not-local'; + blocked?: + | 'no-remote' + | 'missing-remote' + | 'diverged' + | 'conflict' + | 'not-local' + | 'multiple-checkouts' + | 'changed'; } export interface BranchSyncResult { status: BranchSyncStatus; From 91244119ff905e6fe283df556eb389b6292ddaee Mon Sep 17 00:00:00 2001 From: fmfsaisai Date: Sat, 24 Oct 2026 22:37:02 +0800 Subject: [PATCH 04/11] =?UTF-8?q?fix(worktree):=20=E6=9C=AC=E5=9C=B0?= =?UTF-8?q?=E5=88=86=E6=94=AF=E5=86=99=E5=85=A5=E4=B8=8D=E4=BD=BF=E7=94=A8?= =?UTF-8?q?=E6=8E=A2=E6=B5=8B=E8=B6=85=E6=97=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: fmfsaisai --- apps/desktop/src/main/worktree/branchSync.ts | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/apps/desktop/src/main/worktree/branchSync.ts b/apps/desktop/src/main/worktree/branchSync.ts index 042ac098065..4bfbdb7637c 100644 --- a/apps/desktop/src/main/worktree/branchSync.ts +++ b/apps/desktop/src/main/worktree/branchSync.ts @@ -8,6 +8,8 @@ import type { } from '../../shared/worktreeBranchSync'; const localOpts = { timeoutMs: 5_000, extraEnv: { LC_ALL: 'C' } }; +// Never interrupt a local write halfway through checkout; disable interactive prompts instead. +const writeOpts = { extraEnv: { LC_ALL: 'C', GIT_TERMINAL_PROMPT: '0' } }; async function git(cwd: string, ...args: string[]): Promise { return (await gitExec(args, cwd, localOpts)).stdout.trim(); } @@ -210,13 +212,10 @@ async function syncBranchOnce(req: BranchSyncRequest): Promise if ((await git(status.checkoutPath, 'symbolic-ref', 'HEAD')) !== `refs/heads/${req.branch}`) throw new Error('The checked-out branch changed; retry'); try { - await git( + await gitExec( + ['merge', '--ff-only', '--no-autostash', '--no-overwrite-ignore', next], status.checkoutPath, - 'merge', - '--ff-only', - '--no-autostash', - '--no-overwrite-ignore', - next, + writeOpts, ); } catch (error) { const refreshed = await readBranchSyncStatus(req); @@ -229,13 +228,10 @@ async function syncBranchOnce(req: BranchSyncRequest): Promise try { // Local fetch refuses non-fast-forwards and branches checked out in any // worktree at write time. update-ref has no checkout guard. - await git( + await gitExec( + ['fetch', '--no-tags', '--recurse-submodules=no', '.', `${next}:refs/heads/${req.branch}`], req.baseRepo, - 'fetch', - '--no-tags', - '--recurse-submodules=no', - '.', - `${next}:refs/heads/${req.branch}`, + writeOpts, ); } catch (error) { const refreshed = await readBranchSyncStatus(req); From 661ba8fe277dc4f9c6ee8cee7bbe62e085ecee20 Mon Sep 17 00:00:00 2001 From: fmfsaisai Date: Sat, 24 Oct 2026 22:48:46 +0800 Subject: [PATCH 05/11] =?UTF-8?q?fix(worktree):=20=E5=88=B7=E6=96=B0?= =?UTF-8?q?=E5=9B=BE=E6=A0=87=E5=A4=8D=E7=94=A8=E5=85=B1=E4=BA=AB=20Spinne?= =?UTF-8?q?r=20=E5=8A=A8=E7=94=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: fmfsaisai --- .../src/renderer/components/new-chat/WorktreeChipsRow.tsx | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/renderer/components/new-chat/WorktreeChipsRow.tsx b/apps/desktop/src/renderer/components/new-chat/WorktreeChipsRow.tsx index 3bc4fc3f223..4effb7ed04c 100644 --- a/apps/desktop/src/renderer/components/new-chat/WorktreeChipsRow.tsx +++ b/apps/desktop/src/renderer/components/new-chat/WorktreeChipsRow.tsx @@ -50,6 +50,7 @@ import { useTranslation } from 'react-i18next'; import { cn } from '@/lib/utils'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { Tip, Tooltip } from '@/components/ui/tooltip'; +import { Spinner } from '@/components/ui/spinner'; import { FolderPickerPopover, addRecentFolder, @@ -870,11 +871,7 @@ export function BranchWorktreeChip({ {sync.done ? ( ) : ( - + )} From f5322e66c80f161c50562acb69a220dad5807830 Mon Sep 17 00:00:00 2001 From: fmfsaisai Date: Sat, 24 Oct 2026 22:56:22 +0800 Subject: [PATCH 06/11] =?UTF-8?q?fix(worktree):=20=E7=B2=BE=E7=AE=80?= =?UTF-8?q?=E5=88=86=E6=94=AF=E5=88=B7=E6=96=B0=E6=8F=90=E7=A4=BA=E6=96=87?= =?UTF-8?q?=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: fmfsaisai --- .../src/renderer/i18n/locales/en/common.json | 22 +++++++++---------- .../src/renderer/i18n/locales/ja/common.json | 22 +++++++++---------- .../src/renderer/i18n/locales/ko/common.json | 22 +++++++++---------- .../renderer/i18n/locales/zh-CN/common.json | 22 +++++++++---------- .../renderer/i18n/locales/zh-TW/common.json | 22 +++++++++---------- 5 files changed, 55 insertions(+), 55 deletions(-) diff --git a/apps/desktop/src/renderer/i18n/locales/en/common.json b/apps/desktop/src/renderer/i18n/locales/en/common.json index fd17bb85412..27c89e25e17 100644 --- a/apps/desktop/src/renderer/i18n/locales/en/common.json +++ b/apps/desktop/src/renderer/i18n/locales/en/common.json @@ -11367,20 +11367,20 @@ } }, "branchSync": { - "multiple-checkouts": "{{branch}} is checked out in multiple directories and cannot be updated safely", + "multiple-checkouts": "{{branch}} is open in multiple directories. Cannot update", "changed": "The branch state changed. Please retry.", - "action": "Fetch the latest commits for {{remoteBranch}} from {{remote}} and update local {{branch}}", - "remoteAction": "Fetch the latest commits for {{remoteBranch}} from {{remote}}", - "checkedOut": "{{branch}} is checked out in “{{directory}}”. Its files will also update; your uncommitted changes will be preserved.", - "fetching": "Fetching from {{remote}}…", + "action": "Pull the latest commits from {{remote}}/{{remoteBranch}}", + "remoteAction": "Pull the latest commits from {{remote}}/{{remoteBranch}}", + "checkedOut": "Also updates files in {{directory}}", + "fetching": "Pulling…", "updated": "Updated by {{count}} commits", "current": "Already up to date", - "keptAhead": "Keeping the local version with {{count}} unpushed commits", - "failed": "Last fetch failed: {{reason}}. Click to retry.", - "diverged": "Local {{branch}} and {{remote}}/{{remoteBranch}} have diverged and cannot update automatically. The new worktree will use local {{branch}}.", - "conflict": "Your uncommitted changes overlap the remote updates. Commit them or use git stash before updating.", + "keptAhead": "{{count}} unpushed local commits. No update needed", + "failed": "Pull failed: {{reason}}", + "diverged": "Local and {{remote}} have diverged.\nCannot update automatically", + "conflict": "Local changes conflict with remote updates.\nCommit or stash first", "missing-remote": "{{remoteBranch}} does not exist on {{remote}}", - "no-remote": "This project has no remote repository", + "no-remote": "No remote repository configured", "not-local": "Select a local branch or the remote default branch", "ahead": "{{count}} commits ahead of {{remote}}", "behind": "{{count}} commits behind {{remote}}", @@ -11393,7 +11393,7 @@ "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": "The repository changed or is busy. Retry after the other Git operation finishes.", + "busy": "Repository is busy. Try again later", "changed": "The checked-out branch changed. Retry." } } diff --git a/apps/desktop/src/renderer/i18n/locales/ja/common.json b/apps/desktop/src/renderer/i18n/locales/ja/common.json index ad22c3bd581..c58ead8b060 100644 --- a/apps/desktop/src/renderer/i18n/locales/ja/common.json +++ b/apps/desktop/src/renderer/i18n/locales/ja/common.json @@ -11332,20 +11332,20 @@ } }, "branchSync": { - "multiple-checkouts": "{{branch}} は複数のディレクトリで開かれているため、安全に更新できません", + "multiple-checkouts": "{{branch}} が複数のディレクトリで開かれているため、更新できません", "changed": "ブランチの状態が変わりました。再試行してください", - "action": "{{remote}} から {{remoteBranch}} の最新コミットを取得し、ローカルの {{branch}} を更新", - "remoteAction": "{{remote}} から {{remoteBranch}} の最新コミットを取得", - "checkedOut": "{{branch}} は「{{directory}}」で使用中です。そのファイルも更新されます。未コミットの変更は保持されます。", - "fetching": "{{remote}} から取得中…", + "action": "{{remote}}/{{remoteBranch}} の最新コミットを取得", + "remoteAction": "{{remote}}/{{remoteBranch}} の最新コミットを取得", + "checkedOut": "{{directory}} 内のファイルも更新します", + "fetching": "取得中…", "updated": "{{count}} コミット更新しました", "current": "すでに最新です", - "keptAhead": "未プッシュの {{count}} コミットを含むローカル版を保持します", - "failed": "前回の取得に失敗:{{reason}}。クリックして再試行。", - "diverged": "ローカルの {{branch}} と {{remote}}/{{remoteBranch}} は分岐しており、自動更新できません。新しい worktree はローカルの {{branch}} を使用します。", - "conflict": "未コミットの変更とリモートの更新が同じファイルにあります。先にコミットするか、git stash で変更を退避してください。", + "keptAhead": "未プッシュのコミットが {{count}} 件あります。更新は不要です", + "failed": "取得失敗:{{reason}}", + "diverged": "ローカルと {{remote}} の両方に新しいコミットがあり、自動更新できません", + "conflict": "未コミットの変更がリモートの更新と競合しています。先にコミットまたは stash してください", "missing-remote": "{{remote}} に {{remoteBranch}} がありません", - "no-remote": "このプロジェクトにリモートリポジトリはありません", + "no-remote": "リモートリポジトリが未設定です", "not-local": "ローカルブランチまたはリモートの既定ブランチを選択してください", "ahead": "{{remote}} より {{count}} コミット先行", "behind": "{{remote}} より {{count}} コミット遅れ", @@ -11358,7 +11358,7 @@ "auth": "Git の認証に失敗しました。リモートの認証情報を確認してください。", "network": "リモートリポジトリに接続できません。接続を確認してください。", "missing": "リモートブランチは存在しません。", - "busy": "リポジトリが変更されたか使用中です。他の Git 操作の完了後に再試行してください。", + "busy": "リポジトリは使用中です。後で再試行してください", "changed": "チェックアウト中のブランチが変更されました。再試行してください。" } } diff --git a/apps/desktop/src/renderer/i18n/locales/ko/common.json b/apps/desktop/src/renderer/i18n/locales/ko/common.json index 90e8a6a942b..80f0b41baeb 100644 --- a/apps/desktop/src/renderer/i18n/locales/ko/common.json +++ b/apps/desktop/src/renderer/i18n/locales/ko/common.json @@ -11332,20 +11332,20 @@ } }, "branchSync": { - "multiple-checkouts": "{{branch}} 브랜치가 여러 디렉터리에 열려 있어 안전하게 업데이트할 수 없습니다", + "multiple-checkouts": "{{branch}}이(가) 여러 디렉터리에 열려 있어 업데이트할 수 없습니다", "changed": "브랜치 상태가 변경되었습니다. 다시 시도해 주세요", - "action": "{{remote}}에서 {{remoteBranch}}의 최신 커밋을 가져와 로컬 {{branch}} 업데이트", - "remoteAction": "{{remote}}에서 {{remoteBranch}}의 최신 커밋 가져오기", - "checkedOut": "{{branch}}은(는) “{{directory}}”에서 사용 중입니다. 해당 파일도 업데이트되며 커밋하지 않은 변경 사항은 유지됩니다.", - "fetching": "{{remote}}에서 가져오는 중…", + "action": "{{remote}}/{{remoteBranch}}의 최신 커밋 가져오기", + "remoteAction": "{{remote}}/{{remoteBranch}}의 최신 커밋 가져오기", + "checkedOut": "{{directory}}의 파일도 업데이트됩니다", + "fetching": "가져오는 중…", "updated": "커밋 {{count}}개 업데이트됨", "current": "이미 최신 상태입니다", - "keptAhead": "푸시하지 않은 커밋 {{count}}개가 있는 로컬 버전을 유지합니다", - "failed": "마지막 가져오기 실패: {{reason}}. 클릭하여 다시 시도하세요.", - "diverged": "로컬 {{branch}}와 {{remote}}/{{remoteBranch}} 양쪽에 새 커밋이 있어 자동 업데이트할 수 없습니다. 새 worktree는 로컬 {{branch}}을(를) 사용합니다.", - "conflict": "커밋하지 않은 변경 사항과 원격 업데이트가 같은 파일에 있습니다. 먼저 커밋하거나 git stash로 변경 사항을 보관하세요.", + "keptAhead": "푸시하지 않은 로컬 커밋 {{count}}개. 업데이트가 필요 없습니다", + "failed": "가져오기 실패: {{reason}}", + "diverged": "로컬과 {{remote}} 양쪽에 새 커밋이 있어 자동 업데이트할 수 없습니다", + "conflict": "커밋하지 않은 변경 사항이 원격 업데이트와 충돌합니다. 먼저 커밋하거나 stash 하세요", "missing-remote": "{{remote}}에 {{remoteBranch}}이(가) 없습니다", - "no-remote": "이 프로젝트에 원격 저장소가 설정되지 않았습니다", + "no-remote": "원격 저장소가 설정되지 않았습니다", "not-local": "로컬 브랜치 또는 원격 기본 브랜치를 선택하세요", "ahead": "{{remote}}보다 커밋 {{count}}개 앞섬", "behind": "{{remote}}보다 커밋 {{count}}개 뒤처짐", @@ -11358,7 +11358,7 @@ "auth": "Git 인증에 실패했습니다. 원격 인증 정보를 확인하세요.", "network": "원격 저장소에 연결할 수 없습니다. 연결을 확인하세요.", "missing": "원격 브랜치가 더 이상 존재하지 않습니다.", - "busy": "저장소가 변경되었거나 사용 중입니다. 다른 Git 작업이 끝난 후 다시 시도하세요.", + "busy": "저장소가 사용 중입니다. 잠시 후 다시 시도하세요", "changed": "체크아웃된 브랜치가 변경되었습니다. 다시 시도하세요." } } 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 a7f3677ffb4..0798846febf 100644 --- a/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json +++ b/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json @@ -11332,20 +11332,20 @@ } }, "branchSync": { - "multiple-checkouts": "{{branch}} 同时在多个目录里打开,没法安全更新", + "multiple-checkouts": "{{branch}} 在多个目录中打开,无法更新", "changed": "分支状态已变化,请重试", - "action": "从 {{remote}} 拉取 {{remoteBranch}} 的最新提交,并更新本地 {{branch}}", - "remoteAction": "从 {{remote}} 拉取 {{remoteBranch}} 的最新提交", - "checkedOut": "{{branch}} 正在「{{directory}}」里使用,那里的文件会一起更新,你没提交的改动不受影响", - "fetching": "正在从 {{remote}} 拉取…", + "action": "拉取 {{remote}}/{{remoteBranch}} 的最新提交", + "remoteAction": "拉取 {{remote}}/{{remoteBranch}} 的最新提交", + "checkedOut": "会同步更新 {{directory}} 中的文件", + "fetching": "正在拉取…", "updated": "已更新 {{count}} 个提交", "current": "已是最新", - "keptAhead": "本地有 {{count}} 个提交还没推送,保留本地版本", - "failed": "上次拉取失败:{{reason}},点击重试", - "diverged": "本地 {{branch}} 和 {{remote}} 上的 {{remoteBranch}} 各有新提交,没法自动更新。新 worktree 会用本地 {{branch}}", - "conflict": "你还没提交的改动和远程的更新涉及同一批文件,没法更新。可以先提交,或者用 git stash 把改动收起来", + "keptAhead": "本地有 {{count}} 个未推送的提交,无需更新", + "failed": "拉取失败:{{reason}}", + "diverged": "本地与 {{remote}} 各有新提交,无法自动更新", + "conflict": "未提交的改动与远程更新冲突,请先提交或 stash", "missing-remote": "{{remote}} 上没有 {{remoteBranch}}", - "no-remote": "这个项目没有设置远程仓库", + "no-remote": "未设置远程仓库", "not-local": "请先选择本地分支或远程主分支", "ahead": "领先 {{remote}} {{count}} 个提交", "behind": "落后 {{remote}} {{count}} 个提交", @@ -11358,7 +11358,7 @@ "auth": "Git 认证失败,请检查远程仓库凭证", "network": "无法连接远程仓库,请检查网络连接", "missing": "远程分支已不存在", - "busy": "仓库正在更新或已发生变化,请等其它 Git 操作完成后重试", + "busy": "仓库正忙,请稍后重试", "changed": "检出的分支已变化,请重试" } } 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 e7ac8939d42..733b53136dd 100644 --- a/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json +++ b/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json @@ -11332,20 +11332,20 @@ } }, "branchSync": { - "multiple-checkouts": "{{branch}} 同時在多個目錄中開啟,無法安全更新", + "multiple-checkouts": "{{branch}} 在多個目錄中開啟,無法更新", "changed": "分支狀態已變更,請重試", - "action": "從 {{remote}} 取得 {{remoteBranch}} 的最新提交,並更新本機 {{branch}}", - "remoteAction": "從 {{remote}} 取得 {{remoteBranch}} 的最新提交", - "checkedOut": "{{branch}} 正在「{{directory}}」中使用,那裡的檔案會一起更新,你尚未提交的變更不受影響", - "fetching": "正在從 {{remote}} 取得更新…", + "action": "取得 {{remote}}/{{remoteBranch}} 的最新提交", + "remoteAction": "取得 {{remote}}/{{remoteBranch}} 的最新提交", + "checkedOut": "會同步更新 {{directory}} 中的檔案", + "fetching": "正在取得更新…", "updated": "已更新 {{count}} 個提交", "current": "已是最新", - "keptAhead": "本機有 {{count}} 個提交尚未推送,保留本機版本", - "failed": "上次取得更新失敗:{{reason}},點擊重試", - "diverged": "本機 {{branch}} 和 {{remote}} 上的 {{remoteBranch}} 各有新提交,無法自動更新。新 worktree 會使用本機 {{branch}}", - "conflict": "尚未提交的變更與遠端更新涉及相同檔案,無法更新。請先提交,或使用 git stash 收起變更", + "keptAhead": "本機有 {{count}} 個尚未推送的提交,無需更新", + "failed": "取得更新失敗:{{reason}}", + "diverged": "本機與 {{remote}} 各有新提交,無法自動更新", + "conflict": "尚未提交的變更與遠端更新衝突,請先提交或 stash", "missing-remote": "{{remote}} 上沒有 {{remoteBranch}}", - "no-remote": "這個專案沒有設定遠端儲存庫", + "no-remote": "未設定遠端儲存庫", "not-local": "請先選擇本機分支或遠端主分支", "ahead": "領先 {{remote}} {{count}} 個提交", "behind": "落後 {{remote}} {{count}} 個提交", @@ -11358,7 +11358,7 @@ "auth": "Git 認證失敗,請檢查遠端儲存庫憑證", "network": "無法連線至遠端儲存庫,請檢查網路連線", "missing": "遠端分支已不存在", - "busy": "儲存庫正在更新或已變更,請等其他 Git 操作完成後重試", + "busy": "儲存庫忙碌中,請稍後重試", "changed": "檢出的分支已變更,請重試" } } From f1afd19f72c6684138bf54a9da02406c85b235ea Mon Sep 17 00:00:00 2001 From: fmfsaisai Date: Sat, 24 Oct 2026 23:01:27 +0800 Subject: [PATCH 07/11] =?UTF-8?q?fix(worktree):=20=E5=88=B7=E6=96=B0?= =?UTF-8?q?=E6=8F=90=E7=A4=BA=E6=8C=89=E5=AE=8C=E6=95=B4=E5=8D=95=E8=AF=8D?= =?UTF-8?q?=E6=8D=A2=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: fmfsaisai --- .../src/renderer/components/new-chat/WorktreeChipsRow.tsx | 5 ++++- apps/desktop/src/renderer/i18n/locales/en/common.json | 4 ++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/renderer/components/new-chat/WorktreeChipsRow.tsx b/apps/desktop/src/renderer/components/new-chat/WorktreeChipsRow.tsx index 4effb7ed04c..aa89c549464 100644 --- a/apps/desktop/src/renderer/components/new-chat/WorktreeChipsRow.tsx +++ b/apps/desktop/src/renderer/components/new-chat/WorktreeChipsRow.tsx @@ -876,7 +876,10 @@ export function BranchWorktreeChip({ - + {sync.hint} diff --git a/apps/desktop/src/renderer/i18n/locales/en/common.json b/apps/desktop/src/renderer/i18n/locales/en/common.json index 27c89e25e17..32acded893f 100644 --- a/apps/desktop/src/renderer/i18n/locales/en/common.json +++ b/apps/desktop/src/renderer/i18n/locales/en/common.json @@ -11377,8 +11377,8 @@ "current": "Already up to date", "keptAhead": "{{count}} unpushed local commits. No update needed", "failed": "Pull failed: {{reason}}", - "diverged": "Local and {{remote}} have diverged.\nCannot update automatically", - "conflict": "Local changes conflict with remote updates.\nCommit or stash first", + "diverged": "Local and {{remote}} have diverged. Cannot update automatically", + "conflict": "Local 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", From 12dc5eccecb695636c51cc80d569823dae24b7fb Mon Sep 17 00:00:00 2001 From: fmfsaisai Date: Sat, 24 Oct 2026 23:04:27 +0800 Subject: [PATCH 08/11] =?UTF-8?q?fix(worktree):=20=E4=B8=BA=E5=88=B7?= =?UTF-8?q?=E6=96=B0=E6=8F=90=E7=A4=BA=E7=9A=84=E4=B8=A4=E6=AE=B5=E8=AF=B4?= =?UTF-8?q?=E6=98=8E=E5=A2=9E=E5=8A=A0=E9=97=B4=E8=B7=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: fmfsaisai --- .../desktop/scripts/fixtures/branch-sync/ui-page.mjs | 12 ++++++++++++ .../components/new-chat/WorktreeChipsRow.tsx | 6 +++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/apps/desktop/scripts/fixtures/branch-sync/ui-page.mjs b/apps/desktop/scripts/fixtures/branch-sync/ui-page.mjs index 29bfe529800..b3a8fa0ab23 100644 --- a/apps/desktop/scripts/fixtures/branch-sync/ui-page.mjs +++ b/apps/desktop/scripts/fixtures/branch-sync/ui-page.mjs @@ -23,6 +23,18 @@ export default { }); await trigger.waitFor(); await ui.capture('capsule'); + if (ui.case === 'default' || ui.case === 'worker') { + const action = page.getByRole('button', { + name: await ui.t('newChat.branchSync.action', { + remote: 'origin', + remoteBranch: 'main', + }), + exact: false, + }); + await action.hover(); + await page.getByRole('tooltip').waitFor(); + await ui.capture('action-hint'); + } if (ui.case === 'conflict' || ui.case === 'diverged') { const action = page.getByRole('button', { name: await ui.t(`newChat.branchSync.${ui.case}`, { diff --git a/apps/desktop/src/renderer/components/new-chat/WorktreeChipsRow.tsx b/apps/desktop/src/renderer/components/new-chat/WorktreeChipsRow.tsx index aa89c549464..bdb90ee2c33 100644 --- a/apps/desktop/src/renderer/components/new-chat/WorktreeChipsRow.tsx +++ b/apps/desktop/src/renderer/components/new-chat/WorktreeChipsRow.tsx @@ -880,7 +880,11 @@ export function BranchWorktreeChip({ side="top" className="max-w-[320px] whitespace-pre-line break-normal [overflow-wrap:anywhere]" > - {sync.hint} +
+ {sync.hint.split('\n').map((paragraph, index) => ( +

{paragraph}

+ ))} +
)} From 7512b1e38bd2ba8c5335e7e874d881d0581be1f2 Mon Sep 17 00:00:00 2001 From: fmfsaisai Date: Sat, 24 Oct 2026 23:07:48 +0800 Subject: [PATCH 09/11] =?UTF-8?q?fix(worktree):=20=E6=8F=90=E7=A4=BA?= =?UTF-8?q?=E5=90=8C=E6=AD=A5=E6=97=B6=E4=BF=9D=E7=95=99=E6=9C=AA=E6=8F=90?= =?UTF-8?q?=E4=BA=A4=E6=94=B9=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: fmfsaisai --- apps/desktop/src/renderer/i18n/locales/en/common.json | 2 +- apps/desktop/src/renderer/i18n/locales/ja/common.json | 2 +- apps/desktop/src/renderer/i18n/locales/ko/common.json | 2 +- apps/desktop/src/renderer/i18n/locales/zh-CN/common.json | 2 +- apps/desktop/src/renderer/i18n/locales/zh-TW/common.json | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/renderer/i18n/locales/en/common.json b/apps/desktop/src/renderer/i18n/locales/en/common.json index 32acded893f..a270a5db534 100644 --- a/apps/desktop/src/renderer/i18n/locales/en/common.json +++ b/apps/desktop/src/renderer/i18n/locales/en/common.json @@ -11371,7 +11371,7 @@ "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 files in {{directory}}", + "checkedOut": "Also updates files in {{directory}}; uncommitted changes are kept", "fetching": "Pulling…", "updated": "Updated by {{count}} commits", "current": "Already up to date", diff --git a/apps/desktop/src/renderer/i18n/locales/ja/common.json b/apps/desktop/src/renderer/i18n/locales/ja/common.json index c58ead8b060..bb13cbde51f 100644 --- a/apps/desktop/src/renderer/i18n/locales/ja/common.json +++ b/apps/desktop/src/renderer/i18n/locales/ja/common.json @@ -11336,7 +11336,7 @@ "changed": "ブランチの状態が変わりました。再試行してください", "action": "{{remote}}/{{remoteBranch}} の最新コミットを取得", "remoteAction": "{{remote}}/{{remoteBranch}} の最新コミットを取得", - "checkedOut": "{{directory}} 内のファイルも更新します", + "checkedOut": "{{directory}} 内のファイルも更新します。未コミットの変更は保持されます", "fetching": "取得中…", "updated": "{{count}} コミット更新しました", "current": "すでに最新です", diff --git a/apps/desktop/src/renderer/i18n/locales/ko/common.json b/apps/desktop/src/renderer/i18n/locales/ko/common.json index 80f0b41baeb..e9709bcb87f 100644 --- a/apps/desktop/src/renderer/i18n/locales/ko/common.json +++ b/apps/desktop/src/renderer/i18n/locales/ko/common.json @@ -11336,7 +11336,7 @@ "changed": "브랜치 상태가 변경되었습니다. 다시 시도해 주세요", "action": "{{remote}}/{{remoteBranch}}의 최신 커밋 가져오기", "remoteAction": "{{remote}}/{{remoteBranch}}의 최신 커밋 가져오기", - "checkedOut": "{{directory}}의 파일도 업데이트됩니다", + "checkedOut": "{{directory}}의 파일도 업데이트됩니다. 커밋하지 않은 변경 사항은 유지됩니다", "fetching": "가져오는 중…", "updated": "커밋 {{count}}개 업데이트됨", "current": "이미 최신 상태입니다", 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 0798846febf..b231a8c3123 100644 --- a/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json +++ b/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json @@ -11336,7 +11336,7 @@ "changed": "分支状态已变化,请重试", "action": "拉取 {{remote}}/{{remoteBranch}} 的最新提交", "remoteAction": "拉取 {{remote}}/{{remoteBranch}} 的最新提交", - "checkedOut": "会同步更新 {{directory}} 中的文件", + "checkedOut": "会同步更新 {{directory}} 中的文件,未提交的改动会保留", "fetching": "正在拉取…", "updated": "已更新 {{count}} 个提交", "current": "已是最新", 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 733b53136dd..f3876ce3fa1 100644 --- a/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json +++ b/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json @@ -11336,7 +11336,7 @@ "changed": "分支狀態已變更,請重試", "action": "取得 {{remote}}/{{remoteBranch}} 的最新提交", "remoteAction": "取得 {{remote}}/{{remoteBranch}} 的最新提交", - "checkedOut": "會同步更新 {{directory}} 中的檔案", + "checkedOut": "會同步更新 {{directory}} 中的檔案,未提交的變更會保留", "fetching": "正在取得更新…", "updated": "已更新 {{count}} 個提交", "current": "已是最新", From 924d52d294481ed0c725e717204e61cd686cd2d7 Mon Sep 17 00:00:00 2001 From: fmfsaisai Date: Sat, 24 Oct 2026 23:16:45 +0800 Subject: [PATCH 10/11] =?UTF-8?q?fix(worktree):=20=E6=8C=89=E5=BD=93?= =?UTF-8?q?=E5=89=8D=E5=88=86=E6=94=AF=E8=AF=B4=E6=98=8E=E5=88=B7=E6=96=B0?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E8=8C=83=E5=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: fmfsaisai --- apps/desktop/src/renderer/hooks/useBranchSync.ts | 3 +-- apps/desktop/src/renderer/i18n/locales/en/common.json | 2 +- apps/desktop/src/renderer/i18n/locales/ja/common.json | 2 +- apps/desktop/src/renderer/i18n/locales/ko/common.json | 2 +- apps/desktop/src/renderer/i18n/locales/zh-CN/common.json | 2 +- apps/desktop/src/renderer/i18n/locales/zh-TW/common.json | 2 +- 6 files changed, 6 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/renderer/hooks/useBranchSync.ts b/apps/desktop/src/renderer/hooks/useBranchSync.ts index cdb3aef829d..0b4405be041 100644 --- a/apps/desktop/src/renderer/hooks/useBranchSync.ts +++ b/apps/desktop/src/renderer/hooks/useBranchSync.ts @@ -146,8 +146,7 @@ export function useBranchSync( : 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', { ...vars, directory: status.checkoutPath.split(/[\\/]/).filter(Boolean).at(-1) })}`; + if (status?.checkoutPath) hint += `\n${t('newChat.branchSync.checkedOut')}`; if (status?.blocked) hint = blockedHint(status); if (state.result && !status?.blocked) hint = state.result.updated diff --git a/apps/desktop/src/renderer/i18n/locales/en/common.json b/apps/desktop/src/renderer/i18n/locales/en/common.json index a270a5db534..4c2e6b4014c 100644 --- a/apps/desktop/src/renderer/i18n/locales/en/common.json +++ b/apps/desktop/src/renderer/i18n/locales/en/common.json @@ -11371,7 +11371,7 @@ "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 files in {{directory}}; uncommitted changes are kept", + "checkedOut": "Also updates committed files on this branch; uncommitted changes are kept", "fetching": "Pulling…", "updated": "Updated by {{count}} commits", "current": "Already up to date", diff --git a/apps/desktop/src/renderer/i18n/locales/ja/common.json b/apps/desktop/src/renderer/i18n/locales/ja/common.json index bb13cbde51f..3d2e59b56e3 100644 --- a/apps/desktop/src/renderer/i18n/locales/ja/common.json +++ b/apps/desktop/src/renderer/i18n/locales/ja/common.json @@ -11336,7 +11336,7 @@ "changed": "ブランチの状態が変わりました。再試行してください", "action": "{{remote}}/{{remoteBranch}} の最新コミットを取得", "remoteAction": "{{remote}}/{{remoteBranch}} の最新コミットを取得", - "checkedOut": "{{directory}} 内のファイルも更新します。未コミットの変更は保持されます", + "checkedOut": "現在のブランチのコミット済みファイルも更新します。未コミットの変更は保持されます", "fetching": "取得中…", "updated": "{{count}} コミット更新しました", "current": "すでに最新です", diff --git a/apps/desktop/src/renderer/i18n/locales/ko/common.json b/apps/desktop/src/renderer/i18n/locales/ko/common.json index e9709bcb87f..61b4cca2e59 100644 --- a/apps/desktop/src/renderer/i18n/locales/ko/common.json +++ b/apps/desktop/src/renderer/i18n/locales/ko/common.json @@ -11336,7 +11336,7 @@ "changed": "브랜치 상태가 변경되었습니다. 다시 시도해 주세요", "action": "{{remote}}/{{remoteBranch}}의 최신 커밋 가져오기", "remoteAction": "{{remote}}/{{remoteBranch}}의 최신 커밋 가져오기", - "checkedOut": "{{directory}}의 파일도 업데이트됩니다. 커밋하지 않은 변경 사항은 유지됩니다", + "checkedOut": "현재 브랜치의 커밋된 파일도 업데이트됩니다. 커밋하지 않은 변경 사항은 유지됩니다", "fetching": "가져오는 중…", "updated": "커밋 {{count}}개 업데이트됨", "current": "이미 최신 상태입니다", 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 b231a8c3123..766f25324c8 100644 --- a/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json +++ b/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json @@ -11336,7 +11336,7 @@ "changed": "分支状态已变化,请重试", "action": "拉取 {{remote}}/{{remoteBranch}} 的最新提交", "remoteAction": "拉取 {{remote}}/{{remoteBranch}} 的最新提交", - "checkedOut": "会同步更新 {{directory}} 中的文件,未提交的改动会保留", + "checkedOut": "会同步更新当前分支已提交的文件,未提交的改动会保留", "fetching": "正在拉取…", "updated": "已更新 {{count}} 个提交", "current": "已是最新", 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 f3876ce3fa1..89e27033c48 100644 --- a/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json +++ b/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json @@ -11336,7 +11336,7 @@ "changed": "分支狀態已變更,請重試", "action": "取得 {{remote}}/{{remoteBranch}} 的最新提交", "remoteAction": "取得 {{remote}}/{{remoteBranch}} 的最新提交", - "checkedOut": "會同步更新 {{directory}} 中的檔案,未提交的變更會保留", + "checkedOut": "會同步更新目前分支已提交的檔案,未提交的變更會保留", "fetching": "正在取得更新…", "updated": "已更新 {{count}} 個提交", "current": "已是最新", From f512dea3d61a661f616577357ce9e7c4a5739862 Mon Sep 17 00:00:00 2001 From: fmfsaisai Date: Sat, 24 Oct 2026 23:21:44 +0800 Subject: [PATCH 11/11] =?UTF-8?q?fix(worktree):=20=E5=AE=8C=E5=96=84?= =?UTF-8?q?=E5=88=B7=E6=96=B0=E6=96=87=E6=A1=88=E5=A4=8D=E6=95=B0=E4=B8=8E?= =?UTF-8?q?=E5=88=86=E5=8F=89=E7=8A=B6=E6=80=81=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: fmfsaisai --- .../branch-sync/branch-sync-fixture.tsx | 8 +++--- .../scripts/fixtures/branch-sync/ui-page.mjs | 27 ++++++++++++++++++- .../__tests__/BranchWorktreeChip.test.tsx | 22 +++++++++++++++ .../src/renderer/hooks/useBranchSync.ts | 22 ++++++++++----- .../src/renderer/i18n/locales/en/common.json | 19 ++++++++----- .../src/renderer/i18n/locales/ja/common.json | 11 ++++---- .../src/renderer/i18n/locales/ko/common.json | 11 ++++---- .../renderer/i18n/locales/zh-CN/common.json | 11 ++++---- .../renderer/i18n/locales/zh-TW/common.json | 11 ++++---- 9 files changed, 105 insertions(+), 37 deletions(-) diff --git a/apps/desktop/scripts/fixtures/branch-sync/branch-sync-fixture.tsx b/apps/desktop/scripts/fixtures/branch-sync/branch-sync-fixture.tsx index 65157cf2bdf..dca4f0ba21d 100644 --- a/apps/desktop/scripts/fixtures/branch-sync/branch-sync-fixture.tsx +++ b/apps/desktop/scripts/fixtures/branch-sync/branch-sync-fixture.tsx @@ -36,9 +36,9 @@ mountPage({ remote: 'origin', remoteBranch: 'main', remoteOnly: req.branch.startsWith('refs/'), - ahead: ctx.case === 'diverged' ? 2 : 0, - behind: 3, - dirtyFiles: 3, + ahead: ['diverged', 'unchecked'].includes(ctx.case) ? 1 : 0, + behind: ctx.case === 'unchecked' ? 0 : ctx.case === 'default' ? 3 : 1, + dirtyFiles: ctx.case === 'diverged' ? 2 : ctx.case === 'default' ? 3 : 1, checkoutPath: '/projects/cindy', ...(ctx.case === 'diverged' || ctx.case === 'conflict' ? { blocked: ctx.case } : {}), }); @@ -51,7 +51,7 @@ mountPage({ worktreeBranchSyncStatus: async (req: BranchSyncRequest) => read(req), worktreeSyncBranch: async (req: BranchSyncRequest) => ({ status: { ...read(req), behind: 0 }, - updated: 3, + updated: ctx.case === 'unchecked' ? 0 : ctx.case === 'worker' ? 1 : 3, }), }; }, diff --git a/apps/desktop/scripts/fixtures/branch-sync/ui-page.mjs b/apps/desktop/scripts/fixtures/branch-sync/ui-page.mjs index b3a8fa0ab23..0688378ff67 100644 --- a/apps/desktop/scripts/fixtures/branch-sync/ui-page.mjs +++ b/apps/desktop/scripts/fixtures/branch-sync/ui-page.mjs @@ -57,6 +57,23 @@ export default { }); assert.equal(await remote.count(), ['unchecked', 'old-host'].includes(ui.case) ? 0 : 1); await ui.capture('branches'); + if (ui.case === 'unchecked') { + await page.keyboard.press('Escape'); + await page + .getByRole('button', { + name: await ui.t('newChat.branchSync.action', { remote: 'origin', remoteBranch: 'main' }), + exact: false, + }) + .click(); + const result = page.getByRole('button', { + name: await ui.t('newChat.branchSync.keptAhead', { count: 1 }), + exact: true, + }); + await page.mouse.move(340, 350); + await result.hover(); + await page.getByRole('tooltip').waitFor(); + await ui.capture('kept-ahead'); + } if (ui.case === 'default' || ui.case === 'worker') { await page.getByRole('combobox').fill('origin/main'); assert.equal(await page.getByRole('option').count(), 2); @@ -72,10 +89,18 @@ export default { await action.click(); await page .getByRole('button', { - name: await ui.t('newChat.branchSync.updated', { count: 3 }), + name: await ui.t('newChat.branchSync.updated', { count: ui.case === 'worker' ? 1 : 3 }), exact: true, }) .waitFor(); + await page.mouse.move(340, 350); + await page + .getByRole('button', { + name: await ui.t('newChat.branchSync.updated', { count: ui.case === 'worker' ? 1 : 3 }), + exact: true, + }) + .hover(); + await page.getByRole('tooltip').waitFor(); await ui.capture('updated'); } }, diff --git a/apps/desktop/src/renderer/components/new-chat/__tests__/BranchWorktreeChip.test.tsx b/apps/desktop/src/renderer/components/new-chat/__tests__/BranchWorktreeChip.test.tsx index 955ce4c01ce..4733de4ae78 100644 --- a/apps/desktop/src/renderer/components/new-chat/__tests__/BranchWorktreeChip.test.tsx +++ b/apps/desktop/src/renderer/components/new-chat/__tests__/BranchWorktreeChip.test.tsx @@ -103,6 +103,28 @@ it('shares counts with the selected row and keeps full remote refs distinct duri fireEvent.keyDown(screen.getByRole('combobox'), { key: 'Enter' }); expect(props.onPick).toHaveBeenLastCalledWith('origin/main'); }); +// Product rule: diverged branch details name the remote once and retain dirty-file counts. +// Standard tier: exercise the real capsule with both counts positive; the existing case covers behind-only. +it('combines diverged counts into one status segment and keeps dirty files', async () => { + status.mockResolvedValue({ ...baseStatus, ahead: 1, behind: 1, blocked: 'diverged' }); + render(); + await waitFor(() => expect(status).toHaveBeenCalledOnce()); + await act(async () => { + fireEvent.click(branchButton()); + }); + const expected = [ + mocks.t('newChat.branchSync.diverging', { + remote: 'origin/main', + ahead: 1, + behind: 1, + count: 1, + }), + mocks.t('newChat.branchSync.dirty', { count: 2 }), + ].join(' · '); + expect(screen.getByText(expected)).toBeTruthy(); + expect(screen.queryByText(/newChat.branchSync.ahead/)).toBeNull(); + expect(screen.queryByText(/newChat.branchSync.behind/)).toBeNull(); +}); it('shows remote sources only when worktree is checked or branchOnly is used', async () => { const view = render(); await waitFor(() => expect(refreshButton().hasAttribute('disabled')).toBe(false)); diff --git a/apps/desktop/src/renderer/hooks/useBranchSync.ts b/apps/desktop/src/renderer/hooks/useBranchSync.ts index 0b4405be041..c57583c5e94 100644 --- a/apps/desktop/src/renderer/hooks/useBranchSync.ts +++ b/apps/desktop/src/renderer/hooks/useBranchSync.ts @@ -156,15 +156,25 @@ export function useBranchSync( : 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 = [ - status?.ahead - ? t('newChat.branchSync.ahead', { - ...vars, + diverging + ? t('newChat.branchSync.diverging', { remote: `${vars.remote}/${vars.remoteBranch}`, - count: status.ahead, + ahead, + behind, + count: ahead, }) - : '', - status?.behind + : 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}`, diff --git a/apps/desktop/src/renderer/i18n/locales/en/common.json b/apps/desktop/src/renderer/i18n/locales/en/common.json index 4c2e6b4014c..40e9771f6c1 100644 --- a/apps/desktop/src/renderer/i18n/locales/en/common.json +++ b/apps/desktop/src/renderer/i18n/locales/en/common.json @@ -11373,18 +11373,25 @@ "remoteAction": "Pull the latest commits from {{remote}}/{{remoteBranch}}", "checkedOut": "Also updates committed files on this branch; uncommitted changes are kept", "fetching": "Pulling…", - "updated": "Updated by {{count}} commits", + "updated_one": "Pulled {{count}} new commit", + "updated_other": "Pulled {{count}} new commits", "current": "Already up to date", - "keptAhead": "{{count}} unpushed local commits. No update needed", + "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": "Local changes conflict with remote updates. Commit or stash first", + "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": "{{count}} commits ahead of {{remote}}", - "behind": "{{count}} commits behind {{remote}}", - "dirty": "{{count}} uncommitted files", + "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": { diff --git a/apps/desktop/src/renderer/i18n/locales/ja/common.json b/apps/desktop/src/renderer/i18n/locales/ja/common.json index 3d2e59b56e3..0531a9d281d 100644 --- a/apps/desktop/src/renderer/i18n/locales/ja/common.json +++ b/apps/desktop/src/renderer/i18n/locales/ja/common.json @@ -11338,18 +11338,19 @@ "remoteAction": "{{remote}}/{{remoteBranch}} の最新コミットを取得", "checkedOut": "現在のブランチのコミット済みファイルも更新します。未コミットの変更は保持されます", "fetching": "取得中…", - "updated": "{{count}} コミット更新しました", + "updated_other": "{{count}} 件のコミットを取り込みました", "current": "すでに最新です", - "keptAhead": "未プッシュのコミットが {{count}} 件あります。更新は不要です", + "keptAhead_other": "未プッシュのコミットが {{count}} 件あります。更新は不要です", "failed": "取得失敗:{{reason}}", "diverged": "ローカルと {{remote}} の両方に新しいコミットがあり、自動更新できません", "conflict": "未コミットの変更がリモートの更新と競合しています。先にコミットまたは stash してください", "missing-remote": "{{remote}} に {{remoteBranch}} がありません", "no-remote": "リモートリポジトリが未設定です", "not-local": "ローカルブランチまたはリモートの既定ブランチを選択してください", - "ahead": "{{remote}} より {{count}} コミット先行", - "behind": "{{remote}} より {{count}} コミット遅れ", - "dirty": "未コミットのファイル {{count}} 件", + "ahead_other": "{{remote}} より {{count}} コミット先行", + "behind_other": "{{remote}} より {{count}} コミット遅れ", + "dirty_other": "未コミットのファイル {{count}} 件", + "diverging_other": "{{ahead}} コミット先行、{{behind}} コミット遅れ({{remote}})", "remoteGroup": "リモート", "localGroup": "ローカルブランチ", "errors": { diff --git a/apps/desktop/src/renderer/i18n/locales/ko/common.json b/apps/desktop/src/renderer/i18n/locales/ko/common.json index 61b4cca2e59..6e06b44b5dd 100644 --- a/apps/desktop/src/renderer/i18n/locales/ko/common.json +++ b/apps/desktop/src/renderer/i18n/locales/ko/common.json @@ -11338,18 +11338,19 @@ "remoteAction": "{{remote}}/{{remoteBranch}}의 최신 커밋 가져오기", "checkedOut": "현재 브랜치의 커밋된 파일도 업데이트됩니다. 커밋하지 않은 변경 사항은 유지됩니다", "fetching": "가져오는 중…", - "updated": "커밋 {{count}}개 업데이트됨", + "updated_other": "커밋 {{count}}개 업데이트됨", "current": "이미 최신 상태입니다", - "keptAhead": "푸시하지 않은 로컬 커밋 {{count}}개. 업데이트가 필요 없습니다", + "keptAhead_other": "푸시하지 않은 로컬 커밋 {{count}}개. 업데이트가 필요 없습니다", "failed": "가져오기 실패: {{reason}}", "diverged": "로컬과 {{remote}} 양쪽에 새 커밋이 있어 자동 업데이트할 수 없습니다", "conflict": "커밋하지 않은 변경 사항이 원격 업데이트와 충돌합니다. 먼저 커밋하거나 stash 하세요", "missing-remote": "{{remote}}에 {{remoteBranch}}이(가) 없습니다", "no-remote": "원격 저장소가 설정되지 않았습니다", "not-local": "로컬 브랜치 또는 원격 기본 브랜치를 선택하세요", - "ahead": "{{remote}}보다 커밋 {{count}}개 앞섬", - "behind": "{{remote}}보다 커밋 {{count}}개 뒤처짐", - "dirty": "커밋하지 않은 파일 {{count}}개", + "ahead_other": "{{remote}}보다 커밋 {{count}}개 앞섬", + "behind_other": "{{remote}}보다 커밋 {{count}}개 뒤처짐", + "dirty_other": "커밋하지 않은 파일 {{count}}개", + "diverging_other": "{{ahead}}개 커밋 앞섬, {{behind}}개 커밋 뒤처짐({{remote}})", "remoteGroup": "원격", "localGroup": "로컬 브랜치", "errors": { 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 766f25324c8..6d02d356354 100644 --- a/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json +++ b/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json @@ -11338,18 +11338,19 @@ "remoteAction": "拉取 {{remote}}/{{remoteBranch}} 的最新提交", "checkedOut": "会同步更新当前分支已提交的文件,未提交的改动会保留", "fetching": "正在拉取…", - "updated": "已更新 {{count}} 个提交", + "updated_other": "已更新 {{count}} 个提交", "current": "已是最新", - "keptAhead": "本地有 {{count}} 个未推送的提交,无需更新", + "keptAhead_other": "本地有 {{count}} 个未推送的提交,无需更新", "failed": "拉取失败:{{reason}}", "diverged": "本地与 {{remote}} 各有新提交,无法自动更新", "conflict": "未提交的改动与远程更新冲突,请先提交或 stash", "missing-remote": "{{remote}} 上没有 {{remoteBranch}}", "no-remote": "未设置远程仓库", "not-local": "请先选择本地分支或远程主分支", - "ahead": "领先 {{remote}} {{count}} 个提交", - "behind": "落后 {{remote}} {{count}} 个提交", - "dirty": "未提交 {{count}} 个文件", + "ahead_other": "领先 {{remote}} {{count}} 个提交", + "behind_other": "落后 {{remote}} {{count}} 个提交", + "dirty_other": "未提交 {{count}} 个文件", + "diverging_other": "领先 {{ahead}}、落后 {{behind}} 个提交({{remote}})", "remoteGroup": "远程", "localGroup": "本地分支", "errors": { 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 89e27033c48..091b4d8a5f5 100644 --- a/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json +++ b/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json @@ -11338,18 +11338,19 @@ "remoteAction": "取得 {{remote}}/{{remoteBranch}} 的最新提交", "checkedOut": "會同步更新目前分支已提交的檔案,未提交的變更會保留", "fetching": "正在取得更新…", - "updated": "已更新 {{count}} 個提交", + "updated_other": "已更新 {{count}} 個提交", "current": "已是最新", - "keptAhead": "本機有 {{count}} 個尚未推送的提交,無需更新", + "keptAhead_other": "本機有 {{count}} 個尚未推送的提交,無需更新", "failed": "取得更新失敗:{{reason}}", "diverged": "本機與 {{remote}} 各有新提交,無法自動更新", "conflict": "尚未提交的變更與遠端更新衝突,請先提交或 stash", "missing-remote": "{{remote}} 上沒有 {{remoteBranch}}", "no-remote": "未設定遠端儲存庫", "not-local": "請先選擇本機分支或遠端主分支", - "ahead": "領先 {{remote}} {{count}} 個提交", - "behind": "落後 {{remote}} {{count}} 個提交", - "dirty": "尚未提交 {{count}} 個檔案", + "ahead_other": "領先 {{remote}} {{count}} 個提交", + "behind_other": "落後 {{remote}} {{count}} 個提交", + "dirty_other": "尚未提交 {{count}} 個檔案", + "diverging_other": "領先 {{ahead}}、落後 {{behind}} 個提交({{remote}})", "remoteGroup": "遠端", "localGroup": "本機分支", "errors": {