Ryan
Ryan

Reputation: 24009

How can I discover the git commit hash that I stashed from?

I realize that my question is very similar to How to list the parent commit of a stash in `git stash list` and Get git stash parent commit, but those had so many confusing, disparate responses that I'm asking my own question.

Let's assume that if I run git stash list, I see stash@{0}: On featureX: someMessageHere

How can I reveal the hash of the commit that I was working from when I made that stash (which I guess could be considered a parent commit)?

I've seen so many different answers, and I'm confused about what these each do, how they are different, and which one is the answer to my question:

git log -g --format="%gd %H" refs/stash |
while read name hash; do
    printf "%s %s " $name $(git rev-parse --short $name^)
    git log -1 --format=%s $hash
done

For extra context, this is the reason I'm asking.

Upvotes: 9

Views: 5105

Answers (2)

LeGEC
LeGEC

Reputation: 51780

The commit you are looking for is stash@{0}^ :

git show stash@{0}^
git log -1 --oneline stash@{0}^
git rev-parse stash@{0}^

[update] or the shorter:

git show stash^
git log -1 --oneline stash^
git rev-parse stash^

(note that your can use the stash@{xx} syntax for any entry of git stash list)

Upvotes: 11

torek
torek

Reputation: 487725

LeGEC's answer is correct. To (I hope) help you understand this part, though:

I'm confused about what these each do, how they are different, and which one is the answer to my question: [list of various commands]

... let's take a quick (well... maybe not so quick) trip through how Git works internally, in this respect.

First, the big central thing in Git is the commit. There's a recurring theme in Git: you make a commit (git commit), you find a commit (many ways), you show a commit (git show or sometimes git log), you check out a commit (git checkout or git switch), and you look through or at commits (git log again). Even git stash works by making commits.

There are three big features about commits:

  • Each one has a unique ID. This is its hash ID, which looks like, e.g., 4a0fcf9f760c9774be77f51e1e88a7499b53d2e2. Lots of Git commands abbreviate these—you can sometimes go as short as the first four characters, 4a0f for instance, as long as that isn't ambiguous, but in a big repository you'll usually need 7 or more characters (and the Linux repository is up to 12 now).1

  • Each one stores a full snapshot of files. We won't go into much detail here.

  • And, each one stores some metadata: information such as who made the commit, when, and why (the log message). One piece of this metadata is for Git itself, and it gives the hash ID of the commit's parent commit—the commit that comes right before the commit itself.

Most commits have exactly one parent. Some have two or more, in which case the first parent is the interesting one here. At least one commit—the very first one anyone ever made in the repository—necessarily has no parent, because there's no commit that comes before the first commit. Typically there is only one of these root commits; all others have history.


1These things look random, but really are not random at all. As you add more and more objects to a Git repository, each of which gets one of these unique-to-that-object IDs, it becomes more and more likely that you need to use a more-full name to tell them apart. It's like a party: the name Bruce might be unique if there are only ten people there, but once you're up to 10,000, you probably need at least a last initial too.

There are four kinds of Git objects in a repository, but mostly, we deal with the commit objects, and get to ignore the others.


The parent trail is how Git works

This parent—or first parent, for merge commits—is how Git works: backwards. We typically start Git at the last commit, or more precisely, the last commit in some branch. Git then does something about that last commit, such as: show us the author's date and name-and-email and log message. Then, Git uses the parent of that commit to move back one commit. It shows us the previous commit. Then Git goes on to the parent's parent—the grandparent of the original commit—and shows us that commit, and then it moves back again.

When there aren't any merges, this forms a nice simple backwards-looking chain of commits. If we let a single uppercase letter stand in for each commit's hash ID, we can draw this like so:

... <-F <-G <-H

Here H is the last commit in the chain. We (somehow) have Git find this commit, and show it. Then Git finds G's hash ID, as stored inside the metadata for H. Git uses that to look up commit G, which it shows us. Then Git finds F's hash ID inside G, and so on.

