\d+)(?[-+][0-9A-Za-z.-]+)?$",
+ RegexOptions.Compiled | RegexOptions.CultureInvariant);
+
+ ///
+ /// Returns the highest semantic-version tag, or the first tag when none are semver.
+ ///
+ public static RemoteTag? SelectLatest(IReadOnlyList tags)
+ {
+ if (tags.Count == 0)
+ {
+ return null;
+ }
+
+ var ranked = tags
+ .Select(tag => (Tag: tag, Version: TryParse(tag.Name)))
+ .ToList();
+
+ var semver = ranked
+ .Where(item => item.Version is not null)
+ .OrderByDescending(item => item.Version)
+ .ToList();
+
+ if (semver.Count > 0)
+ {
+ return semver[0].Tag;
+ }
+
+ return tags[0];
+ }
+
+ ///
+ /// Finds a tag by name, accepting an optional leading v.
+ ///
+ public static RemoteTag? Find(IReadOnlyList tags, string name)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(name);
+
+ var exact = tags.FirstOrDefault(tag => tag.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
+ if (exact is not null)
+ {
+ return exact;
+ }
+
+ var trimmed = name.StartsWith('v') || name.StartsWith('V') ? name[1..] : name;
+ return tags.FirstOrDefault(tag =>
+ {
+ var candidate = tag.Name.StartsWith('v') || tag.Name.StartsWith('V') ? tag.Name[1..] : tag.Name;
+ return candidate.Equals(trimmed, StringComparison.OrdinalIgnoreCase);
+ });
+ }
+
+ ///
+ /// Parses git ls-remote --tags output into unique tag names.
+ ///
+ public static IReadOnlyList ParseLsRemote(string output)
+ {
+ ArgumentNullException.ThrowIfNull(output);
+
+ var tags = new Dictionary(StringComparer.Ordinal);
+ using var reader = new StringReader(output);
+ while (reader.ReadLine() is { } line)
+ {
+ if (string.IsNullOrWhiteSpace(line))
+ {
+ continue;
+ }
+
+ var parts = line.Split('\t', 2, StringSplitOptions.RemoveEmptyEntries);
+ if (parts.Length != 2)
+ {
+ parts = line.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+ }
+
+ if (parts.Length != 2)
+ {
+ continue;
+ }
+
+ var sha = parts[0].Trim();
+ var refName = parts[1].Trim();
+ var peeled = refName.EndsWith("^{}", StringComparison.Ordinal);
+ if (peeled)
+ {
+ refName = refName[..^3];
+ }
+
+ const string prefix = "refs/tags/";
+ if (!refName.StartsWith(prefix, StringComparison.Ordinal))
+ {
+ continue;
+ }
+
+ var name = refName[prefix.Length..];
+ if (peeled || !tags.ContainsKey(name))
+ {
+ tags[name] = new RemoteTag(name, sha);
+ }
+ }
+
+ return tags.Values.ToList();
+ }
+
+ private static Version? TryParse(string name)
+ {
+ var match = SemVerRegex.Match(name);
+ if (!match.Success)
+ {
+ return null;
+ }
+
+ var major = int.Parse(match.Groups["major"].Value);
+ var minor = int.Parse(match.Groups["minor"].Value);
+ var patch = int.Parse(match.Groups["patch"].Value);
+
+ // Prefer stable releases over pre-releases of the same version.
+ var revision = match.Groups["pre"].Success ? 0 : 1;
+ return new Version(major, minor, patch, revision);
+ }
+}
diff --git a/build.cmd b/build.cmd
new file mode 100644
index 0000000..5195625
--- /dev/null
+++ b/build.cmd
@@ -0,0 +1,2 @@
+@echo off
+dotnet test %*
diff --git a/build.sh b/build.sh
new file mode 100755
index 0000000..373cf1b
--- /dev/null
+++ b/build.sh
@@ -0,0 +1,4 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+dotnet test "${@}"
diff --git a/global.json b/global.json
new file mode 100644
index 0000000..2920a09
--- /dev/null
+++ b/global.json
@@ -0,0 +1,6 @@
+{
+ "sdk": {
+ "version": "8.0.400",
+ "rollForward": "latestFeature"
+ }
+}
diff --git a/scripts/install.ps1 b/scripts/install.ps1
new file mode 100644
index 0000000..5fc7240
--- /dev/null
+++ b/scripts/install.ps1
@@ -0,0 +1,124 @@
+# Installs bld onto PATH for Windows.
+# Usage:
+# ./scripts/install.ps1
+# irm https://raw.githubusercontent.com/IngeniumSE/BuildCLI/main/scripts/install.ps1 | iex
+[CmdletBinding()]
+param(
+ [string] $RepoUrl = $(if ($env:BUILDCLI_REPO_URL) { $env:BUILDCLI_REPO_URL } else { "https://github.com/IngeniumSE/BuildCLI.git" }),
+ [string] $InstallDir = $(if ($env:BLD_INSTALL_DIR) { $env:BLD_INSTALL_DIR } elseif ($env:BUILDCLI_INSTALL_DIR) { $env:BUILDCLI_INSTALL_DIR } else { Join-Path $env:LOCALAPPDATA "Ingenium\bld" }),
+ [switch] $FrameworkDependent
+)
+
+$ErrorActionPreference = "Stop"
+
+function Write-Log {
+ param([string] $Message)
+ Write-Host "==> $Message"
+}
+
+function Get-Rid {
+ $arch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture
+ switch ($arch) {
+ "X64" { return "win-x64" }
+ "Arm64" { return "win-arm64" }
+ default { throw "Unsupported architecture: $arch" }
+ }
+}
+
+function Ensure-Dotnet {
+ if (Get-Command dotnet -ErrorAction SilentlyContinue) {
+ return
+ }
+
+ Write-Log "dotnet was not found; installing the .NET 8 SDK"
+ $installScript = Join-Path $env:TEMP "dotnet-install.ps1"
+ Invoke-WebRequest -Uri "https://dot.net/v1/dotnet-install.ps1" -OutFile $installScript
+ & $installScript -Channel 8.0
+ $env:PATH = "$env:USERPROFILE\.dotnet;$env:USERPROFILE\.dotnet\tools;$env:PATH"
+ if (-not (Get-Command dotnet -ErrorAction SilentlyContinue)) {
+ throw "dotnet installation completed but the SDK is still not on PATH."
+ }
+}
+
+function Get-SourceDirectory {
+ $scriptDir = $PSScriptRoot
+ if ($scriptDir -and (Test-Path (Join-Path $scriptDir "..\apps\Ingenium.BuildCli\Ingenium.BuildCli.csproj"))) {
+ return (Resolve-Path (Join-Path $scriptDir "..")).Path
+ }
+
+ if (-not (Get-Command git -ErrorAction SilentlyContinue)) {
+ throw "git is required to install bld."
+ }
+
+ $checkout = Join-Path $env:TEMP ("buildcli-src-" + [Guid]::NewGuid().ToString("N"))
+ Write-Log "Cloning $RepoUrl"
+ git clone --depth 1 $RepoUrl $checkout
+ if ($LASTEXITCODE -ne 0) {
+ throw "Failed to clone $RepoUrl"
+ }
+ return $checkout
+}
+
+function Add-ToUserPath {
+ param([string] $Directory)
+
+ $current = [Environment]::GetEnvironmentVariable("Path", "User")
+ if ([string]::IsNullOrEmpty($current)) {
+ [Environment]::SetEnvironmentVariable("Path", $Directory, "User")
+ return
+ }
+
+ $parts = $current.Split(";", [System.StringSplitOptions]::RemoveEmptyEntries)
+ if ($parts -contains $Directory) {
+ return
+ }
+
+ [Environment]::SetEnvironmentVariable("Path", ($current.TrimEnd(";") + ";" + $Directory), "User")
+}
+
+Ensure-Dotnet
+if (-not (Get-Command git -ErrorAction SilentlyContinue)) {
+ throw "git is required to install bld."
+}
+
+$sourceDir = Get-SourceDirectory
+$rid = Get-Rid
+New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null
+
+Write-Log "Publishing bld for $rid"
+$publishArgs = @(
+ "publish", (Join-Path $sourceDir "apps\Ingenium.BuildCli\Ingenium.BuildCli.csproj"),
+ "-c", "Release",
+ "-r", $rid,
+ "-o", $InstallDir,
+ "--nologo"
+)
+
+if ($FrameworkDependent) {
+ $publishArgs += @("--self-contained", "false")
+}
+else {
+ $publishArgs += @(
+ "--self-contained", "true",
+ "-p:PublishSingleFile=true",
+ "-p:IncludeNativeLibrariesForSelfExtract=true"
+ )
+}
+
+& dotnet @publishArgs
+if ($LASTEXITCODE -ne 0) {
+ throw "dotnet publish failed."
+}
+
+$executable = Join-Path $InstallDir "bld.exe"
+if (-not (Test-Path $executable)) {
+ throw "Publish succeeded but $executable was not produced."
+}
+
+Add-ToUserPath $InstallDir
+$env:PATH = "$InstallDir;$env:PATH"
+
+Write-Log "Installed $executable"
+Write-Log "Added $InstallDir to the user PATH"
+Write-Host ""
+Write-Host "Open a new terminal, then run: bld --help"
diff --git a/scripts/install.sh b/scripts/install.sh
new file mode 100755
index 0000000..b4687a5
--- /dev/null
+++ b/scripts/install.sh
@@ -0,0 +1,130 @@
+#!/usr/bin/env bash
+# Installs bld onto PATH for macOS and Linux.
+# Usage:
+# ./scripts/install.sh
+# curl -sSL https://raw.githubusercontent.com/IngeniumSE/BuildCLI/main/scripts/install.sh | bash
+set -euo pipefail
+
+REPO_URL="${BUILDCLI_REPO_URL:-https://github.com/IngeniumSE/BuildCLI.git}"
+INSTALL_DIR="${BLD_INSTALL_DIR:-${BUILDCLI_INSTALL_DIR:-${HOME}/.local/share/ingenium/bld}}"
+BIN_DIR="${BUILDCLI_BIN_DIR:-${HOME}/.local/bin}"
+SELF_CONTAINED="${BUILDCLI_SELF_CONTAINED:-true}"
+
+log() {
+ printf '==> %s\n' "$*"
+}
+
+fail() {
+ printf 'error: %s\n' "$*" >&2
+ exit 1
+}
+
+require() {
+ command -v "$1" >/dev/null 2>&1 || fail "'$1' is required to install bld."
+}
+
+detect_rid() {
+ local os arch
+ os="$(uname -s | tr '[:upper:]' '[:lower:]')"
+ arch="$(uname -m)"
+
+ case "$os" in
+ linux) os="linux" ;;
+ darwin) os="osx" ;;
+ mingw*|msys*|cygwin*) os="win" ;;
+ *) fail "Unsupported operating system: $(uname -s)" ;;
+ esac
+
+ case "$arch" in
+ x86_64|amd64) arch="x64" ;;
+ arm64|aarch64) arch="arm64" ;;
+ *) fail "Unsupported architecture: $arch" ;;
+ esac
+
+ printf '%s-%s\n' "$os" "$arch"
+}
+
+ensure_dotnet() {
+ if command -v dotnet >/dev/null 2>&1; then
+ return
+ fi
+
+ log "dotnet was not found; installing the .NET 8 SDK into ~/.dotnet"
+ require curl
+ curl -sSL https://dot.net/v1/dotnet-install.sh | bash /dev/stdin --channel 8.0 --install-dir "${HOME}/.dotnet"
+ export PATH="${HOME}/.dotnet:${HOME}/.dotnet/tools:${PATH}"
+ command -v dotnet >/dev/null 2>&1 || fail "dotnet installation completed but the SDK is still not on PATH."
+}
+
+resolve_source() {
+ local self="${BASH_SOURCE[0]:-}"
+ if [[ -n "${self}" && -f "${self}" ]]; then
+ local script_dir
+ script_dir="$(cd "$(dirname "${self}")" && pwd)"
+ if [[ -f "${script_dir}/../apps/Ingenium.BuildCli/Ingenium.BuildCli.csproj" ]]; then
+ printf '%s\n' "$(cd "${script_dir}/.." && pwd)"
+ return
+ fi
+ fi
+
+ require git
+ local checkout
+ checkout="$(mktemp -d "${TMPDIR:-/tmp}/buildcli-src.XXXXXX")"
+ log "Cloning ${REPO_URL}"
+ git clone --depth 1 "${REPO_URL}" "${checkout}"
+ printf '%s\n' "${checkout}"
+}
+
+main() {
+ require git
+ ensure_dotnet
+
+ local source_dir rid configuration
+ source_dir="$(resolve_source)"
+ rid="$(detect_rid)"
+ configuration="Release"
+
+ log "Publishing bld for ${rid}"
+ mkdir -p "${INSTALL_DIR}" "${BIN_DIR}"
+
+ local publish_args=(
+ dotnet publish "${source_dir}/apps/Ingenium.BuildCli/Ingenium.BuildCli.csproj"
+ -c "${configuration}"
+ -r "${rid}"
+ -o "${INSTALL_DIR}"
+ --nologo
+ )
+
+ if [[ "${SELF_CONTAINED}" == "true" ]]; then
+ publish_args+=(--self-contained true -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true)
+ else
+ publish_args+=(--self-contained false)
+ fi
+
+ "${publish_args[@]}"
+
+ local executable="${INSTALL_DIR}/bld"
+ [[ -f "${executable}" ]] || fail "Publish succeeded but ${executable} was not produced."
+ chmod +x "${executable}"
+
+ ln -sfn "${executable}" "${BIN_DIR}/bld"
+ log "Installed ${executable}"
+ log "Linked ${BIN_DIR}/bld"
+
+ if ! command -v bld >/dev/null 2>&1; then
+ cat <.
+
+using Ingenium.BuildCli.Extensions;
+
+namespace Ingenium.BuildCli.Tests;
+
+public sealed class BuildExtensionNamesTests
+{
+ [Theory]
+ [InlineData("Framework", "FrameworkBuildExtensions")]
+ [InlineData("FrameworkBuildExtensions", "FrameworkBuildExtensions")]
+ [InlineData("open-f1", "OpenF1BuildExtensions")]
+ [InlineData("1repo", "RepoBuildExtensions")]
+ public void ToProjectName_AddsSuffixOnce(string name, string expected)
+ {
+ Assert.Equal(expected, BuildExtensionNames.ToProjectName(name));
+ }
+}
diff --git a/tests/Ingenium.BuildCli.Tests/BuildExtensionServiceTests.cs b/tests/Ingenium.BuildCli.Tests/BuildExtensionServiceTests.cs
new file mode 100644
index 0000000..3bb2432
--- /dev/null
+++ b/tests/Ingenium.BuildCli.Tests/BuildExtensionServiceTests.cs
@@ -0,0 +1,67 @@
+// This work is licensed under the terms of the MIT license.
+// For a copy, see .
+
+using Ingenium.BuildCli;
+using Ingenium.BuildCli.Extensions;
+using Ingenium.BuildCli.Tests.Support;
+
+namespace Ingenium.BuildCli.Tests;
+
+public sealed class BuildExtensionServiceTests
+{
+ [Fact]
+ public async Task Create_WritesExpectedLayout()
+ {
+ using var workspace = GitTestWorkspace.Create();
+ var service = new BuildExtensionService(GitTestWorkspace.CreateClient());
+
+ var scaffold = await service.CreateAsync(new BuildExtensionRequest
+ {
+ RepositoryPath = workspace.ParentRepo,
+ Name = "Framework"
+ });
+
+ Assert.Equal("FrameworkBuildExtensions", scaffold.ProjectName);
+ Assert.True(File.Exists(Path.Combine(workspace.ParentRepo, "build-extensions", "Directory.Build.props")));
+ Assert.True(File.Exists(Path.Combine(workspace.ParentRepo, "build-extensions", "Directory.Build.targets")));
+ Assert.True(File.Exists(Path.Combine(scaffold.ProjectDirectory, "FrameworkBuildExtensions.csproj")));
+ Assert.True(File.Exists(Path.Combine(scaffold.ProjectDirectory, "SampleTask.cs")));
+
+ var project = File.ReadAllText(Path.Combine(scaffold.ProjectDirectory, "FrameworkBuildExtensions.csproj"));
+ Assert.Contains("Build.Abstractions", project);
+ Assert.Contains("net8.0", project);
+
+ var props = File.ReadAllText(Path.Combine(workspace.ParentRepo, "build-extensions", "Directory.Build.props"));
+ Assert.Contains("../build/apps/Directory.Build.props", props);
+ }
+
+ [Fact]
+ public async Task Create_ThrowsWhenProjectExists()
+ {
+ using var workspace = GitTestWorkspace.Create();
+ var service = new BuildExtensionService(GitTestWorkspace.CreateClient());
+ var request = new BuildExtensionRequest
+ {
+ RepositoryPath = workspace.ParentRepo,
+ Name = "Demo"
+ };
+
+ await service.CreateAsync(request);
+ var error = await Assert.ThrowsAsync(() => service.CreateAsync(request));
+ Assert.Equal(ExitCodes.AlreadyExists, error.ExitCode);
+ }
+
+ [Fact]
+ public async Task Create_UsesRepositoryFolderName()
+ {
+ using var workspace = GitTestWorkspace.Create();
+ var service = new BuildExtensionService(GitTestWorkspace.CreateClient());
+
+ var scaffold = await service.CreateAsync(new BuildExtensionRequest
+ {
+ RepositoryPath = workspace.ParentRepo
+ });
+
+ Assert.Equal("ParentBuildExtensions", scaffold.ProjectName);
+ }
+}
diff --git a/tests/Ingenium.BuildCli.Tests/BuildHostArgumentsTests.cs b/tests/Ingenium.BuildCli.Tests/BuildHostArgumentsTests.cs
new file mode 100644
index 0000000..0d15285
--- /dev/null
+++ b/tests/Ingenium.BuildCli.Tests/BuildHostArgumentsTests.cs
@@ -0,0 +1,47 @@
+// This work is licensed under the terms of the MIT license.
+// For a copy, see .
+
+using Ingenium.BuildCli.Host;
+
+namespace Ingenium.BuildCli.Tests;
+
+public sealed class BuildHostArgumentsTests
+{
+ [Fact]
+ public void Create_AddsDefaultTarget()
+ {
+ var args = BuildHostArguments.Create(null, null, []);
+
+ Assert.Equal(["--no-launch-profile", "--", "--target", "Default"], args);
+ }
+
+ [Fact]
+ public void Create_DoesNotDuplicateTarget()
+ {
+ var args = BuildHostArguments.Create("Default", "Release", ["--target", "PackProjects"]);
+
+ Assert.DoesNotContain("Default", args);
+ Assert.Contains("--configuration", args);
+ Assert.Contains("Release", args);
+ Assert.Contains("PackProjects", args);
+ }
+
+ [Fact]
+ public void FindBuildProject_PrefersAppsBuild()
+ {
+ var root = Path.Combine(Path.GetTempPath(), "buildcli-tests", Guid.NewGuid().ToString("N"));
+ var projectDir = Path.Combine(root, "apps", "Build");
+ Directory.CreateDirectory(projectDir);
+ var project = Path.Combine(projectDir, "Build.csproj");
+ File.WriteAllText(project, "");
+
+ try
+ {
+ Assert.Equal(project, BuildHostService.FindBuildProject(root));
+ }
+ finally
+ {
+ Directory.Delete(root, recursive: true);
+ }
+ }
+}
diff --git a/tests/Ingenium.BuildCli.Tests/BuildRepositoryUrlsTests.cs b/tests/Ingenium.BuildCli.Tests/BuildRepositoryUrlsTests.cs
new file mode 100644
index 0000000..4e44033
--- /dev/null
+++ b/tests/Ingenium.BuildCli.Tests/BuildRepositoryUrlsTests.cs
@@ -0,0 +1,44 @@
+// This work is licensed under the terms of the MIT license.
+// For a copy, see .
+
+using Ingenium.BuildCli.Submodule;
+
+namespace Ingenium.BuildCli.Tests;
+
+public sealed class BuildRepositoryUrlsTests
+{
+ [Theory]
+ [InlineData("git@github.com:IngeniumSE/Build.git", true)]
+ [InlineData("https://github.com/IngeniumSE/Build.git", true)]
+ [InlineData("https://github.com/IngeniumSE/Build", true)]
+ [InlineData("git@github.com:IngeniumSE/CLI.git", false)]
+ [InlineData(null, false)]
+ public void IsBuildRepository_RecognizesCanonicalUrls(string? url, bool expected)
+ {
+ Assert.Equal(expected, BuildRepositoryUrls.IsBuildRepository(url));
+ }
+
+ [Fact]
+ public void InferFromParentRemote_UsesHttpsWhenParentIsHttps()
+ {
+ var url = BuildRepositoryUrls.InferFromParentRemote("https://github.com/IngeniumSE/CLI.git", useHttps: null);
+
+ Assert.Equal(BuildRepositoryUrls.Https, url);
+ }
+
+ [Fact]
+ public void InferFromParentRemote_UsesSshByDefault()
+ {
+ var url = BuildRepositoryUrls.InferFromParentRemote("git@github.com:IngeniumSE/CLI.git", useHttps: null);
+
+ Assert.Equal(BuildRepositoryUrls.Ssh, url);
+ }
+
+ [Fact]
+ public void InferFromParentRemote_HonoursHttpsOverride()
+ {
+ var url = BuildRepositoryUrls.InferFromParentRemote("git@github.com:IngeniumSE/CLI.git", useHttps: true);
+
+ Assert.Equal(BuildRepositoryUrls.Https, url);
+ }
+}
diff --git a/tests/Ingenium.BuildCli.Tests/BuildSubmoduleGuardTests.cs b/tests/Ingenium.BuildCli.Tests/BuildSubmoduleGuardTests.cs
new file mode 100644
index 0000000..599b3e2
--- /dev/null
+++ b/tests/Ingenium.BuildCli.Tests/BuildSubmoduleGuardTests.cs
@@ -0,0 +1,48 @@
+// This work is licensed under the terms of the MIT license.
+// For a copy, see .
+
+using Ingenium.BuildCli.Submodule;
+
+namespace Ingenium.BuildCli.Tests;
+
+public sealed class BuildSubmoduleGuardTests
+{
+ [Fact]
+ public void EnsureInitialized_ThrowsWhenNotRegistered()
+ {
+ var error = Assert.Throws(() => BuildSubmoduleGuard.EnsureInitialized(new BuildSubmoduleStatus
+ {
+ RepositoryRoot = "/tmp/repo",
+ IsRegistered = false,
+ IsInitialized = false
+ }));
+
+ Assert.Equal(ExitCodes.SubmoduleNotFound, error.ExitCode);
+ Assert.Contains("bld init", error.Message);
+ }
+
+ [Fact]
+ public void EnsureInitialized_ThrowsWhenNotCheckedOut()
+ {
+ var error = Assert.Throws(() => BuildSubmoduleGuard.EnsureInitialized(new BuildSubmoduleStatus
+ {
+ RepositoryRoot = "/tmp/repo",
+ IsRegistered = true,
+ IsInitialized = false
+ }));
+
+ Assert.Contains("not initialized", error.Message);
+ }
+
+ [Fact]
+ public void EnsureInitialized_AllowsReadySubmodule()
+ {
+ BuildSubmoduleGuard.EnsureInitialized(new BuildSubmoduleStatus
+ {
+ RepositoryRoot = "/tmp/repo",
+ IsRegistered = true,
+ IsInitialized = true,
+ RelativePath = "build"
+ });
+ }
+}
diff --git a/tests/Ingenium.BuildCli.Tests/BuildSubmoduleServiceTests.cs b/tests/Ingenium.BuildCli.Tests/BuildSubmoduleServiceTests.cs
new file mode 100644
index 0000000..7356907
--- /dev/null
+++ b/tests/Ingenium.BuildCli.Tests/BuildSubmoduleServiceTests.cs
@@ -0,0 +1,262 @@
+// This work is licensed under the terms of the MIT license.
+// For a copy, see .
+
+using Ingenium.BuildCli;
+using Ingenium.BuildCli.Git;
+using Ingenium.BuildCli.Submodule;
+using Ingenium.BuildCli.Tests.Support;
+
+namespace Ingenium.BuildCli.Tests;
+
+public sealed class BuildSubmoduleServiceTests
+{
+ [Fact]
+ public async Task Init_AddsSubmoduleAtLatestTag()
+ {
+ using var workspace = GitTestWorkspace.Create();
+ var service = CreateService();
+
+ var change = await service.InitAsync(new BuildSubmoduleRequest
+ {
+ RepositoryPath = workspace.ParentRepo,
+ Url = workspace.BuildRepo
+ });
+
+ Assert.True(change.Added);
+ Assert.Equal("build", change.RelativePath);
+ Assert.Equal("v1.1.0", change.CheckedOutRef);
+ Assert.True(Directory.Exists(Path.Combine(workspace.ParentRepo, "build")));
+ Assert.Contains("v1.1.0", GitTestWorkspace.Git(Path.Combine(workspace.ParentRepo, "build"), "tag", "--points-at", "HEAD"));
+ }
+
+ [Fact]
+ public async Task Init_ChecksOutSpecificTag()
+ {
+ using var workspace = GitTestWorkspace.Create();
+ var service = CreateService();
+
+ var change = await service.InitAsync(new BuildSubmoduleRequest
+ {
+ RepositoryPath = workspace.ParentRepo,
+ Url = workspace.BuildRepo,
+ Tag = "v1.0.0"
+ });
+
+ Assert.Equal("v1.0.0", change.CheckedOutRef);
+ Assert.Contains("v1.0.0", GitTestWorkspace.Git(Path.Combine(workspace.ParentRepo, "build"), "tag", "--points-at", "HEAD"));
+ }
+
+ [Fact]
+ public async Task Init_ThrowsWhenAlreadyInitialized()
+ {
+ using var workspace = GitTestWorkspace.Create();
+ var service = CreateService();
+ var request = new BuildSubmoduleRequest
+ {
+ RepositoryPath = workspace.ParentRepo,
+ Url = workspace.BuildRepo
+ };
+
+ await service.InitAsync(request);
+
+ var error = await Assert.ThrowsAsync(() => service.InitAsync(request));
+ Assert.Equal(ExitCodes.AlreadyInitialized, error.ExitCode);
+ }
+
+ [Fact]
+ public async Task Update_MovesFromOlderTagToLatest()
+ {
+ using var workspace = GitTestWorkspace.Create();
+ var service = CreateService();
+
+ await service.InitAsync(new BuildSubmoduleRequest
+ {
+ RepositoryPath = workspace.ParentRepo,
+ Url = workspace.BuildRepo,
+ Tag = "v1.0.0"
+ });
+
+ var change = await service.UpdateAsync(new BuildSubmoduleRequest
+ {
+ RepositoryPath = workspace.ParentRepo,
+ Url = workspace.BuildRepo
+ });
+
+ Assert.False(change.Added);
+ Assert.Equal("v1.1.0", change.CheckedOutRef);
+ Assert.NotEqual(change.PreviousCommit, change.Commit);
+ }
+
+ [Fact]
+ public async Task Update_CanPinSpecificTag()
+ {
+ using var workspace = GitTestWorkspace.Create();
+ var service = CreateService();
+
+ await service.InitAsync(new BuildSubmoduleRequest
+ {
+ RepositoryPath = workspace.ParentRepo,
+ Url = workspace.BuildRepo
+ });
+
+ var change = await service.UpdateAsync(new BuildSubmoduleRequest
+ {
+ RepositoryPath = workspace.ParentRepo,
+ Url = workspace.BuildRepo,
+ Tag = "v1.0.0"
+ });
+
+ Assert.Equal("v1.0.0", change.CheckedOutRef);
+ }
+
+ [Fact]
+ public async Task Status_ReportsRegisteredSubmodule()
+ {
+ using var workspace = GitTestWorkspace.Create();
+ var service = CreateService();
+
+ await service.InitAsync(new BuildSubmoduleRequest
+ {
+ RepositoryPath = workspace.ParentRepo,
+ Url = workspace.BuildRepo,
+ Tag = "v1.0.0"
+ });
+
+ var status = await service.GetStatusAsync(new BuildSubmoduleRequest
+ {
+ RepositoryPath = workspace.ParentRepo
+ });
+
+ Assert.True(status.IsRegistered);
+ Assert.True(status.IsInitialized);
+ Assert.Equal("build", status.RelativePath);
+ Assert.Contains("v1.0.0", status.CurrentTags);
+ Assert.Equal("v1.1.0", status.LatestTag);
+ Assert.False(status.IsLatest);
+ }
+
+ [Fact]
+ public async Task ListTags_ReturnsRemoteTags()
+ {
+ using var workspace = GitTestWorkspace.Create();
+ var service = CreateService();
+
+ var tags = await service.ListTagsAsync(new BuildSubmoduleRequest
+ {
+ RepositoryPath = workspace.ParentRepo,
+ Url = workspace.BuildRepo
+ });
+
+ Assert.Contains(tags, tag => tag.Name == "v1.0.0");
+ Assert.Contains(tags, tag => tag.Name == "v1.1.0");
+ }
+
+ [Fact]
+ public async Task Update_ThrowsWhenSubmoduleIsMissing()
+ {
+ using var workspace = GitTestWorkspace.Create();
+ var service = CreateService();
+
+ var error = await Assert.ThrowsAsync(() => service.UpdateAsync(new BuildSubmoduleRequest
+ {
+ RepositoryPath = workspace.ParentRepo
+ }));
+
+ Assert.Equal(ExitCodes.SubmoduleNotFound, error.ExitCode);
+ }
+
+ [Fact]
+ public async Task Init_UsesCustomSubmodulePath()
+ {
+ using var workspace = GitTestWorkspace.Create();
+ var service = CreateService();
+
+ var change = await service.InitAsync(new BuildSubmoduleRequest
+ {
+ RepositoryPath = workspace.ParentRepo,
+ Url = workspace.BuildRepo,
+ SubmodulePath = "Build"
+ });
+
+ Assert.Equal("Build", change.RelativePath);
+ Assert.True(Directory.Exists(Path.Combine(workspace.ParentRepo, "Build")));
+ }
+
+ [Fact]
+ public async Task Repair_Reset_DiscardsLocalChanges()
+ {
+ using var workspace = GitTestWorkspace.Create();
+ var service = CreateService();
+ var request = new BuildSubmoduleRequest
+ {
+ RepositoryPath = workspace.ParentRepo,
+ Url = workspace.BuildRepo
+ };
+
+ await service.InitAsync(request);
+ var readme = Path.Combine(workspace.ParentRepo, "build", "README.md");
+ var original = File.ReadAllText(readme);
+ File.WriteAllText(readme, "broken locally");
+
+ var result = await service.RepairAsync(request, RepairStrategy.Reset);
+
+ Assert.Equal(RepairStrategy.Reset, result.Strategy);
+ Assert.Equal(original, File.ReadAllText(readme));
+ Assert.Equal(string.Empty, GitTestWorkspace.Git(Path.Combine(workspace.ParentRepo, "build"), "status", "--porcelain"));
+ }
+
+ [Fact]
+ public async Task Repair_Stash_PreservesLocalChanges()
+ {
+ using var workspace = GitTestWorkspace.Create();
+ var service = CreateService();
+ var request = new BuildSubmoduleRequest
+ {
+ RepositoryPath = workspace.ParentRepo,
+ Url = workspace.BuildRepo
+ };
+
+ await service.InitAsync(request);
+ var readme = Path.Combine(workspace.ParentRepo, "build", "README.md");
+ var original = File.ReadAllText(readme);
+ File.WriteAllText(readme, "keep me");
+
+ var result = await service.RepairAsync(request, RepairStrategy.Stash);
+
+ Assert.Equal(original, File.ReadAllText(readme));
+ Assert.False(string.IsNullOrWhiteSpace(result.StashRef));
+ var stashShow = GitTestWorkspace.Git(Path.Combine(workspace.ParentRepo, "build"), "stash", "show", "-p", result.StashRef!);
+ Assert.Contains("keep me", stashShow);
+ }
+
+ [Fact]
+ public async Task Repair_Reinit_ChecksOutRequestedTag()
+ {
+ using var workspace = GitTestWorkspace.Create();
+ var service = CreateService();
+
+ await service.InitAsync(new BuildSubmoduleRequest
+ {
+ RepositoryPath = workspace.ParentRepo,
+ Url = workspace.BuildRepo
+ });
+
+ var result = await service.RepairAsync(
+ new BuildSubmoduleRequest
+ {
+ RepositoryPath = workspace.ParentRepo,
+ Url = workspace.BuildRepo,
+ Tag = "v1.0.0"
+ },
+ RepairStrategy.Reinit);
+
+ Assert.Equal(RepairStrategy.Reinit, result.Strategy);
+ Assert.Equal("v1.0.0", result.Change.CheckedOutRef);
+ Assert.Contains("v1.0.0", GitTestWorkspace.Git(Path.Combine(workspace.ParentRepo, "build"), "tag", "--points-at", "HEAD"));
+ }
+
+ private static BuildSubmoduleService CreateService()
+ {
+ return new BuildSubmoduleService(GitTestWorkspace.CreateClient());
+ }
+}
diff --git a/tests/Ingenium.BuildCli.Tests/CommandAppTests.cs b/tests/Ingenium.BuildCli.Tests/CommandAppTests.cs
new file mode 100644
index 0000000..fce489e
--- /dev/null
+++ b/tests/Ingenium.BuildCli.Tests/CommandAppTests.cs
@@ -0,0 +1,122 @@
+// This work is licensed under the terms of the MIT license.
+// For a copy, see .
+
+using Ingenium.BuildCli.Git;
+using Ingenium.BuildCli.Submodule;
+using Ingenium.BuildCli.Tests.Support;
+
+using Ingenium.BuildCli;
+
+using Microsoft.Extensions.DependencyInjection;
+
+using Spectre.Console.Cli;
+using Spectre.Console.Testing;
+
+namespace Ingenium.BuildCli.Tests;
+
+public sealed class CommandAppTests
+{
+ [Fact]
+ public async Task Help_ListsPrimaryCommands()
+ {
+ var console = new TestConsole();
+ var app = CreateApp(console);
+ var exitCode = await app.RunAsync(["--help"]);
+
+ Assert.Equal(0, exitCode);
+ var output = console.Output;
+ Assert.Contains("init", output, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("update", output, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("status", output, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("tags", output, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("repair", output, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("build", output, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("extension", output, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public async Task InitHelp_DescribesTagOption()
+ {
+ var console = new TestConsole();
+ var app = CreateApp(console);
+ var exitCode = await app.RunAsync(["init", "--help"]);
+
+ Assert.Equal(0, exitCode);
+ Assert.Contains("--tag", console.Output, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public async Task Status_RendersUninitializedRepository()
+ {
+ using var workspace = GitTestWorkspace.Create();
+ var console = new TestConsole();
+ var app = CreateApp(console);
+ var exitCode = await app.RunAsync(["status", "--path", workspace.ParentRepo, "--url", workspace.BuildRepo]);
+
+ Assert.Equal(0, exitCode);
+ Assert.Contains("not added", console.Output, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public async Task InitThenStatus_ShowsCurrentTag()
+ {
+ using var workspace = GitTestWorkspace.Create();
+ var console = new TestConsole();
+ var app = CreateApp(console);
+
+ var initExit = await app.RunAsync([
+ "init",
+ "--path", workspace.ParentRepo,
+ "--url", workspace.BuildRepo,
+ "--tag", "v1.0.0"
+ ]);
+ Assert.Equal(0, initExit);
+
+ console = new TestConsole();
+ app = CreateApp(console);
+ var statusExit = await app.RunAsync([
+ "status",
+ "--path", workspace.ParentRepo,
+ "--url", workspace.BuildRepo
+ ]);
+
+ Assert.Equal(0, statusExit);
+ Assert.Contains("v1.0.0", console.Output);
+ }
+
+ [Fact]
+ public async Task NoCommand_DefaultsToBuild_AndRequiresInitializedSubmodule()
+ {
+ using var workspace = GitTestWorkspace.Create();
+ var console = new TestConsole();
+ var exitCode = await BuildCliApplication.RunAsync(
+ ["--path", workspace.ParentRepo, "--url", workspace.BuildRepo],
+ console,
+ services => services.AddSingleton(_ => GitTestWorkspace.CreateClient()));
+
+ Assert.Equal(ExitCodes.SubmoduleNotFound, exitCode);
+ Assert.Contains("bld init", console.Output, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void SelectLatest_UsedByStatusModel()
+ {
+ var status = new BuildSubmoduleStatus
+ {
+ RepositoryRoot = "/tmp/repo",
+ Commit = "abc123",
+ LatestCommit = "abc123def",
+ LatestTag = "v1.0.0"
+ };
+
+ Assert.True(status.IsLatest);
+ }
+
+ private static CommandApp CreateApp(TestConsole console)
+ {
+ return BuildCliApplication.Create(console, services =>
+ {
+ services.AddSingleton(_ => GitTestWorkspace.CreateClient());
+ });
+ }
+}
diff --git a/tests/Ingenium.BuildCli.Tests/CommandLineDefaultsTests.cs b/tests/Ingenium.BuildCli.Tests/CommandLineDefaultsTests.cs
new file mode 100644
index 0000000..f249e02
--- /dev/null
+++ b/tests/Ingenium.BuildCli.Tests/CommandLineDefaultsTests.cs
@@ -0,0 +1,35 @@
+// This work is licensed under the terms of the MIT license.
+// For a copy, see .
+
+namespace Ingenium.BuildCli.Tests;
+
+public sealed class CommandLineDefaultsTests
+{
+ [Fact]
+ public void Apply_DefaultsEmptyArgsToBuild()
+ {
+ Assert.Equal(["build"], CommandLineDefaults.Apply([]));
+ }
+
+ [Fact]
+ public void Apply_LeavesHelpAndVersionAlone()
+ {
+ Assert.Equal(["--help"], CommandLineDefaults.Apply(["--help"]));
+ Assert.Equal(["-v"], CommandLineDefaults.Apply(["-v"]));
+ }
+
+ [Fact]
+ public void Apply_LeavesKnownCommandsAlone()
+ {
+ Assert.Equal(["init", "--tag", "v1.0.0"], CommandLineDefaults.Apply(["init", "--tag", "v1.0.0"]));
+ Assert.Equal(["status"], CommandLineDefaults.Apply(["status"]));
+ }
+
+ [Fact]
+ public void Apply_TreatsLeadingOptionsAsBuildOptions()
+ {
+ Assert.Equal(
+ ["build", "--path", "./src", "--configuration", "Release"],
+ CommandLineDefaults.Apply(["--path", "./src", "--configuration", "Release"]));
+ }
+}
diff --git a/tests/Ingenium.BuildCli.Tests/GitmodulesParserTests.cs b/tests/Ingenium.BuildCli.Tests/GitmodulesParserTests.cs
new file mode 100644
index 0000000..b516eb7
--- /dev/null
+++ b/tests/Ingenium.BuildCli.Tests/GitmodulesParserTests.cs
@@ -0,0 +1,59 @@
+// This work is licensed under the terms of the MIT license.
+// For a copy, see .
+
+using Ingenium.BuildCli.Git;
+
+namespace Ingenium.BuildCli.Tests;
+
+public sealed class GitmodulesParserTests
+{
+ [Fact]
+ public void Parse_ReadsNamedSubmoduleEntries()
+ {
+ const string contents = """
+ [submodule "build"]
+ path = build
+ url = git@github.com:IngeniumSE/Build.git
+ branch = main
+
+ [submodule "docs"]
+ path = docs/vendor
+ url = https://example.com/docs.git
+ """;
+
+ var entries = GitmodulesParser.Parse(contents);
+
+ Assert.Equal(2, entries.Count);
+ Assert.Equal("build", entries[0].Name);
+ Assert.Equal("build", entries[0].Path);
+ Assert.Equal("git@github.com:IngeniumSE/Build.git", entries[0].Url);
+ Assert.Equal("main", entries[0].Branch);
+ Assert.Equal("docs/vendor", entries[1].Path);
+ }
+
+ [Fact]
+ public void Parse_IgnoresCommentsAndUnknownKeys()
+ {
+ const string contents = """
+ # generated
+ [submodule "Build"]
+ path = Build
+ url = https://github.com/IngeniumSE/Build.git
+ ignore = dirty
+ """;
+
+ var entries = GitmodulesParser.Parse(contents);
+
+ Assert.Single(entries);
+ Assert.Equal("Build", entries[0].Path);
+ Assert.Equal("https://github.com/IngeniumSE/Build.git", entries[0].Url);
+ }
+
+ [Fact]
+ public void ParseFile_ReturnsEmptyWhenMissing()
+ {
+ var entries = GitmodulesParser.ParseFile(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"), ".gitmodules"));
+
+ Assert.Empty(entries);
+ }
+}
diff --git a/tests/Ingenium.BuildCli.Tests/GlobalUsings.cs b/tests/Ingenium.BuildCli.Tests/GlobalUsings.cs
new file mode 100644
index 0000000..c802f44
--- /dev/null
+++ b/tests/Ingenium.BuildCli.Tests/GlobalUsings.cs
@@ -0,0 +1 @@
+global using Xunit;
diff --git a/tests/Ingenium.BuildCli.Tests/Ingenium.BuildCli.Tests.csproj b/tests/Ingenium.BuildCli.Tests/Ingenium.BuildCli.Tests.csproj
new file mode 100644
index 0000000..14e60f5
--- /dev/null
+++ b/tests/Ingenium.BuildCli.Tests/Ingenium.BuildCli.Tests.csproj
@@ -0,0 +1,22 @@
+
+
+
+ net8.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/Ingenium.BuildCli.Tests/RepairStrategyParserTests.cs b/tests/Ingenium.BuildCli.Tests/RepairStrategyParserTests.cs
new file mode 100644
index 0000000..d928057
--- /dev/null
+++ b/tests/Ingenium.BuildCli.Tests/RepairStrategyParserTests.cs
@@ -0,0 +1,29 @@
+// This work is licensed under the terms of the MIT license.
+// For a copy, see .
+
+using Ingenium.BuildCli.Submodule;
+
+namespace Ingenium.BuildCli.Tests;
+
+public sealed class RepairStrategyParserTests
+{
+ [Theory]
+ [InlineData("stash", RepairStrategy.Stash)]
+ [InlineData("reset", RepairStrategy.Reset)]
+ [InlineData("head", RepairStrategy.Reset)]
+ [InlineData("reinit", RepairStrategy.Reinit)]
+ [InlineData("re-init", RepairStrategy.Reinit)]
+ [InlineData("reinitialize", RepairStrategy.Reinit)]
+ public void TryParse_AcceptsAliases(string value, RepairStrategy expected)
+ {
+ Assert.True(RepairStrategyParser.TryParse(value, out var strategy));
+ Assert.Equal(expected, strategy);
+ }
+
+ [Fact]
+ public void TryParse_RejectsUnknownValues()
+ {
+ Assert.False(RepairStrategyParser.TryParse("explode", out _));
+ Assert.False(RepairStrategyParser.TryParse(" ", out _));
+ }
+}
diff --git a/tests/Ingenium.BuildCli.Tests/Support/GitTestWorkspace.cs b/tests/Ingenium.BuildCli.Tests/Support/GitTestWorkspace.cs
new file mode 100644
index 0000000..cf92d30
--- /dev/null
+++ b/tests/Ingenium.BuildCli.Tests/Support/GitTestWorkspace.cs
@@ -0,0 +1,132 @@
+// This work is licensed under the terms of the MIT license.
+// For a copy, see .
+
+using System.Diagnostics;
+
+using Ingenium.BuildCli.Git;
+
+namespace Ingenium.BuildCli.Tests.Support;
+
+///
+/// Creates temporary git repositories used by integration tests.
+///
+public sealed class GitTestWorkspace : IDisposable
+{
+ private GitTestWorkspace(string root, string buildRepo, string parentRepo)
+ {
+ Root = root;
+ BuildRepo = buildRepo;
+ ParentRepo = parentRepo;
+ }
+
+ public string Root { get; }
+
+ public string BuildRepo { get; }
+
+ public string ParentRepo { get; }
+
+ public static GitTestWorkspace Create(bool includeTags = true)
+ {
+ var root = Path.Combine(Path.GetTempPath(), "buildcli-tests", Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(root);
+
+ var buildWorking = Path.Combine(root, "build-src");
+ var buildRepo = Path.Combine(root, "build.git");
+ var parentRepo = Path.Combine(root, "parent");
+
+ Directory.CreateDirectory(buildWorking);
+ Git(buildWorking, "init", "-b", "main");
+ ConfigureIdentity(buildWorking);
+ File.WriteAllText(Path.Combine(buildWorking, "README.md"), "build v1");
+ Git(buildWorking, "add", ".");
+ Git(buildWorking, "commit", "-m", "Initial build");
+ if (includeTags)
+ {
+ Git(buildWorking, "tag", "-a", "v1.0.0", "-m", "v1.0.0");
+ }
+
+ File.WriteAllText(Path.Combine(buildWorking, "README.md"), "build v2");
+ Git(buildWorking, "add", ".");
+ Git(buildWorking, "commit", "-m", "Second build");
+ if (includeTags)
+ {
+ Git(buildWorking, "tag", "-a", "v1.1.0", "-m", "v1.1.0");
+ }
+
+ Git(buildWorking, "clone", "--bare", buildWorking, buildRepo);
+
+ Directory.CreateDirectory(parentRepo);
+ Git(parentRepo, "init", "-b", "main");
+ ConfigureIdentity(parentRepo);
+ File.WriteAllText(Path.Combine(parentRepo, "README.md"), "parent");
+ Git(parentRepo, "add", ".");
+ Git(parentRepo, "commit", "-m", "Initial parent");
+
+ return new GitTestWorkspace(root, buildRepo, parentRepo);
+ }
+
+ public static GitClient CreateClient()
+ {
+ return new GitClient(globalArguments: ["-c", "protocol.file.allow=always"]);
+ }
+
+ public string Git(params string[] arguments)
+ {
+ return Git(ParentRepo, arguments);
+ }
+
+ public static string Git(string workingDirectory, params string[] arguments)
+ {
+ var startInfo = new ProcessStartInfo
+ {
+ FileName = "git",
+ WorkingDirectory = workingDirectory,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ UseShellExecute = false
+ };
+
+ startInfo.ArgumentList.Add("-c");
+ startInfo.ArgumentList.Add("protocol.file.allow=always");
+ foreach (var argument in arguments)
+ {
+ startInfo.ArgumentList.Add(argument);
+ }
+
+ using var process = Process.Start(startInfo)
+ ?? throw new InvalidOperationException("Failed to start git.");
+ var stdout = process.StandardOutput.ReadToEnd();
+ var stderr = process.StandardError.ReadToEnd();
+ process.WaitForExit();
+ if (process.ExitCode != 0)
+ {
+ throw new InvalidOperationException($"git {string.Join(' ', arguments)} failed: {stderr}");
+ }
+
+ return stdout.Trim();
+ }
+
+ public void Dispose()
+ {
+ try
+ {
+ Directory.Delete(Root, recursive: true);
+ }
+ catch (IOException)
+ {
+ }
+ catch (UnauthorizedAccessException)
+ {
+ }
+ }
+
+ private static void ConfigureIdentity(string workingDirectory)
+ {
+ Git(workingDirectory, "config", "user.email", "buildcli@ingenium.local");
+ Git(workingDirectory, "config", "user.name", "BuildCLI Tests");
+ Git(workingDirectory, "config", "commit.gpgsign", "false");
+ Git(workingDirectory, "config", "tag.gpgsign", "false");
+ Git(workingDirectory, "config", "init.defaultBranch", "main");
+ Git(workingDirectory, "config", "protocol.file.allow", "always");
+ }
+}
diff --git a/tests/Ingenium.BuildCli.Tests/TagSelectorTests.cs b/tests/Ingenium.BuildCli.Tests/TagSelectorTests.cs
new file mode 100644
index 0000000..8010cee
--- /dev/null
+++ b/tests/Ingenium.BuildCli.Tests/TagSelectorTests.cs
@@ -0,0 +1,73 @@
+// This work is licensed under the terms of the MIT license.
+// For a copy, see .
+
+using Ingenium.BuildCli.Submodule;
+
+namespace Ingenium.BuildCli.Tests;
+
+public sealed class TagSelectorTests
+{
+ [Fact]
+ public void ParseLsRemote_PrefersPeeledAnnotatedTags()
+ {
+ const string output = """
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa refs/tags/v1.0.0
+ bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb refs/tags/v1.0.0^{}
+ cccccccccccccccccccccccccccccccccccccccc refs/tags/v1.1.0
+ """;
+
+ var tags = TagSelector.ParseLsRemote(output);
+
+ Assert.Equal(2, tags.Count);
+ Assert.Equal("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", tags.Single(tag => tag.Name == "v1.0.0").Commit);
+ Assert.Equal("cccccccccccccccccccccccccccccccccccccccc", tags.Single(tag => tag.Name == "v1.1.0").Commit);
+ }
+
+ [Fact]
+ public void SelectLatest_PrefersHighestStableSemVer()
+ {
+ var tags = new[]
+ {
+ new RemoteTag("v1.2.0", "a"),
+ new RemoteTag("v2.0.0-preview.1", "b"),
+ new RemoteTag("v2.0.0", "c"),
+ new RemoteTag("not-a-version", "d")
+ };
+
+ var latest = TagSelector.SelectLatest(tags);
+
+ Assert.Equal("v2.0.0", latest?.Name);
+ }
+
+ [Fact]
+ public void SelectLatest_PrefersStableOverPrereleaseOfSameVersion()
+ {
+ var tags = new[]
+ {
+ new RemoteTag("1.0.0-preview", "a"),
+ new RemoteTag("1.0.0", "b")
+ };
+
+ var latest = TagSelector.SelectLatest(tags);
+
+ Assert.Equal("1.0.0", latest?.Name);
+ }
+
+ [Fact]
+ public void Find_MatchesOptionalLeadingV()
+ {
+ var tags = new[]
+ {
+ new RemoteTag("v1.2.3", "abc")
+ };
+
+ Assert.Equal("v1.2.3", TagSelector.Find(tags, "1.2.3")?.Name);
+ Assert.Equal("v1.2.3", TagSelector.Find(tags, "v1.2.3")?.Name);
+ }
+
+ [Fact]
+ public void SelectLatest_ReturnsNullForEmptyList()
+ {
+ Assert.Null(TagSelector.SelectLatest([]));
+ }
+}