A surprising way to lose your file in Git

7 points by kazinator ↗ HN
In the spirit of the recent: https://news.ycombinator.com/item?id=33047150 here is how git can eat your file:

We make a new directory, initialize a Git repo there:

  $ mkdir git-test
  $ cd git-test
  git-test$ git init
  Initialized empty Git repository in /home/kaz/git-test/.git/
Now we create a file and stage it for addition:

  git-test$ echo content > file
  git-test$ git add file
We can see it in the dir listing, of course:

  git-test$ ls
  file
And see the diff between the stage and the index, which adds the file:

  git-test$ git diff --cached
  diff --git a/file b/file
  new file mode 100644
  index 0000000..d95f3ad
  --- /dev/null
  +++ b/file
  @@ -0,0 +1 @@
  +content
Now, we do a git reset --hard to eliminate the staging:

  git-test $ git reset --hard
Where is the working file?

  git-test $ ls
  git-test $ 
Oops!

32 comments

[ 12.5 ms ] story [ 1359 ms ] thread
Well, it shouldn't be surprising. `git reset --hard` would do this whether it was a new file or or new repo or not.

To eliminate the staging you want `git restore` or carefully `git reset` (not --hard)

Every git tutorial I've seen tells you `git reset` is a potentially destructive action.

It should not be surprising that:

1. you can loose data that has not been committed

2. you can loose data when playing with destructive commands

What "this" would it do? Certainly not blow the whole file away, just changes.

I've not made any changes to this file. I have nothing to revert.

What I had really wanted in that situation was even more destructive. Or, well paradoxically, less destructive.

Namely:

  rm -rf .git
  git init
to just start again.
I understand the line of thinking, but if you are doing "git reset --hard" you are saying "put the working tree (e.g. files) back the way they are in latest commit".

So if there were no files or they were empty? Well, that's what you get.

Git is tricky and has a complex and oft panned command-line UI. This is not surprising either, widely known. I like it and get along with it fine; not everybody does.

But certainly you should not be running commands like `git reset` or `rm -fr <anything>` unless you really know what you are doing.

Surprising behavior is interesting if it's based on a good working knowledge of git and still a surprise. Being surprised that a keg of gunpowder blows up when set afire, but you just did not expect it to actually do that, is not as interesting.

There is no "latest commit" in that example; one has not been made in that repo at all.

The only prior state when the file didn't exist was before git was initialized. At no point did I make any edits to the file after creating it.

Git invented that prior state by equating the addition of a file to the index to an operation which creates a file where there was no file before. I.e. git is pretending that the file originated in git's index object, which owns it.

This is a good assumption for commits. When we check out to a commit where in which a file doesn't exist, leaving behind a commit in which it does, of course we want the file to disappear.

If we throw away an uncommitted index in which a file exists, to return to a clean HEAD where it doesn't, it's a bad idea to remove the file in that case, even though the index is commit-like in some respects.

There will be cases where that causes an unwanted file to inconveniently exist. Those situations could be taken care of by a flag in the index which tracks a file's provenance: was the file added to the index from the outside by a "git add", or is the file in the index as a result of a git operation (from where that file could be obtained again).

> There is no "latest commit" in that example; one has not been made in that repo at all.

Consider that the first real commit is to a virtual commit with hash 0000000... that contains an empty tree.

I seems perfectly logical to me. OP did a hard reset to that virtual commit 000000.

Yep, agree. I should have been more clear about the edge case of no prior commit.

anytime you do a "reset --hard" files and changes in the working tree are likely to get deleted/reverted/etc. That's as consistent as it gets. You don't reset --hard unless you want that behavior.

That's another screwup in git. That commit does not behave like a commit.

For instance, you cannot do this:

  git reset HEAD^ -- file  #  stage a file removal from the commit
or say you have made two commits and want to reorder them with interactive rebase

  git rebase -i HEAD^^     #  oops
Whenever I start a new repo, I do this:

  git init
  git commit --allow-empty -m NIL
