From fe1fad3bd44315408ae2074ed4d62ea27f4cd46d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 11:11:13 +0000 Subject: [PATCH 1/3] Add a Spectre.Console CLI for managing the Build submodule. Introduce buildcli with init, update, status, and tags commands so parent repos can add Ingenium Build and pin it to the latest or a specific tag. Include install scripts, CI, and git-backed tests. Co-authored-by: Matthew Abbott --- .editorconfig | 71 ++++ .github/workflows/ci.yml | 31 ++ .gitignore | 114 +++++ Directory.Build.props | 29 ++ Directory.Packages.props | 22 + Ingenium-BuildCLI.sln | 36 ++ LICENSE | 21 + README.md | 93 ++++- apps/Ingenium.BuildCli/AppVersion.cs | 34 ++ apps/Ingenium.BuildCli/BuildCliApplication.cs | 85 ++++ apps/Ingenium.BuildCli/BuildCliException.cs | 40 ++ .../Ingenium.BuildCli/Commands/InitCommand.cs | 67 +++ .../Commands/RepositorySettings.cs | 52 +++ .../Commands/StatusCommand.cs | 53 +++ .../Ingenium.BuildCli/Commands/TagsCommand.cs | 65 +++ .../Commands/UpdateCommand.cs | 73 ++++ .../Git/AnsiConsoleGitTrace.cs | 36 ++ apps/Ingenium.BuildCli/Git/GitClient.cs | 212 ++++++++++ apps/Ingenium.BuildCli/Git/GitResult.cs | 41 ++ apps/Ingenium.BuildCli/Git/GitmoduleEntry.cs | 13 + .../Ingenium.BuildCli/Git/GitmodulesParser.cs | 107 +++++ apps/Ingenium.BuildCli/Git/IGitClient.cs | 41 ++ apps/Ingenium.BuildCli/Git/IGitTrace.cs | 20 + .../Infrastructure/TypeRegistrar.cs | 49 +++ .../Infrastructure/TypeResolver.cs | 40 ++ .../Ingenium.BuildCli.csproj | 23 + apps/Ingenium.BuildCli/Program.cs | 6 + .../Rendering/ConsoleWriter.cs | 157 +++++++ .../Submodule/BuildRepositoryUrls.cs | 101 +++++ .../Submodule/BuildSubmoduleChange.cs | 45 ++ .../Submodule/BuildSubmoduleRequest.cs | 42 ++ .../Submodule/BuildSubmoduleService.cs | 394 ++++++++++++++++++ .../Submodule/BuildSubmoduleStatus.cs | 72 ++++ .../Submodule/IBuildSubmoduleService.cs | 30 ++ apps/Ingenium.BuildCli/Submodule/RemoteTag.cs | 11 + .../Submodule/TagSelector.cs | 132 ++++++ build.cmd | 2 + build.sh | 4 + global.json | 6 + scripts/install.ps1 | 124 ++++++ scripts/install.sh | 130 ++++++ .../BuildRepositoryUrlsTests.cs | 44 ++ .../BuildSubmoduleServiceTests.cs | 189 +++++++++ .../CommandAppTests.cs | 103 +++++ .../GitmodulesParserTests.cs | 59 +++ tests/Ingenium.BuildCli.Tests/GlobalUsings.cs | 1 + .../Ingenium.BuildCli.Tests.csproj | 22 + .../Support/GitTestWorkspace.cs | 132 ++++++ .../TagSelectorTests.cs | 73 ++++ 49 files changed, 3346 insertions(+), 1 deletion(-) create mode 100644 .editorconfig create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 Directory.Build.props create mode 100644 Directory.Packages.props create mode 100644 Ingenium-BuildCLI.sln create mode 100644 LICENSE create mode 100644 apps/Ingenium.BuildCli/AppVersion.cs create mode 100644 apps/Ingenium.BuildCli/BuildCliApplication.cs create mode 100644 apps/Ingenium.BuildCli/BuildCliException.cs create mode 100644 apps/Ingenium.BuildCli/Commands/InitCommand.cs create mode 100644 apps/Ingenium.BuildCli/Commands/RepositorySettings.cs create mode 100644 apps/Ingenium.BuildCli/Commands/StatusCommand.cs create mode 100644 apps/Ingenium.BuildCli/Commands/TagsCommand.cs create mode 100644 apps/Ingenium.BuildCli/Commands/UpdateCommand.cs create mode 100644 apps/Ingenium.BuildCli/Git/AnsiConsoleGitTrace.cs create mode 100644 apps/Ingenium.BuildCli/Git/GitClient.cs create mode 100644 apps/Ingenium.BuildCli/Git/GitResult.cs create mode 100644 apps/Ingenium.BuildCli/Git/GitmoduleEntry.cs create mode 100644 apps/Ingenium.BuildCli/Git/GitmodulesParser.cs create mode 100644 apps/Ingenium.BuildCli/Git/IGitClient.cs create mode 100644 apps/Ingenium.BuildCli/Git/IGitTrace.cs create mode 100644 apps/Ingenium.BuildCli/Infrastructure/TypeRegistrar.cs create mode 100644 apps/Ingenium.BuildCli/Infrastructure/TypeResolver.cs create mode 100644 apps/Ingenium.BuildCli/Ingenium.BuildCli.csproj create mode 100644 apps/Ingenium.BuildCli/Program.cs create mode 100644 apps/Ingenium.BuildCli/Rendering/ConsoleWriter.cs create mode 100644 apps/Ingenium.BuildCli/Submodule/BuildRepositoryUrls.cs create mode 100644 apps/Ingenium.BuildCli/Submodule/BuildSubmoduleChange.cs create mode 100644 apps/Ingenium.BuildCli/Submodule/BuildSubmoduleRequest.cs create mode 100644 apps/Ingenium.BuildCli/Submodule/BuildSubmoduleService.cs create mode 100644 apps/Ingenium.BuildCli/Submodule/BuildSubmoduleStatus.cs create mode 100644 apps/Ingenium.BuildCli/Submodule/IBuildSubmoduleService.cs create mode 100644 apps/Ingenium.BuildCli/Submodule/RemoteTag.cs create mode 100644 apps/Ingenium.BuildCli/Submodule/TagSelector.cs create mode 100644 build.cmd create mode 100755 build.sh create mode 100644 global.json create mode 100644 scripts/install.ps1 create mode 100755 scripts/install.sh create mode 100644 tests/Ingenium.BuildCli.Tests/BuildRepositoryUrlsTests.cs create mode 100644 tests/Ingenium.BuildCli.Tests/BuildSubmoduleServiceTests.cs create mode 100644 tests/Ingenium.BuildCli.Tests/CommandAppTests.cs create mode 100644 tests/Ingenium.BuildCli.Tests/GitmodulesParserTests.cs create mode 100644 tests/Ingenium.BuildCli.Tests/GlobalUsings.cs create mode 100644 tests/Ingenium.BuildCli.Tests/Ingenium.BuildCli.Tests.csproj create mode 100644 tests/Ingenium.BuildCli.Tests/Support/GitTestWorkspace.cs create mode 100644 tests/Ingenium.BuildCli.Tests/TagSelectorTests.cs 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..2c7b0dd 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,93 @@ # 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 `buildcli` from any git repository that should host the Build submodule. + +```text +buildcli init Add the Build submodule (defaults to the latest tag) +buildcli init --tag v1.2.3 Add the Build submodule pinned to a specific tag +buildcli update Move an existing submodule to the latest tag +buildcli update --tag v1.2.3 Move an existing submodule to a specific tag +buildcli status Show the current submodule path, commit, and tags +buildcli tags List tags advertised by the Build remote +``` + +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. + +## Installation + +The installer publishes a self-contained `buildcli` 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/buildcli`, with a symlink at `~/.local/bin/buildcli`. 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\BuildCli`. That directory is added to the user `PATH`. Open a new terminal before running `buildcli`. + +### .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..dc6427c --- /dev/null +++ b/apps/Ingenium.BuildCli/BuildCliApplication.cs @@ -0,0 +1,85 @@ +// This work is licensed under the terms of the MIT license. +// For a copy, see . + +using Ingenium.BuildCli.Commands; +using Ingenium.BuildCli.Git; +using Ingenium.BuildCli.Infrastructure; +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(); + 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; + } + + /// + /// Registers commands, examples, and the global exception handler. + /// + public static void Configure(IConfigurator config) + { + config.SetApplicationName("buildcli"); + 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"); + } +} diff --git a/apps/Ingenium.BuildCli/BuildCliException.cs b/apps/Ingenium.BuildCli/BuildCliException.cs new file mode 100644 index 0000000..eda51db --- /dev/null +++ b/apps/Ingenium.BuildCli/BuildCliException.cs @@ -0,0 +1,40 @@ +// 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; +} 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/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/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/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..01407cb --- /dev/null +++ b/apps/Ingenium.BuildCli/Ingenium.BuildCli.csproj @@ -0,0 +1,23 @@ + + + + Exe + net8.0 + buildcli + Ingenium.BuildCli + CLI for adding and updating the Ingenium Build git submodule. + true + buildcli + Ingenium.BuildCli + ingenium;build;git;submodule;cli + 0.1.0 + LatestMajor + + + + + + + + + diff --git a/apps/Ingenium.BuildCli/Program.cs b/apps/Ingenium.BuildCli/Program.cs new file mode 100644 index 0000000..568bb14 --- /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.Create().RunAsync(args); diff --git a/apps/Ingenium.BuildCli/Rendering/ConsoleWriter.cs b/apps/Ingenium.BuildCli/Rendering/ConsoleWriter.cs new file mode 100644 index 0000000..692647f --- /dev/null +++ b/apps/Ingenium.BuildCli/Rendering/ConsoleWriter.cs @@ -0,0 +1,157 @@ +// This work is licensed under the terms of the MIT license. +// For a copy, see . + +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 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/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..3395430 --- /dev/null +++ b/apps/Ingenium.BuildCli/Submodule/BuildSubmoduleService.cs @@ -0,0 +1,394 @@ +// 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 'buildcli 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); + } + + 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 'buildcli 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 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..5014cad --- /dev/null +++ b/apps/Ingenium.BuildCli/Submodule/IBuildSubmoduleService.cs @@ -0,0 +1,30 @@ +// 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); +} 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/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..7b3a12b
--- /dev/null
+++ b/scripts/install.ps1
@@ -0,0 +1,124 @@
+# Installs buildcli 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:BUILDCLI_INSTALL_DIR) { $env:BUILDCLI_INSTALL_DIR } else { Join-Path $env:LOCALAPPDATA "Ingenium\BuildCli" }),
+	[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 buildcli."
+	}
+
+	$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 buildcli."
+}
+
+$sourceDir = Get-SourceDirectory
+$rid = Get-Rid
+New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null
+
+Write-Log "Publishing buildcli 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 "buildcli.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: buildcli --help"
diff --git a/scripts/install.sh b/scripts/install.sh
new file mode 100755
index 0000000..e962b31
--- /dev/null
+++ b/scripts/install.sh
@@ -0,0 +1,130 @@
+#!/usr/bin/env bash
+# Installs buildcli 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="${BUILDCLI_INSTALL_DIR:-${HOME}/.local/share/ingenium/buildcli}"
+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 buildcli."
+}
+
+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 buildcli 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}/buildcli"
+	[[ -f "${executable}" ]] || fail "Publish succeeded but ${executable} was not produced."
+	chmod +x "${executable}"
+
+	ln -sfn "${executable}" "${BIN_DIR}/buildcli"
+	log "Installed ${executable}"
+	log "Linked ${BIN_DIR}/buildcli"
+
+	if ! command -v buildcli >/dev/null 2>&1; then
+		cat <.
+
+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/BuildSubmoduleServiceTests.cs b/tests/Ingenium.BuildCli.Tests/BuildSubmoduleServiceTests.cs
new file mode 100644
index 0000000..34a4db9
--- /dev/null
+++ b/tests/Ingenium.BuildCli.Tests/BuildSubmoduleServiceTests.cs
@@ -0,0 +1,189 @@
+// 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")));
+	}
+
+	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..7a39275
--- /dev/null
+++ b/tests/Ingenium.BuildCli.Tests/CommandAppTests.cs
@@ -0,0 +1,103 @@
+// 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 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);
+	}
+
+	[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 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/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/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([]));
+	}
+}

