duncan
duncan

Reputation: 31912

Retrospectively rename branch and reword commits

I have this repo, origin/master

I had a JIRA ticket, X-123. I created a branch named X-123, did some commits against it, which have been pushed up to origin/X-123. And then a pull request made from origin/X-123 to the master branch, that got merged in.

The work's all complete except now in hindsight, it turns out the work I did should really have been against ticket Y-789. Ideally I want to:

Right now I'd settle for just the last of those; any ideas? Since all the work's already been merged into master, it's maybe shutting the door after the horse has bolted. I'm tempted to revert all the work I did against X-123, then repeat it against Y-789, but it seems as if there should be some easy step here (this can't be that unusual surely?).

Upvotes: 1

Views: 1166

Answers (1)

torek
torek

Reputation: 487735

There's probably a better, Jira-specific, way to deal with this, but here's how you could do it all in Git.

Renaming a branch—a local name—is trivial:

git branch -m <newname>

while you're on it, or:

git branch -m <oldname> <newname>

Making changes to existing commits, however, is impossible; and renaming the branch does no good. But despair not! :-)

Some important background

First, a small aside:

I have this repo, origin/master

origin/master is not a repository. It's a typical name for your Git's memory: "This is what I saw in another Git, which I'm calling origin, the last time I called up that other Git on the Internet-phone and asked it about its branches. It said it had a master that was commit a234b67... or whatever, so I wrote down origin/master in my repository to remember that their master = a234b67...."

Your Git does this with every branch it sees in that other Git: if they have a branch named X-123, you get a copy named origin/X-123. That includes when your Git asks their Git to create an X-123. As soon as they say "OK", your Git knows that their Git now has this X-123 (and that its hash ID is whatever your Git told them to use to create it!).

Second, let's just note here that a branch name, in your repository, like master or X-123 is a shortened form of a full name that Git calls a reference. Any full name that starts with refs/heads/ is a branch, so your master is really refs/heads/master and your X-123 is really refs/heads/X-123.

These so-called remote-tracking branches like origin/master and origin/X-123 are references whose full name starts with refs/remotes/ and goes on to include the origin/ part. Your Git just renames a foreign Git's branch names (refs/heads/master) to make your remote-tracking names (refs/remotes/origin/master).

(For that matter, tag names are just references in refs/tags/.)

Later, your Git strips away the refs/heads/ or refs/remotes/ part for display purposes. That's why you don't normally see these prefixes. Sometimes, for whatever reason, Git only strips away refs/ and you see remotes/origin/master. In a few cases, knowing the full name is important. They probably won't come up here, but it's worth knowing.

You can't modify commits, but you can copy them

Whether or not you rename your local branch from X-123 (refs/heads/X-123) to Y-789 (refs/heads/Y-789), the name itself simply maps to some commit hash: ac0ffee4deadcafe... or whatever. Changing the name leaves the same commit hash in the new name. You must now make copies of all the commits except the merge.

The main tool you have for copying commits one at a time is git cherry-pick. Let's say you make a new branch Y-789 that has no copies of the old commits yet, nor the merge. (I'm assuming your development branched off from master based on your description, but if not, just use the other name here.) Let's draw this as a commit graph fragment:

...--o--*-----m------M   <-- master, origin/master
         \          /
          A--B--C--D   <-- X-123, origin/X-123

A round o represents an ordinary commit. One of them, *, is filled in to mark it: that's the merge base between what master was earlier, back when it pointed to commit m, and what is still the tip of X-123 and origin/X-123. Another one, m, has a letter to indicate that it is where master was before X-123 was merged in, and the last one, M, is marked to show that it's the merge commit, to which master and origin/master point. Finally, four of them have labels A-B-C-D so that we can refer to them as the originals that we plan to copy.

What we want now is to make the Y-789 series of commits, which will be copies of the A-B-C-D series. So we make a new label, Y-789, pointing to the merge base:

...--o--*   <-- Y-789 (HEAD)
        |\
        | ----m------M   <-- master, origin/master
         \          /
          A--B--C--D   <-- X-123, origin/X-123

As indicated by the (HEAD), we want to be on this new branch now.

Next, we need to copy A but, in the copy, change its commit message slightly, so that instead of X-123 blah it says Y-789 blah. To do that:

git cherry-pick --edit <hash ID of A>

