diff --git a/src/Husky/TaskRunner/ArgumentParser.cs b/src/Husky/TaskRunner/ArgumentParser.cs index 13ddf19..16b742b 100644 --- a/src/Husky/TaskRunner/ArgumentParser.cs +++ b/src/Husky/TaskRunner/ArgumentParser.cs @@ -37,7 +37,7 @@ public async Task ParseAsync(HuskyTask task, string[]? optionArg return Array.Empty(); // this is not lazy, because each task can have different patterns - var matcher = GetPatternMatcher(task); + var matcher = GetPatternMatcher(task, optionArguments); // set default pathMode value var pathMode = task.PathMode ?? PathModes.Relative; @@ -303,19 +303,19 @@ private static void AddCustomArguments(List args, string[]? option "⚠️ No arguments passed to the run command".Husky(ConsoleColor.Yellow); } - public static Matcher GetPatternMatcher(HuskyTask task) + public static Matcher GetPatternMatcher(HuskyTask task, string[]? optionArguments = null) { var matcher = new Matcher(); var hasMatcher = false; if (task.Include is { Length: > 0 }) { - matcher.AddIncludePatterns(task.Include); + matcher.AddIncludePatterns(ResolvePatternVariables(task.Include, optionArguments)); hasMatcher = true; } if (task.Exclude is { Length: > 0 }) { - matcher.AddExcludePatterns(task.Exclude); + matcher.AddExcludePatterns(ResolvePatternVariables(task.Exclude, optionArguments)); hasMatcher = true; } @@ -324,4 +324,20 @@ public static Matcher GetPatternMatcher(HuskyTask task) return matcher; } + + private static IEnumerable ResolvePatternVariables(string[] patterns, string[]? optionArguments) + { + foreach (var pattern in patterns) + { + if (pattern.Contains("${args}") && optionArguments is { Length: > 0 }) + { + foreach (var arg in optionArguments) + yield return pattern.Replace("${args}", arg); + } + else + { + yield return pattern; + } + } + } } diff --git a/src/Husky/TaskRunner/ExecutableTaskFactory.cs b/src/Husky/TaskRunner/ExecutableTaskFactory.cs index 132d14d..f2423d7 100644 --- a/src/Husky/TaskRunner/ExecutableTaskFactory.cs +++ b/src/Husky/TaskRunner/ExecutableTaskFactory.cs @@ -39,7 +39,7 @@ public ExecutableTaskFactory(IServiceProvider provider, IGit git, IArgumentParse var cwd = await _git.GetTaskCwdAsync(huskyTask); var argsInfo = await _argumentParser.ParseAsync(huskyTask, options.Arguments?.ToArray()); - if (await CheckIfWeShouldSkipTheTask(huskyTask, argsInfo)) + if (await CheckIfWeShouldSkipTheTask(huskyTask, argsInfo, options.Arguments?.ToArray())) return null; // skip the task // check for chunk @@ -63,7 +63,7 @@ public ExecutableTaskFactory(IServiceProvider provider, IGit git, IArgumentParse ); } - private async Task CheckIfWeShouldSkipTheTask(HuskyTask huskyTask, ArgumentInfo[] argsInfo) + private async Task CheckIfWeShouldSkipTheTask(HuskyTask huskyTask, ArgumentInfo[] argsInfo, string[]? optionArguments = null) { if (huskyTask is { FilteringRule: FilteringRules.Variable, Args: not null } && huskyTask.Args.Length > argsInfo.Length) { @@ -82,7 +82,7 @@ private async Task CheckIfWeShouldSkipTheTask(HuskyTask huskyTask, Argumen return true; } - var matcher = ArgumentParser.GetPatternMatcher(huskyTask); + var matcher = ArgumentParser.GetPatternMatcher(huskyTask, optionArguments); // get match staged files with glob var matches = matcher.Match(stagedFiles); diff --git a/tests/HuskyIntegrationTests/Issue113Tests.cs b/tests/HuskyIntegrationTests/Issue113Tests.cs new file mode 100644 index 0000000..c34de89 --- /dev/null +++ b/tests/HuskyIntegrationTests/Issue113Tests.cs @@ -0,0 +1,285 @@ +using System.Runtime.CompilerServices; +using DotNet.Testcontainers.Containers; +using FluentAssertions; + +namespace HuskyIntegrationTests; + +public class Issue113Tests(ITestOutputHelper output) +{ + [Fact] + public async Task ArgsVariable_InIncludePattern_ShouldMatchFilesUnderArgsDirectory() + { + // arrange + const string taskRunner = + """ + { + "tasks": [ + { + "name": "Echo", + "command": "echo", + "args": [ + "${staged}" + ], + "include": [ + "${args}/**/*.cs" + ] + } + ] + } + """; + await using var c = await ArrangeContainer(taskRunner); + + // act: run with --args src (the include pattern becomes src/**/*.cs which matches) + var result = await c.BashAsync(output, "dotnet husky run --args src"); + + // assert + result.ExitCode.Should().Be(0); + result.Stdout.Should().Contain(DockerHelper.SuccessfullyExecuted); + result.Stdout.Should().NotContain(DockerHelper.Skipped); + } + + [Fact] + public async Task ArgsVariable_InIncludePattern_ShouldSkip_WhenNoMatchedFiles() + { + // arrange + const string taskRunner = + """ + { + "tasks": [ + { + "name": "Echo", + "command": "echo", + "args": [ + "${staged}" + ], + "include": [ + "${args}/**/*.cs" + ] + } + ] + } + """; + await using var c = await ArrangeContainer(taskRunner); + + // act: run with --args tests (the include pattern becomes tests/**/*.cs which does NOT match) + var result = await c.BashAsync(output, "dotnet husky run --args tests"); + + // assert + result.ExitCode.Should().Be(0); + result.Stdout.Should().Contain(DockerHelper.Skipped); + } + + [Fact] + public async Task ArgsVariable_InExcludePattern_ShouldSkip_WhenExcludedByArgs() + { + // arrange + const string taskRunner = + """ + { + "tasks": [ + { + "name": "Echo", + "command": "echo", + "args": [ + "${staged}" + ], + "include": [ + "**/*.cs" + ], + "exclude": [ + "${args}/**/*.cs" + ] + } + ] + } + """; + await using var c = await ArrangeContainer(taskRunner); + + // act: run with --args src (the exclude pattern becomes src/**/*.cs which excludes src/Foo.cs) + var result = await c.BashAsync(output, "dotnet husky run --args src"); + + // assert + result.ExitCode.Should().Be(0); + result.Stdout.Should().Contain(DockerHelper.Skipped); + } + + [Fact] + public async Task ArgsVariable_InExcludePattern_ShouldNotSkip_WhenNotExcludedByArgs() + { + // arrange + const string taskRunner = + """ + { + "tasks": [ + { + "name": "Echo", + "command": "echo", + "args": [ + "${staged}" + ], + "include": [ + "**/*.cs" + ], + "exclude": [ + "${args}/**/*.cs" + ] + } + ] + } + """; + await using var c = await ArrangeContainer(taskRunner); + + // act: run with --args tests (the exclude pattern becomes tests/**/*.cs which does NOT exclude src/Foo.cs) + var result = await c.BashAsync(output, "dotnet husky run --args tests"); + + // assert + result.ExitCode.Should().Be(0); + result.Stdout.Should().Contain(DockerHelper.SuccessfullyExecuted); + result.Stdout.Should().NotContain(DockerHelper.Skipped); + } + + // ── Regression tests: old behavior must still work ────────────────────────── + + [Fact] + public async Task StagedVariable_WithStaticInclude_ShouldRun_WhenPatternMatchesStagedFiles() + { + // arrange: old behavior — ${staged} in args, plain static include glob (no ${args}) + const string taskRunner = + """ + { + "tasks": [ + { + "name": "Echo", + "command": "echo", + "filteringRule": "staged", + "args": [ + "${staged}" + ], + "include": [ + "**/*.cs" + ] + } + ] + } + """; + await using var c = await ArrangeContainer(taskRunner); + + // act: run without --args; staged src/Foo.cs matches **/*.cs + var result = await c.BashAsync(output, "dotnet husky run"); + + // assert + result.ExitCode.Should().Be(0); + result.Stdout.Should().Contain(DockerHelper.SuccessfullyExecuted); + result.Stdout.Should().NotContain(DockerHelper.Skipped); + } + + [Fact] + public async Task StagedVariable_WithStaticInclude_ShouldSkip_WhenPatternDoesNotMatchStagedFiles() + { + // arrange: old behavior — ${staged} in args, plain static include glob (no ${args}) + const string taskRunner = + """ + { + "tasks": [ + { + "name": "Echo", + "command": "echo", + "filteringRule": "staged", + "args": [ + "${staged}" + ], + "include": [ + "**/*.ts" + ] + } + ] + } + """; + await using var c = await ArrangeContainer(taskRunner); + + // act: run without --args; no .ts files are staged so no match + var result = await c.BashAsync(output, "dotnet husky run"); + + // assert + result.ExitCode.Should().Be(0); + result.Stdout.Should().Contain(DockerHelper.Skipped); + } + + [Fact] + public async Task NoVariable_WithStaticArgs_WithMatchingInclude_ShouldRun() + { + // arrange: old behavior — no variables anywhere, plain static args and include + const string taskRunner = + """ + { + "tasks": [ + { + "name": "Echo", + "command": "echo", + "filteringRule": "staged", + "args": [ + "Husky.Net is awesome!" + ], + "include": [ + "**/*.cs" + ] + } + ] + } + """; + await using var c = await ArrangeContainer(taskRunner); + + // act: run without --args; staged src/Foo.cs matches **/*.cs + var result = await c.BashAsync(output, "dotnet husky run"); + + // assert + result.ExitCode.Should().Be(0); + result.Stdout.Should().Contain(DockerHelper.SuccessfullyExecuted); + result.Stdout.Should().NotContain(DockerHelper.Skipped); + } + + [Fact] + public async Task StaticIncludePattern_ShouldNotBeAffectedByArgs_WhenNoArgsVariable() + { + // arrange: new behavior baseline — static include pattern (no ${args}), + // verify pattern is NOT substituted even when --args is supplied + const string taskRunner = + """ + { + "tasks": [ + { + "name": "Echo", + "command": "echo", + "filteringRule": "staged", + "args": [ + "${staged}" + ], + "include": [ + "**/*.cs" + ] + } + ] + } + """; + await using var c = await ArrangeContainer(taskRunner); + + // act: --args is provided but the include pattern has no ${args}, so it + // must remain a plain **/*.cs glob and still match staged src/Foo.cs + var result = await c.BashAsync(output, "dotnet husky run --args tests"); + + // assert + result.ExitCode.Should().Be(0); + result.Stdout.Should().Contain(DockerHelper.SuccessfullyExecuted); + result.Stdout.Should().NotContain(DockerHelper.Skipped); + } + + private async Task ArrangeContainer(string taskRunner, [CallerMemberName] string name = null!) + { + var c = await DockerHelper.StartWithInstalledHusky(name); + await c.UpdateTaskRunner(taskRunner); + await c.BashAsync("mkdir -p /test/src"); + await c.BashAsync("echo 'public class Foo {}' > /test/src/Foo.cs"); + await c.BashAsync("git add ."); + return c; + } +}