weno
weno

Reputation: 856

How to restore latest commit when there are locally new renamed files?

I am on a myBranch and doing some changes that include renaming files.

I want to experiment, and then possibly wipe all my changes (restore to latest commit/HEAD) if something goes wrong.

Normally I would do: git checkout -- . and that'd be it.

However it has problems with removing renamed staged files for commit (also removing them from my working directory).

So the scenario I am dealing with when I want to reset, is:

Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
        renamed:    old_file.txt -> new_file.txt
                    ...
                    < files >

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)
        ...
        < files >

There are potentially a lot of different files.

How do I restore to head discarding all commited and uncommitted changes that include renaming files?

The cleanest solution I came up with was:

git reset
git clean -f
git checkout -- .

Upvotes: 2

Views: 435

Answers (1)

torek
torek

Reputation: 487755

TL;DR

Just remove any extra unwanted copies, after which your git checkout -- . trick will work fine.

If you are very sure you won't lose any work you want saved, you can do git reset --hard to do everything at once (but note that this reaches out across the entire working tree, not just whatever sub-directory you might be in).

Long

In Git, file renaming operations aren't recorded anywhere. They are detected based on content. Step back from your immediate problem of "restore working tree contents" for a moment and consider what happens when you have two existing Git commits, which hold two different snapshots-in-time of all your files.

Comparing two commits

In the old commit, you had files A, B, and C. We put this commit on the left side. In the new commit, you have files B, C, and N. We put this commit on the right side. Here they are:

old       new
---       ---
 A
 B         B
 C         C
           N

When Git compares the left side to the right side, it sees the two B files and the two C files and says to itself: Aha, those must be the same files each time, perhaps with different contents. I'll compare old-B and new-B; then I'll compare old-C and new-C. If the paired-up files are the same, I will say nothing. If the paired-up files differ, I will tell you what is different.

That leaves old-A and new-N. Before Git just says delete file A, and create N with all-new contents you can—and Git automatically does, since Git 2.12 or so—have Git check: is what's in N a lot like what was in A?* If so, Git will say: rename file A to N and then, if needed, also show what else to change to make file A become file N.

Git does the same thing with your working tree files

I'm going to go very quickly over something important here: Git actually has three copies of each file, not just two. There's one frozen-for-all-time copy of each file in the current commit, then one copy of each file ready-to-go into the next commit you could make right now stored in Git's index, and last, there's one copy of each file as an ordinary file, so that you can see it and work with it.

The frozen copy is in a special, read-only, Git-only format, automatically de-duplicated against all other commits. The index "copy"—which isn't really a copy as it uses the frozen format with de-duplication—is not directly visible.1 Only your working tree copy is visible: that copy is in the format that all the ordinary, non-Git programs on your computer can use, so when you look at your directory, that's what you see.

Git doesn't use this working tree copy most of the time: it just copies it out from a commit (by first copying the commit to Git's index, then un-freezing the index copy into the working tree) when you check out that commit. The git add command, however, copies the working tree copy back into Git's index, replacing the old index copy. That's why you have to git add a file even if Git already knows about it: Git uses the index copy, not the working tree copy, when making new commits.

To rename a file, you normally rename the index copy and the working-tree copy at the same time. (You cannot change any part of any existing commit, so that question never even comes up.) Normally, you might use git mv to do this. However, all that really matters is that the index copy of a file named path/to/file is now gone, and instead, the index now contains a file named new/name/of/somefile. Likewise, the working tree file path/to/file is now gone and instead, the working tree holds a file named new/name/of/somefile.


1Actually, you can see the index contents directly, using git ls-files --stage. It's just that this isn't something normal users really ever want: the ls-files command is meant for writing Git commands, not for everyday use. For everyday work, it's much more useful to see the kind of stuff that git status prints.


git status

