diff --git a/CHANGES.md b/CHANGES.md
index 7e7c971e39..6fac04cd17 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -12,6 +12,7 @@ We adhere to the [keepachangelog](https://keepachangelog.com/en/1.0.0/) format (
 ## [Unreleased]
 ### Added
 * New static method to `DiffMessageFormatter` which allows to retrieve diffs with their line numbers ([#1960](https://github.com/diffplug/spotless/issues/1960))
+* Format shell via [shfmt](https://github.com/mvdan/sh). ([#1994](https://github.com/diffplug/spotless/pull/1994))
 ### Fixed
 * Fix empty files with biome >= 1.5.0 when formatting files that are in the ignore list of the biome configuration file. ([#1989](https://github.com/diffplug/spotless/pull/1989) fixes [#1987](https://github.com/diffplug/spotless/issues/1987))
 * Fix a regression in BufStep where the same arguments were being provided to every `buf` invocation. ([#1976](https://github.com/diffplug/spotless/issues/1976))
diff --git a/README.md b/README.md
index d31fd15b8c..4515eaf0a1 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@
 [![Maven Plugin](https://img.shields.io/maven-central/v/com.diffplug.spotless/spotless-maven-plugin?color=blue&label=maven%20plugin)](plugin-maven)
 [![SBT Plugin](https://img.shields.io/badge/sbt%20plugin-0.1.3-blue)](https://github.com/moznion/sbt-spotless)
 
-Spotless can format <antlr | c | c# | c++ | css | flow | graphql | groovy | html | java | javascript | json | jsx | kotlin | less | license headers | markdown | objective-c | protobuf | python | scala | scss | sql | typeScript | vue | yaml | anything> using <gradle | maven | sbt | anything>.
+Spotless can format <antlr | c | c# | c++ | css | flow | graphql | groovy | html | java | javascript | json | jsx | kotlin | less | license headers | markdown | objective-c | protobuf | python | scala | scss | shell | sql | typeScript | vue | yaml | anything> using <gradle | maven | sbt | anything>.
 
 You probably want one of the links below:
 
@@ -75,6 +75,7 @@ lib('generic.ReplaceRegexStep')                  +'{{yes}}       | {{yes}}
 lib('generic.ReplaceStep')                       +'{{yes}}       | {{yes}}      | {{no}}       | {{no}}  |',
 lib('generic.TrimTrailingWhitespaceStep')        +'{{yes}}       | {{yes}}      | {{no}}       | {{no}}  |',
 lib('antlr4.Antlr4FormatterStep')                +'{{yes}}       | {{yes}}      | {{no}}       | {{no}}  |',
+lib('biome.BiomeStep')                           +'{{yes}}       | {{yes}}      | {{no}}       | {{no}}  |',
 lib('cpp.ClangFormatStep')                       +'{{yes}}       | {{no}}       | {{no}}       | {{no}}  |',
 extra('cpp.EclipseFormatterStep')                +'{{yes}}       | {{yes}}      | {{yes}}      | {{no}}  |',
 lib('gherkin.GherkinUtilsStep')                  +'{{yes}}       | {{yes}}      | {{no}}       | {{no}}  |',
@@ -101,8 +102,8 @@ lib('npm.TsFmtFormatterStep')                    +'{{yes}}       | {{yes}}
 lib('pom.SortPomStepStep')                       +'{{no}}        | {{yes}}      | {{no}}       | {{no}}  |',
 lib('protobuf.BufStep')                          +'{{yes}}       | {{no}}       | {{no}}       | {{no}}  |',
 lib('python.BlackStep')                          +'{{yes}}       | {{no}}       | {{no}}       | {{no}}  |',
-lib('biome.BiomeStep')                           +'{{yes}}       | {{yes}}      | {{no}}       | {{no}}  |',
 lib('scala.ScalaFmtStep')                        +'{{yes}}       | {{yes}}      | {{yes}}      | {{no}}  |',
+lib('shell.ShfmtStep')                           +'{{yes}}       | {{no}}       | {{no}}       | {{no}}  |',
 lib('sql.DBeaverSQLFormatterStep')               +'{{yes}}       | {{yes}}      | {{yes}}      | {{no}}  |',
 extra('wtp.EclipseWtpFormatterStep')             +'{{yes}}       | {{yes}}      | {{no}}       | {{no}}  |',
 lib('yaml.JacksonYamlStep')                      +'{{yes}}       | {{yes}}      | {{no}}       | {{no}}  |',
@@ -127,6 +128,7 @@ lib('yaml.JacksonYamlStep')                      +'{{yes}}       | {{yes}}
 | [`generic.ReplaceStep`](lib/src/main/java/com/diffplug/spotless/generic/ReplaceStep.java) | :+1:       | :+1:      | :white_large_square:       | :white_large_square:  |
 | [`generic.TrimTrailingWhitespaceStep`](lib/src/main/java/com/diffplug/spotless/generic/TrimTrailingWhitespaceStep.java) | :+1:       | :+1:      | :white_large_square:       | :white_large_square:  |
 | [`antlr4.Antlr4FormatterStep`](lib/src/main/java/com/diffplug/spotless/antlr4/Antlr4FormatterStep.java) | :+1:       | :+1:      | :white_large_square:       | :white_large_square:  |
+| [`biome.BiomeStep`](lib/src/main/java/com/diffplug/spotless/biome/BiomeStep.java) | :+1:       | :+1:      | :white_large_square:       | :white_large_square:  |
 | [`cpp.ClangFormatStep`](lib/src/main/java/com/diffplug/spotless/cpp/ClangFormatStep.java) | :+1:       | :white_large_square:       | :white_large_square:       | :white_large_square:  |
 | [`cpp.EclipseFormatterStep`](lib-extra/src/main/java/com/diffplug/spotless/extra/cpp/EclipseFormatterStep.java) | :+1:       | :+1:      | :+1:      | :white_large_square:  |
 | [`gherkin.GherkinUtilsStep`](lib/src/main/java/com/diffplug/spotless/gherkin/GherkinUtilsStep.java) | :+1:       | :+1:      | :white_large_square:       | :white_large_square:  |
@@ -153,8 +155,8 @@ lib('yaml.JacksonYamlStep')                      +'{{yes}}       | {{yes}}
 | [`pom.SortPomStepStep`](lib/src/main/java/com/diffplug/spotless/pom/SortPomStepStep.java) | :white_large_square:        | :+1:      | :white_large_square:       | :white_large_square:  |
 | [`protobuf.BufStep`](lib/src/main/java/com/diffplug/spotless/protobuf/BufStep.java) | :+1:       | :white_large_square:       | :white_large_square:       | :white_large_square:  |
 | [`python.BlackStep`](lib/src/main/java/com/diffplug/spotless/python/BlackStep.java) | :+1:       | :white_large_square:       | :white_large_square:       | :white_large_square:  |
-| [`biome.BiomeStep`](lib/src/main/java/com/diffplug/spotless/biome/BiomeStep.java) | :+1:       | :+1:      | :white_large_square:       | :white_large_square:  |
 | [`scala.ScalaFmtStep`](lib/src/main/java/com/diffplug/spotless/scala/ScalaFmtStep.java) | :+1:       | :+1:      | :+1:      | :white_large_square:  |
+| [`shell.ShfmtStep`](lib/src/main/java/com/diffplug/spotless/shell/ShfmtStep.java) | :+1:       | :white_large_square:       | :white_large_square:       | :white_large_square:  |
 | [`sql.DBeaverSQLFormatterStep`](lib/src/main/java/com/diffplug/spotless/sql/DBeaverSQLFormatterStep.java) | :+1:       | :+1:      | :+1:      | :white_large_square:  |
 | [`wtp.EclipseWtpFormatterStep`](lib-extra/src/main/java/com/diffplug/spotless/extra/wtp/EclipseWtpFormatterStep.java) | :+1:       | :+1:      | :white_large_square:       | :white_large_square:  |
 | [`yaml.JacksonYamlStep`](lib/src/main/java/com/diffplug/spotless/yaml/JacksonYamlStep.java) | :+1:       | :+1:      | :white_large_square:       | :white_large_square:  |
diff --git a/gradle/special-tests.gradle b/gradle/special-tests.gradle
index b096efbe13..ae9da9a06e 100644
--- a/gradle/special-tests.gradle
+++ b/gradle/special-tests.gradle
@@ -1,9 +1,10 @@
 apply plugin: 'com.adarshr.test-logger'
 def special = [
-	'Npm',
 	'Black',
+	'Buf',
 	'Clang',
-	'Buf'
+	'Npm',
+	'Shfmt'
 ]
 
 boolean isCiServer = System.getenv().containsKey("CI")
diff --git a/lib/src/main/java/com/diffplug/spotless/shell/ShfmtStep.java b/lib/src/main/java/com/diffplug/spotless/shell/ShfmtStep.java
new file mode 100644
index 0000000000..cc452fce69
--- /dev/null
+++ b/lib/src/main/java/com/diffplug/spotless/shell/ShfmtStep.java
@@ -0,0 +1,110 @@
+/*
+ * Copyright 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
+ *
+ *     http://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.shell;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.Serializable;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.Objects;
+import java.util.regex.Pattern;
+
+import javax.annotation.Nullable;
+
+import com.diffplug.spotless.ForeignExe;
+import com.diffplug.spotless.FormatterFunc;
+import com.diffplug.spotless.FormatterStep;
+import com.diffplug.spotless.ProcessRunner;
+
+import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
+
+public class ShfmtStep {
+	public static String name() {
+		return "shfmt";
+	}
+
+	public static String defaultVersion() {
+		return "3.7.0";
+	}
+
+	private final String version;
+	private final @Nullable String pathToExe;
+
+	private ShfmtStep(String version, @Nullable String pathToExe) {
+		this.version = version;
+		this.pathToExe = pathToExe;
+	}
+
+	public static ShfmtStep withVersion(String version) {
+		return new ShfmtStep(version, null);
+	}
+
+	public ShfmtStep withPathToExe(String pathToExe) {
+		return new ShfmtStep(version, pathToExe);
+	}
+
+	public FormatterStep create() {
+		return FormatterStep.createLazy(name(), this::createState, State::toFunc);
+	}
+
+	private State createState() throws IOException, InterruptedException {
+		String howToInstall = "" +
+				"You can download shfmt from https://github.com/mvdan/sh and " +
+				"then point Spotless to it with {@code pathToExe('/path/to/shfmt')} " +
+				"or you can use your platform's package manager:" +
+				"\n  win:   choco install shfmt" +
+				"\n  mac:   brew install shfmt" +
+				"\n  linux: apt install shfmt" +
+				"\n    github issue to handle this better: https://github.com/diffplug/spotless/issues/673";
+		final ForeignExe exe = ForeignExe.nameAndVersion("shfmt", version)
+				.pathToExe(pathToExe)
+				.versionRegex(Pattern.compile("(\\S*)"))
+				.fixCantFind(howToInstall)
+				.fixWrongVersion(
+						"You can tell Spotless to use the version you already have with {@code shfmt('{versionFound}')}" +
+								"or you can download the currently specified version, {version}.\n" + howToInstall);
+		return new State(this, exe);
+	}
+
+	@SuppressFBWarnings("SE_TRANSIENT_FIELD_NOT_RESTORED")
+	static class State implements Serializable {
+		private static final long serialVersionUID = -1825662356883926318L;
+		// used for up-to-date checks and caching
+		final String version;
+		final transient ForeignExe exe;
+		// used for executing
+		private transient @Nullable List<String> args;
+
+		State(ShfmtStep step, ForeignExe pathToExe) {
+			this.version = step.version;
+			this.exe = Objects.requireNonNull(pathToExe);
+		}
+
+		String format(ProcessRunner runner, String input, File file) throws IOException, InterruptedException {
+			if (args == null) {
+				args = List.of(exe.confirmVersionAndGetAbsolutePath(), "-i", "2", "-ci");
+			}
+
+			return runner.exec(input.getBytes(StandardCharsets.UTF_8), args).assertExitZero(StandardCharsets.UTF_8);
+		}
+
+		FormatterFunc.Closeable toFunc() {
+			ProcessRunner runner = new ProcessRunner();
+			return FormatterFunc.Closeable.of(runner, this::format);
+		}
+	}
+}
diff --git a/plugin-gradle/CHANGES.md b/plugin-gradle/CHANGES.md
index b6bd7c419e..ac903322af 100644
--- a/plugin-gradle/CHANGES.md
+++ b/plugin-gradle/CHANGES.md
@@ -3,6 +3,8 @@
 We adhere to the [keepachangelog](https://keepachangelog.com/en/1.0.0/) format (starting after version `3.27.0`).
 
 ## [Unreleased]
+### Added
+* Support for shell via [shfmt](https://github.com/mvdan/sh).
 ### Fixed
 * Fix empty files with biome >= 1.5.0 when formatting files that are in the ignore list of the biome configuration file. ([#1989](https://github.com/diffplug/spotless/pull/1989) fixes [#1987](https://github.com/diffplug/spotless/issues/1987))=======
 * Fix a regression in BufStep where the same arguments were being provided to every `buf` invocation. ([#1976](https://github.com/diffplug/spotless/issues/1976))
diff --git a/plugin-gradle/README.md b/plugin-gradle/README.md
index 98416cdd05..1ea1ef5cf5 100644
--- a/plugin-gradle/README.md
+++ b/plugin-gradle/README.md
@@ -69,6 +69,7 @@ Spotless supports all of Gradle's built-in performance features (incremental bui
   - [Javascript](#javascript) ([prettier](#prettier), [ESLint](#eslint-javascript), [Biome](#biome))
   - [JSON](#json) ([simple](#simple), [gson](#gson), [jackson](#jackson), [Biome](#biome), [jsonPatch](#jsonPatch))
   - [YAML](#yaml)
+  - [Shell](#shell)
   - [Gherkin](#gherkin)
   - Multiple languages
     - [Prettier](#prettier) ([plugins](#prettier-plugins), [npm detection](#npm-detection), [`.npmrc` detection](#npmrc-detection), [caching `npm install` results](#caching-results-of-npm-install))
@@ -982,6 +983,38 @@ spotless {
 }
 ```
 
+## Shell
+
+`com.diffplug.gradle.spotless.ShellExtension` [javadoc](https://javadoc.io/doc/com.diffplug.spotless/spotless-plugin-gradle/6.23.3/com/diffplug/gradle/spotless/ShellExtension.html), [code](https://github.com/diffplug/spotless/blob/main/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/ShellExtension.java)
+
+```gradle
+spotless {
+  shell {
+    target 'scripts/**/*.sh' // default: '*.sh'
+
+    shfmt()  // has its own section below
+  }
+}
+```
+
+### shfmt
+
+[homepage](https://github.com/mvdan/sh). [changelog](https://github.com/mvdan/sh/blob/master/CHANGELOG.md).
+
+```gradle
+shfmt('3.7.0') // version is optional
+
+// if shfmt is not on your path, you must specify its location manually
+shfmt().pathToExe('/opt/homebrew/bin/shfmt')
+// Spotless always checks the version of the shfmt it is using
+// and will fail with an error if it does not match the expected version
+// (whether manually specified or default). If there is a problem, Spotless
+// will suggest commands to help install the correct version.
+//   TODO: handle installation & packaging automatically - https://github.com/diffplug/spotless/issues/674
+```
+
+<a name="applying-freshmark-to-markdown-files"></a>
+
 ## Gherkin
 
 - `com.diffplug.gradle.spotless.GherkinExtension` [javadoc](https://javadoc.io/doc/com.diffplug.spotless/spotless-plugin-gradle/6.23.3/com/diffplug/gradle/spotless/GherkinExtension.html), [code](https://github.com/diffplug/spotless/blob/main/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/GherkinExtension.java)
diff --git a/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/ShellExtension.java b/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/ShellExtension.java
new file mode 100644
index 0000000000..9149467eaa
--- /dev/null
+++ b/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/ShellExtension.java
@@ -0,0 +1,73 @@
+/*
+ * Copyright 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
+ *
+ *     http://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.gradle.spotless;
+
+import java.util.Objects;
+
+import javax.inject.Inject;
+
+import com.diffplug.spotless.FormatterStep;
+import com.diffplug.spotless.shell.ShfmtStep;
+
+public class ShellExtension extends FormatExtension {
+	private static final String SHELL_FILE_EXTENSION = "*.sh";
+
+	static final String NAME = "shell";
+
+	@Inject
+	public ShellExtension(SpotlessExtension spotless) {
+		super(spotless);
+	}
+
+	/** If the user hasn't specified files, assume all shell files should be checked. */
+	@Override
+	protected void setupTask(SpotlessTask task) {
+		if (target == null) {
+			target = parseTarget(SHELL_FILE_EXTENSION);
+		}
+		super.setupTask(task);
+	}
+
+	/** Adds the specified version of <a href="https://github.com/mvdan/sh">shfmt</a>. */
+	public ShfmtExtension shfmt(String version) {
+		Objects.requireNonNull(version);
+		return new ShfmtExtension(version);
+	}
+
+	/** Adds the specified version of <a href="https://github.com/mvdan/sh">shfmt</a>. */
+	public ShfmtExtension shfmt() {
+		return shfmt(ShfmtStep.defaultVersion());
+	}
+
+	public class ShfmtExtension {
+		ShfmtStep step;
+
+		ShfmtExtension(String version) {
+			this.step = ShfmtStep.withVersion(version);
+			addStep(createStep());
+		}
+
+		public ShfmtExtension pathToExe(String pathToExe) {
+			step = step.withPathToExe(pathToExe);
+			replaceStep(createStep());
+			return this;
+		}
+
+		private FormatterStep createStep() {
+			return step.create();
+		}
+	}
+}
diff --git a/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/SpotlessExtension.java b/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/SpotlessExtension.java
index 7ed0837eb5..b4c8e3cc03 100644
--- a/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/SpotlessExtension.java
+++ b/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/SpotlessExtension.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2016-2023 DiffPlug
+ * Copyright 2016-2024 DiffPlug
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -205,6 +205,12 @@ public void protobuf(Action<ProtobufExtension> closure) {
 		format(ProtobufExtension.NAME, ProtobufExtension.class, closure);
 	}
 
+	/** Configures the special shell-specific extension. */
+	public void shell(Action<ShellExtension> closure) {
+		requireNonNull(closure);
+		format(ShellExtension.NAME, ShellExtension.class, closure);
+	}
+
 	/** Configures the special YAML-specific extension. */
 	public void yaml(Action<YamlExtension> closure) {
 		requireNonNull(closure);
diff --git a/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/ShfmtIntegrationTest.java b/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/ShfmtIntegrationTest.java
new file mode 100644
index 0000000000..9693790679
--- /dev/null
+++ b/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/ShfmtIntegrationTest.java
@@ -0,0 +1,41 @@
+/*
+ * Copyright 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
+ *
+ *     http://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.gradle.spotless;
+
+import java.io.IOException;
+
+import org.junit.jupiter.api.Test;
+
+import com.diffplug.spotless.tag.ShfmtTest;
+
+@ShfmtTest
+public class ShfmtIntegrationTest extends GradleIntegrationHarness {
+	@Test
+	void shfmt() throws IOException {
+		setFile("build.gradle").toLines(
+				"plugins {",
+				"  id 'com.diffplug.spotless'",
+				"}",
+				"spotless {",
+				"  shell {",
+				"    shfmt()",
+				"  }",
+				"}");
+		setFile("shfmt.sh").toResource("shell/shfmt/shfmt.sh");
+		gradleRunner().withArguments("spotlessApply").build();
+		assertFile("shfmt.sh").sameAsResource("shell/shfmt/shfmt.clean");
+	}
+}
diff --git a/testlib/src/main/java/com/diffplug/spotless/tag/ShfmtTest.java b/testlib/src/main/java/com/diffplug/spotless/tag/ShfmtTest.java
new file mode 100644
index 0000000000..efea44ec07
--- /dev/null
+++ b/testlib/src/main/java/com/diffplug/spotless/tag/ShfmtTest.java
@@ -0,0 +1,30 @@
+/*
+ * Copyright 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
+ *
+ *     http://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.tag;
+
+import static java.lang.annotation.ElementType.METHOD;
+import static java.lang.annotation.ElementType.TYPE;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
+
+import org.junit.jupiter.api.Tag;
+
+@Target({TYPE, METHOD})
+@Retention(RUNTIME)
+@Tag("Shfmt")
+public @interface ShfmtTest {}
diff --git a/testlib/src/main/resources/shell/shfmt/shfmt.clean b/testlib/src/main/resources/shell/shfmt/shfmt.clean
new file mode 100644
index 0000000000..c1b9b25064
--- /dev/null
+++ b/testlib/src/main/resources/shell/shfmt/shfmt.clean
@@ -0,0 +1,17 @@
+#!/usr/bin/env bash
+
+function foo() {
+  if [ -x $file ]; then
+    myArray=(item1 item2 item3)
+  elif [ $file1 -nt $file2 ]; then
+    unset myArray
+  else
+    echo "Usage: $0 file ..."
+  fi
+}
+
+for ((i = 0; i < 5; i++)); do
+  read -p r
+  print -n $r
+  wait $!
+done
diff --git a/testlib/src/main/resources/shell/shfmt/shfmt.sh b/testlib/src/main/resources/shell/shfmt/shfmt.sh
new file mode 100644
index 0000000000..9d15c477d4
--- /dev/null
+++ b/testlib/src/main/resources/shell/shfmt/shfmt.sh
@@ -0,0 +1,18 @@
+#!/usr/bin/env bash
+
+function foo() {
+    if [ -x $file ]; then
+              myArray=(item1 item2 item3)
+  elif [ $file1 -nt $file2 ]
+    then
+        unset myArray
+        else
+echo "Usage: $0 file ..."
+    fi
+}
+
+for ((i = 0; i < 5; i++)); do
+    read -p r
+  print -n $r
+  wait $!
+done
diff --git a/testlib/src/test/java/com/diffplug/spotless/shell/ShfmtStepTest.java b/testlib/src/test/java/com/diffplug/spotless/shell/ShfmtStepTest.java
new file mode 100644
index 0000000000..41dc38e225
--- /dev/null
+++ b/testlib/src/test/java/com/diffplug/spotless/shell/ShfmtStepTest.java
@@ -0,0 +1,32 @@
+/*
+ * Copyright 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
+ *
+ *     http://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.shell;
+
+import org.junit.jupiter.api.Test;
+
+import com.diffplug.spotless.ResourceHarness;
+import com.diffplug.spotless.StepHarness;
+import com.diffplug.spotless.tag.ShfmtTest;
+
+@ShfmtTest
+public class ShfmtStepTest extends ResourceHarness {
+	@Test
+	void test() throws Exception {
+		try (StepHarness harness = StepHarness.forStep(ShfmtStep.withVersion(ShfmtStep.defaultVersion()).create())) {
+			harness.testResource("shell/shfmt/shfmt.sh", "shell/shfmt/shfmt.clean").close();
+		}
+	}
+}