/* * CDDL HEADER START * * The contents of this file are subject to the terms of the * Common Development and Distribution License (the "License"). * You may not use this file except in compliance with the License. * * See LICENSE.txt included in this distribution for the specific * language governing permissions and limitations under the License. * * When distributing Covered Code, include this CDDL HEADER in each * file and include the License file at LICENSE.txt. * If applicable, add the following below this CDDL HEADER, with the * fields enclosed by brackets "[]" replaced with your own identifying * information: Portions Copyright [yyyy] [name of copyright owner] * * CDDL HEADER END */ /* * Copyright (c) 2006, 2017, Oracle and/or its affiliates. All rights reserved. */ package org.opensolaris.opengrok.history; import java.io.BufferedReader; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.File; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.TreeSet; import java.util.logging.Level; import java.util.logging.Logger; import java.util.regex.Matcher; import java.util.regex.Pattern; import org.opensolaris.opengrok.configuration.RuntimeEnvironment; import org.opensolaris.opengrok.logger.LoggerFactory; import org.opensolaris.opengrok.util.Executor; import org.opensolaris.opengrok.web.Util; /** * Access to a Mercurial repository. * */ public class MercurialRepository extends Repository { private static final Logger LOGGER = LoggerFactory.getLogger(MercurialRepository.class); private static final long serialVersionUID = 1L; /** * the property name used to obtain the client command for this repository */ public static final String CMD_PROPERTY_KEY = "org.opensolaris.opengrok.history.Mercurial"; /** * the command to use to access the repository if none was given explicitly */ public static final String CMD_FALLBACK = "hg"; /** * The boolean property and environment variable name to indicate whether * forest-extension in Mercurial adds repositories inside the repositories. */ public static final String NOFOREST_PROPERTY_KEY = "org.opensolaris.opengrok.history.mercurial.disableForest"; static final String CHANGESET = "changeset: "; static final String USER = "user: "; static final String DATE = "date: "; static final String DESCRIPTION = "description: "; static final String FILE_COPIES = "file_copies: "; static final String FILES = "files: "; static final String END_OF_ENTRY = "mercurial_history_end_of_entry"; private static final String TEMPLATE_STUB = CHANGESET + "{rev}:{node|short}\\n" + USER + "{author}\\n" + DATE + "{date|isodate}\\n" + DESCRIPTION + "{desc|strip|obfuscate}\\n"; private static final String FILE_LIST = FILES + "{files}\\n"; /** * Templates for formatting hg log output for files. */ private static final String FILE_TEMPLATE = TEMPLATE_STUB + END_OF_ENTRY + "\\n"; /** * Template for formatting hg log output for directories. */ private static final String DIR_TEMPLATE_RENAMED = TEMPLATE_STUB + FILE_LIST + FILE_COPIES + "{file_copies}\\n" + END_OF_ENTRY + "\\n"; private static final String DIR_TEMPLATE = TEMPLATE_STUB + FILE_LIST + END_OF_ENTRY + "\\n"; /** * Pattern used to extract author/revision from hg annotate. */ private static final Pattern ANNOTATION_PATTERN = Pattern.compile("^\\s*(\\d+):"); private static final Pattern LOG_COPIES_PATTERN = Pattern.compile("^(\\d+):(.*)"); public MercurialRepository() { type = "Mercurial"; datePatterns = new String[]{ "yyyy-MM-dd hh:mm ZZZZ" }; ignoredFiles.add(".hgtags"); ignoredFiles.add(".hgignore"); ignoredDirs.add(".hg"); } /** * Return name of the branch or "default" */ @Override String determineBranch() throws IOException { List<String> cmd = new ArrayList<>(); ensureCommand(CMD_PROPERTY_KEY, CMD_FALLBACK); cmd.add(RepoCommand); cmd.add("branch"); Executor executor = new Executor(cmd, new File(directoryName)); if (executor.exec(false) != 0) { throw new IOException(executor.getErrorString()); } return executor.getOutputString().trim(); } /** * Get an executor to be used for retrieving the history log for the named * file or directory. * * @param file The file or directory to retrieve history for * @param sinceRevision the oldest changeset to return from the executor, or * {@code null} if all changesets should be returned. * For files this does not apply and full history is returned. * @return An Executor ready to be started */ Executor getHistoryLogExecutor(File file, String sinceRevision) throws HistoryException, IOException { String abs = file.getCanonicalPath(); String filename = ""; RuntimeEnvironment env = RuntimeEnvironment.getInstance(); if (abs.length() > directoryName.length()) { filename = abs.substring(directoryName.length() + 1); } List<String> cmd = new ArrayList<>(); ensureCommand(CMD_PROPERTY_KEY, CMD_FALLBACK); cmd.add(RepoCommand); cmd.add("log"); if (file.isDirectory()) { // If this is non-default branch we would like to get the changesets // on that branch and also follow any changesets from the parent branch. if (sinceRevision != null) { cmd.add("-r"); String[] parts = sinceRevision.split(":"); if (parts.length == 2) { cmd.add("reverse(" + parts[0] + "::'" + getBranch() + "')"); } else { throw new HistoryException( "Don't know how to parse changeset identifier: " + sinceRevision); } } else { cmd.add("-r"); cmd.add("reverse(0::'" + getBranch() + "')"); } } else { // For plain files we would like to follow the complete history // (this is necessary for getting the original name in given revision // when handling renamed files) // It is not needed to filter on a branch as 'hg log' will follow // the active branch. // Due to behavior of recent Mercurial versions, it is not possible // to filter the changesets of a file based on revision. // For files this does not matter since if getHistory() is called // for a file, the file has to be renamed so we want its complete history. cmd.add("--follow"); cmd.add(filename); } cmd.add("--template"); if (file.isDirectory()) { cmd.add(env.isHandleHistoryOfRenamedFiles() ? DIR_TEMPLATE_RENAMED : DIR_TEMPLATE); } else { cmd.add(FILE_TEMPLATE); } return new Executor(cmd, new File(directoryName), sinceRevision != null); } /** * Try to get file contents for given revision. * * @param fullpath full pathname of the file * @param rev revision * @return contents of the file in revision rev */ private InputStream getHistoryRev(String fullpath, String rev) { InputStream ret = null; File directory = new File(directoryName); Process process = null; String revision = rev; if (rev.indexOf(':') != -1) { revision = rev.substring(0, rev.indexOf(':')); } try { String filename = fullpath.substring(directoryName.length() + 1); ensureCommand(CMD_PROPERTY_KEY, CMD_FALLBACK); String argv[] = {RepoCommand, "cat", "-r", revision, filename}; process = Runtime.getRuntime().exec(argv, null, directory); ByteArrayOutputStream out = new ByteArrayOutputStream(); byte[] buffer = new byte[32 * 1024]; try (InputStream in = process.getInputStream()) { int len; while ((len = in.read(buffer)) != -1) { if (len > 0) { out.write(buffer, 0, len); } } } /* * If exit value of the process was not 0 then the file did * not exist or internal hg error occured. */ if (process.waitFor() == 0) { ret = new ByteArrayInputStream(out.toByteArray()); } else { ret = null; } } catch (Exception exp) { LOGGER.log(Level.SEVERE, "Failed to get history: {0}", exp.getClass().toString()); } finally { // Clean up zombie-processes... if (process != null) { try { process.exitValue(); } catch (IllegalThreadStateException exp) { // the process is still running??? just kill it.. process.destroy(); } } } return ret; } /** * Get the name of file in given revision. * * @param fullpath file path * @param full_rev_to_find revision number (in the form of * {rev}:{node|short}) * @returns original filename */ private String findOriginalName(String fullpath, String full_rev_to_find) throws IOException { Matcher matcher = LOG_COPIES_PATTERN.matcher(""); String file = fullpath.substring(directoryName.length() + 1); ArrayList<String> argv = new ArrayList<>(); // Extract {rev} from the full revision specification string. String[] rev_array = full_rev_to_find.split(":"); String rev_to_find = rev_array[0]; if (rev_to_find.isEmpty()) { LOGGER.log(Level.SEVERE, "Invalid revision string: {0}", full_rev_to_find); return null; } /* * Get the list of file renames for given file to the specified * revision. We need to get them from the newest to the oldest * so that we can follow the renames down to the revision we are after. */ argv.add(ensureCommand(CMD_PROPERTY_KEY, CMD_FALLBACK)); argv.add("log"); argv.add("--follow"); /* * hg log --follow -r behavior has changed since Mercurial 3.4 * so filtering the changesets of a file no longer works with --follow. * This is tracked by https://bz.mercurial-scm.org/show_bug.cgi?id=4959 * Once this is fixed and Mercurial versions with the fix are prevalent, * we can revert to the old behavior. */ // argv.add("-r"); // Use reverse() to get the changesets from newest to oldest. // argv.add("reverse(" + rev_to_find + ":)"); argv.add("--template"); argv.add("{rev}:{file_copies}\\n"); argv.add(fullpath); ProcessBuilder pb = new ProcessBuilder(argv); Process process = null; try { process = pb.start(); try (BufferedReader in = new BufferedReader( new InputStreamReader(process.getInputStream()))) { String line; while ((line = in.readLine()) != null) { matcher.reset(line); if (!matcher.find()) { LOGGER.log(Level.SEVERE, "Failed to match: {0}", line); return (null); } String rev = matcher.group(1); String content = matcher.group(2); if (rev.equals(rev_to_find)) { break; } if (!content.isEmpty()) { /* * Split string of 'newfile1 (oldfile1)newfile2 * (oldfile2) ...' into pairs of renames. */ String[] splitArray = content.split("\\)"); for (String s : splitArray) { /* * This will fail for file names containing ' ('. */ String[] move = s.split(" \\("); if (file.equals(move[0])) { file = move[1]; break; } } } if (rev.equals(rev_to_find)) { break; } } } } finally { if (process != null) { try { process.exitValue(); } catch (IllegalThreadStateException e) { // the process is still running??? just kill it.. process.destroy(); } } } return (fullpath.substring(0, directoryName.length() + 1) + file); } @Override public InputStream getHistoryGet(String parent, String basename, String rev) { String fullpath; try { fullpath = new File(parent, basename).getCanonicalPath(); } catch (IOException exp) { LOGGER.log(Level.SEVERE, "Failed to get canonical path: {0}", exp.getClass().toString()); return null; } InputStream ret = getHistoryRev(fullpath, rev); if (ret == null) { /* * If we failed to get the contents it might be that the file was * renamed so we need to find its original name in that revision * and retry with the original name. */ String origpath; try { origpath = findOriginalName(fullpath, rev); } catch (IOException exp) { LOGGER.log(Level.SEVERE, "Failed to get original revision: {0}", exp.getClass().toString()); return null; } if (origpath != null) { ret = getHistoryRev(origpath, rev); } } return ret; } /** * Annotate the specified file/revision. * * @param file file to annotate * @param revision revision to annotate * @return file annotation * @throws java.io.IOException */ @Override public Annotation annotate(File file, String revision) throws IOException { ArrayList<String> argv = new ArrayList<>(); ensureCommand(CMD_PROPERTY_KEY, CMD_FALLBACK); argv.add(RepoCommand); argv.add("annotate"); argv.add("-n"); if (revision != null) { argv.add("-r"); if (revision.indexOf(':') == -1) { argv.add(revision); } else { argv.add(revision.substring(0, revision.indexOf(':'))); } } argv.add(file.getName()); ProcessBuilder pb = new ProcessBuilder(argv); pb.directory(file.getParentFile()); Process process = null; Annotation ret = null; HashMap<String, HistoryEntry> revs = new HashMap<>(); // Construct hash map for history entries from history cache. This is // needed later to get user string for particular revision. try { History hist = HistoryGuru.getInstance().getHistory(file, false); for (HistoryEntry e : hist.getHistoryEntries()) { // Chop out the colon and all hexadecimal what follows. // This is because the whole changeset identification is // stored in history index while annotate only needs the // revision identifier. revs.put(e.getRevision().replaceFirst(":[a-f0-9]+", ""), e); } } catch (HistoryException he) { LOGGER.log(Level.SEVERE, "Error: cannot get history for file {0}", file); return null; } try { process = pb.start(); try (BufferedReader in = new BufferedReader( new InputStreamReader(process.getInputStream()))) { ret = new Annotation(file.getName()); String line; int lineno = 0; Matcher matcher = ANNOTATION_PATTERN.matcher(""); while ((line = in.readLine()) != null) { ++lineno; matcher.reset(line); if (matcher.find()) { String rev = matcher.group(1); String author = "N/A"; // Use the history index hash map to get the author. if (revs.get(rev) != null) { author = revs.get(rev).getAuthor(); } ret.addLine(rev, Util.getEmail(author.trim()), true); } else { LOGGER.log(Level.SEVERE, "Error: did not find annotation in line {0}: [{1}]", new Object[]{lineno, line}); } } } } finally { if (process != null) { try { process.exitValue(); } catch (IllegalThreadStateException e) { // the process is still running??? just kill it.. process.destroy(); } } } return ret; } @Override protected String getRevisionForAnnotate(String history_revision) { String[] brev = history_revision.split(":"); return brev[0]; } @Override public boolean fileHasAnnotation(File file) { return true; } @Override public void update() throws IOException { File directory = new File(directoryName); List<String> cmd = new ArrayList<>(); ensureCommand(CMD_PROPERTY_KEY, CMD_FALLBACK); cmd.add(RepoCommand); cmd.add("showconfig"); Executor executor = new Executor(cmd, directory); if (executor.exec() != 0) { throw new IOException(executor.getErrorString()); } if (executor.getOutputString().contains("paths.default=")) { cmd.clear(); cmd.add(RepoCommand); cmd.add("pull"); cmd.add("-u"); executor = new Executor(cmd, directory); if (executor.exec() != 0) { throw new IOException(executor.getErrorString()); } } } @Override public boolean fileHasHistory(File file) { // Todo: is there a cheap test for whether mercurial has history // available for a file? // Otherwise, this is harmless, since mercurial's commands will just // print nothing if there is no history. return true; } @Override boolean isRepositoryFor(File file) { if (file.isDirectory()) { File f = new File(file, ".hg"); return f.exists() && f.isDirectory(); } return false; } @Override boolean supportsSubRepositories() { String val = System.getenv(NOFOREST_PROPERTY_KEY); return !(val == null ? Boolean.getBoolean(NOFOREST_PROPERTY_KEY) : Boolean.parseBoolean(val)); } @Override public boolean isWorking() { if (working == null) { ensureCommand(CMD_PROPERTY_KEY, CMD_FALLBACK); working = checkCmd(RepoCommand); } return working; } @Override boolean hasHistoryForDirectories() { return true; } @Override History getHistory(File file) throws HistoryException { return getHistory(file, null); } @Override History getHistory(File file, String sinceRevision) throws HistoryException { RuntimeEnvironment env = RuntimeEnvironment.getInstance(); // Note that the filtering of revisions based on sinceRevision is done // in the history log executor by passing appropriate options to // the 'hg' executable. // This is done only for directories since if getHistory() is used // for file, the file is renamed and its complete history is fetched // so no sinceRevision filter is needed. // See findOriginalName() code for more details. History result = new MercurialHistoryParser(this).parse(file, sinceRevision); // Assign tags to changesets they represent. // We don't need to check if this repository supports tags, // because we know it :-) if (env.isTagsEnabled()) { assignTagsInHistory(result); } return result; } /** * We need to create list of all tags prior to creation of HistoryEntries * per file. * * @return true. */ @Override boolean hasFileBasedTags() { return true; } @Override protected void buildTagList(File directory) { this.tagList = new TreeSet<>(); ArrayList<String> argv = new ArrayList<>(); ensureCommand(CMD_PROPERTY_KEY, CMD_FALLBACK); argv.add(RepoCommand); argv.add("tags"); ProcessBuilder pb = new ProcessBuilder(argv); pb.directory(directory); Process process = null; try { process = pb.start(); try (BufferedReader in = new BufferedReader( new InputStreamReader(process.getInputStream()))) { String line; while ((line = in.readLine()) != null) { String parts[] = line.split(" *"); if (parts.length < 2) { LOGGER.log(Level.WARNING, "Failed to parse tag list: {0}", "Tag line contains more than 2 columns: " + line); this.tagList = null; break; } // Grrr, how to parse tags with spaces inside? // This solution will lose multiple spaces ;-/ String tag = parts[0]; for (int i = 1; i < parts.length - 1; ++i) { tag = tag.concat(" "); tag = tag.concat(parts[i]); } // The implicit 'tip' tag only causes confusion so ignore it. if (tag.contentEquals("tip")) { continue; } String revParts[] = parts[parts.length - 1].split(":"); if (revParts.length != 2) { LOGGER.log(Level.WARNING, "Failed to parse tag list: {0}", "Mercurial revision parsing error: " + parts[parts.length - 1]); this.tagList = null; break; } TagEntry tagEntry = new MercurialTagEntry(Integer.parseInt(revParts[0]), tag); // Reverse the order of the list this.tagList.add(tagEntry); } } } catch (IOException e) { LOGGER.log(Level.WARNING, "Failed to read tag list: {0}", e.getMessage()); this.tagList = null; } if (process != null) { try { process.exitValue(); } catch (IllegalThreadStateException e) { // the process is still running??? just kill it.. process.destroy(); } } } @Override String determineParent() throws IOException { File directory = new File(directoryName); List<String> cmd = new ArrayList<>(); ensureCommand(CMD_PROPERTY_KEY, CMD_FALLBACK); cmd.add(RepoCommand); cmd.add("paths"); cmd.add("default"); Executor executor = new Executor(cmd, directory); if (executor.exec(false) != 0) { throw new IOException(executor.getErrorString()); } return executor.getOutputString().trim(); } @Override String determineCurrentVersion() throws IOException { String line = null; File directory = new File(directoryName); List<String> cmd = new ArrayList<>(); ensureCommand(CMD_PROPERTY_KEY, CMD_FALLBACK); cmd.add(RepoCommand); cmd.add("log"); cmd.add("-l"); cmd.add("1"); cmd.add("--template"); cmd.add("{date|isodate}: {node|short} {author} {desc|strip}"); Executor executor = new Executor(cmd, directory); if (executor.exec(false) != 0) { throw new IOException(executor.getErrorString()); } return executor.getOutputString().trim(); } }