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
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
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.