Search code examples
gitgit-reset

How do I reset master and keep my branch in git?


Suppose I do

$ git checkout master
$ touch foo.py
$ git commit -m "oops" foo.py
$ git checkout -b new_branch
$ touch bar.py
$ git commit -m "changes" bar.py

Now when I try to push back changes on new_branch, I get

Local branch 'master' is ahead of remote branch 'origin/master'

How do I reset master while not losing my changes (foo.py, bar.py) on new_branch?

I read the git reset page, and it looked like it might involve --keep, but I couldn't tell.


Solution

  • This can be very confusing initially, and what you needed was a proper introduction to how Git implements branches; but at this point we'll use the retrofit method. :-) The trick to understanding all this is that Git's commits are permanent and unchanging, but its branches—or more precisely, branch names—are temporary, and in fact mostly irrelevant.

    There are three things that matter when you are building a new commit (they are HEAD, the index, and the work-tree), but once you have the commit built and committed, it's quite permanent and it is very hard to get Git to lose it entirely. It's easy enough to accidentally misplace it, though, so let's try to avoid that. :-)

    If we ignore the branch names entirely, we can draw a graph of the commits that exist in your repository. Given what you have done—making two new commits—let's draw them like this, where round os represent commits, and A and B are your two new commits:

    ...--o--o--o
                \
                 A
                  \
                   B
    

    We could draw them all on one line, but I want to leave room to write labels in on the right. Commit A is your "oops", and B is your "changes".

    The main noteworthy thing about this graph drawing is that each commit points to (stores the hash ID of) its predecessor commit. This means that commit B points back to commit A. Commit A points back to the next-most-recent commit, which points back still further, and so on.

    Now we add the labels—the branch names. The last boring commit o still probably has a label origin/master. Commit A has the label master, and commit B has the label new_branch, so let's draw these in:

    ...--o--o--o   <-- origin/master
                \
                 A   <-- master
                  \
                   B   <-- new_branch (HEAD)
    

    This is what branch names are and do for you: they are pointers to commits; they remember each commit's big ugly hash ID for you.

    When you are on some branch and make a new commit, the branch name comes along for the ride. The special name HEAD remembers which branch you're on, so that Git knows which name to move to the new commit. (We needed this temporarily at least, when master and new_branch briefly both pointed to commit A.)

    What you want to do now is move master back to point to the last of the boring o commits. To do so, you can use git reset, which lets you move a name in an arbitrary way:

    git checkout master
    git reset --hard origin/master
    

    This assumes (see final section below) that origin/master really does point to the last of the boring o commits. The git reset --hard says: Wipe out my current index and work-tree, and move my current branch—according to HEAD—so that it points to the commit I name here. We must git checkout master first, so that HEAD names master. Then the git reset does this:

    ...--o--o--o   <-- master (HEAD), origin/master
                \
                 A
                  \
                   B   <-- new_branch
    

    So now your two new commits A and B are found only via new_branch and not via master.

    (There are several more things that branch names do for you. In particular, they protect commits from being removed by Git's "garbage collector". If a commit has any name by which we can find it, it's protected. If it has no name, it's no longer protected. There are some semi-hidden names that protect everything for a while—at least 30 days by default—to make sure commits don't get trashed accidentally, but finding them through these reflog names is annoying, so we try not to rely on it so much.

    The branch names are also used for git push, so there they matter a fair bit.)

    (I mention "trashing" the index and work-tree above, which isn't completely true. The git reset command, used the way we're using it here, has three jobs it can do:

    • move the current branch
    • reset the index
    • reset the work-tree

    It always does the first, optionally adds the second job, and then optionally adds the third. The --hard mode does all three, the --mixed mode does the first two, and the --soft mode does only one. To understand them all properly, we would have to look closely at the definitions of index and work-tree and I'll leave that for other SO questions/answers. The key item here, though, is that you don't want to git reset --hard until you have everything saved away in commits.)

    What if there's no origin/master (or it points back too far)?

    We assumed, above, that there was a handy label—a so-called remote-tracking branch—identifying the commit we want to git reset our master to point-to. If we don't have this label, or it points to an even earlier commit, we must locate that commit some other way.

    There are a lot of ways to do this. The most straightforward is to go by its hash ID. If you run git log while on some branch, you see each commit that is "on" or "contained in" that branch, normally in Git's usual backwards-going order. For instance, we might git log and see commit B, with its big ugly hash ID, then commit A with its hash ID, and then that boring commit o with its big ugly hash ID.

    We can use the raw hash ID:

    git checkout master && git reset --hard <hash-id>
    

    if we don't have the label. If we do have the label, we should probably just use it.

    You might remember to get help from "A DOG" with git log:

    git log --all --decorate --oneline --graph
    

    The all makes Git show all branches and all remote-tracking branches (and everything else: tags, the "stash", notes, etc). The decorate option attaches the label names to commits. The oneline option makes the output show just one line for each commit, and the graph option makes Git try to draw the commit graph.