How to commit directly to parent branch from the child branch?

Viewed 557

Let's say I am in branch B that is re-based on branch A. I am happy with a commit that I have done with branch B and I would like to "push" that commit to branch A and rebased on that. Also, there are a lot of uncommitted files on branch B that I am still working on. I can do the following:

<B> git commit -m "msg"
<B> git stash 
<A> git checkout A
<A> git cherry-pick my_commit_hash_from_branch_b
<A> git checkout B
<B> git rebase A
<B> git stash pop

Is there a shortcut to do above from branch B (directly commit to the parent branch) if the branches have not diverged yet?

1 Answers

Branches don't have parents in Git, so the question can't quite be answered as phrased. But I think I know what you mean. It's both harder than you think (in some situations that aren't the one you are thinking of), and easier. And, if your Git is at least 2.15 or later, I'd suggest looking into git worktree add for sanity purposes.

Background

Let's draw this and make sure that we all agree what this all means. We start with the fact that commits have parents, so that if we draw a simple linear chain of commits, using uppercase letters to stand in for the actual hash IDs, we get a picture like this one:

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

where H stands in for the hash ID of some commit that's the last commit on some branch. Commit H consists of two parts: a source snapshot of all files, and some metadata. In the metadata for commit H, Git has stored the actual hash ID of earlier commit G, so that H points to G. Commit G is similar: it stores a snapshot and some metadata, and that metadata causes G to point backwards to F, which itself points backwards. This backwards-looking chain of commits is the history in the repository.

The point where branch names come into this picture is that they let us (and Git) find the commits, by storing the hash IDs of each "last commit". So if we have branches br1 and br2—two different branch names—that both have H, or rather the full big ugly hash ID of H, in them, we have this picture:

...--F--G--H   <-- br1, br2

Whenever we make a new commit, Git writes the new commit out—it writes a snapshot and some metadata—with the new commit's parent hash set to the current commit. Writing out the new commit assigns it its new, big ugly random-looking hash ID, which we'll call I, so that I points back to H:

...--G--H
         \
          I

and then the last step of git commit is to write I's hash ID into the current branch name.

To make all of this work, Git needs to know two things:

  1. What is the name of the current branch?
  2. What is the hash ID of the current commit?

Git gets both of these two things from the special name HEAD, by attaching that name to one of the two branch names. So in our initial state:

...--G--H   <-- br1, br2

we really have:

...--G--H   <-- br1 (HEAD), br2

By asking Git: which branch name is HEAD attached to? we find out which branch name we're using, and of course that's br1. By asking Git: which hash ID does the name HEAD find? we find out which commit we're using, and that's commit H. If we check out the other name, br2, we change names without changing commits.

Once we make a new commit, the commits that were on both branches are still on both branches, but the one branch name that has advanced now has one commit only on that branch:

...--G--H   <-- br2
         \
          I   <-- br1 (HEAD)

What you've literally asked (which is not quite what you meant to ask)

Let's say I am in branch B that is re-based on branch A.

Let's draw this. Since I use letters like A, B, C for commits, I'll use branch-A and branch-B here:

...--G--H   <-- branch-A
         \
          I--J--K   <-- branch-B (HEAD)

I am happy with a commit that I have done with branch B and I would like to "push" that commit to branch A and rebased on that.

Which of the three commits, I-J-K, that are currently only on branch-B would you like to have branch-A contain? Without making any new commits, your only option at this point is to move the label branch-A to point to I, J, or K. If you choose to point it to I you get:

...--G--H--I   <-- branch-A
            \
             J--K   <-- branch-B (HEAD)

which achieves what you want, but if you choose to point it to J you get:

...--G--H--I--J   <-- branch-A
               \
                K   <-- branch-B (HEAD)

which effectively moves both commits I and J into branch-A.

Also, there are a lot of uncommitted files on branch B that I am still working on.

Uncommitted files are never on any branch!

A branch name never holds any files. A branch name holds one commit hash ID. That one commit is, by definition, the last commit on the branch. That's what we see above. So if something isn't committed, it cannot be on any branch. Only commits are on branches.

With that in mind, here is what I think you are asking

