Skip to content

Instantly share code, notes, and snippets.

@kch
Last active July 9, 2025 21:12
Show Gist options
  • Save kch/e4d5859516af51c85938a52b238d3488 to your computer and use it in GitHub Desktop.
Save kch/e4d5859516af51c85938a52b238d3488 to your computer and use it in GitHub Desktop.
reword commits inline in interactive rebase file
#!/usr/bin/env ruby
# git-reword: Interactive tool for bulk editing commit messages' first line
#
# Opens the rebase todo sequence for editing, amends any commit messages with
# the inline-reworded message. Other rebase operations are performed normally.
#
# If you reword inline but also use a reword action on the same line, the
# inline edit wins.
#
# Usage:
# git-reword [base-ref] # Open the rebase todo for editing with inline rewording
#
# Examples:
# git-reword # Last 10 commits
# git-reword HEAD~5 # Last 5 commits
# git-reword main # All commits since main branch
require "shellwords"
def die msg=nil, code=1
$stderr.puts "#{File.basename($0)}: #{msg}" if msg
exit code
end
git_dir = `git rev-parse --git-dir`.chomp
die "Can't find git directory" unless $?.success?
ARGV << "HEAD~10" if ARGV.empty?
# Get the actual rebase file that git would show by capturing it
todo_content = `GIT_SEQUENCE_EDITOR=cat git rebase --quiet --interactive #{ARGV.shelljoin} < /dev/null`.chomp
exit 1 unless $?.success?
die "No commits to rebase", 0 if todo_content.empty? || todo_content.start_with?("noop")
def parse_lines(rebase_todo) = rebase_todo.lines.map(&:strip).grep_v(/^#|^$/).map{ it.split(/ +/, 3) }
# Parse original commits to track message changes later
original_commits = parse_lines todo_content
# Let user edit the rebase file
reword_dir = "#{git_dir}/reword"
todo_file = "#{reword_dir}/git-rebase-todo"
Dir.mkdir reword_dir unless Dir.exist? reword_dir
File.write todo_file, todo_content
system "#{ENV["EDITOR"]||"vim"} #{todo_file.shellescape}"
reworded_commits = parse_lines File.read todo_file
# Find commits where messages changed - match by SHA
original_by_sha = original_commits.to_h { it[1..] }
reworded_by_sha = reworded_commits.to_h { it[1..] }
reworded_by_sha = reworded_by_sha.slice(*original_by_sha.keys).reject { |sha, msg| original_by_sha[sha] == msg }
# Build final rebase script with exec commands for message changes
lines = []
reworded_commits.each do |commit|
action, sha, message = commit
lines << "#{action} #{sha} #{message}"
next unless reworded_by_sha[sha]
message.sub!(/^# /, "") # git 2.50 adds # marks before msgs
original_body = `git log --max-count=1 --format=%b #{sha} --`.chomp
# If single line, inline it. Otherwise use temp file.
if original_body.empty?
lines << "exec git commit --amend --message='#{message.gsub(%['], %['"'"'])}'"
else
msg_file = "#{reword_dir}/COMMIT_EDITMSG_#{sha}"
File.write(msg_file, message + "\n\n" + original_body)
lines << "exec git commit --amend --file=#{msg_file.shellescape} && rm #{msg_file.shellescape} # #{message}"
end
end
rebase_script = lines.join("\n")
# # Execute the rebase with our modified script
IO.popen("GIT_SEQUENCE_EDITOR='cat >' git rebase --interactive #{ARGV.shelljoin}", "w") { it << rebase_script }
exit if $?.success?
die unless Dir.exist?("#{git_dir}/rebase-merge") # rebase in progress from error caused by todo contents
die "Rebase failed. Run 'git rebase --abort' to cancel."
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment