Skip to content

Commit

Permalink
merge-recursive: switch directory rename detection default
Browse files Browse the repository at this point in the history
When all of x/a, x/b, and x/c have moved to z/a, z/b, and z/c on one
branch, there is a question about whether x/d added on a different
branch should remain at x/d or appear at z/d when the two branches are
merged.  There are different possible viewpoints here:

  A) The file was placed at x/d; it's unrelated to the other files in
     x/ so it doesn't matter that all the files from x/ moved to z/ on
     one branch; x/d should still remain at x/d.

  B) x/d is related to the other files in x/, and x/ was renamed to z/;
     therefore x/d should be moved to z/d.

Since there was no ability to detect directory renames prior to
git-2.18, users experienced (A) regardless of context.  Choice (B) was
implemented in git-2.18, with no option to go back to (A), and has been
in use since.  However, one user reported that the merge results did not
match their expectations, making the change of default problematic,
especially since there was no notice printed when directory rename
detection moved files.

Note that there is also a third possibility here:

  C) There are different answers depending on the context and content
     that cannot be determined by git, so this is a conflict.  Use a
     higher stage in the index to record the conflict and notify the
     user of the potential issue instead of silently selecting a
     resolution for them.

Add an option for users to specify their preference for whether to use
directory rename detection, and default to (C).  Even when directory
rename detection is on, add notice messages about files moved into new
directories.

As a sidenote, x/d did not have to be a new file here; it could have
already existed at some other path and been renamed to x/d, with
directory rename detection just renaming it again to z/d.  Thus, it's
not just new files, but also a modification to all rename types (normal
renames, rename/add, rename/delete, rename/rename(1to1),
rename/rename(1to2), and rename/rename(2to1)).

Signed-off-by: Elijah Newren <newren@gmail.com>
Signed-off-by: Junio C Hamano <gitster@pobox.com>
  • Loading branch information
newren authored and gitster committed Apr 8, 2019
1 parent e62d112 commit 8c8e5bd
Show file tree
Hide file tree
Showing 5 changed files with 552 additions and 87 deletions.
19 changes: 16 additions & 3 deletions Documentation/config/merge.txt
Expand Up @@ -39,9 +39,22 @@ merge.renameLimit::
is turned off.

merge.renames::
Whether and how Git detects renames. If set to "false",
rename detection is disabled. If set to "true", basic rename
detection is enabled. Defaults to the value of diff.renames.
Whether Git detects renames. If set to "false", rename detection
is disabled. If set to "true", basic rename detection is enabled.
Defaults to the value of diff.renames.

merge.directoryRenames::
Whether Git detects directory renames, affecting what happens at
merge time to new files added to a directory on one side of
history when that directory was renamed on the other side of
history. If merge.directoryRenames is set to "false", directory
rename detection is disabled, meaning that such new files will be
left behind in the old directory. If set to "true", directory
rename detection is enabled, meaning that such new files will be
moved into the new directory. If set to "conflict", a conflict
will be reported for such paths. If merge.renames is false,
merge.directoryRenames is ignored and treated as false. Defaults
to "conflict".

merge.renormalize::
Tell Git that canonical representation of files in the
Expand Down
146 changes: 123 additions & 23 deletions merge-recursive.c
Expand Up @@ -1370,30 +1370,39 @@ static int handle_rename_via_dir(struct merge_options *opt,
*/
const struct rename *ren = ci->ren1;
const struct diff_filespec *dest = ren->pair->two;
char *file_path = dest->path;
int mark_conflicted = (opt->detect_directory_renames == 1);
assert(ren->dir_rename_original_dest);

if (!opt->call_depth && would_lose_untracked(opt, dest->path)) {
char *alt_path = unique_path(opt, dest->path, ren->branch);

mark_conflicted = 1;
file_path = unique_path(opt, dest->path, ren->branch);
output(opt, 1, _("Error: Refusing to lose untracked file at %s; "
"writing to %s instead."),
dest->path, alt_path);
"writing to %s instead."),
dest->path, file_path);
}

