mirror of
https://github.com/jparkerweb/plan2code.git
synced 2026-09-17 16:22:23 -07:00
Pin LF line endings and renormalize the working tree
scripts/validate-char-count.js measures characters on disk, so a CRLF checkout added ~1 char per line and pushed the 11k-budget prompt files over the limit depending on how the repo was cloned. Pins eol=lf, marks binaries, and renormalizes the files that had CRLF. AI Assisted
This commit is contained in:
@@ -1,169 +1,169 @@
|
||||
// Completion markers for plan2code-loop
|
||||
const COMPLETION_MARKERS = ['TASK_COMPLETE', 'TASK_BLOCKED', 'LOOP_COMPLETE', 'PREREQ_COMPLETE', 'PREREQ_ASSUMED'] as const;
|
||||
|
||||
export type CompletionMarker = (typeof COMPLETION_MARKERS)[number];
|
||||
|
||||
export interface CompletionCheckResult {
|
||||
completed: boolean;
|
||||
marker?: CompletionMarker;
|
||||
taskId?: string; // e.g., "1.1", "2.3"
|
||||
taskName?: string; // e.g., "Initialize project structure"
|
||||
reason?: string; // For TASK_BLOCKED: reason
|
||||
}
|
||||
|
||||
export function checkForCompletion(output: string): CompletionCheckResult {
|
||||
// Check for LOOP_COMPLETE first (highest priority)
|
||||
if (output.includes('LOOP_COMPLETE')) {
|
||||
return { completed: true, marker: 'LOOP_COMPLETE' };
|
||||
}
|
||||
|
||||
// Check for TASK_COMPLETE with task info
|
||||
// Format: TASK_COMPLETE: 1.1 - Task description
|
||||
// Or: TASK_COMPLETE: 1.1: Task description
|
||||
// Or: TASK_COMPLETE[1.1]: Task description
|
||||
const completeMatch = output.match(/TASK_COMPLETE[:\[\s]+(\d+\.\d+)[\]:\-\s]+(.+?)(?:\n|$)/i);
|
||||
if (completeMatch) {
|
||||
return {
|
||||
completed: true,
|
||||
marker: 'TASK_COMPLETE',
|
||||
taskId: completeMatch[1],
|
||||
taskName: completeMatch[2].trim(),
|
||||
};
|
||||
}
|
||||
|
||||
// Simple TASK_COMPLETE without structured info - try to extract task from context
|
||||
if (output.includes('TASK_COMPLETE')) {
|
||||
// Try to find task info nearby
|
||||
const taskMatch = output.match(/(?:task|completed?)\s+(\d+\.\d+)[:\s]+([^\n]{5,80})/i);
|
||||
return {
|
||||
completed: true,
|
||||
marker: 'TASK_COMPLETE',
|
||||
taskId: taskMatch?.[1],
|
||||
taskName: taskMatch?.[2]?.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
// Check for PREREQ_COMPLETE with prereq info
|
||||
// Format: PREREQ_COMPLETE: P1.1 - Verified Phase 1 complete
|
||||
const prereqMatch = output.match(/PREREQ_COMPLETE[:\[\s]+([^\]:\-\s]+)[\]:\-\s]+(.+?)(?:\n|$)/i);
|
||||
if (prereqMatch) {
|
||||
return {
|
||||
completed: true,
|
||||
marker: 'PREREQ_COMPLETE',
|
||||
taskId: prereqMatch[1],
|
||||
taskName: prereqMatch[2].trim(),
|
||||
};
|
||||
}
|
||||
|
||||
// Check for PREREQ_ASSUMED with prereq info
|
||||
// Format: PREREQ_ASSUMED: P2.1 - Design approval (cannot verify)
|
||||
const assumedMatch = output.match(/PREREQ_ASSUMED[:\[\s]+([^\]:\-\s]+)[\]:\-\s]+(.+?)(?:\n|$)/i);
|
||||
if (assumedMatch) {
|
||||
return {
|
||||
completed: true,
|
||||
marker: 'PREREQ_ASSUMED',
|
||||
taskId: assumedMatch[1],
|
||||
taskName: assumedMatch[2].trim(),
|
||||
};
|
||||
}
|
||||
|
||||
// Check for TASK_BLOCKED with task info and reason
|
||||
// Format: TASK_BLOCKED: 1.1 - Reason why blocked
|
||||
const blockedWithTaskMatch = output.match(/TASK_BLOCKED[:\[\s]+(\d+\.\d+)[\]:\-\s]+(.+?)(?:\n|$)/i);
|
||||
if (blockedWithTaskMatch) {
|
||||
return {
|
||||
completed: true,
|
||||
marker: 'TASK_BLOCKED',
|
||||
taskId: blockedWithTaskMatch[1],
|
||||
reason: blockedWithTaskMatch[2].trim(),
|
||||
};
|
||||
}
|
||||
|
||||
// TASK_BLOCKED with just reason (no task ID)
|
||||
const blockedMatch = output.match(/TASK_BLOCKED:\s*(.+?)(?:\n|$)/);
|
||||
if (blockedMatch) {
|
||||
return {
|
||||
completed: true,
|
||||
marker: 'TASK_BLOCKED',
|
||||
reason: blockedMatch[1].trim()
|
||||
};
|
||||
}
|
||||
|
||||
// Simple TASK_BLOCKED without any info
|
||||
if (output.includes('TASK_BLOCKED')) {
|
||||
return { completed: true, marker: 'TASK_BLOCKED', reason: 'No reason provided' };
|
||||
}
|
||||
|
||||
return { completed: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract ALL completion markers from output (for phase mode).
|
||||
* Returns an array of all TASK_COMPLETE/TASK_BLOCKED markers found,
|
||||
* plus whether LOOP_COMPLETE or PHASE_COMPLETE was present.
|
||||
*/
|
||||
export function checkForAllCompletions(output: string): {
|
||||
tasks: CompletionCheckResult[];
|
||||
loopComplete: boolean;
|
||||
phaseComplete: boolean;
|
||||
} {
|
||||
const tasks: CompletionCheckResult[] = [];
|
||||
let loopComplete = false;
|
||||
let phaseComplete = false;
|
||||
|
||||
if (output.includes('LOOP_COMPLETE')) {
|
||||
loopComplete = true;
|
||||
}
|
||||
|
||||
if (output.includes('PHASE_COMPLETE')) {
|
||||
phaseComplete = true;
|
||||
}
|
||||
|
||||
// Find all TASK_COMPLETE markers with task info
|
||||
// Format: TASK_COMPLETE: 1.1 - Task description
|
||||
const completeRegex = /TASK_COMPLETE[:\[\s]+(\d+\.\d+)[\]:\-\s]+(.+?)(?:\n|$)/gi;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = completeRegex.exec(output)) !== null) {
|
||||
tasks.push({
|
||||
completed: true,
|
||||
marker: 'TASK_COMPLETE',
|
||||
taskId: match[1],
|
||||
taskName: match[2].trim(),
|
||||
});
|
||||
}
|
||||
|
||||
// Find all TASK_BLOCKED markers with task info
|
||||
const blockedRegex = /TASK_BLOCKED[:\[\s]+(\d+\.\d+)[\]:\-\s]+(.+?)(?:\n|$)/gi;
|
||||
while ((match = blockedRegex.exec(output)) !== null) {
|
||||
tasks.push({
|
||||
completed: true,
|
||||
marker: 'TASK_BLOCKED',
|
||||
taskId: match[1],
|
||||
reason: match[2].trim(),
|
||||
});
|
||||
}
|
||||
|
||||
// Find PREREQ_COMPLETE markers
|
||||
const prereqRegex = /PREREQ_COMPLETE[:\[\s]+([^\]:\-\s]+)[\]:\-\s]+(.+?)(?:\n|$)/gi;
|
||||
while ((match = prereqRegex.exec(output)) !== null) {
|
||||
tasks.push({
|
||||
completed: true,
|
||||
marker: 'PREREQ_COMPLETE',
|
||||
taskId: match[1],
|
||||
taskName: match[2].trim(),
|
||||
});
|
||||
}
|
||||
|
||||
// Find PREREQ_ASSUMED markers
|
||||
const assumedRegex = /PREREQ_ASSUMED[:\[\s]+([^\]:\-\s]+)[\]:\-\s]+(.+?)(?:\n|$)/gi;
|
||||
while ((match = assumedRegex.exec(output)) !== null) {
|
||||
tasks.push({
|
||||
completed: true,
|
||||
marker: 'PREREQ_ASSUMED',
|
||||
taskId: match[1],
|
||||
taskName: match[2].trim(),
|
||||
});
|
||||
}
|
||||
|
||||
return { tasks, loopComplete, phaseComplete };
|
||||
}
|
||||
// Completion markers for plan2code-loop
|
||||
const COMPLETION_MARKERS = ['TASK_COMPLETE', 'TASK_BLOCKED', 'LOOP_COMPLETE', 'PREREQ_COMPLETE', 'PREREQ_ASSUMED'] as const;
|
||||
|
||||
export type CompletionMarker = (typeof COMPLETION_MARKERS)[number];
|
||||
|
||||
export interface CompletionCheckResult {
|
||||
completed: boolean;
|
||||
marker?: CompletionMarker;
|
||||
taskId?: string; // e.g., "1.1", "2.3"
|
||||
taskName?: string; // e.g., "Initialize project structure"
|
||||
reason?: string; // For TASK_BLOCKED: reason
|
||||
}
|
||||
|
||||
export function checkForCompletion(output: string): CompletionCheckResult {
|
||||
// Check for LOOP_COMPLETE first (highest priority)
|
||||
if (output.includes('LOOP_COMPLETE')) {
|
||||
return { completed: true, marker: 'LOOP_COMPLETE' };
|
||||
}
|
||||
|
||||
// Check for TASK_COMPLETE with task info
|
||||
// Format: TASK_COMPLETE: 1.1 - Task description
|
||||
// Or: TASK_COMPLETE: 1.1: Task description
|
||||
// Or: TASK_COMPLETE[1.1]: Task description
|
||||
const completeMatch = output.match(/TASK_COMPLETE[:\[\s]+(\d+\.\d+)[\]:\-\s]+(.+?)(?:\n|$)/i);
|
||||
if (completeMatch) {
|
||||
return {
|
||||
completed: true,
|
||||
marker: 'TASK_COMPLETE',
|
||||
taskId: completeMatch[1],
|
||||
taskName: completeMatch[2].trim(),
|
||||
};
|
||||
}
|
||||
|
||||
// Simple TASK_COMPLETE without structured info - try to extract task from context
|
||||
if (output.includes('TASK_COMPLETE')) {
|
||||
// Try to find task info nearby
|
||||
const taskMatch = output.match(/(?:task|completed?)\s+(\d+\.\d+)[:\s]+([^\n]{5,80})/i);
|
||||
return {
|
||||
completed: true,
|
||||
marker: 'TASK_COMPLETE',
|
||||
taskId: taskMatch?.[1],
|
||||
taskName: taskMatch?.[2]?.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
// Check for PREREQ_COMPLETE with prereq info
|
||||
// Format: PREREQ_COMPLETE: P1.1 - Verified Phase 1 complete
|
||||
const prereqMatch = output.match(/PREREQ_COMPLETE[:\[\s]+([^\]:\-\s]+)[\]:\-\s]+(.+?)(?:\n|$)/i);
|
||||
if (prereqMatch) {
|
||||
return {
|
||||
completed: true,
|
||||
marker: 'PREREQ_COMPLETE',
|
||||
taskId: prereqMatch[1],
|
||||
taskName: prereqMatch[2].trim(),
|
||||
};
|
||||
}
|
||||
|
||||
// Check for PREREQ_ASSUMED with prereq info
|
||||
// Format: PREREQ_ASSUMED: P2.1 - Design approval (cannot verify)
|
||||
const assumedMatch = output.match(/PREREQ_ASSUMED[:\[\s]+([^\]:\-\s]+)[\]:\-\s]+(.+?)(?:\n|$)/i);
|
||||
if (assumedMatch) {
|
||||
return {
|
||||
completed: true,
|
||||
marker: 'PREREQ_ASSUMED',
|
||||
taskId: assumedMatch[1],
|
||||
taskName: assumedMatch[2].trim(),
|
||||
};
|
||||
}
|
||||
|
||||
// Check for TASK_BLOCKED with task info and reason
|
||||
// Format: TASK_BLOCKED: 1.1 - Reason why blocked
|
||||
const blockedWithTaskMatch = output.match(/TASK_BLOCKED[:\[\s]+(\d+\.\d+)[\]:\-\s]+(.+?)(?:\n|$)/i);
|
||||
if (blockedWithTaskMatch) {
|
||||
return {
|
||||
completed: true,
|
||||
marker: 'TASK_BLOCKED',
|
||||
taskId: blockedWithTaskMatch[1],
|
||||
reason: blockedWithTaskMatch[2].trim(),
|
||||
};
|
||||
}
|
||||
|
||||
// TASK_BLOCKED with just reason (no task ID)
|
||||
const blockedMatch = output.match(/TASK_BLOCKED:\s*(.+?)(?:\n|$)/);
|
||||
if (blockedMatch) {
|
||||
return {
|
||||
completed: true,
|
||||
marker: 'TASK_BLOCKED',
|
||||
reason: blockedMatch[1].trim()
|
||||
};
|
||||
}
|
||||
|
||||
// Simple TASK_BLOCKED without any info
|
||||
if (output.includes('TASK_BLOCKED')) {
|
||||
return { completed: true, marker: 'TASK_BLOCKED', reason: 'No reason provided' };
|
||||
}
|
||||
|
||||
return { completed: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract ALL completion markers from output (for phase mode).
|
||||
* Returns an array of all TASK_COMPLETE/TASK_BLOCKED markers found,
|
||||
* plus whether LOOP_COMPLETE or PHASE_COMPLETE was present.
|
||||
*/
|
||||
export function checkForAllCompletions(output: string): {
|
||||
tasks: CompletionCheckResult[];
|
||||
loopComplete: boolean;
|
||||
phaseComplete: boolean;
|
||||
} {
|
||||
const tasks: CompletionCheckResult[] = [];
|
||||
let loopComplete = false;
|
||||
let phaseComplete = false;
|
||||
|
||||
if (output.includes('LOOP_COMPLETE')) {
|
||||
loopComplete = true;
|
||||
}
|
||||
|
||||
if (output.includes('PHASE_COMPLETE')) {
|
||||
phaseComplete = true;
|
||||
}
|
||||
|
||||
// Find all TASK_COMPLETE markers with task info
|
||||
// Format: TASK_COMPLETE: 1.1 - Task description
|
||||
const completeRegex = /TASK_COMPLETE[:\[\s]+(\d+\.\d+)[\]:\-\s]+(.+?)(?:\n|$)/gi;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = completeRegex.exec(output)) !== null) {
|
||||
tasks.push({
|
||||
completed: true,
|
||||
marker: 'TASK_COMPLETE',
|
||||
taskId: match[1],
|
||||
taskName: match[2].trim(),
|
||||
});
|
||||
}
|
||||
|
||||
// Find all TASK_BLOCKED markers with task info
|
||||
const blockedRegex = /TASK_BLOCKED[:\[\s]+(\d+\.\d+)[\]:\-\s]+(.+?)(?:\n|$)/gi;
|
||||
while ((match = blockedRegex.exec(output)) !== null) {
|
||||
tasks.push({
|
||||
completed: true,
|
||||
marker: 'TASK_BLOCKED',
|
||||
taskId: match[1],
|
||||
reason: match[2].trim(),
|
||||
});
|
||||
}
|
||||
|
||||
// Find PREREQ_COMPLETE markers
|
||||
const prereqRegex = /PREREQ_COMPLETE[:\[\s]+([^\]:\-\s]+)[\]:\-\s]+(.+?)(?:\n|$)/gi;
|
||||
while ((match = prereqRegex.exec(output)) !== null) {
|
||||
tasks.push({
|
||||
completed: true,
|
||||
marker: 'PREREQ_COMPLETE',
|
||||
taskId: match[1],
|
||||
taskName: match[2].trim(),
|
||||
});
|
||||
}
|
||||
|
||||
// Find PREREQ_ASSUMED markers
|
||||
const assumedRegex = /PREREQ_ASSUMED[:\[\s]+([^\]:\-\s]+)[\]:\-\s]+(.+?)(?:\n|$)/gi;
|
||||
while ((match = assumedRegex.exec(output)) !== null) {
|
||||
tasks.push({
|
||||
completed: true,
|
||||
marker: 'PREREQ_ASSUMED',
|
||||
taskId: match[1],
|
||||
taskName: match[2].trim(),
|
||||
});
|
||||
}
|
||||
|
||||
return { tasks, loopComplete, phaseComplete };
|
||||
}
|
||||
|
||||
+136
-136
@@ -1,136 +1,136 @@
|
||||
import { execa } from 'execa';
|
||||
import { existsSync, readFileSync, writeFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { logger } from './logger.js';
|
||||
|
||||
export interface GitCommitOptions {
|
||||
taskName: string;
|
||||
jiraTicketId?: string;
|
||||
cwd?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if directory is a git repository
|
||||
*/
|
||||
export async function isGitRepo(cwd: string): Promise<boolean> {
|
||||
const result = await execa('git', ['rev-parse', '--git-dir'], { cwd, reject: false });
|
||||
return result.exitCode === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize a git repository if one doesn't exist
|
||||
*/
|
||||
export async function ensureGitRepo(cwd: string): Promise<boolean> {
|
||||
if (await isGitRepo(cwd)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
logger.info('Initializing git repository...');
|
||||
const result = await execa('git', ['init'], { cwd, reject: false });
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
logger.error(`Failed to initialize git repo: ${result.stderr}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
logger.success('Git repository initialized');
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Required entries for the .gitignore file
|
||||
*/
|
||||
const REQUIRED_GITIGNORE_ENTRIES = ['specs/', 'specs--completed/', '.plan2code-loop', '.plan2code-metrics', 'nul', 'node_modules/'];
|
||||
|
||||
/**
|
||||
* Ensure .gitignore exists with required entries
|
||||
*/
|
||||
export function ensureGitignore(cwd: string): void {
|
||||
const gitignorePath = join(cwd, '.gitignore');
|
||||
let content = '';
|
||||
|
||||
if (existsSync(gitignorePath)) {
|
||||
content = readFileSync(gitignorePath, 'utf-8');
|
||||
}
|
||||
|
||||
const lines = content.split('\n').map(line => line.trim());
|
||||
const missingEntries = REQUIRED_GITIGNORE_ENTRIES.filter(entry => !lines.includes(entry));
|
||||
|
||||
if (missingEntries.length > 0) {
|
||||
const needsNewline = content.length > 0 && !content.endsWith('\n');
|
||||
const newContent = content + (needsNewline ? '\n' : '') + missingEntries.join('\n') + '\n';
|
||||
writeFileSync(gitignorePath, newContent);
|
||||
logger.dim(`Added to .gitignore: ${missingEntries.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a local git commit for a completed task
|
||||
*/
|
||||
export async function createTaskCommit(options: GitCommitOptions): Promise<boolean> {
|
||||
const { taskName, jiraTicketId, cwd = process.cwd() } = options;
|
||||
|
||||
logger.dim(`Git commit check in: ${cwd}`);
|
||||
|
||||
try {
|
||||
// Ensure we have a git repo
|
||||
if (!await ensureGitRepo(cwd)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Ensure .gitignore exists with required entries
|
||||
ensureGitignore(cwd);
|
||||
|
||||
// Check if there are any changes to commit
|
||||
const statusResult = await execa('git', ['status', '--porcelain'], { cwd, reject: false });
|
||||
|
||||
// Debug: show what git status returned
|
||||
if (statusResult.stdout?.trim()) {
|
||||
logger.dim(`Git status found changes:\n${statusResult.stdout.slice(0, 500)}`);
|
||||
}
|
||||
|
||||
if (statusResult.exitCode !== 0) {
|
||||
logger.error(`Git status failed: ${statusResult.stderr}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!statusResult.stdout?.trim()) {
|
||||
logger.dim('No changes to commit');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Stage all changes
|
||||
const addResult = await execa('git', ['add', '-A'], { cwd, reject: false });
|
||||
if (addResult.exitCode !== 0) {
|
||||
logger.error(`Git add failed: ${addResult.stderr}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Build commit message
|
||||
let commitMessage = taskName;
|
||||
if (jiraTicketId) {
|
||||
commitMessage = `${taskName}\n\n${jiraTicketId}\nAI Assisted`;
|
||||
} else {
|
||||
commitMessage = `${taskName}\n\nAI Assisted`;
|
||||
}
|
||||
|
||||
// Create the commit
|
||||
const commitResult = await execa('git', ['commit', '-m', commitMessage], { cwd, reject: false });
|
||||
|
||||
if (commitResult.exitCode !== 0) {
|
||||
// Check if it's just "nothing to commit" vs actual error
|
||||
if (commitResult.stdout?.includes('nothing to commit') || commitResult.stderr?.includes('nothing to commit')) {
|
||||
logger.dim('No changes to commit');
|
||||
return false;
|
||||
}
|
||||
logger.error(`Git commit failed: ${commitResult.stderr || commitResult.stdout}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
logger.success(`Created commit: ${taskName}${jiraTicketId ? ` (${jiraTicketId})` : ''}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to create commit: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
import { execa } from 'execa';
|
||||
import { existsSync, readFileSync, writeFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { logger } from './logger.js';
|
||||
|
||||
export interface GitCommitOptions {
|
||||
taskName: string;
|
||||
jiraTicketId?: string;
|
||||
cwd?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if directory is a git repository
|
||||
*/
|
||||
export async function isGitRepo(cwd: string): Promise<boolean> {
|
||||
const result = await execa('git', ['rev-parse', '--git-dir'], { cwd, reject: false });
|
||||
return result.exitCode === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize a git repository if one doesn't exist
|
||||
*/
|
||||
export async function ensureGitRepo(cwd: string): Promise<boolean> {
|
||||
if (await isGitRepo(cwd)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
logger.info('Initializing git repository...');
|
||||
const result = await execa('git', ['init'], { cwd, reject: false });
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
logger.error(`Failed to initialize git repo: ${result.stderr}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
logger.success('Git repository initialized');
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Required entries for the .gitignore file
|
||||
*/
|
||||
const REQUIRED_GITIGNORE_ENTRIES = ['specs/', 'specs--completed/', '.plan2code-loop', '.plan2code-metrics', 'nul', 'node_modules/'];
|
||||
|
||||
/**
|
||||
* Ensure .gitignore exists with required entries
|
||||
*/
|
||||
export function ensureGitignore(cwd: string): void {
|
||||
const gitignorePath = join(cwd, '.gitignore');
|
||||
let content = '';
|
||||
|
||||
if (existsSync(gitignorePath)) {
|
||||
content = readFileSync(gitignorePath, 'utf-8');
|
||||
}
|
||||
|
||||
const lines = content.split('\n').map(line => line.trim());
|
||||
const missingEntries = REQUIRED_GITIGNORE_ENTRIES.filter(entry => !lines.includes(entry));
|
||||
|
||||
if (missingEntries.length > 0) {
|
||||
const needsNewline = content.length > 0 && !content.endsWith('\n');
|
||||
const newContent = content + (needsNewline ? '\n' : '') + missingEntries.join('\n') + '\n';
|
||||
writeFileSync(gitignorePath, newContent);
|
||||
logger.dim(`Added to .gitignore: ${missingEntries.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a local git commit for a completed task
|
||||
*/
|
||||
export async function createTaskCommit(options: GitCommitOptions): Promise<boolean> {
|
||||
const { taskName, jiraTicketId, cwd = process.cwd() } = options;
|
||||
|
||||
logger.dim(`Git commit check in: ${cwd}`);
|
||||
|
||||
try {
|
||||
// Ensure we have a git repo
|
||||
if (!await ensureGitRepo(cwd)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Ensure .gitignore exists with required entries
|
||||
ensureGitignore(cwd);
|
||||
|
||||
// Check if there are any changes to commit
|
||||
const statusResult = await execa('git', ['status', '--porcelain'], { cwd, reject: false });
|
||||
|
||||
// Debug: show what git status returned
|
||||
if (statusResult.stdout?.trim()) {
|
||||
logger.dim(`Git status found changes:\n${statusResult.stdout.slice(0, 500)}`);
|
||||
}
|
||||
|
||||
if (statusResult.exitCode !== 0) {
|
||||
logger.error(`Git status failed: ${statusResult.stderr}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!statusResult.stdout?.trim()) {
|
||||
logger.dim('No changes to commit');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Stage all changes
|
||||
const addResult = await execa('git', ['add', '-A'], { cwd, reject: false });
|
||||
if (addResult.exitCode !== 0) {
|
||||
logger.error(`Git add failed: ${addResult.stderr}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Build commit message
|
||||
let commitMessage = taskName;
|
||||
if (jiraTicketId) {
|
||||
commitMessage = `${taskName}\n\n${jiraTicketId}\nAI Assisted`;
|
||||
} else {
|
||||
commitMessage = `${taskName}\n\nAI Assisted`;
|
||||
}
|
||||
|
||||
// Create the commit
|
||||
const commitResult = await execa('git', ['commit', '-m', commitMessage], { cwd, reject: false });
|
||||
|
||||
if (commitResult.exitCode !== 0) {
|
||||
// Check if it's just "nothing to commit" vs actual error
|
||||
if (commitResult.stdout?.includes('nothing to commit') || commitResult.stderr?.includes('nothing to commit')) {
|
||||
logger.dim('No changes to commit');
|
||||
return false;
|
||||
}
|
||||
logger.error(`Git commit failed: ${commitResult.stderr || commitResult.stdout}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
logger.success(`Created commit: ${taskName}${jiraTicketId ? ` (${jiraTicketId})` : ''}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to create commit: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
export { logger, MASCOT, type Logger } from './logger.js';
|
||||
export {
|
||||
checkForCompletion,
|
||||
checkForAllCompletions,
|
||||
type CompletionMarker,
|
||||
type CompletionCheckResult
|
||||
} from './completion.js';
|
||||
export {
|
||||
executeCommand,
|
||||
type ExecuteOptions,
|
||||
type ExecuteResult
|
||||
} from './process.js';
|
||||
export {
|
||||
createTaskCommit,
|
||||
isGitRepo,
|
||||
ensureGitRepo,
|
||||
ensureGitignore,
|
||||
type GitCommitOptions
|
||||
} from './git.js';
|
||||
export { logger, MASCOT, type Logger } from './logger.js';
|
||||
export {
|
||||
checkForCompletion,
|
||||
checkForAllCompletions,
|
||||
type CompletionMarker,
|
||||
type CompletionCheckResult
|
||||
} from './completion.js';
|
||||
export {
|
||||
executeCommand,
|
||||
type ExecuteOptions,
|
||||
type ExecuteResult
|
||||
} from './process.js';
|
||||
export {
|
||||
createTaskCommit,
|
||||
isGitRepo,
|
||||
ensureGitRepo,
|
||||
ensureGitignore,
|
||||
type GitCommitOptions
|
||||
} from './git.js';
|
||||
|
||||
@@ -1,81 +1,81 @@
|
||||
import { execa, type Options as ExecaOptions } from 'execa';
|
||||
|
||||
const MAX_OUTPUT_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
|
||||
function truncateOutput(output: string, maxSize: number): string {
|
||||
if (output.length > maxSize) {
|
||||
return output.slice(0, maxSize) + '\n...[truncated]';
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
export interface ExecuteOptions {
|
||||
command: string;
|
||||
args: string[];
|
||||
cwd: string;
|
||||
timeout: number; // milliseconds
|
||||
env?: Record<string, string>;
|
||||
signal?: AbortSignal; // For cancellation
|
||||
stdin?: string; // Input to pass via stdin as string
|
||||
stdinFile?: string; // Path to file to pipe as stdin
|
||||
}
|
||||
|
||||
export interface ExecuteResult {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exitCode: number;
|
||||
timedOut: boolean;
|
||||
cancelled: boolean;
|
||||
duration: number; // milliseconds
|
||||
}
|
||||
|
||||
export async function executeCommand(
|
||||
options: ExecuteOptions
|
||||
): Promise<ExecuteResult> {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const execaOptions: ExecaOptions = {
|
||||
cwd: options.cwd,
|
||||
timeout: options.timeout,
|
||||
env: { ...process.env, ...options.env },
|
||||
reject: false,
|
||||
all: true,
|
||||
};
|
||||
|
||||
// Add cancellation signal if provided
|
||||
if (options.signal) {
|
||||
(execaOptions as any).cancelSignal = options.signal;
|
||||
}
|
||||
|
||||
// Add stdin input if provided (string or file)
|
||||
if (options.stdin) {
|
||||
(execaOptions as any).input = options.stdin;
|
||||
} else if (options.stdinFile) {
|
||||
(execaOptions as any).inputFile = options.stdinFile;
|
||||
}
|
||||
|
||||
const result = await execa(options.command, options.args, execaOptions);
|
||||
|
||||
return {
|
||||
stdout: truncateOutput(result.stdout || '', MAX_OUTPUT_SIZE),
|
||||
stderr: truncateOutput(result.stderr || '', MAX_OUTPUT_SIZE),
|
||||
exitCode: result.exitCode ?? 1,
|
||||
timedOut: result.timedOut ?? false,
|
||||
cancelled: result.isCanceled ?? false,
|
||||
duration: Date.now() - startTime,
|
||||
};
|
||||
} catch (error: any) {
|
||||
// Check if this was a cancellation
|
||||
const isCancelled = error?.isCanceled || options.signal?.aborted;
|
||||
|
||||
return {
|
||||
stdout: error?.stdout || '',
|
||||
stderr: error?.stderr || (error instanceof Error ? error.message : String(error)),
|
||||
exitCode: isCancelled ? -1 : 1,
|
||||
timedOut: false,
|
||||
cancelled: isCancelled,
|
||||
duration: Date.now() - startTime,
|
||||
};
|
||||
}
|
||||
}
|
||||
import { execa, type Options as ExecaOptions } from 'execa';
|
||||
|
||||
const MAX_OUTPUT_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
|
||||
function truncateOutput(output: string, maxSize: number): string {
|
||||
if (output.length > maxSize) {
|
||||
return output.slice(0, maxSize) + '\n...[truncated]';
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
export interface ExecuteOptions {
|
||||
command: string;
|
||||
args: string[];
|
||||
cwd: string;
|
||||
timeout: number; // milliseconds
|
||||
env?: Record<string, string>;
|
||||
signal?: AbortSignal; // For cancellation
|
||||
stdin?: string; // Input to pass via stdin as string
|
||||
stdinFile?: string; // Path to file to pipe as stdin
|
||||
}
|
||||
|
||||
export interface ExecuteResult {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exitCode: number;
|
||||
timedOut: boolean;
|
||||
cancelled: boolean;
|
||||
duration: number; // milliseconds
|
||||
}
|
||||
|
||||
export async function executeCommand(
|
||||
options: ExecuteOptions
|
||||
): Promise<ExecuteResult> {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const execaOptions: ExecaOptions = {
|
||||
cwd: options.cwd,
|
||||
timeout: options.timeout,
|
||||
env: { ...process.env, ...options.env },
|
||||
reject: false,
|
||||
all: true,
|
||||
};
|
||||
|
||||
// Add cancellation signal if provided
|
||||
if (options.signal) {
|
||||
(execaOptions as any).cancelSignal = options.signal;
|
||||
}
|
||||
|
||||
// Add stdin input if provided (string or file)
|
||||
if (options.stdin) {
|
||||
(execaOptions as any).input = options.stdin;
|
||||
} else if (options.stdinFile) {
|
||||
(execaOptions as any).inputFile = options.stdinFile;
|
||||
}
|
||||
|
||||
const result = await execa(options.command, options.args, execaOptions);
|
||||
|
||||
return {
|
||||
stdout: truncateOutput(result.stdout || '', MAX_OUTPUT_SIZE),
|
||||
stderr: truncateOutput(result.stderr || '', MAX_OUTPUT_SIZE),
|
||||
exitCode: result.exitCode ?? 1,
|
||||
timedOut: result.timedOut ?? false,
|
||||
cancelled: result.isCanceled ?? false,
|
||||
duration: Date.now() - startTime,
|
||||
};
|
||||
} catch (error: any) {
|
||||
// Check if this was a cancellation
|
||||
const isCancelled = error?.isCanceled || options.signal?.aborted;
|
||||
|
||||
return {
|
||||
stdout: error?.stdout || '',
|
||||
stderr: error?.stderr || (error instanceof Error ? error.message : String(error)),
|
||||
exitCode: isCancelled ? -1 : 1,
|
||||
timedOut: false,
|
||||
cancelled: isCancelled,
|
||||
duration: Date.now() - startTime,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user