feat: add submodule acquisition support in project config (#387)
This commit is contained in:
parent
2fdbe8a795
commit
f6334b8e75
@ -4,6 +4,12 @@
|
|||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
const { mockLogInfo, mockLogDebug, mockLogError } = vi.hoisted(() => ({
|
||||||
|
mockLogInfo: vi.fn(),
|
||||||
|
mockLogDebug: vi.fn(),
|
||||||
|
mockLogError: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('node:child_process', () => ({
|
vi.mock('node:child_process', () => ({
|
||||||
execFileSync: vi.fn(),
|
execFileSync: vi.fn(),
|
||||||
}));
|
}));
|
||||||
@ -24,9 +30,9 @@ vi.mock('node:fs', () => ({
|
|||||||
vi.mock('../shared/utils/index.js', async (importOriginal) => ({
|
vi.mock('../shared/utils/index.js', async (importOriginal) => ({
|
||||||
...(await importOriginal<Record<string, unknown>>()),
|
...(await importOriginal<Record<string, unknown>>()),
|
||||||
createLogger: () => ({
|
createLogger: () => ({
|
||||||
info: vi.fn(),
|
info: mockLogInfo,
|
||||||
debug: vi.fn(),
|
debug: mockLogDebug,
|
||||||
error: vi.fn(),
|
error: mockLogError,
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@ -35,14 +41,22 @@ vi.mock('../infra/config/global/globalConfig.js', () => ({
|
|||||||
getBuiltinPiecesEnabled: vi.fn().mockReturnValue(true),
|
getBuiltinPiecesEnabled: vi.fn().mockReturnValue(true),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('../infra/config/project/projectConfig.js', async (importOriginal) => ({
|
||||||
|
...(await importOriginal<Record<string, unknown>>()),
|
||||||
|
loadProjectConfig: vi.fn(() => ({ piece: 'default' })),
|
||||||
|
}));
|
||||||
|
|
||||||
import { execFileSync } from 'node:child_process';
|
import { execFileSync } from 'node:child_process';
|
||||||
import { loadGlobalConfig } from '../infra/config/global/globalConfig.js';
|
import { loadGlobalConfig } from '../infra/config/global/globalConfig.js';
|
||||||
|
import { loadProjectConfig } from '../infra/config/project/projectConfig.js';
|
||||||
import { createSharedClone, createTempCloneForBranch } from '../infra/task/clone.js';
|
import { createSharedClone, createTempCloneForBranch } from '../infra/task/clone.js';
|
||||||
|
|
||||||
const mockExecFileSync = vi.mocked(execFileSync);
|
const mockExecFileSync = vi.mocked(execFileSync);
|
||||||
|
const mockLoadProjectConfig = vi.mocked(loadProjectConfig);
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
mockLoadProjectConfig.mockReturnValue({ piece: 'default' });
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('cloneAndIsolate git config propagation', () => {
|
describe('cloneAndIsolate git config propagation', () => {
|
||||||
@ -458,6 +472,104 @@ describe('resolveBaseBranch', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('clone submodule arguments', () => {
|
||||||
|
function setupCloneArgsCapture(): string[][] {
|
||||||
|
const cloneCalls: string[][] = [];
|
||||||
|
|
||||||
|
mockExecFileSync.mockImplementation((_cmd, args) => {
|
||||||
|
const argsArr = args as string[];
|
||||||
|
|
||||||
|
if (argsArr[0] === 'rev-parse' && argsArr[1] === '--abbrev-ref' && argsArr[2] === 'HEAD') {
|
||||||
|
return 'main\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('');
|
||||||
|
});
|
||||||
|
|
||||||
|
return cloneCalls;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('should append recurse flag when submodules is all', () => {
|
||||||
|
mockLoadProjectConfig.mockReturnValue({ piece: 'default', submodules: 'all' });
|
||||||
|
const cloneCalls = setupCloneArgsCapture();
|
||||||
|
|
||||||
|
createSharedClone('/project', {
|
||||||
|
worktree: true,
|
||||||
|
taskSlug: 'submodule-all',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(cloneCalls).toHaveLength(1);
|
||||||
|
expect(cloneCalls[0]).toContain('--recurse-submodules');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should append path-scoped recurse flags when submodules is explicit list', () => {
|
||||||
|
mockLoadProjectConfig.mockReturnValue({ piece: 'default', submodules: ['path/a', 'path/b'] });
|
||||||
|
const cloneCalls = setupCloneArgsCapture();
|
||||||
|
|
||||||
|
createSharedClone('/project', {
|
||||||
|
worktree: true,
|
||||||
|
taskSlug: 'submodule-path-list',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(cloneCalls).toHaveLength(1);
|
||||||
|
expect(cloneCalls[0]).toContain('--recurse-submodules=path/a');
|
||||||
|
expect(cloneCalls[0]).toContain('--recurse-submodules=path/b');
|
||||||
|
const creatingLog = mockLogInfo.mock.calls.find((call) =>
|
||||||
|
typeof call[0] === 'string' && call[0].includes('Creating shared clone')
|
||||||
|
);
|
||||||
|
expect(creatingLog?.[0]).toContain('targets: path/a, path/b');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should append recurse flag when withSubmodules is true and submodules is unset', () => {
|
||||||
|
mockLoadProjectConfig.mockReturnValue({ piece: 'default', withSubmodules: true });
|
||||||
|
const cloneCalls = setupCloneArgsCapture();
|
||||||
|
|
||||||
|
createSharedClone('/project', {
|
||||||
|
worktree: true,
|
||||||
|
taskSlug: 'with-submodules-fallback',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(cloneCalls).toHaveLength(1);
|
||||||
|
expect(cloneCalls[0]).toContain('--recurse-submodules');
|
||||||
|
const creatingLog = mockLogInfo.mock.calls.find((call) =>
|
||||||
|
typeof call[0] === 'string' && call[0].includes('Creating shared clone')
|
||||||
|
);
|
||||||
|
expect(creatingLog?.[0]).toContain('with submodule');
|
||||||
|
expect(creatingLog?.[0]).toContain('targets: all');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should keep existing clone args when submodule acquisition is disabled', () => {
|
||||||
|
mockLoadProjectConfig.mockReturnValue({ piece: 'default', withSubmodules: false });
|
||||||
|
const cloneCalls = setupCloneArgsCapture();
|
||||||
|
|
||||||
|
createSharedClone('/project', {
|
||||||
|
worktree: true,
|
||||||
|
taskSlug: 'without-submodules',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(cloneCalls).toHaveLength(1);
|
||||||
|
expect(cloneCalls[0].some((arg) => arg.startsWith('--recurse-submodules'))).toBe(false);
|
||||||
|
const creatingLog = mockLogInfo.mock.calls.find((call) =>
|
||||||
|
typeof call[0] === 'string' && call[0].includes('Creating shared clone')
|
||||||
|
);
|
||||||
|
expect(creatingLog?.[0]).toContain('without submodule');
|
||||||
|
expect(creatingLog?.[0]).toContain('targets: none');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('autoFetch: true — fetch, rev-parse origin/<branch>, reset --hard', () => {
|
describe('autoFetch: true — fetch, rev-parse origin/<branch>, reset --hard', () => {
|
||||||
it('should run git fetch, resolve origin/<branch> commit hash, and reset --hard in the clone', () => {
|
it('should run git fetch, resolve origin/<branch> commit hash, and reset --hard in the clone', () => {
|
||||||
// Given: autoFetch is enabled in global config.
|
// Given: autoFetch is enabled in global config.
|
||||||
|
|||||||
@ -1206,6 +1206,95 @@ describe('loadProjectConfig snake_case normalization', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('loadProjectConfig submodules', () => {
|
||||||
|
let testDir: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
testDir = join(tmpdir(), `takt-test-${randomUUID()}`);
|
||||||
|
mkdirSync(testDir, { recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (existsSync(testDir)) {
|
||||||
|
rmSync(testDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should normalize case-insensitive submodules all to canonical all', () => {
|
||||||
|
const projectConfigDir = getProjectConfigDir(testDir);
|
||||||
|
mkdirSync(projectConfigDir, { recursive: true });
|
||||||
|
writeFileSync(join(projectConfigDir, 'config.yaml'), 'submodules: ALL\n');
|
||||||
|
|
||||||
|
const config = loadProjectConfig(testDir);
|
||||||
|
|
||||||
|
expect(config.submodules).toBe('all');
|
||||||
|
expect(config.withSubmodules).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should keep explicit submodule path list as target set', () => {
|
||||||
|
const projectConfigDir = getProjectConfigDir(testDir);
|
||||||
|
mkdirSync(projectConfigDir, { recursive: true });
|
||||||
|
writeFileSync(join(projectConfigDir, 'config.yaml'), [
|
||||||
|
'submodules:',
|
||||||
|
' - path/a',
|
||||||
|
' - path/b',
|
||||||
|
].join('\n'));
|
||||||
|
|
||||||
|
const config = loadProjectConfig(testDir);
|
||||||
|
|
||||||
|
expect(config.submodules).toEqual(['path/a', 'path/b']);
|
||||||
|
expect(config.withSubmodules).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject wildcard-only path in submodules', () => {
|
||||||
|
const projectConfigDir = getProjectConfigDir(testDir);
|
||||||
|
mkdirSync(projectConfigDir, { recursive: true });
|
||||||
|
writeFileSync(join(projectConfigDir, 'config.yaml'), [
|
||||||
|
'submodules:',
|
||||||
|
' - "*"',
|
||||||
|
].join('\n'));
|
||||||
|
|
||||||
|
expect(() => loadProjectConfig(testDir)).toThrow('Invalid submodules');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject wildcard-like path in submodules', () => {
|
||||||
|
const projectConfigDir = getProjectConfigDir(testDir);
|
||||||
|
mkdirSync(projectConfigDir, { recursive: true });
|
||||||
|
writeFileSync(join(projectConfigDir, 'config.yaml'), [
|
||||||
|
'submodules:',
|
||||||
|
' - libs/*',
|
||||||
|
].join('\n'));
|
||||||
|
|
||||||
|
expect(() => loadProjectConfig(testDir)).toThrow('Invalid submodules');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should prefer submodules over with_submodules', () => {
|
||||||
|
const projectConfigDir = getProjectConfigDir(testDir);
|
||||||
|
mkdirSync(projectConfigDir, { recursive: true });
|
||||||
|
writeFileSync(join(projectConfigDir, 'config.yaml'), [
|
||||||
|
'submodules:',
|
||||||
|
' - path/a',
|
||||||
|
'with_submodules: true',
|
||||||
|
].join('\n'));
|
||||||
|
|
||||||
|
const config = loadProjectConfig(testDir);
|
||||||
|
|
||||||
|
expect(config.submodules).toEqual(['path/a']);
|
||||||
|
expect(config.withSubmodules).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should treat with_submodules true as fallback full acquisition when submodules is unset', () => {
|
||||||
|
const projectConfigDir = getProjectConfigDir(testDir);
|
||||||
|
mkdirSync(projectConfigDir, { recursive: true });
|
||||||
|
writeFileSync(join(projectConfigDir, 'config.yaml'), 'with_submodules: true\n');
|
||||||
|
|
||||||
|
const config = loadProjectConfig(testDir);
|
||||||
|
|
||||||
|
expect(config.submodules).toBeUndefined();
|
||||||
|
expect(config.withSubmodules).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('saveProjectConfig snake_case denormalization', () => {
|
describe('saveProjectConfig snake_case denormalization', () => {
|
||||||
let testDir: string;
|
let testDir: string;
|
||||||
|
|
||||||
@ -1247,6 +1336,28 @@ describe('saveProjectConfig snake_case denormalization', () => {
|
|||||||
expect((saved as Record<string, unknown>).base_branch).toBeUndefined();
|
expect((saved as Record<string, unknown>).base_branch).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should persist withSubmodules as with_submodules and reload correctly', () => {
|
||||||
|
saveProjectConfig(testDir, { piece: 'default', withSubmodules: true });
|
||||||
|
|
||||||
|
const saved = loadProjectConfig(testDir);
|
||||||
|
|
||||||
|
expect(saved.withSubmodules).toBe(true);
|
||||||
|
expect((saved as Record<string, unknown>).with_submodules).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should persist submodules and ignore with_submodules when both are provided', () => {
|
||||||
|
saveProjectConfig(testDir, { piece: 'default', submodules: ['path/a'], withSubmodules: true });
|
||||||
|
|
||||||
|
const projectConfigDir = getProjectConfigDir(testDir);
|
||||||
|
const content = readFileSync(join(projectConfigDir, 'config.yaml'), 'utf-8');
|
||||||
|
const saved = loadProjectConfig(testDir);
|
||||||
|
|
||||||
|
expect(content).toContain('submodules:');
|
||||||
|
expect(content).not.toContain('with_submodules:');
|
||||||
|
expect(saved.submodules).toEqual(['path/a']);
|
||||||
|
expect(saved.withSubmodules).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it('should persist concurrency and reload correctly', () => {
|
it('should persist concurrency and reload correctly', () => {
|
||||||
saveProjectConfig(testDir, { piece: 'default', concurrency: 3 });
|
saveProjectConfig(testDir, { piece: 'default', concurrency: 3 });
|
||||||
|
|
||||||
@ -1264,6 +1375,7 @@ describe('saveProjectConfig snake_case denormalization', () => {
|
|||||||
expect(content).toContain('auto_pr:');
|
expect(content).toContain('auto_pr:');
|
||||||
expect(content).toContain('draft_pr:');
|
expect(content).toContain('draft_pr:');
|
||||||
expect(content).toContain('base_branch:');
|
expect(content).toContain('base_branch:');
|
||||||
|
expect(content).not.toContain('withSubmodules:');
|
||||||
expect(content).not.toContain('autoPr:');
|
expect(content).not.toContain('autoPr:');
|
||||||
expect(content).not.toContain('draftPr:');
|
expect(content).not.toContain('draftPr:');
|
||||||
expect(content).not.toContain('baseBranch:');
|
expect(content).not.toContain('baseBranch:');
|
||||||
|
|||||||
@ -41,6 +41,29 @@ describe('Schemas accept opencode provider', () => {
|
|||||||
expect(result.concurrency).toBe(3);
|
expect(result.concurrency).toBe(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should accept submodules all in ProjectConfigSchema', () => {
|
||||||
|
const result = ProjectConfigSchema.parse({ submodules: 'ALL' });
|
||||||
|
expect(result.submodules).toBe('ALL');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should accept explicit submodule path list in ProjectConfigSchema', () => {
|
||||||
|
const result = ProjectConfigSchema.parse({ submodules: ['path/a', 'path/b'] });
|
||||||
|
expect(result.submodules).toEqual(['path/a', 'path/b']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should accept with_submodules in ProjectConfigSchema', () => {
|
||||||
|
const result = ProjectConfigSchema.parse({ with_submodules: true });
|
||||||
|
expect(result.with_submodules).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject wildcard path in ProjectConfigSchema submodules', () => {
|
||||||
|
expect(() => ProjectConfigSchema.parse({ submodules: ['libs/*'] })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject non-all string in ProjectConfigSchema submodules', () => {
|
||||||
|
expect(() => ProjectConfigSchema.parse({ submodules: 'libs' })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
it('should accept opencode in CustomAgentConfigSchema', () => {
|
it('should accept opencode in CustomAgentConfigSchema', () => {
|
||||||
const result = CustomAgentConfigSchema.parse({
|
const result = CustomAgentConfigSchema.parse({
|
||||||
name: 'test',
|
name: 'test',
|
||||||
|
|||||||
@ -38,6 +38,9 @@ export interface AnalyticsConfig {
|
|||||||
retentionDays?: number;
|
retentionDays?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Project-level submodule acquisition selection */
|
||||||
|
export type SubmoduleSelection = 'all' | string[];
|
||||||
|
|
||||||
/** Language setting for takt */
|
/** Language setting for takt */
|
||||||
export type Language = 'en' | 'ja';
|
export type Language = 'en' | 'ja';
|
||||||
|
|
||||||
@ -141,4 +144,8 @@ export interface ProjectConfig {
|
|||||||
concurrency?: number;
|
concurrency?: number;
|
||||||
/** Base branch to clone from (overrides global baseBranch) */
|
/** Base branch to clone from (overrides global baseBranch) */
|
||||||
baseBranch?: string;
|
baseBranch?: string;
|
||||||
|
/** Compatibility flag for full submodule acquisition when submodules is unset */
|
||||||
|
withSubmodules?: boolean;
|
||||||
|
/** Submodule acquisition mode (all or explicit path list) */
|
||||||
|
submodules?: SubmoduleSelection;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -498,4 +498,15 @@ export const ProjectConfigSchema = z.object({
|
|||||||
concurrency: z.number().int().min(1).max(10).optional(),
|
concurrency: z.number().int().min(1).max(10).optional(),
|
||||||
/** Base branch to clone from (overrides global base_branch) */
|
/** Base branch to clone from (overrides global base_branch) */
|
||||||
base_branch: z.string().optional(),
|
base_branch: z.string().optional(),
|
||||||
|
/** Submodule acquisition mode (all or explicit path list) */
|
||||||
|
submodules: z.union([
|
||||||
|
z.string().refine((value) => value.trim().toLowerCase() === 'all', {
|
||||||
|
message: 'submodules string value must be "all"',
|
||||||
|
}),
|
||||||
|
z.array(z.string().min(1)).refine((paths) => paths.every((path) => !path.includes('*')), {
|
||||||
|
message: 'submodules path entries must not include wildcard "*"',
|
||||||
|
}),
|
||||||
|
]).optional(),
|
||||||
|
/** Compatibility flag for full submodule acquisition when submodules is unset */
|
||||||
|
with_submodules: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|||||||
@ -10,7 +10,7 @@ import { parse, stringify } from 'yaml';
|
|||||||
import { copyProjectResourcesToDir } from '../../resources/index.js';
|
import { copyProjectResourcesToDir } from '../../resources/index.js';
|
||||||
import type { ProjectLocalConfig } from '../types.js';
|
import type { ProjectLocalConfig } from '../types.js';
|
||||||
import type { ProviderPermissionProfiles } from '../../../core/models/provider-profiles.js';
|
import type { ProviderPermissionProfiles } from '../../../core/models/provider-profiles.js';
|
||||||
import type { AnalyticsConfig } from '../../../core/models/persisted-global-config.js';
|
import type { AnalyticsConfig, SubmoduleSelection } from '../../../core/models/persisted-global-config.js';
|
||||||
import { applyProjectConfigEnvOverrides } from '../env/config-env-overrides.js';
|
import { applyProjectConfigEnvOverrides } from '../env/config-env-overrides.js';
|
||||||
import { normalizeProviderOptions } from '../loaders/pieceParser.js';
|
import { normalizeProviderOptions } from '../loaders/pieceParser.js';
|
||||||
import { invalidateResolvedConfigCache } from '../resolutionCache.js';
|
import { invalidateResolvedConfigCache } from '../resolutionCache.js';
|
||||||
@ -22,6 +22,50 @@ const DEFAULT_PROJECT_CONFIG: ProjectLocalConfig = {
|
|||||||
piece: 'default',
|
piece: 'default',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const SUBMODULES_ALL = 'all';
|
||||||
|
|
||||||
|
function normalizeSubmodules(raw: unknown): SubmoduleSelection | undefined {
|
||||||
|
if (raw === undefined) return undefined;
|
||||||
|
|
||||||
|
if (typeof raw === 'string') {
|
||||||
|
const normalized = raw.trim().toLowerCase();
|
||||||
|
if (normalized === SUBMODULES_ALL) {
|
||||||
|
return SUBMODULES_ALL;
|
||||||
|
}
|
||||||
|
throw new Error('Invalid submodules: string value must be "all"');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(raw)) {
|
||||||
|
if (raw.length === 0) {
|
||||||
|
throw new Error('Invalid submodules: explicit path list must not be empty');
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedPaths = raw.map((entry) => {
|
||||||
|
if (typeof entry !== 'string') {
|
||||||
|
throw new Error('Invalid submodules: path entries must be strings');
|
||||||
|
}
|
||||||
|
const trimmed = entry.trim();
|
||||||
|
if (trimmed.length === 0) {
|
||||||
|
throw new Error('Invalid submodules: path entries must not be empty');
|
||||||
|
}
|
||||||
|
if (trimmed.includes('*')) {
|
||||||
|
throw new Error(`Invalid submodules: wildcard is not supported (${trimmed})`);
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
});
|
||||||
|
|
||||||
|
return normalizedPaths;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Invalid submodules: must be "all" or an explicit path list');
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeWithSubmodules(raw: unknown): boolean | undefined {
|
||||||
|
if (raw === undefined) return undefined;
|
||||||
|
if (typeof raw === 'boolean') return raw;
|
||||||
|
throw new Error('Invalid with_submodules: value must be boolean');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get project takt config directory (.takt in project)
|
* Get project takt config directory (.takt in project)
|
||||||
* Note: Defined locally to avoid circular dependency with paths.ts
|
* Note: Defined locally to avoid circular dependency with paths.ts
|
||||||
@ -108,18 +152,26 @@ export function loadProjectConfig(projectDir: string): ProjectLocalConfig {
|
|||||||
auto_pr,
|
auto_pr,
|
||||||
draft_pr,
|
draft_pr,
|
||||||
base_branch,
|
base_branch,
|
||||||
|
submodules,
|
||||||
|
with_submodules,
|
||||||
provider_options,
|
provider_options,
|
||||||
provider_profiles,
|
provider_profiles,
|
||||||
analytics,
|
analytics,
|
||||||
...rest
|
...rest
|
||||||
} = parsedConfig;
|
} = parsedConfig;
|
||||||
|
|
||||||
|
const normalizedSubmodules = normalizeSubmodules(submodules);
|
||||||
|
const normalizedWithSubmodules = normalizeWithSubmodules(with_submodules);
|
||||||
|
const effectiveWithSubmodules = normalizedSubmodules === undefined ? normalizedWithSubmodules : undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...DEFAULT_PROJECT_CONFIG,
|
...DEFAULT_PROJECT_CONFIG,
|
||||||
...(rest as ProjectLocalConfig),
|
...(rest as ProjectLocalConfig),
|
||||||
autoPr: auto_pr as boolean | undefined,
|
autoPr: auto_pr as boolean | undefined,
|
||||||
draftPr: draft_pr as boolean | undefined,
|
draftPr: draft_pr as boolean | undefined,
|
||||||
baseBranch: base_branch as string | undefined,
|
baseBranch: base_branch as string | undefined,
|
||||||
|
submodules: normalizedSubmodules,
|
||||||
|
withSubmodules: effectiveWithSubmodules,
|
||||||
analytics: normalizeAnalytics(analytics as Record<string, unknown> | undefined),
|
analytics: normalizeAnalytics(analytics as Record<string, unknown> | undefined),
|
||||||
providerOptions: normalizeProviderOptions(provider_options as {
|
providerOptions: normalizeProviderOptions(provider_options as {
|
||||||
codex?: { network_access?: boolean };
|
codex?: { network_access?: boolean };
|
||||||
@ -149,6 +201,7 @@ export function saveProjectConfig(projectDir: string, config: ProjectLocalConfig
|
|||||||
copyProjectResourcesToDir(configDir);
|
copyProjectResourcesToDir(configDir);
|
||||||
|
|
||||||
const savePayload: Record<string, unknown> = { ...config };
|
const savePayload: Record<string, unknown> = { ...config };
|
||||||
|
const normalizedSubmodules = normalizeSubmodules(config.submodules);
|
||||||
|
|
||||||
const rawAnalytics = denormalizeAnalytics(config.analytics);
|
const rawAnalytics = denormalizeAnalytics(config.analytics);
|
||||||
if (rawAnalytics) {
|
if (rawAnalytics) {
|
||||||
@ -169,9 +222,21 @@ export function saveProjectConfig(projectDir: string, config: ProjectLocalConfig
|
|||||||
if (config.autoPr !== undefined) savePayload.auto_pr = config.autoPr;
|
if (config.autoPr !== undefined) savePayload.auto_pr = config.autoPr;
|
||||||
if (config.draftPr !== undefined) savePayload.draft_pr = config.draftPr;
|
if (config.draftPr !== undefined) savePayload.draft_pr = config.draftPr;
|
||||||
if (config.baseBranch !== undefined) savePayload.base_branch = config.baseBranch;
|
if (config.baseBranch !== undefined) savePayload.base_branch = config.baseBranch;
|
||||||
|
if (normalizedSubmodules !== undefined) {
|
||||||
|
savePayload.submodules = normalizedSubmodules;
|
||||||
|
delete savePayload.with_submodules;
|
||||||
|
} else {
|
||||||
|
delete savePayload.submodules;
|
||||||
|
if (config.withSubmodules !== undefined) {
|
||||||
|
savePayload.with_submodules = config.withSubmodules;
|
||||||
|
} else {
|
||||||
|
delete savePayload.with_submodules;
|
||||||
|
}
|
||||||
|
}
|
||||||
delete savePayload.autoPr;
|
delete savePayload.autoPr;
|
||||||
delete savePayload.draftPr;
|
delete savePayload.draftPr;
|
||||||
delete savePayload.baseBranch;
|
delete savePayload.baseBranch;
|
||||||
|
delete savePayload.withSubmodules;
|
||||||
|
|
||||||
const content = stringify(savePayload, { indent: 2 });
|
const content = stringify(savePayload, { indent: 2 });
|
||||||
writeFileSync(configPath, content, 'utf-8');
|
writeFileSync(configPath, content, 'utf-8');
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
import type { MovementProviderOptions } from '../../core/models/piece-types.js';
|
import type { MovementProviderOptions } from '../../core/models/piece-types.js';
|
||||||
import type { ProviderPermissionProfiles } from '../../core/models/provider-profiles.js';
|
import type { ProviderPermissionProfiles } from '../../core/models/provider-profiles.js';
|
||||||
import type { AnalyticsConfig } from '../../core/models/persisted-global-config.js';
|
import type { AnalyticsConfig, SubmoduleSelection } from '../../core/models/persisted-global-config.js';
|
||||||
|
|
||||||
/** Project configuration stored in .takt/config.yaml */
|
/** Project configuration stored in .takt/config.yaml */
|
||||||
export interface ProjectLocalConfig {
|
export interface ProjectLocalConfig {
|
||||||
@ -18,6 +18,10 @@ export interface ProjectLocalConfig {
|
|||||||
draftPr?: boolean;
|
draftPr?: boolean;
|
||||||
/** Base branch to clone from (overrides global baseBranch) */
|
/** Base branch to clone from (overrides global baseBranch) */
|
||||||
baseBranch?: string;
|
baseBranch?: string;
|
||||||
|
/** Submodule acquisition mode (all or explicit path list) */
|
||||||
|
submodules?: SubmoduleSelection;
|
||||||
|
/** Compatibility flag for full submodule acquisition when submodules is unset */
|
||||||
|
withSubmodules?: boolean;
|
||||||
/** Verbose output mode */
|
/** Verbose output mode */
|
||||||
verbose?: boolean;
|
verbose?: boolean;
|
||||||
/** Number of tasks to run concurrently in takt run (1-10) */
|
/** Number of tasks to run concurrently in takt run (1-10) */
|
||||||
|
|||||||
@ -11,7 +11,7 @@ import * as fs from 'node:fs';
|
|||||||
import * as path from 'node:path';
|
import * as path from 'node:path';
|
||||||
import { execFileSync } from 'node:child_process';
|
import { execFileSync } from 'node:child_process';
|
||||||
import { createLogger } from '../../shared/utils/index.js';
|
import { createLogger } from '../../shared/utils/index.js';
|
||||||
import { resolveConfigValue } from '../config/index.js';
|
import { loadProjectConfig, resolveConfigValue } from '../config/index.js';
|
||||||
import { detectDefaultBranch } from './branchList.js';
|
import { detectDefaultBranch } from './branchList.js';
|
||||||
import type { WorktreeOptions, WorktreeResult } from './types.js';
|
import type { WorktreeOptions, WorktreeResult } from './types.js';
|
||||||
|
|
||||||
@ -21,6 +21,33 @@ const log = createLogger('clone');
|
|||||||
|
|
||||||
const CLONE_META_DIR = 'clone-meta';
|
const CLONE_META_DIR = 'clone-meta';
|
||||||
|
|
||||||
|
function resolveCloneSubmoduleOptions(projectDir: string): { args: string[]; label: string; targets: string } {
|
||||||
|
const config = loadProjectConfig(projectDir);
|
||||||
|
const resolvedSubmodules = config.submodules ?? (config.withSubmodules === true ? 'all' : undefined);
|
||||||
|
|
||||||
|
if (resolvedSubmodules === 'all') {
|
||||||
|
return {
|
||||||
|
args: ['--recurse-submodules'],
|
||||||
|
label: 'with submodule',
|
||||||
|
targets: 'all',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(resolvedSubmodules) && resolvedSubmodules.length > 0) {
|
||||||
|
return {
|
||||||
|
args: resolvedSubmodules.map((submodulePath) => `--recurse-submodules=${submodulePath}`),
|
||||||
|
label: 'with submodule',
|
||||||
|
targets: resolvedSubmodules.join(', '),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
args: [],
|
||||||
|
label: 'without submodule',
|
||||||
|
targets: 'none',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages git clone lifecycle for task isolation.
|
* Manages git clone lifecycle for task isolation.
|
||||||
*
|
*
|
||||||
@ -188,10 +215,12 @@ export class CloneManager {
|
|||||||
*/
|
*/
|
||||||
private static cloneAndIsolate(projectDir: string, clonePath: string, branch?: string): void {
|
private static cloneAndIsolate(projectDir: string, clonePath: string, branch?: string): void {
|
||||||
const referenceRepo = CloneManager.resolveMainRepo(projectDir);
|
const referenceRepo = CloneManager.resolveMainRepo(projectDir);
|
||||||
|
const cloneSubmoduleOptions = resolveCloneSubmoduleOptions(projectDir);
|
||||||
|
|
||||||
fs.mkdirSync(path.dirname(clonePath), { recursive: true });
|
fs.mkdirSync(path.dirname(clonePath), { recursive: true });
|
||||||
|
|
||||||
const cloneArgs = ['clone', '--reference', referenceRepo, '--dissociate'];
|
const cloneArgs = ['clone', '--reference', referenceRepo, '--dissociate'];
|
||||||
|
cloneArgs.push(...cloneSubmoduleOptions.args);
|
||||||
if (branch) {
|
if (branch) {
|
||||||
cloneArgs.push('--branch', branch);
|
cloneArgs.push('--branch', branch);
|
||||||
}
|
}
|
||||||
@ -240,8 +269,12 @@ export class CloneManager {
|
|||||||
|
|
||||||
const clonePath = CloneManager.resolveClonePath(projectDir, options);
|
const clonePath = CloneManager.resolveClonePath(projectDir, options);
|
||||||
const branch = CloneManager.resolveBranchName(options);
|
const branch = CloneManager.resolveBranchName(options);
|
||||||
|
const cloneSubmoduleOptions = resolveCloneSubmoduleOptions(projectDir);
|
||||||
|
|
||||||
log.info('Creating shared clone', { path: clonePath, branch });
|
log.info(
|
||||||
|
`Creating shared clone (${cloneSubmoduleOptions.label}, targets: ${cloneSubmoduleOptions.targets})`,
|
||||||
|
{ path: clonePath, branch }
|
||||||
|
);
|
||||||
|
|
||||||
if (CloneManager.branchExists(projectDir, branch)) {
|
if (CloneManager.branchExists(projectDir, branch)) {
|
||||||
CloneManager.cloneAndIsolate(projectDir, clonePath, branch);
|
CloneManager.cloneAndIsolate(projectDir, clonePath, branch);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user