From 34fb99c693394eb6113b8f05e300cfcfe7c2d77f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Mar 2026 17:51:50 +0000 Subject: [PATCH 1/4] Initial plan From 41c2ec98d02b4d9a4d94de858e4c964e4131a510 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Mar 2026 17:57:56 +0000 Subject: [PATCH 2/4] feat: support variables in include/exclude glob patterns --- .husky/commit-msg | 0 src/Husky/TaskRunner/ArgumentParser.cs | 24 ++++- src/Husky/TaskRunner/ExecutableTaskFactory.cs | 6 +- tests/HuskyIntegrationTests/Issue113Tests.cs | 89 +++++++++++++++++++ 4 files changed, 112 insertions(+), 7 deletions(-) mode change 100644 => 100755 .husky/commit-msg create mode 100644 tests/HuskyIntegrationTests/Issue113Tests.cs diff --git a/.husky/commit-msg b/.husky/commit-msg old mode 100644 new mode 100755 diff --git a/src/Husky/TaskRunner/ArgumentParser.cs b/src/Husky/TaskRunner/ArgumentParser.cs index 91efa2c..cf9bd15 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; @@ -302,19 +302,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; } @@ -323,4 +323,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..e0e5a25 --- /dev/null +++ b/tests/HuskyIntegrationTests/Issue113Tests.cs @@ -0,0 +1,89 @@ +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); + + // add a C# file inside the "src" subdirectory + await c.BashAsync("mkdir -p /test/src"); + await c.BashAsync("echo 'public class Foo {}' > /test/src/Foo.cs"); + await c.BashAsync("git add ."); + + // 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.Stderr.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); + + // add a C# file inside the "src" subdirectory + await c.BashAsync("mkdir -p /test/src"); + await c.BashAsync("echo 'public class Foo {}' > /test/src/Foo.cs"); + await c.BashAsync("git add ."); + + // 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.Stderr.Should().Contain(DockerHelper.Skipped); + } + + 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); + return c; + } +} From 6efed186a1794f248230b48fc532c7aa31c96986 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Mar 2026 18:21:09 +0000 Subject: [PATCH 3/4] fix: correct integration test assertions and expand test coverage --- tests/HuskyIntegrationTests/Issue113Tests.cs | 89 +++++++++++++++++--- 1 file changed, 75 insertions(+), 14 deletions(-) diff --git a/tests/HuskyIntegrationTests/Issue113Tests.cs b/tests/HuskyIntegrationTests/Issue113Tests.cs index e0e5a25..6ac8f93 100644 --- a/tests/HuskyIntegrationTests/Issue113Tests.cs +++ b/tests/HuskyIntegrationTests/Issue113Tests.cs @@ -29,17 +29,13 @@ public async Task ArgsVariable_InIncludePattern_ShouldMatchFilesUnderArgsDirecto """; await using var c = await ArrangeContainer(taskRunner); - // add a C# file inside the "src" subdirectory - await c.BashAsync("mkdir -p /test/src"); - await c.BashAsync("echo 'public class Foo {}' > /test/src/Foo.cs"); - await c.BashAsync("git add ."); - // 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.Stderr.Should().NotContain(DockerHelper.Skipped); + result.Stdout.Should().Contain(DockerHelper.SuccessfullyExecuted); + result.Stdout.Should().NotContain(DockerHelper.Skipped); } [Fact] @@ -65,25 +61,90 @@ public async Task ArgsVariable_InIncludePattern_ShouldSkip_WhenNoMatchedFiles() """; await using var c = await ArrangeContainer(taskRunner); - // add a C# file inside the "src" subdirectory - await c.BashAsync("mkdir -p /test/src"); - await c.BashAsync("echo 'public class Foo {}' > /test/src/Foo.cs"); - await c.BashAsync("git add ."); - // 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.Stderr.Should().Contain(DockerHelper.Skipped); + 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); } 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("mkdir -p /test/src"); + await c.BashAsync("echo 'public class Foo {}' > /test/src/Foo.cs"); + await c.BashAsync("git add ."); return c; } } From fb03f674f216eab5397993ebfae5ad49732bb76b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Mar 2026 19:04:21 +0000 Subject: [PATCH 4/4] test: add regression tests for old behavior in Issue113Tests --- tests/HuskyIntegrationTests/Issue113Tests.cs | 135 +++++++++++++++++++ 1 file changed, 135 insertions(+) diff --git a/tests/HuskyIntegrationTests/Issue113Tests.cs b/tests/HuskyIntegrationTests/Issue113Tests.cs index 6ac8f93..c34de89 100644 --- a/tests/HuskyIntegrationTests/Issue113Tests.cs +++ b/tests/HuskyIntegrationTests/Issue113Tests.cs @@ -138,6 +138,141 @@ public async Task ArgsVariable_InExcludePattern_ShouldNotSkip_WhenNotExcludedByA 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);