Phase 0: TDD Autopilot Dry-Run Foundation (#1282)
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -72,3 +72,14 @@ export {
|
||||
type ComplexityAnalysis,
|
||||
type TaskComplexityData
|
||||
} from './reports/index.js';
|
||||
|
||||
// Re-export services
|
||||
export {
|
||||
PreflightChecker,
|
||||
TaskLoaderService,
|
||||
type CheckResult,
|
||||
type PreflightResult,
|
||||
type TaskValidationResult,
|
||||
type ValidationErrorType,
|
||||
type DependencyIssue
|
||||
} from './services/index.js';
|
||||
|
||||
@@ -6,8 +6,19 @@
|
||||
export { TaskService } from './task-service.js';
|
||||
export { OrganizationService } from './organization.service.js';
|
||||
export { ExportService } from './export.service.js';
|
||||
export { PreflightChecker } from './preflight-checker.service.js';
|
||||
export { TaskLoaderService } from './task-loader.service.js';
|
||||
export type { Organization, Brief } from './organization.service.js';
|
||||
export type {
|
||||
ExportTasksOptions,
|
||||
ExportResult
|
||||
} from './export.service.js';
|
||||
export type {
|
||||
CheckResult,
|
||||
PreflightResult
|
||||
} from './preflight-checker.service.js';
|
||||
export type {
|
||||
TaskValidationResult,
|
||||
ValidationErrorType,
|
||||
DependencyIssue
|
||||
} from './task-loader.service.js';
|
||||
|
||||
395
packages/tm-core/src/services/preflight-checker.service.ts
Normal file
395
packages/tm-core/src/services/preflight-checker.service.ts
Normal file
@@ -0,0 +1,395 @@
|
||||
/**
|
||||
* @fileoverview Preflight Checker Service
|
||||
* Validates environment and prerequisites for autopilot execution
|
||||
*/
|
||||
|
||||
import { readFileSync, existsSync, readdirSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { execSync } from 'child_process';
|
||||
import { getLogger } from '../logger/factory.js';
|
||||
import {
|
||||
isGitRepository,
|
||||
isGhCliAvailable,
|
||||
getDefaultBranch
|
||||
} from '../utils/git-utils.js';
|
||||
|
||||
const logger = getLogger('PreflightChecker');
|
||||
|
||||
/**
|
||||
* Result of a single preflight check
|
||||
*/
|
||||
export interface CheckResult {
|
||||
/** Whether the check passed */
|
||||
success: boolean;
|
||||
/** The value detected/validated */
|
||||
value?: any;
|
||||
/** Error or warning message */
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete preflight validation results
|
||||
*/
|
||||
export interface PreflightResult {
|
||||
/** Overall success - all checks passed */
|
||||
success: boolean;
|
||||
/** Test command detection result */
|
||||
testCommand: CheckResult;
|
||||
/** Git working tree status */
|
||||
gitWorkingTree: CheckResult;
|
||||
/** Required tools availability */
|
||||
requiredTools: CheckResult;
|
||||
/** Default branch detection */
|
||||
defaultBranch: CheckResult;
|
||||
/** Summary message */
|
||||
summary: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool validation result
|
||||
*/
|
||||
interface ToolCheck {
|
||||
name: string;
|
||||
available: boolean;
|
||||
version?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* PreflightChecker validates environment for autopilot execution
|
||||
*/
|
||||
export class PreflightChecker {
|
||||
private projectRoot: string;
|
||||
|
||||
constructor(projectRoot: string) {
|
||||
if (!projectRoot) {
|
||||
throw new Error('projectRoot is required for PreflightChecker');
|
||||
}
|
||||
this.projectRoot = projectRoot;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect test command from package.json
|
||||
*/
|
||||
async detectTestCommand(): Promise<CheckResult> {
|
||||
try {
|
||||
const packageJsonPath = join(this.projectRoot, 'package.json');
|
||||
const packageJsonContent = readFileSync(packageJsonPath, 'utf-8');
|
||||
const packageJson = JSON.parse(packageJsonContent);
|
||||
|
||||
if (!packageJson.scripts || !packageJson.scripts.test) {
|
||||
return {
|
||||
success: false,
|
||||
message:
|
||||
'No test script found in package.json. Please add a "test" script.'
|
||||
};
|
||||
}
|
||||
|
||||
const testCommand = packageJson.scripts.test;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
value: testCommand,
|
||||
message: `Test command: ${testCommand}`
|
||||
};
|
||||
} catch (error: any) {
|
||||
if (error.code === 'ENOENT') {
|
||||
return {
|
||||
success: false,
|
||||
message: 'package.json not found in project root'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to read package.json: ${error.message}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check git working tree status
|
||||
*/
|
||||
async checkGitWorkingTree(): Promise<CheckResult> {
|
||||
try {
|
||||
// Check if it's a git repository
|
||||
const isRepo = await isGitRepository(this.projectRoot);
|
||||
if (!isRepo) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Not a git repository. Initialize git first.'
|
||||
};
|
||||
}
|
||||
|
||||
// Check for changes (staged/unstaged/untracked) without requiring HEAD
|
||||
const status = execSync('git status --porcelain', {
|
||||
cwd: this.projectRoot,
|
||||
encoding: 'utf-8',
|
||||
timeout: 5000
|
||||
});
|
||||
if (status.trim().length > 0) {
|
||||
return {
|
||||
success: false,
|
||||
value: 'dirty',
|
||||
message:
|
||||
'Working tree has uncommitted or untracked changes. Please commit or stash them.'
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
value: 'clean',
|
||||
message: 'Working tree is clean'
|
||||
};
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Git check failed: ${error.message}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect project types based on common configuration files
|
||||
*/
|
||||
private detectProjectTypes(): string[] {
|
||||
const types: string[] = [];
|
||||
|
||||
if (existsSync(join(this.projectRoot, 'package.json'))) types.push('node');
|
||||
if (
|
||||
existsSync(join(this.projectRoot, 'requirements.txt')) ||
|
||||
existsSync(join(this.projectRoot, 'setup.py')) ||
|
||||
existsSync(join(this.projectRoot, 'pyproject.toml'))
|
||||
)
|
||||
types.push('python');
|
||||
if (
|
||||
existsSync(join(this.projectRoot, 'pom.xml')) ||
|
||||
existsSync(join(this.projectRoot, 'build.gradle'))
|
||||
)
|
||||
types.push('java');
|
||||
if (existsSync(join(this.projectRoot, 'go.mod'))) types.push('go');
|
||||
if (existsSync(join(this.projectRoot, 'Cargo.toml'))) types.push('rust');
|
||||
if (existsSync(join(this.projectRoot, 'composer.json'))) types.push('php');
|
||||
if (existsSync(join(this.projectRoot, 'Gemfile'))) types.push('ruby');
|
||||
const files = readdirSync(this.projectRoot);
|
||||
if (files.some((f) => f.endsWith('.csproj') || f.endsWith('.sln')))
|
||||
types.push('dotnet');
|
||||
|
||||
return types;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get required tools for a project type
|
||||
*/
|
||||
private getToolsForProjectType(
|
||||
type: string
|
||||
): Array<{ command: string; args: string[] }> {
|
||||
const toolMap: Record<
|
||||
string,
|
||||
Array<{ command: string; args: string[] }>
|
||||
> = {
|
||||
node: [
|
||||
{ command: 'node', args: ['--version'] },
|
||||
{ command: 'npm', args: ['--version'] }
|
||||
],
|
||||
python: [
|
||||
{ command: 'python3', args: ['--version'] },
|
||||
{ command: 'pip3', args: ['--version'] }
|
||||
],
|
||||
java: [{ command: 'java', args: ['--version'] }],
|
||||
go: [{ command: 'go', args: ['version'] }],
|
||||
rust: [{ command: 'cargo', args: ['--version'] }],
|
||||
php: [
|
||||
{ command: 'php', args: ['--version'] },
|
||||
{ command: 'composer', args: ['--version'] }
|
||||
],
|
||||
ruby: [
|
||||
{ command: 'ruby', args: ['--version'] },
|
||||
{ command: 'bundle', args: ['--version'] }
|
||||
],
|
||||
dotnet: [{ command: 'dotnet', args: ['--version'] }]
|
||||
};
|
||||
|
||||
return toolMap[type] || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate required tools availability
|
||||
*/
|
||||
async validateRequiredTools(): Promise<CheckResult> {
|
||||
const tools: ToolCheck[] = [];
|
||||
|
||||
// Always check git and gh CLI
|
||||
tools.push(this.checkTool('git', ['--version']));
|
||||
tools.push(await this.checkGhCli());
|
||||
|
||||
// Detect project types and check their tools
|
||||
const projectTypes = this.detectProjectTypes();
|
||||
|
||||
if (projectTypes.length === 0) {
|
||||
logger.warn('No recognized project type detected');
|
||||
} else {
|
||||
logger.info(`Detected project types: ${projectTypes.join(', ')}`);
|
||||
}
|
||||
|
||||
for (const type of projectTypes) {
|
||||
const typeTools = this.getToolsForProjectType(type);
|
||||
for (const tool of typeTools) {
|
||||
tools.push(this.checkTool(tool.command, tool.args));
|
||||
}
|
||||
}
|
||||
|
||||
// Determine overall success
|
||||
const allAvailable = tools.every((tool) => tool.available);
|
||||
const missingTools = tools
|
||||
.filter((tool) => !tool.available)
|
||||
.map((tool) => tool.name);
|
||||
|
||||
if (!allAvailable) {
|
||||
return {
|
||||
success: false,
|
||||
value: tools,
|
||||
message: `Missing required tools: ${missingTools.join(', ')}`
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
value: tools,
|
||||
message: 'All required tools are available'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a command-line tool is available
|
||||
*/
|
||||
private checkTool(command: string, versionArgs: string[]): ToolCheck {
|
||||
try {
|
||||
const version = execSync(`${command} ${versionArgs.join(' ')}`, {
|
||||
cwd: this.projectRoot,
|
||||
encoding: 'utf-8',
|
||||
stdio: 'pipe',
|
||||
timeout: 5000
|
||||
})
|
||||
.trim()
|
||||
.split('\n')[0];
|
||||
|
||||
return {
|
||||
name: command,
|
||||
available: true,
|
||||
version,
|
||||
message: `${command} ${version}`
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
name: command,
|
||||
available: false,
|
||||
message: `${command} not found`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check GitHub CLI installation and authentication status
|
||||
*/
|
||||
private async checkGhCli(): Promise<ToolCheck> {
|
||||
try {
|
||||
const version = execSync('gh --version', {
|
||||
cwd: this.projectRoot,
|
||||
encoding: 'utf-8',
|
||||
stdio: 'pipe',
|
||||
timeout: 5000
|
||||
})
|
||||
.trim()
|
||||
.split('\n')[0];
|
||||
const authed = await isGhCliAvailable(this.projectRoot);
|
||||
return {
|
||||
name: 'gh',
|
||||
available: true,
|
||||
version,
|
||||
message: authed
|
||||
? 'GitHub CLI installed (authenticated)'
|
||||
: 'GitHub CLI installed (not authenticated)'
|
||||
};
|
||||
} catch {
|
||||
return { name: 'gh', available: false, message: 'GitHub CLI not found' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect default branch
|
||||
*/
|
||||
async detectDefaultBranch(): Promise<CheckResult> {
|
||||
try {
|
||||
const defaultBranch = await getDefaultBranch(this.projectRoot);
|
||||
|
||||
if (!defaultBranch) {
|
||||
return {
|
||||
success: false,
|
||||
message:
|
||||
'Could not determine default branch. Make sure remote is configured.'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
value: defaultBranch,
|
||||
message: `Default branch: ${defaultBranch}`
|
||||
};
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to detect default branch: ${error.message}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run all preflight checks
|
||||
*/
|
||||
async runAllChecks(): Promise<PreflightResult> {
|
||||
logger.info('Running preflight checks...');
|
||||
|
||||
const testCommand = await this.detectTestCommand();
|
||||
const gitWorkingTree = await this.checkGitWorkingTree();
|
||||
const requiredTools = await this.validateRequiredTools();
|
||||
const defaultBranch = await this.detectDefaultBranch();
|
||||
|
||||
const allSuccess =
|
||||
testCommand.success &&
|
||||
gitWorkingTree.success &&
|
||||
requiredTools.success &&
|
||||
defaultBranch.success;
|
||||
|
||||
// Build summary
|
||||
const passed: string[] = [];
|
||||
const failed: string[] = [];
|
||||
|
||||
if (testCommand.success) passed.push('Test command');
|
||||
else failed.push('Test command');
|
||||
|
||||
if (gitWorkingTree.success) passed.push('Git working tree');
|
||||
else failed.push('Git working tree');
|
||||
|
||||
if (requiredTools.success) passed.push('Required tools');
|
||||
else failed.push('Required tools');
|
||||
|
||||
if (defaultBranch.success) passed.push('Default branch');
|
||||
else failed.push('Default branch');
|
||||
|
||||
const total = passed.length + failed.length;
|
||||
const summary = allSuccess
|
||||
? `All preflight checks passed (${passed.length}/${total})`
|
||||
: `Preflight checks failed: ${failed.join(', ')} (${passed.length}/${total} passed)`;
|
||||
|
||||
logger.info(summary);
|
||||
|
||||
return {
|
||||
success: allSuccess,
|
||||
testCommand,
|
||||
gitWorkingTree,
|
||||
requiredTools,
|
||||
defaultBranch,
|
||||
summary
|
||||
};
|
||||
}
|
||||
}
|
||||
401
packages/tm-core/src/services/task-loader.service.ts
Normal file
401
packages/tm-core/src/services/task-loader.service.ts
Normal file
@@ -0,0 +1,401 @@
|
||||
/**
|
||||
* @fileoverview Task Loader Service
|
||||
* Loads and validates tasks for autopilot execution
|
||||
*/
|
||||
|
||||
import type { Task, Subtask, TaskStatus } from '../types/index.js';
|
||||
import { TaskService } from './task-service.js';
|
||||
import { ConfigManager } from '../config/config-manager.js';
|
||||
import { getLogger } from '../logger/factory.js';
|
||||
|
||||
const logger = getLogger('TaskLoader');
|
||||
|
||||
/**
|
||||
* Validation error types
|
||||
*/
|
||||
export type ValidationErrorType =
|
||||
| 'task_not_found'
|
||||
| 'task_completed'
|
||||
| 'no_subtasks'
|
||||
| 'circular_dependencies'
|
||||
| 'missing_dependencies'
|
||||
| 'invalid_structure';
|
||||
|
||||
/**
|
||||
* Validation result for task loading
|
||||
*/
|
||||
export interface TaskValidationResult {
|
||||
/** Whether validation passed */
|
||||
success: boolean;
|
||||
/** Loaded task (only present if validation succeeded) */
|
||||
task?: Task;
|
||||
/** Error type */
|
||||
errorType?: ValidationErrorType;
|
||||
/** Human-readable error message */
|
||||
errorMessage?: string;
|
||||
/** Actionable suggestion for fixing the error */
|
||||
suggestion?: string;
|
||||
/** Dependency analysis (only for dependency errors) */
|
||||
dependencyIssues?: DependencyIssue[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Dependency issue details
|
||||
*/
|
||||
export interface DependencyIssue {
|
||||
/** Subtask ID with the issue */
|
||||
subtaskId: string;
|
||||
/** Type of dependency issue */
|
||||
issueType: 'circular' | 'missing' | 'invalid';
|
||||
/** Description of the issue */
|
||||
message: string;
|
||||
/** The problematic dependency reference */
|
||||
dependencyRef?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* TaskLoaderService loads and validates tasks for autopilot execution
|
||||
*/
|
||||
export class TaskLoaderService {
|
||||
private taskService: TaskService | null = null;
|
||||
private projectRoot: string;
|
||||
|
||||
constructor(projectRoot: string) {
|
||||
if (!projectRoot) {
|
||||
throw new Error('projectRoot is required for TaskLoaderService');
|
||||
}
|
||||
this.projectRoot = projectRoot;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure TaskService is initialized
|
||||
*/
|
||||
private async ensureInitialized(): Promise<void> {
|
||||
if (this.taskService) return;
|
||||
|
||||
const configManager = await ConfigManager.create(this.projectRoot);
|
||||
this.taskService = new TaskService(configManager);
|
||||
await this.taskService.initialize();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and validate a task for autopilot execution
|
||||
*/
|
||||
async loadAndValidateTask(taskId: string): Promise<TaskValidationResult> {
|
||||
logger.info(`Loading task ${taskId}...`);
|
||||
|
||||
// Step 1: Load task
|
||||
const task = await this.loadTask(taskId);
|
||||
if (!task) {
|
||||
return {
|
||||
success: false,
|
||||
errorType: 'task_not_found',
|
||||
errorMessage: `Task with ID "${taskId}" not found`,
|
||||
suggestion:
|
||||
'Use "task-master list" to see available tasks or verify the task ID is correct.'
|
||||
};
|
||||
}
|
||||
|
||||
// Step 2: Validate task status
|
||||
const statusValidation = this.validateTaskStatus(task);
|
||||
if (!statusValidation.success) {
|
||||
return statusValidation;
|
||||
}
|
||||
|
||||
// Step 3: Check for subtasks
|
||||
const subtaskValidation = this.validateSubtasksExist(task);
|
||||
if (!subtaskValidation.success) {
|
||||
return subtaskValidation;
|
||||
}
|
||||
|
||||
// Step 4: Validate subtask structure
|
||||
const structureValidation = this.validateSubtaskStructure(task);
|
||||
if (!structureValidation.success) {
|
||||
return structureValidation;
|
||||
}
|
||||
|
||||
// Step 5: Analyze dependencies
|
||||
const dependencyValidation = this.validateDependencies(task);
|
||||
if (!dependencyValidation.success) {
|
||||
return dependencyValidation;
|
||||
}
|
||||
|
||||
logger.info(`Task ${taskId} validated successfully`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
task
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load task using TaskService
|
||||
*/
|
||||
private async loadTask(taskId: string): Promise<Task | null> {
|
||||
try {
|
||||
await this.ensureInitialized();
|
||||
if (!this.taskService) {
|
||||
throw new Error('TaskService initialization failed');
|
||||
}
|
||||
return await this.taskService.getTask(taskId);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to load task ${taskId}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate task status is appropriate for autopilot
|
||||
*/
|
||||
private validateTaskStatus(task: Task): TaskValidationResult {
|
||||
const completedStatuses: TaskStatus[] = ['done', 'completed', 'cancelled'];
|
||||
|
||||
if (completedStatuses.includes(task.status)) {
|
||||
return {
|
||||
success: false,
|
||||
errorType: 'task_completed',
|
||||
errorMessage: `Task "${task.title}" is already ${task.status}`,
|
||||
suggestion:
|
||||
'Autopilot can only execute tasks that are pending or in-progress. Use a different task.'
|
||||
};
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate task has subtasks
|
||||
*/
|
||||
private validateSubtasksExist(task: Task): TaskValidationResult {
|
||||
if (!task.subtasks || task.subtasks.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
errorType: 'no_subtasks',
|
||||
errorMessage: `Task "${task.title}" has no subtasks`,
|
||||
suggestion: this.buildExpansionSuggestion(task)
|
||||
};
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build helpful suggestion for expanding tasks
|
||||
*/
|
||||
private buildExpansionSuggestion(task: Task): string {
|
||||
const suggestions: string[] = [
|
||||
`Autopilot requires tasks to be broken down into subtasks for execution.`
|
||||
];
|
||||
|
||||
// Add expansion command suggestion
|
||||
suggestions.push(`\nExpand this task using:`);
|
||||
suggestions.push(` task-master expand --id=${task.id}`);
|
||||
|
||||
// If task has complexity analysis, mention it
|
||||
if (task.complexity || task.recommendedSubtasks) {
|
||||
suggestions.push(
|
||||
`\nThis task has complexity analysis available. Consider reviewing it first:`
|
||||
);
|
||||
suggestions.push(` task-master show ${task.id}`);
|
||||
} else {
|
||||
suggestions.push(
|
||||
`\nOr analyze task complexity first to determine optimal subtask count:`
|
||||
);
|
||||
suggestions.push(` task-master analyze-complexity --from=${task.id}`);
|
||||
}
|
||||
|
||||
return suggestions.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate subtask structure
|
||||
*/
|
||||
private validateSubtaskStructure(task: Task): TaskValidationResult {
|
||||
for (const subtask of task.subtasks) {
|
||||
// Check required fields
|
||||
if (!subtask.title || !subtask.description) {
|
||||
return {
|
||||
success: false,
|
||||
errorType: 'invalid_structure',
|
||||
errorMessage: `Subtask ${task.id}.${subtask.id} is missing required fields`,
|
||||
suggestion:
|
||||
'Subtasks must have title and description. Re-expand the task or manually fix the subtask structure.'
|
||||
};
|
||||
}
|
||||
|
||||
// Validate dependencies are arrays
|
||||
if (subtask.dependencies && !Array.isArray(subtask.dependencies)) {
|
||||
return {
|
||||
success: false,
|
||||
errorType: 'invalid_structure',
|
||||
errorMessage: `Subtask ${task.id}.${subtask.id} has invalid dependencies format`,
|
||||
suggestion:
|
||||
'Dependencies must be an array. Fix the task structure manually.'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate subtask dependencies
|
||||
*/
|
||||
private validateDependencies(task: Task): TaskValidationResult {
|
||||
const issues: DependencyIssue[] = [];
|
||||
const subtaskIds = new Set(task.subtasks.map((st) => String(st.id)));
|
||||
|
||||
for (const subtask of task.subtasks) {
|
||||
const subtaskId = `${task.id}.${subtask.id}`;
|
||||
|
||||
// Check for missing dependencies
|
||||
if (subtask.dependencies && subtask.dependencies.length > 0) {
|
||||
for (const depId of subtask.dependencies) {
|
||||
const depIdStr = String(depId);
|
||||
|
||||
if (!subtaskIds.has(depIdStr)) {
|
||||
issues.push({
|
||||
subtaskId,
|
||||
issueType: 'missing',
|
||||
message: `References non-existent subtask ${depIdStr}`,
|
||||
dependencyRef: depIdStr
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for circular dependencies
|
||||
const circularCheck = this.detectCircularDependency(
|
||||
subtask,
|
||||
task.subtasks,
|
||||
new Set()
|
||||
);
|
||||
|
||||
if (circularCheck) {
|
||||
issues.push({
|
||||
subtaskId,
|
||||
issueType: 'circular',
|
||||
message: `Circular dependency detected: ${circularCheck.join(' -> ')}`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (issues.length > 0) {
|
||||
const errorType =
|
||||
issues[0].issueType === 'circular'
|
||||
? 'circular_dependencies'
|
||||
: 'missing_dependencies';
|
||||
|
||||
return {
|
||||
success: false,
|
||||
errorType,
|
||||
errorMessage: `Task "${task.title}" has dependency issues`,
|
||||
suggestion:
|
||||
'Fix dependency issues manually or re-expand the task:\n' +
|
||||
issues
|
||||
.map((issue) => ` - ${issue.subtaskId}: ${issue.message}`)
|
||||
.join('\n'),
|
||||
dependencyIssues: issues
|
||||
};
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect circular dependencies using depth-first search
|
||||
*/
|
||||
private detectCircularDependency(
|
||||
subtask: Subtask,
|
||||
allSubtasks: Subtask[],
|
||||
visited: Set<string>
|
||||
): string[] | null {
|
||||
const subtaskId = String(subtask.id);
|
||||
|
||||
if (visited.has(subtaskId)) {
|
||||
return [subtaskId];
|
||||
}
|
||||
|
||||
visited.add(subtaskId);
|
||||
|
||||
if (subtask.dependencies && subtask.dependencies.length > 0) {
|
||||
for (const depId of subtask.dependencies) {
|
||||
const depIdStr = String(depId);
|
||||
const dependency = allSubtasks.find((st) => String(st.id) === depIdStr);
|
||||
|
||||
if (dependency) {
|
||||
const circular = this.detectCircularDependency(
|
||||
dependency,
|
||||
allSubtasks,
|
||||
new Set(visited)
|
||||
);
|
||||
|
||||
if (circular) {
|
||||
return [subtaskId, ...circular];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get ordered subtask execution sequence
|
||||
* Returns subtasks in dependency order (tasks with no deps first)
|
||||
*/
|
||||
getExecutionOrder(task: Task): Subtask[] {
|
||||
const ordered: Subtask[] = [];
|
||||
const completed = new Set<string>();
|
||||
|
||||
// Keep adding subtasks whose dependencies are all completed
|
||||
while (ordered.length < task.subtasks.length) {
|
||||
let added = false;
|
||||
|
||||
for (const subtask of task.subtasks) {
|
||||
const subtaskId = String(subtask.id);
|
||||
|
||||
if (completed.has(subtaskId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if all dependencies are completed
|
||||
const allDepsCompleted =
|
||||
!subtask.dependencies ||
|
||||
subtask.dependencies.length === 0 ||
|
||||
subtask.dependencies.every((depId) => completed.has(String(depId)));
|
||||
|
||||
if (allDepsCompleted) {
|
||||
ordered.push(subtask);
|
||||
completed.add(subtaskId);
|
||||
added = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Safety check to prevent infinite loop
|
||||
if (!added && ordered.length < task.subtasks.length) {
|
||||
logger.warn(
|
||||
`Could not determine complete execution order for task ${task.id}`
|
||||
);
|
||||
// Add remaining subtasks in original order
|
||||
for (const subtask of task.subtasks) {
|
||||
if (!completed.has(String(subtask.id))) {
|
||||
ordered.push(subtask);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return ordered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up resources
|
||||
*/
|
||||
async cleanup(): Promise<void> {
|
||||
// TaskService doesn't require explicit cleanup
|
||||
// Resources are automatically released when instance is garbage collected
|
||||
}
|
||||
}
|
||||
421
packages/tm-core/src/utils/git-utils.ts
Normal file
421
packages/tm-core/src/utils/git-utils.ts
Normal file
@@ -0,0 +1,421 @@
|
||||
/**
|
||||
* @fileoverview Git utilities for Task Master
|
||||
* Git integration utilities using raw git commands and gh CLI
|
||||
*/
|
||||
|
||||
import { exec, execSync } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
/**
|
||||
* GitHub repository information
|
||||
*/
|
||||
export interface GitHubRepoInfo {
|
||||
name: string;
|
||||
owner: { login: string };
|
||||
defaultBranchRef: { name: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the specified directory is inside a git repository
|
||||
*/
|
||||
export async function isGitRepository(projectRoot: string): Promise<boolean> {
|
||||
if (!projectRoot) {
|
||||
throw new Error('projectRoot is required for isGitRepository');
|
||||
}
|
||||
|
||||
try {
|
||||
await execAsync('git rev-parse --git-dir', { cwd: projectRoot });
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous check if directory is in a git repository
|
||||
*/
|
||||
export function isGitRepositorySync(projectRoot: string): boolean {
|
||||
if (!projectRoot) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
execSync('git rev-parse --git-dir', {
|
||||
cwd: projectRoot,
|
||||
stdio: 'ignore'
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current git branch name
|
||||
*/
|
||||
export async function getCurrentBranch(
|
||||
projectRoot: string
|
||||
): Promise<string | null> {
|
||||
if (!projectRoot) {
|
||||
throw new Error('projectRoot is required for getCurrentBranch');
|
||||
}
|
||||
|
||||
try {
|
||||
const { stdout } = await execAsync('git rev-parse --abbrev-ref HEAD', {
|
||||
cwd: projectRoot
|
||||
});
|
||||
return stdout.trim();
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous get current git branch name
|
||||
*/
|
||||
export function getCurrentBranchSync(projectRoot: string): string | null {
|
||||
if (!projectRoot) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const stdout = execSync('git rev-parse --abbrev-ref HEAD', {
|
||||
cwd: projectRoot,
|
||||
encoding: 'utf8'
|
||||
});
|
||||
return stdout.trim();
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of all local git branches
|
||||
*/
|
||||
export async function getLocalBranches(projectRoot: string): Promise<string[]> {
|
||||
if (!projectRoot) {
|
||||
throw new Error('projectRoot is required for getLocalBranches');
|
||||
}
|
||||
|
||||
try {
|
||||
const { stdout } = await execAsync(
|
||||
'git branch --format="%(refname:short)"',
|
||||
{ cwd: projectRoot, maxBuffer: 10 * 1024 * 1024 }
|
||||
);
|
||||
return stdout
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter((branch) => branch.length > 0)
|
||||
.map((branch) => branch.trim());
|
||||
} catch (error) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of all remote branches
|
||||
*/
|
||||
export async function getRemoteBranches(
|
||||
projectRoot: string
|
||||
): Promise<string[]> {
|
||||
if (!projectRoot) {
|
||||
throw new Error('projectRoot is required for getRemoteBranches');
|
||||
}
|
||||
|
||||
try {
|
||||
const { stdout } = await execAsync(
|
||||
'git branch -r --format="%(refname:short)"',
|
||||
{ cwd: projectRoot, maxBuffer: 10 * 1024 * 1024 }
|
||||
);
|
||||
const names = stdout
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter((branch) => branch.length > 0 && !branch.includes('HEAD'))
|
||||
.map((branch) => branch.replace(/^[^/]+\//, '').trim());
|
||||
return Array.from(new Set(names));
|
||||
} catch (error) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if gh CLI is available and authenticated
|
||||
*/
|
||||
export async function isGhCliAvailable(projectRoot?: string): Promise<boolean> {
|
||||
try {
|
||||
const options = projectRoot ? { cwd: projectRoot } : {};
|
||||
await execAsync('gh auth status', options);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get GitHub repository information using gh CLI
|
||||
*/
|
||||
export async function getGitHubRepoInfo(
|
||||
projectRoot: string
|
||||
): Promise<GitHubRepoInfo | null> {
|
||||
if (!projectRoot) {
|
||||
throw new Error('projectRoot is required for getGitHubRepoInfo');
|
||||
}
|
||||
|
||||
try {
|
||||
const { stdout } = await execAsync(
|
||||
'gh repo view --json name,owner,defaultBranchRef',
|
||||
{ cwd: projectRoot }
|
||||
);
|
||||
return JSON.parse(stdout) as GitHubRepoInfo;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get git repository root directory
|
||||
*/
|
||||
export async function getGitRepositoryRoot(
|
||||
projectRoot: string
|
||||
): Promise<string | null> {
|
||||
if (!projectRoot) {
|
||||
throw new Error('projectRoot is required for getGitRepositoryRoot');
|
||||
}
|
||||
|
||||
try {
|
||||
const { stdout } = await execAsync('git rev-parse --show-toplevel', {
|
||||
cwd: projectRoot
|
||||
});
|
||||
return stdout.trim();
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default branch name for the repository
|
||||
*/
|
||||
export async function getDefaultBranch(
|
||||
projectRoot: string
|
||||
): Promise<string | null> {
|
||||
if (!projectRoot) {
|
||||
throw new Error('projectRoot is required for getDefaultBranch');
|
||||
}
|
||||
|
||||
try {
|
||||
// Try to get from GitHub first (if gh CLI is available)
|
||||
if (await isGhCliAvailable(projectRoot)) {
|
||||
const repoInfo = await getGitHubRepoInfo(projectRoot);
|
||||
if (repoInfo && repoInfo.defaultBranchRef) {
|
||||
return repoInfo.defaultBranchRef.name;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to git remote info (support non-origin remotes)
|
||||
const remotesRaw = await execAsync('git remote', { cwd: projectRoot });
|
||||
const remotes = remotesRaw.stdout.trim().split('\n').filter(Boolean);
|
||||
if (remotes.length > 0) {
|
||||
const primary = remotes.includes('origin') ? 'origin' : remotes[0];
|
||||
// Parse `git remote show` (preferred)
|
||||
try {
|
||||
const { stdout } = await execAsync(`git remote show ${primary}`, {
|
||||
cwd: projectRoot,
|
||||
maxBuffer: 10 * 1024 * 1024
|
||||
});
|
||||
const m = stdout.match(/HEAD branch:\s+([^\s]+)/);
|
||||
if (m) return m[1].trim();
|
||||
} catch {}
|
||||
// Fallback to symbolic-ref of remote HEAD
|
||||
try {
|
||||
const { stdout } = await execAsync(
|
||||
`git symbolic-ref refs/remotes/${primary}/HEAD`,
|
||||
{ cwd: projectRoot }
|
||||
);
|
||||
return stdout.replace(`refs/remotes/${primary}/`, '').trim();
|
||||
} catch {}
|
||||
}
|
||||
// If we couldn't determine, throw to trigger final fallbacks
|
||||
throw new Error('default-branch-not-found');
|
||||
} catch (error) {
|
||||
// Final fallback - common default branch names
|
||||
const commonDefaults = ['main', 'master'];
|
||||
const branches = await getLocalBranches(projectRoot);
|
||||
const remoteBranches = await getRemoteBranches(projectRoot);
|
||||
|
||||
for (const defaultName of commonDefaults) {
|
||||
if (
|
||||
branches.includes(defaultName) ||
|
||||
remoteBranches.includes(defaultName)
|
||||
) {
|
||||
return defaultName;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we're currently on the default branch
|
||||
*/
|
||||
export async function isOnDefaultBranch(projectRoot: string): Promise<boolean> {
|
||||
if (!projectRoot) {
|
||||
throw new Error('projectRoot is required for isOnDefaultBranch');
|
||||
}
|
||||
|
||||
try {
|
||||
const [currentBranch, defaultBranch] = await Promise.all([
|
||||
getCurrentBranch(projectRoot),
|
||||
getDefaultBranch(projectRoot)
|
||||
]);
|
||||
return (
|
||||
currentBranch !== null &&
|
||||
defaultBranch !== null &&
|
||||
currentBranch === defaultBranch
|
||||
);
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current working directory is inside a Git work-tree
|
||||
*/
|
||||
export function insideGitWorkTree(): boolean {
|
||||
try {
|
||||
execSync('git rev-parse --is-inside-work-tree', {
|
||||
stdio: 'ignore',
|
||||
cwd: process.cwd()
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize branch name to be a valid tag name
|
||||
*/
|
||||
export function sanitizeBranchNameForTag(branchName: string): string {
|
||||
if (!branchName || typeof branchName !== 'string') {
|
||||
return 'unknown-branch';
|
||||
}
|
||||
|
||||
// Replace invalid characters with hyphens and clean up
|
||||
return branchName
|
||||
.replace(/[^a-zA-Z0-9_.-]/g, '-') // Replace invalid chars with hyphens (allow dots)
|
||||
.replace(/^-+|-+$/g, '') // Remove leading/trailing hyphens
|
||||
.replace(/-+/g, '-') // Collapse multiple hyphens
|
||||
.toLowerCase() // Convert to lowercase
|
||||
.substring(0, 50); // Limit length
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a branch name would create a valid tag name
|
||||
*/
|
||||
export function isValidBranchForTag(branchName: string): boolean {
|
||||
if (!branchName || typeof branchName !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if it's a reserved branch name that shouldn't become tags
|
||||
const reservedBranches = ['main', 'master', 'develop', 'dev', 'head'];
|
||||
if (reservedBranches.includes(branchName.toLowerCase())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if sanitized name would be meaningful
|
||||
const sanitized = sanitizeBranchNameForTag(branchName);
|
||||
return sanitized.length > 0 && sanitized !== 'unknown-branch';
|
||||
}
|
||||
|
||||
/**
|
||||
* Git worktree information
|
||||
*/
|
||||
export interface GitWorktree {
|
||||
path: string;
|
||||
branch: string | null;
|
||||
head: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of all git worktrees
|
||||
*/
|
||||
export async function getWorktrees(
|
||||
projectRoot: string
|
||||
): Promise<GitWorktree[]> {
|
||||
if (!projectRoot) {
|
||||
throw new Error('projectRoot is required for getWorktrees');
|
||||
}
|
||||
|
||||
try {
|
||||
const { stdout } = await execAsync('git worktree list --porcelain', {
|
||||
cwd: projectRoot
|
||||
});
|
||||
|
||||
const worktrees: GitWorktree[] = [];
|
||||
const lines = stdout.trim().split('\n');
|
||||
let current: Partial<GitWorktree> = {};
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('worktree ')) {
|
||||
// flush previous entry if present
|
||||
if (current.path) {
|
||||
worktrees.push({
|
||||
path: current.path,
|
||||
branch: current.branch || null,
|
||||
head: current.head || ''
|
||||
});
|
||||
current = {};
|
||||
}
|
||||
current.path = line.substring(9);
|
||||
} else if (line.startsWith('HEAD ')) {
|
||||
current.head = line.substring(5);
|
||||
} else if (line.startsWith('branch ')) {
|
||||
current.branch = line.substring(7).replace('refs/heads/', '');
|
||||
} else if (line === '' && current.path) {
|
||||
worktrees.push({
|
||||
path: current.path,
|
||||
branch: current.branch || null,
|
||||
head: current.head || ''
|
||||
});
|
||||
current = {};
|
||||
}
|
||||
}
|
||||
|
||||
// Handle last entry if no trailing newline
|
||||
if (current.path) {
|
||||
worktrees.push({
|
||||
path: current.path,
|
||||
branch: current.branch || null,
|
||||
head: current.head || ''
|
||||
});
|
||||
}
|
||||
|
||||
return worktrees;
|
||||
} catch (error) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a branch is checked out in any worktree
|
||||
* Returns the worktree path if found, null otherwise
|
||||
*/
|
||||
export async function isBranchCheckedOut(
|
||||
projectRoot: string,
|
||||
branchName: string
|
||||
): Promise<string | null> {
|
||||
if (!projectRoot) {
|
||||
throw new Error('projectRoot is required for isBranchCheckedOut');
|
||||
}
|
||||
if (!branchName) {
|
||||
throw new Error('branchName is required for isBranchCheckedOut');
|
||||
}
|
||||
|
||||
const worktrees = await getWorktrees(projectRoot);
|
||||
const worktree = worktrees.find((wt) => wt.branch === branchName);
|
||||
return worktree ? worktree.path : null;
|
||||
}
|
||||
@@ -13,6 +13,25 @@ export {
|
||||
getParentTaskId
|
||||
} from './id-generator.js';
|
||||
|
||||
// Export git utilities
|
||||
export {
|
||||
isGitRepository,
|
||||
isGitRepositorySync,
|
||||
getCurrentBranch,
|
||||
getCurrentBranchSync,
|
||||
getLocalBranches,
|
||||
getRemoteBranches,
|
||||
isGhCliAvailable,
|
||||
getGitHubRepoInfo,
|
||||
getGitRepositoryRoot,
|
||||
getDefaultBranch,
|
||||
isOnDefaultBranch,
|
||||
insideGitWorkTree,
|
||||
sanitizeBranchNameForTag,
|
||||
isValidBranchForTag,
|
||||
type GitHubRepoInfo
|
||||
} from './git-utils.js';
|
||||
|
||||
// Additional utility exports
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user