I have a project with branch-1
and branch-2
In branch-1
I copy a file to make another use of it:
Before
root/
code.cpp
After
root/
src/
code.cpp
test/
code.cpp
Accidentally, when merge to master (which I have not permission to change), git recognize as rename {root/ => root/test/}code.cpp
.
Now every time I change code is code.cpp
in branch-2
and cherry-pick to branch-1
, all changes will be apply to test/code.cpp
instead of src/code.cpp
How do I correct this?
CodePudding user response:
If you need to combine work in the files after the fact, consider git merge-file
(see below). But the answer to "how do I fix this" is: You don't. Git doesn't actually have file renaming in the first place, so there's nothing to fix!
Now, you might object that, hey, Git itself is saying "rename" here. And it is. But it's not because the file was renamed. It's because Git is testing—at the time you ask it to tell you about the two commits—to see if it can pretend the file was renamed. If Git thinks that saying "rename this file" produces shorter and clearer output, Git will say that.
You can turn this off with the --no-renames
option:
git diff --no-renames <commit-hash-ID-1> <commit-hash-ID-2>
Now when Git compares two commits, it won't ever say "rename". Instead, it will say "delete file" and "add file".
when [I] merge
For git merge
, use -X no-renames
instead of --no-renames
. (Why this is a different option spelling than git diff
's --no-renames
is mostly historical, and the usual bad user-experience design of Git.)
and cherry-pick
The cherry-pick code uses Git's merge engine, so here again, use -X no-renames
to make Git stop calling that a "rename".
Long: all the raw details
We'll start with an overview of git merge
, since all this stuff uses Git's merge engine.
Git's automatic method for combining work
When you run git merge other
, Git does the following:
- locate the current commit (via
HEAD
); - locate the commit specified by
other
: this can be a branch name or a raw commit hash ID, or anything acceptable togit rev-parse
really; and - using those two commits, locate a third commit, the merge base.
To illustrate this with a regular (and real1) merge, let's consider the following graph fragment:
o--...--L <-- br1 (HEAD)
/
...--o--B
\
o--...--R <-- br2
That is, you're currently "on" branch br1
, using commit L
(L
here stands in for a big ugly hash ID, which we'll call LOCAL
as git mergetool
does, or --ours
as git restore
does). You ask Git to merge in commit R
via git merge br2
. Git uses the history—the parent linkage from commits to earlier commits—to find a third commit, which I call B
here. This is the merge base. It is the "best common (shared) commit", or technically, the output from running a Lowest Common Ancestor algorithm.2
Git now, in effect, runs:
git diff --find-renames <hash-of-B> <hash-of-L>
to see what "we" changed on "our" branch br1
. Using -X no-renames
makes Git leave out the --find-renames
part.
Git then runs a second git diff
command:
git diff --find-renames <hash-of-B> <hash-of-R>
to see what "they" changed on "their" branch br2
, since the same starting point (commit B
). Again, using -X no-renames
disables the --find-renames
step.
With the --find-renames
step, Git will sometimes pair up some file that was in B
and is deleted in L
and/or R
with some file that wasn't in B
but is newly-created in L
and/or R
. Git will call this a rename and will attempt to preserve this rename change. Without the --find-renames
step, Git will not do this pairing-up, which avoids the problem that occurs if Git finds the wrong pair, but now leaves you with "file was deleted".
In all cases—with or without --find-renames
—Git now has a bunch of possibilities:
- the file exists and has the same name in all three commits (
B
,L
, andR
); - a file was renamed and/or deleted in the
B
-to-L
and/orB
-to-R
transition; - a file was created from scratch in one or both of the
B
-to-L
and/orB
-to-R
transitions.
The first case is the easiest and most common one, which Git usually handles pretty well on its own. The second and third cases can cause Git to stop with a merge conflict if it does not know how to combine the B
-to-L
and B
-to-R
changes.
In cases where three version of the file do exist (renames included), we now have "three versions" of "the same file" (even if the file has different file names in L
and/or R
). Git now tries to combine any content changes made in the B
-to-L
and/or B
-to-R
diffs. If one side touched the contents and the other side did not, Git will take the changes from the side that did touch the contents. If neither side touched the contents, Git will take any of the three versions of the file. If both sides touched the contents, Git uses git merge-file
on the three input files to attempt to combine the changes.
The result of git merge-file
can either be "successful merge" or "merge conflict detected". If a merge conflict is detected, Git:
- writes its best approximation of merging to the working tree, and
- leaves the three input files in its index, using nonzero staging numbers.
Using git ls-files --stage --unmerged
, you can see the three versions of the file in Git's index; you can use git checkout-index
or similar (including git mergetool
) to get the three versions.
When a file is (or is detected as) deleted, though, there is at least one version that is not in the index. You may still be able to recover the merge base version of the file from the index, which is particularly handy for merges, since identifying commit B
can be tricky. See the git checkout-index
documentation for details here.
If the file wasn't actually deleted, but was instead renamed—Git just mis-identified the correct new file—and/or has work that needs to be combined, you now have to bypass Git's normal automation.
1The git merge
command will sometimes use short-cuts, when a real merge is not required. We won't cover that case here since it doesn't apply to any of the problem situations.
2If the LCA algorithm emits more than one commit hash ID, things get complicated; I don't cover that case here.
Using git merge-file
When Git does an automatic three-way merge on three file contents, it will identify sections that one "side" (e.g., ours / left-side / local) touched and the other side did not change, and will take the change. Only where "our" and "their" changes actually overlap or abut will Git declare a merge conflict.
When Git doesn't identify the correct input files, though, its automated merging is useless. For these cases, you simply have to extract the three inputs. You can then feed the three files to the git merge-file
command:
git merge-file ours base theirs
or:
git merge-file foo.LOCAL foo.BASE foo.REMOTE
(as git mergetool
calls them). Git will write its best effort at doing the same three-way merge as usual to the ours
(or foo.LOCAL
) file, treating the base
and theirs
files as the merge base and "remote".
The git merge-file
command will use the same kind of conflict markers; to make it put the right text into the conflict markers, use the -L
option.
Making the merge commit result
Once you have the correct file contents under the correct names, use git add
(and git rm
if necessary and appropriate) to tell Git that you've successfully combined the changes. Then use git merge --continue
or git commit
to complete the merge.
If git merge -X no-renames
tries to commit the merge because Git thinks it has done so successfully, even though it has not actually done so correctly, use the --no-commit
option to prevent git merge
from making a commit.
Cherry-pick
As noted above, cherry-pick is actually implemented using the merge engine. Running:
git cherry-pick <commit-specifier>
runs the same change-combining code as git merge
with one alteration: Git uses the parent of the specified commit as the merge base.
That is:
- the
--ours
commit is theHEAD
commit as usual; - the
--theirs
commit is the commit you specified as usual; but - the merge base is the (single) parent of the specified commit.
Everything else works as for a regular merge, up until the merge is done. Then instead of git merge --continue
, you run git cherry-pick --continue
(or, again, git commit
) to have Git make a new commit. Unlike with git merge
, the new commit will be an ordinary (single-parent) commit. As with git merge
, if cherry-pick thinks it can do the cherry-pick on its own, but will get it wrong, you can use --no-commit
to prevent this from happening.