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
18 changes: 16 additions & 2 deletions docs/guide/automate.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,29 @@ You can set the `HUSKY` environment variable to `0` in order to disable husky in
To manually attach husky to your project, add the below code to one of your projects (*.csproj/*.vbproj).

``` xml:no-line-numbers:no-v-pre
<Target Name="husky" BeforeTargets="Restore;CollectPackageReferences" Condition="'$(HUSKY)' != 0">
<Target Name="husky" BeforeTargets="Restore;CollectPackageReferences" Condition="'$(HUSKY)' != 0"
Inputs="../../.config/dotnet-tools.json"
Outputs="../../.husky/_/install.stamp">
<Exec Command="dotnet tool restore" StandardOutputImportance="Low" StandardErrorImportance="High"/>
<Exec Command="dotnet husky install" StandardOutputImportance="Low" StandardErrorImportance="High"
WorkingDirectory="../../" /> <!--Update this to the relative path to your project root dir -->
<Touch Files="../../.husky/_/install.stamp" AlwaysCreate="true" />
<ItemGroup>
<FileWrites Include="../../.husky/_/install.stamp" />
</ItemGroup>
</Target>
```

::: tip
Make sure to update the working directory depending on your folder structure it should be a relative path to your project root dir
Make sure to update the working directory and the `Inputs`/`Outputs`/`Touch`/`FileWrites` paths depending on your folder structure. All paths should be relative to your project and point to the repository root dir.
:::

::: tip
The target uses MSBuild incremental build support (`Inputs`/`Outputs`) to avoid re-running on every build. It only re-runs when `.config/dotnet-tools.json` changes (e.g. tool version update) or after `dotnet clean`. The stamp file is created inside `.husky/_/` which is already gitignored.
:::

::: tip
For solutions with multiple projects, consider placing the target in a `Directory.Build.targets` file at the repository root. When placed at the root, you can replace relative paths (e.g. `../../`) with `$(MSBuildThisFileDirectory)` which resolves to the directory containing the targets file.
:::

::: warning
Expand Down
17 changes: 17 additions & 0 deletions src/Husky/Cli/AttachCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -62,10 +62,15 @@ protected override async ValueTask SafeExecuteAsync(IConsole console)

private XElement GetTarget(string condition, string rootRelativePath)
{
var sentinelPath = Path.Combine(rootRelativePath, ".husky", "_", "install.stamp");
var inputPath = Path.Combine(rootRelativePath, ".config", "dotnet-tools.json");

var target = new XElement("Target");
target.SetAttributeValue("Name", "Husky");
target.SetAttributeValue("BeforeTargets", "Restore;CollectPackageReferences");
target.SetAttributeValue("Condition", condition);
target.SetAttributeValue("Inputs", inputPath);
target.SetAttributeValue("Outputs", sentinelPath);
var exec = new XElement("Exec");
exec.SetAttributeValue("Command", "dotnet tool restore");
exec.SetAttributeValue("StandardOutputImportance", "Low");
Expand All @@ -77,6 +82,18 @@ private XElement GetTarget(string condition, string rootRelativePath)
exec.SetAttributeValue("StandardErrorImportance", "High");
exec.SetAttributeValue("WorkingDirectory", rootRelativePath);
target.Add(exec);

var touch = new XElement("Touch");
touch.SetAttributeValue("Files", sentinelPath);
touch.SetAttributeValue("AlwaysCreate", "true");
target.Add(touch);

var itemGroup = new XElement("ItemGroup");
var fileWrites = new XElement("FileWrites");
fileWrites.SetAttributeValue("Include", sentinelPath);
itemGroup.Add(fileWrites);
target.Add(itemGroup);

return target;
}

Expand Down
49 changes: 24 additions & 25 deletions src/Husky/Cli/InstallCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,6 @@ protected override async ValueTask SafeExecuteAsync(IConsole console)
{
"Checking git path and working directory".LogVerbose();

// Ensure that we're inside a git repository
// If git command is not found, we should return exception.
// That's why ExitCode needs to be checked explicitly.
if ((await _git.ExecAsync("rev-parse")).ExitCode != 0) throw new CommandException(FailedMsg);

var cwd = Environment.CurrentDirectory;

// set default husky folder
Expand All @@ -56,6 +51,25 @@ protected override async ValueTask SafeExecuteAsync(IConsole console)
if (!path.StartsWith(cwd))
throw new CommandException($"{path}\nNot allowed (see {DOCS_URL})\n" + FailedMsg);

if (AllowParallelism)
{
RunUnderMutexControl(path, cwd);
}
else
{
await DoInstallAsync(path, cwd);
}

"Git hooks installed".Log(ConsoleColor.Green);
}

private async Task DoInstallAsync(string path, string cwd)
{
// Ensure that we're inside a git repository
// If git command is not found, we should return exception.
// That's why ExitCode needs to be checked explicitly.
if ((await _git.ExecAsync("rev-parse")).ExitCode != 0) throw new CommandException(FailedMsg);

// Check if we're in a submodule (issue #69)
if (await _git.IsSubmodule(cwd))
{
Expand All @@ -78,42 +92,27 @@ protected override async ValueTask SafeExecuteAsync(IConsole console)
throw new CommandException($".git can't be found (see {DOCS_URL})\n" + FailedMsg);
}

if (AllowParallelism)
{
// breaks if another instance already running
if (RunUnderMutexControl(path))
{
"Resource creation skipped due to multiple executions".LogVerbose();
return;
}
}
else
{
await CreateResourcesAsync(path);
}

"Git hooks installed".Log(ConsoleColor.Green);
await CreateResourcesAsync(path);
}

private bool RunUnderMutexControl(string path)
private void RunUnderMutexControl(string path, string cwd)
{
using var mutex = new Mutex(false, "Global\\" + appGuid);
if (!mutex.WaitOne(0, false))
{
// another instance is already running
return true;
"Resource creation skipped due to multiple executions".LogVerbose();
return;
}

try
{
CreateResources(path);
DoInstallAsync(path, cwd).GetAwaiter().GetResult();
}
finally
{
mutex.ReleaseMutex();
}

return false;
}

private void CreateResources(string path)
Expand Down
8 changes: 7 additions & 1 deletion src/Husky/Husky.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,15 @@
<Compile Remove="Utils\Dotnet\ParallelETWProvider.cs" />
<Compile Remove="Utils\Dotnet\Parallel.cs" />
</ItemGroup>
<Target Name="Husky" BeforeTargets="Restore;CollectPackageReferences" Condition="'$(HUSKY)' != 0 and '$(IsCrossTargetingBuild)' == 'true'">
<Target Name="Husky" BeforeTargets="Restore;CollectPackageReferences" Condition="'$(HUSKY)' != 0 and '$(IsCrossTargetingBuild)' == 'true'"
Inputs="../../.config/dotnet-tools.json"
Outputs="../../.husky/_/install.stamp">
<Exec Command="dotnet tool restore" StandardOutputImportance="Low" StandardErrorImportance="High" />
<Exec Command="dotnet husky install" StandardOutputImportance="Low" StandardErrorImportance="High" WorkingDirectory="..\.." />
<Touch Files="../../.husky/_/install.stamp" AlwaysCreate="true" />
<ItemGroup>
<FileWrites Include="../../.husky/_/install.stamp" />
</ItemGroup>
</Target>

<PropertyGroup Condition="'$(Configuration)' == 'IntegrationTest'">
Expand Down
33 changes: 26 additions & 7 deletions tests/HuskyTest/Cli/AttachCommandTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,17 @@
// Assert
_xmlIo.Received(1).Load(Arg.Any<string>());
_xmlIo.Received(1).Save(Arg.Any<string>(), Arg.Is(_xmlDoc));
_xmlDoc.Descendants("Target")
.FirstOrDefault(q => q.Attribute("Name")?.Value == "Husky")?.Descendants("Exec")
.Should().NotBeNull().And.HaveCount(2);

var huskyTarget = _xmlDoc.Descendants("Target")
.FirstOrDefault(q => q.Attribute("Name")?.Value == "Husky");
huskyTarget.Should().NotBeNull();
huskyTarget!.Descendants("Exec").Should().HaveCount(2);
huskyTarget.Attribute("Inputs").Should().NotBeNull();
huskyTarget.Attribute("Outputs").Should().NotBeNull();
huskyTarget.Descendants("Touch").Should().HaveCount(1);
huskyTarget.Descendants("Touch").First().Attribute("AlwaysCreate")?.Value.Should().Be("true");
huskyTarget.Descendants("ItemGroup").Descendants("FileWrites").Should().HaveCount(1);

_console.ReadOutputString().Trim().Should().Be("Husky dev-dependency successfully attached to this project.");
}

Expand Down Expand Up @@ -137,7 +145,7 @@
.SingleOrDefault(q => q.Attribute("Name")?.Value == "Husky")?.Descendants("Exec");

targetExecElements.Should().NotBeNull().And.HaveCount(2);
targetExecElements.SingleOrDefault(e => e.Attribute("Command").Value.Contains("dotnet husky install --ignore-submodule")).Should().NotBeNull();

Check warning on line 148 in tests/HuskyTest/Cli/AttachCommandTests.cs

View workflow job for this annotation

GitHub Actions / build

Dereference of a possibly null reference.

Check warning on line 148 in tests/HuskyTest/Cli/AttachCommandTests.cs

View workflow job for this annotation

GitHub Actions / build

Possible null reference argument for parameter 'source' in 'XElement? Enumerable.SingleOrDefault<XElement>(IEnumerable<XElement> source, Func<XElement, bool> predicate)'.

Check warning on line 148 in tests/HuskyTest/Cli/AttachCommandTests.cs

View workflow job for this annotation

GitHub Actions / build

Dereference of a possibly null reference.

Check warning on line 148 in tests/HuskyTest/Cli/AttachCommandTests.cs

View workflow job for this annotation

GitHub Actions / build

Possible null reference argument for parameter 'source' in 'XElement? Enumerable.SingleOrDefault<XElement>(IEnumerable<XElement> source, Func<XElement, bool> predicate)'.
_xmlIo.Received(1).Save(Arg.Any<string>(), Arg.Any<XElement>());
}

Expand All @@ -164,11 +172,22 @@
// Assert
_xmlIo.Received(1).Save(Arg.Any<string>(), Arg.Any<XElement>());

var exec = _xmlDoc.Descendants("Target")
.FirstOrDefault(q => q.Attribute("Name")?.Value == "Husky")?
.Descendants("Exec").FirstOrDefault(q => q.Attribute("Command")?.Value == "dotnet husky install");
var huskyTarget = _xmlDoc.Descendants("Target")
.FirstOrDefault(q => q.Attribute("Name")?.Value == "Husky");
huskyTarget.Should().NotBeNull();

var rootRelativePath = string.Join(Path.DirectorySeparatorChar, relativePath);

var exec = huskyTarget!.Descendants("Exec")
.FirstOrDefault(q => q.Attribute("Command")?.Value == "dotnet husky install");
exec.Should().NotBeNull();
exec!.Attribute("WorkingDirectory")?.Value.Should().Be(string.Join(Path.DirectorySeparatorChar, relativePath));
exec!.Attribute("WorkingDirectory")?.Value.Should().Be(rootRelativePath);

var expectedSentinel = Path.Combine(rootRelativePath, ".husky", "_", "install.stamp");
var expectedInput = Path.Combine(rootRelativePath, ".config", "dotnet-tools.json");
huskyTarget.Attribute("Inputs")?.Value.Should().Be(expectedInput);
huskyTarget.Attribute("Outputs")?.Value.Should().Be(expectedSentinel);
huskyTarget.Descendants("Touch").First().Attribute("Files")?.Value.Should().Be(expectedSentinel);
huskyTarget.Descendants("ItemGroup").Descendants("FileWrites").First().Attribute("Include")?.Value.Should().Be(expectedSentinel);
}
}
66 changes: 66 additions & 0 deletions tests/HuskyTest/Cli/InstallCommandTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,72 @@ public async Task Install_Succeed()
await command.ExecuteAsync(_console);
}

[Fact]
public async Task Install_WithParallelism_ShouldNotInterleaveGitCalls()
{
// Arrange
var configInProgress = false;
var interleaved = false;
var configEntered = new SemaphoreSlim(0, 1);
var configCanExit = new SemaphoreSlim(0, 1);
var now = DateTimeOffset.Now;

var cliWrap = Substitute.For<ICliWrap>();
var fileSystem = Substitute.For<IFileSystem>();
fileSystem.Directory.Exists(Path.Combine(Environment.CurrentDirectory, ".git")).Returns(true);

// Install A mocks: blocks inside git config to hold the mutex open
var gitA = Substitute.For<IGit>();
gitA.ExecAsync("rev-parse").Returns(Task.FromResult(new CommandResult(0, now, now)));
gitA.IsSubmodule(Arg.Any<string>()).Returns(Task.FromResult(false));
gitA.ExecAsync(Arg.Is<string>(s => s.StartsWith("config core.hooksPath")))
.Returns(async _ =>
{
configInProgress = true;
configEntered.Release();
await configCanExit.WaitAsync();
configInProgress = false;
return new CommandResult(0, now, now);
});
gitA.ExecBufferedAsync("config --local --list")
.Returns(new BufferedCommandResult(0, now, now, "", ""));

// Install B mocks: detect if reads run while A's config is in progress
var gitB = Substitute.For<IGit>();
gitB.ExecAsync("rev-parse").Returns(_ =>
{
if (configInProgress) interleaved = true;
return Task.FromResult(new CommandResult(0, now, now));
});
gitB.IsSubmodule(Arg.Any<string>()).Returns(_ =>
{
if (configInProgress) interleaved = true;
return Task.FromResult(false);
});
gitB.ExecAsync(Arg.Is<string>(s => s.StartsWith("config core.hooksPath")))
.Returns(Task.FromResult(new CommandResult(0, now, now)));
gitB.ExecBufferedAsync("config --local --list")
.Returns(new BufferedCommandResult(0, now, now, "", ""));

var commandA = new InstallCommand(gitA, cliWrap, fileSystem) { AllowParallelism = true };
var commandB = new InstallCommand(gitB, cliWrap, fileSystem) { AllowParallelism = true };
var consoleA = new FakeInMemoryConsole();
var consoleB = new FakeInMemoryConsole();

// Act: start A, wait for it to be inside git config, then start B
var taskA = Task.Run(() => commandA.ExecuteAsync(consoleA).AsTask());
await configEntered.WaitAsync();

var taskB = Task.Run(() => commandB.ExecuteAsync(consoleB).AsTask());
configCanExit.Release();
await Task.WhenAll(taskA, taskB);

// Assert
interleaved.Should().BeFalse(
"git read operations (rev-parse, IsSubmodule) should not run while another " +
"process is writing git config, as this causes 'Permission denied' errors");
}

[Fact(Skip = "Skipping this test in CICD, since it won't support it")]
public async Task Install_WithAllowParallelism_ParallelExecutionShouldAbortResourceCreation()
{
Expand Down
Loading