diff --git a/.husky/commit-msg b/.husky/commit-msg old mode 100644 new mode 100755 diff --git a/docs/.vuepress/public/schema.json b/docs/.vuepress/public/schema.json index b196391..5a4c635 100644 --- a/docs/.vuepress/public/schema.json +++ b/docs/.vuepress/public/schema.json @@ -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." diff --git a/docs/guide/task-configuration.md b/docs/guide/task-configuration.md index 51800fd..b3e6274 100644 --- a/docs/guide/task-configuration.md +++ b/docs/guide/task-configuration.md @@ -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"] + } + ] +} +``` + diff --git a/src/Husky/TaskRunner/ArgumentParser.cs b/src/Husky/TaskRunner/ArgumentParser.cs index 91efa2c..13ddf19 100644 --- a/src/Husky/TaskRunner/ArgumentParser.cs +++ b/src/Husky/TaskRunner/ArgumentParser.cs @@ -16,7 +16,7 @@ public interface IArgumentParser public partial class ArgumentParser : IArgumentParser { private readonly IGit _git; - private readonly Lazy>> _customVariableTasks; + private readonly Lazy>> _customVariableTasks; private const string StagedWithSeparatorPattern = @".*(\$\{staged(?:\:(.+))\}).*"; #if NET7_0_OR_GREATER @@ -27,7 +27,7 @@ public partial class ArgumentParser : IArgumentParser public ArgumentParser(IGit git) { _git = git; - _customVariableTasks = new Lazy>>(GetCustomVariableTasks); + _customVariableTasks = new Lazy>>(GetCustomVariableTasks); } public async Task ParseAsync(HuskyTask task, string[]? optionArguments = null) @@ -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> GetCustomVariableOutput(HuskyTask task) + private async Task> GetCustomVariableOutput(HuskyVariable variable) { var output = Array.Empty(); 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) @@ -172,17 +173,17 @@ private async Task> GetCustomVariableOutput(HuskyTask task) return output; } - private async Task> GetCustomVariableTasks() + private async Task> GetCustomVariableTasks() { var dir = Path.Combine( await _git.GetGitPathAsync(), await _git.GetHuskyPathAsync(), "task-runner.json" ); - var tasks = new List(); + var variables = new List(); 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 args, PathModes pathMode) diff --git a/src/Husky/TaskRunner/HuskyVariable.cs b/src/Husky/TaskRunner/HuskyVariable.cs new file mode 100644 index 0000000..147b200 --- /dev/null +++ b/src/Husky/TaskRunner/HuskyVariable.cs @@ -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; } + + /// + /// 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). + /// + public bool Staged { get; set; } +} diff --git a/src/Husky/Utils/Utils.cs b/src/Husky/Utils/Utils.cs index ec56602..2390f68 100644 --- a/src/Husky/Utils/Utils.cs +++ b/src/Husky/Utils/Utils.cs @@ -14,4 +14,15 @@ public static async Task 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 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; + } } + diff --git a/tests/HuskyIntegrationTests/StagedCustomVariableTests.cs b/tests/HuskyIntegrationTests/StagedCustomVariableTests.cs new file mode 100644 index 0000000..8249595 --- /dev/null +++ b/tests/HuskyIntegrationTests/StagedCustomVariableTests.cs @@ -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 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; + } +} diff --git a/tests/HuskyTest/TaskRunner/ArgumentParserTests.cs b/tests/HuskyTest/TaskRunner/ArgumentParserTests.cs new file mode 100644 index 0000000..18a371e --- /dev/null +++ b/tests/HuskyTest/TaskRunner/ArgumentParserTests.cs @@ -0,0 +1,186 @@ +using System.Runtime.InteropServices; +using FluentAssertions; +using Husky.Services.Contracts; +using Husky.Stdout; +using Husky.TaskRunner; +using NSubstitute; +using Xunit; + +namespace HuskyTest.TaskRunner +{ + public class ArgumentParserTests : IDisposable + { + private readonly IGit _git; + private readonly string _tempDir; + private readonly string _huskyDir; + + public ArgumentParserTests() + { + var console = new CliFx.Infrastructure.FakeInMemoryConsole(); + LoggerEx.logger = new Husky.Stdout.Logger(console); + + _git = Substitute.For(); + _tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + _huskyDir = Path.Combine(_tempDir, ".husky"); + Directory.CreateDirectory(_huskyDir); + + // Default git mock setup + _git.GetGitPathAsync().Returns(_tempDir); + _git.GetHuskyPathAsync().Returns(".husky"); + } + + public void Dispose() + { + if (Directory.Exists(_tempDir)) + Directory.Delete(_tempDir, recursive: true); + } + + private void WriteTaskRunner(string content) + { + File.WriteAllText(Path.Combine(_huskyDir, "task-runner.json"), content); + } + + private (string command, string[] args) GetEchoCommand(string output) + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + return ("cmd", ["/c", "echo", output]); + return ("echo", [output]); + } + + [Fact] + public async Task ParseAsync_WithCustomVariable_StagedFalse_ReturnsCustomVariableArgumentType() + { + // Arrange + var (cmd, cmdArgs) = GetEchoCommand("test.cs"); + var argsJson = string.Join(", ", cmdArgs.Select(a => $"\"{a}\"")); + WriteTaskRunner($$""" + { + "variables": [ + { + "name": "my-files", + "command": "{{cmd}}", + "args": [{{argsJson}}], + "staged": false + } + ], + "tasks": [] + } + """); + + var parser = new ArgumentParser(_git); + var huskyTask = new HuskyTask + { + Name = "test", + Command = "echo", + Args = ["${my-files}"], + Include = ["**/*.cs"] + }; + + // Act + var args = await parser.ParseAsync(huskyTask); + + // Assert + args.Should().NotBeEmpty(); + args.Should().AllSatisfy(a => a.ArgumentTypes.Should().Be(ArgumentTypes.CustomVariable)); + } + + [Fact] + public async Task ParseAsync_WithCustomVariable_StagedNotProvided_ReturnsCustomVariableArgumentType() + { + // Arrange + var (cmd, cmdArgs) = GetEchoCommand("test.cs"); + var argsJson = string.Join(", ", cmdArgs.Select(a => $"\"{a}\"")); + WriteTaskRunner($$""" + { + "variables": [ + { + "name": "my-files", + "command": "{{cmd}}", + "args": [{{argsJson}}] + } + ], + "tasks": [] + } + """); + + var parser = new ArgumentParser(_git); + var huskyTask = new HuskyTask + { + Name = "test", + Command = "echo", + Args = ["${my-files}"], + Include = ["**/*.cs"] + }; + + // Act + var args = await parser.ParseAsync(huskyTask); + + // Assert + args.Should().NotBeEmpty(); + args.Should().AllSatisfy(a => a.ArgumentTypes.Should().Be(ArgumentTypes.CustomVariable)); + } + + [Fact] + public async Task ParseAsync_WithCustomVariable_StagedTrue_ReturnsStagedFileArgumentType() + { + // Arrange + var (cmd, cmdArgs) = GetEchoCommand("test.cs"); + var argsJson = string.Join(", ", cmdArgs.Select(a => $"\"{a}\"")); + WriteTaskRunner($$""" + { + "variables": [ + { + "name": "my-files", + "command": "{{cmd}}", + "args": [{{argsJson}}], + "staged": true + } + ], + "tasks": [] + } + """); + + var parser = new ArgumentParser(_git); + var huskyTask = new HuskyTask + { + Name = "test", + Command = "echo", + Args = ["${my-files}"], + Include = ["**/*.cs"] + }; + + // Act + var args = await parser.ParseAsync(huskyTask); + + // Assert + args.Should().NotBeEmpty(); + args.Should().AllSatisfy(a => a.ArgumentTypes.Should().Be(ArgumentTypes.StagedFile)); + } + + [Fact] + public async Task ParseAsync_WithUnknownCustomVariable_ReturnsEmptyArgs() + { + // Arrange + WriteTaskRunner(""" + { + "variables": [], + "tasks": [] + } + """); + + var parser = new ArgumentParser(_git); + var huskyTask = new HuskyTask + { + Name = "test", + Command = "echo", + Args = ["${unknown-variable}"] + }; + + // Act + var args = await parser.ParseAsync(huskyTask); + + // Assert + args.Should().BeEmpty(); + } + } +}