/* * Copyright (C) 2018-2022, Andre Bossert * Copyright (C) 2019, Tim Neumann * * 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.pgm; import static org.eclipse.jgit.treewalk.TreeWalk.OperationType.CHECKOUT_OP; import java.io.BufferedReader; import java.io.File; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStreamReader; import java.nio.charset.Charset; import java.text.MessageFormat; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.TreeMap; import org.eclipse.jgit.api.Git; import org.eclipse.jgit.api.Status; import org.eclipse.jgit.api.StatusCommand; import org.eclipse.jgit.api.errors.GitAPIException; import org.eclipse.jgit.diff.ContentSource; import org.eclipse.jgit.dircache.DirCache; import org.eclipse.jgit.dircache.DirCacheCheckout; import org.eclipse.jgit.dircache.DirCacheCheckout.CheckoutMetadata; import org.eclipse.jgit.dircache.DirCacheEntry; import org.eclipse.jgit.dircache.DirCacheIterator; import org.eclipse.jgit.errors.NoWorkTreeException; import org.eclipse.jgit.errors.RevisionSyntaxException; import org.eclipse.jgit.internal.diffmergetool.ExternalMergeTool; import org.eclipse.jgit.internal.diffmergetool.FileElement; import org.eclipse.jgit.internal.diffmergetool.MergeTools; import org.eclipse.jgit.internal.diffmergetool.ToolException; import org.eclipse.jgit.lib.Constants; import org.eclipse.jgit.lib.CoreConfig.EolStreamType; import org.eclipse.jgit.lib.IndexDiff.StageState; import org.eclipse.jgit.lib.ObjectId; import org.eclipse.jgit.lib.Repository; import org.eclipse.jgit.lib.internal.BooleanTriState; import org.eclipse.jgit.pgm.internal.CLIText; import org.eclipse.jgit.revwalk.RevWalk; import org.eclipse.jgit.treewalk.TreeWalk; import org.eclipse.jgit.treewalk.WorkingTreeOptions; import org.eclipse.jgit.treewalk.filter.PathFilterGroup; import org.eclipse.jgit.util.FS.ExecutionResult; import org.eclipse.jgit.util.SystemReader; import org.kohsuke.args4j.Argument; import org.kohsuke.args4j.Option; import org.kohsuke.args4j.spi.RestOfArgumentsHandler; @Command(name = "mergetool", common = true, usage = "usage_MergeTool") class MergeTool extends TextBuiltin { private MergeTools mergeTools; private Optional toolName = Optional.empty(); @Option(name = "--tool", aliases = { "-t" }, metaVar = "metaVar_tool", usage = "usage_ToolForMerge") void setToolName(String name) { toolName = Optional.of(name); } private BooleanTriState prompt = BooleanTriState.UNSET; @Option(name = "--prompt", usage = "usage_prompt") void setPrompt(@SuppressWarnings("unused") boolean on) { prompt = BooleanTriState.TRUE; } @Option(name = "--no-prompt", aliases = { "-y" }, usage = "usage_noPrompt") void noPrompt(@SuppressWarnings("unused") boolean on) { prompt = BooleanTriState.FALSE; } @Option(name = "--tool-help", usage = "usage_toolHelp") private boolean toolHelp; private boolean gui = false; @Option(name = "--gui", aliases = { "-g" }, usage = "usage_MergeGuiTool") void setGui(@SuppressWarnings("unused") boolean on) { gui = true; } @Option(name = "--no-gui", usage = "usage_noGui") void noGui(@SuppressWarnings("unused") boolean on) { gui = false; } @Argument(required = false, index = 0, metaVar = "metaVar_paths") @Option(name = "--", metaVar = "metaVar_paths", handler = RestOfArgumentsHandler.class) protected List filterPaths; private BufferedReader inputReader; @Override protected void init(Repository repository, String gitDir) { super.init(repository, gitDir); mergeTools = new MergeTools(repository); inputReader = new BufferedReader( new InputStreamReader(ins, SystemReader.getInstance().getDefaultCharset())); } enum MergeResult { SUCCESSFUL, FAILED, ABORTED } @Override protected void run() { try { if (toolHelp) { showToolHelp(); } else { // get the changed files Map files = getFiles(); if (files.size() > 0) { merge(files); } else { outw.println(CLIText.get().mergeToolNoFiles); } } outw.flush(); } catch (Exception e) { throw die(e.getMessage(), e); } } private void informUserNoTool(List tools) { try { StringBuilder toolNames = new StringBuilder(); for (String name : tools) { toolNames.append(name + " "); //$NON-NLS-1$ } outw.println(MessageFormat .format(CLIText.get().mergeToolPromptToolName, toolNames)); outw.flush(); } catch (IOException e) { throw new IllegalStateException("Cannot output text", e); //$NON-NLS-1$ } } private void merge(Map files) throws Exception { // sort file names List mergedFilePaths = new ArrayList<>(files.keySet()); Collections.sort(mergedFilePaths); // show the files StringBuilder mergedFiles = new StringBuilder(); for (String mergedFilePath : mergedFilePaths) { mergedFiles.append(MessageFormat.format("{0}\n", mergedFilePath)); //$NON-NLS-1$ } outw.println(MessageFormat.format(CLIText.get().mergeToolMerging, mergedFiles)); outw.flush(); boolean showPrompt = mergeTools.isInteractive(); if (prompt != BooleanTriState.UNSET) { showPrompt = prompt == BooleanTriState.TRUE; } // merge the files MergeResult mergeResult = MergeResult.SUCCESSFUL; for (String mergedFilePath : mergedFilePaths) { // if last merge failed... if (mergeResult == MergeResult.FAILED) { // check if user wants to continue if (showPrompt && !isContinueUnresolvedPaths()) { mergeResult = MergeResult.ABORTED; } } // aborted ? if (mergeResult == MergeResult.ABORTED) { break; } // get file stage state and merge StageState fileState = files.get(mergedFilePath); if (fileState == StageState.BOTH_MODIFIED) { mergeResult = mergeModified(mergedFilePath, showPrompt); } else if ((fileState == StageState.DELETED_BY_US) || (fileState == StageState.DELETED_BY_THEM)) { mergeResult = mergeDeleted(mergedFilePath, fileState == StageState.DELETED_BY_US); } else { outw.println(MessageFormat.format( CLIText.get().mergeToolUnknownConflict, mergedFilePath)); mergeResult = MergeResult.ABORTED; } } } private MergeResult mergeModified(String mergedFilePath, boolean showPrompt) throws Exception { outw.println(MessageFormat.format(CLIText.get().mergeToolNormalConflict, mergedFilePath)); outw.flush(); boolean isMergeSuccessful = true; ContentSource baseSource = ContentSource.create(db.newObjectReader()); ContentSource localSource = ContentSource.create(db.newObjectReader()); ContentSource remoteSource = ContentSource.create(db.newObjectReader()); // temporary directory if mergetool.writeToTemp == true File tempDir = mergeTools.createTempDirectory(); // the parent directory for temp files (can be same as tempDir or just // the worktree dir) File tempFilesParent = tempDir != null ? tempDir : db.getWorkTree(); try { FileElement base = null; FileElement local = null; FileElement remote = null; FileElement merged = new FileElement(mergedFilePath, FileElement.Type.MERGED, db.getWorkTree()); DirCache cache = db.readDirCache(); try (RevWalk revWalk = new RevWalk(db); TreeWalk treeWalk = new TreeWalk(db, revWalk.getObjectReader())) { treeWalk.setFilter( PathFilterGroup.createFromStrings(mergedFilePath)); DirCacheIterator cacheIter = new DirCacheIterator(cache); treeWalk.addTree(cacheIter); while (treeWalk.next()) { if (treeWalk.isSubtree()) { treeWalk.enterSubtree(); continue; } final EolStreamType eolStreamType = treeWalk .getEolStreamType(CHECKOUT_OP); final String filterCommand = treeWalk.getFilterCommand( Constants.ATTR_FILTER_TYPE_SMUDGE); WorkingTreeOptions opt = db.getConfig() .get(WorkingTreeOptions.KEY); CheckoutMetadata checkoutMetadata = new CheckoutMetadata( eolStreamType, filterCommand); DirCacheEntry entry = treeWalk .getTree(DirCacheIterator.class).getDirCacheEntry(); if (entry == null) { continue; } ObjectId id = entry.getObjectId(); switch (entry.getStage()) { case DirCacheEntry.STAGE_1: base = new FileElement(mergedFilePath, FileElement.Type.BASE); DirCacheCheckout.getContent(db, mergedFilePath, checkoutMetadata, baseSource.open(mergedFilePath, id), opt, new FileOutputStream( base.createTempFile(tempFilesParent))); break; case DirCacheEntry.STAGE_2: local = new FileElement(mergedFilePath, FileElement.Type.LOCAL); DirCacheCheckout.getContent(db, mergedFilePath, checkoutMetadata, localSource.open(mergedFilePath, id), opt, new FileOutputStream( local.createTempFile(tempFilesParent))); break; case DirCacheEntry.STAGE_3: remote = new FileElement(mergedFilePath, FileElement.Type.REMOTE); DirCacheCheckout.getContent(db, mergedFilePath, checkoutMetadata, remoteSource.open(mergedFilePath, id), opt, new FileOutputStream(remote .createTempFile(tempFilesParent))); break; } } } if ((local == null) || (remote == null)) { throw die(MessageFormat.format(CLIText.get().mergeToolDied, mergedFilePath)); } long modifiedBefore = merged.getFile().lastModified(); try { // TODO: check how to return the exit-code of the // tool to jgit / java runtime ? // int rc =... Optional optionalResult = mergeTools.merge( local, remote, merged, base, tempDir, toolName, prompt, gui, this::promptForLaunch, this::informUserNoTool); if (optionalResult.isPresent()) { ExecutionResult result = optionalResult.get(); Charset defaultCharset = SystemReader.getInstance() .getDefaultCharset(); outw.println(new String(result.getStdout().toByteArray(), defaultCharset)); outw.flush(); errw.println(new String(result.getStderr().toByteArray(), defaultCharset)); errw.flush(); } else { return MergeResult.ABORTED; } } catch (ToolException e) { isMergeSuccessful = false; outw.println(e.getResultStdout()); outw.flush(); errw.println(e.getMessage()); errw.println(MessageFormat.format( CLIText.get().mergeToolMergeFailed, mergedFilePath)); errw.flush(); if (e.isCommandExecutionError()) { throw die(CLIText.get().mergeToolExecutionError, e); } } // if merge was successful check file modified if (isMergeSuccessful) { long modifiedAfter = merged.getFile().lastModified(); if (modifiedBefore == modifiedAfter) { outw.println(MessageFormat.format( CLIText.get().mergeToolFileUnchanged, mergedFilePath)); isMergeSuccessful = !showPrompt || isMergeSuccessful(); } } // if automatically or manually successful // -> add the file to the index if (isMergeSuccessful) { addFile(mergedFilePath); } } finally { baseSource.close(); localSource.close(); remoteSource.close(); } return isMergeSuccessful ? MergeResult.SUCCESSFUL : MergeResult.FAILED; } private MergeResult mergeDeleted(String mergedFilePath, boolean deletedByUs) throws Exception { outw.println(MessageFormat.format(CLIText.get().mergeToolFileUnchanged, mergedFilePath)); if (deletedByUs) { outw.println(CLIText.get().mergeToolDeletedConflictByUs); } else { outw.println(CLIText.get().mergeToolDeletedConflictByThem); } int mergeDecision = getDeletedMergeDecision(); if (mergeDecision == 1) { // add modified file addFile(mergedFilePath); } else if (mergeDecision == -1) { // remove deleted file rmFile(mergedFilePath); } else { return MergeResult.ABORTED; } return MergeResult.SUCCESSFUL; } private void addFile(String fileName) throws Exception { try (Git git = new Git(db)) { git.add().addFilepattern(fileName).call(); } } private void rmFile(String fileName) throws Exception { try (Git git = new Git(db)) { git.rm().addFilepattern(fileName).call(); } } private boolean hasUserAccepted(String message) throws IOException { boolean yes = true; outw.print(message + " "); //$NON-NLS-1$ outw.flush(); BufferedReader br = inputReader; String line = null; while ((line = br.readLine()) != null) { if (line.equalsIgnoreCase("y")) { //$NON-NLS-1$ yes = true; break; } else if (line.equalsIgnoreCase("n")) { //$NON-NLS-1$ yes = false; break; } outw.print(message); outw.flush(); } return yes; } private boolean isContinueUnresolvedPaths() throws IOException { return hasUserAccepted(CLIText.get().mergeToolContinueUnresolvedPaths); } private boolean isMergeSuccessful() throws IOException { return hasUserAccepted(CLIText.get().mergeToolWasMergeSuccessfull); } private boolean promptForLaunch(String toolNamePrompt) { try { boolean launch = true; outw.print(MessageFormat.format(CLIText.get().mergeToolLaunch, toolNamePrompt) + " "); //$NON-NLS-1$ outw.flush(); BufferedReader br = inputReader; String line = null; if ((line = br.readLine()) != null) { if (!line.equalsIgnoreCase("y") && !line.equalsIgnoreCase("")) { //$NON-NLS-1$ //$NON-NLS-2$ launch = false; } } return launch; } catch (IOException e) { throw new IllegalStateException("Cannot output text", e); //$NON-NLS-1$ } } private int getDeletedMergeDecision() throws IOException { int ret = 0; // abort final String message = CLIText.get().mergeToolDeletedMergeDecision + " "; //$NON-NLS-1$ outw.print(message); outw.flush(); BufferedReader br = inputReader; String line = null; while ((line = br.readLine()) != null) { if (line.equalsIgnoreCase("m")) { //$NON-NLS-1$ ret = 1; // modified break; } else if (line.equalsIgnoreCase("d")) { //$NON-NLS-1$ ret = -1; // deleted break; } else if (line.equalsIgnoreCase("a")) { //$NON-NLS-1$ break; } outw.print(message); outw.flush(); } return ret; } private void showToolHelp() throws IOException { Map predefTools = mergeTools .getPredefinedTools(true); StringBuilder availableToolNames = new StringBuilder(); StringBuilder notAvailableToolNames = new StringBuilder(); for (String name : predefTools.keySet()) { if (predefTools.get(name).isAvailable()) { availableToolNames.append(MessageFormat.format("\t\t{0}\n", name)); //$NON-NLS-1$ } else { notAvailableToolNames.append(MessageFormat.format("\t\t{0}\n", name)); //$NON-NLS-1$ } } StringBuilder userToolNames = new StringBuilder(); Map userTools = mergeTools .getUserDefinedTools(); for (String name : userTools.keySet()) { userToolNames.append(MessageFormat.format("\t\t{0}.cmd {1}\n", //$NON-NLS-1$ name, userTools.get(name).getCommand())); } outw.println(MessageFormat.format( CLIText.get().mergeToolHelpSetToFollowing, availableToolNames, userToolNames, notAvailableToolNames)); } private Map getFiles() throws RevisionSyntaxException, NoWorkTreeException, GitAPIException { Map files = new TreeMap<>(); try (Git git = new Git(db)) { StatusCommand statusCommand = git.status(); if (filterPaths != null && filterPaths.size() > 0) { for (String path : filterPaths) { statusCommand.addPath(path); } } Status status = statusCommand.call(); files = status.getConflictingStageState(); } return files; } }