diff --git a/.husky/commit-msg b/.husky/commit-msg old mode 100644 new mode 100755 diff --git a/.husky/task-runner.json b/.husky/task-runner.json index 7dcdcb6..baf45c9 100644 --- a/.husky/task-runner.json +++ b/.husky/task-runner.json @@ -3,8 +3,8 @@ "variables": [ { "name": "root-dir", - "command": "cmd", - "args": ["/c", "dir", "/b"] + "command": "bash", + "args": ["-c", "pwd"] } ], "tasks": [ @@ -19,11 +19,11 @@ "args" :["husky", "exec", ".husky/csx/version-updater.csx", "--args", "${args}"] }, { - "name": "echo staged files", + "name": "echo-staged-files", "pathMode": "absolute", - "command": "cmd", + "command": "echo", "group": "pre-commit", - "args": [ "/c", "echo", "${staged}"] + "args": ["${staged}"] } ] } diff --git a/src/Husky/Services/Contracts/ICliWrap.cs b/src/Husky/Services/Contracts/ICliWrap.cs index a1fc25a..272102a 100644 --- a/src/Husky/Services/Contracts/ICliWrap.cs +++ b/src/Husky/Services/Contracts/ICliWrap.cs @@ -7,8 +7,10 @@ namespace Husky.Services.Contracts; public interface ICliWrap { Task ExecBufferedAsync(string fileName, string args); + Task ExecBufferedAsync(string fileName, IEnumerable args); ValueTask SetExecutablePermission(params string[] files); Task ExecDirectAsync(string fileName, string args); + Task ExecDirectAsync(string fileName, IEnumerable args); Task RunCommandAsync(string fileName, IEnumerable args, string cwd, OutputTypes output = OutputTypes.Verbose); diff --git a/src/Husky/Services/Contracts/IGit.cs b/src/Husky/Services/Contracts/IGit.cs index e54be73..35f360b 100644 --- a/src/Husky/Services/Contracts/IGit.cs +++ b/src/Husky/Services/Contracts/IGit.cs @@ -35,5 +35,7 @@ public interface IGit /// Task GetDiffStagedRecord(); Task ExecAsync(string args); + Task ExecAsync(IEnumerable args); Task ExecBufferedAsync(string args); + Task ExecBufferedAsync(IEnumerable args); } diff --git a/src/Husky/Services/Git.cs b/src/Husky/Services/Git.cs index ed007db..a907f13 100644 --- a/src/Husky/Services/Git.cs +++ b/src/Husky/Services/Git.cs @@ -185,11 +185,21 @@ public Task ExecAsync(string args) return _cliWrap.ExecDirectAsync("git", args); } + public Task ExecAsync(IEnumerable args) + { + return _cliWrap.ExecDirectAsync("git", args); + } + public Task ExecBufferedAsync(string args) { return _cliWrap.ExecBufferedAsync("git", args); } + public Task ExecBufferedAsync(IEnumerable args) + { + return _cliWrap.ExecBufferedAsync("git", args); + } + private async Task GetHuskyPath() { try diff --git a/src/Husky/Services/HuskyCliWrap.cs b/src/Husky/Services/HuskyCliWrap.cs index 7098bb0..b879548 100644 --- a/src/Husky/Services/HuskyCliWrap.cs +++ b/src/Husky/Services/HuskyCliWrap.cs @@ -26,6 +26,23 @@ public async Task ExecBufferedAsync(string fileName, stri } } + public async Task ExecBufferedAsync(string fileName, IEnumerable args) + { + try + { + var result = await CliWrap.Cli + .Wrap(fileName) + .WithArguments(args) + .ExecuteBufferedAsync(); + return result; + } + catch (Exception) + { + $"failed to execute command '{fileName}'".LogErr(); + throw; + } + } + public async ValueTask SetExecutablePermission(params string[] files) { if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) @@ -53,6 +70,18 @@ public async Task ExecDirectAsync(string fileName, string args) return result; } + public async Task ExecDirectAsync(string fileName, IEnumerable args) + { + var result = await CliWrap.Cli + .Wrap(fileName) + .WithArguments(args) + .WithValidation(CommandResultValidation.None) + .WithStandardOutputPipe(PipeTarget.ToDelegate(q => q.Log())) + .WithStandardErrorPipe(PipeTarget.ToDelegate(q => q.LogErr())) + .ExecuteAsync(); + return result; + } + public async Task RunCommandAsync( string fileName, IEnumerable args, diff --git a/src/Husky/TaskRunner/StagedTask.cs b/src/Husky/TaskRunner/StagedTask.cs index e8c0222..e4a5064 100644 --- a/src/Husky/TaskRunner/StagedTask.cs +++ b/src/Husky/TaskRunner/StagedTask.cs @@ -1,5 +1,4 @@ using System.IO.Abstractions; -using System.Runtime.InteropServices; using System.Text.RegularExpressions; using CliFx.Exceptions; using Husky.Services.Contracts; @@ -107,7 +106,7 @@ private async Task PartialExecution(List partialStaged foreach (var tf in tmpFiles) { // add formatted temp file to git database - var result = await _git.ExecBufferedAsync($"hash-object -w {tf.tmp_path}"); + var result = await _git.ExecBufferedAsync(new[] { "hash-object", "-w", tf.tmp_path }); var newHash = result.StandardOutput.Trim(); // check if the partial hash exists @@ -122,7 +121,7 @@ private async Task PartialExecution(List partialStaged { $"Updating index entry for {tf.src_path}".LogVerbose(); await _git.ExecAsync( - $"update-index --cacheinfo {tf.dst_mode},{newHash},{tf.src_path}" + new[] { "update-index", "--cacheinfo", $"{tf.dst_mode},{newHash},{tf.src_path}" } ); } else @@ -143,14 +142,17 @@ private async Task ReStageFiles(IEnumerable partialStagedFiles .OfType() .Where(q => q.ArgumentTypes == ArgumentTypes.StagedFile) .Except(partialStagedFiles) - .Select(q => !RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? $"\"{q.RelativePath}\"" : $"\"{q.RelativePath.Replace("/", @"\")}\"") + .Select(q => q.RelativePath) .ToList(); if (stagedFiles.Any()) { "Re-staging staged files...".LogVerbose(); string.Join(Environment.NewLine, stagedFiles).LogVerbose(); - await _git.ExecAsync($"add {string.Join(" ", stagedFiles)}"); + + // Build git add command with file paths as separate arguments + List gitAddArgs = ["add", ..stagedFiles]; + await _git.ExecAsync(gitAddArgs); } } diff --git a/tests/HuskyIntegrationTests/PathWithSpacesTests.cs b/tests/HuskyIntegrationTests/PathWithSpacesTests.cs new file mode 100644 index 0000000..af89049 --- /dev/null +++ b/tests/HuskyIntegrationTests/PathWithSpacesTests.cs @@ -0,0 +1,277 @@ +using System.Runtime.CompilerServices; +using DotNet.Testcontainers.Containers; +using FluentAssertions; + +namespace HuskyIntegrationTests; + +/// +/// Integration tests for handling file paths with spaces in git commands. +/// Tests the fix for issue where paths like "src/Private Assemblies/Test.cs" +/// were incorrectly split into separate arguments. +/// +public class PathWithSpacesTests(ITestOutputHelper output) +{ + [Fact] + public async Task StagedFiles_WithSpacesInPath_ShouldExecuteSuccessfully() + { + // arrange + const string taskRunner = + """ + { + "tasks": [ + { + "name": "echo-staged", + "command": "echo", + "group": "pre-commit", + "args": [ + "${staged}" + ] + } + ] + } + """; + await using var c = await ArrangeContainer(taskRunner); + + // Create files with spaces in directory names + await c.BashAsync("mkdir -p 'src/Private Assemblies'"); + await c.BashAsync("echo 'test content' > 'src/Private Assemblies/Test.cs'"); + await c.BashAsync("git add ."); + + // act + var result = await c.BashAsync(output, "git commit -m 'test files with spaces'"); + + // assert + result.ExitCode.Should().Be(0); + result.Stderr.Should().NotContain("fatal: pathspec"); + result.Stderr.Should().Contain("src/Private Assemblies/Test.cs"); + } + + [Fact] + public async Task StagedFiles_WithMultipleSpacesInPath_ShouldExecuteSuccessfully() + { + // arrange + const string taskRunner = + """ + { + "tasks": [ + { + "name": "wc-staged", + "command": "wc", + "group": "pre-commit", + "args": [ + "-l", + "${staged}" + ] + } + ] + } + """; + await using var c = await ArrangeContainer(taskRunner); + + // Create files with multiple spaces in directory names + await c.BashAsync("mkdir -p 'My Multiple Spaces/Dir'"); + await c.BashAsync("echo 'line1' > 'My Multiple Spaces/Dir/Test.cs'"); + await c.BashAsync("echo 'line2' >> 'My Multiple Spaces/Dir/Test.cs'"); + await c.BashAsync("git add ."); + + // act + var result = await c.BashAsync(output, "git commit -m 'test multiple spaces'"); + + // assert + result.ExitCode.Should().Be(0); + result.Stderr.Should().NotContain("fatal: pathspec"); + result.Stderr.Should().NotContain("No such file or directory"); + } + + [Fact] + public async Task StagedFiles_WithParenthesesAndSpaces_ShouldExecuteSuccessfully() + { + // arrange + const string taskRunner = + """ + { + "tasks": [ + { + "name": "echo-staged", + "command": "echo", + "group": "pre-commit", + "pathMode": "absolute", + "args": [ + "${staged}" + ] + } + ] + } + """; + await using var c = await ArrangeContainer(taskRunner); + + // Create files with special characters and spaces + await c.BashAsync("mkdir -p 'My (Parentheses) Dir'"); + await c.BashAsync("echo 'test' > 'My (Parentheses) Dir/Test2.cs'"); + await c.BashAsync("git add ."); + + // act + var result = await c.BashAsync(output, "git commit -m 'test special chars'"); + + // assert + result.ExitCode.Should().Be(0); + result.Stderr.Should().NotContain("fatal: pathspec"); + result.Stderr.Should().Contain("My (Parentheses) Dir/Test2.cs"); + } + + [Fact] + public async Task StagedFiles_WithMixedSpecialCharsAndSpaces_ShouldExecuteSuccessfully() + { + // arrange + const string taskRunner = + """ + { + "tasks": [ + { + "name": "echo-staged", + "command": "echo", + "group": "pre-commit", + "args": [ + "${staged}" + ] + } + ] + } + """; + await using var c = await ArrangeContainer(taskRunner); + + // Create files with mixed characters + await c.BashAsync("mkdir -p 'My-Mixed Dashes_And_Underscores With Spaces'"); + await c.BashAsync("echo 'test' > 'My-Mixed Dashes_And_Underscores With Spaces/Test3.cs'"); + await c.BashAsync("git add ."); + + // act + var result = await c.BashAsync(output, "git commit -m 'test mixed chars'"); + + // assert + result.ExitCode.Should().Be(0); + result.Stderr.Should().NotContain("fatal: pathspec"); + } + + [Fact] + public async Task StagedFiles_WithComplexPathStructure_ShouldExecuteSuccessfully() + { + // arrange + const string taskRunner = + """ + { + "tasks": [ + { + "name": "echo-staged", + "command": "echo", + "group": "pre-commit", + "pathMode": "absolute", + "args": [ + "${staged}" + ] + } + ] + } + """; + await using var c = await ArrangeContainer(taskRunner); + + // Create the complex path from the original issue + await c.BashAsync("mkdir -p 'src/Private Assemblies/My Controllers'"); + await c.BashAsync("echo 'controller content' > 'src/Private Assemblies/My Controllers/TestController.cs'"); + await c.BashAsync("git add ."); + + // act + var result = await c.BashAsync(output, "git commit -m 'test complex path'"); + + // assert + result.ExitCode.Should().Be(0); + result.Stderr.Should().NotContain("fatal: pathspec 'src/Private'"); + result.Stderr.Should().Contain("src/Private Assemblies/My Controllers/TestController.cs"); + } + + [Fact] + public async Task StagedFiles_WithMultipleFilesWithSpaces_ShouldExecuteSuccessfully() + { + // arrange + const string taskRunner = + """ + { + "tasks": [ + { + "name": "echo-staged", + "command": "echo", + "group": "pre-commit", + "args": [ + "${staged}" + ] + } + ] + } + """; + await using var c = await ArrangeContainer(taskRunner); + + // Create multiple files with spaces in different directories + await c.BashAsync("mkdir -p 'Dir One'"); + await c.BashAsync("mkdir -p 'Dir Two'"); + await c.BashAsync("mkdir -p 'Dir Three/Sub Dir'"); + await c.BashAsync("echo 'file1' > 'Dir One/File1.cs'"); + await c.BashAsync("echo 'file2' > 'Dir Two/File2.cs'"); + await c.BashAsync("echo 'file3' > 'Dir Three/Sub Dir/File3.cs'"); + await c.BashAsync("git add ."); + + // act + var result = await c.BashAsync(output, "git commit -m 'test multiple files'"); + + // assert + result.ExitCode.Should().Be(0); + result.Stderr.Should().NotContain("fatal: pathspec"); + result.Stderr.Should().Contain("Dir One/File1.cs"); + result.Stderr.Should().Contain("Dir Two/File2.cs"); + result.Stderr.Should().Contain("Dir Three/Sub Dir/File3.cs"); + } + + [Fact] + public async Task StagedFiles_RelativePathMode_WithSpaces_ShouldExecuteSuccessfully() + { + // arrange + const string taskRunner = + """ + { + "tasks": [ + { + "name": "echo-staged", + "command": "echo", + "group": "pre-commit", + "pathMode": "relative", + "args": [ + "${staged}" + ] + } + ] + } + """; + await using var c = await ArrangeContainer(taskRunner); + + // Create files with spaces + await c.BashAsync("mkdir -p 'src/My Project'"); + await c.BashAsync("echo 'test' > 'src/My Project/Test.cs'"); + await c.BashAsync("git add ."); + + // act + var result = await c.BashAsync(output, "git commit -m 'test relative path'"); + + // assert + result.ExitCode.Should().Be(0); + result.Stderr.Should().NotContain("fatal: pathspec"); + } + + private async Task ArrangeContainer(string taskRunner, [CallerMemberName] string name = null!) + { + var c = await DockerHelper.StartWithInstalledHusky(name); + await c.BashAsync("dotnet tool restore"); + await c.BashAsync("git add ."); + await c.UpdateTaskRunner(taskRunner); + await c.BashAsync("dotnet husky add pre-commit -c 'dotnet husky run'"); + return c; + } +} diff --git a/tests/HuskyTest/Services/GitTests.cs b/tests/HuskyTest/Services/GitTests.cs index da56bf7..b7fe1a3 100644 --- a/tests/HuskyTest/Services/GitTests.cs +++ b/tests/HuskyTest/Services/GitTests.cs @@ -65,5 +65,66 @@ public async Task GetStagedFiles_Return_StagedFiles() "src\\mythirdfile.cs" }); } + + [Fact] + public async Task GetStagedFiles_WithSpacesInPath_Return_StagedFiles() + { + // Arrange + var git = new Git(_cliWrap); + var now = DateTime.UtcNow; + var gitOutput = $"src/Private Assemblies/MyController.cs{Environment.NewLine}My Project/Test.cs{Environment.NewLine}file with spaces.cs"; + _cliWrap.ExecBufferedAsync("git", "diff --staged --name-only --no-ext-diff --diff-filter=AM").Returns(Task.FromResult(new CliWrap.Buffered.BufferedCommandResult(0, now, now, gitOutput, string.Empty))); + + // Act + var stagedFiles = await git.GetStagedFilesAsync(); + + // Assert + stagedFiles.Should() + .NotBeEmpty() + .And + .HaveCount(3) + .And + .Contain(new List + { + "src/Private Assemblies/MyController.cs", + "My Project/Test.cs", + "file with spaces.cs" + }); + } + + [Fact] + public async Task ExecAsync_WithArrayArgs_CallsCliWrapWithArrayArgs() + { + // Arrange + var git = new Git(_cliWrap); + var now = DateTime.UtcNow; + var args = new[] { "add", "src/Private Assemblies/Test.cs", "My Project/File.cs" }; + _cliWrap.ExecDirectAsync("git", Arg.Is>(a => a.SequenceEqual(args))) + .Returns(Task.FromResult(new CliWrap.CommandResult(0, now, now))); + + // Act + await git.ExecAsync(args); + + // Assert + await _cliWrap.Received(1).ExecDirectAsync("git", Arg.Is>(a => a.SequenceEqual(args))); + } + + [Fact] + public async Task ExecBufferedAsync_WithArrayArgs_CallsCliWrapWithArrayArgs() + { + // Arrange + var git = new Git(_cliWrap); + var now = DateTime.UtcNow; + var args = new[] { "hash-object", "-w", "src/Private Assemblies/temp.cs" }; + _cliWrap.ExecBufferedAsync("git", Arg.Is>(a => a.SequenceEqual(args))) + .Returns(Task.FromResult(new CliWrap.Buffered.BufferedCommandResult(0, now, now, "abc123", string.Empty))); + + // Act + var result = await git.ExecBufferedAsync(args); + + // Assert + await _cliWrap.Received(1).ExecBufferedAsync("git", Arg.Is>(a => a.SequenceEqual(args))); + result.StandardOutput.Should().Be("abc123"); + } } }