Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
167 changes: 167 additions & 0 deletions src/__tests__/clone.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/<branch> 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 <branch>
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/<branch>, reset --hard', () => {
it('should run git fetch, resolve origin/<branch> commit hash, and reset --hard in the clone', () => {
// Given: autoFetch is enabled in global config.
Expand Down
11 changes: 11 additions & 0 deletions src/infra/task/clone.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down