diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..2c58b4a --- /dev/null +++ b/.editorconfig @@ -0,0 +1,71 @@ +# EditorConfig is awesome:http://EditorConfig.org + +# top-most EditorConfig file +root = true + +# Don't use tabs for indentation. +[*] +indent_style = tab +guidelines = 80, 120, 160 +# (Please don't specify an indent_size here; that has too many unintended consequences.) + +# Code files +[*.{cs,csx,vb,vbx}] +indent_size = 2 + +# Xml project files +[*.{csproj,vbproj,vcxproj,vcxproj.filters,proj,projitems,shproj}] +indent_size = 2 + +# Xml config files +[*.{props,targets,ruleset,config,nuspec,resx,vsixmanifest,vsct}] +indent_size = 2 + +# JSON files +[*.json] +indent_size = 2 + +# YAML files +[*.yml] +indent_style = space + +# Shell scripts +[*.sh] +indent_style = tab + +# Dotnet code style settings: +[*.cs] +# Sort using and Import directives with System.* appearing first +dotnet_sort_system_directives_first = true + +# Specify validation methods +dotnet_code_quality.null_check_validation_methods = IsNotNull + +# Don't use this. qualifier +dotnet_style_qualification_for_field = false:suggestion +dotnet_style_qualification_for_property = false:suggestion + +# use int x = .. over Int32 +dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion + +# use int.MaxValue over Int32.MaxValue +dotnet_style_predefined_type_for_member_access = true:suggestion + +# Require var all the time. +csharp_style_var_for_built_in_types = false:suggestion +csharp_style_var_when_type_is_apparent = true:suggestion +csharp_style_var_elsewhere = true:suggestion + +# Disallow throw expressions. +csharp_style_throw_expression = false:suggestion + +# Newline settings +csharp_new_line_before_open_brace = all +csharp_new_line_before_else = true +csharp_new_line_before_catch = true +csharp_new_line_before_finally = true +csharp_new_line_before_members_in_object_initializers = true +csharp_new_line_before_members_in_anonymous_types = true + +# CA1707: Identifiers should not contain underscores +dotnet_diagnostic.CA1707.severity = silent diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1598dd0 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,31 @@ +name: CI + +on: + push: + branches: + - main + pull_request: + +jobs: + test: + name: Test (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + os: + - ubuntu-latest + - windows-latest + - macos-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + global-json-file: global.json + + - name: Test + run: dotnet test --configuration Release --nologo diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..333fee0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,114 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# Test results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* +TestResult.xml +nunit-*.xml +coverage*[.json, .xml, .info] +*.coverage +*.coveragexml + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ +artefacts/ + +# NuGet +*.nupkg +*.snupkg +**/[Pp]ackages/* + +# Files built by Visual Studio +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# ReSharper +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# JetBrains Rider +.idea/ +*.sln.iml + +# Visual Studio Code +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json + +# macOS +.DS_Store +.AppleDouble +.LSOverride +._* + +# Windows +Thumbs.db +ehthumbs.db +[Dd]esktop.ini +$RECYCLE.BIN/ +*.lnk + +# Publish output +publish/ + +# Local install leftovers +*.tar.gz +tarballs/ diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..fc36340 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,29 @@ + + + latest + enable + enable + true + true + + + + Ingenium Software Engineering + Ingenium Software Engineering Limited + Copyright (c) Ingenium Software Engineering Limited + MIT + git + https://github.com/IngeniumSE/BuildCLI + https://github.com/IngeniumSE/BuildCLI + + + + Test + $(MSBuildProjectName.Replace('.Tests', '')) + + + + false + true + + diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 0000000..fdf2873 --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,22 @@ + + + true + + + + + + + + + + + + + + + + + + + diff --git a/Ingenium-BuildCLI.sln b/Ingenium-BuildCLI.sln new file mode 100644 index 0000000..7259db0 --- /dev/null +++ b/Ingenium-BuildCLI.sln @@ -0,0 +1,36 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "apps", "apps", "{AF527B55-93CA-49AB-8D5A-D4B3292CFD9C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ingenium.BuildCli", "apps\Ingenium.BuildCli\Ingenium.BuildCli.csproj", "{354C7E07-3EAF-43DC-9F90-8F48A3702200}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0CEF0E8A-FDDD-4043-888D-B8039CDA2D4B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ingenium.BuildCli.Tests", "tests\Ingenium.BuildCli.Tests\Ingenium.BuildCli.Tests.csproj", "{884A2022-058D-4424-ACC4-B15B9BA777EB}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {354C7E07-3EAF-43DC-9F90-8F48A3702200}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {354C7E07-3EAF-43DC-9F90-8F48A3702200}.Debug|Any CPU.Build.0 = Debug|Any CPU + {354C7E07-3EAF-43DC-9F90-8F48A3702200}.Release|Any CPU.ActiveCfg = Release|Any CPU + {354C7E07-3EAF-43DC-9F90-8F48A3702200}.Release|Any CPU.Build.0 = Release|Any CPU + {884A2022-058D-4424-ACC4-B15B9BA777EB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {884A2022-058D-4424-ACC4-B15B9BA777EB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {884A2022-058D-4424-ACC4-B15B9BA777EB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {884A2022-058D-4424-ACC4-B15B9BA777EB}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {354C7E07-3EAF-43DC-9F90-8F48A3702200} = {AF527B55-93CA-49AB-8D5A-D4B3292CFD9C} + {884A2022-058D-4424-ACC4-B15B9BA777EB} = {0CEF0E8A-FDDD-4043-888D-B8039CDA2D4B} + EndGlobalSection +EndGlobal diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..18911a4 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Ingenium Software Engineering Limited + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index e8d034f..7c1a86c 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,118 @@ # BuildCLI -A CLI for orchestrating an Ingenium Build + +A cross-platform CLI for adding and updating the [Ingenium Build](https://github.com/IngeniumSE/Build) git submodule in a parent repository. + +The tool is written in C# and uses [Spectre.Console](https://spectreconsole.net/) for command parsing and terminal layout. + +## Commands + +Run `bld` from any git repository that should host the Build submodule. + +A bare `bld` (no command) runs `build` after verifying that the Build submodule is initialized. If it is missing, the CLI stops and tells you to run `bld init`. + +```text +bld Run the Default Cake target (same as `bld build`) +bld init Add the Build submodule (defaults to the latest tag) +bld init --tag v1.2.3 Add the Build submodule pinned to a specific tag +bld update Move an existing submodule to the latest tag +bld update --tag v1.2.3 Move an existing submodule to a specific tag +bld status Show the current submodule path, commit, and tags +bld tags List tags advertised by the Build remote +bld repair --strategy stash Stash local submodule changes, then restore the parent HEAD +bld repair --strategy reset --yes + Discard local changes and restore the parent-recorded HEAD +bld repair --strategy reinit --tag v1.2.3 --yes + Delete and clone the submodule again at a tagged version +bld build Run the Build host Default target +bld build TestProjects Run a specific Cake target in the Build submodule +bld extension Create build-extensions/{Repo}BuildExtensions +bld extension Framework Create build-extensions/FrameworkBuildExtensions +``` + +Common options: + +| Option | Description | +| --- | --- | +| `-p`, `--path ` | Parent repository path. Defaults to the current directory. | +| `--submodule-path ` | Relative submodule path. Detects an existing Build entry, otherwise `build`. | +| `--url ` | Override the submodule URL. Defaults to `git@github.com:IngeniumSE/Build.git`. | +| `--https` | Use `https://github.com/IngeniumSE/Build.git` instead of SSH. | +| `-t`, `--tag ` | Tag, branch, or commit to check out. | +| `-f`, `--force` | Re-initialize or overwrite an existing submodule during `init`. | +| `--verbose` | Write the git commands that are executed. | + +`init` and `update` stage `.gitmodules` and the submodule gitlink. They do not create a commit, so you can review the change in the parent repository first. + +Existing Ingenium repositories that already use `build` or `Build` as the submodule path are detected automatically. + +`repair` can prompt for a strategy when run interactively. `reset` and `reinit` are destructive and require `--yes` in non-interactive use. + +`build` first checks that the Build submodule is registered and checked out. It then restores .NET local tools when `.config/dotnet-tools.json` is present, and runs `apps/Build` the same way `./build.sh` does. + +`extension` writes the layout the Build host already imports: + +```text +build-extensions/Directory.Build.props +build-extensions/Directory.Build.targets +build-extensions/{Name}BuildExtensions/{Name}BuildExtensions.csproj +build-extensions/{Name}BuildExtensions/SampleTask.cs +``` + +## Installation + +The installer publishes a self-contained `bld` binary and places it on your PATH. Git is required. The .NET 8 SDK is installed automatically when it is missing. + +### macOS and Linux + +From a clone: + +```bash +./scripts/install.sh +``` + +Or later, once this repository is available remotely: + +```bash +curl -sSL https://raw.githubusercontent.com/IngeniumSE/BuildCLI/main/scripts/install.sh | bash +``` + +The default install location is `~/.local/share/ingenium/bld`, with a symlink at `~/.local/bin/bld`. Add `~/.local/bin` to `PATH` if the installer reports that the command is not visible yet. + +### Windows + +From a clone: + +```powershell +./scripts/install.ps1 +``` + +Or later: + +```powershell +irm https://raw.githubusercontent.com/IngeniumSE/BuildCLI/main/scripts/install.ps1 | iex +``` + +The default install location is `%LOCALAPPDATA%\Ingenium\bld`. That directory is added to the user `PATH`. Open a new terminal before running `bld`. + +### .NET tool + +The project is also packable as a .NET global tool: + +```bash +dotnet pack apps/Ingenium.BuildCli -c Release +dotnet tool install --global --add-source apps/Ingenium.BuildCli/nupkg Ingenium.BuildCli +``` + +## Development + +```bash +dotnet test +``` + +or: + +```bash +./build.sh +``` + +The solution targets .NET 8 and follows the Ingenium repository layout (`apps/`, `tests/`, central package management). diff --git a/apps/Ingenium.BuildCli/AppVersion.cs b/apps/Ingenium.BuildCli/AppVersion.cs new file mode 100644 index 0000000..be8d08e --- /dev/null +++ b/apps/Ingenium.BuildCli/AppVersion.cs @@ -0,0 +1,34 @@ +// This work is licensed under the terms of the MIT license. +// For a copy, see . + +using System.Reflection; + +namespace Ingenium.BuildCli; + +/// +/// Exposes the current CLI version from the assembly metadata. +/// +public static class AppVersion +{ + /// + /// Gets the informational version, falling back to the assembly version. + /// + public static string Current + { + get + { + var assembly = typeof(AppVersion).Assembly; + var informational = assembly + .GetCustomAttribute() + ?.InformationalVersion; + + if (!string.IsNullOrWhiteSpace(informational)) + { + var plus = informational.IndexOf('+'); + return plus >= 0 ? informational[..plus] : informational; + } + + return assembly.GetName().Version?.ToString() ?? "0.0.0"; + } + } +} diff --git a/apps/Ingenium.BuildCli/BuildCliApplication.cs b/apps/Ingenium.BuildCli/BuildCliApplication.cs new file mode 100644 index 0000000..acedc81 --- /dev/null +++ b/apps/Ingenium.BuildCli/BuildCliApplication.cs @@ -0,0 +1,119 @@ +// This work is licensed under the terms of the MIT license. +// For a copy, see . + +using Ingenium.BuildCli.Commands; +using Ingenium.BuildCli.Extensions; +using Ingenium.BuildCli.Git; +using Ingenium.BuildCli.Host; +using Ingenium.BuildCli.Infrastructure; +using Ingenium.BuildCli.Execution; +using Ingenium.BuildCli.Rendering; +using Ingenium.BuildCli.Submodule; + +using Microsoft.Extensions.DependencyInjection; + +using Spectre.Console; +using Spectre.Console.Cli; + +namespace Ingenium.BuildCli; + +/// +/// Configures the Spectre.Console.Cli application and its services. +/// +public static class BuildCliApplication +{ + /// + /// Creates the configured command application. + /// + public static CommandApp Create(IAnsiConsole? console = null, Action? configureServices = null) + { + var services = new ServiceCollection(); + services.AddSingleton(console ?? AnsiConsole.Console); + services.AddSingleton(); + services.AddSingleton(provider => new GitClient(trace: provider.GetRequiredService())); + services.AddSingleton(provider => new ProcessRunner(provider.GetRequiredService())); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + configureServices?.Invoke(services); + + var app = new CommandApp(new TypeRegistrar(services)); + app.Configure(config => + { + if (console is not null) + { + config.ConfigureConsole(console); + } + + Configure(config); + }); + return app; + } + + /// + /// Runs the CLI, defaulting a bare invocation to build. + /// + public static Task RunAsync( + string[] args, + IAnsiConsole? console = null, + Action? configureServices = null) + { + return Create(console, configureServices).RunAsync(CommandLineDefaults.Apply(args)); + } + + /// + /// Registers commands, examples, and the global exception handler. + /// + public static void Configure(IConfigurator config) + { + config.SetApplicationName(CliInfo.Name); + config.SetApplicationVersion(AppVersion.Current); + config.ValidateExamples(); + + config.SetExceptionHandler((exception, resolver) => + { + var console = resolver?.Resolve(typeof(IAnsiConsole)) as IAnsiConsole ?? AnsiConsole.Console; + ConsoleWriter.WriteError(console, exception); + return exception is BuildCliException buildCliException + ? buildCliException.ExitCode + : ExitCodes.GeneralError; + }); + + config.AddCommand("init") + .WithDescription("Add the Ingenium Build submodule to a repository.") + .WithExample("init") + .WithExample("init", "--tag", "v1.2.3") + .WithExample("init", "--path", "./src", "--https"); + + config.AddCommand("update") + .WithDescription("Update the Build submodule to the latest tag or a specific version.") + .WithExample("update") + .WithExample("update", "--tag", "v1.2.3"); + + config.AddCommand("status") + .WithDescription("Show the current Build submodule state.") + .WithExample("status"); + + config.AddCommand("tags") + .WithDescription("List tags available on the Build remote.") + .WithExample("tags") + .WithExample("tags", "--https"); + + config.AddCommand("repair") + .WithDescription("Repair a dirty or broken Build submodule.") + .WithExample("repair", "--strategy", "stash") + .WithExample("repair", "--strategy", "reset", "--yes") + .WithExample("repair", "--strategy", "reinit", "--tag", "v1.2.3", "--yes"); + + config.AddCommand("build") + .WithDescription("Run a Cake target through the Build submodule. This is the default when no command is passed.") + .WithExample("build") + .WithExample("build", "TestProjects") + .WithExample("build", "Default", "--configuration", "Release"); + + config.AddCommand("extension") + .WithDescription("Create a Cake build-extension project in build-extensions/.") + .WithExample("extension") + .WithExample("extension", "Framework"); + } +} diff --git a/apps/Ingenium.BuildCli/BuildCliException.cs b/apps/Ingenium.BuildCli/BuildCliException.cs new file mode 100644 index 0000000..41b8131 --- /dev/null +++ b/apps/Ingenium.BuildCli/BuildCliException.cs @@ -0,0 +1,43 @@ +// This work is licensed under the terms of the MIT license. +// For a copy, see . + +namespace Ingenium.BuildCli; + +/// +/// Represents a user-facing CLI failure with a specific process exit code. +/// +public sealed class BuildCliException : Exception +{ + /// + /// Initializes a new instance of the class. + /// + /// The error message shown to the user. + /// The process exit code to return. + public BuildCliException(string message, int exitCode = ExitCodes.GeneralError) + : base(message) + { + ExitCode = exitCode; + } + + /// + /// Gets the process exit code associated with this failure. + /// + public int ExitCode { get; } +} + +/// +/// Well-known process exit codes used by the CLI. +/// +public static class ExitCodes +{ + public const int Success = 0; + public const int GeneralError = 1; + public const int NotAGitRepository = 2; + public const int GitNotFound = 3; + public const int SubmoduleNotFound = 4; + public const int AlreadyInitialized = 5; + public const int RefNotFound = 6; + public const int Cancelled = 7; + public const int BuildFailed = 8; + public const int AlreadyExists = 9; +} diff --git a/apps/Ingenium.BuildCli/CliInfo.cs b/apps/Ingenium.BuildCli/CliInfo.cs new file mode 100644 index 0000000..5a3e8bc --- /dev/null +++ b/apps/Ingenium.BuildCli/CliInfo.cs @@ -0,0 +1,15 @@ +// This work is licensed under the terms of the MIT license. +// For a copy, see . + +namespace Ingenium.BuildCli; + +/// +/// User-facing identity of the CLI executable. +/// +public static class CliInfo +{ + /// + /// The command name installed on PATH. + /// + public const string Name = "bld"; +} diff --git a/apps/Ingenium.BuildCli/CommandLineDefaults.cs b/apps/Ingenium.BuildCli/CommandLineDefaults.cs new file mode 100644 index 0000000..802cc30 --- /dev/null +++ b/apps/Ingenium.BuildCli/CommandLineDefaults.cs @@ -0,0 +1,67 @@ +// This work is licensed under the terms of the MIT license. +// For a copy, see . + +namespace Ingenium.BuildCli; + +/// +/// Applies default command routing so a bare invocation runs build. +/// +public static class CommandLineDefaults +{ + /// + /// The command used when the user does not specify one. + /// + public const string DefaultCommand = "build"; + + private static readonly HashSet Commands = new(StringComparer.OrdinalIgnoreCase) + { + "init", + "update", + "status", + "tags", + "repair", + "build", + "extension" + }; + + private static readonly HashSet MetaOptions = new(StringComparer.OrdinalIgnoreCase) + { + "-h", + "--help", + "-v", + "--version" + }; + + /// + /// Inserts build when no command was supplied. + /// + public static string[] Apply(IReadOnlyList args) + { + ArgumentNullException.ThrowIfNull(args); + + if (args.Count == 0) + { + return [DefaultCommand]; + } + + var first = args[0]; + if (MetaOptions.Contains(first) || Commands.Contains(first)) + { + return args as string[] ?? args.ToArray(); + } + + if (first.StartsWith('-')) + { + var routed = new string[args.Count + 1]; + routed[0] = DefaultCommand; + for (var i = 0; i < args.Count; i++) + { + routed[i + 1] = args[i]; + } + + return routed; + } + + return args as string[] ?? args.ToArray(); + } +} diff --git a/apps/Ingenium.BuildCli/Commands/BuildCommand.cs b/apps/Ingenium.BuildCli/Commands/BuildCommand.cs new file mode 100644 index 0000000..7b37ed1 --- /dev/null +++ b/apps/Ingenium.BuildCli/Commands/BuildCommand.cs @@ -0,0 +1,96 @@ +// This work is licensed under the terms of the MIT license. +// For a copy, see . + +using System.ComponentModel; + +using Ingenium.BuildCli.Git; +using Ingenium.BuildCli.Host; +using Ingenium.BuildCli.Rendering; +using Ingenium.BuildCli.Submodule; + +using Spectre.Console; +using Spectre.Console.Cli; + +namespace Ingenium.BuildCli.Commands; + +/// +/// Triggers a build through the Build submodule Cake host. +/// +public sealed class BuildCommand : AsyncCommand +{ + private readonly IAnsiConsole _console; + private readonly IBuildSubmoduleService _submodules; + private readonly IBuildHostService _host; + private readonly IGitTrace _trace; + + /// + /// Initializes a new instance of the class. + /// + public BuildCommand(IAnsiConsole console, IBuildSubmoduleService submodules, IBuildHostService host, IGitTrace trace) + { + _console = console; + _submodules = submodules; + _host = host; + _trace = trace; + } + + /// + public override async Task ExecuteAsync(CommandContext context, Settings settings) + { + _trace.Enabled = settings.Verbose; + ConsoleWriter.WriteHeader(_console, "build"); + + var request = settings.ToRequest(); + var status = await _submodules.GetStatusAsync(request); + BuildSubmoduleGuard.EnsureInitialized(status); + _console.MarkupLine($"[grey]Build submodule initialized at {Markup.Escape(status.RelativePath ?? "build")} ({Markup.Escape(DescribeRef(status))}).[/]"); + _console.WriteLine(); + + var target = string.IsNullOrWhiteSpace(settings.Target) ? "Default" : settings.Target; + _console.MarkupLine($"Running Build target [bold]{Markup.Escape(target)}[/]..."); + _console.WriteLine(); + + var extra = context.Remaining.Raw.ToArray(); + var exitCode = await _host.RunAsync(new BuildHostRequest + { + Repository = request, + Target = target, + Configuration = settings.Configuration, + ExtraArguments = extra + }); + + _console.WriteLine(); + if (exitCode == 0) + { + _console.MarkupLine($"[green]Build target[/] [bold]{Markup.Escape(target)}[/] [green]completed.[/]"); + return ExitCodes.Success; + } + + _console.MarkupLine($"[red]Build target[/] [bold]{Markup.Escape(target)}[/] [red]failed with exit code {exitCode}.[/]"); + return ExitCodes.BuildFailed; + } + + private static string DescribeRef(BuildSubmoduleStatus status) + { + if (status.CurrentTags.Count > 0) + { + return string.Join(", ", status.CurrentTags); + } + + return string.IsNullOrEmpty(status.Commit) ? "unknown" : ConsoleWriter.ShortSha(status.Commit); + } + + /// + /// Settings for . + /// + public sealed class Settings : RepositorySettings + { + [CommandArgument(0, "[TARGET]")] + [Description("The Cake target to run. Defaults to Default.")] + public string? Target { get; init; } + + [CommandOption("-c|--configuration ")] + [Description("The build configuration forwarded to the Build host.")] + public string? Configuration { get; init; } + } +} diff --git a/apps/Ingenium.BuildCli/Commands/ExtensionCommand.cs b/apps/Ingenium.BuildCli/Commands/ExtensionCommand.cs new file mode 100644 index 0000000..f9fbbba --- /dev/null +++ b/apps/Ingenium.BuildCli/Commands/ExtensionCommand.cs @@ -0,0 +1,67 @@ +// This work is licensed under the terms of the MIT license. +// For a copy, see . + +using System.ComponentModel; + +using Ingenium.BuildCli.Extensions; +using Ingenium.BuildCli.Git; +using Ingenium.BuildCli.Rendering; + +using Spectre.Console; +using Spectre.Console.Cli; + +namespace Ingenium.BuildCli.Commands; + +/// +/// Scaffolds a Cake build-extension project for the current repository. +/// +public sealed class ExtensionCommand : AsyncCommand +{ + private readonly IAnsiConsole _console; + private readonly IBuildExtensionService _extensions; + private readonly IGitTrace _trace; + + /// + /// Initializes a new instance of the class. + /// + public ExtensionCommand(IAnsiConsole console, IBuildExtensionService extensions, IGitTrace trace) + { + _console = console; + _extensions = extensions; + _trace = trace; + } + + /// + public override async Task ExecuteAsync(CommandContext context, Settings settings) + { + _trace.Enabled = settings.Verbose; + ConsoleWriter.WriteHeader(_console, "extension"); + + var scaffold = await _extensions.CreateAsync(new BuildExtensionRequest + { + RepositoryPath = string.IsNullOrWhiteSpace(settings.Path) ? Environment.CurrentDirectory : settings.Path, + Name = settings.Name, + SubmodulePath = settings.SubmodulePath, + Force = settings.Force + }); + + _console.MarkupLine($"[green]Created[/] build extension [bold]{Markup.Escape(scaffold.ProjectName)}[/]."); + _console.WriteLine(); + ConsoleWriter.WriteExtension(_console, scaffold); + return ExitCodes.Success; + } + + /// + /// Settings for . + /// + public sealed class Settings : RepositorySettings + { + [CommandArgument(0, "[NAME]")] + [Description("Extension name. Defaults to the repository folder name, with a BuildExtensions suffix.")] + public string? Name { get; init; } + + [CommandOption("-f|--force")] + [Description("Overwrite an existing extension project.")] + public bool Force { get; init; } + } +} diff --git a/apps/Ingenium.BuildCli/Commands/InitCommand.cs b/apps/Ingenium.BuildCli/Commands/InitCommand.cs new file mode 100644 index 0000000..c116e67 --- /dev/null +++ b/apps/Ingenium.BuildCli/Commands/InitCommand.cs @@ -0,0 +1,67 @@ +// This work is licensed under the terms of the MIT license. +// For a copy, see . + +using System.ComponentModel; + +using Ingenium.BuildCli.Git; +using Ingenium.BuildCli.Rendering; +using Ingenium.BuildCli.Submodule; + +using Spectre.Console; +using Spectre.Console.Cli; + +namespace Ingenium.BuildCli.Commands; + +/// +/// Adds the Ingenium Build submodule to a parent repository. +/// +public sealed class InitCommand : AsyncCommand +{ + private readonly IAnsiConsole _console; + private readonly IBuildSubmoduleService _service; + private readonly IGitTrace _trace; + + /// + /// Initializes a new instance of the class. + /// + public InitCommand(IAnsiConsole console, IBuildSubmoduleService service, IGitTrace trace) + { + _console = console; + _service = service; + _trace = trace; + } + + /// + public override async Task ExecuteAsync(CommandContext context, Settings settings) + { + _trace.Enabled = settings.Verbose; + ConsoleWriter.WriteHeader(_console, "init"); + + var request = settings.ToRequest(settings.Tag, settings.Force); + var change = await _console.Status() + .Spinner(Spinner.Known.Dots) + .StartAsync("Adding the Build submodule...", async _ => + await _service.InitAsync(request)); + + _console.MarkupLine(change.Added + ? "[green]Added[/] the Build submodule." + : "[green]Initialized[/] the existing Build submodule."); + _console.WriteLine(); + ConsoleWriter.WriteChange(_console, change); + return ExitCodes.Success; + } + + /// + /// Settings for . + /// + public sealed class Settings : RepositorySettings + { + [CommandOption("-t|--tag ")] + [Description("A tag, branch, or commit to check out. Defaults to the latest tag.")] + public string? Tag { get; init; } + + [CommandOption("-f|--force")] + [Description("Replace or re-initialize an existing Build submodule.")] + public bool Force { get; init; } + } +} diff --git a/apps/Ingenium.BuildCli/Commands/RepairCommand.cs b/apps/Ingenium.BuildCli/Commands/RepairCommand.cs new file mode 100644 index 0000000..45181df --- /dev/null +++ b/apps/Ingenium.BuildCli/Commands/RepairCommand.cs @@ -0,0 +1,131 @@ +// This work is licensed under the terms of the MIT license. +// For a copy, see . + +using System.ComponentModel; + +using Ingenium.BuildCli.Git; +using Ingenium.BuildCli.Rendering; +using Ingenium.BuildCli.Submodule; + +using Spectre.Console; +using Spectre.Console.Cli; + +namespace Ingenium.BuildCli.Commands; + +/// +/// Repairs a dirty or broken Build submodule. +/// +public sealed class RepairCommand : AsyncCommand +{ + private readonly IAnsiConsole _console; + private readonly IBuildSubmoduleService _service; + private readonly IGitTrace _trace; + + /// + /// Initializes a new instance of the class. + /// + public RepairCommand(IAnsiConsole console, IBuildSubmoduleService service, IGitTrace trace) + { + _console = console; + _service = service; + _trace = trace; + } + + /// + public override async Task ExecuteAsync(CommandContext context, Settings settings) + { + _trace.Enabled = settings.Verbose; + ConsoleWriter.WriteHeader(_console, "repair"); + + var strategy = ResolveStrategy(settings); + if (strategy is null) + { + return ExitCodes.Cancelled; + } + + if (!Confirm(settings, strategy.Value)) + { + _console.MarkupLine("[yellow]Repair cancelled.[/]"); + return ExitCodes.Cancelled; + } + + var request = settings.ToRequest(settings.Tag); + var result = await _console.Status() + .Spinner(Spinner.Known.Dots) + .StartAsync($"Repairing the Build submodule ({strategy.Value.ToString().ToLowerInvariant()})...", async _ => + await _service.RepairAsync(request, strategy.Value)); + + _console.MarkupLine("[green]Repaired[/] the Build submodule."); + _console.WriteLine(); + ConsoleWriter.WriteRepair(_console, result); + return ExitCodes.Success; + } + + private RepairStrategy? ResolveStrategy(Settings settings) + { + if (RepairStrategyParser.TryParse(settings.Strategy, out var parsed)) + { + return parsed; + } + + if (!string.IsNullOrWhiteSpace(settings.Strategy)) + { + throw new BuildCliException("Unknown repair strategy. Use stash, reset, or reinit."); + } + + if (!_console.Profile.Capabilities.Interactive) + { + throw new BuildCliException("A repair strategy is required. Use --strategy stash, reset, or reinit."); + } + + return _console.Prompt( + new SelectionPrompt() + .Title("How should the Build submodule be repaired?") + .AddChoices(RepairStrategy.Stash, RepairStrategy.Reset, RepairStrategy.Reinit) + .UseConverter(strategy => strategy switch + { + RepairStrategy.Stash => "stash — save local changes, then restore the parent-recorded commit", + RepairStrategy.Reset => "reset — discard local changes and restore the parent-recorded HEAD", + RepairStrategy.Reinit => "reinit — remove and clone the submodule again at a tagged version", + _ => strategy.ToString() + })); + } + + private bool Confirm(Settings settings, RepairStrategy strategy) + { + if (settings.Yes || strategy == RepairStrategy.Stash) + { + return true; + } + + if (!_console.Profile.Capabilities.Interactive) + { + throw new BuildCliException( + $"Refusing to run a destructive '{strategy.ToString().ToLowerInvariant()}' repair without --yes."); + } + + var message = strategy == RepairStrategy.Reset + ? "Discard local Build submodule changes and restore the parent-recorded HEAD?" + : "Delete and re-initialize the Build submodule at a tagged version?"; + + return _console.Confirm(message, defaultValue: false); + } + + /// + /// Settings for . + /// + public sealed class Settings : RepositorySettings + { + [CommandOption("-s|--strategy ")] + [Description("Repair strategy: stash, reset (alias: head), or reinit.")] + public string? Strategy { get; init; } + + [CommandOption("-t|--tag ")] + [Description("A tag, branch, or commit to check out after repair. Defaults to the parent HEAD for stash/reset, or the latest tag for reinit.")] + public string? Tag { get; init; } + + [CommandOption("-y|--yes")] + [Description("Do not prompt before a destructive reset or reinit.")] + public bool Yes { get; init; } + } +} diff --git a/apps/Ingenium.BuildCli/Commands/RepositorySettings.cs b/apps/Ingenium.BuildCli/Commands/RepositorySettings.cs new file mode 100644 index 0000000..64ff07e --- /dev/null +++ b/apps/Ingenium.BuildCli/Commands/RepositorySettings.cs @@ -0,0 +1,52 @@ +// This work is licensed under the terms of the MIT license. +// For a copy, see . + +using System.ComponentModel; + +using Ingenium.BuildCli.Submodule; + +using Spectre.Console.Cli; + +namespace Ingenium.BuildCli.Commands; + +/// +/// Shared options that identify the parent repository and Build submodule. +/// +public class RepositorySettings : CommandSettings +{ + [CommandOption("-p|--path ")] + [Description("Path to the parent git repository. Defaults to the current directory.")] + public string? Path { get; init; } + + [CommandOption("--submodule-path ")] + [Description("Relative path of the Build submodule. Defaults to an existing Build entry, or 'build'.")] + public string? SubmodulePath { get; init; } + + [CommandOption("--url ")] + [Description("Override the Build submodule URL.")] + public string? Url { get; init; } + + [CommandOption("--https")] + [Description("Use the HTTPS Build URL instead of SSH.")] + public bool UseHttps { get; init; } + + [CommandOption("--verbose")] + [Description("Write the git commands that are executed.")] + public bool Verbose { get; init; } + + /// + /// Creates a service request from these settings. + /// + public BuildSubmoduleRequest ToRequest(string? tag = null, bool force = false) + { + return new BuildSubmoduleRequest + { + RepositoryPath = string.IsNullOrWhiteSpace(Path) ? Environment.CurrentDirectory : Path, + SubmodulePath = SubmodulePath, + Url = Url, + UseHttps = UseHttps, + Tag = tag, + Force = force + }; + } +} diff --git a/apps/Ingenium.BuildCli/Commands/StatusCommand.cs b/apps/Ingenium.BuildCli/Commands/StatusCommand.cs new file mode 100644 index 0000000..583d53e --- /dev/null +++ b/apps/Ingenium.BuildCli/Commands/StatusCommand.cs @@ -0,0 +1,53 @@ +// This work is licensed under the terms of the MIT license. +// For a copy, see . + +using Ingenium.BuildCli.Git; +using Ingenium.BuildCli.Rendering; +using Ingenium.BuildCli.Submodule; + +using Spectre.Console; +using Spectre.Console.Cli; + +namespace Ingenium.BuildCli.Commands; + +/// +/// Shows the current Build submodule state. +/// +public sealed class StatusCommand : AsyncCommand +{ + private readonly IAnsiConsole _console; + private readonly IBuildSubmoduleService _service; + private readonly IGitTrace _trace; + + /// + /// Initializes a new instance of the class. + /// + public StatusCommand(IAnsiConsole console, IBuildSubmoduleService service, IGitTrace trace) + { + _console = console; + _service = service; + _trace = trace; + } + + /// + public override async Task ExecuteAsync(CommandContext context, Settings settings) + { + _trace.Enabled = settings.Verbose; + ConsoleWriter.WriteHeader(_console, "status"); + + var status = await _console.Status() + .Spinner(Spinner.Known.Dots) + .StartAsync("Reading Build submodule status...", async _ => + await _service.GetStatusAsync(settings.ToRequest())); + + ConsoleWriter.WriteStatus(_console, status); + return ExitCodes.Success; + } + + /// + /// Settings for . + /// + public sealed class Settings : RepositorySettings + { + } +} diff --git a/apps/Ingenium.BuildCli/Commands/TagsCommand.cs b/apps/Ingenium.BuildCli/Commands/TagsCommand.cs new file mode 100644 index 0000000..ad129e8 --- /dev/null +++ b/apps/Ingenium.BuildCli/Commands/TagsCommand.cs @@ -0,0 +1,65 @@ +// This work is licensed under the terms of the MIT license. +// For a copy, see . + +using Ingenium.BuildCli.Git; +using Ingenium.BuildCli.Rendering; +using Ingenium.BuildCli.Submodule; + +using Spectre.Console; +using Spectre.Console.Cli; + +namespace Ingenium.BuildCli.Commands; + +/// +/// Lists tags available on the Build remote. +/// +public sealed class TagsCommand : AsyncCommand +{ + private readonly IAnsiConsole _console; + private readonly IBuildSubmoduleService _service; + private readonly IGitTrace _trace; + + /// + /// Initializes a new instance of the class. + /// + public TagsCommand(IAnsiConsole console, IBuildSubmoduleService service, IGitTrace trace) + { + _console = console; + _service = service; + _trace = trace; + } + + /// + public override async Task ExecuteAsync(CommandContext context, Settings settings) + { + _trace.Enabled = settings.Verbose; + ConsoleWriter.WriteHeader(_console, "tags"); + + var request = settings.ToRequest(); + var tags = await _console.Status() + .Spinner(Spinner.Known.Dots) + .StartAsync("Listing Build tags...", async _ => + await _service.ListTagsAsync(request)); + + string? currentCommit = null; + try + { + var status = await _service.GetStatusAsync(request); + currentCommit = status.Commit; + } + catch (BuildCliException) + { + // Listing tags should still work outside a git repository. + } + + ConsoleWriter.WriteTags(_console, tags, currentCommit); + return ExitCodes.Success; + } + + /// + /// Settings for . + /// + public sealed class Settings : RepositorySettings + { + } +} diff --git a/apps/Ingenium.BuildCli/Commands/UpdateCommand.cs b/apps/Ingenium.BuildCli/Commands/UpdateCommand.cs new file mode 100644 index 0000000..87a63db --- /dev/null +++ b/apps/Ingenium.BuildCli/Commands/UpdateCommand.cs @@ -0,0 +1,73 @@ +// This work is licensed under the terms of the MIT license. +// For a copy, see . + +using System.ComponentModel; + +using Ingenium.BuildCli.Git; +using Ingenium.BuildCli.Rendering; +using Ingenium.BuildCli.Submodule; + +using Spectre.Console; +using Spectre.Console.Cli; + +namespace Ingenium.BuildCli.Commands; + +/// +/// Updates the Build submodule to the latest tag or a specific ref. +/// +public sealed class UpdateCommand : AsyncCommand +{ + private readonly IAnsiConsole _console; + private readonly IBuildSubmoduleService _service; + private readonly IGitTrace _trace; + + /// + /// Initializes a new instance of the class. + /// + public UpdateCommand(IAnsiConsole console, IBuildSubmoduleService service, IGitTrace trace) + { + _console = console; + _service = service; + _trace = trace; + } + + /// + public override async Task ExecuteAsync(CommandContext context, Settings settings) + { + _trace.Enabled = settings.Verbose; + ConsoleWriter.WriteHeader(_console, "update"); + + var request = settings.ToRequest(settings.Tag); + var change = await _console.Status() + .Spinner(Spinner.Known.Dots) + .StartAsync( + string.IsNullOrWhiteSpace(settings.Tag) + ? "Updating the Build submodule to the latest tag..." + : $"Updating the Build submodule to {settings.Tag}...", + async _ => await _service.UpdateAsync(request)); + + if (!string.IsNullOrEmpty(change.PreviousCommit) && + string.Equals(change.PreviousCommit, change.Commit, StringComparison.OrdinalIgnoreCase)) + { + _console.MarkupLine("[green]Build submodule is already at the requested ref.[/]"); + } + else + { + _console.MarkupLine("[green]Updated[/] the Build submodule."); + } + + _console.WriteLine(); + ConsoleWriter.WriteChange(_console, change); + return ExitCodes.Success; + } + + /// + /// Settings for . + /// + public sealed class Settings : RepositorySettings + { + [CommandOption("-t|--tag ")] + [Description("A tag, branch, or commit to check out. Defaults to the latest tag.")] + public string? Tag { get; init; } + } +} diff --git a/apps/Ingenium.BuildCli/Extensions/BuildExtensionNames.cs b/apps/Ingenium.BuildCli/Extensions/BuildExtensionNames.cs new file mode 100644 index 0000000..8b9a2b5 --- /dev/null +++ b/apps/Ingenium.BuildCli/Extensions/BuildExtensionNames.cs @@ -0,0 +1,61 @@ +// This work is licensed under the terms of the MIT license. +// For a copy, see . + +using System.Globalization; +using System.Text; + +namespace Ingenium.BuildCli.Extensions; + +/// +/// Converts repository or user-supplied names into *BuildExtensions project names. +/// +public static class BuildExtensionNames +{ + /// + /// The folder the Build host imports via a wildcard project reference. + /// + public const string FolderName = "build-extensions"; + + /// + /// Returns a project name that ends with BuildExtensions. + /// + public static string ToProjectName(string name) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + + var pascal = ToPascalIdentifier(name); + if (pascal.EndsWith("BuildExtensions", StringComparison.OrdinalIgnoreCase)) + { + return pascal; + } + + return pascal + "BuildExtensions"; + } + + /// + /// Converts an arbitrary name into a PascalCase identifier. + /// + public static string ToPascalIdentifier(string name) + { + var builder = new StringBuilder(); + var startWord = true; + foreach (var ch in name.Trim()) + { + if (!char.IsLetterOrDigit(ch)) + { + startWord = true; + continue; + } + + if (builder.Length == 0 && char.IsDigit(ch)) + { + continue; + } + + builder.Append(startWord ? char.ToUpper(ch, CultureInfo.InvariantCulture) : ch); + startWord = false; + } + + return builder.Length == 0 ? "Repo" : builder.ToString(); + } +} diff --git a/apps/Ingenium.BuildCli/Extensions/BuildExtensionScaffold.cs b/apps/Ingenium.BuildCli/Extensions/BuildExtensionScaffold.cs new file mode 100644 index 0000000..3abd92a --- /dev/null +++ b/apps/Ingenium.BuildCli/Extensions/BuildExtensionScaffold.cs @@ -0,0 +1,56 @@ +// This work is licensed under the terms of the MIT license. +// For a copy, see . + +namespace Ingenium.BuildCli.Extensions; + +/// +/// Describes the files created for a Build extension project. +/// +public sealed class BuildExtensionScaffold +{ + /// + /// Gets the parent repository root. + /// + public required string RepositoryRoot { get; init; } + + /// + /// Gets the generated project name. + /// + public required string ProjectName { get; init; } + + /// + /// Gets the project directory. + /// + public required string ProjectDirectory { get; init; } + + /// + /// Gets the files that were written. + /// + public IReadOnlyList WrittenFiles { get; init; } = []; +} + +/// +/// Options for scaffolding a Build extension. +/// +public sealed class BuildExtensionRequest +{ + /// + /// Gets the parent repository path. + /// + public string RepositoryPath { get; init; } = Environment.CurrentDirectory; + + /// + /// Gets the extension name. When omitted, the repository folder name is used. + /// + public string? Name { get; init; } + + /// + /// Gets an optional Build submodule path used for project references. + /// + public string? SubmodulePath { get; init; } + + /// + /// Gets a value indicating whether an existing extension project may be replaced. + /// + public bool Force { get; init; } +} diff --git a/apps/Ingenium.BuildCli/Extensions/BuildExtensionService.cs b/apps/Ingenium.BuildCli/Extensions/BuildExtensionService.cs new file mode 100644 index 0000000..8f73b45 --- /dev/null +++ b/apps/Ingenium.BuildCli/Extensions/BuildExtensionService.cs @@ -0,0 +1,106 @@ +// This work is licensed under the terms of the MIT license. +// For a copy, see . + +using Ingenium.BuildCli.Git; +using Ingenium.BuildCli.Submodule; + +namespace Ingenium.BuildCli.Extensions; + +/// +/// Writes the build-extensions project layout imported by apps/Build/Build.csproj. +/// +public sealed class BuildExtensionService : IBuildExtensionService +{ + private readonly IGitClient _git; + + /// + /// Initializes a new instance of the class. + /// + public BuildExtensionService(IGitClient git) + { + _git = git; + } + + /// + public async Task CreateAsync(BuildExtensionRequest request, CancellationToken cancellationToken = default) + { + if (!_git.IsGitAvailable()) + { + throw new BuildCliException("git was not found on PATH. Install Git and try again.", ExitCodes.GitNotFound); + } + + var root = await _git.GetRepositoryRootAsync(request.RepositoryPath, cancellationToken); + var submodulePath = ResolveSubmodulePath(root, request.SubmodulePath); + var projectName = BuildExtensionNames.ToProjectName(request.Name ?? Path.GetFileName(root.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar))); + var extensionsRoot = Path.Combine(root, BuildExtensionNames.FolderName); + var projectDirectory = Path.Combine(extensionsRoot, projectName); + var projectFile = Path.Combine(projectDirectory, projectName + ".csproj"); + + if (File.Exists(projectFile) && !request.Force) + { + throw new BuildCliException( + $"A build extension already exists at '{Path.Combine(BuildExtensionNames.FolderName, projectName)}'. Use --force to replace it.", + ExitCodes.AlreadyExists); + } + + Directory.CreateDirectory(projectDirectory); + + var written = new List(); + WriteIfMissingOrForced( + Path.Combine(extensionsRoot, "Directory.Build.props"), + BuildExtensionTemplates.DirectoryBuildProps(submodulePath), + overwrite: false, + written); + WriteIfMissingOrForced( + Path.Combine(extensionsRoot, "Directory.Build.targets"), + BuildExtensionTemplates.DirectoryBuildTargets(submodulePath), + overwrite: false, + written); + WriteIfMissingOrForced( + projectFile, + BuildExtensionTemplates.Project(submodulePath), + overwrite: request.Force, + written); + WriteIfMissingOrForced( + Path.Combine(projectDirectory, "SampleTask.cs"), + BuildExtensionTemplates.SampleTask(projectName), + overwrite: request.Force, + written); + + return new BuildExtensionScaffold + { + RepositoryRoot = root, + ProjectName = projectName, + ProjectDirectory = projectDirectory, + WrittenFiles = written + }; + } + + private static string ResolveSubmodulePath(string repositoryRoot, string? requested) + { + if (!string.IsNullOrWhiteSpace(requested)) + { + return requested.Replace('\\', '/').Trim('/'); + } + + var entries = GitmodulesParser.ParseFile(Path.Combine(repositoryRoot, ".gitmodules")); + var build = entries.FirstOrDefault(entry => BuildRepositoryUrls.IsBuildRepository(entry.Url)) + ?? entries.FirstOrDefault(entry => + entry.Path.Equals(BuildRepositoryUrls.DefaultPath, StringComparison.OrdinalIgnoreCase) + || entry.Path.Equals("Build", StringComparison.Ordinal)); + + return build?.Path ?? BuildRepositoryUrls.DefaultPath; + } + + private static void WriteIfMissingOrForced(string path, string contents, bool overwrite, List written) + { + if (File.Exists(path) && !overwrite) + { + return; + } + + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, contents); + written.Add(path); + } +} diff --git a/apps/Ingenium.BuildCli/Extensions/BuildExtensionTemplates.cs b/apps/Ingenium.BuildCli/Extensions/BuildExtensionTemplates.cs new file mode 100644 index 0000000..fb9e441 --- /dev/null +++ b/apps/Ingenium.BuildCli/Extensions/BuildExtensionTemplates.cs @@ -0,0 +1,111 @@ +// This work is licensed under the terms of the MIT license. +// For a copy, see . + +namespace Ingenium.BuildCli.Extensions; + +/// +/// Source templates for a Build extension project. +/// +public static class BuildExtensionTemplates +{ + /// + /// Returns build-extensions/Directory.Build.props content. + /// + public static string DirectoryBuildProps(string submodulePath) + { + var import = $"../{ToGitPath(submodulePath)}/apps/Directory.Build.props"; + return $""" + + + + + """; + } + + /// + /// Returns build-extensions/Directory.Build.targets content. + /// + public static string DirectoryBuildTargets(string submodulePath) + { + var import = $"../{ToGitPath(submodulePath)}/apps/Directory.Build.targets"; + return $""" + + + + + """; + } + + /// + /// Returns the extension .csproj content. + /// + public static string Project(string submodulePath) + { + var reference = $@"..\..\{ToGitPath(submodulePath).Replace('/', '\\')}\apps\Build.Abstractions\Build.Abstractions.csproj"; + return $""" + + + + net8.0 + + + + + + + + + """; + } + + /// + /// Returns a sample Cake task that the Build host will discover. + /// + public static string SampleTask(string projectName) + { + var taskName = projectName.EndsWith("BuildExtensions", StringComparison.Ordinal) + ? projectName[..^"BuildExtensions".Length] + : projectName; + + if (string.IsNullOrEmpty(taskName)) + { + taskName = "Sample"; + } + + return $$""" + // This work is licensed under the terms of the MIT license. + // For a copy, see . + + namespace {{projectName}} + { + using Build; + + using Cake.Frosting; + + /// + /// A sample task discovered by the Ingenium Build host. + /// + [TaskName("{{taskName}}")] + public sealed class SampleTask : BuildTask + { + public SampleTask(BuildServices services) + : base(services) + { + } + + /// + protected override void RunCore(BuildContext context) + { + context.Log.Information("Hello from the {{projectName}} build extension."); + } + } + } + + """; + } + + private static string ToGitPath(string path) + { + return path.Replace('\\', '/').Trim('/'); + } +} diff --git a/apps/Ingenium.BuildCli/Extensions/IBuildExtensionService.cs b/apps/Ingenium.BuildCli/Extensions/IBuildExtensionService.cs new file mode 100644 index 0000000..089e09a --- /dev/null +++ b/apps/Ingenium.BuildCli/Extensions/IBuildExtensionService.cs @@ -0,0 +1,15 @@ +// This work is licensed under the terms of the MIT license. +// For a copy, see . + +namespace Ingenium.BuildCli.Extensions; + +/// +/// Scaffolds a Cake build-extension project in the layout expected by the Build submodule. +/// +public interface IBuildExtensionService +{ + /// + /// Creates build-extensions/{Name}BuildExtensions and the shared Directory.Build files. + /// + Task CreateAsync(BuildExtensionRequest request, CancellationToken cancellationToken = default); +} diff --git a/apps/Ingenium.BuildCli/Git/AnsiConsoleGitTrace.cs b/apps/Ingenium.BuildCli/Git/AnsiConsoleGitTrace.cs new file mode 100644 index 0000000..4c79bc7 --- /dev/null +++ b/apps/Ingenium.BuildCli/Git/AnsiConsoleGitTrace.cs @@ -0,0 +1,36 @@ +// This work is licensed under the terms of the MIT license. +// For a copy, see . + +using Spectre.Console; + +namespace Ingenium.BuildCli.Git; + +/// +/// Writes git diagnostics to the console when verbose mode is enabled. +/// +public sealed class AnsiConsoleGitTrace : IGitTrace +{ + private readonly IAnsiConsole _console; + + /// + /// Initializes a new instance of the class. + /// + public AnsiConsoleGitTrace(IAnsiConsole console) + { + _console = console; + } + + /// + public bool Enabled { get; set; } + + /// + public void Write(string message) + { + if (!Enabled || string.IsNullOrWhiteSpace(message)) + { + return; + } + + _console.MarkupLine($"[grey]{Markup.Escape(message)}[/]"); + } +} diff --git a/apps/Ingenium.BuildCli/Git/GitClient.cs b/apps/Ingenium.BuildCli/Git/GitClient.cs new file mode 100644 index 0000000..cb85554 --- /dev/null +++ b/apps/Ingenium.BuildCli/Git/GitClient.cs @@ -0,0 +1,212 @@ +// This work is licensed under the terms of the MIT license. +// For a copy, see . + +using System.Diagnostics; +using System.Text; + +namespace Ingenium.BuildCli.Git; + +/// +/// Invokes the git executable as a child process. +/// +public sealed class GitClient : IGitClient +{ + private readonly string? _gitExecutable; + private readonly IReadOnlyList _globalArguments; + private readonly IGitTrace? _trace; + + /// + /// Initializes a new instance of the class. + /// + /// + /// An optional explicit path to git. When omitted, git is resolved from PATH. + /// + /// + /// Optional arguments inserted before every git command, such as -c protocol.file.allow=always. + /// + /// Optional diagnostic sink used when verbose mode is enabled. + public GitClient(string? gitExecutable = null, IEnumerable? globalArguments = null, IGitTrace? trace = null) + { + _gitExecutable = gitExecutable; + _globalArguments = globalArguments?.ToArray() ?? []; + _trace = trace; + } + + /// + public bool IsGitAvailable() + { + try + { + using var process = Start(Environment.CurrentDirectory, ["--version"]); + process.WaitForExit(5000); + return process.ExitCode == 0; + } + catch (Exception ex) when (ex is System.ComponentModel.Win32Exception or FileNotFoundException or InvalidOperationException) + { + return false; + } + } + + /// + public async Task GetRepositoryRootAsync(string path, CancellationToken cancellationToken = default) + { + var workingDirectory = ResolveWorkingDirectory(path); + var result = await RunAsync(workingDirectory, ["rev-parse", "--show-toplevel"], cancellationToken); + if (!result.IsSuccess) + { + throw new BuildCliException( + $"'{path}' is not inside a git repository.", + ExitCodes.NotAGitRepository); + } + + var root = result.StandardOutput.Trim(); + if (string.IsNullOrWhiteSpace(root)) + { + throw new BuildCliException( + $"'{path}' is not inside a git repository.", + ExitCodes.NotAGitRepository); + } + + return Path.GetFullPath(root); + } + + /// + public async Task RunAsync( + string workingDirectory, + IReadOnlyList arguments, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(workingDirectory); + ArgumentNullException.ThrowIfNull(arguments); + + if (!Directory.Exists(workingDirectory)) + { + throw new BuildCliException($"Working directory '{workingDirectory}' does not exist."); + } + + using var process = Start(workingDirectory, arguments); + var stdoutTask = process.StandardOutput.ReadToEndAsync(cancellationToken); + var stderrTask = process.StandardError.ReadToEndAsync(cancellationToken); + + try + { + await process.WaitForExitAsync(cancellationToken); + } + catch (OperationCanceledException) + { + TryKill(process); + throw; + } + + var stdout = await stdoutTask; + var stderr = await stderrTask; + var result = new GitResult(process.ExitCode, stdout, stderr, FormatCommand(arguments)); + _trace?.Write($"$ {result.Command}"); + if (!string.IsNullOrWhiteSpace(result.StandardOutput)) + { + _trace?.Write(result.StandardOutput.TrimEnd()); + } + + if (!string.IsNullOrWhiteSpace(result.StandardError)) + { + _trace?.Write(result.StandardError.TrimEnd()); + } + + return result; + } + + /// + public async Task RunRequiredAsync( + string workingDirectory, + IReadOnlyList arguments, + string failureMessage, + int exitCode = ExitCodes.GeneralError, + CancellationToken cancellationToken = default) + { + var result = await RunAsync(workingDirectory, arguments, cancellationToken); + if (!result.IsSuccess) + { + throw new BuildCliException($"{failureMessage}{Environment.NewLine}{result.ErrorMessage}", exitCode); + } + + return result; + } + + private Process Start(string workingDirectory, IReadOnlyList arguments) + { + var startInfo = new ProcessStartInfo + { + FileName = _gitExecutable ?? "git", + WorkingDirectory = workingDirectory, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + StandardOutputEncoding = Encoding.UTF8, + StandardErrorEncoding = Encoding.UTF8 + }; + + foreach (var argument in _globalArguments) + { + startInfo.ArgumentList.Add(argument); + } + + foreach (var argument in arguments) + { + startInfo.ArgumentList.Add(argument); + } + + // Keep git non-interactive so the CLI never hangs waiting for a prompt. + startInfo.Environment["GIT_TERMINAL_PROMPT"] = "0"; + startInfo.Environment["GCM_INTERACTIVE"] = "never"; + + var process = new Process { StartInfo = startInfo }; + if (!process.Start()) + { + throw new BuildCliException("Failed to start git.", ExitCodes.GitNotFound); + } + + return process; + } + + private static string ResolveWorkingDirectory(string path) + { + var fullPath = Path.GetFullPath(path); + if (Directory.Exists(fullPath)) + { + return fullPath; + } + + var directory = Path.GetDirectoryName(fullPath); + if (!string.IsNullOrEmpty(directory) && Directory.Exists(directory)) + { + return directory; + } + + throw new BuildCliException($"Path '{path}' does not exist."); + } + + private static string FormatCommand(IReadOnlyList arguments) + { + return "git " + string.Join(' ', arguments.Select(QuoteIfNeeded)); + } + + private static string QuoteIfNeeded(string value) + { + return value.Contains(' ', StringComparison.Ordinal) ? $"\"{value}\"" : value; + } + + private static void TryKill(Process process) + { + try + { + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + } + } + catch (InvalidOperationException) + { + } + } +} diff --git a/apps/Ingenium.BuildCli/Git/GitResult.cs b/apps/Ingenium.BuildCli/Git/GitResult.cs new file mode 100644 index 0000000..2fdbe45 --- /dev/null +++ b/apps/Ingenium.BuildCli/Git/GitResult.cs @@ -0,0 +1,41 @@ +// This work is licensed under the terms of the MIT license. +// For a copy, see . + +namespace Ingenium.BuildCli.Git; + +/// +/// The captured result of a git process invocation. +/// +/// The process exit code. +/// Captured standard output. +/// Captured standard error. +/// The command line that was executed. +public sealed record GitResult( + int ExitCode, + string StandardOutput, + string StandardError, + string Command) +{ + /// + /// Gets a value indicating whether the process exited successfully. + /// + public bool IsSuccess => ExitCode == 0; + + /// + /// Gets a trimmed combined error message suitable for display. + /// + public string ErrorMessage + { + get + { + var error = StandardError.Trim(); + if (!string.IsNullOrEmpty(error)) + { + return error; + } + + var output = StandardOutput.Trim(); + return string.IsNullOrEmpty(output) ? $"git exited with code {ExitCode}." : output; + } + } +} diff --git a/apps/Ingenium.BuildCli/Git/GitmoduleEntry.cs b/apps/Ingenium.BuildCli/Git/GitmoduleEntry.cs new file mode 100644 index 0000000..a98cf22 --- /dev/null +++ b/apps/Ingenium.BuildCli/Git/GitmoduleEntry.cs @@ -0,0 +1,13 @@ +// This work is licensed under the terms of the MIT license. +// For a copy, see . + +namespace Ingenium.BuildCli.Git; + +/// +/// A single submodule recorded in .gitmodules. +/// +/// The submodule name. +/// The path relative to the repository root. +/// The remote URL, when present. +/// The tracked branch, when present. +public sealed record GitmoduleEntry(string Name, string Path, string? Url, string? Branch); diff --git a/apps/Ingenium.BuildCli/Git/GitmodulesParser.cs b/apps/Ingenium.BuildCli/Git/GitmodulesParser.cs new file mode 100644 index 0000000..50852e2 --- /dev/null +++ b/apps/Ingenium.BuildCli/Git/GitmodulesParser.cs @@ -0,0 +1,107 @@ +// This work is licensed under the terms of the MIT license. +// For a copy, see . + +using System.Text.RegularExpressions; + +namespace Ingenium.BuildCli.Git; + +/// +/// Parses a .gitmodules file into submodule entries. +/// +public static class GitmodulesParser +{ + private static readonly Regex SectionRegex = new( + @"^\[submodule\s+""(?[^""]+)""\]\s*$", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + + private static readonly Regex PropertyRegex = new( + @"^\s*(?[A-Za-z0-9_-]+)\s*=\s*(?.+?)\s*$", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + + /// + /// Parses the supplied .gitmodules text. + /// + public static IReadOnlyList Parse(string contents) + { + ArgumentNullException.ThrowIfNull(contents); + + var entries = new List(); + string? name = null; + string? path = null; + string? url = null; + string? branch = null; + + void Flush() + { + if (name is null) + { + return; + } + + entries.Add(new GitmoduleEntry(name, path ?? name, url, branch)); + name = null; + path = null; + url = null; + branch = null; + } + + using var reader = new StringReader(contents); + while (reader.ReadLine() is { } line) + { + if (string.IsNullOrWhiteSpace(line) || line.TrimStart().StartsWith('#')) + { + continue; + } + + var section = SectionRegex.Match(line); + if (section.Success) + { + Flush(); + name = section.Groups["name"].Value; + continue; + } + + if (name is null) + { + continue; + } + + var property = PropertyRegex.Match(line); + if (!property.Success) + { + continue; + } + + var key = property.Groups["key"].Value; + var value = property.Groups["value"].Value; + if (key.Equals("path", StringComparison.OrdinalIgnoreCase)) + { + path = value; + } + else if (key.Equals("url", StringComparison.OrdinalIgnoreCase)) + { + url = value; + } + else if (key.Equals("branch", StringComparison.OrdinalIgnoreCase)) + { + branch = value; + } + } + + Flush(); + return entries; + } + + /// + /// Parses a .gitmodules file from disk. Returns an empty list when the file is missing. + /// + public static IReadOnlyList ParseFile(string gitmodulesPath) + { + if (!File.Exists(gitmodulesPath)) + { + return []; + } + + return Parse(File.ReadAllText(gitmodulesPath)); + } +} diff --git a/apps/Ingenium.BuildCli/Git/IGitClient.cs b/apps/Ingenium.BuildCli/Git/IGitClient.cs new file mode 100644 index 0000000..39041bb --- /dev/null +++ b/apps/Ingenium.BuildCli/Git/IGitClient.cs @@ -0,0 +1,41 @@ +// This work is licensed under the terms of the MIT license. +// For a copy, see . + +namespace Ingenium.BuildCli.Git; + +/// +/// Runs git commands and inspects the local environment. +/// +public interface IGitClient +{ + /// + /// Returns true when a git executable can be located. + /// + bool IsGitAvailable(); + + /// + /// Resolves the git repository root that contains . + /// + /// A file or directory inside the repository. + /// A token used to cancel the operation. + /// The absolute repository root path. + Task GetRepositoryRootAsync(string path, CancellationToken cancellationToken = default); + + /// + /// Runs git in with the supplied arguments. + /// + Task RunAsync( + string workingDirectory, + IReadOnlyList arguments, + CancellationToken cancellationToken = default); + + /// + /// Runs git and throws when the command fails. + /// + Task RunRequiredAsync( + string workingDirectory, + IReadOnlyList arguments, + string failureMessage, + int exitCode = ExitCodes.GeneralError, + CancellationToken cancellationToken = default); +} diff --git a/apps/Ingenium.BuildCli/Git/IGitTrace.cs b/apps/Ingenium.BuildCli/Git/IGitTrace.cs new file mode 100644 index 0000000..8d2c99d --- /dev/null +++ b/apps/Ingenium.BuildCli/Git/IGitTrace.cs @@ -0,0 +1,20 @@ +// This work is licensed under the terms of the MIT license. +// For a copy, see . + +namespace Ingenium.BuildCli.Git; + +/// +/// Optional diagnostic output for git invocations. +/// +public interface IGitTrace +{ + /// + /// Gets or sets a value indicating whether git commands should be written. + /// + bool Enabled { get; set; } + + /// + /// Writes a diagnostic line. + /// + void Write(string message); +} diff --git a/apps/Ingenium.BuildCli/Host/BuildHostService.cs b/apps/Ingenium.BuildCli/Host/BuildHostService.cs new file mode 100644 index 0000000..30fc81d --- /dev/null +++ b/apps/Ingenium.BuildCli/Host/BuildHostService.cs @@ -0,0 +1,145 @@ +// This work is licensed under the terms of the MIT license. +// For a copy, see . + +using Ingenium.BuildCli.Execution; +using Ingenium.BuildCli.Submodule; + +namespace Ingenium.BuildCli.Host; + +/// +/// Locates apps/Build in the submodule and runs it with dotnet. +/// +public sealed class BuildHostService : IBuildHostService +{ + private readonly IBuildSubmoduleService _submodules; + private readonly IProcessRunner _processes; + + /// + /// Initializes a new instance of the class. + /// + public BuildHostService(IBuildSubmoduleService submodules, IProcessRunner processes) + { + _submodules = submodules; + _processes = processes; + } + + /// + public async Task RunAsync(BuildHostRequest request, CancellationToken cancellationToken = default) + { + if (!_processes.IsAvailable("dotnet")) + { + throw new BuildCliException( + "dotnet was not found on PATH. Install the .NET SDK and try again.", + ExitCodes.BuildFailed); + } + + var status = await _submodules.GetStatusAsync(request.Repository, cancellationToken); + BuildSubmoduleGuard.EnsureInitialized(status); + if (string.IsNullOrWhiteSpace(status.RelativePath)) + { + throw new BuildCliException( + $"The Build submodule path could not be resolved. Run '{CliInfo.Name} init' first.", + ExitCodes.SubmoduleNotFound); + } + + var submoduleRoot = Path.Combine(status.RepositoryRoot, status.RelativePath.Replace('/', Path.DirectorySeparatorChar)); + var project = FindBuildProject(submoduleRoot); + var arguments = BuildHostArguments.Create(request.Target, request.Configuration, request.ExtraArguments); + + await RestoreToolsAsync(submoduleRoot, cancellationToken); + + var result = await _processes.RunAsync( + "dotnet", + arguments.Prepend(project).Prepend("--project").Prepend("run").ToArray(), + submoduleRoot, + inheritOutput: true, + cancellationToken); + + return result.ExitCode; + } + + /// + /// Finds the Cake host project under apps/Build. + /// + public static string FindBuildProject(string submoduleRoot) + { + var apps = Path.Combine(submoduleRoot, "apps"); + if (Directory.Exists(apps)) + { + var matches = Directory.GetFiles(apps, "Build.csproj", SearchOption.AllDirectories); + var preferred = matches.FirstOrDefault(path => + string.Equals(Path.GetFileName(Path.GetDirectoryName(path)), "Build", StringComparison.OrdinalIgnoreCase)); + if (preferred is not null) + { + return preferred; + } + + if (matches.Length > 0) + { + return matches[0]; + } + } + + throw new BuildCliException( + $"Could not find apps/Build/Build.csproj under '{submoduleRoot}'.", + ExitCodes.SubmoduleNotFound); + } + + private async Task RestoreToolsAsync(string submoduleRoot, CancellationToken cancellationToken) + { + var manifest = Path.Combine(submoduleRoot, ".config", "dotnet-tools.json"); + if (!File.Exists(manifest)) + { + return; + } + + var result = await _processes.RunAsync( + "dotnet", + ["tool", "restore"], + submoduleRoot, + inheritOutput: true, + cancellationToken); + + if (!result.IsSuccess) + { + throw new BuildCliException( + "dotnet tool restore failed in the Build submodule.", + ExitCodes.BuildFailed); + } + } +} + +/// +/// Builds the dotnet run argument list for the Cake host. +/// +public static class BuildHostArguments +{ + /// + /// Creates Cake host arguments, omitting a duplicate --target when extra arguments already supply one. + /// + public static IReadOnlyList Create(string? target, string? configuration, IReadOnlyList? extraArguments) + { + var extras = extraArguments ?? []; + var args = new List { "--no-launch-profile", "--" }; + + if (!HasSwitch(extras, "--target") && !HasSwitch(extras, "-t")) + { + args.Add("--target"); + args.Add(string.IsNullOrWhiteSpace(target) ? "Default" : target); + } + + if (!string.IsNullOrWhiteSpace(configuration) && !HasSwitch(extras, "--configuration")) + { + args.Add("--configuration"); + args.Add(configuration); + } + + args.AddRange(extras); + return args; + } + + private static bool HasSwitch(IReadOnlyList arguments, string name) + { + return arguments.Any(argument => argument.Equals(name, StringComparison.OrdinalIgnoreCase)); + } +} diff --git a/apps/Ingenium.BuildCli/Host/IBuildHostService.cs b/apps/Ingenium.BuildCli/Host/IBuildHostService.cs new file mode 100644 index 0000000..b673231 --- /dev/null +++ b/apps/Ingenium.BuildCli/Host/IBuildHostService.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.Host; + +/// +/// Runs the Cake Frosting host inside the Build submodule. +/// +public interface IBuildHostService +{ + /// + /// Invokes the Build host with the supplied Cake target and extra arguments. + /// + /// The process exit code from dotnet run. + Task RunAsync(BuildHostRequest request, CancellationToken cancellationToken = default); +} + +/// +/// Options for invoking the Build submodule host. +/// +public sealed class BuildHostRequest +{ + /// + /// Gets the parent repository and submodule location. + /// + public required BuildSubmoduleRequest Repository { get; init; } + + /// + /// Gets the Cake target to run. Defaults to Default. + /// + public string Target { get; init; } = "Default"; + + /// + /// Gets an optional Cake/MSBuild configuration. + /// + public string? Configuration { get; init; } + + /// + /// Gets additional arguments forwarded to the Build host after --. + /// + public IReadOnlyList ExtraArguments { get; init; } = []; +} diff --git a/apps/Ingenium.BuildCli/Infrastructure/TypeRegistrar.cs b/apps/Ingenium.BuildCli/Infrastructure/TypeRegistrar.cs new file mode 100644 index 0000000..c2e715c --- /dev/null +++ b/apps/Ingenium.BuildCli/Infrastructure/TypeRegistrar.cs @@ -0,0 +1,49 @@ +// This work is licensed under the terms of the MIT license. +// For a copy, see . + +using Microsoft.Extensions.DependencyInjection; + +using Spectre.Console.Cli; + +namespace Ingenium.BuildCli.Infrastructure; + +/// +/// Adapts to Spectre.Console.Cli's type registrar. +/// +public sealed class TypeRegistrar : ITypeRegistrar +{ + private readonly IServiceCollection _services; + + /// + /// Initializes a new instance of the class. + /// + /// The service collection to populate. + public TypeRegistrar(IServiceCollection services) + { + _services = services; + } + + /// + public ITypeResolver Build() + { + return new TypeResolver(_services.BuildServiceProvider()); + } + + /// + public void Register(Type service, Type implementation) + { + _services.AddSingleton(service, implementation); + } + + /// + public void RegisterInstance(Type service, object implementation) + { + _services.AddSingleton(service, implementation); + } + + /// + public void RegisterLazy(Type service, Func factory) + { + _services.AddSingleton(service, _ => factory()); + } +} diff --git a/apps/Ingenium.BuildCli/Infrastructure/TypeResolver.cs b/apps/Ingenium.BuildCli/Infrastructure/TypeResolver.cs new file mode 100644 index 0000000..99dc4db --- /dev/null +++ b/apps/Ingenium.BuildCli/Infrastructure/TypeResolver.cs @@ -0,0 +1,40 @@ +// This work is licensed under the terms of the MIT license. +// For a copy, see . + +using Microsoft.Extensions.DependencyInjection; + +using Spectre.Console.Cli; + +namespace Ingenium.BuildCli.Infrastructure; + +/// +/// Resolves command and service instances from a built service provider. +/// +public sealed class TypeResolver : ITypeResolver, IDisposable +{ + private readonly IServiceProvider _provider; + + /// + /// Initializes a new instance of the class. + /// + /// The root service provider. + public TypeResolver(IServiceProvider provider) + { + _provider = provider; + } + + /// + public object? Resolve(Type? type) + { + return type is null ? null : _provider.GetService(type); + } + + /// + public void Dispose() + { + if (_provider is IDisposable disposable) + { + disposable.Dispose(); + } + } +} diff --git a/apps/Ingenium.BuildCli/Ingenium.BuildCli.csproj b/apps/Ingenium.BuildCli/Ingenium.BuildCli.csproj new file mode 100644 index 0000000..dbe63ff --- /dev/null +++ b/apps/Ingenium.BuildCli/Ingenium.BuildCli.csproj @@ -0,0 +1,23 @@ + + + + Exe + net8.0 + bld + Ingenium.BuildCli + CLI for adding and updating the Ingenium Build git submodule. + true + bld + Ingenium.BuildCli + ingenium;build;git;submodule;cli + 0.1.0 + LatestMajor + + + + + + + + + diff --git a/apps/Ingenium.BuildCli/Process/IProcessRunner.cs b/apps/Ingenium.BuildCli/Process/IProcessRunner.cs new file mode 100644 index 0000000..a61c1c6 --- /dev/null +++ b/apps/Ingenium.BuildCli/Process/IProcessRunner.cs @@ -0,0 +1,28 @@ +// This work is licensed under the terms of the MIT license. +// For a copy, see . + +namespace Ingenium.BuildCli.Execution; + +/// +/// Runs child processes used by the CLI. +/// +public interface IProcessRunner +{ + /// + /// Returns true when can be started. + /// + bool IsAvailable(string fileName); + + /// + /// Runs a process in . + /// + /// + /// When true, the child process writes directly to the current console. + /// + Task RunAsync( + string fileName, + IReadOnlyList arguments, + string workingDirectory, + bool inheritOutput, + CancellationToken cancellationToken = default); +} diff --git a/apps/Ingenium.BuildCli/Process/ProcessRunResult.cs b/apps/Ingenium.BuildCli/Process/ProcessRunResult.cs new file mode 100644 index 0000000..397e81a --- /dev/null +++ b/apps/Ingenium.BuildCli/Process/ProcessRunResult.cs @@ -0,0 +1,18 @@ +// This work is licensed under the terms of the MIT license. +// For a copy, see . + +namespace Ingenium.BuildCli.Execution; + +/// +/// The captured result of a child process. +/// +/// The process exit code. +/// Captured standard output, when redirected. +/// Captured standard error, when redirected. +public sealed record ProcessRunResult(int ExitCode, string StandardOutput, string StandardError) +{ + /// + /// Gets a value indicating whether the process exited successfully. + /// + public bool IsSuccess => ExitCode == 0; +} diff --git a/apps/Ingenium.BuildCli/Process/ProcessRunner.cs b/apps/Ingenium.BuildCli/Process/ProcessRunner.cs new file mode 100644 index 0000000..2e29b7d --- /dev/null +++ b/apps/Ingenium.BuildCli/Process/ProcessRunner.cs @@ -0,0 +1,145 @@ +// This work is licensed under the terms of the MIT license. +// For a copy, see . + +using System.Diagnostics; +using System.Text; + +using Ingenium.BuildCli.Git; + +namespace Ingenium.BuildCli.Execution; + +/// +/// Starts child processes with optional output capture. +/// +public sealed class ProcessRunner : IProcessRunner +{ + private readonly IGitTrace? _trace; + + /// + /// Initializes a new instance of the class. + /// + public ProcessRunner(IGitTrace? trace = null) + { + _trace = trace; + } + + /// + public bool IsAvailable(string fileName) + { + try + { + using var process = Start(fileName, ["--version"], Environment.CurrentDirectory, inheritOutput: false); + process.WaitForExit(5000); + return process.ExitCode == 0; + } + catch (Exception ex) when (ex is System.ComponentModel.Win32Exception or FileNotFoundException or InvalidOperationException) + { + return false; + } + } + + /// + public async Task RunAsync( + string fileName, + IReadOnlyList arguments, + string workingDirectory, + bool inheritOutput, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(fileName); + ArgumentException.ThrowIfNullOrWhiteSpace(workingDirectory); + ArgumentNullException.ThrowIfNull(arguments); + + if (!Directory.Exists(workingDirectory)) + { + throw new BuildCliException($"Working directory '{workingDirectory}' does not exist."); + } + + _trace?.Write($"$ {fileName} {string.Join(' ', arguments)}"); + + using var process = Start(fileName, arguments, workingDirectory, inheritOutput); + Task? stdoutTask = null; + Task? stderrTask = null; + if (!inheritOutput) + { + stdoutTask = process.StandardOutput.ReadToEndAsync(cancellationToken); + stderrTask = process.StandardError.ReadToEndAsync(cancellationToken); + } + + try + { + await process.WaitForExitAsync(cancellationToken); + } + catch (OperationCanceledException) + { + try + { + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + } + } + catch (InvalidOperationException) + { + } + + throw; + } + + var stdout = stdoutTask is null ? string.Empty : await stdoutTask; + var stderr = stderrTask is null ? string.Empty : await stderrTask; + if (!inheritOutput) + { + if (!string.IsNullOrWhiteSpace(stdout)) + { + _trace?.Write(stdout.TrimEnd()); + } + + if (!string.IsNullOrWhiteSpace(stderr)) + { + _trace?.Write(stderr.TrimEnd()); + } + } + + return new ProcessRunResult(process.ExitCode, stdout, stderr); + } + + private static System.Diagnostics.Process Start( + string fileName, + IReadOnlyList arguments, + string workingDirectory, + bool inheritOutput) + { + var startInfo = new ProcessStartInfo + { + FileName = fileName, + WorkingDirectory = workingDirectory, + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = !inheritOutput, + RedirectStandardError = !inheritOutput + }; + + if (!inheritOutput) + { + startInfo.StandardOutputEncoding = Encoding.UTF8; + startInfo.StandardErrorEncoding = Encoding.UTF8; + } + + foreach (var argument in arguments) + { + startInfo.ArgumentList.Add(argument); + } + + startInfo.Environment["DOTNET_CLI_TELEMETRY_OPTOUT"] = "1"; + startInfo.Environment["DOTNET_NOLOGO"] = "1"; + + var process = new System.Diagnostics.Process { StartInfo = startInfo }; + if (!process.Start()) + { + throw new BuildCliException($"Failed to start '{fileName}'."); + } + + return process; + } +} diff --git a/apps/Ingenium.BuildCli/Program.cs b/apps/Ingenium.BuildCli/Program.cs new file mode 100644 index 0000000..a8ecb82 --- /dev/null +++ b/apps/Ingenium.BuildCli/Program.cs @@ -0,0 +1,6 @@ +// This work is licensed under the terms of the MIT license. +// For a copy, see . + +using Ingenium.BuildCli; + +return await BuildCliApplication.RunAsync(args); diff --git a/apps/Ingenium.BuildCli/Rendering/ConsoleWriter.cs b/apps/Ingenium.BuildCli/Rendering/ConsoleWriter.cs new file mode 100644 index 0000000..1a6b113 --- /dev/null +++ b/apps/Ingenium.BuildCli/Rendering/ConsoleWriter.cs @@ -0,0 +1,221 @@ +// This work is licensed under the terms of the MIT license. +// For a copy, see . + +using Ingenium.BuildCli.Extensions; +using Ingenium.BuildCli.Submodule; + +using Spectre.Console; + +namespace Ingenium.BuildCli.Rendering; + +/// +/// Shared Spectre.Console layout used by CLI commands. +/// +public static class ConsoleWriter +{ + /// + /// Writes the standard command header. + /// + public static void WriteHeader(IAnsiConsole console, string title) + { + console.Write(new Rule($"[teal]Ingenium Build CLI[/] {Markup.Escape(title)}").LeftJustified()); + console.WriteLine(); + } + + /// + /// Writes a successful init or update summary. + /// + public static void WriteChange(IAnsiConsole console, BuildSubmoduleChange change) + { + var table = new Table() + .Border(TableBorder.Rounded) + .HideHeaders() + .AddColumn(new TableColumn("Key").PadRight(2)) + .AddColumn("Value"); + + table.AddRow("[grey]Path[/]", Markup.Escape(change.RelativePath)); + table.AddRow("[grey]URL[/]", Markup.Escape(change.Url)); + table.AddRow("[grey]Ref[/]", Markup.Escape(change.CheckedOutRef)); + table.AddRow("[grey]Commit[/]", Markup.Escape(ShortSha(change.Commit))); + + if (!string.IsNullOrEmpty(change.PreviousCommit) && + !string.Equals(change.PreviousCommit, change.Commit, StringComparison.OrdinalIgnoreCase)) + { + table.AddRow("[grey]Previous[/]", Markup.Escape(ShortSha(change.PreviousCommit))); + } + + console.Write(table); + console.WriteLine(); + console.MarkupLine("[grey]The submodule change is staged. Commit it in the parent repository when ready.[/]"); + } + + /// + /// Writes the current submodule status as a table. + /// + public static void WriteStatus(IAnsiConsole console, BuildSubmoduleStatus status) + { + var table = new Table() + .Border(TableBorder.Rounded) + .AddColumn("Property") + .AddColumn("Value"); + + table.AddRow("Repository", Markup.Escape(status.RepositoryRoot)); + table.AddRow("Submodule", Markup.Escape(status.RelativePath ?? BuildRepositoryUrls.DefaultPath)); + table.AddRow("URL", Markup.Escape(status.Url ?? "-")); + table.AddRow("Registered", status.IsRegistered ? "[green]yes[/]" : "[yellow]no[/]"); + table.AddRow("Initialized", status.IsInitialized ? "[green]yes[/]" : "[yellow]no[/]"); + table.AddRow("Commit", Markup.Escape(status.Commit is null ? "-" : ShortSha(status.Commit))); + table.AddRow("Current tag", status.CurrentTags.Count == 0 ? "-" : Markup.Escape(string.Join(", ", status.CurrentTags))); + table.AddRow("Latest tag", Markup.Escape(status.LatestTag ?? "-")); + table.AddRow("Up to date", FormatUpToDate(status)); + + console.Write(table); + } + + /// + /// Writes a repair summary. + /// + public static void WriteRepair(IAnsiConsole console, RepairResult result) + { + var table = new Table() + .Border(TableBorder.Rounded) + .HideHeaders() + .AddColumn(new TableColumn("Key").PadRight(2)) + .AddColumn("Value"); + + table.AddRow("[grey]Strategy[/]", Markup.Escape(result.Strategy.ToString().ToLowerInvariant())); + table.AddRow("[grey]Path[/]", Markup.Escape(result.Change.RelativePath)); + table.AddRow("[grey]Ref[/]", Markup.Escape(result.Change.CheckedOutRef)); + table.AddRow("[grey]Commit[/]", Markup.Escape(ShortSha(result.Change.Commit))); + if (!string.IsNullOrEmpty(result.StashRef)) + { + table.AddRow("[grey]Stash[/]", Markup.Escape(result.StashRef)); + } + + console.Write(table); + if (result.Actions.Count > 0) + { + console.WriteLine(); + foreach (var action in result.Actions) + { + console.MarkupLine($"[grey]•[/] {Markup.Escape(action)}"); + } + } + + console.WriteLine(); + console.MarkupLine("[grey]The submodule change is staged. Commit it in the parent repository when ready.[/]"); + } + + /// + /// Writes the files created for a build extension. + /// + public static void WriteExtension(IAnsiConsole console, BuildExtensionScaffold scaffold) + { + var table = new Table() + .Border(TableBorder.Rounded) + .HideHeaders() + .AddColumn(new TableColumn("Key").PadRight(2)) + .AddColumn("Value"); + + table.AddRow("[grey]Project[/]", Markup.Escape(scaffold.ProjectName)); + table.AddRow("[grey]Path[/]", Markup.Escape(Path.GetRelativePath(scaffold.RepositoryRoot, scaffold.ProjectDirectory))); + console.Write(table); + + if (scaffold.WrittenFiles.Count > 0) + { + console.WriteLine(); + console.MarkupLine("[grey]Files:[/]"); + foreach (var file in scaffold.WrittenFiles) + { + console.MarkupLine($"[grey]•[/] {Markup.Escape(Path.GetRelativePath(scaffold.RepositoryRoot, file))}"); + } + } + + console.WriteLine(); + console.MarkupLine("[grey]The Build host imports every project under build-extensions/ automatically.[/]"); + } + + /// + /// Writes advertised remote tags. + /// + public static void WriteTags(IAnsiConsole console, IReadOnlyList tags, string? currentCommit) + { + if (tags.Count == 0) + { + console.MarkupLine("[yellow]No tags were found on the Build remote.[/]"); + return; + } + + var latest = TagSelector.SelectLatest(tags); + var table = new Table() + .Border(TableBorder.Rounded) + .AddColumn("Tag") + .AddColumn("Commit") + .AddColumn("Notes"); + + foreach (var tag in tags.OrderByDescending(item => item.Name, StringComparer.OrdinalIgnoreCase)) + { + var notes = new List(); + if (latest is not null && tag.Name.Equals(latest.Name, StringComparison.Ordinal)) + { + notes.Add("[green]latest[/]"); + } + + if (!string.IsNullOrEmpty(currentCommit) && + (tag.Commit.StartsWith(currentCommit, StringComparison.OrdinalIgnoreCase) + || currentCommit.StartsWith(tag.Commit, StringComparison.OrdinalIgnoreCase))) + { + notes.Add("[teal]current[/]"); + } + + table.AddRow( + Markup.Escape(tag.Name), + Markup.Escape(ShortSha(tag.Commit)), + string.Join(" ", notes)); + } + + console.Write(table); + } + + /// + /// Writes a user-facing error. + /// + public static void WriteError(IAnsiConsole console, Exception exception) + { + if (exception is BuildCliException) + { + console.MarkupLine($"[red]Error:[/] {Markup.Escape(exception.Message)}"); + return; + } + + console.WriteException(exception, ExceptionFormats.ShortenPaths | ExceptionFormats.ShortenTypes); + } + + /// + /// Returns a shortened SHA for display. + /// + public static string ShortSha(string sha) + { + return sha.Length <= 12 ? sha : sha[..12]; + } + + private static string FormatUpToDate(BuildSubmoduleStatus status) + { + if (!status.IsRegistered) + { + return "[yellow]not added[/]"; + } + + if (!status.IsInitialized) + { + return "[yellow]not initialized[/]"; + } + + if (status.LatestTag is null) + { + return "[grey]unknown[/]"; + } + + return status.IsLatest ? "[green]yes[/]" : "[yellow]no[/]"; + } +} diff --git a/apps/Ingenium.BuildCli/Submodule/BuildRepositoryUrls.cs b/apps/Ingenium.BuildCli/Submodule/BuildRepositoryUrls.cs new file mode 100644 index 0000000..fb031e3 --- /dev/null +++ b/apps/Ingenium.BuildCli/Submodule/BuildRepositoryUrls.cs @@ -0,0 +1,101 @@ +// This work is licensed under the terms of the MIT license. +// For a copy, see . + +namespace Ingenium.BuildCli.Submodule; + +/// +/// Canonical locations of the Ingenium Build repository. +/// +public static class BuildRepositoryUrls +{ + /// + /// The default SSH clone URL for the Build submodule. + /// + public const string Ssh = "git@github.com:IngeniumSE/Build.git"; + + /// + /// The HTTPS clone URL for the Build submodule. + /// + public const string Https = "https://github.com/IngeniumSE/Build.git"; + + /// + /// The default relative path used when adding the submodule. + /// + public const string DefaultPath = "build"; + + /// + /// Returns the default Build URL for the requested transport. + /// + public static string GetDefault(bool useHttps) + { + return useHttps ? Https : Ssh; + } + + /// + /// Returns true when points at the Ingenium Build repository. + /// + public static bool IsBuildRepository(string? url) + { + if (string.IsNullOrWhiteSpace(url)) + { + return false; + } + + var normalized = Normalize(url); + return normalized.Contains("ingeniumse/build", StringComparison.Ordinal); + } + + /// + /// Chooses SSH or HTTPS based on an existing parent-repo remote, unless an override is supplied. + /// + public static string InferFromParentRemote(string? parentRemoteUrl, bool? useHttps) + { + if (useHttps == true) + { + return Https; + } + + if (useHttps == false) + { + return Ssh; + } + + if (!string.IsNullOrWhiteSpace(parentRemoteUrl) && + parentRemoteUrl.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) + { + return Https; + } + + return Ssh; + } + + /// + /// Normalizes a git URL for comparison. + /// + public static string Normalize(string url) + { + var value = url.Trim(); + if (value.EndsWith(".git", StringComparison.OrdinalIgnoreCase)) + { + value = value[..^4]; + } + + value = value.Replace('\\', '/'); + if (value.StartsWith("git@", StringComparison.OrdinalIgnoreCase)) + { + var colon = value.IndexOf(':'); + if (colon > 0) + { + value = value[(colon + 1)..]; + } + } + + const string httpsGithub = "https://github.com/"; + if (value.StartsWith(httpsGithub, StringComparison.OrdinalIgnoreCase)) + { + value = value[httpsGithub.Length..]; + } + + return value.Trim('/').ToLowerInvariant(); + } +} diff --git a/apps/Ingenium.BuildCli/Submodule/BuildSubmoduleChange.cs b/apps/Ingenium.BuildCli/Submodule/BuildSubmoduleChange.cs new file mode 100644 index 0000000..8aefdee --- /dev/null +++ b/apps/Ingenium.BuildCli/Submodule/BuildSubmoduleChange.cs @@ -0,0 +1,45 @@ +// This work is licensed under the terms of the MIT license. +// For a copy, see . + +namespace Ingenium.BuildCli.Submodule; + +/// +/// Describes the result of an init or update operation. +/// +public sealed class BuildSubmoduleChange +{ + /// + /// Gets the parent repository root. + /// + public required string RepositoryRoot { get; init; } + + /// + /// Gets the relative submodule path. + /// + public required string RelativePath { get; init; } + + /// + /// Gets the submodule remote URL. + /// + public required string Url { get; init; } + + /// + /// Gets the ref that was checked out (tag, branch, or commit). + /// + public required string CheckedOutRef { get; init; } + + /// + /// Gets the commit SHA after the operation. + /// + public required string Commit { get; init; } + + /// + /// Gets the commit SHA before the operation, when the submodule already existed. + /// + public string? PreviousCommit { get; init; } + + /// + /// Gets a value indicating whether the submodule was newly added. + /// + public bool Added { get; init; } +} diff --git a/apps/Ingenium.BuildCli/Submodule/BuildSubmoduleGuard.cs b/apps/Ingenium.BuildCli/Submodule/BuildSubmoduleGuard.cs new file mode 100644 index 0000000..ca579b5 --- /dev/null +++ b/apps/Ingenium.BuildCli/Submodule/BuildSubmoduleGuard.cs @@ -0,0 +1,32 @@ +// This work is licensed under the terms of the MIT license. +// For a copy, see . + +namespace Ingenium.BuildCli.Submodule; + +/// +/// Verifies that the Build submodule is present before a build can run. +/// +public static class BuildSubmoduleGuard +{ + /// + /// Throws when the Build submodule has not been added or checked out. + /// + public static void EnsureInitialized(BuildSubmoduleStatus status) + { + ArgumentNullException.ThrowIfNull(status); + + if (!status.IsRegistered) + { + throw new BuildCliException( + $"The Build submodule has not been added to this repository. Run '{CliInfo.Name} init' first.", + ExitCodes.SubmoduleNotFound); + } + + if (!status.IsInitialized) + { + throw new BuildCliException( + $"The Build submodule is registered but not initialized. Run '{CliInfo.Name} init' or '{CliInfo.Name} repair --strategy reinit'.", + ExitCodes.SubmoduleNotFound); + } + } +} diff --git a/apps/Ingenium.BuildCli/Submodule/BuildSubmoduleRequest.cs b/apps/Ingenium.BuildCli/Submodule/BuildSubmoduleRequest.cs new file mode 100644 index 0000000..28b399a --- /dev/null +++ b/apps/Ingenium.BuildCli/Submodule/BuildSubmoduleRequest.cs @@ -0,0 +1,42 @@ +// This work is licensed under the terms of the MIT license. +// For a copy, see . + +namespace Ingenium.BuildCli.Submodule; + +/// +/// Shared options for Build submodule operations. +/// +public sealed class BuildSubmoduleRequest +{ + /// + /// Gets the path of the parent repository, or a directory inside it. + /// Defaults to the current working directory. + /// + public string RepositoryPath { get; init; } = Environment.CurrentDirectory; + + /// + /// Gets an optional relative submodule path. When omitted, an existing Build + /// submodule is detected or is used. + /// + public string? SubmodulePath { get; init; } + + /// + /// Gets an optional clone URL override. + /// + public string? Url { get; init; } + + /// + /// Gets a value indicating whether the HTTPS clone URL should be used. + /// + public bool UseHttps { get; init; } + + /// + /// Gets a tag, branch, or commit to check out. When omitted, the latest tag is used. + /// + public string? Tag { get; init; } + + /// + /// Gets a value indicating whether an existing submodule may be replaced or updated in place. + /// + public bool Force { get; init; } +} diff --git a/apps/Ingenium.BuildCli/Submodule/BuildSubmoduleService.cs b/apps/Ingenium.BuildCli/Submodule/BuildSubmoduleService.cs new file mode 100644 index 0000000..db10d0b --- /dev/null +++ b/apps/Ingenium.BuildCli/Submodule/BuildSubmoduleService.cs @@ -0,0 +1,573 @@ +// This work is licensed under the terms of the MIT license. +// For a copy, see . + +using Ingenium.BuildCli.Git; + +namespace Ingenium.BuildCli.Submodule; + +/// +/// Orchestrates git submodule operations for the Ingenium Build repository. +/// +public sealed class BuildSubmoduleService : IBuildSubmoduleService +{ + private readonly IGitClient _git; + + /// + /// Initializes a new instance of the class. + /// + public BuildSubmoduleService(IGitClient git) + { + _git = git; + } + + /// + public async Task InitAsync(BuildSubmoduleRequest request, CancellationToken cancellationToken = default) + { + var context = await CreateContextAsync(request, requireRegistered: false, cancellationToken); + if (context.Entry is not null && !request.Force) + { + if (IsInitialized(context)) + { + throw new BuildCliException( + $"The Build submodule is already initialized at '{context.RelativePath}'. Use '{CliInfo.Name} update' to change version.", + ExitCodes.AlreadyInitialized); + } + + await EnsureCheckedOutAsync(context, cancellationToken); + return await CheckoutRefAsync(context, request.Tag, added: false, previousCommit: null, cancellationToken); + } + + if (context.Entry is not null && request.Force) + { + await EnsureCheckedOutAsync(context, cancellationToken); + return await CheckoutRefAsync(context, request.Tag, added: false, previousCommit: await TryGetHeadAsync(context, cancellationToken), cancellationToken); + } + + var destination = Path.Combine(context.RepositoryRoot, context.RelativePath); + if (Directory.Exists(destination) && Directory.EnumerateFileSystemEntries(destination).Any() && !request.Force) + { + throw new BuildCliException( + $"The path '{context.RelativePath}' already exists and is not empty. Use --force to add the submodule anyway."); + } + + var addArgs = new List { "submodule", "add" }; + if (request.Force) + { + addArgs.Add("--force"); + } + + addArgs.Add("--name"); + addArgs.Add(Path.GetFileName(context.RelativePath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar))); + addArgs.Add(context.Url); + addArgs.Add(ToGitPath(context.RelativePath)); + + await _git.RunRequiredAsync( + context.RepositoryRoot, + addArgs, + $"Failed to add the Build submodule from '{context.Url}'.", + cancellationToken: cancellationToken); + + var initialized = context with { Entry = new GitmoduleEntry(Path.GetFileName(context.RelativePath), context.RelativePath, context.Url, null) }; + return await CheckoutRefAsync(initialized, request.Tag, added: true, previousCommit: null, cancellationToken); + } + + /// + public async Task UpdateAsync(BuildSubmoduleRequest request, CancellationToken cancellationToken = default) + { + var context = await CreateContextAsync(request, requireRegistered: true, cancellationToken); + await EnsureCheckedOutAsync(context, cancellationToken); + var previous = await TryGetHeadAsync(context, cancellationToken); + return await CheckoutRefAsync(context, request.Tag, added: false, previous, cancellationToken); + } + + /// + public async Task GetStatusAsync(BuildSubmoduleRequest request, CancellationToken cancellationToken = default) + { + EnsureGitAvailable(); + var root = await _git.GetRepositoryRootAsync(request.RepositoryPath, cancellationToken); + var entry = FindBuildEntry(root, request); + var url = ResolveUrl(request, entry, await TryGetOriginUrlAsync(root, cancellationToken)); + var relativePath = ResolveRelativePath(request, entry); + var initialized = entry is not null && IsInitialized(new SubmoduleContext(root, relativePath, url, entry)); + + string? commit = null; + IReadOnlyList currentTags = []; + if (initialized) + { + var context = new SubmoduleContext(root, relativePath, url, entry); + commit = await TryGetHeadAsync(context, cancellationToken); + currentTags = await GetTagsPointingAtHeadAsync(context, cancellationToken); + } + + RemoteTag? latest = null; + try + { + var tags = await ListRemoteTagsAsync(url, cancellationToken); + latest = TagSelector.SelectLatest(tags); + } + catch (BuildCliException) + { + // Status should still render when the remote is unreachable. + } + + return new BuildSubmoduleStatus + { + RepositoryRoot = root, + IsRegistered = entry is not null, + IsInitialized = initialized, + RelativePath = relativePath, + Url = url, + Commit = commit, + CurrentTags = currentTags, + LatestTag = latest?.Name, + LatestCommit = latest?.Commit + }; + } + + /// + public async Task> ListTagsAsync(BuildSubmoduleRequest request, CancellationToken cancellationToken = default) + { + EnsureGitAvailable(); + string url; + try + { + var root = await _git.GetRepositoryRootAsync(request.RepositoryPath, cancellationToken); + var entry = FindBuildEntry(root, request); + url = ResolveUrl(request, entry, await TryGetOriginUrlAsync(root, cancellationToken)); + } + catch (BuildCliException ex) when (ex.ExitCode == ExitCodes.NotAGitRepository) + { + url = request.Url ?? BuildRepositoryUrls.GetDefault(request.UseHttps); + } + + return await ListRemoteTagsAsync(url, cancellationToken); + } + + /// + public async Task RepairAsync(BuildSubmoduleRequest request, RepairStrategy strategy, CancellationToken cancellationToken = default) + { + var context = await CreateContextAsync(request, requireRegistered: true, cancellationToken); + var actions = new List(); + var recorded = await TryGetRecordedCommitAsync(context, cancellationToken); + string? stashRef = null; + + switch (strategy) + { + case RepairStrategy.Stash: + await EnsureCheckedOutAsync(context, cancellationToken); + stashRef = await TryStashAsync(context, actions, cancellationToken); + break; + case RepairStrategy.Reset: + await EnsureCheckedOutAsync(context, cancellationToken); + await ResetHardAsync(context, actions, cancellationToken); + break; + case RepairStrategy.Reinit: + await ReinitializeAsync(context, actions, cancellationToken); + break; + default: + throw new BuildCliException($"Unknown repair strategy '{strategy}'."); + } + + string? checkoutRef = request.Tag; + if (strategy is RepairStrategy.Stash or RepairStrategy.Reset && string.IsNullOrWhiteSpace(checkoutRef)) + { + checkoutRef = recorded; + } + + var previous = await TryGetHeadAsync(context, cancellationToken); + var change = await CheckoutRefAsync(context, checkoutRef, added: false, previous, cancellationToken); + if (strategy is RepairStrategy.Stash or RepairStrategy.Reset && string.IsNullOrWhiteSpace(request.Tag) && recorded is not null) + { + actions.Add($"Restored the parent-recorded commit {recorded[..Math.Min(12, recorded.Length)]}."); + } + else + { + actions.Add($"Checked out '{change.CheckedOutRef}'."); + } + + return new RepairResult + { + Strategy = strategy, + Change = change, + RecordedCommit = recorded, + StashRef = stashRef, + Actions = actions + }; + } + + private async Task CreateContextAsync( + BuildSubmoduleRequest request, + bool requireRegistered, + CancellationToken cancellationToken) + { + EnsureGitAvailable(); + var root = await _git.GetRepositoryRootAsync(request.RepositoryPath, cancellationToken); + var entry = FindBuildEntry(root, request); + if (requireRegistered && entry is null) + { + throw new BuildCliException( + $"The Build submodule is not registered in this repository. Run '{CliInfo.Name} init' first.", + ExitCodes.SubmoduleNotFound); + } + + var url = ResolveUrl(request, entry, await TryGetOriginUrlAsync(root, cancellationToken)); + var relativePath = ResolveRelativePath(request, entry); + return new SubmoduleContext(root, relativePath, url, entry); + } + + private void EnsureGitAvailable() + { + if (!_git.IsGitAvailable()) + { + throw new BuildCliException( + "git was not found on PATH. Install Git and try again.", + ExitCodes.GitNotFound); + } + } + + private static GitmoduleEntry? FindBuildEntry(string repositoryRoot, BuildSubmoduleRequest request) + { + var entries = GitmodulesParser.ParseFile(Path.Combine(repositoryRoot, ".gitmodules")); + if (entries.Count == 0) + { + return null; + } + + if (!string.IsNullOrWhiteSpace(request.SubmodulePath)) + { + var requested = ToGitPath(request.SubmodulePath); + return entries.FirstOrDefault(entry => + entry.Path.Equals(requested, StringComparison.OrdinalIgnoreCase) + || entry.Name.Equals(requested, StringComparison.OrdinalIgnoreCase)); + } + + var buildEntries = entries.Where(entry => BuildRepositoryUrls.IsBuildRepository(entry.Url)).ToList(); + if (buildEntries.Count == 1) + { + return buildEntries[0]; + } + + if (buildEntries.Count > 1) + { + return buildEntries.FirstOrDefault(entry => + entry.Path.Equals(BuildRepositoryUrls.DefaultPath, StringComparison.OrdinalIgnoreCase)) + ?? buildEntries[0]; + } + + return entries.FirstOrDefault(entry => + entry.Path.Equals(BuildRepositoryUrls.DefaultPath, StringComparison.OrdinalIgnoreCase) + || entry.Path.Equals("Build", StringComparison.Ordinal)); + } + + private static string ResolveRelativePath(BuildSubmoduleRequest request, GitmoduleEntry? entry) + { + if (!string.IsNullOrWhiteSpace(request.SubmodulePath)) + { + return ToGitPath(request.SubmodulePath); + } + + return entry?.Path ?? BuildRepositoryUrls.DefaultPath; + } + + private static string ResolveUrl(BuildSubmoduleRequest request, GitmoduleEntry? entry, string? parentRemote) + { + if (!string.IsNullOrWhiteSpace(request.Url)) + { + return request.Url; + } + + if (!string.IsNullOrWhiteSpace(entry?.Url)) + { + return entry.Url; + } + + return BuildRepositoryUrls.InferFromParentRemote(parentRemote, request.UseHttps ? true : null); + } + + private async Task TryGetOriginUrlAsync(string repositoryRoot, CancellationToken cancellationToken) + { + var result = await _git.RunAsync(repositoryRoot, ["remote", "get-url", "origin"], cancellationToken); + return result.IsSuccess ? result.StandardOutput.Trim() : null; + } + + private static bool IsInitialized(SubmoduleContext context) + { + var gitDir = Path.Combine(context.AbsolutePath, ".git"); + return File.Exists(gitDir) || Directory.Exists(gitDir); + } + + private async Task EnsureCheckedOutAsync(SubmoduleContext context, CancellationToken cancellationToken) + { + if (IsInitialized(context)) + { + return; + } + + await _git.RunRequiredAsync( + context.RepositoryRoot, + ["submodule", "update", "--init", "--", ToGitPath(context.RelativePath)], + $"Failed to initialize the Build submodule at '{context.RelativePath}'.", + cancellationToken: cancellationToken); + } + + private async Task CheckoutRefAsync( + SubmoduleContext context, + string? requestedRef, + bool added, + string? previousCommit, + CancellationToken cancellationToken) + { + await _git.RunRequiredAsync( + context.AbsolutePath, + ["fetch", "origin", "--tags", "--prune"], + "Failed to fetch Build submodule tags.", + cancellationToken: cancellationToken); + + var tags = await ListRemoteTagsAsync(context.Url, cancellationToken); + string checkoutRef; + if (string.IsNullOrWhiteSpace(requestedRef)) + { + var latest = TagSelector.SelectLatest(tags); + checkoutRef = latest?.Name ?? await GetDefaultRemoteRefAsync(context, cancellationToken); + } + else + { + var match = TagSelector.Find(tags, requestedRef); + checkoutRef = match?.Name ?? requestedRef; + } + + var checkout = await _git.RunAsync( + context.AbsolutePath, + ["checkout", "--detach", checkoutRef], + cancellationToken); + + if (!checkout.IsSuccess) + { + throw new BuildCliException( + $"Could not check out '{checkoutRef}' in the Build submodule.{Environment.NewLine}{checkout.ErrorMessage}", + ExitCodes.RefNotFound); + } + + await _git.RunRequiredAsync( + context.RepositoryRoot, + ["add", "--", ToGitPath(context.RelativePath), ".gitmodules"], + "Failed to stage the Build submodule change.", + cancellationToken: cancellationToken); + + var commit = await TryGetHeadAsync(context, cancellationToken) + ?? throw new BuildCliException("The Build submodule checkout succeeded but HEAD could not be read."); + + return new BuildSubmoduleChange + { + RepositoryRoot = context.RepositoryRoot, + RelativePath = context.RelativePath, + Url = context.Url, + CheckedOutRef = checkoutRef, + Commit = commit, + PreviousCommit = previousCommit, + Added = added + }; + } + + private async Task GetDefaultRemoteRefAsync(SubmoduleContext context, CancellationToken cancellationToken) + { + var result = await _git.RunAsync(context.AbsolutePath, ["rev-parse", "--abbrev-ref", "origin/HEAD"], cancellationToken); + if (result.IsSuccess) + { + var value = result.StandardOutput.Trim(); + if (!string.IsNullOrEmpty(value) && !value.Equals("origin/HEAD", StringComparison.Ordinal)) + { + return value; + } + } + + foreach (var candidate in new[] { "origin/main", "origin/master" }) + { + var probe = await _git.RunAsync(context.AbsolutePath, ["rev-parse", "--verify", candidate], cancellationToken); + if (probe.IsSuccess) + { + return candidate; + } + } + + throw new BuildCliException( + "The Build repository has no tags and no default branch could be determined.", + ExitCodes.RefNotFound); + } + + private async Task TryGetRecordedCommitAsync(SubmoduleContext context, CancellationToken cancellationToken) + { + var path = ToGitPath(context.RelativePath); + foreach (var spec in new[] { $"HEAD:{path}", $":{path}" }) + { + var result = await _git.RunAsync(context.RepositoryRoot, ["rev-parse", spec], cancellationToken); + if (result.IsSuccess) + { + var sha = result.StandardOutput.Trim(); + if (!string.IsNullOrEmpty(sha)) + { + return sha; + } + } + } + + return null; + } + + private async Task IsDirtyAsync(SubmoduleContext context, CancellationToken cancellationToken) + { + if (!IsInitialized(context)) + { + return false; + } + + var result = await _git.RunAsync(context.AbsolutePath, ["status", "--porcelain"], cancellationToken); + return result.IsSuccess && !string.IsNullOrWhiteSpace(result.StandardOutput); + } + + private async Task TryStashAsync(SubmoduleContext context, List actions, CancellationToken cancellationToken) + { + if (!await IsDirtyAsync(context, cancellationToken)) + { + actions.Add("No local submodule changes to stash."); + return null; + } + + var message = $"{CliInfo.Name} repair {DateTimeOffset.UtcNow:yyyy-MM-dd HH:mm:ss} UTC"; + var result = await _git.RunAsync( + context.AbsolutePath, + ["stash", "push", "-u", "-m", message], + cancellationToken); + + if (!result.IsSuccess) + { + if (result.ErrorMessage.Contains("No local changes", StringComparison.OrdinalIgnoreCase)) + { + actions.Add("No local submodule changes to stash."); + return null; + } + + throw new BuildCliException($"Failed to stash Build submodule changes.{Environment.NewLine}{result.ErrorMessage}"); + } + + var stashRef = await TryGetLatestStashRefAsync(context, cancellationToken); + actions.Add(stashRef is null + ? "Stashed local submodule changes." + : $"Stashed local submodule changes as {stashRef}."); + return stashRef; + } + + private async Task TryGetLatestStashRefAsync(SubmoduleContext context, CancellationToken cancellationToken) + { + var result = await _git.RunAsync(context.AbsolutePath, ["stash", "list", "-n", "1", "--format=%gd"], cancellationToken); + if (!result.IsSuccess) + { + return null; + } + + var value = result.StandardOutput.Trim(); + return string.IsNullOrEmpty(value) ? null : value; + } + + private async Task ResetHardAsync(SubmoduleContext context, List actions, CancellationToken cancellationToken) + { + await _git.RunRequiredAsync( + context.AbsolutePath, + ["reset", "--hard"], + "Failed to reset the Build submodule to HEAD.", + cancellationToken: cancellationToken); + await _git.RunRequiredAsync( + context.AbsolutePath, + ["clean", "-fd"], + "Failed to clean untracked files from the Build submodule.", + cancellationToken: cancellationToken); + actions.Add("Discarded local submodule changes and untracked files."); + } + + private async Task ReinitializeAsync(SubmoduleContext context, List actions, CancellationToken cancellationToken) + { + var deinit = await _git.RunAsync( + context.RepositoryRoot, + ["submodule", "deinit", "-f", "--", ToGitPath(context.RelativePath)], + cancellationToken); + if (deinit.IsSuccess) + { + actions.Add("Deinitialized the Build submodule."); + } + + TryDeleteDirectory(context.AbsolutePath); + TryDeleteDirectory(Path.Combine(context.RepositoryRoot, ".git", "modules", context.RelativePath.Replace('/', Path.DirectorySeparatorChar))); + actions.Add("Removed the submodule working tree and cached git directory."); + + await _git.RunRequiredAsync( + context.RepositoryRoot, + ["submodule", "update", "--init", "--force", "--", ToGitPath(context.RelativePath)], + "Failed to re-initialize the Build submodule.", + cancellationToken: cancellationToken); + actions.Add("Re-initialized the Build submodule from the recorded URL."); + } + + private static void TryDeleteDirectory(string path) + { + if (!Directory.Exists(path)) + { + return; + } + + foreach (var info in new DirectoryInfo(path).EnumerateFileSystemInfos("*", SearchOption.AllDirectories)) + { + info.Attributes &= ~FileAttributes.ReadOnly; + } + + Directory.Delete(path, recursive: true); + } + + private async Task TryGetHeadAsync(SubmoduleContext context, CancellationToken cancellationToken) + { + if (!IsInitialized(context)) + { + return null; + } + + var result = await _git.RunAsync(context.AbsolutePath, ["rev-parse", "HEAD"], cancellationToken); + return result.IsSuccess ? result.StandardOutput.Trim() : null; + } + + private async Task> GetTagsPointingAtHeadAsync(SubmoduleContext context, CancellationToken cancellationToken) + { + var result = await _git.RunAsync(context.AbsolutePath, ["tag", "--points-at", "HEAD"], cancellationToken); + if (!result.IsSuccess) + { + return []; + } + + return result.StandardOutput + .Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .ToArray(); + } + + private async Task> ListRemoteTagsAsync(string url, CancellationToken cancellationToken) + { + var result = await _git.RunRequiredAsync( + Environment.CurrentDirectory, + ["ls-remote", "--tags", "--sort=-v:refname", url], + $"Failed to list tags from '{url}'.", + cancellationToken: cancellationToken); + + return TagSelector.ParseLsRemote(result.StandardOutput); + } + + private static string ToGitPath(string path) + { + return path.Replace('\\', '/').Trim('/'); + } + + private sealed record SubmoduleContext( + string RepositoryRoot, + string RelativePath, + string Url, + GitmoduleEntry? Entry) + { + public string AbsolutePath => Path.Combine(RepositoryRoot, RelativePath.Replace('/', Path.DirectorySeparatorChar)); + } +} diff --git a/apps/Ingenium.BuildCli/Submodule/BuildSubmoduleStatus.cs b/apps/Ingenium.BuildCli/Submodule/BuildSubmoduleStatus.cs new file mode 100644 index 0000000..61c70dd --- /dev/null +++ b/apps/Ingenium.BuildCli/Submodule/BuildSubmoduleStatus.cs @@ -0,0 +1,72 @@ +// This work is licensed under the terms of the MIT license. +// For a copy, see . + +namespace Ingenium.BuildCli.Submodule; + +/// +/// A snapshot of the Build submodule in a parent repository. +/// +public sealed class BuildSubmoduleStatus +{ + /// + /// Gets the absolute path of the parent repository. + /// + public required string RepositoryRoot { get; init; } + + /// + /// Gets a value indicating whether the submodule is recorded in .gitmodules. + /// + public bool IsRegistered { get; init; } + + /// + /// Gets a value indicating whether the submodule working tree has been checked out. + /// + public bool IsInitialized { get; init; } + + /// + /// Gets the relative submodule path, when known. + /// + public string? RelativePath { get; init; } + + /// + /// Gets the configured submodule URL, when known. + /// + public string? Url { get; init; } + + /// + /// Gets the currently checked-out commit SHA, when available. + /// + public string? Commit { get; init; } + + /// + /// Gets tags that point at the current commit. + /// + public IReadOnlyList CurrentTags { get; init; } = []; + + /// + /// Gets the latest remote tag name, when one exists. + /// + public string? LatestTag { get; init; } + + /// + /// Gets the commit of the latest remote tag, when one exists. + /// + public string? LatestCommit { get; init; } + + /// + /// Gets a value indicating whether the current commit matches the latest tag. + /// + public bool IsLatest + { + get + { + if (string.IsNullOrEmpty(Commit) || string.IsNullOrEmpty(LatestCommit)) + { + return false; + } + + return Commit.StartsWith(LatestCommit, StringComparison.OrdinalIgnoreCase) + || LatestCommit.StartsWith(Commit, StringComparison.OrdinalIgnoreCase); + } + } +} diff --git a/apps/Ingenium.BuildCli/Submodule/IBuildSubmoduleService.cs b/apps/Ingenium.BuildCli/Submodule/IBuildSubmoduleService.cs new file mode 100644 index 0000000..7112a58 --- /dev/null +++ b/apps/Ingenium.BuildCli/Submodule/IBuildSubmoduleService.cs @@ -0,0 +1,35 @@ +// This work is licensed under the terms of the MIT license. +// For a copy, see . + +namespace Ingenium.BuildCli.Submodule; + +/// +/// Adds and updates the Ingenium Build git submodule in a parent repository. +/// +public interface IBuildSubmoduleService +{ + /// + /// Adds the Build submodule, or initializes it when it is already registered. + /// + Task InitAsync(BuildSubmoduleRequest request, CancellationToken cancellationToken = default); + + /// + /// Updates an existing Build submodule to the latest tag or a specific ref. + /// + Task UpdateAsync(BuildSubmoduleRequest request, CancellationToken cancellationToken = default); + + /// + /// Returns the current Build submodule state. + /// + Task GetStatusAsync(BuildSubmoduleRequest request, CancellationToken cancellationToken = default); + + /// + /// Lists tags advertised by the Build remote. + /// + Task> ListTagsAsync(BuildSubmoduleRequest request, CancellationToken cancellationToken = default); + + /// + /// Repairs a dirty or broken Build submodule. + /// + Task RepairAsync(BuildSubmoduleRequest request, RepairStrategy strategy, CancellationToken cancellationToken = default); +} diff --git a/apps/Ingenium.BuildCli/Submodule/RemoteTag.cs b/apps/Ingenium.BuildCli/Submodule/RemoteTag.cs new file mode 100644 index 0000000..9e06475 --- /dev/null +++ b/apps/Ingenium.BuildCli/Submodule/RemoteTag.cs @@ -0,0 +1,11 @@ +// This work is licensed under the terms of the MIT license. +// For a copy, see . + +namespace Ingenium.BuildCli.Submodule; + +/// +/// A tag advertised by a git remote. +/// +/// The tag name, without the refs/tags/ prefix. +/// The commit the tag points at. +public sealed record RemoteTag(string Name, string Commit); diff --git a/apps/Ingenium.BuildCli/Submodule/RepairResult.cs b/apps/Ingenium.BuildCli/Submodule/RepairResult.cs new file mode 100644 index 0000000..28b8a2e --- /dev/null +++ b/apps/Ingenium.BuildCli/Submodule/RepairResult.cs @@ -0,0 +1,35 @@ +// This work is licensed under the terms of the MIT license. +// For a copy, see . + +namespace Ingenium.BuildCli.Submodule; + +/// +/// The outcome of a submodule repair. +/// +public sealed class RepairResult +{ + /// + /// Gets the strategy that was applied. + /// + public required RepairStrategy Strategy { get; init; } + + /// + /// Gets the resulting submodule state. + /// + public required BuildSubmoduleChange Change { get; init; } + + /// + /// Gets the parent-recorded submodule commit, when one was available. + /// + public string? RecordedCommit { get; init; } + + /// + /// Gets the created stash reference, when the stash strategy saved changes. + /// + public string? StashRef { get; init; } + + /// + /// Gets the human-readable actions that were performed. + /// + public IReadOnlyList Actions { get; init; } = []; +} diff --git a/apps/Ingenium.BuildCli/Submodule/RepairStrategy.cs b/apps/Ingenium.BuildCli/Submodule/RepairStrategy.cs new file mode 100644 index 0000000..239b933 --- /dev/null +++ b/apps/Ingenium.BuildCli/Submodule/RepairStrategy.cs @@ -0,0 +1,62 @@ +// This work is licensed under the terms of the MIT license. +// For a copy, see . + +namespace Ingenium.BuildCli.Submodule; + +/// +/// Strategies for repairing a broken or dirty Build submodule. +/// +public enum RepairStrategy +{ + /// + /// Stash local submodule changes, then restore the parent-recorded commit. + /// + Stash = 0, + + /// + /// Discard local submodule changes and restore the parent-recorded HEAD. + /// + Reset = 1, + + /// + /// Deinitialize the submodule and check it out again at a tagged version. + /// + Reinit = 2 +} + +/// +/// Parses repair strategy names, including aliases such as head and re-init. +/// +public static class RepairStrategyParser +{ + /// + /// Attempts to parse a strategy name. + /// + public static bool TryParse(string? value, out RepairStrategy strategy) + { + strategy = default; + if (string.IsNullOrWhiteSpace(value)) + { + return false; + } + + switch (value.Trim().ToLowerInvariant()) + { + case "stash": + strategy = RepairStrategy.Stash; + return true; + case "reset": + case "head": + strategy = RepairStrategy.Reset; + return true; + case "reinit": + case "re-init": + case "reinitialize": + case "reinitialise": + strategy = RepairStrategy.Reinit; + return true; + default: + return false; + } + } +} diff --git a/apps/Ingenium.BuildCli/Submodule/TagSelector.cs b/apps/Ingenium.BuildCli/Submodule/TagSelector.cs new file mode 100644 index 0000000..bff5c99 --- /dev/null +++ b/apps/Ingenium.BuildCli/Submodule/TagSelector.cs @@ -0,0 +1,132 @@ +// This work is licensed under the terms of the MIT license. +// For a copy, see . + +using System.Text.RegularExpressions; + +namespace Ingenium.BuildCli.Submodule; + +/// +/// Chooses the latest tag from a remote tag list. +/// +public static class TagSelector +{ + private static readonly Regex SemVerRegex = new( + @"^v?(?\d+)\.(?\d+)\.(?\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([]));
+	}
+}