TL;DR
You need to git reset --hard HEAD
(or anything equivalent) before applying with --index
like this. All the usual caveats around hard resets apply.
Long
I linked in a comment to How do I properly git stash/pop in pre-commit hooks to get a clean working tree for tests? which shows how to do the final pop (or equivalent), and some of the caveats around this. However, the answer to the question as asked—specifically How to force git to use fast-forward when applying a stash—is that you can't, and in fact, the question doesn't even make sense: fast-forward is a different concept from stashing and unstashing.1
A Git stash is simply a set of commits (two unless you use the --all
or --include-untracked
option, then you get three) with a special arrangement. The commits save:
- the index at the time of
git stash
(using git write-tree
);
- the work-tree contents at the time of
git stash
(using rather complex code);
- and last in this list, but actually done earlier, if you did use
--all
or --include-untracked
, the files that were untracked including ignored files (--all
) or the files that were untracked excluding ignored files (--include-untracked
).
Git then resets the work-tree, normally to match the HEAD
commit, and if --all
or --include-untracked
were used, removes the files stored in the third commit as well. When you use --keep-index
, though, Git resets the work-tree to match the index contents.
The reference named refs/stash
is modified to point to the work-tree commit. This commit has, as its parents, the HEAD
commit (parent #1), the index commit (parent #2), and if present, the untracked-files commit (parent #3). The index has as its parent the HEAD
commit. The untracked-files commit has no parent (is a root commit):
...--o--o--o <-- refs/heads/somebranch (HEAD)
|\
i-w <-- refs/stash
/
u
or more typically, the same without u
.
When git stash
resets to HEAD
(i.e., without --keep-index
), all you have to do undo what git stash
did is run git stash pop --index
(note: not --keep-index
!). This runs git stash apply
with the same options and arguments,2 and if that succeeds without merge conflicts, runs git stash drop
on the same stash.
The apply can use both the index commit and the work-tree commit to recover what you were working on, but by default, it ignores the index commit. Adding --index
tells Git to apply the index commit (turned into a diff against the current index contents) to the current index contents, using git apply --index
. If this fails, git stash
stops and does nothing. In this I would suggest one turn the stash into a new branch using git stash branch
, though git stash
merely suggests applying without --index
.3
In any case, Git then tries to apply the work-tree commit to the current work-tree.4 If you had stashed without --keep-index
, and made no changes to the current work-tree, this would always succeed: the current index and work-tree would match the HEAD
commit so this would leave the current index unchanged and apply all the differences in the work-tree commit to the work-tree itself, resulting in recovery of the stashed work-tree.
The problem at this point is that you did use --keep-index
, so the current work-tree matches the index that you set up, rather than matching the HEAD
commit. Hence, before you apply the stash (with or without --index
), you must first reset the work-tree to match the HEAD
commit, i.e., git reset --hard
. The index and work-tree states you want are in the stash you're about to apply, so this is safe as long as the current index and work-tree have not been modified by whatever pre-commit / pre-push code you have.
Once you have done that, a git apply --index
of the stash commits will restore both the index and the work-tree (modulo that bug in the linked question!).
Footnotes
These are out of order on purpose because footnote 1 is so long.
2The argument to git stash apply
defaults to refs/stash
. If you give it any argument, the behavior is a little fancier: in recent versions of Git, if you give it an all-numeric argument n it examines stash@{n}
, otherwise it uses whatever you gave it. It passes this string to git rev-parse
to make sure that it converts to a valid hash ID, and that when suffixed with :
, ^1
, ^1:
, ^2
, and ^2:
, those also convert to valid hash IDs. If the string produces a valid hash ID with both ^3
and ^3:
, those are also remembered. These collectively form the w_commit
, w_tree
, b_commit
, b_tree
, i_commit
, and i_tree
, plus the u_commit
and u_tree
if they exist. See the gitrevisions documentation for how this works in more detail.
What this boils down to is that any argument you pass to git stash apply
must have the form of a merge commit, with at least two parents. Git does not check whether there are additional parents beyond the prospective three, nor whether this merge commit really is a stash: it just assumes that if it has the right set of parent-age, you intend to use it as one.
3This might be sensible enough for Git neophytes who are not trying to stash the index separately and used --index
on git stash apply
or git stash pop
without understand it. Once you do understand the index, though, it's clearly wrong: you wanted to restore the stashed index's changes relative to your current index, to your current index, not ignore them entirely! Committing your current index if appropriate, then committing your current work-tree if appropriate, and then turning the stash into a branch and committing its work-tree, gives you everything you need to build the correct final results.
4Technical details: the application uses git merge-recursive
—this is what implements git merge -s recursive
—with some secret environment variables to set the names on the conflict markers, if there is a conflict. The merge base is the commit that was HEAD
when the stash was made, the current tree is the the result of writing the current (at un-stash time) index, and the item being merged is the work-tree commit, or more precisely, its tree. This makes use of the fact that some merges can be run with uncommitted changes. The front end git merge
command prohibits merge attempts with uncommitted changes, as the results can be very messy when there's a problem.
1The fast-forward concept is also a bit more complicated than one typically sees it at first. That is, we see it when merging—see What is the difference between `git merge` and `git merge --no-ff`?—but it actually refers to updating a reference, such as a branch name. A branch name update is a fast-forward if and only if the new commit hash has the old commit hash as an ancestor, i.e., if git merge-base --is-ancester $old_hash $new_hash
returns a zero exit status.
When git merge
performs one of these fast-forward operations, it means that Git has changed the HEAD
commit to point to the new hash, and also updated the index and work-tree as necessary. If you were to fast-forward to the work-tree commit in the stash, that would expose the weird technically-a-merge work-tree commit to the rest of Git, where it would be, at the least, very confusing.
Note that git fetch
and git push
also perform fast-forward operations, or with --force
, allow non-fast-forward changes to branch and (for fetch) remote-tracking names. The receiver of a push normally requires a fast-forward because that means that the updated branch name contains all of the commits that it used to, plus some additional commits. A forced, non-fast-forward update discards commits from the branch (whether or not it adds new ones). The somewhat mysterious git fetch
output records whether a remote-tracking name was fast-forwarded or forced in three (!) ways:
$ git fetch
remote: Counting objects: 1701, done.
remote: Compressing objects: 100% (711/711), done.
remote: Total 1701 (delta 1363), reused 1318 (delta 989)
Receiving objects: 100% (1701/1701), 975.29 KiB | 3.65 MiB/s, done.
Resolving deltas: 100% (1363/1363), completed with 284 local objects.
From [url]
3e5524907..53f9a3e15 master -> origin/master
61856ae69..ad0ab374a next -> origin/next
+ fc16284ea...4bc8c995a pu -> origin/pu (forced update)
9125ddae1..9db014fc5 todo -> origin/todo
* [new tag] v2.18.0 -> v2.18.0
* [new tag] v2.18.0-rc2 -> v2.18.0-rc2
Note the +
in front of line recording the update to origin/pu
, and the words (forced updated)
added. That's two of the three ways. Pay attention to the dots between the two abbreviated commit hashes, though: all the other lines, which are not forced updates, show two dots, but this update shows three dots. That's because we can use git rev-list
or git log
with this same three-dot syntax to view the commits added and removed:
$ git log --oneline --decorate --graph --left-right fc16284ea...4bc8c995a
> 4bc8c995a (origin/pu) Merge branch 'sb/diff-color-move-more' into pu
|\
| > 76db2b132 SQUASH????? Documentation breakage emergency fix
| > f2d78d2c6 diff.c: add white space mode to move detection that allows indent changes
| > a58e68b88 diff.c: factor advance_or_nullify out of mark_color_as_move
[massive snippage]
< fc16284ea Merge branch 'mk/http-backend-content-length' into pu
|\
| < 202e4a2ff SQUASH???
| < cb6d3213e http-backend: respect CONTENT_LENGTH for receive-pack
< | 4486a82e5 Merge branch 'ag/rebase-p' into pu
< | a84cc85f3 Merge branch 'nd/completion-negation' into pu
[much more snippage]
The --left-right
option, along with the three-dot syntax, tells Git to mark which "side" the commits came from. In this case the >
commits are now on the pickup branch, and the <
commits have been taken off it. These particular removed commits are now entirely unreferenced and will be garbage collected soon(ish).