From bef5d75e40215bceb3e5a945941bcf26bd3353a0 Mon Sep 17 00:00:00 2001
From: Cursor Agent 
Date: Sun, 16 Aug 2026 11:18:25 +0000
Subject: [PATCH 2/3] Add repair, build, and extension commands.

Repair can stash local submodule changes, reset to the parent-recorded
HEAD, or fully re-initialize at a tagged version. Build invokes the Cake
host in apps/Build. Extension scaffolds the build-extensions layout the
Build submodule already imports.

Co-authored-by: Matthew Abbott 
---
 README.md                                     |  21 ++
 apps/Ingenium.BuildCli/BuildCliApplication.cs |  23 +++
 apps/Ingenium.BuildCli/BuildCliException.cs   |   3 +
 .../Commands/BuildCommand.cs                  |  77 ++++++++
 .../Commands/ExtensionCommand.cs              |  67 +++++++
 .../Commands/RepairCommand.cs                 | 131 +++++++++++++
 .../Extensions/BuildExtensionNames.cs         |  61 ++++++
 .../Extensions/BuildExtensionScaffold.cs      |  56 ++++++
 .../Extensions/BuildExtensionService.cs       | 106 +++++++++++
 .../Extensions/BuildExtensionTemplates.cs     | 111 +++++++++++
 .../Extensions/IBuildExtensionService.cs      |  15 ++
 .../Host/BuildHostService.cs                  | 151 +++++++++++++++
 .../Host/IBuildHostService.cs                 |  44 +++++
 .../Process/IProcessRunner.cs                 |  28 +++
 .../Process/ProcessRunResult.cs               |  18 ++
 .../Process/ProcessRunner.cs                  | 145 ++++++++++++++
 .../Rendering/ConsoleWriter.cs                |  64 +++++++
 .../Submodule/BuildSubmoduleService.cs        | 179 ++++++++++++++++++
 .../Submodule/IBuildSubmoduleService.cs       |   5 +
 .../Submodule/RepairResult.cs                 |  35 ++++
 .../Submodule/RepairStrategy.cs               |  62 ++++++
 .../BuildExtensionNamesTests.cs               |  19 ++
 .../BuildExtensionServiceTests.cs             |  67 +++++++
 .../BuildHostArgumentsTests.cs                |  47 +++++
 .../BuildSubmoduleServiceTests.cs             |  73 +++++++
 .../CommandAppTests.cs                        |   3 +
 .../RepairStrategyParserTests.cs              |  29 +++
 27 files changed, 1640 insertions(+)
 create mode 100644 apps/Ingenium.BuildCli/Commands/BuildCommand.cs
 create mode 100644 apps/Ingenium.BuildCli/Commands/ExtensionCommand.cs
 create mode 100644 apps/Ingenium.BuildCli/Commands/RepairCommand.cs
 create mode 100644 apps/Ingenium.BuildCli/Extensions/BuildExtensionNames.cs
 create mode 100644 apps/Ingenium.BuildCli/Extensions/BuildExtensionScaffold.cs
 create mode 100644 apps/Ingenium.BuildCli/Extensions/BuildExtensionService.cs
 create mode 100644 apps/Ingenium.BuildCli/Extensions/BuildExtensionTemplates.cs
 create mode 100644 apps/Ingenium.BuildCli/Extensions/IBuildExtensionService.cs
 create mode 100644 apps/Ingenium.BuildCli/Host/BuildHostService.cs
 create mode 100644 apps/Ingenium.BuildCli/Host/IBuildHostService.cs
 create mode 100644 apps/Ingenium.BuildCli/Process/IProcessRunner.cs
 create mode 100644 apps/Ingenium.BuildCli/Process/ProcessRunResult.cs
 create mode 100644 apps/Ingenium.BuildCli/Process/ProcessRunner.cs
 create mode 100644 apps/Ingenium.BuildCli/Submodule/RepairResult.cs
 create mode 100644 apps/Ingenium.BuildCli/Submodule/RepairStrategy.cs
 create mode 100644 tests/Ingenium.BuildCli.Tests/BuildExtensionNamesTests.cs
 create mode 100644 tests/Ingenium.BuildCli.Tests/BuildExtensionServiceTests.cs
 create mode 100644 tests/Ingenium.BuildCli.Tests/BuildHostArgumentsTests.cs
 create mode 100644 tests/Ingenium.BuildCli.Tests/RepairStrategyParserTests.cs

