merge-ort: precompute subset of sources for which we need rename detection

rename detection works by trying to pair all file deletions (or
"sources") with all file additions (or "destinations"), checking
similarity, and then marking the sufficiently similar ones as renames.
This can be expensive if there are many sources and destinations on a
given side of history as it results in an N x M comparison matrix.
However, there are many cases where we can compute in advance that
detecting renames for some of the sources provides no useful information
and thus that we can exclude those sources from the matrix.

To see why, first note that the merge machinery uses detected renames in
two ways:

   * directory rename detection: when one side of history renames a
       directory, and the other side of history adds new files to that
       directory, we want to be able to warn the user about the need to
       chose whether those new files stay in the old directory or move
       to the new one.

   * three-way content merging: in order to do three-way content merging
       of files, we need three different file versions.  If one side of
       history renamed a file, then some of the content for the file is
       found under a different path than in the merge base or on the
       other side of history.

Add a simple testcase showing the two kinds of reasons renames are
relevant; it's a testcase that will only pass if we detect both kinds of
needed renames.

Other than the testcase added above, this commit concentrates just on
the three-way content merging; it will punt and mark all sources as
needed for directory rename detection, and leave it to future commits to
narrow that down more.

The point of three-way content merging is to reconcile changes made on
*both* sides of history.  What if the file wasn't modified on both
sides?  There are two possibilities:

   * If it wasn't modified on the renamed side:
       -> then we get to do exact rename detection, which is cheap.

   * If it wasn't modified on the unrenamed side:
       -> then detection of a rename for that source file is irrelevant

That latter claim might be surprising at first, so let's walk through a
case to show why rename detection for that source file is irrelevant.
Let's use two filenames, old.c & new.c, with the following abbreviated
object ids (and where the value '000000' is used to denote that the file
is missing in that commit):

                 old.c     new.c
   MERGE_BASE:   01d01d    000000
   MERGE_SIDE1:  01d01d    000000
   MERGE_SIDE2:  000000    5e1ec7

If the rename *isn't* detected:
   then old.c looks like it was unmodified on one side and deleted on
   the other and should thus be removed.  new.c looks like a new file we
   should keep as-is.

If the rename *is* detected:
   then a three-way content merge is done.  Since the version of the
   file in MERGE_BASE and MERGE_SIDE1 are identical, the three-way merge
   will produce exactly the version of the file whose abbreviated
   object id is 5e1ec7.  It will record that file at the path new.c,
   while removing old.c from the directory.

Note that these two results are identical -- a single file named 'new.c'
with object id 5e1ec7.  In other words, it doesn't matter if the rename
is detected in the case where the file is unmodified on the unrenamed
side.

Use this information to compute whether we need rename detection for
each source created in add_pair().

