From 9d192d16b7a4026b35ad2bcaff9edb9f2670de2b Mon Sep 17 00:00:00 2001 From: bndw Date: Sat, 28 Feb 2026 20:07:05 -0800 Subject: feat: git branches --- CLAUDE.md | 10 +++ renderer/src/types.ts | 1 + src/main/claude/index.ts | 26 ++++++ src/main/db/index.ts | 10 +++ src/main/db/schema.ts | 1 + src/main/db/sessions.ts | 4 +- src/main/git.ts | 202 +++++++++++++++++++++++++++++++++++++++++++++++ src/main/ipc/handlers.ts | 17 +++- 8 files changed, 267 insertions(+), 4 deletions(-) create mode 100644 src/main/git.ts diff --git a/CLAUDE.md b/CLAUDE.md index e54032e..d2e968a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,9 +45,19 @@ All renderer→main communication goes through named IPC channels registered in ### Database SQLite at `app.getPath("userData")/claude-flow.db`. Tables: `projects`, `sessions`, `messages`. Foreign keys ON, WAL mode enabled. +Schema migrations: `db/index.ts::getDb()` calls `initSchema()` which uses `CREATE TABLE IF NOT EXISTS`. **New columns require explicit `ALTER TABLE` migration** run after `initSchema()`. + +### SDK Permission Modes +`PermissionMode` values: `'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' | 'dontAsk'` + +**Known bug**: `permissionMode: 'bypassPermissions'` requires a companion flag `allowDangerouslySkipPermissions: true` in the `query()` options. This flag is currently missing from `claude/index.ts`. + +`allowedTools: string[]` in the SDK maps to Claude Code's `--allowedTools` CLI flag and supports patterns like `'Bash(git *)'` to auto-allow only specific Bash command forms. + ## Important Notes - `ANTHROPIC_API_KEY` env var must be set before launching - Artifacts are stored in `.claude-flow/sessions/` inside the target project - `bypassPermissions` mode is a user-controlled toggle in implement phase only - Token usage (from `SDKResultMessage.usage`) is displayed in the ActionBar +- No git library in dependencies — use Node.js `child_process` (built-in) for git operations diff --git a/renderer/src/types.ts b/renderer/src/types.ts index 11062ee..fd79dc8 100644 --- a/renderer/src/types.ts +++ b/renderer/src/types.ts @@ -13,6 +13,7 @@ export interface Session { phase: Phase; claude_session_id: string | null; permission_mode: PermissionMode; + git_branch: string | null; created_at: number; updated_at: number; } diff --git a/src/main/claude/index.ts b/src/main/claude/index.ts index 4dd49f2..8971844 100644 --- a/src/main/claude/index.ts +++ b/src/main/claude/index.ts @@ -4,12 +4,17 @@ import { getPhaseConfig, getNextPhase, getArtifactFilename } from "./phases"; import type { Phase, UserPermissionMode } from "./phases"; import { getProject } from "../db/projects"; import { updateSession } from "../db/sessions"; +import { autoCommitTurn } from "../git"; import fs from "node:fs"; import path from "node:path"; // Track active queries by session ID const activeQueries = new Map(); +// Snapshot of plan.md content per session, updated after each implement turn. +// Used to detect newly-completed tasks for commit messages. +const planSnapshots = new Map(); + function ensureDir(dirPath: string): void { if (!fs.existsSync(dirPath)) { fs.mkdirSync(dirPath, { recursive: true }); @@ -52,7 +57,14 @@ export async function sendMessage({ resume: session.claude_session_id ?? undefined, tools: phaseConfig.tools, permissionMode: phaseConfig.permissionMode, + // Required companion flag when bypassPermissions is active + allowDangerouslySkipPermissions: phaseConfig.permissionMode === "bypassPermissions", systemPrompt: phaseConfig.systemPrompt, + // Allow Claude to inspect git state during implementation without prompts. + // git add/commit intentionally omitted — the app handles those. + ...(session.phase === "implement" && { + allowedTools: ["Bash(git status*)", "Bash(git log*)", "Bash(git diff*)"], + }), }, }); @@ -71,6 +83,20 @@ export async function sendMessage({ } finally { activeQueries.delete(session.id); } + + // Auto-commit after a successful implement-phase turn. + // This runs only if for-await completed without throwing (successful turn). + // Interrupted / errored turns skip the commit. + if (session.phase === "implement") { + const previousPlan = planSnapshots.get(session.id) ?? ""; + const currentPlan = autoCommitTurn( + project.path, + session.git_branch, + previousPlan, + sessionDir + ); + planSnapshots.set(session.id, currentPlan); + } } export function interruptSession(sessionId: string): void { diff --git a/src/main/db/index.ts b/src/main/db/index.ts index a77cdd4..1613abc 100644 --- a/src/main/db/index.ts +++ b/src/main/db/index.ts @@ -20,9 +20,19 @@ export function getDb(): Database.Database { db.pragma("foreign_keys = ON"); initSchema(db); + runMigrations(db); + return db; } +function runMigrations(db: Database.Database): void { + // v2: add git_branch column to sessions + const cols = db.pragma("table_info(sessions)") as { name: string }[]; + if (!cols.find((c) => c.name === "git_branch")) { + db.exec("ALTER TABLE sessions ADD COLUMN git_branch TEXT"); + } +} + export function closeDb() { if (db) { db.close(); diff --git a/src/main/db/schema.ts b/src/main/db/schema.ts index c2093f9..39ee567 100644 --- a/src/main/db/schema.ts +++ b/src/main/db/schema.ts @@ -17,6 +17,7 @@ export function initSchema(db: Database.Database) { phase TEXT NOT NULL DEFAULT 'research', claude_session_id TEXT, permission_mode TEXT NOT NULL DEFAULT 'acceptEdits', + git_branch TEXT, created_at INTEGER NOT NULL DEFAULT (unixepoch()), updated_at INTEGER NOT NULL DEFAULT (unixepoch()) ); diff --git a/src/main/db/sessions.ts b/src/main/db/sessions.ts index 684bb9e..3e6352c 100644 --- a/src/main/db/sessions.ts +++ b/src/main/db/sessions.ts @@ -11,6 +11,7 @@ export interface Session { phase: Phase; claude_session_id: string | null; permission_mode: PermissionMode; + git_branch: string | null; created_at: number; updated_at: number; } @@ -52,6 +53,7 @@ export function createSession(projectId: string, name: string): Session { phase: "research", claude_session_id: null, permission_mode: "acceptEdits", + git_branch: null, created_at: now, updated_at: now, }; @@ -59,7 +61,7 @@ export function createSession(projectId: string, name: string): Session { export function updateSession( id: string, - updates: Partial> + updates: Partial> ): void { const db = getDb(); const sets: string[] = []; 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; +} diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index 145c6e2..f0a5b82 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -2,6 +2,7 @@ import { ipcMain, dialog, type BrowserWindow } from "electron"; import * as projects from "../db/projects"; import * as sessions from "../db/sessions"; import * as claude from "../claude"; +import { createSessionBranch } from "../git"; import type { UserPermissionMode } from "../claude/phases"; export function registerIpcHandlers(mainWindow: BrowserWindow): void { @@ -19,9 +20,19 @@ export function registerIpcHandlers(mainWindow: BrowserWindow): void { sessions.listSessions(projectId) ); - ipcMain.handle("sessions:create", (_, projectId: string, name: string) => - sessions.createSession(projectId, name) - ); + ipcMain.handle("sessions:create", (_, projectId: string, name: string) => { + const project = projects.getProject(projectId); + if (!project) throw new Error("Project not found"); + + const session = sessions.createSession(projectId, name); + + const branchName = createSessionBranch(project.path, session.name, session.id); + if (branchName) { + sessions.updateSession(session.id, { git_branch: branchName }); + } + + return { ...session, git_branch: branchName ?? null }; + }); ipcMain.handle("sessions:delete", (_, id: string) => { const session = sessions.getSession(id); -- cgit v1.2.3