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..224c92bc9bd 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); +var builder = Pipeline.CreateBuilderFromSource(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/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/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/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 d40dad5597e..3b0f45c877b 100644 --- a/src/ModularPipelines/Pipeline.cs +++ b/src/ModularPipelines/Pipeline.cs @@ -26,7 +26,28 @@ public static class Pipeline /// public static PipelineBuilder CreateBuilder(string[]? args = null) { - return new PipelineBuilder(args); + 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, + [System.Runtime.CompilerServices.CallerFilePath] string sourceFilePath = "") + { + return new PipelineBuilder(new PipelineBuilderOptions + { + Args = args, + WorkingDirectory = PipelineDirectory.TryFindPipelineProject(sourceFilePath), + }); } /// @@ -34,6 +55,11 @@ public static PipelineBuilder CreateBuilder(string[]? args = null) /// /// The builder options. /// A new pipeline builder instance. + /// + /// 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 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..48a788d2e89 --- /dev/null +++ b/src/ModularPipelines/PipelineDirectory.cs @@ -0,0 +1,85 @@ +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); + var sourceProject = Directory.Exists(sourceDirectory) + ? FindAncestor(sourceDirectory!, IsPipelineProject) + : null; + + return sourceProject ?? 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/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/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..6e72ba6457a --- /dev/null +++ b/test/ModularPipelines.UnitTests/Registration/PipelineWorkingDirectoryTests.cs @@ -0,0 +1,137 @@ +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 Checksum, + string ZipPath, + string UnzipPath, + 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 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); + + return new WorkingDirectoryObservation( + context.Environment.WorkingDirectory, + context.Files.GetFile("relative.txt").Path, + checksum, + zip.Path, + unzipped.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.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); + } + } + 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 + { + 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()) + { + 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 CreateBuilderRetainsSingleArgumentBinarySignature() + { + var method = typeof(Pipeline).GetMethod(nameof(Pipeline.CreateBuilder), [typeof(string[])]); + + await Assert.That(method).IsNotNull(); + } + + [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); + } + } +}