When you run git status, Git runs two internal git diff operations, with rename detection always enabled:

  • First, Git compares the HEAD commit's files (which are all frozen forever in time in the commit that HEAD names) to all of the files in the index. If some file is gone on the left, but some name appears on the right that isn't on the left, Git checks: is that new right-side file really just a renaming of the left-side file? If so, git status will tell you that in the changes staged for commit, some file is being renamed.

    For each file that's the same in HEAD and the index, git status says nothing at all. For each file that doesn't match in some way, git status tells you about the way it doesn't match. These are the changes staged for commit. They are not actually committed!

  • Then, Git compares all the files in the index to the files in your working tree. If some file is gone from the index, but some new name(s) show up in the working tree, Git will check to see if that could be from renaming a file.

    As before, when the index and working tree copies don't match, git status says something about this. These are the changes not staged for commit. Committing actually uses the copies of files that are in Git's index, rather than the working tree copies, so that's why these are not staged.

So, just as with any two commits, Git doesn't actually store a rename operation. It just says: Huh, left side had file A, right side has no A, but right side has new file N. Let's see if we can build file N by renaming A. If so, Git calls for renaming A to N.

Hence, if you have in your working tree some extra file F that shouldn't be there, and you remove F, well, now it's gone. If you have a missing file G, you can restore it from the commit, and now it's there. Now there's no rename. If you want the rename, you just need to remove G and create F (with the right contents) and now Git will compare the left (has F, lacks G) to the right (lacks F, has G) and oh hey, look, a rename!

This means that all (?) you need to do is manipulate the index and working tree contents. To add a new file to the index, create the file in your working tree and run git add file. To remove a file from the index, use:

  • git rm file: removes the file from the index and from your working tree, or
  • git rm --cached file: removes the file from Git's index but leaves it alone in your working tree.

The git restore command (mentioned in git status output) works by copying files from:

  • any commit, or
  • the index

and placing the copy in:

  • the index, and/or
  • your working tree

Your task is to create the right files under the desired names, and remove files stored using any undesired names.

git reset --hard

Using git reset can make this painless, but be aware that git reset can be quite destructive. We mentioned earlier the whole thing about Git's index holding the copies of files that will go into the next commit. The reset command does one, two, or all three of the following things:

  1. First, git reset affects the current branch name.

    You must tell Git how to affect the name. If you don't say anything, Git assumes the effect is to "reset" it to HEAD, which is where it already is. So the default reset is a do-nothing reset. This isn't useful by itself, but if you tell git reset to go on to step 2, or steps 2 and 3 both, it becomes useful.

    If you use --soft, Git stops here, after doing step 1. Otherwise it goes on to step 2.

  2. Then, git reset resets Git's index.

    Remember, the index already contains every file that should go into the next commit. If you haven't done anything to the index, the index's files are those that were copied out of the commit you last checked out. If you have done things to the index since then, the index has new versions of those files, and/or has some file names changed in it, and/or has new files and/or is missing old files (and a rename is the same as removing the old name and creating a new name with the same contents).

    The reset command will now make the index contents match the commit contents. If you chose some other commit in step 1, this is this other commit's contents. If not, this is the same commit's contents—so this step is really only useful if you've updated the index's files in some way. However, if you want Git to go on to step 3, you have to let it do this step 2.

    If you use --mixed, or the default, Git stops here.

  3. Last, git reset resets your work-tree. This actually happens in combination with step 2: whenever Git has to remove an index copy of a file in step 2, it will also remove the work-tree copy of the file. If Git has to add an index copy of a file in step 2, it will wipe out the contents of the copy in your work-tree and replace it with the (defrosted) copy from the updated index. If the file doesn't exist in the index, though, it's untracked and Git won't touch it here in step 3.

    You only get step three if you ask for it with --hard. If you have fiddled with the index contents, or use two separate git resets, one with --mixed and then a second with --hard, this can leave some files behind in your working tree, because step 2 might have made them untracked. If they're in the way at this point, though, you just need to remove them with the ordinary everyday file remover on your system.

The main thing to remember about git reset is that it will, optionally, reset both Git's index (which usually contains files you can recover from somewhere, since mostly they came out of some earlier commit) and your working tree files. Those working tree files are not in Git and if they're not from some commit, you cannot get them back from Git. So before using git reset --hard, be very sure you won't lose a working tree file that you want to keep.

Upvotes: 3

Related Questions