diff --git a/CHANGELOG.md b/CHANGELOG.md index 60d361f..f32ffbe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,12 +13,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - **Project values moved out of client options.** `DotNetBuildSettings`, `PackageRepository`, `ReleaseSettings`, `DockerImages` and `TofuModule` are arguments now; `GitOptions` and `NuGetOptions` keep only what the environment sets. - **Clients are scoped to the run.** Anything registered as a singleton that depends on one fails `Build`. - **A resource detector reads the application's services**, not the run's. +- **`IGit` acts on the repository each call names.** Every method takes the repository first, and `InRepository` is gone. +- **A command with no directory of its own runs in the project root inside a run, and in the process's directory outside one.** ### Added - **`WorkflowApplication.Run(workflow, job, arguments, directory, options)`**, for a host that chooses the job and its arguments itself, and `WorkflowApplication.Workflows`. - **`JobCommand.Create` and a public `WorkflowFlags`**, for a host building its own command line from Ritten's commands. - **`WorkflowRegistry.Add()` builds the workflow from the application's services**, so it can take dependencies. +- **`services.AddGit()` and `services.AddCommandRunner()`** register the clients with any host's services, such as a background service's. Outside a run nothing is narrated. +- **The command runner logs what it runs through `ILogger`**: the executable, its directory and its exit code, never its arguments. ### Removed diff --git a/CLAUDE.md b/CLAUDE.md index 56d176a..4b6cfaf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -66,7 +66,7 @@ Projects: `src/Ritten.Core` is the engine — package `Ritten.Core`, released in `NuGet/` and the release-state half of `Releases/` (`ReleaseState`, `ReleaseLine`, `PackagePublication`, `ReleasableGate`) are the `Ritten.NuGet` package, laid out like `Ritten.Core` (root namespace `Ritten`, a folder per namespace) so the namespaces didn't move with them. What only preparing a release needs — `ReleaseKind`, `RequestedVersion`, `NuGetVersion.Next`, `PrepareVersion` — stays in the tool. -Each domain folder — `Changelogs/`, `CodeCoverage/`, `Commands/`, `DotNet/`, `Git/`, `GitHub/`, `NuGet/`, `Releases/`, `Reporting/` — owns its client interface, the argument types its steps read, steps (in a `Steps/` subfolder), and a `WorkflowBuilderExtensions.cs` whose `Add*()` registers the client and its dry-run pairing, never a project's values. External processes (dotnet, git, gh) run through `Commands/ICommandRunner`. +Each domain folder — `Changelogs/`, `CodeCoverage/`, `Commands/`, `DotNet/`, `Git/`, `GitHub/`, `NuGet/`, `Releases/`, `Reporting/` — owns its client interface, the argument types its steps read, steps (in a `Steps/` subfolder), and a `WorkflowBuilderExtensions.cs` whose `Add*()` registers the client and its dry-run pairing, never a project's values. External processes (dotnet, git, gh) run through `Commands/ICommandRunner`, in the run's project root unless a command names its own directory. `ICommandRunner` and `IGit` also register on a plain `IServiceCollection` (`services.AddCommandRunner()`, `services.AddGit()`) for hosts with no workflow run: there the narrative is a silent `IWorkflowLog`, which `Build` drops because an application always has the run's, and commands run in the process's directory. **Clients hold no repository or project of their own** — `IGit` takes the repository on every call, `IOpenTofu` its module — so one client serves every repository a host works with. Beside the narrative, `CommandRunner` logs each command through `ILogger` (executable, directory, exit code; never the arguments), so a host's own logging hears what ran without third-party loggers reaching the console. `Workflows/DotNetTool/` (`"workflow": "dotnet-tool"`) holds the workflow, its job classes (`init`, `status`, `build`, `install`, `prepare`, `check`, `deploy`) and `DotNetToolArguments` — the `ritten.json` schema — with `PrepareDotNetToolArguments`/`InstallDotNetToolArguments` adding the options only those jobs take; `init` neither requires a project nor reports. `Workflows/DotNetPackage/` (`"dotnet-package"`) is its deliberately-identical sibling for library packages — flat siblings, duplicated declarations, no sharing *across* workflows. `Workflows/WorkflowApplicationBuilderExtensions.cs` composes them for `Program.cs`. diff --git a/Directory.Packages.props b/Directory.Packages.props index eaeaaad..573bd30 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -7,6 +7,7 @@ + diff --git a/src/Ritten.Core/Commands/CommandRunner.cs b/src/Ritten.Core/Commands/CommandRunner.cs index 6205efb..a15b306 100644 --- a/src/Ritten.Core/Commands/CommandRunner.cs +++ b/src/Ritten.Core/Commands/CommandRunner.cs @@ -1,11 +1,19 @@ using System.Diagnostics; using System.Text; +using Microsoft.Extensions.Logging; using Ritten.Contracts.FileSystem; using Ritten.Reporting; namespace Ritten.Commands; -internal class CommandRunner(IWorkflowLog log, IFileSystem fileSystem) : ICommandRunner +/// +/// Runs commands, narrating them to the run's log and recording them as diagnostics. +/// +/// The run's narrative; silent outside a run. +/// Where diagnostics go, whatever the host does with them. +/// The run's file system, whose project root a command runs in unless it names its own +/// directory; outside a run there is none, and commands run in the process's directory. +internal class CommandRunner(IWorkflowLog log, ILogger logger, IFileSystem? fileSystem = null) : ICommandRunner { public async Task Run(Command command, CancellationToken cancellationToken = default) { @@ -33,12 +41,13 @@ public async Task Run(Command command, CancellationToken cancella private async Task Execute(Command command, CancellationToken cancellationToken) { + var directory = Path.Combine(fileSystem?.ProjectRoot.AbsolutePath ?? Environment.CurrentDirectory, command.WorkingDirectory ?? string.Empty); using var process = new Process(); process.EnableRaisingEvents = true; process.StartInfo = new ProcessStartInfo { FileName = command.Path, - WorkingDirectory = Path.Combine(fileSystem.ProjectRoot.AbsolutePath, command.WorkingDirectory ?? string.Empty), + WorkingDirectory = directory, RedirectStandardInput = command.StandardInput is not null, RedirectStandardOutput = true, RedirectStandardError = true, @@ -75,6 +84,8 @@ private async Task Execute(Command command, CancellationToken can log.Verbose($"Running `{command.Path} {string.Join(" ", command.Arguments)}`"); } + // The executable and where it ran, never the arguments: they can carry secrets. + logger.LogDebug("Running {Executable} in {Directory}.", Path.GetFileName(command.Path), directory); process.Start(); process.BeginOutputReadLine(); process.BeginErrorReadLine(); @@ -109,10 +120,12 @@ await Task.WhenAny( var exitLogLevel = process.ExitCode == 0 ? WorkflowLogLevel.Verbose : WorkflowLogLevel.Detail; log.Log(exitLogLevel, $"Exit code: {process.ExitCode}"); + logger.LogDebug("{Executable} exited with code {ExitCode}.", Path.GetFileName(command.Path), process.ExitCode); var result = new CommandResult(process.ExitCode, stdOut.ToString(), stdErr.ToString()); if (command.ThrowsOnError && result.IsError) { + logger.LogWarning("{Executable} failed with exit code {ExitCode}.", Path.GetFileName(command.Path), result.ExitCode.Value); throw new CommandFailedException(FailureMessage(command, result), result); } diff --git a/src/Ritten.Core/Commands/ServiceCollectionExtensions.cs b/src/Ritten.Core/Commands/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..bd5536f --- /dev/null +++ b/src/Ritten.Core/Commands/ServiceCollectionExtensions.cs @@ -0,0 +1,25 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Ritten.Reporting; + +namespace Ritten.Commands; + +/// +/// Registers the command runner with any host's services, inside a workflow application or not. +/// +public static class ServiceCollectionExtensions +{ + extension(IServiceCollection services) + { + /// + /// Adds the command runner, scoped to the unit of work it serves. + /// + public IServiceCollection AddCommandRunner() + { + services.AddLogging(); + services.TryAddSingleton(); + services.TryAddScoped(); + return services; + } + } +} diff --git a/src/Ritten.Core/Commands/WorkflowBuilderExtensions.cs b/src/Ritten.Core/Commands/WorkflowBuilderExtensions.cs index 3a34611..cf211fd 100644 --- a/src/Ritten.Core/Commands/WorkflowBuilderExtensions.cs +++ b/src/Ritten.Core/Commands/WorkflowBuilderExtensions.cs @@ -1,4 +1,3 @@ -using Microsoft.Extensions.DependencyInjection.Extensions; using Ritten.Engine; namespace Ritten.Commands; @@ -15,7 +14,7 @@ public static class WorkflowBuilderExtensions /// public IWorkflowBuilder AddCommandRunner() { - builder.Services.TryAddScoped(); + builder.Services.AddCommandRunner(); return builder; } } diff --git a/src/Ritten.Core/Engine/WorkflowApplicationBuilder.cs b/src/Ritten.Core/Engine/WorkflowApplicationBuilder.cs index 36fddfb..57bab3b 100644 --- a/src/Ritten.Core/Engine/WorkflowApplicationBuilder.cs +++ b/src/Ritten.Core/Engine/WorkflowApplicationBuilder.cs @@ -68,7 +68,8 @@ internal Result Build(Func environment) var services = new ServiceCollection(); var decorators = new DecoratorRegistry().Decorate(); AddRunFacts(services, runtime.Value); - foreach (var service in Services) + // A client registered for any host brings a silent narrative for when there's no run; here there always is. + foreach (var service in Services.Where(s => s.ImplementationType != typeof(SilentWorkflowLog))) { services.Add(service); } @@ -118,6 +119,7 @@ private static Result Fail(List errors) private static void AddRunFacts(IServiceCollection services, DetectRuntimeResult runtime) { services.AddOptions(); + services.AddLogging(); services.AddSingleton(TimeProvider.System); services.AddSingleton(new WorkflowEnvironment(runtime.Environment)); services.AddScoped(); @@ -127,6 +129,10 @@ private static void AddRunFacts(IServiceCollection services, DetectRuntimeResult services.AddScoped(provider => Current(provider).Job.Steps); services.AddScoped(provider => Current(provider).Console); services.AddScoped(provider => provider.GetRequiredService()); + + // Ahead of the host's services, so a client registered for any host finds the run's narrative already + // there; a host that registers its own still wins. + services.AddScoped(provider => provider.GetRequiredService()); services.AddScoped(provider => { var run = Current(provider); @@ -139,7 +145,6 @@ private static void AddRunFacts(IServiceCollection services, DetectRuntimeResult /// private static void AddDefaults(IServiceCollection services) { - services.TryAddScoped(provider => provider.GetRequiredService()); services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); diff --git a/src/Ritten.Core/Reporting/SilentWorkflowLog.cs b/src/Ritten.Core/Reporting/SilentWorkflowLog.cs new file mode 100644 index 0000000..5494c05 --- /dev/null +++ b/src/Ritten.Core/Reporting/SilentWorkflowLog.cs @@ -0,0 +1,15 @@ +namespace Ritten.Reporting; + +/// +/// The narrative outside a run, where nobody is watching one. +/// +internal sealed class SilentWorkflowLog : IWorkflowLog +{ + /// + public bool IsEnabled(WorkflowLogLevel level) => false; + + /// + public void Log(WorkflowLogLevel level, string? message, Exception? exception = null) + { + } +} diff --git a/src/Ritten.Core/Ritten.Core.csproj b/src/Ritten.Core/Ritten.Core.csproj index d1fd3b9..9c600b6 100644 --- a/src/Ritten.Core/Ritten.Core.csproj +++ b/src/Ritten.Core/Ritten.Core.csproj @@ -14,6 +14,7 @@ + diff --git a/src/Ritten.DotNet/Steps/ResolveRelease.cs b/src/Ritten.DotNet/Steps/ResolveRelease.cs index 3082f5d..5ec0f0b 100644 --- a/src/Ritten.DotNet/Steps/ResolveRelease.cs +++ b/src/Ritten.DotNet/Steps/ResolveRelease.cs @@ -1,4 +1,5 @@ using Ritten.Contracts; +using Ritten.Contracts.FileSystem; using Ritten.Git; using Ritten.Reporting; @@ -9,8 +10,9 @@ namespace Ritten.DotNet.Steps; /// /// The workflow log. /// The git client. +/// The file system, whose project root is the repository asked. [Step("resolve release", StepKind.Work)] -public class ResolveRelease(IWorkflowLog log, IGit git) +public class ResolveRelease(IWorkflowLog log, IGit git, IFileSystem fileSystem) { /// /// Derives the release's identity from the already-read projects. @@ -31,7 +33,7 @@ public async Task> Run(PackageRepository? declared, PackageS // wins, then the project file's RepositoryUrl, then the origin remote. var repository = declared?.Url ?? release.Repository - ?? RepositoryUrls.ToWebUrl(await git.GetRemoteUrl("origin", cancellationToken)); + ?? RepositoryUrls.ToWebUrl(await git.GetRemoteUrl(fileSystem.ProjectRoot, "origin", cancellationToken)); log.Detail($"Releasing as {release.Name} (v{release.Version})."); log.Verbose($"Repository: {repository ?? "unknown"}."); diff --git a/src/Ritten.Git/DryRunGit.cs b/src/Ritten.Git/DryRunGit.cs index 91a77fa..98ac18b 100644 --- a/src/Ritten.Git/DryRunGit.cs +++ b/src/Ritten.Git/DryRunGit.cs @@ -9,63 +9,60 @@ namespace Ritten.Git; internal class DryRunGit(IWorkflowLog log, IGit inner) : IGit { /// - public IGit InRepository(IDirectory repository) => new DryRunGit(log, inner.InRepository(repository)); + public Task IsRepository(IDirectory repository, CancellationToken ct = default) => inner.IsRepository(repository, ct); /// - public Task IsRepository(CancellationToken ct = default) => inner.IsRepository(ct); + public Task RepositoryRoot(IDirectory repository, CancellationToken ct = default) => + inner.RepositoryRoot(repository, ct); /// - public Task RepositoryRoot(CancellationToken ct = default) => - inner.RepositoryRoot(ct); + public Task CurrentBranch(IDirectory repository, CancellationToken ct = default) => + inner.CurrentBranch(repository, ct); /// - public Task CurrentBranch(CancellationToken ct = default) => - inner.CurrentBranch(ct); + public Task Upstream(IDirectory repository, CancellationToken ct = default) => + inner.Upstream(repository, ct); /// - public Task Upstream(CancellationToken ct = default) => - inner.Upstream(ct); + public Task CommitsAhead(IDirectory repository, string reference, CancellationToken ct = default) => + inner.CommitsAhead(repository, reference, ct); /// - public Task CommitsAhead(string reference, CancellationToken ct = default) => - inner.CommitsAhead(reference, ct); + public Task GetRemoteUrl(IDirectory repository, string remote, CancellationToken ct = default) => + inner.GetRemoteUrl(repository, remote, ct); /// - public Task GetRemoteUrl(string remote, CancellationToken ct = default) => - inner.GetRemoteUrl(remote, ct); - - /// - public Task AddRemote(string remote, string url, CancellationToken ct = default) + public Task AddRemote(IDirectory repository, string remote, string url, CancellationToken ct = default) { log.Skipped($"Would add remote {remote} at {url}."); return Task.CompletedTask; } /// - public Task Show(string reference, string path, CancellationToken ct = default) => - inner.Show(reference, path, ct); + public Task Show(IDirectory repository, string reference, string path, CancellationToken ct = default) => + inner.Show(repository, reference, path, ct); /// - public Task> ChangedFiles(string path, CancellationToken ct = default) => - inner.ChangedFiles(path, ct); + public Task> ChangedFiles(IDirectory repository, string path, CancellationToken ct = default) => + inner.ChangedFiles(repository, path, ct); /// - public Task> ChangedFilesSince(string reference, string path, CancellationToken ct = default) => - inner.ChangedFilesSince(reference, path, ct); + public Task> ChangedFilesSince(IDirectory repository, string reference, string path, CancellationToken ct = default) => + inner.ChangedFilesSince(repository, reference, path, ct); /// - public Task FetchMergeBase(string remote, string branch, CancellationToken ct = default) => - inner.FetchMergeBase(remote, branch, ct); + public Task FetchMergeBase(IDirectory repository, string remote, string branch, CancellationToken ct = default) => + inner.FetchMergeBase(repository, remote, branch, ct); /// - public Task Stage(string path, CancellationToken ct = default) + public Task Stage(IDirectory repository, string path, CancellationToken ct = default) { log.Skipped($"Would stage the changes under {path}."); return Task.CompletedTask; } /// - public Task Commit(string message, CancellationToken ct = default) + public Task Commit(IDirectory repository, string message, CancellationToken ct = default) { log.Skipped($"Would commit \"{message}\"."); return Task.CompletedTask; @@ -73,6 +70,7 @@ public Task Commit(string message, CancellationToken ct = default) /// public Task Push( + IDirectory repository, string remote, string branch, GitCredential? credential = null, @@ -84,33 +82,33 @@ public Task Push( } /// - public Task TagExists(string tag, CancellationToken ct = default) => - inner.TagExists(tag, ct); + public Task TagExists(IDirectory repository, string tag, CancellationToken ct = default) => + inner.TagExists(repository, tag, ct); /// - public Task> Tags(string pattern = "*", CancellationToken ct = default) => - inner.Tags(pattern, ct); + public Task> Tags(IDirectory repository, string pattern = "*", CancellationToken ct = default) => + inner.Tags(repository, pattern, ct); /// - public Task IsShallow(CancellationToken ct = default) => inner.IsShallow(ct); + public Task IsShallow(IDirectory repository, CancellationToken ct = default) => inner.IsShallow(repository, ct); /// - public Task> TrackedFiles(IReadOnlyList? pathspecs = null, CancellationToken ct = default) => - inner.TrackedFiles(pathspecs, ct); + public Task> TrackedFiles(IDirectory repository, IReadOnlyList? pathspecs = null, CancellationToken ct = default) => + inner.TrackedFiles(repository, pathspecs, ct); /// - public Task RemoteTagExists(string remote, string tag, CancellationToken ct = default) => - inner.RemoteTagExists(remote, tag, ct); + public Task RemoteTagExists(IDirectory repository, string remote, string tag, CancellationToken ct = default) => + inner.RemoteTagExists(repository, remote, tag, ct); /// - public Task CreateTag(string tag, string? commit = null, CancellationToken ct = default) + public Task CreateTag(IDirectory repository, string tag, string? commit = null, CancellationToken ct = default) { log.Skipped($"Would create tag {tag}{(commit is null ? "" : $" at {commit}")}."); return Task.CompletedTask; } /// - public Task PushTag(string remote, string tag, CancellationToken ct = default) + public Task PushTag(IDirectory repository, string remote, string tag, CancellationToken ct = default) { log.Skipped($"Would push tag {tag} to {remote}."); return Task.CompletedTask; diff --git a/src/Ritten.Git/GitClient.cs b/src/Ritten.Git/GitClient.cs index 11a5f3c..30bbdf2 100644 --- a/src/Ritten.Git/GitClient.cs +++ b/src/Ritten.Git/GitClient.cs @@ -4,99 +4,84 @@ namespace Ritten.Git; -internal class GitClient : IGit +internal class GitClient(ICommandRunner commands) : IGit { - private readonly ICommandRunner _commands; - private readonly IDirectory? _repository; - - public GitClient(ICommandRunner commands) : this(commands, null) - { - } - - private GitClient(ICommandRunner commands, IDirectory? repository) - { - _commands = commands; - _repository = repository; - } - - public IGit InRepository(IDirectory repository) => new GitClient(_commands, repository); - - public async Task IsRepository(CancellationToken ct = default) + public async Task IsRepository(IDirectory repository, CancellationToken ct = default) { // Answers "true" inside a working tree and fails outside one (or inside a bare // repository, which has no working tree to commit in), so the exit code is the answer. - var result = await _commands.Run(Git("rev-parse", "--is-inside-work-tree").QuietOutput(), ct); + var result = await commands.Run(Git(repository, "rev-parse", "--is-inside-work-tree").QuietOutput(), ct); return result.IsSuccess && result.StandardOutput.Trim() == "true"; } - public async Task RepositoryRoot(CancellationToken ct = default) + public async Task RepositoryRoot(IDirectory repository, CancellationToken ct = default) { - var result = await _commands.Run( - Git("rev-parse", "--show-toplevel").QuietOutput(), + var result = await commands.Run( + Git(repository, "rev-parse", "--show-toplevel").QuietOutput(), ct); return result.IsSuccess && !string.IsNullOrWhiteSpace(result.StandardOutput) ? new PhysicalDirectory(result.StandardOutput.Trim()) : null; } - public async Task CurrentBranch(CancellationToken ct = default) + public async Task CurrentBranch(IDirectory repository, CancellationToken ct = default) { // Prints nothing rather than failing when HEAD is detached, so an empty answer is the null. - var result = await _commands.Run( - Git("branch", "--show-current").QuietOutput(), + var result = await commands.Run( + Git(repository, "branch", "--show-current").QuietOutput(), ct); return result.IsSuccess && !string.IsNullOrWhiteSpace(result.StandardOutput) ? result.StandardOutput.Trim() : null; } - public async Task Upstream(CancellationToken ct = default) + public async Task Upstream(IDirectory repository, CancellationToken ct = default) { // A branch without an upstream is an expected answer, not a failure. - var result = await _commands.Run( - Git("rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}").QuietOutput(), + var result = await commands.Run( + Git(repository, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}").QuietOutput(), ct); return result.IsSuccess && !string.IsNullOrWhiteSpace(result.StandardOutput) ? result.StandardOutput.Trim() : null; } - public async Task CommitsAhead(string reference, CancellationToken ct = default) + public async Task CommitsAhead(IDirectory repository, string reference, CancellationToken ct = default) { - var result = await _commands.Run( - Git("rev-list", "--count", $"{reference}..HEAD").QuietOutput().ThrowOnError(), + var result = await commands.Run( + Git(repository, "rev-list", "--count", $"{reference}..HEAD").QuietOutput().ThrowOnError(), ct); return int.Parse(result.StandardOutput.Trim(), System.Globalization.CultureInfo.InvariantCulture); } - public async Task GetRemoteUrl(string remote, CancellationToken ct = default) + public async Task GetRemoteUrl(IDirectory repository, string remote, CancellationToken ct = default) { - var result = await _commands.Run( - Git("remote", "get-url", remote).QuietOutput(), + var result = await commands.Run( + Git(repository, "remote", "get-url", remote).QuietOutput(), ct); return result.IsSuccess && !string.IsNullOrWhiteSpace(result.StandardOutput) ? result.StandardOutput.Trim() : null; } - public async Task AddRemote(string remote, string url, CancellationToken ct = default) => - await _commands.Run(Git("remote", "add", remote, url).QuietOutput().ThrowOnError(), ct); + public async Task AddRemote(IDirectory repository, string remote, string url, CancellationToken ct = default) => + await commands.Run(Git(repository, "remote", "add", remote, url).QuietOutput().ThrowOnError(), ct); - public async Task Show(string reference, string path, CancellationToken ct = default) + public async Task Show(IDirectory repository, string reference, string path, CancellationToken ct = default) { // A file that doesn't exist at the reference is an expected answer, not a failure. - var result = await _commands.Run( - Git("show", $"{reference}:{path}").QuietOutput(), + var result = await commands.Run( + Git(repository, "show", $"{reference}:{path}").QuietOutput(), ct); return result.IsSuccess ? result.StandardOutput : null; } - public async Task> ChangedFilesSince(string reference, string path, CancellationToken ct = default) + public async Task> ChangedFilesSince(IDirectory repository, string reference, string path, CancellationToken ct = default) { // Three dots, not two: the merge base rather than the reference's tip, so work the base // has done since the branch started is not reported as this branch's. - var result = await _commands.Run( - Git("diff", "--name-only", $"{reference}...HEAD", "--", path).QuietOutput().ThrowOnError(), + var result = await commands.Run( + Git(repository, "diff", "--name-only", $"{reference}...HEAD", "--", path).QuietOutput().ThrowOnError(), ct); return @@ -108,30 +93,30 @@ .. result.StandardOutput ]; } - public async Task FetchMergeBase(string remote, string branch, CancellationToken ct = default) + public async Task FetchMergeBase(IDirectory repository, string remote, string branch, CancellationToken ct = default) { string[] refspec = [remote, $"+refs/heads/{branch}:refs/remotes/{remote}/{branch}"]; - if (!await IsShallow(ct)) + if (!await IsShallow(repository, ct)) { - await _commands.Run(Git(["fetch", "--quiet", "--no-tags", .. refspec]).QuietOutput().ThrowOnError(), ct); + await commands.Run(Git(repository, ["fetch", "--quiet", "--no-tags", .. refspec]).QuietOutput().ThrowOnError(), ct); return; } // Fetching the base alone is not enough: HEAD's own history ends at the shallow boundary, // so the two never meet. Naming HEAD's commit deepens it alongside, and a few rounds find // the merge base of any ordinary pull request without fetching the whole history. - var head = (await _commands.Run(Git("rev-parse", "HEAD").QuietOutput().ThrowOnError(), ct)).StandardOutput.Trim(); + var head = (await commands.Run(Git(repository, "rev-parse", "HEAD").QuietOutput().ThrowOnError(), ct)).StandardOutput.Trim(); foreach (var depth in MergeBaseDepths) { // Not every server serves a commit by its id, so a refused fetch falls through to the last resort. - var fetched = await _commands.Run(Git(["fetch", "--quiet", "--no-tags", $"--depth={depth}", .. refspec, head]).QuietOutput(), ct); - if (fetched.IsSuccess && await HasMergeBase($"refs/remotes/{remote}/{branch}", ct)) + var fetched = await commands.Run(Git(repository, ["fetch", "--quiet", "--no-tags", $"--depth={depth}", .. refspec, head]).QuietOutput(), ct); + if (fetched.IsSuccess && await HasMergeBase(repository, $"refs/remotes/{remote}/{branch}", ct)) { return; } } - await _commands.Run(Git(["fetch", "--quiet", "--no-tags", "--unshallow", .. refspec]).QuietOutput().ThrowOnError(), ct); + await commands.Run(Git(repository, ["fetch", "--quiet", "--no-tags", "--unshallow", .. refspec]).QuietOutput().ThrowOnError(), ct); } /// @@ -139,14 +124,14 @@ public async Task FetchMergeBase(string remote, string branch, CancellationToken /// internal static readonly int[] MergeBaseDepths = [50, 1000]; - private async Task HasMergeBase(string reference, CancellationToken ct) => - (await _commands.Run(Git("merge-base", reference, "HEAD").QuietOutput(), ct)).IsSuccess; + private async Task HasMergeBase(IDirectory repository, string reference, CancellationToken ct) => + (await commands.Run(Git(repository, "merge-base", reference, "HEAD").QuietOutput(), ct)).IsSuccess; - public async Task> ChangedFiles(string path, CancellationToken ct = default) + public async Task> ChangedFiles(IDirectory repository, string path, CancellationToken ct = default) { // --porcelain rather than `diff --quiet` so that untracked files are reported too. - var result = await _commands.Run( - Git("status", "--porcelain", "--", path).QuietOutput().ThrowOnError(), + var result = await commands.Run( + Git(repository, "status", "--porcelain", "--", path).QuietOutput().ThrowOnError(), ct); // Porcelain status codes are fixed-width ("XY "), so the path starts at offset 3 — @@ -165,20 +150,21 @@ .. result.StandardOutput ]; } - public async Task Stage(string path, CancellationToken ct = default) => - await _commands.Run(Git("add", "--all", "--", path).QuietOutput().ThrowOnError(), ct); + public async Task Stage(IDirectory repository, string path, CancellationToken ct = default) => + await commands.Run(Git(repository, "add", "--all", "--", path).QuietOutput().ThrowOnError(), ct); - public async Task Commit(string message, CancellationToken ct = default) => - await _commands.Run(Git("commit", "--quiet", "--message", message).ThrowOnError(), ct); + public async Task Commit(IDirectory repository, string message, CancellationToken ct = default) => + await commands.Run(Git(repository, "commit", "--quiet", "--message", message).ThrowOnError(), ct); public async Task Push( + IDirectory repository, string remote, string branch, GitCredential? credential = null, bool setUpstream = false, CancellationToken ct = default) { - var command = Git("push", "--quiet"); + var command = Git(repository, "push", "--quiet"); if (setUpstream) { command = command.AndArguments("--set-upstream"); @@ -190,40 +176,40 @@ public async Task Push( command = command.WithEnvironmentVariables(CredentialEnvironment(credential)); } - await _commands.Run(command.ThrowOnError(), ct); + await commands.Run(command.ThrowOnError(), ct); } - public async Task TagExists(string tag, CancellationToken ct = default) + public async Task TagExists(IDirectory repository, string tag, CancellationToken ct = default) { - var result = await _commands.Run( - Git("rev-parse", "--verify", "--quiet", $"refs/tags/{tag}").QuietOutput(), + var result = await commands.Run( + Git(repository, "rev-parse", "--verify", "--quiet", $"refs/tags/{tag}").QuietOutput(), ct); return result.IsSuccess; } - public async Task RemoteTagExists(string remote, string tag, CancellationToken ct = default) + public async Task RemoteTagExists(IDirectory repository, string remote, string tag, CancellationToken ct = default) { - var result = await _commands.Run( - Git("ls-remote", "--tags", remote, $"refs/tags/{tag}").QuietOutput().ThrowOnError(), + var result = await commands.Run( + Git(repository, "ls-remote", "--tags", remote, $"refs/tags/{tag}").QuietOutput().ThrowOnError(), ct); return !string.IsNullOrWhiteSpace(result.StandardOutput); } - public async Task CreateTag(string tag, string? commit = null, CancellationToken ct = default) + public async Task CreateTag(IDirectory repository, string tag, string? commit = null, CancellationToken ct = default) { - var command = Git("tag", tag).ThrowOnError(); + var command = Git(repository, "tag", tag).ThrowOnError(); if (!string.IsNullOrEmpty(commit)) { command = command.AndArguments(commit); } - await _commands.Run(command, ct); + await commands.Run(command, ct); } - public async Task PushTag(string remote, string tag, CancellationToken ct = default) + public async Task PushTag(IDirectory repository, string remote, string tag, CancellationToken ct = default) { - var command = Git("push", remote, tag).ThrowOnError(); - await _commands.Run(command, ct); + var command = Git(repository, "push", remote, tag).ThrowOnError(); + await commands.Run(command, ct); } /// @@ -247,30 +233,27 @@ public async Task PushTag(string remote, string tag, CancellationToken ct = defa /// A git command against the repository this client addresses: the working directory when /// none was named, since git finds the repository from wherever it runs. /// - public async Task> Tags(string pattern = "*", CancellationToken ct = default) + public async Task> Tags(IDirectory repository, string pattern = "*", CancellationToken ct = default) { - var result = await _commands.Run(Git("tag", "--list", pattern).QuietOutput().ThrowOnError(), ct); + var result = await commands.Run(Git(repository, "tag", "--list", pattern).QuietOutput().ThrowOnError(), ct); return result.StandardOutput.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); } - public async Task IsShallow(CancellationToken ct = default) + public async Task IsShallow(IDirectory repository, CancellationToken ct = default) { - var result = await _commands.Run(Git("rev-parse", "--is-shallow-repository").QuietOutput().ThrowOnError(), ct); + var result = await commands.Run(Git(repository, "rev-parse", "--is-shallow-repository").QuietOutput().ThrowOnError(), ct); return result.StandardOutput.Trim() == "true"; } - public async Task> TrackedFiles(IReadOnlyList? pathspecs = null, CancellationToken ct = default) + public async Task> TrackedFiles(IDirectory repository, IReadOnlyList? pathspecs = null, CancellationToken ct = default) { string[] limits = pathspecs is { Count: > 0 } ? ["--", .. pathspecs] : []; - var result = await _commands.Run(Git(["ls-files", "-z", .. limits]).QuietOutput().ThrowOnError(), ct); + var result = await commands.Run(Git(repository, ["ls-files", "-z", .. limits]).QuietOutput().ThrowOnError(), ct); // NUL-separated, so a name is read exactly as written; the runner ends what it captured with a newline. return result.StandardOutput.TrimEnd('\n').Split('\0', StringSplitOptions.RemoveEmptyEntries); } - private Command Git(params string[] arguments) - { - var command = Command.Create("git").WithArguments(arguments); - return _repository is null ? command : command.InDirectory(_repository.AbsolutePath); - } + private static Command Git(IDirectory repository, params string[] arguments) => + Command.Create("git").WithArguments(arguments).InDirectory(repository.AbsolutePath); } diff --git a/src/Ritten.Git/IGit.cs b/src/Ritten.Git/IGit.cs index aab8788..edf6ade 100644 --- a/src/Ritten.Git/IGit.cs +++ b/src/Ritten.Git/IGit.cs @@ -6,105 +6,103 @@ namespace Ritten.Git; /// Exposes functionality for interacting with a git repository. /// /// -/// By default, all operations run on the current git repository for the workflow. -/// Use to create a new client for a different repo. +/// Holds no repository of its own: every operation names the one it acts on, so one client serves every repository +/// a host works with. /// public interface IGit { /// - /// Gets a client for the repository at the given directory. + /// Checks whether the directory is a git working tree, or inside one. /// - /// The working tree of the repository. - IGit InRepository(IDirectory repository); + Task IsRepository(IDirectory repository, CancellationToken ct = default); /// - /// Checks whether the directory the client addresses is a git working tree, or inside one. + /// Gets the root of the repository, or null when the directory is in none. /// - Task IsRepository(CancellationToken ct = default); - - /// - /// Gets the root of the repository, or null when the client isn't addressing one. - /// - Task RepositoryRoot(CancellationToken ct = default); + Task RepositoryRoot(IDirectory repository, CancellationToken ct = default); /// /// Gets the branch that is checked out, or null when HEAD is detached. /// - Task CurrentBranch(CancellationToken ct = default); + Task CurrentBranch(IDirectory repository, CancellationToken ct = default); /// /// Gets the upstream of the checked-out branch as remote/branch, or null when it has none. /// - Task Upstream(CancellationToken ct = default); + Task Upstream(IDirectory repository, CancellationToken ct = default); /// /// Counts the commits on HEAD that the given reference — typically the upstream — doesn't have. /// - Task CommitsAhead(string reference, CancellationToken ct = default); + Task CommitsAhead(IDirectory repository, string reference, CancellationToken ct = default); /// /// Gets the URL of the given remote, or null when the remote doesn't exist. /// - Task GetRemoteUrl(string remote, CancellationToken ct = default); + Task GetRemoteUrl(IDirectory repository, string remote, CancellationToken ct = default); /// /// Adds a remote with the given URL. /// - Task AddRemote(string remote, string url, CancellationToken ct = default); + Task AddRemote(IDirectory repository, string remote, string url, CancellationToken ct = default); /// /// Reads the given file as it exists at the given reference, or null when it doesn't exist there. /// - Task Show(string reference, string path, CancellationToken ct = default); + Task Show(IDirectory repository, string reference, string path, CancellationToken ct = default); /// /// Lists the paths under the given path that differ from the committed state, including untracked files. /// - Task> ChangedFiles(string path, CancellationToken ct = default); + Task> ChangedFiles(IDirectory repository, string path, CancellationToken ct = default); /// /// Lists the paths under the given path that this branch has changed since it diverged from /// the given reference. /// + /// The working tree of the repository, or a directory inside it. /// /// If you call this in a PR, make sure to call first. /// /// The reference the branch diverged from, such as origin/main. /// The path to limit the answer to. /// A token to monitor for cancellation requests. - Task> ChangedFilesSince(string reference, string path, CancellationToken ct = default); + Task> ChangedFilesSince(IDirectory repository, string reference, string path, CancellationToken ct = default); /// /// Fetches the given branch into its remote-tracking reference, with enough history that it /// and HEAD share a merge base. /// + /// The working tree of the repository, or a directory inside it. /// /// If you call from CI, you should call this first.. /// /// The remote to fetch from, such as origin. /// The branch to fetch, such as main. /// A token to monitor for cancellation requests. - Task FetchMergeBase(string remote, string branch, CancellationToken ct = default); + Task FetchMergeBase(IDirectory repository, string remote, string branch, CancellationToken ct = default); /// /// Stages every change under the given path — modifications, additions and deletions alike. /// - Task Stage(string path, CancellationToken ct = default); + Task Stage(IDirectory repository, string path, CancellationToken ct = default); /// /// Commits what is staged with the given message. /// - Task Commit(string message, CancellationToken ct = default); + Task Commit(IDirectory repository, string message, CancellationToken ct = default); /// /// Pushes the given branch to the given remote. /// + /// The working tree of the repository, or a directory inside it. /// The remote to push to. /// The branch to push. /// What to authenticate with, when the remote's own configuration doesn't answer. /// Whether to record the remote branch as the local one's upstream. /// A token to monitor for cancellation requests. Task Push( + IDirectory repository, string remote, string branch, GitCredential? credential = null, @@ -115,37 +113,38 @@ Task Push( /// /// Checks whether the given tag exists in the local repository. /// - Task TagExists(string tag, CancellationToken ct = default); + Task TagExists(IDirectory repository, string tag, CancellationToken ct = default); /// /// Gets the local tags that match . /// - Task> Tags(string pattern = "*", CancellationToken ct = default); + Task> Tags(IDirectory repository, string pattern = "*", CancellationToken ct = default); /// /// Checks whether the clone is shallow (only holds part of its history). /// - Task IsShallow(CancellationToken ct = default); + Task IsShallow(IDirectory repository, CancellationToken ct = default); /// /// Gets the tracked files matching the given pathspecs. /// + /// The working tree of the repository, or a directory inside it. /// The path specs to match, or null for all files.. /// A token to monitor for cancellation requests. - Task> TrackedFiles(IReadOnlyList? pathspecs = null, CancellationToken ct = default); + Task> TrackedFiles(IDirectory repository, IReadOnlyList? pathspecs = null, CancellationToken ct = default); /// /// Checks whether the given tag exists on the given remote. /// - Task RemoteTagExists(string remote, string tag, CancellationToken ct = default); + Task RemoteTagExists(IDirectory repository, string remote, string tag, CancellationToken ct = default); /// /// Creates a lightweight tag pointing at the given commit, or at HEAD if no commit is given. /// - Task CreateTag(string tag, string? commit = null, CancellationToken ct = default); + Task CreateTag(IDirectory repository, string tag, string? commit = null, CancellationToken ct = default); /// /// Pushes the given tag to the given remote. /// - Task PushTag(string remote, string tag, CancellationToken ct = default); + Task PushTag(IDirectory repository, string remote, string tag, CancellationToken ct = default); } diff --git a/src/Ritten.Git/ServiceCollectionExtensions.cs b/src/Ritten.Git/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..d438e7f --- /dev/null +++ b/src/Ritten.Git/ServiceCollectionExtensions.cs @@ -0,0 +1,24 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Ritten.Commands; + +namespace Ritten.Git; + +/// +/// Registers the git client with any host's services, inside a workflow application or not. +/// +public static class ServiceCollectionExtensions +{ + extension(IServiceCollection services) + { + /// + /// Adds the git client, which acts on whichever repository each call names. + /// + public IServiceCollection AddGit() + { + services.AddCommandRunner(); + services.TryAddScoped(); + return services; + } + } +} diff --git a/src/Ritten.Git/WorkflowBuilderExtensions.cs b/src/Ritten.Git/WorkflowBuilderExtensions.cs index 90d4f6e..8c828c3 100644 --- a/src/Ritten.Git/WorkflowBuilderExtensions.cs +++ b/src/Ritten.Git/WorkflowBuilderExtensions.cs @@ -1,6 +1,4 @@ using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.DependencyInjection.Extensions; -using Ritten.Commands; using Ritten.Engine; namespace Ritten.Git; @@ -17,8 +15,7 @@ public static class WorkflowBuilderExtensions /// public IWorkflowBuilder AddGit() { - builder.AddCommandRunner(); - builder.Services.TryAddScoped(); + builder.Services.AddGit(); builder.Services.AddOptions().Configure(GitOptions.ConfigureFromEnvironment); builder.Decorators.Decorate(); return builder; diff --git a/src/Ritten.NuGet/Releases/Steps/ReadShippedChanges.cs b/src/Ritten.NuGet/Releases/Steps/ReadShippedChanges.cs index 114769e..9057082 100644 --- a/src/Ritten.NuGet/Releases/Steps/ReadShippedChanges.cs +++ b/src/Ritten.NuGet/Releases/Steps/ReadShippedChanges.cs @@ -1,4 +1,5 @@ using Ritten.Contracts; +using Ritten.Contracts.FileSystem; using Ritten.DotNet; using Ritten.DotNet.Steps; using Ritten.Git; @@ -11,9 +12,10 @@ namespace Ritten.Releases.Steps; /// /// The pull request under review, if any. /// The git client. +/// The file system, whose project root is the repository asked. /// The workflow log. [Step("read shipped changes", StepKind.Work)] -public class ReadShippedChanges(PullRequest pullRequest, IGit git, IWorkflowLog log) +public class ReadShippedChanges(PullRequest pullRequest, IGit git, IFileSystem fileSystem, IWorkflowLog log) { /// /// The repository-wide files that change every project's output: its build properties and its package versions. @@ -47,7 +49,7 @@ public async Task> Run(ReleaseSettings release, Packa // A CI checkout is shallow and holds only the commit under test, so the base is fetched // first — with history enough to find where this branch left it. - await git.FetchMergeBase(Remote, baseRef, cancellationToken); + await git.FetchMergeBase(fileSystem.ProjectRoot, Remote, baseRef, cancellationToken); // A project's directory is the honest unit: its sources, its embedded resources and the // project file itself all ship. A project it references without packing is not covered — @@ -60,7 +62,7 @@ public async Task> Run(ReleaseSettings release, Packa List files = []; foreach (var path in paths) { - files.AddRange(await git.ChangedFilesSince($"{Remote}/{baseRef}", path, cancellationToken)); + files.AddRange(await git.ChangedFilesSince(fileSystem.ProjectRoot, $"{Remote}/{baseRef}", path, cancellationToken)); } var changes = new ShippedChanges(baseRef, [.. files.Distinct()]); diff --git a/src/Ritten/Git/Steps/GitTag.cs b/src/Ritten/Git/Steps/GitTag.cs index 011adda..51f734a 100644 --- a/src/Ritten/Git/Steps/GitTag.cs +++ b/src/Ritten/Git/Steps/GitTag.cs @@ -1,5 +1,6 @@ using Microsoft.Extensions.Options; using Ritten.Contracts; +using Ritten.Contracts.FileSystem; using Ritten.DotNet; using Ritten.DotNet.Steps; using Ritten.Releases; @@ -13,8 +14,9 @@ namespace Ritten.Git.Steps; /// The workflow log. /// The commit to tag, from the environment. /// The git client. +/// The file system, whose project root is the repository tagged. [Step("git tag", StepKind.Publish)] -public class GitTag(IWorkflowLog log, IOptions options, IGit git) +public class GitTag(IWorkflowLog log, IOptions options, IGit git, IFileSystem fileSystem) { /// /// Tags the release and pushes the tag. @@ -27,22 +29,22 @@ public async Task Run(ReleaseSettings release, Project project, Canc var tag = $"{release.TagPrefix}{project.Version}"; // A failed deployment may have already pushed the tag; rerunning should carry on, not crash. - if (await git.RemoteTagExists("origin", tag, cancellationToken)) + if (await git.RemoteTagExists(fileSystem.ProjectRoot, "origin", tag, cancellationToken)) { log.Skipped($"Tag {tag} already exists on origin; skipping."); return StepResult.Successful; } - if (await git.TagExists(tag, cancellationToken)) + if (await git.TagExists(fileSystem.ProjectRoot, tag, cancellationToken)) { log.Detail($"Tag {tag} already exists locally; pushing it."); } else { - await git.CreateTag(tag, options.Value.CommitSha, cancellationToken); + await git.CreateTag(fileSystem.ProjectRoot, tag, options.Value.CommitSha, cancellationToken); } - await git.PushTag("origin", tag, cancellationToken); + await git.PushTag(fileSystem.ProjectRoot, "origin", tag, cancellationToken); return StepResult.Successful; } } diff --git a/src/Ritten/Init/Steps/EnsureActionsWorkflow.cs b/src/Ritten/Init/Steps/EnsureActionsWorkflow.cs index 20df923..2bcc746 100644 --- a/src/Ritten/Init/Steps/EnsureActionsWorkflow.cs +++ b/src/Ritten/Init/Steps/EnsureActionsWorkflow.cs @@ -35,7 +35,7 @@ ToolPin tool /// A token to monitor for cancellation requests. public async Task Run(DotNetBuildSettings build, DiscoveredProjects found, CancellationToken ct = default) { - if (await git.RepositoryRoot(ct) is not { } root) + if (await git.RepositoryRoot(fileSystem.ProjectRoot, ct) is not { } root) { log.Warning("This isn't a git repository, so there's nowhere GitHub Actions would read a workflow from."); return StepResult.Successful; diff --git a/src/Ritten/Init/Steps/EnsureToolManifest.cs b/src/Ritten/Init/Steps/EnsureToolManifest.cs index 6109d6a..6e80e12 100644 --- a/src/Ritten/Init/Steps/EnsureToolManifest.cs +++ b/src/Ritten/Init/Steps/EnsureToolManifest.cs @@ -24,7 +24,7 @@ public class EnsureToolManifest(IWorkflowLog log, IDotNet dotnet, IGit git, IFil public async Task Run(CancellationToken ct = default) { // One manifest at the repository's root serves every project in it. - var root = await git.RepositoryRoot(ct) ?? fileSystem.ProjectRoot; + var root = await git.RepositoryRoot(fileSystem.ProjectRoot, ct) ?? fileSystem.ProjectRoot; var scope = ToolScope.Local(root); var pinned = await dotnet.InstalledToolVersion(tool.PackageId, scope, ct); if (pinned == tool.Version) diff --git a/tests/Ritten.Tests/Commands/CommandRunnerTests.cs b/tests/Ritten.Tests/Commands/CommandRunnerTests.cs index 0f6eee6..fac53d3 100644 --- a/tests/Ritten.Tests/Commands/CommandRunnerTests.cs +++ b/tests/Ritten.Tests/Commands/CommandRunnerTests.cs @@ -1,4 +1,7 @@ using System.Diagnostics; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using Ritten.Commands; using Ritten.Contracts.FileSystem; using Ritten.Reporting; @@ -154,6 +157,54 @@ await Should.ThrowAsync(() => span.Status.ShouldBe(ActivityStatusCode.Error); } + [Fact] + public async Task Run_WithoutARun_RunsInTheProcessDirectory() + { + // Outside a workflow run there is no project, so a command that names no directory runs where the host does. + var runner = new CommandRunner(Substitute.For(), NullLogger.Instance); + + var result = await runner.Run(Shell("pwd").QuietOutput(), TestContext.Current.CancellationToken); + + result.StandardOutput.Trim().ShouldEndWith(Path.GetFileName(Environment.CurrentDirectory.TrimEnd('/'))); + } + + [Fact] + public async Task Run_RecordsTheCommandAsADiagnostic_WithoutItsArguments() + { + // The host's logging hears what ran and how it ended; arguments can carry secrets, so they never reach it. + var logger = new ListLogger(); + var runner = new CommandRunner(Substitute.For(), logger); + + await runner.Run(Shell("exit 3").QuietOutput(), TestContext.Current.CancellationToken); + + logger.Messages.ShouldBe(["Running sh in " + Environment.CurrentDirectory + ".", "sh exited with code 3."]); + logger.Messages.ShouldAllBe(message => !message.Contains("exit 3")); + } + + [Fact] + public async Task AddCommandRunner_RunsCommandsForAHostWithNoWorkflowRun() + { + // Registered on any host's services, the runner narrates nothing and needs nothing a run provides. + var services = new ServiceCollection().AddCommandRunner().BuildServiceProvider(new ServiceProviderOptions { ValidateScopes = true, ValidateOnBuild = true }); + using var scope = services.CreateScope(); + + var result = await scope.ServiceProvider.GetRequiredService().Run(Shell("echo hi").QuietOutput(), TestContext.Current.CancellationToken); + + result.StandardOutput.Trim().ShouldBe("hi"); + } + + private sealed class ListLogger : ILogger + { + public List Messages { get; } = []; + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) => + Messages.Add(formatter(state, exception)); + } + private static CommandRunner Runner(string? currentDirectory = null) => Runner(Substitute.For(), currentDirectory); @@ -161,7 +212,7 @@ private static CommandRunner Runner(IWorkflowLog log, string? currentDirectory = { var fileSystem = Substitute.For(); fileSystem.ProjectRoot.AbsolutePath.Returns(currentDirectory ?? Path.GetTempPath()); - return new CommandRunner(log, fileSystem); + return new CommandRunner(log, NullLogger.Instance, fileSystem); } private static Command Shell(string script) => Command.Create("/bin/sh").WithArguments("-c", script); diff --git a/tests/Ritten.Tests/DotNet/DotNetClientTests.cs b/tests/Ritten.Tests/DotNet/DotNetClientTests.cs index c85d054..57663ba 100644 --- a/tests/Ritten.Tests/DotNet/DotNetClientTests.cs +++ b/tests/Ritten.Tests/DotNet/DotNetClientTests.cs @@ -1,3 +1,4 @@ +using Microsoft.Extensions.Logging.Abstractions; using NuGet.Versioning; using Ritten.Commands; using Ritten.Contracts.FileSystem; @@ -267,7 +268,7 @@ public async Task ReadProject_ResolvesPropertiesInheritedFromDirectoryBuildProps var fileSystem = Substitute.For(); fileSystem.ProjectRoot.AbsolutePath.Returns(project.Root); - var client = new DotNetClient(new CommandRunner(Substitute.For(), fileSystem), fileSystem); + var client = new DotNetClient(new CommandRunner(Substitute.For(), NullLogger.Instance, fileSystem), fileSystem); var result = await client.ReadProject(ProjectFile(project.CsprojPath), TestContext.Current.CancellationToken); diff --git a/tests/Ritten.Tests/DotNet/ResolveReleaseTests.cs b/tests/Ritten.Tests/DotNet/ResolveReleaseTests.cs index 8552029..439603b 100644 --- a/tests/Ritten.Tests/DotNet/ResolveReleaseTests.cs +++ b/tests/Ritten.Tests/DotNet/ResolveReleaseTests.cs @@ -1,4 +1,5 @@ using NuGet.Versioning; +using Ritten.Contracts.FileSystem; using Ritten.DotNet; using Ritten.DotNet.Steps; using Ritten.Git; @@ -23,7 +24,7 @@ public async Task AnExplicitSettingWinsOverEverySource() var release = await Produce(Entry(repository: "https://github.com/csproj/repo")); release.Repository.ShouldBe("https://github.com/configured/repo"); - await _git.DidNotReceiveWithAnyArgs().GetRemoteUrl(default!, TestContext.Current.CancellationToken); + await _git.DidNotReceiveWithAnyArgs().GetRemoteUrl(Arg.Any(), default!, TestContext.Current.CancellationToken); } [Fact] @@ -32,13 +33,13 @@ public async Task TheProjectFileWinsOverTheRemote() var release = await Produce(Entry(repository: "https://github.com/csproj/repo")); release.Repository.ShouldBe("https://github.com/csproj/repo"); - await _git.DidNotReceiveWithAnyArgs().GetRemoteUrl(default!, TestContext.Current.CancellationToken); + await _git.DidNotReceiveWithAnyArgs().GetRemoteUrl(Arg.Any(), default!, TestContext.Current.CancellationToken); } [Fact] public async Task FallsBackToTheOriginRemoteNormalised() { - _git.GetRemoteUrl("origin", Arg.Any()).Returns("git@github.com:remote/repo.git"); + _git.GetRemoteUrl(Arg.Any(), "origin", Arg.Any()).Returns("git@github.com:remote/repo.git"); var release = await Produce(Entry()); @@ -85,5 +86,5 @@ private async Task Produce(params Project[] entries) } private ResolveRelease Step() => - new(Substitute.For(), _git); + new(Substitute.For(), _git, Substitute.For()); } diff --git a/tests/Ritten.Tests/Extensions/WorkflowBuilderExtensionsTests.cs b/tests/Ritten.Tests/Extensions/WorkflowBuilderExtensionsTests.cs index 76f1324..da0557e 100644 --- a/tests/Ritten.Tests/Extensions/WorkflowBuilderExtensionsTests.cs +++ b/tests/Ritten.Tests/Extensions/WorkflowBuilderExtensionsTests.cs @@ -6,6 +6,7 @@ using Ritten.Contracts; using Ritten.DotNet; using Ritten.Engine; +using Ritten.Engine.FileSystem; using Ritten.Git; using Ritten.GitHub; using Ritten.NuGet; @@ -40,6 +41,25 @@ public void Registrations_AreIdempotent() services.Count(d => d.ServiceType == typeof(IWorkflowReport)).ShouldBe(1); } + [Fact] + public async Task AddGit_ServesAHostWithNoWorkflowRun() + { + // A background service or any other host gets the same client, asking about whichever repository it names. + var services = new ServiceCollection().AddGit().BuildServiceProvider(new ServiceProviderOptions { ValidateScopes = true, ValidateOnBuild = true }); + using var scope = services.CreateScope(); + var outside = Directory.CreateTempSubdirectory("ritten-not-a-repo-"); + try + { + var git = scope.ServiceProvider.GetRequiredService(); + + (await git.IsRepository(new PhysicalDirectory(outside.FullName), TestContext.Current.CancellationToken)).ShouldBeFalse(); + } + finally + { + outside.Delete(recursive: true); + } + } + [Fact] public void AddGit_RegistersItsCommandRunnerDependency() { diff --git a/tests/Ritten.Tests/Git/GitClientTests.cs b/tests/Ritten.Tests/Git/GitClientTests.cs index 85fa8ef..0847c5b 100644 --- a/tests/Ritten.Tests/Git/GitClientTests.cs +++ b/tests/Ritten.Tests/Git/GitClientTests.cs @@ -1,3 +1,4 @@ +using Microsoft.Extensions.Logging.Abstractions; using Ritten.Commands; using Ritten.Contracts.FileSystem; using Ritten.Engine.FileSystem; @@ -22,21 +23,22 @@ public async ValueTask InitializeAsync() { var fileSystem = Substitute.For(); fileSystem.ProjectRoot.AbsolutePath.Returns(_repository); - _commands = new CommandRunner(Substitute.For(), fileSystem); + _commands = new CommandRunner(Substitute.For(), NullLogger.Instance, fileSystem); _git = new GitClient(_commands); await Git("init", "--initial-branch=main", "."); - await Git("-c", "user.name=Tests", "-c", "user.email=tests@example.com", "commit", "--allow-empty", "-m", "init"); + + // A signing agent would stop to ask partway through a test. + await Git("config", "commit.gpgsign", "false"); + await Git("-c", "user.name=Tests", "-c", "user.email=tests@example.com", "-c", "commit.gpgsign=false", "commit", "--allow-empty", "-m", "init"); await Git("init", "--bare", _remote); await Git("remote", "add", "origin", _remote); } - private ICommandRunner RunnerIn(string directory) - { - var fileSystem = Substitute.For(); - fileSystem.ProjectRoot.AbsolutePath.Returns(directory); - return new CommandRunner(Substitute.For(), fileSystem); - } + /// + /// The repository the tests set up. + /// + private IDirectory Repository => new PhysicalDirectory(_repository); public ValueTask DisposeAsync() { @@ -50,9 +52,9 @@ public async Task RepositoryRoot_IsTheRepositoryNotTheWorkingDirectory() { // The command runs in a subdirectory, so this proves git answered rather than the path. var nested = Directory.CreateDirectory(Path.Combine(_repository, "src", "Thing")); - var git = new GitClient(RunnerIn(nested.FullName)); + var at = new PhysicalDirectory(nested.FullName); - var root = await git.RepositoryRoot(TestContext.Current.CancellationToken); + var root = await _git.RepositoryRoot(at, TestContext.Current.CancellationToken); // Asserted by what the directory is rather than by its path, which the platform is free // to resolve differently — macOS reaches the temp directory through a symlink. @@ -67,11 +69,11 @@ public async Task IsRepository_IsTrueInsideAWorkingTreeAndFalseOutside() var outside = Directory.CreateTempSubdirectory("ritten-not-a-repo-"); try { - (await _git.IsRepository(TestContext.Current.CancellationToken)).ShouldBeTrue(); - (await _git.InRepository(new PhysicalDirectory(nested.FullName)).IsRepository(TestContext.Current.CancellationToken)).ShouldBeTrue(); - (await _git.InRepository(new PhysicalDirectory(outside.FullName)).IsRepository(TestContext.Current.CancellationToken)).ShouldBeFalse(); + (await _git.IsRepository(Repository, TestContext.Current.CancellationToken)).ShouldBeTrue(); + (await _git.IsRepository(new PhysicalDirectory(nested.FullName), TestContext.Current.CancellationToken)).ShouldBeTrue(); + (await _git.IsRepository(new PhysicalDirectory(outside.FullName), TestContext.Current.CancellationToken)).ShouldBeFalse(); // A bare repository is git's, but there is no working tree to commit in. - (await _git.InRepository(new PhysicalDirectory(_remote)).IsRepository(TestContext.Current.CancellationToken)).ShouldBeFalse(); + (await _git.IsRepository(new PhysicalDirectory(_remote), TestContext.Current.CancellationToken)).ShouldBeFalse(); } finally { @@ -85,9 +87,9 @@ public async Task RepositoryRoot_IsNullOutsideARepository() var outside = Directory.CreateTempSubdirectory("ritten-not-a-repo-"); try { - var git = new GitClient(RunnerIn(outside.FullName)); + var at = new PhysicalDirectory(outside.FullName); - (await git.RepositoryRoot(TestContext.Current.CancellationToken)).ShouldBeNull(); + (await _git.RepositoryRoot(at, TestContext.Current.CancellationToken)).ShouldBeNull(); } finally { @@ -98,7 +100,7 @@ public async Task RepositoryRoot_IsNullOutsideARepository() [Fact] public async Task GetRemoteUrl_ReturnsTheRemotesUrl() { - var url = await _git.GetRemoteUrl("origin", TestContext.Current.CancellationToken); + var url = await _git.GetRemoteUrl(Repository, "origin", TestContext.Current.CancellationToken); url.ShouldBe(_remote); } @@ -106,7 +108,7 @@ public async Task GetRemoteUrl_ReturnsTheRemotesUrl() [Fact] public async Task GetRemoteUrl_IsNullWhenTheRemoteDoesNotExist() { - var url = await _git.GetRemoteUrl("nowhere", TestContext.Current.CancellationToken); + var url = await _git.GetRemoteUrl(Repository, "nowhere", TestContext.Current.CancellationToken); url.ShouldBeNull(); } @@ -116,10 +118,10 @@ public async Task Show_ReadsTheFileAsItExistsAtTheReference() { await File.WriteAllTextAsync(Path.Combine(_repository, "a.txt"), "committed", TestContext.Current.CancellationToken); await Git("add", "a.txt"); - await Git("-c", "user.name=Tests", "-c", "user.email=tests@example.com", "commit", "-m", "add a.txt"); + await Git("-c", "user.name=Tests", "-c", "user.email=tests@example.com", "-c", "commit.gpgsign=false", "commit", "-m", "add a.txt"); await File.WriteAllTextAsync(Path.Combine(_repository, "a.txt"), "changed", TestContext.Current.CancellationToken); - var content = await _git.Show("HEAD", "a.txt", TestContext.Current.CancellationToken); + var content = await _git.Show(Repository, "HEAD", "a.txt", TestContext.Current.CancellationToken); content.ShouldNotBeNull().Trim().ShouldBe("committed"); } @@ -127,7 +129,7 @@ public async Task Show_ReadsTheFileAsItExistsAtTheReference() [Fact] public async Task Show_IsNullWhenTheFileDoesNotExistAtTheReference() { - (await _git.Show("HEAD", "missing.txt", TestContext.Current.CancellationToken)).ShouldBeNull(); + (await _git.Show(Repository, "HEAD", "missing.txt", TestContext.Current.CancellationToken)).ShouldBeNull(); } [Fact] @@ -135,11 +137,11 @@ public async Task ChangedFiles_ReportsModifiedAndUntrackedFiles() { await File.WriteAllTextAsync(Path.Combine(_repository, "tracked.txt"), "committed", TestContext.Current.CancellationToken); await Git("add", "tracked.txt"); - await Git("-c", "user.name=Tests", "-c", "user.email=tests@example.com", "commit", "-m", "add tracked.txt"); + await Git("-c", "user.name=Tests", "-c", "user.email=tests@example.com", "-c", "commit.gpgsign=false", "commit", "-m", "add tracked.txt"); await File.WriteAllTextAsync(Path.Combine(_repository, "tracked.txt"), "changed", TestContext.Current.CancellationToken); await File.WriteAllTextAsync(Path.Combine(_repository, "untracked.txt"), "new", TestContext.Current.CancellationToken); - var changes = await _git.ChangedFiles(".", TestContext.Current.CancellationToken); + var changes = await _git.ChangedFiles(Repository, ".", TestContext.Current.CancellationToken); changes.ShouldBe(["tracked.txt", "untracked.txt"], ignoreOrder: true); } @@ -147,46 +149,46 @@ public async Task ChangedFiles_ReportsModifiedAndUntrackedFiles() [Fact] public async Task ChangedFiles_IsEmptyForACleanPath() { - (await _git.ChangedFiles(".", TestContext.Current.CancellationToken)).ShouldBeEmpty(); + (await _git.ChangedFiles(Repository, ".", TestContext.Current.CancellationToken)).ShouldBeEmpty(); } [Fact] public async Task TagExists_IsFalseForAMissingTag() { - (await _git.TagExists("v9.9.9", TestContext.Current.CancellationToken)).ShouldBeFalse(); + (await _git.TagExists(Repository, "v9.9.9", TestContext.Current.CancellationToken)).ShouldBeFalse(); } [Fact] public async Task CreateTag_MakesTheTagVisibleLocally() { - await _git.CreateTag("v1.0.0", ct: TestContext.Current.CancellationToken); + await _git.CreateTag(Repository, "v1.0.0", ct: TestContext.Current.CancellationToken); - (await _git.TagExists("v1.0.0", TestContext.Current.CancellationToken)).ShouldBeTrue(); - (await _git.RemoteTagExists("origin", "v1.0.0", TestContext.Current.CancellationToken)).ShouldBeFalse(); + (await _git.TagExists(Repository, "v1.0.0", TestContext.Current.CancellationToken)).ShouldBeTrue(); + (await _git.RemoteTagExists(Repository, "origin", "v1.0.0", TestContext.Current.CancellationToken)).ShouldBeFalse(); } [Fact] public async Task PushTag_MakesTheTagVisibleOnTheRemote() { - await _git.CreateTag("v1.1.0", ct: TestContext.Current.CancellationToken); - await _git.PushTag("origin", "v1.1.0", TestContext.Current.CancellationToken); + await _git.CreateTag(Repository, "v1.1.0", ct: TestContext.Current.CancellationToken); + await _git.PushTag(Repository, "origin", "v1.1.0", TestContext.Current.CancellationToken); - (await _git.RemoteTagExists("origin", "v1.1.0", TestContext.Current.CancellationToken)).ShouldBeTrue(); + (await _git.RemoteTagExists(Repository, "origin", "v1.1.0", TestContext.Current.CancellationToken)).ShouldBeTrue(); } [Fact] - public async Task InRepository_AddressesThatRepositoryInsteadOfTheWorkingDirectory() + public async Task EachCall_AddressesTheRepositoryItNames() { var other = Directory.CreateTempSubdirectory("ritten-git-other-"); try { await Git("init", "--initial-branch=trunk", other.FullName); - var git = _git.InRepository(new PhysicalDirectory(other.FullName)); + var at = new PhysicalDirectory(other.FullName); - (await git.CurrentBranch(TestContext.Current.CancellationToken)).ShouldBe("trunk"); - (await git.GetRemoteUrl("origin", TestContext.Current.CancellationToken)).ShouldBeNull(); - (await _git.CurrentBranch(TestContext.Current.CancellationToken)).ShouldBe("main"); + (await _git.CurrentBranch(at, TestContext.Current.CancellationToken)).ShouldBe("trunk"); + (await _git.GetRemoteUrl(at, "origin", TestContext.Current.CancellationToken)).ShouldBeNull(); + (await _git.CurrentBranch(Repository, TestContext.Current.CancellationToken)).ShouldBe("main"); } finally { @@ -197,15 +199,15 @@ public async Task InRepository_AddressesThatRepositoryInsteadOfTheWorkingDirecto [Fact] public async Task Upstream_IsNullUntilTheBranchHasOne() { - (await _git.Upstream(TestContext.Current.CancellationToken)).ShouldBeNull(); + (await _git.Upstream(Repository, TestContext.Current.CancellationToken)).ShouldBeNull(); } [Fact] public async Task AddRemote_MakesTheRemoteAnswerToItsName() { - await _git.AddRemote("mirror", "https://example.com/mirror.git", TestContext.Current.CancellationToken); + await _git.AddRemote(Repository, "mirror", "https://example.com/mirror.git", TestContext.Current.CancellationToken); - (await _git.GetRemoteUrl("mirror", TestContext.Current.CancellationToken)).ShouldBe("https://example.com/mirror.git"); + (await _git.GetRemoteUrl(Repository, "mirror", TestContext.Current.CancellationToken)).ShouldBe("https://example.com/mirror.git"); } [Fact] @@ -215,14 +217,14 @@ public async Task Stage_Commit_Push_LandTheChangeOnTheRemote() await Git("config", "user.email", "tests@example.com"); await File.WriteAllTextAsync(Path.Combine(_repository, "note.md"), "hello", TestContext.Current.CancellationToken); - await _git.Stage(".", TestContext.Current.CancellationToken); - await _git.Commit("Add a note", TestContext.Current.CancellationToken); - await _git.Push("origin", "main", setUpstream: true, ct: TestContext.Current.CancellationToken); + await _git.Stage(Repository, ".", TestContext.Current.CancellationToken); + await _git.Commit(Repository, "Add a note", TestContext.Current.CancellationToken); + await _git.Push(Repository, "origin", "main", setUpstream: true, ct: TestContext.Current.CancellationToken); - (await _git.ChangedFiles(".", TestContext.Current.CancellationToken)).ShouldBeEmpty(); - (await _git.Upstream(TestContext.Current.CancellationToken)).ShouldBe("origin/main"); - (await _git.CommitsAhead("origin/main", TestContext.Current.CancellationToken)).ShouldBe(0); - (await _git.InRepository(new PhysicalDirectory(_remote)).Show("main", "note.md", TestContext.Current.CancellationToken)) + (await _git.ChangedFiles(Repository, ".", TestContext.Current.CancellationToken)).ShouldBeEmpty(); + (await _git.Upstream(Repository, TestContext.Current.CancellationToken)).ShouldBe("origin/main"); + (await _git.CommitsAhead(Repository, "origin/main", TestContext.Current.CancellationToken)).ShouldBe(0); + (await _git.Show(new PhysicalDirectory(_remote), "main", "note.md", TestContext.Current.CancellationToken)) .ShouldNotBeNull().Trim().ShouldBe("hello"); } @@ -231,11 +233,11 @@ public async Task CommitsAhead_CountsWhatTheRemoteLacks() { await Git("config", "user.name", "Tests"); await Git("config", "user.email", "tests@example.com"); - await _git.Push("origin", "main", setUpstream: true, ct: TestContext.Current.CancellationToken); + await _git.Push(Repository, "origin", "main", setUpstream: true, ct: TestContext.Current.CancellationToken); await Git("commit", "--allow-empty", "-m", "one"); await Git("commit", "--allow-empty", "-m", "two"); - (await _git.CommitsAhead("origin/main", TestContext.Current.CancellationToken)).ShouldBe(2); + (await _git.CommitsAhead(Repository, "origin/main", TestContext.Current.CancellationToken)).ShouldBe(2); } [Fact] @@ -244,15 +246,15 @@ public async Task Stage_TakesDeletionsAsWellAsAdditions() await Git("config", "user.name", "Tests"); await Git("config", "user.email", "tests@example.com"); await File.WriteAllTextAsync(Path.Combine(_repository, "gone.md"), "soon", TestContext.Current.CancellationToken); - await _git.Stage(".", TestContext.Current.CancellationToken); - await _git.Commit("Add", TestContext.Current.CancellationToken); + await _git.Stage(Repository, ".", TestContext.Current.CancellationToken); + await _git.Commit(Repository, "Add", TestContext.Current.CancellationToken); File.Delete(Path.Combine(_repository, "gone.md")); - await _git.Stage(".", TestContext.Current.CancellationToken); - await _git.Commit("Remove", TestContext.Current.CancellationToken); + await _git.Stage(Repository, ".", TestContext.Current.CancellationToken); + await _git.Commit(Repository, "Remove", TestContext.Current.CancellationToken); - (await _git.ChangedFiles(".", TestContext.Current.CancellationToken)).ShouldBeEmpty(); - (await _git.Show("HEAD", "gone.md", TestContext.Current.CancellationToken)).ShouldBeNull(); + (await _git.ChangedFiles(Repository, ".", TestContext.Current.CancellationToken)).ShouldBeEmpty(); + (await _git.Show(Repository, "HEAD", "gone.md", TestContext.Current.CancellationToken)).ShouldBeNull(); } [Fact] @@ -261,7 +263,7 @@ public async Task Push_WithACredential_HandsItToGitThroughTheEnvironmentOnly() var commands = new FakeCommandRunner(); var git = new GitClient(commands); - await git.Push("origin", "main", new GitCredential("tom", "s3cret"), ct: TestContext.Current.CancellationToken); + await git.Push(Repository, "origin", "main", new GitCredential("tom", "s3cret"), ct: TestContext.Current.CancellationToken); var push = commands.Executed.ShouldHaveSingleItem(); push.Arguments.ShouldNotContain(a => a.Contains("s3cret") || a.Contains("tom")); @@ -277,7 +279,7 @@ public async Task Push_WithoutACredential_LeavesTheEnvironmentAlone() var commands = new FakeCommandRunner(); var git = new GitClient(commands); - await git.Push("origin", "main", ct: TestContext.Current.CancellationToken); + await git.Push(Repository, "origin", "main", ct: TestContext.Current.CancellationToken); commands.Executed.ShouldHaveSingleItem().EnvironmentVariables.ShouldBeEmpty(); } @@ -289,7 +291,7 @@ public async Task Tags_ListsTheTagsThePatternMatches() await Git("tag", "v1.0.1"); await Git("tag", "nightly"); - var tags = await _git.Tags("v*", TestContext.Current.CancellationToken); + var tags = await _git.Tags(Repository, "v*", TestContext.Current.CancellationToken); tags.ShouldBe(["v1.0.0", "v1.0.1"]); } @@ -297,20 +299,20 @@ public async Task Tags_ListsTheTagsThePatternMatches() [Fact] public async Task IsShallow_IsFalseForAWholeClone() { - (await _git.IsShallow(TestContext.Current.CancellationToken)).ShouldBeFalse(); + (await _git.IsShallow(Repository, TestContext.Current.CancellationToken)).ShouldBeFalse(); } [Fact] public async Task IsShallow_IsTrueForAClonePartOfTheHistory() { - await Git("-c", "user.name=Tests", "-c", "user.email=tests@example.com", "commit", "--allow-empty", "-m", "second"); + await Git("-c", "user.name=Tests", "-c", "user.email=tests@example.com", "-c", "commit.gpgsign=false", "commit", "--allow-empty", "-m", "second"); var clone = Directory.CreateTempSubdirectory("ritten-git-shallow-"); try { // file://, not a path: git ignores --depth for a local clone it can hard-link. await Git("clone", "--depth", "1", $"file://{_repository}", clone.FullName); - (await new GitClient(RunnerIn(clone.FullName)).IsShallow(TestContext.Current.CancellationToken)).ShouldBeTrue(); + (await _git.IsShallow(new PhysicalDirectory(clone.FullName), TestContext.Current.CancellationToken)).ShouldBeTrue(); } finally { @@ -329,10 +331,10 @@ public async Task TrackedFiles_ListsWhatGitTracksBeneathTheDirectory_RelativeToI await File.WriteAllTextAsync(Path.Combine(component.FullName, "scratch.yaml"), "", TestContext.Current.CancellationToken); await File.WriteAllTextAsync(Path.Combine(_repository, "elsewhere.yaml"), "", TestContext.Current.CancellationToken); await Git("add", "monitoring/grafana/compose.yaml", "monitoring/grafana/config", "monitoring/grafana/notes.md", "elsewhere.yaml"); - var git = new GitClient(RunnerIn(component.FullName)); + var at = new PhysicalDirectory(component.FullName); - var all = await git.TrackedFiles(ct: TestContext.Current.CancellationToken); - var yaml = await git.TrackedFiles(["*.yaml"], TestContext.Current.CancellationToken); + var all = await _git.TrackedFiles(at, ct: TestContext.Current.CancellationToken); + var yaml = await _git.TrackedFiles(at, ["*.yaml"], TestContext.Current.CancellationToken); // The untracked scratch.yaml is not listed, nor is what lies outside the directory. all.ShouldBe(["compose.yaml", "config/rules file.yaml", "notes.md"]); @@ -348,9 +350,9 @@ public async Task ChangedFilesSince_ReportsWhatTheBranchTouched() await Git("checkout", "-b", "feature"); await File.WriteAllTextAsync(Path.Combine(_repository, "touched.txt"), "x", TestContext.Current.CancellationToken); await Git("add", "touched.txt"); - await Git("-c", "user.name=Tests", "-c", "user.email=tests@example.com", "commit", "-m", "touch"); + await Git("-c", "user.name=Tests", "-c", "user.email=tests@example.com", "-c", "commit.gpgsign=false", "commit", "-m", "touch"); - var changed = await _git.ChangedFilesSince("main", ".", TestContext.Current.CancellationToken); + var changed = await _git.ChangedFilesSince(Repository, "main", ".", TestContext.Current.CancellationToken); changed.ShouldBe(["touched.txt"]); } @@ -361,16 +363,16 @@ public async Task ChangedFilesSince_IgnoresWhatTheBaseDidMeanwhile() await Git("checkout", "-b", "feature"); await File.WriteAllTextAsync(Path.Combine(_repository, "mine.txt"), "x", TestContext.Current.CancellationToken); await Git("add", "mine.txt"); - await Git("-c", "user.name=Tests", "-c", "user.email=tests@example.com", "commit", "-m", "mine"); + await Git("-c", "user.name=Tests", "-c", "user.email=tests@example.com", "-c", "commit.gpgsign=false", "commit", "-m", "mine"); await Git("checkout", "main"); await File.WriteAllTextAsync(Path.Combine(_repository, "theirs.txt"), "x", TestContext.Current.CancellationToken); await Git("add", "theirs.txt"); - await Git("-c", "user.name=Tests", "-c", "user.email=tests@example.com", "commit", "-m", "theirs"); + await Git("-c", "user.name=Tests", "-c", "user.email=tests@example.com", "-c", "commit.gpgsign=false", "commit", "-m", "theirs"); await Git("checkout", "feature"); // Two dots would call theirs.txt a change of this branch's; three dots ask the merge base. - var changed = await _git.ChangedFilesSince("main", ".", TestContext.Current.CancellationToken); + var changed = await _git.ChangedFilesSince(Repository, "main", ".", TestContext.Current.CancellationToken); changed.ShouldBe(["mine.txt"]); } @@ -383,9 +385,9 @@ public async Task ChangedFilesSince_NarrowsToThePathAsked() await File.WriteAllTextAsync(Path.Combine(_repository, "inside", "a.txt"), "x", TestContext.Current.CancellationToken); await File.WriteAllTextAsync(Path.Combine(_repository, "outside.txt"), "x", TestContext.Current.CancellationToken); await Git("add", "."); - await Git("-c", "user.name=Tests", "-c", "user.email=tests@example.com", "commit", "-m", "both"); + await Git("-c", "user.name=Tests", "-c", "user.email=tests@example.com", "-c", "commit.gpgsign=false", "commit", "-m", "both"); - var changed = await _git.ChangedFilesSince("main", "inside", TestContext.Current.CancellationToken); + var changed = await _git.ChangedFilesSince(Repository, "main", "inside", TestContext.Current.CancellationToken); changed.ShouldBe(["inside/a.txt"]); } @@ -394,7 +396,7 @@ public async Task ChangedFilesSince_NarrowsToThePathAsked() public async Task ChangedFilesSince_RefusesAReferenceItCannotResolve() => // Empty would mean "nothing changed", which is what makes a caller skip its work. await Should.ThrowAsync( - _git.ChangedFilesSince("origin/never-fetched", ".", TestContext.Current.CancellationToken)); + _git.ChangedFilesSince(Repository, "origin/never-fetched", ".", TestContext.Current.CancellationToken)); [Fact] public async Task FetchMergeBase_LetsAShallowCheckoutCompareWithItsBase() @@ -405,7 +407,7 @@ public async Task FetchMergeBase_LetsAShallowCheckoutCompareWithItsBase() await CommitFile("base.txt"); for (var i = 0; i < GitClient.MergeBaseDepths[0]; i++) { - await Git("-c", "user.name=Tests", "-c", "user.email=tests@example.com", "commit", "--allow-empty", "-m", $"history {i}"); + await Git("-c", "user.name=Tests", "-c", "user.email=tests@example.com", "-c", "commit.gpgsign=false", "commit", "--allow-empty", "-m", $"history {i}"); } await Git("checkout", "-b", "feature"); @@ -417,11 +419,11 @@ public async Task FetchMergeBase_LetsAShallowCheckoutCompareWithItsBase() await WithClone(["--depth", "1", "--branch", "feature"], async clone => { - await clone.FetchMergeBase("origin", "main", TestContext.Current.CancellationToken); + await _git.FetchMergeBase(clone, "origin", "main", TestContext.Current.CancellationToken); - var changed = await clone.ChangedFilesSince("origin/main", ".", TestContext.Current.CancellationToken); + var changed = await _git.ChangedFilesSince(clone, "origin/main", ".", TestContext.Current.CancellationToken); changed.ShouldBe(["mine.txt", "mine-too.txt"], ignoreOrder: true); - (await clone.IsShallow(TestContext.Current.CancellationToken)).ShouldBeTrue(); + (await _git.IsShallow(clone, TestContext.Current.CancellationToken)).ShouldBeTrue(); }); } @@ -431,7 +433,7 @@ public async Task FetchMergeBase_FindsABaseFurtherBackThanTheFirstRound() await Git("checkout", "-b", "feature"); for (var i = 0; i <= GitClient.MergeBaseDepths[0]; i++) { - await Git("-c", "user.name=Tests", "-c", "user.email=tests@example.com", "commit", "--allow-empty", "-m", $"step {i}"); + await Git("-c", "user.name=Tests", "-c", "user.email=tests@example.com", "-c", "commit.gpgsign=false", "commit", "--allow-empty", "-m", $"step {i}"); } await CommitFile("mine.txt"); @@ -439,9 +441,9 @@ public async Task FetchMergeBase_FindsABaseFurtherBackThanTheFirstRound() await WithClone(["--depth", "1", "--branch", "feature"], async clone => { - await clone.FetchMergeBase("origin", "main", TestContext.Current.CancellationToken); + await _git.FetchMergeBase(clone, "origin", "main", TestContext.Current.CancellationToken); - (await clone.ChangedFilesSince("origin/main", ".", TestContext.Current.CancellationToken)).ShouldBe(["mine.txt"]); + (await _git.ChangedFilesSince(clone, "origin/main", ".", TestContext.Current.CancellationToken)).ShouldBe(["mine.txt"]); }); } @@ -455,10 +457,10 @@ await WithClone([], async clone => await CommitFile("later.txt"); await Git("push", "origin", "main"); - await clone.FetchMergeBase("origin", "main", TestContext.Current.CancellationToken); + await _git.FetchMergeBase(clone, "origin", "main", TestContext.Current.CancellationToken); - (await clone.Show("origin/main", "later.txt", TestContext.Current.CancellationToken)).ShouldNotBeNull(); - (await clone.IsShallow(TestContext.Current.CancellationToken)).ShouldBeFalse(); + (await _git.Show(clone, "origin/main", "later.txt", TestContext.Current.CancellationToken)).ShouldNotBeNull(); + (await _git.IsShallow(clone, TestContext.Current.CancellationToken)).ShouldBeFalse(); }); } @@ -466,17 +468,17 @@ private async Task CommitFile(string name) { await File.WriteAllTextAsync(Path.Combine(_repository, name), name, TestContext.Current.CancellationToken); await Git("add", name); - await Git("-c", "user.name=Tests", "-c", "user.email=tests@example.com", "commit", "-m", name); + await Git("-c", "user.name=Tests", "-c", "user.email=tests@example.com", "-c", "commit.gpgsign=false", "commit", "-m", name); } - private async Task WithClone(string[] options, Func test) + private async Task WithClone(string[] options, Func test) { var clone = Directory.CreateTempSubdirectory("ritten-git-clone-"); try { // file://, not a path: git ignores --depth for a local clone it can hard-link. await Git(["clone", .. options, $"file://{_remote}", clone.FullName]); - await test(new GitClient(RunnerIn(clone.FullName))); + await test(new PhysicalDirectory(clone.FullName)); } finally { diff --git a/tests/Ritten.Tests/Git/GitTagTests.cs b/tests/Ritten.Tests/Git/GitTagTests.cs index 143c96a..e86fc3b 100644 --- a/tests/Ritten.Tests/Git/GitTagTests.cs +++ b/tests/Ritten.Tests/Git/GitTagTests.cs @@ -1,5 +1,6 @@ using Microsoft.Extensions.Options; using NuGet.Versioning; +using Ritten.Contracts.FileSystem; using Ritten.DotNet; using Ritten.Git; using Ritten.Git.Steps; @@ -20,12 +21,12 @@ public class GitTagTests [Fact] public async Task SkipsWhenTheTagAlreadyExistsOnOrigin() { - _git.RemoteTagExists("origin", "v1.2.0", Arg.Any()).Returns(true); + _git.RemoteTagExists(Arg.Any(), "origin", "v1.2.0", Arg.Any()).Returns(true); await Step().Run(_release, TheProject, TestContext.Current.CancellationToken); - await _git.DidNotReceiveWithAnyArgs().CreateTag(default!, default, TestContext.Current.CancellationToken); - await _git.DidNotReceiveWithAnyArgs().PushTag(default!, default!, TestContext.Current.CancellationToken); + await _git.DidNotReceiveWithAnyArgs().CreateTag(Arg.Any(), default!, default, TestContext.Current.CancellationToken); + await _git.DidNotReceiveWithAnyArgs().PushTag(Arg.Any(), default!, default!, TestContext.Current.CancellationToken); } [Fact] @@ -33,8 +34,8 @@ public async Task CreatesAndPushesTheTagWhenItDoesNotExist() { await Step().Run(_release, TheProject, TestContext.Current.CancellationToken); - await _git.Received().CreateTag("v1.2.0", null, Arg.Any()); - await _git.Received().PushTag("origin", "v1.2.0", Arg.Any()); + await _git.Received().CreateTag(Arg.Any(), "v1.2.0", null, Arg.Any()); + await _git.Received().PushTag(Arg.Any(), "origin", "v1.2.0", Arg.Any()); } [Fact] @@ -44,18 +45,18 @@ public async Task TagsTheConfiguredCommitSha() await Step().Run(_release, TheProject, TestContext.Current.CancellationToken); - await _git.Received().CreateTag("v1.2.0", "abc123", Arg.Any()); + await _git.Received().CreateTag(Arg.Any(), "v1.2.0", "abc123", Arg.Any()); } [Fact] public async Task PushesAnExistingLocalTagWithoutRecreatingIt() { - _git.TagExists("v1.2.0", Arg.Any()).Returns(true); + _git.TagExists(Arg.Any(), "v1.2.0", Arg.Any()).Returns(true); await Step().Run(_release, TheProject, TestContext.Current.CancellationToken); - await _git.DidNotReceiveWithAnyArgs().CreateTag(default!, default, TestContext.Current.CancellationToken); - await _git.Received().PushTag("origin", "v1.2.0", Arg.Any()); + await _git.DidNotReceiveWithAnyArgs().CreateTag(Arg.Any(), default!, default, TestContext.Current.CancellationToken); + await _git.Received().PushTag(Arg.Any(), "origin", "v1.2.0", Arg.Any()); } [Fact] @@ -65,9 +66,9 @@ public async Task HonoursTheTagPrefix() await Step().Run(_release, TheProject, TestContext.Current.CancellationToken); - await _git.Received().CreateTag("release/1.2.0", null, Arg.Any()); + await _git.Received().CreateTag(Arg.Any(), "release/1.2.0", null, Arg.Any()); } private GitTag Step() => - new(Substitute.For(), Options.Create(_options), _git); + new(Substitute.For(), Options.Create(_options), _git, Substitute.For()); } diff --git a/tests/Ritten.Tests/Init/EnsureActionsWorkflowTests.cs b/tests/Ritten.Tests/Init/EnsureActionsWorkflowTests.cs index 5ea326e..bdd40bd 100644 --- a/tests/Ritten.Tests/Init/EnsureActionsWorkflowTests.cs +++ b/tests/Ritten.Tests/Init/EnsureActionsWorkflowTests.cs @@ -35,7 +35,7 @@ public class EnsureActionsWorkflowTests public EnsureActionsWorkflowTests() { - _git.RepositoryRoot(Arg.Any()).Returns(_repository); + _git.RepositoryRoot(Arg.Any(), Arg.Any()).Returns(_repository); _fileSystem.ProjectRoot.Returns(_root); _repository.GetFile(Arg.Any()).Returns(_globalJson); @@ -199,7 +199,7 @@ public async Task RestoresTheJobItOwnsWhenItHasBeenEditedAway() [Fact] public async Task SaysSoWhenThereIsNoRepositoryToWriteInto() { - _git.RepositoryRoot(Arg.Any()).Returns((IDirectory?)null); + _git.RepositoryRoot(Arg.Any(), Arg.Any()).Returns((IDirectory?)null); var result = await Step().Run(new DotNetBuildSettings(), Found("src/My.Tool/My.Tool.csproj"), TestContext.Current.CancellationToken); diff --git a/tests/Ritten.Tests/Init/EnsureToolManifestTests.cs b/tests/Ritten.Tests/Init/EnsureToolManifestTests.cs index 4c896ca..afff979 100644 --- a/tests/Ritten.Tests/Init/EnsureToolManifestTests.cs +++ b/tests/Ritten.Tests/Init/EnsureToolManifestTests.cs @@ -26,7 +26,7 @@ public class EnsureToolManifestTests public EnsureToolManifestTests() { _fileSystem.ProjectRoot.Returns(_project); - _git.RepositoryRoot(Arg.Any()).Returns(_repository); + _git.RepositoryRoot(Arg.Any(), Arg.Any()).Returns(_repository); SetManifest(exists: false); } @@ -93,7 +93,7 @@ public async Task PinsAtTheRepositoryRootRatherThanTheProject() [Fact] public async Task FallsBackToTheProjectWhenThereIsNoRepository() { - _git.RepositoryRoot(Arg.Any()).Returns((IDirectory?)null); + _git.RepositoryRoot(Arg.Any(), Arg.Any()).Returns((IDirectory?)null); var missing = Missing(); _project.GetFile(Arg.Any()).Returns(missing); diff --git a/tests/Ritten.Tests/Releases/ReadShippedChangesTests.cs b/tests/Ritten.Tests/Releases/ReadShippedChangesTests.cs index 4f927cd..3b2a2a5 100644 --- a/tests/Ritten.Tests/Releases/ReadShippedChangesTests.cs +++ b/tests/Ritten.Tests/Releases/ReadShippedChangesTests.cs @@ -1,5 +1,6 @@ using NuGet.Versioning; using Ritten.Contracts; +using Ritten.Contracts.FileSystem; using Ritten.DotNet; using Ritten.Git; using Ritten.Releases; @@ -18,7 +19,7 @@ public class ReadShippedChangesTests private readonly IGit _git = Substitute.For(); public ReadShippedChangesTests() => - _git.ChangedFilesSince(Arg.Any(), Arg.Any(), Arg.Any()).Returns([]); + _git.ChangedFilesSince(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()).Returns([]); [Fact] public async Task IsUnreviewedOutsideAPullRequest() @@ -27,7 +28,7 @@ public async Task IsUnreviewedOutsideAPullRequest() changes.Reviewed.ShouldBeFalse(); changes.Any.ShouldBeFalse(); - await _git.DidNotReceiveWithAnyArgs().ChangedFilesSince(default!, default!, TestContext.Current.CancellationToken); + await _git.DidNotReceiveWithAnyArgs().ChangedFilesSince(Arg.Any(), default!, default!, TestContext.Current.CancellationToken); } [Fact] @@ -37,8 +38,8 @@ public async Task IsUnreviewedWhenTheCadenceDoesNotJudgeTheChanges() var changes = await Produce(Reviewing("main"), ReleaseCadence.Curated, "src/My.Tool/My.Tool.csproj"); changes.Reviewed.ShouldBeFalse(); - await _git.DidNotReceiveWithAnyArgs().FetchMergeBase(default!, default!, TestContext.Current.CancellationToken); - await _git.DidNotReceiveWithAnyArgs().ChangedFilesSince(default!, default!, TestContext.Current.CancellationToken); + await _git.DidNotReceiveWithAnyArgs().FetchMergeBase(Arg.Any(), default!, default!, TestContext.Current.CancellationToken); + await _git.DidNotReceiveWithAnyArgs().ChangedFilesSince(Arg.Any(), default!, default!, TestContext.Current.CancellationToken); } [Fact] @@ -46,8 +47,8 @@ public async Task FetchesTheBaseBeforeDiffing() { var fetched = false; var diffedFirst = false; - _git.When(g => g.FetchMergeBase("origin", "main", Arg.Any())).Do(_ => fetched = true); - _git.When(g => g.ChangedFilesSince(Arg.Any(), Arg.Any(), Arg.Any())).Do(_ => diffedFirst |= !fetched); + _git.When(g => g.FetchMergeBase(Arg.Any(), "origin", "main", Arg.Any())).Do(_ => fetched = true); + _git.When(g => g.ChangedFilesSince(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any())).Do(_ => diffedFirst |= !fetched); await Produce(Reviewing("main"), "src/My.Tool/My.Tool.csproj"); @@ -63,15 +64,15 @@ public async Task DiffsEachShippedProjectAndTheSharedInputsAgainstTheRemoteBase( foreach (var path in new[] { "src/My.Tool", "src/My.Core", "Directory.Build.props", "Directory.Packages.props" }) { - await _git.Received(1).ChangedFilesSince("origin/main", path, Arg.Any()); + await _git.Received(1).ChangedFilesSince(Arg.Any(), "origin/main", path, Arg.Any()); } } [Fact] public async Task ReportsWhatChanged() { - _git.ChangedFilesSince("origin/main", "src/My.Tool", Arg.Any()).Returns(["src/My.Tool/Program.cs"]); - _git.ChangedFilesSince("origin/main", "Directory.Packages.props", Arg.Any()).Returns(["Directory.Packages.props"]); + _git.ChangedFilesSince(Arg.Any(), "origin/main", "src/My.Tool", Arg.Any()).Returns(["src/My.Tool/Program.cs"]); + _git.ChangedFilesSince(Arg.Any(), "origin/main", "Directory.Packages.props", Arg.Any()).Returns(["Directory.Packages.props"]); var changes = await Produce(Reviewing("main"), "src/My.Tool/My.Tool.csproj"); @@ -94,7 +95,7 @@ public async Task TreatsAProjectAtTheRootAsTheWholeRepository() { await Produce(Reviewing("main"), "My.Tool.csproj"); - await _git.Received(1).ChangedFilesSince("origin/main", ".", Arg.Any()); + await _git.Received(1).ChangedFilesSince(Arg.Any(), "origin/main", ".", Arg.Any()); } private static PullRequest Reviewing(string baseRef) => new() { Number = 7, BaseRef = baseRef }; @@ -110,7 +111,7 @@ private async Task Produce(PullRequest pullRequest, ReleaseCaden }; var release = new ReleaseSettings { Cadence = cadence }; - var result = await new ReadShippedChanges(pullRequest, _git, Substitute.For()).Run(release, packages, TestContext.Current.CancellationToken); + var result = await new ReadShippedChanges(pullRequest, _git, Substitute.For(), Substitute.For()).Run(release, packages, TestContext.Current.CancellationToken); return result.Value.ShouldNotBeNull(); } }