Setting up pre-commit hooks for a .NET project

Formatter: dotnet-format

Installation: https://github.com/dotnet/format

At the moment, dotnet-format is going to be a part of the next .NET SDK 6. The README suggests installing dotnet-format as a global tool, but I wanted it to be install as a dependecies like node_modules. Therefore, I had to create a manifest for toolings first (think of it as package.json) before installing dotnet-format.

# Create a maninfest for local tools
dotnet new tool-manifest

# Install dotnet-format for this project only
dotnet tool install dotnet-format

dotnet-format is also customizable via .editorconfig, with a few options available. I set it up to match my current Visual Studio Code format

root = true

[*.{cs,vb}]
indent_style = space
indent_size = 2
end_of_line = crlf

Then, I add a command to rebuild local tools if someone else want to contribute

"setup": "dotnet tool restore",

Finally, the script for formatting. Note that I also set the verbosity level (-v) to quite (q).

"format": "dotnet format -v q",

Linter: StyleCop Analyzers

Installation: https://github.com/DotNetAnalyzers/StyleCopAnalyzers

StyleCop Analyzers is a really popular tool for linting C# project. It's installed as a nuget and run as the same time as the build command. Remember to install the correct version for your C# (below table is from their repo).

C# versionStyleCop.Analyzers versionVisual Studio version
1.0 - 6.0v1.0.2 or higherVS2015+
7.0 - 7.3v1.1.0-beta or higherVS2017+
8.0v1.2.0-beta or higherVS2019

Because StyleCop Analyzers run on build command, the build script can be used for linting

"lint": "dotnet build"

One final touch, StyleCop Analyzers treats lints as warnings. It is recommended to set these lints as error

<PropertyGroup>
  <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
  <!-- Other properties... -->
</PropertyGroup>

Seem like StyleCop Analyzers doesn't like my half C#, half JavaScript conventions.

# More errors hidden

\paper-csharp\modules\file_parser\TextFile.cs(15,12): error SA1206: The 'public' modifier should appear before 'static' [\paper-csharp\paper-csharp.csproj]
\paper-csharp\modules\file_parser\TextFile.cs(31,10): error SA1513: Closing brace should be followed by blank line [C:\Users\fearn\Despaper-csharp\paper-csharp.csproj]
\paper-csharp\Program.cs(1,1): error SA1633: The file header is missing or not located at the top of the file. [\paper-csharp\paper-csharp.csproj]
\paper-csharp\modules\cli\Generator.cs(115,74): error SA1101: Prefix local calls with this [\paper-csharp\paper-csharp.csproj]
\paper-csharp\modules\cli\Generator.cs(117,34): error SA1101: Prefix local calls with this [\paper-csharp\paper-csharp.csproj]

0 Warning(s)
136 Error(s)

Editor integration

Visual Studio Code has some really nice feature to sync your workspace everywhere. The first one is launch.json use to run with build command. I set up some default arguments to pass in the ssg for testing (the option configurations.args).

{
  "version": "0.2.0",
  "configurations": [
    {
      // Use IntelliSense to find out which attributes exist for C# debugging
      // Use hover for the description of the existing attributes
      // For further information visit https://github.com/OmniSharp/omnisharp-vscode/blob/master/debugger-launchjson.md
      "name": ".NET Core Launch (console)",
      "type": "coreclr",
      "request": "launch",
      "preLaunchTask": "build",
      // If you have changed target frameworks, make sure to update the program path.
      "program": "${workspaceFolder}/bin/Debug/net5.0/paper-csharp.dll",
      "args": ["-i", "sample"],
      "cwd": "${workspaceFolder}",
      // For more information about the 'console' field, see https://aka.ms/VSCode-CS-LaunchJson-Console
      "console": "internalConsole",
      "stopAtEntry": false
    },
    {
      "name": ".NET Core Attach",
      "type": "coreclr",
      "request": "attach"
    }
  ]
}

The second file is settings.json. This acts as a editor settings. Settings from Preferences: User Settings (search using Ctrl + Shift + P) can be defined here. In my case, I want the formatter to automatically run on save

{
  "editor.formatOnSave": true,
}

Last one is extension.json. I didn't do it here, but I set it up on another web project. It's a way to hint other developers about extensions that make development faster. Just need to put in the IDs of those extensions.

{
  "recommendations": [
    "eamodio.gitlens",
    "dsznajder.es7-react-js-snippets",
    "esbenp.prettier-vscode"
  ]
}

Pre-commit hook: husky & lint-staged

Husky is a tool to set up scripts to run before changes are commited.

lint-staged runs your script on all staged files and re-stage those changes. For example, lint-staged re-stage your changes after your formatters make additional formatting.

# Install husky as a dependency
$ npm install husky -D

# Add an additional script "prepare" inside package.json
$ npm set-script prepare "husky install"

# Create a husky config file
$ npx husky add .husky/pre-commit "npm test"

A new directory .husky should appear in your project. Inside, there's a pre-commit file. This is basically a bash file, bash commands can be added in here. Be creative.

#!/bin/sh
. "$(dirname "$0")/_/husky.sh"

echo '🐶 Checking your commit...'

# echo an error message on fail
npm run pre-commit || echo '❌ Build fail. Fix errors showed above'

Now add a script pre-commit inside package.json. What my pre-commit do is running both the formatter and the linter. If there's an error (not warnings) in either script, husky'll exit and the code need to be fixed before commiting again.

"pre-commit": "npx lint-staged --relative && npm run lint"

The final step is specifying a script to run on staged C# files only. A flag --include is passed because lint-staged pass a list of staged files to the script, and we only want to format those staged files, not all our files.

"lint-staged": {
  "*.cs": "dotnet format --include"
},

The final package.json will look similar to this

{
  "scripts": {
    "setup": "dotnet tool restore",
    "predev": "dotnet run -- -i sample -o dist",
    "dev": "vite dist",
    "build": "dotnet run -- -i sample -o build",
    "format": "dotnet format -v q",
    "lint": "dotnet build",
    "prepare": "husky install",
    "pre-commit": "npx lint-staged --relative && npm run lint"
  },
  "lint-staged": {
    "*.cs": "dotnet format --include"
  }
}

Ending note

Finally I can check off learning all these pre-commit setup. The other is running tests.

These setup was interesting but never enough for me to dig in. But after learning about them, it's really easy to set up another one, and save so much time doing code review. Also a really great way to learn about conventions and best practices. Now, I'm off to fix those 136 linting errors. PR welcome...

Additional Resources