Search code examples
gitgithubfeature-branch

Pushing from feature branch pushed a different branch?


From our develop branch, I created a new branch with git checkout -b jsm/logging. Made my changes, commited, and pushed to origin with git push -u origin HEAD. Did a PR and merged and delted the remote branch. Made another tweak and amended my last commit with git commit --amend --no-edit -a. Then I checked my status and force-pushed with git push -f. To my surprise, the wrong branch had been (force) pushed! Have a look at my console log (note that I've aliased g to git, st is an alias for status, and co is an alias for checkout).

Side note: I've also noticed that when I attempt to push develop, for instance, Git often complains that master is out of sync (need to pull first) -- but why is it doing anything with master when I'm not on that branch? Seems to be related, not sure what the issue is.

console log (branch name before "$"):

josh:~/Projects/my-project jsm/logging $ git commit --amend --no-edit  -a
[jsm/logging 4cdb3dc] add logging
 Date: Mon Aug 27 15:18:41 2018 -0400
 1 file changed, 12 insertions(+), 6 deletions(-)

josh:~/Projects/my-project jsm/logging $ git st
## jsm/logging...origin/jsm/logging [ahead 1, behind 1]

josh:~/Projects/my-project jsm/logging $ git push -f 
Counting objects: 1, done.
Writing objects: 100% (1/1), 685 bytes | 0 bytes/s, done.
Total 1 (delta 0), reused 0 (delta 0)
To git@github.com:my-org/my-project
 + 5a649bc...8d320d2 develop -> develop (forced update)
                     ^^^^^^^ why is it pushing a different branch than I'm on?!

josh:~/Projects/my-project jsm/logging $ g co develop
Switched to branch 'develop'
Your branch is up-to-date with 'origin/develop'.

josh:~/Projects/my-project develop $ g co jsm/logging
Switched to branch 'jsm/logging'
Your branch and 'origin/jsm/logging' have diverged,
and have 1 and 1 different commit each, respectively.
  (use "git pull" to merge the remote branch into yours)

josh:~/Projects/my-project jsm/logging $ git st
## jsm/logging...origin/jsm/logging [ahead 1, behind 1]

josh:~/Projects/my-project jsm/logging $ git push -fu origin jsm/logging
Counting objects: 11, done.
Delta compression using up to 8 threads.
Compressing objects: 100% (8/8), done.
Writing objects: 100% (11/11), 1.04 KiB | 0 bytes/s, done.
Total 11 (delta 5), reused 0 (delta 0)
remote: Resolving deltas: 100% (5/5), completed with 5 local objects.
To git@github.com:my-org/my-project
 * [new branch]      jsm/logging -> jsm/logging
Branch jsm/logging set up to track remote branch jsm/logging from origin.

git config

alias.st=status -sb
alias.co=checkout
alias.cob=checkout -b
pull.rebase=true
push.default=matching
core.repositoryformatversion=0
core.filemode=true
core.bare=false
core.logallrefupdates=true
remote.origin.url=git@github.com:my-org/my-project
remote.origin.fetch=+refs/heads/*:refs/remotes/origin/*
branch.develop.remote=origin
branch.develop.merge=refs/heads/develop
branch.jsm/logging.remote=origin
branch.jsm/logging.merge=refs/heads/jsm/logging

Solution

  • knugie's comment contains the correct reason: you set push.default to matching. You may have been expecting to use current, or even upstream. That's the TL;DR right there.

    The default push.default in modern Git is simple, which most people consider safer—matching is what was the default before 2.0, and it caused a lot of people a lot of headaches. But to understand why this is the case, let's look at what git push does, at a high level. This is also what git fetch does, sort of, so it's worth covering both to some extent. I'll leave out the special fetch-specific rules, though.

    Long description (optional)

    First, your Git picks out a (single) other Git to contact. (If you fetch from or push to multiple remotes, Git does it one at a time.) This other Git is found at some URL. Typically you use a name like origin to supply the URL, but you can spell one out directly, if you like. (This is rarely a good idea—it's mostly a holdover from prehistoric Git.) Or you can let Git figure it out. If there's only one remote, named origin, Git will get it right every time. :-)

    Then, your Git calls up another Git at that URL (remote.origin.url). That other Git has its own branches, tags, and other references. Your Git has their Git list all their branches, tags, and other references. You can see what your Git sees by running git ls-remote origin, which does these two steps, prints out the result, and stops.

    For git push, though, the next step depends on several things:

    • Did you list refspecs on the command line? (If so, Git uses the refspecs you listed.) The refspecs go after the remote name: git push origin <refspec1> <refspec2> ..., for instance. If you ran git push with no extra arguments, you did not list any refspecs.

    • If you did not list refspecs, is there a special default for this remote? (If so, that's the default. Note that this is a refspec, not a string literal like push.default!)

    • Otherwise, push.default is the default. It has five settings, which we'll get into below.

    For git fetch, there is a similar pattern, but Git almost always winds up using the remote.remote.fetch setting, e.g., remote.origin.fetch, because there's always such a setting, and you as a user will tend to just run git fetch origin, or even just git fetch.

    This leaves one other obvious question: what the heck is a refspec anyway?

    Refspecs

    The second-simplest form of a refspec looks like master:master or jsm/logging:jsm/logging—or, for git fetch, like master:origin/master. That is, there's a left-hand side name, a colon : character, and a right-hand side name.

    The name on the left is the source and the name on the right is the destination. Each name is a reference or ref name, which means you can spell out a full name like refs/heads/master. If you don't spell out the name, Git will typically guess correctly that master is a branch-name and v1.2 is a tag-name (by looking at the branch and tag names you have), but if it guesses wrong, or you want to be really sure, you can spell out the full name.

    But I said this is the second-simplest form. The simplest is to omit the colon and destination entirely: master or v1.2 or jsm/logging. Here, fetch and push differ in how they treat these: they're both still the source for the operation, but for git push, the destination is a copy of the source. For git fetch, the destination is to not save—to discard—the name. Since we are looking at git push, we can skip the fetch special-ness, and concentrate on how git push likes to use the same name on both sides.

    Worth noting: you can add a leading + to a refspec. This sets the force flag for that refspec only. Let's look at a simple example below.

    Fetch and push are mainly about commits

    There are two things that fetch and push have to achieve. The first, and most important by far, is to transfer commits. Without the commits, Git has nothing: the commits are the reason Git exists. They hold (indirectly) the files.

    So if you run git push, you have your Git give to their Git any commits that you have, that they don't, that they are going to need. If you run git fetch, you have your Git obtain from their Git any commits that they have that you don't that you will need.

    The set of commits that you, or they, will need is determined by reachability, which is a fairly big topic. For a really nice introduction to this, see Think Like (a) Git. The one line overly-summarized summary, though, is that they will need the commits that are on your branch, when you push your branch; you will need the commits that are on their branch, when you fetch their branch.

    Having transferred the right set of commits to the right Git, your two Gits now must cooperate in one last step: setting some name(s). If you are git pushing, you have your Git ask their Git to set their names: you ask them to update their master, or update or create their jsm/logging, for instance. If you are git fetching, you have your Git set your origin/master based on their master, for instance—and that particular trick, of renaming their master to your origin/master, happens through the refspecs set up in remote.origin.fetch.

    With refspecs, Git pushes or fetches just those things you specified

    So, if you do name some set of refspecs on the command line, your Git will fetch or push commits based on the source names you listed. The receiving Git will remember the commits fetched or pushed by setting some names—in your Git if fetching, in theirs if pushing—based on the destination names you listed.

    Note that git push can send its final name-setting operations as either polite requests—*please set your master to a123456....—or as rather forceful commands: set your master to a123456... or it's straight to bed without supper! Their Git can still refuse commands, but the usual default is to check polite requests to see if they merely add new commits, and obey forceful commands.

    Without refspecs, git push falls back, perhaps all the way to push.default

    If you just run git push origin or git push—with or without the force flag—your Git uses some default setting. If you don't have a specific refspec for the remote, your Git uses push.default. This is where its five settings come in:

    • nothing: this makes git push fail, forcing you to list some refspec(s). (I tried this myself for a while but found it too painful.)

    • current: this tells your Git to use your current branch. It may have been what you were expecting. It's equivalent to doing git push remote refs/heads/branch:refs/heads/branch.

    • upstream (aka tracking): this tells your Git to use your current branch as the source, but use its upstream name as the destination. That is, if your current branch is B, but B's upstream is origin/not-B, this is equivalent to git push origin B:not-B.1

    • simple: similar to upstream but requires that the upstream name match the current branch name. That is, if master has origin/master as its upstream, and you are on master, git push pushes to origin/master as you would expect—but if B pushes to origin/not-B and you are on B, git push simply fails.

    • matching: your Git goes through their Git's list of branch names (all the git ls-remote names that start with refs/heads/). For every branch name that they have, your Git pushes your branch of the same name.

    Note that if you use the --force flag, this applies to all pushed branches. If your mode is matching, it applies to the matching branches. That's why your output read:

    + 5a649bc...8d320d2 develop -> develop (forced update)
    

    Your Git found that both you and they had refs/heads/develop. Theirs was 5a649bc, yours was 8d320d2, and 5a649bc is not an ancestor of 8d320d2. A polite request—*please set your develop to 8d320d2—would have been rejected, but with the force flag in effect, your Git sent a command, and their Git obeyed. That lost some commit(s) from their develop, so they said "forced update" and your Git printed that and the three dots (a normal non-forced push shows only two dots).

    If you still have commit 5a649bc in your own repository, you can easily recover from this. If not, it's trickier. To recover, if you do have it, consider running:

    git push origin +5a649bc:refs/heads/develop
    

    This uses a refspec in which the + (force flag) is set, the source is the raw commit hash 5a649bc, and the destination is the branch name develop. Note that it's wise (and maybe even necessary) to spell out refs/heads/develop here since the source "name" is a raw hash ID, so your Git doesn't know this is supposed to be a branch.


    1This may or may not summon Shakespeare's ghost. (Or is that King Hamlet?)