0

I can't remove a commit from master. I tried lots of commands from stackOverflow, but nothing works.

Last command git revert 655734672ea28811e0723981853f05cbcdda8ec8 just changed the commit. This how it looks now.

.............................................(master)
$ git log
commit ddd171a390ce613a87e7f4e00135b48a1b03c2ad (HEAD -> master)
Author: pet......... <fffffafafa@yahoo.com>
Date:   Sat Mar 7 15:08:57 2020 +0000

    Revert "adding jacoco-maven-plugin"
    This reverts commit 655734672ea28811e0723981853f05cbcdda8ec8.`
commit 655734672ea28811e0723981853f05cbcdda8ec8 (_D)
Author: pet.... <petttttttt@yahoo.com>
Date:   Sat Mar 7 13:42:48 2020 +0000

   adding jacoco-maven-plugin
Mehdi
  • 7,204
  • 1
  • 32
  • 44
mat1
  • 83
  • 10
  • The concept “remove commit” is meaningless, as no commit is really ever removed. And “change commit” is pretty meaningless too. Please explain in terms of your log what you are _really_ wishing to do. – matt Mar 07 '20 at 15:36
  • Probably `git reset --hard HEAD^^` is what you want now. It will set HEAD to two commits before. – Tarek Dakhran Mar 07 '20 at 15:53
  • Does this answer your question? [How do I undo the most recent local commits in Git?](https://stackoverflow.com/questions/927358/how-do-i-undo-the-most-recent-local-commits-in-git) – phd Mar 07 '20 at 17:07
  • https://stackoverflow.com/search?q=%5Bgit%5D+undo+commit – phd Mar 07 '20 at 17:07
  • I put wrong message into commit and then I pushed first/initial commit to the remote. As the result all files/dir structure on GitHub had on the right side/middle columns wrong message. To remediate it I wanted to repeat that commit first by removing it locally and then pushing it once ance again with new message. I assume that was not right approach. In the subsequent push I was able to change that message on GitHub repo but only for the files/folders that have been changed. I used git reset --hard HEAD^ which differes in single ^ and git reset --hard HEAD~2, neither of them worked. – mat1 Mar 07 '20 at 19:59
  • None of the `reset --hard HEAD^^` versions works, error message is below. I tried it now on test remote repo. As I said I wanted to remove commit which has been already pushed to origin. `$ git reset --hard HEAD^ fatal: ambiguous argument 'HEAD^': unknown revision or path not in the working tree. Use '--' to separate paths from revisions, like this: 'git [...] -- [...]'` – mat1 Mar 07 '20 at 23:03

2 Answers2

1

git revert creates a new commit undoing the changes from a given commit. It seems that the operation you described produces the desired result.

Read also: How to undo (almost) anything with Git .

Mehdi
  • 7,204
  • 1
  • 32
  • 44
1

Git is:

  1. distributed, meaning, there is more than one repository; and
  2. built specifically to make removing commits a little difficult: it "likes" to add commits, not remove them.

Your original question left off the fact that, having added two commits, you published them to GitHub. (You did eventually note this in a comment.)

If you really do want to remove some commits, you must now convince every Git that has them to give them up. Even if/when you manage this, they won't go away immediately: in general, you get at least 30 days to change your mind and bring them back again. But we won't get into that here. Let's just look at how you convince one Git to discard some commit(s), using git reset.

We'll use git reset --hard here for reasons we won't get into properly. Be aware that git reset --hard destroys uncommitted work, so before you do that, make sure you do not have anything uncommitted that you do want to save.

As Tarek Dakhran commented, you probably wanted git reset --hard HEAD^^. You'll need more than that, though—and before you blindly any of these commands, it is crucially important that you understand two things:

  • what this kind of git reset is going to do;
  • what HEAD^^ means.

To get there, and see how to reset your GitHub repository too, let's do a quick recap of some Git basics.

Git stores commits

Git is all about commits. Git is not really about branches, nor is it about files. It's about commits. The commit is your fundamental unit of storage in Git.1 What a commit contains comes in two parts.

First, each commit holds a full snapshot of all of your files. This isn't a set of changes since a previous commit! It's just the entire set of files, as a snapshot. That's the main bulk of a typical commit, and because it is, the files that are stored in a commit like this are stored in a special, read-only, Git-only, frozen form. Only Git can actually use these files, but the up-side of this is that making hundreds or thousands of snapshots doesn't take lots of space.

Meanwhile, the rest of the commit consists of metadata, or information about the commit: who made it, when, and so on. This metadata ties each new commit to some existing commit. Like the snapshot, this metadata is frozen forever once you make the commit. Nobody and nothing can change it: not you, and not even Git itself.

The unchangeable-ness of every part of a commit is what makes commits great for archival ... and quite useless for getting any new work done. This is why Git normally extracts one commit for you to work on. The compressed, dehydrated, and frozen-for-all-time files come out and get defrosted and rehydrated and turned back into ordinary files. These files aren't in Git at all, but they let you get your work done. That's part of why a --hard reset is relatively dangerous (but not the whole story). We'll ignore these usable files here and just concentrate on the commits themselves.

Every commit has a unique hash ID. The hash ID of a commit is the commit, in a sense. That hash ID is reserved for that commit, and only that commit can ever use it. In a way, that hash ID was reserved for that commit before you made the commit, and is still reserved for that commit even after you manage to remove it. Because the ID has to be unique—and reserved for all time for that commit—it has to be a truly enormous number. That's why commit hash IDs are so big and ugly, like 51ebf55b9309824346a6589c9f3b130c6f371b8f (and even these aren't big enough any more—they only count to about 1048—and Git is moving to bigger ones).

The uniqueness of these hash IDs means that every Git that has a clone of some repository can talk to any other Git that has a clone of the same repository, and just exchange hash IDs with the other Git. If your Git has 51ebf55b9309824346a6589c9f3b130c6f371b8f and their Git doesn't, then you have a commit they don't, and they can get it from you. Now you both have 51ebf55b9309824346a6589c9f3b130c6f371b8f.

That would be all there is to it, except for the part where we said that every commit can store the hash ID(s) of some earlier commit(s). Commit 51ebf55b9309824346a6589c9f3b130c6f371b8f stores commit hash ID f97741f6e9c46a75b4322760d77322e53c4322d7. That's its parent: the commit that comes just before it.


1A commit can be broken down into smaller units: commit objects, trees and sub-trees, and blob objects. But that's not the level on which you normally deal with Git.


This is the key to Git and branches

We now see that:

  • Every commit has a big ugly hash ID, unique to that commit.
  • Every commit contains the hash ID of some earlier commit, which is the parent of that commit. (A commit can contain two parent hash IDs, making the commit a merge commit, but we won't get into these here.)

When anything contains a commit hash ID, we say that thing points to the commit. So commits form backwards-pointing chains.

If we use single uppercase letters to stand in for hash IDs, we can draw this kind of chain of commits like this. Imagine we have a nearly-new repository with just three commits in it. We'll call them A, B, and C, even though in reality they have some random-looking hash IDs. Commit C is the last commit, so it points back to B:

    B <-C

But B has a backwards-pointing arrow (really, a stored hash ID) pointing to A:

A <-B <-C

Commit A is special. Being the very first commit ever made in this repository, it can't point back, so it doesn't.

Adding new commits to a repository just consists of writing out the new commit such that it points back to the previous last commit. For instance, to add a new commit we'll call D, we just draw it in:

A <-B <-C <-D

No part of any existing commit can change, but none does: C doesn't point to D, D points to C. So we're good here, except for one thing. These hash IDs, in a real repository, don't just increment like this. We have big ugly things like 51ebf55b9309824346a6589c9f3b130c6f371b8f and f97741f6e9c46a75b4322760d77322e53c4322d7. There's no obvious way to put them in order. How do we know which one is last?

Given any one hash ID, we can use that commit to find the previous commit. So one option would be: extract every commit in the repository, and see which one(s) don't have anything pointing back to them. We might extract C first, then A, then B, then D, or some other order, and then we look at all of them and realize that, hey, D points to C points to B points to A, but nobody points to D, so it must be the last one.

This works, but it's really slow in a big repository. It can take multiple minutes to check all that stuff.

We could write down the hash ID of the last commit (currently D), perhaps on a scrap of paper or whiteboard. But we have a computer! Why not write it down in the computer? And that's what a branch name does: it's just a file, or a line in a file, or something like that,2 where Git has scribbled down the hash ID of the last commit in the chain. Since this has the hash ID of a commit, it points to a commit:

A--B--C--D   <-- master

We can get lazy now and stop drawing the arrows between commits, since they can never change, but let's keep drawing the ones from the branch names, because they do change. To add a new commit to master, we make a new commit. It gets some random-looking hash ID that we'll call E, and it points back to D. Then we'll have Git write the hash ID of new commit E into the name master, so that master points to E now:

A--B--C--D--E   <-- master

Let's create a new branch name, feature, now. It too points to existing commit E:

A--B--C--D--E   <-- feature, master

Now let's create a new commit F. Which branch name gets updated? To answer that last question, Git uses the special name HEAD. It stores the name of the branch into the HEAD file.3 We can draw this by attaching the special name HEAD to one of the two branches:

A--B--C--D--E   <-- feature, master (HEAD)

Here, we're using commit E because we're on branch master, as git status would say. If we now git checkout feature, we continue using commit E, but we're now on branch feature, like this:

A--B--C--D--E   <-- feature (HEAD), master

Now let's create new commit F. Git will make F point back to E, the current commit, and will then make the current branch name point to new commit F:

A--B--C--D--E   <-- master
             \
              F   <-- feature (HEAD)

We've just seen how branches grow, within one repository.


2Git currently uses both methods to store branch-to-hash-ID information. The information may be in its own file, or may be a line in some shared file.

3This HEAD file is currently always a file. It's a very special file: if your computer crashes, and when it recovers, it removes the file HEAD, Git will stop believing that your repository is a repository. As the HEAD file tends to be pretty active, this is actually a bit common—not that the computer crashes often, but that when it does, you have to re-create the file to make the repository function.


Git is distributed

When we use Git, we hardly ever have just have one repository. In your case, for instance, you have at least two repositories:

  • yours, on your laptop (or whatever computer); and
  • your GitHub repository, stored over in GitHub's computers (that part of "the cloud").

Each of these repositories has its own branch names. This is what is about to cause all our problems.

To make your laptop repository, you probably ran:

git clone <url>

This creates a new, empty repository on your laptop. In this empty repository, it creates a remote, using the name origin. The remote stores the URL you put in on the command line (or entered into whatever GUI you used), so that from now on, instead of typing in https://github.com/... you can just type in origin, which is easier and shorter. Then your Git calls up that other Git, at the URL.

The other Git, at this point, lists out its branch names and the hash IDs that these branch names select. If they have master and develop, for instance, your Git gets to see that. Whatever hash IDs those names select, your Git gets to see those, too.

Your Git now checks: do I have this hash ID? Of course, your Git has nothing yet, so the answer this time is no. (In a future connection, maybe the answer will be yes.) Your Git now asks their Git to send that commit. Their Git is required to offer your Git the parent of that commit, by its hash ID. Your Git checks: do I have this commit? This goes on until they've sent the root commit—the commit A that has no parent—or they get to a commit you already have.

In this way, your Git and their Git agree on what you have already and what you need from them. They then package up everything you need—commits and all their snapshotted files, minus any files you might already have in commits you already have—and send all that over. Since this is your very first fetch operation, you have nothing at all and they send everything over, but the next time you get stuff from them, they'll only send anything new.

Let's suppose they have:

A--B--C--D--E   <-- master (HEAD)
             \
              F   <-- feature

(your Git does get to see their HEAD). Your Git will ask for and receive every commit, and know their layout. But your Git renames their branches: their master becomes your origin/master, and their feature becomes your origin/feature. These are your remote-tracking names. We'll see why your Git does this in a moment.

Your Git is now done talking with their Git, so your Git disconnects from them and creates or updates your remote-tracking names. In your repository, you end up with:

A--B--C--D--E   <-- origin/master
             \
              F   <-- origin/feature

Note that you don't have a master yet!

As the last step of git clone, your Git creates a branch name for you. You can choose which branch it should create—e.g., git clone -b feature means create feature—but by default, it looks at their HEAD and uses that name. Since their HEAD selected their master, your Git creates your master branch:

A--B--C--D--E   <-- master (HEAD), origin/master
             \
              F   <-- origin/feature

Note that the only easy way you have of finding commit F is by your remote-tracking name origin/feature. You have two easy ways to find commit E: master and origin/master.

Now, let's make a new commit on master in the usual way. This new commit will have, as its parent, commit E, and we'll call the new commit G:

              G   <-- master (HEAD)
             /
A--B--C--D--E   <-- origin/master
             \
              F   <-- origin/feature

Note that your master, in your laptop Git, has now diverged from their master, over on GitHub, that your laptop Git remembers as origin/master.

Your master is your branch. You get to do whatever you want with it! Their master is in their repository, which they control.

fetch vs push

Let's make a couple more commits in our master now:

              G--H--I   <-- master (HEAD)
             /
A--B--C--D--E   <-- origin/master
             \
              F   <-- origin/feature

Note that, so far, we have not sent any of these commits to the other Git. We have commits G-H-I on our master, on the laptop, or wherever our Git is, but they don't have these at all.

We can now run git push origin master. Our Git will call up their Git, like we did before, but this time, instead of getting stuff from them we will give stuff to them, starting with commit I—the commit to which our master points. We ask them: Do you have commit I? They don't, so we ask them if they have H, and G, and then E. They do have E so we'll give them the G-H-I chain of commits.

Once we've given them these three commits (and the files that they need and don't have—obviously they have E so they have its snapshot, so our Git can figure out a minimal set of files to send), our Git asks them: Please, if it's OK, set your master to point to I.

That is, we ask them to move their master branch, from the master name we used in our git push origin master. They don't have a mat1/master to keep track of your repository. They just have their branches.

When we ask them to move their master like this, if they do, they'll end up with:

A--B--C--D--E--G--H--I   <-- master (HEAD)
             \
              F   <-- feature

They won't lose any commits at all, so they'll accept our polite request. Their name master now points to commit I, just like our name master, so our Git adjusts our remote-tracking name:

              G--H--I   <-- master (HEAD), origin/master
             /
A--B--C--D--E
             \
              F   <-- origin/feature

(Exercise: why is our G on a line above the line with E? Why didn't we draw it like this for their repository?)

git reset makes removing commits locally easy

Now, let's suppose commits H and I are bad and you'd like to get rid of them. What we can do is:

git checkout master             # if needed
git reset --hard <hash-of-G>

What this will do, in our repository, we can draw like this:

                 H--I   <-- origin/master
                /
A--B--C--D--E--G   <-- master (HEAD)
             \
              F   <-- origin/feature

Note that commits H and I are not gone. They are still there, in our repository, findable by starting with the name origin/master, which points to commit I. From there, commit I points back to commit H, which points back to commit G.

Our master, however, points to commit G. If we start from here, we'll see commit G, then commit E, then D, and so on. We won't see commits H-I at all.

What git reset does is let us point the current branch name, the one to which HEAD is attached, to any commit anywhere in our entire repository. We can just select that commit by its hash ID, which we can see by running git log. Cut and paste the hash ID from git log output into a git reset --hard command and you can move the current branch name to any commit.

If we do this, and then decide that, gosh, we want commits H-I after all, we can just git reset --hard again, with the hash ID of commit I this time, and make master point to I again. So aside from wrecking any uncommitted work, git reset --hard is sort of safe. (Of course, wrecking uncommitted work is a pretty big aside!)

We still have to get their Git to change: we need more force

Moving our master back to G only gets us updated. The other Git, over on GitHub ... they still have their master pointing to commit I.

If we run git push origin master now, we'll offer then commit G. They already have it, so they'll say so. Then we'll ask them, politely: Please, if it's OK, make your master point to commit G.

They will say no! The reason they'll say no is simple: if they did that, they'd have:

                 H--I   [abandoned]
                /
A--B--C--D--E--G   <-- master (HEAD)
             \
              F   <-- feature

Their master can no longer find commit I. In fact, they have no way to find commit I. Commit I becomes lost, and so does commit H because commit I was how they found commit H.

To convince them to do this anyway, we need a more forceful command, not just a polite request. To get that forceful command, we can use git push --force or git push --force-with-lease. This changes the last step of our git push. Instead of asking please, if it's OK, we send a command: Set your master! Or, with --force-with-lease, we send the most complicated one: I think your master points to commit I. If so, make it point to G instead! Then tell me whether I was right.

The --force-with-lease acts as a sort of safety check. If the Git over on GitHub lists not just to you, but to other Gits as well, maybe some other Git had them set their master to a commit J that's based on I. If you're the only one who sends commits to your GitHub repository, that obviously hasn't happened, and you can just use the plain --force type of push.

Again: --force allows you to tell another Git: move a name in some arbitrary way, that doesn't necessarily just add commits. A regular git push adds commits to the end of a branch, making the branch name point to a commit "further to the right" if you draw your graph-of-commits the way I have been above. A git push that removes commits makes the branch name point to an earlier commit, losing some later commit, or—in the most complicated cases—does both, removes some and adds other commits. (We haven't drawn this case and we'll leave that for other StackOverflow answers ... which already exist, actually, regarding rebasing.)

Recap

  • Branch names find the last commit in a branch (by definition).
  • Making new commits extends a branch, by having the new commit point back to the previous tip, and writing the new commit's big ugly random-looking hash ID into the branch name.
  • git reset allows you to move branch names around however you like. This can "remove" commits from the branch (they still exist in the repository, and maybe there's another way to find them).
  • git push asks some other Git to move its branch names. It defaults to only allowing branch-name-moves that *add8 commits.

Hence, since you have pushed the commits you want to "remove", you'll need to git reset your own Git, but then git push --force or git push --force-with-lease to get the GitHub Git to do the same thing.

What HEAD^ etc are about

When we were looking for ways to "remove" commits with git reset, I suggested:

  • Run git log (and here I'll suggest using git log --decorate --oneline --graph, which is so useful that you should probably make an alias, git dog, to do it: D.O.G. stands for Decorate Oneline Graph).
  • Cut-and-paste commit hash IDs.

This works, and is a perfectly fine way to deal with things. It's a bit clumsy sometimes, though. What if there were a way to say: starting from the current commit, count back two commits (or any other number)?

That is, we have some chain of commits:

...--G--H--I--...

We can pick out one commit in the chain, such as H, by its hash ID, or any other way. Maybe it has a name pointing to it:

...--G--H   <-- branchname
         \
          I--J   <-- master (HEAD)

Using the name branchname will, in general, select commit H. If Git needs a commit hash ID, as in git reset, the name branchname will get the hash ID of commit H.

Using the name HEAD tells Git: look at the branch to which HEAD is attached, and use that name. Since HEAD is attached to master, and master points to J, the name HEAD means "commit J*, in places where Git needs a commit hash ID.

This means we could run:

git reset --hard branchname

right now and get:

...--G--H   <-- branchname, master (HEAD)
         \
          I--J   [abandoned]

The name branchname selects commit H, and git reset moves the branch name to which HEAD is attached—master—to the commit we select.

Note, by the way, that:

git reset --hard HEAD

means: look at the name HEAD to figure out which commit we're using now, then do a hard reset that moves the current branch name to that commit. But since that's the commit we're using now, this "moves" the name to point to the same commit it already points to. In other words, move to where you're standing already ... ok, don't move after all. So this git reset --hard lets us throw out uncommitted work, if that's what we need to do.

In any case, adding a single caret ^ character after any branch name or hash ID means select that commit's parent. So since branchname means commit H, branchname^ means commit G.

If we have:

...--F--G--H--I   <-- master (HEAD)

then master^ and HEAD^ both mean commit H. Adding another ^ tells Git to do that again: master^^ or HEAD^^ means commit G. Adding a third ^ selects commit F, and so on.

If you want to count five commits back, you can write master^^^^^ or HEAD^^^^^, but it's easier to use master~5 or HEAD~5. Once you get past "two steps back", the tilde ~ notation is shorter. Note that you can always use the tilde notation anyway: master~1 and master^ both count one commit back. You can omit the 1 here too, and write master~ or HEAD~.

(There is a difference between ^2 and ~2. For more about this, see the gitrevisions documentation.)

torek
  • 448,244
  • 59
  • 642
  • 775