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
Empty file modified .husky/commit-msg
100644 → 100755
Empty file.
10 changes: 10 additions & 0 deletions docs/.vuepress/public/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,16 @@
"description": "Arguments for the variable task command. Built-in variables can be used, such as ${staged}, ${git-files}, ${last-commit}",
"examples": ["${staged}", "${git-files}", "${last-commit}"]
},
"cwd": {
"type": "string",
"description": "Current working directory for the variable command.",
"default": "."
},
"staged": {
"type": "boolean",
"description": "When true, files returned by this variable are treated as staged files, enabling re-staging after formatting (same behavior as the built-in ${staged} variable).",
"default": false
},
"windows": {
"$ref": "#/definitions/variableTaskOverrides",
"description": "Overrides for the variable task on Windows."
Expand Down
31 changes: 31 additions & 0 deletions docs/guide/task-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,3 +97,34 @@ defining custom `${root-dir-files}` variable to access root directory files
]
}
```

#### `staged` property for custom variables

By default, custom variables do not trigger re-staging of formatted files after a task runs. If your custom variable returns a list of staged files (e.g., output of `git diff --cached --name-only`), you can set `"staged": true` on the variable definition to enable the same re-staging behavior as the built-in `${staged}` variable.

e.g.

Using a custom staged variable with a broader diff filter that also re-stages formatted files

``` json
{
"variables": [
{
"name": "staged-diff-files",
"command": "git",
"args": ["diff", "--cached", "--name-only", "--no-ext-diff", "--diff-filter=ACMRTUXB"],
"staged": true
}
],
"tasks": [
{
"name": "Run csharpier",
"group": "pre-commit",
"command": "dotnet",
"args": ["csharpier", "${staged-diff-files}"],
"include": ["**/*.cs"]
}
]
}
```

29 changes: 15 additions & 14 deletions src/Husky/TaskRunner/ArgumentParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ public interface IArgumentParser
public partial class ArgumentParser : IArgumentParser
{
private readonly IGit _git;
private readonly Lazy<Task<IList<HuskyTask>>> _customVariableTasks;
private readonly Lazy<Task<IList<HuskyVariable>>> _customVariableTasks;

private const string StagedWithSeparatorPattern = @".*(\$\{staged(?:\:(.+))\}).*";
#if NET7_0_OR_GREATER
Expand All @@ -27,7 +27,7 @@ public partial class ArgumentParser : IArgumentParser
public ArgumentParser(IGit git)
{
_git = git;
_customVariableTasks = new Lazy<Task<IList<HuskyTask>>>(GetCustomVariableTasks);
_customVariableTasks = new Lazy<Task<IList<HuskyVariable>>>(GetCustomVariableTasks);
}

public async Task<ArgumentInfo[]> ParseAsync(HuskyTask task, string[]? optionArguments = null)
Expand Down Expand Up @@ -133,28 +133,29 @@ PathModes pathMode
return;
}

var huskyVariableTask = customVariables.Last(q => q.Name == variable);
var huskyVariable = customVariables.Last(q => q.Name == variable);
var gitPath = await _git.GetGitPathAsync();

// get relative paths for matcher
var files = (await GetCustomVariableOutput(huskyVariableTask))
var files = (await GetCustomVariableOutput(huskyVariable))
.Where(q => !string.IsNullOrWhiteSpace(q))
.Select(q => Path.IsPathFullyQualified(q) ? Path.GetRelativePath(gitPath, q) : q);
var matches = matcher.Match(gitPath, files);
AddMatchedFiles(args, pathMode, ArgumentTypes.CustomVariable, matches, gitPath);
var argumentType = huskyVariable.Staged ? ArgumentTypes.StagedFile : ArgumentTypes.CustomVariable;
AddMatchedFiles(args, pathMode, argumentType, matches, gitPath);
}

private async Task<IEnumerable<string>> GetCustomVariableOutput(HuskyTask task)
private async Task<IEnumerable<string>> GetCustomVariableOutput(HuskyVariable variable)
{
var output = Array.Empty<string>();
try
{
if (task.Command == null || task.Args == null)
if (variable.Command == null || variable.Args == null)
return output;
var cwd = await _git.GetTaskCwdAsync(task);
var cwd = await _git.GetTaskCwdAsync(variable);
var result = await CliWrap.Cli
.Wrap(task.Command)
.WithArguments(task.Args)
.Wrap(variable.Command)
.WithArguments(variable.Args)
.WithWorkingDirectory(cwd)
.ExecuteBufferedAsync();
if (result.ExitCode == 0)
Expand All @@ -172,17 +173,17 @@ private async Task<IEnumerable<string>> GetCustomVariableOutput(HuskyTask task)
return output;
}

private async Task<IList<HuskyTask>> GetCustomVariableTasks()
private async Task<IList<HuskyVariable>> GetCustomVariableTasks()
{
var dir = Path.Combine(
await _git.GetGitPathAsync(),
await _git.GetHuskyPathAsync(),
"task-runner.json"
);
var tasks = new List<HuskyTask>();
var variables = new List<HuskyVariable>();
var config = new ConfigurationBuilder().AddJsonFile(dir).Build();
config.GetSection("variables").Bind(tasks);
return tasks;
config.GetSection("variables").Bind(variables);
return variables;
}

private async Task AddAllFiles(Matcher matcher, List<ArgumentInfo> args, PathModes pathMode)
Expand Down
15 changes: 15 additions & 0 deletions src/Husky/TaskRunner/HuskyVariable.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
namespace Husky.TaskRunner;

public class HuskyVariable
{
public string? Name { get; set; }
public string? Command { get; set; }
public string[]? Args { get; set; }
public string? Cwd { get; set; }

/// <summary>
/// When true, files returned by this variable will be treated as staged files,
/// enabling re-staging after formatting (same behavior as the built-in ${staged} variable).
/// </summary>
public bool Staged { get; set; }
}
11 changes: 11 additions & 0 deletions src/Husky/Utils/Utils.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,15 @@ public static async Task<string> GetTaskCwdAsync(this IGit git, HuskyTask task)
cwd = Path.IsPathFullyQualified(task.Cwd) ? task.Cwd : Path.GetFullPath(task.Cwd, Environment.CurrentDirectory);
return cwd;
}

public static async Task<string> GetTaskCwdAsync(this IGit git, HuskyVariable variable)
{
string cwd;
if (string.IsNullOrEmpty(variable.Cwd))
cwd = Path.GetFullPath(await git.GetGitPathAsync(), Environment.CurrentDirectory);
else
cwd = Path.IsPathFullyQualified(variable.Cwd) ? variable.Cwd : Path.GetFullPath(variable.Cwd, Environment.CurrentDirectory);
return cwd;
}
}

173 changes: 173 additions & 0 deletions tests/HuskyIntegrationTests/StagedCustomVariableTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
using System.Runtime.CompilerServices;
using DotNet.Testcontainers.Containers;
using FluentAssertions;

namespace HuskyIntegrationTests;

public class StagedCustomVariableTests(ITestOutputHelper output)
{
[Fact]
public async Task StagedVariable_WithMatchingFiles_ShouldExecuteAndRestageFiles()
{
// Arrange
const string taskRunner =
"""
{
"variables": [
{
"name": "staged-cs-files",
"command": "git",
"args": ["diff", "--cached", "--name-only", "--no-ext-diff", "--diff-filter=AM"],
"staged": true
}
],
"tasks": [
{
"name": "Echo staged cs files",
"group": "pre-commit",
"command": "echo",
"args": ["${staged-cs-files}"],
"include": ["**/*.cs"]
}
]
}
""";
await using var c = await ArrangeContainer(taskRunner);

// add a .cs file and stage it
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
result.ExitCode.Should().Be(0);
result.Stderr.Should().Contain(DockerHelper.SuccessfullyExecuted);
}

[Fact]
public async Task NonStagedVariable_WithMatchingFiles_ShouldExecuteNormally()
{
// Arrange
const string taskRunner =
"""
{
"variables": [
{
"name": "staged-cs-files",
"command": "git",
"args": ["diff", "--cached", "--name-only", "--no-ext-diff", "--diff-filter=AM"],
"staged": false
}
],
"tasks": [
{
"name": "Echo staged cs files",
"group": "pre-commit",
"command": "echo",
"args": ["${staged-cs-files}"],
"include": ["**/*.cs"]
}
]
}
""";
await using var c = await ArrangeContainer(taskRunner);

// add a .cs file and stage it
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
result.ExitCode.Should().Be(0);
result.Stderr.Should().Contain(DockerHelper.SuccessfullyExecuted);
}

[Fact]
public async Task StagedVariable_WithoutMatchingFiles_ShouldSkip()
{
// Arrange
const string taskRunner =
"""
{
"variables": [
{
"name": "staged-cs-files",
"command": "git",
"args": ["diff", "--cached", "--name-only", "--no-ext-diff", "--diff-filter=AM"],
"staged": true
}
],
"tasks": [
{
"name": "Echo staged cs files",
"group": "pre-commit",
"command": "echo",
"args": ["${staged-cs-files}"],
"include": ["**/*.cs"]
}
]
}
""";
await using var c = await ArrangeContainer(taskRunner);

// Only the task-runner.json is staged (not a .cs file)
await c.BashAsync("git add .");

// act
var result = await c.BashAsync(output, "git commit -m 'add task-runner.json only'");

// assert
result.ExitCode.Should().Be(0);
result.Stderr.Should().Contain(DockerHelper.Skipped);
}

[Fact]
public async Task VariableWithoutStagedProperty_WithoutMatchingFiles_ShouldSkip()
{
// Arrange
const string taskRunner =
"""
{
"variables": [
{
"name": "staged-cs-files",
"command": "git",
"args": ["diff", "--cached", "--name-only", "--no-ext-diff", "--diff-filter=AM"]
}
],
"tasks": [
{
"name": "Echo staged cs files",
"group": "pre-commit",
"command": "echo",
"args": ["${staged-cs-files}"],
"include": ["**/*.cs"]
}
]
}
""";
await using var c = await ArrangeContainer(taskRunner);

// Only the task-runner.json is staged (not a .cs file)
await c.BashAsync("git add .");

// act
var result = await c.BashAsync(output, "git commit -m 'add task-runner.json only'");

// assert
result.ExitCode.Should().Be(0);
result.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