blob: 5fd6843706d65ad341b42a969e59f0172e485365 [file] [log] [blame]
/*******************************************************************************
* Copyright (c) 2012 Sonatype Inc. and others.
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the Eclipse Public License v1.0
* which accompanies this distribution, and is available at
* http://www.eclipse.org/legal/epl-v10.html
*
* Contributors:
* Sonatype Inc. - initial API and implementation
*******************************************************************************/
package org.eclipse.tycho.extras.buildtimestamp.jgit;
import java.io.File;
import java.io.IOException;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Set;
import org.apache.maven.execution.MavenSession;
import org.apache.maven.plugin.MojoExecution;
import org.apache.maven.plugin.MojoExecutionException;
import org.apache.maven.project.MavenProject;
import org.codehaus.plexus.component.annotations.Component;
import org.codehaus.plexus.component.annotations.Requirement;
import org.codehaus.plexus.logging.Logger;
import org.codehaus.plexus.util.xml.Xpp3Dom;
import org.eclipse.jgit.api.Status;
import org.eclipse.jgit.lib.Constants;
import org.eclipse.jgit.lib.IndexDiff;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.revwalk.RevCommit;
import org.eclipse.jgit.revwalk.RevWalk;
import org.eclipse.jgit.storage.file.FileRepositoryBuilder;
import org.eclipse.jgit.submodule.SubmoduleWalk.IgnoreSubmoduleMode;
import org.eclipse.jgit.treewalk.FileTreeIterator;
import org.eclipse.jgit.treewalk.filter.AndTreeFilter;
import org.eclipse.jgit.treewalk.filter.TreeFilter;
import org.eclipse.tycho.buildversion.BuildTimestampProvider;
/**
* Build timestamp provider that returns date of the most recent git commit that touches any file
* under project basedir. Because this timestamp provider is meant to be used for reproducible
* builds, by default an exception is thrown if <code>git status</code> is not clean (i.e.
* uncommitted changes are detected).
* <p/>
* If uncommitted changes should be tolerated with a warning, configure
*
* <pre>
* &lt;jgit.dirtyWorkingTree&gt;warning&lt;/jgit.dirtyWorkingTree&gt;
* </pre>
*
* In this case, this timestamp provider will delegate to the default timestamp provider which uses
* the current build timestamp.
* <p/>
*
* For additional flexibility, some files can be ignored using gitignore patterns specified in
* &ltjgit.ignore> element of tycho-packaging-plugin configuration block.
*
* <p>
* Typical usage
*
* <pre>
* ...
* &lt;plugin&gt;
* &lt;groupId&gt;org.eclipse.tycho&lt;/groupId&gt;
* &lt;artifactId&gt;tycho-packaging-plugin&lt;/artifactId&gt;
* &lt;version&gt;${tycho-version}&lt;/version&gt;
* &lt;dependencies&gt;
* &lt;dependency&gt;
* &lt;groupId&gt;org.eclipse.tycho.extras&lt;/groupId&gt;
* &lt;artifactId&gt;tycho-buildtimestamp-jgit&lt;/artifactId&gt;
* &lt;version&gt;${tycho-version}&lt;/version&gt;
* &lt;/dependency&gt;
* &lt;/dependencies&gt;
* &lt;configuration&gt;
* &lt;timestampProvider&gt;jgit&lt;/timestampProvider&gt;
* &lt;jgit.ignore>pom.xml&lt;/jgit.ignore>
* &lt;/configuration&gt;
* &lt;/plugin&gt;
* ...
* </pre>
*/
@Component(role = BuildTimestampProvider.class, hint = "jgit")
public class JGitBuildTimestampProvider implements BuildTimestampProvider {
@Requirement(hint = "default")
private BuildTimestampProvider defaultTimestampProvider;
@Requirement
private Logger logger;
private static enum DirtyBehavior {
ERROR, WARNING, IGNORE;
public static DirtyBehavior getDirtyWorkingTreeBehaviour(MojoExecution execution) {
final DirtyBehavior defaultBehaviour = ERROR;
Xpp3Dom pluginConfiguration = (Xpp3Dom) execution.getPlugin().getConfiguration();
if (pluginConfiguration == null) {
return defaultBehaviour;
}
Xpp3Dom dirtyWorkingTreeDom = pluginConfiguration.getChild("jgit.dirtyWorkingTree");
if (dirtyWorkingTreeDom == null) {
return defaultBehaviour;
}
String value = dirtyWorkingTreeDom.getValue();
if (value == null) {
return defaultBehaviour;
}
value = value.trim();
if ("warning".equals(value)) {
return WARNING;
} else if ("ignore".equals(value)) {
return IGNORE;
}
return defaultBehaviour;
}
}
@Override
public Date getTimestamp(MavenSession session, MavenProject project, MojoExecution execution)
throws MojoExecutionException {
FileRepositoryBuilder builder = new FileRepositoryBuilder() //
.readEnvironment() //
.findGitDir(project.getBasedir()) //
.setMustExist(true);
if (builder.getGitDir() == null) {
throw new MojoExecutionException("No git repository found searching upwards from " + project.getBasedir());
}
try {
try (Repository repository = builder.build()) {
String relPath = getRelPath(repository, project);
TreeFilter pathFilter = createPathFilter(relPath, execution);
ObjectId headId = repository.resolve(Constants.HEAD);
if (headId == null) {
String message = "Git repository without HEAD on " + project.getBasedir()
+ ". You can make a first commit to solve that.";
logger.warn(message);
logger.warn("Fallback to default timestamp provider");
return defaultTimestampProvider.getTimestamp(session, project, execution);
}
DirtyBehavior dirtyBehaviour = DirtyBehavior.getDirtyWorkingTreeBehaviour(execution);
if (dirtyBehaviour != DirtyBehavior.IGNORE) {
// 1. check if 'git status' is clean for relPath
IndexDiff diff = new IndexDiff(repository, headId, new FileTreeIterator(repository));
// Ignore all the submodules (together with the pathFilter this will ignore changes done in not related submodules #480951)
diff.setIgnoreSubmoduleMode(IgnoreSubmoduleMode.ALL);
if (pathFilter != null) {
diff.setFilter(pathFilter);
}
diff.diff();
Status status = new Status(diff);
if (!status.isClean()) {
String message = "Working tree is dirty.\ngit status " + (relPath != null ? relPath : "")
+ ":\n" + toGitStatusStyleOutput(diff);
if (dirtyBehaviour == DirtyBehavior.WARNING) {
logger.warn(message);
logger.warn("Fallback to default timestamp provider");
return defaultTimestampProvider.getTimestamp(session, project, execution);
} else {
throw new MojoExecutionException(message + "\n"
+ "You are trying to use tycho-buildtimestamp-jgit on a directory that has uncommitted changes (see details above)."
+ "\nEither commit all changes/add files to .gitignore, or enable fallback to default timestamp provider by configuring "
+ "\njgit.dirtyWorkingTree=warning for tycho-packaging-plugin");
}
}
}
// 2. get latest commit for relPath
try (RevWalk walk = new RevWalk(repository)) {
if (pathFilter != null) {
walk.setTreeFilter(AndTreeFilter.create(pathFilter, TreeFilter.ANY_DIFF));
}
walk.markStart(walk.parseCommit(headId));
walk.setRewriteParents(false);
RevCommit commit = walk.next();
// When dirtyBehaviour==ignore and no commit was ever done,
// the commit is null, so we fallback to the defaultTimestampProvider
if (commit == null) {
logger.info(
"Fallback to default timestamp provider, because no commit could be found for that project (Shared but not committed yet).");
return defaultTimestampProvider.getTimestamp(session, project, execution);
}
return new Date(commit.getCommitTime() * 1000L);
}
}
} catch (IOException e) {
throw new MojoExecutionException("Could not determine git commit timestamp", e);
}
}
private static TreeFilter createPathFilter(String relPath, MojoExecution execution) throws IOException {
if (relPath != null && relPath.length() > 0) {
return new PathFilter(relPath, getIgnoreFilter(execution));
}
return null;
}
private static String getIgnoreFilter(MojoExecution execution) {
Xpp3Dom pluginConfiguration = (Xpp3Dom) execution.getPlugin().getConfiguration();
if (pluginConfiguration == null) {
return null;
}
Xpp3Dom ignoreDom = pluginConfiguration.getChild("jgit.ignore");
if (ignoreDom == null) {
return null;
}
return ignoreDom.getValue().trim();
}
private String getRelPath(Repository repository, MavenProject project) throws IOException {
String workTree = repository.getWorkTree().getCanonicalPath();
String path = project.getBasedir().getCanonicalPath();
if (!path.startsWith(workTree)) {
throw new IOException(project + " is not in git repository working tree " + repository.getWorkTree());
}
path = path.substring(workTree.length());
if (path.startsWith(File.separator)) {
path = path.substring(File.separator.length());
}
// git stores paths unix-style
path = path.replace(File.separatorChar, '/');
return path;
}
private static String toGitStatusStyleOutput(IndexDiff diff) {
StringWriter sw = new StringWriter();
PrintWriter pw = new PrintWriter(sw);
List<String> toBeCommitted = new ArrayList<>(diff.getAdded());
toBeCommitted.addAll(diff.getChanged());
toBeCommitted.addAll(diff.getRemoved());
if (toBeCommitted.size() > 0) {
pw.println("Changes to be committed:");
printList(pw, "\tnew file: ", diff.getAdded());
printList(pw, "\tmodified: ", diff.getChanged());
printList(pw, "\tdeleted: ", diff.getRemoved());
}
List<String> notStaged = new ArrayList<>(diff.getModified());
notStaged.addAll(diff.getMissing());
if (notStaged.size() > 0) {
pw.println();
pw.println("Changes not staged for commit:");
printList(pw, "\tmodified: ", diff.getModified());
printList(pw, "\tdeleted: ", diff.getMissing());
}
if (diff.getConflicting().size() > 0) {
pw.println();
pw.println("Conflicting files:");
printList(pw, "\tconflict: ", diff.getConflicting());
}
if (diff.getUntracked().size() > 0) {
pw.println();
pw.println("Untracked files:");
printList(pw, "\t", diff.getUntracked());
}
return sw.toString();
}
private static void printList(PrintWriter witer, String prefix, Set<String> files) {
for (String file : files) {
witer.println(prefix + file);
}
}
}