I have the following concern. I was working on a branch (let's call it A) where I implemented a new function. I have only committed the changes, but I did not push them. Now I realized later that I am on the wrong branch. So I changed to the right branch (B). How can I transfer the changes from branch A to branch B?
So that in B everything so far remains and everything new from A in B is deposited.
CodePudding user response:
If:
- there is something that you do like about some commit(s), but
- there is something else that you don't like about those same commits
then usually the correct approach to fixing this is to use git rebase
. There is always a caveat about git rebase
, which I'll describe in a moment, but since you haven't sent these commits to some other Git repository yet—the commits that you want to change in some way are entirely yours, existing in your own Git repository only—this caveat will not apply in your case.
In your particular case you won't need to use rebase at all, though. You will instead want to use git cherry-pick
and then git reset
or git branch -f
. Or, you may not even need to do the cherry-pick.
What to know about commits (and Git in general)
Git is really all about commits. It is not about files, although commits do hold files. It is not about branches either, although branch names help us (and Git) find the commits. In the end, though, it's just the commits that matter. This means you need to know all about commits.
In Git:
Each commit is numbered, with a unique, but big and ugly and random-looking, hash ID or object ID. These are not actually random at all: the numbers are the outputs of a cryptographic hash function. Every Git uses the same computations, so that every Git everywhere in the universe will agree that some particular commit gets that number. No other commit can have that number, whatever it is: that number is now used up by that particular commit. Since the numbers have to be universally unique, they have to be huge (and hence ugly and impossible for humans to use).
Git stores these commits, and other internal objects that support the commits, in a big database—a key-value store—where a hash ID is the key and the commit (or other object) is the value. You give Git the key, e.g., by cutting and pasting from
git log
output, and Git can find the commit and hence use it. That's not normally how we actually use Git, but it's important to know: Git needs the key, i.e., the hash ID.Each commit stores two things:
Every commit stores a full snapshot of every file, as of the time you made it. These are stored in a special, read-only, Git-only, compressed and de-duplicated format, not as ordinary files on your computer. Depending on your OS, Git may be able to store files that your computer literally can't use or extract (e.g., a file named
aux.h
on Windows), which is sometimes a problem. (You have to make these files on an OS that can name them, of course, such as Linux. The point of all this, though, is just to show that these files aren't regular files.)Every commit also stores some metadata, or information about the commit itself: who made it, for instance, and when. The metadata include the log message that
git log
shows. Crucially for Git, the metadata for each commit includes a list—usually just one entry long—of previous commit hash IDs.
Because of the hashing tricks that Git uses, no commit—no internal object of any kind—can ever be changed once it's stored. (This is how the file storage works too, and is how Git de-duplicates files and can store files that your computer can't. They're all just data in that big database.)
Again, the metadata for a commit stores the hash ID(s) of some previous commit(s). Most commits have just one entry in this list and that one entry is the parent of this commit. This means that child commits remember their parents' names, but parents don't remember their children: the parents are frozen in time the moment they're made, and the eventual existence of their children can't be added to their records. But when the children are born, the parents exist, so a child can save its parent commit's number.
What this all means is that commits form backwards looking chains, where the latest commit points back one hop to the next-to-latest, and that commit points back another hop, and so on. That is, if we draw a small chain of commits whose last commit has hash H
, we get:
... <-F <-G <-H
The commit whose hash is H
saves a snapshot of all files, plus metadata; the metadata for H
lets Git find commit G
, because H
points to its parent G
. Commit G
in turn saves a snapshot of all files plus metadata, and G
's metadata points back to F
. This repeats all the way back to the very first commit, which—being the first commit—can't point backwards. It has an empty parent list.
The git log
program therefore just needs to know one commit hash ID, namely H
's. From there, git log
can show H
, then move back one hop to G
and show G
. From there, it can move back another hop to F
, and so on. The action stops when you get tired of reading git log
output and quit the program, or when it gets all the way back to the very first commit.
Branch names help us find the commits
The problem here is that we still need to memorize, somehow, the hash ID of commit H
, the last one in the chain. We could jot it down on a whiteboard, or on paper, or something—but we have a computer. Why not have the computer save the hash ID for us? And that's just what a branch name is all about.
Each branch name, in Git, saves just one hash ID. Whatever hash ID is in the branch name, we say that that name points to that commit, and that that commit is the tip commit of that branch. So:
...--F--G--H <-- main
here we have the branch name main
pointing to commit H
. We no longer need to memorize the hash ID H
: we can just type in main
instead. Git will use the name main
to find H
, and then use H
to find G
, and G
to find F
, and so on.
Once we do this, we have an easy way to add new commits: we simply make a new commit, such as I
, so that it points back to H
, and then write I
's hash ID into the name main
like this:
...--F--G--H--I <-- main
Or, if we don't want to change our name main
, we make a new name, such as develop
or br1
:
...--F--G--H <-- br1, main
Now that we have more than one name, we need to know which one we're using to find commit H
, so we'll draw in the special name HEAD
, attached to one of the branch names, to show that:
...--F--G--H <-- br1, main (HEAD)
Here we're using commit H
through the name main
. If we run:
git switch br1
we get:
...--F--G--H <-- br1 (HEAD), main
Nothing else changes—Git notices that we're moving "from H
to H
", as it were—and so Git takes some short-cuts and doesn't bother doing any other work for this case. But now we're on branch br1
, as git status
will say. Now when we make a new commit I
, we'll get this:
I <-- br1 (HEAD)
/
...--F--G--H <-- main
The name main
stayed in place, while the name br1
moved to point to new commit I
.
Your situation as you've described it
I was working on a branch (let's call it A) where I implemented a new function. I have only committed the changes, but I did not push them. Now I realized later that I am on the wrong branch. So I changed to the right branch (B). How can I transfer the changes from branch A to branch B?
Let's draw this:
...--G--H <-- br-A (HEAD), main
\
I--J <-- br-B
You were on branch br-A
and made a new commit, which we'll call K
:
K <-- br-A (HEAD)
/
...--G--H <-- main
\
I--J <-- br-B
There are some things that you do like about commit K
: for instance, its snapshot differs from that in commit H
by whatever change you made. Its log message says what you want the log message to say, too.
But there's one thing that you don't like about commit K
: it comes after commit H
, when you'd like to have it come after commit J
.
You can't change a commit
We noted near the top that no commit, once made, can ever change. Your existing commit K
is set in stone: nobody, nothing, not even Git itself, can change anything about commit K
. It comes after H
and it has the snapshot and log message that it has, and that will be true forever.
But ... what if we could copy K
to a new and improved commit? Let's call this new-and-improved commit K'
, to indicate that it's a copy of K
, but with some things different.
What should be different? Well, we'd like it to come after J
, for one thing. And then we'd like it to make the same change to J
that K
made to H
. That is, if we ask what's different in the H
-vs-K
snapshots, and then ask what's different in the J
-vs-K'
snapshot we're about to make, we'd like to get the same changes.
There is a fairly low level Git command that copies exactly one commit like this, called git cherry-pick
. This is in fact what we're going to end up using.
Still, we should talk here about git rebase
. If we had a dozen, or a hundred, commits to copy, cherry-picking each one might be tedious; git rebase
will automate the repeated cherry-picking, too. So rebase is the usual command to use.
Here's how rebase works:
- First, we have Git list out all the commits that it needs to copy. In this case that's just commit
K
. - Then, we have Git check out (switch to) the commit where we want the copies to go. In this case that's commit
J
. - Next, we have Git copy each commit, one at a time, from the list it made.
- Then we have Git take the branch name that found the last of the commits to copy, and move that name to point to the last-copied commit.
The end result of all of this, in this case, is:
K ???
/
...--G--H <-- main
\
I--J <-- br-B
\
K' <-- br-A (HEAD)
Note how commit K
still exists. It's just that nobody can find it any more. The name br-A
now finds the copy, commit K'
.
Cherry-picking
This isn't what we want, so instead of using git rebase
, let's use git cherry-pick
. We will first run:
git switch br-B
to get:
K <-- br-A
/
...--G--H <-- main
\
I--J <-- br-B (HEAD)
Now we'll run:
git cherry-pick br-A
This uses the name br-A
to find commit K
, and then copies it to where we are now. That is, we get a new commit that makes the same changes that commit K
makes, and has the same log message. This commit goes on the branch we're on now, so br-B
gets updated to point to the copy:
K <-- br-A
/
...--G--H <-- main
\
I--J--K' <-- br-B (HEAD)
We should now inspect and test the new commit to make sure we really do like the result (because if we don't, there are a bunch more things you can do here). But assuming all goes well, now we'd like to discard commit K
off the end of br-A
.
We can't actually delete commit K
. But a branch name simply holds the hash ID of the last commit that we want to say is "on the branch", and we can change the hash ID stored in a branch name.
Here things get slightly complicated, because Git has two different ways to do that. Which one to use depends on whether we've checked out that particular branch.
git reset
If we now run:
git switch br-A
to get:
K <-- br-A (HEAD)
/
...--G--H <-- main
\
I--J--K' <-- br-B
we can use git reset --hard
to drop commit K
off the end of the current branch. We simply find the hash ID of the previous commit, i.e., hash ID H
. We can do this with git log
, and then cut-and-paste the hash ID, or we can use some special syntax that Git has built in:
git reset --hard HEAD~
The syntax HEAD~
means: find the commit named by HEAD
, then step back to its (first and only in this case) parent. That locates commit H
, in this particular drawing.
The reset command then moves the branch name to point to this commit, and—because of the --hard
—updates both our working tree and Git's index aka staging area to match:
K ???
/
...--G--H <-- br-A (HEAD), main
\
I--J--K' <-- br-B
Commit K
no longer has a way to find it, so unless you tell them, nobody will ever know it was there.
Note that given this particular drawing, we could also have done git reset --hard main
. The HEAD~1
style syntax works even in other cases, though.
git branch -f
If we don't first check out br-A
, we can use git branch -f
to force it back one step. This has the same kind of effect as git reset
, but because we didn't check out the branch by name, we don't have to worry about our working tree and Git's index/staging-area:
git branch -f br-A br-A~
Here, we use the tilde suffix to the name br-A
to have Git step back one first-parent hop. The effect is exactly the same, but we can only do this if we haven't checked out branch br-A
.
A special case
Suppose that our drawings above aren't quite right. That is, suppose that instead of branches br-A
and br-B
pointing to different commits before we made commit K
, they both pointed to the same commit. For instance, we might have had:
...--G--H <-- main
\
I--J <-- br-A (HEAD), br-B
If we were in this situation and then made commit K
, we would get this:
...--G--H <-- main
\
I--J <-- br-B
\
K <-- br-A (HEAD)
Note that in this case, there is nothing we don't like about commit K
: it has the right snapshot and it has the right metadata. The only problem is that the name br-A
points to K
, with br-B
pointing to J
. We'd like instead to have br-B
pointing to K
and br-A
pointing to J
.
We can get what we want by:
- moving the two branch names, or
- swapping the branch names
We can do the first one with a combination of git reset
and git branch -f
. We just have to be careful not to lose commit K
's hash ID.
We can run git log
and cut and paste K
's hash ID, so that we don't lose it, and then run:
git reset --hard HEAD~
to get:
...--G--H <-- main
\
I--J <-- br-A (HEAD), br-B
\
K ???
Then we can run:
git branch -f br-B <hash-of-K>
pasting in the correct hash, to get:
...--G--H <-- main
\
I--J <-- br-A (HEAD)
\
K <-- br-B
for instance. Or, rather than taking that slightly risky method (what happens if we accidentally cut some other text and lose the hash ID?), we can update br-B
first, with:
git branch -f br-B br-A
or:
git checkout br-B; git merge --ff-only br-A
(which introduces the --ff-only
merge concept, which I'm not going to explain here) to get:
...--G--H <-- main
\
I--J
\
K <-- br-A, br-B
with one of those being the current branch. Then we can fix up br-A
to move it back one hop.
Last, we can use the "rename both branches" trick. This requires picking a third name to use temporarily:
git branch -m temp # rename br-A to temp
git branch -m br-B br-A # rename br-B to br-A
git branch -m br-B # rename temp to br-B
In all of these cases, no commits had to be copied because K
already was in the right form. We just needed to shuffle the names around a bit.
The key is usually to draw the graph
If you aren't sure about these kinds of things, draw the graph.
You can have Git or some other program draw the graph for you: see Pretty Git branch graphs. Note that it takes some practice to be able to draw and read graphs, but this is an important skill, in Git.
Once you've drawn the graph, you can tell whether you need new and improved commits—which you can get with git cherry-pick
and maybe git rebase
—and/or which branch names you need to re-point.
This also gives you insight into that caveat I mentioned. When you copy commits to new-and-improved ones, any Git repository that already has the old-and-lousy ones1 also needs updating. So if you've used git push
to send the old-and-lousy commits to some other Git repository, be sure they—whoever "they" are—are willing to update too. If you can't get them to switch, making new-and-improved commits is just going to make a big mess of duplicate commits, because they'll keep putting the old and lousy ones back in even if you keep taking them out. So if you have published some commits, make sure they—whoever "they are, again—agree to switch to the improved ones, before you go rebasing or whatever.
1If something is new-and-improved, what does that tell you about the old version? Maybe "lousy" is too strong here, but it is at least memorable.