Skip to main content

Enhancing Flutter/Dart code quality with pre-commit hooks

· 8 min read
Olivier Revial
Lead mobile developer at Steeple

Enhancing Flutter/Dart code quality with pre-commit hooks

On any real-world Flutter project (but any project, really...), you want to run an analyzer and a linter at some point to ensure your quality matches the baseline your team agreed on. If you've been following me for some time, you might know that I love a promising CI/CD pipeline (heck, I even wrote a series on the topic 🙃). I always recommend running these checks in your CI pipeline.

I also love having good analysis rules in place. For this, my team put in place a custom version of analysis-options.yaml. I'm a big fan of the DCM. It's a great way to enforce a consistent code style and keep your codebase clean and maintainable.

The only problem is that running these checks in our CI pipeline with quite aggressive analyzers can be slow and cumbersome. It can also lead to long CI cycles that fail because of 2 minor style issues. Honestly, this isn't very pleasant. However, we still want to enforce strict coding rules, so how can we speed up our development cycle?

Spoiler: enter Git Hooks!

What Are Git Hooks?

Before diving into our solution, let’s briefly discuss Git hooks. Git hooks are scripts that run automatically at specific points in the Git workflow, as described in the schema below:

Git hooks

They can enforce policies, check code quality, and automate repetitive tasks.

Among these hooks, the pre-commit hook is particularly useful.

A pre-commit hook is a script that runs before a commit is created. It allows you to inspect the changes that are about to be committed and potentially prevent them if certain conditions are unmet. This makes it a powerful tool for maintaining code quality and consistency.

Why Add a Pre-Commit Hook?

Our project relies heavily on CI pipelines to perform Dart analysis and linting checks. While this ensures a high standard of code quality, it introduces several challenges:

  1. Delayed Feedback: Developers often wait several minutes for CI feedback after pushing their changes.
  2. Increased CI Load: Running these checks on every push increases the load on our CI infrastructure, leading to longer queue times and higher operational costs (e.g., GitHub Actions minutes).
  3. Post-Commit Discoveries: Issues that could easily be fixed locally are often discovered only after a CI run, resulting in multiple CI cycles and unnecessary commits.

To mitigate these issues, we will add a pre-commit hook that performs Dart analysis locally before committing any code. This will provide immediate feedback to developers, reduce the number of CI cycles and operational costs, and improve my mental health (I hate waiting for CI to fail because of a missing comma).

You may note that using hooks on big projects can start taking much time and slow down local development. Always find a balance.

Incorporating Dart & DCM analysis in pre-commit hook

I created a pre-commit hook that performs Dart analysis locally before committing code. This script checks for the necessary tools, runs Dart analysis, and prevents commits if the analysis fails:

Let’s see how this script works, step-by-step (the complete script is available at the end):

