diff --git a/README.md b/README.md index 6388281..1661589 100644 --- a/README.md +++ b/README.md @@ -220,6 +220,8 @@ Without a `WorkingDirectory` the process inherits the current directory of the c The type is `AbsoluteDirectoryPath` rather than a string on purpose. A relative directory would have to be resolved against the caller's current directory — the process-global state this option exists to avoid depending on, since it is shared by every thread and races with concurrent calls. +A relative executable path that names a directory, such as `./build.sh` or `tools/build.sh`, is resolved against `WorkingDirectory`, the way `cd repo && ./build.sh` works in a shell. Without that, the process would start whichever `build.sh` sits in the caller's current directory. A bare name such as `git` or `dotnet` has no directory part, so it is still found through the usual search, and an absolute path is used as given. + ### Environment Variables `EnvironmentVariables` is an overlay on the inherited environment, not a replacement: a name you do not list keeps whatever the calling process had. A `null` value removes a variable, which is how you unset something the parent had set: diff --git a/RunCommand.Test/RunCommandTests.cs b/RunCommand.Test/RunCommandTests.cs index 64562b3..6c56fba 100644 --- a/RunCommand.Test/RunCommandTests.cs +++ b/RunCommand.Test/RunCommandTests.cs @@ -740,6 +740,56 @@ public async Task ExecuteAsyncShouldStartTheProcessInTheGivenWorkingDirectory() "Expected the process to start in the directory it was given."); } + [TestMethod] + [DataRow("")] + [DataRow("sub")] + public async Task ExecuteAsyncShouldResolveARelativeExecutablePathAgainstTheWorkingDirectory(string subdirectory) + { + // A same-named script sits in the caller's current directory too, so running the wrong copy + // shows up in the output rather than as a start failure + bool isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + string scriptName = $"{nameof(RunCommandTests)}-{Guid.NewGuid():N}{(isWindows ? ".cmd" : ".sh")}"; + string relativePath = string.IsNullOrEmpty(subdirectory) ? $".{Path.DirectorySeparatorChar}{scriptName}" : $"{subdirectory}/{scriptName}"; + + string workingDirectory = CreateDirectoryForTest(nameof(ExecuteAsyncShouldResolveARelativeExecutablePathAgainstTheWorkingDirectory) + subdirectory); + string intendedScript = Path.Join(workingDirectory, subdirectory, scriptName); + string callerScript = Path.Join(Environment.CurrentDirectory, subdirectory, scriptName); + _ = Directory.CreateDirectory(Path.GetDirectoryName(intendedScript)!); + _ = Directory.CreateDirectory(Path.GetDirectoryName(callerScript)!); + WriteScript(intendedScript, "working-directory"); + WriteScript(callerScript, "caller"); + + try + { + List output = []; + int exitCode = await RunCommand.ExecuteAsync( + relativePath, + [], + new LineOutputHandler(onStandardOutput: output.Add), + new CommandOptions { WorkingDirectory = AbsoluteDirectoryPath.Create(workingDirectory) }).ConfigureAwait(false); + + Assert.AreEqual(0, exitCode, "Expected the script to run successfully."); + Assert.AreEqual("working-directory", string.Concat(output).Trim(), "Expected the script in the working directory to run, not the caller's copy."); + } + finally + { + File.Delete(callerScript); + File.Delete(intendedScript); + } + } + + private static void WriteScript(string path, string message) + { + if (OperatingSystem.IsWindows()) + { + File.WriteAllText(path, $"@echo {message}\r\n"); + return; + } + + File.WriteAllText(path, $"#!/bin/sh\necho {message}\n"); + File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + } + [TestMethod] public async Task ExecuteAsyncShouldInheritTheCurrentDirectoryWhenNoWorkingDirectoryIsGiven() { diff --git a/RunCommand/RunCommand.cs b/RunCommand/RunCommand.cs index 9d41adb..efa79ad 100644 --- a/RunCommand/RunCommand.cs +++ b/RunCommand/RunCommand.cs @@ -316,6 +316,33 @@ public static async Task ExecuteAsync(string fileName, IEnumerable return await RunAsync(startInfo, outputHandler, useElevation, cancellationToken).ConfigureAwait(false); } + /// + /// Resolves a relative executable path that names a directory, such as ./build.sh or + /// tools/build.sh, against the working directory the command runs in. + /// + /// The executable as the caller gave it. + /// The command's working directory, or if none was set. + /// Whether a backslash also separates directories. + /// The path to start. + /// + /// Process.Start resolves a relative path against the calling process's current directory, not + /// against , so without this ./build.sh ran + /// the caller's copy, or failed to start, whatever + /// said. Resolving it here does what cd dir && ./build.sh does in a shell. A bare name + /// such as git has no directory part and keeps the usual search, and an absolute path is + /// already unambiguous. + /// + private static string ResolveFileName(string fileName, string? workingDirectory, bool isWindows) + { + if (workingDirectory is null || Path.IsPathRooted(fileName)) + { + return fileName; + } + + bool hasDirectoryPart = fileName.Contains('/') || (isWindows && fileName.Contains('\\')); + return hasDirectoryPart ? Path.GetFullPath(Path.Combine(workingDirectory, fileName)) : fileName; + } + private static ProcessStartInfo CreateStartInfo(string fileName, OutputHandler outputHandler, CommandOptions options, out bool useElevation) { bool isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); @@ -345,7 +372,7 @@ private static ProcessStartInfo CreateStartInfo(string fileName, OutputHandler o ProcessStartInfo startInfo = new() { - FileName = fileName, + FileName = ResolveFileName(fileName, options.WorkingDirectory?.WeakString, isWindows), CreateNoWindow = true, };