Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
feat: add filteringRuleVariable to filter tasks by variable output
Co-authored-by: alirezanet <7004080+alirezanet@users.noreply.github.com>
  • Loading branch information
Copilot and alirezanet committed Mar 12, 2026
commit f5087d1966308e0d138840bf49a0f5b857919e3e
4 changes: 4 additions & 0 deletions docs/.vuepress/public/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
23 changes: 23 additions & 0 deletions src/Husky/TaskRunner/ArgumentParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ namespace Husky.TaskRunner;
public interface IArgumentParser
{
Task<ArgumentInfo[]> ParseAsync(HuskyTask huskyTask, string[]? optionArguments = null);
Task<bool> HasVariableMatchAsync(HuskyTask huskyTask, string variableName, string[]? optionArguments = null);
}

public partial class ArgumentParser : IArgumentParser
Expand Down Expand Up @@ -97,6 +98,28 @@ public async Task<ArgumentInfo[]> ParseAsync(HuskyTask task, string[]? optionArg
return args.ToArray();
}

public async Task<bool> 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<ArgumentInfo> args, PathModes pathMode, Match? match = null)
{
var stagedFiles = (await _git.GetStagedFilesAsync())
Expand Down
43 changes: 29 additions & 14 deletions src/Husky/TaskRunner/ExecutableTaskFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -72,24 +72,39 @@ private async Task<bool> 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;

}

Expand Down
1 change: 1 addition & 0 deletions src/Husky/TaskRunner/HuskyTask.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
}
172 changes: 172 additions & 0 deletions tests/HuskyIntegrationTests/FilteringRuleVariableTests.cs
Original file line number Diff line number Diff line change
@@ -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<IContainer> 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;
}
}
Loading
Loading