(Note that we say that commits point back to their earlier—parent—commits. That's why we drew these backwards-pointing arrows. It's sometimes important to realize that Git can easily go backwards, but has a hard time going forwards. Commit G points back to earlier F, but not forwards to later H. Most of the time, though, we don't really have to care, and it's hard to draw these arrows well, so most of the time, I don't bother.)

This is what git log does, for instance. But how does it find commit H? Well, the easy way is that we tell it git log master. To the above drawing, we can add one more pointer: we have the name master, pointing to commit H, like this:

...--F--G--H   <-- master

If we git checkout master and make a new commit, Git will add the new commit such that its parent is H:

...--F--G--H   <-- master
            \
             I

but then immediately update the name master so that it points to commit I now:

...--F--G--H--I   <-- master

What this last part means is that git log uses a name to find the last commit. If we give it a branch name, that's the name it uses. If we don't give it any name, git log uses the special name HEAD. But we can also give it something that isn't a branch name, and that's what stash is.

What to know about stash commits

When git stash save (the old way to make a stash) or git stash push (the new way to make a stash) makes its commits, it sets them up so that the special name stash refers to one of these commits, and that commit has, as its first parent—we'll talk more about first parents in a moment—the commit that was (and still is) current right as you run git stash.

That is, if we draw them, we get:

...--G--H   <-- master
        |\
        i-w   <-- stash

I won't go into why I call them i and w here, but the git stash documentation also calls them I and W (uppercase instead of lowercase—I like to keep my uppercase letters for more normal commits, not for these stash ones).

The important thing here is that the first parent of commit w is commit H, which is the commit you're on at the time you run git stash push or whatever you used to create w.

There's a whole complicated set of ways to name commits

When Git needs a commit—or in fact any internal object, but again we're interested just in the commits here—there are in fact many ways to name it The complete list is covered in the gitrevisions documentation. For our purposes, though, we want to look specifically at the ^ and ~ suffixes. We'll get to the curly-brace-and-number suffix later.

If we take some valid name, like HEAD or master or stash, and add a caret / hat ^ or tilde ~ to the end, this is a directive to Git's internal revision-finder: starting with the commit we've already named, find the commit's parent(s). The ^ suffix then selects the first parent of the commit, so that stash^ means the first parent of the commit found by the name stash.

The tilde suffix also selects first-parents. This seems redundant at first: develop^ and develop~ both select the first parent of the commit selected by the name branch. We can add numbers after them, though, and then they become different. The key to understanding this lies in the diagrams we drew above. Suppose we have:

          I--J
         /    \
...--G--H      M   <-- develop
         \    /
          K--L   <-- feature

Here, commit M is a merge commit, so it has two parents. Let's say that the first parent of M is J, and the second parent of M is L—which is what we'd get if we made commit M by doing git checkout develop when develop named commit J, then ran git merge feature to make M.

The syntax develop^2 means find the second parent of commit M, i.e., find commit L. This names the same commit that we'd get using the name feature—so we could just do that, as long as we haven't yet deleted the name feature. But the point is, M^2 or develop^2 finds commit L, because this means find the second parent.

Meanwhile, the syntax develop~2 means find the first parent of the first parent of commit M, i.e., find commit I. That's because the 2 in this case is the number times to step back. So we step back once, along the first-parent line from M to J, then again, along the first (and only) parent line from J to I.

When the number after ^ or ~ is 1, or isn't there at all, both do exactly the same thing: ^1 means find the first parent (which steps back one first-parent link), and ~1 means step back one first-parent link.

With that out of the way, let's start looking at your bullet list

  • git show stash@{1}^

We'll cover the @{1} in a moment. For now, imagine this just said stash^. The name stash would find some commit, and the ^ would find its first parent. Then git show would show that commit. The git show command does this by:

  • printing out the commit's hash ID
  • printing a formatted version of the log message with author and such (you can change this with the --pretty=format:... option)
  • showing a diff:
    • Git gets this commit's parent's snapshot out, into a temporary area (in memory)
    • then Git gets this commit's snapshot out too
    • and then Git compares the two snapshots and tells you about files that are different, without saying anything about files that are the same

The last part makes it look like the commit itself holds a diff—but it doesn't. The diff was computed for you, when git show got around to doing that.

  • git log -1 commitish^

Again, the ^ suffix makes Git go back to the commit's parent. Then git log -1 shows the log message, but not the diff, of one commit—the first part of what git show shows—but with the -1, stops after showing that one commit.

  • git log -1 stash@{0}^

This is similar, except now we have stash@{0}^ instead of commitish^. The ^ suffix applies to the stash@{0} specifier, which we'll get to in a bit, again.

  • git log -g --no-walk --parents refs/stash

This one is quite a bit different. The --no-walk option is redundant with -g and has no meaning because -g takes over. The --parents option does have a meaning, though. To talk about -g properly, we need to get to the section where we cover the @{number} part. Let's leave the last two commands for later, and get to reflogs now.

Reflogs

In Git, each reference—each name like master or develop or, indeed, stash—can also keep its own separate log of "previous" values. For normal branch names, these logs just remember where the branch names used to point. Each log entry therefore remembers one hash ID: the old value of the branch name.

For instance, when you make a new commit, Git automatically advances the branch name to point to the new commit. But the name used to point to the commit's parent, so the log now contains the parent hash ID. If you use git reset to re-set the branch, this puts the pre-reset hash ID into the log, too. So the log just accumulates each hash ID as you work.

There's one other important thing to know here: the suffix @{number} selects the number'th log entry. The number zero means the current value of the name. So master@{0} is just a long way to spell master, but master@{1} is the old value of master, and master@{2} is the value that was the old value, but now is even-older, after you did something that updated master.

Git normally purges old log entries after a while—after 90 days by default for most log entries, and 30 days by default for some. But stash is special, and its log entries are normally never purged based on age. Since stash isn't a branch name, it's not manipulated by branch commands. It's manipulated instead by the git stash command, with its push, pop, and drop operations.

Here, git stash uses the stash reflog to keep track of earlier saved stashes. As you use git stash push, Git renumbers the previous log entries so that what was stash@{0} becomes stash@{1}, what was stash@{1} becomes stash@{2}, and so on. This is actually the same as any normal branch reflog entry (except for the never-expiring part). But what's different is that when you use git stash pop or git stash drop, Git will throw out the old stash@{0} entry, so that what was stash@{1} is now stash@{0}, what was stash@{2} is now stash@{1}, and so on.

So now we can properly address the original stash@{1}^ from the first git show:

git show stash@{1}^

The stash@{1} operation means find the stash commit that's one level deep in the stash stack. The ^ suffix then selects its first parent.

Since stash@{1} is the w commit of the stash one level deep in the stash stack, stash@{1}^ is its parent commit. That's the commit that this stash is hung from.

We can also, finally, address this one:

  • git log -g --parents refs/stash

(I've taken out the pointless --no-walk.)

  • The -g option directs git log to look into the reflogs, rather than doing its usual thing of finding a commit and then walking backwards through history. The one reflog it will look into is that for refs/stash—which is the full spelling of stash.

  • The --parents option tells git log to show not only each commit hash ID, but also all of its parent commit hash IDs.

  • So we'll see each w commit in the stash stack, along with both of its parents. The first parent will be the commit from which the i-w pair hangs, and the second parent will be the i commit.

The last two commands

  • git for-each-ref --format='%(refname:short)' --points-at $(git rev-parse refs/stash~1) refs/heads

The git for-each-ref command is an internal workhorse command—one that's not really intended for end users—that implements both git branch --list and git tag --list, along with several others. Because this command is meant for writing user-facing commands, rather than being used directly by users, it has a lot of options:

  • The --format option tells it how to produce its output. Here, we choose to print the short form of a name (which will be a branch name due to a later option).

  • The --points-at option tells it not to bother printing the name unless the name specifically names a particular commit. The commit we're telling it we want here is the output of another Git command, git rev-parse.

  • The refs/heads option tells git for-each-ref which refs to use. The refs/heads space holds all your branch names. So this tells it: Looking only at my branch names, find those that name one specific commit; then, for any name you found, print the short version of that branch's name.

The commit we choose to have it search for is the hash ID of commit refs/stash~1. This uses the ~ suffix to get the first parent of the commit identified by the name refs/stash. That's the fully-spelled-out form of stash, so we're asking Git to use refs/stash to find a w commit, then using ~ to find its parent, which would be, e.g., commit H. Then we have Git search through all the branch names to see if any of those names commit H. If the name refs/heads/master—branch master—identifies commit H, the command will print the name master.

Finally:

git log -g --format="%gd %H" refs/stash |
while read name hash; do
    printf "%s %s " $name $(git rev-parse --short $name^)
    git log -1 --format=%s $hash
done

This once again uses git log -g and refs/stash to look at the reflog entries for refs/stash. The %gd %H directives to --format tell Git how to print each such entry: %gd prints stash@{0} or stash@{1} or stash@{2} or whatever is appropriate, and %H prints the hash ID associated with that reflog entry.

The output from this command goes into a shell loop. This loop reads the name—the stash@{number} part—and the hash ID. Then the printf command prints:

  • the name;
  • a space;
  • the short version of the hash found by git rev-parse when given the hash ID we just read plus the ^ suffix, i.e., the short version of the hash of the stash's parent commit;
  • and one more space, but no newline yet.

Last, this runs git log -1 --format=%s $hash, which prints the subject line of the stash itself.

So this will also print the information you want, though—due to the --short in the git rev-parse command—using an abbreviated form for each of your stash's appropriate parent commit hash IDs.

Upvotes: 9

Related Questions