# Function to compare two semver versions
compare_versions() {
local v1=$1 v2=$2
local IFS=.
local i
local -a v1_parts=($v1) v2_parts=($v2)
for ((i=0; i<${#v1_parts[@]}; i++)); do
[[ ${v1_parts[i]} -lt ${v2_parts[i]} ]] && return 1
[[ ${v1_parts[i]} -gt ${v2_parts[i]} ]] && return 0
done
return 0
}

Here, we start with a utility function that will help us compare tool versions between requirements we defined in our projects and actual installed versions (more on later). This function compares using semver versioning.

# Get the staged files
STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM)

# Filter for Dart files and pubspec.yaml
DART_FILES=$(echo "$STAGED_FILES" | grep -E "\\.dart$|\\.yaml$")

# If no relevant files are found, exit
if [ -z "$DART_FILES" ]; then
echo "💤 No Dart or Yaml files found in the staging area. Skipping analysis."
exit 0
fi

The first step is to get the staged files (i.e., files to be committed) and filter for Dart and YAML files. If no relevant files are found, the script exits. This step is crucial because we only want to run the analysis if Dart or YAML files were changed; there is no need to run it if we've only added a README or updated the CHANGELOG.

# Check if Dart is installed
if ! command -v dart &> /dev/null
then
echo "❌ Dart is not installed. Please install Dart to proceed."
exit 1
fi

# Check if the dcm CLI tool is installed
if ! command -v dcm &> /dev/null
then
echo "❌ dcm CLI tool is not installed. Please install it to proceed."
exit 1
fi

In the next step, we want to ensure that our analysis tools are installed. In our case, we use the standard Dart analysis and additional DCM. Of course, if you are not using DCM in your project or are using some other plugin, you can update accordingly.

# Check minimum dcm version
if [ ! -f versions.txt ]; then
echo "❌ versions.txt file not found in the repository root. Please add it with the minimum_dcm_version."
exit 1
fi

MIN_DCM_VERSION=$(grep '^minimum_dcm_version=' versions.txt | cut -d'=' -f2)

if [ -z "$MIN_DCM_VERSION" ]; then
echo "❌ Minimum dcm version not specified in versions.txt."
exit 1
fi

# Determine the platform and use the appropriate grep command
if [[ "$OSTYPE" == "darwin"* ]]; then
# macOS
DCM_VERSION=$(dcm --version | grep -oE '[0-9]+\\.[0-9]+\\.[0-9]+')
else
# Other Unix-like systems
DCM_VERSION=$(dcm --version | grep -oP '\\d+\\.\\d+\\.\\d+')
fi

if ! compare_versions $DCM_VERSION $MIN_DCM_VERSION; then
echo "❌ DCM version $DCM_VERSION is less than the required minimum version $MIN_DCM_VERSION."
exit 1
fi

Here, we check the minimum version of the DCM CLI tool required for the project. This is useful if you have a team working on the project and want to ensure everyone uses the tool's version. We do that because different DCM versions will lead to different analysis reports, which is weird, so we'd prefer a fixed version every developer uses.

Note that we store the minimum version of DCM CLI in the versions.txt file at the root of the repository with the following content:

minimum_dcm_version=1.17.2

Of course, we could define other tool versions in this file and add the check for these tool versions in our script above too.

You can see that we don't declare versions for Dart and Flutter, and we don’t need to because there are already sets in the pubspec.yaml file, e.g.:

environment:
sdk: '>=3.4.0 <4.0.0'
flutter: ">=3.22.0 <4.0.0"
# Run Dart analysis
echo "⌛️ Running \\"dart analyze --fatal-infos\\"..."
dart analyze --fatal-infos
DART_ANALYSIS_EXIT_CODE=$?

# Run DCM analysis
echo "⌛️ Running \\"dcm analyze --fatal-style --fatal-performance ./\\"..."
dcm analyze --fatal-style --fatal-performance ./
DCM_ANALYSIS_EXIT_CODE=$?

# Check the exit codes of both analyses and provide detailed error messages
if [ $DART_ANALYSIS_EXIT_CODE -ne 0 ]; then
echo "❌ Dart analysis failed."
else
echo "✅ Dart analysis passed."
fi

Now that all the tools are set up with the appropriate version, it's time to run the analysis locally. In our case, dart analyze and dcm analyze. We use aggressive flags to make the hook fail if the analysis has information, style, or performance issues. This is a personal choice; you can adjust the flags to your liking.

if [ $DCM_ANALYSIS_EXIT_CODE -ne 0 ]; then
echo "❌ DCM analysis failed."
else
echo "✅ DCM analysis passed."
fi

# If either analysis failed, exit with an error code
if [ $DART_ANALYSIS_EXIT_CODE -ne 0 ] || [ $DCM_ANALYSIS_EXIT_CODE -ne 0 ]; then
echo "❌ One or both analyses failed. Please fix the issues and try committing again."
exit 1
fi

The next step is to output the analysis result and make the hook fail if any analyses fail. This is important because we want to prevent bad code from being committed to the repository. Note that we chose to run both analyses, even if the first one failed, to avoid annoying round-trips.

# If everything passed, allow the commit
echo "✅ All analyses passed. Commit is allowed."
exit 0

🎉 Finally, if everything passes, we display a beautiful success log and allow the commit to go through!

You can check my complete pre-commit hook here.

Conclusion

Implementing pre-commit hooks for Dart analysis using DCM has been a game-changer for our development workflow. Providing immediate feedback and reducing the load on our CI system has facilitated our processes and improved overall efficiency. While setting up and maintaining these hooks requires effort, the benefits far outweigh the challenges, making pre-commit hooks a valuable addition to any development team’s toolkit.

If you’re experiencing long feedback cycles and high operational costs due to CI-dependent validation, consider integrating pre-commit hooks into your workflow. It’s a simple yet effective way to enhance development efficiency and code quality.

Resources