Support for the pre-commit hook

Introduce support for the pre-commit hook into JGit, along with the
--no-verify commit command option to bypass it when rebasing /
cherry-picking.

Change-Id: If86df98577fa56c5c03d783579c895a38bee9d18
Signed-off-by: Laurent Goubet <laurent.goubet@obeo.fr>
Signed-off-by: Matthias Sohn <matthias.sohn@sap.com>
diff --git a/org.eclipse.jgit.java7.test/META-INF/MANIFEST.MF b/org.eclipse.jgit.java7.test/META-INF/MANIFEST.MF
index a6e8f67..182c267 100644
--- a/org.eclipse.jgit.java7.test/META-INF/MANIFEST.MF
+++ b/org.eclipse.jgit.java7.test/META-INF/MANIFEST.MF
@@ -6,6 +6,7 @@
 Bundle-Vendor: %provider_name
 Bundle-RequiredExecutionEnvironment: JavaSE-1.7
 Import-Package: org.eclipse.jgit.api;version="[3.7.0,3.8.0)",
+ org.eclipse.jgit.api.errors;version="[3.7.0,3.8.0)",
  org.eclipse.jgit.diff;version="[3.7.0,3.8.0)",
  org.eclipse.jgit.dircache;version="[3.7.0,3.8.0)",
  org.eclipse.jgit.internal.storage.file;version="3.7.0",
diff --git a/org.eclipse.jgit.java7.test/src/org/eclipse/jgit/util/HookTest.java b/org.eclipse.jgit.java7.test/src/org/eclipse/jgit/util/HookTest.java
index 73086ab..9655088 100644
--- a/org.eclipse.jgit.java7.test/src/org/eclipse/jgit/util/HookTest.java
+++ b/org.eclipse.jgit.java7.test/src/org/eclipse/jgit/util/HookTest.java
@@ -44,12 +44,15 @@
 
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertNull;
+import static org.junit.Assert.fail;
 
 import java.io.ByteArrayOutputStream;
 import java.io.File;
 import java.io.IOException;
 import java.io.PrintStream;
 
+import org.eclipse.jgit.api.Git;
+import org.eclipse.jgit.api.errors.RejectCommitException;
 import org.eclipse.jgit.junit.JGitTestUtil;
 import org.eclipse.jgit.junit.RepositoryTestCase;
 import org.junit.Assume;
@@ -91,6 +94,33 @@ public void testRunHook() throws Exception {
 				res.getStatus());
 	}
 
