Skip to content

Commit d21a311

Browse files
committed
fix: normalize generated hook line endings
1 parent 3dc77af commit d21a311

7 files changed

Lines changed: 120 additions & 37 deletions

File tree

src/Husky/Cli/AddCommand.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
using CliFx.Attributes;
33
using CliFx.Infrastructure;
44
using Husky.Stdout;
5+
using Husky.Utils;
56
using Microsoft.Extensions.DependencyInjection;
67

78
namespace Husky.Cli;
@@ -40,7 +41,9 @@ protected override async ValueTask SafeExecuteAsync(IConsole console)
4041
return;
4142
}
4243

43-
await _fileSystem.File.AppendAllTextAsync(hookPath, $"{Command}\n");
44+
var existingHookContent = await _fileSystem.File.ReadAllTextAsync(hookPath);
45+
var hookContent = ShellScriptLineEndings.Normalize($"{existingHookContent}{Command}\n");
46+
await _fileSystem.File.WriteAllTextAsync(hookPath, hookContent);
4447
$"added to '{hookPath}' hook".Log(ConsoleColor.Green);
4548
}
4649
}

src/Husky/Cli/InstallCommand.cs

Lines changed: 9 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using CliFx.Infrastructure;
66
using Husky.Services.Contracts;
77
using Husky.Stdout;
8+
using Husky.Utils;
89

910
namespace Husky.Cli;
1011

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

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

118-
private void CreateResources(string path)
119-
{
120-
$"Creating resources and configuration files in '{path}'".LogVerbose();
121-
122-
// Create .husky/_
123-
_fileSystem.Directory.CreateDirectory(Path.Combine(path, "_"));
124-
125-
// Create .husky/_/. ignore
126-
_fileSystem.File.WriteAllText(Path.Combine(path, "_/.gitignore"), "*");
127-
128-
// Copy husky.sh to .husky/_/husky.sh
129-
var husky_shPath = Path.Combine(path, "_", "husky.sh");
130-
{
131-
using var stream = Assembly.GetAssembly(typeof(Program))!.GetManifestResourceStream("Husky.templates.husky.sh")!;
132-
using var sr = new StreamReader(stream);
133-
var content = sr.ReadToEnd();
134-
_fileSystem.File.WriteAllText(husky_shPath, content);
135-
}
136-
137-
// here we have to run the `ConfigureGitAndFilePermission` synchronously because mutex will fail if thread changes
138-
ConfigureGitAndFilePermission(path, husky_shPath).GetAwaiter().GetResult();
139-
140-
// Created task-runner.json file
141-
// We don't want to override this file
142-
if (!_fileSystem.File.Exists(Path.Combine(path, "task-runner.json")))
143-
{
144-
using var stream = Assembly.GetAssembly(typeof(Program))!.GetManifestResourceStream("Husky.templates.task-runner.json")!;
145-
using var sr = new StreamReader(stream);
146-
var content = sr.ReadToEnd();
147-
_fileSystem.File.WriteAllText(Path.Combine(path, "task-runner.json"), content);
148-
}
149-
}
150-
151121
private async Task CreateResourcesAsync(string path)
152122
{
153123
$"Creating resources and configuration files asynchronously in '{path}'".LogVerbose();
@@ -158,12 +128,17 @@ private async Task CreateResourcesAsync(string path)
158128
// Create .husky/_/. ignore
159129
await _fileSystem.File.WriteAllTextAsync(Path.Combine(path, "_/.gitignore"), "*");
160130

131+
// Keep Husky hook scripts LF-only even when users have core.autocrlf enabled.
132+
var gitAttributesPath = Path.Combine(path, GIT_ATTRIBUTES_FILE_NAME);
133+
if (!_fileSystem.File.Exists(gitAttributesPath))
134+
await _fileSystem.File.WriteAllTextAsync(gitAttributesPath, GIT_ATTRIBUTES_CONTENT);
135+
161136
// Copy husky.sh to .husky/_/husky.sh
162137
var husky_shPath = Path.Combine(path, "_", "husky.sh");
163138
{
164139
await using var stream = Assembly.GetAssembly(typeof(Program))!.GetManifestResourceStream("Husky.templates.husky.sh")!;
165140
using var sr = new StreamReader(stream);
166-
var content = await sr.ReadToEndAsync();
141+
var content = ShellScriptLineEndings.Normalize(await sr.ReadToEndAsync());
167142
await _fileSystem.File.WriteAllTextAsync(husky_shPath, content);
168143
}
169144

src/Husky/Cli/SetCommand.cs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,9 @@ private async Task CreateHook(string huskyPath)
4141
{
4242
await using var stream = Assembly.GetAssembly(typeof(Program))!.GetManifestResourceStream("Husky.templates.hook")!;
4343
using var sr = new StreamReader(stream);
44-
var content = await sr.ReadToEndAsync();
45-
await _fileSystem.File.WriteAllTextAsync(hookPath, $"{content}\n{Command}\n");
44+
var content = ShellScriptLineEndings.Normalize(await sr.ReadToEndAsync());
45+
var hookContent = ShellScriptLineEndings.Normalize($"{content}\n{Command}\n");
46+
await _fileSystem.File.WriteAllTextAsync(hookPath, hookContent);
4647
}
4748

4849
// needed for linux
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
namespace Husky.Utils;
2+
3+
public static class ShellScriptLineEndings
4+
{
5+
public static string Normalize(string content)
6+
{
7+
return content.Replace("\r\n", "\n").Replace("\r", "\n");
8+
}
9+
}

tests/HuskyTest/Cli/AddCommandTests.cs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,5 +58,28 @@ public async Task Add_WhenHookNameContainsPathSeparator_ThrowException()
5858

5959
await act.Should().ThrowAsync<CommandException>().WithMessage($"hook name can not contain path separator");
6060
}
61+
62+
[Fact]
63+
public async Task Add_WhenHookExists_NormalizesLineEndingsAfterAppendingCommand()
64+
{
65+
// Arrange
66+
const string huskyPath = ".husky";
67+
const string hookName = "pre-commit";
68+
var hookPath = Path.Combine(huskyPath, hookName);
69+
var command = new AddCommand(_serviceProvider, _io) { Command = "echo first\r\necho second", HookName = hookName };
70+
71+
_git.GetHuskyPathAsync().Returns(Task.FromResult(huskyPath));
72+
_io.File.Exists(Path.Combine(huskyPath, "_", "husky.sh")).Returns(true);
73+
_io.File.Exists(hookPath).Returns(true);
74+
_io.File.ReadAllTextAsync(hookPath).Returns(Task.FromResult("#!/bin/sh\r\necho existing\r\n"));
75+
76+
// Act
77+
await command.ExecuteAsync(_console);
78+
79+
// Assert
80+
await _io.File.Received(1).WriteAllTextAsync(
81+
hookPath,
82+
"#!/bin/sh\necho existing\necho first\necho second\n");
83+
}
6184
}
6285
}

