import { execFileSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- export function slugify(name: string, maxLen = 20): string { return name .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-|-$/g, "") .slice(0, maxLen) .replace(/-$/, ""); } /** * Ensure `.claude-flow/` is present in the project's .gitignore. * Creates the file if it doesn't exist. Idempotent. */ export function ensureGitIgnore(projectPath: string): void { const gitignorePath = path.join(projectPath, ".gitignore"); const entry = ".claude-flow/"; if (fs.existsSync(gitignorePath)) { const content = fs.readFileSync(gitignorePath, "utf-8"); const lines = content.split("\n").map((l) => l.trim()); if (!lines.includes(entry)) { const suffix = content.endsWith("\n") ? "" : "\n"; fs.appendFileSync(gitignorePath, `${suffix}${entry}\n`, "utf-8"); } } else { fs.writeFileSync(gitignorePath, `${entry}\n`, "utf-8"); } } function isGitRepo(projectPath: string): boolean { try { execFileSync("git", ["rev-parse", "--is-inside-work-tree"], { cwd: projectPath, stdio: "pipe", }); return true; } catch { return false; } } /** * Initialize a git repo if one doesn't already exist. * Does not create an initial commit — git checkout -b works on unborn branches. */ export function ensureGitRepo(projectPath: string): void { if (!isGitRepo(projectPath)) { execFileSync("git", ["init"], { cwd: projectPath, stdio: "pipe" }); } } // --------------------------------------------------------------------------- // Current branch query // --------------------------------------------------------------------------- /** * Returns the name of the currently checked-out branch, * or null if git is unavailable or HEAD is detached. */ export function getCurrentBranch(projectPath: string): string | null { try { return ( execFileSync("git", ["branch", "--show-current"], { cwd: projectPath, stdio: "pipe", }) .toString() .trim() || null ); } catch { return null; } } // --------------------------------------------------------------------------- // Branch creation // --------------------------------------------------------------------------- /** * Ensure .gitignore is set up, init git if needed, then create and checkout * a new branch named `claude-flow/-`. * Returns the branch name on success, null if git is unavailable or fails. */ export function createSessionBranch( projectPath: string, sessionName: string, sessionId: string ): string | null { try { ensureGitIgnore(projectPath); ensureGitRepo(projectPath); const branchName = `claude-flow/${slugify(sessionName)}-${sessionId.slice(0, 8)}`; execFileSync("git", ["checkout", "-b", branchName], { cwd: projectPath, stdio: "pipe", }); return branchName; } catch { return null; } } // --------------------------------------------------------------------------- // Safe staging // --------------------------------------------------------------------------- /** * Stage everything with `git add -A`, then explicitly un-stage `.claude-flow/` * regardless of whether it is gitignored or previously tracked. * This is belt-and-suspenders: .gitignore is the first line of defense, this * explicit un-stage is the second guarantee. */ function stageWithSafety(projectPath: string): void { execFileSync("git", ["add", "-A"], { cwd: projectPath }); // Explicit un-stage of .claude-flow/ — protects against previously-tracked dirs try { // git restore --staged available since git 2.23 (2019) execFileSync("git", ["restore", "--staged", "--", ".claude-flow/"], { cwd: projectPath, stdio: "pipe", }); } catch { // Fallback for older git try { execFileSync("git", ["reset", "HEAD", "--", ".claude-flow/"], { cwd: projectPath, stdio: "pipe", }); } catch { /* nothing staged there — ignore */ } } } function hasAnythingStaged(projectPath: string): boolean { try { // exit 0 = index matches HEAD (nothing staged) execFileSync("git", ["diff", "--cached", "--quiet"], { cwd: projectPath, stdio: "pipe", }); return false; } catch { // exit 1 = differences exist = something is staged return true; } } // --------------------------------------------------------------------------- // Checkbox parsing // --------------------------------------------------------------------------- function findNewlyCompletedTasks(before: string, after: string): string[] { const checked = (s: string): Set => new Set(s.split("\n").filter((l) => /^\s*-\s*\[[xX]\]/.test(l))); const beforeChecked = checked(before); const afterChecked = checked(after); return [...afterChecked].filter((l) => !beforeChecked.has(l)); } function extractTaskName(checkboxLine: string): string { return checkboxLine.replace(/^\s*-\s*\[[xX]\]\s*/, "").trim(); } // --------------------------------------------------------------------------- // Commit subject builders // --------------------------------------------------------------------------- const LOCK_FILES = new Set([ "package-lock.json", "yarn.lock", "pnpm-lock.yaml", "bun.lockb", "Cargo.lock", "Gemfile.lock", "poetry.lock", ]); /** * Builds a `feat:` subject from completed task names. * Keeps the total subject ≤ 72 chars. * 1 task → "feat: {name}" * N tasks → "feat: {name} (+N-1 more)" */ function buildTaskSubject(taskNames: string[]): string { const prefix = "feat: "; const MAX = 72; if (taskNames.length === 1) { const full = `${prefix}${taskNames[0]}`; if (full.length <= MAX) return full; return `${prefix}${taskNames[0].slice(0, MAX - prefix.length - 1)}\u2026`; } const suffix = ` (+${taskNames.length - 1} more)`; const available = MAX - prefix.length - suffix.length; const first = taskNames[0]; const truncated = first.length > available ? `${first.slice(0, available - 1)}\u2026` : first; return `${prefix}${truncated}${suffix}`; } /** * Returns the basenames of staged files, excluding lock files. * Returns empty array on any failure. */ function getStagedFileNames(projectPath: string): string[] { try { const raw = execFileSync("git", ["diff", "--cached", "--name-only"], { cwd: projectPath, stdio: "pipe", }) .toString() .trim(); if (!raw) return []; return raw .split("\n") .map((f) => path.basename(f.trim())) .filter((f) => f && !LOCK_FILES.has(f)); } catch { return []; } } /** * Builds a `chore:` subject from the staged file list when no tasks completed. * 1 file → "chore: Update {file}" * 2 files → "chore: Update {file1}, {file2}" * 3+ files → "chore: Update {file1} (+N more files)" * 0 files → "chore: Implement progress" */ function buildFileSubject(projectPath: string): string { const files = getStagedFileNames(projectPath); if (files.length === 0) return "chore: Implement progress"; if (files.length === 1) return `chore: Update ${files[0]}`; if (files.length === 2) return `chore: Update ${files[0]}, ${files[1]}`; return `chore: Update ${files[0]} (+${files.length - 1} more files)`; } // --------------------------------------------------------------------------- // Auto-commit // --------------------------------------------------------------------------- /** * Called after each implement-phase Claude turn completes. * * - Reads current plan.md from sessionDir * - Diffs against previousPlan to detect newly-checked tasks * - Stages all project changes (safely, excluding .claude-flow/) * - Commits if anything was staged, with a message summarising completed tasks * - Returns the current plan.md content so the caller can update its snapshot * * The snapshot is always returned (and should always be updated by the caller) * regardless of whether a commit was made, to prevent double-counting tasks. */ export function autoCommitTurn( projectPath: string, gitBranch: string | null, previousPlan: string, sessionDir: string ): string { const planPath = path.join(sessionDir, "plan.md"); const currentPlan = fs.existsSync(planPath) ? fs.readFileSync(planPath, "utf-8") : ""; // Always return currentPlan so caller can update snapshot, even if no git if (!gitBranch) return currentPlan; const newlyCompleted = findNewlyCompletedTasks(previousPlan, currentPlan); try { stageWithSafety(projectPath); if (!hasAnythingStaged(projectPath)) return currentPlan; let commitMsg: string; if (newlyCompleted.length > 0) { const taskNames = newlyCompleted.map(extractTaskName); const subject = buildTaskSubject(taskNames); const body = taskNames.map((t) => `- \u2705 ${t}`).join("\n"); commitMsg = `${subject}\n\n${body}`; } else { commitMsg = buildFileSubject(projectPath); } execFileSync("git", ["commit", "-m", commitMsg], { cwd: projectPath }); } catch { // Non-fatal — git may not be configured, nothing to commit, etc. } return currentPlan; }