mirror of
https://github.com/PR0M3TH3AN/TORCH.git
synced 2026-10-31 09:51:52 +00:00
174 lines
7.5 KiB
JavaScript
174 lines
7.5 KiB
JavaScript
import { execFileSync } from 'node:child_process';
|
|
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
import { homedir } from 'node:os';
|
|
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
import { TorchError } from './errors.mjs';
|
|
import { loadProjectConfig } from './config.mjs';
|
|
import { readInstallManifest, writeInstallManifest } from './install.mjs';
|
|
|
|
function git(root, args, { optional = false } = {}) {
|
|
try {
|
|
return execFileSync('git', ['-C', root, ...args], {
|
|
encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
|
|
}).trim();
|
|
} catch (error) {
|
|
if (optional) return '';
|
|
throw new TorchError(`Git command failed: git ${args.join(' ')}`, {
|
|
code: 'GIT_COMMAND_FAILED', details: error.stderr?.toString().trim() || undefined,
|
|
});
|
|
}
|
|
}
|
|
|
|
function parseJson(path, code) {
|
|
try { return JSON.parse(readFileSync(path, 'utf8')); } catch (error) {
|
|
throw new TorchError(`Cannot read ${path}`, { code, details: error.message });
|
|
}
|
|
}
|
|
|
|
export function loadFleetDefinition(root) {
|
|
const config = loadProjectConfig(root);
|
|
const roster = parseJson(join(root, '.torch', 'roster.yaml'), 'ROSTER_INVALID');
|
|
return { config, roster };
|
|
}
|
|
|
|
function expandPath(value, root) {
|
|
if (value === '~') return homedir();
|
|
if (value.startsWith('~/')) return join(homedir(), value.slice(2));
|
|
return isAbsolute(value) ? value : resolve(root, value);
|
|
}
|
|
|
|
function listedWorktrees(root) {
|
|
const text = git(root, ['worktree', 'list', '--porcelain']);
|
|
const records = [];
|
|
let current = null;
|
|
for (const line of text.split('\n')) {
|
|
if (line.startsWith('worktree ')) {
|
|
current = { path: line.slice(9), branch: null, head: null };
|
|
records.push(current);
|
|
} else if (current && line.startsWith('branch refs/heads/')) current.branch = line.slice(18);
|
|
else if (current && line.startsWith('HEAD ')) current.head = line.slice(5);
|
|
}
|
|
return records;
|
|
}
|
|
|
|
function branchExists(root, branch) {
|
|
return Boolean(git(root, ['show-ref', '--verify', '--quiet', `refs/heads/${branch}`], { optional: true })
|
|
|| git(root, ['branch', '--list', branch], { optional: true }));
|
|
}
|
|
|
|
function validateAreaId(id) {
|
|
if (!/^[a-z0-9][a-z0-9-]*$/.test(id)) {
|
|
throw new TorchError(`Unsafe domain id for a worktree: ${id}`, { code: 'INVALID_DOMAIN_ID' });
|
|
}
|
|
}
|
|
|
|
function validateWorktreeName(name, areaId) {
|
|
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name)) {
|
|
throw new TorchError(`Unsafe worktree name for ${areaId}: ${name}`, { code: 'INVALID_WORKTREE_NAME' });
|
|
}
|
|
}
|
|
|
|
export function planWorktrees({ repository, parentOverride } = {}) {
|
|
const { config, roster } = loadFleetDefinition(repository.root);
|
|
const manifest = readInstallManifest(repository.root);
|
|
const parent = expandPath(parentOverride ?? config.paths.worktree_parent, repository.root);
|
|
const fleetRoot = join(parent, config.project.id);
|
|
const prefix = config.git.branch_prefix ?? 'torch/';
|
|
const worktrees = listedWorktrees(repository.root);
|
|
const actions = [];
|
|
const conflicts = [];
|
|
|
|
for (const area of roster.areas) {
|
|
validateAreaId(area.id);
|
|
const worktreeName = area.worktree_name ?? area.id;
|
|
validateWorktreeName(worktreeName, area.id);
|
|
const path = area.worktree_name ? join(parent, worktreeName) : join(fleetRoot, worktreeName);
|
|
const branch = area.branch ?? `${prefix}${area.id}`;
|
|
if (!git(repository.root, ['check-ref-format', '--branch', branch], { optional: true })) {
|
|
conflicts.push({ area: area.id, type: 'invalid-branch', path, branch });
|
|
continue;
|
|
}
|
|
const byPath = worktrees.find((entry) => resolve(entry.path) === resolve(path));
|
|
const byBranch = worktrees.find((entry) => entry.branch === branch);
|
|
if (byPath && byPath.branch === branch) {
|
|
actions.push({ area: area.id, path, branch, action: 'keep' });
|
|
} else if (byPath) {
|
|
conflicts.push({ area: area.id, type: 'path-owned-by-other-branch', path, expected: branch, actual: byPath.branch });
|
|
} else if (byBranch || branchExists(repository.root, branch)) {
|
|
conflicts.push({ area: area.id, type: 'branch-already-exists', path, branch, currentPath: byBranch?.path ?? null });
|
|
} else if (existsSync(path)) {
|
|
conflicts.push({ area: area.id, type: 'path-already-exists', path, branch });
|
|
} else {
|
|
actions.push({ area: area.id, path, branch, action: 'create' });
|
|
}
|
|
}
|
|
|
|
const mainBranch = config.project.main_branch;
|
|
if (!branchExists(repository.root, mainBranch)) {
|
|
conflicts.push({ type: 'main-branch-missing', branch: mainBranch });
|
|
}
|
|
if (repository.dirtyEntries.length && actions.some((action) => action.action === 'create')) {
|
|
conflicts.push({ type: 'main-checkout-dirty', entries: repository.dirtyEntries });
|
|
}
|
|
|
|
return {
|
|
projectId: manifest.projectId, parent, fleetRoot, mainBranch,
|
|
actions, conflicts, canProceed: conflicts.length === 0, mutationPerformed: false,
|
|
};
|
|
}
|
|
|
|
export function ensureTaskIgnored(root, installationId) {
|
|
const common = git(root, ['rev-parse', '--git-common-dir']);
|
|
const commonPath = isAbsolute(common) ? common : resolve(root, common);
|
|
const excludePath = join(commonPath, 'info', 'exclude');
|
|
mkdirSync(dirname(excludePath), { recursive: true });
|
|
const existing = existsSync(excludePath) ? readFileSync(excludePath, 'utf8') : '';
|
|
if (existing.split(/\r?\n/).includes('.task')) return { path: excludePath, added: false };
|
|
appendFileSync(excludePath, `${existing && !existing.endsWith('\n') ? '\n' : ''}# TORCH ${installationId}\n.task\n`, 'utf8');
|
|
return { path: excludePath, added: true };
|
|
}
|
|
|
|
export function removeTaskIgnore(record, installationId) {
|
|
if (!record.added || !existsSync(record.path)) return;
|
|
const marker = `# TORCH ${installationId}\n.task\n`;
|
|
const content = readFileSync(record.path, 'utf8');
|
|
if (content.includes(marker)) writeFileSync(record.path, content.replace(marker, ''), 'utf8');
|
|
}
|
|
|
|
export function createWorktrees({ repository, parentOverride } = {}) {
|
|
const plan = planWorktrees({ repository, parentOverride });
|
|
if (!plan.canProceed) {
|
|
throw new TorchError('Worktree creation stopped because the plan has conflicts', {
|
|
code: 'WORKTREE_CONFLICT', details: plan.conflicts,
|
|
});
|
|
}
|
|
const manifest = readInstallManifest(repository.root);
|
|
const ignore = ensureTaskIgnored(repository.root, manifest.installationId);
|
|
mkdirSync(plan.fleetRoot, { recursive: true });
|
|
const created = [];
|
|
try {
|
|
for (const action of plan.actions.filter((item) => item.action === 'create')) {
|
|
git(repository.root, ['worktree', 'add', '-b', action.branch, action.path, plan.mainBranch]);
|
|
writeFileSync(join(action.path, '.task'), `Unassigned · ${action.area}\n`, { encoding: 'utf8', flag: 'wx' });
|
|
created.push(action);
|
|
}
|
|
} catch (error) {
|
|
for (const action of [...created].reverse()) {
|
|
git(repository.root, ['worktree', 'remove', action.path], { optional: true });
|
|
git(repository.root, ['branch', '-d', action.branch], { optional: true });
|
|
}
|
|
removeTaskIgnore(ignore, manifest.installationId);
|
|
throw error;
|
|
}
|
|
|
|
manifest.external = (manifest.external ?? []).filter((entry) => entry.type !== 'worktree');
|
|
manifest.external.push(...plan.actions.map((action) => ({
|
|
type: 'worktree', area: action.area, path: action.path, branch: action.branch,
|
|
})));
|
|
if (ignore.added) manifest.patched.push({
|
|
type: 'git-info-exclude-line', path: ignore.path, line: '.task', installationId: manifest.installationId,
|
|
});
|
|
writeInstallManifest(repository.root, manifest);
|
|
return { ...plan, created, mutationPerformed: created.length > 0 };
|
|
}
|