Reputation: 11
I want to edit an old commit in git. How do I undo just a part of an old commit? (I am a bit of a git rookie, so sorry if I am confused about terms etc).
Example: in a commit, say three commits back, I deleted some code that should not have been deleted (yet). Now I want to go back and undo that particular change of that commit. It's a local repo.
What I have tried is what google gives me: stash + rebase -i + amend, but these invariably end up in a mess that takes (me) hours to resolve. None of them lets me actually edit the commit, only to do new changes - which in case of deleted code means me to locate and re-enter the deleted lines again, by hand (cut and paste). Then, even if the rebase is successful, the stash pop always fails... with a merge mess on my hands..). There just must be an easier way!
When I do a git add -p
, it allows me edit the commit, line-by-line, and confirm what goes in. Now I want to redo that process, but on a commit. A git unadd -p
of sorts, allowing me to delete the lines in the .patch with '-' in front.
Sorry, if my frustration with this comes through.
Upvotes: 1
Views: 2464
Reputation: 535140
I'll model it for you. Here's the starting situation:
% git log --oneline --graph
* b7ba319 (HEAD -> main) four
* 503d08a three
* 4e98a2f two
* c7f6c15 one
Now, it happens that back in two
I made a mistake and changed "good" to "bad" in the file a.txt. Let's examine a.txt in every commit to see the story:
% git show @~3:a.txt
this is a good line
this is okay
so is this
this is fine
whatever
this is all right
========
% git show @~2:a.txt
this is a bad line
this is okay
so is this
this is fine
whatever
this is all right
this is okay too
========
% git show @~1:a.txt
this is a bad line
this is okay
so is this
this is fine
whatever
this is all right
this is okay too
I like this
========
% git show @:a.txt
this is a bad line
this is okay
so is this
this is fine
whatever
this is all right
this is okay too
I like this
this is great
Okay, now I propose to go back and fix up two
. I'll use interactive rebase:
% git rebase -i HEAD~3
Inside the editor, here's what I say:
edit 4e98a2f two
pick 503d08a three
pick b7ba319 four
I close the editor, and Git says:
Stopped at 4e98a2f... two
You can amend the commit now, with
git commit --amend
Once you are satisfied with your changes, run
git rebase --continue
Okay, I'll do just what Git told me to do. I edit a.txt and fix it up, changing "bad" to "good" in the first line:
% pico a.txt
Now I'm done editing, so I let Git know about the change:
% git add .
% git commit --amend
% git rebase --continue
Git finishes the rebase. Let's review what the history of a.txt is now:
% git show @~3:a.txt
this is a good line
this is okay
so is this
this is fine
whatever
this is all right
========
% git show @~2:a.txt
this is a good line
this is okay
so is this
this is fine
whatever
this is all right
this is okay too
========
% git show @~1:a.txt
this is a good line
this is okay
so is this
this is fine
whatever
this is all right
this is okay too
I like this
========
% git show @:a.txt
this is a good line
this is okay
so is this
this is fine
whatever
this is all right
this is okay too
I like this
this is great
As you can see, I successfully changed the history from two
all the way forward. I believe that's what you said you wanted.
Note that, as you've been told, this involves replacing commits. The SHA numbers have all changed:
% git log --oneline --graph
* 6c10f14 (HEAD -> main) four
* 1058899 three
* 0e05cba two
* c7f6c15 one
Upvotes: 1
Reputation: 97718
There are a few concepts to straighten out here, but I'll try to keep each one brief.
As you already know, before you can commit any changes, you have to run some version of git add. What this does is apply those changes to a kind of "work-in-progress commit" called the "staging area" (and occasionally also the "cache" or the "index"; git is not very consistent with its terminology).
"Unstaging" changes has traditionally been one of many jobs of the rather confusing "git reset" command, but in recent versions we have the more straight-forward git restore. Specifically, the opposite of "git add" is "git restore --staged" or "git restore -S" for short. Like "git add", you can either list whole files and directories, or use "--patch" ("-p" for short) to interactively choose "hunks" to restore.
The above commands determine what goes into the next commit, but what if you want to change the last commit instead? For that, you can use the git commit --amend command.
Although intuitive, the name "amend" is slightly misleading here: a commit in git can never be edited, because its identity (the commit hash) is based on its entire contents. It might be better to have called it "git commit --replace".
The way it works is that you "stage" some changes with "git add" as normal, which creates a new snapshot in the "staging area"; but instead of being a child of the currently checked out commit, the new commit is put into history instead of that commit. In effect, it creates a parallel universe, where that is what you committed in the first place.
In order to make the new version of the commit, we need to get the old code from somewhere. Again, the "git restore" command is our friend here, because we can specify the source we want to restore changes from. In this case, we want to restore from "the parent of the current commit", which can be written as "HEAD^" ("HEAD" being the currently checked out commit, and "^" translating roughly to "parent of").
So we can interactively put code from before the current commit into the staging area, and then replace the current commit with a new version, like so:
git restore --source=HEAD^ --staged --patch
git commit --amend
As already mentioned, git's commits are immutable, but just as we can replace one commit with "git commit --amend", we can replace a whole sequence of commits with the powerful but complex git rebase.
Essentially, a rebase looks at a series of commits and tries to "replay" them as though you were making the same changes again. It has various uses, but the one we're interested in here is "interactive mode", which lets you go back in time and do things slightly differently this time.
If you run "git rebase -i" and mark a commit as "edit", git replays the commit, and then pauses. This puts you into the same situation as if you'd changed your mind immediately after you committed - you can fix the parts you committed by mistake, and replace the commit with "git commit --amend".
Once you've finished with that commit, you run "git rebase --continue", and the rebase continues forwards through history, creating an alternative reality where that was the version you committed.
Whenever you "rewrite history", you are creating a series of new commits, with new commit hashes. If there are any other branches which were based on the original commits, they will become stranded in an alternative history, and need to be rebased in turn.
It's therefore generally considered a bad idea to rewrite the history of any branch that has been "shared" - that is, which anyone has based other work on.
Upvotes: 0
Reputation: 488183
You literally can't edit any existing commit. This is most of why what you're doing is leading to blind alleys. You can't think of it as changing a commit; you need to think of it differently.
What you can do—and are trying to do based on various instructions—is to use the existing commits, which give you the ability to "go back in time" as it were, to go back in time to a good starting point, and then build new commits that you use instead of the existing bad commits:
<-- older -------------------------------------- newer -->
... <-commit3 <-commit4 <-commit5 <-... <-last-commit
By "rewinding" in time to, say, commit4, then making a new and improved version of commit #5, followed by a new and improved version of commit #6, and so on, we end up with this:
E--F--G--H [abandoned]
/
A--B--C--D
\
E'-F'-G'-H' <-- main
Here, instead of numbering the commits, I've used letters: commit A
is the first one, B
is the second, and so on. That, and I got lazy about drawing the arrows: they come out of each commit and point backwards, but for easier drawing, I just drew connecting lines this time. I also added a branch name, in this case, main
.
There are numerous keys to making this work and making it easier:
Any uncommitted work cannot be handled this way. You must commit stuff. I don't like git stash
myself, but the point of using git stash
is that it commits your work. (The main thing that makes a stash commit—really, a pair of commits, that git stash
makes at the same time—special is that it's not on any branch at all. This makes it easy to "move" to another branch—because it's not on any branch—but really hard to work with.) I recommend just making an actual commit, if you have uncommitted stuff.
The interactive rebase is the way to tell Git to stop after it copies a commit, and let you make changes. There's a learning curve here though. It really is tricky to do, the first few times.
Remember that each commit is uniquely identified by its hash ID. It's the hash ID that is the "true name" of a commit. When you start copying commits to new-and-improved commits, the original commits are still there! Some operations may still be able to see them, and you'll see duplicates. If you have not yet finished the copying process, this is quite normal: do not be alarmed. If you have finished the copying process, things are trickier.
Remember that Git finds commits by working backwards. A branch name like main
or master
, or develop
, or feature/short
, or whatever you may be using, literally contains the raw hash ID of the last commit in some chain. That last commit points backwards to an earlier (second-to-last) commit; the earlier commit points backwards yet again; and so on.
This is why we "rewind and rebuild". If something was wrong in commit E
, we rewind to commit D
: the point before things went wrong.
Unfortunately, rewinding to point D
means we can't see commit E
any more—not with git log
anyway, which starts at the current commit (now D
) and works backwards. But git log branch-name
will still start at the end and work backwards. This is where you'll potentially see duplicates: once you've made your new-and-improved E'
, git log
will start at E'
and work backwards, while git log branch-name
will start at H
and work backwards. Note that H
goes back to G
, G
goes to F
, and F
goes to E
, so you see only E
this time. The hash ID of E
is different from that of your new and improved E'
, but the commit message is the same.
Let's get into some of the more detailed aspects of rebasing now.
HEAD
Let's start with the normal, attached-HEAD setup:
...--F--G--H <-- main (HEAD)
Here, we are on our main
branch. The latest commit is commit H
. The special name HEAD
is attached to the name main
, and git log
shows commit H
, then commit G
, then commit F
, and so on.
Let's look at a more complicated repository with several branch names:
I--J <-- develop (HEAD)
/
...--G--H <-- main
\
K--L <-- feature
Here, we did a git checkout develop
to get onto our develop
branch. Its latest commit is commit J
. Commit J
leads back to commit I
, which leads back to H
—which is the last commit of main
, but just a middle commit of develop
. A simple git log
will show J
, then I
, then H
, and so on.
If we run git checkout feature
now, git log
will show commit L
first, then K
, then H
, then G
, and so on. Again, Git just works backwards, one commit at a time. The commits up through and including H
are on all three branches.
If we want to check out some historic commit—one that does not have a branch name pointing to it—such as commit F
, one way we can do that, in Git, is to run git checkout
with a raw hash ID.
In this case, our drawing might look more like this:
...--E--F <-- HEAD
\
G--H <-- main
I've left out any other branches because there's not enough room to draw them in. Note how the special name HEAD
is no longer attached to any branch name, though: it just picks out the current commit directly. This is why git log
will start by showing you commit F
, not commit H
, and then work backwards from there.
The git rebase
command uses this detached-HEAD mode. Let's say that we have main
ending at commit H
, followed by dev
with four more commits, and we're on dev
:
...--G--H <-- main
\
I--J--K--L <-- dev (HEAD)
Running git rebase -i HEAD~3
will give us an instruction sheet that says:
pick <hash-of-J>
pick <hash-of-K>
pick <hash-of-L>
If we change pick
to edit
on all three of these, write out the instruction sheet, and exit the editor, Git now starts by copying J
, more or less like this:
...--G--H <-- main
\
I--J--K--L <-- dev
\
J' <-- HEAD
Because we said "edit", Git stops after copying J
to J'
.1 This gives us a chance to make file changes, run git add
, and then run git commit --amend
.
What --amend
does is tricky. Git literally can't change J'
. It can, however, make yet another new commit—let's call this one J"
to indicate that it's a modified copy of J'
, which itself was a mostly-unmodified copy of J
—but set things up so that J"
points back to I
, not to J'
:
...--G--H <-- main
\
I--J--K--L <-- dev
|\
| J' [abandoned]
\
J" <-- HEAD
We now instruct git rebase
to --continue
, and it goes on to copy K
to K'
.2 If we stop drawing in J'
—there's no way to find it so we don't need to draw it any more—this looks like this:
...--G--H <-- main
\
I--J--K--L <-- dev
\
J"-K' <-- HEAD
If we now make changes, use git add
, and use git commit --amend
, we make a new commit K"
and abandon K'
:
...--G--H <-- main
\
I--J--K--L <-- dev
\
J"-K' [abandoned]
\
K" <-- HEAD
Again, we just stop bothering drawing K'
after this, so that our next picture is:
...--G--H <-- main
\
I--J--K--L <-- dev
\
J"-K"-L' <-- HEAD
and this gives us the opportunity to modify and git commit --amend
yet again.
1In this particular case, Git will "cheat" and just re-use J
directly, unless we add more options to our git rebase
command. But I don't want to get into these details.
2This time Git really does have to copy K
: it really can't re-use the original. That's why I wanted to pretend that Git had to copy J
earlier, to simplify what's going on.
It kind of is crazy and hard, but once you've done a few of these, it isn't too hard after all. You just have to remember a few things:
Commits (and branch names) are cheap. Git is perfectly willing to make dozens of commits, just to throw them away in the future. What we really want, in the end, is a series of commits that we keep, by keeping a branch name pointing to the last one. If we abandon an old commits, so that there's no way to find it, Git will eventually get rid of it.3
The above sequence of git rebase
copying one commit at a time is just a fancy automated way to run git cherry-pick
over and over again. We have to do that because we can't actually change any existing commit, so we have to copy the old commits to new ones. The cherry-pick command is the internal Git command that copies one commit.
Because Git works backwards, and no commit can ever be changed, "replacing" even just one old commit somewhere in the middle of a chain, such as making a new-and-improved J'
to replace the original J
, requires copying all subsequent commits.
So that's why git rebase
does this crazy thing of copying every commit. Using pick
means do the cherry-pick, then move on to the next commit. Using edit
means do the cherry-pick, but then stop. Once Git stops, you get a chance to replace the commit it just made. You can replace it with two commits, or remove it entirely, or whatever you want to do ... then you resume the cherry-picking with git rebase --continue
.
3"Eventually" usually means that at least 30 days must pass from the time we made the commits, due to what Git calls reflog entries. The details are not really important here: just know that if you just now finished a rebase, but then decide you hate the result, it's easy to undo it. Later—as time goes by—it gets harder, and after the 30+ days have passed, it may become impossible.
There's a common pattern that recurs when making changes and then redoing work:
Rather than going back and fixing it right now, Git's rebase -i
now has a feature where you can make a fixing commit as soon as you're ready, then use git rebase -i
to rearrange and combine the fixing commit with the original commit.
Let's use your example from the top:
... in a commit, say three commits back, I deleted some code that should not have been deleted (yet). Now I want to go back and undo that particular change of that commit. It's a local repo.
So, you had something working, and a branch tip (maybe main
). You created a new branch name dev
:
git checkout -b dev
Then you made commit #1, then #2, then #3, then #4:
<some hacking>
git commit -m "prepare for delete"
<delete some stuff>
git commit -m "delete bad things"
<more hacking>
git commit -m "add first part of good things"
<more hacking>
git commit -m "add second part of good things"
You're in the middle of working on #5 right now when you realize you deleted too much stuff in #2, "delete bad things". If you don't have to put the code back right now, go ahead and finish #5 and commit:
<hacking>
git commit -m "add third part of good things"
Now you can use git revert
, git checkout -p
, git revert -n
and git reset -p
, or whatever you like to prepare to restore the deleted-too-much parts of commit #2. When that's ready, run:
git commit --fixup <hash of commit #2>
Git makes a commit that has the effect of restoring the lines you didn't want to delete. This is a commit-#6 in your series, but its subject line is:
fixup! delete bad things
Later, you can run:
git rebase -i --autosquash main
to rebase dev
onto main
using the auto-squash / auto-fix-up mode. Git will:
delete bad things
commit;delete bad things
commit in the instruction sheet; andpick
line here to fixup
.This fixup
instruction tells git rebase
that, when it comes to that point in the commit series, it should not stop at that point, but rather combine the delete commit with the restore commit, drop the fixup
commit's commit log message, and then proceed with the remainder of the pick
lines.
It's best to try this out in a scratch repository, playing with the interactive rebase to learn how squash and fixup commits work first, then start turning on autosquash mode. In the end, though, it turns out to be quite powerful and useful.
Upvotes: 1