I have the following Git history:
I would like to interactive rebase from commit 1f63
(2 commits prior) to HEAD at feature/project-setup
as follows:
git rebase -i HEAD~2
The git-rebase-todo
file then has the following lines:
pick ff7abc8 Install initial project site packages
pick 1696181 Add `.bumpversion.cfg`
If I change the first line to edit
, apply my changes, then do a git commit --amend
and git rebase --continue
, my commit history now looks like this:
I understand interactive git rebase is cherry-picking the two commits onto the root of the rebase (in this case, commit 1f63
). My question is, how can I overwrite 1696
with e16f
and have the branching stay the same? (I want my final history to look like the original, but 1696
will be replaced by the new commit e16f
that has my changes)
My initial thought is I might first need to cherry pick those commits, then delete them, add a break, checkout feature/project-setup
and then do a fast-forward merge commit. Any thoughts?
Edit: If there's a simpler way to do this that doesn't require resetting develop, master, and 0cea8
and then remerging, please let me know.
Here is a (relatively) simple bash script that will update branch names and tags. USE AT YOUR OWN RISK! Place this either in
/usr/bin/ # On Linux
or
C:\Program Files\Git\usr\bin # On Windows
and name it
git-rebase-bti
(without a file extension), and make it executable
chmod +x path/to/git-rebase-bti
or on Windows
icacls path/to/git-rebase-bti /grant your_usrnm:(rx)
though I believe Windows gives executable permissions for files by default? (Don't quote me on that)
#! /bin/sh -
#
# Git Rebase Branch Names, Tags, and Forks
#
# File:
# git-rebase-bti
#
# Installation Instructions:
# Place this file in the following folder:
# - Linux: `/usr/bin/`
# - Windows: `C:\Program Files\Git\usr\bin`
#
# Usage:
# git rebase-bti <SHA>
#
# Authors:
# Copyleft 2020 Adam Hendry. All rights reserved.
#
# Original Author:
# Copyleft 2020 Adam Hendry. All rights reserved.
#
# License:
# GNU GPL vers. 2.0
#
# This script is free software; you can redistribute it and/or modify it
# under the terms of the GNU General Public License as published by
# the Free Software Foundation.
#
# This script is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this script; if not, write to the
# Free Software Foundation, Inc., 59 Temple Place, Suite 330,
# Boston, MA 02111-1307 USA
GIT_DIR='.git'
REBASE_DIR="${GIT_DIR}/rebase-merge"
TODO_FILE="${REBASE_DIR}/git-rebase-todo"
TODO_BACKUP="${TODO_FILE}.backup"
HEADS_FOLDER='refs/heads'
TAGS_FOLDER='refs/tags'
REWRITTEN_FOLDER='refs/rewritten'
# Initialize associative array (dictionary) variables
declare -A labels_by_sha # Rebase label names indexed by original SHA
declare -A shas_by_label # Original SHAs indexed by rebase label names
# Get heads (remove '.git/refs/heads' from beginning)
heads=($(find "${GIT_DIR}/${HEADS_FOLDER}" -type f | cut -d '/' -f 4-))
# Get tags (remove '.git/refs/tags' from beginning)
tags=($(find "${GIT_DIR}/${TAGS_FOLDER}" -type f | cut -d '/' -f 4-))
# Start the rebase operation in the background
git rebase -i --rebase-merges $1 &
# Capture the process ID
pid_main=$!
# Wait until the todo file is created
until [ -e "$TODO_FILE" ] && [ -e "$TODO_BACKUP" ]
do
continue
done
# Store rebase message
rebase_message=$(tac $TODO_FILE | sed '/^$/q' | tac)
# Store todo list
rebase_message_length=$(echo "$rebase_message" | wc -l)
todo_list=$(cat $TODO_FILE | head -n -"$rebase_message_length")
# Prompt user
printf "Calculating todo file. Please wait..." > $TODO_FILE
# Get label names
label_names=($(grep -oP '^(l|label) \K[^ ]*$' -- $TODO_BACKUP))
for label_name in "${label_names[@]}"
do
if [ $label_name = 'onto' ]
then
continue
fi
command_line=$(grep -B 1 -P '^(l|label) '"$label_name"'$' $TODO_BACKUP | head -n 1 | sed 's/\n//g')
command_name=$(echo "$command_line" | grep -oP '^(p|pick|m|merge)(?= )')
label_sha=
if [ "$command_name" = 'p' ] || [ "$command_name" = 'pick' ]
then
label_sha=$(echo $command_line | grep -oP '^(p|pick) \K[[:alnum:]]*' | cut -c1-7)
elif [ "$command_name" = 'm' ] || [ "$command_name" = 'merge' ]
then
label_sha=$(echo $command_line | grep -oP '^(m|merge) -[cC] \K[[:alnum:]]*' | cut -c1-7)
fi
shas_by_label["$label_name"]="$label_sha"
labels_by_sha["$label_sha"]="$label_name"
done
# Restore Branch Names
todo_list+="\n\n# Restore Branch Names\n"
for head in "${heads[@]}"
do
sha=$(cat "${GIT_DIR}/${HEADS_FOLDER}/${head}" | cut -c1-7)
if [ -n "${labels_by_sha[$sha]}" ]
then
todo_list+='exec git update-ref '"${HEADS_FOLDER}/${head}"' '"${REWRITTEN_FOLDER}/${labels_by_sha[$sha]}\n"
fi
# elif in `git rev-list`, pick sha and label it, then `git update-ref` here`
done
todo_list+='\n# Restore Tag Names\n'
for tag in "${tags[@]}"
do
sha=$(cat "${GIT_DIR}/${TAGS_FOLDER}/${tag}" | cut -c1-7)
if [ -n "${labels_by_sha[$sha]}" ]
then
todo_list+='exec git update-ref '"${TAGS_FOLDER}/${tag}"' '"${REWRITTEN_FOLDER}/${labels_by_sha[$sha]}\n"
fi
done
todo_list+="$rebase_message"
# Update todo file
printf "$todo_list" > $TODO_FILE
# Wait until the rebase operation is completed
wait $pid_main
# Exit the script
exit 0
Interactive rebasing can be used to effect these changes, but the branch and tag names that exist between HEAD
and the root of the rebase, which would otherwise prevent Git's garbage collection from removing these older commits, must first be removed and then reapplied after the rebasing. Unfortunately, to work properly, rebasing must be started from the tip of your history (i.e. the develop
branch)
Rebase from develop
:
git checkout develop
git branch -D feature/project-setup
git branch -D master
git tag -d 0.1.0
git rebase -i --rebase-merges
Add the edit
to the commit you wish to change, then stage the changes (git add -A
), amend commit (git commit --amend
), and finish rebasing (git rebase --continue
).
Afterwards, add the branch and tag names back one-by-one
git branch master cfa8
git branch feature/project-setup 1696
git checkout master
git tag 0.1.0
Although the git-rebasetags script developed here is a good start, it only works on Linux machines, only rebases tags and not branch names, matches on tag commit messages (which won't work for non-annotated tags), and uses python instead of shell scripting, which is slightly less portable.
Alternatively, the rebase-todo
could be updated as follows:
label onto
# Branch feature-project-setup
reset onto
pick ff7abc83 Install initial project site packages
pick 1696181f Add `.bumpversion.cfg`
label feature-project-setup
# Branch release-0-1-0
reset 8e2d63e # Initial commit
merge -C c598c3bf feature-project-setup # Merge branch 'feature/project-setup' into develop
label branch-point
pick 0cea85a3 Bump version: 0.0.0 → 0.1.0
label release-0-1-0
# Branch 0-1-0
reset 8e2d63e # Initial commit
merge -C cfa8ed17 release-0-1-0 # Merge branch 'release/0.1.0' into master
label 0-1-0
reset branch-point # Merge branch 'feature/project-setup' into develop
merge -C a22db135 0-1-0 # Merge tag '0.1.0' into develop
label develop
# Reset branch and tag names
reset feature-project-setup
exec git branch -D feature/project-setup
exec git branch feature/project-setup
reset 0-1-0
exec git tag -d 0.1.0
exec git tag 0.1.0
exec git branch -D master
exec git branch master
reset develop
Or, since git rebase writes labels to refs/rewritten
, this could be done in fewer lines with some plumbing commands:
exec git update-ref refs/heads/feature/project-setup refs/rewritten/feature-project-setup
exec git update-ref refs/heads/master refs/rewritten/0-1-0
exec git update-ref refs/tags/0.1.0 refs/rewritten/0-1-0
where in the above the label 0-1-0
applies to both master
and tag 0.1.0
in this particular instance.
It would be great if this could be made into extra options for rebase, like --rebase-tags
and rebase-branch-names
. Unfortunately, a pre-rebase
hook won't work because this happens before the rebase-todo
is made. Also, there is no post-rebase
hook. So, it seems a separate shell script would be prudent.
Lastly, the above doesn't rebase fork points, which would also be needed if there's an unmerged fork point in the rebase path revision list. If that happens, you can try adding the following at the end of your todo list:
reset new_base_branch # Be sure to `label new_base_branch` before here at right spot
exec git branch temp_name # Give new base a temp name
exec git checkout branch_to_rebase
exec git rebase temp_name
exec git branch -D temp_name
This would also be a great additional option (like --rebase-forks
), but the code would also need to check that branch_to_rebase
doesn't actually have a child that merges back into the onto
branch outside the rebase path. For best safety, I would always rebase -i --rebase-merges
from the tip commit of your repository.