1

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
Piotr Siupa
  • 3,929
  • 2
  • 29
  • 65

2 Answers2

1

Your main problem is "the working directory is often a mess of untested solutions, TODOs and a code that is generally very WIP.". These changes should be checked in as temporary commits, e.g.:

git add ...
git commit -m "==== Untested solution 1 ====="
git add ...
git commit -m "==== Untested solution 2 ====="
git add ...
git commit -m "==== TODO something ====="
git add ...
git commit -m "==== WIP part 1 ====="
git add ...
git commit -m "==== WIP part 2 ====="

When changes are checked in as proper commits there cannot exist any anxiety over loosing the distinction between the index and the working directory.


So with "the working directory is a huge mess" solved, let's focus on the question.

Now, there is a recurring situation when I realize that for the my current changes require something else to work first.

You should use branches for this!

mkdir example2 && cd example2 || 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 history reference point 1
git add foo
# <-------- Git history reference point 2
sed -i '3i a WIP line that should NOT be staged' foo
# <-------- Git history reference point 3

# So at this point, you realize that the line added to index needs an update but you
# do not want to include the current WIP change (and neither do you want to lose it)
# Well, the solution is to create a temporary commit for the stuff in the index
# and then check out a new branch and commit the WIP change there.

git commit -m '==== the stash with index ===='
git checkout -b wip_branch
git add foo
git commit -m '==== WIP change ===='

git checkout main
git reset HEAD^  # This discards the actual commit '==== the stash with index ====' but
                 # keeps the changes from that commit in the working directory,
                 # thus in practice restore Git history reference point 1.

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

git rebase main wip_branch

The rebase triggers a conflict which is simple to resolve by using KDiff3.

KDiff3 did not automatically match the two lines that are the same in this instance

screenshot 1

but you can override by adding manual diff alignment (indicated with orange on the side).

screenshot 2

Before the rebase the history looks like

screenshot 3

and after

screenshot 4

Resolved by running

$ git rebase main wip_branch
Auto-merging foo
CONFLICT (content): Merge conflict in foo
error: could not apply 8e05888... ==== the stash with index ====
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 8e05888... ==== the stash with index ====

$ git resolve-conflict-using-kdiff3
================================================================================
1 unmerged files in total:
        foo
================================================================================
Handling foo (1/1): Modified on both branches
1: 5776cea 2: bdb7168 3: 84ae2e5
Launch kdiff3 for foo? [YyNnQq123] (y):
Update foo with merge result? [YyNnQq] (y): y
interactive rebase in progress; onto b0f7593
Last command done (1 command done):
   pick 8e05888 ==== the stash with index ====
Next command to do (1 remaining command):
   pick a33c2fd ==== WIP change ====
  (use "git rebase --edit-todo" to view and edit)
You are currently rebasing branch 'wip_branch' on 'b0f7593'.
  (all conflicts fixed: run "git rebase --continue")

Untracked files:
  (use "git add <file>..." to include in what will be committed)
        foo.merged.orig

nothing added to commit but untracked files present (use "git add" to track)
Command(s) suggested to continue:


git rebase --skip



$ git rebase --skip
Successfully rebased and updated refs/heads/wip_branch.

$ git checkout main
Switched to branch 'main'

$ git merge --ff wip_branch
Updating b0f7593..8815707
Fast-forward
 foo | 1 +
 1 file changed, 1 insertion(+)

$ git branch -d wip_branch
Deleted branch wip_branch (was 8815707).

$ git reset HEAD^
Unstaged changes after reset:
M       foo

$

at which point the code is back to Git history reference point 3 but with the additional conflicting change injected into the history.


This answer is the most generic answer, for the particular example you provided, I could have gotten away with just using git add -p and avoided the following branch and rebase operation.

hlovdal
  • 26,565
  • 10
  • 94
  • 165
  • 1
    Oh, by the way [stop using `git stash`](https://stackoverflow.com/a/27117335/23118) and use temporary commits exclusively instead. Normal commits are much safer in they do not have [dangerous](https://stackoverflow.com/questions/5737002/how-to-delete-a-stash-created-with-git-stash-create/5737041#comment27818770_5737041), [complicated](https://stackoverflow.com/q/48619276/23118) [quirks](https://stackoverflow.com/a/75512773/23118) like stash have. – hlovdal Jul 27 '23 at 14:57
  • `git stash` has its advantages. I won't confuse stashes with normal branches, I can neatly display the them by using `git stash list` and their number shows in `git status`. From your answer I infer that there is no Git command that would do what I want. I think that I'll write a script that does rebases on the stash commits, similarly to what you've shown but without the need to do anything non-standard before the "stash pop". – Piotr Siupa Jul 27 '23 at 17:30
  • There's one think I don't understand about your example, maybe because I don't use `git rebase` often. Where the commit "==== the stash with index ====" went during the rebase? – Piotr Siupa Jul 27 '23 at 17:31
  • I don't see the benefit in committing my WIP changes. Maybe we have a different picture of what they are. For example, some comment like `//TODO check if it works if "length" is 0`. Or maybe some additional guard that I'm not sure if it is necessary until I finish the function I'm writing. Generally, they are some things that need to be done before I commit my changes and `git diff` allows me to view the full list of them. – Piotr Siupa Jul 27 '23 at 17:37
  • `Where the commit "==== the stash with index ====" went during the rebase?` It disappeared through the `git rebase --skip` step (because the changes in that original commit where already in place in the new rebase target branch, thus the rebased commit ended up being no (new) changes, and skipping it then is the natural choice). – hlovdal Jul 28 '23 at 00:17
0

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 command and is quite error-prone. A much better idea is 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.

Piotr Siupa
  • 3,929
  • 2
  • 29
  • 65