diff --git a/src/__tests__/clone.test.ts b/src/__tests__/clone.test.ts index 11a5e79..522593e 100644 --- a/src/__tests__/clone.test.ts +++ b/src/__tests__/clone.test.ts @@ -56,6 +56,11 @@ describe('cloneAndIsolate git config propagation', () => { const argsArr = args as string[]; const options = opts as { cwd?: string }; + // git rev-parse --abbrev-ref HEAD (resolveBaseBranch: getCurrentBranch) + if (argsArr[0] === 'rev-parse' && argsArr[1] === '--abbrev-ref' && argsArr[2] === 'HEAD') { + return 'main\n'; + } + // git clone if (argsArr[0] === 'clone') { return Buffer.from(''); @@ -176,6 +181,11 @@ describe('branch and worktree path formatting with issue numbers', () => { mockExecFileSync.mockImplementation((cmd, args) => { const argsArr = args as string[]; + // git rev-parse --abbrev-ref HEAD (resolveBaseBranch: getCurrentBranch) + if (argsArr[0] === 'rev-parse' && argsArr[1] === '--abbrev-ref' && argsArr[2] === 'HEAD') { + return 'main\n'; + } + // git clone if (argsArr[0] === 'clone') { const clonePath = argsArr[argsArr.length - 1]; @@ -313,3 +323,133 @@ describe('branch and worktree path formatting with issue numbers', () => { expect(result.path).toMatch(/\/\d{8}T\d{4}$/); }); }); + +describe('resolveBaseBranch', () => { + it('should not fetch when auto_fetch is disabled (default)', () => { + // Given: auto_fetch is off (default), HEAD is on main + const fetchCalls: string[][] = []; + + mockExecFileSync.mockImplementation((_cmd, args) => { + const argsArr = args as string[]; + + if (argsArr[0] === 'fetch') { + fetchCalls.push(argsArr); + return Buffer.from(''); + } + if (argsArr[0] === 'rev-parse' && argsArr[1] === '--abbrev-ref') { + return 'main\n'; + } + if (argsArr[0] === 'clone') 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] === 'rev-parse' && argsArr[1] === '--verify') { + throw new Error('branch not found'); + } + if (argsArr[0] === 'checkout') return Buffer.from(''); + return Buffer.from(''); + }); + + // When + createSharedClone('/project', { + worktree: true, + taskSlug: 'test-no-fetch', + }); + + // Then: no fetch was performed + expect(fetchCalls).toHaveLength(0); + }); + + it('should use current branch as base when no base_branch config', () => { + // Given: HEAD is on develop + const cloneCalls: string[][] = []; + + mockExecFileSync.mockImplementation((_cmd, args) => { + const argsArr = args as string[]; + + if (argsArr[0] === 'rev-parse' && argsArr[1] === '--abbrev-ref') { + return 'develop\n'; + } + 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] === 'rev-parse' && argsArr[1] === '--verify') { + throw new Error('branch not found'); + } + if (argsArr[0] === 'checkout') return Buffer.from(''); + return Buffer.from(''); + }); + + // When + createSharedClone('/project', { + worktree: true, + taskSlug: 'use-current-branch', + }); + + // Then: clone was called with --branch develop (current branch) + expect(cloneCalls).toHaveLength(1); + expect(cloneCalls[0]).toContain('--branch'); + expect(cloneCalls[0]).toContain('develop'); + }); + + it('should continue clone creation when fetch fails (network error)', () => { + // Given: fetch throws (no network) + mockExecFileSync.mockImplementation((_cmd, args) => { + const argsArr = args as string[]; + + if (argsArr[0] === 'fetch') { + throw new Error('Could not resolve host: github.com'); + } + if (argsArr[0] === 'rev-parse' && argsArr[1] === '--abbrev-ref') { + return 'main\n'; + } + if (argsArr[0] === 'clone') 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] === 'rev-parse') throw new Error('branch not found'); + if (argsArr[0] === 'checkout') return Buffer.from(''); + return Buffer.from(''); + }); + + // When/Then: should not throw, clone still created + const result = createSharedClone('/project', { + worktree: true, + taskSlug: 'offline-task', + }); + + expect(result.branch).toMatch(/offline-task$/); + }); + + it('should also resolve base branch before createTempCloneForBranch', () => { + // Given + mockExecFileSync.mockImplementation((_cmd, args) => { + const argsArr = args as string[]; + + if (argsArr[0] === 'rev-parse' && argsArr[1] === '--abbrev-ref') { + return 'main\n'; + } + if (argsArr[0] === 'clone') 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(''); + } + return Buffer.from(''); + }); + + // When/Then: should not throw + const result = createTempCloneForBranch('/project', 'existing-branch'); + expect(result.branch).toBe('existing-branch'); + }); +}); diff --git a/src/__tests__/pipelineExecution.test.ts b/src/__tests__/pipelineExecution.test.ts index 373c501..fba1452 100644 --- a/src/__tests__/pipelineExecution.test.ts +++ b/src/__tests__/pipelineExecution.test.ts @@ -31,10 +31,11 @@ vi.mock('../features/tasks/index.js', () => ({ executeTask: mockExecuteTask, })); -const mockResolveConfigValues = vi.fn(); -vi.mock('../infra/config/index.js', () => ({ - resolveConfigValues: mockResolveConfigValues, -})); +const mockResolveConfigValues = vi.fn(); +vi.mock('../infra/config/index.js', () => ({ + resolveConfigValues: mockResolveConfigValues, + resolveConfigValue: vi.fn(() => undefined), +})); // Mock execFileSync for git operations const mockExecFileSync = vi.fn(); @@ -67,13 +68,13 @@ vi.mock('../shared/utils/index.js', async (importOriginal) => ({ const { executePipeline } = await import('../features/pipeline/index.js'); describe('executePipeline', () => { - beforeEach(() => { - vi.clearAllMocks(); - // Default: git operations succeed - mockExecFileSync.mockReturnValue('abc1234\n'); - // Default: no pipeline config - mockResolveConfigValues.mockReturnValue({ pipeline: undefined }); - }); + beforeEach(() => { + vi.clearAllMocks(); + // Default: git operations succeed + mockExecFileSync.mockReturnValue('abc1234\n'); + // Default: no pipeline config + mockResolveConfigValues.mockReturnValue({ pipeline: undefined }); + }); it('should return exit code 2 when neither --issue nor --task is specified', async () => { const exitCode = await executePipeline({ @@ -305,11 +306,11 @@ describe('executePipeline', () => { describe('PipelineConfig template expansion', () => { it('should use commit_message_template when configured', async () => { - mockResolveConfigValues.mockReturnValue({ - pipeline: { - commitMessageTemplate: 'fix: {title} (#{issue})', - }, - }); + mockResolveConfigValues.mockReturnValue({ + pipeline: { + commitMessageTemplate: 'fix: {title} (#{issue})', + }, + }); mockFetchIssue.mockReturnValueOnce({ number: 42, @@ -337,11 +338,11 @@ describe('executePipeline', () => { }); it('should use default_branch_prefix when configured', async () => { - mockResolveConfigValues.mockReturnValue({ - pipeline: { - defaultBranchPrefix: 'feat/', - }, - }); + mockResolveConfigValues.mockReturnValue({ + pipeline: { + defaultBranchPrefix: 'feat/', + }, + }); mockFetchIssue.mockReturnValueOnce({ number: 10, @@ -369,11 +370,11 @@ describe('executePipeline', () => { }); it('should use pr_body_template when configured for PR creation', async () => { - mockResolveConfigValues.mockReturnValue({ - pipeline: { - prBodyTemplate: '## Summary\n{issue_body}\n\nCloses #{issue}', - }, - }); + mockResolveConfigValues.mockReturnValue({ + pipeline: { + prBodyTemplate: '## Summary\n{issue_body}\n\nCloses #{issue}', + }, + }); mockFetchIssue.mockReturnValueOnce({ number: 50, diff --git a/src/core/models/persisted-global-config.ts b/src/core/models/persisted-global-config.ts index 4722d64..981a7b5 100644 --- a/src/core/models/persisted-global-config.ts +++ b/src/core/models/persisted-global-config.ts @@ -123,6 +123,10 @@ export interface PersistedGlobalConfig { concurrency: number; /** Polling interval in ms for picking up new tasks during takt run (default: 500, range: 100-5000) */ taskPollIntervalMs: number; + /** Opt-in: fetch remote before cloning to keep clones up-to-date (default: false) */ + autoFetch?: boolean; + /** Base branch to clone from (default: current branch) */ + baseBranch?: string; } /** Project-level configuration */ @@ -132,4 +136,6 @@ export interface ProjectConfig { providerOptions?: MovementProviderOptions; /** Provider-specific permission profiles */ providerProfiles?: ProviderPermissionProfiles; + /** Base branch to clone from (overrides global baseBranch) */ + baseBranch?: string; } diff --git a/src/core/models/schemas.ts b/src/core/models/schemas.ts index 226d379..fa5d356 100644 --- a/src/core/models/schemas.ts +++ b/src/core/models/schemas.ts @@ -481,6 +481,10 @@ export const GlobalConfigSchema = z.object({ concurrency: z.number().int().min(1).max(10).optional().default(1), /** Polling interval in ms for picking up new tasks during takt run (default: 500, range: 100-5000) */ task_poll_interval_ms: z.number().int().min(100).max(5000).optional().default(500), + /** Opt-in: fetch remote before cloning to keep clones up-to-date (default: false) */ + auto_fetch: z.boolean().optional().default(false), + /** Base branch to clone from (default: current branch) */ + base_branch: z.string().optional(), }); /** Project config schema */ @@ -489,4 +493,6 @@ export const ProjectConfigSchema = z.object({ provider: z.enum(['claude', 'codex', 'opencode', 'mock']).optional(), provider_options: MovementProviderOptionsSchema, provider_profiles: ProviderPermissionProfilesSchema, + /** Base branch to clone from (overrides global base_branch) */ + base_branch: z.string().optional(), }); diff --git a/src/features/pipeline/execute.ts b/src/features/pipeline/execute.ts index ca70ff2..038c000 100644 --- a/src/features/pipeline/execute.ts +++ b/src/features/pipeline/execute.ts @@ -19,7 +19,7 @@ import { buildPrBody, type GitHubIssue, } from '../../infra/github/index.js'; -import { stageAndCommit, getCurrentBranch } from '../../infra/task/index.js'; +import { stageAndCommit, resolveBaseBranch } from '../../infra/task/index.js'; import { executeTask, type TaskExecutionOptions, type PipelineExecutionOptions } from '../tasks/index.js'; import { resolveConfigValues } from '../../infra/config/index.js'; import { info, error, success, status, blankLine } from '../../shared/ui/index.js'; @@ -134,11 +134,12 @@ export async function executePipeline(options: PipelineExecutionOptions): Promis return EXIT_ISSUE_FETCH_FAILED; } - // --- Step 2: Create branch (skip if --skip-git) --- + // --- Step 2: Sync & create branch (skip if --skip-git) --- let branch: string | undefined; let baseBranch: string | undefined; if (!skipGit) { - baseBranch = getCurrentBranch(cwd); + const resolved = resolveBaseBranch(cwd); + baseBranch = resolved.branch; branch = options.branch ?? generatePipelineBranchName(pipelineConfig, options.issueNumber); info(`Creating branch: ${branch}`); try { diff --git a/src/infra/config/env/config-env-overrides.ts b/src/infra/config/env/config-env-overrides.ts index 166b919..ae2b1ce 100644 --- a/src/infra/config/env/config-env-overrides.ts +++ b/src/infra/config/env/config-env-overrides.ts @@ -124,6 +124,8 @@ const GLOBAL_ENV_SPECS: readonly EnvSpec[] = [ { path: 'verbose', type: 'boolean' }, { path: 'concurrency', type: 'number' }, { path: 'task_poll_interval_ms', type: 'number' }, + { path: 'auto_fetch', type: 'boolean' }, + { path: 'base_branch', type: 'string' }, ]; const PROJECT_ENV_SPECS: readonly EnvSpec[] = [ @@ -140,6 +142,7 @@ const PROJECT_ENV_SPECS: readonly EnvSpec[] = [ { path: 'provider_options.claude.sandbox.allow_unsandboxed_commands', type: 'boolean' }, { path: 'provider_options.claude.sandbox.excluded_commands', type: 'json' }, { path: 'provider_profiles', type: 'json' }, + { path: 'base_branch', type: 'string' }, ]; export function applyGlobalConfigEnvOverrides(target: Record): void { diff --git a/src/infra/config/global/globalConfig.ts b/src/infra/config/global/globalConfig.ts index 9a712e3..bf10412 100644 --- a/src/infra/config/global/globalConfig.ts +++ b/src/infra/config/global/globalConfig.ts @@ -220,6 +220,8 @@ export class GlobalConfigManager { verbose: parsed.verbose, concurrency: parsed.concurrency, taskPollIntervalMs: parsed.task_poll_interval_ms, + autoFetch: parsed.auto_fetch, + baseBranch: parsed.base_branch, }; validateProviderModelCompatibility(config.provider, config.model); this.cachedConfig = config; @@ -350,6 +352,12 @@ export class GlobalConfigManager { if (config.taskPollIntervalMs !== undefined && config.taskPollIntervalMs !== 500) { raw.task_poll_interval_ms = config.taskPollIntervalMs; } + if (config.autoFetch !== undefined) { + raw.auto_fetch = config.autoFetch; + } + if (config.baseBranch) { + raw.base_branch = config.baseBranch; + } writeFileSync(configPath, stringifyYaml(raw), 'utf-8'); this.invalidateCache(); invalidateAllResolvedConfigCache(); diff --git a/src/infra/config/resolveConfigValue.ts b/src/infra/config/resolveConfigValue.ts index 7cc3448..e184018 100644 --- a/src/infra/config/resolveConfigValue.ts +++ b/src/infra/config/resolveConfigValue.ts @@ -79,6 +79,8 @@ const RESOLUTION_REGISTRY: Partial<{ [K in ConfigParameterKey]: ResolutionRule( return project.providerOptions as LoadedConfig[K] | undefined; case 'providerProfiles': return project.providerProfiles as LoadedConfig[K] | undefined; + case 'baseBranch': + return (project as Record).base_branch as LoadedConfig[K] | undefined; default: return undefined; } diff --git a/src/infra/task/clone.ts b/src/infra/task/clone.ts index ad4bc22..5ff0a19 100644 --- a/src/infra/task/clone.ts +++ b/src/infra/task/clone.ts @@ -124,6 +124,64 @@ export class CloneManager { return projectDir; } + /** + * Resolve the base branch for cloning and optionally fetch from remote. + * + * 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 + * 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) + * + * 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; + + // Determine base branch: config base_branch → current branch + const baseBranch = configBaseBranch ?? CloneManager.getCurrentBranch(projectDir); + + if (!autoFetch) { + return { branch: baseBranch }; + } + + try { + // Fetch only — do not modify any local branch refs + execFileSync('git', ['fetch', 'origin'], { + cwd: projectDir, + stdio: 'pipe', + }); + + // Get the latest commit hash from the remote-tracking ref + const fetchedCommit = execFileSync( + 'git', ['rev-parse', `origin/${baseBranch}`], + { cwd: projectDir, encoding: 'utf-8', stdio: 'pipe' }, + ).trim(); + + log.info('Fetched remote and resolved base branch', { baseBranch, fetchedCommit }); + return { branch: baseBranch, fetchedCommit }; + } catch (err) { + // Network errors, no remote, no tracking ref — all non-fatal + log.info('Failed to fetch from remote, continuing with local state', { baseBranch, error: String(err) }); + return { branch: baseBranch }; + } + } + + /** 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. @@ -180,6 +238,8 @@ export class CloneManager { /** Create a git clone for a task */ createSharedClone(projectDir: string, options: WorktreeOptions): WorktreeResult { + const { branch: baseBranch, fetchedCommit } = CloneManager.resolveBaseBranch(projectDir); + const clonePath = CloneManager.resolveClonePath(projectDir, options); const branch = CloneManager.resolveBranchName(options); @@ -188,7 +248,12 @@ export class CloneManager { if (CloneManager.branchExists(projectDir, branch)) { CloneManager.cloneAndIsolate(projectDir, clonePath, branch); } else { - CloneManager.cloneAndIsolate(projectDir, clonePath); + // Clone from the base branch so the task starts from latest state + CloneManager.cloneAndIsolate(projectDir, clonePath, baseBranch); + // If we fetched a newer commit from remote, reset to it + if (fetchedCommit) { + execFileSync('git', ['reset', '--hard', fetchedCommit], { cwd: clonePath, stdio: 'pipe' }); + } execFileSync('git', ['checkout', '-b', branch], { cwd: clonePath, stdio: 'pipe' }); } @@ -200,6 +265,8 @@ export class CloneManager { /** Create a temporary clone for an existing branch */ createTempCloneForBranch(projectDir: string, branch: string): WorktreeResult { + CloneManager.resolveBaseBranch(projectDir); + const timestamp = CloneManager.generateTimestamp(); const clonePath = path.join(CloneManager.resolveCloneBaseDir(projectDir), `tmp-${timestamp}`); @@ -285,3 +352,7 @@ export function removeCloneMeta(projectDir: string, branch: string): void { export function cleanupOrphanedClone(projectDir: string, branch: string): void { defaultManager.cleanupOrphanedClone(projectDir, branch); } + +export function resolveBaseBranch(projectDir: string): { branch: string; fetchedCommit?: string } { + return CloneManager.resolveBaseBranch(projectDir); +} diff --git a/src/infra/task/index.ts b/src/infra/task/index.ts index de1b865..4c7ed33 100644 --- a/src/infra/task/index.ts +++ b/src/infra/task/index.ts @@ -44,6 +44,7 @@ export { saveCloneMeta, removeCloneMeta, cleanupOrphanedClone, + resolveBaseBranch, } from './clone.js'; export { detectDefaultBranch,