From 5bf041a9adb30af4bac2f36c63f870f5a1876327 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Wed, 5 Aug 2026 01:16:48 +0100 Subject: [PATCH 1/3] feat: add pipeline working directory --- .../PipelineProjectDirectory.cs | 53 -------- .../templates/modularpipeline/Program.cs | 5 +- src/ModularPipelines/Context/Command.cs | 12 +- .../Domains/IEnvironmentDomainContext.cs | 5 +- .../EnvironmentDomainContext.cs | 6 +- .../Domains/Implementations/FilesContext.cs | 15 ++- .../Context/EnvironmentContext.cs | 8 +- .../Context/FileSystemContext.cs | 20 +-- .../Context/IEnvironmentContext.cs | 14 +-- src/ModularPipelines/Pipeline.cs | 13 +- src/ModularPipelines/PipelineBuilder.cs | 22 +++- .../PipelineBuilderOptions.cs | 8 ++ src/ModularPipelines/PipelineDirectory.cs | 86 +++++++++++++ .../PipelineWorkingDirectory.cs | 9 ++ .../PipelineBuilderRegistrationTests.cs | 1 + .../PipelineWorkingDirectoryTests.cs | 114 ++++++++++++++++++ 16 files changed, 297 insertions(+), 94 deletions(-) delete mode 100644 src/ModularPipelines.Templates/templates/modularpipeline/PipelineProjectDirectory.cs create mode 100644 src/ModularPipelines/PipelineDirectory.cs create mode 100644 src/ModularPipelines/PipelineWorkingDirectory.cs create mode 100644 test/ModularPipelines.UnitTests/Registration/PipelineWorkingDirectoryTests.cs diff --git a/src/ModularPipelines.Templates/templates/modularpipeline/PipelineProjectDirectory.cs b/src/ModularPipelines.Templates/templates/modularpipeline/PipelineProjectDirectory.cs deleted file mode 100644 index b63bd79e18d..00000000000 --- a/src/ModularPipelines.Templates/templates/modularpipeline/PipelineProjectDirectory.cs +++ /dev/null @@ -1,53 +0,0 @@ -using System.Runtime.CompilerServices; - -namespace TemplatePipeline; - -internal static class PipelineProjectDirectory -{ - private const string DirectoryVariable = "MODULAR_PIPELINES_DIRECTORY"; - - public static string Find([CallerFilePath] string sourceFilePath = "") - { - var configuredDirectory = Environment.GetEnvironmentVariable(DirectoryVariable); - if (!string.IsNullOrWhiteSpace(configuredDirectory)) - { - return ValidateConfiguredDirectory(configuredDirectory); - } - - var sourceDirectory = Path.GetDirectoryName(sourceFilePath); - return IsPipelineDirectory(sourceDirectory) - ? sourceDirectory! - : FindFromBuildOutput(); - } - - private static string ValidateConfiguredDirectory(string configuredDirectory) - { - var fullPath = Path.GetFullPath(configuredDirectory); - return IsPipelineDirectory(fullPath) - ? fullPath - : throw new InvalidOperationException( - $"{DirectoryVariable} must point to a directory containing appsettings.json and a project file."); - } - - private static string FindFromBuildOutput() - { - for (var directory = new DirectoryInfo(AppContext.BaseDirectory); - directory is not null; - directory = directory.Parent) - { - if (IsPipelineDirectory(directory.FullName)) - { - return directory.FullName; - } - } - - throw new InvalidOperationException( - $"Could not locate the pipeline project directory. Set {DirectoryVariable} to its path."); - } - - private static bool IsPipelineDirectory(string? directory) => - directory is not null - && Directory.Exists(directory) - && File.Exists(Path.Combine(directory, "appsettings.json")) - && Directory.EnumerateFiles(directory, "*.csproj").Any(); -} diff --git a/src/ModularPipelines.Templates/templates/modularpipeline/Program.cs b/src/ModularPipelines.Templates/templates/modularpipeline/Program.cs index a881e3ab96a..eb945833853 100644 --- a/src/ModularPipelines.Templates/templates/modularpipeline/Program.cs +++ b/src/ModularPipelines.Templates/templates/modularpipeline/Program.cs @@ -6,13 +6,10 @@ using TemplatePipeline.Modules; using TemplatePipeline.Settings; -var pipelineDirectory = PipelineProjectDirectory.Find(); -Environment.CurrentDirectory = pipelineDirectory; - var builder = Pipeline.CreateBuilder(args); builder.Configuration - .AddJsonFile(Path.Combine(pipelineDirectory, "appsettings.json"), optional: false) + .AddJsonFile("appsettings.json", optional: false) .AddEnvironmentVariables(); builder.Services.Configure(builder.Configuration.GetSection("Build")); diff --git a/src/ModularPipelines/Context/Command.cs b/src/ModularPipelines/Context/Command.cs index 989cb614a27..17c022fa6bd 100644 --- a/src/ModularPipelines/Context/Command.cs +++ b/src/ModularPipelines/Context/Command.cs @@ -37,6 +37,7 @@ internal sealed class Command : ICommandContext private readonly ISecretObfuscator _secretObfuscator; private readonly ICommandExecutionCounter _commandExecutionCounter; private readonly IOptions _pipelineOptions; + private readonly PipelineWorkingDirectory _pipelineWorkingDirectory; public Command( ICommandLogger commandLogger, @@ -46,7 +47,8 @@ public Command( ISecretRegistry secretRegistry, ISecretObfuscator secretObfuscator, ICommandExecutionCounter commandExecutionCounter, - IOptions pipelineOptions) + IOptions pipelineOptions, + PipelineWorkingDirectory pipelineWorkingDirectory) { _commandLogger = commandLogger; _commandLineBuilder = commandLineBuilder; @@ -56,6 +58,7 @@ public Command( _secretObfuscator = secretObfuscator; _commandExecutionCounter = commandExecutionCounter; _pipelineOptions = pipelineOptions; + _pipelineWorkingDirectory = pipelineWorkingDirectory; } public async Task ExecuteCommandLineToolAsync( @@ -64,7 +67,12 @@ public async Task ExecuteCommandLineToolAsync( CancellationToken cancellationToken = default) { _commandExecutionCounter.Record(AmbientModuleContext.CurrentModuleType); - var execOpts = executionOptions ?? new CommandExecutionOptions(); + var execOpts = (executionOptions ?? new CommandExecutionOptions()) with + { + WorkingDirectory = executionOptions?.WorkingDirectory is { } workingDirectory + ? _pipelineWorkingDirectory.ResolvePath(workingDirectory) + : _pipelineWorkingDirectory.Path, + }; RegisterSecrets(options, execOpts); var (command, commandInput, tool, parsedArgs) = CreateCommand(options, execOpts); diff --git a/src/ModularPipelines/Context/Domains/IEnvironmentDomainContext.cs b/src/ModularPipelines/Context/Domains/IEnvironmentDomainContext.cs index 1d2f6737168..44c581ebcaa 100644 --- a/src/ModularPipelines/Context/Domains/IEnvironmentDomainContext.cs +++ b/src/ModularPipelines/Context/Domains/IEnvironmentDomainContext.cs @@ -29,10 +29,11 @@ public interface IEnvironmentDomainContext string UserName { get; } /// - /// Gets the working directory captured when the pipeline context was created. + /// Gets the pipeline's configured working directory. /// /// - /// To run a command in another directory, set + /// Set when creating the pipeline, + /// or override an individual command with /// . /// string WorkingDirectory { get; } diff --git a/src/ModularPipelines/Context/Domains/Implementations/EnvironmentDomainContext.cs b/src/ModularPipelines/Context/Domains/Implementations/EnvironmentDomainContext.cs index 5f0ca80c03a..d468827ea8c 100644 --- a/src/ModularPipelines/Context/Domains/Implementations/EnvironmentDomainContext.cs +++ b/src/ModularPipelines/Context/Domains/Implementations/EnvironmentDomainContext.cs @@ -18,13 +18,15 @@ internal class EnvironmentDomainContext : IEnvironmentDomainContext /// /// The environment variables context. /// The build system context. + /// The configured pipeline working directory. public EnvironmentDomainContext( IEnvironmentVariablesContext variables, - IBuildSystemContext buildSystem) + IBuildSystemContext buildSystem, + PipelineWorkingDirectory workingDirectory) { Variables = variables; BuildSystem = buildSystem; - WorkingDirectory = System.Environment.CurrentDirectory; + WorkingDirectory = workingDirectory.Path; } /// diff --git a/src/ModularPipelines/Context/Domains/Implementations/FilesContext.cs b/src/ModularPipelines/Context/Domains/Implementations/FilesContext.cs index 79481e1cf8c..1fdefea3415 100644 --- a/src/ModularPipelines/Context/Domains/Implementations/FilesContext.cs +++ b/src/ModularPipelines/Context/Domains/Implementations/FilesContext.cs @@ -11,11 +11,13 @@ namespace ModularPipelines.Context.Domains.Implementations; internal class FilesContext( IFileSystemContext fileSystemContext, IFileSystemProvider fileSystemProvider, + PipelineWorkingDirectory workingDirectory, IZipContext zip, IChecksumContext checksum) : IFilesContext { private readonly IFileSystemContext _fileSystemContext = fileSystemContext; private readonly IFileSystemProvider _fileSystemProvider = fileSystemProvider; + private readonly PipelineWorkingDirectory _workingDirectory = workingDirectory; /// public File GetFile(string path) => _fileSystemContext.GetFile(path); @@ -28,12 +30,12 @@ internal class FilesContext( /// public IEnumerable Glob(string pattern) => - GetFolder(System.Environment.CurrentDirectory).GetFiles(pattern); + GetFolder(_workingDirectory.Path).GetFiles(pattern); /// public IEnumerable GlobFolders(string pattern) { - var currentDirectory = System.Environment.CurrentDirectory; + var currentDirectory = _workingDirectory.Path; var matcher = new Matcher(StringComparison.OrdinalIgnoreCase) .AddInclude(pattern); @@ -47,19 +49,20 @@ public IEnumerable GlobFolders(string pattern) /// public Task ReadAsync(string path, CancellationToken cancellationToken = default) - => _fileSystemProvider.ReadAllTextAsync(path, cancellationToken); + => _fileSystemProvider.ReadAllTextAsync(_workingDirectory.ResolvePath(path), cancellationToken); /// public Task WriteAsync(string path, string content, CancellationToken cancellationToken = default) - => _fileSystemProvider.WriteAllTextAsync(path, content, cancellationToken); + => _fileSystemProvider.WriteAllTextAsync(_workingDirectory.ResolvePath(path), content, cancellationToken); /// public Task ExistsAsync(string path, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); + var resolvedPath = _workingDirectory.ResolvePath(path); return Task.FromResult( - _fileSystemProvider.FileExists(path) - || _fileSystemProvider.DirectoryExists(path)); + _fileSystemProvider.FileExists(resolvedPath) + || _fileSystemProvider.DirectoryExists(resolvedPath)); } /// diff --git a/src/ModularPipelines/Context/EnvironmentContext.cs b/src/ModularPipelines/Context/EnvironmentContext.cs index 6ea1a31b7c9..a8b9fb79209 100644 --- a/src/ModularPipelines/Context/EnvironmentContext.cs +++ b/src/ModularPipelines/Context/EnvironmentContext.cs @@ -9,11 +9,13 @@ internal class EnvironmentContext : IEnvironmentContext private readonly IHostEnvironment _hostEnvironment; public EnvironmentContext(IHostEnvironment hostEnvironment, - IEnvironmentVariablesContext environmentVariables) + IEnvironmentVariablesContext environmentVariables, + PipelineWorkingDirectory workingDirectory) { _hostEnvironment = hostEnvironment; EnvironmentVariables = environmentVariables; ContentDirectory = _hostEnvironment.ContentRootPath!; + WorkingDirectory = new Folder(workingDirectory.Path); OperatingSystem = OperatingSystemHelper.GetOperatingSystem(); } @@ -30,7 +32,7 @@ public EnvironmentContext(IHostEnvironment hostEnvironment, public Folder ContentDirectory { get; } - public Folder WorkingDirectory { get; } = Environment.CurrentDirectory!; + public Folder WorkingDirectory { get; } public IEnvironmentVariablesContext EnvironmentVariables { get; } @@ -38,4 +40,4 @@ public EnvironmentContext(IHostEnvironment hostEnvironment, { return Environment.GetFolderPath(specialFolder); } -} \ No newline at end of file +} diff --git a/src/ModularPipelines/Context/FileSystemContext.cs b/src/ModularPipelines/Context/FileSystemContext.cs index 62c36a1b7b3..284da8ad177 100644 --- a/src/ModularPipelines/Context/FileSystemContext.cs +++ b/src/ModularPipelines/Context/FileSystemContext.cs @@ -6,23 +6,27 @@ namespace ModularPipelines.Context; internal class FileSystemContext : IFileSystemContext { private readonly IFileSystemProvider _provider; + private readonly PipelineWorkingDirectory _workingDirectory; - public FileSystemContext(IFileSystemProvider provider) + public FileSystemContext( + IFileSystemProvider provider, + PipelineWorkingDirectory workingDirectory) { _provider = provider; + _workingDirectory = workingDirectory; } public void DeleteFile(File file) => file.Delete(); public void DeleteFolder(Folder folder) => folder.Delete(); - public File CopyFile(File file, string destinationFilePath) => file.CopyTo(destinationFilePath); + public File CopyFile(File file, string destinationFilePath) => file.CopyTo(ResolvePath(destinationFilePath)); - public Folder CopyFolder(Folder folder, string destinationFolder) => folder.CopyTo(destinationFolder); + public Folder CopyFolder(Folder folder, string destinationFolder) => folder.CopyTo(ResolvePath(destinationFolder)); - public void MoveFile(File file, string destinationFilePath) => file.MoveTo(destinationFilePath); + public void MoveFile(File file, string destinationFilePath) => file.MoveTo(ResolvePath(destinationFilePath)); - public void MoveFolder(Folder folder, string destinationFolderPath) => folder.MoveTo(destinationFolderPath); + public void MoveFolder(Folder folder, string destinationFolderPath) => folder.MoveTo(ResolvePath(destinationFolderPath)); public bool FileExists(File file) => file.Exists; @@ -36,7 +40,7 @@ public FileSystemContext(IFileSystemProvider provider) public void SetFolderAttributes(Folder folder, FileAttributes attributes) => folder.Attributes = attributes; - public File GetFile(string filePath) => new(filePath, _provider); + public File GetFile(string filePath) => new(ResolvePath(filePath), _provider); public IEnumerable GetFiles(Folder rootFolder, Func predicate) { @@ -48,7 +52,7 @@ public IEnumerable GetFolders(Folder rootFolder, Func pred return rootFolder.GetFolders(predicate); } - public Folder GetFolder(string path) => new(path, _provider); + public Folder GetFolder(string path) => new(ResolvePath(path), _provider); public Folder GetFolder(Environment.SpecialFolder specialFolder) { @@ -66,4 +70,6 @@ public string GetNewTemporaryFilePath() { return _provider.Combine(_provider.GetTempPath(), _provider.GetRandomFileName()); } + + private string ResolvePath(string path) => _workingDirectory.ResolvePath(path); } diff --git a/src/ModularPipelines/Context/IEnvironmentContext.cs b/src/ModularPipelines/Context/IEnvironmentContext.cs index 5299b084b79..a1789bdc85f 100644 --- a/src/ModularPipelines/Context/IEnvironmentContext.cs +++ b/src/ModularPipelines/Context/IEnvironmentContext.cs @@ -36,16 +36,16 @@ public interface IEnvironmentContext /// /// /// This property is immutable after pipeline initialization. - /// If you need to change the working directory for command execution, - /// use command options or directly. /// public Folder ContentDirectory { get; } - /// + /// + /// Gets the pipeline's configured working directory. + /// /// - /// This property captures the working directory at pipeline initialization time and is immutable. - /// If you need to change the working directory for command execution, - /// use command options or directly. + /// This property is immutable after pipeline initialization. Set + /// when creating the pipeline, + /// or override an individual command with . /// public Folder WorkingDirectory { get; } @@ -56,4 +56,4 @@ public interface IEnvironmentContext /// Gets the Environment Variables available to this Pipeline. /// public IEnvironmentVariablesContext EnvironmentVariables { get; } -} \ No newline at end of file +} diff --git a/src/ModularPipelines/Pipeline.cs b/src/ModularPipelines/Pipeline.cs index d40dad5597e..cc02d60e148 100644 --- a/src/ModularPipelines/Pipeline.cs +++ b/src/ModularPipelines/Pipeline.cs @@ -1,3 +1,5 @@ +using System.Runtime.CompilerServices; + namespace ModularPipelines; /// @@ -9,6 +11,7 @@ public static class Pipeline /// Creates a new pipeline builder. /// /// Optional command line arguments. + /// The calling source file path, supplied by the compiler. /// A new pipeline builder instance. /// /// @@ -24,9 +27,15 @@ public static class Pipeline /// var summary = await pipeline.RunAsync(); /// /// - public static PipelineBuilder CreateBuilder(string[]? args = null) + public static PipelineBuilder CreateBuilder( + string[]? args = null, + [CallerFilePath] string sourceFilePath = "") { - return new PipelineBuilder(args); + return new PipelineBuilder(new PipelineBuilderOptions + { + Args = args, + WorkingDirectory = PipelineDirectory.TryFindPipelineProject(sourceFilePath), + }); } /// diff --git a/src/ModularPipelines/PipelineBuilder.cs b/src/ModularPipelines/PipelineBuilder.cs index 2446a646aaa..92ded325f55 100644 --- a/src/ModularPipelines/PipelineBuilder.cs +++ b/src/ModularPipelines/PipelineBuilder.cs @@ -40,11 +40,6 @@ public sealed class PipelineBuilder : IDisposable private readonly PipelineCommandLineOptions _commandLineOptions; private PipelineOptions _options; - internal PipelineBuilder(string[]? args) - : this(new PipelineBuilderOptions { Args = args }) - { - } - internal PipelineBuilder(PipelineBuilderOptions options) { ArgumentNullException.ThrowIfNull(options); @@ -79,6 +74,7 @@ internal PipelineBuilder(PipelineBuilderOptions options) _environment = CreateHostEnvironment(options, args); _resources = _environment.Resources; + _configuration.SetBasePath(_environment.WorkingDirectory); _hostBuilder.UseEnvironment(_environment.EnvironmentName); _hostBuilder.UseContentRoot(_environment.ContentRootPath); @@ -122,6 +118,11 @@ internal void SetOptions(PipelineOptions options) /// public IHostEnvironment Environment => _environment; + /// + /// Gets the default working directory for commands and relative file paths. + /// + public string WorkingDirectory => _environment.WorkingDirectory; + /// /// Retained for source compatibility. Resources are owned by the built pipeline. /// @@ -289,8 +290,13 @@ private static PipelineHostEnvironment CreateHostEnvironment( options.EnvironmentName, hostConfiguration[HostDefaults.EnvironmentKey], System.Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT")); - var contentRootPath = Path.GetFullPath(FirstNonEmpty( + var workingDirectory = Path.GetFullPath(FirstNonEmpty( Directory.GetCurrentDirectory(), + options.WorkingDirectory, + options.ContentRootPath, + hostConfiguration[HostDefaults.ContentRootKey])); + var contentRootPath = Path.GetFullPath(FirstNonEmpty( + workingDirectory, options.ContentRootPath, hostConfiguration[HostDefaults.ContentRootKey])); var applicationName = FirstNonEmpty( @@ -305,6 +311,7 @@ private static PipelineHostEnvironment CreateHostEnvironment( ApplicationName = applicationName, EnvironmentName = environmentName, ContentRootPath = contentRootPath, + WorkingDirectory = workingDirectory, }; } @@ -348,6 +355,7 @@ private async Task BuildPipelineAsync(bool initializePipeline) services .AddSingleton(_commandLineOptions) + .AddSingleton(new PipelineWorkingDirectory(_environment.WorkingDirectory)) .AddSingleton(_options) .AddTransient, PipelineOptionsFactory>(); @@ -540,6 +548,8 @@ private sealed class PipelineHostEnvironment(PipelineBuilderResources resources) public string ContentRootPath { get; set; } = string.Empty; + internal string WorkingDirectory { get; init; } = string.Empty; + public IFileProvider ContentRootFileProvider { get => Resources.ContentRootFileProvider; diff --git a/src/ModularPipelines/PipelineBuilderOptions.cs b/src/ModularPipelines/PipelineBuilderOptions.cs index 1ecf4b84dae..476d32fd00b 100644 --- a/src/ModularPipelines/PipelineBuilderOptions.cs +++ b/src/ModularPipelines/PipelineBuilderOptions.cs @@ -30,4 +30,12 @@ public sealed record PipelineBuilderOptions /// Gets the content root path. /// public string? ContentRootPath { get; init; } + + /// + /// Gets the default working directory for commands and relative file paths. + /// + /// + /// When omitted, the configured content root is used, falling back to the process working directory. + /// + public string? WorkingDirectory { get; init; } } diff --git a/src/ModularPipelines/PipelineDirectory.cs b/src/ModularPipelines/PipelineDirectory.cs new file mode 100644 index 00000000000..334fe2e1ee5 --- /dev/null +++ b/src/ModularPipelines/PipelineDirectory.cs @@ -0,0 +1,86 @@ +using System.Runtime.CompilerServices; + +namespace ModularPipelines; + +/// +/// Locates conventional directories used by a pipeline. +/// +public static class PipelineDirectory +{ + private const string DirectoryVariable = "MODULAR_PIPELINES_DIRECTORY"; + + /// + /// Finds the pipeline project containing appsettings.json and a project file. + /// + /// The calling source file path, supplied by the compiler. + /// The absolute pipeline project directory. + /// The pipeline project cannot be located. + public static string FindPipelineProject([CallerFilePath] string sourceFilePath = "") => + TryFindPipelineProject(sourceFilePath) + ?? throw new InvalidOperationException( + $"Could not locate the pipeline project directory. Set {DirectoryVariable} to its path."); + + /// + /// Finds the nearest Git repository root. + /// + /// The calling source file path, supplied by the compiler. + /// The absolute Git repository root. + /// A Git repository root cannot be located. + public static string FindGitRoot([CallerFilePath] string sourceFilePath = "") => + FindAncestor(GetSearchDirectory(sourceFilePath), IsGitRoot) + ?? throw new InvalidOperationException("Could not locate a Git repository root."); + + internal static string? TryFindPipelineProject(string sourceFilePath) + { + var configuredDirectory = Environment.GetEnvironmentVariable(DirectoryVariable); + if (!string.IsNullOrWhiteSpace(configuredDirectory)) + { + var fullPath = Path.GetFullPath(configuredDirectory); + return IsPipelineProject(fullPath) + ? fullPath + : throw new InvalidOperationException( + $"{DirectoryVariable} must point to a directory containing appsettings.json and a project file."); + } + + var sourceDirectory = Path.GetDirectoryName(sourceFilePath); + if (IsPipelineProject(sourceDirectory)) + { + return Path.GetFullPath(sourceDirectory!); + } + + return FindAncestor(AppContext.BaseDirectory, IsPipelineProject); + } + + private static string GetSearchDirectory(string sourceFilePath) + { + var sourceDirectory = Path.GetDirectoryName(sourceFilePath); + return Directory.Exists(sourceDirectory) + ? sourceDirectory! + : Directory.GetCurrentDirectory(); + } + + private static string? FindAncestor(string startDirectory, Func predicate) + { + for (var directory = new DirectoryInfo(startDirectory); + directory is not null; + directory = directory.Parent) + { + if (predicate(directory.FullName)) + { + return directory.FullName; + } + } + + return null; + } + + private static bool IsPipelineProject(string? directory) => + directory is not null + && Directory.Exists(directory) + && File.Exists(Path.Combine(directory, "appsettings.json")) + && Directory.EnumerateFiles(directory, "*.csproj").Any(); + + private static bool IsGitRoot(string directory) => + Directory.Exists(Path.Combine(directory, ".git")) + || File.Exists(Path.Combine(directory, ".git")); +} diff --git a/src/ModularPipelines/PipelineWorkingDirectory.cs b/src/ModularPipelines/PipelineWorkingDirectory.cs new file mode 100644 index 00000000000..6c951db209c --- /dev/null +++ b/src/ModularPipelines/PipelineWorkingDirectory.cs @@ -0,0 +1,9 @@ +namespace ModularPipelines; + +internal sealed record PipelineWorkingDirectory(string Path) +{ + public string ResolvePath(string path) => + System.IO.Path.IsPathFullyQualified(path) + ? path + : System.IO.Path.GetFullPath(path, Path); +} diff --git a/test/ModularPipelines.UnitTests/Registration/PipelineBuilderRegistrationTests.cs b/test/ModularPipelines.UnitTests/Registration/PipelineBuilderRegistrationTests.cs index 722f7c8d2f4..b7861228cd1 100644 --- a/test/ModularPipelines.UnitTests/Registration/PipelineBuilderRegistrationTests.cs +++ b/test/ModularPipelines.UnitTests/Registration/PipelineBuilderRegistrationTests.cs @@ -151,6 +151,7 @@ public async Task Environment_IsCachedAndHonorsBuilderOptions() await Assert.That(environment.ApplicationName).IsEqualTo("ConfiguredApp"); await Assert.That(environment.EnvironmentName).IsEqualTo("ConfiguredEnvironment"); await Assert.That(environment.ContentRootPath).IsEqualTo(Path.GetFullPath(contentRoot)); + await Assert.That(builder.WorkingDirectory).IsEqualTo(Path.GetFullPath(contentRoot)); await Assert.That(environment.ContentRootFileProvider.GetFileInfo(fileName).Exists).IsTrue(); } finally diff --git a/test/ModularPipelines.UnitTests/Registration/PipelineWorkingDirectoryTests.cs b/test/ModularPipelines.UnitTests/Registration/PipelineWorkingDirectoryTests.cs new file mode 100644 index 00000000000..efdfccb2cbc --- /dev/null +++ b/test/ModularPipelines.UnitTests/Registration/PipelineWorkingDirectoryTests.cs @@ -0,0 +1,114 @@ +using Microsoft.Extensions.Configuration; +using ModularPipelines.Context; +using ModularPipelines.Extensions; +using ModularPipelines.Modules; + +namespace ModularPipelines.UnitTests.Registration; + +[TUnit.Core.NotInParallel("ProcessEnvironment")] +public class PipelineWorkingDirectoryTests +{ + private sealed record WorkingDirectoryObservation( + string EnvironmentDirectory, + string FilePath, + string CommandDirectory); + + private sealed class ObserveWorkingDirectoryModule : Module + { + protected internal override async Task ExecuteAsync( + IModuleContext context, + CancellationToken cancellationToken) + { + await context.Files.WriteAsync("relative.txt", "content", cancellationToken); + var command = await context.Shell.PowerShell.ScriptAsync( + new("Write-Output $PWD.Path"), + cancellationToken: cancellationToken); + + return new WorkingDirectoryObservation( + context.Environment.WorkingDirectory, + context.Files.GetFile("relative.txt").Path, + command.WorkingDirectory); + } + } + + [Test] + public async Task ConfiguredWorkingDirectoryScopesCommandsAndFiles() + { + var processDirectory = Environment.CurrentDirectory; + var workingDirectory = Directory.CreateTempSubdirectory("pipeline-working-directory-"); + + try + { + using var builder = Pipeline.CreateBuilder(new PipelineBuilderOptions + { + WorkingDirectory = workingDirectory.FullName, + }); + builder.AddModule(); + + var summary = await builder.ExecutePipelineAsync(); + var result = await summary.Modules.OfType().Single(); + var observation = result.ValueOrDefault!; + + using (Assert.Multiple()) + { + await Assert.That(builder.WorkingDirectory).IsEqualTo(workingDirectory.FullName); + await Assert.That(builder.Environment.ContentRootPath).IsEqualTo(workingDirectory.FullName); + await Assert.That(observation.EnvironmentDirectory).IsEqualTo(workingDirectory.FullName); + await Assert.That(observation.FilePath) + .IsEqualTo(Path.Combine(workingDirectory.FullName, "relative.txt")); + await Assert.That(observation.CommandDirectory).IsEqualTo(workingDirectory.FullName); + await Assert.That(Environment.CurrentDirectory).IsEqualTo(processDirectory); + } + } + finally + { + workingDirectory.Delete(recursive: true); + } + } + + [Test] + public async Task CreateBuilderFindsPipelineProjectFromCallerPath() + { + var projectDirectory = Directory.CreateTempSubdirectory("pipeline-project-"); + await File.WriteAllTextAsync( + Path.Combine(projectDirectory.FullName, "appsettings.json"), + "{\"PipelineProject\":true}"); + await File.WriteAllTextAsync(Path.Combine(projectDirectory.FullName, "Pipeline.csproj"), ""); + + try + { + using var builder = Pipeline.CreateBuilder( + sourceFilePath: Path.Combine(projectDirectory.FullName, "Program.cs")); + builder.Configuration.AddJsonFile("appsettings.json"); + + using (Assert.Multiple()) + { + await Assert.That(builder.WorkingDirectory).IsEqualTo(projectDirectory.FullName); + await Assert.That(builder.Configuration.GetValue("PipelineProject")).IsTrue(); + } + } + finally + { + projectDirectory.Delete(recursive: true); + } + } + + [Test] + public async Task FindGitRootWalksParentDirectories() + { + var repositoryDirectory = Directory.CreateTempSubdirectory("pipeline-git-root-"); + var nestedDirectory = Directory.CreateDirectory(Path.Combine(repositoryDirectory.FullName, "src", "Pipeline")); + Directory.CreateDirectory(Path.Combine(repositoryDirectory.FullName, ".git")); + + try + { + var result = PipelineDirectory.FindGitRoot(Path.Combine(nestedDirectory.FullName, "Program.cs")); + + await Assert.That(result).IsEqualTo(repositoryDirectory.FullName); + } + finally + { + repositoryDirectory.Delete(recursive: true); + } + } +} From 15e9687778acd9d4f881255b9d0e86063b6665de Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Wed, 5 Aug 2026 03:40:50 +0100 Subject: [PATCH 2/3] docs: explain working directory default --- src/ModularPipelines/Pipeline.cs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/ModularPipelines/Pipeline.cs b/src/ModularPipelines/Pipeline.cs index cc02d60e148..c8e20911668 100644 --- a/src/ModularPipelines/Pipeline.cs +++ b/src/ModularPipelines/Pipeline.cs @@ -13,6 +13,13 @@ public static class Pipeline /// Optional command line arguments. /// The calling source file path, supplied by the compiler. /// A new pipeline builder instance. + /// + /// The pipeline working directory defaults to the project containing the calling source file. + /// MODULAR_PIPELINES_DIRECTORY can override that project directory. If neither can be + /// resolved, the process working directory is used. To preserve the process working directory + /// explicitly, use and leave + /// unset. + /// /// /// /// var builder = Pipeline.CreateBuilder(args); @@ -43,6 +50,11 @@ public static PipelineBuilder CreateBuilder( /// /// The builder options. /// A new pipeline builder instance. + /// + /// Unlike , this overload does not infer a + /// pipeline project directory. When is + /// unset, the configured content root is used, falling back to the process working directory. + /// /// /// /// var builder = Pipeline.CreateBuilder(new PipelineBuilderOptions From 48b417cc269cfa211aab59bbfa4f6c07afbd21a7 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Wed, 5 Aug 2026 04:37:33 +0100 Subject: [PATCH 3/3] fix: complete working directory scoping --- .../templates/modularpipeline/Program.cs | 2 +- src/ModularPipelines/Context/Checksum.cs | 5 ++- src/ModularPipelines/Context/Zip.cs | 8 ++++- src/ModularPipelines/Pipeline.cs | 33 +++++++++++-------- src/ModularPipelines/PipelineDirectory.cs | 9 +++-- .../Helpers/ZipTests.cs | 6 ++-- .../PipelineWorkingDirectoryTests.cs | 27 +++++++++++++-- 7 files changed, 63 insertions(+), 27 deletions(-) diff --git a/src/ModularPipelines.Templates/templates/modularpipeline/Program.cs b/src/ModularPipelines.Templates/templates/modularpipeline/Program.cs index eb945833853..224c92bc9bd 100644 --- a/src/ModularPipelines.Templates/templates/modularpipeline/Program.cs +++ b/src/ModularPipelines.Templates/templates/modularpipeline/Program.cs @@ -6,7 +6,7 @@ using TemplatePipeline.Modules; using TemplatePipeline.Settings; -var builder = Pipeline.CreateBuilder(args); +var builder = Pipeline.CreateBuilderFromSource(args); builder.Configuration .AddJsonFile("appsettings.json", optional: false) diff --git a/src/ModularPipelines/Context/Checksum.cs b/src/ModularPipelines/Context/Checksum.cs index 0484aaac6b4..f23d2f2c03c 100644 --- a/src/ModularPipelines/Context/Checksum.cs +++ b/src/ModularPipelines/Context/Checksum.cs @@ -4,10 +4,13 @@ namespace ModularPipelines.Context; -internal class Checksum(IFileSystemProvider fileSystemProvider) : IChecksumContext +internal class Checksum( + IFileSystemProvider fileSystemProvider, + PipelineWorkingDirectory workingDirectory) : IChecksumContext { public string Md5(string filePath) { + filePath = workingDirectory.ResolvePath(filePath); if (!fileSystemProvider.FileExists(filePath)) { throw new FileNotFoundException($"Cannot calculate MD5 checksum: file not found at '{filePath}'", filePath); diff --git a/src/ModularPipelines/Context/Zip.cs b/src/ModularPipelines/Context/Zip.cs index 794c9d76b91..3de9058df1d 100644 --- a/src/ModularPipelines/Context/Zip.cs +++ b/src/ModularPipelines/Context/Zip.cs @@ -6,12 +6,16 @@ namespace ModularPipelines.Context; -internal class Zip(IFileSystemProvider fileSystemProvider) : IZipContext +internal class Zip( + IFileSystemProvider fileSystemProvider, + PipelineWorkingDirectory workingDirectory) : IZipContext { private readonly IFileSystemProvider _fileSystemProvider = fileSystemProvider; + private readonly PipelineWorkingDirectory _workingDirectory = workingDirectory; public File ZipFolder(Folder folder, string outputPath, CompressionLevel compressionLevel) { + outputPath = _workingDirectory.ResolvePath(outputPath); var outputIsDirectory = _fileSystemProvider.DirectoryExists(outputPath) || (!_fileSystemProvider.FileExists(outputPath) && IsDirectoryPath(outputPath)); @@ -74,6 +78,8 @@ public Folder UnZipToFolder(string zipPath, string outputFolderPath, bool overwr { ArgumentException.ThrowIfNullOrWhiteSpace(zipPath); ArgumentException.ThrowIfNullOrWhiteSpace(outputFolderPath); + zipPath = _workingDirectory.ResolvePath(zipPath); + outputFolderPath = _workingDirectory.ResolvePath(outputFolderPath); if (!_fileSystemProvider.FileExists(zipPath)) { diff --git a/src/ModularPipelines/Pipeline.cs b/src/ModularPipelines/Pipeline.cs index c8e20911668..3b0f45c877b 100644 --- a/src/ModularPipelines/Pipeline.cs +++ b/src/ModularPipelines/Pipeline.cs @@ -1,5 +1,3 @@ -using System.Runtime.CompilerServices; - namespace ModularPipelines; /// @@ -11,15 +9,7 @@ public static class Pipeline /// Creates a new pipeline builder. /// /// Optional command line arguments. - /// The calling source file path, supplied by the compiler. /// A new pipeline builder instance. - /// - /// The pipeline working directory defaults to the project containing the calling source file. - /// MODULAR_PIPELINES_DIRECTORY can override that project directory. If neither can be - /// resolved, the process working directory is used. To preserve the process working directory - /// explicitly, use and leave - /// unset. - /// /// /// /// var builder = Pipeline.CreateBuilder(args); @@ -34,9 +24,24 @@ public static class Pipeline /// var summary = await pipeline.RunAsync(); /// /// - public static PipelineBuilder CreateBuilder( + public static PipelineBuilder CreateBuilder(string[]? args = null) + { + return new PipelineBuilder(new PipelineBuilderOptions { Args = args }); + } + + /// + /// Creates a new pipeline builder whose working directory is inferred from the calling source file. + /// + /// Optional command line arguments. + /// The calling source file path, supplied by the compiler. + /// A new pipeline builder instance. + /// + /// MODULAR_PIPELINES_DIRECTORY can override the inferred project directory. If neither can + /// be resolved, the process working directory is used. + /// + public static PipelineBuilder CreateBuilderFromSource( string[]? args = null, - [CallerFilePath] string sourceFilePath = "") + [System.Runtime.CompilerServices.CallerFilePath] string sourceFilePath = "") { return new PipelineBuilder(new PipelineBuilderOptions { @@ -51,8 +56,8 @@ public static PipelineBuilder CreateBuilder( /// The builder options. /// A new pipeline builder instance. /// - /// Unlike , this overload does not infer a - /// pipeline project directory. When is + /// This overload does not infer a pipeline project directory. When + /// is /// unset, the configured content root is used, falling back to the process working directory. /// /// diff --git a/src/ModularPipelines/PipelineDirectory.cs b/src/ModularPipelines/PipelineDirectory.cs index 334fe2e1ee5..48a788d2e89 100644 --- a/src/ModularPipelines/PipelineDirectory.cs +++ b/src/ModularPipelines/PipelineDirectory.cs @@ -43,12 +43,11 @@ public static string FindGitRoot([CallerFilePath] string sourceFilePath = "") => } var sourceDirectory = Path.GetDirectoryName(sourceFilePath); - if (IsPipelineProject(sourceDirectory)) - { - return Path.GetFullPath(sourceDirectory!); - } + var sourceProject = Directory.Exists(sourceDirectory) + ? FindAncestor(sourceDirectory!, IsPipelineProject) + : null; - return FindAncestor(AppContext.BaseDirectory, IsPipelineProject); + return sourceProject ?? FindAncestor(AppContext.BaseDirectory, IsPipelineProject); } private static string GetSearchDirectory(string sourceFilePath) diff --git a/test/ModularPipelines.UnitTests/Helpers/ZipTests.cs b/test/ModularPipelines.UnitTests/Helpers/ZipTests.cs index 9e42ecec4d4..e75ad1afcc7 100644 --- a/test/ModularPipelines.UnitTests/Helpers/ZipTests.cs +++ b/test/ModularPipelines.UnitTests/Helpers/ZipTests.cs @@ -176,7 +176,7 @@ public async Task PhysicalProviderPreservesFileLastWriteTimes() { await System.IO.File.WriteAllTextAsync(sourceFile, "contents"); System.IO.File.SetLastWriteTime(sourceFile, expectedTimestamp); - var zip = new Zip(SystemFileSystemProvider.Instance); + var zip = new Zip(SystemFileSystemProvider.Instance, new PipelineWorkingDirectory(root)); zip.ZipFolder(new Folder(sourceDirectory), zipPath, CompressionLevel.Optimal); zip.UnZipToFolder(zipPath, destinationDirectory, overwriteFiles: true); @@ -205,7 +205,7 @@ public async Task ZipFolderRejectsExistingOutputWithoutChangingIt() { await System.IO.File.WriteAllTextAsync(sourceFile, "contents"); await System.IO.File.WriteAllTextAsync(zipPath, "existing"); - var zip = new Zip(SystemFileSystemProvider.Instance); + var zip = new Zip(SystemFileSystemProvider.Instance, new PipelineWorkingDirectory(root)); await Assert.That(() => zip.ZipFolder( @@ -269,7 +269,7 @@ public async Task UnZipDoesNotOverwriteFileCreatedAfterExistenceCheck() It.IsAny())) .Returns((string path, FileMode mode, FileAccess access) => System.IO.File.Open(path, mode, access, FileShare.None)); - var zip = new Zip(fileSystemProvider.Object); + var zip = new Zip(fileSystemProvider.Object, new PipelineWorkingDirectory(root)); await Assert.That(() => zip.UnZipToFolder(zipPath, destinationDirectory, overwriteFiles: false)) diff --git a/test/ModularPipelines.UnitTests/Registration/PipelineWorkingDirectoryTests.cs b/test/ModularPipelines.UnitTests/Registration/PipelineWorkingDirectoryTests.cs index efdfccb2cbc..6e72ba6457a 100644 --- a/test/ModularPipelines.UnitTests/Registration/PipelineWorkingDirectoryTests.cs +++ b/test/ModularPipelines.UnitTests/Registration/PipelineWorkingDirectoryTests.cs @@ -11,6 +11,9 @@ public class PipelineWorkingDirectoryTests private sealed record WorkingDirectoryObservation( string EnvironmentDirectory, string FilePath, + string Checksum, + string ZipPath, + string UnzipPath, string CommandDirectory); private sealed class ObserveWorkingDirectoryModule : Module @@ -20,6 +23,9 @@ protected internal override async Task ExecuteAsync CancellationToken cancellationToken) { await context.Files.WriteAsync("relative.txt", "content", cancellationToken); + var checksum = context.Files.Checksum.Md5("relative.txt"); + var zip = context.Files.Zip.ZipFolder(context.Files.GetFolder("."), "out.zip"); + var unzipped = context.Files.Zip.UnZipToFolder("out.zip", "unzipped"); var command = await context.Shell.PowerShell.ScriptAsync( new("Write-Output $PWD.Path"), cancellationToken: cancellationToken); @@ -27,6 +33,9 @@ protected internal override async Task ExecuteAsync return new WorkingDirectoryObservation( context.Environment.WorkingDirectory, context.Files.GetFile("relative.txt").Path, + checksum, + zip.Path, + unzipped.Path, command.WorkingDirectory); } } @@ -56,6 +65,11 @@ public async Task ConfiguredWorkingDirectoryScopesCommandsAndFiles() await Assert.That(observation.EnvironmentDirectory).IsEqualTo(workingDirectory.FullName); await Assert.That(observation.FilePath) .IsEqualTo(Path.Combine(workingDirectory.FullName, "relative.txt")); + await Assert.That(observation.Checksum).IsEqualTo("9A0364B9E99BB480DD25E1F0284C8555"); + await Assert.That(observation.ZipPath) + .IsEqualTo(Path.Combine(workingDirectory.FullName, "out.zip")); + await Assert.That(observation.UnzipPath) + .IsEqualTo(Path.Combine(workingDirectory.FullName, "unzipped")); await Assert.That(observation.CommandDirectory).IsEqualTo(workingDirectory.FullName); await Assert.That(Environment.CurrentDirectory).IsEqualTo(processDirectory); } @@ -77,8 +91,9 @@ await File.WriteAllTextAsync( try { - using var builder = Pipeline.CreateBuilder( - sourceFilePath: Path.Combine(projectDirectory.FullName, "Program.cs")); + var nestedDirectory = Directory.CreateDirectory(Path.Combine(projectDirectory.FullName, "src", "Pipeline")); + using var builder = Pipeline.CreateBuilderFromSource( + sourceFilePath: Path.Combine(nestedDirectory.FullName, "Program.cs")); builder.Configuration.AddJsonFile("appsettings.json"); using (Assert.Multiple()) @@ -93,6 +108,14 @@ await File.WriteAllTextAsync( } } + [Test] + public async Task CreateBuilderRetainsSingleArgumentBinarySignature() + { + var method = typeof(Pipeline).GetMethod(nameof(Pipeline.CreateBuilder), [typeof(string[])]); + + await Assert.That(method).IsNotNull(); + } + [Test] public async Task FindGitRootWalksParentDirectories() {