Last active
November 6, 2025 15:18
-
-
Save colelawrence/8353df1a62ea4964c0408221f70d2fa9 to your computer and use it in GitHub Desktop.
Non-destructive git fixup committer - associates changed files with their last most recent commit and creates commit groups for fixup
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bun | |
| /// <reference types="bun" /> | |
| import { existsSync } from "node:fs"; | |
| import { writeFile } from "node:fs/promises"; | |
| import { basename, extname } from "node:path"; | |
| interface Options { | |
| /** e.g. `false` */ | |
| interactive: boolean; | |
| /** e.g. `100` */ | |
| depth: number; | |
| /** Enable new file reference detection, e.g. `true` */ | |
| semanticDetection: boolean; | |
| /** Enable experimental identifier/string rename detection, e.g. `false` */ | |
| unstableSemanticDetection: boolean; | |
| semanticThresholds: { | |
| /** e.g. `3` */ | |
| minReplacements: number; | |
| /** e.g. `2` */ | |
| minFiles: number; | |
| /** e.g. `5` */ | |
| minReplacementsInOneFile: number; | |
| }; | |
| /** e.g. `"fixup-plan.sh"` or `undefined` */ | |
| writeScript?: string; | |
| /** e.g. `false` */ | |
| verbose: boolean; | |
| /** e.g. `["src/file1.ts", "src/file2.ts"]` */ | |
| specificFiles: string[]; | |
| /** e.g. `["glob/*.test.ts"]` */ | |
| excludePatterns: string[]; | |
| /** Show Jujutsu command alternatives, e.g. `false` */ | |
| showJj: boolean; | |
| } | |
| interface SemanticGroup { | |
| /** e.g. `"identifier"` or `"new-file-references"` */ | |
| type: "identifier" | "string" | "file-rename" | "new-file-references"; | |
| /** e.g. `"UserSession"` or `""` for new-file-references */ | |
| from: string; | |
| /** e.g. `"Session"` or `"tools/new-file.ts"` for new-file-references */ | |
| to: string; | |
| /** Map of file path to occurrence count, e.g. `Map { "src/auth.ts" => 5 }` */ | |
| replacements: Map<string, number>; | |
| /** e.g. `21` */ | |
| totalReplacements: number; | |
| /** Hunks with both semantic + other changes, e.g. `3` */ | |
| mixedHunks: number; | |
| hunks: DiffHunk[]; | |
| /** For new-file-references type, e.g. `["tools/new-file.ts"]` */ | |
| newFiles?: string[]; | |
| } | |
| interface DiffHunk { | |
| /** e.g. `"src/auth/session.ts"` */ | |
| file: string; | |
| /** e.g. `42` */ | |
| startLine: number; | |
| /** e.g. `5` */ | |
| lineCount: number; | |
| /** e.g. `"@@ -42,5 +42,5 @@\n-old line\n+new line"` */ | |
| content: string; | |
| /** e.g. `false` */ | |
| hasMixedChanges: boolean; | |
| } | |
| interface FileRename { | |
| /** e.g. `"src/utils/date.ts"` */ | |
| from: string; | |
| /** e.g. `"src/utils/datetime.ts"` */ | |
| to: string; | |
| /** Files that have import updates, e.g. `["src/auth.ts", "src/app.tsx"]` */ | |
| importUpdates: string[]; | |
| } | |
| interface FixupGroup { | |
| /** e.g. `"abc1234"` (short hash) */ | |
| commit: string; | |
| /** e.g. `"Add user authentication"` */ | |
| message: string; | |
| /** e.g. `["src/auth/login.ts", "src/auth/session.ts"]` */ | |
| files: string[]; | |
| /** Unix timestamp for sorting, e.g. `1726027925` */ | |
| timestamp: number; | |
| } | |
| interface ChangedFile { | |
| /** e.g. `"src/auth/login.ts"` */ | |
| path: string; | |
| /** e.g. `"modified"` or `"added"` */ | |
| status: "modified" | "added" | "deleted" | "renamed"; | |
| /** For renamed files, e.g. `"src/auth/old-login.ts"` */ | |
| oldPath?: string; | |
| } | |
| class GitFixupFormatter { | |
| private options: Options; | |
| private changedFiles: ChangedFile[] = []; | |
| private semanticGroups: SemanticGroup[] = []; | |
| private fileRenames: FileRename[] = []; | |
| private fixupGroups: FixupGroup[] = []; | |
| private commands: string[] = []; | |
| constructor(options: Options) { | |
| this.options = options; | |
| } | |
| async run(): Promise<void> { | |
| try { | |
| // Detect changed files | |
| await this.detectChangedFiles(); | |
| if (this.changedFiles.length === 0) { | |
| console.log("No changed files found."); | |
| process.exit(0); | |
| } | |
| console.log(`Found ${this.changedFiles.length} changed files\n`); | |
| // Detect semantic changes if enabled | |
| if (this.options.semanticDetection) { | |
| await this.detectSemanticChanges(); | |
| } | |
| // Group remaining files by commit for fixups | |
| await this.groupByCommit(); | |
| // Generate commands | |
| this.generateCommands(); | |
| // Output the plan | |
| this.outputPlan(); | |
| // Write recovery script | |
| await this.writeRecoveryScript(); | |
| // Execute if interactive | |
| if (this.options.interactive) { | |
| await this.executeInteractive(); | |
| } | |
| } catch (error) { | |
| console.error(`Error: ${error}`); | |
| await this.writeRecoveryScript(); | |
| process.exit(1); | |
| } | |
| } | |
| private async detectChangedFiles(): Promise<void> { | |
| const { stdout, exitCode } = await Bun.spawn(["git", "diff", "--name-status", "-M", "HEAD"], { | |
| stdout: "pipe", | |
| }).exited.then(async (code) => { | |
| return { | |
| exitCode: code, | |
| stdout: await new Response( | |
| Bun.spawn(["git", "diff", "--name-status", "-M", "HEAD"], { stdout: "pipe" }).stdout, | |
| ).text(), | |
| }; | |
| }); | |
| if (exitCode !== 0) { | |
| throw new Error("Failed to get git diff"); | |
| } | |
| const lines = stdout | |
| .trim() | |
| .split("\n") | |
| .filter((l) => l); | |
| for (const line of lines) { | |
| const parts = line.split("\t"); | |
| const status = parts[0]; | |
| if (status.startsWith("R")) { | |
| // Renamed file | |
| this.changedFiles.push({ | |
| path: parts[2], | |
| status: "renamed", | |
| oldPath: parts[1], | |
| }); | |
| } else if (status === "M") { | |
| this.changedFiles.push({ path: parts[1], status: "modified" }); | |
| } else if (status === "A") { | |
| this.changedFiles.push({ path: parts[1], status: "added" }); | |
| } else if (status === "D") { | |
| this.changedFiles.push({ path: parts[1], status: "deleted" }); | |
| } | |
| } | |
| // Filter by specific files if provided | |
| if (this.options.specificFiles.length > 0) { | |
| this.changedFiles = this.changedFiles.filter((f) => this.options.specificFiles.includes(f.path)); | |
| } | |
| // Filter by exclude patterns | |
| if (this.options.excludePatterns.length > 0) { | |
| this.changedFiles = this.changedFiles.filter( | |
| (f) => !this.options.excludePatterns.some((pattern) => this.matchesPattern(f.path, pattern)), | |
| ); | |
| } | |
| } | |
| private matchesPattern(path: string, pattern: string): boolean { | |
| // Simple glob matching | |
| const regex = new RegExp("^" + pattern.replace(/\*/g, ".*").replace(/\?/g, ".") + "$"); | |
| return regex.test(path); | |
| } | |
| private escapePath(path: string): string { | |
| // Escape single quotes by replacing ' with '\'' | |
| return this.escapeSingleQuotes(path); | |
| } | |
| private async detectSemanticChanges(): Promise<void> { | |
| const semanticGroups: SemanticGroup[] = []; | |
| // Always detect new file references (stable feature) | |
| const newFileReferences = await this.detectNewFileReferences(); | |
| semanticGroups.push(...newFileReferences); | |
| // Only detect identifier/string renames if unstable flag is enabled | |
| if (this.options.unstableSemanticDetection) { | |
| // Get the full diff with zero context | |
| const { stdout, exitCode } = await Bun.spawn(["git", "diff", "--unified=0", "--no-color", "-M", "HEAD"], { | |
| stdout: "pipe", | |
| }).exited.then(async (code) => { | |
| return { | |
| exitCode: code, | |
| stdout: await new Response( | |
| Bun.spawn(["git", "diff", "--unified=0", "--no-color", "-M", "HEAD"], { stdout: "pipe" }).stdout, | |
| ).text(), | |
| }; | |
| }); | |
| if (exitCode !== 0) { | |
| throw new Error("Failed to get git diff"); | |
| } | |
| const hunks = this.parseDiffHunks(stdout); | |
| // Detect identifier renames (UNSTABLE - prone to false positives from import reordering) | |
| const identifierCandidates = this.detectIdentifierRenames(hunks); | |
| // Detect string literal changes (UNSTABLE) | |
| const stringCandidates = this.detectStringLiteralChanges(hunks); | |
| // Add filtered candidates | |
| semanticGroups.push(...this.filterSemanticCandidates(identifierCandidates, "identifier")); | |
| semanticGroups.push(...this.filterSemanticCandidates(stringCandidates, "string")); | |
| } | |
| // Sort semantic groups by priority: | |
| // 1. New file references first (since they're new features) | |
| // 2. Identifier/string renames next (refactoring) | |
| // The order within each type is preserved from detection | |
| semanticGroups.sort((a, b) => { | |
| if (a.type === "new-file-references" && b.type !== "new-file-references") return -1; | |
| if (a.type !== "new-file-references" && b.type === "new-file-references") return 1; | |
| return 0; // Preserve original order within same type | |
| }); | |
| this.semanticGroups = semanticGroups; | |
| // Detect file renames and import updates | |
| this.detectFileRenames(); | |
| if (this.options.verbose) { | |
| console.log( | |
| `Detected ${this.semanticGroups.length} semantic groups (unstable: ${this.options.unstableSemanticDetection})`, | |
| ); | |
| console.log(`Detected ${this.fileRenames.length} file renames`); | |
| } | |
| } | |
| private parseDiffHunks(diff: string): DiffHunk[] { | |
| const hunks: DiffHunk[] = []; | |
| const lines = diff.split("\n"); | |
| let currentFile = ""; | |
| let i = 0; | |
| while (i < lines.length) { | |
| const line = lines[i]; | |
| // Parse file header | |
| if (line.startsWith("diff --git")) { | |
| i++; | |
| continue; | |
| } | |
| // Parse new file path | |
| if (line.startsWith("+++")) { | |
| const match = line.match(/^\+\+\+ b\/(.*)/); | |
| if (match) { | |
| currentFile = match[1]; | |
| } | |
| i++; | |
| continue; | |
| } | |
| // Parse hunk header | |
| if (line.startsWith("@@")) { | |
| const match = line.match(/@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/); | |
| if (match && currentFile) { | |
| const startLine = parseInt(match[3]); | |
| const lineCount = parseInt(match[4] || "1"); | |
| // Collect hunk content | |
| const hunkLines: string[] = [line]; | |
| i++; | |
| while (i < lines.length && !lines[i].startsWith("@@") && !lines[i].startsWith("diff")) { | |
| if (lines[i].startsWith("+") || lines[i].startsWith("-")) { | |
| hunkLines.push(lines[i]); | |
| } | |
| i++; | |
| } | |
| hunks.push({ | |
| file: currentFile, | |
| startLine, | |
| lineCount, | |
| content: hunkLines.join("\n"), | |
| hasMixedChanges: false, // Will be determined later | |
| }); | |
| continue; | |
| } | |
| } | |
| i++; | |
| } | |
| return hunks; | |
| } | |
| private shouldSkipSemanticAnalysis(file: string): boolean { | |
| // Skip files that shouldn't have semantic analysis | |
| const skipExtensions = [".json", ".lock", ".snap", ".md"]; | |
| const skipPatterns = [/drizzle\/meta\//, /node_modules\//, /\.git\//, /__snapshots__\//]; | |
| return skipExtensions.some((ext) => file.endsWith(ext)) || skipPatterns.some((pattern) => pattern.test(file)); | |
| } | |
| private detectIdentifierRenames(hunks: DiffHunk[]): Map<string, SemanticGroup> { | |
| const candidates = new Map<string, SemanticGroup>(); | |
| const identifierPattern = /\b[A-Za-z_][A-Za-z0-9_]*\b/g; | |
| // Skip common keywords and built-in names | |
| const skipWords = new Set([ | |
| "const", | |
| "let", | |
| "var", | |
| "function", | |
| "class", | |
| "interface", | |
| "type", | |
| "import", | |
| "export", | |
| "from", | |
| "return", | |
| "if", | |
| "else", | |
| "for", | |
| "while", | |
| "true", | |
| "false", | |
| "null", | |
| "undefined", | |
| "this", | |
| "super", | |
| "new", | |
| "public", | |
| "private", | |
| "protected", | |
| "static", | |
| "async", | |
| "await", | |
| ]); | |
| for (const hunk of hunks) { | |
| // Skip files that shouldn't have semantic analysis | |
| if (this.shouldSkipSemanticAnalysis(hunk.file)) { | |
| continue; | |
| } | |
| const lines = hunk.content.split("\n"); | |
| const removedLines: string[] = []; | |
| const addedLines: string[] = []; | |
| for (const line of lines) { | |
| if (line.startsWith("-") && !line.startsWith("---")) { | |
| removedLines.push(line.substring(1)); | |
| } else if (line.startsWith("+") && !line.startsWith("+++")) { | |
| addedLines.push(line.substring(1)); | |
| } | |
| } | |
| // Look for identifier replacements | |
| for (let i = 0; i < Math.min(removedLines.length, addedLines.length); i++) { | |
| const removed = removedLines[i]; | |
| const added = addedLines[i]; | |
| // Extract identifiers from both lines | |
| const removedIds = Array.from(removed.matchAll(identifierPattern)) | |
| .map((m) => m[0]) | |
| .filter((id) => !skipWords.has(id)); | |
| const addedIds = Array.from(added.matchAll(identifierPattern)) | |
| .map((m) => m[0]) | |
| .filter((id) => !skipWords.has(id)); | |
| // Find simple replacements (same position, different identifier) | |
| for (let j = 0; j < Math.min(removedIds.length, addedIds.length); j++) { | |
| const from = removedIds[j]; | |
| const to = addedIds[j]; | |
| if (from !== to && from.length > 2 && to.length > 2) { | |
| const key = `${from}→${to}`; | |
| if (!candidates.has(key)) { | |
| candidates.set(key, { | |
| type: "identifier", | |
| from, | |
| to, | |
| replacements: new Map(), | |
| totalReplacements: 0, | |
| mixedHunks: 0, | |
| hunks: [], | |
| }); | |
| } | |
| const group = candidates.get(key)!; | |
| group.replacements.set(hunk.file, (group.replacements.get(hunk.file) || 0) + 1); | |
| group.totalReplacements++; | |
| if (!group.hunks.includes(hunk)) { | |
| group.hunks.push(hunk); | |
| } | |
| } | |
| } | |
| } | |
| } | |
| return candidates; | |
| } | |
| private detectStringLiteralChanges(hunks: DiffHunk[]): Map<string, SemanticGroup> { | |
| const candidates = new Map<string, SemanticGroup>(); | |
| const stringPattern = /["'`]([^"'`]+)["'`]/g; | |
| for (const hunk of hunks) { | |
| // Skip files that shouldn't have semantic analysis | |
| if (this.shouldSkipSemanticAnalysis(hunk.file)) { | |
| continue; | |
| } | |
| const lines = hunk.content.split("\n"); | |
| const removedLines: string[] = []; | |
| const addedLines: string[] = []; | |
| for (const line of lines) { | |
| if (line.startsWith("-") && !line.startsWith("---")) { | |
| removedLines.push(line.substring(1)); | |
| } else if (line.startsWith("+") && !line.startsWith("+++")) { | |
| addedLines.push(line.substring(1)); | |
| } | |
| } | |
| // Look for string literal replacements | |
| for (let i = 0; i < Math.min(removedLines.length, addedLines.length); i++) { | |
| const removed = removedLines[i]; | |
| const added = addedLines[i]; | |
| const removedStrings = Array.from(removed.matchAll(stringPattern)).map((m) => m[1]); | |
| const addedStrings = Array.from(added.matchAll(stringPattern)).map((m) => m[1]); | |
| for (let j = 0; j < Math.min(removedStrings.length, addedStrings.length); j++) { | |
| const from = removedStrings[j]; | |
| const to = addedStrings[j]; | |
| if (from !== to && from.length > 2 && to.length > 2) { | |
| const key = `"${from}"→"${to}"`; | |
| if (!candidates.has(key)) { | |
| candidates.set(key, { | |
| type: "string", | |
| from, | |
| to, | |
| replacements: new Map(), | |
| totalReplacements: 0, | |
| mixedHunks: 0, | |
| hunks: [], | |
| }); | |
| } | |
| const group = candidates.get(key)!; | |
| group.replacements.set(hunk.file, (group.replacements.get(hunk.file) || 0) + 1); | |
| group.totalReplacements++; | |
| if (!group.hunks.includes(hunk)) { | |
| group.hunks.push(hunk); | |
| } | |
| } | |
| } | |
| } | |
| } | |
| return candidates; | |
| } | |
| private filterSemanticCandidates( | |
| candidates: Map<string, SemanticGroup>, | |
| type: "identifier" | "string", | |
| ): SemanticGroup[] { | |
| const filtered: SemanticGroup[] = []; | |
| const { minReplacements, minFiles, minReplacementsInOneFile } = this.options.semanticThresholds; | |
| for (const [key, group] of candidates) { | |
| const fileCount = group.replacements.size; | |
| const maxInOneFile = Math.max(...Array.from(group.replacements.values())); | |
| // Apply thresholds | |
| if ( | |
| group.totalReplacements >= minReplacements && | |
| (fileCount >= minFiles || maxInOneFile >= minReplacementsInOneFile) | |
| ) { | |
| filtered.push(group); | |
| } | |
| } | |
| // Sort by total replacements (most significant first) | |
| return filtered.sort((a, b) => b.totalReplacements - a.totalReplacements); | |
| } | |
| private async detectNewFileReferences(): Promise<SemanticGroup[]> { | |
| const groups: SemanticGroup[] = []; | |
| // Get added files | |
| const addedFiles = this.changedFiles.filter((f) => f.status === "added"); | |
| if (addedFiles.length === 0) { | |
| return groups; | |
| } | |
| // Get modified files (excluding added ones) | |
| const modifiedFiles = this.changedFiles.filter((f) => f.status === "modified"); | |
| if (modifiedFiles.length === 0) { | |
| return groups; | |
| } | |
| // For each added file, check if its basename appears in modified files | |
| for (const addedFile of addedFiles) { | |
| const addedBasename = basename(addedFile.path); | |
| const addedBasenameNoExt = basename(addedFile.path, extname(addedFile.path)); | |
| const referencingFiles = new Map<string, number>(); | |
| // Search for references in modified files | |
| for (const modifiedFile of modifiedFiles) { | |
| try { | |
| const content = await Bun.file(modifiedFile.path).text(); | |
| // Count occurrences of the basename or basename without extension | |
| let count = 0; | |
| // Look for the full filename | |
| count += (content.match(new RegExp(this.escapeRegex(addedBasename), "g")) || []).length; | |
| // Look for basename without extension (for imports like "./file" instead of "./file.ts") | |
| if (addedBasenameNoExt !== addedBasename) { | |
| count += (content.match(new RegExp(`["'\`].*${this.escapeRegex(addedBasenameNoExt)}["'\`]`, "g")) || []) | |
| .length; | |
| } | |
| if (count > 0) { | |
| referencingFiles.set(modifiedFile.path, count); | |
| } | |
| } catch (error) { | |
| if (this.options.verbose) { | |
| console.warn(`Could not read ${modifiedFile.path}: ${error}`); | |
| } | |
| } | |
| } | |
| // If this added file is referenced in modified files, create a semantic group | |
| if (referencingFiles.size > 0) { | |
| groups.push({ | |
| type: "new-file-references", | |
| from: "", | |
| to: addedFile.path, | |
| replacements: referencingFiles, | |
| totalReplacements: Array.from(referencingFiles.values()).reduce((a, b) => a + b, 0), | |
| mixedHunks: 0, | |
| hunks: [], | |
| newFiles: [addedFile.path], | |
| }); | |
| } else { | |
| // This added file is not referenced anywhere - exclude it from fixups | |
| if (this.options.verbose) { | |
| console.log(`⊖ Excluding unreferenced new file: ${addedFile.path}`); | |
| } | |
| } | |
| } | |
| return groups; | |
| } | |
| private escapeRegex(str: string): string { | |
| return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); | |
| } | |
| private detectFileRenames(): void { | |
| for (const file of this.changedFiles) { | |
| if (file.status === "renamed" && file.oldPath) { | |
| this.fileRenames.push({ | |
| from: file.oldPath, | |
| to: file.path, | |
| importUpdates: [], // TODO: detect import updates | |
| }); | |
| } | |
| } | |
| } | |
| private async groupByCommit(): Promise<void> { | |
| // Get files that aren't part of semantic groups or renames | |
| const semanticFiles = new Set<string>(); | |
| for (const group of this.semanticGroups) { | |
| // Add files that reference the changes | |
| for (const [file] of group.replacements) { | |
| semanticFiles.add(file); | |
| } | |
| // Add new files that are being referenced | |
| if (group.newFiles) { | |
| for (const file of group.newFiles) { | |
| semanticFiles.add(file); | |
| } | |
| } | |
| } | |
| for (const rename of this.fileRenames) { | |
| semanticFiles.add(rename.to); | |
| if (rename.from) semanticFiles.add(rename.from); | |
| } | |
| const remainingFiles = this.changedFiles | |
| .filter((f) => !semanticFiles.has(f.path) && f.status !== "added") | |
| .map((f) => f.path); | |
| if (remainingFiles.length === 0) { | |
| return; | |
| } | |
| // Group by most recent commit | |
| const commitMap = new Map<string, { message: string; files: string[]; timestamp: number }>(); | |
| for (const file of remainingFiles) { | |
| const commit = await this.getMostRecentCommit(file); | |
| if (!commit) { | |
| if (this.options.verbose) { | |
| console.warn(`⚠️ Skipping ${file}: no commit history found`); | |
| } | |
| continue; | |
| } | |
| if (!commitMap.has(commit.hash)) { | |
| commitMap.set(commit.hash, { message: commit.message, files: [], timestamp: commit.timestamp }); | |
| } | |
| commitMap.get(commit.hash)!.files.push(file); | |
| } | |
| // Convert to fixup groups | |
| for (const [hash, { message, files, timestamp }] of commitMap) { | |
| this.fixupGroups.push({ commit: hash, message, files, timestamp }); | |
| } | |
| // Sort by timestamp (oldest first) so fixups apply in chronological order | |
| this.fixupGroups.sort((a, b) => a.timestamp - b.timestamp); | |
| } | |
| private async getMostRecentCommit( | |
| file: string, | |
| ): Promise<{ hash: string; message: string; timestamp: number } | null> { | |
| const { stdout, exitCode } = await Bun.spawn( | |
| ["git", "log", "-1", `--max-count=${this.options.depth}`, "--pretty=format:%H|||%s|||%ct", "--", file], | |
| { stdout: "pipe" }, | |
| ).exited.then(async (code) => { | |
| return { | |
| exitCode: code, | |
| stdout: await new Response( | |
| Bun.spawn( | |
| ["git", "log", "-1", `--max-count=${this.options.depth}`, "--pretty=format:%H|||%s|||%ct", "--", file], | |
| { stdout: "pipe" }, | |
| ).stdout, | |
| ).text(), | |
| }; | |
| }); | |
| if (exitCode !== 0 || !stdout.trim()) { | |
| return null; | |
| } | |
| const [hash, message, timestampStr] = stdout.trim().split("|||"); | |
| // Only use the first line of the commit message (subject line) to avoid shell escaping issues | |
| const subjectLine = message.split("\n")[0].trim(); | |
| const timestamp = parseInt(timestampStr); | |
| return { hash: hash.substring(0, 7), message: subjectLine, timestamp }; | |
| } | |
| private escapeSingleQuotes(str: string): string { | |
| return `'${str.replace(/'/g, `'"'"'`)}'`; | |
| } | |
| private generateCommands(): void { | |
| this.commands = []; | |
| let groupIndex = 1; | |
| // Add initial unstage command at the beginning | |
| if (this.semanticGroups.length > 0 || this.fixupGroups.length > 0 || this.fileRenames.length > 0) { | |
| this.commands.push(`# Unstage all files first`, `git reset HEAD`, ``); | |
| } | |
| // Commands for semantic groups | |
| for (const group of this.semanticGroups) { | |
| const commitMsg = this.getSemanticCommitMessage(group); | |
| if (group.type === "new-file-references") { | |
| const newFile = group.newFiles?.[0] || ""; | |
| const referencingFiles = Array.from(group.replacements.keys()); | |
| const allFiles = [newFile, ...referencingFiles]; | |
| this.commands.push(`# [semantic-${groupIndex}] New file: ${newFile}`, `git add \\`); | |
| // Add all files with line breaks and indentation | |
| for (let i = 0; i < allFiles.length; i++) { | |
| const file = allFiles[i]; | |
| const isLast = i === allFiles.length - 1; | |
| this.commands.push(` ${this.escapePath(file)}${isLast ? " && \\" : " \\"}`); | |
| } | |
| this.commands.push(`git commit -m ${this.escapeSingleQuotes(commitMsg)}`); | |
| if (this.options.showJj) { | |
| this.commands.push( | |
| ``, | |
| `# Jujutsu alternative:`, | |
| `# The new file and references are already in your working copy`, | |
| `jj squash -m ${this.escapeSingleQuotes(commitMsg)}`, | |
| ); | |
| } | |
| } else { | |
| const files = Array.from(group.replacements.keys()); | |
| this.commands.push( | |
| `# [semantic-${groupIndex}] ${group.type} rename: ${group.from} → ${group.to}`, | |
| `git add \\`, | |
| ); | |
| // Add all files with line breaks and indentation | |
| for (let i = 0; i < files.length; i++) { | |
| const file = files[i]; | |
| const isLast = i === files.length - 1; | |
| this.commands.push(` ${this.escapePath(file)}${isLast ? " && \\" : " \\"}`); | |
| } | |
| this.commands.push(`git commit -m ${this.escapeSingleQuotes(commitMsg)}`); | |
| if (this.options.showJj) { | |
| this.commands.push(``, `# Jujutsu alternative:`, `jj squash -m ${this.escapeSingleQuotes(commitMsg)}`); | |
| } | |
| } | |
| this.commands.push(``); | |
| groupIndex++; | |
| } | |
| // Commands for file renames | |
| for (const rename of this.fileRenames) { | |
| const commitMsg = `refactor(rename-path): ${basename(rename.from)} → ${basename(rename.to)}`; | |
| this.commands.push( | |
| `# File rename: ${rename.from} → ${rename.to}`, | |
| `git mv ${this.escapePath(rename.from)} ${this.escapePath(rename.to)} && \\`, | |
| `git commit -m ${this.escapeSingleQuotes(commitMsg)}`, | |
| ); | |
| if (this.options.showJj) { | |
| this.commands.push( | |
| ``, | |
| `# Jujutsu alternative:`, | |
| `mv ${this.escapePath(rename.from)} ${this.escapePath(rename.to)} && \\`, | |
| `jj describe -m ${this.escapeSingleQuotes(commitMsg)}`, | |
| ); | |
| } | |
| this.commands.push(``); | |
| } | |
| // Commands for fixup groups | |
| for (const group of this.fixupGroups) { | |
| const dateInfo = this.formatTimestampWithRelative(group.timestamp); | |
| // Split files by status: deleted vs added/modified | |
| const deletedFiles = group.files.filter((f) => { | |
| const changedFile = this.changedFiles.find((cf) => cf.path === f); | |
| return changedFile?.status === "deleted"; | |
| }); | |
| const addedOrModifiedFiles = group.files.filter((f) => !deletedFiles.includes(f)); | |
| this.commands.push(`# [fixup ${group.commit}] ${group.message.replace(/\n/g, " ")} — ${dateInfo}`); | |
| // Generate git add for added/modified files | |
| if (addedOrModifiedFiles.length > 0) { | |
| this.commands.push(`git add \\`); | |
| for (let i = 0; i < addedOrModifiedFiles.length; i++) { | |
| const file = addedOrModifiedFiles[i]; | |
| const isLast = i === addedOrModifiedFiles.length - 1; | |
| if (isLast && deletedFiles.length === 0) { | |
| // Last file and no deletions, chain to commit | |
| this.commands.push(` ${this.escapePath(file)} && \\`); | |
| } else if (isLast && deletedFiles.length > 0) { | |
| // Last file but deletions coming, add && and continue to git rm | |
| this.commands.push(` ${this.escapePath(file)} && \\`); | |
| } else { | |
| // Not last file, just continue | |
| this.commands.push(` ${this.escapePath(file)} \\`); | |
| } | |
| } | |
| } | |
| // Generate git rm for deleted files | |
| if (deletedFiles.length > 0) { | |
| this.commands.push(`git rm \\`); | |
| for (let i = 0; i < deletedFiles.length; i++) { | |
| const file = deletedFiles[i]; | |
| const isLast = i === deletedFiles.length - 1; | |
| this.commands.push(` ${this.escapePath(file)}${isLast ? " && \\" : " \\"}`); | |
| } | |
| } | |
| this.commands.push( | |
| `git commit -m ${this.escapeSingleQuotes(`fixup! ${group.message.replace(/^\s*fixup\! /, "")}`)}`, | |
| ); | |
| if (this.options.showJj) { | |
| this.commands.push(``, `# Jujutsu alternative:`, `jj squash --into ${group.commit}`); | |
| } | |
| this.commands.push(``); | |
| } | |
| if (this.options.showJj && (this.semanticGroups.length > 0 || this.fixupGroups.length > 0)) { | |
| this.commands.push( | |
| ``, | |
| `# ════════════════════════════════════════════════════════════`, | |
| `# Jujutsu Workflow Notes:`, | |
| `# ════════════════════════════════════════════════════════════`, | |
| `# - In jj, your working copy is automatically a commit`, | |
| `# - No need for 'git add' or staging - just edit and squash`, | |
| `# - Use 'jj absorb' to automatically distribute changes`, | |
| `# - Use 'jj split' to split working changes into commits`, | |
| `# - Use 'jj undo' to undo any operation (safer than git)`, | |
| ``, | |
| ); | |
| } | |
| } | |
| private formatTimestampWithRelative(timestamp: number): string { | |
| const date = new Date(timestamp * 1000); | |
| const now = Date.now(); | |
| const diffMs = now - date.getTime(); | |
| const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); | |
| const dateStr = date.toISOString().split("T")[0]; // YYYY-MM-DD | |
| let relativeStr: string; | |
| if (diffDays < 1) { | |
| relativeStr = "today"; | |
| } else if (diffDays === 1) { | |
| relativeStr = "1 day ago"; | |
| } else if (diffDays < 7) { | |
| relativeStr = `${diffDays} days ago`; | |
| } else if (diffDays < 28) { | |
| const weeks = Math.floor(diffDays / 7); | |
| relativeStr = weeks === 1 ? "1 week ago" : `${weeks} weeks ago`; | |
| } else if (diffDays < 365) { | |
| const months = Math.floor(diffDays / 30); | |
| relativeStr = months === 1 ? "1 month ago" : `${months} months ago`; | |
| } else { | |
| const years = Math.floor(diffDays / 365); | |
| relativeStr = years === 1 ? "1 year ago" : `${years} years ago`; | |
| } | |
| return `${dateStr} (${relativeStr})`; | |
| } | |
| private getSemanticCommitMessage(group: SemanticGroup): string { | |
| if (group.type === "new-file-references") { | |
| const newFile = group.newFiles?.[0] || ""; | |
| const fileCount = group.replacements.size; | |
| return `feat: Add ${basename(newFile)} (referenced in ${fileCount} files)`; | |
| } | |
| const fileCount = group.replacements.size; | |
| const type = group.type === "identifier" ? "rename" : "strings"; | |
| return `refactor(${type}): ${group.from} → ${group.to} (${group.totalReplacements} replacements across ${fileCount} files)`; | |
| } | |
| private outputPlan(): void { | |
| console.log("━".repeat(60)); | |
| console.log(`Semantic Groups (${this.semanticGroups.length})`); | |
| console.log("━".repeat(60)); | |
| console.log(); | |
| for (let i = 0; i < this.semanticGroups.length; i++) { | |
| const group = this.semanticGroups[i]; | |
| const files = Array.from(group.replacements.entries()); | |
| if (group.type === "new-file-references") { | |
| const newFile = group.newFiles?.[0] || ""; | |
| console.log(`[semantic-${i + 1}] New file: ${newFile}`); | |
| console.log(` Referenced in ${files.length} modified files`); | |
| console.log(); | |
| console.log(" New file:"); | |
| console.log(` ${newFile}`); | |
| console.log(); | |
| console.log(" Referenced by:"); | |
| for (const [file, count] of files) { | |
| console.log(` ${file} (${count} references)`); | |
| } | |
| } else { | |
| console.log( | |
| `[semantic-${i + 1}] ${group.type === "identifier" ? "Identifier" : "String"} rename: ${group.from} → ${group.to}`, | |
| ); | |
| console.log( | |
| ` ${group.totalReplacements} replacements across ${files.length} files${group.mixedHunks > 0 ? ` (${group.mixedHunks} mixed with other changes)` : ""}`, | |
| ); | |
| console.log(); | |
| console.log(" Files:"); | |
| for (const [file, count] of files) { | |
| console.log(` ${file} (${count} occurrences)`); | |
| } | |
| } | |
| console.log(); | |
| } | |
| if (this.fileRenames.length > 0) { | |
| for (const rename of this.fileRenames) { | |
| console.log(`[file-rename] ${rename.from} → ${rename.to}`); | |
| if (rename.importUpdates.length > 0) { | |
| console.log(` Import updates in ${rename.importUpdates.length} files`); | |
| } | |
| console.log(); | |
| } | |
| } | |
| if (this.semanticGroups.length === 0 && this.fileRenames.length === 0) { | |
| console.log(" (none detected)"); | |
| console.log(); | |
| } | |
| console.log("━".repeat(60)); | |
| console.log(`Formatting Fixups by Commit (${this.fixupGroups.length})`); | |
| console.log("━".repeat(60)); | |
| console.log(); | |
| for (const group of this.fixupGroups) { | |
| const dateInfo = this.formatTimestampWithRelative(group.timestamp); | |
| console.log(`[fixup ${group.commit}] "${group.message}" — ${dateInfo}`); | |
| console.log(` ${group.files.length} files:`); | |
| for (const file of group.files) { | |
| console.log(` ${file}`); | |
| } | |
| console.log(); | |
| } | |
| if (this.fixupGroups.length === 0) { | |
| console.log(" (none)"); | |
| console.log(); | |
| } | |
| console.log("━".repeat(60)); | |
| console.log(); | |
| console.log("Copy commands above or run with --interactive to execute step-by-step."); | |
| console.log(); | |
| } | |
| private async writeRecoveryScript(): Promise<void> { | |
| const timestamp = | |
| new Date().toISOString().replace(/[:.]/g, "-").split("T")[0] + | |
| "-" + | |
| new Date().toTimeString().split(" ")[0].replace(/:/g, ""); | |
| const scriptPath = `continue-git-fixup-${timestamp}.local.sh`; | |
| const scriptContent = [ | |
| "#!/bin/bash", | |
| "# Auto-generated git fixup commands", | |
| `# Generated at ${new Date().toISOString()}`, | |
| "", | |
| "set -e # Exit on error", | |
| "", | |
| ...this.commands, | |
| "", | |
| "# ════════════════════════════════════════════════════════════", | |
| "# After running these commands, apply fixups with autosquash:", | |
| "# ════════════════════════════════════════════════════════════", | |
| "# git rebase -i --autosquash main", | |
| "# git rebase -i --autosquash origin/main", | |
| "#", | |
| "# Or with Jujutsu:", | |
| "# jj rebase -d main", | |
| "", | |
| ].join("\n"); | |
| await writeFile(scriptPath, scriptContent, { mode: 0o755 }); | |
| console.log(`Script saved to: ${scriptPath}`); | |
| } | |
| private async executeInteractive(): Promise<void> { | |
| console.log("\n━".repeat(60)); | |
| console.log("Interactive Mode"); | |
| console.log("━".repeat(60)); | |
| console.log(); | |
| for (let i = 0; i < this.commands.length; i++) { | |
| const cmd = this.commands[i]; | |
| // Skip empty lines and comments in execution | |
| if (!cmd.trim() || cmd.startsWith("#")) { | |
| console.log(cmd); | |
| continue; | |
| } | |
| console.log(`\n> ${cmd}`); | |
| console.log("Execute this command? [y/n/q] "); | |
| // Read user input | |
| const input = prompt(""); | |
| if (input?.toLowerCase() === "q") { | |
| console.log("\nCancelled. Remaining commands saved to recovery script."); | |
| // Write remaining commands to recovery script | |
| const remaining = this.commands.slice(i); | |
| const timestamp = | |
| new Date().toISOString().replace(/[:.]/g, "-").split("T")[0] + | |
| "-" + | |
| new Date().toTimeString().split(" ")[0].replace(/:/g, ""); | |
| const scriptPath = `continue-git-fixup-${timestamp}.local.sh`; | |
| await writeFile(scriptPath, remaining.join("\n"), { mode: 0o755 }); | |
| console.log(`Remaining commands saved to: ${scriptPath}`); | |
| process.exit(2); | |
| } | |
| if (input?.toLowerCase() === "y") { | |
| try { | |
| const result = await Bun.spawn(cmd.split(" "), { | |
| stdout: "inherit", | |
| stderr: "inherit", | |
| }).exited; | |
| if (result !== 0) { | |
| throw new Error(`Command failed with exit code ${result}`); | |
| } | |
| } catch (error) { | |
| console.error(`Error executing command: ${error}`); | |
| console.log("\nDo you want to continue? [y/n] "); | |
| const continueInput = prompt(""); | |
| if (continueInput?.toLowerCase() !== "y") { | |
| process.exit(1); | |
| } | |
| } | |
| } | |
| } | |
| console.log("\n✅ All commands executed successfully!"); | |
| } | |
| } | |
| function showHelp(): void { | |
| console.log(` | |
| Git Fixup Formatter - Generate fixup commits from formatting changes | |
| USAGE: | |
| bun tools/git-fixup-formatter.ts [OPTIONS] [-- FILES...] | |
| OPTIONS: | |
| -i, --interactive Execute commands interactively with confirmation | |
| --depth <N> Limit git log search depth (default: 100) | |
| --no-semantic Disable all semantic change detection | |
| --unstable Enable UNSTABLE identifier/string rename detection | |
| (may produce false positives from import reordering) | |
| --show-jj Show Jujutsu (jj) command alternatives | |
| --write-script <PATH> Write commands to specific script file | |
| -v, --verbose Verbose output | |
| -h, --help Show this help message | |
| SEMANTIC DETECTION: | |
| By default: Detects new files and groups them with files that reference them | |
| With --unstable: Also detects identifier/string renames (experimental, may have false positives) | |
| With --no-semantic: Disables all semantic detection | |
| EXAMPLES: | |
| bun tools/git-fixup-formatter.ts | |
| bun tools/git-fixup-formatter.ts --interactive | |
| bun tools/git-fixup-formatter.ts --no-semantic | |
| bun tools/git-fixup-formatter.ts --unstable | |
| bun tools/git-fixup-formatter.ts --show-jj | |
| bun tools/git-fixup-formatter.ts -- src/file1.ts src/file2.ts | |
| See tools/git-fixup-formatter-README.md for more information. | |
| `); | |
| process.exit(0); | |
| } | |
| // Parse command line arguments | |
| async function parseArgs(): Promise<Options> { | |
| const args = process.argv.slice(2); | |
| if (args.includes("-h") || args.includes("--help")) { | |
| showHelp(); | |
| } | |
| const options: Options = { | |
| interactive: false, | |
| depth: 100, | |
| semanticDetection: true, // Enable new file reference detection by default | |
| unstableSemanticDetection: false, // Disable unstable features by default | |
| semanticThresholds: { | |
| minReplacements: 3, | |
| minFiles: 2, | |
| minReplacementsInOneFile: 5, | |
| }, | |
| writeScript: undefined, | |
| verbose: false, | |
| specificFiles: [], | |
| excludePatterns: [], | |
| showJj: false, | |
| }; | |
| let i = 0; | |
| while (i < args.length) { | |
| const arg = args[i]; | |
| switch (arg) { | |
| case "--interactive": | |
| case "-i": | |
| options.interactive = true; | |
| break; | |
| case "--depth": | |
| options.depth = parseInt(args[++i]); | |
| break; | |
| case "--no-semantic": | |
| options.semanticDetection = false; | |
| options.unstableSemanticDetection = false; | |
| break; | |
| case "--unstable": | |
| options.unstableSemanticDetection = true; | |
| break; | |
| case "--show-jj": | |
| options.showJj = true; | |
| break; | |
| case "--write-script": | |
| options.writeScript = args[++i]; | |
| break; | |
| case "--verbose": | |
| case "-v": | |
| options.verbose = true; | |
| break; | |
| case "--": | |
| // Remaining args are specific files | |
| options.specificFiles = args.slice(i + 1); | |
| i = args.length; | |
| break; | |
| default: | |
| if (!arg.startsWith("-")) { | |
| options.specificFiles.push(arg); | |
| } | |
| } | |
| i++; | |
| } | |
| // Load config file if exists | |
| const configPath = ".git-fixup-formatter.json"; | |
| if (existsSync(configPath)) { | |
| try { | |
| const config = JSON.parse(await Bun.file(configPath).text()); | |
| if (config.defaultDepth) options.depth = config.defaultDepth; | |
| if (config.semanticDetection !== undefined) options.semanticDetection = config.semanticDetection; | |
| if (config.semanticThresholds) { | |
| options.semanticThresholds = { ...options.semanticThresholds, ...config.semanticThresholds }; | |
| } | |
| if (config.excludePatterns) options.excludePatterns = config.excludePatterns; | |
| } catch (error) { | |
| console.warn(`Warning: Could not parse config file: ${error}`); | |
| } | |
| } | |
| return options; | |
| } | |
| // Main | |
| const options = await parseArgs(); | |
| const formatter = new GitFixupFormatter(options); | |
| await formatter.run(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment