ok/jj
1
0
Fork 0
forked from mirrors/jj

tree: simplify conflict before resolving at hunk level

I ran into a bug the other day where `jj status` said there was a
conflict in a file but there were no conflict markers in the working
copy. The commit was created when I squashed a conflict resolution
into the commit's parent. The rebased child commit then ended up in
this state. I.e., it looked something like this before squashing:

```
C (no conflict)
|
| B conflict
|/
A conflict
```

The conflict in B was different from the conflict in A. When I
squashed in C, jj would try to resolve the conflicts by first creating
a 7-way conflict (3 from A, 3 from B, 1 from C). Because of the exact
content-level changes, the 7-way conflict couldn't be automatically
resolved by `files::merge()` (the way it currently works
anyway). However, after simplifying the conflict, it could be
resolved. Because `MergedTree::merge()` does another round of conflict
simplification of the result at the end of the function, it was the
simplifed version that actually got stored in the commit. So when
inspecting the conflict later (e.g. in the working copy, as I did), it
could be automatically resolved.

I think there are at least two ways to solve this. One is to call
`merge_trees()` again after calling `tree.simplify()` in
`MergedTree::merge()`. However, I think it would only matter in the
case of content-level conflicts. Therefore, it seems better to make
the content-level resolution solve this case to start with. I've done
that by simplifying the conflict before passing it into
`files::merge()`. We could even do the fix in `files::merge()`, but
doing it before calling it has the advantage that we can avoid reading
some unchanged content from the backend.
This commit is contained in:
Martin von Zweigbergk 2023-09-20 21:28:56 -07:00
parent afdc3fc8b5
commit 51b5d168ae
3 changed files with 150 additions and 1 deletions

View file

@ -392,7 +392,15 @@ impl MergedTree {
// a resolved merge. However, that function will always preserve the arity of
// conflicts it cannot resolve. So we simplify the conflict again
// here to possibly reduce a complex conflict to a simpler one.
Ok(MergedTree::Merge(tree.simplify()))
let tree = tree.simplify();
// If debug assertions are enabled, check that the merge was idempotent. In
// particular, that this last simplification doesn't enable further automatic
// resolutions
if cfg!(debug_assertions) {
let re_merged = merge_trees(&tree).unwrap();
debug_assert_eq!(re_merged, tree);
}
Ok(MergedTree::Merge(tree))
}
}
}

View file