It's probably worth noting that there used to be a few other edge or
corner cases besides three-way content merges and directory rename
detection where lack of rename detection could have affected the result,
but those cases actually highlighted where conflict resolution methods
were not consistent with each other.  Fixing those inconsistencies were
thus critically important to enabling this optimization.  That work
involved the following:

 * bringing consistency to add/add, rename/add, and rename/rename
    conflict types, as done back in the topic merged at commit
    ac193e0e0a ("Merge branch 'en/merge-path-collision'", 2019-01-04),
    and further extended in commits 2a7c16c980 ("t6422, t6426: be more
    flexible for add/add conflicts involving renames", 2020-08-10) and
    e8eb99d4a6 ("t642[23]: be more flexible for add/add conflicts
    involving pair renames", 2020-08-10)

  * making rename/delete more consistent with modify/delete
    as done in commits 1f3c9ba707 ("t6425: be more flexible with
    rename/delete conflict messages", 2020-08-10) and 727c75b23f
    ("t6404, t6423: expect improved rename/delete handling in ort
    backend", 2020-10-26)

Since the set of relevant_sources we compute has not yet been narrowed
down for directory rename detection, we do not pass it to
diffcore_rename_extended() yet.  That will be done after subsequent
commits narrow down the list of relevant_sources needed for directory
rename detection reasons.

Signed-off-by: Elijah Newren <newren@gmail.com>
Signed-off-by: Junio C Hamano <gitster@pobox.com>
This commit is contained in:
Elijah Newren 2021-03-11 00:38:25 +00:00 committed by Junio C Hamano
parent 9799889f2e
commit 32a56dfb99
2 changed files with 103 additions and 3 deletions

View File

@ -88,6 +88,20 @@ struct rename_info {
*/
struct strmap dir_renames[3];
/*
* relevant_sources: deleted paths for which we need rename detection
*
* relevant_sources is a set of deleted paths on each side of
* history for which we need rename detection. If a path is deleted
* on one side of history, we need to detect if it is part of a
* rename if either
* * we need to detect renames for an ancestor directory
* * the file is modified/deleted on the other side of history
* If neither of those are true, we can skip rename detection for
* that path.
*/
struct strset relevant_sources[3];
/*
* needed_limit: value needed for inexact rename detection to run
*
@ -358,6 +372,8 @@ static void clear_or_reinit_internal_opts(struct merge_options_internal *opti,
strmap_clear(&renames->dir_rename_count[i], 1);
strmap_func(&renames->dir_renames[i], 0);
strset_func(&renames->relevant_sources[i]);
}
if (!reinitialize) {
@ -533,12 +549,21 @@ static void add_pair(struct merge_options *opt,
struct name_entry *names,
const char *pathname,
unsigned side,
unsigned is_add /* if false, is_delete */)
unsigned is_add /* if false, is_delete */,
unsigned match_mask)
{
struct diff_filespec *one, *two;
struct rename_info *renames = &opt->priv->renames;
int names_idx = is_add ? side : 0;
if (!is_add) {
unsigned content_relevant = (match_mask == 0);
unsigned location_relevant = 1; /* FIXME: compute this */
if (content_relevant || location_relevant)
strset_add(&renames->relevant_sources[side], pathname);
}
one = alloc_filespec(pathname);
two = alloc_filespec(pathname);
fill_filespec(is_add ? two : one,
@ -575,11 +600,13 @@ static void collect_rename_info(struct merge_options *opt,
/* Check for deletion on side */
if ((filemask & 1) && !(filemask & side_mask))
add_pair(opt, names, fullname, side, 0 /* delete */);
add_pair(opt, names, fullname, side, 0 /* delete */,
match_mask & filemask);
/* Check for addition on side */
if (!(filemask & 1) && (filemask & side_mask))
add_pair(opt, names, fullname, side, 1 /* add */);
add_pair(opt, names, fullname, side, 1 /* add */,
match_mask & filemask);
}
}
@ -3228,6 +3255,8 @@ static void merge_start(struct merge_options *opt, struct merge_result *result)
NULL, 1);
strmap_init_with_options(&renames->dir_renames[i],
NULL, 0);
strset_init_with_options(&renames->relevant_sources[i],
NULL, 0);
}
/*

View File

@ -4895,6 +4895,77 @@ test_expect_merge_algorithm failure success '12f: Trivial directory resolve, cac
)
'
# Testcase 12g, Testcase with two kinds of "relevant" renames
# Commit O: somefile_O, subdir/{a_O,b_O}
# Commit A: somefile_A, subdir/{a_O,b_O,c_A}
# Commit B: newfile_B, newdir/{a_B,b_B}
# Expected: newfile_{merged}, newdir/{a_B,b_B,c_A}
test_setup_12g () {
test_create_repo 12g &&
(
cd 12g &&
mkdir -p subdir &&
test_write_lines upon a time there was a >somefile &&
test_write_lines 1 2 3 4 5 6 7 8 9 10 >subdir/a &&
test_write_lines one two three four five six >subdir/b &&
git add . &&
test_tick &&
git commit -m "O" &&
git branch O &&
git branch A &&
git branch B &&
git switch A &&
test_write_lines once upon a time there was a >somefile &&
> subdir/c &&
git add somefile subdir/c &&
test_tick &&
git commit -m "A" &&
git checkout B &&
git mv somefile newfile &&
git mv subdir newdir &&
echo repo >>newfile &&
test_write_lines 1 2 3 4 5 6 7 8 9 10 11 >newdir/a &&
test_write_lines one two three four five six seven >newdir/b &&
git add newfile newdir &&
test_tick &&
git commit -m "B"
)
}
test_expect_success '12g: Testcase with two kinds of "relevant" renames' '
test_setup_12g &&
(
cd 12g &&
git checkout A^0 &&
git -c merge.directoryRenames=true merge -s recursive B^0 &&
test_write_lines once upon a time there was a repo >expect &&
test_cmp expect newfile &&
git ls-files -s >out &&
test_line_count = 4 out &&
git rev-parse >actual \
HEAD:newdir/a HEAD:newdir/b HEAD:newdir/c &&
git rev-parse >expect \
B:newdir/a B:newdir/b A:subdir/c &&
test_cmp expect actual &&
test_must_fail git rev-parse HEAD:subdir/a &&
test_must_fail git rev-parse HEAD:subdir/b &&
test_must_fail git rev-parse HEAD:subdir/c &&
test_path_is_missing subdir/ &&
test_path_is_file newdir/c
)
'
###########################################################################
# SECTION 13: Checking informational and conflict messages
#