Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/input/docs/reference/build-servers/gitlab.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ To use GitVersion with GitLab CI, either use the [MSBuild
Task](/docs/usage/msbuild) or put the GitVersion executable in your
runner's `PATH`.

### Merge Request pipelines

GitVersion supports GitLab Merge Request refs natively. In MR pipelines, GitLab sets `CI_MERGE_REQUEST_REF_PATH` (e.g. `refs/merge-requests/15/head` or `refs/merge-requests/15/merge`). GitVersion uses this variable when present and treats the ref as a pull-request branch, exposing it as `pull-requests/<iid>` so that your `pull-request` configuration in GitVersion.yml applies without any CI workarounds (no need to create synthetic refs under `refs/heads/`). The branch name matches the default regex `^(pull-requests|pull|pr)[\/-](?<Number>\d*)`.

A working example of integrating GitVersion with GitLab is maintained in the project [Utterly Automated Versioning][utterly-automated-versioning]

Here is a summary of what it demonstrated (many more details in the [Readme][readme])
Expand Down
6 changes: 6 additions & 0 deletions docs/input/docs/usage/docker.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ On GitHub Actions, you may need to set the following environment variables:
docker run --rm -v "$(pwd):/repo" --env GITHUB_ACTIONS=true --env GITHUB_REF=$(GITHUB_REF) gittools/gitversion:{tag} /repo
```

On GitLab CI (including Merge Request pipelines), pass the GitLab variables so GitVersion can detect the branch or MR ref:

```sh
docker run --rm -v "$(pwd):/repo" --env GITLAB_CI=true --env CI_MERGE_REQUEST_REF_PATH=$CI_MERGE_REQUEST_REF_PATH --env CI_COMMIT_REF_NAME=$CI_COMMIT_REF_NAME --env CI_COMMIT_TAG=$CI_COMMIT_TAG gittools/gitversion:{tag} /repo
```

### Tags

Most of the tags we provide have both arm64 and amd64 variants. If you need to pull a architecture specific tag you can do that like:
Expand Down
4 changes: 3 additions & 1 deletion src/GitVersion.App.Tests/PullRequestInBuildAgentTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,9 @@ private static async Task VerifyPullRequestVersionIsCalculatedProperly(string pu
new object[] { "refs/pull-requests/5/merge", "refs/pull-requests/5/merge", false, true, false },
new object[] { "refs/pull/5/merge", "refs/pull/5/merge", false, true, false },
new object[] { "refs/heads/pull/5/head", "pull/5/head", true, false, false },
new object[] { "refs/remotes/pull/5/merge", "pull/5/merge", false, true, true }
new object[] { "refs/remotes/pull/5/merge", "pull/5/merge", false, true, true },
new object[] { "refs/merge-requests/15/head", "pull-requests/15", false, true, false },
new object[] { "refs/merge-requests/15/merge", "pull-requests/15", false, true, false }
];

[TestCaseSource(nameof(PrMergeRefInputs))]
Expand Down
52 changes: 47 additions & 5 deletions src/GitVersion.BuildAgents.Tests/Agents/GitLabCiTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,13 @@ public void SetUp()
}

[TearDown]
public void TearDown() => this.environment.SetEnvironmentVariable(GitLabCi.EnvironmentVariableName, null);
public void TearDown()
{
this.environment.SetEnvironmentVariable(GitLabCi.EnvironmentVariableName, null);
this.environment.SetEnvironmentVariable(GitLabCi.CommitRefNameEnvironmentVariableName, null);
this.environment.SetEnvironmentVariable(GitLabCi.CommitTagEnvironmentVariableName, null);
this.environment.SetEnvironmentVariable(GitLabCi.MergeRequestRefPathEnvironmentVariableName, null);
}

[Test]
public void ShouldSetBuildNumber()
Expand All @@ -50,7 +56,7 @@ public void ShouldSetOutputVariables()
[TestCase("#3-change_projectname", "#3-change_projectname")]
public void GetCurrentBranchShouldHandleBranches(string branchName, string expectedResult)
{
this.environment.SetEnvironmentVariable("CI_COMMIT_REF_NAME", branchName);
this.environment.SetEnvironmentVariable(GitLabCi.CommitRefNameEnvironmentVariableName, branchName);

var result = this.buildServer.GetCurrentBranch(false);

Expand All @@ -63,8 +69,8 @@ public void GetCurrentBranchShouldHandleBranches(string branchName, string expec
[TestCase("v1.2.1", "v1.2.1", null)]
public void GetCurrentBranchShouldHandleTags(string branchName, string commitTag, string? expectedResult)
{
this.environment.SetEnvironmentVariable("CI_COMMIT_REF_NAME", branchName);
this.environment.SetEnvironmentVariable("CI_COMMIT_TAG", commitTag); // only set in pipelines for tags
this.environment.SetEnvironmentVariable(GitLabCi.CommitRefNameEnvironmentVariableName, branchName);
this.environment.SetEnvironmentVariable(GitLabCi.CommitTagEnvironmentVariableName, commitTag); // only set in pipelines for tags

var result = this.buildServer.GetCurrentBranch(false);

Expand All @@ -85,13 +91,49 @@ public void GetCurrentBranchShouldHandleTags(string branchName, string commitTag
[TestCase("#3-change_projectname", "#3-change_projectname")]
public void GetCurrentBranchShouldHandlePullRequests(string branchName, string expectedResult)
{
this.environment.SetEnvironmentVariable("CI_COMMIT_REF_NAME", branchName);
this.environment.SetEnvironmentVariable(GitLabCi.CommitRefNameEnvironmentVariableName, branchName);

var result = this.buildServer.GetCurrentBranch(false);

result.ShouldBe(expectedResult);
}

[TestCase("refs/merge-requests/15/head")]
[TestCase("refs/merge-requests/15/merge")]
[TestCase("refs/merge-requests/1/head")]
public void GetCurrentBranch_WhenMergeRequestRefPathSet_ReturnsMergeRequestRefPath(string mrRefPath)
{
this.environment.SetEnvironmentVariable(GitLabCi.MergeRequestRefPathEnvironmentVariableName, mrRefPath);
this.environment.SetEnvironmentVariable(GitLabCi.CommitRefNameEnvironmentVariableName, "some-branch");

var result = this.buildServer.GetCurrentBranch(false);

result.ShouldBe(mrRefPath);
}

[Test]
public void GetCurrentBranch_WhenMergeRequestRefPathAndCommitRefNameSet_PrefersMergeRequestRefPath()
{
this.environment.SetEnvironmentVariable(GitLabCi.MergeRequestRefPathEnvironmentVariableName, "refs/merge-requests/42/head");
this.environment.SetEnvironmentVariable(GitLabCi.CommitRefNameEnvironmentVariableName, "feature/foo");

var result = this.buildServer.GetCurrentBranch(false);

result.ShouldBe("refs/merge-requests/42/head");
}

[Test]
public void GetCurrentBranch_WhenTagSet_ReturnsNull()
{
this.environment.SetEnvironmentVariable(GitLabCi.CommitTagEnvironmentVariableName, "v1.0.0");
this.environment.SetEnvironmentVariable(GitLabCi.MergeRequestRefPathEnvironmentVariableName, "refs/merge-requests/10/head");
this.environment.SetEnvironmentVariable(GitLabCi.CommitRefNameEnvironmentVariableName, "main");

var result = this.buildServer.GetCurrentBranch(false);

result.ShouldBeNull();
}

[Test]
public void WriteAllVariablesToTheTextWriter()
{
Expand Down
22 changes: 14 additions & 8 deletions src/GitVersion.BuildAgents/Agents/GitLabCi.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ namespace GitVersion.Agents;
internal class GitLabCi : BuildAgentBase
{
public const string EnvironmentVariableName = "GITLAB_CI";
public const string CommitRefNameEnvironmentVariableName = "CI_COMMIT_REF_NAME";
public const string CommitTagEnvironmentVariableName = "CI_COMMIT_TAG";
public const string MergeRequestRefPathEnvironmentVariableName = "CI_MERGE_REQUEST_REF_PATH";

private string? file;

public GitLabCi(IEnvironment environment, ILog log, IFileSystem fileSystem) : base(environment, log, fileSystem) => WithPropertyFile("gitversion.properties");
Expand All @@ -22,14 +26,16 @@ public override string[] SetOutputVariables(string name, string? value) =>
$"GitVersion_{name}={value}"
];

// CI_COMMIT_REF_NAME can contain either the branch or the tag
// See https://docs.gitlab.com/ee/ci/variables/predefined_variables.html
// CI_COMMIT_TAG is only available in tag pipelines,
// so we can exit if CI_COMMIT_REF_NAME would return the tag
public override string? GetCurrentBranch(bool usingDynamicRepos) =>
string.IsNullOrEmpty(this.Environment.GetEnvironmentVariable("CI_COMMIT_TAG"))
? this.Environment.GetEnvironmentVariable("CI_COMMIT_REF_NAME")
: null;
// CI_COMMIT_REF_NAME = branch/tag name. In MR pipelines, CI_MERGE_REQUEST_REF_PATH = refs/merge-requests/<iid>/head.
public override string? GetCurrentBranch(bool usingDynamicRepos)
{
if (!string.IsNullOrEmpty(this.Environment.GetEnvironmentVariable(CommitTagEnvironmentVariableName)))
return null;
var mrRef = this.Environment.GetEnvironmentVariable(MergeRequestRefPathEnvironmentVariableName);
if (!string.IsNullOrEmpty(mrRef))
return mrRef;
return this.Environment.GetEnvironmentVariable(CommitRefNameEnvironmentVariableName);
}

public override bool PreventFetch() => true;

Expand Down
21 changes: 15 additions & 6 deletions src/GitVersion.Core/Core/GitPreparer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,12 @@ private void EnsureHeadIsAttachedToBranch(string? currentBranchName, Authenticat
var localBranchesWhereCommitShaIsHead = this.repository.Branches.Where(b => !b.IsRemote && b.Tip?.Sha == headSha).ToList();

var matchingCurrentBranch = !currentBranchName.IsNullOrEmpty()
? localBranchesWhereCommitShaIsHead.SingleOrDefault(b => b.Name.Canonical.Replace("/heads/", "/") == currentBranchName.Replace("/heads/", "/"))
? localBranchesWhereCommitShaIsHead.SingleOrDefault(b =>
{
if (ReferenceName.TryParseMergeRequestsRef(currentBranchName, out var mergeRequestId))
return b.Name.Canonical == ReferenceName.LocalBranchPrefix + ReferenceName.MergeRequestsRefFriendlyName(mergeRequestId);
return b.Name.Canonical.Replace("/heads/", "/") == currentBranchName.Replace("/heads/", "/");
})
: null;
if (matchingCurrentBranch != null)
{
Expand Down Expand Up @@ -379,11 +384,15 @@ public void EnsureLocalBranchExistsForCurrentBranch(IRemote remote, string? curr

const string referencePrefix = "refs/";
var isLocalBranch = currentBranch.StartsWith(ReferenceName.LocalBranchPrefix);
var localCanonicalName = !currentBranch.StartsWith(referencePrefix)
? ReferenceName.LocalBranchPrefix + currentBranch
: isLocalBranch
? currentBranch
: ReferenceName.LocalBranchPrefix + currentBranch[referencePrefix.Length..];
string localCanonicalName;
if (ReferenceName.TryParseMergeRequestsRef(currentBranch, out var mergeRequestId))
localCanonicalName = ReferenceName.LocalBranchPrefix + ReferenceName.MergeRequestsRefFriendlyName(mergeRequestId);
else
localCanonicalName = !currentBranch.StartsWith(referencePrefix)
? ReferenceName.LocalBranchPrefix + currentBranch
: isLocalBranch
? currentBranch
: ReferenceName.LocalBranchPrefix + currentBranch[referencePrefix.Length..];

var repoTip = this.repository.Head.Tip;

Expand Down
9 changes: 7 additions & 2 deletions src/GitVersion.Core/Core/RepositoryStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,13 @@ public IBranch GetTargetBranch(string? targetBranchName)
if (targetBranchName.IsNullOrEmpty())
return desiredBranch;

// There are some edge cases where HEAD is not pointing to the desired branch.
// Therefore, it's important to verify if 'currentBranch' is indeed the desired branch.
if (ReferenceName.TryParseMergeRequestsRef(targetBranchName, out var mergeRequestId))
{
var prBranch = FindBranch(ReferenceName.MergeRequestsRefFriendlyName(mergeRequestId));
if (prBranch != null)
return prBranch;
}

var targetBranch = FindBranch(targetBranchName);

// CanonicalName can be "refs/heads/develop", so we need to check for "/{TargetBranch}" as well
Expand Down
39 changes: 38 additions & 1 deletion src/GitVersion.Core/Git/ReferenceName.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,18 @@ public class ReferenceName : IEquatable<ReferenceName?>, IComparable<ReferenceNa
[
"refs/pull/",
"refs/pull-requests/",
"refs/merge-requests/",
"refs/remotes/pull/",
"refs/remotes/pull-requests/"
];

/// <summary>
/// The sole <see cref="PullRequestPrefixes" /> entry for <c>refs/merge-requests/&lt;id&gt;/head|merge</c>.
/// Adding another prefix that also contains <c>/merge-requests/</c> will fail at type initialization.
/// </summary>
private static readonly string mergeRequestsRefPrefix = PullRequestPrefixes.Single(
p => p.Contains("/merge-requests/", StringComparison.Ordinal));

public ReferenceName(string canonical)
{
Canonical = canonical.NotNull();
Expand Down Expand Up @@ -84,6 +92,29 @@ public bool EquivalentTo(string? name) =>
|| Friendly.Equals(name, StringComparison.OrdinalIgnoreCase)
|| WithoutOrigin.Equals(name, StringComparison.OrdinalIgnoreCase);

/// <summary>
/// Parses canonical refs under refs/merge-requests/&lt;id&gt;/head or /merge (convention used by some Git hosts) and extracts the merge-request id.
/// </summary>
public static bool TryParseMergeRequestsRef(string? canonicalRef, out int mergeRequestId)
{
mergeRequestId = 0;
if (string.IsNullOrEmpty(canonicalRef) || !canonicalRef.StartsWith(mergeRequestsRefPrefix, StringComparison.Ordinal))
return false;
var after = canonicalRef.Substring(mergeRequestsRefPrefix.Length);
var slash = after.IndexOf('/');
if (slash <= 0 || slash >= after.Length - 1) return false;
var suffix = after[(slash + 1)..];
if (!suffix.Equals("head", StringComparison.OrdinalIgnoreCase) && !suffix.Equals("merge", StringComparison.OrdinalIgnoreCase))
return false;
return int.TryParse(after.Substring(0, slash), System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out mergeRequestId)
&& mergeRequestId > 0;
}

/// <summary>
/// Returns the branch-style name pull-requests/&lt;id&gt; for default pull-request configuration matching.
/// </summary>
public static string MergeRequestsRefFriendlyName(int mergeRequestId) => $"pull-requests/{mergeRequestId}";
Comment on lines +113 to +116
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MergeRequestsRefFriendlyName(int mergeRequestId) is exposed as a public API but will generate values like pull-requests/0 or pull-requests/-1 if called with invalid IDs. Consider validating mergeRequestId > 0 (or documenting expected input) to avoid accidental creation/lookup of nonsensical ref names by API consumers.

Copilot uses AI. Check for mistakes.

private string Shorten()
{
if (IsLocalBranch)
Expand All @@ -92,7 +123,13 @@ private string Shorten()
if (IsRemoteBranch)
return Canonical[RemoteTrackingBranchPrefix.Length..];

return IsTag ? Canonical[TagPrefix.Length..] : Canonical;
if (IsTag)
return Canonical[TagPrefix.Length..];

if (TryParseMergeRequestsRef(Canonical, out var mergeRequestId))
return MergeRequestsRefFriendlyName(mergeRequestId);

return Canonical;
}

private string RemoveOrigin()
Expand Down
2 changes: 2 additions & 0 deletions src/GitVersion.Core/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
#nullable enable
static GitVersion.Git.ReferenceName.MergeRequestsRefFriendlyName(int mergeRequestId) -> string!
static GitVersion.Git.ReferenceName.TryParseMergeRequestsRef(string? canonicalRef, out int mergeRequestId) -> bool
Comment thread
JDanRibeiro marked this conversation as resolved.
4 changes: 3 additions & 1 deletion src/GitVersion.LibGit2Sharp/Git/GitRepository.mutating.cs
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,9 @@ public void CreateBranchForPullRequestBranch(AuthenticationInfo auth) => Reposit
}
else if (referenceName.IsPullRequest)
{
var fakeBranchName = canonicalName.Replace("refs/pull/", "refs/heads/pull/").Replace("refs/pull-requests/", "refs/heads/pull-requests/");
var fakeBranchName = ReferenceName.TryParseMergeRequestsRef(canonicalName, out var mergeRequestId)
? $"{ReferenceName.LocalBranchPrefix}{ReferenceName.MergeRequestsRefFriendlyName(mergeRequestId)}"
: canonicalName.Replace("refs/pull/", "refs/heads/pull/").Replace("refs/pull-requests/", "refs/heads/pull-requests/");

this.log.Info($"Creating fake local branch '{fakeBranchName}'.");
References.Add(fakeBranchName, headTipSha);
Expand Down
Loading