Search code examples
gitgit-track

Can Git track an ancestor branch as well as its own branch?


Sorry if this is a noob question, but I've been searching for an hour+ and can't get a clear answer.

What I'd like to do is this:

  • Checkout a remote branch git checkout origin/feature
  • Make a local branch based off that branch git checkout -b feature_my_tweak
  • So now I have a local copy of feature called feature_my_tweak that I can play around with
  • Make some changes and commit -m "added my tweak" so now my local feature_my_tweak has diverged from feature
  • Now do a git push --set-upstream origin feature_my_tweak to push my local branch to the remote server
  • Now a workmate pushes a change to feature on the remote server

At this point, I would like to be able to git pull and have git see that there was a change to feature and pull it (fetch/merge) into my local feature_my_tweak branch. Is there a way to do that automatically? Or do I have to do a manually git merge feature to get all the changes from that branch to my local feature_my_tweaks branch? I guess I thought there was a way to have one branch track another.

Another part of this is that I'd still like to be able to make local changes to feature_my_tweaks and still be able to git push and have that only push up to the remote origin/feature_my_tweak branch and not pollute the origin/feature branch.

Is there a way to do all that? From what I can read, it seems that if any branch_A is tracking a remote branch_B, any pushes will go to branch_B, which is what I don't want.

I hope that makes sense.


