diff options
| author | bndw <ben@bdw.to> | 2026-02-28 20:07:05 -0800 |
|---|---|---|
| committer | bndw <ben@bdw.to> | 2026-02-28 20:07:05 -0800 |
| commit | 9d192d16b7a4026b35ad2bcaff9edb9f2670de2b (patch) | |
| tree | 2603dd04c3567074e84be271c448ede02ee7097d /src/main | |
| parent | 283013c09d4855529e846951a1e090f0f16030a8 (diff) | |
feat: git branches
Diffstat (limited to 'src/main')
| -rw-r--r-- | src/main/claude/index.ts | 26 | ||||
| -rw-r--r-- | src/main/db/index.ts | 10 | ||||
| -rw-r--r-- | src/main/db/schema.ts | 1 | ||||
| -rw-r--r-- | src/main/db/sessions.ts | 4 | ||||
| -rw-r--r-- | src/main/git.ts | 202 | ||||
| -rw-r--r-- | src/main/ipc/handlers.ts | 17 |
6 files changed, 256 insertions, 4 deletions
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"; | |||
| 4 | import type { Phase, UserPermissionMode } from "./phases"; | 4 | import type { Phase, UserPermissionMode } from "./phases"; |
| 5 | import { getProject } from "../db/projects"; | 5 | import { getProject } from "../db/projects"; |
| 6 | import { updateSession } from "../db/sessions"; | 6 | import { updateSession } from "../db/sessions"; |
| 7 | import { autoCommitTurn } from "../git"; | ||
| 7 | import fs from "node:fs"; | 8 | import fs from "node:fs"; |
| 8 | import path from "node:path"; | 9 | import path from "node:path"; |
| 9 | 10 | ||
| 10 | // Track active queries by session ID | 11 | // Track active queries by session ID |
| 11 | const activeQueries = new Map<string, Query>(); | 12 | const activeQueries = new Map<string, Query>(); |
| 12 | 13 | ||
| 14 | // Snapshot of plan.md content per session, updated after each implement turn. | ||
| 15 | // Used to detect newly-completed tasks for commit messages. | ||
| 16 | const planSnapshots = new Map<string, string>(); | ||
| 17 | |||
| 13 | function ensureDir(dirPath: string): void { | 18 | function ensureDir(dirPath: string): void { |
| 14 | if (!fs.existsSync(dirPath)) { | 19 | if (!fs.existsSync(dirPath)) { |
| 15 | fs.mkdirSync(dirPath, { recursive: true }); | 20 | fs.mkdirSync(dirPath, { recursive: true }); |
| @@ -52,7 +57,14 @@ export async function sendMessage({ | |||
| 52 | resume: session.claude_session_id ?? undefined, | 57 | resume: session.claude_session_id ?? undefined, |
| 53 | tools: phaseConfig.tools, | 58 | tools: phaseConfig.tools, |
| 54 | permissionMode: phaseConfig.permissionMode, | 59 | permissionMode: phaseConfig.permissionMode, |
| 60 | // Required companion flag when bypassPermissions is active | ||
| 61 | allowDangerouslySkipPermissions: phaseConfig.permissionMode === "bypassPermissions", | ||
| 55 | systemPrompt: phaseConfig.systemPrompt, | 62 | systemPrompt: phaseConfig.systemPrompt, |
| 63 | // Allow Claude to inspect git state during implementation without prompts. | ||
| 64 | // git add/commit intentionally omitted — the app handles those. | ||
| 65 | ...(session.phase === "implement" && { | ||
| 66 | allowedTools: ["Bash(git status*)", "Bash(git log*)", "Bash(git diff*)"], | ||
| 67 | }), | ||
| 56 | }, | 68 | }, |
| 57 | }); | 69 | }); |
| 58 | 70 | ||
| @@ -71,6 +83,20 @@ export async function sendMessage({ | |||
| 71 | } finally { | 83 | } finally { |
| 72 | activeQueries.delete(session.id); | 84 | activeQueries.delete(session.id); |
| 73 | } | 85 | } |
| 86 | |||
| 87 | // Auto-commit after a successful implement-phase turn. | ||
| 88 | // This runs only if for-await completed without throwing (successful turn). | ||
| 89 | // Interrupted / errored turns skip the commit. | ||
| 90 | if (session.phase === "implement") { | ||
| 91 | const previousPlan = planSnapshots.get(session.id) ?? ""; | ||
| 92 | const currentPlan = autoCommitTurn( | ||
| 93 | project.path, | ||
| 94 | session.git_branch, | ||
| 95 | previousPlan, | ||
| 96 | sessionDir | ||
| 97 | ); | ||
| 98 | planSnapshots.set(session.id, currentPlan); | ||
| 99 | } | ||
| 74 | } | 100 | } |
| 75 | 101 | ||
| 76 | export function interruptSession(sessionId: string): void { | 102 | 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 { | |||
| 20 | db.pragma("foreign_keys = ON"); | 20 | db.pragma("foreign_keys = ON"); |
| 21 | 21 | ||
| 22 | initSchema(db); | 22 | initSchema(db); |
| 23 | runMigrations(db); | ||
| 24 | |||
| 23 | return db; | 25 | return db; |
| 24 | } | 26 | } |
| 25 | 27 | ||
| 28 | function runMigrations(db: Database.Database): void { | ||
| 29 | // v2: add git_branch column to sessions | ||
| 30 | const cols = db.pragma("table_info(sessions)") as { name: string }[]; | ||
| 31 | if (!cols.find((c) => c.name === "git_branch")) { | ||
| 32 | db.exec("ALTER TABLE sessions ADD COLUMN git_branch TEXT"); | ||
| 33 | } | ||
| 34 | } | ||
| 35 | |||
| 26 | export function closeDb() { | 36 | export function closeDb() { |
| 27 | if (db) { | 37 | if (db) { |
| 28 | db.close(); | 38 | 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) { | |||
| 17 | phase TEXT NOT NULL DEFAULT 'research', | 17 | phase TEXT NOT NULL DEFAULT 'research', |
| 18 | claude_session_id TEXT, | 18 | claude_session_id TEXT, |
| 19 | permission_mode TEXT NOT NULL DEFAULT 'acceptEdits', | 19 | permission_mode TEXT NOT NULL DEFAULT 'acceptEdits', |
| 20 | git_branch TEXT, | ||
| 20 | created_at INTEGER NOT NULL DEFAULT (unixepoch()), | 21 | created_at INTEGER NOT NULL DEFAULT (unixepoch()), |
| 21 | updated_at INTEGER NOT NULL DEFAULT (unixepoch()) | 22 | updated_at INTEGER NOT NULL DEFAULT (unixepoch()) |
| 22 | ); | 23 | ); |
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 { | |||
| 11 | phase: Phase; | 11 | phase: Phase; |
| 12 | claude_session_id: string | null; | 12 | claude_session_id: string | null; |
| 13 | permission_mode: PermissionMode; | 13 | permission_mode: PermissionMode; |
| 14 | git_branch: string | null; | ||
| 14 | created_at: number; | 15 | created_at: number; |
| 15 | updated_at: number; | 16 | updated_at: number; |
| 16 | } | 17 | } |
| @@ -52,6 +53,7 @@ export function createSession(projectId: string, name: string): Session { | |||
| 52 | phase: "research", | 53 | phase: "research", |
| 53 | claude_session_id: null, | 54 | claude_session_id: null, |
| 54 | permission_mode: "acceptEdits", | 55 | permission_mode: "acceptEdits", |
| 56 | git_branch: null, | ||
| 55 | created_at: now, | 57 | created_at: now, |
| 56 | updated_at: now, | 58 | updated_at: now, |
| 57 | }; | 59 | }; |
| @@ -59,7 +61,7 @@ export function createSession(projectId: string, name: string): Session { | |||
| 59 | 61 | ||
| 60 | export function updateSession( | 62 | export function updateSession( |
| 61 | id: string, | 63 | id: string, |
| 62 | updates: Partial<Pick<Session, "name" | "phase" | "claude_session_id" | "permission_mode">> | 64 | updates: Partial<Pick<Session, "name" | "phase" | "claude_session_id" | "permission_mode" | "git_branch">> |
| 63 | ): void { | 65 | ): void { |
| 64 | const db = getDb(); | 66 | const db = getDb(); |
| 65 | const sets: string[] = []; | 67 | 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 @@ | |||
| 1 | import { execFileSync } from "node:child_process"; | ||
| 2 | import fs from "node:fs"; | ||
| 3 | import path from "node:path"; | ||
| 4 | |||
| 5 | // --------------------------------------------------------------------------- | ||
| 6 | // Helpers | ||
| 7 | // --------------------------------------------------------------------------- | ||
| 8 | |||
| 9 | export function slugify(name: string, maxLen = 20): string { | ||
| 10 | return name | ||
| 11 | .toLowerCase() | ||
| 12 | .replace(/[^a-z0-9]+/g, "-") | ||
| 13 | .replace(/^-|-$/g, "") | ||
| 14 | .slice(0, maxLen) | ||
| 15 | .replace(/-$/, ""); | ||
| 16 | } | ||
| 17 | |||
| 18 | /** | ||
| 19 | * Ensure `.claude-flow/` is present in the project's .gitignore. | ||
| 20 | * Creates the file if it doesn't exist. Idempotent. | ||
| 21 | */ | ||
| 22 | export function ensureGitIgnore(projectPath: string): void { | ||
| 23 | const gitignorePath = path.join(projectPath, ".gitignore"); | ||
| 24 | const entry = ".claude-flow/"; | ||
| 25 | |||
| 26 | if (fs.existsSync(gitignorePath)) { | ||
| 27 | const content = fs.readFileSync(gitignorePath, "utf-8"); | ||
| 28 | const lines = content.split("\n").map((l) => l.trim()); | ||
| 29 | if (!lines.includes(entry)) { | ||
| 30 | const suffix = content.endsWith("\n") ? "" : "\n"; | ||
| 31 | fs.appendFileSync(gitignorePath, `${suffix}${entry}\n`, "utf-8"); | ||
| 32 | } | ||
| 33 | } else { | ||
| 34 | fs.writeFileSync(gitignorePath, `${entry}\n`, "utf-8"); | ||
| 35 | } | ||
| 36 | } | ||
| 37 | |||
| 38 | function isGitRepo(projectPath: string): boolean { | ||
| 39 | try { | ||
| 40 | execFileSync("git", ["rev-parse", "--is-inside-work-tree"], { | ||
| 41 | cwd: projectPath, | ||
| 42 | stdio: "pipe", | ||
| 43 | }); | ||
| 44 | return true; | ||
| 45 | } catch { | ||
| 46 | return false; | ||
| 47 | } | ||
| 48 | } | ||
| 49 | |||
| 50 | /** | ||
| 51 | * Initialize a git repo if one doesn't already exist. | ||
| 52 | * Does not create an initial commit — git checkout -b works on unborn branches. | ||
| 53 | */ | ||
| 54 | export function ensureGitRepo(projectPath: string): void { | ||
| 55 | if (!isGitRepo(projectPath)) { | ||
| 56 | execFileSync("git", ["init"], { cwd: projectPath, stdio: "pipe" }); | ||
| 57 | } | ||
| 58 | } | ||
| 59 | |||
| 60 | // --------------------------------------------------------------------------- | ||
| 61 | // Branch creation | ||
| 62 | // --------------------------------------------------------------------------- | ||
| 63 | |||
| 64 | /** | ||
| 65 | * Ensure .gitignore is set up, init git if needed, then create and checkout | ||
| 66 | * a new branch named `claude-flow/<slug>-<shortId>`. | ||
| 67 | * Returns the branch name on success, null if git is unavailable or fails. | ||
| 68 | */ | ||
| 69 | export function createSessionBranch( | ||
| 70 | projectPath: string, | ||
| 71 | sessionName: string, | ||
| 72 | sessionId: string | ||
| 73 | ): string | null { | ||
| 74 | try { | ||
| 75 | ensureGitIgnore(projectPath); | ||
| 76 | ensureGitRepo(projectPath); | ||
| 77 | const branchName = `claude-flow/${slugify(sessionName)}-${sessionId.slice(0, 8)}`; | ||
| 78 | execFileSync("git", ["checkout", "-b", branchName], { | ||
| 79 | cwd: projectPath, | ||
| 80 | stdio: "pipe", | ||
| 81 | }); | ||
| 82 | return branchName; | ||
| 83 | } catch { | ||
| 84 | return null; | ||
| 85 | } | ||
| 86 | } | ||
| 87 | |||
| 88 | // --------------------------------------------------------------------------- | ||
| 89 | // Safe staging | ||
| 90 | // --------------------------------------------------------------------------- | ||
| 91 | |||
| 92 | /** | ||
| 93 | * Stage everything with `git add -A`, then explicitly un-stage `.claude-flow/` | ||
| 94 | * regardless of whether it is gitignored or previously tracked. | ||
| 95 | * This is belt-and-suspenders: .gitignore is the first line of defense, this | ||
| 96 | * explicit un-stage is the second guarantee. | ||
| 97 | */ | ||
| 98 | function stageWithSafety(projectPath: string): void { | ||
| 99 | execFileSync("git", ["add", "-A"], { cwd: projectPath }); | ||
| 100 | // Explicit un-stage of .claude-flow/ — protects against previously-tracked dirs | ||
| 101 | try { | ||
| 102 | // git restore --staged available since git 2.23 (2019) | ||
| 103 | execFileSync("git", ["restore", "--staged", "--", ".claude-flow/"], { | ||
| 104 | cwd: projectPath, | ||
| 105 | stdio: "pipe", | ||
| 106 | }); | ||
| 107 | } catch { | ||
| 108 | // Fallback for older git | ||
| 109 | try { | ||
| 110 | execFileSync("git", ["reset", "HEAD", "--", ".claude-flow/"], { | ||
| 111 | cwd: projectPath, | ||
| 112 | stdio: "pipe", | ||
| 113 | }); | ||
| 114 | } catch { /* nothing staged there — ignore */ } | ||
| 115 | } | ||
| 116 | } | ||
| 117 | |||
| 118 | function hasAnythingStaged(projectPath: string): boolean { | ||
| 119 | try { | ||
| 120 | // exit 0 = index matches HEAD (nothing staged) | ||
| 121 | execFileSync("git", ["diff", "--cached", "--quiet"], { | ||
| 122 | cwd: projectPath, | ||
| 123 | stdio: "pipe", | ||
| 124 | }); | ||
| 125 | return false; | ||
| 126 | } catch { | ||
| 127 | // exit 1 = differences exist = something is staged | ||
| 128 | return true; | ||
| 129 | } | ||
| 130 | } | ||
| 131 | |||
| 132 | // --------------------------------------------------------------------------- | ||
| 133 | // Checkbox parsing | ||
| 134 | // --------------------------------------------------------------------------- | ||
| 135 | |||
| 136 | function findNewlyCompletedTasks(before: string, after: string): string[] { | ||
| 137 | const checked = (s: string): Set<string> => | ||
| 138 | new Set(s.split("\n").filter((l) => /^\s*-\s*\[[xX]\]/.test(l))); | ||
| 139 | const beforeChecked = checked(before); | ||
| 140 | const afterChecked = checked(after); | ||
| 141 | return [...afterChecked].filter((l) => !beforeChecked.has(l)); | ||
| 142 | } | ||
| 143 | |||
| 144 | function extractTaskName(checkboxLine: string): string { | ||
| 145 | return checkboxLine.replace(/^\s*-\s*\[[xX]\]\s*/, "").trim(); | ||
| 146 | } | ||
| 147 | |||
| 148 | // --------------------------------------------------------------------------- | ||
| 149 | // Auto-commit | ||
| 150 | // --------------------------------------------------------------------------- | ||
| 151 | |||
| 152 | /** | ||
| 153 | * Called after each implement-phase Claude turn completes. | ||
| 154 | * | ||
| 155 | * - Reads current plan.md from sessionDir | ||
| 156 | * - Diffs against previousPlan to detect newly-checked tasks | ||
| 157 | * - Stages all project changes (safely, excluding .claude-flow/) | ||
| 158 | * - Commits if anything was staged, with a message summarising completed tasks | ||
| 159 | * - Returns the current plan.md content so the caller can update its snapshot | ||
| 160 | * | ||
| 161 | * The snapshot is always returned (and should always be updated by the caller) | ||
| 162 | * regardless of whether a commit was made, to prevent double-counting tasks. | ||
| 163 | */ | ||
| 164 | export function autoCommitTurn( | ||
| 165 | projectPath: string, | ||
| 166 | gitBranch: string | null, | ||
| 167 | previousPlan: string, | ||
| 168 | sessionDir: string | ||
| 169 | ): string { | ||
| 170 | const planPath = path.join(sessionDir, "plan.md"); | ||
| 171 | const currentPlan = fs.existsSync(planPath) | ||
| 172 | ? fs.readFileSync(planPath, "utf-8") | ||
| 173 | : ""; | ||
| 174 | |||
| 175 | // Always return currentPlan so caller can update snapshot, even if no git | ||
| 176 | if (!gitBranch) return currentPlan; | ||
| 177 | |||
| 178 | const newlyCompleted = findNewlyCompletedTasks(previousPlan, currentPlan); | ||
| 179 | |||
| 180 | try { | ||
| 181 | stageWithSafety(projectPath); | ||
| 182 | |||
| 183 | if (!hasAnythingStaged(projectPath)) return currentPlan; | ||
| 184 | |||
| 185 | let commitMsg: string; | ||
| 186 | if (newlyCompleted.length > 0) { | ||
| 187 | const count = newlyCompleted.length; | ||
| 188 | const taskLines = newlyCompleted | ||
| 189 | .map((l) => `- ✅ ${extractTaskName(l)}`) | ||
| 190 | .join("\n"); | ||
| 191 | commitMsg = `feat: Complete ${count} task${count > 1 ? "s" : ""}\n\n${taskLines}`; | ||
| 192 | } else { | ||
| 193 | commitMsg = "chore: Implement progress (no tasks completed this turn)"; | ||
| 194 | } | ||
| 195 | |||
| 196 | execFileSync("git", ["commit", "-m", commitMsg], { cwd: projectPath }); | ||
| 197 | } catch { | ||
| 198 | // Non-fatal — git may not be configured, nothing to commit, etc. | ||
| 199 | } | ||
| 200 | |||
| 201 | return currentPlan; | ||
| 202 | } | ||
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"; | |||
| 2 | import * as projects from "../db/projects"; | 2 | import * as projects from "../db/projects"; |
| 3 | import * as sessions from "../db/sessions"; | 3 | import * as sessions from "../db/sessions"; |
| 4 | import * as claude from "../claude"; | 4 | import * as claude from "../claude"; |
| 5 | import { createSessionBranch } from "../git"; | ||
| 5 | import type { UserPermissionMode } from "../claude/phases"; | 6 | import type { UserPermissionMode } from "../claude/phases"; |
| 6 | 7 | ||
| 7 | export function registerIpcHandlers(mainWindow: BrowserWindow): void { | 8 | export function registerIpcHandlers(mainWindow: BrowserWindow): void { |
| @@ -19,9 +20,19 @@ export function registerIpcHandlers(mainWindow: BrowserWindow): void { | |||
| 19 | sessions.listSessions(projectId) | 20 | sessions.listSessions(projectId) |
| 20 | ); | 21 | ); |
| 21 | 22 | ||
| 22 | ipcMain.handle("sessions:create", (_, projectId: string, name: string) => | 23 | ipcMain.handle("sessions:create", (_, projectId: string, name: string) => { |
| 23 | sessions.createSession(projectId, name) | 24 | const project = projects.getProject(projectId); |
| 24 | ); | 25 | if (!project) throw new Error("Project not found"); |
| 26 | |||
| 27 | const session = sessions.createSession(projectId, name); | ||
| 28 | |||
| 29 | const branchName = createSessionBranch(project.path, session.name, session.id); | ||
| 30 | if (branchName) { | ||
| 31 | sessions.updateSession(session.id, { git_branch: branchName }); | ||
| 32 | } | ||
| 33 | |||
| 34 | return { ...session, git_branch: branchName ?? null }; | ||
| 35 | }); | ||
| 25 | 36 | ||
| 26 | ipcMain.handle("sessions:delete", (_, id: string) => { | 37 | ipcMain.handle("sessions:delete", (_, id: string) => { |
| 27 | const session = sessions.getSession(id); | 38 | const session = sessions.getSession(id); |
