From 131f2b7b4eee2adad72556b934fea770e9a33c75 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Wed, 27 May 2026 16:15:37 +0200 Subject: [PATCH 1/5] Save Aspire dashboard login URL to workspace on startup --- developer-cli/Commands/RunCommand.cs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/developer-cli/Commands/RunCommand.cs b/developer-cli/Commands/RunCommand.cs index 1280ada9ca..23c40bcd0c 100644 --- a/developer-cli/Commands/RunCommand.cs +++ b/developer-cli/Commands/RunCommand.cs @@ -312,6 +312,7 @@ private static void TailLogUntilReady(string logPath) { const string readyMarker = "Distributed application started."; const string misleadingShutdownHint = " Press Ctrl+C to shut down."; + const string dashboardLoginMarker = "Login to the dashboard at "; var deadline = DateTime.UtcNow.AddSeconds(60); var offset = 0L; var sawFirstLine = false; @@ -332,6 +333,14 @@ private static void TailLogUntilReady(string logPath) var displayLine = line.Replace(misleadingShutdownHint, "").TrimEnd(); AnsiConsole.WriteLine(displayLine); + var dashboardIndex = line.IndexOf(dashboardLoginMarker, StringComparison.Ordinal); + if (dashboardIndex >= 0) + { + var dashboardUrl = line[(dashboardIndex + dashboardLoginMarker.Length)..].Trim(); + var dashboardUrlPath = Path.Combine(Configuration.WorkspaceFolder, "aspire-dashboard-url.txt"); + File.WriteAllText(dashboardUrlPath, dashboardUrl); + } + if (line.Contains(readyMarker)) { AnsiConsole.MarkupLine("[green]Aspire AppHost is ready.[/]"); From 84f4099901b33997ca1b7c363430b92c9fa39507 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Wed, 27 May 2026 16:31:02 +0200 Subject: [PATCH 2/5] Block running the published CLI alias from a worktree --- developer-cli/Installation/Configuration.cs | 48 +++++++++++++++++++++ developer-cli/Program.cs | 2 + 2 files changed, 50 insertions(+) diff --git a/developer-cli/Installation/Configuration.cs b/developer-cli/Installation/Configuration.cs index f52417f7b1..b46150d46d 100644 --- a/developer-cli/Installation/Configuration.cs +++ b/developer-cli/Installation/Configuration.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using System.Reflection; using System.Runtime.InteropServices; using System.Text.Json; @@ -88,6 +89,53 @@ public static void SaveConfigurationSetting(ConfigurationSetting configurationSe File.WriteAllText(ConfigFile, configuration); } + public static void ExitIfRunningFromWrongRepository() + { + try + { + var cwd = Directory.GetCurrentDirectory(); + var cwdRoot = RunGit("rev-parse --show-toplevel", cwd); + if (cwdRoot is null) return; + + if (string.Equals(Path.GetFullPath(cwdRoot), Path.GetFullPath(SourceCodeFolder), StringComparison.Ordinal)) return; + + AnsiConsole.MarkupLine($"[red]The {AliasName} alias cannot run from a different repository or worktree ([blue]{Path.GetFullPath(cwdRoot)}[/]).[/]"); + AnsiConsole.MarkupLine($"[red]Run it from [blue]{SourceCodeFolder}[/] or from outside any git repository.[/]"); + Environment.Exit(1); + } + catch + { + // Git not available or unexpected error — skip silently + } + } + + private static string? RunGit(string arguments, string workingDirectory) + { + try + { + var process = Process.Start(new ProcessStartInfo + { + FileName = "git", + Arguments = arguments, + WorkingDirectory = workingDirectory, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + } + ); + + if (process is null) return null; + var output = process.StandardOutput.ReadToEnd().Trim(); + process.WaitForExit(); + return process.ExitCode == 0 ? output : null; + } + catch + { + return null; + } + } + public static class Windows { private const char PathDelimiter = ';'; diff --git a/developer-cli/Program.cs b/developer-cli/Program.cs index 6f2135feeb..7b078dde36 100644 --- a/developer-cli/Program.cs +++ b/developer-cli/Program.cs @@ -13,6 +13,8 @@ Environment.Exit(1); } +Configuration.ExitIfRunningFromWrongRepository(); + if (args.Length == 0) { args = ["--help"]; From 94c499deb61912dfa3042578ee0f6050a8cdaa97 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Wed, 27 May 2026 19:53:51 +0200 Subject: [PATCH 3/5] Add banned-push-words command with pre-push hook enforcement --- .../EndpointMetadataTests.cs | 4 +- .../Commands/BannedPushWordsCommand.cs | 222 ++++++++++++++++++ developer-cli/git-hooks/pre-push | 100 +++++++- 3 files changed, 313 insertions(+), 13 deletions(-) create mode 100644 developer-cli/Commands/BannedPushWordsCommand.cs diff --git a/application/account/Tests/ArchitectureTests/EndpointMetadataTests.cs b/application/account/Tests/ArchitectureTests/EndpointMetadataTests.cs index 7e1967707c..a1493bd7de 100644 --- a/application/account/Tests/ArchitectureTests/EndpointMetadataTests.cs +++ b/application/account/Tests/ArchitectureTests/EndpointMetadataTests.cs @@ -42,8 +42,8 @@ public sealed class EndpointMetadataTests : IDisposable "GET:/internal-api/live", "GET:/internal-api/ready", // /internal-api/account/tenants/{id} stays anonymous: server-to-server call from main SCS in - // downstream projects (DataMentor, ProductConnect). Until downstream callers can pass an auth - // token, the BlockInternalApiTransform + ACA private ingress are the perimeter. + // downstream projects. Until downstream callers can pass an auth token, the + // BlockInternalApiTransform + ACA private ingress are the perimeter. "DELETE:/internal-api/account/tenants/{id}", // SinglePageAppFallbackExtensions registers a framework-level catch-all that emits 404 for // any unmatched /internal-api/* path. The 404 emitter is not a callable endpoint. diff --git a/developer-cli/Commands/BannedPushWordsCommand.cs b/developer-cli/Commands/BannedPushWordsCommand.cs new file mode 100644 index 0000000000..67b56b39b7 --- /dev/null +++ b/developer-cli/Commands/BannedPushWordsCommand.cs @@ -0,0 +1,222 @@ +using System.CommandLine; +using System.Text.RegularExpressions; +using DeveloperCli.Installation; +using DeveloperCli.Utilities; +using Spectre.Console; + +namespace DeveloperCli.Commands; + +public sealed class BannedPushWordsCommand : Command +{ + public BannedPushWordsCommand() : base("banned-push-words", "Configure words banned from diffs and which remotes to enforce them on") + { + var wordsArgument = new Argument("words") + { + Description = "Words to ban (replaces the current list)", + Arity = ArgumentArity.ZeroOrMore + }; + var clearOption = new Option("--clear") { Description = "Remove all banned words and enforced remotes" }; + + Arguments.Add(wordsArgument); + Options.Add(clearOption); + + SetAction(parseResult => Execute( + parseResult.GetValue(wordsArgument) ?? [], + parseResult.GetValue(clearOption) + ) + ); + } + + private static void Execute(string[] words, bool clear) + { + var wordsFilePath = Path.Combine(Configuration.PublishFolder, $"{Configuration.AliasName}.banned-push-words"); + var remotesFilePath = Path.Combine(Configuration.PublishFolder, $"{Configuration.AliasName}.banned-push-words-remotes"); + + if (clear) + { + if (File.Exists(wordsFilePath)) File.Delete(wordsFilePath); + if (File.Exists(remotesFilePath)) File.Delete(remotesFilePath); + AnsiConsole.MarkupLine("[green]Cleared all banned push words and enforced remotes.[/]"); + return; + } + + if (words.Length == 0) + { + ListCurrentConfiguration(wordsFilePath, remotesFilePath); + return; + } + + Directory.CreateDirectory(Configuration.PublishFolder); + + SetBannedWords(wordsFilePath, words); + PromptEnforcedRemotes(remotesFilePath); + } + + private static void ListCurrentConfiguration(string wordsFilePath, string remotesFilePath) + { + var hasWords = File.Exists(wordsFilePath) && new FileInfo(wordsFilePath).Length > 0; + var hasRemotes = File.Exists(remotesFilePath) && new FileInfo(remotesFilePath).Length > 0; + + if (!hasWords && !hasRemotes) + { + AnsiConsole.MarkupLine("[yellow]No push guards configured.[/]"); + AnsiConsole.MarkupLine($"[grey]Set up with: [blue]{Configuration.AliasName} banned-push-words Word1 Word2[/][/]"); + return; + } + + if (hasWords) + { + var existingWords = File.ReadAllLines(wordsFilePath).Where(l => !string.IsNullOrWhiteSpace(l)).ToArray(); + AnsiConsole.MarkupLine("[blue]Banned push words:[/]"); + PrintWordsWithPermutations(existingWords); + AnsiConsole.WriteLine(); + } + + if (hasRemotes) + { + var enforcedUrls = File.ReadAllLines(remotesFilePath).Where(l => !string.IsNullOrWhiteSpace(l)).ToArray(); + var remotes = GetPushRemotes(); + AnsiConsole.MarkupLine("[blue]Enforced on remotes:[/]"); + foreach (var url in enforcedUrls) + { + var name = remotes.FirstOrDefault(r => NormalizeGitUrl(r.Url).Equals(url, StringComparison.OrdinalIgnoreCase))?.Name; + AnsiConsole.MarkupLine(name is not null ? $" - [blue]{Markup.Escape(name)}[/] → {Markup.Escape(url)}" : $" - {Markup.Escape(url)}"); + } + } + else if (hasWords) + { + AnsiConsole.MarkupLine("[grey]Enforced on: all remotes (no specific remotes selected)[/]"); + } + } + + private static void SetBannedWords(string filePath, string[] words) + { + File.WriteAllLines(filePath, words); + + AnsiConsole.MarkupLine($"[green]Set {words.Length} banned push word(s):[/]"); + PrintWordsWithPermutations(words); + AnsiConsole.WriteLine(); + } + + private static void PromptEnforcedRemotes(string remotesFilePath) + { + var remotes = GetPushRemotes(); + if (remotes.Length == 0) return; + + if (!AnsiConsole.Profile.Capabilities.Interactive) return; + + var enforcedUrls = File.Exists(remotesFilePath) + ? File.ReadAllLines(remotesFilePath).Where(l => !string.IsNullOrWhiteSpace(l)).ToHashSet(StringComparer.OrdinalIgnoreCase) + : new HashSet(StringComparer.OrdinalIgnoreCase); + + if (enforcedUrls.Count > 0) + { + AnsiConsole.MarkupLine("[blue]Currently enforced on:[/]"); + foreach (var url in enforcedUrls) + { + var name = remotes.FirstOrDefault(r => NormalizeGitUrl(r.Url).Equals(url, StringComparison.OrdinalIgnoreCase))?.Name; + AnsiConsole.MarkupLine(name is not null ? $" - [blue]{Markup.Escape(name)}[/] → {Markup.Escape(url)}" : $" - {Markup.Escape(url)}"); + } + + AnsiConsole.WriteLine(); + } + + var selected = AnsiConsole.Prompt( + new MultiSelectionPrompt() + .Title("Select remotes to [blue]enforce[/] banned words on:") + .NotRequired() + .InstructionsText("[grey](Press [blue][/] to toggle, [green][/] to confirm. None = enforce on all.)[/]") + .UseConverter(r => $"{Markup.Escape(r.Name)} → {Markup.Escape(r.Url)}") + .AddChoices(remotes) + ); + + var normalizedUrls = selected.Select(r => NormalizeGitUrl(r.Url)).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + + if (normalizedUrls.Length > 0) + { + File.WriteAllLines(remotesFilePath, normalizedUrls); + AnsiConsole.MarkupLine($"[green]Enforcing banned words on {normalizedUrls.Length} remote(s):[/]"); + foreach (var remote in selected) + { + AnsiConsole.MarkupLine($" - [blue]{Markup.Escape(remote.Name)}[/] → {Markup.Escape(remote.Url)}"); + } + } + else + { + if (File.Exists(remotesFilePath)) File.Delete(remotesFilePath); + AnsiConsole.MarkupLine("[green]Enforcing banned words on all remotes.[/]"); + } + } + + private static PushRemote[] GetPushRemotes() + { + var output = ProcessHelper.StartProcess("git remote -v", Configuration.SourceCodeFolder, true, exitOnError: false).Trim(); + if (string.IsNullOrEmpty(output)) return []; + + return output.Split('\n') + .Where(line => line.Contains("(push)")) + .Select(line => + { + var parts = line.Split('\t', 2); + var name = parts[0]; + var url = parts[1].Replace("(push)", "").Trim(); + return new PushRemote(name, url); + } + ) + .ToArray(); + } + + private static string NormalizeGitUrl(string url) + { + url = url.Trim(); + url = Regex.Replace(url, "^https?://", ""); + url = Regex.Replace(url, "^git@([^:]+):", "$1/"); + url = Regex.Replace(url, @"\.git$", ""); + return url.TrimEnd('/').ToLowerInvariant(); + } + + private static void PrintWordsWithPermutations(string[] words) + { + foreach (var word in words) + { + var permutations = GeneratePermutations(word); + AnsiConsole.MarkupLine($" - [blue]{Markup.Escape(word)}[/]"); + if (permutations.Length > 0) + { + AnsiConsole.MarkupLine($" [grey]also matches: {Markup.Escape(string.Join(", ", permutations))}[/]"); + } + } + } + + private static string[] GeneratePermutations(string word) + { + var parts = SplitPascalCase(word); + + if (parts.Length <= 1) + { + return [word.ToLowerInvariant(), word.ToUpperInvariant()]; + } + + var lowerParts = parts.Select(p => p.ToLowerInvariant()).ToArray(); + var upperParts = parts.Select(p => p.ToUpperInvariant()).ToArray(); + var camel = lowerParts[0] + string.Join("", parts.Skip(1)); + + return + [ + camel, + string.Join("_", lowerParts), + string.Join("-", lowerParts), + string.Join("_", upperParts), + string.Join(" ", parts), + string.Concat(lowerParts) + ]; + } + + private static string[] SplitPascalCase(string word) + { + var parts = Regex.Split(word, "(?<=[a-z])(?=[A-Z])"); + return parts.Where(p => p.Length > 0).ToArray(); + } + + private sealed record PushRemote(string Name, string Url); +} diff --git a/developer-cli/git-hooks/pre-push b/developer-cli/git-hooks/pre-push index 030c5cb1ae..24cb8ed854 100755 --- a/developer-cli/git-hooks/pre-push +++ b/developer-cli/git-hooks/pre-push @@ -1,5 +1,8 @@ #!/usr/bin/env bash -# Rejects pushes whose branch name does not match the project's naming convention. +# Rejects pushes whose branch name does not match the project's naming convention, +# or whose diff contains words banned via the developer CLI. +# +# Branch-name rules: # GitHub's server-side branch-name restriction (rulesets / metadata restrictions) is Enterprise-only, # so this hook is the source of truth on the free plan. # @@ -11,28 +14,103 @@ # The regex is RE2-compatible (no lookahead) so the same pattern can be reused server-side # if the project ever moves to a plan that supports branch-name rulesets. # +# Banned-word rules: +# The developer CLI stores per-machine banned words in ~/.PlatformPlatform/.banned-push-words. +# Both the diff and the word list are normalized (lowercased, spaces/hyphens/underscores stripped) +# before comparison, so "FooBar" catches foo_bar, Foo Bar, foo-bar, FOOBAR, etc. +# +# An optional remotes file (~/.PlatformPlatform/.banned-push-words-remotes) narrows enforcement +# to specific remotes. If the file is absent or empty, banned words are checked on all pushes. +# Configure with: banned-push-words +# # Installed into .git/hooks/pre-push by `pp install` and re-synced by the CLI's auto-rebuild # whenever any file under developer-cli/git-hooks/ changes. +remote="$1" zero='0000000000000000000000000000000000000000' -pattern='^(demo/|experiment/)?([a-z0-9]{1,7}|[a-z0-9]{9,}|[a-vx-z0-9][a-z0-9]{7}|w[a-np-z0-9][a-z0-9]{6}|wo[a-qs-z0-9][a-z0-9]{5}|wor[a-jl-z0-9][a-z0-9]{4}|work[a-su-z0-9][a-z0-9]{3}|workt[a-qs-z0-9][a-z0-9]{2}|worktr[a-df-z0-9][a-z0-9]|worktre[a-df-z0-9])(-[a-z0-9]+)*$' + +# --- Resolve CLI alias and publish folder --- +repo_root="$(git rev-parse --show-toplevel 2>/dev/null)" +csproj="$repo_root/developer-cli/DeveloperCli.csproj" +alias_name="" +if [ -f "$csproj" ]; then + alias_name=$(sed -n 's/.*\([^<]*\)<\/AssemblyName>.*/\1/p' "$csproj" | head -1) +fi +[ -z "$alias_name" ] && alias_name="pp" + +case "$(uname -s)" in + MINGW*|MSYS*|CYGWIN*) publish_folder="${LOCALAPPDATA}/PlatformPlatform" ;; + *) publish_folder="$HOME/.PlatformPlatform" ;; +esac + +# --- Determine whether banned-word check applies to this remote --- +normalize_git_url() { + local url="$1" + url="${url#https://}" + url="${url#http://}" + if [[ "$url" == git@* ]]; then + url="${url#git@}" + url="${url/://}" + fi + url="${url%.git}" + url="${url%/}" + echo "$url" | tr '[:upper:]' '[:lower:]' +} + +banned_file="${publish_folder}/${alias_name}.banned-push-words" +remotes_file="${publish_folder}/${alias_name}.banned-push-words-remotes" +has_banned_words=false + +if [ -f "$banned_file" ] && grep -q '[^[:space:]]' "$banned_file" 2>/dev/null; then + if [ -f "$remotes_file" ] && grep -q '[^[:space:]]' "$remotes_file" 2>/dev/null; then + push_url=$(git remote get-url --push "$remote" 2>/dev/null) + if [ -n "$push_url" ]; then + normalized_push_url=$(normalize_git_url "$push_url") + if grep -qiFx "$normalized_push_url" "$remotes_file" 2>/dev/null; then + has_banned_words=true + fi + fi + else + has_banned_words=true + fi +fi + +# --- Per-ref checks --- +branch_pattern='^(demo/|experiment/)?([a-z0-9]{1,7}|[a-z0-9]{9,}|[a-vx-z0-9][a-z0-9]{7}|w[a-np-z0-9][a-z0-9]{6}|wo[a-qs-z0-9][a-z0-9]{5}|wor[a-jl-z0-9][a-z0-9]{4}|work[a-su-z0-9][a-z0-9]{3}|workt[a-qs-z0-9][a-z0-9]{2}|worktr[a-df-z0-9][a-z0-9]|worktre[a-df-z0-9])(-[a-z0-9]+)*$' while read -r local_ref local_oid remote_ref remote_oid; do # Skip deletions [ "$local_oid" = "$zero" ] && continue - # Skip non-branch refs (tags, etc.) + # --- Branch name check --- branch="${remote_ref#refs/heads/}" - [ "$branch" = "$remote_ref" ] && continue + if [ "$branch" != "$remote_ref" ] && [ "$branch" != "main" ]; then + if ! printf '%s' "$branch" | grep -Eq "$branch_pattern"; then + echo "pre-push: refusing to push '$branch'." >&2 + echo " Branch names must be lowercase kebab-case, optionally prefixed with 'demo/' or 'experiment/'," >&2 + echo " and must not start with 'worktree-'." >&2 + exit 1 + fi + fi - # The `main` branch is exempt so it can always be pushed regardless of the pattern - [ "$branch" = "main" ] && continue + # --- Banned word check --- + if [ "$has_banned_words" = true ]; then + if [ "$remote_oid" = "$zero" ]; then + range="${remote}/main..${local_oid}" + else + range="${remote_oid}..${local_oid}" + fi - if ! printf '%s' "$branch" | grep -Eq "$pattern"; then - echo "pre-push: refusing to push '$branch'." >&2 - echo " Branch names must be lowercase kebab-case, optionally prefixed with 'demo/' or 'experiment/'," >&2 - echo " and must not start with 'worktree-'." >&2 - exit 1 + if git diff "$range" 2>/dev/null \ + | grep '^+' | grep -v '^+++' \ + | sed 's/[ _-]//g' | tr '[:upper:]' '[:lower:]' \ + | grep -qFf <(grep -v '^$' "$banned_file" | sed 's/[ _-]//g' | tr '[:upper:]' '[:lower:]'); then + echo "pre-push: refusing to push -- the diff contains a banned word." >&2 + echo " Banned words: $(grep -v '^$' "$banned_file" | paste -sd ', ' -)" >&2 + echo " Matching ignores case, spaces, hyphens, and underscores." >&2 + echo " Configure with: $alias_name banned-push-words" >&2 + exit 1 + fi fi done From c1dad15f74d68da50e9ab10e2d489dacf4bb8062 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Wed, 27 May 2026 23:44:41 +0200 Subject: [PATCH 4/5] Show duration in quiet mode output for all CLI commands --- developer-cli/Commands/BuildCommand.cs | 2 +- developer-cli/Commands/End2EndCommand.cs | 9 ++++++++- developer-cli/Commands/FormatCommand.cs | 2 +- developer-cli/Commands/LintCommand.cs | 2 +- 4 files changed, 11 insertions(+), 4 deletions(-) diff --git a/developer-cli/Commands/BuildCommand.cs b/developer-cli/Commands/BuildCommand.cs index 8c883ff90f..eda6203e2b 100644 --- a/developer-cli/Commands/BuildCommand.cs +++ b/developer-cli/Commands/BuildCommand.cs @@ -108,7 +108,7 @@ private static void Execute(bool backend, bool frontend, bool emails, bool devel if (quiet) { - Console.WriteLine("Build succeeded."); + Console.WriteLine($"Build succeeded in {Stopwatch.GetElapsedTime(startTime).Format()}."); } else { diff --git a/developer-cli/Commands/End2EndCommand.cs b/developer-cli/Commands/End2EndCommand.cs index 551455f2af..f972743288 100644 --- a/developer-cli/Commands/End2EndCommand.cs +++ b/developer-cli/Commands/End2EndCommand.cs @@ -226,7 +226,14 @@ private static void Execute( stopwatch.Stop(); - if (!quiet) + if (quiet) + { + Console.WriteLine(overallSuccess + ? $"All tests completed in {stopwatch.Elapsed.TotalSeconds:F1}s." + : $"Some tests failed in {stopwatch.Elapsed.TotalSeconds:F1}s." + ); + } + else { AnsiConsole.MarkupLine(overallSuccess ? $"[green]All tests completed in {stopwatch.Elapsed.TotalSeconds:F1} seconds[/]" diff --git a/developer-cli/Commands/FormatCommand.cs b/developer-cli/Commands/FormatCommand.cs index e4e085c205..238f355cb8 100644 --- a/developer-cli/Commands/FormatCommand.cs +++ b/developer-cli/Commands/FormatCommand.cs @@ -86,7 +86,7 @@ private static void Execute(bool backend, bool frontend, bool developerCli, stri if (quiet) { - Console.WriteLine("Code formatted successfully."); + Console.WriteLine($"Code formatted successfully in {Stopwatch.GetElapsedTime(startTime).Format()}."); } else { diff --git a/developer-cli/Commands/LintCommand.cs b/developer-cli/Commands/LintCommand.cs index a38b95ecc3..d066941223 100644 --- a/developer-cli/Commands/LintCommand.cs +++ b/developer-cli/Commands/LintCommand.cs @@ -90,7 +90,7 @@ private static void Execute(bool backend, bool frontend, bool developerCli, stri Environment.Exit(1); } - Console.WriteLine("Linting completed successfully. No issues found."); + Console.WriteLine($"Linting completed successfully in {Stopwatch.GetElapsedTime(startTime).Format()}. No issues found."); } else { From 1edf228d1a28a881311c7406ecc732de0beff2ac Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Wed, 27 May 2026 23:44:50 +0200 Subject: [PATCH 5/5] Add source state cache to skip redundant format and lint runs --- developer-cli/Commands/FormatCommand.cs | 12 ++++++ developer-cli/Commands/LintCommand.cs | 12 ++++++ developer-cli/Utilities/SourceStateCache.cs | 48 +++++++++++++++++++++ 3 files changed, 72 insertions(+) create mode 100644 developer-cli/Utilities/SourceStateCache.cs diff --git a/developer-cli/Commands/FormatCommand.cs b/developer-cli/Commands/FormatCommand.cs index 238f355cb8..90815de1fa 100644 --- a/developer-cli/Commands/FormatCommand.cs +++ b/developer-cli/Commands/FormatCommand.cs @@ -52,6 +52,16 @@ private static void Execute(bool backend, bool frontend, bool developerCli, stri try { + const string cacheKey = "format"; + if (SourceStateCache.IsUpToDate(cacheKey)) + { + if (quiet) + Console.WriteLine("No changes since last format run, skipping."); + else + AnsiConsole.MarkupLine("[green]No changes since last format run, skipping.[/]"); + return; + } + var initialUncommittedFiles = quiet ? null : GitHelper.GetChangedFiles(); if (!quiet && initialUncommittedFiles!.Count > 0) { @@ -84,6 +94,8 @@ private static void Execute(bool backend, bool frontend, bool developerCli, stri developerCliTime = Stopwatch.GetElapsedTime(startTime) - backendTime - frontendTime; } + SourceStateCache.Save(cacheKey); + if (quiet) { Console.WriteLine($"Code formatted successfully in {Stopwatch.GetElapsedTime(startTime).Format()}."); diff --git a/developer-cli/Commands/LintCommand.cs b/developer-cli/Commands/LintCommand.cs index d066941223..22223fee64 100644 --- a/developer-cli/Commands/LintCommand.cs +++ b/developer-cli/Commands/LintCommand.cs @@ -53,6 +53,16 @@ private static void Execute(bool backend, bool frontend, bool developerCli, stri try { + const string cacheKey = "lint"; + if (SourceStateCache.IsUpToDate(cacheKey)) + { + if (quiet) + Console.WriteLine("No changes since last lint run, skipping."); + else + AnsiConsole.MarkupLine("[green]No changes since last lint run, skipping.[/]"); + return; + } + var startTime = Stopwatch.GetTimestamp(); var backendTime = TimeSpan.Zero; var frontendTime = TimeSpan.Zero; @@ -82,6 +92,8 @@ private static void Execute(bool backend, bool frontend, bool developerCli, stri developerCliTime = Stopwatch.GetElapsedTime(startTime) - backendTime - frontendTime; } + if (!hasIssues) SourceStateCache.Save(cacheKey); + if (quiet) { if (hasIssues) diff --git a/developer-cli/Utilities/SourceStateCache.cs b/developer-cli/Utilities/SourceStateCache.cs new file mode 100644 index 0000000000..29005c142c --- /dev/null +++ b/developer-cli/Utilities/SourceStateCache.cs @@ -0,0 +1,48 @@ +using System.Security.Cryptography; +using System.Text; +using DeveloperCli.Installation; + +namespace DeveloperCli.Utilities; + +public static class SourceStateCache +{ + private static readonly string CacheDirectory = Path.Combine(Configuration.WorkspaceFolder, "developer-cli", "cache"); + + public static bool IsUpToDate(string cacheKey) + { + var cachePath = GetCachePath(cacheKey); + if (!File.Exists(cachePath)) return false; + + try + { + var savedState = File.ReadAllText(cachePath).Trim(); + return savedState == ComputeStateKey(); + } + catch + { + return false; + } + } + + public static void Save(string cacheKey) + { + try + { + Directory.CreateDirectory(CacheDirectory); + File.WriteAllText(GetCachePath(cacheKey), ComputeStateKey()); + } + catch + { + // Caching is best-effort; failures should not break the workflow + } + } + + private static string GetCachePath(string cacheKey) => Path.Combine(CacheDirectory, $"{cacheKey}.hash"); + + private static string ComputeStateKey() + { + var head = ProcessHelper.StartProcess("git rev-parse HEAD", Configuration.SourceCodeFolder, redirectOutput: true, exitOnError: false).Trim(); + var stash = ProcessHelper.StartProcess("git stash create", Configuration.SourceCodeFolder, redirectOutput: true, exitOnError: false).Trim(); + return Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes($"{head}:{stash}"))); + } +}