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); + } + +}