to create a commit that actually serves as a "terminating nil" in the list since for unfathomable reasons, the 0 commit hash doesn't serve that purpose. You can't point at that 0 with HEAD^ to indicate "the point before this commit".
> The only prior state when the file didn't exist was before git was initialized.

And I'd say the state of a repo with no commits is an empty tree, which is completely rational and consistent. It's what you get when you clone a repo with no commits, for example. If you `git reset --hard` to this state, that's what you get.

The ironclad rule is ANYTIME you run `git reset --hard` you are going to reset the work tree and have the potential to remove files and lose uncommitted changes. That command exists for this reason and is quite useful for when you want to remove and throw away anything that might have changed. Although `git reset` and `git checkout` are overloaded in IMHO confusing ways, `git reset --hard` does exactly what it says it will - the other usages are the odd ones.

Don't ever do `git reset --hard` if you have uncommitted changes (e.g. the repo is dirty). There's no special case for "before the first commit".

Another way to untangle the two issues 1. prior to first commit / invented previous state and 2. git reset --hard eats your file is to note that `git reset --hard` would "eat" an indexed, but not committed, file in exactly the same way if you `reset --hard` to a prior commit when there are prior commits too. No difference.

If you `git reset --hard` with uncommitted changes to an previously committed file, indexed or not, the changes are reverted too. It's the whole point to `reset --hard`.

Staging a file with 'git add' shows a somewhat long informational message what's worth reading. It says that a file can be unstaged by issuing 'git reset HEAD <file>'. The work tree is left unchanged. 'git reset <commit>' is also useful to "forget" the last <commit>..HEAD commits, but leaving the work tree as-is (for adding back the changes in a single commit, for example). This should not be done to walk back already pushed commits, or a force push will be necessary with all its consequences.
That's what 'git reset --hard' does. Read the git-reset man page:

  --hard
      Resets the index and working tree. Any changes to tracked files in the working tree since <commit> are discarded.
What changes to tracked files?

What commit; I've not made one.

> What commit; I've not made one.

Yes, that's why it's discarded.

The git init already made a commit of the initial empty tree. That's where your HEAD is currently pointing. You staged a file and then ran git reset --hard. That took you back to your initial commit that was created by git init.
Your claim is not correct.

  $ git init
  Initialized empty Git repository in /home/kaz/junk/gittest/.git/
  $ echo content > file
  $ git add file
  $ git commit -m first-commit-not-second
  [master (root-commit) 2bf5805] first-commit-not-second
   1 file changed, 1 insertion(+)
   create mode 100644 file
OK, now let's reset the file to the previous commit, to stage a change which removes it from that commit:

  $ git reset HEAD^ -- file
  fatal: Failed to resolve 'HEAD^' as a valid tree.
There is no commit made by git init. The above reset would work if there was a commit before HEAD.

Git apologists will say anything to defend its traps and pitfalls.

Anyone who thinks they can mansplain git to me should read first understand my StackOverflow answer, where I give detailed instructions on how to "git stash" only the staged changes, in a git that doesn't have the recent --staged option in git stash. This is done by manipulating raw tree objects to create commits in the way stash wants them.

https://stackoverflow.com/questions/14759748/how-can-i-stash...

I've been using this thing daily since around 2008.

