Push: show pre-push hook output in PushResultDialog

Capture the output of the pre-push hook, if any, and show it in the
result dialog.

Bug: 580910
Change-Id: I56326da9870911e70cff4d14daada14e1438e1ca
Signed-off-by: Thomas Wolf <twolf@apache.org>
diff --git a/org.eclipse.egit.core/src/org/eclipse/egit/core/internal/CoreText.java b/org.eclipse.egit.core/src/org/eclipse/egit/core/internal/CoreText.java
index 6016d2a..87b09d5 100644
--- a/org.eclipse.egit.core/src/org/eclipse/egit/core/internal/CoreText.java
+++ b/org.eclipse.egit.core/src/org/eclipse/egit/core/internal/CoreText.java
@@ -424,6 +424,9 @@
 	public static String PullOperation_TaskName;
 
 	/** */
+	public static String PushOperation_ForUri;
+
+	/** */
 	public static String PushOperation_InternalExceptionOccurredMessage;
 
 	/** */
diff --git a/org.eclipse.egit.core/src/org/eclipse/egit/core/internal/coretext.properties b/org.eclipse.egit.core/src/org/eclipse/egit/core/internal/coretext.properties
index ed7df68..9f7eee1 100644
--- a/org.eclipse.egit.core/src/org/eclipse/egit/core/internal/coretext.properties
+++ b/org.eclipse.egit.core/src/org/eclipse/egit/core/internal/coretext.properties
@@ -159,6 +159,7 @@
 PullOperation_DetachedHeadMessage=No local branch is currently checked out
 PullOperation_PullNotConfiguredMessage=The current branch is not configured for pull
 PullOperation_TaskName=Pulling {0,choice,1#1 repository|1<{0} repositories}
+PushOperation_ForUri=For URI {0}:
 PushOperation_InternalExceptionOccurredMessage=An internal Exception occurred during push: {0}
 PushOperation_ExceptionOccurredDuringPushOnUriMessage=An exception occurred during push on URI {0}: {1}
 PushOperation_resultCancelled=Operation was cancelled.
diff --git a/org.eclipse.egit.core/src/org/eclipse/egit/core/op/PushOperation.java b/org.eclipse.egit.core/src/org/eclipse/egit/core/op/PushOperation.java
index 4b36726..f4c247c 100644
--- a/org.eclipse.egit.core/src/org/eclipse/egit/core/op/PushOperation.java
+++ b/org.eclipse.egit.core/src/org/eclipse/egit/core/op/PushOperation.java
@@ -3,7 +3,7 @@
  * Copyright (C) 2011, Mathias Kinzler <mathias.kinzler@sap.com>
  * Copyright (C) 2012, Robin Stocker <robin@nibor.org>
  * Copyright (C) 2015, Stephan Hackstedt <stephan.hackstedt@googlemail.com>
- * Copyright (C) 2016, 2022 Thomas Wolf <thomas.wolf@paranor.ch>
+ * Copyright (C) 2016, 2022 Thomas Wolf <twolf@apache.org>
  *
  * All rights reserved. This program and the accompanying materials
  * are made available under the terms of the Eclipse Public License 2.0
@@ -14,9 +14,13 @@
  *******************************************************************************/
 package org.eclipse.egit.core.op;
 
+import java.io.ByteArrayOutputStream;
 import java.io.OutputStream;
+import java.io.PrintStream;
 import java.lang.reflect.InvocationTargetException;
 import java.net.URISyntaxException;
+import java.nio.charset.Charset;
+import java.text.MessageFormat;
 import java.util.Collection;
 import java.util.List;
 
@@ -39,6 +43,7 @@
 import org.eclipse.jgit.transport.RemoteRefUpdate.Status;
 import org.eclipse.jgit.transport.Transport;
 import org.eclipse.jgit.transport.URIish;
+import org.eclipse.jgit.util.SystemReader;
 import org.eclipse.osgi.util.NLS;
 
 /**
@@ -230,7 +235,11 @@
 
 		operationResult = new PushOperationResult();
 		try (Git git = new Git(localDb)) {
-			if (specification != null)
+			Charset hookCharset = SystemReader.getInstance()
+					.getDefaultCharset();
+			if (specification != null) {
+				StringBuilder allHookOutputs = new StringBuilder();
+				StringBuilder allHookErrors = new StringBuilder();
 				for (final URIish uri : specification.getURIs()) {
 					if (progress.isCanceled()) {
 						operationResult.addOperationResult(uri,
@@ -251,13 +260,27 @@
 							transport.setCredentialsProvider(
 									credentialsProvider);
 						}
-						PushResult result = transport.push(gitSubMonitor,
-								refUpdates, out);
-
-						operationResult.addOperationResult(result.getURI(),
-								result);
-						specification.addURIRefUpdates(result.getURI(),
-								result.getRemoteUpdates());
+						try (ByteArrayOutputStream hookOutBytes = new ByteArrayOutputStream();
+								ByteArrayOutputStream hookErrBytes = new ByteArrayOutputStream();
+								PrintStream stdout = new PrintStream(hookOutBytes, true, hookCharset);
+								PrintStream stderr = new PrintStream(hookErrBytes, true, hookCharset)) {
+							transport.setHookOutputStream(stdout);
+							transport.setHookErrorStream(stderr);
+							PushResult result = transport.push(gitSubMonitor,
+									refUpdates, out);
+							stdout.flush();
+							stderr.flush();
+							addHookMessage(result.getURI(),
+									hookOutBytes.toString(hookCharset),
+									allHookOutputs);
+							addHookMessage(result.getURI(),
+									hookErrBytes.toString(hookCharset),
+									allHookErrors);
+							operationResult.addOperationResult(result.getURI(),
+									result);
+							specification.addURIRefUpdates(result.getURI(),
+									result.getRemoteUpdates());
+						}
 					} catch (JGitInternalException e) {
 						String errorMessage = e.getCause() != null
 								? e.getCause().getMessage() : e.getMessage();
@@ -269,10 +292,17 @@
 						handleException(uri, e, e.getMessage());
 					}
 				}
-			else {
+				operationResult.setHookOutput(allHookOutputs.toString(),
+						allHookErrors.toString());
+			} else {
 				final EclipseGitProgressTransformer gitMonitor = new EclipseGitProgressTransformer(
 						progress.newChild(totalWork));
-				try {
+				try (ByteArrayOutputStream hookOutBytes = new ByteArrayOutputStream();
+						ByteArrayOutputStream hookErrBytes = new ByteArrayOutputStream();
+						PrintStream stdout = new PrintStream(hookOutBytes, true,
+								hookCharset);
+						PrintStream stderr = new PrintStream(hookErrBytes, true,
+								hookCharset)) {
 					Iterable<PushResult> results = git.push()
 							.setRemote(remoteName)
 							.setDryRun(dryRun)
@@ -280,7 +310,14 @@
 							.setProgressMonitor(gitMonitor)
 							.setCredentialsProvider(credentialsProvider)
 							.setOutputStream(out)
+							.setHookOutputStream(stdout)
+							.setHookErrorStream(stderr)
 							.call();
+					stdout.flush();
+					stderr.flush();
+					operationResult.setHookOutput(
+							hookOutBytes.toString(hookCharset),
+							hookErrBytes.toString(hookCharset));
 					for (PushResult result : results) {
 						operationResult.addOperationResult(result.getURI(),
 								result);
@@ -301,6 +338,18 @@
 		}
 	}
 
+	private void addHookMessage(URIish uri, String msg, StringBuilder all) {
+		if (!msg.isEmpty()) {
+			if (all.length() > 0 && all.charAt(all.length() - 1) != '\n') {
+				all.append('\n');
+			}
+			all.append(
+					MessageFormat.format(CoreText.PushOperation_ForUri, uri));
+			all.append('\n');
+			all.append(msg);
+		}
+	}
+
 	private void handleException(final URIish uri, Exception e,
 			String userMessage) {
 		String uriString;
diff --git a/org.eclipse.egit.core/src/org/eclipse/egit/core/op/PushOperationResult.java b/org.eclipse.egit.core/src/org/eclipse/egit/core/op/PushOperationResult.java
index 5fe23f0..5dbbaed 100644
--- a/org.eclipse.egit.core/src/org/eclipse/egit/core/op/PushOperationResult.java
+++ b/org.eclipse.egit.core/src/org/eclipse/egit/core/op/PushOperationResult.java
@@ -1,5 +1,5 @@
 /*******************************************************************************
- * Copyright (C) 2008, Marek Zawirski <marek.zawirski@gmail.com>
+ * Copyright (C) 2008, 2022 Marek Zawirski <marek.zawirski@gmail.com> and others.
  *
  * All rights reserved. This program and the accompanying materials
  * are made available under the terms of the Eclipse Public License 2.0
@@ -33,8 +33,13 @@
  * @see PushOperation
  */
 public class PushOperationResult {
+
 	private LinkedHashMap<URIish, Entry> urisEntries;
 
+	private String hookOut;
+
+	private String hookErr;
+
 	/**
 	 * Construct empty push operation result.
 	 */
@@ -118,6 +123,37 @@
 	}
 
 	/**
+	 * Sets the output of a pre-push hook.
+	 *
+	 * @param stdout
+	 *            of the pre-push hook
+	 * @param stderr
+	 *            of the pre-push hook
+	 */
+	public void setHookOutput(String stdout, String stderr) {
+		hookOut = stdout;
+		hookErr = stderr;
+	}
+
+	/**
+	 * Retrieves the stdout output of a pre-push hook, if any.
+	 *
+	 * @return the hook's output to stdout, or an empty string
+	 */
+	public String getHookStdOut() {
+		return hookOut == null ? "" : hookOut; //$NON-NLS-1$
+	}
+
+	/**
+	 * Retrieves the stderr output of a pre-push hook, if any.
+	 *
+	 * @return the hook's output to stderr, or an empty string
+	 */
+	public String getHookStdErr() {
+		return hookErr == null ? "" : hookErr; //$NON-NLS-1$
+	}
+
+	/**
 	 * @return string being list of failed URIs with their error messages.
 	 */
 	public String getErrorStringForAllURis() {
diff --git a/org.eclipse.egit.ui.test/src/org/eclipse/egit/ui/internal/push/PushToUpstreamTest.java b/org.eclipse.egit.ui.test/src/org/eclipse/egit/ui/internal/push/PushToUpstreamTest.java
index d0803cc..9ce6c47 100644
--- a/org.eclipse.egit.ui.test/src/org/eclipse/egit/ui/internal/push/PushToUpstreamTest.java
+++ b/org.eclipse.egit.ui.test/src/org/eclipse/egit/ui/internal/push/PushToUpstreamTest.java
@@ -1,5 +1,6 @@
 /*******************************************************************************
  * Copyright (c) 2014, 2022 Robin Stocker <robin@nibor.org> and others.
+ *
  * All rights reserved. This program and the accompanying materials
  * are made available under the terms of the Eclipse Public License 2.0
  * which accompanies this distribution, and is available at
@@ -12,10 +13,15 @@
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
 
 import java.io.File;
+import java.nio.file.Files;
+import java.text.MessageFormat;
 import java.util.concurrent.TimeUnit;
+import java.util.stream.Collectors;
 
+import org.eclipse.egit.core.internal.CoreText;
 import org.eclipse.egit.core.op.BranchOperation;
 import org.eclipse.egit.core.op.CreateLocalBranchOperation;
 import org.eclipse.egit.ui.JobFamilies;
@@ -25,6 +31,7 @@
 import org.eclipse.egit.ui.test.JobJoiner;
 import org.eclipse.egit.ui.test.TestUtil;
 import org.eclipse.jgit.lib.ConfigConstants;
+import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.swtbot.swt.finder.SWTBot;
@@ -70,6 +77,46 @@
 	}
 
 	@Test
+	public void pushWithHook() throws Exception {
+		checkoutNewLocalBranch("foo");
+		// Existing configuration without push refspec
+		String remoteName = "origin";
+		String pushUrl = repository.getConfig().getString("remote", "push",
+				"pushurl");
+		repository.getConfig().setString("remote", remoteName, "url", pushUrl);
+		repository.getConfig().setString("remote", remoteName, "fetch",
+				"refs/heads/*:refs/remotes/origin/*");
+		File gitDir = repository.getDirectory();
+		File hookDir = new File(gitDir, "hooks");
+		assertTrue(hookDir.mkdir() || hookDir.isDirectory());
+		File hookFile = new File(hookDir, "pre-push");
+		Files.writeString(hookFile.toPath(), "#!/bin/sh\n"
+				+ "echo \"1:$1 2:$2 3:$3\"\n" // to stdout
+				+ "cat - 1>&2\n" // to stderr
+				+ "exit 0\n");
+		if (repository.getFS().supportsExecute()) {
+			repository.getFS().setExecute(hookFile, true);
+		}
+		String headId = repository.resolve(Constants.HEAD).getName();
+		String forUri = MessageFormat.format(CoreText.PushOperation_ForUri,
+				pushUrl);
+		String expectedHookOutput = MessageFormat.format(
+				UIText.PushResultTable_PrePushHookOutput,
+				"stdout: " + forUri+ '\n'
+				+ "stdout: 1:" + pushUrl + " 2:" + pushUrl + " 3:\n",
+				"stderr: " + forUri + '\n'
+				+ "stderr: refs/heads/foo " + headId
+				+ " refs/heads/foo "+ ObjectId.zeroId().getName() + '\n');
+		String resultText = pushToUpstream("origin", "foo", true, false);
+		assertEquals("Hook message doesn't match: " + resultText,
+				expectedHookOutput,
+				resultText.substring(0, Math.min(resultText.length(),
+						expectedHookOutput.length())));
+
+		assertBranchPushed("foo", remoteRepository);
+	}
+
+	@Test
 	public void pushIsDisabledWithPushDefaultNothing() throws Exception {
 		checkoutNewLocalBranch("foo");
 		repository.getConfig().setString(ConfigConstants.CONFIG_PUSH_SECTION,
@@ -194,7 +241,7 @@
 		pushToUpstream(remoteName, "", false, false);
 	}
 
-	private void pushToUpstream(String remoteName, String branchName,
+	private String pushToUpstream(String remoteName, String branchName,
 			boolean expectBranchWizard, boolean expectMultipleWarning) {
 		SWTBotTree project = selectProject();
 		JobJoiner joiner = null;
@@ -218,7 +265,10 @@
 		}
 		SWTBotShell resultDialog = TestUtil
 				.botForShellStartingWith("Push Results");
+		String resultText = resultDialog.bot().styledText().getLines().stream()
+				.collect(Collectors.joining("\n"));
 		resultDialog.close();
+		return resultText;
 	}
 
 	private void assertPushToUpstreamDisabled() {
diff --git a/org.eclipse.egit.ui/src/org/eclipse/egit/ui/internal/UIText.java b/org.eclipse.egit.ui/src/org/eclipse/egit/ui/internal/UIText.java
index 1a91842..2e9e727 100644
--- a/org.eclipse.egit.ui/src/org/eclipse/egit/ui/internal/UIText.java
+++ b/org.eclipse.egit.ui/src/org/eclipse/egit/ui/internal/UIText.java
@@ -2992,6 +2992,9 @@
 	public static String PushResultTable_MessageText;
 
 	/** */
+	public static String PushResultTable_PrePushHookOutput;
+
+	/** */
 	public static String PushResultTable_repository;
 
 	/** */
diff --git a/org.eclipse.egit.ui/src/org/eclipse/egit/ui/internal/push/PushResultTable.java b/org.eclipse.egit.ui/src/org/eclipse/egit/ui/internal/push/PushResultTable.java
index 4c29d83..57f0e59 100644
--- a/org.eclipse.egit.ui/src/org/eclipse/egit/ui/internal/push/PushResultTable.java
+++ b/org.eclipse.egit.ui/src/org/eclipse/egit/ui/internal/push/PushResultTable.java
@@ -1,5 +1,6 @@
 /*******************************************************************************
- * Copyright (C) 2008, 2015 Marek Zawirski <marek.zawirski@gmail.com> and others.
+ * Copyright (C) 2008, 2022 Marek Zawirski <marek.zawirski@gmail.com> and others.
+ *
  * All rights reserved. This program and the accompanying materials
  * are made available under the terms of the Eclipse Public License 2.0
  * which accompanies this distribution, and is available at
@@ -9,8 +10,11 @@
  *******************************************************************************/
 package org.eclipse.egit.ui.internal.push;
 
+import java.text.MessageFormat;
 import java.util.ArrayList;
 import java.util.List;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
 
 import org.eclipse.egit.core.op.PushOperationResult;
 import org.eclipse.egit.ui.UIUtils;
@@ -75,6 +79,8 @@
 
 	private Repository repo;
 
+	private String hookResult;
+
 	PushResultTable(final Composite parent) {
 		this(parent, null);
 	}
@@ -216,8 +222,13 @@
 					return;
 				}
 				Object selected = structuredSelection.getFirstElement();
-				if (selected instanceof RefUpdateElement)
-					text.setText(getResult((RefUpdateElement) selected));
+				if (selected instanceof RefUpdateElement) {
+					String toShow = getResult((RefUpdateElement) selected);
+					if (!hookResult.isEmpty()) {
+						toShow = hookResult + toShow;
+					}
+					text.setText(toShow);
+				}
 			}
 		});
 
