feat: opt-in auto_fetch and base_branch config for clone

Replace the always-on syncDefaultBranch with opt-in resolveBaseBranch:

- Add auto_fetch config (default: false) — only fetch when enabled
- Add base_branch config (project and global) — fallback to current branch
- Fetch-only mode: git fetch origin without modifying local branches
- Use fetched commit hash (origin/<base_branch>) to reset clone to latest
- No more git merge --ff-only or git fetch origin main:main

Config example:
  # ~/.takt/config.yaml or .takt/config.yaml
  auto_fetch: true
  base_branch: develop

Addresses review feedback: opt-in behavior, no local branch changes,
configurable base branch with current-branch fallback.
This commit is contained in:
Tomohisa Takaoka 2026-02-21 12:30:26 -08:00
parent 4823a9cb83
commit 1d7336950e
10 changed files with 271 additions and 30 deletions

View File

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

View File

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

View File

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

View File

@ -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(),
});

View File

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

View File

@ -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<string, unknown>): void {

View File

@ -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();

View File

@ -79,6 +79,8 @@ const RESOLUTION_REGISTRY: Partial<{ [K in ConfigParameterKey]: ResolutionRule<K
draftPr: { layers: ['local', 'global'] },
analytics: { layers: ['local', 'global'], mergeMode: 'analytics' },
verbose: { layers: ['local', 'global'], defaultValue: false },
autoFetch: { layers: ['global'], defaultValue: false },
baseBranch: { layers: ['local', 'global'] },
};
function resolveAnalyticsMerged(
@ -128,6 +130,8 @@ function getLocalLayerValue<K extends ConfigParameterKey>(
return project.providerOptions as LoadedConfig[K] | undefined;
case 'providerProfiles':
return project.providerProfiles as LoadedConfig[K] | undefined;
case 'baseBranch':
return (project as Record<string, unknown>).base_branch as LoadedConfig[K] | undefined;
default:
return undefined;
}

View File

@ -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/<baseBranch>`
*
* 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);
}

View File

@ -44,6 +44,7 @@ export {
saveCloneMeta,
removeCloneMeta,
cleanupOrphanedClone,
resolveBaseBranch,
} from './clone.js';
export {
detectDefaultBranch,