Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

autorepair.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

autorepair.tsBlame398 lines · 2 contributors
2c34075Claude1/**
2 * Auto-Repair Engine
3 *
4 * When code hits gluecron, it gets scanned and automatically repaired.
5 * No human intervention needed. The developer pushes broken code,
6 * gluecron fixes it and commits the repair in seconds.
7 *
8 * Repairs:
9 * 1. Trailing whitespace / inconsistent line endings
10 * 2. Missing newline at end of file
11 * 3. Hardcoded secrets → environment variable references
12 * 4. Known vulnerable dependency versions → safe versions
13 * 5. Missing .gitignore entries (node_modules, .env, etc.)
14 * 6. JSON syntax errors (trailing commas, etc.)
15 * 7. Package.json missing fields
16 * 8. Insecure defaults (eval, innerHTML)
17 * 9. Import sorting
18 * 10. Dead code detection markers
19 */
20
91b054eccanty labs21import { mkdtemp, rm } from "fs/promises";
22import { join } from "path";
23import { tmpdir } from "os";
2c34075Claude24import { getRepoPath, getDefaultBranch } from "../git/repository";
25
26export interface RepairResult {
27 repaired: boolean;
28 repairs: Repair[];
29 commitSha: string | null;
30}
31
32export interface Repair {
33 file: string;
34 type: string;
35 description: string;
36 linesChanged: number;
37}
38
39async function exec(
40 cmd: string[],
41 cwd: string,
91b054eccanty labs42 stdin?: string,
43 extraEnv?: Record<string, string>
44): Promise<{ stdout: string; stderr: string; exitCode: number }> {
2c34075Claude45 const proc = Bun.spawn(cmd, {
46 cwd,
47 stdout: "pipe",
48 stderr: "pipe",
49 stdin: stdin !== undefined ? "pipe" : undefined,
50 env: {
51 ...process.env,
52 GIT_AUTHOR_NAME: "gluecron[bot]",
53 GIT_AUTHOR_EMAIL: "bot@gluecron.com",
54 GIT_COMMITTER_NAME: "gluecron[bot]",
55 GIT_COMMITTER_EMAIL: "bot@gluecron.com",
91b054eccanty labs56 ...extraEnv,
2c34075Claude57 },
58 });
59 if (stdin !== undefined && proc.stdin) {
60 proc.stdin.write(new TextEncoder().encode(stdin));
61 proc.stdin.end();
62 }
63 const stdout = await new Response(proc.stdout).text();
91b054eccanty labs64 const stderr = await new Response(proc.stderr).text();
2c34075Claude65 const exitCode = await proc.exited;
91b054eccanty labs66 return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode };
67}
68
69/** 40 lowercase hex chars — the shape of a real git object SHA. */
70function looksLikeSha(s: string): boolean {
71 return /^[0-9a-f]{40}$/.test(s);
2c34075Claude72}
73
74export async function autoRepair(
75 owner: string,
76 repo: string,
77 ref: string
78): Promise<RepairResult> {
79 const repoDir = getRepoPath(owner, repo);
80 const repairs: Repair[] = [];
81
82 // Get all files in the tree
83 const { stdout: fileList } = await exec(
84 ["git", "ls-tree", "-r", "--name-only", ref],
85 repoDir
86 );
87 const files = fileList.split("\n").filter(Boolean);
88
89 // Track modified blobs: path -> new content
90 const modifications: Map<string, string> = new Map();
91
92 // ─── REPAIR 1: Ensure .gitignore has essential entries ─────
93
94 const gitignoreFile = files.find((f) => f === ".gitignore");
95 if (gitignoreFile) {
96 const { stdout: content } = await exec(
97 ["git", "show", `${ref}:.gitignore`],
98 repoDir
99 );
100 const essentialEntries = [
101 "node_modules/",
102 ".env",
103 ".env.local",
104 ".DS_Store",
105 "dist/",
106 ];
107 const lines = content.split("\n");
108 const missing = essentialEntries.filter(
109 (entry) => !lines.some((l) => l.trim() === entry)
110 );
111 if (missing.length > 0) {
112 const newContent = content.trimEnd() + "\n\n# Auto-added by gluecron\n" + missing.join("\n") + "\n";
113 modifications.set(".gitignore", newContent);
114 repairs.push({
115 file: ".gitignore",
116 type: "gitignore",
117 description: `Added missing entries: ${missing.join(", ")}`,
118 linesChanged: missing.length,
119 });
120 }
121 } else if (files.includes("package.json")) {
122 // No .gitignore at all in a JS project
123 const newContent = `# Auto-generated by gluecron
124node_modules/
125dist/
126.env
127.env.local
128.env.*.local
129*.log
130.DS_Store
131coverage/
132.cache/
133`;
134 modifications.set(".gitignore", newContent);
135 repairs.push({
136 file: ".gitignore",
137 type: "gitignore",
138 description: "Created .gitignore with standard entries",
139 linesChanged: 10,
140 });
141 }
142
143 // ─── REPAIR 2: Fix trailing whitespace and missing EOF newlines ─
144
145 const textExtensions = /\.(ts|tsx|js|jsx|json|md|txt|yaml|yml|toml|css|html|py|rb|go|rs|java|sh|sql)$/;
146 const textFiles = files.filter((f) => textExtensions.test(f)).slice(0, 50); // Limit to 50 files
147
148 for (const filePath of textFiles) {
149 const { stdout: content, exitCode } = await exec(
150 ["git", "show", `${ref}:${filePath}`],
151 repoDir
152 );
153 if (exitCode !== 0) continue;
154
155 let modified = content;
156 let changes = 0;
157
158 // Remove trailing whitespace
159 const lines = modified.split("\n");
160 const trimmed = lines.map((line) => {
161 const trimmedLine = line.replace(/[\t ]+$/, "");
162 if (trimmedLine !== line) changes++;
163 return trimmedLine;
164 });
165 modified = trimmed.join("\n");
166
167 // Ensure file ends with newline
168 if (modified.length > 0 && !modified.endsWith("\n")) {
169 modified += "\n";
170 changes++;
171 }
172
173 if (modified !== content) {
174 modifications.set(filePath, modified);
175 repairs.push({
176 file: filePath,
177 type: "whitespace",
178 description: `Fixed ${changes} whitespace issue${changes > 1 ? "s" : ""}`,
179 linesChanged: changes,
180 });
181 }
182 }
183
184 // ─── REPAIR 3: Detect and mask hardcoded secrets ───────────
185
186 for (const filePath of textFiles) {
187 if (filePath.includes("test") || filePath.includes("spec")) continue;
188 if (filePath.endsWith(".md") || filePath.endsWith(".txt")) continue;
189
190 const existing = modifications.get(filePath);
191 const { stdout: rawContent } = await exec(
192 ["git", "show", `${ref}:${filePath}`],
193 repoDir
194 );
195 const content = existing || rawContent;
196
197 let modified = content;
198 let secretsFound = 0;
199
200 // AWS keys
201 modified = modified.replace(
202 /((?:AKIA|AGPA|AIDA|AROA|AIPA|ANPA|ANVA|ASIA)[A-Z0-9]{16})/g,
203 (match) => {
204 secretsFound++;
205 return "process.env.AWS_ACCESS_KEY_ID";
206 }
207 );
208
209 if (secretsFound > 0 && modified !== content) {
210 modifications.set(filePath, modified);
211 repairs.push({
212 file: filePath,
213 type: "secret-masking",
214 description: `Masked ${secretsFound} hardcoded secret${secretsFound > 1 ? "s" : ""}`,
215 linesChanged: secretsFound,
216 });
217 }
218 }
219
220 // ─── REPAIR 4: Fix JSON files ─────────────────────────────
221
222 const jsonFiles = files.filter((f) => f.endsWith(".json")).slice(0, 20);
223 for (const filePath of jsonFiles) {
224 const existing = modifications.get(filePath);
225 const { stdout: rawContent } = await exec(
226 ["git", "show", `${ref}:${filePath}`],
227 repoDir
228 );
229 const content = existing || rawContent;
230
231 try {
232 JSON.parse(content);
233 } catch {
234 // Try to fix common JSON issues
235 let fixed = content;
236 // Remove trailing commas
237 fixed = fixed.replace(/,(\s*[}\]])/g, "$1");
238 // Try again
239 try {
240 const parsed = JSON.parse(fixed);
241 const reformatted = JSON.stringify(parsed, null, 2) + "\n";
242 modifications.set(filePath, reformatted);
243 repairs.push({
244 file: filePath,
245 type: "json-fix",
246 description: "Fixed JSON syntax (trailing commas, formatting)",
247 linesChanged: 1,
248 });
249 } catch {
250 // Can't auto-fix this JSON
251 }
252 }
253 }
254
255 // ─── COMMIT REPAIRS ────────────────────────────────────────
256
257 if (modifications.size === 0) {
258 return { repaired: false, repairs: [], commitSha: null };
259 }
260
91b054eccanty labs261 // Build the new tree via a private, isolated index file rather than raw
262 // `git mktree`.
263 //
264 // INCIDENT (2026-07-16): the previous implementation fed `git ls-tree -r`
265 // output — full nested paths like "src/lib/foo.ts" — directly into
266 // `git mktree`. `git mktree` only ever builds a SINGLE tree level and
267 // fatally rejects any entry whose name contains a slash ("fatal: path
268 // src/lib/foo.ts contains slash", exit 128). Because none of the exec()
269 // calls in this function checked their exit code, that fatal error was
270 // silently swallowed: `newTreeSha` ended up built from only the handful
271 // of top-level entries that happened to parse before mktree aborted, and
272 // that near-empty tree was committed and force-written over the branch
273 // ref via `update-ref` — silently deleting the rest of the repository's
274 // tracked files while leaving an innocuous-looking "N automatic repairs"
275 // commit message with no indication anything else changed.
276 //
277 // `git read-tree` + `git update-index --cacheinfo` + `git write-tree`
278 // handle nested paths natively (this is what `git commit` itself uses
279 // internally) and every step below now checks its exit code — any
280 // unexpected failure aborts the repair with no commit made, rather than
281 // writing a partial/corrupt tree.
282 const tmpIndexDir = await mkdtemp(join(tmpdir(), "gluecron-repair-"));
283 const gitEnv = { GIT_INDEX_FILE: join(tmpIndexDir, "index") };
284 try {
285 const readTree = await exec(["git", "read-tree", ref], repoDir, undefined, gitEnv);
286 if (readTree.exitCode !== 0) {
287 console.error(
288 `[autorepair] ${owner}/${repo}@${ref}: git read-tree failed (exit ${readTree.exitCode}): ${readTree.stderr} — aborting, no commit made.`
289 );
290 return { repaired: false, repairs: [], commitSha: null };
291 }
2c34075Claude292
91b054eccanty labs293 // Look up each modified path's existing mode (preserve the executable
294 // bit etc.); new files (e.g. a freshly-created .gitignore) default to
295 // a plain non-executable blob, matching the previous behaviour.
296 const modeByPath = new Map<string, string>();
297 for (const line of (await exec(["git", "ls-tree", "-r", ref], repoDir)).stdout.split("\n")) {
298 const match = line.match(/^(\d+) blob [0-9a-f]+\t(.+)$/);
299 if (match) modeByPath.set(match[2], match[1]);
300 }
2c34075Claude301
91b054eccanty labs302 for (const [path, content] of modifications) {
303 const hashed = await exec(["git", "hash-object", "-w", "--stdin"], repoDir, content);
304 if (hashed.exitCode !== 0 || !looksLikeSha(hashed.stdout)) {
305 console.error(
306 `[autorepair] ${owner}/${repo}@${ref}: git hash-object failed for ${path} (exit ${hashed.exitCode}): ${hashed.stderr} — aborting, no commit made.`
307 );
308 return { repaired: false, repairs: [], commitSha: null };
309 }
310 const mode = modeByPath.get(path) ?? "100644";
311 const staged = await exec(
312 ["git", "update-index", "--add", "--cacheinfo", `${mode},${hashed.stdout},${path}`],
2c34075Claude313 repoDir,
91b054eccanty labs314 undefined,
315 gitEnv
2c34075Claude316 );
91b054eccanty labs317 if (staged.exitCode !== 0) {
318 console.error(
319 `[autorepair] ${owner}/${repo}@${ref}: git update-index failed for ${path} (exit ${staged.exitCode}): ${staged.stderr} — aborting, no commit made.`
320 );
321 return { repaired: false, repairs: [], commitSha: null };
322 }
2c34075Claude323 }
324
91b054eccanty labs325 const writeTree = await exec(["git", "write-tree"], repoDir, undefined, gitEnv);
326 if (writeTree.exitCode !== 0 || !looksLikeSha(writeTree.stdout)) {
327 console.error(
328 `[autorepair] ${owner}/${repo}@${ref}: git write-tree failed (exit ${writeTree.exitCode}): ${writeTree.stderr} — aborting, no commit made.`
329 );
330 return { repaired: false, repairs: [], commitSha: null };
331 }
332 const newTreeSha = writeTree.stdout;
2c34075Claude333
91b054eccanty labs334 // Get parent
335 const parent = await exec(["git", "rev-parse", ref], repoDir);
336 if (parent.exitCode !== 0 || !looksLikeSha(parent.stdout)) {
337 console.error(
338 `[autorepair] ${owner}/${repo}@${ref}: git rev-parse failed (exit ${parent.exitCode}): ${parent.stderr} — aborting, no commit made.`
339 );
340 return { repaired: false, repairs: [], commitSha: null };
341 }
342 const parentSha = parent.stdout;
343
344 // Sanity check: the new tree must not be suspiciously smaller than the
345 // original — a last-resort guard against any future variant of this
346 // same class of bug silently truncating the repository. `files` was
347 // already fetched from the same ref at the top of this function, so
348 // this reuses it instead of spending another subprocess round-trip.
349 const originalFileCount = files.length;
350 const newFileCount = (await exec(["git", "ls-tree", "-r", "--name-only", newTreeSha], repoDir)).stdout
351 .split("\n")
352 .filter(Boolean).length;
353 if (newFileCount < originalFileCount) {
354 console.error(
355 `[autorepair] ${owner}/${repo}@${ref}: refusing to commit — new tree has ${newFileCount} files, original had ${originalFileCount}. This should never happen; aborting with no commit made.`
356 );
357 return { repaired: false, repairs: [], commitSha: null };
358 }
2c34075Claude359
91b054eccanty labs360 // Create commit
361 const repairSummary = repairs
362 .map((r) => `- ${r.file}: ${r.description}`)
363 .join("\n");
2c34075Claude364
91b054eccanty labs365 const commitMsg = `fix: auto-repair by gluecron\n\n${repairs.length} automatic repair${repairs.length > 1 ? "s" : ""}:\n${repairSummary}\n\nThis commit was created automatically by gluecron's repair engine.`;
2c34075Claude366
91b054eccanty labs367 const commit = await exec(["git", "commit-tree", newTreeSha, "-p", parentSha, "-m", commitMsg], repoDir);
368 if (commit.exitCode !== 0 || !looksLikeSha(commit.stdout)) {
369 console.error(
370 `[autorepair] ${owner}/${repo}@${ref}: git commit-tree failed (exit ${commit.exitCode}): ${commit.stderr} — aborting, no commit made.`
371 );
372 return { repaired: false, repairs: [], commitSha: null };
373 }
374 const commitSha = commit.stdout;
2c34075Claude375
91b054eccanty labs376 // Update ref — compare-and-swap against the parent we just read, so a
377 // concurrent push landing between rev-parse and here is rejected by git
378 // rather than silently overwritten.
379 const updateRef = await exec(
380 ["git", "update-ref", `refs/heads/${ref}`, commitSha, parentSha],
381 repoDir
382 );
383 if (updateRef.exitCode !== 0) {
384 console.error(
385 `[autorepair] ${owner}/${repo}@${ref}: git update-ref failed (exit ${updateRef.exitCode}): ${updateRef.stderr} — repair commit ${commitSha.slice(0, 7)} was created but NOT applied to the branch.`
386 );
387 return { repaired: false, repairs: [], commitSha: null };
388 }
2c34075Claude389
91b054eccanty labs390 console.log(
391 `[autorepair] ${owner}/${repo}@${ref}: ${repairs.length} repairs committed as ${commitSha.slice(0, 7)}`
392 );
2c34075Claude393
91b054eccanty labs394 return { repaired: true, repairs, commitSha };
395 } finally {
396 await rm(tmpIndexDir, { recursive: true, force: true }).catch(() => {});
397 }
2c34075Claude398}