Skip to content

Commit a0820a0

Browse files
Copilotalirezanet
andauthored
feat: support variables in include/exclude glob patterns (#161)
* Initial plan * feat: support variables in include/exclude glob patterns * fix: correct integration test assertions and expand test coverage * test: add regression tests for old behavior in Issue113Tests --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: AliReZa Sabouri <7004080+alirezanet@users.noreply.github.com>
1 parent 69c0bef commit a0820a0

3 files changed

Lines changed: 308 additions & 7 deletions

File tree

src/Husky/TaskRunner/ArgumentParser.cs

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ public async Task<ArgumentInfo[]> ParseAsync(HuskyTask task, string[]? optionArg
3737
return Array.Empty<ArgumentInfo>();
3838

3939
// this is not lazy, because each task can have different patterns
40-
var matcher = GetPatternMatcher(task);
40+
var matcher = GetPatternMatcher(task, optionArguments);
4141

4242
// set default pathMode value
4343
var pathMode = task.PathMode ?? PathModes.Relative;
@@ -303,19 +303,19 @@ private static void AddCustomArguments(List<ArgumentInfo> args, string[]? option
303303
"⚠️ No arguments passed to the run command".Husky(ConsoleColor.Yellow);
304304
}
305305

306-
public static Matcher GetPatternMatcher(HuskyTask task)
306+
public static Matcher GetPatternMatcher(HuskyTask task, string[]? optionArguments = null)
307307
{
308308
var matcher = new Matcher();
309309
var hasMatcher = false;
310310
if (task.Include is { Length: > 0 })
311311
{
312-
matcher.AddIncludePatterns(task.Include);
312+
matcher.AddIncludePatterns(ResolvePatternVariables(task.Include, optionArguments));
313313
hasMatcher = true;
314314
}
315315

316316
if (task.Exclude is { Length: > 0 })
317317
{
318-
matcher.AddExcludePatterns(task.Exclude);
318+
matcher.AddExcludePatterns(ResolvePatternVariables(task.Exclude, optionArguments));
319319
hasMatcher = true;
320320
}
321321

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

325325
return matcher;
326326
}
327+
328+
private static IEnumerable<string> ResolvePatternVariables(string[] patterns, string[]? optionArguments)
329+
{
330+
foreach (var pattern in patterns)
331+
{
332+
if (pattern.Contains("${args}") && optionArguments is { Length: > 0 })
333+
{
334+
foreach (var arg in optionArguments)
335+
yield return pattern.Replace("${args}", arg);
336+
}
337+
else
338+
{
339+
yield return pattern;
340+
}
341+
}
342+
}
327343
}

src/Husky/TaskRunner/ExecutableTaskFactory.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ public ExecutableTaskFactory(IServiceProvider provider, IGit git, IArgumentParse
3939
var cwd = await _git.GetTaskCwdAsync(huskyTask);
4040
var argsInfo = await _argumentParser.ParseAsync(huskyTask, options.Arguments?.ToArray());
4141

42-
if (await CheckIfWeShouldSkipTheTask(huskyTask, argsInfo))
42+
if (await CheckIfWeShouldSkipTheTask(huskyTask, argsInfo, options.Arguments?.ToArray()))
4343
return null; // skip the task
4444

4545
// check for chunk
@@ -63,7 +63,7 @@ public ExecutableTaskFactory(IServiceProvider provider, IGit git, IArgumentParse
6363
);
6464
}
6565

66-
private async Task<bool> CheckIfWeShouldSkipTheTask(HuskyTask huskyTask, ArgumentInfo[] argsInfo)
66+
private async Task<bool> CheckIfWeShouldSkipTheTask(HuskyTask huskyTask, ArgumentInfo[] argsInfo, string[]? optionArguments = null)
6767
{
6868
if (huskyTask is { FilteringRule: FilteringRules.Variable, Args: not null } && huskyTask.Args.Length > argsInfo.Length)
6969
{
@@ -82,7 +82,7 @@ private async Task<bool> CheckIfWeShouldSkipTheTask(HuskyTask huskyTask, Argumen
8282
return true;
8383
}
8484

85-
var matcher = ArgumentParser.GetPatternMatcher(huskyTask);
85+
var matcher = ArgumentParser.GetPatternMatcher(huskyTask, optionArguments);
8686

8787
// get match staged files with glob
8888
var matches = matcher.Match(stagedFiles);
Lines changed: 285 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,285 @@
1+
using System.Runtime.CompilerServices;
2+
using DotNet.Testcontainers.Containers;
3+
using FluentAssertions;
4+
5+
namespace HuskyIntegrationTests;
6+
7+
public class Issue113Tests(ITestOutputHelper output)
8+
{
9+
[Fact]
10+
public async Task ArgsVariable_InIncludePattern_ShouldMatchFilesUnderArgsDirectory()
11+
{
12+
// arrange
13+
const string taskRunner =
14+
"""
15+
{
16+
"tasks": [
17+
{
18+
"name": "Echo",
19+
"command": "echo",
20+
"args": [
21+
"${staged}"
22+
],
23+
"include": [
24+
"${args}/**/*.cs"
25+
]
26+
}
27+
]
28+
}
29+
""";
30+
await using var c = await ArrangeContainer(taskRunner);
31+
32+
// act: run with --args src (the include pattern becomes src/**/*.cs which matches)
33+
var result = await c.BashAsync(output, "dotnet husky run --args src");
34+
35+
// assert
36+
result.ExitCode.Should().Be(0);
37+
result.Stdout.Should().Contain(DockerHelper.SuccessfullyExecuted);
38+
result.Stdout.Should().NotContain(DockerHelper.Skipped);
39+
}
40+
41+
[Fact]
42+
public async Task ArgsVariable_InIncludePattern_ShouldSkip_WhenNoMatchedFiles()
43+
{
44+
// arrange
45+
const string taskRunner =
46+
"""
47+
{
48+
"tasks": [
49+
{
50+
"name": "Echo",
51+
"command": "echo",
52+
"args": [
53+
"${staged}"
54+
],
55+
"include": [
56+
"${args}/**/*.cs"
57+
]
58+
}
59+
]
60+
}
61+
""";
62+
await using var c = await ArrangeContainer(taskRunner);
63+
64+
// act: run with --args tests (the include pattern becomes tests/**/*.cs which does NOT match)
65+
var result = await c.BashAsync(output, "dotnet husky run --args tests");
66+
67+
// assert
68+
result.ExitCode.Should().Be(0);
69+
result.Stdout.Should().Contain(DockerHelper.Skipped);
70+
}
71+
72+
[Fact]
73+
public async Task ArgsVariable_InExcludePattern_ShouldSkip_WhenExcludedByArgs()
74+
{
75+
// arrange
76+
const string taskRunner =
77+
"""
78+
{
79+
"tasks": [
80+
{
81+
"name": "Echo",
82+
"command": "echo",
83+
"args": [
84+
"${staged}"
85+
],
86+
"include": [
87+
"**/*.cs"
88+
],
89+
"exclude": [
90+
"${args}/**/*.cs"
91+
]
92+
}
93+
]
94+
}
95+
""";
96+
await using var c = await ArrangeContainer(taskRunner);
97+
98+
// act: run with --args src (the exclude pattern becomes src/**/*.cs which excludes src/Foo.cs)
99+
var result = await c.BashAsync(output, "dotnet husky run --args src");
100+
101+
// assert
102+
result.ExitCode.Should().Be(0);
103+
result.Stdout.Should().Contain(DockerHelper.Skipped);
104+
}
105+
106+
[Fact]
107+
public async Task ArgsVariable_InExcludePattern_ShouldNotSkip_WhenNotExcludedByArgs()
108+
{
109+
// arrange
110+
const string taskRunner =
111+
"""
112+
{
113+
"tasks": [
114+
{
115+
"name": "Echo",
116+
"command": "echo",
117+
"args": [
118+
"${staged}"
119+
],
120+
"include": [
121+
"**/*.cs"
122+
],
123+
"exclude": [
124+
"${args}/**/*.cs"
125+
]
126+
}
127+
]
128+
}
129+
""";
130+
await using var c = await ArrangeContainer(taskRunner);
131+
132+
// act: run with --args tests (the exclude pattern becomes tests/**/*.cs which does NOT exclude src/Foo.cs)
133+
var result = await c.BashAsync(output, "dotnet husky run --args tests");
134+
135+
// assert
136+
result.ExitCode.Should().Be(0);
137+
result.Stdout.Should().Contain(DockerHelper.SuccessfullyExecuted);
138+
result.Stdout.Should().NotContain(DockerHelper.Skipped);
139+
}
140+
141+
// ── Regression tests: old behavior must still work ──────────────────────────
142+
143+
[Fact]
144+
public async Task StagedVariable_WithStaticInclude_ShouldRun_WhenPatternMatchesStagedFiles()
145+
{
146+
// arrange: old behavior — ${staged} in args, plain static include glob (no ${args})
147+
const string taskRunner =
148+
"""
149+
{
150+
"tasks": [
151+
{
152+
"name": "Echo",
153+
"command": "echo",
154+
"filteringRule": "staged",
155+
"args": [
156+
"${staged}"
157+
],
158+
"include": [
159+
"**/*.cs"
160+
]
161+
}
162+
]
163+
}
164+
""";
165+
await using var c = await ArrangeContainer(taskRunner);
166+
167+
// act: run without --args; staged src/Foo.cs matches **/*.cs
168+
var result = await c.BashAsync(output, "dotnet husky run");
169+
170+
// assert
171+
result.ExitCode.Should().Be(0);
172+
result.Stdout.Should().Contain(DockerHelper.SuccessfullyExecuted);
173+
result.Stdout.Should().NotContain(DockerHelper.Skipped);
174+
}
175+
176+
[Fact]
177+
public async Task StagedVariable_WithStaticInclude_ShouldSkip_WhenPatternDoesNotMatchStagedFiles()
178+
{
179+
// arrange: old behavior — ${staged} in args, plain static include glob (no ${args})
180+
const string taskRunner =
181+
"""
182+
{
183+
"tasks": [
184+
{
185+
"name": "Echo",
186+
"command": "echo",
187+
"filteringRule": "staged",
188+
"args": [
189+
"${staged}"
190+
],
191+
"include": [
192+
"**/*.ts"
193+
]
194+
}
195+
]
196+
}
197+
""";
198+
await using var c = await ArrangeContainer(taskRunner);
199+
200+
// act: run without --args; no .ts files are staged so no match
201+
var result = await c.BashAsync(output, "dotnet husky run");
202+
203+
// assert
204+
result.ExitCode.Should().Be(0);
205+
result.Stdout.Should().Contain(DockerHelper.Skipped);
206+
}
207+
208+
[Fact]
209+
public async Task NoVariable_WithStaticArgs_WithMatchingInclude_ShouldRun()
210+
{
211+
// arrange: old behavior — no variables anywhere, plain static args and include
212+
const string taskRunner =
213+
"""
214+
{
215+
"tasks": [
216+
{
217+
"name": "Echo",
218+
"command": "echo",
219+
"filteringRule": "staged",
220+
"args": [
221+
"Husky.Net is awesome!"
222+
],
223+
"include": [
224+
"**/*.cs"
225+
]
226+
}
227+
]
228+
}
229+
""";
230+
await using var c = await ArrangeContainer(taskRunner);
231+
232+
// act: run without --args; staged src/Foo.cs matches **/*.cs
233+
var result = await c.BashAsync(output, "dotnet husky run");
234+
235+
// assert
236+
result.ExitCode.Should().Be(0);
237+
result.Stdout.Should().Contain(DockerHelper.SuccessfullyExecuted);
238+
result.Stdout.Should().NotContain(DockerHelper.Skipped);
239+
}
240+
241+
[Fact]
242+
public async Task StaticIncludePattern_ShouldNotBeAffectedByArgs_WhenNoArgsVariable()
243+
{
244+
// arrange: new behavior baseline — static include pattern (no ${args}),
245+
// verify pattern is NOT substituted even when --args is supplied
246+
const string taskRunner =
247+
"""
248+
{
249+
"tasks": [
250+
{
251+
"name": "Echo",
252+
"command": "echo",
253+
"filteringRule": "staged",
254+
"args": [
255+
"${staged}"
256+
],
257+
"include": [
258+
"**/*.cs"
259+
]
260+
}
261+
]
262+
}
263+
""";
264+
await using var c = await ArrangeContainer(taskRunner);
265+
266+
// act: --args is provided but the include pattern has no ${args}, so it
267+
// must remain a plain **/*.cs glob and still match staged src/Foo.cs
268+
var result = await c.BashAsync(output, "dotnet husky run --args tests");
269+
270+
// assert
271+
result.ExitCode.Should().Be(0);
272+
result.Stdout.Should().Contain(DockerHelper.SuccessfullyExecuted);
273+
result.Stdout.Should().NotContain(DockerHelper.Skipped);
274+
}
275+
276+
private async Task<IContainer> ArrangeContainer(string taskRunner, [CallerMemberName] string name = null!)
277+
{
278+
var c = await DockerHelper.StartWithInstalledHusky(name);
279+
await c.UpdateTaskRunner(taskRunner);
280+
await c.BashAsync("mkdir -p /test/src");
281+
await c.BashAsync("echo 'public class Foo {}' > /test/src/Foo.cs");
282+
await c.BashAsync("git add .");
283+
return c;
284+
}
285+
}

0 commit comments

Comments
 (0)