Solution

  • TL;DR

    The short version of the answer boils down to no, but it doesn't matter, don't worry about it and just do it manually; it's easy. If you have one particular branch that you do this with often, write a script or a Git alias to do it.

    Medium

    To do this manually, for your branch B where you want to know if there's new stuff on origin/feature, vs your own branch feature_my_tweaks with upstream set to origin/feature_my_tweaks, just run:

    git rev-list --count --left-right feature_my_tweaks...origin/feature
    

    This prints out two numbers. The number on the left is the number of commits that you have on feature_my_tweaks that are not part of your origin/feature. The number on the right is the number of commits that are on your origin/feature that are not on your feature_my_tweaks.

    If the second number is not zero, and you want to, you can now run git checkout feature_my_tweaks; git rebase origin/feature or git checkout feature_my_tweaks; git merge origin/feature.

    Whether and when to use git rebase is up to you, but after rebasing, you will need to git push --force origin feature_my_tweaks (or git push --force-with-lease origin feature_my_tweaks) to get the Git over at origin to discard the old commits that your git rebase discarded when it copied your commits to new-and-improved commits.

    Long

    Probably the biggest problem here is terminology. What does the word tracking mean to you? Git uses this one word in at least three different ways,1 so at most one of them can match up with the one you're thinking of. (Possibly none of them match exactly, depending on what you're thinking happens here.)

    Regardless of which word(s) you use, here is the right way to think about this:

    • What matters are commits. Each commit has a unique hash ID. Every Git everywhere—every clone of this repository—uses that hash ID for that commit. If you have a commit with that hash ID, it's the same commit. If you don't, you don't have that commit.

    • Names in Git take a bunch of forms. Each name holds one hash ID—just one!

      The three forms that you use are:

      • Branch names: master, develop, feature or feature/one, and so on. These are yours, to do with as you will. (You'll probably want yours to match up with other peoples' names, though, at least at various times.)

      • Tag names: v2.1 and so on. While yours are yours, your Git will try to share them with other Git repositories: if your Git sees that theirs has a v2.1 and you don't, your Git is likely to copy theirs to yours.

      • Remote-tracking names: origin/master, origin/feature, and so on. Git calls these remote-tracking branch names and some people shorten that to remote-tracking branch, but they're not quite like branch names. Your Git slaves these to some other Git's branch names. You have your Git call up their Git and get any new commits from them. Then your Git updates your remote-tracking names so that they match theirs.

        The remote-tracking name is built by sticking the remote name (in this case origin) in front of their branch name, with a slash to keep them apart. That's why your origin/master "tracks" their master, updated every time you run git fetch origin.2


      All these names work similarly. They all have long forms: refs/heads/master is the full spelling of master, for instance, vs refs/remotes/origin/master for origin/master. That's how Git knows which one is which kind of name. Git normally then shortens the name for display to you, taking off the refs/heads/ part for branch names, or the refs/remotes/ part for remote-tracking names.

    Note that git fetch is as close as there is to the opposite of git push. It might seem like these should be push and pull, but due to a historical accident3 it's push and fetch. Pull just means: Run fetch, then run a second Git command, git merge by default, to merge with the current branch's upstream.

    Fetch and push are very similar, but with two key differences:

    1. Fetch gets things. You tell your Git: call up the Git whose URL you have stored under the name origin (or whatever remote name you use here). Your Git calls up a server at that URL, which must answer as a Git repository. That server then tells you about its branch names and their commits, and your Git checks to see if you have those commits. If not, your Git asks their Git for those commits, and their parent commits if you don't have them, and the parents' parents if needed, and so on. They give you all the commits they have, that you don't, that your Git needs to complete these.

      Having gotten the commits, git fetch then updates your remote-tracking names by renaming their branches.

    2. Push sends things. As before, you have your Git call up another Git, by a remote name like origin. Then your Git gives them commits, rather than getting commits from them. But here, things are slightly different:

      • The commit(s) your Git offers are the tip commits of any branches you are pushing. (If you are just pushing one branch, that's just one commit.) If they don't have those commits, your Git must offer the parents of these tip commits. (Most commits have just one parent, so that's one more commit.) If they don't have those, your Git must offer more parents, and so on. Through this process, your Git finds all the commits their Git needs to have a complete picture of the tip commit you're sending: all of its history.

        When you were fetching, they offered all their branches. The difference: you're only pushing whichever branches you specified.

      • After your Git has sent any commits you have, that they don't, that they need to complete the tip commit(s) for the branch(es) you're pushing, your Git sends a polite request for them to set their branch name(s).

        When you were fetching, your Git set your remote-tracking names. You're now asking them to set their branch names.

    To summarize these key points:

    • fetch gets (all, by default, unless you limit it) commits and branches from them and renames their branches to your remote-tracking names
    • push sends (one, by default)4 branch-tip commit (and its ancestors if/as needed) and then asks them to set their branch name(s)

    You can have git push ask them to set a different name than the one you use to select the commit(s) to send. For instance:

    git push origin test-xyz:new-feature
    

    sends the tip commit of your test-xyz branch (and its parents and other ancestors if/as needed), but asks them to set or create their branch name new-feature.

    So:

    ... it seems that if any branch_A is tracking a remote branch_B, any pushes will go to branch_B ...

    This is entirely wrong, at least by default. (There is a setting for push.default that does make that happen, though.)

    There is a lot more to know here, in particular, what this git rev-list --left-right --count is doing and what it means for commits to be "on"—or more precisely, reachable from—a branch name, but I've kind of run out of time and space in this answer, at this point.


    1In particular, files can be tracked or untracked, some names are remote-tracking names, and a branch with an upstream set is said to be tracking its upstream. The verb (in its present participle form) becomes an adjective, modifying name or file, in the first two cases.

    2There are ways to run a limited git fetch that doesn't update all your remote-tracking names. Using git pull sometimes does this, for instance. I prefer to separate my git fetch and my other commands, rather than using the git pull fetch-then-run-another-Git-command command.

    3It's common after fetching to want to integrate what you fetched, so at first, git fetch was a back-end "plumbing" command, not meant for users to run, and git pull ran git fetch, then git merge. This was before remotes existed as a concept, and hence before remote-tracking names existed either, so there wasn't any reason for users to run git fetch.

    Over time, Git grew remotes and remote-tracking names, and now the difference between pull—fetch and combine—and just fetch without combining (or at least, without doing it yet) became both useful and important. But the name pull was already in use to mean fetch and combine, hence this particular historical accident.

    4That is, this is the default in Git 2.0 and later. You can change it with push.default, and Git versions older than 2.0 default to matching.

    When using matching, your Git asks their Git about their branch names, matches up your matching branch names, and defaults to pushing from your matching names to their matching names.

    If you change this setting to upstream, your Git asks their Git to set their branch based on the upstream setting of your branch, which is what you were assuming in your question.

    The modern default setting is simple, which requires that the upstream be set to a branch of the same name, so that git push on your end just fails right away if your upstream is set to a different name of their side. However, you can override this easily by typing in git push origin branch, which means the same thing as git push origin branch:branch: ask their Git to use the same branch name as you are using in your Git.