diff --git a/README.md b/README.md
index 2c7b0dd..23aa550 100644
--- a/README.md
+++ b/README.md
@@ -15,6 +15,14 @@ buildcli update               Move an existing submodule to the latest tag
 buildcli update --tag v1.2.3  Move an existing submodule to a specific tag
 buildcli status               Show the current submodule path, commit, and tags
 buildcli tags                 List tags advertised by the Build remote
+buildcli repair --strategy stash              Stash local submodule changes, then restore the parent HEAD
+buildcli repair --strategy reset --yes        Discard local changes and restore the parent-recorded HEAD
+buildcli repair --strategy reinit --tag v1.2.3 --yes
+                                              Delete and clone the submodule again at a tagged version
+buildcli build                Run the Build host Default target
+buildcli build TestProjects   Run a specific Cake target in the Build submodule
+buildcli extension            Create build-extensions/{Repo}BuildExtensions
+buildcli extension Framework  Create build-extensions/FrameworkBuildExtensions
 ```
 
 Common options:
@@ -33,6 +41,19 @@ Common options:
 
 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` restores .NET local tools in the submodule when `.config/dotnet-tools.json` is present, then 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 `buildcli` binary and places it on your PATH. Git is required. The .NET 8 SDK is installed automatically when it is missing.
diff --git a/apps/Ingenium.BuildCli/BuildCliApplication.cs b/apps/Ingenium.BuildCli/BuildCliApplication.cs
index dc6427c..c8e056d 100644
--- a/apps/Ingenium.BuildCli/BuildCliApplication.cs
+++ b/apps/Ingenium.BuildCli/BuildCliApplication.cs
@@ -2,8 +2,11 @@
 // 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;
 
