diff --git a/.prettierignore b/.prettierignore index d8a963a2..b05bda30 100644 --- a/.prettierignore +++ b/.prettierignore @@ -6,6 +6,8 @@ node_modules/ .worktrees/ .claude/ .letta/ +.planning/ +CLAUDE.md package-lock.json *.AppImage *.deb diff --git a/electron/ipc/channels.ts b/electron/ipc/channels.ts index cac6fb6f..b46e07a8 100644 --- a/electron/ipc/channels.ts +++ b/electron/ipc/channels.ts @@ -30,6 +30,7 @@ export enum IPC { RebaseTask = 'rebase_task', GetMainBranch = 'get_main_branch', GetCurrentBranch = 'get_current_branch', + CheckoutBranch = 'checkout_branch', GetBranches = 'get_branches', CheckIsGitRepo = 'check_is_git_repo', CommitAll = 'commit_all', diff --git a/electron/ipc/git.test.ts b/electron/ipc/git.test.ts new file mode 100644 index 00000000..634b372a --- /dev/null +++ b/electron/ipc/git.test.ts @@ -0,0 +1,118 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { promisify } from 'util'; + +vi.mock('child_process', () => { + const mockExecFile = vi.fn(); + // Attach custom promisify handler so promisify(execFile) resolves with + // { stdout, stderr } rather than just the first callback argument. + (mockExecFile as unknown as Record)[promisify.custom] = ( + file: unknown, + args: unknown, + opts: unknown, + ): Promise<{ stdout: string; stderr: string }> => + new Promise((resolve, reject) => { + mockExecFile(file, args, opts, (err: Error | null, stdout: string, stderr: string) => { + if (err) reject(err); + else resolve({ stdout, stderr }); + }); + }); + + return { + execFile: mockExecFile, + spawn: vi.fn(() => ({ + stdout: { on: vi.fn() }, + stderr: { on: vi.fn() }, + on: vi.fn(), + })), + }; +}); + +import { execFile } from 'child_process'; +import { + getAllFileDiffsFromBranch, + getChangedFilesFromBranch, + getFileDiffFromBranch, +} from './git.js'; + +type ExecFileCallback = (err: Error | null, stdout: string, stderr: string) => void; +type MockHandler = (args: string[], cb: ExecFileCallback) => void; + +/** Configure the mocked execFile — double cast avoids execFile's 12 overloads */ +function setupMock(calls: string[][], handler: MockHandler): void { + const impl = (_cmd: string, args: string[], _opts: unknown, cb: ExecFileCallback) => { + calls.push(args); + handler(args, cb); + }; + vi.mocked(execFile).mockImplementation(impl as unknown as typeof execFile); +} + +describe('baseBranch fallback to detectMainBranch', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('getAllFileDiffsFromBranch', () => { + it('falls back to detected main branch when baseBranch is undefined', async () => { + const calls: string[][] = []; + setupMock(calls, (argsArr, cb) => { + const isDiff = argsArr[0] === 'diff'; + cb(isDiff ? null : new Error('no remote'), '', ''); + }); + + await getAllFileDiffsFromBranch('/repo', 'feature', undefined); + + const diffCall = calls.find((a) => a[0] === 'diff'); + expect(diffCall).toBeDefined(); + const refSpec = diffCall?.[2] ?? ''; + expect(refSpec).toMatch(/^main\.\.\./); + }); + + it("uses the provided baseBranch when it is 'develop'", async () => { + const calls: string[][] = []; + setupMock(calls, (_argsArr, cb) => { + cb(null, '', ''); + }); + + await getAllFileDiffsFromBranch('/repo', 'feature', 'develop'); + + const diffCall = calls.find((a) => a[0] === 'diff'); + expect(diffCall).toBeDefined(); + const refSpec = diffCall?.[2] ?? ''; + expect(refSpec).toBe('develop...feature'); + const detectionCalls = calls.filter((a) => + ['symbolic-ref', 'rev-parse', 'config', 'remote'].includes(a[0]), + ); + expect(detectionCalls).toHaveLength(0); + }); + }); + + describe('getChangedFilesFromBranch', () => { + it("uses 'develop' directly when baseBranch is 'develop'", async () => { + const calls: string[][] = []; + setupMock(calls, (_argsArr, cb) => { + cb(null, '', ''); + }); + + await getChangedFilesFromBranch('/repo', 'feature', 'develop'); + + const diffCall = calls.find((a) => a[0] === 'diff'); + const tripleRef = diffCall?.find((arg) => arg.includes('...')) ?? ''; + expect(tripleRef).toBe('develop...feature'); + }); + }); + + describe('getFileDiffFromBranch', () => { + it("uses 'develop' directly when baseBranch is 'develop'", async () => { + const calls: string[][] = []; + setupMock(calls, (_argsArr, cb) => { + cb(null, '', ''); + }); + + await getFileDiffFromBranch('/repo', 'feature', 'src/foo.ts', 'develop'); + + const diffCall = calls.find((a) => a[0] === 'diff'); + const tripleRef = diffCall?.find((arg) => arg.includes('...')) ?? ''; + expect(tripleRef).toBe('develop...feature'); + }); + }); +}); diff --git a/electron/ipc/git.ts b/electron/ipc/git.ts index 289508ab..7407bc17 100644 --- a/electron/ipc/git.ts +++ b/electron/ipc/git.ts @@ -192,79 +192,30 @@ async function getCurrentBranchName(repoRoot: string): Promise { return stdout.trim(); } -/** - * Resolve a branch name to whichever ref is further ahead for comparisons: - * local branch or its remote-tracking counterpart. Using the most advanced - * ref prevents diffs from showing files already present on the other side. - * Falls back to the bare name when no remote ref exists (local-only repos). - * - * Note: for merge-base computation, use detectMergeBase() directly — it - * compares both local and remote merge-bases to pick the closest one. - */ -async function resolveComparisonRef(repoRoot: string, branch: string): Promise { - if (branch.includes('/')) return branch; - if (!(await remoteTrackingRefExists(repoRoot, branch))) return branch; - - const remote = `origin/${branch}`; - try { - const { stdout } = await exec('git', ['rev-list', '--count', `${branch}..${remote}`], { - cwd: repoRoot, - }); - const originAhead = parseInt(stdout.trim(), 10) || 0; - return originAhead > 0 ? remote : branch; - } catch { - return branch; - } -} - async function detectMergeBase( repoRoot: string, head?: string, baseBranch?: string, ): Promise { - const branch = baseBranch ?? (await detectMainBranch(repoRoot)); - const headRef = head ?? 'HEAD'; - const key = `${cacheKey(repoRoot)}:${branch}`; + const mainBranch = baseBranch ?? (await detectMainBranch(repoRoot)); + const key = `${cacheKey(repoRoot)}:${mainBranch}`; const cached = mergeBaseCache.get(key); if (cached) { if (cached.expiresAt > Date.now()) return cached.value; mergeBaseCache.delete(key); } - // When a remote-tracking ref exists, compute merge-base against both the - // local branch and origin/, then pick whichever is closer to HEAD. - // This avoids showing extra files when local and remote have diverged. - const refs = [branch]; - if (!branch.includes('/') && (await remoteTrackingRefExists(repoRoot, branch))) { - refs.push(`origin/${branch}`); - } - - let best: string | null = null; - for (const ref of refs) { - try { - const { stdout } = await exec('git', ['merge-base', ref, headRef], { cwd: repoRoot }); - const mb = stdout.trim(); - if (!mb) continue; - if (!best) { - best = mb; - continue; - } - if (mb === best) continue; - // Two different merge-bases: pick the descendant (closer to HEAD). - // `--is-ancestor A B` succeeds when A is reachable from B. - const aIsAncestor = await exec('git', ['merge-base', '--is-ancestor', best, mb], { - cwd: repoRoot, - }).then( - () => true, - () => false, - ); - if (aIsAncestor) best = mb; - } catch { - /* ref may not resolve — skip */ - } + let result: string; + try { + const { stdout } = await exec('git', ['merge-base', mainBranch, head ?? 'HEAD'], { + cwd: repoRoot, + }); + const hash = stdout.trim(); + result = hash || mainBranch; + } catch { + result = mainBranch; } - const result = best || branch; mergeBaseCache.set(key, { value: result, expiresAt: Date.now() + MERGE_BASE_TTL }); return result; } @@ -533,6 +484,10 @@ export async function getCurrentBranch(projectRoot: string): Promise { return getCurrentBranchName(projectRoot); } +export async function checkoutBranch(projectRoot: string, branchName: string): Promise { + await exec('git', ['checkout', branchName], { cwd: projectRoot }); +} + export async function getBranches(projectRoot: string): Promise { const { stdout } = await exec('git', ['branch', '--list', '--format=%(refname:short)'], { cwd: projectRoot, @@ -722,10 +677,7 @@ export async function getAllFileDiffsFromBranch( branchName: string, baseBranch?: string, ): Promise { - const mainBranch = await resolveComparisonRef( - projectRoot, - baseBranch ?? (await detectMainBranch(projectRoot)), - ); + const mainBranch = baseBranch ?? (await detectMainBranch(projectRoot)); try { const { stdout } = await exec('git', ['diff', '-U3', `${mainBranch}...${branchName}`], { cwd: projectRoot, @@ -873,10 +825,7 @@ export async function getWorktreeStatus( }); const hasUncommittedChanges = statusOut.trim().length > 0; - const mainBranch = await resolveComparisonRef( - worktreePath, - baseBranch ?? (await detectMainBranch(worktreePath).catch(() => 'HEAD')), - ); + const mainBranch = baseBranch ?? (await detectMainBranch(worktreePath).catch(() => 'HEAD')); let hasCommittedChanges = false; try { const { stdout: logOut } = await exec('git', ['log', `${mainBranch}..HEAD`, '--oneline'], { @@ -909,10 +858,7 @@ export async function checkMergeStatus( worktreePath: string, baseBranch?: string, ): Promise<{ main_ahead_count: number; conflicting_files: string[] }> { - const mainBranch = await resolveComparisonRef( - worktreePath, - baseBranch ?? (await detectMainBranch(worktreePath)), - ); + const mainBranch = baseBranch ?? (await detectMainBranch(worktreePath)); let mainAheadCount = 0; try { @@ -953,10 +899,9 @@ export async function mergeTask( return withWorktreeLock(lockKey, async () => { const mainBranch = baseBranch ?? (await detectMainBranch(projectRoot)); - const comparisonRef = await resolveComparisonRef(projectRoot, mainBranch); const { linesAdded, linesRemoved } = await computeBranchDiffStats( projectRoot, - comparisonRef, + mainBranch, branchName, ); @@ -971,7 +916,7 @@ export async function mergeTask( const originalBranch = await getCurrentBranchName(projectRoot).catch(() => null); - // Checkout main (bare branch name, not remote-tracking ref) + // Checkout main await exec('git', ['checkout', mainBranch], { cwd: projectRoot }); const restoreBranch = async () => { @@ -1029,10 +974,7 @@ export async function mergeTask( } export async function getBranchLog(worktreePath: string, baseBranch?: string): Promise { - const mainBranch = await resolveComparisonRef( - worktreePath, - baseBranch ?? (await detectMainBranch(worktreePath).catch(() => 'HEAD')), - ); + const mainBranch = baseBranch ?? (await detectMainBranch(worktreePath).catch(() => 'HEAD')); try { const { stdout } = await exec( 'git', @@ -1053,10 +995,7 @@ export async function getChangedFilesFromBranch( branchName: string, baseBranch?: string, ): Promise { - const mainBranch = await resolveComparisonRef( - projectRoot, - baseBranch ?? (await detectMainBranch(projectRoot)), - ); + const mainBranch = baseBranch ?? (await detectMainBranch(projectRoot)); let diffStr = ''; try { @@ -1095,10 +1034,7 @@ export async function getFileDiffFromBranch( filePath: string, baseBranch?: string, ): Promise { - const mainBranch = await resolveComparisonRef( - projectRoot, - baseBranch ?? (await detectMainBranch(projectRoot)), - ); + const mainBranch = baseBranch ?? (await detectMainBranch(projectRoot)); let diff = ''; try { diff --git a/electron/ipc/register.ts b/electron/ipc/register.ts index c57ada2a..dc54d2e6 100644 --- a/electron/ipc/register.ts +++ b/electron/ipc/register.ts @@ -45,6 +45,7 @@ import { removeWorktree, isGitRepo, getBranches, + checkoutBranch, } from './git.js'; import { createTask, deleteTask } from './tasks.js'; import { listAgents } from './agents.js'; @@ -327,6 +328,11 @@ export function registerAllHandlers(win: BrowserWindow): void { validatePath(args.projectRoot, 'projectRoot'); return getCurrentBranch(args.projectRoot); }); + ipcMain.handle(IPC.CheckoutBranch, (_e, args) => { + validatePath(args.projectRoot, 'projectRoot'); + validateBranchName(args.branchName, 'branchName'); + return checkoutBranch(args.projectRoot, args.branchName); + }); ipcMain.handle(IPC.CheckIsGitRepo, (_e, args) => { validatePath(args.path, 'path'); return isGitRepo(args.path); diff --git a/electron/preload.cjs b/electron/preload.cjs index 59890e73..9371171e 100644 --- a/electron/preload.cjs +++ b/electron/preload.cjs @@ -35,6 +35,7 @@ const ALLOWED_CHANNELS = new Set([ 'rebase_task', 'get_main_branch', 'get_current_branch', + 'checkout_branch', 'get_branches', 'check_is_git_repo', // Persistence diff --git a/package-lock.json b/package-lock.json index e3c6168d..bacd2cf4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "parallel-code", - "version": "1.2.0", + "version": "1.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "parallel-code", - "version": "1.2.0", + "version": "1.1.1", "license": "MIT", "dependencies": { "@xterm/addon-fit": "^0.12.0-beta.195", diff --git a/src/components/AgentSelector.tsx b/src/components/AgentSelector.tsx index 41b4d2b2..71331561 100644 --- a/src/components/AgentSelector.tsx +++ b/src/components/AgentSelector.tsx @@ -9,31 +9,7 @@ interface AgentSelectorProps { onSelect: (agent: AgentDef) => void; } -/** - * Roving-tabindex agent picker. - * Only the selected agent is in the Tab order; Arrow keys move between agents. - */ export function AgentSelector(props: AgentSelectorProps) { - const btnRefs: HTMLButtonElement[] = []; - - function handleKeyDown(e: KeyboardEvent, idx: number) { - const agents = props.agents; - let nextIdx: number | null = null; - - if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { - e.preventDefault(); - nextIdx = (idx + 1) % agents.length; - } else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { - e.preventDefault(); - nextIdx = (idx - 1 + agents.length) % agents.length; - } - - if (nextIdx !== null) { - props.onSelect(agents[nextIdx]); - btnRefs[nextIdx]?.focus(); - } - } - return (
-
+
- {(agent, i) => { + {(agent) => { const isSelected = () => props.selectedAgent?.id === agent.id; return (