Skip to content

Collaborative Git Workflows

When starting a project, especially a collaborative project with a lot of contributors, you need to decide how to organize the development process. If joining an existing project, you need to thumb through their documentation or contact the project maintainers to see how to contribute to their project.

This guide is oriented for the use of Gitlab as the server hosting the remote repository of a project, though it can easily be adapted for Github or any other online software forge offering Git support.

Branches and Workflows

One of the thing you need to decide upon quickly, is the branching strategy you will use as a team. This is rather important, as it will determine what are the roles of each branch, how the creation and merging of branches should proceed and what development workflow you will generally use.

Fortunately, you don't have to come up with your own workflow, as git users have been experimenting on those for decades. Here are the more popular:

Workflow Key Features Pros Cons
Trunk-Based - Single long-lived main branch
- Short-lived feature branches
- Frequent merges
- Often uses feature flags
✅ Simple and fast integration
✅ Encourages CI/CD
✅ Fewer long-running branches
❌ Requires strong CI discipline
❌ Feature flags can be complex
❌ Less release control
Git Flow - main + develop branches
- Long-lived branches
- Separate feature, release, hotfix branches
✅ Structured process
✅ Supports release staging
✅ Isolates features and fixes
❌ Complex to manage
❌ Slower delivery
❌ Not CI/CD-friendly without automation
GitHub Flow - Single main branch
- All changes via PR
- Rapid short-lived branches
- Continuous integration/deployment
✅ Very simple
✅ Ideal for rapid delivery
✅ Encourages review & testing
❌ No formal release flow
❌ Less suitable for large teams
❌ Needs strict discipline

Trunk-Based Development

Principle

Trunk-based development encourages all developers to work in a single shared branch—usually called main—and to integrate small, frequent changes. Feature branches are short-lived and merged back rapidly, often multiple times per day. This approach reduces integration risk and aligns with continuous integration/deployment.

Git Graph

%%{init: { 'logLevel': 'debug', 'theme': 'base', 'gitGraph': {'showBranches': true, 'showCommitLabel':true}} }%%
gitGraph
   commit id: "Initial commit"
   commit id: "Main development"
   branch feature-a
   commit id: "Feature A work"
   checkout main
   commit id: "Minor fix"
   merge feature-a id: "Merge Feature A"
   branch feature-b
   commit id: "Feature B work"
   checkout main
   merge feature-b id: "Merge Feature B"
   commit id: "Release prep"

Key Features

  • A single long-lived branch (main)
  • Short-lived feature branches merged quickly
  • Strong reliance on CI/CD and testing
  • Often used with feature toggles/flags

Pros

  • Simplifies branching strategy
  • Encourages frequent integration
  • CI-friendly, ideal for DevOps and CD
  • Reduces merge conflicts and drift

Cons

  • Requires a solid CI setup to avoid breakage
  • Feature flags can add complexity
  • Less structure for managing releases

Git Flow

Principle

Git Flow separates code into multiple branches for features, releases, and hotfixes. It uses a develop branch for integration and a main branch for production. Releases are prepared in a dedicated release branch, and urgent patches are handled through hotfix branches.

Git Graph

%%{init: { 'logLevel': 'debug', 'theme': 'base', 'gitGraph': {'showBranches': true, 'showCommitLabel':true}} }%%
gitGraph
   commit id: "Initial commit"
   branch develop order: 3
   commit id: "Dev work"
   branch feature-x order: 4
   commit id: "Feature X"
   checkout develop
   merge feature-x id: "Merge Feature X"
   branch release order: 1
   commit id: "Release prep"
   checkout main
   merge release id: "Release to main" tag: "v1.0"
   checkout develop
   merge release id: "Merge release back"
   branch hotfix order: 2
   commit id: "Hotfix 1"
   checkout main
   merge hotfix id: "Hotfix to main" tag: "v1.0.1"
   checkout develop
   merge hotfix id: "Hotfix back to develop"

