package hudson.plugins.mercurial;
import edu.umd.cs.findbugs.annotations.CheckForNull;
import edu.umd.cs.findbugs.annotations.SuppressWarnings;
import hudson.EnvVars;
import hudson.Extension;
import hudson.FilePath;
import hudson.Launcher;
import hudson.Util;
import hudson.FilePath.FileCallable;
import hudson.Launcher.ProcStarter;
import hudson.model.AbstractBuild;
import hudson.model.AbstractProject;
import hudson.model.BuildListener;
import hudson.model.Descriptor;
import hudson.model.Node;
import hudson.model.TaskListener;
import hudson.plugins.mercurial.browser.HgBrowser;
import hudson.plugins.mercurial.browser.HgWeb;
import hudson.remoting.VirtualChannel;
import hudson.scm.ChangeLogParser;
import hudson.scm.PollingResult;
import hudson.scm.PollingResult.Change;
import hudson.scm.RepositoryBrowser;
import hudson.scm.RepositoryBrowsers;
import hudson.scm.SCM;
import hudson.scm.SCMDescriptor;
import hudson.scm.SCMRevisionState;
import hudson.util.ArgumentListBuilder;
import hudson.util.ForkOutputStream;
import java.io.BufferedReader;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.io.PrintStream;
import java.io.Serializable;
import java.net.MalformedURLException;
import java.net.URL;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.StringTokenizer;
import java.util.concurrent.TimeUnit;
import java.util.logging.Logger;
import static java.util.logging.Level.FINE;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import net.sf.json.JSONObject;
import org.kohsuke.stapler.DataBoundConstructor;
import org.kohsuke.stapler.StaplerRequest;
import org.kohsuke.stapler.framework.io.WriterOutputStream;
/**
* Mercurial SCM.
*/
public class MercurialSCM extends SCM implements Serializable {
/**
* Name of selected installation, if any.
*/
private final String installation;
/**
* Source repository URL from which we pull.
*/
private final String source;
/**
* Prefixes of files within the repository which we're dependent on.
* Storing as member variable so as to only parse the dependencies string once.
* Will be either null (use whole repo), or nonempty list of subdir names.
*/
private transient Set<String> _modules;
// Same thing, but not parsed for jelly.
private final String modules;
/**
* In-repository branch to follow. Null indicates "default".
*/
private final String branch;
/** Slash-separated subdirectory of the workspace in which the repository will be kept; null for top level. */
private final String subdir;
private final boolean clean;
private final boolean forest;
private HgBrowser browser;
@DataBoundConstructor
public MercurialSCM(String installation, String source, String branch, String modules, String subdir, HgBrowser browser, boolean clean, boolean forest) {
this.installation = installation;
this.source = source;
this.modules = Util.fixNull(modules);
this.subdir = Util.fixEmptyAndTrim(subdir);
this.clean = clean;
this.forest = forest;
parseModules();
branch = Util.fixEmpty(branch);
if (branch != null && branch.equals("default")) {
branch = null;
}
this.branch = branch;
this.browser = browser;
}
private void parseModules() {
if (modules.trim().length() > 0) {
_modules = new HashSet<String>();
// split by commas and whitespace, except "\ "
for (String r : modules.split("(?<!\\\\)[ \\r\\n,]+")) {
if (r.length() == 0) { // initial spaces should be ignored
continue;
}
// now replace "\ " to " ".
r = r.replaceAll("\\\\ ", " ");
// Strip leading slashes
while (r.startsWith("/")) {
r = r.substring(1);
}
// Use unix file path separators
r = r.replace('\\', '/');
_modules.add(r);
}
} else {
_modules = null;
}
}
private Object readResolve() {
parseModules();
return this;
}
public String getInstallation() {
return installation;
}
/**
* Gets the source repository path.
* Either URL or local file path.
*/
public String getSource() {
return source;
}
/**
* In-repository branch to follow. Never null.
*/
public String getBranch() {
return branch == null ? "default" : branch;
}
private String getBranch(EnvVars env) {
return branch == null ? "default" : env.expand(branch);
}
public String getSubdir() {
return subdir;
}
private FilePath workspace2Repo(FilePath workspace) {
return subdir != null ? workspace.child(subdir) : workspace;
}
@Override
@SuppressWarnings("DLS_DEAD_LOCAL_STORE")
public HgBrowser getBrowser() {
if (browser == null) {
try {
return new HgWeb(source); // #2406
} catch (MalformedURLException x) {
// forget it
}
}
return browser;
}
/**
* True if we want clean check out each time. This means deleting everything in the repository checkout
* (except <tt>.hg</tt>)
*/
public boolean isClean() {
return clean;
}
/**
* True if we want consider repository a forest
*/
public boolean isForest() {
return forest;
}
private ArgumentListBuilder findHgExe(AbstractBuild<?,?> build, TaskListener listener, boolean allowDebug) throws IOException, InterruptedException {
return findHgExe(build.getBuiltOn(), listener, allowDebug);
}
/**
* @param allowDebug
* If the caller intends to parse the stdout from Mercurial, pass in false to indicate
* that the optional --debug option shall never be activated.
*/
ArgumentListBuilder findHgExe(Node node, TaskListener listener, boolean allowDebug) throws IOException, InterruptedException {
for (MercurialInstallation inst : MercurialInstallation.allInstallations()) {
if (inst.getName().equals(installation)) {
// XXX what about forEnvironment?
ArgumentListBuilder b = new ArgumentListBuilder(inst.executableWithSubstitution(
inst.forNode(node, listener).getHome()));
if (forest) {
String downloadForest = inst.getDownloadForest();
if (downloadForest != null) {
// Uniquify path so if user chooses a different URL it will be downloaded again.
FilePath forestPy = node.getRootPath().child(String.format("forest-%08X.py", downloadForest.hashCode()));
if (!forestPy.exists()) {
listener.getLogger().println("Downloading: " + downloadForest);
InputStream is = new URL(downloadForest).openStream();
try {
forestPy.copyFrom(is);
} finally {
is.close();
}
}
b.add("--config", "extensions.forest=" + forestPy.getRemote());
}
}
if (allowDebug && inst.getDebug()) {
b.add("--debug");
}
return b;
}
}
return new ArgumentListBuilder(getDescriptor().getHgExe());
}
static ProcStarter launch(Launcher launcher) {
return launcher.launch().envs(Collections.singletonMap("HGPLAIN", "true"));
}
@Override
public SCMRevisionState calcRevisionsFromBuild(AbstractBuild<?, ?> build, Launcher launcher, TaskListener listener)
throws IOException, InterruptedException {
// tag action is added during checkout, so this shouldn't be called, but just in case.
HgExe hg = new HgExe(this, launcher, build, listener, build.getEnvironment(listener));
return new MercurialTagAction(hg.tip(workspace2Repo(build.getWorkspace())));
}
private static final String FILES_STYLE = "changeset = 'id:{node}\\nfiles:{files}\\n'\n" + "file = '{file}:'";
@Override
protected PollingResult compareRemoteRevisionWith(AbstractProject<?, ?> project, Launcher launcher, FilePath workspace,
TaskListener listener, SCMRevisionState _baseline) throws IOException, InterruptedException {
MercurialTagAction baseline = (MercurialTagAction)_baseline;
EnvVars env = project.getLastBuild().getEnvironment(listener);
PrintStream output = listener.getLogger();
// XXX do canUpdate check similar to in checkout, and possibly return INCOMPARABLE
// Mercurial requires the style file to be in a file..
Set<String> changedFileNames = new HashSet<String>();
FilePath tmpFile = workspace.createTextTempFile("tmp", "style", FILES_STYLE);
try {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
// Get the list of changed files.
AbstractProject<?,?> _project = project; // javac considers project.getLastBuild() to be a Run
Node node = _project.getLastBuiltOn(); // HUDSON-5984: ugly but matches what AbstractProject.poll uses
ArgumentListBuilder cmd = findHgExe(node, listener, false);
cmd.add(forest ? "fincoming" : "incoming", "--style", tmpFile.getRemote());
cmd.add("--no-merges");
cmd.add("--rev", getBranch(env));
cmd.add("--newest-first");
String cachedSource = cachedSource(node, launcher, listener, true);
if (cachedSource != null) {
cmd.add(cachedSource);
}
joinWithPossibleTimeout(
launch(launcher).cmds(cmd).stdout(new ForkOutputStream(baos, output)).pwd(workspace2Repo(workspace)),
true, listener);
MercurialTagAction cur = parseIncomingOutput(baos, baseline, changedFileNames);
return new PollingResult(baseline,cur,computeDegreeOfChanges(changedFileNames,output));
} finally {
tmpFile.delete();
}
}
static int joinWithPossibleTimeout(ProcStarter proc, boolean useTimeout, final TaskListener listener) throws IOException, InterruptedException {
return useTimeout ? proc.start().joinWithTimeout(/* #4528: not in JDK 5: 1, TimeUnit.HOURS*/60 * 60, TimeUnit.SECONDS, listener) : proc.join();
}
private Change computeDegreeOfChanges(Set<String> changedFileNames, PrintStream output) {
LOGGER.log(FINE, "Changed file names: {0}", changedFileNames);
if (changedFileNames.isEmpty()) {
return Change.NONE;
}
Set<String> depchanges = dependentChanges(changedFileNames);
LOGGER.log(FINE, "Dependent changed file names: {0}", depchanges);
if (depchanges.isEmpty()) {
output.println("Non-dependent changes detected");
return Change.INSIGNIFICANT;
}
output.println("Dependent changes detected");
return Change.SIGNIFICANT;
}
/**
* Filter out the given file name list by picking up changes that are in the modules we care about.
*/
private Set<String> dependentChanges(Set<String> changedFileNames) {
if (_modules == null) {
// Old project created before this feature was added.
return changedFileNames;
}
Set<String> affecting = new HashSet<String>();
for (String changedFile : changedFileNames) {
for (String dependency : _modules) {
if (changedFile.startsWith(dependency)) {
affecting.add(changedFile);
break;
}
}
}
return affecting;
}
private static Pattern FILES_LINE = Pattern.compile("files:(.*)");
private MercurialTagAction parseIncomingOutput(ByteArrayOutputStream output, MercurialTagAction baseline, Set<String> result) throws IOException {
String headId = null; // the tip of the remote revision
BufferedReader in = new BufferedReader(new InputStreamReader(
new ByteArrayInputStream(output.toByteArray())));
String line;
while ((line = in.readLine()) != null) {
Matcher matcher = FILES_LINE.matcher(line);
if (matcher.matches()) {
for (String s : matcher.group(1).split(":")) {
if (s.length() > 0) {
result.add(s);
}
}
}
if (line.startsWith("id:")) {
String id = line.substring(3);
if (headId == null) {
headId = id;
}
if (id.equals(baseline.id)) {
// Trim the baseline changeset and earlier.
// HUDSON-6337 uses --newest-first to try to order them;
// --prune would be better but incoming does not support it.
break;
}
}
}
if (headId==null) {
return baseline; // no new revisions found
}
return new MercurialTagAction(headId);
}
@Override
public boolean checkout(AbstractBuild<?,?> build, Launcher launcher, FilePath workspace, final BuildListener listener, File changelogFile)
throws IOException, InterruptedException {
boolean canUpdate = workspace2Repo(workspace).act(new FileCallable<Boolean>() {
public Boolean invoke(File ws, VirtualChannel channel) throws IOException {
if (!HgRc.getHgRcFile(ws).exists()) {
return false;
}
HgRc hgrc = new HgRc(ws);
return canUpdate(hgrc);
}
private boolean canUpdate(HgRc ini) {
String upstream = ini.getSection("paths").get("default");
if (upstream == null) {
return false;
}
if (upstream.equals(source)) {
return true;
}
if ((upstream + '/').equals(source)) {
return true;
}
if (upstream.equals(source + '/')) {
return true;
}
if (source.startsWith("file:/") && new File(upstream).toURI().toString().equals(source)) {
return true;
}
listener.error(
"Workspace reports paths.default as " + upstream +
"\nwhich looks different than " + source +
"\nso falling back to fresh clone rather than incremental update");
return false;
}
});
if (canUpdate) {
return update(build, launcher, workspace2Repo(workspace), listener, changelogFile);
} else {
return clone(build, launcher, workspace2Repo(workspace), listener, changelogFile);
}
}
/**
* Updates the current repository.
*/
private boolean update(AbstractBuild<?,?> build, Launcher launcher, FilePath repository, BuildListener listener, File changelogFile)
throws InterruptedException, IOException {
EnvVars env = build.getEnvironment(listener);
HgExe hg = new HgExe(this, launcher, build, listener, env);
if(clean) {
if (hg.run(forest ? "fupdate" : "update", "--clean", ".").pwd(repository).join() != 0) {
listener.error("Failed to clobber local modifications");
return false;
}
if (forest) {
StringTokenizer trees = new StringTokenizer(hg.popen(repository, listener, false, new ArgumentListBuilder("ftrees", "--convert")));
while (trees.hasMoreTokens()) {
String tree = trees.nextToken();
if (hg.cleanAll().pwd(tree.equals(".") ? repository : repository.child(tree)).join() != 0) {
listener.error("Failed to clean unversioned files in " + tree);
return false;
}
}
} else {
if (hg.cleanAll().pwd(repository).join() != 0) {
listener.error("Failed to clean unversioned files");
return false;
}
}
}
FilePath hgBundle = new FilePath(repository, "hg.bundle");
// delete the file prior to "hg incoming",
// as one user reported that it causes a failure.
// The error message was "abort: file 'hg.bundle' already exists"
hgBundle.delete();
// calc changelog and create bundle
final FileOutputStream os = new FileOutputStream(changelogFile);
int r;
final String cachedSource;
try {
try {
os.write("<changesets>\n".getBytes());
ArgumentListBuilder args = findHgExe(build, listener, false);
args.add(forest ? "fincoming" : "incoming", "--quiet");
if (!forest) {
args.add("--bundle", "hg.bundle");
}
args.add("--template", MercurialChangeSet.CHANGELOG_TEMPLATE);
args.add("--rev", getBranch(env));
cachedSource = cachedSource(build.getBuiltOn(), launcher, listener, false);
if (cachedSource != null) {
args.add(cachedSource);
}
ByteArrayOutputStream errorLog = new ByteArrayOutputStream();
// mercurial produces text in the platform default encoding, so we need to
// convert it back to UTF-8
WriterOutputStream o = new WriterOutputStream(new OutputStreamWriter(os, "UTF-8"));
try {
r = launch(launcher).cmds(args).envs(env)
.stdout(new ForkOutputStream(o,errorLog)).pwd(repository).join();
} finally {
o.flush(); // make sure to commit all output
}
if(r!=0 && r!=1) {// 0.9.4 returns 1 for no changes
Util.copyStream(new ByteArrayInputStream(errorLog.toByteArray()),listener.getLogger());
listener.error("Failed to determine incoming changes");
return false;
}
} catch (IOException e) {
listener.error("Failed to pull");
e.printStackTrace(listener.getLogger());
return false;
} finally {
os.write("</changesets>".getBytes());
}
} finally {
os.close();
}
// pull
if (r == 0 && (hgBundle.exists() || forest)) {
// if incoming didn't fetch anything, it will return 1. That was for 0.9.3.
// in 0.9.4 apparently it returns 0.
try {
ProcStarter ps;
if (forest) {
ps = hg.run("fpull", "--rev", getBranch(env));
} else {
ps = hg.run("unbundle", "hg.bundle");
}
if(ps.pwd(repository).join()!=0) {
listener.error("Failed to pull");
return false;
}
if (cachedSource != null && build.getNumber() % 100 == 0) {
// Periodically recreate hardlinks to the cache to save disk space.
hg.run("--config", "extensions.relink=", "relink", cachedSource).pwd(repository).join(); // ignore failures
}
if(hg.run(forest ? "fupdate" : "update", "--clean", "--rev", getBranch(env)).pwd(repository).join()!=0) {
listener.error("Failed to update");
return false;
}
} catch (IOException e) {
listener.error("Failed to pull");
e.printStackTrace(listener.getLogger());
return false;
}
}
hgBundle.delete(); // do not leave it in workspace
build.addAction(new MercurialTagAction(hg.tip(repository)));
return true;
}
/**
* Start from scratch and clone the whole repository.
*/
private boolean clone(AbstractBuild<?,?> build, Launcher launcher, FilePath repository, BuildListener listener, File changelogFile)
throws InterruptedException, IOException {
try {
repository.deleteRecursive();
} catch (IOException e) {
e.printStackTrace(listener.error("Failed to clean the repository checkout"));
return false;
}
EnvVars env = build.getEnvironment(listener);
HgExe hg = new HgExe(this,launcher,build.getBuiltOn(),listener,env);
ArgumentListBuilder args = new ArgumentListBuilder();
args.add(forest ? "fclone" : "clone");
args.add("--rev", getBranch(env));
String cachedSource = cachedSource(build.getBuiltOn(), launcher, listener, false);
if (cachedSource != null) {
args.add(cachedSource);
} else {
args.add(source);
}
args.add(repository.getRemote());
try {
if(hg.run(args).join()!=0) {
listener.error("Failed to clone "+source);
return false;
}
} catch (IOException e) {
e.printStackTrace(listener.error("Failed to clone "+source));
return false;
}
if (cachedSource != null) {
FilePath hgrc = repository.child(".hg/hgrc");
if (hgrc.exists()) {
String hgrcText = hgrc.readToString();
if (!hgrcText.contains(cachedSource)) {
listener.error(".hg/hgrc did not contain " + cachedSource + " as expected:\n" + hgrcText);
return false;
}
hgrc.write(hgrcText.replace(cachedSource, source), null);
}
// Passing --rev disables hardlinks, so we need to recreate them:
hg.run("--config", "extensions.relink=", "relink", cachedSource)
.pwd(repository).join(); // ignore failures
}
build.addAction(new MercurialTagAction(hg.tip(repository)));
return createEmptyChangeLog(changelogFile, listener, "changelog");
}
@Override
public void buildEnvVars(AbstractBuild<?,?> build, Map<String, String> env) {
MercurialTagAction a = build.getAction(MercurialTagAction.class);
if (a != null) {
env.put("MERCURIAL_REVISION", a.id);
}
}
@Override
public ChangeLogParser createChangeLogParser() {
return new MercurialChangeLogParser(_modules);
}
@Override
public DescriptorImpl getDescriptor() {
return (DescriptorImpl) super.getDescriptor();
}
public String getModules() {
return modules;
}
static boolean CACHE_LOCAL_REPOS = false;
private @CheckForNull String cachedSource(Node node, Launcher launcher, TaskListener listener, boolean fromPolling) {
if (!CACHE_LOCAL_REPOS && source.matches("(file:|[/\\\\]).+")) {
return null;
}
if (forest) {
// Caching forests not supported yet - too complicated.
return null;
}
boolean useCaches = false;
for (MercurialInstallation inst : MercurialInstallation.allInstallations()) {
if (inst.getName().equals(installation)) {
useCaches = inst.isUseCaches();
break;
}
}
if (!useCaches) {
return null;
}
try {
FilePath cache = Cache.fromURL(source).repositoryCache(this, node, launcher, listener, fromPolling);
if (cache != null) {
return cache.getRemote();
} else {
listener.error("Failed to use repository cache for " + source);
return null;
}
} catch (Exception x) {
x.printStackTrace(listener.error("Failed to use repository cache for " + source));
return null;
}
}
@Extension
public static final class DescriptorImpl extends SCMDescriptor<MercurialSCM> {
private String hgExe;
public DescriptorImpl() {
super(HgBrowser.class);
load();
}
/**
* {@inheritDoc}
*
* Due to compatibility issues with older version we implement this ourselves instead of relying
* on the parent method. Kohsuke implemented a fix for this in the core (r21961), so we may drop
* this function after 1.325 is released.
*
* @todo: remove this function after 1.325 is released.
*
* @see <a href="https://hudson.dev.java.net/issues/show_bug.cgi?id=4514">#4514</a>
* @see <a href="http://fisheye4.atlassian.com/changelog/hudson/trunk/hudson?cs=21961">core fix</a>
*/
@Override
public List<Descriptor<RepositoryBrowser<?>>> getBrowserDescriptors() {
return RepositoryBrowsers.filter(HgBrowser.class);
}
public String getDisplayName() {
return "Mercurial";
}
/**
* Path to mercurial executable.
*/
public String getHgExe() {
if (hgExe == null) {
return "hg";
}
return hgExe;
}
@Override
public SCM newInstance(StaplerRequest req, JSONObject formData) throws FormException {
return super.newInstance(req, formData);
}
@Override
public boolean configure(StaplerRequest req, JSONObject json) throws FormException {
hgExe = req.getParameter("mercurial.hgExe");
save();
return true;
}
}
private static final long serialVersionUID = 1L;
private static final Logger LOGGER = Logger.getLogger(MercurialSCM.class.getName());
}