Durée de lecture : environ 10 minutes

Now that we have covered Git’s basics, we will review some common issues and how to solve them. Lost commits, wrong branches, finding a problematic commit… I will list a few problems you will probably encounter on a regular basis and give you some tips so that you stop considering them as problems 🙂

I’ve lost commits !

Each commit is saved in the repository. No matter what you do, even if you think you have lost a recent commit, it is probably still there. Remember, branches behave a lot like linked lists ; if a node was created but no one points to it, it might not be easily visible but chances are it still exists 🙂
In order to find a previous commit, you can use the git reflog command. The reflog (for “reference log”) is some kind of history for the HEAD pointer. It will display what happened to HEAD in the past and therefore will let you see the lost commit’s ID (SHA-1). Once you get this ID, a simple git cherry-pick <commit's ID> will get the commit (or, to be more accurate, a copy of it) back into your branch. If you do not want to get the commit back and simply want to read what’s in it, simply use git checkout and copy/paste whatever code you need.

An example. Let’s pretend I’m working on a branch and I am unhappy with my latest commit :

Guillaume@Gron MINGW64 /i/Dev/git (main)
$ git log
commit 1e0ac27d85cd0eb579cc4bddee59ea013ce2e79e (HEAD -> main)
Author: Guillaume Téchené
Date:   Sun Jun 13 11:55:00 2021 +0200

    Commit that breaks things

commit e23a64fba84f89e1871400de040d80cc65f929bf
Author: Guillaume Téchené
Date:   Sun Jun 13 11:54:38 2021 +0200

    Fixed stuff

commit 2c82e84e8a3f039e2b94163ced9b0a2122db3a1b
Author: Guillaume Téchené
Date:   Sun Jun 13 11:50:53 2021 +0200

    Initial commit

Now let’s say I do a git reset --hard HEAD~1 in order to “lose” the latest commit, and finally realize there was some important work I would like to get back, what can I do ? If I had copied the commit’s SHA then it would be a no-brainer but unfortunately I haven’t. git log won’t help me because it does not display the commit’s hash since the commit was erased from the history. Hence git reflog :

Guillaume@Gron MINGW64 /i/Dev/git (main)
$ git reflog
e23a64f (HEAD -> main) HEAD@{0}: reset: moving to HEAD~1
1e0ac27 HEAD@{1}: commit: Commit that breaks things
e23a64f (HEAD -> main) HEAD@{2}: commit: Fixed stuff
2c82e84 HEAD@{3}: commit (initial): Initial commit

Voilà ! The commit’s shortened ID is 1e0ac27 ; running git cherry-pick 1e0ac27 will put a similar commit back on top of our branch and git log will display the same output as before the git reset except that the git’s ID has now changed (even if it looks the same, it’s a new commit from Git’s point of view).

I’ve lost an old commit !

Sometimes git reflog might not be enough or it may be difficult to parse. A thing to keep in mind is that git has some kind of garbage collector. It will completely delete objects that have not been referenced for some time. It happens quite frequently but the default behavior is not to remove objects that are less than 2 weeks old.
Anyway, let’s pretend you want to find an old commit that was not removed by git’s garbage collector but that you can’t find (or be bothered) with git reflog. In this case, you can get a list of all objects (not only commits) by running git fsck --lost-found. I won’t go much into details and the output is quite scarce :

Guillaume@Gron MINGW64 /i/Dev/git (main)
$ git fsck --lost-found
Checking object directories: 100% (256/256), done.
Checking objects: 100% (10/10), done.
dangling commit a6307ec1f21c89e409a5cb7839639ec701b7bcd4
dangling commit 1e0ac27d85cd0eb579cc4bddee59ea013ce2e79e

We can see there are 2 “dangling” commits, i.e. no one (no parent/child, no tag, etc…). references these commits. A git show <SHA> command will then tell you all there is to know about any commit as long as you provide its SHA. It can be quite tedious if there are a lot of commits in this list.

I have modified the history but I can’t push !

This is quite a usual story. You’re working on your branch, pushing commits, then you realize your latest commit was a mistake and reset it. When you want to push the impact of your reset, you get the following error message :

Guillaume@Gron MINGW64 /i/Dev/git (main)
$ git push
To https://my-nice-git-remote.com/AwesomeProject
 ! [rejected]        main -> main (non-fast-forward)
error: failed to push some refs to 'https://my-nice-git-remote.com/AwesomeProject'
hint: Updates were rejected because the tip of your current branch is behind
hint: its remote counterpart. Integrate the remote changes (e.g.
hint: 'git pull ...') before pushing again.
hint: See the 'Note about fast-forwards' in 'git push --help' for details.

Of course, if you do what you’re told by Git, you will perform a git pull which will completely undo your reset by fetching and merging the commit you just reset :p Which is just normal because the reset moved your HEAD behind the remote’s history and Git has seen it, so it asks you to synchronize before pushing any modification. What you want to do is both rewrite history and push it ; it’s not exactly considered good practice but on a branch where you’re working by yourself you might simply want to have a clean history. Therefore you have to force these changes and this is why the –force option exists to git push. However, there is a more gentle version of it which is --force-with-lease : it will let you push modifications of existing commits but not overwrite the new ones. This is way more subtle and adequate in most situations. In fact, I do not remember having to use –force instead of –force-with-lease.

