diff --git a/src/__tests__/clone.test.ts b/src/__tests__/clone.test.ts index 89d24e4..bfd0440 100644 --- a/src/__tests__/clone.test.ts +++ b/src/__tests__/clone.test.ts @@ -4,6 +4,12 @@ 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', () => ({ execFileSync: vi.fn(), })); @@ -24,9 +30,9 @@ vi.mock('node:fs', () => ({ vi.mock('../shared/utils/index.js', async (importOriginal) => ({ ...(await importOriginal>()), createLogger: () => ({ - info: vi.fn(), - debug: vi.fn(), - error: vi.fn(), + info: mockLogInfo, + debug: mockLogDebug, + error: mockLogError, }), })); @@ -35,14 +41,22 @@ vi.mock('../infra/config/global/globalConfig.js', () => ({ getBuiltinPiecesEnabled: vi.fn().mockReturnValue(true), })); +vi.mock('../infra/config/project/projectConfig.js', async (importOriginal) => ({ + ...(await importOriginal>()), + loadProjectConfig: vi.fn(() => ({ piece: 'default' })), +})); + import { execFileSync } from 'node:child_process'; import { loadGlobalConfig } from '../infra/config/global/globalConfig.js'; +import { loadProjectConfig } from '../infra/config/project/projectConfig.js'; import { createSharedClone, createTempCloneForBranch } from '../infra/task/clone.js'; const mockExecFileSync = vi.mocked(execFileSync); +const mockLoadProjectConfig = vi.mocked(loadProjectConfig); beforeEach(() => { vi.clearAllMocks(); + mockLoadProjectConfig.mockReturnValue({ piece: 'default' }); }); 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/, reset --hard', () => { it('should run git fetch, resolve origin/ commit hash, and reset --hard in the clone', () => { // Given: autoFetch is enabled in global config. diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts index 34b5f02..e4b8bbb 100644 --- a/src/__tests__/config.test.ts +++ b/src/__tests__/config.test.ts @@ -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', () => { let testDir: string; @@ -1247,6 +1336,28 @@ describe('saveProjectConfig snake_case denormalization', () => { expect((saved as Record).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).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', () => { saveProjectConfig(testDir, { piece: 'default', concurrency: 3 }); @@ -1264,6 +1375,7 @@ describe('saveProjectConfig snake_case denormalization', () => { expect(content).toContain('auto_pr:'); expect(content).toContain('draft_pr:'); expect(content).toContain('base_branch:'); + expect(content).not.toContain('withSubmodules:'); expect(content).not.toContain('autoPr:'); expect(content).not.toContain('draftPr:'); expect(content).not.toContain('baseBranch:'); diff --git a/src/__tests__/opencode-config.test.ts b/src/__tests__/opencode-config.test.ts index 6be44a4..a07dcfa 100644 --- a/src/__tests__/opencode-config.test.ts +++ b/src/__tests__/opencode-config.test.ts @@ -41,6 +41,29 @@ describe('Schemas accept opencode provider', () => { 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', () => { const result = CustomAgentConfigSchema.parse({ name: 'test', diff --git a/src/core/models/persisted-global-config.ts b/src/core/models/persisted-global-config.ts index 699f498..4f2f5b4 100644 --- a/src/core/models/persisted-global-config.ts +++ b/src/core/models/persisted-global-config.ts @@ -38,6 +38,9 @@ export interface AnalyticsConfig { retentionDays?: number; } +/** Project-level submodule acquisition selection */ +export type SubmoduleSelection = 'all' | string[]; + /** Language setting for takt */ export type Language = 'en' | 'ja'; @@ -141,4 +144,8 @@ export interface ProjectConfig { concurrency?: number; /** Base branch to clone from (overrides global baseBranch) */ baseBranch?: string; + /** Compatibility flag for full submodule acquisition when submodules is unset */ + withSubmodules?: boolean; + /** Submodule acquisition mode (all or explicit path list) */ + submodules?: SubmoduleSelection; } diff --git a/src/core/models/schemas.ts b/src/core/models/schemas.ts index e04f782..549f27c 100644 --- a/src/core/models/schemas.ts +++ b/src/core/models/schemas.ts @@ -498,4 +498,15 @@ export const ProjectConfigSchema = z.object({ concurrency: z.number().int().min(1).max(10).optional(), /** Base branch to clone from (overrides global base_branch) */ 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(), }); diff --git a/src/infra/config/project/projectConfig.ts b/src/infra/config/project/projectConfig.ts index bbdd97d..f65c3f2 100644 --- a/src/infra/config/project/projectConfig.ts +++ b/src/infra/config/project/projectConfig.ts @@ -10,7 +10,7 @@ import { parse, stringify } from 'yaml'; import { copyProjectResourcesToDir } from '../../resources/index.js'; import type { ProjectLocalConfig } from '../types.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 { normalizeProviderOptions } from '../loaders/pieceParser.js'; import { invalidateResolvedConfigCache } from '../resolutionCache.js'; @@ -22,6 +22,50 @@ const DEFAULT_PROJECT_CONFIG: ProjectLocalConfig = { 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) * Note: Defined locally to avoid circular dependency with paths.ts @@ -108,18 +152,26 @@ export function loadProjectConfig(projectDir: string): ProjectLocalConfig { auto_pr, draft_pr, base_branch, + submodules, + with_submodules, provider_options, provider_profiles, analytics, ...rest } = parsedConfig; + const normalizedSubmodules = normalizeSubmodules(submodules); + const normalizedWithSubmodules = normalizeWithSubmodules(with_submodules); + const effectiveWithSubmodules = normalizedSubmodules === undefined ? normalizedWithSubmodules : undefined; + return { ...DEFAULT_PROJECT_CONFIG, ...(rest as ProjectLocalConfig), autoPr: auto_pr as boolean | undefined, draftPr: draft_pr as boolean | undefined, baseBranch: base_branch as string | undefined, + submodules: normalizedSubmodules, + withSubmodules: effectiveWithSubmodules, analytics: normalizeAnalytics(analytics as Record | undefined), providerOptions: normalizeProviderOptions(provider_options as { codex?: { network_access?: boolean }; @@ -149,6 +201,7 @@ export function saveProjectConfig(projectDir: string, config: ProjectLocalConfig copyProjectResourcesToDir(configDir); const savePayload: Record = { ...config }; + const normalizedSubmodules = normalizeSubmodules(config.submodules); const rawAnalytics = denormalizeAnalytics(config.analytics); if (rawAnalytics) { @@ -169,9 +222,21 @@ export function saveProjectConfig(projectDir: string, config: ProjectLocalConfig if (config.autoPr !== undefined) savePayload.auto_pr = config.autoPr; if (config.draftPr !== undefined) savePayload.draft_pr = config.draftPr; 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.draftPr; delete savePayload.baseBranch; + delete savePayload.withSubmodules; const content = stringify(savePayload, { indent: 2 }); writeFileSync(configPath, content, 'utf-8'); diff --git a/src/infra/config/types.ts b/src/infra/config/types.ts index 3a2bc51..cb2a122 100644 --- a/src/infra/config/types.ts +++ b/src/infra/config/types.ts @@ -4,7 +4,7 @@ import type { MovementProviderOptions } from '../../core/models/piece-types.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 */ export interface ProjectLocalConfig { @@ -18,6 +18,10 @@ export interface ProjectLocalConfig { draftPr?: boolean; /** Base branch to clone from (overrides global baseBranch) */ 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?: boolean; /** Number of tasks to run concurrently in takt run (1-10) */ diff --git a/src/infra/task/clone.ts b/src/infra/task/clone.ts index 1bf8b20..d0371fa 100644 --- a/src/infra/task/clone.ts +++ b/src/infra/task/clone.ts @@ -11,7 +11,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { execFileSync } from 'node:child_process'; 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 type { WorktreeOptions, WorktreeResult } from './types.js'; @@ -21,6 +21,33 @@ const log = createLogger('clone'); 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. * @@ -188,10 +215,12 @@ export class CloneManager { */ private static cloneAndIsolate(projectDir: string, clonePath: string, branch?: string): void { const referenceRepo = CloneManager.resolveMainRepo(projectDir); + const cloneSubmoduleOptions = resolveCloneSubmoduleOptions(projectDir); fs.mkdirSync(path.dirname(clonePath), { recursive: true }); const cloneArgs = ['clone', '--reference', referenceRepo, '--dissociate']; + cloneArgs.push(...cloneSubmoduleOptions.args); if (branch) { cloneArgs.push('--branch', branch); } @@ -240,8 +269,12 @@ export class CloneManager { const clonePath = CloneManager.resolveClonePath(projectDir, 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)) { CloneManager.cloneAndIsolate(projectDir, clonePath, branch);