takt: refactor-clone-manager (#359)

This commit is contained in:
nrs 2026-02-22 21:28:46 +09:00 committed by GitHub
parent e75e024fa8
commit c066db46c7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 88 additions and 17 deletions

View File

@ -36,6 +36,7 @@ vi.mock('../infra/config/global/globalConfig.js', () => ({
}));
import { execFileSync } from 'node:child_process';
import { loadGlobalConfig } from '../infra/config/global/globalConfig.js';
import { createSharedClone, createTempCloneForBranch } from '../infra/task/clone.js';
const mockExecFileSync = vi.mocked(execFileSync);
@ -456,3 +457,84 @@ describe('resolveBaseBranch', () => {
expect(result.branch).toBe('existing-branch');
});
});
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.
// resolveBaseBranch calls resolveConfigValue twice (baseBranch then autoFetch),
// each triggers one loadGlobalConfig() call — queue two return values.
vi.mocked(loadGlobalConfig)
.mockReturnValueOnce({ autoFetch: true } as ReturnType<typeof loadGlobalConfig>)
.mockReturnValueOnce({ autoFetch: true } as ReturnType<typeof loadGlobalConfig>);
const fetchCalls: string[][] = [];
const revParseOriginCalls: string[][] = [];
const resetCalls: string[][] = [];
mockExecFileSync.mockImplementation((_cmd, args, opts) => {
const argsArr = args as string[];
const options = opts as { encoding?: string } | undefined;
// getCurrentBranch: git rev-parse --abbrev-ref HEAD (encoding: 'utf-8')
if (argsArr[0] === 'rev-parse' && argsArr[1] === '--abbrev-ref') {
return 'main';
}
// git fetch origin
if (argsArr[0] === 'fetch') {
fetchCalls.push(argsArr);
return Buffer.from('');
}
// git rev-parse origin/<branch> (encoding: 'utf-8') — returns fetched commit hash
if (argsArr[0] === 'rev-parse' && typeof argsArr[1] === 'string' && argsArr[1].startsWith('origin/')) {
revParseOriginCalls.push(argsArr);
return options?.encoding ? 'abc123def456' : Buffer.from('abc123def456\n');
}
// git reset --hard <commit>
if (argsArr[0] === 'reset' && argsArr[1] === '--hard') {
resetCalls.push(argsArr);
return Buffer.from('');
}
// git clone
if (argsArr[0] === 'clone') return Buffer.from('');
// git remote remove origin
if (argsArr[0] === 'remote') return Buffer.from('');
// git config --local (reading from source repo — nothing set)
if (argsArr[0] === 'config' && argsArr[1] === '--local') throw new Error('not set');
// git config <key> <value> (writing to clone)
if (argsArr[0] === 'config') return Buffer.from('');
// git rev-parse --verify (branchExists) — branch not found, triggers new branch creation
if (argsArr[0] === 'rev-parse') throw new Error('branch not found');
// git checkout -b
if (argsArr[0] === 'checkout') return Buffer.from('');
return Buffer.from('');
});
// When
createSharedClone('/project-autofetch-test', {
worktree: true,
taskSlug: 'autofetch-task',
});
// Then: git fetch origin was called exactly once
expect(fetchCalls).toHaveLength(1);
expect(fetchCalls[0]).toEqual(['fetch', 'origin']);
// Then: remote tracking ref for the base branch was resolved
expect(revParseOriginCalls).toHaveLength(1);
expect(revParseOriginCalls[0]).toEqual(['rev-parse', 'origin/main']);
// Then: clone was reset to the fetched commit
expect(resetCalls).toHaveLength(1);
expect(resetCalls[0]).toEqual(['reset', '--hard', 'abc123def456']);
});
});

View File

@ -133,7 +133,7 @@ function getLocalLayerValue<K extends ConfigParameterKey>(
case 'providerProfiles':
return project.providerProfiles as LoadedConfig[K] | undefined;
case 'baseBranch':
return (project as Record<string, unknown>).base_branch as LoadedConfig[K] | undefined;
return project.base_branch as LoadedConfig[K] | undefined;
default:
return undefined;
}

View File

@ -139,17 +139,17 @@ export class CloneManager {
*
* When `auto_fetch` config is true:
* 1. Runs `git fetch origin` (without modifying local branches)
* 2. Resolves base branch from config `base_branch` current branch fallback
* 2. Resolves base branch from config `base_branch` remote default branch fallback
* 3. Returns the branch name and the fetched commit hash of `origin/<baseBranch>`
*
* When `auto_fetch` is false (default):
* Returns only the branch name (config `base_branch` current branch fallback)
* Returns only the branch name (config `base_branch` remote default branch fallback)
*
* Any failure (network, no remote, etc.) is non-fatal.
*/
static resolveBaseBranch(projectDir: string): { branch: string; fetchedCommit?: string } {
const configBaseBranch = resolveConfigValue(projectDir, 'baseBranch') as string | undefined;
const autoFetch = resolveConfigValue(projectDir, 'autoFetch') as boolean | undefined;
const configBaseBranch = resolveConfigValue(projectDir, 'baseBranch');
const autoFetch = resolveConfigValue(projectDir, 'autoFetch');
// Determine base branch: config base_branch → remote default branch
const baseBranch = configBaseBranch ?? detectDefaultBranch(projectDir);
@ -180,18 +180,6 @@ export class CloneManager {
}
}
/** Get current branch name */
private static getCurrentBranch(projectDir: string): string {
try {
return execFileSync(
'git', ['rev-parse', '--abbrev-ref', 'HEAD'],
{ cwd: projectDir, encoding: 'utf-8', stdio: 'pipe' },
).trim();
} catch {
return 'main';
}
}
/** Clone a repository and remove origin to isolate from the main repo.
* When `branch` is specified, `--branch` is passed to `git clone` so the
* branch is checked out as a local branch *before* origin is removed.
@ -275,6 +263,7 @@ export class CloneManager {
/** Create a temporary clone for an existing branch */
createTempCloneForBranch(projectDir: string, branch: string): WorktreeResult {
// fetch の副作用(リモートの最新状態への同期)のために呼び出す
CloneManager.resolveBaseBranch(projectDir);
const timestamp = CloneManager.generateTimestamp();