Home > Back-end >  Git fix wrong rename mapping
Git fix wrong rename mapping

Time:12-21

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 to git 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, and R);
  • a file was renamed and/or deleted in the B-to-L and/or B-to-R transition;
  • a file was created from scratch in one or both of the B-to-L and/or B-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 the HEAD 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.

  •  Tags:  
  • git
  • Related