Skip to content
Open
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
5 changes: 4 additions & 1 deletion src/Husky/Cli/AddCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using CliFx.Attributes;
using CliFx.Infrastructure;
using Husky.Stdout;
using Husky.Utils;
using Microsoft.Extensions.DependencyInjection;

namespace Husky.Cli;
Expand Down Expand Up @@ -40,7 +41,9 @@ protected override async ValueTask SafeExecuteAsync(IConsole console)
return;
}

await _fileSystem.File.AppendAllTextAsync(hookPath, $"{Command}\n");
var existingHookContent = await _fileSystem.File.ReadAllTextAsync(hookPath);
var hookContent = ShellScriptLineEndings.Normalize($"{existingHookContent}{Command}\n");
await _fileSystem.File.WriteAllTextAsync(hookPath, hookContent);
$"added to '{hookPath}' hook".Log(ConsoleColor.Green);
}
}
43 changes: 9 additions & 34 deletions src/Husky/Cli/InstallCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using CliFx.Infrastructure;
using Husky.Services.Contracts;
using Husky.Stdout;
using Husky.Utils;

namespace Husky.Cli;

Expand All @@ -16,6 +17,8 @@ public class InstallCommand : CommandBase
private readonly IFileSystem _fileSystem;
private const string FailedMsg = "Git hooks installation failed";
private const string HUSKY_FOLDER_NAME = ".husky";
private const string GIT_ATTRIBUTES_FILE_NAME = ".gitattributes";
private const string GIT_ATTRIBUTES_CONTENT = "* text eol=lf\n";
private const string DOCS_URL = "https://alirezanet.github.io/Husky.Net/guide/getting-started";

[CommandOption("dir", 'd', Description = "The custom directory to install Husky hooks.")]
Expand Down Expand Up @@ -115,39 +118,6 @@ private void RunUnderMutexControl(string path, string cwd)
}
}

private void CreateResources(string path)
{
$"Creating resources and configuration files in '{path}'".LogVerbose();

// Create .husky/_
_fileSystem.Directory.CreateDirectory(Path.Combine(path, "_"));

// Create .husky/_/. ignore
_fileSystem.File.WriteAllText(Path.Combine(path, "_/.gitignore"), "*");

// Copy husky.sh to .husky/_/husky.sh
var husky_shPath = Path.Combine(path, "_", "husky.sh");
{
using var stream = Assembly.GetAssembly(typeof(Program))!.GetManifestResourceStream("Husky.templates.husky.sh")!;
using var sr = new StreamReader(stream);
var content = sr.ReadToEnd();
_fileSystem.File.WriteAllText(husky_shPath, content);
}

// here we have to run the `ConfigureGitAndFilePermission` synchronously because mutex will fail if thread changes
ConfigureGitAndFilePermission(path, husky_shPath).GetAwaiter().GetResult();

// Created task-runner.json file
// We don't want to override this file
if (!_fileSystem.File.Exists(Path.Combine(path, "task-runner.json")))
{
using var stream = Assembly.GetAssembly(typeof(Program))!.GetManifestResourceStream("Husky.templates.task-runner.json")!;
using var sr = new StreamReader(stream);
var content = sr.ReadToEnd();
_fileSystem.File.WriteAllText(Path.Combine(path, "task-runner.json"), content);
}
}

private async Task CreateResourcesAsync(string path)
{
$"Creating resources and configuration files asynchronously in '{path}'".LogVerbose();
Expand All @@ -158,12 +128,17 @@ private async Task CreateResourcesAsync(string path)
// Create .husky/_/. ignore
await _fileSystem.File.WriteAllTextAsync(Path.Combine(path, "_/.gitignore"), "*");

// Keep Husky hook scripts LF-only even when users have core.autocrlf enabled.
var gitAttributesPath = Path.Combine(path, GIT_ATTRIBUTES_FILE_NAME);
if (!_fileSystem.File.Exists(gitAttributesPath))
await _fileSystem.File.WriteAllTextAsync(gitAttributesPath, GIT_ATTRIBUTES_CONTENT);

// Copy husky.sh to .husky/_/husky.sh
var husky_shPath = Path.Combine(path, "_", "husky.sh");
{
await using var stream = Assembly.GetAssembly(typeof(Program))!.GetManifestResourceStream("Husky.templates.husky.sh")!;
using var sr = new StreamReader(stream);
var content = await sr.ReadToEndAsync();
var content = ShellScriptLineEndings.Normalize(await sr.ReadToEndAsync());
await _fileSystem.File.WriteAllTextAsync(husky_shPath, content);
}

Expand Down
5 changes: 3 additions & 2 deletions src/Husky/Cli/SetCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,9 @@ private async Task CreateHook(string huskyPath)
{
await using var stream = Assembly.GetAssembly(typeof(Program))!.GetManifestResourceStream("Husky.templates.hook")!;
using var sr = new StreamReader(stream);
var content = await sr.ReadToEndAsync();
await _fileSystem.File.WriteAllTextAsync(hookPath, $"{content}\n{Command}\n");
var content = ShellScriptLineEndings.Normalize(await sr.ReadToEndAsync());
var hookContent = ShellScriptLineEndings.Normalize($"{content}\n{Command}\n");
await _fileSystem.File.WriteAllTextAsync(hookPath, hookContent);
Comment on lines +44 to +46

Copilot AI Apr 30, 2026

Copy link

Choose a reason for hiding this comment

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

set now normalizes the initially generated hook content, but add can still append Command containing \r/CRLF (see AddCommand.AppendAllTextAsync(...)), reintroducing CR characters into an existing hook script and breaking execution on Linux. Normalize the appended content (or read/normalize/rewrite the whole file) in the add flow as well so hooks remain LF-only after subsequent add operations.

Copilot uses AI. Check for mistakes.
}

// needed for linux
Expand Down
9 changes: 9 additions & 0 deletions src/Husky/Utils/ShellScriptLineEndings.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
namespace Husky.Utils;

public static class ShellScriptLineEndings
{
public static string Normalize(string content)
{
return content.Replace("\r\n", "\n").Replace("\r", "\n");
}
}
23 changes: 23 additions & 0 deletions tests/HuskyTest/Cli/AddCommandTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -58,5 +58,28 @@ public async Task Add_WhenHookNameContainsPathSeparator_ThrowException()

await act.Should().ThrowAsync<CommandException>().WithMessage($"hook name can not contain path separator");
}