Key Features

  • main holds only production-ready code
  • develop is the integration branch
  • Long-lived feature, release, and hotfix branches
  • Uses semantic versioning and tags

Pros

  • Clear structure for teams with scheduled releases
  • Isolates features and bugfixes
  • Suitable for large projects with QA/staging phases

Cons

  • Overhead of managing multiple branches
  • Slower integration cycle
  • Confusing for beginners
  • Not ideal for continuous delivery

GitHub Flow

Principle

GitHub Flow focuses on continuous deployment and simplicity. All development happens in short-lived branches off main, and every change goes through a pull request (merge request). Merges only happen when tests pass and code is reviewed.

Git Graph

%%{init: { 'logLevel': 'debug', 'theme': 'base', 'gitGraph': {'showBranches': true, 'showCommitLabel':true}} }%%
gitGraph
   commit id: "Initial commit"
   commit id: "Main updates"
   branch feature-x
   commit id: "Feature X work"
   commit id: "Polish"
   checkout main
   merge feature-x id: "Merge Feature X"
   branch feature-y
   commit id: "Feature Y work"
   checkout main
   merge feature-y id: "Merge Feature Y"
   commit id: "Release v1.0" tag: "v1.0"

Key Features

  • Always-deployable main
  • Each feature via pull request
  • Continuous delivery emphasis
  • Review-first approach

Pros

  • Simple and effective
  • Encourages fast iterations
  • Great for open-source and startups
  • Works well with modern CI/CD tools

Cons

  • Can be too simplistic for enterprise workflows
  • No built-in support for hotfixes or releases
  • Relies heavily on discipline for versioning

Merge Strategies

When collaborating on code using Git, how you integrate changes from one branch into another has a big impact on your project's commit history, traceability, and debugging ease. Git supports multiple merging strategies, each with its pros and cons depending on your project's structure and workflow.

Merge Commit (default)

Command:

1
git merge feature-branch

What it does: Creates a new commit that combines the changes of both branches, preserving the history of both lines.

%%{init: { 'logLevel': 'debug', 'theme': 'base', 'gitGraph': {'showBranches': true, 'showCommitLabel':true}} }%%
gitGraph:
   commit id: "Initial"
   commit id: "Modif1"
   commit id: "Modif2"
   branch feature-branch
   commit id: "feature: Modif1"
   commit id: "feature: Modif2"
   checkout main
   merge feature-branch id: "Merge feature-branch" type: HIGHLIGHT

Use when:

  • You want a clear record of when two branches were merged
  • Preserving the full history is important
  • You're following Git Flow or working in teams with multiple branches

Pros:

  • Maintains full history
  • Easy to trace merges
  • Safer in collaborative environments

Cons:

  • More verbose history
  • Can create "merge commits noise" if overused

Fast-Forward Merge

Command:

1
git merge --ff-only feature-branch

What it does: Moves the target branch pointer forward to the tip of the feature branch—no new commit created. Let's say we have created the same feature-branch as in previous section, with the same 2 commits. We will get the following state after fast-forward merging:

%%{init: { 'logLevel': 'debug', 'theme': 'base', 'gitGraph': {'showBranches': true, 'showCommitLabel':true}} }%%
gitGraph:
   commit id: "Initial"
   commit id: "Modif1"
   commit id: "Modif2"
   commit id: "feature-branch: Modif1"
   commit id: "feature-branch: Modif2" type: HIGHLIGHT

Use when:

  • The target branch hasn’t diverged (e.g., working solo or rebased feature branches)
  • You want a clean, linear history

Pros:

  • Clean, linear history
  • No unnecessary commits

Cons:

  • History can be misleading (harder to trace features)
  • Not possible if branches diverged

Squash and Merge

Command:

1
git merge --squash feature-branch

Or via GitHub/GitLab UI: “Squash and Merge”

What it does: Combines all commits from the feature branch into a single commit on the target branch. Does not create a merge commit.

