Search code examples
gitrebase

Git Interactive Rebase Patch Before a 3-Way Merge


I have the following Git history:

Git History Before

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:

Git History After

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.


Solution

  • UPDATE: 06-MAY-2021

    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
    

    Answer:

    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.