I have committed on the wrong branch and I can’t push…

Another common use case and it’s very simple to solve. Simply creating a branch and then undoing your modifications on the branch you started will fix it. Let’s see how it works with a simple example. I’m on my main branch and, because of security policies at my company, I am not allowed to push directly onto it. However, I have committed on my local main branch :

Guillaume@Gron MINGW64 /i/Dev/git (main)
$ git log
commit fb878f53793a687b27e3945e6ef08ac7c0a6315e (HEAD -> main)
Author: Guillaume Téchené
Date:   Sun Jun 13 11:55:47 2021 +0200

    Commit on the wrong branch

commit e23a64fba84f89e1871400de040d80cc65f929bf
Author: Guillaume Téchené
Date:   Sun Jun 13 11:54:38 2021 +0200

    Fixed stuff

commit 2c82e84e8a3f039e2b94163ced9b0a2122db3a1b
Author: Guillaume Téchené
Date:   Sun Jun 13 11:50:53 2021 +0200

    Initial commit

If I just create a branch and check it out, I can see I will get my changes with me. This is perfectly normal because that’s the way branches work in Git :

Guillaume@Gron MINGW64 /i/Dev/git (main)
$ git checkout -b my_new_branch
Switched to a new branch 'my_new_branch'

Guillaume@Gron MINGW64 /i/Dev/git (my_new_branch)
$ git log
commit fb878f53793a687b27e3945e6ef08ac7c0a6315e (HEAD -> main)
Author: Guillaume Téchené
Date:   Sun Jun 13 11:55:47 2021 +0200

    Commit on the wrong branch

commit e23a64fba84f89e1871400de040d80cc65f929bf
Author: Guillaume Téchené
Date:   Sun Jun 13 11:54:38 2021 +0200

    Fixed stuff

commit 2c82e84e8a3f039e2b94163ced9b0a2122db3a1b
Author: Guillaume Téchené
Date:   Sun Jun 13 11:50:53 2021 +0200

    Initial commit

So far so good. I can push this branch if I want in order to “back it up”, should anything go wrong. Now I just need to cancel the mess I have made in my local main. Again, git reset to the rescue :

Guillaume@Gron MINGW64 /i/Dev/git (main)
$ git reset --hard origin/master
HEAD is now at e23a64f Fixed stuff

Please note I have specified the local copy of the remote branch here, origin/master. This is some kind of safeguard, as it explicitly states “forget everything I have done since I last fetched the remote main branch”. I don’t care how many commits are wrong, I just want to clean up my local main branch. Of course, a git reset --hard HEAD~1 would have worked just the same in this specific example.

I have committed a file that I did not intend to commit along with other files !

So let’s say you have committed several files, and among them was a modification that should not have been part of this commit. For example, you might have started working on a feature while receiving a hotfix request and committed changes related to this feature along with the hotfix. How do you “remove” this file and keep the others ?
What you want to do is to go back to the state right before committing, unstage or cancel the unwanted modifications and create another, clean commit. Technically, this will be done using git reset --soft HEAD~1 to go back to the previous state while keeping the local modifications, then git reset <path to the file to remove from the commit> to exclude the file and finally commit again. Here’s an example :

$ git log --name-status
commit 64369f4590e02af2fe47c36e257a9467d1941805 (HEAD -> main)
Author: Guillaume Téchené <guillaume.techene@outlook.com>
Date:   Mon Jun 14 11:53:01 2021 +0200

    Commit for hotfix ; also contains unwanted modifications

A       new feature.txt
M       test.txt

commit 2c82e84e8a3f039e2b94163ced9b0a2122db3a1b
Author: Guillaume Téchené <guillaume.techene@outlook.com>
Date:   Mon Jun 14 11:50:53 2021 +0200

    Initial commit

A       test.txt

OK, I have committed a file that should not have been part of this. Let’s reset :

Guillaume@Gron MINGW64 /i/Dev/git (main)
$ git reset --soft HEAD~1

Guillaume@Gron MINGW64 /i/Dev/git (main)
$ git log
commit 2c82e84e8a3f039e2b94163ced9b0a2122db3a1b (HEAD -> main)
Author: Guillaume Téchené <guillaume.techene@outlook.com>
Date:   Mon Jun 14 11:50:53 2021 +0200

    Initial commit

Guillaume@Gron MINGW64 /i/Dev/git (main)
$ git status
On branch main
Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
        new file:   new feature.txt
        modified:   test.txt

As you can see, the commit is gone thanks to git reset but the --soft option kept the modifications locally so you can edit them. That’s a very nice feature that will help us remove the file we want to exclude from the commit :

Guillaume@Gron MINGW64 /i/Dev/git (main)
$ git reset new\ feature.txt


Guillaume@Gron MINGW64 /i/Dev/git (main)
$ git status
On branch main
Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
        modified:   test.txt

