/********************************************************************************
* CruiseControl, a Continuous Integration Toolkit
* Copyright (c) 2001, ThoughtWorks, Inc.
* 200 E. Randolph, 25th Floor
* Chicago, IL 60601 USA
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions
* are met:
*
* + Redistributions of source code must retain the above copyright
* notice, this list of conditions and the following disclaimer.
*
* + Redistributions in binary form must reproduce the above
* copyright notice, this list of conditions and the following
* disclaimer in the documentation and/or other materials provided
* with the distribution.
*
* + Neither the name of ThoughtWorks, Inc., CruiseControl, nor the
* names of its contributors may be used to endorse or promote
* products derived from this software without specific prior
* written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
* "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
* A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR
* CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
* EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
* PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
* PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
* LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
* NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
********************************************************************************/
package net.sourceforge.cruisecontrol.sourcecontrols;
import net.sourceforge.cruisecontrol.CruiseControlException;
import net.sourceforge.cruisecontrol.Modification;
import net.sourceforge.cruisecontrol.SourceControl;
import net.sourceforge.cruisecontrol.util.Commandline;
import net.sourceforge.cruisecontrol.util.IO;
import net.sourceforge.cruisecontrol.util.StreamLogger;
import net.sourceforge.cruisecontrol.util.Util;
import net.sourceforge.cruisecontrol.util.ValidationHelper;
import org.apache.log4j.Logger;
import org.jdom.Document;
import org.jdom.Element;
import org.jdom.JDOMException;
import org.jdom.input.SAXBuilder;
import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.Reader;
import java.text.DateFormat;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.TimeZone;
/**
* This class implements the SourceControl methods for a Subversion repository.
* The call to Subversion is assumed to work without any setup. This implies
* that either authentication data must be available or the login parameters are
* specified in the cc configuration file.
*
* Note: You can also observe for changes a Subversion repository that you have
* not checked out locally.
*
* @see <a href="http://subversion.tigris.org/">subversion.tigris.org</a>
* @author <a href="etienne.studer@canoo.com">Etienne Studer</a>
*/
public class SVN implements SourceControl {
/** serialVersionUID */
private static final long serialVersionUID = -144583234813298598L;
private static final Logger LOG = Logger.getLogger(SVN.class);
/** Date format expected by Subversion */
private static final String SVN_DATE_FORMAT_IN = "yyyy-MM-dd'T'HH:mm:ss'Z'";
/** Date format returned by Subversion in XML output */
private static final String SVN_DATE_FORMAT_OUT = "yyyy-MM-dd'T'HH:mm:ss.SSS";
private final SourceControlProperties properties = new SourceControlProperties();
/** Configuration parameters */
private String repositoryLocation;
private String localWorkingCopy;
private String userName;
private String password;
private String configDir;
private boolean checkExternals = false;
private boolean useLocalRevision = false;
public Map<String, String> getProperties() {
return properties.getPropertiesAndReset();
}
public void setProperty(String property) {
properties.assignPropertyName(property);
}
public void setPropertyOnDelete(String propertyOnDelete) {
properties.assignPropertyOnDeleteName(propertyOnDelete);
}
/**
* @param configDir the configuration directory for the subversion client.
*/
public void setConfigDir(String configDir) {
this.configDir = configDir;
}
/**
* Sets whether externals used by the project should also be checked
* for modifications.
*
* @param value true/false
*/
public void setCheckExternals(boolean value) {
checkExternals = value;
}
/**
* Sets the repository location to use when making calls to Subversion.
*
* @param repositoryLocation String indicating the url to the Subversion
* repository on which to find the log history.
*/
public void setRepositoryLocation(String repositoryLocation) {
this.repositoryLocation = repositoryLocation;
}
/**
* Sets the local working copy to use when making calls to Subversion.
*
* @param localWorkingCopy String indicating the relative or absolute path
* to the local working copy of the Subversion
* repository of which to find the log history.
*/
public void setLocalWorkingCopy(String localWorkingCopy) {
this.localWorkingCopy = localWorkingCopy;
}
/**
* Sets the username for authentication.
* @param userName svn user
*/
public void setUsername(String userName) {
this.userName = userName;
}
/**
* Sets the password for authentication.
* @param password svn password
*/
public void setPassword(String password) {
this.password = password;
}
/**
* This method validates that at least the repository location or the local
* working copy location has been specified.
*
* @throws CruiseControlException Thrown when the repository location and
* the local working copy location are both
* null
*/
public void validate() throws CruiseControlException {
ValidationHelper.assertTrue(repositoryLocation != null || localWorkingCopy != null,
"At least 'repositoryLocation'or 'localWorkingCopy' is a required attribute on the Subversion task ");
if (localWorkingCopy != null) {
File workingDir = new File(localWorkingCopy);
ValidationHelper.assertTrue(workingDir.exists() && workingDir.isDirectory(),
"'localWorkingCopy' must be an existing directory. Was "
+ workingDir.getAbsolutePath());
}
}
/**
* Returns a list of modifications detailing all the changes between
* the last build and the latest revision in the repository.
* @return the list of modifications, or an empty list if we failed
* to retrieve the changes.
*/
public List<Modification> getModifications(final Date lastBuild, final Date now) {
HashMap<String, List<String[]>> directories = new HashMap<String, List<String[]>>();
Commandline propCommand = new Commandline();
// the propget command can be pretty expensive on large projects
// so only execute if the checkExternals flag is set in the config
if (checkExternals) {
try {
propCommand = buildPropgetCommand();
} catch (CruiseControlException e) {
LOG.error("Error building history command", e);
}
try {
directories = execPropgetCommand(propCommand);
} catch (Exception e) {
LOG.error("Error executing svn propget command " + propCommand, e);
}
}
final List<Modification> modifications = new ArrayList<Modification>();
Commandline command;
final HashMap<Commandline, String> commandsAndPaths = new HashMap<Commandline, String>();
try {
// always check the root
final String startRevision = formatSVNDate(lastBuild);
String endRevision;
if (useLocalRevision) {
endRevision = execInfoCommand(buildInfoCommand(null));
} else {
endRevision = formatSVNDate(now);
}
command = buildHistoryCommand(startRevision, endRevision);
commandsAndPaths.put(command, null);
for (final String directory : directories.keySet()) {
if (useLocalRevision) {
endRevision = execInfoCommand(buildInfoCommand(directory));
} else {
endRevision = formatSVNDate(now);
}
for (final String[] external : directories.get(directory)) {
final String path = directory + "/" + external[0];
final String svnURL = external[1];
if (repositoryLocation != null) {
command = buildHistoryCommand(startRevision, endRevision, svnURL);
commandsAndPaths.put(command, null);
} else {
command = buildHistoryCommand(startRevision, endRevision, svnURL);
commandsAndPaths.put(command, path);
}
}
}
} catch (CruiseControlException e) {
LOG.error("Error building history command", e);
return modifications;
}
try {
for (final Commandline commandline : commandsAndPaths.keySet()) {
command = commandline;
final String path = commandsAndPaths.get(command);
modifications.addAll(execHistoryCommand(
command, lastBuild, path));
}
} catch (Exception e) {
LOG.error("Error executing svn log command " + command, e);
}
fillPropertiesIfNeeded(modifications);
return modifications;
}
/**
* Generates the command line for the svn propget command.
*
* For example:
*
* 'svn propget -R svn:externals repositoryLocation'
* @return new command line object
* @throws net.sourceforge.cruisecontrol.CruiseControlException if working directory is invalid
*/
Commandline buildPropgetCommand() throws CruiseControlException {
Commandline command = new Commandline();
command.setExecutable("svn");
if (localWorkingCopy != null) {
command.setWorkingDirectory(localWorkingCopy);
}
command.createArgument("propget");
command.createArgument("-R");
command.createArgument("--non-interactive");
command.createArgument("svn:externals");
if (repositoryLocation != null) {
command.createArgument(repositoryLocation);
}
LOG.debug("Executing command: " + command);
return command;
}
Commandline buildInfoCommand(String path) throws CruiseControlException {
Commandline command = new Commandline();
command.setExecutable("svn");
if (localWorkingCopy != null) {
command.setWorkingDirectory(localWorkingCopy);
}
command.createArgument("info");
command.createArgument("--xml");
if (path != null) {
command.createArgument(path);
}
LOG.debug("Executing command: " + command);
return command;
}
/**
* Generates the command line for the svn log command.
*
* For example:
*
* 'svn log --non-interactive --xml -v -r "{lastbuildTime}":"{checkTime}" repositoryLocation'
* @return history command
* @param lastBuild date
* @param checkTime checkTime
* @throws net.sourceforge.cruisecontrol.CruiseControlException exception
*/
Commandline buildHistoryCommand(String lastBuild, String checkTime)
throws CruiseControlException {
return buildHistoryCommand(lastBuild, checkTime, null);
}
Commandline buildHistoryCommand(String lastBuild, String checkTime, String path)
throws CruiseControlException {
Commandline command = new Commandline();
command.setExecutable("svn");
if (localWorkingCopy != null) {
command.setWorkingDirectory(localWorkingCopy);
}
command.createArgument("log");
command.createArgument("--non-interactive");
command.createArgument("--xml");
command.createArgument("-v");
command.createArgument("-r");
command.createArgument(lastBuild + ":" + checkTime);
if (configDir != null) {
command.createArguments("--config-dir", configDir);
}
if (userName != null || password != null) {
command.createArgument("--no-auth-cache");
if (userName != null) {
command.createArguments("--username", userName);
}
if (password != null) {
command.createArguments("--password", password);
}
}
if (path != null) {
command.createArgument(path);
} else if (repositoryLocation != null) {
command.createArgument(repositoryLocation);
}
LOG.debug("Executing command: " + command);
return command;
}
static String formatSVNDate(Date date) {
return formatSVNDate(date, Util.isWindows());
}
static String formatSVNDate(Date lastBuild, boolean isWindows) {
DateFormat f = new SimpleDateFormat(SVN_DATE_FORMAT_IN);
f.setTimeZone(TimeZone.getTimeZone("GMT"));
String dateStr = f.format(lastBuild);
if (isWindows) {
return "\"{" + dateStr + "}\"";
} else {
return "{" + dateStr + "}";
}
}
private static HashMap<String, List<String[]>> execPropgetCommand(Commandline command)
throws InterruptedException, IOException {
final Process p = command.execute();
final Thread stderr = logErrorStream(p);
final BufferedReader reader = new BufferedReader(
new InputStreamReader(p.getInputStream(), "UTF8"));
final HashMap<String, List<String[]>> directories = new HashMap<String, List<String[]>>();
try {
parsePropgetReader(reader, directories);
p.waitFor();
stderr.join();
} finally {
reader.close();
IO.close(p);
}
return directories;
}
/**
* Parse results from exec of propget command for svn externals.
* @param reader exec reader (UTF-8)
* @param directories will be populated with external directories
* @throws IOException if an error occurs
*/
static void parsePropgetReader(final BufferedReader reader, final Map<String, List<String[]>> directories)
throws IOException {
String line;
String currentDir = null;
while ((line = reader.readLine()) != null) {
String[] split = line.split(" - ");
// the directory containing the externals
if (split.length > 1) {
currentDir = split[0];
directories.put(currentDir, new ArrayList<String[]>());
line = split[1];
}
split = line.split("\\s+"); // CC-949: "\\s" fails if multiple spaces exist as separator in external path
if (!split[0].equals("")) {
final List<String[]> externals = directories.get(currentDir);
// split contains: [externalPath, externalSvnURL]
externals.add(split);
}
}
}
private static List<Modification> execHistoryCommand(final Commandline command, final Date lastBuild,
final String externalPath)
throws InterruptedException, IOException, ParseException, JDOMException {
final Process p = command.execute();
final Thread stderr = logErrorStream(p);
final InputStreamReader reader = new InputStreamReader(p.getInputStream(), "UTF-8");
final List<Modification> modifications;
try {
modifications = SVNLogXMLParser.parseAndFilter(reader, lastBuild, externalPath);
p.waitFor();
stderr.join();
} finally {
reader.close();
IO.close(p);
}
return modifications;
}
private String execInfoCommand(final Commandline command) throws CruiseControlException {
try {
final Process p = command.execute();
final Thread stderr = logErrorStream(p);
final InputStream svnStream = p.getInputStream();
final InputStreamReader reader = new InputStreamReader(svnStream, "UTF-8");
final String revision;
try {
revision = SVNInfoXMLParser.parse(reader);
p.waitFor();
stderr.join();
} finally {
reader.close();
IO.close(p);
}
return revision;
} catch (IOException e) {
throw new CruiseControlException(e);
} catch (JDOMException e) {
throw new CruiseControlException(e);
} catch (InterruptedException e) {
throw new CruiseControlException(e);
}
}
private static Thread logErrorStream(Process p) {
final Thread stderr = new Thread(StreamLogger.getWarnPumper(LOG, p.getErrorStream()));
stderr.start();
return stderr;
}
void fillPropertiesIfNeeded(final List<Modification> modifications) {
if (!modifications.isEmpty()) {
properties.modificationFound();
int maxRevision = 0;
for (final Modification modification : modifications) {
maxRevision = Math.max(maxRevision, Integer.parseInt(modification.revision));
final Modification.ModifiedFile file = modification.files.get(0);
if (file.action.equals("deleted")) {
properties.deletionFound();
}
}
properties.put("svnrevision", "" + maxRevision);
} else {
String endRevision;
Commandline infoCommand;
try {
infoCommand = buildInfoCommand(null);
} catch (CruiseControlException e) {
LOG.error("Error building svn info command", e);
return;
}
try {
endRevision = execInfoCommand(infoCommand);
properties.put("svnrevision", "" + endRevision);
} catch (CruiseControlException e) {
LOG.error("Error executing svn info command " + infoCommand, e);
}
}
}
public static DateFormat getOutDateFormatter() {
DateFormat f = new SimpleDateFormat(SVN_DATE_FORMAT_OUT);
f.setTimeZone(TimeZone.getTimeZone("GMT"));
return f;
}
static final class SVNLogXMLParser {
private SVNLogXMLParser() {
}
static List parseAndFilter(Reader reader, Date lastBuild)
throws ParseException, JDOMException, IOException {
return parseAndFilter(reader, lastBuild, null);
}
static List<Modification> parseAndFilter(final Reader reader, final Date lastBuild, final String externalPath)
throws ParseException, JDOMException, IOException {
final Modification[] modifications = parse(reader, externalPath);
return filterModifications(modifications, lastBuild);
}
static Modification[] parse(Reader reader)
throws ParseException, JDOMException, IOException {
return parse(reader, null);
}
static Modification[] parse(Reader reader, String externalPath)
throws ParseException, JDOMException, IOException {
SAXBuilder builder = new SAXBuilder(false);
Document document = builder.build(reader);
return parseDOMTree(document, externalPath);
}
static Modification[] parseDOMTree(final Document document, final String externalPath)
throws ParseException {
final List<Modification> modifications = new ArrayList<Modification>();
final Element rootElement = document.getRootElement();
final List logEntries = rootElement.getChildren("logentry");
for (final Object logEntry1 : logEntries) {
final Element logEntry = (Element) logEntry1;
final Modification[] modificationsOfRevision =
parseLogEntry(logEntry, externalPath);
modifications.addAll(Arrays.asList(modificationsOfRevision));
}
return modifications.toArray(new Modification[modifications.size()]);
}
static Modification[] parseLogEntry(final Element logEntry, final String externalPath)
throws ParseException {
final List<Modification> modifications = new ArrayList<Modification>();
final Element logEntryPaths = logEntry.getChild("paths");
if (logEntryPaths != null) {
final List paths = logEntryPaths.getChildren("path");
for (final Object path1 : paths) {
Element path = (Element) path1;
Modification modification = new Modification("svn");
modification.modifiedTime = convertDate(logEntry.getChildText("date"));
modification.userName = logEntry.getChildText("author");
modification.comment = logEntry.getChildText("msg");
modification.revision = logEntry.getAttributeValue("revision");
Modification.ModifiedFile modfile = modification.createModifiedFile(path.getText(), null);
// modfile.folderName seems to add too many /'s
if (externalPath != null) {
modfile.fileName = "/" + externalPath + ":" + modfile.fileName;
}
modfile.action = convertAction(path.getAttributeValue("action"));
modfile.revision = modification.revision;
modifications.add(modification);
}
}
return modifications.toArray(new Modification[modifications.size()]);
}
/**
* Converts the specified SVN date string into a Date.
* @param date with format "yyyy-MM-dd'T'HH:mm:ss.SSS" + "...Z"
* @return converted date
* @throws ParseException if specified date doesn't match the expected format
*/
static Date convertDate(String date) throws ParseException {
final int zIndex = date.indexOf('Z');
if (zIndex - 3 < 0) {
throw new ParseException(date
+ " doesn't match the expected subversion date format", date.length());
}
String withoutMicroSeconds = date.substring(0, zIndex - 3);
return getOutDateFormatter().parse(withoutMicroSeconds);
}
static String convertAction(String action) {
if (action.equals("A")) {
return "added";
}
if (action.equals("M")) {
return "modified";
}
if (action.equals("D")) {
return "deleted";
}
return "unknown";
}
/**
* Unlike CVS, Subversion maps dates to revisions which leads to a
* different behavior when using the svn log command in conjunction with
* dates, e.g., a date maps to a revision but the revision may have been
* created earlier than the specified date. Therefore, if we are only
* interested in changes that took place after the last build date, we
* have to filter the modifications returned from the log command and
* omit modifications that are older than the last build date.
*
* @see <a href="http://subversion.tigris.org/">subversion.tigris.org</a>
* @return subset of modifications
* @param modifications source
* @param lastBuild last build date
*/
static List<Modification> filterModifications(final Modification[] modifications, final Date lastBuild) {
final List<Modification> filtered = new ArrayList<Modification>();
for (final Modification modification : modifications) {
if (modification.modifiedTime.getTime() > lastBuild.getTime()) {
filtered.add(modification);
}
}
return filtered;
}
}
static final class SVNInfoXMLParser {
private SVNInfoXMLParser() { }
public static String parse(final Reader reader) throws JDOMException, IOException {
final SAXBuilder builder = new SAXBuilder(false);
final Document document = builder.build(reader);
return document.getRootElement().getChild("entry").getAttribute("revision").getValue();
}
}
public void setUseLocalRevision(boolean useLocalRevision) {
this.useLocalRevision = useLocalRevision;
}
}