aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorbndw <ben@bdw.to>2026-02-28 20:07:05 -0800
committerbndw <ben@bdw.to>2026-02-28 20:07:05 -0800
commit9d192d16b7a4026b35ad2bcaff9edb9f2670de2b (patch)
tree2603dd04c3567074e84be271c448ede02ee7097d /src
parent283013c09d4855529e846951a1e090f0f16030a8 (diff)
feat: git branches
Diffstat (limited to 'src')
-rw-r--r--src/main/claude/index.ts26
-rw-r--r--src/main/db/index.ts10
-rw-r--r--src/main/db/schema.ts1
-rw-r--r--src/main/db/sessions.ts4
-rw-r--r--src/main/git.ts202
-rw-r--r--src/main/ipc/handlers.ts17
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";
4import type { Phase, UserPermissionMode } from "./phases"; 4import type { Phase, UserPermissionMode } from "./phases";
5import { getProject } from "../db/projects"; 5import { getProject } from "../db/projects";
6import { updateSession } from "../db/sessions"; 6import { updateSession } from "../db/sessions";
7import { autoCommitTurn } from "../git";
7import fs from "node:fs"; 8import fs from "node:fs";
8import path from "node:path"; 9import path from "node:path";
9 10
10// Track active queries by session ID 11// Track active queries by session ID
11const activeQueries = new Map<string, Query>(); 12const 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.
16const planSnapshots = new Map<string, string>();
17
13function ensureDir(dirPath: string): void { 18function 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
76export function interruptSession(sessionId: string): void { 102export 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
28function 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
26export function closeDb() { 36export 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
60export function updateSession( 62export 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 @@
1import { execFileSync } from "node:child_process";
2import fs from "node:fs";
3import path from "node:path";
4
5// ---------------------------------------------------------------------------
6// Helpers
7// ---------------------------------------------------------------------------
8
9export 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 */
22export 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
38function 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 */
54export 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 */
69export 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 */
98function 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
118function 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
136function 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
144function 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 */
164export 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";
2import * as projects from "../db/projects"; 2import * as projects from "../db/projects";
3import * as sessions from "../db/sessions"; 3import * as sessions from "../db/sessions";
4import * as claude from "../claude"; 4import * as claude from "../claude";
5import { createSessionBranch } from "../git";
5import type { UserPermissionMode } from "../claude/phases"; 6import type { UserPermissionMode } from "../claude/phases";
6 7
7export function registerIpcHandlers(mainWindow: BrowserWindow): void { 8export 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);