The setup you have in mind is not:

...--G--H   <-- branch-A
         \
          I--J--K   <-- branch-B (HEAD)

but rather:

...--G--H   <-- branch-A, branch-B (HEAD)

where commit H is the current commit and is the last commit on both branches. Meanwhile, you've been doing work and have some files not-staged-for-commit and/or some files staged-for-commit (see the optional "more background" below for more about this). You would, at this point, like to make a new commit I but have both branch names advance, like this:

git <mystery command set 1 (if any commands are required here)>
git add <arguments>
git commit <arguments>
git <mystery command set 2>

such that the result is:

...--G--H--I   <-- branch-A, branch-B (HEAD)

where new commit I has the updates you chose to commit, and your work-tree remains undisturbed.

This part is very easy to do! The mystery commands in set 1 are no commands at all, and for the mystery commands in set 2, there's a sneaky way to (ab?)use git push to do the job as a single command. Here's what we do:

git add ...         # add any files you want updated in commit `I`
git commit          # use -m option if desired (rarely a good idea; see notes)
git push . branch-B:branch-A

The git commit command results in:

...--G--H   <-- branch-A
         \
          I   <-- branch-B (HEAD)

as usual, with new commit I only on branch branch-B. The git push command is how we advance the name branch-A. We use the special "remote" name .—it's special in that it's not actually a remote at all; it refers to this repository itself—and in effect, we have our Git call up itself, as if it were another Git. Our Git now asks the "other Git" (i.e., ourselves) whether we have commit I—of course we do—and then asks the "other Git" (i.e., ourselves) to update "its" (our) branch name branch-A, in a fast-forward fashion,1 such that the name now points to branch-B. This is of course OK with ourselves: the push is accepted, and our own Git's branch-A label now refers to commit I too. We can straighten out our drawing as:

...--G--H--I   <-- branch-A, branch-B (HEAD)

which is exactly what we wanted to have. (Note that you can spell the git push command as git push . HEAD:branch-A instead, if you like; this spelling is suitable for use in a Git or shell alias, since it automatically refers to the current branch through the magic of the special name HEAD. You thus only have to specify the name you want fast-forwarded.)


1Remember, a fast-forward occurs when the new hash ID stored in some name—a branch name or a remote-tracking name, normally—represents a commit that is a descendant of the commit whose hash ID was in the name just a moment ago. That is, it's OK to move from H to I because I has H as a parent. It would be OK to move from F straight to I, too, because I leads to H which leads to G which leads to F.


More background (all optional)

It's important to know about the actual mechanism that Git uses when making commits. Git could hide this much better, but purposely doesn't. Because it doesn't, some of the things Git says make no sense until you do understand it. But before we get to what Git does here, let's start with why Git does it.

Everything in every commit is frozen for all time. This includes all the files in each snapshot. They literally cannot be changed. Moreover, they're in a special Git-only format, that most programs can't read. But if they can't be changed, or even read by other programs, how can you get any work done?

For this reason, Git has to copy all the files out of a commit before you can use them. This means the files that you work with—in what Git calls your working tree or work-tree—aren't in Git.

Now, Git could stop here, with just the current-commit frozen copy of the file, and the useful copy that you can modify and put into a new commit. Other version control systems do stop here. But in Git's case (and also in some of those other VCSes), this would make it slow to make a new commit: Git would have to go re-compress and re-Git-ify every file, see if it's the same or different, and so on. So it doesn't do that.

Git could have you announce which files you've changed, and re-Git-ify those files at git commit time. But Git doesn't do that either. Instead, what Git does is re-Git-ify each updated file when you run git add on it. To make that work conveniently for itself, Git keeps a data structure that has three names. The three names for this thing are the index, the staging area, and the cache. The name "cache" is rarely seen these days: it mostly occurs in spellings like git rm --cached or git diff --cached. The name staging area refers to how you use it. The meaningless name, index, is the one Git uses the most internally, because "staging area" doesn't cover a bunch of auxiliary uses that Git mostly successfully hides away.

