From 5a686e6d2659dde711608353e18d5e578b49e9f8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Mar 2026 18:06:42 +0000 Subject: [PATCH 1/3] Initial plan From f5087d1966308e0d138840bf49a0f5b857919e3e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Mar 2026 18:18:56 +0000 Subject: [PATCH 2/3] feat: add filteringRuleVariable to filter tasks by variable output Co-authored-by: alirezanet <7004080+alirezanet@users.noreply.github.com> --- docs/.vuepress/public/schema.json | 4 + src/Husky/TaskRunner/ArgumentParser.cs | 23 +++ src/Husky/TaskRunner/ExecutableTaskFactory.cs | 43 +++-- src/Husky/TaskRunner/HuskyTask.cs | 1 + .../FilteringRuleVariableTests.cs | 172 ++++++++++++++++++ .../TaskRunner/ArgumentParserTests.cs | 132 ++++++++++++++ 6 files changed, 361 insertions(+), 14 deletions(-) create mode 100644 tests/HuskyIntegrationTests/FilteringRuleVariableTests.cs diff --git a/docs/.vuepress/public/schema.json b/docs/.vuepress/public/schema.json index 5a4c635..4f6e95d 100644 --- a/docs/.vuepress/public/schema.json +++ b/docs/.vuepress/public/schema.json @@ -84,6 +84,10 @@ "description": "The filtering rule for this task. Can be 'variable' or 'staged'.", "default": "variable" }, + "filteringRuleVariable": { + "type": "string", + "description": "Name of a variable (defined in the 'variables' section) whose output is used to filter task execution. If the variable returns no files matching the task's include/exclude patterns, the task is skipped. Works independently of the filteringRule setting." + }, "windows": { "$ref": "#/definitions/windowsOverrides", "description": "Overrides all settings for Windows." diff --git a/src/Husky/TaskRunner/ArgumentParser.cs b/src/Husky/TaskRunner/ArgumentParser.cs index 8c81e01..ad1ffd7 100644 --- a/src/Husky/TaskRunner/ArgumentParser.cs +++ b/src/Husky/TaskRunner/ArgumentParser.cs @@ -11,6 +11,7 @@ namespace Husky.TaskRunner; public interface IArgumentParser { Task ParseAsync(HuskyTask huskyTask, string[]? optionArguments = null); + Task HasVariableMatchAsync(HuskyTask huskyTask, string variableName, string[]? optionArguments = null); } public partial class ArgumentParser : IArgumentParser @@ -97,6 +98,28 @@ public async Task ParseAsync(HuskyTask task, string[]? optionArg return args.ToArray(); } + public async Task HasVariableMatchAsync(HuskyTask huskyTask, string variableName, string[]? optionArguments = null) + { + var customVariables = await _customVariableTasks.Value; + + if (customVariables.All(q => q.Name != variableName)) + { + $"⚠️ the filtering variable '{variableName}' not found".Husky(ConsoleColor.Yellow); + return false; + } + + var huskyVariable = customVariables.Last(q => q.Name == variableName); + var gitPath = await _git.GetGitPathAsync(); + + var files = (await GetCustomVariableOutput(huskyVariable)) + .Where(q => !string.IsNullOrWhiteSpace(q)) + .Select(q => Path.IsPathFullyQualified(q) ? Path.GetRelativePath(gitPath, q) : q); + + var matcher = GetPatternMatcher(huskyTask, optionArguments); + var matches = matcher.Match(gitPath, files); + return matches.HasMatches; + } + private async Task AddStagedFiles(Matcher matcher, ICollection args, PathModes pathMode, Match? match = null) { var stagedFiles = (await _git.GetStagedFilesAsync()) diff --git a/src/Husky/TaskRunner/ExecutableTaskFactory.cs b/src/Husky/TaskRunner/ExecutableTaskFactory.cs index 72f542a..a7abd96 100644 --- a/src/Husky/TaskRunner/ExecutableTaskFactory.cs +++ b/src/Husky/TaskRunner/ExecutableTaskFactory.cs @@ -72,24 +72,39 @@ private async Task CheckIfWeShouldSkipTheTask(HuskyTask huskyTask, Argumen return true; } - if (huskyTask.FilteringRule != FilteringRules.Staged) return false; - - var stagedFiles = (await _git.GetStagedFilesAsync()) - .Where(q => !string.IsNullOrWhiteSpace(q)) - .ToArray(); - if (stagedFiles.Length == 0) + if (huskyTask.FilteringRule == FilteringRules.Staged) { - "💤 Skipped, no staged files".Husky(ConsoleColor.Blue); - return true; + var stagedFiles = (await _git.GetStagedFilesAsync()) + .Where(q => !string.IsNullOrWhiteSpace(q)) + .ToArray(); + if (stagedFiles.Length == 0) + { + "💤 Skipped, no staged files".Husky(ConsoleColor.Blue); + return true; + } + + var matcher = ArgumentParser.GetPatternMatcher(huskyTask, optionArguments); + + // get match staged files with glob + var matches = matcher.Match(stagedFiles); + if (!matches.HasMatches) + { + "💤 Skipped, no staged matched files".Husky(ConsoleColor.Blue); + return true; + } } - var matcher = ArgumentParser.GetPatternMatcher(huskyTask, optionArguments); + if (!string.IsNullOrWhiteSpace(huskyTask.FilteringRuleVariable)) + { + var hasMatches = await _argumentParser.HasVariableMatchAsync(huskyTask, huskyTask.FilteringRuleVariable, optionArguments); + if (!hasMatches) + { + "💤 Skipped, no matched files".Husky(ConsoleColor.Blue); + return true; + } + } - // get match staged files with glob - var matches = matcher.Match(stagedFiles); - if (matches.HasMatches) return false; - "💤 Skipped, no staged matched files".Husky(ConsoleColor.Blue); - return true; + return false; } diff --git a/src/Husky/TaskRunner/HuskyTask.cs b/src/Husky/TaskRunner/HuskyTask.cs index 9a71a2c..bac3813 100644 --- a/src/Husky/TaskRunner/HuskyTask.cs +++ b/src/Husky/TaskRunner/HuskyTask.cs @@ -12,6 +12,7 @@ public class HuskyTask public string? Branch { get; set; } public HuskyTask? Windows { get; set; } public FilteringRules? FilteringRule { get; set; } + public string? FilteringRuleVariable { get; set; } public string[]? Include { get; set; } public string[]? Exclude { get; set; } } diff --git a/tests/HuskyIntegrationTests/FilteringRuleVariableTests.cs b/tests/HuskyIntegrationTests/FilteringRuleVariableTests.cs new file mode 100644 index 0000000..cca6b3b --- /dev/null +++ b/tests/HuskyIntegrationTests/FilteringRuleVariableTests.cs @@ -0,0 +1,172 @@ +using System.Runtime.CompilerServices; +using DotNet.Testcontainers.Containers; +using FluentAssertions; + +namespace HuskyIntegrationTests; + +public class FilteringRuleVariableTests(ITestOutputHelper output) +{ + [Fact] + public async Task FilteringRuleVariable_WithMatchingFiles_ShouldRunTask() + { + // Arrange + const string taskRunner = + """ + { + "variables": [ + { + "name": "staged-cs-files", + "command": "git", + "args": ["diff", "--cached", "--name-only", "--diff-filter=AM"] + } + ], + "tasks": [ + { + "name": "dotnet-test", + "group": "pre-commit", + "command": "echo", + "args": ["dotnet test executed"], + "include": ["**/*.cs"], + "filteringRuleVariable": "staged-cs-files" + } + ] + } + """; + await using var c = await ArrangeContainer(taskRunner); + + // stage a .cs file - should trigger the task + await c.AddCsharpClass("public class MyClass { }", "MyClass.cs"); + await c.BashAsync("git add ."); + + // act + var result = await c.BashAsync(output, "git commit -m 'add MyClass.cs'"); + + // assert - task should run because variable returns matching .cs files + result.ExitCode.Should().Be(0); + result.Stderr.Should().Contain(DockerHelper.SuccessfullyExecuted); + } + + [Fact] + public async Task FilteringRuleVariable_WithNoMatchingFiles_ShouldSkipTask() + { + // Arrange - task only runs for .cs files but we'll only stage a .ts file + const string taskRunner = + """ + { + "variables": [ + { + "name": "staged-cs-files", + "command": "git", + "args": ["diff", "--cached", "--name-only", "--diff-filter=AM"] + } + ], + "tasks": [ + { + "name": "dotnet-test", + "group": "pre-commit", + "command": "echo", + "args": ["dotnet test executed"], + "include": ["**/*.cs"], + "filteringRuleVariable": "staged-cs-files" + } + ] + } + """; + await using var c = await ArrangeContainer(taskRunner); + + // only stage a .ts file - should NOT trigger the .cs task + await c.BashAsync("echo 'const x = 1;' > /test/app.ts"); + await c.BashAsync("git add ."); + + // act + var result = await c.BashAsync(output, "git commit -m 'add app.ts only'"); + + // assert - task should be skipped because variable returns no .cs files + result.ExitCode.Should().Be(0); + result.Stderr.Should().Contain(DockerHelper.Skipped); + } + + [Fact] + public async Task FilteringRuleVariable_WithNonExistentVariable_ShouldSkipTask() + { + // Arrange - filteringRuleVariable references a variable that doesn't exist + const string taskRunner = + """ + { + "variables": [], + "tasks": [ + { + "name": "dotnet-test", + "group": "pre-commit", + "command": "echo", + "args": ["dotnet test executed"], + "include": ["**/*.cs"], + "filteringRuleVariable": "non-existent-variable" + } + ] + } + """; + await using var c = await ArrangeContainer(taskRunner); + + await c.AddCsharpClass("public class MyClass { }", "MyClass.cs"); + await c.BashAsync("git add ."); + + // act + var result = await c.BashAsync(output, "git commit -m 'add MyClass.cs'"); + + // assert - task should be skipped because the variable is not found + result.ExitCode.Should().Be(0); + result.Stderr.Should().Contain(DockerHelper.Skipped); + } + + [Fact] + public async Task FilteringRuleVariable_WithoutVariableInArgs_ShouldFilterByVariableOutput() + { + // This test specifically validates the new feature: + // A task with NO variable in its args can still be filtered by filteringRuleVariable. + // Previously, the only way to filter by variable was to include the variable in args. + + const string taskRunner = + """ + { + "variables": [ + { + "name": "staged-cs-files", + "command": "git", + "args": ["diff", "--cached", "--name-only", "--diff-filter=AM"] + } + ], + "tasks": [ + { + "name": "dotnet-test", + "group": "pre-commit", + "command": "echo", + "args": ["running dotnet test"], + "include": ["**/*.cs"], + "filteringRuleVariable": "staged-cs-files" + } + ] + } + """; + // Note: "args" does NOT contain "${staged-cs-files}" - it only has static args. + // The task should still be filtered by the variable's output. + + await using var c = await ArrangeContainer(taskRunner); + + // Stage only non-.cs files + await c.BashAsync("echo 'const x = 1;' > /test/app.ts"); + await c.BashAsync("git add ."); + + var skipResult = await c.BashAsync(output, "git commit -m 'add ts file - should skip'"); + skipResult.ExitCode.Should().Be(0); + skipResult.Stderr.Should().Contain(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("dotnet husky add pre-commit -c 'dotnet husky run -g pre-commit'"); + return c; + } +} diff --git a/tests/HuskyTest/TaskRunner/ArgumentParserTests.cs b/tests/HuskyTest/TaskRunner/ArgumentParserTests.cs index 18a371e..abeaecc 100644 --- a/tests/HuskyTest/TaskRunner/ArgumentParserTests.cs +++ b/tests/HuskyTest/TaskRunner/ArgumentParserTests.cs @@ -182,5 +182,137 @@ public async Task ParseAsync_WithUnknownCustomVariable_ReturnsEmptyArgs() // Assert args.Should().BeEmpty(); } + + [Fact] + public async Task HasVariableMatchAsync_WithMatchingFiles_ReturnsTrue() + { + // Arrange + var (cmd, cmdArgs) = GetEchoCommand("src/MyClass.cs"); + var argsJson = string.Join(", ", cmdArgs.Select(a => $"\"{a}\"")); + WriteTaskRunner($$""" + { + "variables": [ + { + "name": "cs-files", + "command": "{{cmd}}", + "args": [{{argsJson}}] + } + ], + "tasks": [] + } + """); + + var parser = new ArgumentParser(_git); + var huskyTask = new HuskyTask + { + Name = "dotnet-test", + Command = "dotnet", + Args = ["test"], + Include = ["**/*.cs"] + }; + + // Act + var result = await parser.HasVariableMatchAsync(huskyTask, "cs-files"); + + // Assert + result.Should().BeTrue(); + } + + [Fact] + public async Task HasVariableMatchAsync_WithNoMatchingFiles_ReturnsFalse() + { + // Arrange + var (cmd, cmdArgs) = GetEchoCommand("src/MyComponent.ts"); + var argsJson = string.Join(", ", cmdArgs.Select(a => $"\"{a}\"")); + WriteTaskRunner($$""" + { + "variables": [ + { + "name": "ts-files", + "command": "{{cmd}}", + "args": [{{argsJson}}] + } + ], + "tasks": [] + } + """); + + var parser = new ArgumentParser(_git); + var huskyTask = new HuskyTask + { + Name = "dotnet-test", + Command = "dotnet", + Args = ["test"], + Include = ["**/*.cs"] + }; + + // Act + var result = await parser.HasVariableMatchAsync(huskyTask, "ts-files"); + + // Assert + result.Should().BeFalse(); + } + + [Fact] + public async Task HasVariableMatchAsync_WithNonExistentVariable_ReturnsFalse() + { + // Arrange + WriteTaskRunner(""" + { + "variables": [], + "tasks": [] + } + """); + + var parser = new ArgumentParser(_git); + var huskyTask = new HuskyTask + { + Name = "dotnet-test", + Command = "dotnet", + Args = ["test"], + Include = ["**/*.cs"] + }; + + // Act + var result = await parser.HasVariableMatchAsync(huskyTask, "non-existent-variable"); + + // Assert + result.Should().BeFalse(); + } + + [Fact] + public async Task HasVariableMatchAsync_WithNoIncludePatterns_AndNonEmptyOutput_ReturnsTrue() + { + // Arrange + var (cmd, cmdArgs) = GetEchoCommand("some-file.txt"); + var argsJson = string.Join(", ", cmdArgs.Select(a => $"\"{a}\"")); + WriteTaskRunner($$""" + { + "variables": [ + { + "name": "any-files", + "command": "{{cmd}}", + "args": [{{argsJson}}] + } + ], + "tasks": [] + } + """); + + var parser = new ArgumentParser(_git); + var huskyTask = new HuskyTask + { + Name = "my-task", + Command = "echo", + Args = ["hello"] + // No Include patterns - should match all files + }; + + // Act + var result = await parser.HasVariableMatchAsync(huskyTask, "any-files"); + + // Assert + result.Should().BeTrue(); + } } } From 169bce927ce614a4fbca671c23481d274d75ff4e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Mar 2026 18:59:16 +0000 Subject: [PATCH 3/3] feat: support custom variable references in include/exclude patterns Co-authored-by: alirezanet <7004080+alirezanet@users.noreply.github.com> --- docs/.vuepress/public/schema.json | 8 +- src/Husky/TaskRunner/ArgumentParser.cs | 115 +++++++++++++++--- src/Husky/TaskRunner/ExecutableTaskFactory.cs | 47 ++++--- src/Husky/TaskRunner/HuskyTask.cs | 1 - .../FilteringRuleVariableTests.cs | 73 ++++++----- .../TaskRunner/ArgumentParserTests.cs | 96 +++++++++------ 6 files changed, 222 insertions(+), 118 deletions(-) diff --git a/docs/.vuepress/public/schema.json b/docs/.vuepress/public/schema.json index 4f6e95d..87c10db 100644 --- a/docs/.vuepress/public/schema.json +++ b/docs/.vuepress/public/schema.json @@ -70,24 +70,20 @@ "items": { "type": "string" }, - "description": "Glob pattern to select files." + "description": "Glob patterns to select files. Supports ${args} and custom variable references (e.g. ${my-variable}). When all entries are custom-variable references that produce no output the task is skipped automatically." }, "exclude": { "type": "array", "items": { "type": "string" }, - "description": "Glob pattern to exclude files." + "description": "Glob patterns to exclude files. Supports ${args} and custom variable references (e.g. ${my-variable})." }, "filteringRule": { "$ref": "#/definitions/filteringRules", "description": "The filtering rule for this task. Can be 'variable' or 'staged'.", "default": "variable" }, - "filteringRuleVariable": { - "type": "string", - "description": "Name of a variable (defined in the 'variables' section) whose output is used to filter task execution. If the variable returns no files matching the task's include/exclude patterns, the task is skipped. Works independently of the filteringRule setting." - }, "windows": { "$ref": "#/definitions/windowsOverrides", "description": "Overrides all settings for Windows." diff --git a/src/Husky/TaskRunner/ArgumentParser.cs b/src/Husky/TaskRunner/ArgumentParser.cs index ad1ffd7..1dd88d3 100644 --- a/src/Husky/TaskRunner/ArgumentParser.cs +++ b/src/Husky/TaskRunner/ArgumentParser.cs @@ -11,7 +11,15 @@ namespace Husky.TaskRunner; public interface IArgumentParser { Task ParseAsync(HuskyTask huskyTask, string[]? optionArguments = null); - Task HasVariableMatchAsync(HuskyTask huskyTask, string variableName, string[]? optionArguments = null); + + /// + /// Builds a pattern matcher for the given task, resolving any custom variable references + /// (e.g. ${my-variable}) in include/exclude patterns by executing + /// the corresponding variable command. + /// Returns null when all include patterns were custom-variable references that + /// produced no output, signalling that the task should be skipped. + /// + Task GetPatternMatcherAsync(HuskyTask huskyTask, string[]? optionArguments = null); } public partial class ArgumentParser : IArgumentParser @@ -38,7 +46,10 @@ 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, optionArguments); + // GetPatternMatcherAsync resolves custom-variable references in include/exclude; + // it returns null when all include patterns were empty variables (skip signal). + // In that case we still parse args so the Variable filtering-rule count check works. + var matcher = await GetPatternMatcherAsync(task, optionArguments) ?? CreateEmptyMatcher(); // set default pathMode value var pathMode = task.PathMode ?? PathModes.Relative; @@ -98,26 +109,42 @@ public async Task ParseAsync(HuskyTask task, string[]? optionArg return args.ToArray(); } - public async Task HasVariableMatchAsync(HuskyTask huskyTask, string variableName, string[]? optionArguments = null) + public async Task GetPatternMatcherAsync(HuskyTask task, string[]? optionArguments = null) { - var customVariables = await _customVariableTasks.Value; + var matcher = new Matcher(); + var hasMatcher = false; + var hadCustomVariableInInclude = false; - if (customVariables.All(q => q.Name != variableName)) + if (task.Include is { Length: > 0 }) { - $"⚠️ the filtering variable '{variableName}' not found".Husky(ConsoleColor.Yellow); - return false; + hadCustomVariableInInclude = task.Include.Any(IsCustomVariablePattern); + var resolved = (await ResolvePatternVariablesAsync(task.Include, optionArguments)).ToList(); + if (resolved.Count > 0) + { + matcher.AddIncludePatterns(resolved); + hasMatcher = true; + } } - var huskyVariable = customVariables.Last(q => q.Name == variableName); - var gitPath = await _git.GetGitPathAsync(); + // If every include entry was a custom-variable reference that resolved to nothing, + // signal "skip this task" by returning null. + if (hadCustomVariableInInclude && !hasMatcher) + return null; - var files = (await GetCustomVariableOutput(huskyVariable)) - .Where(q => !string.IsNullOrWhiteSpace(q)) - .Select(q => Path.IsPathFullyQualified(q) ? Path.GetRelativePath(gitPath, q) : q); + if (task.Exclude is { Length: > 0 }) + { + var resolved = (await ResolvePatternVariablesAsync(task.Exclude, optionArguments)).ToList(); + if (resolved.Count > 0) + { + matcher.AddExcludePatterns(resolved); + hasMatcher = true; + } + } - var matcher = GetPatternMatcher(huskyTask, optionArguments); - var matches = matcher.Match(gitPath, files); - return matches.HasMatches; + if (!hasMatcher) + matcher.AddInclude("**/*"); + + return matcher; } private async Task AddStagedFiles(Matcher matcher, ICollection args, PathModes pathMode, Match? match = null) @@ -348,6 +375,42 @@ public static Matcher GetPatternMatcher(HuskyTask task, string[]? optionArgument return matcher; } + private async Task> ResolvePatternVariablesAsync(string[] patterns, string[]? optionArguments) + { + var result = new List(); + foreach (var pattern in patterns) + { + if (IsCustomVariablePattern(pattern)) + { + // Expand custom variable to the file paths returned by its command + var varName = pattern[2..^1]; + var customVariables = await _customVariableTasks.Value; + if (customVariables.All(q => q.Name != varName)) + { + $"⚠️ the custom variable '{varName}' not found in include/exclude pattern".Husky(ConsoleColor.Yellow); + // Variable not found → contributes nothing (may trigger skip) + continue; + } + + var huskyVariable = customVariables.Last(q => q.Name == varName); + var files = (await GetCustomVariableOutput(huskyVariable)) + .Where(q => !string.IsNullOrWhiteSpace(q)); + result.AddRange(files); + } + else if (pattern.Contains("${args}") && optionArguments != null && optionArguments.Length > 0) + { + foreach (var arg in optionArguments) + result.Add(pattern.Replace("${args}", arg)); + } + else + { + result.Add(pattern); + } + } + + return result; + } + private static IEnumerable ResolvePatternVariables(string[] patterns, string[]? optionArguments) { foreach (var pattern in patterns) @@ -363,4 +426,26 @@ private static IEnumerable ResolvePatternVariables(string[] patterns, st } } } + + /// + /// Returns true when the pattern is a bare ${variable-name} reference to a custom + /// variable (not a built-in like ${staged}, ${args}, etc.). + /// + private static bool IsCustomVariablePattern(string pattern) + { + if (!pattern.StartsWith("${") || !pattern.EndsWith("}")) + return false; + + var name = pattern[2..^1]; + return name is not ("args" or "staged" or "git-files" or "all-files" or "last-commit") + && !name.StartsWith("staged:"); + } + + /// Returns a matcher that never matches any file path. + private static Matcher CreateEmptyMatcher() + { + var m = new Matcher(); + m.AddInclude("__no_match__/__no_match__"); + return m; + } } diff --git a/src/Husky/TaskRunner/ExecutableTaskFactory.cs b/src/Husky/TaskRunner/ExecutableTaskFactory.cs index a7abd96..193dc6d 100644 --- a/src/Husky/TaskRunner/ExecutableTaskFactory.cs +++ b/src/Husky/TaskRunner/ExecutableTaskFactory.cs @@ -66,46 +66,45 @@ public ExecutableTaskFactory(IServiceProvider provider, IGit git, IArgumentParse private async Task CheckIfWeShouldSkipTheTask(HuskyTask huskyTask, ArgumentInfo[] argsInfo, string[]? optionArguments = null) { + // Variable rule: skip when a variable in args resolved to no files if (huskyTask is { FilteringRule: FilteringRules.Variable, Args: not null } && huskyTask.Args.Length > argsInfo.Length) { "💤 Skipped, no matched files".Husky(ConsoleColor.Blue); return true; } - if (huskyTask.FilteringRule == FilteringRules.Staged) + // Variable rule: skip when all include patterns were custom-variable references + // that produced no output (GetPatternMatcherAsync returns null in that case) + if (huskyTask.FilteringRule == FilteringRules.Variable) { - var stagedFiles = (await _git.GetStagedFilesAsync()) - .Where(q => !string.IsNullOrWhiteSpace(q)) - .ToArray(); - if (stagedFiles.Length == 0) + var matcher = await _argumentParser.GetPatternMatcherAsync(huskyTask, optionArguments); + if (matcher == null) { - "💤 Skipped, no staged files".Husky(ConsoleColor.Blue); - return true; - } - - var matcher = ArgumentParser.GetPatternMatcher(huskyTask, optionArguments); - - // get match staged files with glob - var matches = matcher.Match(stagedFiles); - if (!matches.HasMatches) - { - "💤 Skipped, no staged matched files".Husky(ConsoleColor.Blue); + "💤 Skipped, no matched files".Husky(ConsoleColor.Blue); return true; } } - if (!string.IsNullOrWhiteSpace(huskyTask.FilteringRuleVariable)) + if (huskyTask.FilteringRule != FilteringRules.Staged) return false; + + var stagedFiles = (await _git.GetStagedFilesAsync()) + .Where(q => !string.IsNullOrWhiteSpace(q)) + .ToArray(); + if (stagedFiles.Length == 0) { - var hasMatches = await _argumentParser.HasVariableMatchAsync(huskyTask, huskyTask.FilteringRuleVariable, optionArguments); - if (!hasMatches) - { - "💤 Skipped, no matched files".Husky(ConsoleColor.Blue); - return true; - } + "💤 Skipped, no staged files".Husky(ConsoleColor.Blue); + return true; } - return false; + // Use async matcher so that custom-variable references in include/exclude are resolved + var stagedMatcher = await _argumentParser.GetPatternMatcherAsync(huskyTask, optionArguments) + ?? ArgumentParser.GetPatternMatcher(huskyTask, optionArguments); + // get match staged files with glob + var matches = stagedMatcher.Match(stagedFiles); + if (matches.HasMatches) return false; + "💤 Skipped, no staged matched files".Husky(ConsoleColor.Blue); + return true; } private IExecutableTask CreateChunkTask( diff --git a/src/Husky/TaskRunner/HuskyTask.cs b/src/Husky/TaskRunner/HuskyTask.cs index bac3813..9a71a2c 100644 --- a/src/Husky/TaskRunner/HuskyTask.cs +++ b/src/Husky/TaskRunner/HuskyTask.cs @@ -12,7 +12,6 @@ public class HuskyTask public string? Branch { get; set; } public HuskyTask? Windows { get; set; } public FilteringRules? FilteringRule { get; set; } - public string? FilteringRuleVariable { get; set; } public string[]? Include { get; set; } public string[]? Exclude { get; set; } } diff --git a/tests/HuskyIntegrationTests/FilteringRuleVariableTests.cs b/tests/HuskyIntegrationTests/FilteringRuleVariableTests.cs index cca6b3b..1d9b2fd 100644 --- a/tests/HuskyIntegrationTests/FilteringRuleVariableTests.cs +++ b/tests/HuskyIntegrationTests/FilteringRuleVariableTests.cs @@ -4,12 +4,16 @@ namespace HuskyIntegrationTests; +/// +/// Tests for using custom variable references (e.g. ${variable-name}) directly in +/// include / exclude patterns to conditionally skip tasks. +/// public class FilteringRuleVariableTests(ITestOutputHelper output) { [Fact] - public async Task FilteringRuleVariable_WithMatchingFiles_ShouldRunTask() + public async Task VariableInInclude_WithMatchingFiles_ShouldRunTask() { - // Arrange + // Arrange: include uses a custom variable; variable returns .cs files that are being staged const string taskRunner = """ { @@ -26,30 +30,30 @@ public async Task FilteringRuleVariable_WithMatchingFiles_ShouldRunTask() "group": "pre-commit", "command": "echo", "args": ["dotnet test executed"], - "include": ["**/*.cs"], - "filteringRuleVariable": "staged-cs-files" + "include": ["${staged-cs-files}"] } ] } """; await using var c = await ArrangeContainer(taskRunner); - // stage a .cs file - should trigger the task + // stage a .cs file – the variable will return it, so include resolves to that file await c.AddCsharpClass("public class MyClass { }", "MyClass.cs"); await c.BashAsync("git add ."); // act var result = await c.BashAsync(output, "git commit -m 'add MyClass.cs'"); - // assert - task should run because variable returns matching .cs files + // assert - task should run because include variable has output result.ExitCode.Should().Be(0); result.Stderr.Should().Contain(DockerHelper.SuccessfullyExecuted); } [Fact] - public async Task FilteringRuleVariable_WithNoMatchingFiles_ShouldSkipTask() + public async Task VariableInInclude_WithNoMatchingFiles_ShouldSkipTask() { - // Arrange - task only runs for .cs files but we'll only stage a .ts file + // Arrange: task has ${staged-cs-files} as its only include pattern. + // We only stage a .ts file, so the variable returns nothing matching .cs. const string taskRunner = """ { @@ -57,7 +61,7 @@ public async Task FilteringRuleVariable_WithNoMatchingFiles_ShouldSkipTask() { "name": "staged-cs-files", "command": "git", - "args": ["diff", "--cached", "--name-only", "--diff-filter=AM"] + "args": ["diff", "--cached", "--name-only", "--diff-filter=AM", "--", "*.cs"] } ], "tasks": [ @@ -66,30 +70,29 @@ public async Task FilteringRuleVariable_WithNoMatchingFiles_ShouldSkipTask() "group": "pre-commit", "command": "echo", "args": ["dotnet test executed"], - "include": ["**/*.cs"], - "filteringRuleVariable": "staged-cs-files" + "include": ["${staged-cs-files}"] } ] } """; await using var c = await ArrangeContainer(taskRunner); - // only stage a .ts file - should NOT trigger the .cs task + // only stage a .ts file – variable will return nothing await c.BashAsync("echo 'const x = 1;' > /test/app.ts"); await c.BashAsync("git add ."); // act - var result = await c.BashAsync(output, "git commit -m 'add app.ts only'"); + var result = await c.BashAsync(output, "git commit -m 'add ts file only'"); - // assert - task should be skipped because variable returns no .cs files + // assert - task should be skipped because include variable returned no files result.ExitCode.Should().Be(0); result.Stderr.Should().Contain(DockerHelper.Skipped); } [Fact] - public async Task FilteringRuleVariable_WithNonExistentVariable_ShouldSkipTask() + public async Task VariableInInclude_WithNonExistentVariable_ShouldSkipTask() { - // Arrange - filteringRuleVariable references a variable that doesn't exist + // Arrange: include references a variable that does not exist const string taskRunner = """ { @@ -100,8 +103,7 @@ public async Task FilteringRuleVariable_WithNonExistentVariable_ShouldSkipTask() "group": "pre-commit", "command": "echo", "args": ["dotnet test executed"], - "include": ["**/*.cs"], - "filteringRuleVariable": "non-existent-variable" + "include": ["${non-existent-variable}"] } ] } @@ -114,26 +116,24 @@ public async Task FilteringRuleVariable_WithNonExistentVariable_ShouldSkipTask() // act var result = await c.BashAsync(output, "git commit -m 'add MyClass.cs'"); - // assert - task should be skipped because the variable is not found + // assert - task should be skipped because the variable was not found (empty output) result.ExitCode.Should().Be(0); result.Stderr.Should().Contain(DockerHelper.Skipped); } [Fact] - public async Task FilteringRuleVariable_WithoutVariableInArgs_ShouldFilterByVariableOutput() + public async Task VariableInInclude_MixedWithGlob_VariableEmptyButGlobPresent_ShouldRunTask() { - // This test specifically validates the new feature: - // A task with NO variable in its args can still be filtered by filteringRuleVariable. - // Previously, the only way to filter by variable was to include the variable in args. - + // Arrange: include has both a glob pattern AND a custom variable. + // The variable returns nothing, but the glob "**/*.cs" still matches → task runs. const string taskRunner = """ { "variables": [ { - "name": "staged-cs-files", + "name": "extra-files", "command": "git", - "args": ["diff", "--cached", "--name-only", "--diff-filter=AM"] + "args": ["diff", "--cached", "--name-only", "--diff-filter=AM", "--", "*.ts"] } ], "tasks": [ @@ -141,25 +141,24 @@ public async Task FilteringRuleVariable_WithoutVariableInArgs_ShouldFilterByVari "name": "dotnet-test", "group": "pre-commit", "command": "echo", - "args": ["running dotnet test"], - "include": ["**/*.cs"], - "filteringRuleVariable": "staged-cs-files" + "args": ["dotnet test executed"], + "include": ["**/*.cs", "${extra-files}"] } ] } """; - // Note: "args" does NOT contain "${staged-cs-files}" - it only has static args. - // The task should still be filtered by the variable's output. - await using var c = await ArrangeContainer(taskRunner); - // Stage only non-.cs files - await c.BashAsync("echo 'const x = 1;' > /test/app.ts"); + // stage a .cs file (matches the glob); variable returns nothing (no .ts changes) + await c.AddCsharpClass("public class MyClass { }", "MyClass.cs"); await c.BashAsync("git add ."); - var skipResult = await c.BashAsync(output, "git commit -m 'add ts file - should skip'"); - skipResult.ExitCode.Should().Be(0); - skipResult.Stderr.Should().Contain(DockerHelper.Skipped); + // act + var result = await c.BashAsync(output, "git commit -m 'add MyClass.cs'"); + + // assert - task should run because "**/*.cs" glob still provides include patterns + result.ExitCode.Should().Be(0); + result.Stderr.Should().Contain(DockerHelper.SuccessfullyExecuted); } private async Task ArrangeContainer(string taskRunner, [CallerMemberName] string name = null!) diff --git a/tests/HuskyTest/TaskRunner/ArgumentParserTests.cs b/tests/HuskyTest/TaskRunner/ArgumentParserTests.cs index abeaecc..411cb8d 100644 --- a/tests/HuskyTest/TaskRunner/ArgumentParserTests.cs +++ b/tests/HuskyTest/TaskRunner/ArgumentParserTests.cs @@ -184,9 +184,9 @@ public async Task ParseAsync_WithUnknownCustomVariable_ReturnsEmptyArgs() } [Fact] - public async Task HasVariableMatchAsync_WithMatchingFiles_ReturnsTrue() + public async Task GetPatternMatcherAsync_WithVariableInInclude_AndMatchingOutput_ReturnsMatcher() { - // Arrange + // Arrange: variable returns a .cs file path which becomes the include pattern var (cmd, cmdArgs) = GetEchoCommand("src/MyClass.cs"); var argsJson = string.Join(", ", cmdArgs.Select(a => $"\"{a}\"")); WriteTaskRunner($$""" @@ -208,27 +208,27 @@ public async Task HasVariableMatchAsync_WithMatchingFiles_ReturnsTrue() Name = "dotnet-test", Command = "dotnet", Args = ["test"], - Include = ["**/*.cs"] + Include = ["${cs-files}"] }; // Act - var result = await parser.HasVariableMatchAsync(huskyTask, "cs-files"); + var matcher = await parser.GetPatternMatcherAsync(huskyTask); - // Assert - result.Should().BeTrue(); + // Assert - should return a non-null matcher (variable had output) + matcher.Should().NotBeNull(); } [Fact] - public async Task HasVariableMatchAsync_WithNoMatchingFiles_ReturnsFalse() + public async Task GetPatternMatcherAsync_WithVariableInInclude_AndEmptyOutput_ReturnsNull() { - // Arrange - var (cmd, cmdArgs) = GetEchoCommand("src/MyComponent.ts"); + // Arrange: variable returns nothing; include only contains the variable ref + var (cmd, cmdArgs) = GetEchoCommand(string.Empty); var argsJson = string.Join(", ", cmdArgs.Select(a => $"\"{a}\"")); WriteTaskRunner($$""" { "variables": [ { - "name": "ts-files", + "name": "empty-files", "command": "{{cmd}}", "args": [{{argsJson}}] } @@ -243,18 +243,53 @@ public async Task HasVariableMatchAsync_WithNoMatchingFiles_ReturnsFalse() Name = "dotnet-test", Command = "dotnet", Args = ["test"], - Include = ["**/*.cs"] + Include = ["${empty-files}"] }; // Act - var result = await parser.HasVariableMatchAsync(huskyTask, "ts-files"); + var matcher = await parser.GetPatternMatcherAsync(huskyTask); - // Assert - result.Should().BeFalse(); + // Assert - null signals "should skip" (all include patterns were empty variable refs) + matcher.Should().BeNull(); } [Fact] - public async Task HasVariableMatchAsync_WithNonExistentVariable_ReturnsFalse() + public async Task GetPatternMatcherAsync_MixedInclude_VariableEmptyButGlobPresent_ReturnsMatcher() + { + // Arrange: mixed include - glob stays even when variable is empty + var (cmd, cmdArgs) = GetEchoCommand(string.Empty); + var argsJson = string.Join(", ", cmdArgs.Select(a => $"\"{a}\"")); + WriteTaskRunner($$""" + { + "variables": [ + { + "name": "empty-files", + "command": "{{cmd}}", + "args": [{{argsJson}}] + } + ], + "tasks": [] + } + """); + + var parser = new ArgumentParser(_git); + var huskyTask = new HuskyTask + { + Name = "dotnet-test", + Command = "dotnet", + Args = ["test"], + Include = ["**/*.cs", "${empty-files}"] // glob remains even if variable is empty + }; + + // Act + var matcher = await parser.GetPatternMatcherAsync(huskyTask); + + // Assert - not null because the glob "**/*.cs" still provides a pattern + matcher.Should().NotBeNull(); + } + + [Fact] + public async Task GetPatternMatcherAsync_WithNonExistentVariable_ReturnsNull() { // Arrange WriteTaskRunner(""" @@ -270,31 +305,23 @@ public async Task HasVariableMatchAsync_WithNonExistentVariable_ReturnsFalse() Name = "dotnet-test", Command = "dotnet", Args = ["test"], - Include = ["**/*.cs"] + Include = ["${non-existent-variable}"] }; // Act - var result = await parser.HasVariableMatchAsync(huskyTask, "non-existent-variable"); + var matcher = await parser.GetPatternMatcherAsync(huskyTask); - // Assert - result.Should().BeFalse(); + // Assert - null because variable not found → treated as empty + matcher.Should().BeNull(); } [Fact] - public async Task HasVariableMatchAsync_WithNoIncludePatterns_AndNonEmptyOutput_ReturnsTrue() + public async Task GetPatternMatcherAsync_WithNoInclude_ReturnsMatcher() { - // Arrange - var (cmd, cmdArgs) = GetEchoCommand("some-file.txt"); - var argsJson = string.Join(", ", cmdArgs.Select(a => $"\"{a}\"")); - WriteTaskRunner($$""" + // Arrange: no include patterns at all → default "**/*" matcher + WriteTaskRunner(""" { - "variables": [ - { - "name": "any-files", - "command": "{{cmd}}", - "args": [{{argsJson}}] - } - ], + "variables": [], "tasks": [] } """); @@ -305,14 +332,13 @@ public async Task HasVariableMatchAsync_WithNoIncludePatterns_AndNonEmptyOutput_ Name = "my-task", Command = "echo", Args = ["hello"] - // No Include patterns - should match all files }; // Act - var result = await parser.HasVariableMatchAsync(huskyTask, "any-files"); + var matcher = await parser.GetPatternMatcherAsync(huskyTask); - // Assert - result.Should().BeTrue(); + // Assert - returns a valid matcher (match-all by default) + matcher.Should().NotBeNull(); } } }