1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
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/<slug>-<shortId>`.
* 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<string> =>
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;
}
|