3
  • I'm writing a pre-commit/pre-push script
  • But the script should only act on staged changes
  • To achieve this, I use git stash push --keep-index to remove unstaged changes
  • and then git stash pop to reapply them after the scripts

However, git stash pop will always create a merge conflict if there are both staged and unstaged changes on the same line. For example,

$ echo "print('a')" >> main.py  # main.py already exists
print('a')
$ git add main.py
$ sed -i 's/a/b/g' main.py  # now it's print('b')
$ git status --short
## master
MM main.py
$ git stash push --keep-index
Saved working directory...
$ git stash pop
Auto-mergin main.py
CONFLICT (content): Merge conflict in main.py

How do I get git to apply the changes in the stash preferentially over the staged changes?


I have some idea that this behaviour might be due to the stashed changes having two parents---the first being the HEAD, and the second being the index. Git then tries to perform a three-way merge.

But in my use case, this doesn't make sense. The script does not mutate the files in anyway, so really I'm just looking to 'fast-forward' the stash apply. Or, I need to 'rebase' the stash so that it's only parent is the index.

ning
  • 1,823
  • 1
  • 19
  • 25
  • i dont know if it helps but maybe you can create a patch instead? – paranoidAndroid Jun 23 '18 at 20:13
  • This is not technically a duplicate since you have a bit of an [XY problem](https://meta.stackexchange.com/questions/66377/what-is-the-xy-problem) here, but see https://stackoverflow.com/q/20479794/1256452. One of the answers links to https://pre-commit.com/ which looks good from a quick overview. – torek Jun 23 '18 at 22:08

1 Answers1

3

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

torek
  • 448,244
  • 59
  • 642
  • 775