What this means for you—besides having to know the three terms for it—is that you need to know that Git makes new commits from Git's index, not from your work-tree, and that you must therefore explicitly copy files into Git's index. The index holds, at all times, a copy of each file that will go into the next commit. An initial checkout not only extracts the committed files into the useful form for you, in your work-tree. It also "extracts" the same files into Git's index, in the useful form for Git: pre-compressed-and-Git-ified, ready to go into the next commit. So there are, at all times, three active copies of each file: one in the current or HEAD commit, one in Git's index, and one in your work-tree. It's just that it's extremely common for two or even all three of these copies to match.

Git is really just making things easy for git commit here. Since the index always holds the proposed next commit, git commit just needs to package up the (already frozen) files and use them in the new commit. But this sort of makes for more work for git status, because now Git has to run two comparisons:

  • git status has to compare the HEAD commit vs the index (proposed next commit). For each file that is the same, it says nothing, but for each file that is different, it says staged for commit.
  • git status also has to compare the index vs your work-tree. For each file that is the same, it says nothing; for each file that is different, it says not staged for commit.

There are a bunch of other tricks that Git hides away here, but these are the ones that you have to keep in mind no matter what.

Note that the index is also how Git knows which files go in the snapshot. The files that go in a new commit are those that are in Git's index right now. If you remove a file from Git's index—using git rm, with or without --cached—that file is now no longer in the proposed next commit. If you add a totally new file to Git's index using git add, that file is now in the proposed next commit.

In any case, when you run git commit, Git does not use what is in your work-tree. Those aren't Git's files. Git uses whatever is in its own index. There are a few forms of git commit that copy files into Git's index along the way, e.g., git commit -a is like git add -u followed by git commit—but with a sneaky internal implementation using extra index file copies that allow Git to back it all out if something goes wrong. But ultimately, Git has to put the files into the, or at least an, index in order to commit them.

Using git worktree

Let's note again here that your work-tree is yours, with the exception that some Git commands, like git checkout, git restore, and git reset --hard for instance, tell Git overwrite the files in my work-tree with files from somewhere else. One of these is a huge exception, of course: git checkout or git switch winds up overwriting files.

Git's index, of course, is Git's. You get some control over it though: git checkout means fill it in from some commit and git reset means erase part of it and put things back into it from some commit, for instance. A git rm means remove a file from it, and also from your own work-tree unless you add --cached; and git add means make the index copy of some file match the work-tree copy.2

Git puts new commits, made from Git's index, on the current branch. If you want to switch to some other current branch, of course, you have to use git checkout or (in Git 2.21 or later) git switch, and that overwrites both Git's index and your work-tree. But what if you could have another work-tree without disturbing your main one? This is where git worktree comes in.

Using git worktree add, you can tell Git to make a new, separate, extra work-tree that goes with the current repository. There are a bunch of constraints on this command: the new work-tree has to be "somewhere else" in your file system, for instance, and each added work-tree has to be on a different branch name, or use Git's detached HEAD mode. I won't go into all the details here, but these constraints come right out of the index-and-work-tree model that Git uses:

  • Each added work-tree has to be somewhere so that it doesn't touch this work-tree or any other work-tree.
  • Each added work-tree comes with an added index, private to that particular work-tree.
  • Each added work-tree gets its own special name HEAD.

What this all means is that if you have some branch X and some other branch Y, and want to work on "both branches" at the same time, you can pick one to go in the main repository—let's say, X—and use git worktree add to get the other going:

git checkout X
git worktree add ../branch-Y Y

Now your main worktree (in ./) is on branch X, and the added work-tree, which is available in ../branch-Y, is on branch Y. You can do independent work in either branch. Since each has its own work-tree, you can test things in either branch without messing with your ongoing work in the other branch. But since both work-trees use the same underlying repository, any time you make a new commit in either work-tree, that new commit becomes available for viewing, or other options like rebase, in all the other work-trees too.

This is not nearly as clever as the git push . HEAD:otherbranch trick. But it's also much easier to keep your sanity as you do this kind of work.


2You could say that git add means "copy a file into the work-tree", but if you first remove a file from your work-tree, then git add that file, Git will remove its index copy. So that's why I say it means make it match: it will remove a file if needed, if that's how to make it match.

Related