[Fact]
public async Task Add_WhenHookExists_NormalizesLineEndingsAfterAppendingCommand()
{
// Arrange
const string huskyPath = ".husky";
const string hookName = "pre-commit";
var hookPath = Path.Combine(huskyPath, hookName);
var command = new AddCommand(_serviceProvider, _io) { Command = "echo first\r\necho second", HookName = hookName };

_git.GetHuskyPathAsync().Returns(Task.FromResult(huskyPath));
_io.File.Exists(Path.Combine(huskyPath, "_", "husky.sh")).Returns(true);
_io.File.Exists(hookPath).Returns(true);
_io.File.ReadAllTextAsync(hookPath).Returns(Task.FromResult("#!/bin/sh\r\necho existing\r\n"));

// Act
await command.ExecuteAsync(_console);

// Assert
await _io.File.Received(1).WriteAllTextAsync(
hookPath,
"#!/bin/sh\necho existing\necho first\necho second\n");
}
}
}
42 changes: 42 additions & 0 deletions tests/HuskyTest/Cli/InstallCommandTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,48 @@ public async Task Install_Succeed()
await command.ExecuteAsync(_console);
}

[Fact]
public async Task Install_CreatesGitAttributesInHuskyDirectory()
{
// Arrange
var command = new InstallCommand(_git, _cliWrap, _fileSystem) { AllowParallelism = false };
var now = DateTimeOffset.Now;
_git.ExecAsync("rev-parse").Returns(Task.FromResult(new CommandResult(0, now, now)));
_fileSystem.Directory.Exists(Path.Combine(Environment.CurrentDirectory, ".git")).Returns(true);
_git.ExecAsync("config core.hooksPath .husky").Returns(Task.FromResult(new CommandResult(0, now, now)));
_git.ExecBufferedAsync("config --local --list").Returns(new BufferedCommandResult(0, now, now, "", ""));

// Act
await command.ExecuteAsync(_console);

// Assert
await _fileSystem.File.Received(1).WriteAllTextAsync(
Path.Combine(Environment.CurrentDirectory, ".husky", ".gitattributes"),
"* text eol=lf\n");
}

[Fact]
public async Task Install_WhenGitAttributesAlreadyExists_DoesNotOverwriteIt()
{
// Arrange
var command = new InstallCommand(_git, _cliWrap, _fileSystem) { AllowParallelism = false };
var now = DateTimeOffset.Now;
var gitAttributesPath = Path.Combine(Environment.CurrentDirectory, ".husky", ".gitattributes");
_git.ExecAsync("rev-parse").Returns(Task.FromResult(new CommandResult(0, now, now)));
_fileSystem.Directory.Exists(Path.Combine(Environment.CurrentDirectory, ".git")).Returns(true);
_fileSystem.File.Exists(gitAttributesPath).Returns(true);
_git.ExecAsync("config core.hooksPath .husky").Returns(Task.FromResult(new CommandResult(0, now, now)));
_git.ExecBufferedAsync("config --local --list").Returns(new BufferedCommandResult(0, now, now, "", ""));

// Act
await command.ExecuteAsync(_console);

// Assert
await _fileSystem.File.DidNotReceive().WriteAllTextAsync(
gitAttributesPath,
Arg.Any<string>());
}

[Fact]
public async Task Install_WithParallelism_ShouldNotInterleaveGitCalls()
{
Expand Down
30 changes: 30 additions & 0 deletions tests/HuskyTest/Utils/LineEndingTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
using FluentAssertions;
using Husky.Utils;
using Xunit;

namespace HuskyTest.Utils;

public class LineEndingTests
{
[Fact]
public void NormalizeShellScriptLineEndings_ShouldConvertCrLfToLf()
{
var content = "#!/bin/sh\r\necho husky\r\n";

var normalized = ShellScriptLineEndings.Normalize(content);

normalized.Should().Be("#!/bin/sh\necho husky\n");
normalized.Should().NotContain("\r");
}

[Fact]
public void NormalizeShellScriptLineEndings_ShouldConvertStandaloneCrToLf()
{
var content = "#!/bin/sh\recho husky\r";

var normalized = ShellScriptLineEndings.Normalize(content);

normalized.Should().Be("#!/bin/sh\necho husky\n");
normalized.Should().NotContain("\r");
}
}
Loading