diff --git a/api/client/src/main/java/org/projectnessie/client/api/MergeReferenceBuilder.java b/api/client/src/main/java/org/projectnessie/client/api/MergeReferenceBuilder.java
index f72d538b986..7ea03e8ea5f 100644
--- a/api/client/src/main/java/org/projectnessie/client/api/MergeReferenceBuilder.java
+++ b/api/client/src/main/java/org/projectnessie/client/api/MergeReferenceBuilder.java
@@ -62,5 +62,12 @@ default MergeReferenceBuilder fromRef(Reference fromRef) {
return fromRefName(fromRef.getName()).fromHash(fromRef.getHash());
}
+ /** Perform the merge operation. */
MergeResponse merge() throws NessieNotFoundException, NessieConflictException;
+
+ /**
+ * Perform the merge operation and allows to optionally inspect merge conflicts using the content
+ * state of the conflicting contents on the merge-base, merge-source and merge-target.
+ */
+ MergeResponseInspector mergeInspect() throws NessieNotFoundException, NessieConflictException;
}
diff --git a/api/client/src/main/java/org/projectnessie/client/api/MergeResponseInspector.java b/api/client/src/main/java/org/projectnessie/client/api/MergeResponseInspector.java
new file mode 100644
index 00000000000..b9a31c7dd61
--- /dev/null
+++ b/api/client/src/main/java/org/projectnessie/client/api/MergeResponseInspector.java
@@ -0,0 +1,114 @@
+/*
+ * Copyright (C) 2023 Dremio
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.projectnessie.client.api;
+
+import java.util.stream.Stream;
+import javax.annotation.Nullable;
+import org.immutables.value.Value;
+import org.projectnessie.api.v2.params.Merge;
+import org.projectnessie.error.NessieNotFoundException;
+import org.projectnessie.model.Conflict;
+import org.projectnessie.model.Conflict.ConflictType;
+import org.projectnessie.model.Content;
+import org.projectnessie.model.ContentKey;
+import org.projectnessie.model.MergeBehavior;
+import org.projectnessie.model.MergeKeyBehavior;
+import org.projectnessie.model.MergeResponse;
+
+/**
+ * Allows inspection of merge results, to resolve {@link ConflictType#VALUE_DIFFERS content related}
+ * merge {@link Conflict conflicts} indicated in the {@link #getResponse() merge response}.
+ */
+public interface MergeResponseInspector {
+ /** The merge request sent to Nessie. */
+ Merge getRequest();
+
+ /** The merge response received from Nessie. */
+ MergeResponse getResponse();
+
+ /**
+ * Provides details about the conflicts that happened during a merge operation.
+ *
+ *
The returned stream contains one {@link MergeConflictDetails element} for each conflict.
+ * Non-conflicting contents are not included.
+ *
+ *
Each {@link MergeConflictDetails conflict details object} allows callers to resolve
+ * conflicts based on that information, also known as "content aware merge".
+ *
+ *
Once conflicts have been either resolved, or alternatively specific keys declared to "use
+ * the {@link MergeBehavior#DROP left/from} or {@link MergeBehavior#FORCE right/target} side",
+ * another {@link MergeReferenceBuilder merge operation} cna be performed, providing a {@link
+ * MergeKeyBehavior#getResolvedContent() resolved content} for a content key.
+ *
+ *
Keep in mind that calling this function triggers API calls against nessie to retrieve the
+ * relevant content objects on the merge-base and and the content keys and content objects on the
+ * merge-from (source) and merge-target.
+ */
+ Stream collectMergeConflictDetails() throws NessieNotFoundException;
+
+ @Value.Immutable
+ interface MergeConflictDetails {
+ /** The content ID of the conflicting content. */
+ default String getContentId() {
+ return contentOnMergeBase().getId();
+ }
+
+ /** Key of the content on the {@link MergeResponse#getCommonAncestor() merge-base commit}. */
+ ContentKey keyOnMergeBase();
+
+ /** Key of the content on the {@link MergeReferenceBuilder#fromRef merge-from reference}. */
+ @Nullable
+ @jakarta.annotation.Nullable
+ ContentKey keyOnSource();
+
+ /**
+ * Key of the content on the {@link MergeResponse#getEffectiveTargetHash() merge-target
+ * reference}.
+ */
+ @Nullable
+ @jakarta.annotation.Nullable
+ ContentKey keyOnTarget();
+
+ /** Content object on the {@link MergeResponse#getCommonAncestor() merge-base commit}. */
+ // TODO this can also be null, if the same key was added on source + target but is not present
+ // on merge-base.
+ Content contentOnMergeBase();
+
+ /**
+ * Content on the {@link MergeReferenceBuilder#fromRef merge-from reference}, or {@code null} if
+ * not present on the merge-from.
+ */
+ @Nullable
+ @jakarta.annotation.Nullable
+ Content contentOnSource();
+
+ /**
+ * Content on the {@link MergeResponse#getEffectiveTargetHash() merge-target reference}, or
+ * {@code null} if not present on the merge-target.
+ */
+ @Nullable
+ @jakarta.annotation.Nullable
+ Content contentOnTarget();
+
+ /**
+ * Contains {@link Conflict#conflictType() machine interpretable} and {@link Conflict#message()
+ * human.readable information} about the conflict.
+ */
+ // TODO this can also be null, if the same key was added on source + target but is not present
+ // on merge-base.
+ Conflict conflict();
+ }
+}
diff --git a/api/client/src/main/java/org/projectnessie/client/api/impl/BaseMergeResponseInspector.java b/api/client/src/main/java/org/projectnessie/client/api/impl/BaseMergeResponseInspector.java
new file mode 100644
index 00000000000..1cbf178f7ad
--- /dev/null
+++ b/api/client/src/main/java/org/projectnessie/client/api/impl/BaseMergeResponseInspector.java
@@ -0,0 +1,142 @@
+/*
+ * Copyright (C) 2023 Dremio
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.projectnessie.client.api.impl;
+
+import static java.util.Collections.emptyList;
+import static java.util.Objects.requireNonNull;
+import static org.projectnessie.client.api.impl.MapEntry.mapEntry;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+import org.projectnessie.client.api.ImmutableMergeConflictDetails;
+import org.projectnessie.client.api.MergeResponseInspector;
+import org.projectnessie.error.NessieNotFoundException;
+import org.projectnessie.model.Conflict;
+import org.projectnessie.model.Content;
+import org.projectnessie.model.ContentKey;
+import org.projectnessie.model.Detached;
+import org.projectnessie.model.DiffResponse;
+import org.projectnessie.model.GetMultipleContentsResponse;
+import org.projectnessie.model.MergeResponse;
+
+/** Base class used for testing and production code, because testing does not use the Nessie API. */
+public abstract class BaseMergeResponseInspector implements MergeResponseInspector {
+
+ @Override
+ public Stream collectMergeConflictDetails() throws NessieNotFoundException {
+ // Note: The API exposes a `Stream` so we can optimize this implementation later to reduce
+ // runtime or heap pressure.
+ return mergeConflictDetails().stream();
+ }
+
+ protected List mergeConflictDetails() throws NessieNotFoundException {
+ Map conflictMap = conflictMap();
+ Map mergeBaseContentByKey = mergeBaseContentByKey();
+ if (mergeBaseContentByKey.isEmpty()) {
+ return emptyList();
+ }
+
+ Map> sourceDiff = sourceDiff();
+ Map> targetDiff = targetDiff();
+
+ List details = new ArrayList<>(mergeBaseContentByKey.size());
+
+ for (Map.Entry base : mergeBaseContentByKey.entrySet()) {
+ ContentKey baseKey = base.getKey();
+ Content baseContent = base.getValue();
+
+ Map.Entry source = keyAndContent(baseKey, baseContent, sourceDiff);
+ Map.Entry target = keyAndContent(baseKey, baseContent, targetDiff);
+
+ MergeConflictDetails detail =
+ ImmutableMergeConflictDetails.builder()
+ .conflict(conflictMap.get(baseKey))
+ .keyOnMergeBase(baseKey)
+ .keyOnSource(source.getKey())
+ .keyOnTarget(target.getKey())
+ .contentOnMergeBase(baseContent)
+ .contentOnSource(source.getValue())
+ .contentOnTarget(target.getValue())
+ .build();
+
+ details.add(detail);
+ }
+ return details;
+ }
+
+ protected Map conflictMap() {
+ return getResponse().getDetails().stream()
+ .map(MergeResponse.ContentKeyDetails::getConflict)
+ .filter(Objects::nonNull)
+ .collect(Collectors.toMap(Conflict::key, Function.identity()));
+ }
+
+ protected Set mergeBaseContentIds() throws NessieNotFoundException {
+ return mergeBaseContents().stream()
+ .map(GetMultipleContentsResponse.ContentWithKey::getContent)
+ .map(Content::getId)
+ .filter(Objects::nonNull)
+ .collect(Collectors.toSet());
+ }
+
+ protected Map mergeBaseContentByKey() throws NessieNotFoundException {
+ return mergeBaseContents().stream()
+ .collect(
+ Collectors.toMap(
+ GetMultipleContentsResponse.ContentWithKey::getKey,
+ GetMultipleContentsResponse.ContentWithKey::getContent));
+ }
+
+ protected Map> sourceDiff() throws NessieNotFoundException {
+ // TODO it would help to have the effective from-hash in the response, in case the merge-request
+ // did not contain it. If we have that merge, we can use 'DETACHED' here.
+ String hash = getRequest().getFromHash();
+ String ref = hash != null ? Detached.REF_NAME : getRequest().getFromRefName();
+ return diffByContentId(ref, hash);
+ }
+
+ protected Map> targetDiff() throws NessieNotFoundException {
+ return diffByContentId(Detached.REF_NAME, getResponse().getEffectiveTargetHash());
+ }
+
+ protected abstract List mergeBaseContents()
+ throws NessieNotFoundException;
+
+ protected abstract Map> diffByContentId(
+ String ref, String hash) throws NessieNotFoundException;
+
+ static String contentIdFromDiffEntry(DiffResponse.DiffEntry e) {
+ Content from = e.getFrom();
+ return requireNonNull(from != null ? from.getId() : requireNonNull(e.getTo()).getId());
+ }
+
+ static Map.Entry keyAndContent(
+ ContentKey baseKey, Content baseContent, Map> diff) {
+ List diffs = diff.get(baseContent.getId());
+ if (diffs != null) {
+ int size = diffs.size();
+ DiffResponse.DiffEntry last = diffs.get(size - 1);
+ return mapEntry(last.getKey(), last.getTo());
+ }
+ return mapEntry(baseKey, baseContent);
+ }
+}
diff --git a/api/client/src/main/java/org/projectnessie/client/api/impl/MapEntry.java b/api/client/src/main/java/org/projectnessie/client/api/impl/MapEntry.java
new file mode 100644
index 00000000000..444732a7d6b
--- /dev/null
+++ b/api/client/src/main/java/org/projectnessie/client/api/impl/MapEntry.java
@@ -0,0 +1,53 @@
+/*
+ * Copyright (C) 2023 Dremio
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.projectnessie.client.api.impl;
+
+import java.util.Map;
+import javax.annotation.Nullable;
+import org.immutables.value.Value;
+
+/**
+ * A helper that implements {@link Map.Entry}, because the code is built for Java 8 and Guava's not
+ * a dependency.
+ */
+@Value.Immutable
+abstract class MapEntry implements Map.Entry {
+ private static final MapEntry, ?> EMPTY_ENTRY = (MapEntry, ?>) MapEntry.mapEntry(null, null);
+
+ @SuppressWarnings("unchecked")
+ static Map.Entry emptyEntry() {
+ return (Map.Entry) EMPTY_ENTRY;
+ }
+
+ static Map.Entry mapEntry(K key, V value) {
+ return ImmutableMapEntry.of(key, value);
+ }
+
+ @Override
+ @Value.Parameter(order = 1)
+ @Nullable
+ public abstract K getKey();
+
+ @Override
+ @Value.Parameter(order = 2)
+ @Nullable
+ public abstract V getValue();
+
+ @Override
+ public V setValue(V value) {
+ throw new UnsupportedOperationException();
+ }
+}
diff --git a/api/client/src/main/java/org/projectnessie/client/api/impl/MergeResponseInspectorImpl.java b/api/client/src/main/java/org/projectnessie/client/api/impl/MergeResponseInspectorImpl.java
new file mode 100644
index 00000000000..adbcf13bb8e
--- /dev/null
+++ b/api/client/src/main/java/org/projectnessie/client/api/impl/MergeResponseInspectorImpl.java
@@ -0,0 +1,117 @@
+/*
+ * Copyright (C) 2023 Dremio
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.projectnessie.client.api.impl;
+
+import static java.util.Collections.emptyList;
+import static java.util.stream.Collectors.groupingBy;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import org.immutables.value.Value;
+import org.projectnessie.api.v2.params.Merge;
+import org.projectnessie.client.api.NessieApiV2;
+import org.projectnessie.error.NessieNotFoundException;
+import org.projectnessie.model.Conflict;
+import org.projectnessie.model.Content;
+import org.projectnessie.model.ContentKey;
+import org.projectnessie.model.Detached;
+import org.projectnessie.model.DiffResponse;
+import org.projectnessie.model.GetMultipleContentsResponse;
+import org.projectnessie.model.MergeResponse;
+
+@Value.Immutable
+public abstract class MergeResponseInspectorImpl extends BaseMergeResponseInspector {
+ public static ImmutableMergeResponseInspectorImpl.Builder builder() {
+ return ImmutableMergeResponseInspectorImpl.builder();
+ }
+
+ protected abstract NessieApiV2 api();
+
+ @Override
+ public abstract Merge getRequest();
+
+ @Override
+ public abstract MergeResponse getResponse();
+
+ @Value.Lazy
+ protected List mergeBaseContents()
+ throws NessieNotFoundException {
+ Map conflictMap = conflictMap();
+ if (conflictMap.isEmpty()) {
+ return emptyList();
+ }
+
+ @SuppressWarnings("resource")
+ NessieApiV2 api = api();
+
+ // Collect the contents on the merge base for the conflicting keys.
+ return api.getContent()
+ .keys(new ArrayList<>(conflictMap.keySet()))
+ .refName(Detached.REF_NAME)
+ .hashOnRef(getResponse().getCommonAncestor())
+ .getWithResponse()
+ .getContents();
+ }
+
+ @Value.Lazy
+ protected Map conflictMap() {
+ return super.conflictMap();
+ }
+
+ @Value.Lazy
+ protected Set mergeBaseContentIds() throws NessieNotFoundException {
+ return super.mergeBaseContentIds();
+ }
+
+ @Value.Lazy
+ protected Map mergeBaseContentByKey() throws NessieNotFoundException {
+ return super.mergeBaseContentByKey();
+ }
+
+ @Value.Lazy
+ protected Map> sourceDiff() throws NessieNotFoundException {
+ return super.sourceDiff();
+ }
+
+ @Value.Lazy
+ protected Map> targetDiff() throws NessieNotFoundException {
+ return super.targetDiff();
+ }
+
+ @Override
+ protected Map> diffByContentId(String ref, String hash)
+ throws NessieNotFoundException {
+ @SuppressWarnings("resource")
+ NessieApiV2 api = api();
+
+ Set mergeBaseContentIds = mergeBaseContentIds();
+
+ // Use the diff from merge-base to merge-target and collect the diff-entries by content ID.
+ // This is needed to look up the contents irrespective of a rename between merge-base and
+ // merge-target.
+ return api
+ .getDiff()
+ .fromRefName(Detached.REF_NAME)
+ .fromHashOnRef(getResponse().getCommonAncestor())
+ .toRefName(ref)
+ .toHashOnRef(hash)
+ .stream()
+ .filter(diff -> mergeBaseContentIds.contains(contentIdFromDiffEntry(diff)))
+ .collect(groupingBy(MergeResponseInspectorImpl::contentIdFromDiffEntry));
+ }
+}
diff --git a/api/client/src/main/java/org/projectnessie/client/http/v1api/HttpMergeReference.java b/api/client/src/main/java/org/projectnessie/client/http/v1api/HttpMergeReference.java
index c10ad5987b9..258894e62bb 100644
--- a/api/client/src/main/java/org/projectnessie/client/http/v1api/HttpMergeReference.java
+++ b/api/client/src/main/java/org/projectnessie/client/http/v1api/HttpMergeReference.java
@@ -17,6 +17,7 @@
import org.projectnessie.api.v1.params.ImmutableMerge;
import org.projectnessie.client.api.MergeReferenceBuilder;
+import org.projectnessie.client.api.MergeResponseInspector;
import org.projectnessie.client.builder.BaseMergeReferenceBuilder;
import org.projectnessie.client.http.NessieApiClient;
import org.projectnessie.error.NessieConflictException;
@@ -64,4 +65,9 @@ public MergeResponse merge() throws NessieNotFoundException, NessieConflictExcep
return client.getTreeApi().mergeRefIntoBranch(branchName, hash, merge.build());
}
+
+ @Override
+ public MergeResponseInspector mergeInspect() {
+ throw new UnsupportedOperationException("Merge response inspection is not available in API v1");
+ }
}
diff --git a/api/client/src/main/java/org/projectnessie/client/http/v2api/HttpApiV2.java b/api/client/src/main/java/org/projectnessie/client/http/v2api/HttpApiV2.java
index 1c26ef150b8..f5dd857cc17 100644
--- a/api/client/src/main/java/org/projectnessie/client/http/v2api/HttpApiV2.java
+++ b/api/client/src/main/java/org/projectnessie/client/http/v2api/HttpApiV2.java
@@ -133,7 +133,7 @@ public TransplantCommitsBuilder transplantCommitsIntoBranch() {
@Override
public MergeReferenceBuilder mergeRefIntoBranch() {
- return new HttpMergeReference(client);
+ return new HttpMergeReference(this, client);
}
@Override
diff --git a/api/client/src/main/java/org/projectnessie/client/http/v2api/HttpMergeReference.java b/api/client/src/main/java/org/projectnessie/client/http/v2api/HttpMergeReference.java
index e83623a9e16..02df4b66629 100644
--- a/api/client/src/main/java/org/projectnessie/client/http/v2api/HttpMergeReference.java
+++ b/api/client/src/main/java/org/projectnessie/client/http/v2api/HttpMergeReference.java
@@ -16,7 +16,10 @@
package org.projectnessie.client.http.v2api;
import org.projectnessie.api.v2.params.ImmutableMerge;
+import org.projectnessie.api.v2.params.Merge;
import org.projectnessie.client.api.MergeReferenceBuilder;
+import org.projectnessie.client.api.MergeResponseInspector;
+import org.projectnessie.client.api.impl.MergeResponseInspectorImpl;
import org.projectnessie.client.builder.BaseMergeReferenceBuilder;
import org.projectnessie.client.http.HttpClient;
import org.projectnessie.error.NessieConflictException;
@@ -25,9 +28,11 @@
import org.projectnessie.model.Reference;
final class HttpMergeReference extends BaseMergeReferenceBuilder {
+ private final HttpApiV2 api;
private final HttpClient client;
- public HttpMergeReference(HttpClient client) {
+ public HttpMergeReference(HttpApiV2 api, HttpClient client) {
+ this.api = api;
this.client = client;
}
@@ -39,8 +44,7 @@ public MergeReferenceBuilder keepIndividualCommits(boolean keepIndividualCommits
return this;
}
- @Override
- public MergeResponse merge() throws NessieNotFoundException, NessieConflictException {
+ private Merge buildMerge() {
ImmutableMerge.Builder merge =
ImmutableMerge.builder()
.fromHash(fromHash)
@@ -59,12 +63,38 @@ public MergeResponse merge() throws NessieNotFoundException, NessieConflictExcep
merge.keyMergeModes(mergeModes.values());
}
+ return merge.build();
+ }
+
+ private MergeResponse submitMergeRequest(Merge request)
+ throws NessieNotFoundException, NessieConflictException {
return client
.newRequest()
.path("trees/{ref}/history/merge")
.resolveTemplate("ref", Reference.toPathString(branchName, hash))
.unwrap(NessieNotFoundException.class, NessieConflictException.class)
- .post(merge.build())
+ .post(request)
.readEntity(MergeResponse.class);
}
+
+ @Override
+ public MergeResponse merge() throws NessieNotFoundException, NessieConflictException {
+ Merge request = buildMerge();
+
+ return submitMergeRequest(request);
+ }
+
+ @Override
+ public MergeResponseInspector mergeInspect()
+ throws NessieNotFoundException, NessieConflictException {
+ Merge request = buildMerge();
+
+ MergeResponse response = submitMergeRequest(request);
+
+ return MergeResponseInspectorImpl.builder()
+ .api(api)
+ .request(request)
+ .response(response)
+ .build();
+ }
}
diff --git a/api/client/src/test/java/org/projectnessie/client/api/impl/TestMergeResponseInspector.java b/api/client/src/test/java/org/projectnessie/client/api/impl/TestMergeResponseInspector.java
new file mode 100644
index 00000000000..27689133b79
--- /dev/null
+++ b/api/client/src/test/java/org/projectnessie/client/api/impl/TestMergeResponseInspector.java
@@ -0,0 +1,158 @@
+/*
+ * Copyright (C) 2023 Dremio
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.projectnessie.client.api.impl;
+
+import static org.projectnessie.model.Conflict.conflict;
+import static org.projectnessie.model.DiffResponse.DiffEntry.diffEntry;
+
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+import org.assertj.core.api.SoftAssertions;
+import org.assertj.core.api.junit.jupiter.InjectSoftAssertions;
+import org.assertj.core.api.junit.jupiter.SoftAssertionsExtension;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.projectnessie.api.v2.params.ImmutableMerge;
+import org.projectnessie.api.v2.params.Merge;
+import org.projectnessie.client.api.MergeResponseInspector;
+import org.projectnessie.model.Conflict;
+import org.projectnessie.model.Content;
+import org.projectnessie.model.ContentKey;
+import org.projectnessie.model.Detached;
+import org.projectnessie.model.DiffResponse.DiffEntry;
+import org.projectnessie.model.GetMultipleContentsResponse.ContentWithKey;
+import org.projectnessie.model.IcebergTable;
+import org.projectnessie.model.ImmutableContentKeyDetails;
+import org.projectnessie.model.ImmutableMergeResponse;
+import org.projectnessie.model.MergeBehavior;
+import org.projectnessie.model.MergeResponse;
+
+@ExtendWith(SoftAssertionsExtension.class)
+public class TestMergeResponseInspector {
+ @InjectSoftAssertions protected SoftAssertions soft;
+
+ @Test
+ public void foo() throws Exception {
+ String mainHash = "00000000";
+ String sourceHash = "deadbeef";
+ String mergeBase = "12345678";
+
+ String cidOne = "1";
+ String cidTwo = "2";
+
+ ContentKey keyOne = ContentKey.of("one");
+ ContentKey keyTwo = ContentKey.of("two");
+ ContentKey keyOneSource = ContentKey.of("one-s");
+ ContentKey keyOneTarget = ContentKey.of("one-t");
+
+ IcebergTable oneBase = IcebergTable.of("one", 1, 1, 1, 1, cidOne);
+ IcebergTable twoBase = IcebergTable.of("two", 2, 2, 2, 2, cidTwo);
+ IcebergTable oneSource = IcebergTable.of("one-source", 1, 1, 1, 1, cidOne);
+ IcebergTable twoSource = IcebergTable.of("two-source", 2, 2, 2, 2, cidTwo);
+ IcebergTable oneTarget = IcebergTable.of("one-target", 1, 1, 1, 1, cidOne);
+ IcebergTable twoTarget = IcebergTable.of("two-target", 2, 2, 2, 2, cidTwo);
+ Map> diffsSource = new HashMap<>();
+ diffsSource.put(
+ oneSource.getId(),
+ diffForRename(keyOne, oneBase, keyOneSource, oneSource).collect(Collectors.toList()));
+ diffsSource.put(
+ twoSource.getId(), diffForUpdate(keyTwo, twoBase, twoTarget).collect(Collectors.toList()));
+ Map> diffsTarget = new HashMap<>();
+
+ Function buildContentKeyDetails =
+ key ->
+ ImmutableContentKeyDetails.builder()
+ .key(key)
+ .conflict(conflict(Conflict.ConflictType.VALUE_DIFFERS, key, "value differs"))
+ .mergeBehavior(MergeBehavior.NORMAL)
+ .build();
+
+ Merge request = ImmutableMerge.builder().fromRefName("source").fromHash(sourceHash).build();
+ MergeResponse response =
+ ImmutableMergeResponse.builder()
+ .targetBranch("main")
+ .effectiveTargetHash(mainHash)
+ .commonAncestor(mergeBase)
+ .addDetails(buildContentKeyDetails.apply(keyOne))
+ .addDetails(buildContentKeyDetails.apply(keyTwo))
+ .build();
+
+ MergeResponseInspector tested =
+ new AbstractTestMergeResponseTest(request, response) {
+ @Override
+ protected List mergeBaseContents() {
+ return Arrays.asList(
+ ContentWithKey.of(keyOne, oneBase), ContentWithKey.of(keyTwo, twoBase));
+ }
+
+ @Override
+ protected Map> diffByContentId(String ref, String hash) {
+ if ((ref.equals("main") || ref.equals(Detached.REF_NAME)) && hash.equals(mainHash)) {
+ return diffsTarget;
+ } else if ((ref.equals("source") || ref.equals(Detached.REF_NAME))
+ && hash.equals(sourceHash)) {
+ return diffsSource;
+ } else {
+ throw new IllegalArgumentException(ref + " / " + hash);
+ }
+ }
+ };
+
+ List details =
+ tested.collectMergeConflictDetails().collect(Collectors.toList());
+ soft.assertThat(details)
+ .extracting(MergeResponseInspector.MergeConflictDetails::keyOnMergeBase)
+ .containsExactlyInAnyOrder(keyOne, keyTwo);
+ }
+
+ static Stream diffForRename(
+ ContentKey keyFrom, Content from, ContentKey keyTo, Content to) {
+ return Stream.of(diffEntry(keyFrom, from, null), diffEntry(keyTo, null, to));
+ }
+
+ static Stream diffForReadd(ContentKey key, Content from, Content to) {
+ return diffForUpdate(key, from, to);
+ }
+
+ static Stream diffForUpdate(ContentKey key, Content from, Content to) {
+ return Stream.of(diffEntry(key, from, to));
+ }
+
+ abstract static class AbstractTestMergeResponseTest extends BaseMergeResponseInspector {
+ final Merge request;
+ final MergeResponse response;
+
+ AbstractTestMergeResponseTest(Merge request, MergeResponse response) {
+ this.request = request;
+ this.response = response;
+ }
+
+ @Override
+ public Merge getRequest() {
+ return request;
+ }
+
+ @Override
+ public MergeResponse getResponse() {
+ return response;
+ }
+ }
+}