blob: 82a0095465bab0d3ab0ba38134280319d83c74f6 [file] [log] [blame]
/*
* Copyright 2000-2009 JetBrains s.r.o.
*
* 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 git4idea.merge;
import com.intellij.openapi.diagnostic.Logger;
import com.intellij.openapi.project.Project;
import com.intellij.openapi.vcs.FilePath;
import com.intellij.openapi.vcs.VcsException;
import com.intellij.openapi.vcs.history.VcsRevisionNumber;
import com.intellij.openapi.vcs.merge.MergeData;
import com.intellij.openapi.vcs.merge.MergeProvider;
import com.intellij.openapi.vcs.merge.MergeProvider2;
import com.intellij.openapi.vcs.merge.MergeSession;
import com.intellij.openapi.vfs.VirtualFile;
import com.intellij.util.ArrayUtil;
import com.intellij.util.containers.ContainerUtil;
import com.intellij.util.ui.ColumnInfo;
import com.intellij.vcsUtil.VcsFileUtil;
import com.intellij.vcsUtil.VcsRunnable;
import com.intellij.vcsUtil.VcsUtil;
import git4idea.GitFileRevision;
import git4idea.GitRevisionNumber;
import git4idea.GitUtil;
import git4idea.commands.GitCommand;
import git4idea.commands.GitSimpleHandler;
import git4idea.i18n.GitBundle;
import git4idea.repo.GitRepository;
import git4idea.util.GitFileUtils;
import git4idea.util.StringScanner;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* Merge-changes provider for Git, used by IDEA internal 3-way merge tool
*/
public class GitMergeProvider implements MergeProvider2 {
private static final int ORIGINAL_REVISION_NUM = 1; // common parent
private static final int YOURS_REVISION_NUM = 2; // file content on the local branch: "Yours"
private static final int THEIRS_REVISION_NUM = 3; // remote file content: "Theirs"
private static final Logger LOG = Logger.getInstance(GitMergeProvider.class);
@NotNull private final Project myProject;
/**
* If true the merge provider has a reverse meaning, i. e. yours and theirs are swapped.
* It should be used when conflict is resolved after rebase or unstash.
*/
@NotNull private final Set<VirtualFile> myReverseRoots;
private enum ReverseRequest {
REVERSE,
FORWARD,
DETECT
}
private GitMergeProvider(@NotNull Project project, @NotNull Set<VirtualFile> reverseRoots) {
myProject = project;
myReverseRoots = reverseRoots;
}
public GitMergeProvider(@NotNull Project project, boolean reverse) {
this(project, findReverseRoots(project, reverse ? ReverseRequest.REVERSE : ReverseRequest.FORWARD));
}
@NotNull
public static MergeProvider detect(@NotNull Project project) {
return new GitMergeProvider(project, findReverseRoots(project, ReverseRequest.DETECT));
}
@NotNull
private static Set<VirtualFile> findReverseRoots(@NotNull Project project, @NotNull ReverseRequest reverseOrDetect) {
Set<VirtualFile> reverseMap = ContainerUtil.newHashSet();
for (GitRepository repository : GitUtil.getRepositoryManager(project).getRepositories()) {
boolean reverse;
if (reverseOrDetect == ReverseRequest.DETECT) {
reverse = repository.getState().equals(GitRepository.State.REBASING);
}
else {
reverse = reverseOrDetect == ReverseRequest.REVERSE;
}
if (reverse) {
reverseMap.add(repository.getRoot());
}
}
return reverseMap;
}
@NotNull
public MergeData loadRevisions(final VirtualFile file) throws VcsException {
final MergeData mergeData = new MergeData();
if (file == null) return mergeData;
final VirtualFile root = GitUtil.getGitRoot(file);
final FilePath path = VcsUtil.getFilePath(file.getPath());
VcsRunnable runnable = new VcsRunnable() {
@SuppressWarnings({"ConstantConditions"})
public void run() throws VcsException {
GitFileRevision original = new GitFileRevision(myProject, path, new GitRevisionNumber(":" + ORIGINAL_REVISION_NUM));
GitFileRevision current = new GitFileRevision(myProject, path, new GitRevisionNumber(":" + yoursRevision(root)));
GitFileRevision last = new GitFileRevision(myProject, path, new GitRevisionNumber(":" + theirsRevision(root)));
try {
try {
mergeData.ORIGINAL = original.getContent();
}
catch (Exception ex) {
/// unable to load original revision, use the current instead
/// This could happen in case if rebasing.
mergeData.ORIGINAL = file.contentsToByteArray();
}
mergeData.CURRENT = loadRevisionCatchingErrors(current);
mergeData.LAST = loadRevisionCatchingErrors(last);
mergeData.LAST_REVISION_NUMBER = findLastRevisionNumber(root);
}
catch (IOException e) {
throw new IllegalStateException("Failed to load file content", e);
}
}
};
VcsUtil.runVcsProcessWithProgress(runnable, GitBundle.message("merge.load.files"), false, myProject);
return mergeData;
}
@Nullable
private VcsRevisionNumber findLastRevisionNumber(@NotNull VirtualFile root) {
if (myReverseRoots.contains(root)) {
return resolveHead(root);
}
else {
try {
return GitRevisionNumber.resolve(myProject, root, "MERGE_HEAD");
}
catch (VcsException e) {
LOG.info("Couldn't resolve the MERGE_HEAD in " + root, e); // this may be not a bug, just cherry-pick
try {
return GitRevisionNumber.resolve(myProject, root, "CHERRY_PICK_HEAD");
}
catch (VcsException e1) {
LOG.info("Couldn't resolve neither MERGE_HEAD, nor the CHERRY_PICK_HEAD in " + root, e1);
// for now, we don't know: maybe it is a conflicted file from rebase => then resolve the head.
return resolveHead(root);
}
}
}
}
@Nullable
private GitRevisionNumber resolveHead(@NotNull VirtualFile root) {
try {
return GitRevisionNumber.resolve(myProject, root, "HEAD");
}
catch (VcsException e) {
LOG.error("Couldn't resolve the HEAD in " + root, e);
return null;
}
}
private static byte[] loadRevisionCatchingErrors(@NotNull GitFileRevision revision) throws VcsException, IOException {
try {
return revision.getContent();
} catch (VcsException e) {
String m = e.getMessage().trim();
if (m.startsWith("fatal: ambiguous argument ")
|| (m.startsWith("fatal: Path '") && m.contains("' exists on disk, but not in '"))
|| (m.contains("is in the index, but not at stage "))) {
return ArrayUtil.EMPTY_BYTE_ARRAY;
}
else {
throw e;
}
}
}
/**
* @return number for "yours" revision (taking {@code reverse} flag in account)
* @param root
*/
private int yoursRevision(@NotNull VirtualFile root) {
return myReverseRoots.contains(root) ? THEIRS_REVISION_NUM : YOURS_REVISION_NUM;
}
/**
* @return number for "theirs" revision (taking {@code reverse} flag in account)
* @param root
*/
private int theirsRevision(@NotNull VirtualFile root) {
return myReverseRoots.contains(root) ? YOURS_REVISION_NUM : THEIRS_REVISION_NUM;
}
public void conflictResolvedForFile(VirtualFile file) {
if (file == null) return;
try {
GitFileUtils.addFiles(myProject, GitUtil.getGitRoot(file), file);
}
catch (VcsException e) {
LOG.error("Confirming conflict resolution failed", e);
}
}
public boolean isBinary(@NotNull VirtualFile file) {
return file.getFileType().isBinary();
}
@NotNull
public MergeSession createMergeSession(List<VirtualFile> files) {
return new MyMergeSession(files);
}
/**
* The conflict descriptor
*/
private static class Conflict {
VirtualFile myFile;
VirtualFile myRoot;
Status myStatusTheirs;
Status myStatusYours;
enum Status {
MODIFIED, // modified on the branch
DELETED // deleted on the branch
}
}
/**
* The merge session, it queries conflict information.
*/
private class MyMergeSession implements MergeSession {
Map<VirtualFile, Conflict> myConflicts = new HashMap<VirtualFile, Conflict>();
MyMergeSession(List<VirtualFile> filesToMerge) {
// get conflict type by the file
try {
for (Map.Entry<VirtualFile, List<VirtualFile>> e : GitUtil.sortFilesByGitRoot(filesToMerge).entrySet()) {
Map<String, Conflict> cs = new HashMap<String, Conflict>();
VirtualFile root = e.getKey();
List<VirtualFile> files = e.getValue();
GitSimpleHandler h = new GitSimpleHandler(myProject, root, GitCommand.LS_FILES);
h.setStdoutSuppressed(true);
h.setSilent(true);
h.addParameters("--exclude-standard", "--unmerged", "-t", "-z");
h.endOptions();
String output = h.run();
StringScanner s = new StringScanner(output);
while (s.hasMoreData()) {
if (!"M".equals(s.spaceToken())) {
s.boundedToken('\u0000');
continue;
}
s.spaceToken(); // permissions
s.spaceToken(); // commit hash
int source = Integer.parseInt(s.tabToken());
String file = s.boundedToken('\u0000');
Conflict c = cs.get(file);
if (c == null) {
c = new Conflict();
c.myRoot = root;
cs.put(file, c);
}
if (source == theirsRevision(root)) {
c.myStatusTheirs = Conflict.Status.MODIFIED;
}
else if (source == yoursRevision(root)) {
c.myStatusYours = Conflict.Status.MODIFIED;
}
else if (source != ORIGINAL_REVISION_NUM) {
throw new IllegalStateException("Unknown revision " + source + " for the file: " + file);
}
}
for (VirtualFile f : files) {
String path = VcsFileUtil.relativePath(root, f);
Conflict c = cs.get(path);
LOG.assertTrue(c != null, String.format("The conflict not found for the file: %s(%s)%nFull ls-files output: %n%s",
f.getPath(), path, output));
c.myFile = f;
if (c.myStatusTheirs == null) {
c.myStatusTheirs = Conflict.Status.DELETED;
}
if (c.myStatusYours == null) {
c.myStatusYours = Conflict.Status.DELETED;
}
myConflicts.put(f, c);
}
}
}
catch (VcsException ex) {
throw new IllegalStateException("The git operation should not fail in this context", ex);
}
}
public ColumnInfo[] getMergeInfoColumns() {
return new ColumnInfo[]{new StatusColumn(false), new StatusColumn(true)};
}
public boolean canMerge(VirtualFile file) {
Conflict c = myConflicts.get(file);
return c != null;
}
public void conflictResolvedForFile(VirtualFile file, Resolution resolution) {
Conflict c = myConflicts.get(file);
assert c != null : "Conflict was not loaded for the file: " + file.getPath();
try {
Conflict.Status status;
switch (resolution) {
case AcceptedTheirs:
status = c.myStatusTheirs;
break;
case AcceptedYours:
status = c.myStatusYours;
break;
case Merged:
status = Conflict.Status.MODIFIED;
break;
default:
throw new IllegalArgumentException("Unsupported resolution for unmergable files(" + file.getPath() + "): " + resolution);
}
switch (status) {
case MODIFIED:
GitFileUtils.addFiles(myProject, c.myRoot, file);
break;
case DELETED:
GitFileUtils.deleteFiles(myProject, c.myRoot, file);
break;
default:
throw new IllegalArgumentException("Unsupported status(" + file.getPath() + "): " + status);
}
}
catch (VcsException e) {
LOG.error("Unexpected exception during the git operation (" + file.getPath() + ")", e);
}
}
/**
* The column shows either "yours" or "theirs" status
*/
class StatusColumn extends ColumnInfo<VirtualFile, String> {
/**
* if false, "yours" status is displayed, otherwise "theirs"
*/
private final boolean myIsTheirs;
public StatusColumn(boolean isTheirs) {
super(isTheirs ? GitBundle.message("merge.tool.column.theirs.status") : GitBundle.message("merge.tool.column.yours.status"));
myIsTheirs = isTheirs;
}
public String valueOf(VirtualFile file) {
Conflict c = myConflicts.get(file);
assert c != null : "No conflict for the file " + file;
Conflict.Status s = myIsTheirs ? c.myStatusTheirs : c.myStatusYours;
switch (s) {
case MODIFIED:
return GitBundle.message("merge.tool.column.status.modified");
case DELETED:
return GitBundle.message("merge.tool.column.status.deleted");
default:
throw new IllegalStateException("Unknown status " + s + " for file " + file.getPath());
}
}
@Override
public String getMaxStringValue() {
return GitBundle.message("merge.tool.column.status.modified");
}
@Override
public int getAdditionalWidth() {
return 10;
}
}
}
}