From 9d192d16b7a4026b35ad2bcaff9edb9f2670de2b Mon Sep 17 00:00:00 2001 From: bndw Date: Sat, 28 Feb 2026 20:07:05 -0800 Subject: feat: git branches --- src/main/git.ts | 202 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 202 insertions(+) create mode 100644 src/main/git.ts (limited to 'src/main/git.ts') diff --git a/src/main/git.ts b/src/main/git.ts new file mode 100644 index 0000000..58dc860 --- /dev/null +++ b/src/main/git.ts @@ -0,0 +1,202 @@ +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; +} -- cgit v1.2.3