Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
0b64ffb
FileConventions.Test: add WrapTest failing test
knocte May 13, 2026
865ce4d
FileConventions: fix WrapText multi-space case
webwarrior-ws May 14, 2026
00c768e
FileConventions: add RemoveAllWhitespace test
knocte May 13, 2026
7161883
FileConventions: implement RemoveAllWhitespace
knocte May 13, 2026
dacbcab
scripts,FileConventions(.Test): adopt SafeWrapText
knocte May 13, 2026
54b76e3
husky: add post-commit hook to wrap last commitmsg
knocte May 13, 2026
43b9281
scripts/wrapLatestCommitMsg: ignore warnings
knocte May 13, 2026
c91d90f
Add Directory.Build.targets for Husky.NET hooks
knocte May 13, 2026
93e3113
Dir.Build.targets: surface out possible errors
knocte May 13, 2026
fbbd4d1
Dir.Build.targets: run dnx husky from repo root
knocte May 13, 2026
926abec
Dir.Build.targets: swap husky.net with git config
knocte May 13, 2026
68341ab
Swap npm husky with raw git config hooks install
knocte May 13, 2026
fc13a1e
scripts/wrapLatestCommitMsg: fix $VAR expansion
knocte May 14, 2026
522abc1
.husky/post-commit: skip if cherry-pick
knocte May 14, 2026
9de9924
FileConventions.Test: add WrapTest failing test
webwarrior-ws May 14, 2026
7fceff1
FileConventions: fix WrapParagraph bullet lists
webwarrior-ws May 14, 2026
edecd54
FileConventions.Tests: add 2 more asterisks tests
knocte May 14, 2026
799c091
FileConventions.Tests: 1 more asterisk test
knocte May 14, 2026
54456ce
FileConventions: make last asterisk test pass
knocte May 14, 2026
9b3c42d
FileConventions.Test: new WrapTest failing test
webwarrior-ws May 14, 2026
94fe8ec
FileConventions: make last Wrap test pass
knocte May 14, 2026
be0c495
FileConventions: refactoring to fix some DRY
knocte May 14, 2026
ea9705a
FileConventions: numeric bullets failing test
knocte May 15, 2026
f0cbdf9
FileConventions: make last test pass
knocte May 15, 2026
caa2711
FileConventions: refactor to respect guidelines
knocte May 15, 2026
72fe294
FileConventions.Test: add a real failing test
knocte May 15, 2026
e0cae56
FileConventions: fix real-world wrapping
knocte May 15, 2026
e94492f
Change wrapLastCommMsg postCommit->commitMsg hook
knocte May 15, 2026
0efbb8e
commitlint/tests/plugins: new em-dash failing test
knocte May 15, 2026
5fc6aa2
commitlint,FileConv,scripts: make tests pass
knocte May 15, 2026
0176daf
FileConv.Test: new colonless tests
knocte May 16, 2026
5f28f3f
CI: add a "commitMsg vs PR" sync check job
knocte May 17, 2026
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
49 changes: 49 additions & 0 deletions .github/workflows/checkSingleCommitPR.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
name: Check single-commit PR

on:
pull_request:
types:
- opened
- edited

env:
DOTNET_VERSION: "10.0"
DOTNET_ROLL_FORWARD: Major
CHECKOUT_ACTION_VERSION: "4"
DOTNET_NOLOGO: 1

jobs:
check-single-commit-pr:
name: Check that single-commit PR matches commit message
runs-on: ubuntu-latest
container:
image: "ubuntu:26.04"
steps:
- name: Install required dependencies
run: |
apt update
apt install --yes sudo
sudo apt install --yes --no-install-recommends git
- name: Run actions/checkout
uses: nblockchain/conventions@master
with:
uses: actions/checkout@v${{ env.CHECKOUT_ACTION_VERSION }}
with: |
{
"fetch-depth": 0
}
- name: Run git+githubCI workaround
run: |
# workaround for https://github.com/actions/runner/issues/2033
git config --global --add safe.directory "$GITHUB_WORKSPACE"
- name: Setup .NET
run: |
apt install --yes --no-install-recommends ca-certificates
apt install --yes --no-install-recommends dotnet-sdk-${{ env.DOTNET_VERSION }}
- name: Check single commit matches PR title and description
env:
PR_COMMITS: ${{ github.event.pull_request.commits }}
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
PR_TITLE: ${{ github.event.pull_request.title }}
PR_DESCRIPTION: ${{ github.event.pull_request.body }}
run: dotnet fsi scripts/checkSingleCommitPR.fsx
4 changes: 4 additions & 0 deletions .husky/commit-msg
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
set -euo pipefail