@@ -28,7 +31,10 @@ public static CommandApp Create(IAnsiConsole? console = null, Action();
 		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));
@@ -81,5 +87,22 @@ public static void Configure(IConfigurator config)
 			.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.")
+			.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
index eda51db..41b8131 100644
--- a/apps/Ingenium.BuildCli/BuildCliException.cs
+++ b/apps/Ingenium.BuildCli/BuildCliException.cs
@@ -37,4 +37,7 @@ public static class ExitCodes
 	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/Commands/BuildCommand.cs b/apps/Ingenium.BuildCli/Commands/BuildCommand.cs
new file mode 100644
index 0000000..da1a5aa
--- /dev/null
+++ b/apps/Ingenium.BuildCli/Commands/BuildCommand.cs
@@ -0,0 +1,77 @@
+// 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 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 IBuildHostService _host;
+	private readonly IGitTrace _trace;
+
+	/// 
+	/// Initializes a new instance of the  class.
+	/// 
+	public BuildCommand(IAnsiConsole console, IBuildHostService host, IGitTrace trace)
+	{
+		_console = console;
+		_host = host;
+		_trace = trace;
+	}
+
+	/// 
+	public override async Task ExecuteAsync(CommandContext context, Settings settings)
+	{
+		_trace.Enabled = settings.Verbose;
+		ConsoleWriter.WriteHeader(_console, "build");
+
+		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 = settings.ToRequest(),
+			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;
+	}
+
+	/// 
+	/// 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/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/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/Host/BuildHostService.cs b/apps/Ingenium.BuildCli/Host/BuildHostService.cs
new file mode 100644
index 0000000..f7e1e17
--- /dev/null
+++ b/apps/Ingenium.BuildCli/Host/BuildHostService.cs
@@ -0,0 +1,151 @@
+// 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);
+		if (!status.IsRegistered)
+		{
+			throw new BuildCliException(
+				"The Build submodule is not registered in this repository. Run 'buildcli init' first.",
+				ExitCodes.SubmoduleNotFound);
+		}
+
+		if (!status.IsInitialized || string.IsNullOrWhiteSpace(status.RelativePath))
+		{
+			throw new BuildCliException(
+				"The Build submodule is not initialized. Run 'buildcli init' or 'buildcli repair --strategy reinit'.",
+				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/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/Rendering/ConsoleWriter.cs b/apps/Ingenium.BuildCli/Rendering/ConsoleWriter.cs
index 692647f..1a6b113 100644
--- a/apps/Ingenium.BuildCli/Rendering/ConsoleWriter.cs
+++ b/apps/Ingenium.BuildCli/Rendering/ConsoleWriter.cs
@@ -1,6 +1,7 @@
 // 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;
@@ -71,6 +72,69 @@ public static void WriteStatus(IAnsiConsole console, BuildSubmoduleStatus 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.
 	/// 
diff --git a/apps/Ingenium.BuildCli/Submodule/BuildSubmoduleService.cs b/apps/Ingenium.BuildCli/Submodule/BuildSubmoduleService.cs
index 3395430..8265db7 100644
--- a/apps/Ingenium.BuildCli/Submodule/BuildSubmoduleService.cs
+++ b/apps/Ingenium.BuildCli/Submodule/BuildSubmoduleService.cs
@@ -143,6 +143,58 @@ public async Task> ListTagsAsync(BuildSubmoduleRequest
 		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,
@@ -343,6 +395,133 @@ private async Task GetDefaultRemoteRefAsync(SubmoduleContext context, Ca
 			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 = $"buildcli 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))
diff --git a/apps/Ingenium.BuildCli/Submodule/IBuildSubmoduleService.cs b/apps/Ingenium.BuildCli/Submodule/IBuildSubmoduleService.cs
index 5014cad..7112a58 100644
--- a/apps/Ingenium.BuildCli/Submodule/IBuildSubmoduleService.cs
+++ b/apps/Ingenium.BuildCli/Submodule/IBuildSubmoduleService.cs
@@ -27,4 +27,9 @@ public interface IBuildSubmoduleService
 	/// 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/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/tests/Ingenium.BuildCli.Tests/BuildExtensionNamesTests.cs b/tests/Ingenium.BuildCli.Tests/BuildExtensionNamesTests.cs
new file mode 100644
index 0000000..7261e7b
--- /dev/null
+++ b/tests/Ingenium.BuildCli.Tests/BuildExtensionNamesTests.cs
@@ -0,0 +1,19 @@
+// This work is licensed under the terms of the MIT license.
+// For a copy, see .
+
+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/BuildSubmoduleServiceTests.cs b/tests/Ingenium.BuildCli.Tests/BuildSubmoduleServiceTests.cs
index 34a4db9..7356907 100644
--- a/tests/Ingenium.BuildCli.Tests/BuildSubmoduleServiceTests.cs
+++ b/tests/Ingenium.BuildCli.Tests/BuildSubmoduleServiceTests.cs
@@ -182,6 +182,79 @@ public async Task Init_UsesCustomSubmodulePath()
 		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
index 7a39275..401f715 100644
--- a/tests/Ingenium.BuildCli.Tests/CommandAppTests.cs
+++ b/tests/Ingenium.BuildCli.Tests/CommandAppTests.cs
@@ -27,6 +27,9 @@ public async Task Help_ListsPrimaryCommands()
 		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]
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 _));
+	}
+}

