takt: github-issue-398-branchexists (#401)

This commit is contained in:
nrs 2026-02-28 12:31:24 +09:00 committed by GitHub
parent ae74c0d595
commit e256db8dea
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 178 additions and 0 deletions

View File

@ -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.

View File

@ -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;
}