--- /dev/null
+/*
+ * Copyright (C) 2022 Thomas Wolf <twolf@apache.org> and others
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Distribution License v. 1.0 which is available at
+ * https://www.eclipse.org/org/documents/edl-v10.php.
+ *
+ * SPDX-License-Identifier: BSD-3-Clause
+ */
+package org.eclipse.jgit.merge;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assume.assumeTrue;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.LinkOption;
+
+import org.eclipse.jgit.api.Git;
+import org.eclipse.jgit.api.MergeResult;
+import org.eclipse.jgit.api.MergeResult.MergeStatus;
+import org.eclipse.jgit.api.ResetCommand.ResetType;
+import org.eclipse.jgit.junit.RepositoryTestCase;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.ConfigConstants;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.lib.StoredConfig;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.Parameterized;
+import org.junit.runners.Parameterized.Parameter;
+import org.junit.runners.Parameterized.Parameters;
+
+/**
+ * Tests for merges involving symlinks.
+ */
+@RunWith(Parameterized.class)
+public class SymlinkMergeTest extends RepositoryTestCase {
+
+ @Parameters(name = "target={0}, core.symlinks={1}")
+ public static Object[][] parameters() {
+ return new Object[][] {
+ { Target.NONE, Boolean.TRUE },
+ { Target.FILE, Boolean.TRUE },
+ { Target.DIRECTORY, Boolean.TRUE },
+ { Target.NONE, Boolean.FALSE },
+ { Target.FILE, Boolean.FALSE },
+ { Target.DIRECTORY, Boolean.FALSE },
+ };
+ }
+
+ public enum Target {
+ NONE, FILE, DIRECTORY
+ }
+
+ @Parameter(0)
+ public Target target;
+
+ @Parameter(1)
+ public boolean useSymLinks;
+
+ private void setTargets() throws IOException {
+ switch (target) {
+ case DIRECTORY:
+ assertTrue(new File(trash, "target").mkdir());
+ assertTrue(new File(trash, "target1").mkdir());
+ assertTrue(new File(trash, "target2").mkdir());
+ break;
+ case FILE:
+ writeTrashFile("target", "t");
+ writeTrashFile("target1", "t1");
+ writeTrashFile("target2", "t2");
+ break;
+ default:
+ break;
+ }
+ }
+
+ private void checkTargets() throws IOException {
+ File t = new File(trash, "target");
+ File t1 = new File(trash, "target1");
+ File t2 = new File(trash, "target2");
+ switch (target) {
+ case DIRECTORY:
+ assertTrue(t.isDirectory());
+ assertTrue(t1.isDirectory());
+ assertTrue(t2.isDirectory());
+ break;
+ case FILE:
+ checkFile(t, "t");
+ checkFile(t1, "t1");
+ checkFile(t2, "t2");
+ break;
+ default:
+ assertFalse(Files.exists(t.toPath(), LinkOption.NOFOLLOW_LINKS));
+ assertFalse(Files.exists(t1.toPath(), LinkOption.NOFOLLOW_LINKS));
+ assertFalse(Files.exists(t2.toPath(), LinkOption.NOFOLLOW_LINKS));
+ break;
+ }
+ }
+
+ private void assertSymLink(File link, String content) throws Exception {
+ if (useSymLinks) {
+ assertTrue(Files.isSymbolicLink(link.toPath()));
+ assertEquals(content, db.getFS().readSymLink(link));
+ } else {
+ assertFalse(Files.isSymbolicLink(link.toPath()));
+ assertTrue(link.isFile());
+ checkFile(link, content);
+ }
+ }
+
+ // Link/link conflict: C git records the conflict but leaves the link in the
+ // working tree unchanged.
+
+ @Test
+ public void mergeWithSymlinkConflict() throws Exception {
+ assumeTrue(db.getFS().supportsSymlinks() || !useSymLinks);
+ StoredConfig config = db.getConfig();
+ config.setBoolean(ConfigConstants.CONFIG_CORE_SECTION, null,
+ ConfigConstants.CONFIG_KEY_SYMLINKS, useSymLinks);
+ config.save();
+ try (TestRepository<Repository> repo = new TestRepository<>(db)) {
+ db.incrementOpen();
+ // Create the links directly in the git repo, then use a hard reset
+ // to get them into the workspace. This enables us to run these
+ // tests also with core.symLinks = false.
+ RevCommit base = repo
+ .commit(repo.tree(repo.link("link", repo.blob("target"))));
+ RevCommit side = repo.commit(
+ repo.tree(repo.link("link", repo.blob("target1"))), base);
+ RevCommit head = repo.commit(
+ repo.tree(repo.link("link", repo.blob("target2"))), base);
+ try (Git git = new Git(db)) {
+ setTargets();
+ git.reset().setMode(ResetType.HARD).setRef(head.name()).call();
+ File link = new File(trash, "link");
+ assertSymLink(link, "target2");
+ MergeResult result = git.merge().include(side)
+ .setMessage("merged").call();
+ assertEquals(MergeStatus.CONFLICTING, result.getMergeStatus());
+ // Link should be unmodified
+ assertSymLink(link, "target2");
+ checkTargets();
+ assertEquals("[link, mode:120000, stage:1, content:target]"
+ + "[link, mode:120000, stage:2, content:target2]"
+ + "[link, mode:120000, stage:3, content:target1]",
+ indexState(CONTENT));
+ }
+ }
+ }
+
+ // In file/link conflicts, C git never does a content merge. It records the
+ // stages in the index, and always puts the file into the workspace.
+
+ @Test
+ public void mergeWithFileSymlinkConflict() throws Exception {
+ assumeTrue(db.getFS().supportsSymlinks() || !useSymLinks);
+ StoredConfig config = db.getConfig();
+ config.setBoolean(ConfigConstants.CONFIG_CORE_SECTION, null,
+ ConfigConstants.CONFIG_KEY_SYMLINKS, useSymLinks);
+ config.save();
+ try (TestRepository<Repository> repo = new TestRepository<>(db)) {
+ db.incrementOpen();
+ RevCommit base = repo.commit(repo.tree());
+ RevCommit side = repo.commit(
+ repo.tree(repo.link("link", repo.blob("target1"))), base);
+ RevCommit head = repo.commit(
+ repo.tree(repo.file("link", repo.blob("not a link"))),
+ base);
+ try (Git git = new Git(db)) {
+ setTargets();
+ git.reset().setMode(ResetType.HARD).setRef(head.name()).call();
+ File link = new File(trash, "link");
+ assertFalse(Files.isSymbolicLink(link.toPath()));
+ checkFile(link, "not a link");
+ MergeResult result = git.merge().include(side)
+ .setMessage("merged").call();
+ assertEquals(MergeStatus.CONFLICTING, result.getMergeStatus());
+ // File should be unmodified
+ assertFalse(Files.isSymbolicLink(link.toPath()));
+ checkFile(link, "not a link");
+ checkTargets();
+ assertEquals("[link, mode:100644, stage:2, content:not a link]"
+ + "[link, mode:120000, stage:3, content:target1]",
+ indexState(CONTENT));
+ }
+ }
+ }
+
+ @Test
+ public void mergeWithSymlinkFileConflict() throws Exception {
+ assumeTrue(db.getFS().supportsSymlinks() || !useSymLinks);
+ StoredConfig config = db.getConfig();
+ config.setBoolean(ConfigConstants.CONFIG_CORE_SECTION, null,
+ ConfigConstants.CONFIG_KEY_SYMLINKS, useSymLinks);
+ config.save();
+ try (TestRepository<Repository> repo = new TestRepository<>(db)) {
+ db.incrementOpen();
+ RevCommit base = repo.commit(repo.tree());
+ RevCommit side = repo.commit(
+ repo.tree(repo.file("link", repo.blob("not a link"))),
+ base);
+ RevCommit head = repo.commit(
+ repo.tree(repo.link("link", repo.blob("target2"))), base);
+ try (Git git = new Git(db)) {
+ setTargets();
+ git.reset().setMode(ResetType.HARD).setRef(head.name()).call();
+ File link = new File(trash, "link");
+ assertSymLink(link, "target2");
+ MergeResult result = git.merge().include(side)
+ .setMessage("merged").call();
+ assertEquals(MergeStatus.CONFLICTING, result.getMergeStatus());
+ // Should now be a file!
+ assertFalse(Files.isSymbolicLink(link.toPath()));
+ checkFile(link, "not a link");
+ checkTargets();
+ assertEquals("[link, mode:120000, stage:2, content:target2]"
+ + "[link, mode:100644, stage:3, content:not a link]",
+ indexState(CONTENT));
+ }
+ }
+ }
+
+ // In Delete/modify conflicts with the non-deleted side a link, C git puts
+ // the link into the working tree.
+
+ @Test
+ public void mergeWithSymlinkDeleteModify() throws Exception {
+ assumeTrue(db.getFS().supportsSymlinks() || !useSymLinks);
+ StoredConfig config = db.getConfig();
+ config.setBoolean(ConfigConstants.CONFIG_CORE_SECTION, null,
+ ConfigConstants.CONFIG_KEY_SYMLINKS, useSymLinks);
+ config.save();
+ try (TestRepository<Repository> repo = new TestRepository<>(db)) {
+ db.incrementOpen();
+ RevCommit base = repo
+ .commit(repo.tree(repo.link("link", repo.blob("target"))));
+ RevCommit side = repo.commit(
+ repo.tree(repo.link("link", repo.blob("target1"))), base);
+ RevCommit head = repo.commit(repo.tree(), base);
+ try (Git git = new Git(db)) {
+ setTargets();
+ git.reset().setMode(ResetType.HARD).setRef(head.name()).call();
+ File link = new File(trash, "link");
+ assertFalse(
+ Files.exists(link.toPath(), LinkOption.NOFOLLOW_LINKS));
+ MergeResult result = git.merge().include(side)
+ .setMessage("merged").call();
+ assertEquals(MergeStatus.CONFLICTING, result.getMergeStatus());
+ // Link should have the content from side
+ assertSymLink(link, "target1");
+ checkTargets();
+ assertEquals("[link, mode:120000, stage:1, content:target]"
+ + "[link, mode:120000, stage:3, content:target1]",
+ indexState(CONTENT));
+ }
+ }
+ }
+
+ @Test
+ public void mergeWithSymlinkModifyDelete() throws Exception {
+ assumeTrue(db.getFS().supportsSymlinks() || !useSymLinks);
+ StoredConfig config = db.getConfig();
+ config.setBoolean(ConfigConstants.CONFIG_CORE_SECTION, null,
+ ConfigConstants.CONFIG_KEY_SYMLINKS, useSymLinks);
+ config.save();
+ try (TestRepository<Repository> repo = new TestRepository<>(db)) {
+ db.incrementOpen();
+ RevCommit base = repo
+ .commit(repo.tree(repo.link("link", repo.blob("target"))));
+ RevCommit side = repo.commit(repo.tree(), base);
+ RevCommit head = repo.commit(
+ repo.tree(repo.link("link", repo.blob("target2"))), base);
+ try (Git git = new Git(db)) {
+ setTargets();
+ git.reset().setMode(ResetType.HARD).setRef(head.name()).call();
+ File link = new File(trash, "link");
+ assertSymLink(link, "target2");
+ MergeResult result = git.merge().include(side)
+ .setMessage("merged").call();
+ assertEquals(MergeStatus.CONFLICTING, result.getMergeStatus());
+ // Link should be unmodified
+ assertSymLink(link, "target2");
+ checkTargets();
+ assertEquals("[link, mode:120000, stage:1, content:target]"
+ + "[link, mode:120000, stage:2, content:target2]",
+ indexState(CONTENT));
+ }
+ }
+ }
+}
* Copyright (C) 2010-2012, Matthias Sohn <matthias.sohn@sap.com>
* Copyright (C) 2012, Research In Motion Limited
* Copyright (C) 2017, Obeo (mathieu.cartaud@obeo.fr)
- * Copyright (C) 2018, 2022 Thomas Wolf <thomas.wolf@paranor.ch> and others
+ * Copyright (C) 2018, 2022 Thomas Wolf <twolf@apache.org> and others
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Distribution License v. 1.0 which is available at
return null;
}
+ /**
+ * Adds the conflict stages for the current path of {@link #tw} to the index
+ * builder and returns the "theirs" stage; if present.
+ *
+ * @param base
+ * of the conflict
+ * @param ours
+ * of the conflict
+ * @param theirs
+ * of the conflict
+ * @return the {@link DirCacheEntry} for the "theirs" stage, or {@code null}
+ */
+ private DirCacheEntry addConflict(CanonicalTreeParser base,
+ CanonicalTreeParser ours, CanonicalTreeParser theirs) {
+ add(tw.getRawPath(), base, DirCacheEntry.STAGE_1, EPOCH, 0);
+ add(tw.getRawPath(), ours, DirCacheEntry.STAGE_2, EPOCH, 0);
+ return add(tw.getRawPath(), theirs, DirCacheEntry.STAGE_3, EPOCH, 0);
+ }
+
/**
* adds a entry to the index builder which is a copy of the specified
* DirCacheEntry
// length.
// This path can be skipped on ignoreConflicts, so the caller
// could use virtual commit.
- add(tw.getRawPath(), base, DirCacheEntry.STAGE_1, EPOCH, 0);
- add(tw.getRawPath(), ours, DirCacheEntry.STAGE_2, EPOCH, 0);
- add(tw.getRawPath(), theirs, DirCacheEntry.STAGE_3, EPOCH, 0);
+ addConflict(base, ours, theirs);
unmergedPaths.add(tw.getPathString());
mergeResults.put(tw.getPathString(),
new MergeResult<>(Collections.emptyList()));
add(tw.getRawPath(), ours, DirCacheEntry.STAGE_0, EPOCH, 0);
return true;
} else if (gitLinkMerging) {
- add(tw.getRawPath(), base, DirCacheEntry.STAGE_1, EPOCH, 0);
- add(tw.getRawPath(), ours, DirCacheEntry.STAGE_2, EPOCH, 0);
- add(tw.getRawPath(), theirs, DirCacheEntry.STAGE_3, EPOCH, 0);
+ addConflict(base, ours, theirs);
MergeResult<SubmoduleConflict> result = createGitLinksMergeResult(
base, ours, theirs);
result.setContainsConflicts(true);
default:
break;
}
- add(tw.getRawPath(), base, DirCacheEntry.STAGE_1, EPOCH, 0);
- add(tw.getRawPath(), ours, DirCacheEntry.STAGE_2, EPOCH, 0);
- add(tw.getRawPath(), theirs, DirCacheEntry.STAGE_3, EPOCH, 0);
+ addConflict(base, ours, theirs);
// attribute merge issues are conflicts but not failures
unmergedPaths.add(tw.getPathString());
}
MergeResult<RawText> result = null;
- try {
- result = contentMerge(base, ours, theirs, attributes,
- getContentMergeStrategy());
- } catch (BinaryBlobException e) {
+ boolean hasSymlink = FileMode.SYMLINK.equals(modeO)
+ || FileMode.SYMLINK.equals(modeT);
+ if (!hasSymlink) {
+ try {
+ result = contentMerge(base, ours, theirs, attributes,
+ getContentMergeStrategy());
+ } catch (BinaryBlobException e) {
+ // result == null
+ }
+ }
+ if (result == null) {
switch (getContentMergeStrategy()) {
- case OURS:
- keep(ourDce);
- return true;
- case THEIRS:
- DirCacheEntry theirEntry = add(tw.getRawPath(), theirs,
- DirCacheEntry.STAGE_0, EPOCH, 0);
- addToCheckout(tw.getPathString(), theirEntry, attributes);
- return true;
- default:
- result = new MergeResult<>(Collections.emptyList());
- result.setContainsConflicts(true);
- break;
+ case OURS:
+ keep(ourDce);
+ return true;
+ case THEIRS:
+ DirCacheEntry e = add(tw.getRawPath(), theirs,
+ DirCacheEntry.STAGE_0, EPOCH, 0);
+ if (e != null) {
+ addToCheckout(tw.getPathString(), e, attributes);
+ }
+ return true;
+ default:
+ result = new MergeResult<>(Collections.emptyList());
+ result.setContainsConflicts(true);
+ break;
}
}
if (ignoreConflicts) {
result.setContainsConflicts(false);
}
- updateIndex(base, ours, theirs, result, attributes[T_OURS]);
String currentPath = tw.getPathString();
+ if (hasSymlink) {
+ if (ignoreConflicts) {
+ if (((modeT & FileMode.TYPE_MASK) == FileMode.TYPE_FILE)) {
+ DirCacheEntry e = add(tw.getRawPath(), theirs,
+ DirCacheEntry.STAGE_0, EPOCH, 0);
+ addToCheckout(currentPath, e, attributes);
+ } else {
+ keep(ourDce);
+ }
+ } else {
+ // Record the conflict
+ DirCacheEntry e = addConflict(base, ours, theirs);
+ mergeResults.put(currentPath, result);
+ // If theirs is a file, check it out. In link/file
+ // conflicts, C git prefers the file.
+ if (((modeT & FileMode.TYPE_MASK) == FileMode.TYPE_FILE)
+ && e != null) {
+ addToCheckout(currentPath, e, attributes);
+ }
+ }
+ } else {
+ updateIndex(base, ours, theirs, result, attributes[T_OURS]);
+ }
if (result.containsConflicts() && !ignoreConflicts) {
unmergedPaths.add(currentPath);
}
if (gitLinkMerging && ignoreConflicts) {
add(tw.getRawPath(), ours, DirCacheEntry.STAGE_0, EPOCH, 0);
} else if (gitLinkMerging) {
- add(tw.getRawPath(), base, DirCacheEntry.STAGE_1, EPOCH, 0);
- add(tw.getRawPath(), ours, DirCacheEntry.STAGE_2, EPOCH, 0);
- add(tw.getRawPath(), theirs, DirCacheEntry.STAGE_3, EPOCH, 0);
+ addConflict(base, ours, theirs);
MergeResult<SubmoduleConflict> result = createGitLinksMergeResult(
base, ours, theirs);
result.setContainsConflicts(true);
mergeResults.put(tw.getPathString(), result);
unmergedPaths.add(tw.getPathString());
} else {
+ boolean isSymLink = ((modeO | modeT)
+ & FileMode.TYPE_MASK) == FileMode.TYPE_SYMLINK;
// Content merge strategy does not apply to delete-modify
// conflicts!
MergeResult<RawText> result;
- try {
- result = contentMerge(base, ours, theirs, attributes,
- ContentMergeStrategy.CONFLICT);
- } catch (BinaryBlobException e) {
+ if (isSymLink) {
+ // No need to do a content merge
result = new MergeResult<>(Collections.emptyList());
result.setContainsConflicts(true);
+ } else {
+ try {
+ result = contentMerge(base, ours, theirs,
+ attributes, ContentMergeStrategy.CONFLICT);
+ } catch (BinaryBlobException e) {
+ result = new MergeResult<>(Collections.emptyList());
+ result.setContainsConflicts(true);
+ }
}
if (ignoreConflicts) {
- // In case a conflict is detected the working tree file
- // is again filled with new content (containing conflict
- // markers). But also stage 0 of the index is filled
- // with that content.
result.setContainsConflicts(false);
- updateIndex(base, ours, theirs, result,
- attributes[T_OURS]);
+ if (isSymLink) {
+ if (modeO != 0) {
+ keep(ourDce);
+ } else {
+ // Check out theirs
+ if (isWorktreeDirty(work, ourDce)) {
+ return false;
+ }
+ DirCacheEntry e = add(tw.getRawPath(), theirs,
+ DirCacheEntry.STAGE_0, EPOCH, 0);
+ if (e != null) {
+ addToCheckout(tw.getPathString(), e,
+ attributes);
+ }
+ }
+ } else {
+ // In case a conflict is detected the working tree
+ // file is again filled with new content (containing
+ // conflict markers). But also stage 0 of the index
+ // is filled with that content.
+ updateIndex(base, ours, theirs, result,
+ attributes[T_OURS]);
+ }
} else {
- add(tw.getRawPath(), base, DirCacheEntry.STAGE_1, EPOCH,
- 0);
- add(tw.getRawPath(), ours, DirCacheEntry.STAGE_2, EPOCH,
- 0);
- DirCacheEntry e = add(tw.getRawPath(), theirs,
- DirCacheEntry.STAGE_3, EPOCH, 0);
+ DirCacheEntry e = addConflict(base, ours, theirs);
// OURS was deleted checkout THEIRS
if (modeO == 0) {
// A conflict occurred, the file will contain conflict markers
// the index will be populated with the three stages and the
// workdir (if used) contains the halfway merged content.
- add(tw.getRawPath(), base, DirCacheEntry.STAGE_1, EPOCH, 0);
- add(tw.getRawPath(), ours, DirCacheEntry.STAGE_2, EPOCH, 0);
- add(tw.getRawPath(), theirs, DirCacheEntry.STAGE_3, EPOCH, 0);
+ addConflict(base, ours, theirs);
mergeResults.put(tw.getPathString(), result);
return;
}
// No conflict occurred, the file will contain fully merged content.
// The index will be populated with the new merged version.
- Instant lastModified =
- mergedFile == null ? null : nonNullRepo().getFS().lastModifiedInstant(mergedFile);
+ Instant lastModified = mergedFile == null ? null
+ : nonNullRepo().getFS().lastModifiedInstant(mergedFile);
// Set the mode for the new content. Fall back to REGULAR_FILE if
// we can't merge modes of OURS and THEIRS.
int newMode = mergeFileModes(tw.getRawMode(0), tw.getRawMode(1),
tw.getRawMode(2));
FileMode mode = newMode == FileMode.MISSING.getBits()
? FileMode.REGULAR_FILE : FileMode.fromBits(newMode);
- workTreeUpdater.insertToIndex(rawMerged.openInputStream(), tw.getPathString().getBytes(UTF_8), mode,
- DirCacheEntry.STAGE_0, lastModified, (int) rawMerged.length(),
+ workTreeUpdater.insertToIndex(rawMerged.openInputStream(),
+ tw.getPathString().getBytes(UTF_8), mode,
+ DirCacheEntry.STAGE_0, lastModified,
+ (int) rawMerged.length(),
attributes.get(Constants.ATTR_MERGE));
} finally {
if (rawMerged != null) {