Why I Stopped Writing Bash Scripts and Started Using Dart
Every project has a list of frequently performed actions, such as bootstrapping or running tests with coverage. Developers often choose Bash or similar tools (e.g., Makefiles) to automate tasks. In this article, I highlight where Bash is lacking and why I started preferring Dart.
Limits of Bash
Bash was never designed for complex application logic. As scripts grow, they may hit a ceiling of maintainability and safety.
- Lack of Type Safety: Bash is untyped. A variable can be a string, a number, or a filename, and you often won't know you have a mismatch until the script crashes in the runtime.
- Unfriendly Syntax: The syntax is notoriously unforgiving. In a statement like
if [ "$a" = "$b" ];, missing a single space or quote can cause a syntax error. These typos are rarely caught before runtime. - Hard to Maintain: A 50-line Bash script is readable; a 500-line Bash script is a liability. Complex scripts often become write-only code and can be changed only with AI.
- Bad Cross Platform: Writing cross-platform Bash is harder than it looks. Standard tools like
sedandgrepbehave differently on macOS vs Linux. Windows requires WSL or Git Bash to run them at all.
Bash is still excellent for what it does best: glue code. It is perfect for short, linear tasks, such as triggering a build command or formatting files in CI (e.g., dart format .).
Why You Should Use Dart Instead
Dart is a superior choice if the code contains conditions, loops, data parsing, or reusable logic. It removes the fragility of shell scripting and provides a great DX:
- Safe. Dart is a strongly-typed language with a familiar syntax that is easy to read & write. It runs consistently across macOS, Windows, and Linux without worrying about OS specifics.
- Powerful. First-class async support, native JSON handling, and full-featured I/O provide all the tools needed for complex scripts.
- Scalable. You gain access to
pub.devecosystem. Additionally, you can easily reuse, version, and share the scripts.
To learn more about creating CLI programs with Dart, check out Create CLI with Dart.
Dart Scripts are The Official Way
The Dart team uses Dart Scripts heavily in all their new tools. Here is an example from ffigen:
// tool/ffigen.dart
import 'dart:io';
import 'package:ffigen/ffigen.dart';
void main() {
final packageRoot = Platform.script.resolve('../');
FfiGenerator(
// Required. Output path for the generated bindings.
output: Output(dartFile: packageRoot.resolve('lib/add.g.dart')),
// Optional. Where to look for header files.
headers: Headers(entryPoints: [packageRoot.resolve('src/add.h')]),
// Optional. What functions to generate bindings for.
functions: Functions.includeSet({'add'}),
).generate();
}Instead of running dart run ffigen and creating a separate YAML configuration file, they suggest creating a script that contains both. This is an incredible idea, as it gives us type safety and the ability to read code comments around properties.
The Dart convention for these scripts is to put them into the tool folder. The same trend can be seen across other tools. Here is an example from the new hooks feature:
import 'package:code_assets/code_assets.dart';
import 'package:hooks/hooks.dart';
import 'package:native_toolchain_c/native_toolchain_c.dart';
void main(List<String> args) async {
await build(args, (input, output) async {
if (input.config.buildCodeAssets) {
final builder = CBuilder.library(
name: 'add',
assetName: 'add.g.dart',
sources: ['src/add.c'],
);
await builder.run(input: input, output: output);
}
});
}It is best practice for package authors to provide APIs for CLI packages, allowing them to be imported and run programmatically by other CLIs without requiring I/O and shell operations.
Refactoring Bash Script
In my recent article on creating effective CI, I discussed test sharding – splitting tests into separate groups to run in parallel. This is helpful when you have a lot of tests, and they start to waste too much time. To learn more, visit Effective CI for Flutter.
In that article, I used a Bash script to implement test sharding. It works, however, to understand what happens there, you would need to spend too much time. Let's rewrite it in Dart.
Handling Arguments
The script has a few positional arguments:
# Run Flutter tests in parallel, split across CI shards.
#
# ./flutter_test_runner.sh [SHARD_INDEX] [TOTAL_SHARDS] [SEED] [PKG_PARALLEL]
#
# SHARD_INDEX – 0‑based index of the current shard (default 0)
# TOTAL_SHARDS – how many shards the build is split into (default 1)
# SEED – deterministic shuffle seed (default 123)
# PKG_PARALLEL – packages tested in parallel *per shard* (default $(nproc || sysctl -n hw.ncpu || echo 4))
set -euo pipefail
# ──────────────────────────── arguments ────────────────────────────
SHARD_INDEX=${1:-0}
TOTAL_SHARDS=${2:-1}
SEED=${3:-123}
PKG_PARALLEL=${4:-$( (command -v nproc &>/dev/null && nproc) ||
(command -v sysctl &>/dev/null && sysctl -n hw.ncpu) || echo 4)}
# sanity checks
if [[ $SHARD_INDEX -lt 0 || $SHARD_INDEX -ge $TOTAL_SHARDS ]]; then
echo "❌ SHARD_INDEX must be in the range 0‑$((TOTAL_SHARDS - 1))" >&2
exit 1
fi
if [[ $TOTAL_SHARDS -lt 1 ]]; then
echo "❌ TOTAL_SHARDS must be ≥ 1" >&2
exit 1
fi
echo "▶️ Shard ${SHARD_INDEX}/${TOTAL_SHARDS}, seed $SEED, pkg‑parallel $PKG_PARALLEL"Since we are using Dart, we can use the args package to create named parameters:
ArgParser buildParser() => ArgParser()
..addOption('current-shard', abbr: 'c', mandatory: true)
..addOption('total-shards', abbr: 't', mandatory: true)
..addOption('seed', abbr: 's', mandatory: true)
..addOption('processes', abbr: 'p', defaultsTo: Platform.numberOfProcessors.toString());
void main(List<String> args) {
final parser = buildParser();
final result = parser.parse(args);
final currentShard = int.parse(result['current-shard']);
final totalShards = int.parse(result['total-shards']);
final seed = int.parse(result['seed']);
final processes = int.parse(result['processes']);
stdout
..writeln('Current shard: $currentShard')
..writeln('Total shards: $totalShards')
..writeln('Seed: $seed')
..writeln('Processes: $processes');
}Arguments parsing using package:args
If we try to run it now, the following response is returned:
~/projects/articles/bash_vs_dart
$ dart run bin/bash_vs_dart.dart -c 1 -t 3 --seed 123213
Current shard: 1
Total shards: 3
Seed: 123213
Processes: 12Finding Packages with Tests
After setting up variables, the script scans directories to find Flutter packages that contain tests. If a folder has pubspec.yaml and a test folder, it is added to the list:
# ──────────────────────── locate test packages ─────────────────────
echo "🔍 Scanning for Flutter packages that contain tests…"
PACKAGES=()
READ_CMD=(find . -name pubspec.yaml -type f)
[[ "$OSTYPE" != "darwin"* ]] && READ_CMD+=(-printf "%h\n") || READ_CMD+=(-exec dirname {} \;)
while IFS= read -r dir; do
[[ "$dir" == *"pub-cache"* || "$dir" == *".dart_tool"* ]] && continue
[[ -d "$dir/test" ]] && PACKAGES+=("$dir")
done < <("${READ_CMD[@]}")
TOTAL_PACKAGES=${#PACKAGES[@]}
echo "📦 Found $TOTAL_PACKAGES packages"Here, I have a condition which changes the READ_CMD that finds files based on the OS. I made this because everything worked locally, but on CI (Linux) – not. Nevertheless, let's implement it in Dart:
final packages = <Directory>[];
final excludedPaths = ['pub-cache', '.dart_tool'];
await for (final element in Directory.current.list(recursive: true)) {
if (excludedPaths.any(element.path.contains)) continue;
final pubspecExists = path.basename(element.path) == 'pubspec.yaml';
final testFolderExists = Directory(path.join(element.parent.path, 'test')).existsSync();
if (pubspecExists && testFolderExists) packages.add(element.parent);
}
stdout.writeln('Found ${packages.length} packages.');This code recursively visits directories and finds the packages with tests. Also, if the element contains excluded paths, it just skips it.
Shuffling and Creating Shards
Once we have the packages we want to run tests on, we need to shuffle the packages based on the seed and actually create the shards. I added shuffling to better separate the work between multiple jobs in CI.
# ───────────────────────── shuffle deterministically ───────────────
SHUFFLED=()
for pkg in "${PACKAGES[@]}"; do
HASH=$(echo "$pkg$SEED" | cksum | awk '{print $1}')
SHUFFLED+=("$HASH:$pkg")
done
IFS=$'\n' SHUFFLED=($(sort <<<"${SHUFFLED[*]}"))
unset IFS
for i in "${!SHUFFLED[@]}"; do SHUFFLED[$i]="${SHUFFLED[$i]#*:}"; done
# ───────────────────────── partition into shards ───────────────────
PER_SHARD=$(((TOTAL_PACKAGES + TOTAL_SHARDS - 1) / TOTAL_SHARDS))
START=$((SHARD_INDEX * PER_SHARD))
END=$((START + PER_SHARD - 1))
((END >= TOTAL_PACKAGES)) && END=$((TOTAL_PACKAGES - 1))
TARGET_PACKAGES=("${SHUFFLED[@]:START:END-START+1}")
echo "🧩 This shard runs ${#TARGET_PACKAGES[@]} package(s):"
printf ' • %s\n' "${TARGET_PACKAGES[@]}"Shuffling and partitioning of tests
In Dart, it's much easier:
packages.shuffle(Random(seed));
final shardSize = (packages.length / totalShards).ceil();
final shards = [
for (var i = 0; i < packages.length; i += shardSize)
packages.sublist(i, min(i + shardSize, packages.length)),
];
final thisShardPackages = shards.elementAtOrNull(currentShard - 1) ?? [];
stdout.writeln('This shard runs ${thisShardPackages.length} packages.');Running Tests and Collecting Summary
Now that we know the tests for the current shard, we need to run them and collect the summary:
# ────────────────────────── run in parallel ────────────────────────
TMP_STATUS=$(mktemp)
cleanup() { rm -f "$TMP_STATUS"; }
trap cleanup EXIT
run_tests() {
local PKG="$1"
echo "🚀 $(date +%T) $PKG – started"
pushd "$PKG" >/dev/null
if flutter test --no-pub --concurrency 8 -x golden --coverage \
--file-reporter json:test_report.json; then
echo "OK|$PKG" >>"$TMP_STATUS"
echo "✅ $(date +%T) $PKG – passed"
else
echo "FAIL|$PKG" >>"$TMP_STATUS"
echo "❌ $(date +%T) $PKG – failed"
fi
popd >/dev/null
}
export -f run_tests
export TMP_STATUS
printf '%s\n' "${TARGET_PACKAGES[@]}" |
xargs -n1 -P "$PKG_PARALLEL" -I{} bash -c 'run_tests "$@"' _ {}
# ──────────────────────────── summary ──────────────────────────────
FAILED_PACKAGES=()
while IFS= read -r line; do
if [[ "$line" == FAIL\|* ]]; then
FAILED_PACKAGES+=("${line#FAIL|}")
fi
done <"$TMP_STATUS"
if [[ ${#FAILED_PACKAGES[@]} -eq 0 ]]; then
echo -e "\n🎉 All packages passed on shard $SHARD_INDEX"
exit 0
else
echo -e "\n💥 Failures on shard $SHARD_INDEX:"
printf ' • %s\n' "${FAILED_PACKAGES[@]}"
echo "Total failed: ${#FAILED_PACKAGES[@]}"
exit 1
fiThis part of the script highlights another pain point when working with bash — parallelism. Since Bash doesn't support this natively, we need to create temp files, spawn subprocesses, then wait until they finish.
Let's repeat this parallel behavior in Dart. Here is a class that manages the test queue and records results:
class _TestsRunner {
_TestsRunner(List<Directory> packages, this._processes) : _packageQueue = Queue.of(packages);
final int _processes;
final Queue<Directory> _packageQueue;
final _exitCodes = <Directory, int>{};
final _inProgress = <Future<int>>[];
final _completer = Completer<void>();
Future<Map<Directory, int>> run() async {
unawaited(_processQueue());
await _completer.future;
return _exitCodes;
}
Future<void> _processQueue() async {
while (_inProgress.length < _processes && _packageQueue.isNotEmpty) {
final package = _packageQueue.removeFirst();
final future = _runTestsInPackage(package);
unawaited(_handleFuture(future, package));
}
if (_packageQueue.isEmpty && _inProgress.isEmpty) {
_completer.complete();
}
}
Future<void> _handleFuture(Future<int> future, Directory package) async {
_inProgress.add(future);
try {
final exitCode = await future;
_exitCodes[package] = exitCode;
_inProgress.remove(future);
unawaited(_processQueue());
} catch (error) {
stderr.writeln('Error running tests in ${path.basename(package.path)}: $error');
_exitCodes[package] = -1;
_inProgress.remove(future);
unawaited(_processQueue());
}
}
Future<int> _runTestsInPackage(Directory package) async {
// dart format off
final process = await Process.start('flutter', [
'test',
'--no-pub',
'--coverage',
'--file-reporter', 'json:test_report.json',
'-x', 'golden',
], workingDirectory: package.path);
// dart format on
process.stdout.listen(stdout.add);
process.stderr.listen(stderr.add);
return await process.exitCode;
}
}This test runner creates a queue from a list and processes it asynchronously. Once a task has finished, it starts a new one. It completes the completer when both the queue and the in-progress list are empty.
And this is how we can use this runner:
final testsRunner = _TestsRunner(thisShardPackages, processes);
final exitCodes = await testsRunner.run().then(
(r) => r.entries.toList()..sort((a, b) => a.value.compareTo(b.value)),
);
for (final entry in exitCodes) {
stdout.writeln('${path.basename(entry.key.path)}: ${entry.value != 0 ? '❌' : '✅'}');
}
final exitCode = exitCodes.any((entry) => entry.value != 0) ? 1 : 0;
exit(exitCode);This is a sample response:
00:00 +115: All tests passed!
bash_vs_dart: ✅
example_package1: ✅
example_package3: ✅
example_package2: ✅Summary
That is it. We have fully refactored the Bash script. The script has gotten an additional 20 lines of code, but it has become more robust and type-safe. It is a worthy trade-off.
You can find the full script on Gist:
