I have a dev
branch in which I have merged a feature
branch.
-A------B----C-G-H-I-> dev
/
-D--E--F feature
After the merge, I made multiple other commits (G,H,I). Is it possible to edit the commit message of D and E :
- Without having to re-merge manually
- Without losing C,G,H and I
I tried using git rebase -i -r HEAD~6
(because I want to edit E, which is the 6th commit), but git still prompts me to the manual merging of feature
into dev
(which essentially have already made through B).
Can this be achieved ?
CodePudding user response:
First: no part of any existing commit can ever be changed. That means you cannot touch any of the saved snapshot, nor any of the commit metadata (name, email address, date-and-time stamps, log message, parent commit hash ID, and so on).
All operations that seem to change a commit—including both git commit --amend
and git rebase -i
—actually work by making new and different (presumably improved) commits. You must then convince everyone who was using the old commits to drop those in favor of the new commits.
This is easy to do if you're the only one with those commits: you need only convince yourself that your new and improved commits are the ones to use, and you get that for free by making the new and improved commits.
It's harder to do when you have given the old commits to someone else, via git push
for instance. You now not only have to convince yourself—which is easy and free since Git just does it as you use the commands that do it—but also convince everyone else. That usually involves using git push --force
or git push --force-with-lease
, and then often requires something else beyond that as well. But if the other Git repository belongs to you (e.g., on GitHub) and nobody else has picked up those new commits, the force-push operation alone will suffice and you'll be OK there.
With all that in mind, you can potentially use git rebase -i -r
here. But it won't do what you've asked for, it will at best do what you want instead. A better plan may be to use git replace
and git filter-branch
or git filter-repo
(though these are tricky), or just to do this manually (less tricky, but a little bit cumbersome—but not as much as you might think).
Background
A regular rebase—interactive or not—that doesn't involve any merges works by copying the old commits to new-and-improved ones. For instance, if you had:
...--A--B--C--D--E--F--G <-- dev (HEAD)
with a typo in the commit message for B
. Then HEAD~6
works well because we count back six times from G
: F
, E
, D
, C
, B
, A
. This is both the target commit—the new and improved replacements will go here—and the first commit not to copy, so that the set of commits to copy are B-C-D-E-F-G
. Using interactive rebase, you then change the pick
directive for commit B
to edit
, so that Git copies the commit's effect but gives you a chance to update or replace the commit message before making the new-and-improved commit. When Git makes the new and improved B'
commit, you have, at this point in the middle of the rebase, something that looks like this:
B' <-- HEAD
/
...--A--B--C--D--E--F--G <-- dev
The rebase operation then goes on to copy C
to C'
, D
to D'
, and so on. The end result, just before yanking the name dev
over, is:
B'-C'-D'-E'-F'-G' <-- HEAD
/
...--A--B--C--D--E--F--G <-- dev
As its final step, git rebase
grabs the original branch name, forces it to point to the last-copied commit G'
, and re-attaches HEAD
:
B'-C'-D'-E'-F'-G' <-- dev (HEAD)
/
...--A--B--C--D--E--F--G [abandoned]
The original commits can't be found easily any more, since there is no name for the last one. So they seem to be gone, and it seems that we somehow magically changed the commits. We didn't: the old commits are still there, still under their original hash IDs. The new commits have new, different hash IDs. Changing the contents of the commit message for B
resulted in a new and different hash ID for the new and improved B'
, and we then had to copy C
to a new and improved C'
whose only real change is that it links backwards to B'
instead of B
. This necessitated the copying of D
to D'
so that D'
can point to C'
, and so on down the line.
Your case
With your more complex graph:
...--A-------B--C--G--H--I <-- dev
/
...--D--E--F <-- feature
you have a different problem: you want to copy D
and E
to new-and-improved commits, D'
and E'
. This will force you to copy F
to a new and improved F'
, so that the name feature
can locate commit F'
. You now have to do a new git merge
to merge F'
with A
; the result of this new merge will be a copy of B
, B'
, with one of the parents still being A
but the other parent being F'
instead of F
.
Having copied B
to B'
, you are now in the same situation we had with a standard rebase: we have to copy all subsequent commits to new-and-improved commits, where the only actual "improvement" is that the linkages back to their parents go to the new-and-improved commits. That is, the "improvement" has to bubble all the way down to the end of the line, just as before. So we won't lose the C-G-H-I
sequence, but we have to re-copy these. Any old git rebase
can do that, because that part is linear.
That gives us the direct answer to the question you asked, as you asked it:
... Is it possible to edit the commit message of D and E:
- Without having to re-merge manually
- Without losing C,G,H and I
We definitely have to re-merge. How manual this has to be is another question, but git rebase -r
has some issues here: rebase needs to know which commits to copy, and there's no good and simple way to tell it to copy B
by redoing the merge, and to copy D
and E
and F
and C
through I
, but not other commits. In particular HEAD~6
means count back six first parents.1 You say (slightly paraphrasing) that you
merged
feature
intodev
by which I take it you ran git checkout dev
and then git merge feature
or equivalent. This means that the first parent of merge B
is commit A
, so you'd want git rebase -r -i HEAD~5
to exclude commit A
while keeping commit B
. This would include commits D-E-F
in the list of commits to copy, but might also include many earlier commits, and perhaps many earlier merges as well.
There's no way for Git to copy a merge commit in general. So with -r
, git rebase
just re-runs the merge. Any conflicts you resolved earlier will recur. In this one specific case, there is a way to copy the merge, but Git doesn't use it. That gives us a bunch of options:
Re-perform the merge, re-resolving any conflicts. That's the direct way. You probably don't want to do this though! (Especially if there were some tough conflicts.)
Turn on
git rerere
. This has Git save the conflict resolutions, so that Git can do its own re-resolving. This has one problem: it has to have been turned on at the time you resolved the conflicts, so if it wasn't, you need to go back in time2 and turn it on. Actually there's a script to get Git to "re-learn" the conflict resolution (git rerere-train
; see also What is git-rerere and how does it work?). It's still a bit of a pain though.Go ahead and use manual re-merging. This is easier than it sounds!
Use
git replace
and filter-branch / filter-repo. This is harder than it sounds, unfortunately, and I won't even go into any details here.
1The first parent thing is irrelevant in a linear chain, because here, every parent is a first-parent:
... <-F <-G <-H <-...
Commit H
has one parent G
, so its first parent is G
, its last parent is G
, and its only parent is G
: there's only one parent! What first parent means only really comes into effect when we hit a merge:
I--J
/ \₁
...--H M--N <-- branch
\ /²
K--L
Commit N
has one parent M
, but commit M
has two parents: J
and L
. One of these is the first parent. Here I've annotated the arrows so that we can see parent #1 goes to J
. The other parent is therefore the second parent. Git has a flag, --first-parent
, meaning follow only first parent links, and has that syntax, ~6
as a suffix for instance, which also means follow only the first parent links. Following the second parent is harder: we can write branch~1^2 to reach commit L
, where branch~1
means step back one first parent from N
to M
, and then ^2
means step back to the second parent. Or, branch^1^2
also gets us to L
: branch^1
means first parent of N
which is M
; ^2
then means second parent as before. Leaving out the number means "1", so branch^^2
or branch~^2
are also ways to get to commit L
. Using branch^^2^
gets us to commit K
, and branch^^2^^
or branch^^2~2
gets us to commit H
. The syntax branch~4
is an easier way to name commit H
: we count back four first-parent links, going from N
to M
(1 link) to J
(2 links) to I
(3 links) to H
(4 links).
2Got a spare DeLorean or TARDIS?
Manual re-merging is probably the right answer
To make your job easy, or at least easy-ish:
git checkout feature
orgit switch feature
so that you're using commitF
via the branch name.Use
git rebase -i
to rewrite commitsD-E-F
. The branch namefeature
will now point to the updatedF'
.Use
git checkout
with the historic commit hash ID of commitA
(find it withgit log
if needed). This gives you a detached HEAD, so if you prefergit switch
, usegit switch --detach
(thegit switch
command requires this flag to produce a detached HEAD state).Run
git merge feature
orgit merge -n feature
. This will attempt a merge ofA
and the updatedF'
. If it works all the way through, producing a merge—it won't if you use-n
—you will get:...--D'-E'-F' <-- feature \ B' <-- HEAD _____/ / ...--A-------B--C--G--H--I <-- dev / ...--D--E--F
If you encounter merge conflicts, or use
-n
, the merge will stop before making commitB'
. Now you have a chance to completely replace the merge result if you like, by reading into Git's index from commitB
:Run
git read-tree --reset -u <hash-of-B>
if desired: this erases the entire merge-result-so-far and brings in the files from commitB
. Then rungit commit
. Or, if you didn't use-n
and the merge worked on its own the first time, the merge in step 3 will already have worked on its own this second time, and you'll already have commitB'
. You can, if you like, rungit diff
to compare the new commitB'
against the original commitB
: the two trees should have the same content, so this diff should be entirely empty.Run
git cherry-pick dev~4..dev
. This will copy commitsC-G-H-I
, the same way rebase does. Modern cherry-pick understands the two-dot syntax here (in fact this has been in Git for many years now, though modern cherry-pick is smarter than the versions that this first went into: the same code base now implementsgit rebase -i
now, so you're actually using the same code as rebase).Verify that you like the result of all of this. If so, force the name
dev
to point to the current commit:git branch -f dev HEAD
or:
git checkout -B dev
CodePudding user response:
At the moment : it is not possible to do it without a manual intervention.
git rebase -r
does not spot the following situation :
- I am about to replay a merge between commits
A'
andF'
, - they have the exact same contents as the original commits
A
andF
, - so I'm going to use the exact same content as the original merge commit
B
for the result.
However, if you know that you only reworded the message and didn't change the content of the commits, it is easy to "use the content of B
" manually :
when git rebase
stops on the merge step :
restore the content of the original commit
B
:git restore -s B -SW . # short flags for: # git restore --source B --staged --worktree . # or git checkout B -- .
then proceed with the rebase :
git rebase --continue
CodePudding user response:
This isn't possible because commit messages are used to produce the commit hash; therefore if you change the commit message, you change the SHA of the commit and git will force you to update the history of the subsequent commits too