Skip to content

Instantly share code, notes, and snippets.

@colelawrence
Last active November 6, 2025 15:18
Show Gist options
  • Select an option

  • Save colelawrence/8353df1a62ea4964c0408221f70d2fa9 to your computer and use it in GitHub Desktop.

Select an option

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
#!/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