Is there a simple way to swap staged and non-staged Git content?

Viewed 132

Sometimes while working on something I'll find something else which needs fixing first. At this point I might have already staged part of that file and some other files (new and tracked) and I want to swap out the current staging for the non-staged content. Is there some way to do this?

Example session:

$ cd "$(mktemp --directory)"
$ git init
Initialized empty Git repository in /tmp/tmp.7HssIqQ2qT/.git/
$ echo foo > foo
$ echo bar > bar
$ git add .
$ git commit --message="Initial commit"
[master (root-commit) c6db082] Initial commit
 2 files changed, 2 insertions(+)
 create mode 100644 bar
 create mode 100644 foo
$ echo foo2 > foo
$ echo baz > baz
$ git add foo baz
$ echo foo3 >> foo
$ echo bar2 > bar
$ git status
On branch master
Changes to be committed:
  (use "git reset HEAD <file>..." to unstage)

    new file:   baz
    modified:   foo

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:   bar
    modified:   foo

Basically, how do I swap the above changes to be committed and changes not staged for commit, bearing in mind that only part of the changes to "foo" have been staged? The simplest I can think of so far is creating two stashes and popping them in reverse order, but it doesn't work very well:

$ git stash --include-untracked --keep-index
Saved working directory and index state WIP on master: 3dba6aa Initial commit
$ git status
On branch master
Changes to be committed:
  (use "git reset HEAD <file>..." to unstage)

    new file:   baz
    modified:   foo
$ git stash --include-untracked
$ git status
On branch master
nothing to commit, working tree clean
$ git stash pop stash@{1}
On branch master
Changes to be committed:
  (use "git reset HEAD <file>..." to unstage)

    new file:   baz

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:   bar
    modified:   foo

Dropped stash@{1} (6fb8f81bdb2dc9fc1f218e7c25e74ad32c01fc0e)

Somehow baz is popped as part of the oldest stash, so the state is already messed up.

3 Answers

Note: before I dive into the answer below, I have two notes:

  • You might be better off using git worktree add and working in two branches, or just committing now and then rearranging later. If you commit now, you can use git rebase -i later to swap the order of the commits. This lets you achieve more elegantly, I think, what the patches in the next bullet point will do. (But you do have to remember to rebase.)

  • The trick below uses full snapshots and you might be thinking in terms of differences, in which case, the two git read-trees may not get what you want. Try it out and see if it's what you want, or not. If that's not what you want, consider making two patches via git diff --cached and then git diff, saving both of those, doing a hard reset, and then applying each of the two patches in sequence with a git add; git commit between them. Note that this may well see merge conflicts!

In any case, I don't think there is anything particularly elegant, but git stash—even without --keep-index—gets you, perhaps, the commits you'd like. (Note: I recommend not using --include-untracked.)

Remember that a commit is (or rather, contains) a full snapshot of every file, or more specifically, every file that was in the index at the time you made the commit. Meanwhile, what's in the index is a full copy of every file. What's in your work-tree is up to you; Git will merely either copy a file from your work-tree into the index (git add) or copy from the index to your work-tree (git restore in 2.23 and later).1

Meanwhile, a simple git commit makes a new commit from whatever is in your index right now. If that matches what is in HEAD you need to add a flag,2 but that's what it does. Compare this with git stash, which makes two or three commits. The first of these commits is made from the index as usual, except that it's not on any branch. The second of these commits is made by copying your currently-tracked work-tree files—those that are in the index—into the index, so that the second commit contains your tracked work-tree files. The third commit, if it exists,3 contains the work-tree files that were not in the second commit (and which have subsequently been deleted). We can call the first two commits I and W, and the git-stash documentation does exactly that—follow the link and read the DISCUSSION section.

Hence, running git stash creates two commits, each of which holds the files you'd like to now have in either your work-tree (the I commit) or the index (the W commit). So we can now do that, using either the new git restore command for updating the work-tree and then git read-tree to fill in the index, or using two separate git read-tree commands (in any version of Git):

git stash                  # make commits I and W which are stash^2 and stash
git read-tree -mu stash^2  # copy I commit to index and work-tree
git read-tree stash        # copy W commit to index, leaving work-tree untouched

after which we can drop the stash with git stash drop (though I won't here).

The one part that is slightly tricky here is that baz, which is new, is in the W commit and hence appears in the changes staged for commit section. Hence:

On branch master
Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
    modified:   bar
    new file:   baz
    modified:   foo

Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git restore <file>..." to discard changes in working directory)
    modified:   bar
    modified:   foo

If you don't want this—if you want the index not to have these new files—simply run git rm --cached on them:

$ git rm --cached baz
rm 'baz'
$ git status
On branch master
Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
    modified:   bar
    modified:   foo

Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git restore <file>..." to discard changes in working directory)
    modified:   bar
    modified:   foo

Untracked files:
  (use "git add <file>..." to include in what will be committed)
    baz

Note that removing the new file from the index converts it, by definition, to an untracked file.

(Use git diff --cached, or git diff --staged if you like that spelling better, to see what is now staged.)


I'm deliberately glossing over / ignoring what git checkout or git switch does normally, which involves copying a commit to both the index and your work-tree, here, so as to focus on the concept that we normally do one of these:

commit -> index                # e.g., git reset --mixed
          index -> work-tree   # e.g., git checkout-index / git restore
          index <- work-tree   # e.g., git add

The git restore command is capable of doing commit -> work-tree while completely bypassing—leaving unchanged—the index, which no earlier command can do. We can approximate it with git show HEAD:path/to/file > path/to/file, which is more flexible—we do not have to use the same path twice—but depends on redirection, which is why we have to give the path twice.

2This flag is spelled --allow-empty, but the index is probably not actually empty. It is probably full of files. It's just that the files match those in the HEAD commit, so that the difference is empty.

3The third commit exists if and only if you use -u or -a or their longer spellings, --include-untracked or --all.

Hmm... probably there is some other more elegant way to do it but you could do it like this:

# save content from index into a temp file
git show :0:path-to-file > file-from-index
# if content is in file-from-index, continue with the recipe
# save file from working tree into index
git add path-to-file
# move file as you saved from index into the right working tree path
mv file-from-index path-to-file

There you go.

Okay, to answer the question as asked,

first step is prep work, and leaves you with just the staged changes in the work tree:

git stash -k

After that, to swap to the just-the-unstaged-changes content,

git read-tree -um @; git cherry-pick -nm2 stash

and to swap to the just-the-staged-changes content,

git read-tree -um stash^2

and to get back to the stashed state regardless of what horrors you've visited on your current index and work tree,

git read-tree -um stash
git stash drop

You might need to use --reset -u rather -than -um` if there are potential conflicts in the updates.

Related