Subject: Re: Apparent bug in 'git stash push <subdir>' loses untracked files

From: Thomas Gummerer <[email-masked]>
Date: Wed, 13 Dec 2017 23:05:47 +0000

Hi,

On 12/13, Reid Price wrote:
> When running 'git stash push <subdir>' if there are both tracked and
> untracked files in this subdirectory, the tracked files are stashed
> but the untracked files are discarded.
> 
> I can reproduce this on my system (OSX, git 2.14.1) by running the
> below script as
> 
>     bash -x ./stashbug.sh &> output.txt
> 
> I could not find this indicated anywhere as an existing issue by
> performing generic searches, apologies if this is known.

Thanks for the detailed bug report below.  This is indeed a bug, and
indeed has been broken ever since I introduced the pathspec feature.
Sorry about that.

While I did implement this feature in the first place, I must admit
that I'm a bit out of my depth in terms of my shell foo to fix this
cleanly here.

Just to demonstrate what is wrong, the below diff fixes it (but it
does so in an ugly way that needs a temporary file, and needs a
specific version of the 'read' utility, that supports the '-d' flag,
which isn't in POSIX).

For the pathspec case what we're doing after creating the stash is to
essentially emulate what 'git reset --hard <pathspec>' would do.  As
'git reset --hard <pathspec>' doesn't exist, we do so using the
sequence below of reset -> checkout-index -> clean for the given
pathspec.

This works when the pathspec doesn't match any untracked files, but if
it matches untracked files such as in your case it removes them as
well as the files it should have removed.

One solution to that would be to limit the pathspec given to 'git
clean'.  And that is where my shell foo doesn't go far enough for me
being able to do that, as lists of filenames, which can include spaces
tend to get quite hairy.

Maybe the best solution would be to introduce 'git reset --hard --
<pathspec>', or maybe someone who knows shell programming a little
better than me has an idea? 

diff --git a/git-stash.sh b/git-stash.sh
index 1114005ce2..01bf74015e 100755
--- a/git-stash.sh
+++ b/git-stash.sh
@@ -322,10 +322,15 @@ push_stash () {
 
                if test $# != 0
                then
+                       git ls-files -z >"$(git rev-parse --git-dir)"/stash-to-remove
                        git reset -q -- "$@"
                        git ls-files -z --modified -- "$@" |
                        git checkout-index -z --force --stdin
-                       git clean --force -q -d -- "$@"
+                       while read -r -d '' to_delete
+                       do
+                               git clean --force -q -d -- "$to_delete"
+                       done <"$(git rev-parse --git-dir)"/stash-to-remove
+                       rm "$(git rev-parse --git-dir)"/stash-to-remove
                else
                        git reset --hard -q
                fi
diff --git a/t/t3903-stash.sh b/t/t3903-stash.sh
index 39c7f2ebd7..6952a031b2 100755
--- a/t/t3903-stash.sh
+++ b/t/t3903-stash.sh
@@ -1064,4 +1064,20 @@ test_expect_success 'stash -k -- <pathspec> leaves unstaged files intact' '
        test foo,bar = $(cat foo),$(cat bar)
 '
 
+test_expect_success 'stash -- <subdir> leaves untracked files in subdir intact' '
+       git reset &&
+       >subdir/untracked &&
+       >subdir/tracked1 &&
+       >subdir/tracked2 &&
+       git add subdir/tracked* &&
+       git stash -- subdir/ &&
+       test_path_is_missing subdir/tracked1 &&
+       test_path_is_missing subdir/tracked2 &&
+       test_path_is_file subdir/untracked &&
+       git stash pop &&
+       test_path_is_file subdir/tracked1 &&
+       test_path_is_file subdir/tracked2 &&
+       test_path_is_file subdir/untracked
+'
+
 test_done

>   -Reid
> 
> Contents of stashbug.sh
> ------------------------
>     #!/bin/sh
> 
>     uname -a
>     git --version
>     mkdir -p stashbug
>     cd stashbug
>     git init
>     mkdir dir
>     touch dir/tracked
>     git add dir/tracked
>     git commit -m 'initial'
>     tree; git status
>     mkdir dir/untracked_dir
>     touch dir/untracked_dir/casualty1
>     touch dir/casualty2
>     echo 'contents' > dir/tracked
>     tree; git status
>     git stash push dir/
>     git stash show -v
>     tree; git status
>     git stash pop
>     tree; git status
> ------------------------
> 
> Resulting output.txt
> ---------------------
>     + uname -a
>     Darwin Reids-MacBook-Pro.local 15.6.0 Darwin Kernel Version
> 15.6.0: Tue Apr 11 16:00:51 PDT 2017;
> root:xnu-3248.60.11.5.3~1/RELEASE_X86_64 x86_64
>     + git --version
>     git version 2.14.1
>     + mkdir -p stashbug
>     + cd stashbug
>     + git init
>     Initialized empty Git repository in /Users/reid/git/stashbug/.git/
>     + mkdir dir
>     + touch dir/tracked
>     + git add dir/tracked
>     + git commit -m initial
>     [master (root-commit) 895197e] initial
>      1 file changed, 0 insertions(+), 0 deletions(-)
>      create mode 100644 dir/tracked
>     + tree
>     .
>     └── dir
>         └── tracked
> 
>     1 directory, 1 file
>     + git status
>     On branch master
>     nothing to commit, working tree clean
>     + mkdir dir/untracked_dir
>     + touch dir/untracked_dir/casualty1
>     + touch dir/casualty2
>     + echo contents
>     + tree
>     .
>     └── dir
>         ├── casualty2
>         ├── tracked
>         └── untracked_dir
>             └── casualty1
> 
>     2 directories, 3 files
>     + git status
>     On branch master
>     Changes not staged for commit:
>       (use "git add <file>..." to update what will be committed)
>       (use "git checkout -- <file>..." to discard changes in working directory)
> 
>         modified:   dir/tracked
> 
>     Untracked files:
>       (use "git add <file>..." to include in what will be committed)
> 
>         dir/casualty2
>         dir/untracked_dir/
> 
>     no changes added to commit (use "git add" and/or "git commit -a")
>     + git stash push dir/
>     Saved working directory and index state WIP on master: 895197e initial
>     + git stash show -v
>     diff --git a/dir/tracked b/dir/tracked
>     index e69de29..12f00e9 100644
>     --- a/dir/tracked
>     +++ b/dir/tracked
>     @@ -0,0 +1 @@
>     +contents
>     + tree
>     .
>     └── dir
>         └── tracked
> 
>     1 directory, 1 file
>     + git status
>     On branch master
>     nothing to commit, working tree clean
>     + git stash pop
>     On branch master
>     Changes not staged for commit:
>       (use "git add <file>..." to update what will be committed)
>       (use "git checkout -- <file>..." to discard changes in working directory)
> 
>         modified:   dir/tracked
> 
>     no changes added to commit (use "git add" and/or "git commit -a")
>     Dropped refs/stash@{0} (93ceee344b947ecd8a27a672e3aedd2b2e1acc99)
>     + tree
>     .
>     └── dir
>         └── tracked
> 
>     1 directory, 1 file
>     + git status
>     On branch master
>     Changes not staged for commit:
>       (use "git add <file>..." to update what will be committed)
>       (use "git checkout -- <file>..." to discard changes in working directory)
> 
>         modified:   dir/tracked
> 
>     no changes added to commit (use "git add" and/or "git commit -a")
> ---------------------