|
| 1 | +// Copyright (c) Duende Software. All rights reserved. |
| 2 | +// See LICENSE in the project root for license information. |
| 3 | + |
| 4 | +using static Bullseye.Targets; |
| 5 | +using static SimpleExec.Command; |
| 6 | + |
| 7 | +namespace BuildHelpers; |
| 8 | + |
| 9 | +/// <summary> |
| 10 | +/// Registers build targets for product build scripts. |
| 11 | +/// </summary> |
| 12 | +public static class Targets |
| 13 | +{ |
| 14 | + private static readonly Lazy<string> RepoRoot = new(FindRoot); |
| 15 | + |
| 16 | + private const string Restore = "restore"; |
| 17 | + private const string DebugBuild = "debug-build"; |
| 18 | + private const string ReleaseBuild = "release-build"; |
| 19 | + |
| 20 | + public const string CheckFormatting = "check-formatting"; |
| 21 | + public const string Clean = "clean"; |
| 22 | + public const string CheckNoChanges = "check-no-changes"; |
| 23 | + |
| 24 | + private const string Default = "default"; |
| 25 | + |
| 26 | + /// <summary> |
| 27 | + /// Registers all shared targets parameterized by the product's solution filter path. |
| 28 | + /// </summary> |
| 29 | + /// <param name="slnfPath"> |
| 30 | + /// Repo-relative path to the product's solution filter |
| 31 | + /// (e.g. <c>access-token-management/access-token-management.slnf</c>). |
| 32 | + /// </param> |
| 33 | + public static void SharedTargets(string slnfPath) |
| 34 | + { |
| 35 | + ArgumentNullException.ThrowIfNull(slnfPath); |
| 36 | + |
| 37 | + var getChangedCSharpFilesTask = new Lazy<Task<IReadOnlyCollection<string>>>(() => |
| 38 | + GetChangedCSharpFiles(RepoRoot.Value)); |
| 39 | + |
| 40 | + Target(Restore, () => |
| 41 | + RunAsync("dotnet", $"restore {slnfPath}", RepoRoot.Value)); |
| 42 | + |
| 43 | + Target(DebugBuild, dependsOn: [Restore], () => |
| 44 | + RunAsync("dotnet", $"build {slnfPath} --no-restore -c Debug", RepoRoot.Value)); |
| 45 | + |
| 46 | + Target(CheckFormatting, dependsOn: [DebugBuild], async () => |
| 47 | + { |
| 48 | + var changedCSharpFiles = await getChangedCSharpFilesTask.Value; |
| 49 | + if (changedCSharpFiles.Count == 0) |
| 50 | + { |
| 51 | + await Console.Out.WriteLineAsync("No changed files found."); |
| 52 | + return; |
| 53 | + } |
| 54 | + |
| 55 | + var include = string.Join(" ", changedCSharpFiles.Select(file => $"\"{file}\"")); |
| 56 | + await RunAsync("dotnet", $"format {slnfPath} --verify-no-changes --no-restore --include {include}", RepoRoot.Value); |
| 57 | + }); |
| 58 | + |
| 59 | + Target(Clean, () => |
| 60 | + RunAsync("dotnet", $"clean {slnfPath}", RepoRoot.Value)); |
| 61 | + |
| 62 | + Target(ReleaseBuild, dependsOn: [Restore], () => |
| 63 | + RunAsync("dotnet", $"build {slnfPath} --no-restore -c Release", RepoRoot.Value)); |
| 64 | + |
| 65 | + Target(CheckNoChanges, dependsOn: [ReleaseBuild], async () => |
| 66 | + { |
| 67 | + var (output, _) = await ReadAsync("git", "status --porcelain", workingDirectory: RepoRoot.Value); |
| 68 | + |
| 69 | + if (!string.IsNullOrWhiteSpace(output)) |
| 70 | + { |
| 71 | + await Console.Error.WriteLineAsync("Unexpected changes detected after build:"); |
| 72 | + await Console.Error.WriteLineAsync(output); |
| 73 | + throw new InvalidOperationException( |
| 74 | + "Working tree has uncommitted changes. If these are generated files, commit them before pushing."); |
| 75 | + } |
| 76 | + }); |
| 77 | + } |
| 78 | + |
| 79 | + /// <summary> |
| 80 | + /// Registers a test target that runs <c>dotnet test</c> on a test project with standard options. |
| 81 | + /// </summary> |
| 82 | + /// <param name="targetName">The target name (e.g. <c>"test"</c>).</param> |
| 83 | + /// <param name="testProjectPath"> |
| 84 | + /// Repo-relative path to the test project |
| 85 | + /// (e.g. <c>"access-token-management/test/AccessTokenManagement.Tests"</c>). |
| 86 | + /// </param> |
| 87 | + public static void TestTarget(string targetName, string testProjectPath) => |
| 88 | + Target(targetName, dependsOn: [Restore], () => |
| 89 | + RunAsync( |
| 90 | + "dotnet", |
| 91 | + $"test --project {testProjectPath} -c Release --no-restore /p:TreatWarningsAsErrors=false --coverage " + |
| 92 | + $"--report-trx --report-trx-filename {testProjectPath.Replace('/', '-')}-tests.trx", |
| 93 | + RepoRoot.Value)); |
| 94 | + |
| 95 | + public static void DefaultTarget(IEnumerable<string> dependsOn) => |
| 96 | + Target(Default, dependsOn); |
| 97 | + |
| 98 | + public static Task RunTargetsAndExitAsync(IEnumerable<string> args) => |
| 99 | + Bullseye.Targets.RunTargetsAndExitAsync(args, messageOnly: ex => ex is SimpleExec.ExitCodeException); |
| 100 | + |
| 101 | + private static string FindRoot() |
| 102 | + { |
| 103 | + var root = Directory.GetCurrentDirectory(); |
| 104 | + |
| 105 | + // Repositories have a .git folder, worktrees have a .git file, so check for both. |
| 106 | + while (!Directory.Exists(Path.Combine(root, ".git")) && !File.Exists(Path.Combine(root, ".git"))) |
| 107 | + { |
| 108 | + root = Directory.GetParent(root) is { } parent |
| 109 | + ? parent.FullName |
| 110 | + : throw new InvalidOperationException( |
| 111 | + "Could not find repository root (no .git directory or file found)"); |
| 112 | + } |
| 113 | + |
| 114 | + return root; |
| 115 | + } |
| 116 | + |
| 117 | + private static async Task<IReadOnlyCollection<string>> GetChangedCSharpFiles(string repoRoot) |
| 118 | + { |
| 119 | + var mainRef = Environment.GetEnvironmentVariable("GITHUB_ACTIONS") == "true" |
| 120 | + ? "origin/main" |
| 121 | + : "main"; |
| 122 | + |
| 123 | + var (mergeBase, _) = await ReadAsync( |
| 124 | + "git", $"merge-base {mainRef} HEAD", |
| 125 | + repoRoot); |
| 126 | + |
| 127 | + var (committedInBranchOutput, _) = await ReadAsync( |
| 128 | + "git", $"diff --name-only {mergeBase.Trim()}...HEAD", |
| 129 | + repoRoot); |
| 130 | + |
| 131 | + var (stagedOutput, _) = await ReadAsync( |
| 132 | + "git", "diff --cached --name-only", |
| 133 | + repoRoot); |
| 134 | + |
| 135 | + var (unstagedOutput, _) = await ReadAsync( |
| 136 | + "git", "diff --name-only", |
| 137 | + repoRoot); |
| 138 | + |
| 139 | + var committedInBranch = committedInBranchOutput.Split(['\n', '\r'], StringSplitOptions.RemoveEmptyEntries); |
| 140 | + var staged = stagedOutput.Split(['\n', '\r'], StringSplitOptions.RemoveEmptyEntries); |
| 141 | + var unstaged = unstagedOutput.Split(['\n', '\r'], StringSplitOptions.RemoveEmptyEntries); |
| 142 | + |
| 143 | + var paths = committedInBranch.Concat(unstaged).Concat(staged) |
| 144 | + .Where(name => string.Equals(Path.GetExtension(name), ".cs", StringComparison.OrdinalIgnoreCase) && |
| 145 | + File.Exists(Path.Combine(repoRoot, name))); |
| 146 | + |
| 147 | + return new HashSet<string>(paths); |
| 148 | + } |
| 149 | +} |
0 commit comments