diff options
Diffstat (limited to 'src/main/git.ts')
| -rw-r--r-- | src/main/git.ts | 202 |
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 @@ | |||
| 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 | } | ||
