Search code examples
gitversion-controlrebase

`git rebase --skip` but changes still included


I'm learning Git rebase :
I create a conflict situation to test the behavior of git rebase -i commands. I git rebase --skip when the attended conflict happened,
but a other conflict happened after, and the "skipped" data reappear in a other commit.

At the end : No data lost,
What --skip have done ?

Here the conflict situation I created to test :

  • I have my current code saved in a commit : "saved"
[code]
  • I add a feature -> ctrl + s -> git add . -> New commit : "feature", everything's ok.
[code]
[feature]
  • And something I will skip for the test -> ctrl + s -> git add . -> New commit : "skip".
[code]
[feature]
skip
  • Finally something after -> ctrl + s -> git add . -> New commit : "new feature".
[code]
[feature]
skip
[new feature]

The situation is set.

I now launch the rebase test with git rebase -i HEAD~4,
and move the "skip" commit up (before "feature"), to generate a conflict.

pick 9fc9fbd save
pick 036ad6a skip
pick da647db feature
pick 8763ea1 new feature

Let's go, the conflict is here :

Auto-merging git-modif-annulation.md
CONFLICT (content): Merge conflict in git-modif-annulation.md
error: could not apply 036ad6a... skip
hint: Resolve all conflicts manually, mark them as resolved with
hint: "git add/rm <conflicted_files>", then run "git rebase --continue".
hint: You can instead skip this commit: run "git rebase --skip".
hint: To abort and get back to the state before "git rebase", run "git rebase --abort".
Could not apply 036ad6a... skip

So i git rebase --skip as intended :
-> "skip" is skipped,
-> "feature" is applied,
and...

That wasn't expected :
New conflict on "new feature" :

<<<<<<< HEAD
=======
  skip
  [new feature]
>>>>>>> new feature

[new feature] should be here, but why skip is ?

That make me rethought all I knew about how Git work :
I thought only changes was saved in commits, in an incremental way.
But if skip appear in "new feature", it mean that each commit is a new complete save of the project ? Or maybe just the file ?

Anyway : Skipping "skip" had no effect.
But the GitHub docs about this say it should :

You can run git rebase --skip to completely skip the commit. That means that none of the changes introduced by the problematic commit will be included.

What did i miss ?

Edit :

I try to drop the "skip" commit instead : same result.

pick 9fc9fbd save
pick da647db feature
drop 036ad6a skip
pick 8763ea1 new feature

Drop the "skip" commit generate the same conflict when the next commit is apply :
All the modifications brought by "skip" are brought by the next commit...


Solution

  • It's the cherry pick effect (note: git rebase is one way to batch a sequence of individual cherry-picks).

    When you cherry-pick a commit, git resorts to a 3 way merge, but using a merge base which is not represented by the existing history.

    Let me try to illustrate :

    # starting point:
    
    * (HEAD -> master) master head commit
    | * (feature) c2 - feature head commit
    | * c1
    | * p
    | |
    |/
    *
    *
    ...
    

    if you currently are on master, and run git cherry-pick c1 :

    git will resolve a 3 way merge, using :

    • HEAD as "ours"
    • c1 as "theirs"
    • -> and p as base (<- that's the changing point)

    The mnemonic I use to remember this:

    • you need to identify 3 commits,
    • obviously, HEAD will be involved,
    • to "get the diff for c1" in git, you actually need c1 and his parent

    git behaves exactly as if it was solving the following merge :

    * (HEAD -> master) master head commit  #
    | * c1                                 # <- not your current history
     \|                                    # 
      * p                                  # 
    $ git merge c1
    

    so the diff you see on the "ours" side is the diff between p and HEAD -- even though p and HEAD are, in the sequence of commits, not directly related.


    Now suppose you cherry-pick two commits: git cherry-pick c1 c2 (note : this would be the same as git rebase --onto master p c2)

    For some reason (conflicts ...) you decide to skip over c1.
    On the next step (next step is git cherry-pick c2), the "ours" side of the 3 way diff will be the diff between c1 and master.

    So, even though you haven't kept c1 on top of master, the diff that will be presented to you still relates to the content of c1.


    Coming back to your example: c1 would be skip, c2 would be new feature.

    Even though you skipped c1, when trying to cherry-pick c2, git still tries to reconcile a diff saying "'skip' line was deleted" (git diff skip *current state of rebase* <- you chose to not include skip there) with "add 'new feature' after line 'skip'" (git diff skip new_feature).
    This is why you see this conflict.


    More generally, this is the reason why, when doing a rebase, you may have a feeling of fixing the same conflict over and over again :

    • you fix it when replaying commit n,
    • but when replaying commit n+1, you will still have a diff which doesn't reflect this first resolution.

    There is a git feature to mitigate this problem : git rerere -- acronym for "Reuse recorded resolution"

    To enable it:

    git config --global rerere.enabled true
    

    It somehow stores the resolution you provide for conflicts, and when it identifies the same conflict again, reapplies the same resolution.

    See git help rerere for more details.


    [edit: following your comment]

    I warmly recommend to read: https://git-scm.com/book/en/v2/Git-Tools-Advanced-Merging
    among other things it explains the two different standards to display conflicts in file.

    When you inspect the content of a file which has conflicts, the default display format is the so called merge format :

    $ cat file 
    [code]
    [feature]
    <<<<<<< HEAD
    =======
    skip
    [new feature]
    >>>>>>> 0ecdc58 (new feature)
    

    It shows, for each diff hunk that was determined to conflict, the ours version and the theirs version, but it doesn't display the base version.

    You can ask git to switch to the other diff3 format for a specific file:

    # note: don't do this if you had already fixed part of the conflicts in 'file'
    $ git checkout --conflict=diff3 file
    Recreated 1 merge conflict
    $ cat file
    [code]
    [feature]
    <<<<<<< ours
    ||||||| base
    skip
    =======
    skip
    [new feature]
    >>>>>>> theirs
    

    With that view it is more obvious that the version in ours is actually a deletion, while the version in theirs is an addition, with an incompatible context.

    If you think that the diff3 format is the one you always want, you set the config parameter:

    git config --global merge.conflictstyle diff3