Jeff Zivkovic
Jeff Zivkovic

Reputation: 587

git stash save erased my changes

Apparently I'm a noob with git. I've been working on some major changes. I hadn't yet made any commits on my current branch, because I thought that would also commit those changes to my master branch. So I read about "git stash save", and I understood that would save changes to my current branch without affecting the master. When I tried this, I received this message:

Saved working directory and index state WIP on assignment-join: 92cc8f1 Wrong arguments error
HEAD is now at 92cc8f1 Wrong arguments error.

To my horror, all of my work on the current branch had reset back to the creation of the branch.

Is there any possible way to undo this action?

Also, what is the proper way to save the current branch without affecting the master?

Thank you for your time.

Upvotes: 0

Views: 918

Answers (2)

torek
torek

Reputation: 487785

Apparently I'm a noob with git.

Everyone is some time. :-) (Unless they never use it at all, I suppose.)

I've been working on some major changes. I hadn't yet made any commits on my current branch, because I thought that would also commit those changes to my master branch.

As I hope you have now seen, this is wrong—but I suspect you still have some things you do not understand about Git, which is quite natural since Git is very peculiar.

So I read about "git stash save", and I understood that would save changes to my current branch without affecting the master.

Unfortunately, this is not what it does. To understand precisely what it does takes a lot of Git knowledge, but what you need right now is the five-minute (or maybe 15-minute) quick introduction.

As others already answered, to undo it right after you did it, you can just git stash pop. But there are some subtleties and it's worth covering them lightly.

What git stash save does is make commits. Then, having made these commits, it runs git reset --hard, to throw out your changes (which are now safely saved in commits). (You can change some of this behavior with flags to git stash save.)