dotnet fsi scripts/wrapLatestCommitMsg.fsx "$1"
14 changes: 14 additions & 0 deletions Directory.Build.targets
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<Project>
<Target Name="GitHooksPath" AfterTargets="Build"
Condition="'$(CI)' == ''">
<Exec Command="git config core.hooksPath .husky"
WorkingDirectory="$(MSBuildThisFileDirectory)"
ConsoleToMSBuild="true"
IgnoreExitCode="true">
<Output TaskParameter="ExitCode" PropertyName="_GitConfigExitCode" />
<Output TaskParameter="ConsoleOutput" ItemName="_GitConfigOutput" />
</Exec>
<Warning Text="git config core.hooksPath failed: @(_GitConfigOutput, '%0a')"
Condition="'$(_GitConfigExitCode)' != '0'" />
</Target>
</Project>
3 changes: 1 addition & 2 deletions ReadMe.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ This is a repository that contains several useful things that other [tarsgate](h
* [F# scripts compilation](scripts/compileFSharpScripts.fsx).
* [EOF without EOL detection](scripts/eofConvention.fsx).
* [Mixed line-endings detection](scripts/mixedLineEndings.fsx).
* [Auto-wrap the latest commit message](scripts/wrapLatestCommitMsg.fsx).
* [Auto-wrap commit message body and enforce title max length](scripts/wrapLatestCommitMsg.fsx).
* [Detect non-verbose flags (e.g. `dotnet build -c Debug` instead of `dotnet build --configuration Debug`) being used in scripts or YML CI files (there are exceptions, e.g. `env -S`)](scripts/nonVerboseFlagsInGitHubCIAndScripts.fsx).
* Use of unpinned versions:
* [Use of `-latest` suffix in `runs-on:` GitHubCI tags](scripts/unpinnedGitHubActionsImageVersions.fsx).
Expand All @@ -31,4 +31,3 @@ More things to come:
* Missing important triggers such as push or pull_request, workflow_dispatch, schedule.
* Branch filtering on push trigger (only acceptable one is '**', otherwise '*' doesn't match to branch names with slashes in them).
- GitHub comment auto-responder? E.g. to answer to comments that end with "not working" or "doesn't work" or "does not work", asking for more details.
- wrapLatestCommitMsg.fsx script to fail for obvious requirements that can't be automated (e.g. title max length)
7 changes: 7 additions & 0 deletions commitlint.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export default {
"proper-issue-refs": [RuleConfigSeverity.Error, "always"],
"too-many-spaces": [RuleConfigSeverity.Error, "always"],
"commit-hash-alone": [RuleConfigSeverity.Error, "always"],
"reject-em-dash": [RuleConfigSeverity.Error, "always"],
"title-uppercase": [RuleConfigSeverity.Error, "always"],

// disabled because most of the time it doesn't work, due to https://github.com/conventional-changelog/commitlint/issues/3404
Expand Down Expand Up @@ -89,6 +90,12 @@ export default {
return Plugins.commitHashAlone(rawStr);
},

"reject-em-dash": ({ raw }: { raw: any }) => {
const rawStr = extractStringFromCommitlintParam("raw", raw);

return Plugins.rejectEmDash(rawStr);
},

"empty-wip": ({ header }: { header: any }) => {
const headerStr = extractStringFromCommitlintParam(
"header",
Expand Down
10 changes: 10 additions & 0 deletions commitlint/plugins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -539,6 +539,16 @@ export abstract class Plugins {
];
}

public static rejectEmDash(rawStr: string) {
const offence = rawStr.includes("—");

return [
!offence,
`Please replace em-dashes (—) with a normal dash (-) if they are meant as bullet points or word-unions, or use parentheses if they are meant as a real em-dash.` +
Helpers.errMessageSuffix,
];
}

public static trailingWhitespace(rawStr: string) {
let offence = false;

Expand Down
45 changes: 45 additions & 0 deletions commitlint/tests/plugins.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -786,6 +786,51 @@ http://foo.bar/baz`;
expect(output1.toString().includes("EOL")).toBe(true);
});

test("reject-em-dash1", () => {
const commitMsgWithEmDashAsBullet = `foo: this is only a title

This is a bullet list of things:
— Foo.
— Bar.`;

const rejectEmDash1 = runCommitLintOnMsg(commitMsgWithEmDashAsBullet);
expect(rejectEmDash1.status).not.toBe(0);

const commitMsgWithEmDashAsWordUnion = `foo: this is only a title

Foo — bar baz.`;

const rejectEmDash1Prime = runCommitLintOnMsg(
commitMsgWithEmDashAsWordUnion
);
expect(rejectEmDash1Prime.status).not.toBe(0);

const commitMsgWithEmDashInTitle = `foo: this is — only a title

Bla blah bla.`;

const rejectEmDash1DoublePrime = runCommitLintOnMsg(
commitMsgWithEmDashInTitle
);
expect(rejectEmDash1DoublePrime.status).not.toBe(0);
});

test("reject-em-dash2", () => {
const commitMsgWithoutAnyDash = `foo: this is only a title

Bla blah bla.`;

const rejectEmDash2 = runCommitLintOnMsg(commitMsgWithoutAnyDash);
expect(rejectEmDash2.status).toBe(0);

const commitMsgWithNormalDash = `foo: this is only a title

Foo - bar baz.`;

const rejectEmDash2Prime = runCommitLintOnMsg(commitMsgWithNormalDash);
expect(rejectEmDash2Prime.status).toBe(0);
});

test("footer-refs-validity6", () => {
const commitMsgWithUrlContainingAnchor = `foo: blah blah

Expand Down
5 changes: 2 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,12 @@
"devDependencies": {
"prettier": "2.8.3",
"@types/node": "^25.2.0",
"vitest": "^1.3.1",
"husky": "^9.0.0"
"vitest": "^1.3.1"
},
"scripts": {
"format": "(npx --no-install prettier --version || (echo '\nPlease install `prettier` via `npm install` first; if this problem persists, try `npm rebuild && npm install`' >&2 && exit 1)) && npx --no-install prettier --quote-props=consistent --write './**/*.{yml,ts}'",
"format:check": "(npx --no-install prettier --version || (echo '\nPlease install `prettier` via `npm install` first; if this problem persists, try `npm rebuild && npm install`' >&2 && exit 1)) && npx --no-install prettier --quote-props=consistent --check './**/*.{yml,ts}'",
"test": "vitest --globals=true",
"prepare": "husky"
"prepare": "npx tsx scripts/git-hooks-setup.ts"
}
}
123 changes: 123 additions & 0 deletions scripts/checkSingleCommitPR.fsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
#!/usr/bin/env -S dotnet fsi

open System

#r "nuget: Fsdk, Version=0.6.1--date20260403-0728.git-c9a0eae"
#r "nuget: Mono.Unix, Version=7.1.0-final.1.21458.1"
#r "nuget: YamlDotNet, Version=16.1.3"

#load "../src/FileConventions/Library.fs"

open Fsdk
open Fsdk.Process

let prTitle =
Environment.GetEnvironmentVariable "PR_TITLE"
|> Option.ofObj
|> Option.defaultValue String.Empty

let prDescription =
Environment.GetEnvironmentVariable "PR_DESCRIPTION"
|> Option.ofObj
|> Option.defaultValue String.Empty

let numCommitsStr = Environment.GetEnvironmentVariable "PR_COMMITS"
let commitSha = Environment.GetEnvironmentVariable "PR_HEAD_SHA"

if String.IsNullOrEmpty numCommitsStr then
Console.Error.WriteLine(
"Error: PR_COMMITS environment variable is not set. This script is meant to be run only by GitHubActions triggers, not locally"
)

Environment.Exit 1

if String.IsNullOrEmpty commitSha then
Console.Error.WriteLine(
"Error: PR_HEAD_SHA environment variable is not set."
)

Environment.Exit 1

let numCommits = int numCommitsStr

if numCommits <> 1 then
Console.WriteLine(
sprintf "PR has %i commits. Skipping single-commit check." numCommits
)

Environment.Exit 0

let commitMessage =
Process
.Execute(
{
Command = "git"
Arguments = sprintf "show --format=%%B --no-patch %s" commitSha
},
Echo.Off
)
.UnwrapDefault()
.Trim()

let commitLines = commitMessage.Split([| '\n' |], StringSplitOptions.None)

let commitTitle = commitLines.[0].Trim()

let commitDescription =
let bodyLineStartsAt = 2
let hasBody = (commitLines.Length > bodyLineStartsAt)

if hasBody then
String
.Join(Environment.NewLine, commitLines |> Seq.skip bodyLineStartsAt)
.Trim()
else
String.Empty

let normalizedCommitTitle = FileConventions.RemoveAllWhitespace commitTitle
let normalizedPrTitle = FileConventions.RemoveAllWhitespace prTitle
let titleMatches = normalizedCommitTitle = normalizedPrTitle

let normalizedCommitDesc = FileConventions.RemoveAllWhitespace commitDescription
let normalizedPrDesc = FileConventions.RemoveAllWhitespace prDescription
let descMatches = normalizedCommitDesc = normalizedPrDesc

if not titleMatches || not descMatches then
Console.Error.WriteLine(
"Error: For single-commit PRs, the commit message must match the PR title and description."
)

Console.Error.WriteLine(String.Empty)

if not titleMatches then
Console.Error.WriteLine("Title mismatch:")

Console.Error.WriteLine(
sprintf " Commit msg title (normalized): %s" normalizedCommitTitle
)

Console.Error.WriteLine(
sprintf " PR title (normalized): %s" normalizedPrTitle
)

if not descMatches then
Console.Error.WriteLine("Description mismatch:")

Console.Error.WriteLine(
sprintf " Commit msg body (normalized): %s" normalizedCommitDesc
)

Console.Error.WriteLine(
sprintf " PR description (normalized): %s" normalizedPrDesc
)

Console.Error.WriteLine(String.Empty)

Console.Error.WriteLine(
"Please sync the PR title and description with the commit message."
)

Environment.Exit 1
else
Console.WriteLine("Commit message matches PR title and description.")
Environment.Exit 0
5 changes: 5 additions & 0 deletions scripts/git-hooks-setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { execSync } from "node:child_process";

if (!process.env.CI) {
execSync("git config core.hooksPath .husky", { stdio: "inherit" });
}
Loading
Loading