@@ -299,6 +310,28 @@
 		sashForm.setWeights(defaultValues);
 	}
 
+	private String formatHookOutput(String hookOutput, String hookError) {
+		String out = hookOutput.strip();
+		String err = hookError.strip();
+		if (out.isEmpty() && err.isEmpty()) {
+			return ""; //$NON-NLS-1$
+		}
+		if (!out.isEmpty()) {
+			out = prefixLines("stdout: ", out); //$NON-NLS-1$
+		}
+		if (!err.isEmpty()) {
+			err = prefixLines("stderr: ", err); //$NON-NLS-1$
+		}
+		return MessageFormat.format(UIText.PushResultTable_PrePushHookOutput,
+				out, err);
+	}
+
+	private String prefixLines(String prefix, String text) {
+		return Stream.of(text.split("\n")) //$NON-NLS-1$
+				.map(s -> prefix + s.stripTrailing())
+				.collect(Collectors.joining("\n", "", "\n")); //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$
+	}
+
 	void setData(final Repository localDb, final PushOperationResult result) {
 		reader = localDb.newObjectReader();
 		repo = localDb;
@@ -311,22 +344,27 @@
 			return;
 		}
 
+		hookResult = formatHookOutput(result.getHookStdOut(),
+				result.getHookStdErr()).replaceAll("\n", Text.DELIMITER); //$NON-NLS-1$
 		final List<RefUpdateElement> results = new ArrayList<>();
 
