GitLab’s Merge Request (MR) workflow is the standard way to get code changes into your main branch, but it’s surprisingly easy to introduce regressions if you’re not careful.
Let’s say you’ve got a feature branch, feature/new-login-flow, and you want to merge it into main.
# On your local machine, ensure your feature branch is up-to-date
git checkout feature/new-login-flow
git pull origin feature/new-login-flow
# Ensure main is also up-to-date
git checkout main
git pull origin main
# Merge main into your feature branch to catch conflicts early
git merge main
# If there are conflicts, resolve them now.
# git add .
# git commit -m "Merge main into feature/new-login-flow"
# Push your updated feature branch
git push origin feature/new-login-flow
Now, you go to GitLab and create a new Merge Request from feature/new-login-flow to main.
This is where things can get tricky. You might see a green checkmark indicating all pipelines passed, and think you’re good to go. But what if a change in main that your branch doesn’t directly touch, but relies on, broke something that your tests don’t cover?
The core problem is that by default, GitLab’s CI/CD pipeline for a merge request runs on the latest commit of the target branch (main in this case) plus the latest commit of your source branch (feature/new-login-flow). This means your tests are validated against the current state of main, not the state of main before your changes are merged.
To truly ensure your merge is safe, you need to test your feature branch against the exact state of the target branch at the moment the merge request was created. This is often referred to as testing against the "merge base".
Here’s how you can configure GitLab CI/CD to do this:
Configure Your .gitlab-ci.yml
The key is to use the CI_MERGE_REQUEST_TARGET_BRANCH_SHA variable. This variable holds the commit SHA of the target branch before any merge commits from your source branch are applied.
stages:
- test
- deploy
test_code:
stage: test
script:
- echo "Running tests against the merge base..."
# Your actual test commands go here.
# For example:
# - make test
# - bundle exec rspec
# - npm test
# This is the crucial part:
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
when: on_success
variables:
# Use the SHA of the target branch *before* the merge
# This ensures tests run against the state of main
# as it was when the MR was created.
GIT_STRATEGY: clone
GIT_DEPTH: 0 # Fetch full history to ensure accurate merge-base calculation
GIT_SUBMODULE_STRATEGY: recursive
GIT_CHECKOUT_STRATEGY: none # We'll handle checkout manually below
before_script:
- |
# Fetch the commit SHA of the target branch *before* the merge
TARGET_BRANCH_SHA=$(git rev-parse $CI_MERGE_REQUEST_TARGET_BRANCH_NAME)
echo "Target branch SHA is: $TARGET_BRANCH_SHA"
# Fetch the merge base between the source branch and the target branch
MERGE_BASE_SHA=$(git merge-base HEAD $TARGET_BRANCH_SHA)
echo "Merge base SHA is: $MERGE_BASE_SHA"
# Checkout the merge base commit
git checkout $MERGE_BASE_SHA
echo "Checked out merge base commit: $MERGE_BASE_SHA"
# Now, apply the changes from your feature branch on top of the merge base
# This effectively simulates the state *after* a successful merge
git cherry-pick -n HEAD..$CI_COMMIT_SHA
echo "Applied changes from feature branch on top of merge base."
# Verify the current commit is what we expect
git log -1 --oneline
if [ "$(git log -1 --oneline | awk '{print $1}')" != "$CI_COMMIT_SHA" ]; then
echo "Error: Current commit is not the feature branch commit after cherry-picking."
exit 1
fi
echo "Successfully prepared test environment at the merge base + feature changes."
script:
- echo "Running tests on the prepared environment..."
# Your actual test commands go here.
# Example:
# - make test
Explanation:
rulesblock: This ensures these specific CI configurations only run for merge requests.GIT_STRATEGY: cloneandGIT_DEPTH: 0: We need the full history to accurately calculate the merge base.GIT_CHECKOUT_STRATEGY: none: This prevents GitLab from automatically checking out the commit that triggered the pipeline (which would be the latest commit on your feature branch), allowing us to manually control the checkout.before_script:TARGET_BRANCH_SHA=$(git rev-parse $CI_MERGE_REQUEST_TARGET_BRANCH_NAME): Gets the commit SHA of the target branch (e.g.,main).MERGE_BASE_SHA=$(git merge-base HEAD $TARGET_BRANCH_SHA): This is the core command.git merge-basefinds the common ancestor commit between your current branch (HEAD, which is the latest commit of your feature branch) and the target branch. This is the point from which your changes diverged frommain.git checkout $MERGE_BASE_SHA: We temporarily switch the working directory to the state of the repository at the merge base.git cherry-pick -n HEAD..$CI_COMMIT_SHA: This applies the changes from your feature branch (from the merge base up to your latest commit) onto the merge base, but without committing them. The-nflag means "no commit."HEAD..$CI_COMMIT_SHAspecifies the range of commits to pick from your branch. This effectively reconstructs the state of the repository as if your changes were merged into the merge base.- The
echoandgit log -1 --onelinecommands are for debugging and verifying that we’ve ended up on the correct commit ($CI_COMMIT_SHA).
scriptblock: This is where your actual tests run. Because we’ve checked out the merge base and applied your feature branch’s changes on top, these tests are now running against the code as it would be immediately after a successful merge, isolated from any subsequent changes tomain.
This setup ensures that your pipeline is testing your changes against the precise version of main that they are intended to be merged into, catching regressions that might otherwise slip through.
The next error you’ll hit after fixing this is usually related to a broken dependency that was updated in main after you branched off, but which your feature branch implicitly relies on.