0

I've deleted several files and committed this change in Git in a feature branch, but it turns out one of these is needed after all. Since the file isn't there, I can't easily view its history and see which commit I did this, etc.

I do know the name of the file. How can I get back just this one file? I do physically have a copy of it but it seems like restoring it from the parent branch or rolling back that deletion would be better for version history.

This is a feature branch with dozens of commits, it is just this one file from one commit I want back. I made the change weeks (and many commits) ago and this mistake only was noticed today.

(Looking online I seem to find a lot of people asking how to revert deletions after rebasing and so on but this is a straight-up "oops I committed a file-delete")

Mr. Boy
  • 60,845
  • 93
  • 320
  • 589

2 Answers2

1
  • get the full file path with git log --diff-filter=D --summary | grep delete
  • find the commit you last had the file using git log --all --full-history -- "filepath"
  • Get back to the commit where the file did exists and start a new branch.
  • Merge your current work to that branch.
  • Merge the branch back to your working branch.

See this answer https://stackoverflow.com/a/37622157/12361414

Flo
  • 448
  • 2
  • 6
1

Every commit has a full copy of every file—or more precisely, every file that it has, but stated this way, it sounds redundant. What this means is that if you check out some commit C and begin working, and one of the things you do is delete some file path/to/file and eventually run git commit to make new commit D, commit D has all the files it has, which means that it omits path/to/file. But path/to/file remains in commit C, since no commit can ever be changed, by any power anywhere, once it's made.

What this in turn means is that, if file path/to/file isn't changed in commit C vs some earlier commits B and A, you can get the file path/to/file out of any of those three commits. The file in all three commits is just a duplicate (and in fact, Git de-duplicated it, storing the file only once despite its appearance in all three commits). The mechanism behind this is very clever and elegant, and completely irrelevant to your purposes. All that matters to you is the fact that you can get the file from any commit that has the copy you want back.

While keeping that in mind, if it's easiest to find commit C specifically, rather than commit A or B, we can just do that—find commit C. If it's easiest to find commit B or A, and you're sure the copy in those two commits is just as good, consider using commit B or A to get the file back.

The first command that Florian Endrich showed in his answer, git log --diff-filter=D --summary, is a way to find the commit I'm calling D above—the one in which the file you care about was deleted, and thereby to find the full path name of the file. If you already know the file's path, it's simpler to just use the next command:

git log -n 1 -- path/to/file

which is a simplified version: we just need to get the raw hash ID of commit D. Once we have that, it's a trivial matter to name commit C, as commit C is the parent commit of commit D. Here's an example, trimmed slightly and with one @ changed to a space to (perhaps) cut down a bit on spam:

git log -n 1 -- compat/gmtime.c
commit 84b0115f0dc9483dbc7f064b46afaddc4d94db92
Author: Carlo Marcelo Arenas Belón <carenas gmail.com>
Date:   Thu May 14 12:18:54 2020 -0700

    compat: remove gmtime
    
    ccd469450a (date.c: switch to reentrant {gm,local}time_r, 2019-11-28)
    removes the only gmtime() call we had and moves to gmtime_r() which
    doesn't have the same portability problems. ...

This particular commit, to the Git repository for Git, removes the file compat/gmtime.c. So that file exists in this commit's parent commit, in the form it had just before the removal. What commit is the parent of commit 84b0115f0dc9483dbc7f064b46afaddc4d94db92? Git can tell us:

$ git rev-parse 84b0115f0dc9483dbc7f064b46afaddc4d94db92^
7397ca33730626f682845f8691b39c305535611e

(note the caret, ^, character at the end of the rev-parse argument; you can use ~ if your command line interpreter likes to gnaw on carrots, er, carets).

The parent of 84b0115f0d... is therefore 7397ca3373..., which means if we want compat/gmtime.c back, we can simply ask Git to look at, or extract, the version of that file as it appears in commit 7397ca3373...:

$ git show 7397ca3373:compat/gmtime.c
#include "../git-compat-util.h"
#undef gmtime
#undef gmtime_r
[snipped]

We don't really have to find C's hash ID either, because we can add that ^ suffix to D's hash ID to mean "commit C" in general. So git show 84b0115f0d^:compat/gmtime.c would work just as well.

Given that you want the file back, the command you would want to use is the new git restore in Git 2.23:

git restore --source=<commit> --staged --worktree -- path/to/file

where the <commit> part is the correct hash ID for C, or the hash ID for D followed by a caret ^ or tilde ~ character, or whatever.

If you have an easy-to-use, easy-to-recall name for a commit A or B that precedes commit C, but has the right file in it, you can use that here too:

git restore --source=dev --staged --worktree -- path/to/file

if the copy of the file in the last commit on branch dev is a good copy.

If you don't have Git 2.23 and cannot upgrade, you can use:

git checkout <commit> -- <path>

instead of git restore with --source=... --staged --worktree. In fact, if you do have Git 2.23 or later, you can still use this kind of git checkout, which still works.

Summary

  1. Find a commit with a good copy of the file. Any commit will do: commits only hold full snapshots of files, so any commit with the right snapshot in it is a fine source for that copy of that file.

  2. Use git restore or git checkout to extract the file from that commit. Using --staged --worktree tells git restore to extract the file to both the staging area and your working tree, so that the file is ready to go. Using git checkout always does this as well: with git restore you can choose not to have the file already staged, for instance, so git restore is more flexible, at the cost of requiring more typing.

torek
  • 448,244
  • 59
  • 642
  • 775