/* * 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) 2008, 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.io.Reader; import java.text.ParseException; import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Date; import java.util.LinkedList; import java.util.List; import java.util.TreeSet; import java.util.function.Supplier; 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.util.StringUtils; /** * Access to a Git repository. * */ public class GitRepository extends Repository { private static final Logger LOGGER = LoggerFactory.getLogger(GitRepository.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.git"; /** * The command to use to access the repository if none was given explicitly */ public static final String CMD_FALLBACK = "git"; /** * git blame command */ private static final String BLAME = "blame"; /** * arguments to shorten git IDs */ private static final int CSET_LEN = 8; private static final String ABBREV_LOG = "--abbrev=" + CSET_LEN; private static final String ABBREV_BLAME = "--abbrev=" + (CSET_LEN - 1); /** * Pattern used to extract author/revision from git blame. */ private static final Pattern BLAME_PATTERN = Pattern.compile("^\\W*(\\w+).+?\\((\\D+).*$"); public GitRepository() { type = "git"; /* * Formatter which allows the optional day at the beginning as per * RFC 2822 , section 3.3. Date and Time Specification: * * date-time = [ day-of-week "," ] date FWS time [CFWS] */ datePatterns = new String[]{ "EE, d MMM yyyy HH:mm:ss Z", "d MMM yyyy HH:mm:ss Z" }; ignoredDirs.add(".git"); } /** * Get an executor to be used for retrieving the history log for the named * file. * * @param file The file to retrieve history for * @param sinceRevision the oldest changeset to return from the executor, or * {@code null} if all changesets should be returned * @return An Executor ready to be started */ Executor getHistoryLogExecutor(final File file, String sinceRevision) throws IOException { String abs = file.getCanonicalPath(); String filename = ""; 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"); cmd.add("--abbrev-commit"); cmd.add(ABBREV_LOG); cmd.add("--name-only"); cmd.add("--pretty=fuller"); cmd.add("--date=rfc"); if (file.isFile() && RuntimeEnvironment.getInstance().isHandleHistoryOfRenamedFiles()) { cmd.add("--follow"); } if (sinceRevision != null) { cmd.add(sinceRevision + ".."); } if (filename.length() > 0) { cmd.add("--"); cmd.add(filename); } return new Executor(cmd, new File(getDirectoryName()), sinceRevision != null); } Executor getRenamedFilesExecutor(final File file, String sinceRevision) throws IOException { ensureCommand(CMD_PROPERTY_KEY, CMD_FALLBACK); List<String> cmd = new ArrayList<>(); cmd.add(RepoCommand); cmd.add("log"); cmd.add("--find-renames=8"); // similarity 80% cmd.add("--summary"); cmd.add(ABBREV_LOG); cmd.add("--name-status"); cmd.add("--oneline"); if (file.isFile()) { cmd.add("--follow"); } if (sinceRevision != null) { cmd.add(sinceRevision + ".."); } if (file.getCanonicalPath().length() > directoryName.length() + 1) { // this is a file in the repository cmd.add("--"); cmd.add(file.getCanonicalPath().substring(directoryName.length() + 1)); } return new Executor(cmd, new File(getDirectoryName()), sinceRevision != null); } /** * Create a {@code Reader} that reads an {@code InputStream} using the * correct character encoding. * * @param input a stream with the output from a log or blame command * @return a reader that reads the input * @throws IOException if the reader cannot be created */ Reader newLogReader(InputStream input) throws IOException { // Bug #17731: Git always encodes the log output using UTF-8 (unless // overridden by i18n.logoutputencoding, but let's assume that hasn't // been done for now). Create a reader that uses UTF-8 instead of the // platform's default encoding. return new InputStreamReader(input, "UTF-8"); } /** * 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; try { String filename = fullpath.substring(directoryName.length() + 1); ensureCommand(CMD_PROPERTY_KEY, CMD_FALLBACK); String argv[] = { RepoCommand, "show", rev + ":" + 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 git 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: " + exp.getClass().toString(), exp); } 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; } @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, exp, new Supplier<String>() { @Override public String get() { return String.format("Failed to get canonical path: %s/%s", parent, basename); } }); 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, exp, new Supplier<String>() { @Override public String get() { return String.format("Failed to get original revision: %s/%s (revision %s)", parent, basename, rev); } }); return null; } if (origpath != null) { ret = getHistoryRev(origpath, rev); } } return ret; } /** * Get the name of file in given revision. * * @param fullpath file path * @param changeset changeset * @return original filename * @throws java.io.IOException */ protected String findOriginalName(String fullpath, String changeset) throws IOException { if (fullpath == null || fullpath.isEmpty() || fullpath.length() < directoryName.length()) { throw new IOException(String.format("Invalid file path string: %s", fullpath)); } if (changeset == null || changeset.isEmpty()) { throw new IOException(String.format("Invalid changeset string for path %s: %s", fullpath, changeset)); } String file = fullpath.replace(directoryName + File.separator, ""); /* * Get the list of file renames for given file to the specified * revision. */ String[] argv = { ensureCommand(CMD_PROPERTY_KEY, CMD_FALLBACK), "log", "--follow", "--summary", ABBREV_LOG, "--abbrev-commit", "--name-status", "--pretty=format:commit %h%b", "--", fullpath }; ProcessBuilder pb = new ProcessBuilder(argv); Process process = Runtime.getRuntime().exec(argv, null, new File(directoryName)); try { try (BufferedReader in = new BufferedReader( new InputStreamReader(process.getInputStream()))) { String line; String rev = null; Matcher m; Pattern pattern = Pattern.compile("^R\\d+\\s(.*)\\s(.*)"); while ((line = in.readLine()) != null) { if (line.startsWith("commit ")) { rev = line.substring(7); continue; } if (changeset.equals(rev)) { break; } if ((m = pattern.matcher(line)).find()) { file = m.group(1); } } } } 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); } /** * 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 { List<String> cmd = new ArrayList<>(); ensureCommand(CMD_PROPERTY_KEY, CMD_FALLBACK); cmd.add(RepoCommand); cmd.add(BLAME); cmd.add("-c"); // to get correctly formed changeset IDs cmd.add(ABBREV_BLAME); if (revision != null) { cmd.add(revision); } cmd.add(file.getName()); Executor exec = new Executor(cmd, file.getParentFile()); int status = exec.exec(); // File might have changed its location if (status != 0) { cmd.clear(); ensureCommand(CMD_PROPERTY_KEY, CMD_FALLBACK); cmd.add(RepoCommand); cmd.add(BLAME); cmd.add("-c"); // to get correctly formed changeset IDs cmd.add(ABBREV_BLAME); if (revision != null) { cmd.add(revision); } cmd.add("--"); cmd.add(findOriginalName(file.getAbsolutePath(), revision)); File directory = new File(directoryName); exec = new Executor(cmd, directory); status = exec.exec(); } if (status != 0) { LOGGER.log(Level.WARNING, "Failed to get annotations for: \"{0}\" Exit code: {1}", new Object[]{file.getAbsolutePath(), String.valueOf(status)}); } return parseAnnotation( newLogReader(exec.getOutputStream()), file.getName()); } protected Annotation parseAnnotation(Reader input, String fileName) throws IOException { BufferedReader in = new BufferedReader(input); Annotation ret = new Annotation(fileName); String line = ""; int lineno = 0; Matcher matcher = BLAME_PATTERN.matcher(line); while ((line = in.readLine()) != null) { ++lineno; matcher.reset(line); if (matcher.find()) { String rev = matcher.group(1); String author = matcher.group(2).trim(); ret.addLine(rev, author, true); } else { LOGGER.log(Level.SEVERE, "Error: did not find annotation in line {0}: [{1}] of {2}", new Object[]{String.valueOf(lineno), line, fileName}); } } return ret; } @Override public boolean fileHasAnnotation(File file) { return true; } @Override public void update() throws IOException { File directory = new File(getDirectoryName()); List<String> cmd = new ArrayList<>(); ensureCommand(CMD_PROPERTY_KEY, CMD_FALLBACK); cmd.add(RepoCommand); cmd.add("config"); cmd.add("--list"); Executor executor = new Executor(cmd, directory); if (executor.exec() != 0) { throw new IOException(executor.getErrorString()); } if (executor.getOutputString().contains("remote.origin.url=")) { cmd.clear(); cmd.add(RepoCommand); cmd.add("pull"); cmd.add("-n"); cmd.add("-q"); if (executor.exec() != 0) { throw new IOException(executor.getErrorString()); } } } @Override public boolean fileHasHistory(File file) { // Todo: is there a cheap test for whether Git has history // available for a file? // Otherwise, this is harmless, since Git'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, ".git"); return f.exists() && f.isDirectory(); } return false; } @Override boolean supportsSubRepositories() { return true; } @Override public boolean isWorking() { if (working == null) { ensureCommand(CMD_PROPERTY_KEY, CMD_FALLBACK); working = checkCmd(RepoCommand, "--help"); } 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(); History result = new GitHistoryParser().parse(file, this, 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; } @Override boolean hasFileBasedTags() { return true; } private TagEntry buildTagEntry(File directory, String tags) throws HistoryException, IOException { String hash = null; Date date = null; ArrayList<String> argv = new ArrayList<>(); ensureCommand(CMD_PROPERTY_KEY, CMD_FALLBACK); argv.add(RepoCommand); argv.add("log"); argv.add("--format=commit:%H" + System.getProperty("line.separator") + "Date:%at"); argv.add("-r"); argv.add(tags + "^.." + tags); ProcessBuilder pb = new ProcessBuilder(argv); pb.directory(directory); Process process; process = pb.start(); try (BufferedReader in = new BufferedReader(new InputStreamReader(process.getInputStream()))) { String line; while ((line = in.readLine()) != null) { if (line.startsWith("commit")) { String parts[] = line.split(":"); if (parts.length < 2) { throw new HistoryException("Tag line contains more than 2 columns: " + line); } hash = parts[1]; } if (line.startsWith("Date")) { String parts[] = line.split(":"); if (parts.length < 2) { throw new HistoryException("Tag line contains more than 2 columns: " + line); } date = new Date((long) (Integer.parseInt(parts[1])) * 1000); } } } try { process.exitValue(); } catch (IllegalThreadStateException e) { // the process is still running??? just kill it.. process.destroy(); } // Git can have tags not pointing to any commit, but tree instead // Lets use Unix timestamp of 0 for such commits if (date == null) { date = new Date(0); } TagEntry result = new GitTagEntry(hash, date, tags); return result; } @Override protected void buildTagList(File directory) { this.tagList = new TreeSet<>(); ArrayList<String> argv = new ArrayList<>(); LinkedList<String> tagsList = new LinkedList<>(); ensureCommand(CMD_PROPERTY_KEY, CMD_FALLBACK); argv.add(RepoCommand); argv.add("tag"); ProcessBuilder pb = new ProcessBuilder(argv); pb.directory(directory); Process process = null; try { // First we have to obtain list of all tags, and put it asside // Otherwise we can't use git to get date & hash for each tag process = pb.start(); try (BufferedReader in = new BufferedReader(new InputStreamReader(process.getInputStream()))) { String line; while ((line = in.readLine()) != null) { tagsList.add(line); } } } catch (IOException e) { LOGGER.log(Level.WARNING, "Failed to read tag list: {0}", e.getMessage()); this.tagList = null; } // Make sure this git instance is not running any more if (process != null) { try { process.exitValue(); } catch (IllegalThreadStateException e) { // the process is still running??? just kill it.. process.destroy(); } } try { // Now get hash & date for each tag for (String tags : tagsList) { TagEntry tagEntry = buildTagEntry(directory, tags); // Reverse the order of the list this.tagList.add(tagEntry); } } catch (HistoryException e) { LOGGER.log(Level.WARNING, "Failed to parse tag list: {0}", e.getMessage()); this.tagList = null; } catch (IOException e) { LOGGER.log(Level.WARNING, "Failed to read tag list: {0}", e.getMessage()); this.tagList = null; } } @Override String determineParent() throws IOException { String parent = null; File directory = new File(directoryName); List<String> cmd = new ArrayList<>(); ensureCommand(CMD_PROPERTY_KEY, CMD_FALLBACK); cmd.add(RepoCommand); cmd.add("remote"); cmd.add("-v"); ProcessBuilder pb = new ProcessBuilder(cmd); pb.directory(directory); Process process; process = pb.start(); try (BufferedReader in = new BufferedReader(new InputStreamReader(process.getInputStream()))) { String line; while ((line = in.readLine()) != null) { if (line.startsWith("origin") && line.contains("(fetch)")) { String parts[] = line.split("\\s+"); if (parts.length != 3) { LOGGER.log(Level.WARNING, "Failed to get parent for {0}", directoryName); } parent = parts[1]; break; } } } return parent; } @Override String determineBranch() throws IOException { String branch = null; File directory = new File(directoryName); List<String> cmd = new ArrayList<>(); ensureCommand(CMD_PROPERTY_KEY, CMD_FALLBACK); cmd.add(RepoCommand); cmd.add("branch"); ProcessBuilder pb = new ProcessBuilder(cmd); pb.directory(directory); Process process; process = pb.start(); try (BufferedReader in = new BufferedReader(new InputStreamReader(process.getInputStream()))) { String line; while ((line = in.readLine()) != null) { if (line.startsWith("*")) { branch = line.substring(2).trim(); break; } } } return branch; } private static final SimpleDateFormat outputDateFormat = new SimpleDateFormat("YYYY-MM-dd HH:mm"); @Override String determineCurrentVersion() throws IOException { File directory = new File(directoryName); List<String> cmd = new ArrayList<>(); ensureCommand(CMD_PROPERTY_KEY, CMD_FALLBACK); cmd.add(RepoCommand); cmd.add("log"); cmd.add("-1"); cmd.add("--pretty=%cd: %h %an %s"); cmd.add("--date=rfc"); Executor executor = new Executor(cmd, directory); if (executor.exec(false) != 0) { throw new IOException(executor.getErrorString()); } String output = executor.getOutputString().trim(); int indexOf = StringUtils.nthIndexOf(output, ":", 3); if (indexOf < 0) { throw new IOException( String.format("Couldn't extract date from \"%s\".", new Object[]{output})); } try { Date date = getDateFormat().parse(output.substring(0, indexOf)); return String.format("%s%s", new Object[]{outputDateFormat.format(date), output.substring(indexOf)}); } catch (ParseException ex) { throw new IOException(ex); } } }