@ -433,6 +433,13 @@ pub fn try_resolve_file_conflict(
executable,
}));
}
// Simplify the conflict for two reasons:
// 1. Avoid reading unchanged file contents
// 2. The simplified conflict can sometimes be resolved when the unsimplfied one
// cannot
let file_id_conflict = file_id_conflict.simplify();
let contents: Merge<Vec<u8>> =
file_id_conflict.try_map(|&file_id| -> Result<Vec<u8>, TreeMergeError> {
let mut content = vec![];

View file

@ -14,6 +14,7 @@
use itertools::Itertools;
use jj_lib::backend::{FileId, MergedTreeId, TreeValue};
use jj_lib::files::MergeResult;
use jj_lib::matchers::{EverythingMatcher, FilesMatcher};
use jj_lib::merge::{Merge, MergeBuilder};
use jj_lib::merged_tree::{MergedTree, MergedTreeBuilder, MergedTreeValue};
@ -1182,3 +1183,136 @@ fn test_merge_simplify_result() {
let merged = side1_merged.merge(&base1_merged, &side2_merged).unwrap();
assert_eq!(merged, expected_merged);
}
/// Test that we simplify content-level conflicts before passing them to
/// files::merge().
///
/// This is what happens when you squash a conflict resolution into a conflict
/// and it gets propagated to a child where the conflict is different.
#[test]
fn test_merge_simplify_file_conflict() {
let test_repo = TestRepo::init();
let repo = &test_repo.repo;
let conflict_path = RepoPath::from_internal_string("CHANGELOG.md");
let other_path = RepoPath::from_internal_string("other");
let prefix = r#"### New features
* The `ancestors()` revset function now takes an optional `depth` argument
to limit the depth of the ancestor set. For example, use `jj log -r
'ancestors(@, 5)` to view the last 5 commits.
* Support for the Watchman filesystem monitor is now bundled by default. Set
`core.fsmonitor = "watchman"` in your repo to enable.
"#;
let suffix = r#"
### Fixed bugs
"#;
let parent_base_text = format!(r#"{prefix}{suffix}"#);
let parent_left_text = format!(
r#"{prefix}
* `jj op log` now supports `--no-graph`.
{suffix}"#
);
let parent_right_text = format!(
r#"{prefix}
* You can now configure the set of immutable commits via
`revsets.immutable-heads`. For example, set it to `"main"` to prevent
rewriting commits on the `main` branch.
{suffix}"#
);
let child1_right_text = format!(
r#"{prefix}
* You can now configure the set of immutable commits via
`revsets.immutable-heads`. For example, set it to `"main"` to prevent
rewriting commits on the `main` branch. The new `immutable()` revset resolves
to these immutable commits.
{suffix}"#
);
let child2_text = format!(
r#"{prefix}
* You can now configure the set of immutable commits via
`revsets.immutable-heads`. For example, set it to `"main"` to prevent
rewriting commits on the `main` branch.
* `jj op log` now supports `--no-graph`.
{suffix}"#
);
let expected_text = format!(
r#"{prefix}
* You can now configure the set of immutable commits via
`revsets.immutable-heads`. For example, set it to `"main"` to prevent
rewriting commits on the `main` branch. The new `immutable()` revset resolves
to these immutable commits.
* `jj op log` now supports `--no-graph`.
{suffix}"#
);
// conflict in parent commit
let parent_base = create_single_tree(repo, &[(&conflict_path, &parent_base_text)]);
let parent_left = create_single_tree(repo, &[(&conflict_path, &parent_left_text)]);
let parent_right = create_single_tree(repo, &[(&conflict_path, &parent_right_text)]);
let parent_merged = MergedTree::new(Merge::new(
vec![parent_base],
vec![parent_left, parent_right],
));
// different conflict in child
let child1_base = create_single_tree(
repo,
&[(&other_path, "child1"), (&conflict_path, &parent_base_text)],
);
let child1_left = create_single_tree(
repo,
&[(&other_path, "child1"), (&conflict_path, &parent_left_text)],
);
let child1_right = create_single_tree(
repo,
&[
(&other_path, "child1"),
(&conflict_path, &child1_right_text),
],
);
let child1_merged = MergedTree::new(Merge::new(
vec![child1_base],
vec![child1_left, child1_right],
));
// resolved state
let child2 = create_single_tree(repo, &[(&conflict_path, &child2_text)]);
let child2_merged = MergedTree::resolved(child2.clone());
// expected result
let expected = create_single_tree(
repo,
&[(&other_path, "child1"), (&conflict_path, &expected_text)],
);
let expected_merged = MergedTree::resolved(expected);
let merged = child1_merged.merge(&parent_merged, &child2_merged).unwrap();
assert_eq!(merged, expected_merged);
// Also test the setup by checking that the unsimplified content conflict cannot
// be resolved. If we later change files::merge() so this no longer fails, it
// probably means that we can delete this whole test (the Merge::simplify() call
// in try_resolve_file_conflict() is just an optimization then).
let text_merge = Merge::new(
vec![Merge::new(
vec![parent_base_text.as_bytes()],
vec![parent_left_text.as_bytes(), parent_right_text.as_bytes()],
)],
vec![
Merge::new(
vec![parent_base_text.as_bytes()],
vec![parent_left_text.as_bytes(), child1_right_text.as_bytes()],
),
Merge::resolved(child2_text.as_bytes()),
],
);
assert!(matches!(
jj_lib::files::merge(text_merge.flatten()),
MergeResult::Conflict(_)
));
}