diff --git a/docs/guide/automate.md b/docs/guide/automate.md index 4b8cd2e..f61a05c 100644 --- a/docs/guide/automate.md +++ b/docs/guide/automate.md @@ -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 - + + + + + ``` ::: 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 diff --git a/src/Husky/Cli/AttachCommand.cs b/src/Husky/Cli/AttachCommand.cs index 2adbb41..1dc6723 100644 --- a/src/Husky/Cli/AttachCommand.cs +++ b/src/Husky/Cli/AttachCommand.cs @@ -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"); @@ -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; } diff --git a/src/Husky/Cli/InstallCommand.cs b/src/Husky/Cli/InstallCommand.cs index 83320d8..39e402b 100644 --- a/src/Husky/Cli/InstallCommand.cs +++ b/src/Husky/Cli/InstallCommand.cs @@ -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 @@ -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)) { @@ -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) diff --git a/src/Husky/Husky.csproj b/src/Husky/Husky.csproj index df083b5..bf976df 100644 --- a/src/Husky/Husky.csproj +++ b/src/Husky/Husky.csproj @@ -68,9 +68,15 @@ - + + + + + diff --git a/tests/HuskyTest/Cli/AttachCommandTests.cs b/tests/HuskyTest/Cli/AttachCommandTests.cs index 9d43ed8..b3f11d7 100644 --- a/tests/HuskyTest/Cli/AttachCommandTests.cs +++ b/tests/HuskyTest/Cli/AttachCommandTests.cs @@ -54,9 +54,17 @@ public async Task Attach_WhenParametersProvided_ShouldAddHuskyTargetElement() // Assert _xmlIo.Received(1).Load(Arg.Any()); _xmlIo.Received(1).Save(Arg.Any(), 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."); } @@ -164,11 +172,22 @@ public async Task Attach_WorkingDirectoryShouldBeRelativePathToProjectRoot(strin // Assert _xmlIo.Received(1).Save(Arg.Any(), Arg.Any()); - 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); } } diff --git a/tests/HuskyTest/Cli/InstallCommandTests.cs b/tests/HuskyTest/Cli/InstallCommandTests.cs index 7c8390a..3442cae 100644 --- a/tests/HuskyTest/Cli/InstallCommandTests.cs +++ b/tests/HuskyTest/Cli/InstallCommandTests.cs @@ -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(); + var fileSystem = Substitute.For(); + 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(); + gitA.ExecAsync("rev-parse").Returns(Task.FromResult(new CommandResult(0, now, now))); + gitA.IsSubmodule(Arg.Any()).Returns(Task.FromResult(false)); + gitA.ExecAsync(Arg.Is(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(); + gitB.ExecAsync("rev-parse").Returns(_ => + { + if (configInProgress) interleaved = true; + return Task.FromResult(new CommandResult(0, now, now)); + }); + gitB.IsSubmodule(Arg.Any()).Returns(_ => + { + if (configInProgress) interleaved = true; + return Task.FromResult(false); + }); + gitB.ExecAsync(Arg.Is(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() {