takt/src/infra/task/clone.ts
nrslib b54fbe32b2 clone時に既存ブランチのcheckoutが失敗する問題を修正
cloneAndIsolateがgit remote remove originした後、リモート追跡refが
全て消えるため、default以外の既存ブランチをcheckoutできなかった。

git clone --branchでclone時にローカルブランチを作成するように変更。
併せてブランチ名フォーマットからgit非互換の#を除去。
2026-02-12 11:52:43 +09:00

288 lines
9.7 KiB
TypeScript

/**
* Git clone lifecycle management
*
* Creates, removes, and tracks git clones for task isolation.
* Uses `git clone --reference --dissociate` so each clone has a fully
* independent .git directory, then removes the origin remote to prevent
* Claude Code SDK from traversing back to the main repository.
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import { execFileSync } from 'node:child_process';
import { createLogger, slugify } from '../../shared/utils/index.js';
import { loadGlobalConfig } from '../config/global/globalConfig.js';
import type { WorktreeOptions, WorktreeResult } from './types.js';
export type { WorktreeOptions, WorktreeResult };
const log = createLogger('clone');
const CLONE_META_DIR = 'clone-meta';
/**
* Manages git clone lifecycle for task isolation.
*
* Handles creation, removal, and metadata tracking of clones
* used for parallel task execution.
*/
export class CloneManager {
private static generateTimestamp(): string {
return new Date().toISOString().replace(/[-:.]/g, '').slice(0, 13);
}
/**
* Resolve the base directory for clones from global config.
* Returns the configured worktree_dir (resolved to absolute), or ../
*/
private static resolveCloneBaseDir(projectDir: string): string {
const globalConfig = loadGlobalConfig();
if (globalConfig.worktreeDir) {
return path.isAbsolute(globalConfig.worktreeDir)
? globalConfig.worktreeDir
: path.resolve(projectDir, globalConfig.worktreeDir);
}
return path.join(projectDir, '..');
}
/** Resolve the clone path based on options and global config */
private static resolveClonePath(projectDir: string, options: WorktreeOptions): string {
const timestamp = CloneManager.generateTimestamp();
const slug = slugify(options.taskSlug);
let dirName: string;
if (options.issueNumber !== undefined && slug) {
dirName = `${timestamp}-${options.issueNumber}-${slug}`;
} else if (slug) {
dirName = `${timestamp}-${slug}`;
} else {
dirName = timestamp;
}
if (typeof options.worktree === 'string') {
return path.isAbsolute(options.worktree)
? options.worktree
: path.resolve(projectDir, options.worktree);
}
return path.join(CloneManager.resolveCloneBaseDir(projectDir), dirName);
}
/** Resolve branch name from options */
private static resolveBranchName(options: WorktreeOptions): string {
if (options.branch) {
return options.branch;
}
const slug = slugify(options.taskSlug);
if (options.issueNumber !== undefined && slug) {
return `takt/${options.issueNumber}/${slug}`;
}
const timestamp = CloneManager.generateTimestamp();
return slug ? `takt/${timestamp}-${slug}` : `takt/${timestamp}`;
}
private static branchExists(projectDir: string, branch: string): boolean {
try {
execFileSync('git', ['rev-parse', '--verify', branch], {
cwd: projectDir,
stdio: 'pipe',
});
return true;
} catch {
return false;
}
}
/**
* Resolve the main repository path (handles git worktree case).
* If projectDir is a worktree, returns the main repo path.
* Otherwise, returns projectDir as-is.
*/
private static resolveMainRepo(projectDir: string): string {
const gitPath = path.join(projectDir, '.git');
try {
const stats = fs.statSync(gitPath);
if (stats.isFile()) {
const content = fs.readFileSync(gitPath, 'utf-8');
const match = content.match(/^gitdir:\s*(.+)$/m);
if (match && match[1]) {
const worktreePath = match[1].trim();
const gitDir = path.resolve(worktreePath, '..', '..');
const mainRepoPath = path.dirname(gitDir);
log.info('Detected worktree, using main repo', { worktree: projectDir, mainRepo: mainRepoPath });
return mainRepoPath;
}
}
} catch (err) {
log.debug('Failed to resolve main repo, using projectDir as-is', { error: String(err) });
}
return projectDir;
}
/** 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.
* Without this, non-default branches are lost when `git remote remove origin`
* deletes the remote-tracking refs.
*/
private static cloneAndIsolate(projectDir: string, clonePath: string, branch?: string): void {
const referenceRepo = CloneManager.resolveMainRepo(projectDir);
fs.mkdirSync(path.dirname(clonePath), { recursive: true });
const cloneArgs = ['clone', '--reference', referenceRepo, '--dissociate'];
if (branch) {
cloneArgs.push('--branch', branch);
}
cloneArgs.push(projectDir, clonePath);
execFileSync('git', cloneArgs, {
cwd: projectDir,
stdio: 'pipe',
});
execFileSync('git', ['remote', 'remove', 'origin'], {
cwd: clonePath,
stdio: 'pipe',
});
// Propagate local git user config from source repo to clone
for (const key of ['user.name', 'user.email']) {
try {
const value = execFileSync('git', ['config', '--local', key], {
cwd: projectDir,
stdio: 'pipe',
}).toString().trim();
if (value) {
execFileSync('git', ['config', key, value], {
cwd: clonePath,
stdio: 'pipe',
});
}
} catch {
// not set locally — skip
}
}
}
private static encodeBranchName(branch: string): string {
return branch.replace(/\//g, '--');
}
private static getCloneMetaPath(projectDir: string, branch: string): string {
return path.join(projectDir, '.takt', CLONE_META_DIR, `${CloneManager.encodeBranchName(branch)}.json`);
}
/** Create a git clone for a task */
createSharedClone(projectDir: string, options: WorktreeOptions): WorktreeResult {
const clonePath = CloneManager.resolveClonePath(projectDir, options);
const branch = CloneManager.resolveBranchName(options);
log.info('Creating shared clone', { path: clonePath, branch });
if (CloneManager.branchExists(projectDir, branch)) {
CloneManager.cloneAndIsolate(projectDir, clonePath, branch);
} else {
CloneManager.cloneAndIsolate(projectDir, clonePath);
execFileSync('git', ['checkout', '-b', branch], { cwd: clonePath, stdio: 'pipe' });
}
this.saveCloneMeta(projectDir, branch, clonePath);
log.info('Clone created', { path: clonePath, branch });
return { path: clonePath, branch };
}
/** Create a temporary clone for an existing branch */
createTempCloneForBranch(projectDir: string, branch: string): WorktreeResult {
const timestamp = CloneManager.generateTimestamp();
const clonePath = path.join(CloneManager.resolveCloneBaseDir(projectDir), `tmp-${timestamp}`);
log.info('Creating temp clone for branch', { path: clonePath, branch });
CloneManager.cloneAndIsolate(projectDir, clonePath, branch);
this.saveCloneMeta(projectDir, branch, clonePath);
log.info('Temp clone created', { path: clonePath, branch });
return { path: clonePath, branch };
}
/** Remove a clone directory */
removeClone(clonePath: string): void {
log.info('Removing clone', { path: clonePath });
try {
fs.rmSync(clonePath, { recursive: true, force: true });
log.info('Clone removed', { path: clonePath });
} catch (err) {
log.error('Failed to remove clone', { path: clonePath, error: String(err) });
}
}
/** Save clone metadata (branch → clonePath mapping) */
saveCloneMeta(projectDir: string, branch: string, clonePath: string): void {
const filePath = CloneManager.getCloneMetaPath(projectDir, branch);
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify({ branch, clonePath }));
log.info('Clone meta saved', { branch, clonePath });
}
/** Remove clone metadata for a branch */
removeCloneMeta(projectDir: string, branch: string): void {
try {
fs.unlinkSync(CloneManager.getCloneMetaPath(projectDir, branch));
log.info('Clone meta removed', { branch });
} catch {
// File may not exist — ignore
}
}
/** Clean up an orphaned clone directory associated with a branch */
cleanupOrphanedClone(projectDir: string, branch: string): void {
try {
const raw = fs.readFileSync(CloneManager.getCloneMetaPath(projectDir, branch), 'utf-8');
const meta = JSON.parse(raw) as { clonePath: string };
if (fs.existsSync(meta.clonePath)) {
this.removeClone(meta.clonePath);
log.info('Orphaned clone cleaned up', { branch, clonePath: meta.clonePath });
}
} catch {
// No metadata or parse error — nothing to clean up
}
this.removeCloneMeta(projectDir, branch);
}
}
// ---- Module-level functions ----
const defaultManager = new CloneManager();
export function createSharedClone(projectDir: string, options: WorktreeOptions): WorktreeResult {
return defaultManager.createSharedClone(projectDir, options);
}
export function createTempCloneForBranch(projectDir: string, branch: string): WorktreeResult {
return defaultManager.createTempCloneForBranch(projectDir, branch);
}
export function removeClone(clonePath: string): void {
defaultManager.removeClone(clonePath);
}
export function saveCloneMeta(projectDir: string, branch: string, clonePath: string): void {
defaultManager.saveCloneMeta(projectDir, branch, clonePath);
}
export function removeCloneMeta(projectDir: string, branch: string): void {
defaultManager.removeCloneMeta(projectDir, branch);
}
export function cleanupOrphanedClone(projectDir: string, branch: string): void {
defaultManager.cleanupOrphanedClone(projectDir, branch);
}