aboutsummaryrefslogtreecommitdiffstats
path: root/src/main/git.ts
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/main/git.ts
parent283013c09d4855529e846951a1e090f0f16030a8 (diff)
feat: git branches
Diffstat (limited to 'src/main/git.ts')
-rw-r--r--src/main/git.ts202
1 files changed, 202 insertions, 0 deletions
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}