From c066db46c7b6cf11f5d31d1e41fdaf952d9a3972 Mon Sep 17 00:00:00 2001 From: nrs <38722970+nrslib@users.noreply.github.com> Date: Sun, 22 Feb 2026 21:28:46 +0900 Subject: [PATCH] takt: refactor-clone-manager (#359) --- src/__tests__/clone.test.ts | 82 ++++++++++++++++++++++++++ src/infra/config/resolveConfigValue.ts | 2 +- src/infra/task/clone.ts | 21 ++----- 3 files changed, 88 insertions(+), 17 deletions(-) diff --git a/src/__tests__/clone.test.ts b/src/__tests__/clone.test.ts index acdf199..89d24e4 100644 --- a/src/__tests__/clone.test.ts +++ b/src/__tests__/clone.test.ts @@ -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/, reset --hard', () => { + it('should run git fetch, resolve origin/ 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) + .mockReturnValueOnce({ autoFetch: true } as ReturnType); + + 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/ (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 + 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 (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']); + }); +}); diff --git a/src/infra/config/resolveConfigValue.ts b/src/infra/config/resolveConfigValue.ts index 562658e..86fc104 100644 --- a/src/infra/config/resolveConfigValue.ts +++ b/src/infra/config/resolveConfigValue.ts @@ -133,7 +133,7 @@ function getLocalLayerValue( case 'providerProfiles': return project.providerProfiles as LoadedConfig[K] | undefined; case 'baseBranch': - return (project as Record).base_branch as LoadedConfig[K] | undefined; + return project.base_branch as LoadedConfig[K] | undefined; default: return undefined; } diff --git a/src/infra/task/clone.ts b/src/infra/task/clone.ts index 8ae9c2b..1bf8b20 100644 --- a/src/infra/task/clone.ts +++ b/src/infra/task/clone.ts @@ -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/` * * 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();