9

I want to be able to run my tests for my project with the current state of the index, ignoring non-committed working changes (I later plan to add this to a pre-commit hook). However, I am having trouble figuring out how to remove and then restore the non index changes in a way that never causes merge conflicts. I need this because it is being run by a script, so it shouldn't alter the repository state when finished.

git stash --include-untracked --keep-index and git stash pop come close, but in many cases it results in merge conflicts, even if no changes where made between the two commands.

For example:

mkdir project; cd project; git init .;

# setup the initial project with file.rb and test.rb
cat > file.rb <<EOF
def func()
  return 42
end
EOF

cat > test.rb <<EOF
#!/usr/bin/env ruby
load './file.rb'
if (func() == 42)
  puts "Tests passed"
  exit 0
else
  puts "Tests failed"
  exit 1
end
EOF

chmod +x test.rb
git add .
git commit -m "Initial commit"

# now change file.rb and add the change
cat > file.rb <<EOF
def func()
  return 10 + 32
end
EOF
git add file.rb

# now make a breaking change to func, and don't add the change
cat > file.rb <<EOF
def func()
  return 20 + 32 # not 42 anymore...
end 
EOF

From here I want to run the tests against the current state of the index, and the restore the uncommitted changes. The expected result is for the tests to pass, as the breaking change wasn't added to the index.

The following commands do NOT work:

git commit --include-untracked --keep-index
./test.rb
git stash pop

The the problem occurs in the git stash pop - a merge conflict occurs.

The only other solution I could think of was to make a temporary commit, then stash the remaining changes, then rollback the commit with git reset --soft HEAD~, then pop the stash. However that is both cumbersome, and I'm not sure how safe that is to run in a pre-commit hook.

Is there a better solution to this problem?

khagler
  • 3,728
  • 26
  • 38
