Merge pull request #344 from tomohisa/feat/auto-sync-before-clone
feat: opt-in auto_fetch and base_branch config for clone
This commit is contained in:
commit
1d6770c479
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@ -34,6 +34,7 @@ vi.mock('../features/tasks/index.js', () => ({
|
||||
const mockResolveConfigValues = vi.fn();
|
||||
vi.mock('../infra/config/index.js', () => ({
|
||||
resolveConfigValues: mockResolveConfigValues,
|
||||
resolveConfigValue: vi.fn(() => undefined),
|
||||
}));
|
||||
|
||||
// Mock execFileSync for git operations
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
});
|
||||
|
||||
@ -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 {
|
||||
|
||||
3
src/infra/config/env/config-env-overrides.ts
vendored
3
src/infra/config/env/config-env-overrides.ts
vendored
@ -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 {
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -44,6 +44,7 @@ export {
|
||||
saveCloneMeta,
|
||||
removeCloneMeta,
|
||||
cleanupOrphanedClone,
|
||||
resolveBaseBranch,
|
||||
} from './clone.js';
|
||||
export {
|
||||
detectDefaultBranch,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user