diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2fe142d..716a367 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
+### Added
+- A `runAfterPush` command to run a CLI command after the push is complete. ([#51](https://github.com/diffplug/spotless-changelog/pull/51))
## [3.0.2] - 2023-04-06
### Fixed
diff --git a/README.md b/README.md
index 61d1f7c..0ead0f9 100644
--- a/README.md
+++ b/README.md
@@ -171,6 +171,7 @@ spotlessChangelog { // all defaults
tagPrefix 'release/'
commitMessage 'Published release/{{version}}' // {{version}} will be replaced
tagMessage null // default is null (creates lightweight tag); {{changes}} and {{version}} will be replaced
+ runAfterPush null // runs a CLI command after the push; {{changes}} and {{version}} will be replaced
remote 'origin'
branch 'main'
// default value is `yes`, but if you set it to `no`, then it will
diff --git a/spotless-changelog-lib/src/main/java/com/diffplug/spotless/changelog/GitCfg.java b/spotless-changelog-lib/src/main/java/com/diffplug/spotless/changelog/GitCfg.java
index d8b9a96..f93ae8a 100644
--- a/spotless-changelog-lib/src/main/java/com/diffplug/spotless/changelog/GitCfg.java
+++ b/spotless-changelog-lib/src/main/java/com/diffplug/spotless/changelog/GitCfg.java
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2019-2021 DiffPlug
+ * Copyright (C) 2019-2024 DiffPlug
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -15,7 +15,6 @@
*/
package com.diffplug.spotless.changelog;
-
import java.io.File;
import java.io.IOException;
import pl.tlinkowski.annotation.basic.NullOr;
@@ -31,6 +30,8 @@ public class GitCfg {
public String commitMessage = "Published release/" + COMMIT_MESSAGE_VERSION;
/** Message used in tag, null means lightweight tag. */
public @NullOr String tagMessage = null;
+ /** Runs a CLI command after the push if not null. */
+ public @NullOr String runAfterPush = null;
public String remote = "origin";
public String branch = "main";
public String sshStrictHostKeyChecking = "yes";
diff --git a/spotless-changelog-plugin-gradle/src/main/java/com/diffplug/spotless/changelog/gradle/ChangelogExtension.java b/spotless-changelog-plugin-gradle/src/main/java/com/diffplug/spotless/changelog/gradle/ChangelogExtension.java
index d24b2ea..5d1f038 100644
--- a/spotless-changelog-plugin-gradle/src/main/java/com/diffplug/spotless/changelog/gradle/ChangelogExtension.java
+++ b/spotless-changelog-plugin-gradle/src/main/java/com/diffplug/spotless/changelog/gradle/ChangelogExtension.java
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2019-2023 DiffPlug
+ * Copyright (C) 2019-2024 DiffPlug
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -238,6 +238,11 @@ public void tagMessage(String tagMessage) {
data.gitCfg.tagMessage = tagMessage;
}
+ /** Runs a CLI command after the push if not null - {{changes}} and {{version}} will be replaced. */
+ public void runAfterPush(String runAfterPush) {
+ data.gitCfg.runAfterPush = runAfterPush;
+ }
+
/** Default value is 'origin' */
public void remote(String remote) {
data.gitCfg.remote = remote;
diff --git a/spotless-changelog-plugin-gradle/src/main/java/com/diffplug/spotless/changelog/gradle/ChangelogPlugin.java b/spotless-changelog-plugin-gradle/src/main/java/com/diffplug/spotless/changelog/gradle/ChangelogPlugin.java
index dc7fa66..451f0f3 100644
--- a/spotless-changelog-plugin-gradle/src/main/java/com/diffplug/spotless/changelog/gradle/ChangelogPlugin.java
+++ b/spotless-changelog-plugin-gradle/src/main/java/com/diffplug/spotless/changelog/gradle/ChangelogPlugin.java
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2019-2023 DiffPlug
+ * Copyright (C) 2019-2024 DiffPlug
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -247,6 +247,17 @@ public void push() throws IOException, GitAPIException {
GitActions git = data.gitCfg.withChangelog(data.changelogFile, data.model());
git.addAndCommit();
git.tagBranchPush();
+ if (data.gitCfg.runAfterPush != null) {
+ try (var runner = new ProcessRunner()) {
+ var result = runner.shell(data.gitCfg.runAfterPush);
+ System.out.write(result.stdOut());
+ System.out.flush();
+ System.err.write(result.stdErr());
+ System.err.flush();
+ } catch (IOException | InterruptedException e) {
+ throw new GradleException("runAfterPush failed: " + data.gitCfg.runAfterPush, e);
+ }
+ }
}
}
}
diff --git a/spotless-changelog-plugin-gradle/src/main/java/com/diffplug/spotless/changelog/gradle/ProcessRunner.java b/spotless-changelog-plugin-gradle/src/main/java/com/diffplug/spotless/changelog/gradle/ProcessRunner.java
new file mode 100644
index 0000000..3920f46
--- /dev/null
+++ b/spotless-changelog-plugin-gradle/src/main/java/com/diffplug/spotless/changelog/gradle/ProcessRunner.java
@@ -0,0 +1,363 @@
+/*
+ * Copyright (C) 2020-2024 DiffPlug
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.diffplug.spotless.changelog.gradle;
+
+import static java.util.Objects.requireNonNull;
+
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.function.BiConsumer;
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+/**
+ * Shelling out to a process is harder than it ought to be in Java.
+ * If you don't read stdout and stderr on their own threads, you risk
+ * deadlock on a clogged buffer.
+ *
+ * ProcessRunner allocates two threads specifically for the purpose of
+ * flushing stdout and stderr to buffers. These threads will remain alive until
+ * the ProcessRunner is closed, so it is especially useful for repeated
+ * calls to an external process.
+ */
+class ProcessRunner implements AutoCloseable {
+ private final ExecutorService threadStdOut = Executors.newSingleThreadExecutor();
+ private final ExecutorService threadStdErr = Executors.newSingleThreadExecutor();
+ private final ByteArrayOutputStream bufStdOut;
+ private final ByteArrayOutputStream bufStdErr;
+
+ public ProcessRunner() {
+ this(-1);
+ }
+
+ public static ProcessRunner usingRingBuffersOfCapacity(int limit) {
+ return new ProcessRunner(limit);
+ }
+
+ private ProcessRunner(int limitedBuffers) {
+ this.bufStdOut = limitedBuffers >= 0 ? new RingBufferByteArrayOutputStream(limitedBuffers) : new ByteArrayOutputStream();
+ this.bufStdErr = limitedBuffers >= 0 ? new RingBufferByteArrayOutputStream(limitedBuffers) : new ByteArrayOutputStream();
+ }
+
+ /** Executes the given shell command (using {@code cmd} on windows and {@code sh} on unix). */
+ public Result shell(String cmd) throws IOException, InterruptedException {
+ return shellWinUnix(cmd, cmd);
+ }
+
+ /** Executes the given shell command (using {@code cmd} on windows and {@code sh} on unix). */
+ public Result shellWinUnix(String cmdWin, String cmdUnix) throws IOException, InterruptedException {
+ return shellWinUnix(null, null, cmdWin, cmdUnix);
+ }
+
+ /** Returns true if this JVM is running on a windows machine. */
+ private static boolean machineIsWin() {
+ return System.getProperty("os.name").toLowerCase(Locale.ROOT).contains("win");
+ }
+
+ /** Executes the given shell command (using {@code cmd} on windows and {@code sh} on unix). */
+ public Result shellWinUnix(@Nullable File cwd, @Nullable Map environment, String cmdWin, String cmdUnix) throws IOException, InterruptedException {
+ List args;
+ if (machineIsWin()) {
+ args = Arrays.asList("cmd", "/c", cmdWin);
+ } else {
+ args = Arrays.asList("sh", "-c", cmdUnix);
+ }
+ return exec(cwd, environment, null, args);
+ }
+
+ /** Creates a process with the given arguments. */
+ public Result exec(String... args) throws IOException, InterruptedException {
+ return exec(Arrays.asList(args));
+ }
+
+ /** Creates a process with the given arguments, the given byte array is written to stdin immediately. */
+ public Result exec(@Nullable byte[] stdin, String... args) throws IOException, InterruptedException {
+ return exec(stdin, Arrays.asList(args));
+ }
+
+ /** Creates a process with the given arguments. */
+ public Result exec(List args) throws IOException, InterruptedException {
+ return exec(null, args);
+ }
+
+ /** Creates a process with the given arguments, the given byte array is written to stdin immediately. */
+ public Result exec(@Nullable byte[] stdin, List args) throws IOException, InterruptedException {
+ return exec(null, null, stdin, args);
+ }
+
+ /** Creates a process with the given arguments, the given byte array is written to stdin immediately. */
+ public Result exec(@Nullable File cwd, @Nullable Map environment, @Nullable byte[] stdin, List args) throws IOException, InterruptedException {
+ LongRunningProcess process = start(cwd, environment, stdin, args);
+ try {
+ // wait for the process to finish
+ process.waitFor();
+ // collect the output
+ return process.result();
+ } catch (ExecutionException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ /**
+ * Creates a process with the given arguments, the given byte array is written to stdin immediately.
+ *
+ * Delegates to {@link #start(File, Map, byte[], boolean, List)} with {@code false} for {@code redirectErrorStream}.
+ */
+ public LongRunningProcess start(@Nullable File cwd, @Nullable Map environment, @Nullable byte[] stdin, List args) throws IOException {
+ return start(cwd, environment, stdin, false, args);
+ }
+
+ /**
+ * Creates a process with the given arguments, the given byte array is written to stdin immediately.
+ *
+ * The process is not waited for, so the caller is responsible for calling {@link LongRunningProcess#waitFor()} (if needed).
+ *
+ * To dispose this {@code ProcessRunner} instance, either call {@link #close()} or {@link LongRunningProcess#close()}. After
+ * {@link #close()} or {@link LongRunningProcess#close()} has been called, this {@code ProcessRunner} instance must not be used anymore.
+ */
+ public LongRunningProcess start(@Nullable File cwd, @Nullable Map environment, @Nullable byte[] stdin, boolean redirectErrorStream, List args) throws IOException {
+ checkState();
+ ProcessBuilder builder = new ProcessBuilder(args);
+ if (cwd != null) {
+ builder.directory(cwd);
+ }
+ if (environment != null) {
+ builder.environment().putAll(environment);
+ }
+ if (stdin == null) {
+ stdin = new byte[0];
+ }
+ if (redirectErrorStream) {
+ builder.redirectErrorStream(true);
+ }
+
+ Process process = builder.start();
+ Future outputFut = threadStdOut.submit(() -> drainToBytes(process.getInputStream(), bufStdOut));
+ Future errorFut = null;
+ if (!redirectErrorStream) {
+ errorFut = threadStdErr.submit(() -> drainToBytes(process.getErrorStream(), bufStdErr));
+ }
+ // write stdin
+ process.getOutputStream().write(stdin);
+ process.getOutputStream().flush();
+ process.getOutputStream().close();
+ return new LongRunningProcess(process, args, outputFut, errorFut);
+ }
+
+ private static void drain(InputStream input, OutputStream output) throws IOException {
+ byte[] buf = new byte[1024];
+ int numRead;
+ while ((numRead = input.read(buf)) != -1) {
+ output.write(buf, 0, numRead);
+ }
+ }
+
+ private static byte[] drainToBytes(InputStream input, ByteArrayOutputStream buffer) throws IOException {
+ buffer.reset();
+ drain(input, buffer);
+ return buffer.toByteArray();
+ }
+
+ @Override
+ public void close() {
+ threadStdOut.shutdown();
+ threadStdErr.shutdown();
+ }
+
+ /** Checks if this {@code ProcessRunner} instance is still usable. */
+ private void checkState() {
+ if (threadStdOut.isShutdown() || threadStdErr.isShutdown()) {
+ throw new IllegalStateException("ProcessRunner has been closed and must not be used anymore.");
+ }
+ }
+
+ public static class Result {
+ private final List args;
+ private final int exitCode;
+ private final byte[] stdOut, stdErr;
+
+ public Result(@Nonnull List args, int exitCode, @Nonnull byte[] stdOut, @Nullable byte[] stdErr) {
+ this.args = args;
+ this.exitCode = exitCode;
+ this.stdOut = stdOut;
+ this.stdErr = (stdErr == null ? new byte[0] : stdErr);
+ }
+
+ public List args() {
+ return args;
+ }
+
+ public int exitCode() {
+ return exitCode;
+ }
+
+ public byte[] stdOut() {
+ return stdOut;
+ }
+
+ public byte[] stdErr() {
+ return stdErr;
+ }
+
+ public String stdOutUtf8() {
+ return new String(stdOut, StandardCharsets.UTF_8);
+ }
+
+ public String stdErrUtf8() {
+ return new String(stdErr, StandardCharsets.UTF_8);
+ }
+
+ /** Returns true if the exit code was not zero. */
+ public boolean exitNotZero() {
+ return exitCode != 0;
+ }
+
+ /**
+ * Asserts that the exit code was zero, and if so, returns
+ * the content of stdout encoded with the given charset.
+ *
+ * If the exit code was not zero, throws an exception
+ * with useful debugging information.
+ */
+ public String assertExitZero(Charset charset) {
+ if (exitCode == 0) {
+ return new String(stdOut, charset);
+ } else {
+ throw new RuntimeException(toString());
+ }
+ }
+
+ @Override
+ public String toString() {
+ StringBuilder builder = new StringBuilder();
+ builder.append("> arguments: " + args + "\n");
+ builder.append("> exit code: " + exitCode + "\n");
+ BiConsumer perStream = (name, content) -> {
+ String string = new String(content, Charset.defaultCharset()).trim();
+ if (string.isEmpty()) {
+ builder.append("> " + name + ": (empty)\n");
+ } else {
+ String[] lines = string.replace("\r", "").split("\n");
+ if (lines.length == 1) {
+ builder.append("> " + name + ": " + lines[0] + "\n");
+ } else {
+ builder.append("> " + name + ": (below)\n");
+ for (String line : lines) {
+ builder.append("> ");
+ builder.append(line);
+ builder.append('\n');
+ }
+ }
+ }
+ };
+ perStream.accept(" stdout", stdOut);
+ if (stdErr.length > 0) {
+ perStream.accept(" stderr", stdErr);
+ }
+ return builder.toString();
+ }
+ }
+
+ /**
+ * A long-running process that can be waited for.
+ */
+ public class LongRunningProcess extends Process implements AutoCloseable {
+
+ private final Process delegate;
+ private final List args;
+ private final Future outputFut;
+ private final Future errorFut;
+
+ public LongRunningProcess(@Nonnull Process delegate, @Nonnull List args, @Nonnull Future outputFut, @Nullable Future errorFut) {
+ this.delegate = requireNonNull(delegate);
+ this.args = args;
+ this.outputFut = outputFut;
+ this.errorFut = errorFut;
+ }
+
+ @Override
+ public OutputStream getOutputStream() {
+ return delegate.getOutputStream();
+ }
+
+ @Override
+ public InputStream getInputStream() {
+ return delegate.getInputStream();
+ }
+
+ @Override
+ public InputStream getErrorStream() {
+ return delegate.getErrorStream();
+ }
+
+ @Override
+ public int waitFor() throws InterruptedException {
+ return delegate.waitFor();
+ }
+
+ @Override
+ public boolean waitFor(long timeout, TimeUnit unit) throws InterruptedException {
+ return delegate.waitFor(timeout, unit);
+ }
+
+ @Override
+ public int exitValue() {
+ return delegate.exitValue();
+ }
+
+ @Override
+ public void destroy() {
+ delegate.destroy();
+ }
+
+ @Override
+ public Process destroyForcibly() {
+ return delegate.destroyForcibly();
+ }
+
+ @Override
+ public boolean isAlive() {
+ return delegate.isAlive();
+ }
+
+ public Result result() throws ExecutionException, InterruptedException {
+ int exitCode = waitFor();
+ return new Result(args, exitCode, this.outputFut.get(), (this.errorFut != null ? this.errorFut.get() : null));
+ }
+
+ @Override
+ public void close() {
+ if (isAlive()) {
+ destroy();
+ }
+ ProcessRunner.this.close();
+ }
+ }
+}
diff --git a/spotless-changelog-plugin-gradle/src/main/java/com/diffplug/spotless/changelog/gradle/RingBufferByteArrayOutputStream.java b/spotless-changelog-plugin-gradle/src/main/java/com/diffplug/spotless/changelog/gradle/RingBufferByteArrayOutputStream.java
new file mode 100644
index 0000000..e2079aa
--- /dev/null
+++ b/spotless-changelog-plugin-gradle/src/main/java/com/diffplug/spotless/changelog/gradle/RingBufferByteArrayOutputStream.java
@@ -0,0 +1,132 @@
+/*
+ * Copyright (C) 2023-2024 DiffPlug
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.diffplug.spotless.changelog.gradle;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.io.UnsupportedEncodingException;
+
+class RingBufferByteArrayOutputStream extends ByteArrayOutputStream {
+
+ private final int limit;
+
+ private int zeroIndexPointer = 0;
+
+ private boolean isOverLimit = false;
+
+ public RingBufferByteArrayOutputStream(int limit) {
+ this(limit, 32);
+ }
+
+ public RingBufferByteArrayOutputStream(int limit, int initialCapacity) {
+ super(initialCapacity);
+ if (limit < initialCapacity) {
+ throw new IllegalArgumentException("Limit must be greater than initial capacity. Limit: " + limit + ", initial capacity: " + initialCapacity);
+ }
+ if (limit < 2) {
+ throw new IllegalArgumentException("Limit must be greater than or equal to 2 but is " + limit);
+ }
+ if (limit % 2 != 0) {
+ throw new IllegalArgumentException("Limit must be an even number but is " + limit); // to fit 16 bit unicode chars
+ }
+ this.limit = limit;
+ }
+
+ // ---- writing
+ @Override
+ public synchronized void write(int b) {
+ if (count < limit) {
+ super.write(b);
+ return;
+ }
+ isOverLimit = true;
+ buf[zeroIndexPointer] = (byte) b;
+ zeroIndexPointer = (zeroIndexPointer + 1) % limit;
+ }
+
+ @Override
+ public synchronized void write(byte[] b, int off, int len) {
+ int remaining = limit - count;
+ if (remaining >= len) {
+ super.write(b, off, len);
+ return;
+ }
+ if (remaining > 0) {
+ // write what we can "normally"
+ super.write(b, off, remaining);
+ // rest delegated
+ write(b, off + remaining, len - remaining);
+ return;
+ }
+ // we are over the limit
+ isOverLimit = true;
+ // write till limit is reached
+ int writeTillLimit = Math.min(len, limit - zeroIndexPointer);
+ System.arraycopy(b, off, buf, zeroIndexPointer, writeTillLimit);
+ zeroIndexPointer = (zeroIndexPointer + writeTillLimit) % limit;
+ if (writeTillLimit < len) {
+ // write rest
+ write(b, off + writeTillLimit, len - writeTillLimit);
+ }
+ }
+
+ @Override
+ public synchronized void reset() {
+ super.reset();
+ zeroIndexPointer = 0;
+ isOverLimit = false;
+ }
+
+ // ---- output
+ @Override
+ public synchronized void writeTo(OutputStream out) throws IOException {
+ if (!isOverLimit) {
+ super.writeTo(out);
+ return;
+ }
+ out.write(buf, zeroIndexPointer, limit - zeroIndexPointer);
+ out.write(buf, 0, zeroIndexPointer);
+ }
+
+ @Override
+ public synchronized byte[] toByteArray() {
+ if (!isOverLimit) {
+ return super.toByteArray();
+ }
+ byte[] result = new byte[limit];
+ System.arraycopy(buf, zeroIndexPointer, result, 0, limit - zeroIndexPointer);
+ System.arraycopy(buf, 0, result, limit - zeroIndexPointer, zeroIndexPointer);
+ return result;
+ }
+
+ @Override
+ public synchronized String toString() {
+ if (!isOverLimit) {
+ return super.toString();
+ }
+ return new String(buf, zeroIndexPointer, limit - zeroIndexPointer) + new String(buf, 0, zeroIndexPointer);
+ }
+
+ @Override
+ public synchronized String toString(String charsetName) throws UnsupportedEncodingException {
+ if (!isOverLimit) {
+ return super.toString(charsetName);
+ }
+ return new String(buf, zeroIndexPointer, limit - zeroIndexPointer, charsetName) + new String(buf, 0, zeroIndexPointer, charsetName);
+ }
+
+}