This updates our index and work-tree to copy whatever got changed in A and set up a commit message template, without actually making the commit yet, then invoke git commit in a way that lets us edit the message. (Without --edit, this makes the commit. We could then git commit --amend it to fix its message, but that makes one "throwaway" commit so it's not quite as efficient—though we may not really care; "junk" commits are cheap in Git; read on for ways to automate this).

Then we just repeat for the remaining commits. This builds up a new chain:

...--o--*--A'-B'-C'-D'  <-- Y-789 (HEAD)
        |\
        | ----m------M   <-- master, origin/master
         \          /
          A--B--C--D   <-- X-123, origin/X-123

We can now make a new pull request asking to merge D' (Y-789, which we push so that there is now an origin/Y-789) into master. For someone who knows how (see below for details), the merge will be trivial to do, as master already has the same changes in it, courtesy of merging D; but if this merge gets done as a strict "add merge commit", the result will look like this:

...--o--*--A'-B'-C'---D'  <-- Y-789, origin/Y-789
        |\             \
        | ----m------M--N   <-- master, origin/master
         \          /
          A--B--C--D   <-- X-123, origin/X-123

where N is the new merge commit.

You can now safely delete the X-123 label (and do it in the other repository as well so that git fetch --prune strips out your refs/remotes/origin/X-123), but all those original commits will remain in the graph, and you will have the A-B-C-D commits forever.

To get rid of the originals is harder, because merge commit M exists. It can be done, but everyone who has this merge M—that's you, and the other Git at your origin, and any other Git user who has picked up merge commit M—has to strip this commit out of each of their repositories that has it.

If you do this, though, and then merge D' into master, you get this graph:

...--o--*--A'-B'-C'-D'  <-- Y-789, origin/Y-789
        |\           \
        | ----m-------N   <-- master, origin/master
         \
          A--B--C--D   <-- X-123, origin/X-123

This graph can be re-drawn to be much less messy, and in fact, it will look like the original graph except for using Y-789.

If everyone now deletes their names X-123 and origin/X-123, Git will eventually garbage collect commits A-B-C-D. Until then, no one will see them normally either: they will just be zombie commits, lurking in each repository copy in case someone wants to resurrect them.

Automating the git cherry-pick copying

To automate the fancy copying, you can simply use git rebase. What git rebase does is identify some set of commits—in our case, A-B-C-D—and copy them. When you run it as git rebase -i, it first brings up an editor session on a series of pick commands, each representing a single git cherry-pick. You can change each pick to edit—this is the Big Hammer that lets you run git commit --amend—or reword, which just lets you edit the commit message, just as we did above.

After doing all the cherry picking, git rebase moves the branch label. That is, instead of making a new branch Y-789 to start, Git makes all the new commits on a temporary (unnamed) branch, then moves the existing current branch name to point to the copies.

In other words, we would just run:

git checkout X-123
git rebase -i --onto $(git merge-base X-123 master) master

to tell Git to start in X-123, find (for copying purposes) commits on X-123 that are not on master, and then copy those commits to come after merge base *, while also letting us change pick to reword. Once it's all done, Git will move the name X-123 to point to the final copy:

...--o--*--A'-B'-C'-D'  <-- X-123 (HEAD)
        |\
        | ----m------M   <-- master, origin/master
         \          /
          A--B--C--D   <-- origin/X-123

Note that X-123 has moved. We now want to rename it Y-789 and then git push -u origin Y-789 to create Y-789 on the other Git (and hence origin/Y-789 in ours, and set that as our new upstream). We won't have to delete our existing X-123, nor our origin/X-123, we'll just need to convince the upstream repository to delete his X-123 and then use git fetch --prune as usual.

Then we'll make the pull request and let whoever controls merging deal with it all.

Merging D'

Whether merging D' is easy or hard depends on these things:

  • Were there any merge conflicts when merging D into master?
  • Would whoever does this merge like to strip out commit M? (Note that this has consequences for everyone "downstream", including you: you must all repeat this stripping-out. That could be very easy, or it could be hard.)
  • Does whoever is doing this know about git merge -s ours?
  • If stripping out M and there were conflicts, does whoever is doing this know how to simulate git merge -s ours?

If there were no merge conflicts and no hand-tweaking required, everything is really easy. Just keep or strip M as desired (to strip it, we need git reset --hard and we need to be sure there are no commits after M as well, and then there are all those downstream problems, so be very sure this is what you want). Then merge, and you're done.

If there were merge conflicts, but you can git merge -s ours without stripping M, it's easy: just git merge -s ours to say "we already did the merge correctly, so completely ignore all the commits on the A'-B'-C'-D' chain when making the merge result.

If there were merge conflicts and the person doing the merge is stripping away M, it's a bit harder. The general idea is to strip out M from master but keep the commit and its tree (e.g., make a name pointing to it, or rely on reflogs and just copy-paste the hash, or whatever). Then run git merge, which will see the same merge conflicts; but then remove the merge result and replace it with the tree from the saved commit. Or, equivalently, one can use the git commit-tree plumbing command to do this with less muss-and-fuss, but that's getting pretty deep into Git arcana.

Note that all this assumes you make no changes to the tree, i.e., that git diff of the original D commit and the new D' commit would be empty: only the messages (and hash IDs) have changed, not the source trees associated with the commits.

Upvotes: 7

Related Questions