if (mark_conflicted) {
/*
* Write the file in worktree at alt_path, but not in the
* index. Instead, write to dest->path for the index but
* only at the higher appropriate stage.
* Write the file in worktree at file_path. In the index,
* only record the file at dest->path in the appropriate
* higher stage.
*/
if (update_file(opt, 0, dest, alt_path))
if (update_file(opt, 0, dest, file_path))
return -1;
free(alt_path);
return update_stages(opt, dest->path, NULL,
ren->branch == opt->branch1 ? dest : NULL,
ren->branch == opt->branch1 ? NULL : dest);
if (file_path != dest->path)
free(file_path);
if (update_stages(opt, dest->path, NULL,
ren->branch == opt->branch1 ? dest : NULL,
ren->branch == opt->branch1 ? NULL : dest))
return -1;
return 0; /* not clean, but conflicted */
} else {
/* Update dest->path both in index and in worktree */
if (update_file(opt, 1, dest, dest->path))
return -1;
return 1; /* clean */
}

/* Update dest->path both in index and in worktree */
if (update_file(opt, 1, dest, dest->path))
return -1;
return 0;
}

static int handle_change_delete(struct merge_options *opt,
Expand Down Expand Up @@ -3090,10 +3099,88 @@ static int handle_rename_normal(struct merge_options *opt,
const struct diff_filespec *b,
struct rename_conflict_info *ci)
{
/* Merge the content and write it out */
struct rename *ren = ci->ren1;
struct merge_file_info mfi;
return handle_content_merge(&mfi, opt, path, was_dirty(opt, path),
o, a, b, ci);
int clean;
int side = (ren->branch == opt->branch1 ? 2 : 3);

/* Merge the content and write it out */
clean = handle_content_merge(&mfi, opt, path, was_dirty(opt, path),
o, a, b, ci);

if (clean && opt->detect_directory_renames == 1 &&
ren->dir_rename_original_dest) {
if (update_stages(opt, path,
NULL,
side == 2 ? &mfi.blob : NULL,
side == 2 ? NULL : &mfi.blob))
return -1;
clean = 0; /* not clean, but conflicted */
}
return clean;
}

static void dir_rename_warning(const char *msg,
int is_add,
int clean,
struct merge_options *opt,
struct rename *ren)
{
const char *other_branch;
other_branch = (ren->branch == opt->branch1 ?
opt->branch2 : opt->branch1);
if (is_add) {
output(opt, clean ? 2 : 1, msg,
ren->pair->one->path, ren->branch,
other_branch, ren->pair->two->path);
return;
}
output(opt, clean ? 2 : 1, msg,
ren->pair->one->path, ren->dir_rename_original_dest, ren->branch,
other_branch, ren->pair->two->path);
}
static int warn_about_dir_renamed_entries(struct merge_options *opt,
struct rename *ren)
{
const char *msg;
int clean = 1, is_add;

if (!ren)
return clean;

/* Return early if ren was not affected/created by a directory rename */
if (!ren->dir_rename_original_dest)
return clean;

/* Sanity checks */
assert(opt->detect_directory_renames > 0);
assert(ren->dir_rename_original_type == 'A' ||
ren->dir_rename_original_type == 'R');

/* Check whether to treat directory renames as a conflict */
clean = (opt->detect_directory_renames == 2);

is_add = (ren->dir_rename_original_type == 'A');
if (ren->dir_rename_original_type == 'A' && clean) {
msg = _("Path updated: %s added in %s inside a "
"directory that was renamed in %s; moving it to %s.");
} else if (ren->dir_rename_original_type == 'A' && !clean) {
msg = _("CONFLICT (file location): %s added in %s "
"inside a directory that was renamed in %s, "
"suggesting it should perhaps be moved to %s.");
} else if (ren->dir_rename_original_type == 'R' && clean) {
msg = _("Path updated: %s renamed to %s in %s, inside a "
"directory that was renamed in %s; moving it to %s.");
} else if (ren->dir_rename_original_type == 'R' && !clean) {
msg = _("CONFLICT (file location): %s renamed to %s in %s, "
"inside a directory that was renamed in %s, "
"suggesting it should perhaps be moved to %s.");
} else {
BUG("Impossible dir_rename_original_type/clean combination");
}
dir_rename_warning(msg, is_add, clean, opt, ren);

return clean;
}

/* Per entry merge function */
Expand All @@ -3115,6 +3202,10 @@ static int process_entry(struct merge_options *opt,
if (entry->rename_conflict_info) {
struct rename_conflict_info *ci = entry->rename_conflict_info;
struct diff_filespec *temp;
int path_clean;

path_clean = warn_about_dir_renamed_entries(opt, ci->ren1);
path_clean &= warn_about_dir_renamed_entries(opt, ci->ren2);

/*
* For cases with a single rename, {o,a,b}->path have all been
Expand All @@ -3135,9 +3226,7 @@ static int process_entry(struct merge_options *opt,
ci);
break;
case RENAME_VIA_DIR:
clean_merge = 1;
if (handle_rename_via_dir(opt, ci))
clean_merge = -1;
clean_merge = handle_rename_via_dir(opt, ci);
break;
case RENAME_ADD:
/*
Expand Down Expand Up @@ -3187,6 +3276,8 @@ static int process_entry(struct merge_options *opt,
entry->processed = 0;
break;
}
if (path_clean < clean_merge)
clean_merge = path_clean;
} else if (o_valid && (!a_valid || !b_valid)) {
/* Case A: Deleted in one */
if ((!a_valid && !b_valid) ||
Expand Down Expand Up @@ -3558,6 +3649,15 @@ static void merge_recursive_config(struct merge_options *opt)
opt->merge_detect_rename = git_config_rename("merge.renames", value);
free(value);
}
if (!git_config_get_string("merge.directoryrenames", &value)) {
int boolval = git_parse_maybe_bool(value);
if (0 <= boolval) {
opt->detect_directory_renames = boolval ? 2 : 0;
} else if (!strcasecmp(value, "conflict")) {
opt->detect_directory_renames = 1;
} /* avoid erroring on values from future versions of git */
free(value);
}
git_config(git_xmerge_config, NULL);
}

Expand Down
8 changes: 4 additions & 4 deletions t/t3401-rebase-and-am-rename.sh
Expand Up @@ -42,7 +42,7 @@ test_expect_success 'rebase --interactive: directory rename detected' '
git checkout B^0 &&
set_fake_editor &&
FAKE_LINES="1" git rebase --interactive A &&
FAKE_LINES="1" git -c merge.directoryRenames=true rebase --interactive A &&
git ls-files -s >out &&
test_line_count = 5 out &&
Expand All @@ -58,7 +58,7 @@ test_expect_failure 'rebase (am): directory rename detected' '
git checkout B^0 &&
git rebase A &&
git -c merge.directoryRenames=true rebase A &&
git ls-files -s >out &&
test_line_count = 5 out &&
Expand All @@ -74,7 +74,7 @@ test_expect_success 'rebase --merge: directory rename detected' '
git checkout B^0 &&
git rebase --merge A &&
git -c merge.directoryRenames=true rebase --merge A &&
git ls-files -s >out &&
test_line_count = 5 out &&
Expand All @@ -92,7 +92,7 @@ test_expect_failure 'am: directory rename detected' '
git format-patch -1 B &&
git am --3way 0001*.patch &&
git -c merge.directoryRenames=true am --3way 0001*.patch &&
git ls-files -s >out &&
test_line_count = 5 out &&
Expand Down

0 comments on commit 8c8e5bd

Please sign in to comment.