+	@Test
+	public void testPreCommitHook() throws Exception {
+		assumeSupportedPlatform();
+
+		Hook h = Hook.PRE_COMMIT;
+		writeHookFile(h.getName(),
+				"#!/bin/sh\necho \"test\"\n\necho 1>&2 \"stderr\"\nexit 1");
+		Git git = Git.wrap(db);
+		String path = "a.txt";
+		writeTrashFile(path, "content");
+		git.add().addFilepattern(path).call();
+		ByteArrayOutputStream out = new ByteArrayOutputStream();
+		try {
+			git.commit().setMessage("commit")
+					.setHookOutputStream(new PrintStream(out)).call();
+			fail("expected pre-commit hook to abort commit");
+		} catch (RejectCommitException e) {
+			assertEquals("unexpected error message from pre-commit hook",
+					"Commit rejected by \"pre-commit\" hook.\nstderr\n",
+					e.getMessage());
+			assertEquals("unexpected output from pre-commit hook", "test\n",
+					out.toString());
+		} catch (Throwable e) {
+			fail("unexpected exception thrown by pre-commit hook: " + e);
+		}
+	}
+
 	private File writeHookFile(final String name, final String data)
 			throws IOException {
 		File path = new File(db.getWorkTree() + "/.git/hooks/", name);
diff --git a/org.eclipse.jgit/resources/org/eclipse/jgit/internal/JGitText.properties b/org.eclipse.jgit/resources/org/eclipse/jgit/internal/JGitText.properties
index 71ec6b2..efe6105 100644
--- a/org.eclipse.jgit/resources/org/eclipse/jgit/internal/JGitText.properties
+++ b/org.eclipse.jgit/resources/org/eclipse/jgit/internal/JGitText.properties
@@ -104,6 +104,7 @@
 commitMessageNotSpecified=commit message not specified
 commitOnRepoWithoutHEADCurrentlyNotSupported=Commit on repo without HEAD currently not supported
 commitAmendOnInitialNotPossible=Amending is not possible on initial commit.
+commitRejectedByHook=Commit rejected by "{0}" hook.\n{1}
 compressingObjects=Compressing objects
 connectionFailed=connection failed
 connectionTimeOut=Connection time out: {0}
diff --git a/org.eclipse.jgit/src/org/eclipse/jgit/api/CherryPickCommand.java b/org.eclipse.jgit/src/org/eclipse/jgit/api/CherryPickCommand.java
index 30dcaa5..d4eb0b3 100644
--- a/org.eclipse.jgit/src/org/eclipse/jgit/api/CherryPickCommand.java
+++ b/org.eclipse.jgit/src/org/eclipse/jgit/api/CherryPickCommand.java
@@ -169,7 +169,8 @@ public CherryPickResult call() throws GitAPIException, NoMessageException,
 								.setMessage(srcCommit.getFullMessage())
 								.setReflogComment(reflogPrefix + " " //$NON-NLS-1$
 										+ srcCommit.getShortMessage())
-								.setAuthor(srcCommit.getAuthorIdent()).call();
+								.setAuthor(srcCommit.getAuthorIdent())
+								.setNoVerify(true).call();
 					cherryPickedRefs.add(src);
 				} else {
 					if (merger.failed())
diff --git a/org.eclipse.jgit/src/org/eclipse/jgit/api/CommitCommand.java b/org.eclipse.jgit/src/org/eclipse/jgit/api/CommitCommand.java
index f8406e0..93400aa 100644
--- a/org.eclipse.jgit/src/org/eclipse/jgit/api/CommitCommand.java
+++ b/org.eclipse.jgit/src/org/eclipse/jgit/api/CommitCommand.java
@@ -42,8 +42,10 @@
  */
 package org.eclipse.jgit.api;
 
+import java.io.ByteArrayOutputStream;
 import java.io.IOException;
 import java.io.InputStream;
+import java.io.PrintStream;
 import java.text.MessageFormat;
 import java.util.ArrayList;
 import java.util.Collections;
@@ -56,6 +58,7 @@
 import org.eclipse.jgit.api.errors.NoFilepatternException;
 import org.eclipse.jgit.api.errors.NoHeadException;
 import org.eclipse.jgit.api.errors.NoMessageException;
+import org.eclipse.jgit.api.errors.RejectCommitException;
 import org.eclipse.jgit.api.errors.UnmergedPathsException;
 import org.eclipse.jgit.api.errors.WrongRepositoryStateException;
 import org.eclipse.jgit.dircache.DirCache;
@@ -84,6 +87,9 @@
 import org.eclipse.jgit.treewalk.FileTreeIterator;
 import org.eclipse.jgit.treewalk.TreeWalk;
 import org.eclipse.jgit.util.ChangeIdUtil;
+import org.eclipse.jgit.util.FS;
+import org.eclipse.jgit.util.Hook;
+import org.eclipse.jgit.util.ProcessResult;
 
 /**
  * A class used to execute a {@code Commit} command. It has setters for all
@@ -120,10 +126,19 @@ public class CommitCommand extends GitCommand<RevCommit> {
 	private String reflogComment;
 
 	/**
+	 * Setting this option bypasses the {@link Hook#PRE_COMMIT pre-commit} and
+	 * {@link Hook#COMMIT_MSG commit-msg} hooks.
+	 */
+	private boolean noVerify;
+
+	private PrintStream hookOutRedirect;
+
+	/**
 	 * @param repo
 	 */
 	protected CommitCommand(Repository repo) {
 		super(repo);
+		hookOutRedirect = System.out;
 	}
 
 	/**
@@ -144,11 +159,14 @@ protected CommitCommand(Repository repo) {
 	 *             else
 	 * @throws WrongRepositoryStateException
 	 *             when repository is not in the right state for committing
+	 * @throws RejectCommitException
+	 *             if there are either pre-commit or commit-msg hooks present in
+	 *             the repository and at least one of them rejects the commit.
 	 */
 	public RevCommit call() throws GitAPIException, NoHeadException,
 			NoMessageException, UnmergedPathsException,
-			ConcurrentRefUpdateException,
-			WrongRepositoryStateException {
+			ConcurrentRefUpdateException, WrongRepositoryStateException,
+			RejectCommitException {
 		checkCallable();
 		Collections.sort(only);
 
@@ -160,6 +178,23 @@ public RevCommit call() throws GitAPIException, NoHeadException,
 				throw new WrongRepositoryStateException(MessageFormat.format(
 						JGitText.get().cannotCommitOnARepoWithState,
 						state.name()));
+
+			if (!noVerify) {
+				final ByteArrayOutputStream errorByteArray = new ByteArrayOutputStream();
+				final PrintStream hookErrRedirect = new PrintStream(
+						errorByteArray);
+				ProcessResult preCommitHookResult = FS.DETECTED.runIfPresent(
+						repo, Hook.PRE_COMMIT, new String[0], hookOutRedirect,
+						hookErrRedirect, null);
+				if (preCommitHookResult.getStatus() == ProcessResult.Status.OK
+						&& preCommitHookResult.getExitCode() != 0) {
+					String errorMessage = MessageFormat.format(
+							JGitText.get().commitRejectedByHook, Hook.PRE_COMMIT.getName(),
+							errorByteArray.toString());
+					throw new RejectCommitException(errorMessage);
+				}
+			}
+
 			processOptions(state, rw);
 
 			if (all && !repo.isBare() && repo.getWorkTree() != null) {
@@ -733,4 +768,36 @@ public CommitCommand setReflogComment(String reflogComment) {
 		return this;
 	}
 
+	/**
+	 * Sets the {@link #noVerify} option on this commit command.
+	 * <p>
+	 * Both the {@link Hook#PRE_COMMIT pre-commit} and {@link Hook#COMMIT_MSG
+	 * commit-msg} hooks can block a commit by their return value; setting this
+	 * option to <code>true</code> will bypass these two hooks.
+	 * </p>
+	 *
+	 * @param noVerify
+	 *            Whether this commit should be verified by the pre-commit and
+	 *            commit-msg hooks.
+	 * @return {@code this}
+	 * @since 3.7
+	 */
+	public CommitCommand setNoVerify(boolean noVerify) {
+		this.noVerify = noVerify;
+		return this;
+	}
+
+	/**
+	 * Set the output stream for hook scripts executed by this command. If not
+	 * set it defaults to {@code System.out}.
+	 *
+	 * @param hookStdOut
+	 *            the output stream for hook scripts executed by this command
+	 * @return {@code this}
+	 * @since 3.7
+	 */
+	public CommitCommand setHookOutputStream(PrintStream hookStdOut) {
+		this.hookOutRedirect = hookStdOut;
+		return this;
+	}
 }
diff --git a/org.eclipse.jgit/src/org/eclipse/jgit/api/RebaseCommand.java b/org.eclipse.jgit/src/org/eclipse/jgit/api/RebaseCommand.java
index 47424a9..7d3e823 100644
--- a/org.eclipse.jgit/src/org/eclipse/jgit/api/RebaseCommand.java
+++ b/org.eclipse.jgit/src/org/eclipse/jgit/api/RebaseCommand.java
@@ -462,7 +462,7 @@ private RebaseResult processStep(RebaseTodoLine step, boolean shouldPick)
 			String newMessage = interactiveHandler
 					.modifyCommitMessage(oldMessage);
 			newHead = new Git(repo).commit().setMessage(newMessage)
-					.setAmend(true).call();
+					.setAmend(true).setNoVerify(true).call();
 			return null;
 		case EDIT:
 			rebaseState.createFile(AMEND, commitToPick.name());
@@ -768,15 +768,14 @@ private RevCommit squashIntoPrevious(boolean sequenceContainsSquash,
 			}
 			retNewHead = new Git(repo).commit()
 					.setMessage(stripCommentLines(commitMessage))
-					.setAmend(true).call();
+					.setAmend(true).setNoVerify(true).call();
 			rebaseState.getFile(MESSAGE_SQUASH).delete();
 			rebaseState.getFile(MESSAGE_FIXUP).delete();
 
 		} else {
 			// Next step is either Squash or Fixup
-			retNewHead = new Git(repo).commit()
-					.setMessage(commitMessage).setAmend(true)
-					.call();
+			retNewHead = new Git(repo).commit().setMessage(commitMessage)
+					.setAmend(true).setNoVerify(true).call();
 		}
 		return retNewHead;
 	}
diff --git a/org.eclipse.jgit/src/org/eclipse/jgit/api/errors/RejectCommitException.java b/org.eclipse.jgit/src/org/eclipse/jgit/api/errors/RejectCommitException.java
new file mode 100644
index 0000000..6036a27
--- /dev/null
+++ b/org.eclipse.jgit/src/org/eclipse/jgit/api/errors/RejectCommitException.java
@@ -0,0 +1,61 @@
+/*
+ * Copyright (C) 2015 Obeo.
+ * and other copyright owners as documented in the project's IP log.
+ *
+ * This program and the accompanying materials are made available
+ * under the terms of the Eclipse Distribution License v1.0 which
+ * accompanies this distribution, is reproduced below, and is
+ * available at http://www.eclipse.org/org/documents/edl-v10.php
+ *
+ * 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 the Eclipse Foundation, Inc. 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 COPYRIGHT OWNER 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 org.eclipse.jgit.api.errors;
+
+/**
+ * Exception thrown when a commit is rejected by a hook (either
+ * {@link org.eclipse.jgit.util.Hook#PRE_COMMIT pre-commit} or
+ * {@link org.eclipse.jgit.util.Hook#COMMIT_MSG commit-msg}).
+ *
+ * @since 3.7
+ */
+public class RejectCommitException extends GitAPIException {
+	private static final long serialVersionUID = 1L;
+
+	/**
+	 * @param message
+	 */
+	public RejectCommitException(String message) {
+		super(message);
+	}
+}
diff --git a/org.eclipse.jgit/src/org/eclipse/jgit/internal/JGitText.java b/org.eclipse.jgit/src/org/eclipse/jgit/internal/JGitText.java
index 138f40f..8c81c95 100644
--- a/org.eclipse.jgit/src/org/eclipse/jgit/internal/JGitText.java
+++ b/org.eclipse.jgit/src/org/eclipse/jgit/internal/JGitText.java
@@ -163,6 +163,7 @@ public static JGitText get() {
 	/***/ public String commitMessageNotSpecified;
 	/***/ public String commitOnRepoWithoutHEADCurrentlyNotSupported;
 	/***/ public String commitAmendOnInitialNotPossible;
+	/***/ public String commitRejectedByHook;
 	/***/ public String compressingObjects;
 	/***/ public String connectionFailed;
 	/***/ public String connectionTimeOut;