From 9413a471cb98a43ce6c653cbc1d76d2fba8e1f10 Mon Sep 17 00:00:00 2001 From: leyoonafr Date: Mon, 31 Aug 2026 01:33:38 +0800 Subject: [PATCH] feat: add safe branch switching (#12) --- .../src/repository-protocol-adapter.ts | 6 + apps/launcher/src/standalone-runtime.ts | 5 + apps/ui/src/App.tsx | 4 + .../RepositoryOverview.interactions.test.tsx | 129 ++++++ apps/ui/src/RepositoryOverview.tsx | 123 +++++- apps/ui/src/overview-fixtures.ts | 6 + .../ui/src/protocol-repository-source.test.ts | 84 +++- apps/ui/src/protocol-repository-source.ts | 59 ++- apps/ui/src/repository-overview-model.ts | 17 +- apps/ui/src/repository-store.ts | 148 ++++++- packages/protocol/src/schemas.ts | 1 + packages/repository-engine/src/index.ts | 1 + .../src/repository-engine.ts | 2 +- .../src/repository-observation.test.ts | 21 + .../src/repository-observation.ts | 92 +++- .../src/repository-refresh.ts | 24 + .../src/repository-session.ts | 416 +++++++++++++++++- tests/e2e/protocol-runtime.e2e.test.ts | 83 ++++ .../branch-switching.integration.test.ts | 393 +++++++++++++++++ 19 files changed, 1590 insertions(+), 24 deletions(-) create mode 100644 tests/integration/branch-switching.integration.test.ts diff --git a/apps/launcher/src/repository-protocol-adapter.ts b/apps/launcher/src/repository-protocol-adapter.ts index 7333f74..8981e66 100644 --- a/apps/launcher/src/repository-protocol-adapter.ts +++ b/apps/launcher/src/repository-protocol-adapter.ts @@ -126,6 +126,12 @@ function worktreeStatus(source: PublishedWorktreeSnapshot) { : 'The Worktree status is unavailable.', }; } + if (source.status.inProgressOperation !== undefined) { + return { + kind: 'in_progress' as const, + operation: source.status.inProgressOperation, + }; + } if (source.status.clean) return { kind: 'clean' as const }; return { kind: 'changed' as const, diff --git a/apps/launcher/src/standalone-runtime.ts b/apps/launcher/src/standalone-runtime.ts index 3d27d57..6bfc179 100644 --- a/apps/launcher/src/standalone-runtime.ts +++ b/apps/launcher/src/standalone-runtime.ts @@ -67,6 +67,11 @@ export async function startStandaloneRuntime( repositorySession === undefined || options.projectPath === undefined ? undefined : { + branchSearch: (request) => + repositorySession!.searchBranches(request), + commands: (request) => repositorySession!.dispatch(request), + operationRecovery: (operationId) => + repositorySession!.recoverOperation(operationId), snapshot: async () => toProtocolRepositorySnapshot( await repositorySession!.requestRefresh(), diff --git a/apps/ui/src/App.tsx b/apps/ui/src/App.tsx index 5d06637..bd84b6c 100644 --- a/apps/ui/src/App.tsx +++ b/apps/ui/src/App.tsx @@ -7,6 +7,10 @@ const loadingStore = createRepositoryStore({ subscribe: () => () => undefined, requestRefresh: () => undefined, requestFetch: () => undefined, + searchBranches: async () => ({ refsRevision: 0, candidates: [] }), + switchBranch: async () => { + throw new Error('Branch switching is unavailable while loading.'); + }, }); export function App({ diff --git a/apps/ui/src/RepositoryOverview.interactions.test.tsx b/apps/ui/src/RepositoryOverview.interactions.test.tsx index 35607dd..eccd231 100644 --- a/apps/ui/src/RepositoryOverview.interactions.test.tsx +++ b/apps/ui/src/RepositoryOverview.interactions.test.tsx @@ -3,6 +3,7 @@ import { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { operationIdSchema, refIdSchema } from '@codex-git/protocol'; import { App } from './overview.js'; import { createOverviewFixture } from './overview-fixtures.js'; @@ -381,6 +382,134 @@ describe('Repository overview interactions', () => { ); }); + it('separates Branch groups, disables occupied Local Branches, and navigates to their Worktree', async () => { + const fixture = createOverviewFixture('many-worktrees'); + const fixtureState = fixture.source.getSnapshot(); + const occupiedWorktree = + fixtureState.kind === 'repository' + ? fixtureState.snapshot.worktrees.find( + ({ displayName }) => displayName === 'agent-alpha', + ) + : undefined; + if (occupiedWorktree === undefined) throw new Error('Missing Worktree'); + const source = { + ...fixture.source, + async searchBranches() { + return { + refsRevision: 2, + candidates: [ + { + refId: refIdSchema.parse('ref_0123456789abcdef0123456789abcdef'), + kind: 'local' as const, + displayName: 'available', + occupiedBy: null, + }, + { + refId: refIdSchema.parse('ref_1123456789abcdef0123456789abcdef'), + kind: 'local' as const, + displayName: 'feat/agent-alpha', + occupiedBy: occupiedWorktree.worktreeId, + }, + { + refId: refIdSchema.parse('ref_2123456789abcdef0123456789abcdef'), + kind: 'remote_tracking' as const, + displayName: 'origin/review-ready', + occupiedBy: null, + }, + ], + }; + }, + }; + const store = createRepositoryStore(source); + act(() => root.render()); + + await act(async () => button('Switch Branch for codex-git').click()); + + expect(container.textContent).toContain('Local Branches'); + expect(container.textContent).toContain('Remote-tracking Branches'); + expect(button('Switch codex-git to feat/agent-alpha').disabled).toBe(true); + act(() => button('Go to Worktree occupying feat/agent-alpha').click()); + expect(container.querySelector('#worktree-title')?.textContent).toBe( + 'agent-alpha', + ); + }); + + it('submits an exact Branch target and clears the picker after reconciled success', async () => { + const fixture = createOverviewFixture('one-worktree'); + const targetRefId = refIdSchema.parse( + 'ref_3123456789abcdef0123456789abcdef', + ); + const switchBranch = vi.fn(async () => { + const current = fixture.source.getSnapshot(); + if (current.kind !== 'repository') throw new Error('Expected Repository'); + fixture.publish({ + kind: 'repository', + snapshot: { + ...current.snapshot, + repositoryRevision: current.snapshot.repositoryRevision + 1, + refsRevision: current.snapshot.refsRevision + 1, + worktrees: current.snapshot.worktrees.map((worktree) => ({ + ...worktree, + worktreeRevision: worktree.worktreeRevision + 1, + head: { + kind: 'local_branch' as const, + displayName: 'review-ready', + objectId: '1123456789abcdef0123456789abcdef01234567', + }, + })), + }, + }); + return { + kind: 'succeeded' as const, + operationId: operationIdSchema.parse( + 'operation_0123456789abcdef0123456789abcdef', + ), + result: { + kind: 'branch_switch' as const, + displayName: 'review-ready', + }, + }; + }); + const source = { + ...fixture.source, + async searchBranches() { + return { + refsRevision: 1, + candidates: [ + { + refId: targetRefId, + kind: 'local' as const, + displayName: 'review-ready', + occupiedBy: null, + }, + ], + }; + }, + switchBranch, + }; + const store = createRepositoryStore(source); + act(() => root.render()); + await act(async () => button('Switch Branch for codex-git').click()); + + await act(async () => button('Switch codex-git to review-ready').click()); + + expect(switchBranch).toHaveBeenCalledWith( + expect.objectContaining({ + expectedRefsRevision: 1, + expectedWorktreeRevision: 1, + refId: targetRefId, + }), + ); + expect(container.querySelector('#worktree-title')?.textContent).toBe( + 'codex-git', + ); + expect(container.textContent).toContain('Local Branch review-ready'); + expect(container.textContent).not.toContain('Search cached Branches'); + expect(container.querySelector('[role="status"]')?.textContent).toContain( + 'Branch or HEAD changed', + ); + }); + it('does not dispose a caller-owned store when the overview unmounts', () => { const fixture = createOverviewFixture('one-worktree'); const store = createRepositoryStore(fixture.source); diff --git a/apps/ui/src/RepositoryOverview.tsx b/apps/ui/src/RepositoryOverview.tsx index 456c676..34df3ea 100644 --- a/apps/ui/src/RepositoryOverview.tsx +++ b/apps/ui/src/RepositoryOverview.tsx @@ -16,6 +16,7 @@ export function RepositoryOverview({ store.getSnapshot, store.getSnapshot, ); + const branchPicker = state.branchPicker; const worktreeButtons = useRef(new Map()); const searchInput = useRef(null); const worktreeTitle = useRef(null); @@ -321,7 +322,8 @@ export function RepositoryOverview({ @@ -333,6 +335,109 @@ export function RepositoryOverview({ Upstream actions + {branchPicker.kind === 'closed' ? null : ( +
+

Switch Branch

+ + + {branchPicker.kind === 'loading' ? ( +

Loading cached Branches…

+ ) : branchPicker.kind === 'failed' ? ( +

{branchPicker.message}

+ ) : ( + <> + {branchPicker.message === null ? null : ( +

{branchPicker.message}

+ )} + {(['local', 'remote_tracking'] as const).map((kind) => { + const branches = branchPicker.candidates.filter( + (candidate) => candidate.kind === kind, + ); + return ( +
+

+ {kind === 'local' + ? 'Local Branches' + : 'Remote-tracking Branches'} +

+ {branches.length === 0 ? ( +

No matching Branches.

+ ) : ( +
    + {branches.map((branch) => { + const occupiedElsewhere = + branch.occupiedBy !== null && + branch.occupiedBy !== selected.worktreeId; + const occupyingWorktree = + branch.occupiedBy === null + ? undefined + : snapshot.worktrees.find( + ({ worktreeId }) => + worktreeId === branch.occupiedBy, + ); + return ( +
  • + + {branch.warning == null ? null : ( + {branch.warning} + )} + {!occupiedElsewhere ? null : ( + <> + + Occupied by{' '} + {occupyingWorktree?.displayName ?? + 'another Worktree'} + + + + )} +
  • + ); + })} +
+ )} +
+ ); + })} + + )} +
+ )}