Skip to content
Draft
Show file tree
Hide file tree
Changes from 2 commits
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
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