I think the behavior that is described by my post is suprising and poor. A command which throws away uncommitted changes removed a file whose only connection to the version control system was that its existence was announced to it. (I don't require to be informed about the mechanics of how that came about, due to what procedures being applied to what representations, all of which I understand.)

People writing version control systems should take some kind of oath against anything like that happening.

Under no circumstances lose any file that is not completely in version control, without confirmation.

I can try anyway: your file is still accessible with this (file content is 'foo\n')

    $ git cat-file --batch-all-objects --batch
    257cc5642cb1a054f08cc83f2d943e56fd3ebe99 blob 4
    foo
But as others said, yes I'd expect this. You wanted reset (soft).

(EDIT: formatting)

> Anyone who thinks they can mansplain git to me […]

How is dswilkerson's gender relevant here? (Or yours, given that mansplaining typically implies a man needlessly explaining something to a woman.) I don't think they've even stated that irrelevant signifier anywhere on HN.

Maybe it was a bad pun because of quoting a man page?
“under no circumstances without confirmation” but you did that when you passed it —hard…
Yes —hard should lose files without confirmation

Yes you made a commit by initializing (your HEAD points to the state of the repo that has zero files and reset —-hard to that state means “make my repo have that empty state and don’t ask me to confirm whatever is changed or deletes in order to get there”).

Git has lots of sharp edges and surprising things but this is completely unsurprising to me. I think your misunderstanding here is around what “reset —hard” does. I think with a mental model including the root (empty) state of the repo it’s easier to understand.

Uh, `git reset --hard` doesn't just unstage, it unstages and rolls back all tracked files to `HEAD`... If you just want to unstage, that's what `git reset --soft` is for...
or the newer 'git restore --stage'
I would always do various --force and --hard and similar very carefully and really knowing what they do. In the end they exist for a reason.
git reset is basically git unfuck. It comes in handy when you mess up and want to get back to the working state. So yeah, it nuked your file, as if wasn't part of the committed work.
> is basically git unfuck

Yes, when the odd time it's not being git fuck.

That's the point. "git reset --hard" is a command I reach for umpteen times daily. Its use cases cannot be replaced by "git reset". It isn't just some rarely-used footgun used by Git gurus, who reason very carefully about all of its corner cases and implications in that once-in-a-blue-moon situation. It's a daily driver.

"git reset --hard" could use its own safety mechanisms.

When "git reset --hard" is discarding staged adds, there would be zero downside in simply not removing the goddamned files. "git reset --harder" could do that, for people who like to lose files in corner cases.

When "git reset --hard" is not discarding staged adds, but, say, navigating from a commit where a file exists to one where it doesn't, then in that case it's perfectly fine to remove.

Another safeguard needed in "git reset --hard" is that when you reset to a particular commit which has a file, it will overwrite a local file which is in the way. That could use a safeguard. "git reset --hard" could refuse, but "git reset --harder" would do it. When I say, "go to this commit and make the working tree look like it", I don't mean that I want to ignore conflicts (that I don't even know about) between that commit and local files.

This is what I would have expected. You:

1. Initialize a repo

2. Create and add a file

3. Do not commit

4. Hard reset the state to the last commit (which you have not made)

If you want to keep your staged files --hard is not your friend. The lesson here is that you have to be careful with reset --hard and that you have to have a commit with the state you wanna go back to.

This works like this for uncommitted files, because `git reset --hard` would try to remove any files it knows about. After staging the file, even if you just do it with `git add --intent-to-add`, git would already _know_ about this file.

And then, it seems you expected to be able to run a command which would undo your previous command of staging the file. However, git-reset was never meant to do that. You might argue, it's not aptly named.

git-reset tries to reset the files it knows about to the state in the supplied ref or HEAD. After you told git about a file, it tries to control it even if it's not persisted in its internal objects.

This is true in git for various plumbing and porcelain commands.

On whether that's surprising or not - _it might be_ to people new to git, but as a whole git CLI is not that welcoming to newcomers.

(comment deleted)
In addition to others here posting relevant documentation, let me point out one more thing. Those two words "hard reset" standing together (even on their own) should bring up a mental image of alarm bells and red flashing lights.

As if a "reset" wasn't bad enough, they're giving you an extra dangerous option to go with it, "I want it hard".

Git is an insanely powerful software, but that power doesn't come free.

After all those years, I still take a backup of my local repo before I start attempting some avant-garde dark art sorcery on the repo: "cp -R stuff /tmp/" and I can safely dip myself in binary ink and light the magic smoke incense knowing that I always have a way out.

Everyone but the OP understands how git works. Removing the file is exactly what I would expect to happen.
Are you saying that you could never identify a usability or user experience problem in anything whose mechanisms you understand?