tests/HuskyTest/Cli/InstallCommandTests.cs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,48 @@ public async Task Install_Succeed()
135135
await command.ExecuteAsync(_console);
136136
}
137137

138+
[Fact]
139+
public async Task Install_CreatesGitAttributesInHuskyDirectory()
140+
{
141+
// Arrange
142+
var command = new InstallCommand(_git, _cliWrap, _fileSystem) { AllowParallelism = false };
143+
var now = DateTimeOffset.Now;
144+
_git.ExecAsync("rev-parse").Returns(Task.FromResult(new CommandResult(0, now, now)));
145+
_fileSystem.Directory.Exists(Path.Combine(Environment.CurrentDirectory, ".git")).Returns(true);
146+
_git.ExecAsync("config core.hooksPath .husky").Returns(Task.FromResult(new CommandResult(0, now, now)));
147+
_git.ExecBufferedAsync("config --local --list").Returns(new BufferedCommandResult(0, now, now, "", ""));
148+
149+
// Act
150+
await command.ExecuteAsync(_console);
151+
152+
// Assert
153+
await _fileSystem.File.Received(1).WriteAllTextAsync(
154+
Path.Combine(Environment.CurrentDirectory, ".husky", ".gitattributes"),
155+
"* text eol=lf\n");
156+
}
157+
158+
[Fact]
159+
public async Task Install_WhenGitAttributesAlreadyExists_DoesNotOverwriteIt()
160+
{
161+
// Arrange
162+
var command = new InstallCommand(_git, _cliWrap, _fileSystem) { AllowParallelism = false };
163+
var now = DateTimeOffset.Now;
164+
var gitAttributesPath = Path.Combine(Environment.CurrentDirectory, ".husky", ".gitattributes");
165+
_git.ExecAsync("rev-parse").Returns(Task.FromResult(new CommandResult(0, now, now)));
166+
_fileSystem.Directory.Exists(Path.Combine(Environment.CurrentDirectory, ".git")).Returns(true);
167+
_fileSystem.File.Exists(gitAttributesPath).Returns(true);
168+
_git.ExecAsync("config core.hooksPath .husky").Returns(Task.FromResult(new CommandResult(0, now, now)));
169+
_git.ExecBufferedAsync("config --local --list").Returns(new BufferedCommandResult(0, now, now, "", ""));
170+
171+
// Act
172+
await command.ExecuteAsync(_console);
173+
174+
// Assert
175+
await _fileSystem.File.DidNotReceive().WriteAllTextAsync(
176+
gitAttributesPath,
177+
Arg.Any<string>());
178+
}
179+
138180
[Fact]
139181
public async Task Install_WithParallelism_ShouldNotInterleaveGitCalls()
140182
{
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
using FluentAssertions;
2+
using Husky.Utils;
3+
using Xunit;
4+
5+
namespace HuskyTest.Utils;
6+
7+
public class LineEndingTests
8+
{
9+
[Fact]
10+
public void NormalizeShellScriptLineEndings_ShouldConvertCrLfToLf()
11+
{
12+
var content = "#!/bin/sh\r\necho husky\r\n";
13+
14+
var normalized = ShellScriptLineEndings.Normalize(content);
15+
16+
normalized.Should().Be("#!/bin/sh\necho husky\n");
17+
normalized.Should().NotContain("\r");
18+
}
19+
20+
[Fact]
21+
public void NormalizeShellScriptLineEndings_ShouldConvertStandaloneCrToLf()
22+
{
23+
var content = "#!/bin/sh\recho husky\r";
24+
25+
var normalized = ShellScriptLineEndings.Normalize(content);
26+
27+
normalized.Should().Be("#!/bin/sh\necho husky\n");
28+
normalized.Should().NotContain("\r");
29+
}
30+
}

0 commit comments

Comments
 (0)