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:
parent
4823a9cb83
commit
1d7336950e
@ -56,6 +56,11 @@ describe('cloneAndIsolate git config propagation', () => {
|
|||||||
const argsArr = args as string[];
|
const argsArr = args as string[];
|
||||||
const options = opts as { cwd?: 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
|
// git clone
|
||||||
if (argsArr[0] === 'clone') {
|
if (argsArr[0] === 'clone') {
|
||||||
return Buffer.from('');
|
return Buffer.from('');
|
||||||
@ -176,6 +181,11 @@ describe('branch and worktree path formatting with issue numbers', () => {
|
|||||||
mockExecFileSync.mockImplementation((cmd, args) => {
|
mockExecFileSync.mockImplementation((cmd, args) => {
|
||||||
const argsArr = args as string[];
|
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
|
// git clone
|
||||||
if (argsArr[0] === 'clone') {
|
if (argsArr[0] === 'clone') {
|
||||||
const clonePath = argsArr[argsArr.length - 1];
|
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}$/);
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -31,10 +31,11 @@ vi.mock('../features/tasks/index.js', () => ({
|
|||||||
executeTask: mockExecuteTask,
|
executeTask: mockExecuteTask,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const mockResolveConfigValues = vi.fn();
|
const mockResolveConfigValues = vi.fn();
|
||||||
vi.mock('../infra/config/index.js', () => ({
|
vi.mock('../infra/config/index.js', () => ({
|
||||||
resolveConfigValues: mockResolveConfigValues,
|
resolveConfigValues: mockResolveConfigValues,
|
||||||
}));
|
resolveConfigValue: vi.fn(() => undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
// Mock execFileSync for git operations
|
// Mock execFileSync for git operations
|
||||||
const mockExecFileSync = vi.fn();
|
const mockExecFileSync = vi.fn();
|
||||||
@ -67,13 +68,13 @@ vi.mock('../shared/utils/index.js', async (importOriginal) => ({
|
|||||||
const { executePipeline } = await import('../features/pipeline/index.js');
|
const { executePipeline } = await import('../features/pipeline/index.js');
|
||||||
|
|
||||||
describe('executePipeline', () => {
|
describe('executePipeline', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
// Default: git operations succeed
|
// Default: git operations succeed
|
||||||
mockExecFileSync.mockReturnValue('abc1234\n');
|
mockExecFileSync.mockReturnValue('abc1234\n');
|
||||||
// Default: no pipeline config
|
// Default: no pipeline config
|
||||||
mockResolveConfigValues.mockReturnValue({ pipeline: undefined });
|
mockResolveConfigValues.mockReturnValue({ pipeline: undefined });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return exit code 2 when neither --issue nor --task is specified', async () => {
|
it('should return exit code 2 when neither --issue nor --task is specified', async () => {
|
||||||
const exitCode = await executePipeline({
|
const exitCode = await executePipeline({
|
||||||
@ -305,11 +306,11 @@ describe('executePipeline', () => {
|
|||||||
|
|
||||||
describe('PipelineConfig template expansion', () => {
|
describe('PipelineConfig template expansion', () => {
|
||||||
it('should use commit_message_template when configured', async () => {
|
it('should use commit_message_template when configured', async () => {
|
||||||
mockResolveConfigValues.mockReturnValue({
|
mockResolveConfigValues.mockReturnValue({
|
||||||
pipeline: {
|
pipeline: {
|
||||||
commitMessageTemplate: 'fix: {title} (#{issue})',
|
commitMessageTemplate: 'fix: {title} (#{issue})',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
mockFetchIssue.mockReturnValueOnce({
|
mockFetchIssue.mockReturnValueOnce({
|
||||||
number: 42,
|
number: 42,
|
||||||
@ -337,11 +338,11 @@ describe('executePipeline', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should use default_branch_prefix when configured', async () => {
|
it('should use default_branch_prefix when configured', async () => {
|
||||||
mockResolveConfigValues.mockReturnValue({
|
mockResolveConfigValues.mockReturnValue({
|
||||||
pipeline: {
|
pipeline: {
|
||||||
defaultBranchPrefix: 'feat/',
|
defaultBranchPrefix: 'feat/',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
mockFetchIssue.mockReturnValueOnce({
|
mockFetchIssue.mockReturnValueOnce({
|
||||||
number: 10,
|
number: 10,
|
||||||
@ -369,11 +370,11 @@ describe('executePipeline', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should use pr_body_template when configured for PR creation', async () => {
|
it('should use pr_body_template when configured for PR creation', async () => {
|
||||||
mockResolveConfigValues.mockReturnValue({
|
mockResolveConfigValues.mockReturnValue({
|
||||||
pipeline: {
|
pipeline: {
|
||||||
prBodyTemplate: '## Summary\n{issue_body}\n\nCloses #{issue}',
|
prBodyTemplate: '## Summary\n{issue_body}\n\nCloses #{issue}',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
mockFetchIssue.mockReturnValueOnce({
|
mockFetchIssue.mockReturnValueOnce({
|
||||||
number: 50,
|
number: 50,
|
||||||
|
|||||||
@ -123,6 +123,10 @@ export interface PersistedGlobalConfig {
|
|||||||
concurrency: number;
|
concurrency: number;
|
||||||
/** Polling interval in ms for picking up new tasks during takt run (default: 500, range: 100-5000) */
|
/** Polling interval in ms for picking up new tasks during takt run (default: 500, range: 100-5000) */
|
||||||
taskPollIntervalMs: number;
|
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 */
|
/** Project-level configuration */
|
||||||
@ -132,4 +136,6 @@ export interface ProjectConfig {
|
|||||||
providerOptions?: MovementProviderOptions;
|
providerOptions?: MovementProviderOptions;
|
||||||
/** Provider-specific permission profiles */
|
/** Provider-specific permission profiles */
|
||||||
providerProfiles?: ProviderPermissionProfiles;
|
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),
|
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) */
|
/** 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),
|
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 */
|
/** Project config schema */
|
||||||
@ -489,4 +493,6 @@ export const ProjectConfigSchema = z.object({
|
|||||||
provider: z.enum(['claude', 'codex', 'opencode', 'mock']).optional(),
|
provider: z.enum(['claude', 'codex', 'opencode', 'mock']).optional(),
|
||||||
provider_options: MovementProviderOptionsSchema,
|
provider_options: MovementProviderOptionsSchema,
|
||||||
provider_profiles: ProviderPermissionProfilesSchema,
|
provider_profiles: ProviderPermissionProfilesSchema,
|
||||||
|
/** Base branch to clone from (overrides global base_branch) */
|
||||||
|
base_branch: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|||||||
@ -19,7 +19,7 @@ import {
|
|||||||
buildPrBody,
|
buildPrBody,
|
||||||
type GitHubIssue,
|
type GitHubIssue,
|
||||||
} from '../../infra/github/index.js';
|
} 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 { executeTask, type TaskExecutionOptions, type PipelineExecutionOptions } from '../tasks/index.js';
|
||||||
import { resolveConfigValues } from '../../infra/config/index.js';
|
import { resolveConfigValues } from '../../infra/config/index.js';
|
||||||
import { info, error, success, status, blankLine } from '../../shared/ui/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;
|
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 branch: string | undefined;
|
||||||
let baseBranch: string | undefined;
|
let baseBranch: string | undefined;
|
||||||
if (!skipGit) {
|
if (!skipGit) {
|
||||||
baseBranch = getCurrentBranch(cwd);
|
const resolved = resolveBaseBranch(cwd);
|
||||||
|
baseBranch = resolved.branch;
|
||||||
branch = options.branch ?? generatePipelineBranchName(pipelineConfig, options.issueNumber);
|
branch = options.branch ?? generatePipelineBranchName(pipelineConfig, options.issueNumber);
|
||||||
info(`Creating branch: ${branch}`);
|
info(`Creating branch: ${branch}`);
|
||||||
try {
|
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: 'verbose', type: 'boolean' },
|
||||||
{ path: 'concurrency', type: 'number' },
|
{ path: 'concurrency', type: 'number' },
|
||||||
{ path: 'task_poll_interval_ms', 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[] = [
|
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.allow_unsandboxed_commands', type: 'boolean' },
|
||||||
{ path: 'provider_options.claude.sandbox.excluded_commands', type: 'json' },
|
{ path: 'provider_options.claude.sandbox.excluded_commands', type: 'json' },
|
||||||
{ path: 'provider_profiles', type: 'json' },
|
{ path: 'provider_profiles', type: 'json' },
|
||||||
|
{ path: 'base_branch', type: 'string' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function applyGlobalConfigEnvOverrides(target: Record<string, unknown>): void {
|
export function applyGlobalConfigEnvOverrides(target: Record<string, unknown>): void {
|
||||||
|
|||||||
@ -220,6 +220,8 @@ export class GlobalConfigManager {
|
|||||||
verbose: parsed.verbose,
|
verbose: parsed.verbose,
|
||||||
concurrency: parsed.concurrency,
|
concurrency: parsed.concurrency,
|
||||||
taskPollIntervalMs: parsed.task_poll_interval_ms,
|
taskPollIntervalMs: parsed.task_poll_interval_ms,
|
||||||
|
autoFetch: parsed.auto_fetch,
|
||||||
|
baseBranch: parsed.base_branch,
|
||||||
};
|
};
|
||||||
validateProviderModelCompatibility(config.provider, config.model);
|
validateProviderModelCompatibility(config.provider, config.model);
|
||||||
this.cachedConfig = config;
|
this.cachedConfig = config;
|
||||||
@ -350,6 +352,12 @@ export class GlobalConfigManager {
|
|||||||
if (config.taskPollIntervalMs !== undefined && config.taskPollIntervalMs !== 500) {
|
if (config.taskPollIntervalMs !== undefined && config.taskPollIntervalMs !== 500) {
|
||||||
raw.task_poll_interval_ms = config.taskPollIntervalMs;
|
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');
|
writeFileSync(configPath, stringifyYaml(raw), 'utf-8');
|
||||||
this.invalidateCache();
|
this.invalidateCache();
|
||||||
invalidateAllResolvedConfigCache();
|
invalidateAllResolvedConfigCache();
|
||||||
|
|||||||
@ -79,6 +79,8 @@ const RESOLUTION_REGISTRY: Partial<{ [K in ConfigParameterKey]: ResolutionRule<K
|
|||||||
draftPr: { layers: ['local', 'global'] },
|
draftPr: { layers: ['local', 'global'] },
|
||||||
analytics: { layers: ['local', 'global'], mergeMode: 'analytics' },
|
analytics: { layers: ['local', 'global'], mergeMode: 'analytics' },
|
||||||
verbose: { layers: ['local', 'global'], defaultValue: false },
|
verbose: { layers: ['local', 'global'], defaultValue: false },
|
||||||
|
autoFetch: { layers: ['global'], defaultValue: false },
|
||||||
|
baseBranch: { layers: ['local', 'global'] },
|
||||||
};
|
};
|
||||||
|
|
||||||
function resolveAnalyticsMerged(
|
function resolveAnalyticsMerged(
|
||||||
@ -128,6 +130,8 @@ function getLocalLayerValue<K extends ConfigParameterKey>(
|
|||||||
return project.providerOptions as LoadedConfig[K] | undefined;
|
return project.providerOptions as LoadedConfig[K] | undefined;
|
||||||
case 'providerProfiles':
|
case 'providerProfiles':
|
||||||
return project.providerProfiles as LoadedConfig[K] | undefined;
|
return project.providerProfiles as LoadedConfig[K] | undefined;
|
||||||
|
case 'baseBranch':
|
||||||
|
return (project as Record<string, unknown>).base_branch as LoadedConfig[K] | undefined;
|
||||||
default:
|
default:
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -124,6 +124,64 @@ export class CloneManager {
|
|||||||
return projectDir;
|
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.
|
/** Clone a repository and remove origin to isolate from the main repo.
|
||||||
* When `branch` is specified, `--branch` is passed to `git clone` so the
|
* When `branch` is specified, `--branch` is passed to `git clone` so the
|
||||||
* branch is checked out as a local branch *before* origin is removed.
|
* 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 */
|
/** Create a git clone for a task */
|
||||||
createSharedClone(projectDir: string, options: WorktreeOptions): WorktreeResult {
|
createSharedClone(projectDir: string, options: WorktreeOptions): WorktreeResult {
|
||||||
|
const { branch: baseBranch, fetchedCommit } = CloneManager.resolveBaseBranch(projectDir);
|
||||||
|
|
||||||
const clonePath = CloneManager.resolveClonePath(projectDir, options);
|
const clonePath = CloneManager.resolveClonePath(projectDir, options);
|
||||||
const branch = CloneManager.resolveBranchName(options);
|
const branch = CloneManager.resolveBranchName(options);
|
||||||
|
|
||||||
@ -188,7 +248,12 @@ export class CloneManager {
|
|||||||
if (CloneManager.branchExists(projectDir, branch)) {
|
if (CloneManager.branchExists(projectDir, branch)) {
|
||||||
CloneManager.cloneAndIsolate(projectDir, clonePath, branch);
|
CloneManager.cloneAndIsolate(projectDir, clonePath, branch);
|
||||||
} else {
|
} 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' });
|
execFileSync('git', ['checkout', '-b', branch], { cwd: clonePath, stdio: 'pipe' });
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -200,6 +265,8 @@ export class CloneManager {
|
|||||||
|
|
||||||
/** Create a temporary clone for an existing branch */
|
/** Create a temporary clone for an existing branch */
|
||||||
createTempCloneForBranch(projectDir: string, branch: string): WorktreeResult {
|
createTempCloneForBranch(projectDir: string, branch: string): WorktreeResult {
|
||||||
|
CloneManager.resolveBaseBranch(projectDir);
|
||||||
|
|
||||||
const timestamp = CloneManager.generateTimestamp();
|
const timestamp = CloneManager.generateTimestamp();
|
||||||
const clonePath = path.join(CloneManager.resolveCloneBaseDir(projectDir), `tmp-${timestamp}`);
|
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 {
|
export function cleanupOrphanedClone(projectDir: string, branch: string): void {
|
||||||
defaultManager.cleanupOrphanedClone(projectDir, branch);
|
defaultManager.cleanupOrphanedClone(projectDir, branch);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveBaseBranch(projectDir: string): { branch: string; fetchedCommit?: string } {
|
||||||
|
return CloneManager.resolveBaseBranch(projectDir);
|
||||||
|
}
|
||||||
|
|||||||
@ -44,6 +44,7 @@ export {
|
|||||||
saveCloneMeta,
|
saveCloneMeta,
|
||||||
removeCloneMeta,
|
removeCloneMeta,
|
||||||
cleanupOrphanedClone,
|
cleanupOrphanedClone,
|
||||||
|
resolveBaseBranch,
|
||||||
} from './clone.js';
|
} from './clone.js';
|
||||||
export {
|
export {
|
||||||
detectDefaultBranch,
|
detectDefaultBranch,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user