From 8dafd9b4dd33ddc2be1d28f44ee32d69daaabe62 Mon Sep 17 00:00:00 2001
From: Cursor Agent 
Date: Sun, 16 Aug 2026 11:23:06 +0000
Subject: [PATCH 3/3] Rename the CLI to bld and default a bare run to build.

A command-less `bld` now routes to `build`, but only after checking that
the Build submodule is registered and initialized. Help and version still
work without starting a build.

Co-authored-by: Matthew Abbott 
---
 README.md                                     | 42 ++++++------
 apps/Ingenium.BuildCli/BuildCliApplication.cs | 15 ++++-
 apps/Ingenium.BuildCli/CliInfo.cs             | 15 +++++
 apps/Ingenium.BuildCli/CommandLineDefaults.cs | 67 +++++++++++++++++++
 .../Commands/BuildCommand.cs                  | 23 ++++++-
 .../Host/BuildHostService.cs                  | 12 +---
 .../Ingenium.BuildCli.csproj                  |  4 +-
 apps/Ingenium.BuildCli/Program.cs             |  2 +-
 .../Submodule/BuildSubmoduleGuard.cs          | 32 +++++++++
 .../Submodule/BuildSubmoduleService.cs        |  6 +-
 scripts/install.ps1                           | 14 ++--
 scripts/install.sh                            | 24 +++----
 .../BuildSubmoduleGuardTests.cs               | 48 +++++++++++++
 .../CommandAppTests.cs                        | 16 +++++
 .../CommandLineDefaultsTests.cs               | 35 ++++++++++
 15 files changed, 298 insertions(+), 57 deletions(-)
 create mode 100644 apps/Ingenium.BuildCli/CliInfo.cs
 create mode 100644 apps/Ingenium.BuildCli/CommandLineDefaults.cs
 create mode 100644 apps/Ingenium.BuildCli/Submodule/BuildSubmoduleGuard.cs
 create mode 100644 tests/Ingenium.BuildCli.Tests/BuildSubmoduleGuardTests.cs
 create mode 100644 tests/Ingenium.BuildCli.Tests/CommandLineDefaultsTests.cs

