Search code examples
gitgit-rewrite-history

How to split a commit into two without introducing merge conflicts?


I had lines like this to change:

service.audit(Message.delete()[…] [long line]

I had to add withMessage(…) to them where that was missing. At the same time I formatted the places where the lines were long:

service.audit(Message.
    .delete()
    .withMessage([…])
    .withUser(…)
    .withPayload(…)
    […]

Now I want to split this commit into two:

  1. Format
  2. Add new method

But if I do a split in the standard way, say with:

  • Do an interactive rebase
  • Reset on the commit
  • Stage the changes I want:
    • Remove the call withMessage(…) from the staged changes/index, leaving just the formatting changes
    • Commit
  • Cherry-pick the old commit

I get merge conflicts since I did changes in the same places.


Solution

  • We’re not gonna use git-rebase(1) and/or git-cherry-pick(1) since these turn commits into patches in order to make new commits. That’s why you get merge conflicts.

    Instead you want to make use of the fact that commits are snapshots.

    • You already have the snapshot you want to end up with: that is currently your one and only commit, which will after the split become the second commit
    • You can then craft the first commit by using the changes in the first commit: manually remove the changes you don’t want and then commit
    • Make a new commit for the second change using the original (unsplit) commit

    More concretely:

    • Store the hash of the current commit (hash)
    • Do a reset back one commit
    • Remove the withMessage(…) lines
    • Commit
    • Make a commit hash and the parent set to the current commit (the first commit in the split; HEAD)
    #!/usr/bin/env bash
    
    hash=$(git rev-parse @)
    # Safely do `git reset`
    # See: https://stackoverflow.com/a/2659808/1725151
    git diff-index --quiet --cached HEAD -- \
        || { printf "staged changes: refusing to check out"; exit 1; }
    git diff-files --quiet \
        || { printf "unstaged changes: refusing to check out"; exit 1; }
    git reset --soft @~1
    # Manipulate the staged changes
    # Give a shell prompt to pause and allow that
    printf "Stage your changes and then type ‘exit’\n"
    sh
    # You can edit the commit message with an interactive rebase afterwards
    git commit -msplit
    # Add back the original commit
    git restore -SW -s "$hash" -- .
    git commit -C"$hash"
    

    Work backwards instead of forwards

    In this case it helps to keep the state of the starting point and then move one step backwards. Since you have the desired end-state already the only manual work is to remove things from what will become the first commit of the two.

    Contrast with a rebase session where you go back N commits, change to the state you want back at that point, and then deal with merge conflicts (if overlapping) as you apply the changes forwards.

    Use git-rebase(1) for touchups like changing the commit messages

    It’s natural that the commit messages will change after a split. We leave that for later:

    • Use a placeholder message “split” on the new first commit (git commit -msplit)
    • Keep the original commit message on the second commit

    Since we can already change the commit messages easily in a rebase session: there are no possible conflicts any more so we can just focus on that part.

    Generalization

    This script splits the current commit you are on. What if you want to do a split N commits back?

    1. Do an interactive rebase
    2. Mark the commit you want to split for editing (edit)
    3. Do this procedure
    4. git rebase --continue

    (Thanks to j6t)