-		for (URIish uri : result.getURIs())
-			if (result.isSuccessfulConnection(uri))
+		for (URIish uri : result.getURIs()) {
+			if (result.isSuccessfulConnection(uri)) {
 				for (RemoteRefUpdate update : result.getPushResult(uri)
-						.getRemoteUpdates())
+						.getRemoteUpdates()) {
 					results.add(new RefUpdateElement(result, update, uri,
 							reader, repo));
-
+				}
+			}
+		}
 		treeViewer.setInput(results.toArray());
 		// select the first row of table to get the details of the first
 		// push result shown in the Text control
 		Tree table = treeViewer.getTree();
-		if (table.getItemCount() > 0)
+		if (table.getItemCount() > 0) {
 			treeViewer.setSelection(new StructuredSelection(table.getItem(0)
 					.getData()));
+		}
 		root.layout();
 	}
 
diff --git a/org.eclipse.egit.ui/src/org/eclipse/egit/ui/internal/uitext.properties b/org.eclipse.egit.ui/src/org/eclipse/egit/ui/internal/uitext.properties
index 435ade0..c417b96 100644
--- a/org.eclipse.egit.ui/src/org/eclipse/egit/ui/internal/uitext.properties
+++ b/org.eclipse.egit.ui/src/org/eclipse/egit/ui/internal/uitext.properties
@@ -1013,6 +1013,7 @@
 PushResultDialog_label_failed=Failed pushing to {0}
 PushResultDialog_ConfigureButton=C&onfigure...
 PushResultTable_MessageText=Message Details
+PushResultTable_PrePushHookOutput=Output from the ''pre-push'' hook:\n{0}{1}--------\n
 PushResultTable_repository=Repository
 PushResultTable_statusRemoteRejected=[remote rejected]
 PushResultTable_statusRejected=[rejected]