diff --git a/README.md b/README.md
index 23aa550..7c1a86c 100644
--- a/README.md
+++ b/README.md
@@ -6,23 +6,27 @@ The tool is written in C# and uses [Spectre.Console](https://spectreconsole.net/
 
 ## Commands
 
-Run `buildcli` from any git repository that should host the Build submodule.
+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
-buildcli init                 Add the Build submodule (defaults to the latest tag)
-buildcli init --tag v1.2.3    Add the Build submodule pinned to a specific tag
-buildcli update               Move an existing submodule to the latest tag
-buildcli update --tag v1.2.3  Move an existing submodule to a specific tag
-buildcli status               Show the current submodule path, commit, and tags
-buildcli tags                 List tags advertised by the Build remote
-buildcli repair --strategy stash              Stash local submodule changes, then restore the parent HEAD
-buildcli repair --strategy reset --yes        Discard local changes and restore the parent-recorded HEAD
-buildcli repair --strategy reinit --tag v1.2.3 --yes
-                                              Delete and clone the submodule again at a tagged version
-buildcli build                Run the Build host Default target
-buildcli build TestProjects   Run a specific Cake target in the Build submodule
-buildcli extension            Create build-extensions/{Repo}BuildExtensions
-buildcli extension Framework  Create build-extensions/FrameworkBuildExtensions
+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:
@@ -43,7 +47,7 @@ Existing Ingenium repositories that already use `build` or `Build` as the submod
 
 `repair` can prompt for a strategy when run interactively. `reset` and `reinit` are destructive and require `--yes` in non-interactive use.
 
-`build` restores .NET local tools in the submodule when `.config/dotnet-tools.json` is present, then runs `apps/Build` the same way `./build.sh` does.
+`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:
 
@@ -56,7 +60,7 @@ build-extensions/{Name}BuildExtensions/SampleTask.cs
 
 ## Installation
 
-The installer publishes a self-contained `buildcli` binary and places it on your PATH. Git is required. The .NET 8 SDK is installed automatically when it is missing.
+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
 
@@ -72,7 +76,7 @@ Or later, once this repository is available remotely:
 curl -sSL https://raw.githubusercontent.com/IngeniumSE/BuildCLI/main/scripts/install.sh | bash
 ```
 
-The default install location is `~/.local/share/ingenium/buildcli`, with a symlink at `~/.local/bin/buildcli`. Add `~/.local/bin` to `PATH` if the installer reports that the command is not visible yet.
+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
 
