Search code examples
gitgit-stashgit-merge-conflictgit-index

Pop conflicting Git stash while keeping stash index


Explanation:

I make a heavy use of Git staging area to keep track of the changes that I'm already sure of while the working directory is often a mess of untested solutions, TODOs and a code that is generally very WIP. Loosing the distinction between the index and the working directory is a significant setback because I have to reevaluate all my changes (where often half of a line should be staged and half is a TODO comment).

Now, there is a recurring situation when I realize that for the my current changes require something else to work first. I'm a big fan of staging so what I do in that case is to git stash push and after the other change is committed and the working directory clean again git stash pop --index.

However, it is common that there are some conflicts between my stash entry and the new HEAD (usually very minor ones which is doubly annoying). This locks off the option --index and forces me to drop my cache and manually rebuild it from scratch after resolving the conflicts.

Is there a way to keep/restore the index after the conflicts are resolved? It doesn't matter to me if the conflicts will also be resolved in the staging area or these files remain exactly as they were in the stash.

I would be most happy with a way to just pop the stash without index, resolve the conflicts and slap the old index back on it but if I have to resolve conflicts 2 times (separately for the index), this is also fine.


TL;DR:

I need a way to keep the index when popping stash that conflicts with the current HEAD.


Example:

Here is a simple shell script that creates a new repository and reproduces this situation:

mkdir example && cd example || exit

git init

printf 'first line\nlast line\n' >foo
git add foo
git commit -m 'initial commit'

sed -i '2i a good line that should be staged' foo
git add foo
sed -i '3i a WIP line that should NOT be staged' foo
git stash push -m 'the stash with index'

sed -i '2i some conflicting change' foo
git commit -a -m 'a new HEAD conflicting with stash'

git stash pop --index  # this doesn't work

Solution

  • It doesn't look like there is a out-of-the-box way to resolve conflicts and keep the index at the same time. During conflict resolution, Git uses the staging area for its own purposes, which effectively erases the data there.

    However, a stash entry is just a few commits in the repository. The command git stash is provided for our convenience to manage those commits but we don't have to use it. You can instead pop the stash manually in a way that preserves the index.

    The key is to merge the stash into the current HEAD in 2 steps: first only the index and later the rest. (You can use multiple commits to keep track of which files are from where and make sure that solving conflicts won't remove any information.)


    First, you need to convert the commit structure of the stash entry into something sane. The normal stash entry comprises of 2 or 3 commits woven into a bizarre web of merges. This is not only pointlessly complicated but also hard to work with. Instead, you could have just two linear commits: first with indexed changes and the second with the non-index ones.

    First we move HEAD to the commit that stores the indexed files from the 1st stash entry. stash@{N} is the top commit of the stash entry number N and stash@{N}^2 is its second parent. (Stash entry always has at least 2 parent commits: the base commit at which the entry was created and a commit storing stashed index.) You can use the option --detach because these commits will be temporary and there is no use for a branch.

    git switch --detach stash@{0}^2
    

    For the second commit, you should convert the tip of the stash entry from a merge commit into a normal commit, using git merge --squash. The code below additionally checks if that merge has a 3rd parent that stores untracked files. If this is the case, they are also added.

    git merge --squash stash@{0}
    if git rev-parse stash@{0}^3 1>/dev/null 2>&1
    then
        git ls-tree -r --name-only stash@{0}^3 -z \
        | xargs -0 -- git restore --source=stash@{0}^3 --
        git add .
    fi
    git commit
    

    At this point the Git repository should look like follows:

    A -----> stash index -----> stash non-index (HEAD)
        \
         \-> B
    

    A is the initial commit where the changes were pushed to stash and B is the new commit where you want to apply the changes. (Btw, the original stash entry is not drawn here but it still exists. It isn't lost it or anything.)


    The second step is to just rebase the simplified stash entry onto the branch where you want to apply it.

    It's just one command:

    git rebase --onto B HEAD~2 HEAD
    

    At this stage you will have to resolve the conflicts that blocked you from applying the stash before.

    After it's all finished, the repository should look like this:

    A -----> B -----> stash index -----> stash non-index (HEAD)
    

    The third step is to remove the commits, without losing any changes or the contents of the index.

    It is as simple as:

    git reset --mixed HEAD~
    git reset --soft HEAD~
    

    The forth and the last step is just some cleanup.

    Currently you are in a detached HEAD state and you most likely started the whole operation at a top of some branch like a sane Git user. You need to switch back to your branch:

    git switch your_branch
    

    You can also remove the stash entry if it's no longer needed:

    git stash drop
    

    An entire script

    Popping stash in this way takes a lot of commands and is quite error-prone. A much better idea is to create a script that can do it automatically and also provides some rudimentary idiot-proofing.

    #!/usr/bin/env sh
    
    set -e
    
    git_dir="$(git rev-parse --git-dir)"
    
    rebase_failed=0
    
    if [ "$1" = '--continue' ]
    then
        
        shift
        
        if [ $# -gt 0 ]
        then
            printf 'Too many arguments!\n' 1>&2
            exit 1
        fi
        
        if ! [ -f "$git_dir/better-unstash" ]
        then
            printf 'There is no "better-unstash" operation in progress!\n' 1>&2
            exit 1
        fi
        {
            read -r current_branch
            read -r detached
        } <"$git_dir/better-unstash"
        rm -f "$git_dir/better-unstash"
        
        if ! git -c 'core.editor=true' rebase --continue
        then
            rebase_failed=1
        fi
        
    else
        
        if [ $# -eq 0 ]
        then
            stash='stash@{0}'
        elif [ $# -eq 1 ]
        then
            if [ "$1" -eq "$1" ] 2>/dev/null
            then
                stash="stash@{$1}"
            else
                stash="$1"
            fi
        else
            printf 'Too many arguments!\n' 1>&2
            exit 1
        fi
        
        if ! git diff --quiet HEAD
        then
            # There are still are some limitations.
            printf 'There are uncommitted changes in the working directory!\n' 1>&2
            printf 'Commit or stash them before attempting unstashing with index.\n' 1>&2
            exit 1
        fi
        
        detached=0
        current_branch="$(git rev-parse --abbrev-ref HEAD)"
        if [ "$current_branch" = 'HEAD' ]
        then
            detached=1
            current_branch="$(git rev-parse HEAD)"
        fi
        
        git switch --detach "$stash^2"
        git merge --ff-only --squash "$stash"
        if git rev-parse "$stash^3" 1>/dev/null 2>&1
        then
            git ls-tree -r --name-only "$stash^3" -z \
            | xargs -0 -- git restore --source="$stash^3" --
            git add .
        fi
        git commit --no-edit --no-verify --allow-empty
        if ! git rebase --onto "$current_branch" "HEAD~2" "HEAD"
        then
            rebase_failed=1
        fi
        
    fi
    
    if [ "$rebase_failed" -ne 0 ]
    then
        printf 'USE `%s --continue` INSTEAD OF `git rebase --continue`!\n' "$0"
        printf '%s\n%s\n' "$current_branch" "$detached" >"$git_dir/better-unstash"
        exit 1
    fi
    
    git reset --mixed HEAD~
    git reset --soft HEAD~
    if [ "$detached" -eq 0 ]
    then
        git switch "$current_branch"
    fi
    
    printf 'The stash is kept because this is a higher-risk non-standard script.\n'
    

    This script works similarly to the git rebase in the sense that it will exit on conflicts and it needs to be restarted with a flag --continue after they are fixed.

    For the initial run, you can pass an optional argument that specifies the stash entry to pop.