There are two peculiar things about the commits git stash save saves, though:

  1. They are not on any branch.

  2. There is more than one of them. In fact, there are at least two (there is a third if you add certain flags to your git stash save command; but please don't yet :-) ). While we'll stay away from all the gory details, it's worth considering what these two are, because they matter when you go to make your own commits.

Which leads right back to:

Also, what is the proper way to save the current branch without affecting the master?

The answer is: "make commits".

What to know before making commits

First, if you are used to any other version control system, Git's approach to branching is deeply weird.

Second, Git makes commits, not from the work-tree, but from Git's index, which Git also calls the staging area. We'll define these terms in a moment.

Third, each commit has a "true name" that is one of those big ugly hash IDs you have no doubt seen, 92cc8f1... and badbead... and so on. These are impossible to remember (though you can cut and paste them if you need to) so instead Git lets us save certain hash IDs under various names. To a large extent, that's what a branch name, like master or featureX, is: it's a name for a commit.

Last, this is what a commit is / contains:

  • It has an author and committer (your name and email address, twice; the extra one is for cases where you commit someone else's work, if they email you a patch for instance).
  • It has that hash ID true name.
  • It stores a complete, fully-intact version of every file that is currently in the index. (But it does so with fancy compression and secretly re-using existing stored files from other commits, so that this hardly takes any space.)
  • It stores your commit log message.
  • Perhaps trickiest, it stores the hash ID of a parent commit. (In fact, it can store more than one parent hash, but we'll ignore this here—it's meant for merge commits.)

It's these parent commit IDs that make up the history, in Git. The fact that each commit "points back" to its parent (saves its parent hash ID) is what lets Git show you all the commits in a branch. The branch name itself is just a name for the most recent commit.

Visualizing (part of) the commit graph

Let's draw a tiny repository with only a master branch:

A <-B <-C   <--master

Here, the name master remembers the ID of the third commit (which I call C instead of writing out some incomprehensible hash ID). We say that the name master points to commit C.

But note that it's commit C that remembers the ID of commit B: B is C's parent; and we say that C points to B. Likewise, B points back to A.

A is the very first commit. There's nothing for it to point back to, so it's a little bit special. It just doesn't point anywhere. We call it a root commit, and the fact that there's nowhere to go lets Git stop going backwards.

In other words, Git basically always works backwards. We start with the most recent, or tip, commit of a branch, which we find using a branch name. That commit gives us the ID of its parent, which gives us another parent, and so on.

Drawing all these internal arrows is a pain, so let's not bother. We know commits only remember their parents, not their children. We can only move backwards, we always start at the right and go left in these drawings:

A--B--C   <-- master

I keep the branch arrow in the drawing, though, because the crucial thing about these branch names is that they move when we add new commits. Let's add a new commit D:

A--B--C--D

The new commit D points back to C. But what points to D? Well, if we're on the master branch, it's the name master:

A--B--C--D   <-- master

But we can be on a different branch. Let's make a new branch test, and have both master and test point to C initially:

A--B--C   <-- master, test (HEAD)

Now let's be "on branch test", as git status would say, and make our new commit D. Here's how to draw it:

A--B--C   <-- master
       \
        D   <-- test (HEAD)

Just as before, D points back to C. But this time it's not master that changed, it's test. This time, test now points to the new commit.

This is how branches grow in Git: we make a new commit, and it points back to the current branch tip. Then we make the current branch point to the new commit. But how do we know which branch is our current branch? Well, that's what the HEAD thing is for in the drawing: to act as a reminder, "this is our current branch". (If we only draw in one branch, we don't need the extra annotation, though it doesn't really hurt.)

This is also why making a new commit on some branch, leaves the other branches un-affected: all the other branch names still point to their original commits. Once a commit is made, in Git, it can never be changed.

(You will eventually see the command git commit --amend, which seems to change the current commit. It doesn't, really: it just makes a new commit, the same way we made D. It's just that it shoves the current commit out of the way, instead of adding the new one on the end:

...--E--F--G   <-- branch  [before --amend]

becomes:

          G   [old branch tip, now abandoned]
         /
...--E--F--H   <-- branch  [after --amend]

This is true of all commits in Git: once in, they can never be changed—but you can abandon them, by ripping away all their branch names. This lets Git "garbage collect" unwanted commits, eventually. There are a bunch of secret protections even then, to make sure they stick around for a while.)

What to know about the work-tree and index / staging-area

Files stored in Git's commits are "safe": each commit has its own copy of the saved version of that file (though Git magically-yet-safely shares them between all commits as needed). But, they are in a form useful only to Git itself: zlib-deflated, for compression, and maybe even "packed" with other files for even more compression. If Git is going to be any use, we need a place where we can have the files in their normal form, so that we can work with and on them.

The work-tree is the place Git writes the files in their normal format. The work-tree is therefore (and unsurprisingly) where you do your work. This part is quite straightforward, but of course there's a twist: quite often, we want, in our work-tree, files that we don't want to commit—files we don't want to save every version of, forever.

In any other non-Git version control system, we'd mark these files as "don't commit" and we would be done, but Git is different. Git gives us this extra thing, the staging area, also know as the index.

The short description of the index is that it is where you build the next commit to make. Before you run git commit, you have to copy files from the work-tree, into the index. Once there's a version of the file in the index, that version is what will go into the next commit. If you change the the file in the work-tree again, you have to copy it back to the index again.

This seems like—and, frankly, often is—just a big pain in the butt. Why can't Git just automatically copy the files into the index, when we go to git commit? Actually, it can, but let's hold off on that. The fact that you have to manually git add each updated file into the index, to stage it before committing, lets us avoid adding work-tree files that we don't want to save.

Files that are already in the index, we call tracked. Files that aren't in the index yet, but are in the work-tree, are therefore untracked.

The git status command will compare your index / staging-area to your work-tree, and tell you which files are staged (are in the index and ready to commit), unstaged (in the index, but don't match the work-tree), and untracked (not in the index, but in the work-tree). First, though, git status compares your current commit to your index: files that are in your index, but match those in your current commit, it just shuts up about. They're still tracked; they will be saved in the next commit; but they're the same as what's already committed.

(Of course, all the incessant whining about build artifact files being "untracked" is annoying. So this is where .gitignore comes in: you can list such files there, telling Git: "yes, I know these are untracked, stop whining about them." Note that listing a file in .gitignore does not make it untracked, it just makes Git shut up about it. It's the "not being in the index" part of the equation that actually makes the file untracked.)

This is why git stash save makes two commits

So, we see that Git has this exposed "index" / "staging area" thing, and makes you build up your next commit there. It also has your work-tree, where you are working on files. You can have some changes to some files that are already staged, and more changes to other files that are not staged yet.

The git stash save operation is meant to save away all these pending changes, and then get rid of them from your index and work-tree. So it needs to make two commits: one for the index, and one for the work-tree. If you have carefully staged file README.txt and not yet staged pending and want Git to remember that, git stash save will.

Usually, though, you don't. So git stash apply, by default, doesn't keep this careful separation between index and work-tree. There is a way to keep it and you're supposed to make this decision (keep index carefully staged, or not) when you do the apply step.

Note that git stash pop just means git stash apply && git stash drop: apply the stash's commits now, and if that succeeds—it's possible for this step to fail—then also drop the stash. Fortunately, your git stash apply is going to succeed and there will be no messy consequences of failure here.

When and why you should use commits instead of the stash

If you want to save things, on a branch, you should just commit them. That makes an ordinary commit, that's on an ordinary branch. It is easy to find it again and work with it—well, as easy as anything ever is in Git, anyway.

If you use git stash save, Git makes two (or sometimes three!) commits that are not on any branch. (The special name stash remembers the hash ID of one of these commits, and the git stash code cleverly arranges that one commit to remember the other(s).) The fact that these commits are on no branch means you can move to another branch, then try to apply them. It's the "move to another branch"—really, to another commit—that can make them fail to apply, though; and when they do fail to apply, you have a bit of a mess you have to clean up.

Upvotes: 5

crashmstr
crashmstr

Reputation: 28563

git stash save will save your current work in a "stash", then remove the work from the working copy. You can then apply a stash back to your working copy (can even be a different branch) with git stash pop.

git stash documentation

save [-p|--patch] [-k|--[no-]keep-index] [-u|--include-untracked] [-a|--all] [-q|--quiet] []
Save your local modifications to a new stash and roll them back to HEAD (in the working tree and in the index).

pop [--index] [-q|--quiet] []
Remove a single stashed state from the stash list and apply it on top of the current working tree state, i.e., do the inverse operation of git stash save. The working directory must match the index.

Upvotes: 0

Related Questions