Let's say we have created the same feature-branch as in Merge commit (default), with the same 2 commits. We will get the following state after squash merging and commiting:

%%{init: { 'logLevel': 'debug', 'theme': 'base', 'gitGraph': {'showBranches': true, 'showCommitLabel':true}} }%%
gitGraph:
   commit id: "Initial"
   commit id: "Modif1"
   commit id: "Modif2"
   commit id: "Squash feature-branch" type: HIGHLIGHT

Use when:

  • You want to simplify history
  • Feature branch has lots of small commits
  • You care about what changed, not how

Pros:

  • Clean history
  • Ideal for small features or bugfixes
  • Encourages meaningful commit messages

Cons:

  • Loses individual commit history
  • Harder to debug incremental steps
  • Makes cherry-picking difficult

Rebase (alternative to merging)

Command:

1
2
git checkout feature-branch
git rebase main

What it does: Moves the entire feature branch onto the tip of another branch, rewriting history.

Let's say we have the following git commit graph before rebasing:

%%{init: { 'logLevel': 'debug', 'theme': 'base', 'gitGraph': {'showBranches': true, 'showCommitLabel':true}} }%%
gitGraph:
   commit id: "Initial"
   commit id: "Modif1"
   branch feature-branch
   commit id: "feature: Modif1"
   commit id: "feature: Modif2" type: HIGHLIGHT
   checkout main
   commit id: "Modif2"
   commit id: "Modif3"
We will get the following after:
%%{init: { 'logLevel': 'debug', 'theme': 'base', 'gitGraph': {'showBranches': true, 'showCommitLabel':true}} }%%
gitGraph:
   commit id: "Initial"
   commit id: "Modif1"
   commit id: "Modif2"
   commit id: "Modif3"
   branch feature-branch
   commit id: "feature: Modif1"
   commit id: "feature: Modif2" type: HIGHLIGHT

Use when:

  • You want a linear history
  • You’re working solo or with rebasing discipline

Pros:

  • Very clean history
  • Useful before merging to main

Cons:

  • History rewriting can be dangerous in shared branches
  • Requires discipline and understanding

Summary Table

Strategy Creates New Commit? History Best For
Merge Commit ✅ Yes Full & branched Team collaboration, Git Flow
Fast-Forward ❌ No Linear Solo dev, up-to-date main
Squash and Merge ✅ Yes (single commit) Flattened Simplifying noisy commit history
Rebase ❌ No (rewrites history) Linear Clean history, solo work before merging

Protected Branches

Protected branches are branches in a Git repository that have restrictions placed on them to prevent unwanted changes. This is a key feature for maintaining code quality and enforcing collaboration workflows.

What Are Protected Branches?

In Git (and especially in platforms like GitLab, GitHub, Bitbucket), protected branches are used to:

  • Prevent direct pushes
  • Require merge requests (MRs) for changes
  • Enforce successful CI before merge
  • Restrict who can push, merge, or force-push
  • Prevent branch deletion

By default, teams often protect shared branches like main, develop, or release branches to enforce safe collaboration practices.

Common Protections

Feature Description
Disallow force-push Prevents rewriting history
Prevent direct pushes Only allows changes via Merge Requests
Require code review Only merges allowed after approval(s)
Enforce status checks / CI success Merges blocked until CI passes
Restrict who can merge Limit merging to Maintainers or specific users/groups
Prevent deletion Avoid accidental loss of important branches

When to Use

Use protected branches to:

  • Protect production and release code
  • Enforce team collaboration workflows
  • Avoid accidental or malicious changes
  • Integrate code reviews and CI as gates

Forks

Whenever you are joining an existing project, especially in the open source community, the usual workflow starts by creating your own copy of the remote repository called a fork.

This simply means:

  1. cloning the online repository on your local machine
  2. create a remote repository on your forge of choice
  3. Rename the existing repository remote as something other than origin (e.g upstream)
  4. add your new repository as the origin remote of your local repository.
  5. Make your new origin repository the tracked remote for the main working branch
  6. Push to your new repository

