Search code examples
gitbranchrebasefeature-branch

How do you move committed changes from a branch to master as pending changes?


I was implementing a new feature, for which I created a new branch, and made a few commits onto it, and pushed. I now wish to continue working on this feature in master, in the form of pending / uncommitted changes. Pending because it needs more work before I commit. How do I do this?

When I finally commit, the revision history of master should just indicate one commit, as if I never created another branch or made intermediate commits. How do I do this?

The manual way is to create a second git workspace, open master in one and the branch in the other, and copy-paste each modified file. Is there an automated way of doing this?


Solution

  • First, I'll give the commands, then what they are really doing and why this achieves what you want. I assume below that the feature branch is just called branch—change the name given to git merge if it has some other name.

    git status               # and make sure everything is committed on your branch
                             # do any extra commits here if required
    
    git checkout master      # change the current branch/commit, index, and work-tree
    git merge --squash --no-commit branch
    

    (In git merge, --squash implies --no-commit anyway, so you can omit the second flag argument here. I included it more for illustration. Moreover, --squash means do the merging action in the index and work-tree, but don't make a merge commit.)

    You may, depending on your tools, want to do git reset (which defaults to a --mixed reset with HEAD as the commit) to copy the HEAD commit back into the index, so that plain git diff shows you everything, and git status shows everything as not staged for commit.

    Long

    It's important to note, here, that there isn't really such a thing as pending changes. Git doesn't store changes at all. What Git stores are commits, and each commit is a complete snapshot of all files. When you ask Git what changed, you must tell Git about two commits, or at the least, two trees-full-of-files. Git then figures out, from those two inputs, what's different in snapshots A and B. Then Git tells you those differences—but A and B remain full snapshots. In some ways, none of this matters—you see changes when you want, and snapshots when you want. But if your mental model matches what Git really does, you'll have fewer mysteries to solve: your own job will be easier. The most important way this matters is to remember: In order to see changes or differences, you must provide two inputs. Sometimes, one of those inputs is implied. Sometimes, both of them are implied!

    This last is the case with a simple git diff or git diff --cached / git diff --staged: Git compares the index to the work-tree (plain git diff), or the HEAD commit to the index (--staged or --cached). The option, if any, determines the two inputs. That is, Git takes two of the three things from this list:

    • the HEAD commit, which is a real commit, containing all files in the special Git-only, frozen/read-only form;
    • the index, which is essentially the proposed next commit, and contains a copy of all the files, also in the Git-only form but not quite frozen;
    • the work-tree, which has your files in their ordinary useful form.

    With two of these things in hand, Git compares them and tells you what's different. The git status command runs, as part of its job, both of these two git diffs (with certain options always set, such as --name-status and --find-renames=50%) and summarizes the results: the output from git diff --staged are changes staged for commit and the output from git diff are changes not staged for commit.

    You can also manually run git diff HEAD. This chooses HEAD and your work-tree as the two inputs: you explicitly named one of them, HEAD, in the arguments, and the other is implied. That compares the frozen HEAD contents to the unfrozen, normal-format, work-tree contents, and shows you the difference. Again, Git is comparing full snapshots: the second one is the live work-tree, snapshotted by the process of diffing.

    (Aside: what you want to do, in my opinion, is probably the wrong way to go about it. You can often make your own job easier by committing early and often. Doing diffs is a good idea, but you can do them from base-commit to tip-commit, i.e., across a span of commits, to assess the overall result. You can then use a interactive rebase, or—my preferred method—additional branches, to reconstruct the smaller commits into a more sensible series of commits. But much of this is personal preference. It sounds like your Xcode tool could work a lot better than it is doing; the way you're using it is not wrong, it's just terribly limiting.)

    How and why this works

    I started writing a longer post but it got out of hand, so I'll just say: see some of my other StackOverflow answers for how Git makes commits out of the index, not the work-tree, and how these commits update the branch name to which HEAD is attached.

    Git's merge is itself also a pretty big topic. However, we can say that merging uses the index to achieve its merge result, with the work-tree playing a secondary role, mainly for the case where there are merge conflicts.

    A real merge—that is, a git merge that does a full three-way merge and will, in the end, make a new commit of type merge commit—makes its new commit with two parents. These two parents are two of the three inputs that went into computing the merge result. The third input is implied by the other two; it's the merge base. Git finds the merge base on its own, using the commit graph. Git then compares the merge base to the --ours and --theirs branch tips, as if using two git diff --find-renames commands: one from base to ours, one from base to theirs.

    Ignoring issues like added, deleted, or renamed files, the comparison of these three commits produces a list of who changed which files. If we changed some file that they didn't change, or they changed some file that we didn't, merging these is easy: Git can just take our file, or their file. If neither of us changed the file at all, Git can take any of the three versions of the file (merge base, ours, or theirs).

    For the difficult case—we both changed some file—Git takes the merge base to start with, and combines our changes and their changes and applies the combined changes to the merge-base copy. If our changes and their changes touch different lines, the combining is straightforward and Git declares the result a success (even if it makes no sense in reality). If we and they both changed the same lines, Git declares a merge conflict.

    The merge conflict case is the messiest. Here, Git leaves all three input files in the index, and writes the conflicted merge to the work-tree. Your job, as the human running git merge, is to come up with the correct merge, however you like. You can use the three index copies of the file, or the work-tree copy, or all of these. You resolve the merge yourself and then use git add to write the correct result into the index, replacing the three unmerged copies with a single merged copy. That resolves this conflict; once you have resolved all conflicts, you can finish the merge.

    If you run git merge with --no-commit, Git will do as much merging as it can on its own—which might be all of it—and then stop, the same way it would for a merge conflict. If there were no conflicts, this leaves both the index and work-tree in a ready-to-commit state. (If there were conflicts, the --no-commit option has no effect: Git was already going to stop, with the mess left behind, and still stops, with the mess left behind for you to fix.)

    In all of these cases, though, Git leaves behind a file in the .git directory, telling Git that the next commit—whether from git merge --continue or git commit—should have two parents. That makes the new commit a merge commit, which will tie together the two branches:

                 I--J   [master pointed to J when we started]
                /    \
    ...--F--G--H      M   <-- master (HEAD)
                \    /
                 K--L   <-- branch
    

    That's not what you want. So, we use git merge --squash, which tells Git: Do the merge work as usual, but instead of making a merge commit, arrange for the next commit to be an ordinary commit. That is, if Git were to make a new commit M, it would look like this:

                 I--J--M   <-- master (HEAD)
                /
    ...--F--G--H
                \
                 K--L   <-- branch
    

    For no particularly good reason, --squash inhibits the commit even if the merge goes well. So after git merge --squash, the index and work-tree are updated, but you can make more changes to the work-tree, use git add to copy the updated files into the index, and only when you are done, use git commit to make the new commit.

    (For a more trivial merge, where Git would default to doing a fast-forward, --squash inhibits the fast-forward mode as well, as if by git merge --no-ff. So --squash, without --no-ff and without --no-commit, suffices.)

    The final git reset, if used and needed, just copies the HEAD commit back into the index. That is, you probably want your tool to compare the HEAD commit to the work-tree, as if you were running git diff HEAD. If the tool compares the index to the work-tree instead, you can get the same effect by de-updating the index to match HEAD, after the --squash operation updated it.