
Overview
Twenty years ago, Git emerged as a version control system (VCS) to facilitate teamwork and track changes in a version history. Nowadays, Git is an essential tool for software developers, especially for those working in a team. Complementing Git, platforms like GitHub provide hosting for Git repositories, enabling collaboration and sharing.
In this post, I will show you how to setup a production-ready Git & GitHub workflow for your project. As you may know, this article is a part of the Flutter Ship series, which guides you in shipping a production-ready Flutter app. However, these principles can be applied to any language or framework.
By the end of this article, you will learn the following:
- How to implement a simple but effective branching strategy.
- How to adopt a commit message standard for a clean history and automated changelogs.
- How to automatically generate and manage project changelogs.
- How to use Git hooks to automate local checks, like formating & linting files, linting commits, and running tests.
- How to implement a GitHub PR Checks with GitHub Actions to automate your workflow.
If this sounds like what you’re looking for, let’s dive in!
Branch Strategy
A clear branching strategy is essential for any software project to prevent the chaos of direct commits to the main
branch. Working directly on main
can lead to frequent merge conflicts, broken builds, and lost code, especially in a team setting.
The solution is to use short-lived branches for every new piece of work, whether it’s a feature, bugfix, or chore. While there are many branching strategies, I recommend a strategy based on the popular and straightforward GitHub Flow. It’s simple to understand, promotes continuous delivery, and works exceptionally well for most projects.
The core idea is illustrated below:
Branch Types Explained
- Main Branch (
main
): This is the stable branch that always reflects the latest production-ready state of your project. All releases and deployments are made from here. - Feature branches (
feature-*
ortask-id-*
): Used for developing new features, enhancements, bug fixings or tasks. If you use a task management tool (like Jira), you can integrate with it and name your branch after the task or ticket ID (e.g.,ab-123-add-login
). Each feature/task branch is short-lived and merged back intomain
via a pull request after review and testing.
This model also supports different development environments with ease. When you complete a feature or task, you open a pull request (PR). You can configure a GitHub Action (or any other CI/CD service) to run tests and automate the generation of APKs for Android and IPAs for iOS, and distribute them to testers or users via your chosen platforms. I’ll cover Flutter Continuous Delivery (CD) in detail in a future article.
Git & Github Cheat SheetI assume you already know the basics of Git and GitHub, so I won’t include basic Git commands here. If you need a refresher, check out my Git & GitHub Cheatsheet article.
Git Merge RecommendationIf you merge branches locally,
merge --no-ff
is highly recommended. The--no-ff
flag causes the merge to always create a new commit object, even if the merge could be performed with a fast-forward. This avoids losing information about the historical existence of a feature branch and groups all commits that together added the feature.
Commit Messages
Commit messages are your working history. While the probability of reading them is low, they are still valuable for maintaining a good commit history and understanding changes later on. At least once in a while, you need to read the history to get more details about an emerging issue. Furthermore, in most software projects, the changelog will be generated based on those commit messages to show the release changes between versions. So what could you find or generate if you have the following history?
* 4dcd0dc fix color* 1e0acd7 fix issue* 897cab6 Merge branch 'add-comment'|\| * 7b2c3f3 integration|/* 3d0b48d add feature* 7287225 updated* 1405829 update post* dba0ea9 add post
To have team-level agreed standards for git commit messages, I highly recommend following Conventional Commits.
<type>[optional scope]: <description>
[optional body]
[optional footer(s)]
Check out the following example that uses conventional commit messages, which are much easier to understand and allow you to easily generate changelogs:
* 4dcd0dc Update themeColor fixed property to true* 1e0acd7 fix(comment): Update check script to include biome CI* 897cab6 Merge branch 'add-comment'|\| * 7b2c3f3 feat(comment): Integrate Giscus for comments and add styles|/* 8cbd821 fix(dependencies): Update astro and biome versions in pnpm-lock.yaml* 3d0b48d feat: Add support for rendering mermaid codes in the markdown files* 7287225 ci: Update code quality job* 1405829 refactor(post): Update muslim data post and released* dba0ea9 feat(post): Add the first draft of muslim data post* 31a1ef8 build: Remove astro compress
Commit StructureI highly recommend you to read the Conventional Commits documentation to get more information about the commit structure like type, scope, description, body, fotter, and breaking changes.
AI-Generated Commit Messages
There are many tools that can help you write better conventional commits, and there are also AI tools to generate them. I used to test various tools to create commit messages, but recently I started using GitHub Copilot to generate conventional commit messages. By default, Copilot doesn’t generate conventional commit messages, so we need to set a custom prompt in the settings.
VSCode Copilot Configuration for Commit Messages
If you’re using VS Code with workspace-based settings, you can find the settings file at your-project > .vscode > settings.json
.
For user-based settings, navigate to: Settings > Extensions > GitHub Copilot Chat > Commit Message Generation
as attached below:
After you find the settings, add the following prompt to generate conventional commit messages:
// ...existing settings
"github.copilot.chat.commitMessageGeneration.instructions": [ { "text": "Generate a commit message that MUST follow the Conventional Commits format (feat, fix, docs, style, refactor, test, perf, build, ci, chore, revert). Include a brief description of the change in the subject line (under 72 characters) and a summarized explanation in the body, if necessary. Separate the subject and body with a blank line. Example: 'feat(auth): Add forgot password functionality to the login screen' or 'fix(products): Fix date format in the product list'." }]
// ...existing settings
You can update the above prompt based on your needs. Now you can generate it with one click! 🎉
Always Review AI SuggestionsAI-generated messages are a helpful starting point, but they aren’t always perfect. Always review and refine the message for accuracy and clarity before committing.
Alternative Tools
Besides GitHub Copilot, here are other popular tools for conventional commits:
- Commitizen: Interactive CLI tool that helps you to create conventional commits, auto bump versions and auto changelog generation.
- Conventional Commits VS Code Extension: GUI helper for VS Code users.
- Cocogitto: The Conventional Commits toolbox.
- AI Commits: A CLI that writes your git commit messages for you with AI.
- More: You can find more on the conventional commits about page.
Changelog
A CHANGELOG.md
is a way to tell your consumers what has changed between versions. One of the most effective ways to do this is to generate it based on your git commit messages, which contain all your change history. There are many tools for generating changelogs, but one of my favorite tools is Git Cliff, which is a highly customizable changelog generator. It only takes three simple steps from installation to generation, as explained below:
Setup Git Cliff
- Installation: It supports most package managers, so you can find your preferred one here. I use macOS, so I installed it via Homebrew:
brew install git-cliff
- Initialization: After installation, navigate to your project and initialize it by running the following command, which creates a file named
cliff.toml
for further customization:
git-cliff --init
- Generate: Finally, it’s ready to generate a changelog by running the following command:
git-cliff -o CHANGELOG.md
That’s all you need to generate a changelog! Here’s an example that has been generated by Git Cliff. If you need any further customization, you can simply open and edit the cliff.toml
file.
Common Customizations
I’ve picked some useful customizations from the cliff.toml
to explain here:
1. Update the header & footer description
[changelog]# template for the changelog headerheader = """# Changelog\nAll notable changes to this project will be documented in this file.\n"""
# template for the changelog footerfooter = """<!-- generated by git-cliff -->"""
2. Support conventional and non-conventional commits
[git]# parse the commits based on https://www.conventionalcommits.orgconventional_commits = true# filter out the commits that are not conventionalfilter_unconventional = true# process each line of a commit as an individual commitsplit_commits = false
3. Customize group ordering
# regex for parsing and grouping commitscommit_parsers = [ # Group Ordering { message = "^feat", group = "<!-- 0 -->🚀 Features" }, # 0 { message = "^fix", group = "<!-- 1 -->🐛 Bug Fixes" }, # 1 { message = "^doc", group = "<!-- 3 -->📚 Documentation" }, # 3 { message = "^perf", group = "<!-- 4 -->⚡ Performance" }, # 4 { message = "^refactor", group = "<!-- 2 -->🚜 Refactor" }, # 2 <- ordered to 2 { message = "^style", group = "<!-- 5 -->🎨 Styling" }, { message = "^test", group = "<!-- 6 -->🧪 Testing" }, # ...]
4. Skip specific commit groups
# regex for parsing and grouping commitscommit_parsers = [ # ... { message = "^perf", group = "<!-- 4 -->⚡ Performance" }, { message = "^refactor", group = "<!-- 2 -->🚜 Refactor" }, { message = "^style", group = "<!-- 5 -->🎨 Styling" }, { message = "^test", group = "<!-- 6 -->🧪 Testing" }, { message = "^chore\\(release\\)", skip = true }, # <-- skipped group # ...]
5. Sort commits by date
# Organize commits within sections by oldest or newest ordersort_commits = "oldest"
For more advanced customizations, check out the Git Cliff documentation.
Git Hooks
Git hooks are scripts that automatically execute before or after specific Git actions, like committing, pushing, or merging. They allow you to customize Git’s behavior, automate tasks, enforce policies, and streamline your workflow. read more
We can take advantage of Git hooks to automate the following tasks:
- pre-commit: Before a commit is created.
- Apply
dart fix
to automatically fix issues. - Apply
dart format
to reformat staged files. - Run the Flutter linter to check for analysis issues.
- Apply
- commit-msg: After a commit message is written, but before the commit is created.
- Validate the commit message to ensure it follows the Conventional Commits standard.
- pre-push: Before pushing code to a remote repository.
- Run all Flutter tests to prevent pushing broken code.
Keep Git Hooks Fast and ReliableRegarding the pre-push hook, if you have a large test suite that makes your
git push
command too slow, you can skip this hook and set up a PR check in your CI/CD pipeline as an alternative.
The Challenge with Manual Git Hooks
While you can set up hooks manually by placing executable scripts (e.g., pre-commit
) in your project’s .git/hooks/
directory, this approach has drawbacks. The .git
directory is not version-controlled, so hooks are not shared across your team. Each developer must set them up manually, and keeping them updated is a maintenance burden. A Git hook manager solves these problems.
Git Hook Management with Lefthook
There are many hook managers available, but since there isn’t one made specifically for Dart, we’ll use a language-agnostic tool that works with any project: Lefthook. It’s fast, powerful, and what I use in my own Flutter projects.
To get started, install Lefthook using your preferred package manager and run lefthook install
in your project’s root directory. This command creates a lefthook.yml
configuration file and installs the hooks into your .git
directory.
TIPI highly recommend reading the Lefthook documentation before proceeding.
Lefthook Configuration
Let’s configure lefthook.yml
to run the tasks we outlined earlier.
pre-commit: Fix, Format, and Lint
This hook will run three commands in parallel on your staged Dart files before every commit.
pre-commit: parallel: true commands: auto-fix: run: dart fix --apply && git add {staged_files} pretty: glob: '*.dart' run: dart format {staged_files} && git add {staged_files} linter: run: flutter analyze {staged_files} --no-fatal-infos --no-fatal-warnings
10 collapsed lines
commit-msg: commands: validate: run: 'scripts/validate_commit_msg.sh'
pre-push: parallel: true commands: tests: run: flutter test
commit-msg: Validate Commit Message
Validating a commit message against the Conventional Commits specification is too complex for a single-line command, so we’ll use an external script.
First, create the validation script in this path: scripts/validate_commit_msg.sh
.
#!/bin/bash
# Path to the commit message fileCOMMIT_MSG_FILE=".git/COMMIT_EDITMSG"
# Check if the commit message file existsif [ ! -f "$COMMIT_MSG_FILE" ]; then echo "Commit message file does not exist." exit 1fi
# Read the commit messageCOMMIT_MSG=$(cat "$COMMIT_MSG_FILE")
# Define the Conventional Commits regex patternPATTERN='^(feat|fix|build|chore|ci|docs|style|refactor|perf|test)(\([^)]*\))?(!)?: .+'
# Validate the commit messageif [[ "$COMMIT_MSG" =~ $PATTERN ]]; then echo "👍 Valid commit message!" exit 0else echo "👎 Invalid commit message format." echo "Commit message should follow the Conventional Commits format." exit 1fi
Next, make the script executable:
chmod +x scripts/validate_commit_msg.sh
Finally, add the commit-msg
hook to your lefthook.yml
:
10 collapsed lines
pre-commit: parallel: true commands: auto-fix: run: dart fix --apply && git add {staged_files} pretty: glob: '*.dart' run: dart format {staged_files} && git add {staged_files} linter: run: flutter analyze {staged_files} --no-fatal-infos --no-fatal-warnings
commit-msg: commands: validate: run: 'scripts/validate_commit_msg.sh'
5 collapsed lines
pre-push: parallel: true commands: tests: run: flutter test
pre-push: Run Tests
This hook runs all Flutter tests before you push. The push will be blocked if any tests fail.
15 collapsed lines
pre-commit: parallel: true commands: auto-fix: run: dart fix --apply && git add {staged_files} pretty: glob: '*.dart' run: dart format {staged_files} && git add {staged_files} linter: run: flutter analyze {staged_files} --no-fatal-infos --no-fatal-warnings
commit-msg: commands: validate: run: 'scripts/validate_commit_msg.sh'
pre-push: parallel: true commands: tests: run: flutter test
After you completed the configuration, you need to rerun the lefthook install
to resync the git hooks. With this setup, your local development workflow is now automated to enforce code quality and consistency.
GitHub PR Checks
In this section, we won’t do a deep dive into CI/CD, as we have already planned a dedicated article for it. Instead, we will set up a basic but essential GitHub Actions workflow to automatically analyze and test our code whenever a pull request (PR) is opened against the main
branch.
GitHub Actions is a continuous integration and continuous delivery (CI/CD) platform that allows you to automate your build, test, and deployment pipeline. You can create workflows that build and test every pull request to your repository, or deploy merged pull requests to production. read more
TIPIf you are new to CI/CD, I highly recommend reading the GitHub Actions documentation to get familiar with the core concepts.
Flutter CI
To set up a GitHub Action for running test on PR, create a .github/workflows
directory in the root of your project. Inside that directory, create a file named pull_request.yml
with the following content:
name: Flutter PR CI
# This workflow runs on pull requests targeting the main branch.on: pull_request: branches: - main
jobs: analyze_and_test: name: Analyze & Test runs-on: ubuntu-latest steps: # 1. Check out the repository code - name: Checkout code uses: actions/checkout@v4
# 2. Set up the Flutter environment - name: Set up Flutter uses: subosito/flutter-action@v2 with: # Use the Flutter version from pubspec.yaml flutter-version-file: pubspec.yaml # Enable caching for faster builds cache: true
# 3. Install project dependencies - name: Install dependencies run: flutter pub get
# 4. Run static analysis to check for code quality - name: Analyze code run: flutter analyze --no-fatal-infos --no-fatal-warnings
# 5. Run all tests to ensure the PR is stable - name: Run tests run: flutter test
This workflow ensures that every pull request is automatically checked for code quality and passing tests before it can be merged, preventing broken code from reaching your main
branch.
More Advanced Checks
While our current workflow is robust, you can further enhance code quality and security by enabling additional checks on GitHub.
1. Enforce Conventional Commits in GitHub
Local Git hooks are great for immediate feedback, but they can be bypassed with a simple --no-verify
flag. To guarantee that every commit merged into main
follows the Conventional Commits standard, you can add a check to your CI pipeline. This ensures a clean and consistent commit history, which is significant for generating accurate changelogs and understanding project history.
You can use a GitHub App like Cocogitto-bot to validate all commit messages in a pull request.
2. Automated Dependency Management with Dependabot
Keeping dependencies up-to-date is crucial for security and accessing new features. GitHub’s Dependabot automates this process by:
- Security Updates: Automatically creating pull requests to update vulnerable dependencies as soon as they are discovered.
- Version Updates: Regularly checking for new versions of your dependencies and creating PRs to keep them current.
You can enable and configure Dependabot by creating a dependabot.yml
file in your .github
directory. Here’s a basic configuration for a Flutter project:
version: 2updates: - package-ecosystem: "pub" directory: "/" schedule: interval: "weekly" day: "sunday" time: "00:00" timezone: "Asia/Baghdad" labels: - "dependencies"
This is a basic configuration example. For more advanced setups, you can optimize and customize Dependabot’s behavior.
3. Implement Branch Protection Rules
Branch protection rules are a powerful GitHub feature that prevents direct pushes to important branches like main
and enforces quality gates for pull requests. You can configure them in your repository settings under Settings > Branches > Add branch ruleset.
Key rules include:
- Require status checks to pass: This is essential. It forces your
Analyze & Test
workflow (and any others) to succeed before a PR can be merged. - Require a pull request before merging: Disallows direct pushes to
main
and forces all changes to go through a review process. - Require signed commits: Ensures that commits are from a verified source, enhancing security.
By combining your CI workflow with these advanced checks, you create a nearly foolproof system for maintaining a high-quality, secure, and stable codebase.
Source Code
To see all the concepts from this article implemented in a real Flutter project, check out the Simple Todo App repository. Switch to the 01-git-github-workflow
branch to see all in action.
# Clone and explore the implementationgit clone https://github.com/kosratdev/simple_todo.gitcd simple_todogit checkout 01-git-github-workflow
# Install Lefthook and see the hooks in actionlefthook install
This repository serves as a practical reference for implementing everything we covered in this article. Feel free to fork it and use it as a starting point for your own Flutter projects!
We’ve covered a lot of ground in this article, from establishing a solid branching strategy to automating our entire workflow. By implementing GitHub Flow, Conventional Commits, automated changelogs with Git Cliff, local quality checks with Lefthook, and a basic CI pipeline with GitHub Actions, you’ve built a robust foundation for any Flutter project.
This setup isn’t just about following rules; it’s about creating a predictable, high-quality, and efficient development process. It ensures that every piece of code is consistent, tested, and ready for collaboration, freeing you up to focus on what matters most: building great features.
This is just the beginning of our journey. In the next article of the Flutter Ship series, we’ll take this foundation and build upon it by setting up different flavors for different environments.
I hope this guide has been helpful. If you have any questions, suggestions, or want to share your own workflow tips, please leave a comment below.
Happy coding!🚀