Updating git-edit
12 Mar 2022Around 3 years ago I wrote a post showing my git-edit
command that helps me very quickly perform a rebase where I want to edit a specific commit in my history.
This script has served me well but the original version no longer works when trying to edit history whilst preserving merge commits (merge bubbles), which is the behaviour I want.
It’s a pretty small fix and is a classic case of me over thinking the original implementation and that resulting in it being more brittle than it needed to be.
tl;dr here’s the updated script
Quick recap
Given this history:
* c3b7967 (HEAD -> main) Add greet function
* b80d6e2 Add contributors list
* 38eccff Add readme
When I call my git-edit
command with the second commit:
git edit b80d6e2
The following will happen
- Git will perform a
git rebase --interactive b80d6e2~
- My
git-edit
script will be handed the file path of the git todo list and make the following change
- pick c3b7967 Add greet function
+ edit c3b7967 Add greet function
# Rebase b80d6e2..c3b7967 onto b80d6e2 (1 command)
#
# <git rebase instructions here>
#
- Now I make any edit I need and then run
git rebase --continue
to finish
Preserving merge bubbles - the problem
The issue with the above is that invoking git rebase
will flatten merge bubbles unless you pass the --rebase-merges
flag.
For example if I have this repo
* 5e327d2 (HEAD -> main) Merge branch 'feature/add-greet-function'
|\
| * 6c4ea2d (feature/add-greet-function) Add greet function
|/
* 9b18f79 Merge branch 'feature/add-contributors'
|\
| * e5d86e3 (feature/add-contributors) Add contributors list
|/
* 6716452 Merge branch 'feature/add-readme'
|\
| * a9754cb (feature/add-readme) Add readme
|/
* c3b7967
When I call git edit a9754cb
and make changes the result would be the following
* ab45054 (HEAD -> main) Add greet function
* 07fc0f8 Add contributors list
* a9754cb (feature/add-readme) Add readme
As I alluded to earlier my original implementation was actually more complicated than it needed to be and introduced brittleness - here it is:
#!/usr/bin/env ruby
if ENV['GIT_SEQUENCE_EDITOR'] != __FILE__
exec "GIT_SEQUENCE_EDITOR='#{__FILE__}' git rebase --interactive #{ARGV.first}~1"
end
todo_list = ARGV.first
lines = File.readlines(todo_list)
.lazy
.take_while { |line| line != "\n" }
.map { |line| line.gsub("fixup", "pick") }
.to_a
lines[0] = lines[0].gsub("pick", "edit")
File.write(todo_list, lines.join)
There are two things to call out in this implementation that went wrong.
1) I have hardcoded the assumption that the line I need to edit is the first in the file
2) I’m iterating over the todo list line by line and stopping on the first empty new line
If we look at the todo list for the previous repo when adding the --preserve-merges
flag we can see that both points above are troublesome
label onto
# Branch feature-add-readme
reset onto
pick a9754cb Add readme
label feature-add-readme
# Branch feature-add-contributors
reset onto
merge -C 6716452 feature-add-readme # Merge branch 'feature/add-readme'
label branch-point
pick e5d86e3 Add contributors list
label feature-add-contributors
# Branch feature-add-greet-function
reset branch-point # Merge branch 'feature/add-readme'
merge -C 9b18f79 feature-add-contributors # Merge branch 'feature/add-contributors'
label branch-point-2
pick 6c4ea2d Add greet function
label feature-add-greet-function
reset branch-point-2 # Merge branch 'feature/add-contributors'
merge -C 5e327d2 feature-add-greet-function # Merge branch 'feature/add-greet-function'
# Rebase c3b7967..5e327d2 onto c3b7967 (16 commands)
#
# <git rebase instructions here>
#
The format is completely different to the simple example above because git needs more details to know how to rebuild history. The new format includes empty lines in a few places and the line to edit is no longer the first line 🤦🏼.
Preserving merge bubbles - the fix
The simplest fix is to be less strict - instead of treating this as a line by line operation like a person would I can just be fairly slapdash with substitutions to get the same result.
#!/usr/bin/env ruby
if ENV['GIT_SEQUENCE_EDITOR'] != __FILE__
exec "GIT_SEQUENCE_EDITOR='#{__FILE__}' TARGET_SHA=#{ARGV.first} git rebase -i #{ARGV.first}~1 --rebase-merges"
end
sha = ENV["TARGET_SHA"]
todo_list_file_name = ARGV.first
transformed_todo_list = File.read(todo_list_file_name)
.gsub(/^fixup/, "pick")
.gsub(/^pick #{sha}/, "edit #{sha}")
File.write(todo_list_file_name, transformed_todo_list)
In the above I don’t read the lines of the file I just YOLO it and swap any fixup
at the beginning of a line with pick
and more importantly I change any pick
followed by my sha to an edit
.
The other subtle change is that the when my script is handed the todo list I don’t know what sha the command was invoked with.
To get around this when I invoke the rebase I first set an environment variable TARGET_SHA
so I can pull it out when updating the todo list.
With these changes in place my git-edit
command now works for the simple case and in the less common case where I’m editing further back in history with merge commits.
Bonus
When changing history I have two common cases
1) Making edits to source
2) Updating a commit message
The mechanism for doing either of these is identical apart from needing to use the todo list command reword
instead of edit
.
To avoid writing an identical script I instead create a single script called git-edit
and then symlink to it with another file called git-reword
.
I can then tweak my script to look like this
#!/usr/bin/env ruby
if ENV['GIT_SEQUENCE_EDITOR'] != __FILE__
exec "GIT_SEQUENCE_EDITOR='#{__FILE__}' TARGET_SHA=#{ARGV.first} git rebase -i #{ARGV.first}~1 --rebase-merges"
end
command = File.basename($0).split('-').last
sha = ENV["TARGET_SHA"]
todo_list_file_name = ARGV.first
transformed_todo_list = File.read(todo_list_file_name)
.gsub(/^fixup/, "pick")
.gsub(/^pick #{sha}/, "#{command} #{sha}")
File.write(todo_list_file_name, transformed_todo_list)
In the above the command is determined by which git command was invoked e.g.
Calling git edit
invokes the git-edit
script and calling git reword
invokes the git-reword
script.
The rebase command is then extracted from the script’s file name using
command = File.basename($0).split('-').last