Home > Enterprise >  How does Git branching work in this case?
How does Git branching work in this case?

Time:12-06

Let's say I have a repo "sample" with 2 branches "master" and "develop".

I work on "feature-1" and push my changes to the "develop" branch. These changes haven't been merged to "master".

I then work on "feature-2". How do I push just these changes to "master", without pushing "feature-1" as well?

CodePudding user response:

Note: you've asked an oddly-worded question (see j6t's comment). As a result, you get a long answer. Please read it carefully to see where what you're thinking is happening isn't what is actually happening in Git terminology, so that you can update your mental model and/or terminology.

I think I have provided the answer you are looking for, but it is hard to be sure.

Long

Let's say I have a repo "sample" with 2 branches "master" and "develop".

If you're using git push, you almost certainly have at least two repositories, not just one.

Let's say here that you have one local repository (e.g., on your laptop, where you do your day to day coding), and one hosting-site repository, on, e.g., GitHub. Let's call that other repository the "shared repo" since you use it to send your updates to everyone else.

The branch names in your repository are yours, to do with as you will. You might have master and develop and feature-1 at this point.

The branch names in the shared repository belong to the shared repository. These may be spelled the same—master and develop, for instance—as some of your branch names, but they are their names.

Each branch name, in any Git repository, holds the hash ID of exactly one commit. Two or more branch names may hold the hash ID of a single commit, or there may be thousands of commits, and two names that hold just two of these IDs. This works fine for Git because the purpose of a branch name is to help you and Git find a specific commit, but commits also find specific commits.

In particular, each commit you make remembers the hash ID of some set of previous commits. Most commits hold exactly one previous hash ID, which we call the parent commit. A few commits—merge commits—hold two parent hash IDs, and the very first commit ever has no parent, so its list of parent hash IDs is empty, but most commits just have the one.

What this means—which is the key to answering your question—is that commits remember earlier commits and branch names are only used to remember the last commit in some chain of commits. That is, if we go to draw the commits and a branch name, we get pictures that look like this:

... <-F <-G <-H   <--master

or:

           master
              |
              v
... <-F <-G <-H <-I <-J
                      ^
                      |
                   develop

Note how each branch name points to one commit. Each commit then points backwards to its parent commit. The uppercase letters here, like H, stand in for Git's actual commit hash IDs, which are big, ugly, random-looking hexadecimal numbers that no human ever wants to use.

Commit H here is the last commit on branch master, even though it is not necessarily the last commit ever. Commit J is the last commit on develop (and currently also the last commit ever). Git works backwards, starting from H, when you look at master. Git works backwards, starting from J (and reaching and working through H), when you look at develop.

(Each commit represents—and retains—a full snapshot of every file, plus the metadata that says who made it, when, and so on. The metadata include the raw hash ID of the parent commit(s), which is how Git works backwards. But you mostly just need to remember that Git works backwards.)

How cloning works, in a nutshell

You made your laptop repository by cloning the shared repository:

git clone -b somebranch <url>

When Git clones a repository, it copies all of their commits and none of their branches. Instead, your Git renames their branches into your own remote-tracking names. So if they had:

...--F--G--H   <-- master
            \
             I--J   <-- develop

you get, in your clone at this point:

...--F--G--H   <-- origin/master
            \
             I--J   <-- origin/develop

Note how you have the exact same commits (which will have the exact same big ugly hash ID numbers), but you have no branch names at all, yet.

Before it quits, though, your git clone command creates one branch name in your clone. The name it creates is the one you supply with your -b option. If you didn't supply a -b option, your Git asks their Git—their software running on the shared repository site—what branch name they recommend (usually master or now main). If you did use -b, you need to supply one of their branch names. Your Git matches up this -b with their name—which is your origin/ version now—and uses the commit found by this origin-ized name to create a new branch name in your repository, pointing to the same commit, like this:

...--F--G--H   <-- master (HEAD), origin/master
            \
             I--J   <-- origin/develop