The whole process can look like this, let's say you already created a new empty repository for your fork of another repository:

1
2
3
4
5
git clone <existing repository URL>
git remote rename origin upstream
git remote add origin <new repository URL>
git branch -u origin <current branch>
git push origin <current branch>

This means that you will have two remotes:

  • origin: your forked repository which you own
  • upstream: the original repository which you forked

Most forges offer a direct way to fork a repository on their platform.

A fork simply means you copy a specific version of another repository, then continue development from that point on. This will make the two repository diverge on the long run. You can always get the latest changes from the original upstream repository and apply them to your own forked repository. This can look like this:

1
2
3
git fetch upstream       # Sync the state of the upstream remote repository
git merge upstream/main  # Merge the upstream main branch with your current branch
git push origin main     # Push merged result on your remote repository

If you want to make both repository converge, you might want to push your changes back to the other repository. Depending on the other repository rules and permissions, this might/should be done using a pull request (Github) or merge request (Gitlab).

Info

When directly pushing your branch to the upstream remote if the latter has configured the use of pull requests/merge requests, this will fail and propose you a link to create the request on the upstream repository.

Pull/Merge Requests

A pull or merge request is an official request put on an online repository for your changes to be merged on. This can be used for instance to merge changes from a forked repository back to the original (upstream) repository. Though it might also be used inside a single repository to merge distributed tasks.

The creation and management of such requests is generally done directly on the software forge website. It demands filling a form to explain what your changes are, making your case for merging them back on the original repository. It will then take the shape of a git branch, on which you will push those changes, created on the original repository.

Depending on the maintainer's review process, the interaction will probably proceed on the MR page. You will need to make your case for those changes, possibly adapt and push them, until the maintainer formally accept or decline them.

Some repositories might be configured in a way that MR can only be done in association with an open issue. In that case, you should open an issue, presenting the issue your changes are solving or the new feature it introduces. Then you can create a branch for that issue, and push your changes to it. Once you are ready for review, you can then open the MR (or you can create the branch and MR at the same time). In that case, the discussions will probably take place on the issue page, though if a Continuous Integration (CI) pipeline is set up by the maintainer, it might give feedback on the MR page (e.g tests passing or failing, code bad/well formatted, etc).

Tip

Even if working with others on a single repository, it really is a good practice to enforce strict compliance rules when merging changes on a shared main branch. Those might even be automated by a Continuous Integration (CI) pipeline. Yet simply forcing you and your contributors to go through this formal process, especially if you apply a review process on MR, is a great way to work cleanly on a collaborative project.

Code Review Process

Code review is a fundamental practice in professional software development. It ensures quality, consistency, and knowledge sharing across a team. Regardless of the Git forge (GitLab, GitHub, Bitbucket…), the principles remain the same, though tooling and terminology may differ slightly.

What Is Code Review?

A code review is the process of systematically examining changes made in a codebase—usually through a merge request (MR) or pull request (PR)—before merging them into a shared branch (typically main, develop, or similar).

🔁 Code reviews are typically initiated when a developer opens a merge request or pull request on the project’s Git forge.

Code Review Flow (Generalized)

  1. Create a Feature Branch Work on a new feature, bugfix, or improvement in an isolated branch.

  2. Push Changes Push commits to the remote repository (the forge hosts this).

  3. Open a Merge Request (MR) / Pull Request (PR) This is the formal request for code review and merging. Done via the forge UI (e.g. GitLab → Merge Request, GitHub → Pull Request).

  4. Review and Feedback Teammates or designated reviewers:

  • Comment on specific lines or files
  • Ask for clarifications or changes
  • Approve or request changes
  1. Iterate The author addresses feedback by pushing additional commits to the same branch.

  2. Merge Once approved, the MR/PR is merged using the selected merging strategy (merge commit, squash, or rebase).

Best Practices for Code Review