@@ -88,7 +92,7 @@ Or later:
 irm https://raw.githubusercontent.com/IngeniumSE/BuildCLI/main/scripts/install.ps1 | iex
 ```
 
-The default install location is `%LOCALAPPDATA%\Ingenium\BuildCli`. That directory is added to the user `PATH`. Open a new terminal before running `buildcli`.
+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
 
diff --git a/apps/Ingenium.BuildCli/BuildCliApplication.cs b/apps/Ingenium.BuildCli/BuildCliApplication.cs
index c8e056d..acedc81 100644
--- a/apps/Ingenium.BuildCli/BuildCliApplication.cs
+++ b/apps/Ingenium.BuildCli/BuildCliApplication.cs
@@ -50,12 +50,23 @@ public static CommandApp Create(IAnsiConsole? console = null, Action
+	/// 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("buildcli");
+		config.SetApplicationName(CliInfo.Name);
 		config.SetApplicationVersion(AppVersion.Current);
 		config.ValidateExamples();
 
@@ -95,7 +106,7 @@ public static void Configure(IConfigurator config)
 			.WithExample("repair", "--strategy", "reinit", "--tag", "v1.2.3", "--yes");
 
 		config.AddCommand("build")
-			.WithDescription("Run a Cake target through the Build submodule.")
+			.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");
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
index da1a5aa..7b37ed1 100644
--- a/apps/Ingenium.BuildCli/Commands/BuildCommand.cs
+++ b/apps/Ingenium.BuildCli/Commands/BuildCommand.cs
@@ -6,6 +6,7 @@
 using Ingenium.BuildCli.Git;
 using Ingenium.BuildCli.Host;
 using Ingenium.BuildCli.Rendering;
+using Ingenium.BuildCli.Submodule;
 
 using Spectre.Console;
 using Spectre.Console.Cli;
@@ -18,15 +19,17 @@ namespace Ingenium.BuildCli.Commands;
 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, IBuildHostService host, IGitTrace trace)
+	public BuildCommand(IAnsiConsole console, IBuildSubmoduleService submodules, IBuildHostService host, IGitTrace trace)
 	{
 		_console = console;
+		_submodules = submodules;
 		_host = host;
 		_trace = trace;
 	}
@@ -37,6 +40,12 @@ public override async Task ExecuteAsync(CommandContext context, Settings se
 		_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();
@@ -44,7 +53,7 @@ public override async Task ExecuteAsync(CommandContext context, Settings se
 		var extra = context.Remaining.Raw.ToArray();
 		var exitCode = await _host.RunAsync(new BuildHostRequest
 		{
-			Repository = settings.ToRequest(),
+			Repository = request,
 			Target = target,
 			Configuration = settings.Configuration,
 			ExtraArguments = extra
@@ -61,6 +70,16 @@ public override async Task ExecuteAsync(CommandContext context, Settings se
 		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 .
 	/// 
diff --git a/apps/Ingenium.BuildCli/Host/BuildHostService.cs b/apps/Ingenium.BuildCli/Host/BuildHostService.cs
index f7e1e17..30fc81d 100644
--- a/apps/Ingenium.BuildCli/Host/BuildHostService.cs
+++ b/apps/Ingenium.BuildCli/Host/BuildHostService.cs
@@ -34,17 +34,11 @@ public async Task RunAsync(BuildHostRequest request, CancellationToken canc
 		}
 
 		var status = await _submodules.GetStatusAsync(request.Repository, cancellationToken);
-		if (!status.IsRegistered)
+		BuildSubmoduleGuard.EnsureInitialized(status);
+		if (string.IsNullOrWhiteSpace(status.RelativePath))
 		{
 			throw new BuildCliException(
-				"The Build submodule is not registered in this repository. Run 'buildcli init' first.",
-				ExitCodes.SubmoduleNotFound);
-		}
-
-		if (!status.IsInitialized || string.IsNullOrWhiteSpace(status.RelativePath))
-		{
-			throw new BuildCliException(
-				"The Build submodule is not initialized. Run 'buildcli init' or 'buildcli repair --strategy reinit'.",
+				$"The Build submodule path could not be resolved. Run '{CliInfo.Name} init' first.",
 				ExitCodes.SubmoduleNotFound);
 		}
 
diff --git a/apps/Ingenium.BuildCli/Ingenium.BuildCli.csproj b/apps/Ingenium.BuildCli/Ingenium.BuildCli.csproj
index 01407cb..dbe63ff 100644
--- a/apps/Ingenium.BuildCli/Ingenium.BuildCli.csproj
+++ b/apps/Ingenium.BuildCli/Ingenium.BuildCli.csproj
@@ -3,11 +3,11 @@
 	
 		Exe
 		net8.0
-		buildcli
+		bld
 		Ingenium.BuildCli
 		CLI for adding and updating the Ingenium Build git submodule.
 		true
-		buildcli
+		bld
 		Ingenium.BuildCli
 		ingenium;build;git;submodule;cli
 		0.1.0
diff --git a/apps/Ingenium.BuildCli/Program.cs b/apps/Ingenium.BuildCli/Program.cs
index 568bb14..a8ecb82 100644
--- a/apps/Ingenium.BuildCli/Program.cs
+++ b/apps/Ingenium.BuildCli/Program.cs
@@ -3,4 +3,4 @@
 
 using Ingenium.BuildCli;
 
-return await BuildCliApplication.Create().RunAsync(args);
+return await BuildCliApplication.RunAsync(args);
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/BuildSubmoduleService.cs b/apps/Ingenium.BuildCli/Submodule/BuildSubmoduleService.cs
index 8265db7..db10d0b 100644
--- a/apps/Ingenium.BuildCli/Submodule/BuildSubmoduleService.cs
+++ b/apps/Ingenium.BuildCli/Submodule/BuildSubmoduleService.cs
@@ -29,7 +29,7 @@ public async Task InitAsync(BuildSubmoduleRequest request,
 			if (IsInitialized(context))
 			{
 				throw new BuildCliException(
-					$"The Build submodule is already initialized at '{context.RelativePath}'. Use 'buildcli update' to change version.",
+					$"The Build submodule is already initialized at '{context.RelativePath}'. Use '{CliInfo.Name} update' to change version.",
 					ExitCodes.AlreadyInitialized);
 			}
 
@@ -206,7 +206,7 @@ private async Task CreateContextAsync(
 		if (requireRegistered && entry is null)
 		{
 			throw new BuildCliException(
-				"The Build submodule is not registered in this repository. Run 'buildcli init' first.",
+				$"The Build submodule is not registered in this repository. Run '{CliInfo.Name} init' first.",
 				ExitCodes.SubmoduleNotFound);
 		}
 
@@ -433,7 +433,7 @@ private async Task IsDirtyAsync(SubmoduleContext context, CancellationToke
 			return null;
 		}
 
-		var message = $"buildcli repair {DateTimeOffset.UtcNow:yyyy-MM-dd HH:mm:ss} UTC";
+		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],
diff --git a/scripts/install.ps1 b/scripts/install.ps1
index 7b3a12b..5fc7240 100644
--- a/scripts/install.ps1
+++ b/scripts/install.ps1
@@ -1,11 +1,11 @@
-# Installs buildcli onto PATH for Windows.
+# 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:BUILDCLI_INSTALL_DIR) { $env:BUILDCLI_INSTALL_DIR } else { Join-Path $env:LOCALAPPDATA "Ingenium\BuildCli" }),
+	[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
 )
 
@@ -47,7 +47,7 @@ function Get-SourceDirectory {
 	}
 
 	if (-not (Get-Command git -ErrorAction SilentlyContinue)) {
-		throw "git is required to install buildcli."
+		throw "git is required to install bld."
 	}
 
 	$checkout = Join-Path $env:TEMP ("buildcli-src-" + [Guid]::NewGuid().ToString("N"))
@@ -78,14 +78,14 @@ function Add-ToUserPath {
 
 Ensure-Dotnet
 if (-not (Get-Command git -ErrorAction SilentlyContinue)) {
-	throw "git is required to install buildcli."
+	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 buildcli for $rid"
+Write-Log "Publishing bld for $rid"
 $publishArgs = @(
 	"publish", (Join-Path $sourceDir "apps\Ingenium.BuildCli\Ingenium.BuildCli.csproj"),
 	"-c", "Release",
@@ -110,7 +110,7 @@ if ($LASTEXITCODE -ne 0) {
 	throw "dotnet publish failed."
 }
 
-$executable = Join-Path $InstallDir "buildcli.exe"
+$executable = Join-Path $InstallDir "bld.exe"
 if (-not (Test-Path $executable)) {
 	throw "Publish succeeded but $executable was not produced."
 }
@@ -121,4 +121,4 @@ $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: buildcli --help"
+Write-Host "Open a new terminal, then run: bld --help"
diff --git a/scripts/install.sh b/scripts/install.sh
index e962b31..b4687a5 100755
--- a/scripts/install.sh
+++ b/scripts/install.sh
@@ -1,12 +1,12 @@
 #!/usr/bin/env bash
-# Installs buildcli onto PATH for macOS and Linux.
+# 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="${BUILDCLI_INSTALL_DIR:-${HOME}/.local/share/ingenium/buildcli}"
+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}"
 
@@ -20,7 +20,7 @@ fail() {
 }
 
 require() {
-	command -v "$1" >/dev/null 2>&1 || fail "'$1' is required to install buildcli."
+	command -v "$1" >/dev/null 2>&1 || fail "'$1' is required to install bld."
 }
 
 detect_rid() {
@@ -84,7 +84,7 @@ main() {
 	rid="$(detect_rid)"
 	configuration="Release"
 
-	log "Publishing buildcli for ${rid}"
+	log "Publishing bld for ${rid}"
 	mkdir -p "${INSTALL_DIR}" "${BIN_DIR}"
 
 	local publish_args=(
@@ -103,27 +103,27 @@ main() {
 
 	"${publish_args[@]}"
 
-	local executable="${INSTALL_DIR}/buildcli"
+	local executable="${INSTALL_DIR}/bld"
 	[[ -f "${executable}" ]] || fail "Publish succeeded but ${executable} was not produced."
 	chmod +x "${executable}"
 
-	ln -sfn "${executable}" "${BIN_DIR}/buildcli"
+	ln -sfn "${executable}" "${BIN_DIR}/bld"
 	log "Installed ${executable}"
-	log "Linked ${BIN_DIR}/buildcli"
+	log "Linked ${BIN_DIR}/bld"
 
-	if ! command -v buildcli >/dev/null 2>&1; then
+	if ! command -v bld >/dev/null 2>&1; then
 		cat <.
+
+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/CommandAppTests.cs b/tests/Ingenium.BuildCli.Tests/CommandAppTests.cs
index 401f715..fce489e 100644
--- a/tests/Ingenium.BuildCli.Tests/CommandAppTests.cs
+++ b/tests/Ingenium.BuildCli.Tests/CommandAppTests.cs
@@ -5,6 +5,8 @@
 using Ingenium.BuildCli.Submodule;
 using Ingenium.BuildCli.Tests.Support;
 
+using Ingenium.BuildCli;
+
 using Microsoft.Extensions.DependencyInjection;
 
 using Spectre.Console.Cli;
@@ -82,6 +84,20 @@ public async Task InitThenStatus_ShowsCurrentTag()
 		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()
 	{
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"]));
+	}
+}