David Miani
  • 14,178
  • 2
  • 42
  • 63
  • Is it because `git stash --include-untracked --keep-index` doesn't actually do anything and `git stash pop` is popping off some other previously stashed patch? The problem is that `git stash` will happily exit with a status of 0, even if nothing had been stashed. You need to look out for the "No local changes to save" message coming from `git stash`. It's a bit fragile, but I think it's the best you can do short of getting a change introduced to Git to make `git stash` behave a little differently. – John Szakmeister Dec 15 '12 at 08:24
  • No, it is definitely stashing something, and still causing conflict problems. I believe it is because `git stash --keep-index` still stashes the index even if it doesn't remove the changes from the working copy (going by similar questions). Although the problem with git stash not reporting that the stash wasn't created (or the fact that it doesn't have an option to force a stash to be created even if empty) is another annoyance. – David Miani Dec 15 '12 at 12:22

3 Answers3

8

Like you, I run

git stash --keep-index --include-untracked

I can then run tests and so on.

The next part is tricky. These are some things I tried:

  • git stash pop can fail with conflicts, which is unacceptable.
  • git stash pop --index can fail with conflicts, which is unacceptable.
  • git checkout stash -- . applies all tracked changes (good), but also stages them (unacceptable), and does not restore untracked files from the stash (unacceptable). The stash remains (fine -- I can git stash drop).
  • git merge --squash --strategy-option=theirs stash can fail with conflicts, which is unacceptable, and even when it doesn't conflict it does not restore untracked files from the stash (unacceptable).
  • git stash && git stash pop stash@{1} && git stash pop (trying to apply the changesets in reverse order) can fail with conflicts, which is unacceptable.

But I found a set of commands which does what we want:

# Stash what we actually want to commit
git stash
# Unstash the original dirty tree including any untracked files
git stash pop stash@{1}
# Replace the current index with that from the stash which contains only what we want to commit
git read-tree stash
# Drop the temporary stash of what we want to commit (we have it all in working tree now)
git stash drop

For less output, and condensed into one line:

git stash --quiet && git stash pop --quiet stash@{1} && git read-tree stash && git stash drop --quiet

As far as I'm aware, the only thing this doesn't restore is files which were added in the index and then deleted from the working tree (they'll end up added and present) and files which were renamed in the index and then deleted from the working tree (same outcome). For this reason we need to look for files which match these two cases with a line like git status -z | egrep -z '^[AR]D' | cut -z -c 4- | tr '\0' '\n' before the initial stash, and then loop through and delete them after restoring.

Obviously you should only be running the initial git stash --keep-index --include-untracked if the working tree has any untracked files or unstaged changes. To check for that you can use the test git status --porcelain | egrep --silent '^(\?\?|.[DM])' in your script.

I believe this is better than the existing answers -- it doesn't need any intermediate variables (other than whether the tree was dirty or not, and a record of which files need to be deleted after restoring the stash), has fewer commands and doesn't require garbage collection to be switched off for safety. There are intermediate stashes, but I'd argue this this exactly the kind of thing they're for.

Here's my current pre-commit hook, which does everything mentioned:

#!/bin/sh

# Do we need to tidy up the working tree before tests?
# A --quiet option here doesn't actually suppress the output, hence redirection.
git commit --dry-run >/dev/null
ret=$?
if [ $ret -ne 0 ]; then
    # Nothing to commit, perhaps. Bail with success.
    exit 0
elif git status --porcelain | egrep --silent '^(\?\?|.[DM])'; then
    # There are unstaged changes or untracked files
    dirty=true

    # Remember files which were added or renamed and then deleted, since the
    # stash and read-tree won't restore these
    #
    # We're using -z here to get around the difficulty of parsing
    # - renames (-> appears in the string)
    # - files with spaces or doublequotes (which are doublequoted, but not when
    #   untracked for unknown reasons)
    # We're not trying to store the string with NULs in it in a variable,
    # because you can't do that in a shell script.
    todelete="$(git status -z | egrep -z '^[AR]D' | cut -z -c 4- | tr '\0' '\n')"
else
    dirty=false
fi

if $dirty; then
    # Tidy up the working tree
    git stash --quiet --keep-index --include-untracked
    ret=$?

    # Abort if this failed
    if [ $ret -ne 0 ]; then
        exit $ret
    fi
fi

# Run tests, remember outcome
make precommit
ret=$?

if $dirty; then
    # Restore the working tree and index
    git stash --quiet && git stash pop --quiet stash@{1} && git read-tree stash && git stash drop --quiet
    restore_ret=$?

    # Delete any files which had unstaged deletions
    if [ -n "$todelete" ]; then
        echo "$todelete" | while read file; do
            rm "$file"
        done

        # Abort if this failed
        if [ $restore_ret -ne 0 ]; then
            exit $restore_ret
        fi
    fi
fi

# Exit with the exit status of the tests
exit $ret

Any improvements welcome.

tremby
  • 7,784
  • 3
  • 49
  • 65
  • Shouldn't your regex be `'^(\?\?|MM|\sM)'`? To include modified yet completely unstaged files. – aiham Mar 31 '17 at 12:52
  • Actually how about `'^(\?\?|MM|AM|\sM)'` to also include new files that have been modified since staging? `'^(\?\?|.M)'` might be sufficient – aiham Mar 31 '17 at 13:12
  • 1
    I think you're absolutely right. Editing my answer. (Also changing the `egrep` command so that `wc` isn't needed.) Additionally we need to look for `D` in the second column (file was staged but then deleted from the work tree). – tremby Mar 31 '17 at 19:22
  • 1
    In fact the case of AD isn't handled at all by this script. The file's not deleted again during the restore step. I'm working on handling that. – tremby Mar 31 '17 at 19:34
  • 1
    @aiham, I've updated the answer. I'd love if it could be simplified at all. I don't much like how the AD and RD cases are handled but it's the best I could come up with. It might do weird things in the RD case if something was renamed from a filename which looks like a status message such as `MM somefile`, due to crapness of git-status. – tremby Mar 31 '17 at 22:13
  • 1
    I had one problem with this script. When there are no renamed/deleted files (i.e. `$todelete` is empty), `echo "$todelete" | while read file; do` results in a single iteration with `$file` set to an empty string. This, in turn, leads the script to fail with: "rm: cannot remove '': No such file or directory". Solution that works for me is to pipe echoed `$todelete` through `grep -v "^$"` before sending it to `while read`. – taketwo Feb 07 '18 at 11:09
  • Thanks for that! I've amended the script but fixed it by testing for nonzero length rather than adding a grep. – tremby Feb 07 '18 at 22:56
  • This looked promising, but still didn't solve my problem of restoring untracked files from the stash. I've posted a working solution [here](https://stackoverflow.com/a/55799386/4080966). – Erik Koopmans Apr 22 '19 at 18:52
  • @ErikKoopmans can you be more specific about what's not working? Restoring untracked files was absolutely working when I wrote this. – tremby Apr 23 '19 at 20:13
  • @tremby here's a simple example (in a clean repo): `echo ORIG > untracked.txt; git stash -u; echo NEW > untracked.txt; git add .; git commit -m "Now tracked."; git stash pop stash@{0}`. This fails with `untracked.txt already exists, no checkout`. The usage is a bit different from the question, but I got here through searches for restoring untracked files. – Erik Koopmans Apr 25 '19 at 04:58
  • @ErikKoopmans OK, yeah, that seems like absolutely expected behaviour to me. My script above doesn't attempt to handle that case. My script is intended to get the working tree into the state where everything staged exists in the work tree and everything unstaged does not, in order to run linting or tests or whatever with exactly what was staged, and then get it back to how it was. This is what this question is about. I don't see how your issue would ever come up in this scenario. – tremby Apr 25 '19 at 18:49
  • Fair enough. I followed a link here from another thread which was more related, and was encouraged by your analysis of "can fail with conflicts, which is unacceptable". My situation was failing with conflicts, which was unacceptable... so your solution seemed promising, but did not resolve my issue. I've commented in case anyone else has the same experience. – Erik Koopmans Apr 27 '19 at 10:11
4
$ git config gc.auto 0   # safety play
$ INDEX=`git write-tree`
$ git add -f .
$ WORKTREE=`git write-tree`
$ git read-tree $INDEX
$ git checkout-index -af
$ git clean -dfx
$ # your tests here
$ git read-tree $WORKTREE
$ git checkout-index -af
$ git clean -dfx
$ git read-tree $INDEX
$ git config --unset gc.auto
$ # you're back.

The git clean manpage for -x rather elliptically suggests this solution

jthill
  • 42,819
  • 4
  • 65
  • 113
  • This doesn't quite work. If I run that code with the repo created from the bash code above (replacing `#your tests here`) with `./test.rb`), the tests run correctly. However after the your code is finished, the repo no longer points to the last check in, but a new commit with no message. To recover the last head, I needed to use `git reflog`. Also, there are no files added to the index after your commands have run. – David Miani Dec 16 '12 at 00:40
  • 1
    I should have used `read-tree` not `reset`, my apologies. I fixed it above and removed the (now-)redundant commits when setting `INDEX` and `WORKTREE`. – jthill Dec 16 '12 at 02:36
  • That nearly works (it works perfectly for my example setup). The only problem now is it removes ignored files during and after completion. This is a problem for projects like rails, which have the test sqlite database .gitignored - the tests fail due to the test database not being present. It also deletes the development database which is more problematic. Is it possible to adjust your answer to preserve ignored files? Removing the `git clean` statements might do it, but I'm not sure how that interacts with the `checkout-index` and `read-tree` commands. – David Miani Dec 16 '12 at 05:19
  • All the ignored files are restored at the end, from the `WORKTREE`, right? Anything you want to add from that to your cleaned `INDEX` checkout you can do with `git checkout $WORKTREE -- path/to/it` – jthill Dec 16 '12 at 05:30
  • 1
    You are right, the ignored files are added back after it is done. Also, adding `git checkout $WORKTREE -- 'db/*.sqlite3'` just before the `# your tests here` line fixed the problems with missing databases during the test run. This way also seems more robust with me explicitly choosing what ignored files to use for the test (making bad commit less likely). Thanks for the help! – David Miani Dec 16 '12 at 05:42
  • I'm thinking of coding this into a more "industrial strength" version of `git stash`, with the half before `$ # your tests here` corresponding to `git stash save`, and the one after corresponding to `git stash pop`... But for this I'd not want to leave `gc` off all the time in-between. Does `gc` need to be turned off even during the "tests" phase? IOW, would it be a bad idea to restore `gc` right before the `$ # your tests here` line, and turn it back on right after that same line? – kjo May 10 '13 at 18:00
  • 1
    @kjo You'd need to plug the `git add`ed objects into the reference machinery, `commit-tree`ing the written trees and `update-ref`ing the commits to some ref -- much as stash does. N.B.: stash keeps the stack in its reflog, and that specific log is protected from ordinary expiration – jthill May 10 '13 at 19:13
  • Ah! Now I realize that I had not really understood why you turned off gc in the first place, but now it's clear... Thanks! – kjo May 10 '13 at 21:56
  • 1
    @kjo actually, come to think of it, you wouldn't need to commit the trees. refs can be to anything at all. So for a first cut you could `touch .git/logs/refs/mystash` once, then in the above say `git update-ref refs/mystash $INDEX; git update-ref refs/mystash $WORKTREE` before running the tests, remove the gc.auto manipulation, and in most repos you're good to go. – jthill May 10 '13 at 22:28
  • 1
    @kjo or if you're willing to dispense with the stackability, `git update-ref refs/preserve-this-tree $WORKTREE; git update-ref refs/preserve-that-tree $INDEX` before running the tests will be enough. – jthill May 10 '13 at 22:42
  • @jthill: thanks for all your suggestions! Just getting to the point I can understand them has been a huge boost to my grasp of `git`! Much appreciated. – kjo May 11 '13 at 15:41
  • @jthill: I particularly like your first idea (the one with `refs/mystash`). It's elegant. – kjo May 11 '13 at 15:51
  • 1
    @kjo thank you! Do be a little careful here, you need to consider that anything that adds to the repo could trigger a `gc --auto`. – jthill May 11 '13 at 16:05
0

Here is the best solution I have figured out so far. It works in the git hooks pre-commit and commit-msg. I've tested it when there is:

  • Only staged changes
  • Only unstaged changes
  • Both staged and unstaged changes
  • No changes

In all three cases it works correctly. The big downside is it is pretty hacky, creating and removing temp commits and stashes.

#!/bin/bash

git commit -m "FOR_COMMIT_MSG_HOOK" -n
commit_status=$?
# git stash save always returns 0, even if it
# failed creating a stash. So compare the new and old git stash list
# output to see if a stash was created
current_stash_count="$(git stash list)"
git stash save -q -u "FOR_COMMIT_MSG_HOOK"
new_stash_count="$(git stash list)"

echo "##### Running tests #####"
# put testing code here


if [[ $commit_status -eq 0 ]]; then
    git reset --soft 'HEAD~'
fi

if [[ "$current_stash_count" != "$new_stash_count" ]]; then
  git stash pop -q
fi

exit $result
David Miani
  • 14,178
  • 2
  • 42
  • 63