View Javadoc
1   /*
2    * Copyright (C) 2018-2022, Andre Bossert <andre.bossert@siemens.com>
3    * Copyright (C) 2019, Tim Neumann <tim.neumann@advantest.com>
4    *
5    * This program and the accompanying materials are made available under the
6    * terms of the Eclipse Distribution License v. 1.0 which is available at
7    * https://www.eclipse.org/org/documents/edl-v10.php.
8    *
9    * SPDX-License-Identifier: BSD-3-Clause
10   */
11  package org.eclipse.jgit.internal.diffmergetool;
12  
13  import java.io.File;
14  import java.io.IOException;
15  import java.nio.file.Files;
16  import java.nio.file.Path;
17  import java.nio.file.Paths;
18  import java.nio.file.StandardCopyOption;
19  import java.util.ArrayList;
20  import java.util.Collections;
21  import java.util.LinkedHashSet;
22  import java.util.Map;
23  import java.util.Map.Entry;
24  import java.util.Optional;
25  import java.util.Set;
26  import java.util.TreeMap;
27  
28  import org.eclipse.jgit.internal.JGitText;
29  import org.eclipse.jgit.internal.diffmergetool.FileElement.Type;
30  import org.eclipse.jgit.lib.Repository;
31  import org.eclipse.jgit.lib.StoredConfig;
32  import org.eclipse.jgit.lib.internal.BooleanTriState;
33  import org.eclipse.jgit.treewalk.TreeWalk;
34  import org.eclipse.jgit.util.FS;
35  import org.eclipse.jgit.util.StringUtils;
36  import org.eclipse.jgit.util.FS.ExecutionResult;
37  
38  /**
39   * Manages merge tools.
40   */
41  public class MergeTools {
42  
43  	private final FS fs;
44  
45  	private final File gitDir;
46  
47  	private final File workTree;
48  
49  	private final MergeToolConfig config;
50  
51  	private final Repository repo;
52  
53  	private final Map<String, ExternalMergeTool> predefinedTools;
54  
55  	private final Map<String, ExternalMergeTool> userDefinedTools;
56  
57  	/**
58  	 * Creates the external merge-tools manager for given repository.
59  	 *
60  	 * @param repo
61  	 *            the repository
62  	 */
63  	public MergeTools(Repository repo) {
64  		this(repo, repo.getConfig());
65  	}
66  
67  	/**
68  	 * Creates the external diff-tools manager for given configuration.
69  	 *
70  	 * @param config
71  	 *            the git configuration
72  	 */
73  	public MergeTools(StoredConfig config) {
74  		this(null, config);
75  	}
76  
77  	private MergeTools(Repository repo, StoredConfig config) {
78  		this.repo = repo;
79  		this.config = config.get(MergeToolConfig.KEY);
80  		this.gitDir = repo == null ? null : repo.getDirectory();
81  		this.fs = repo == null ? FS.DETECTED : repo.getFS();
82  		this.workTree = repo == null ? null : repo.getWorkTree();
83  		predefinedTools = setupPredefinedTools();
84  		userDefinedTools = setupUserDefinedTools(predefinedTools);
85  	}
86  
87  	/**
88  	 * Merge two versions of a file with optional base file.
89  	 *
90  	 * @param localFile
91  	 *            The local/left version of the file.
92  	 * @param remoteFile
93  	 *            The remote/right version of the file.
94  	 * @param mergedFile
95  	 *            The file for the result.
96  	 * @param baseFile
97  	 *            The base version of the file. May be null.
98  	 * @param tempDir
99  	 *            The tmepDir used for the files. May be null.
100 	 * @param toolName
101 	 *            Optionally the name of the tool to use. If not given the
102 	 *            default tool will be used.
103 	 * @param prompt
104 	 *            Optionally a flag whether to prompt the user before compare.
105 	 *            If not given the default will be used.
106 	 * @param gui
107 	 *            A flag whether to prefer a gui tool.
108 	 * @param promptHandler
109 	 *            The handler to use when needing to prompt the user if he wants
110 	 *            to continue.
111 	 * @param noToolHandler
112 	 *            The handler to use when needing to inform the user, that no
113 	 *            tool is configured.
114 	 * @return the optional result of executing the tool if it was executed
115 	 * @throws ToolException
116 	 *             when the tool fails
117 	 */
118 	public Optional<ExecutionResult> merge(FileElement localFile,
119 			FileElement remoteFile, FileElement mergedFile,
120 			FileElement baseFile, File tempDir, Optional<String> toolName,
121 			BooleanTriState prompt, boolean gui,
122 			PromptContinueHandler promptHandler,
123 			InformNoToolHandler noToolHandler) throws ToolException {
124 
125 		String toolNameToUse;
126 
127 		if (toolName == null) {
128 			throw new ToolException(JGitText.get().diffToolNullError);
129 		}
130 
131 		if (toolName.isPresent()) {
132 			toolNameToUse = toolName.get();
133 		} else {
134 			toolNameToUse = getDefaultToolName(gui);
135 
136 			if (StringUtils.isEmptyOrNull(toolNameToUse)) {
137 				noToolHandler.inform(new ArrayList<>(predefinedTools.keySet()));
138 				toolNameToUse = getFirstAvailableTool();
139 			}
140 		}
141 
142 		if (StringUtils.isEmptyOrNull(toolNameToUse)) {
143 			throw new ToolException(JGitText.get().diffToolNotGivenError);
144 		}
145 
146 		boolean doPrompt;
147 		if (prompt != BooleanTriState.UNSET) {
148 			doPrompt = prompt == BooleanTriState.TRUE;
149 		} else {
150 			doPrompt = isInteractive();
151 		}
152 
153 		if (doPrompt) {
154 			if (!promptHandler.prompt(toolNameToUse)) {
155 				return Optional.empty();
156 			}
157 		}
158 
159 		ExternalMergeTool tool = getTool(toolNameToUse);
160 		if (tool == null) {
161 			throw new ToolException(
162 					"External merge tool is not defined: " + toolNameToUse); //$NON-NLS-1$
163 		}
164 
165 		return Optional.of(merge(localFile, remoteFile, mergedFile, baseFile,
166 				tempDir, tool));
167 	}
168 
169 	/**
170 	 * Merge two versions of a file with optional base file.
171 	 *
172 	 * @param localFile
173 	 *            the local file element
174 	 * @param remoteFile
175 	 *            the remote file element
176 	 * @param mergedFile
177 	 *            the merged file element
178 	 * @param baseFile
179 	 *            the base file element (can be null)
180 	 * @param tempDir
181 	 *            the temporary directory (needed for backup and auto-remove,
182 	 *            can be null)
183 	 * @param tool
184 	 *            the selected tool
185 	 * @return the execution result from tool
186 	 * @throws ToolException
187 	 */
188 	public ExecutionResult merge(FileElement localFile, FileElement remoteFile,
189 			FileElement mergedFile, FileElement baseFile, File tempDir,
190 			ExternalMergeTool tool) throws ToolException {
191 		FileElement backup = null;
192 		ExecutionResult result = null;
193 		try {
194 			// create additional backup file (copy worktree file)
195 			backup = createBackupFile(mergedFile,
196 					tempDir != null ? tempDir : workTree);
197 			// prepare the command (replace the file paths)
198 			String command = ExternalToolUtils.prepareCommand(
199 					tool.getCommand(baseFile != null), localFile, remoteFile,
200 					mergedFile, baseFile);
201 			// prepare the environment
202 			Map<String, String> env = ExternalToolUtils.prepareEnvironment(
203 					gitDir, localFile, remoteFile, mergedFile, baseFile);
204 			boolean trust = tool.getTrustExitCode() == BooleanTriState.TRUE;
205 			// execute the tool
206 			CommandExecutor cmdExec = new CommandExecutor(fs, trust);
207 			result = cmdExec.run(command, workTree, env);
208 			// keep backup as .orig file
209 			if (backup != null) {
210 				keepBackupFile(mergedFile.getPath(), backup);
211 			}
212 			return result;
213 		} catch (IOException | InterruptedException e) {
214 			throw new ToolException(e);
215 		} finally {
216 			// always delete backup file (ignore that it was may be already
217 			// moved to keep-backup file)
218 			if (backup != null) {
219 				backup.cleanTemporaries();
220 			}
221 			// if the tool returns an error and keepTemporaries is set to true,
222 			// then these temporary files will be preserved
223 			if (!((result == null) && config.isKeepTemporaries())) {
224 				// delete the files
225 				localFile.cleanTemporaries();
226 				remoteFile.cleanTemporaries();
227 				if (baseFile != null) {
228 					baseFile.cleanTemporaries();
229 				}
230 				// delete temporary directory if needed
231 				if (config.isWriteToTemp() && (tempDir != null)
232 						&& tempDir.exists()) {
233 					tempDir.delete();
234 				}
235 			}
236 		}
237 	}
238 
239 	private FileElement createBackupFile(FileElement from, File toParentDir)
240 			throws IOException {
241 		FileElement backup = null;
242 		Path path = Paths.get(from.getPath());
243 		if (Files.exists(path)) {
244 			backup = new FileElement(from.getPath(), Type.BACKUP);
245 			Files.copy(path, backup.createTempFile(toParentDir).toPath(),
246 					StandardCopyOption.REPLACE_EXISTING);
247 		}
248 		return backup;
249 	}
250 
251 	/**
252 	 * Create temporary directory.
253 	 *
254 	 * @return the created temporary directory if (mergetol.writeToTemp == true)
255 	 *         or null if not configured or false.
256 	 * @throws IOException
257 	 */
258 	public File createTempDirectory() throws IOException {
259 		return config.isWriteToTemp()
260 				? Files.createTempDirectory("jgit-mergetool-").toFile() //$NON-NLS-1$
261 				: null;
262 	}
263 
264 	/**
265 	 * Get user defined tool names.
266 	 *
267 	 * @return the user defined tool names
268 	 */
269 	public Set<String> getUserDefinedToolNames() {
270 		return userDefinedTools.keySet();
271 	}
272 
273 	/**
274 	 * @return the predefined tool names
275 	 */
276 	public Set<String> getPredefinedToolNames() {
277 		return predefinedTools.keySet();
278 	}
279 
280 	/**
281 	 * Get all tool names.
282 	 *
283 	 * @return the all tool names (default or available tool name is the first
284 	 *         in the set)
285 	 */
286 	public Set<String> getAllToolNames() {
287 		String defaultName = getDefaultToolName(false);
288 		if (defaultName == null) {
289 			defaultName = getFirstAvailableTool();
290 		}
291 		return ExternalToolUtils.createSortedToolSet(defaultName,
292 				getUserDefinedToolNames(), getPredefinedToolNames());
293 	}
294 
295 	/**
296 	 * Provides {@link Optional} with the name of an external merge tool if
297 	 * specified in git configuration for a path.
298 	 *
299 	 * The formed git configuration results from global rules as well as merged
300 	 * rules from info and worktree attributes.
301 	 *
302 	 * Triggers {@link TreeWalk} until specified path found in the tree.
303 	 *
304 	 * @param path
305 	 *            path to the node in repository to parse git attributes for
306 	 * @return name of the difftool if set
307 	 * @throws ToolException
308 	 */
309 	public Optional<String> getExternalToolFromAttributes(final String path)
310 			throws ToolException {
311 		return ExternalToolUtils.getExternalToolFromAttributes(repo, path,
312 				ExternalToolUtils.KEY_MERGE_TOOL);
313 	}
314 
315 	/**
316 	 * Checks the availability of the predefined tools in the system.
317 	 *
318 	 * @return set of predefined available tools
319 	 */
320 	public Set<String> getPredefinedAvailableTools() {
321 		Map<String, ExternalMergeTool> defTools = getPredefinedTools(true);
322 		Set<String> availableTools = new LinkedHashSet<>();
323 		for (Entry<String, ExternalMergeTool> elem : defTools.entrySet()) {
324 			if (elem.getValue().isAvailable()) {
325 				availableTools.add(elem.getKey());
326 			}
327 		}
328 		return availableTools;
329 	}
330 
331 	/**
332 	 * @return the user defined tools
333 	 */
334 	public Map<String, ExternalMergeTool> getUserDefinedTools() {
335 		return Collections.unmodifiableMap(userDefinedTools);
336 	}
337 
338 	/**
339 	 * Get predefined tools map.
340 	 *
341 	 * @param checkAvailability
342 	 *            true: for checking if tools can be executed; ATTENTION: this
343 	 *            check took some time, do not execute often (store the map for
344 	 *            other actions); false: availability is NOT checked:
345 	 *            isAvailable() returns default false is this case!
346 	 * @return the predefined tools with optionally checked availability (long
347 	 *         running operation)
348 	 */
349 	public Map<String, ExternalMergeTool> getPredefinedTools(
350 			boolean checkAvailability) {
351 		if (checkAvailability) {
352 			for (ExternalMergeTool tool : predefinedTools.values()) {
353 				PreDefinedMergeTool predefTool = (PreDefinedMergeTool) tool;
354 				predefTool.setAvailable(ExternalToolUtils.isToolAvailable(fs,
355 						gitDir, workTree, predefTool.getPath()));
356 			}
357 		}
358 		return Collections.unmodifiableMap(predefinedTools);
359 	}
360 
361 	/**
362 	 * Get first available tool name.
363 	 *
364 	 * @return the name of first available predefined tool or null
365 	 */
366 	public String getFirstAvailableTool() {
367 		String name = null;
368 		for (ExternalMergeTool tool : predefinedTools.values()) {
369 			if (ExternalToolUtils.isToolAvailable(fs, gitDir, workTree,
370 					tool.getPath())) {
371 				name = tool.getName();
372 				break;
373 			}
374 		}
375 		return name;
376 	}
377 
378 	/**
379 	 * Is interactive merge (prompt enabled) ?
380 	 *
381 	 * @return is interactive (config prompt enabled) ?
382 	 */
383 	public boolean isInteractive() {
384 		return config.isPrompt();
385 	}
386 
387 	/**
388 	 * Get the default (gui-)tool name.
389 	 *
390 	 * @param gui
391 	 *            use the diff.guitool setting ?
392 	 * @return the default tool name
393 	 */
394 	public String getDefaultToolName(boolean gui) {
395 		return gui ? config.getDefaultGuiToolName()
396 				: config.getDefaultToolName();
397 	}
398 
399 	private ExternalMergeTool getTool(final String name) {
400 		ExternalMergeTool tool = userDefinedTools.get(name);
401 		if (tool == null) {
402 			tool = predefinedTools.get(name);
403 		}
404 		return tool;
405 	}
406 
407 	private void keepBackupFile(String mergedFilePath, FileElement backup)
408 			throws IOException {
409 		if (config.isKeepBackup()) {
410 			Path backupPath = backup.getFile().toPath();
411 			Files.move(backupPath,
412 					backupPath.resolveSibling(
413 							Paths.get(mergedFilePath).getFileName() + ".orig"), //$NON-NLS-1$
414 					StandardCopyOption.REPLACE_EXISTING);
415 		}
416 	}
417 
418 	private Map<String, ExternalMergeTool> setupPredefinedTools() {
419 		Map<String, ExternalMergeTool> tools = new TreeMap<>();
420 		for (CommandLineMergeTool tool : CommandLineMergeTool.values()) {
421 			tools.put(tool.name(), new PreDefinedMergeTool(tool));
422 		}
423 		return tools;
424 	}
425 
426 	private Map<String, ExternalMergeTool> setupUserDefinedTools(
427 			Map<String, ExternalMergeTool> predefTools) {
428 		Map<String, ExternalMergeTool> tools = new TreeMap<>();
429 		Map<String, ExternalMergeTool> userTools = config.getTools();
430 		for (String name : userTools.keySet()) {
431 			ExternalMergeTool userTool = userTools.get(name);
432 			// if mergetool.<name>.cmd is defined we have user defined tool
433 			if (userTool.getCommand() != null) {
434 				tools.put(name, userTool);
435 			} else if (userTool.getPath() != null) {
436 				// if mergetool.<name>.path is defined we just overload the path
437 				// of predefined tool
438 				PreDefinedMergeTool predefTool = (PreDefinedMergeTool) predefTools
439 						.get(name);
440 				if (predefTool != null) {
441 					predefTool.setPath(userTool.getPath());
442 					if (userTool.getTrustExitCode() != BooleanTriState.UNSET) {
443 						predefTool
444 								.setTrustExitCode(userTool.getTrustExitCode());
445 					}
446 				}
447 			}
448 		}
449 		return tools;
450 	}
451 
452 }