package hudson.scm; import hudson.FilePath; import hudson.Launcher; import hudson.Proc; import hudson.Util; import hudson.model.Build; import hudson.model.BuildListener; import hudson.model.Descriptor; import hudson.model.Project; import hudson.model.TaskListener; import hudson.util.ArgumentListBuilder; import hudson.util.FormFieldValidator; import org.apache.commons.digester.Digester; import org.kohsuke.stapler.StaplerRequest; import org.kohsuke.stapler.StaplerResponse; import org.xml.sax.SAXException; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import java.io.BufferedOutputStream; import java.io.BufferedReader; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.FileReader; import java.io.IOException; import java.io.InputStreamReader; import java.io.OutputStream; import java.io.PrintStream; import java.io.PrintWriter; import java.util.ArrayList; import java.util.Collection; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Map.Entry; import java.util.StringTokenizer; import java.util.logging.Level; import java.util.logging.Logger; import java.util.regex.Matcher; import java.util.regex.Pattern; /** * Subversion. * * Check http://svn.collab.net/repos/svn/trunk/subversion/svn/schema/ for * various output formats. * * @author Kohsuke Kawaguchi */ public class SubversionSCM extends AbstractCVSFamilySCM { private final String modules; private boolean useUpdate; private String username; private String otherOptions; SubversionSCM( String modules, boolean useUpdate, String username, String otherOptions ) { StringBuilder normalizedModules = new StringBuilder(); StringTokenizer tokens = new StringTokenizer(modules); while(tokens.hasMoreTokens()) { if(normalizedModules.length()>0) normalizedModules.append(' '); String m = tokens.nextToken(); if(m.endsWith("/")) // the normalized name is always without the trailing '/' m = m.substring(0,m.length()-1); normalizedModules.append(m); } this.modules = normalizedModules.toString(); this.useUpdate = useUpdate; this.username = nullify(username); this.otherOptions = nullify(otherOptions); } /** * Whitespace-separated list of SVN URLs that represent * modules to be checked out. */ public String getModules() { return modules; } public boolean isUseUpdate() { return useUpdate; } public String getUsername() { return username; } public String getOtherOptions() { return otherOptions; } private Collection getModuleDirNames() { List dirs = new ArrayList(); StringTokenizer tokens = new StringTokenizer(modules); while(tokens.hasMoreTokens()) { dirs.add(getLastPathComponent(tokens.nextToken())); } return dirs; } private boolean calcChangeLog(Build build, File changelogFile, Launcher launcher, BuildListener listener) throws IOException { if(build.getPreviousBuild()==null) { // nothing to compare against return createEmptyChangeLog(changelogFile, listener, "log"); } PrintStream logger = listener.getLogger(); Map previousRevisions = parseRevisionFile(build.getPreviousBuild()); Map thisRevisions = parseRevisionFile(build); Map env = createEnvVarMap(true); for( String module : getModuleDirNames() ) { Integer prevRev = previousRevisions.get(module); if(prevRev==null) { logger.println("no revision recorded for "+module+" in the previous build"); continue; } Integer thisRev = thisRevisions.get(module); if(thisRev!=null && thisRev.equals(prevRev)) { logger.println("no change for "+module+" since the previous build"); continue; } String cmd = DESCRIPTOR.getSvnExe()+" log -v --xml --non-interactive -r "+(prevRev+1)+":BASE "+module; OutputStream os = new BufferedOutputStream(new FileOutputStream(changelogFile)); try { int r = launcher.launch(cmd,env,os,build.getProject().getWorkspace()).join(); if(r!=0) { listener.fatalError("revision check failed"); // report the output FileInputStream log = new FileInputStream(changelogFile); try { Util.copyStream(log,listener.getLogger()); } finally { log.close(); } return false; } } finally { os.close(); } } return true; } /*package*/ static Map parseRevisionFile(Build build) throws IOException { Map revisions = new HashMap(); // module -> revision {// read the revision file of the last build File file = getRevisionFile(build); if(!file.exists()) // nothing to compare against return revisions; BufferedReader br = new BufferedReader(new FileReader(file)); String line; while((line=br.readLine())!=null) { int index = line.indexOf('/'); if(index<0) { continue; // invalid line? } try { revisions.put(line.substring(0,index), Integer.parseInt(line.substring(index+1))); } catch (NumberFormatException e) { // perhaps a corrupted line. ignore } } } return revisions; } public boolean checkout(Build build, Launcher launcher, FilePath workspace, BuildListener listener, File changelogFile) throws IOException { boolean result; if(useUpdate && isUpdatable(workspace,listener)) { result = update(launcher,workspace,listener); if(!result) return false; } else { workspace.deleteContents(); StringTokenizer tokens = new StringTokenizer(modules); while(tokens.hasMoreTokens()) { ArgumentListBuilder cmd = new ArgumentListBuilder(); cmd.add(DESCRIPTOR.getSvnExe(),"co","-q","--non-interactive"); if(username!=null) cmd.add("--username",username); if(otherOptions!=null) cmd.add(Util.tokenize(otherOptions)); cmd.add(tokens.nextToken()); result = run(launcher,cmd,listener,workspace); if(!result) return false; } } // write out the revision file PrintWriter w = new PrintWriter(new FileOutputStream(getRevisionFile(build))); try { Map revMap = buildRevisionMap(workspace,listener); for (Entry e : revMap.entrySet()) { w.println( e.getKey() +'/'+ e.getValue().revision ); } } finally { w.close(); } return calcChangeLog(build, changelogFile, launcher, listener); } /** * Output from "svn info" command. */ public static class SvnInfo { /** The remote URL of this directory */ String url; /** Current workspace revision. */ int revision = -1; private SvnInfo() {} /** * Returns true if this object is fully populated. */ public boolean isComplete() { return url!=null && revision!=-1; } public void setUrl(String url) { this.url = url; } public void setRevision(int revision) { this.revision = revision; } /** * Executes "svn info" command and returns the parsed output * * @param subject * The target to run "svn info". Either local path or remote URL. */ public static SvnInfo parse(String subject, Map env, FilePath workspace, TaskListener listener) throws IOException { String cmd = DESCRIPTOR.getSvnExe()+" info --xml "+subject; listener.getLogger().println("$ "+cmd); ByteArrayOutputStream baos = new ByteArrayOutputStream(); int r = new Proc(cmd,env,baos,workspace.getLocal()).join(); if(r!=0) { // failed. to allow user to diagnose the problem, send output to log listener.getLogger().write(baos.toByteArray()); throw new IOException("svn info failed"); } SvnInfo info = new SvnInfo(); Digester digester = new Digester(); digester.push(info); digester.addBeanPropertySetter("info/entry/url"); digester.addSetProperties("info/entry/commit","revision","revision"); // set attributes. in particular @revision try { digester.parse(new ByteArrayInputStream(baos.toByteArray())); } catch (SAXException e) { // failed. to allow user to diagnose the problem, send output to log listener.getLogger().write(baos.toByteArray()); e.printStackTrace(listener.fatalError("Failed to parse Subversion output")); throw new IOException("Unabled to parse svn info output"); } if(!info.isComplete()) throw new IOException("No revision in the svn info output"); return info; } } /** * Checks .svn files in the workspace and finds out revisions of the modules * that the workspace has. * * @return * null if the parsing somehow fails. Otherwise a map from module names to revisions. */ private Map buildRevisionMap(FilePath workspace, TaskListener listener) throws IOException { PrintStream logger = listener.getLogger(); Map revisions = new HashMap(); Map env = createEnvVarMap(false); // invoke the "svn info" for( String module : getModuleDirNames() ) { // parse the output SvnInfo info = SvnInfo.parse(module,env,workspace,listener); revisions.put(module,info); logger.println("Revision:"+info.revision); } return revisions; } /** * Gets the file that stores the revision. */ private static File getRevisionFile(Build build) { return new File(build.getRootDir(),"revision.txt"); } public boolean update(Launcher launcher, FilePath remoteDir, BuildListener listener) throws IOException { ArgumentListBuilder cmd = new ArgumentListBuilder(); cmd.add(DESCRIPTOR.getSvnExe(), "update", "-q", "--non-interactive"); if(username!=null) cmd.add(" --username ",username); if(otherOptions!=null) cmd.add(Util.tokenize(otherOptions)); StringTokenizer tokens = new StringTokenizer(modules); while(tokens.hasMoreTokens()) { if(!run(launcher,cmd,listener,new FilePath(remoteDir,getLastPathComponent(tokens.nextToken())))) return false; } return true; } /** * Returns true if we can use "svn update" instead of "svn checkout" */ private boolean isUpdatable(FilePath workspace,BuildListener listener) { StringTokenizer tokens = new StringTokenizer(modules); while(tokens.hasMoreTokens()) { String url = tokens.nextToken(); String moduleName = getLastPathComponent(url); File module = workspace.child(moduleName).getLocal(); try { SvnInfo svnInfo = SvnInfo.parse(moduleName, createEnvVarMap(false), workspace, listener); if(!svnInfo.url.equals(url)) { listener.getLogger().println("Checking out a fresh workspace because the workspace is not "+url); return false; } } catch (IOException e) { listener.getLogger().println("Checking out a fresh workspace because Hudson failed to detect the current workspace "+module); e.printStackTrace(listener.error(e.getMessage())); return false; } } return true; } public boolean pollChanges(Project project, Launcher launcher, FilePath workspace, TaskListener listener) throws IOException { // current workspace revision Map wsRev = buildRevisionMap(workspace,listener); Map env = createEnvVarMap(false); // check the corresponding remote revision for (SvnInfo localInfo : wsRev.values()) { SvnInfo remoteInfo = SvnInfo.parse(localInfo.url,env,workspace,listener); listener.getLogger().println("Revision:"+remoteInfo.revision); if(remoteInfo.revision > localInfo.revision) return true; // change found } return false; // no change } public ChangeLogParser createChangeLogParser() { return new SubversionChangeLogParser(); } public DescriptorImpl getDescriptor() { return DESCRIPTOR; } public void buildEnvVars(Map env) { // no environment variable } public FilePath getModuleRoot(FilePath workspace) { String s; // if multiple URLs are specified, pick the first one int idx = modules.indexOf(' '); if(idx>=0) s = modules.substring(0,idx); else s = modules; return workspace.child(getLastPathComponent(s)); } private String getLastPathComponent(String s) { String[] tokens = s.split("/"); return tokens[tokens.length-1]; // return the last token } static final DescriptorImpl DESCRIPTOR = new DescriptorImpl(); public static final class DescriptorImpl extends Descriptor { /** * Path to svn.exe. Null to default. */ private String svnExe; DescriptorImpl() { super(SubversionSCM.class); } protected void convert(Map oldPropertyBag) { svnExe = (String)oldPropertyBag.get("svn_exe"); } public String getDisplayName() { return "Subversion"; } public SCM newInstance(StaplerRequest req) { return new SubversionSCM( req.getParameter("svn_modules"), req.getParameter("svn_use_update")!=null, req.getParameter("svn_username"), req.getParameter("svn_other_options") ); } public String getSvnExe() { String value = svnExe; if(value==null) value = "svn"; return value; } public void setSvnExe(String value) { svnExe = value; save(); } public boolean configure( HttpServletRequest req ) { svnExe = req.getParameter("svn_exe"); return true; } /** * Returns the Subversion version information. * * @return * null if failed to obtain. */ public Version version(Launcher l, String svnExe) { try { if(svnExe==null || svnExe.equals("")) svnExe="svn"; ByteArrayOutputStream out = new ByteArrayOutputStream(); l.launch(new String[]{svnExe,"--version"},new String[0],out,FilePath.RANDOM).join(); // parse the first line for version BufferedReader r = new BufferedReader(new InputStreamReader(new ByteArrayInputStream(out.toByteArray()))); String line; while((line = r.readLine())!=null) { Matcher m = SVN_VERSION.matcher(line); if(m.matches()) return new Version(Integer.parseInt(m.group(2)), m.group(1)); } // ancient version of subversions didn't have the fixed version number line. // or maybe something else is going wrong. LOGGER.log(Level.WARNING, "Failed to parse the first line from svn output: "+line); return new Version(0,"(unknown)"); } catch (IOException e) { // Stack trace likely to be overkill for a problem that isn't necessarily a problem at all: LOGGER.log(Level.WARNING, "Failed to check svn version: {0}", e.toString()); return null; // failed to obtain } } // web methods public void doVersionCheck(StaplerRequest req, StaplerResponse rsp) throws IOException, ServletException { // this method runs a new process, so it needs to be protected new FormFieldValidator(req,rsp,true) { protected void check() throws IOException, ServletException { String svnExe = request.getParameter("exe"); Version v = version(new Launcher(TaskListener.NULL),svnExe); if(v==null) { error("Failed to check subversion version info. Is this a valid path?"); return; } if(v.isOK()) { ok(); } else { error("Version "+v.versionId+" found, but 1.3.0 is required"); } } }.process(); } } public static final class Version { private final int revision; private String versionId; public Version(int revision, String versionId) { this.revision = revision; this.versionId = versionId; } /** * Repository revision ID of this build. */ public int getRevision() { return revision; } /** * Human-readable version string. */ public String getVersionId() { return versionId; } /** * We use "svn info --xml", which is new in 1.3.0 */ public boolean isOK() { return revision>=17949; } } private static final Pattern SVN_VERSION = Pattern.compile("svn, .+ ([0-9.]+) \\(r([0-9]+)\\)"); private static final Logger LOGGER = Logger.getLogger(SubversionSCM.class.getName()); }