Worktree をプロジェクト外に作成するよう変更

- config.yaml に worktree_dir 設定を追加
- デフォルトは ../{tree-name}(プロジェクトの兄弟ディレクトリ)
- Claude Code のプロジェクト検出問題を回避
This commit is contained in:
nrslib 2026-01-29 09:41:24 +09:00
parent f83b826a3d
commit 0ecbf6e56b
4 changed files with 38 additions and 14 deletions

View File

@ -35,6 +35,7 @@ export function loadGlobalConfig(): GlobalConfig {
enabled: parsed.debug.enabled, enabled: parsed.debug.enabled,
logFile: parsed.debug.log_file, logFile: parsed.debug.log_file,
} : undefined, } : undefined,
worktreeDir: parsed.worktree_dir,
}; };
} }
@ -57,6 +58,9 @@ export function saveGlobalConfig(config: GlobalConfig): void {
log_file: config.debug.logFile, log_file: config.debug.logFile,
}; };
} }
if (config.worktreeDir) {
raw.worktree_dir = config.worktreeDir;
}
writeFileSync(configPath, stringifyYaml(raw), 'utf-8'); writeFileSync(configPath, stringifyYaml(raw), 'utf-8');
} }

View File

@ -127,6 +127,8 @@ export const GlobalConfigSchema = z.object({
provider: z.enum(['claude', 'codex', 'mock']).optional().default('claude'), provider: z.enum(['claude', 'codex', 'mock']).optional().default('claude'),
model: z.string().optional(), model: z.string().optional(),
debug: DebugConfigSchema.optional(), debug: DebugConfigSchema.optional(),
/** Directory for worktrees. If empty, uses ../{tree-name} relative to project */
worktree_dir: z.string().optional(),
}); });
/** Project config schema */ /** Project config schema */

View File

@ -160,6 +160,8 @@ export interface GlobalConfig {
provider?: 'claude' | 'codex' | 'mock'; provider?: 'claude' | 'codex' | 'mock';
model?: string; model?: string;
debug?: DebugConfig; debug?: DebugConfig;
/** Directory for worktrees. If empty, uses ../{tree-name} relative to project */
worktreeDir?: string;
} }
/** Project-level configuration */ /** Project-level configuration */

View File

@ -9,7 +9,7 @@ import * as path from 'node:path';
import { execFileSync } from 'node:child_process'; import { execFileSync } from 'node:child_process';
import { createLogger } from '../utils/debug.js'; import { createLogger } from '../utils/debug.js';
import { slugify } from '../utils/slug.js'; import { slugify } from '../utils/slug.js';
import { isPathSafe } from '../config/paths.js'; import { loadGlobalConfig } from '../config/globalConfig.js';
const log = createLogger('worktree'); const log = createLogger('worktree');
@ -37,29 +37,45 @@ function generateTimestamp(): string {
} }
/** /**
* Resolve the worktree path based on options. * Resolve the worktree path based on options and global config.
* Validates that custom paths stay within the project directory.
* *
* @throws Error if a custom path escapes projectDir (path traversal) * Priority:
* 1. Custom path in options.worktree (string)
* 2. worktree_dir from config.yaml (if set)
* 3. Default: ../{tree-name} (outside project to avoid Claude Code project detection issues)
*/ */
function resolveWorktreePath(projectDir: string, options: WorktreeOptions): string { function resolveWorktreePath(projectDir: string, options: WorktreeOptions): string {
const timestamp = generateTimestamp();
const slug = slugify(options.taskSlug);
const dirName = slug ? `${timestamp}-${slug}` : timestamp;
// Custom path specified in task options
if (typeof options.worktree === 'string') { if (typeof options.worktree === 'string') {
const resolved = path.isAbsolute(options.worktree) const resolved = path.isAbsolute(options.worktree)
? options.worktree ? options.worktree
: path.resolve(projectDir, options.worktree); : path.resolve(projectDir, options.worktree);
if (!isPathSafe(projectDir, resolved)) {
throw new Error(`Worktree path escapes project directory: ${options.worktree}`);
}
return resolved; return resolved;
} }
// worktree: true → .takt/worktrees/{timestamp}-{task-slug}/ // Load config to check for worktree_dir setting
const timestamp = generateTimestamp(); let worktreeBaseDir: string | undefined;
const slug = slugify(options.taskSlug); try {
const dirName = slug ? `${timestamp}-${slug}` : timestamp; const globalConfig = loadGlobalConfig();
return path.join(projectDir, '.takt', 'worktrees', dirName); worktreeBaseDir = globalConfig.worktreeDir;
} catch {
// Config not found, use default
}
if (worktreeBaseDir) {
// Use configured worktree directory
const resolved = path.isAbsolute(worktreeBaseDir)
? worktreeBaseDir
: path.resolve(projectDir, worktreeBaseDir);
return path.join(resolved, dirName);
}
// Default: ../{tree-name} (sibling to project directory)
return path.join(projectDir, '..', dirName);
} }
/** /**