Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
24 changes: 20 additions & 4 deletions src/Husky/TaskRunner/ArgumentParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ public async Task<ArgumentInfo[]> ParseAsync(HuskyTask task, string[]? optionArg
return Array.Empty<ArgumentInfo>();

// 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;
Expand Down Expand Up @@ -303,19 +303,19 @@ private static void AddCustomArguments(List<ArgumentInfo> 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;
}

Expand All @@ -324,4 +324,20 @@ public static Matcher GetPatternMatcher(HuskyTask task)

return matcher;
}

private static IEnumerable<string> ResolvePatternVariables(string[] patterns, string[]? optionArguments)
{
foreach (var pattern in patterns)
{
if (pattern.Contains("${args}") && optionArguments is { Length: > 0 })
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does this check mean we only support ${args} variable? if not, we should support all possible variables as before

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Currently only ${args} is supported in include/exclude glob patterns. Other variables like ${staged}, ${all-files}, ${last-commit}, and ${git-files} expand to file path lists, not string fragments — embedding file paths as glob patterns doesn't make semantic sense. ${args} is the only variable that provides literal string values (e.g., a directory name) suitable for substitution into a glob pattern like ${args}/**/*.cs.

{
foreach (var arg in optionArguments)
yield return pattern.Replace("${args}", arg);
}
else
{
yield return pattern;
}
}
}
}
6 changes: 3 additions & 3 deletions src/Husky/TaskRunner/ExecutableTaskFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -63,7 +63,7 @@ public ExecutableTaskFactory(IServiceProvider provider, IGit git, IArgumentParse
);
}

private async Task<bool> CheckIfWeShouldSkipTheTask(HuskyTask huskyTask, ArgumentInfo[] argsInfo)
private async Task<bool> CheckIfWeShouldSkipTheTask(HuskyTask huskyTask, ArgumentInfo[] argsInfo, string[]? optionArguments = null)
{
if (huskyTask is { FilteringRule: FilteringRules.Variable, Args: not null } && huskyTask.Args.Length > argsInfo.Length)
{
Expand All @@ -82,7 +82,7 @@ private async Task<bool> 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);
Expand Down
285 changes: 285 additions & 0 deletions tests/HuskyIntegrationTests/Issue113Tests.cs
Original file line number Diff line number Diff line change
@@ -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<IContainer> 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;
}
}
Loading