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" }); } } // --------------------------------------------------------------------------- // 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(); } // --------------------------------------------------------------------------- // 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 count = newlyCompleted.length; const taskLines = newlyCompleted .map((l) => `- ✅ ${extractTaskName(l)}`) .join("\n"); commitMsg = `feat: Complete ${count} task${count > 1 ? "s" : ""}\n\n${taskLines}`; } else { commitMsg = "chore: Implement progress (no tasks completed this turn)"; } execFileSync("git", ["commit", "-m", commitMsg], { cwd: projectPath }); } catch { // Non-fatal — git may not be configured, nothing to commit, etc. } return currentPlan; }