For Authors For Reviewers
Keep changes small and focused Review promptly and constructively
Add clear title and description Test changes if applicable
Link issues or user stories Ask questions instead of making assumptions
Document design decisions in the MR/PR Look for bugs, maintainability, style
Re-request review after updates Approve only when you're confident

Code Review Features in Git Forges

While the process is mostly the same, forges provide tooling to support it:

Feature GitLab / GitHub / Bitbucket Notes
MR/PR discussions Inline comments on specific lines or files
Suggested changes Direct suggestions that can be committed with one click
Approvals Require approval from one or more reviewers before merge
CI status integration Block merge if CI fails
Reviewers assignment Assign specific people or teams to review
Draft MRs/PRs Indicate work in progress to prevent early merging
Merge checks Enforce branch protection rules (e.g., only merge if approved)

When Is Review Mandatory?

You may enforce review as mandatory by:

  • Protecting branches so only merge requests are allowed
  • Requiring a minimum number of approvals before merge
  • Blocking merge until CI passes

These configurations are done in the forge settings for each repository or project.

Why It Matters

  • Improves quality: Prevent bugs and regressions early
  • Increases team knowledge: Share design decisions and context
  • Encourages consistent style and practices
  • Fosters collaboration: Everyone becomes familiar with the whole codebase

Code Review Checklist (example)

It is a good idea to formalize the objectives of code review as a checklist, which contributions (e.g Merge Requests) must follow to be accepted. Depending on the project, those can vary, but here is a generic example of such checklist for a typical python project.

Note

This is list is rather exhaustive, and contains topics which you may not be familiar with yet. We strongly recommend you to skip it for now, and return to it once you have fully read the Python Professional Software Development guides serie.

🔍 General

  • [ ] Is the purpose of the change clear from the merge/pull request title and description?
  • [ ] Are the changes appropriately scoped (small, focused, single concern)?
  • [ ] Are unrelated changes (e.g., formatting, config tweaks) avoided?

🧠 Correctness & Logic

  • [ ] Does the code do what it claims to do?
  • [ ] Are edge cases and error conditions handled?
  • [ ] Are assumptions clearly documented or enforced?
  • [ ] Are unit tests provided for new or changed logic?

🧼 Code Style & Formatting

  • [ ] Is the code PEP 8 compliant? (use flake8, black, etc.)
  • [ ] Are variable and function names meaningful and consistent?
  • [ ] Are docstrings (PEP 257) used for public functions, classes, and modules?
  • [ ] Is code duplication avoided?

🧪 Tests

  • [ ] Are there sufficient tests (unit, integration, etc.)?
  • [ ] Do the tests cover common and edge cases?
  • [ ] Do all tests pass locally and in CI?
  • [ ] Are tests fast and reliable (no random failures)?

📦 Dependencies & Environment

  • [ ] Are new dependencies justified and minimal?
  • [ ] Are new packages added to requirements.txt or pyproject.toml appropriately?
  • [ ] Are environments reproducible (e.g., via venv, pip, or poetry)?

📚 Documentation

  • [ ] Are public APIs (functions, classes) documented?
  • [ ] Is the README or usage guide updated if relevant?
  • [ ] Is inline documentation used where the logic is non-obvious?
  • [ ] Are any newly added configuration or CLI flags documented?

🔐 Security & Robustness (as applicable)

  • [ ] Are inputs validated and sanitized?
  • [ ] Are secrets, passwords, or tokens avoided in code and logs?
  • [ ] Are exception-handling blocks appropriate (no silent except:)?
  • [ ] Is sensitive functionality protected (e.g., file writes, subprocesses)?

🧰 Tooling & Build

  • [ ] Does the change pass linters (e.g., flake8, pylint)?
  • [ ] Does the change follow project conventions (imports, logging, typing)?
  • [ ] If the project uses pre-commit, do all hooks pass?
  • [ ] Are type annotations (typing) used where helpful?
  • [ ] Is logging used instead of print()?
  • [ ] Are any performance-critical sections optimized or profiled?