Untracked files:
  (use "git add <file>..." to include in what will be committed)
        new feature.txt

Using git reset with no option on this specific file has excluded the file from the commit ; this is called unstaging. We can now commit our hotfix :

Guillaume@Gron MINGW64 /i/Dev/git (main)
$ git commit -m "hotfix"
[main acc9c81] hotfix
 1 file changed, 3 insertions(+), 1 deletion(-)

Guillaume@Gron MINGW64 /i/Dev/git (main)
$ git log --name-status
commit acc9c81a65f1fba712c10bb6486a93cfff85d625 (HEAD -> main)
Author: Guillaume Téchené
Date:   Mon Jun 14 11:56:31 2021 +0200

    hotfix

M       test.txt

commit 2c82e84e8a3f039e2b94163ced9b0a2122db3a1b
Author: Guillaume Téchené
Date:   Mon Jun 14 11:50:53 2021 +0200

    Initial commit

A       test.txt

And here we go ! The proper commit replaced the previous one and we are now free to push our changes.

I have a bug I did not have several revisions ago. How do I find the commit that introduced it ?

This is an interesting question. Most people will go quite far in the history, checkout an old commit, check the bug does not exist, then go a little less far, checkout a less-old commit, etc… this is a long and tiresome process. Luckily, Git has a native command that will do the job for us by working with dichotomy : git bisect. What it does is, given a “good” commit (i.e. without the bug you are tracking) and a “bad” one (i.e. with the bug), it will check out the commit right in the middle between them. You will perform some tests or look at the code or whatever lets you spot the bug. If everything is fine, you will mark the commit as “good”. Git will then look for the commit in the middle between the “bad” one and the “good” one you have just marked and iterate, until you find the commit that originally introduced the bug.

Illustration of a binary search algorithm, working by dichotomy.
Binary search depiction By AlwaysAngry – Own work, CC BY-SA 4.0, https://commons.wikimedia.org/w/index.php?curid=53687795

But wait, there’s more. If you have a test that can run with the command line, git bisect run <command of your test> can automatically run this test for you and mark the commits itself depending on the results of the test ! This makes everything run automagically and quickly.

Here is a a great article about git bisect with a very nice and detailed example. It is unfortunate that this command is not as famous as it should be. You can also read this Twitter thread by Mathias Verraes on how amazing git bisect is.

Some remote branches have been deleted but my local copies of them have not

Yes that’s normal, you have to explicitly tell Git to clean them up by using git remote prune <name of the remote origin> which often ends up being git remote prune origin. Git will then tell you which branches have been deleted locally.

A few interesting aliases and scripts

Git allows you to create aliases thanks to the following command : git config --global alias.<alias name> '<arguments and options>'. Let’s see some of my favourite aliases :

  • Since I use git checkout a lot, I have chosen to abbreviate it to git co : git config --global alias.co 'checkout'. You can then combine it with other options as usual.
  • I also like git status to tell me how far behind or ahead I am from the remote and which file(s) I have currently edited but not committed. But I dislike how verbose it is, hence the following alias : git config --global alias.st 'status --short --branch'
  • We talked about git fsck in a previous paragraph so here is a command line (not aliasable with Git but it probably is through your shell’s aliases) that gives more details about all dangling commits : git fsck --lost-found | grep "dangling commit" | cut -d" " -f 3 | xargs git show --format="%n%Cred*%Creset %as (%C(yellow)%h%Creset %d) %an - %s" --stat
    It’s a bit complicated so here is how it works :
    1) grep "dangling commit" : displays only dangling commits, not the other objects (tags, etc…).
    2) cut -d" " -f 3 : only takes the third word (separated with spaces) of each line. In our case, this will be the commit’s SHA.
    3) xargs: passes each SHA to the following command and executes it. Here, we want to execute the git show command for each commit SHA.
    4) git show --format="%n%Cred*%Creset %as (%C(yellow)%h%Creset %d) %an - %s" --stat : will display the date, short SHA (in yellow), ref (if any), author and title of the commit, as well as the diff stats (list of changed files and number of lines added/removed in each one of them).
  • For some more Git aliases, please check out this nice article.

In order to list all your aliases, you can run git config --list | grep alias. And for more tips when everything goes wrong, you can also pay a visit to Oh Shit, Git !?!

Conclusion

This wraps up this small series of articles about Git. I sincerely hope it has cleared up or demystified some things about this tool. It’s true that Git can be scary at times ; maybe it’s because of the command line, maybe it’s because it’s easy to screw things up, but in the end you realize it’s quite rare to completely lose your work… as long as you understand what you’re doing 🙂

Categories: Dev

Guillaume Téchené

Software developer since 2000, I try to make the right things right. I usually work with .Net (C#) and Azure, mostly because the ecosystem is really friendly and helps me focus on the right things. I try to avoid unnecessary (a.k.a. accidental) complexity and in general everything that gets in the way of solving the essential complexity induced by the business needs. This is why I favor a test-first approach to focus first on the problem space and ask questions to business experts before even coding the feature.