diff --git a/src/__tests__/clone.test.ts b/src/__tests__/clone.test.ts index bfd04400..24cda5fb 100644 --- a/src/__tests__/clone.test.ts +++ b/src/__tests__/clone.test.ts @@ -570,6 +570,173 @@ describe('clone submodule arguments', () => { }); }); +describe('branchExists remote tracking branch fallback', () => { + it('should clone with existing branch when only remote tracking branch exists', () => { + // Given: local branch does not exist, but origin/ does + const cloneCalls: string[][] = []; + const checkoutCalls: string[][] = []; + + mockExecFileSync.mockImplementation((_cmd, args) => { + const argsArr = args as string[]; + + // resolveBaseBranch: detectDefaultBranch + if (argsArr[0] === 'rev-parse' && argsArr[1] === '--abbrev-ref' && argsArr[2] === 'HEAD') { + return 'main\n'; + } + + // branchExists: git rev-parse --verify + if (argsArr[0] === 'rev-parse' && argsArr[1] === '--verify') { + const ref = argsArr[2]; + if (typeof ref === 'string' && ref.startsWith('origin/')) { + // Remote tracking branch exists + return Buffer.from('abc123'); + } + // Local branch does not exist + throw new Error('branch not found'); + } + + if (argsArr[0] === 'clone') { + cloneCalls.push(argsArr); + return Buffer.from(''); + } + if (argsArr[0] === 'remote') return Buffer.from(''); + if (argsArr[0] === 'config') { + if (argsArr[1] === '--local') throw new Error('not set'); + return Buffer.from(''); + } + if (argsArr[0] === 'checkout') { + checkoutCalls.push(argsArr); + return Buffer.from(''); + } + + return Buffer.from(''); + }); + + // When + const result = createSharedClone('/project', { + worktree: '/tmp/clone-remote-branch', + taskSlug: 'remote-branch-task', + branch: 'feature/remote-only', + }); + + // Then: branch is the requested branch name + expect(result.branch).toBe('feature/remote-only'); + + // Then: cloneAndIsolate was called with --branch feature/remote-only (not base branch) + expect(cloneCalls).toHaveLength(1); + expect(cloneCalls[0]).toContain('--branch'); + expect(cloneCalls[0]).toContain('feature/remote-only'); + + // Then: no checkout -b was called (branch already exists on remote) + expect(checkoutCalls).toHaveLength(0); + }); + + it('should create new branch when neither local nor remote tracking branch exists', () => { + // Given: neither local nor remote tracking branch exists + const cloneCalls: string[][] = []; + const checkoutCalls: string[][] = []; + + mockExecFileSync.mockImplementation((_cmd, args) => { + const argsArr = args as string[]; + + if (argsArr[0] === 'rev-parse' && argsArr[1] === '--abbrev-ref' && argsArr[2] === 'HEAD') { + return 'main\n'; + } + + // Both local and remote tracking branch not found + if (argsArr[0] === 'rev-parse' && argsArr[1] === '--verify') { + throw new Error('branch not found'); + } + + if (argsArr[0] === 'clone') { + cloneCalls.push(argsArr); + return Buffer.from(''); + } + if (argsArr[0] === 'remote') return Buffer.from(''); + if (argsArr[0] === 'config') { + if (argsArr[1] === '--local') throw new Error('not set'); + return Buffer.from(''); + } + if (argsArr[0] === 'checkout') { + checkoutCalls.push(argsArr); + return Buffer.from(''); + } + + return Buffer.from(''); + }); + + // When + const result = createSharedClone('/project', { + worktree: '/tmp/clone-no-branch', + taskSlug: 'no-branch-task', + branch: 'feature/brand-new', + }); + + // Then: branch is the requested branch name + expect(result.branch).toBe('feature/brand-new'); + + // Then: cloneAndIsolate was called with --branch main (base branch) + expect(cloneCalls).toHaveLength(1); + expect(cloneCalls[0]).toContain('--branch'); + expect(cloneCalls[0]).toContain('main'); + + // Then: checkout -b was called to create the new branch + expect(checkoutCalls).toHaveLength(1); + expect(checkoutCalls[0]).toEqual(['checkout', '-b', 'feature/brand-new']); + }); + + it('should prefer local branch over remote tracking branch', () => { + // Given: local branch exists + const cloneCalls: string[][] = []; + const checkoutCalls: string[][] = []; + + mockExecFileSync.mockImplementation((_cmd, args) => { + const argsArr = args as string[]; + + if (argsArr[0] === 'rev-parse' && argsArr[1] === '--abbrev-ref' && argsArr[2] === 'HEAD') { + return 'main\n'; + } + + // Local branch exists (first rev-parse --verify call succeeds) + if (argsArr[0] === 'rev-parse' && argsArr[1] === '--verify') { + return Buffer.from('def456'); + } + + if (argsArr[0] === 'clone') { + cloneCalls.push(argsArr); + return Buffer.from(''); + } + if (argsArr[0] === 'remote') return Buffer.from(''); + if (argsArr[0] === 'config') { + if (argsArr[1] === '--local') throw new Error('not set'); + return Buffer.from(''); + } + if (argsArr[0] === 'checkout') { + checkoutCalls.push(argsArr); + return Buffer.from(''); + } + + return Buffer.from(''); + }); + + // When + const result = createSharedClone('/project', { + worktree: '/tmp/clone-local-branch', + taskSlug: 'local-branch-task', + branch: 'feature/local-exists', + }); + + // Then: cloneAndIsolate was called with --branch feature/local-exists + expect(result.branch).toBe('feature/local-exists'); + expect(cloneCalls).toHaveLength(1); + expect(cloneCalls[0]).toContain('--branch'); + expect(cloneCalls[0]).toContain('feature/local-exists'); + + // Then: no checkout -b was called (branch already exists locally) + expect(checkoutCalls).toHaveLength(0); + }); +}); + describe('autoFetch: true — fetch, rev-parse origin/, reset --hard', () => { it('should run git fetch, resolve origin/ commit hash, and reset --hard in the clone', () => { // Given: autoFetch is enabled in global config. diff --git a/src/infra/task/clone.ts b/src/infra/task/clone.ts index d0371fa1..b26c9dab 100644 --- a/src/infra/task/clone.ts +++ b/src/infra/task/clone.ts @@ -122,12 +122,23 @@ export class CloneManager { } private static branchExists(projectDir: string, branch: string): boolean { + // Local branch try { execFileSync('git', ['rev-parse', '--verify', branch], { cwd: projectDir, stdio: 'pipe', }); return true; + } catch { + // not found locally — fall through to remote check + } + // Remote tracking branch + try { + execFileSync('git', ['rev-parse', '--verify', `origin/${branch}`], { + cwd: projectDir, + stdio: 'pipe', + }); + return true; } catch { return false; }