How you use branch names while creating new commits

That HEAD notation I added here is how Git remembers which of your branch names you are using. You pick out—or even create from scratch if you like—an existing branch name or a new branch name. If you pick an existing branch name, you're picking an existing commit: whichever commit that branch name points to. If you create a new name, you can pick out any existing commit, though the default is to use whichever commit you're already using.

Your Git now extracts the commit you chose, into a working area. All commits are completely read-only, and the saved files inside those commits are in a format that only Git can read (and nothing can write once they've been saved). So to use these files, Git has to extract them. The extracted commit is now your current commit, with the branch name you chose being your current branch name. The special name HEAD remember which branch name you're using, and the branch name remembers which commit you're using. So that's why we drew:

...--F--G--H   <-- master (HEAD), origin/master
            \
             I--J   <-- origin/develop

You're currently using commit H, through the name master.

If you now create a new branch name, such as feature-1, you choose which commit it should point-to, but the default is the current commit H:

...--F--G--H   <-- feature-1, master (HEAD), origin/master
            \
             I--J   <-- origin/develop

You can now switch to that branch:

...--F--G--H   <-- feature-1 (HEAD), master, origin/master
            \
             I--J   <-- origin/develop

Note that you're still using commit H, but you're doing so via the name feature-1 now. (Since you did not change which commit you're using, Git doesn't bother removing and replacing any files, but if you were to create the name feature-1 pointing to commit G, or commit J, Git would have to remove the commit-H files from your working area, and put in the other commit's files.)

You now go about making changes to working-tree files, and then you run git add to make Git prepare these updated files for a new commit. (There's a lot to this, but we won't cover any the details here.) You then run git commit. Git will now make a new commit. This new commit gets a new, unique, random-looking (but not actually random) hash ID. We'll call this commit K, using the next uppercase letter:

             K   <-- feature-1 (HEAD)
            /
...--F--G--H   <-- master, origin/master
            \
             I--J   <-- origin/develop

New commit K points back to existing commit H. That's K's parent. Git knows to do this because when you started the git commit command, the HEAD commit was commit H.

Now that commit K exists—has been made by running git commit—Git has stored the hash ID of commit K in the name feature-1. So feature-1 now points to commit K. Commit K saves all the files—both the ones you didn't change, and the updated ones that you ran git add on—and since you git add-ed all your updates, commit K saves all the files from your working tree, forever (or for as long as commit K continues to exist anyway). It's now safe for Git to remove these files from your working tree, if you change to some other commit.

If you go ahead and make more commits, they just add on as before:

             K--L   <-- feature-1 (HEAD)
            /
...--F--G--H   <-- master, origin/master
            \
             I--J   <-- origin/develop

Note how the branch nameyour branch name; the name feature-1 exists only in your own repository right now—always points to the last commit in the branch. This happens automatically when you git commit, since Git updates the name to point to the new commit, which Git made point backwards to the old "last commit" in that branch.

What git push does

Note: this doesn't match your description:

I work on "feature-1" and push my changes to the "develop" branch.

I'm going to describe how you'd push your new commits to feature-1, not to develop.

When you run git push origin feature-1, your Git software, operating on your repository—I'll call this "your Git"—does this:

  • It calls up the other Git operating on the shared repository (I'll call this "their Git").
  • It has them list out their branch names and commit hash IDs. feature-1 isn't in this list, but master and develop are, and they show commits H and J respectively, if nothing else has changed.
  • Your Git now figures out which new commits you have, that they don't, that need to be sent. This would be commits K-L. Your Git uses the knowledge that, since they have commits up through H and J, you only have to send them any updates to any files that you changed or added in those two new commits. This lets your Git send very little data, even if the commits contain thousands or millions of files, with many megabytes of data.
  • Your Git then sends these commits. (For some git push operations, there are no commits to send at all, but here that would be K and L.)
  • Finally, your Git asks them to create or update their name feature-1, in their repository, to make it point to commit L.

Since they didn't have a feature-1 yet, this create-or-update request means create and they may not even have to check whether this is a reasonable thing to do. (Hosting sites often add a lot of checking that base Git doesn't normally do; e.g., GitHub offer protected branches. Base Git just does a simple "does this update drop any commits off the end of the branch" check, which Git calls a fast-forward test, when you're updating rather than creating.) Assuming they obey, which is likely, they now probably have:

             K--L   <-- feature-1
            /
...--F--G--H   <-- master
            \
             I--J   <-- develop

in their repository. Note that the commits are literally identical. their branch names, however, are theirs. For instance, perhaps they have some new commits on their master and develop that you don't have yet:

             K--L   <-- feature-1
            /
...--F--G--H--M   <-- master
            \
             I--J--N   <-- develop

That's perfectly normal and OK: you've sent them your K-L, which they didn't have, and now they have your K-L. You asked them to create the name feature-1, and they did, and their feature-1 now points to their copy of L. You did not ask them to change the hash IDs stored in their master and develop, and they did not, so they still have the commits they have. They've merely added your two comits.

(If you want to get commits M and N, you would now run git fetch origin. Your Git would call up their Git, see that they have M and N that you don't, and get M and N from them. This process is identical to the one you used with git push. But then your Git takes their master and develop names and renames them again, to origin/master and origin/develop. Your Git then updates your origin/master and your origin/develop, to point to M and N respectively. Of course, if they don't have any new commits—if M and N don't exist—you don't need to do any of this. But you find out by running git fetch: git push doesn't tell you, even though it internally found out which commits they had, to be able to send just commits K-L. There are complicated reasons for all of this and some of these details may be subject to change: the ones that aren't are that git push sends your new commits to them, and git fetch gets their new commits to you.)

Merging

Let's go back to this:

I work on "feature-1" and push my changes to the "develop" branch.

You can, literally, run:

git push origin feature-1:develop

This has your Git send your commits (K-L) to their Git as before, but then instead of asking them to create or update their name feature-1, your Git will ask them to create or update their name develop.

Let's look at the graph we drew earlier, without the added M and N commits, but using their names and no feature-1 yet:

             K--L   <-- polite request: make `develop` point to `L`
            /
...--F--G--H   <-- master
            \
             I--J   <-- develop

This how their Git would see this git push. It ends with a request: Please, if it's OK, set your branch name develop to point to commit L.

This is not okay. If they did that, here's what they would have:

             K--L   <-- develop
            /
...--F--G--H   <-- master
            \
             I--J   ???

What happens to commits I-J? The answer is: they can no longer find them. Git finds commits by starting from a name and working backwards. The only name they had that would find J was develop. If they make their develop point to L, no amount of working backwards will ever find I or J. The arrows in commits only point backwards.

(Remember, no part of any commit can ever change, once we make it. So commit H, which points backwards to commit G, can point backwards to G: G existed when we made H. But it can't point forwards to I, which didn't exist yet. When we make our own K, H can't point forwards to I or K: it's set in stone and it points only backwards, to G. So there's never any way to move forwards: Git can only work backwards.)

Now, if, when we started all this, their master and develop both pointed to H, and they still did when we ran:

git push origin feature-1:develop

then—in this particular case—we could add our commits on, because they would start with:

             K--L   <-- polite request to set develop here
            /
...--F--G--H   <-- master, develop

and setting develop would not lose any commits off the end, so they could do that.

Chances are, however, that this isn't what you did: instead, you probably just pushed feature-1. If you're using GitHub or Bitbucket, you then made a pull request, which is a GitHub / Bitbucket feature. If you're using GitLab, you then made a merge request, which is a GitLab feature. Base Git doesn't have these things: hosting sites offer them as add-ons, to add value for people using these hosting sites. (They all do it a little bit differently, too, so that you'll stick with their hosting. But that's another matter entirely.)

These pull or merge requests eventually result in someone—maybe yourself—doing something with the hosting site to add new commits to some branch, by updating some branch name(s) in the hosting-site Git repository, and maybe copying the proposed-to-add commits to new and improved commits first, or adding a merge commit. (Whether, when, and exactly how that happens is a topic for a separate discussion, tagged with the hosting site since each site does this a little differently. Be sure to search StackOverflow for existing discussions here, as there are plenty.)

Back to your question

I then work on "feature-2". How do I push just these changes to "master", without pushing "feature-1" as well?

When you run a second git push, your Git will, once again, send to their Git any new commits you have, that they don't.

Let's assume here that you started with this in your own repository:

...--F--G--H   <-- master (HEAD), origin/master
            \
             I--J   <-- origin/develop

but you then made your feature-1 so that it pointed to commit J, not H:

...--F--G--H   <-- master, origin/master
            \
             I--J   <-- feature-1 (HEAD), origin/develop

You then made a commit K:

...--F--G--H   <-- master, origin/master
            \
             I--J   <-- origin/develop
                 \
                  K   <-- feature-1 (HEAD)

and used git push origin feature-1 to send that to their (origin's) Git repository. Or, more likely, you started with:

...--F--G--H   <-- master (HEAD), origin/master, origin/develop

and ended with:

             I--J   <-- feature-1 (HEAD)
            /
...--F--G--H   <-- master, origin/master, origin/develop

You then were able to send commits I-J to their Git, to be merged or fast-forwarded or whatever into their develop.

You now want to start work on feature-2, which you intend to send to them such that they can easily add these commits to their master. What you need, then, is to create your feature-2 branch name pointing to the same commit that your origin/master—their master—points to.

In case that's been updated, it's a good idea to run git fetch at this point:

git fetch origin

This will collect any new commits they have and update your origin/* names. If there are no new commits or updates on their end, nothing will change in your Git repository. If there are some updates, you'll get any new commits and your origin/* names will get updated appropriately.

Next, you'll want to create your own feature-2 name, pointing to the same commit as their master, i.e., your now-updated origin/master. To do this, we can use:

git switch -c feature-2 --no-track origin/master

(the --no-track option is not really necessary here but in general it may be a good idea). You will now have this:

             I--J   <-- feature-1
            /
...--F--G--H   <-- feature-2 (HEAD), master, origin/master, origin/develop

This git switch -c command—or the equivalent git checkout -b feature-2 --no-track origin/master, if you have an older Git version—is a way to create a branch name, pointing to a particular commit (origin/master's, or commit H in these drawings), and then git switch or git checkout to it, all in one go. It's a shortcut for:

git branch feature-2 --no-track origin/master
git switch feature-2

Because you are switching branches, your Git will now remove, from your working tree, all the files that go with commit J—the commit that was HEAD just a moment ago—and replace them with all the files from commit H, to which the new name feature-2 points.

You can now make new commits as usual. The first such new commit's parent will be commit H, i.e., their master when you start out working:

             I--J   <-- feature-1
            /
...--F--G--H   <-- master, origin/master, origin/develop
            \
             K   <-- feature-2 (HEAD)

A second commit will extend this branch in the obvious way:

             I--J   <-- feature-1
            /
...--F--G--H   <-- master, origin/master, origin/develop
            \
             K--L   <-- feature-2 (HEAD)

Your four new commits, I-J and K-L, can all be sent to their Git repository at any time. You can even push all four at once, creating names feature-1 and feature-2 in their Git repository:

git push origin feature-1 feature-2

Your Git figures out which commits you have that they don't (I-J and K-L, if you haven't sent I-J yet), sends those commits to their Git, and sends them two polite requests to create or update names feature-1 and feature-2 in their Git repository.

(You can then create two pull requests, or two merge requests, if that's appropriate. Or, if you really are pushing directly to their master and develop, you can do that in one git push with:

git push origin feature-1:develop feature-2:master

As we noted before, these polite requests have their Git check to make sure that they won't lose any commits off the end of their branch names, if they make these updates.)

  •  Tags:  
  • git
  • Related