diff --git a/src/Husky/Cli/AddCommand.cs b/src/Husky/Cli/AddCommand.cs index 8f0c555..e80a9eb 100644 --- a/src/Husky/Cli/AddCommand.cs +++ b/src/Husky/Cli/AddCommand.cs @@ -2,6 +2,7 @@ using CliFx.Attributes; using CliFx.Infrastructure; using Husky.Stdout; +using Husky.Utils; using Microsoft.Extensions.DependencyInjection; namespace Husky.Cli; @@ -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); } } diff --git a/src/Husky/Cli/InstallCommand.cs b/src/Husky/Cli/InstallCommand.cs index 39e402b..efc67fc 100644 --- a/src/Husky/Cli/InstallCommand.cs +++ b/src/Husky/Cli/InstallCommand.cs @@ -5,6 +5,7 @@ using CliFx.Infrastructure; using Husky.Services.Contracts; using Husky.Stdout; +using Husky.Utils; namespace Husky.Cli; @@ -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.")] @@ -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(); @@ -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); } diff --git a/src/Husky/Cli/SetCommand.cs b/src/Husky/Cli/SetCommand.cs index 5b2a2ec..d18db59 100644 --- a/src/Husky/Cli/SetCommand.cs +++ b/src/Husky/Cli/SetCommand.cs @@ -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); } // needed for linux diff --git a/src/Husky/Utils/ShellScriptLineEndings.cs b/src/Husky/Utils/ShellScriptLineEndings.cs new file mode 100644 index 0000000..df2eee5 --- /dev/null +++ b/src/Husky/Utils/ShellScriptLineEndings.cs @@ -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"); + } +} diff --git a/tests/HuskyTest/Cli/AddCommandTests.cs b/tests/HuskyTest/Cli/AddCommandTests.cs index 1c953cb..8bf7b48 100644 --- a/tests/HuskyTest/Cli/AddCommandTests.cs +++ b/tests/HuskyTest/Cli/AddCommandTests.cs @@ -58,5 +58,28 @@ public async Task Add_WhenHookNameContainsPathSeparator_ThrowException() await act.Should().ThrowAsync().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"); + } } } diff --git a/tests/HuskyTest/Cli/InstallCommandTests.cs b/tests/HuskyTest/Cli/InstallCommandTests.cs index 3442cae..5f3fdc5 100644 --- a/tests/HuskyTest/Cli/InstallCommandTests.cs +++ b/tests/HuskyTest/Cli/InstallCommandTests.cs @@ -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()); + } + [Fact] public async Task Install_WithParallelism_ShouldNotInterleaveGitCalls() { diff --git a/tests/HuskyTest/Utils/LineEndingTests.cs b/tests/HuskyTest/Utils/LineEndingTests.cs new file mode 100644 index 0000000..d303de9 --- /dev/null +++ b/tests/HuskyTest/Utils/LineEndingTests.cs @@ -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"); + } +}