Skip to content

Commit f5212a9

Browse files
authored
repo: add commit hooks (#300)
* repo: add husky * repo: add pre-commit linting * repo: add commit-msg hook for commit prefixes * repo: add pre-commit linting, format only *.ts files * repo: add docs to commit-msg.js hook * commit-msg.js: add additional docs --------- Co-authored-by: Sebastian Alex <[email protected]>
1 parent d846974 commit f5212a9

File tree

5 files changed

+7613
-10197
lines changed

5 files changed

+7613
-10197
lines changed

.husky/commit-msg

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
#!/usr/bin/env sh
2+
message="$(cat "$1")"
3+
node .husky/scripts/commit-msg.js "$message"

.husky/pre-commit

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
#!/usr/bin/env sh
2+
3+
# Format modified files
4+
prettier $(git diff --cached --name-only --diff-filter=ACMR | grep -e ".ts$" | sed 's| |\\ |g') --write --ignore-unknown
5+
6+
# Update index with modified files
7+
git update-index --again
8+
9+
# Run lint on modified files
10+
eslint $(git diff --cached --name-only --diff-filter=ACMR | grep -e ".ts$" | sed 's| |\\ |g')
11+
12+
# Update index with modified files
13+
git update-index --again

.husky/scripts/commit-msg.js

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
/**
2+
* This script verifies if the commit message contains a valid prefix
3+
* if files have been modified in the following directories:
4+
* - packages/** - message must contain the package name prefix (e.g. "sdk-core"),
5+
* - tools/** - message must contain the tool name prefix (e.g. "cli"),
6+
* - scripts/** - message must contain the "scripts" prefix,
7+
* - smoketests/** - message must contain the "smoketests" prefix.
8+
*
9+
* The list can be extended by modifying VALID_PREFIXES constant.
10+
*/
11+
12+
const { execSync } = require('child_process');
13+
const path = require('path');
14+
const chalk = require('chalk');
15+
16+
/**
17+
* Returns `true` if any function of `fns` returns `true`.
18+
*/
19+
const oneOf =
20+
(...fns) =>
21+
(...args) =>
22+
fns.some((fn) => fn(...args));
23+
24+
const startsWith = (what) => (str) => str.startsWith(what);
25+
26+
/**
27+
* Returns n-th part from left of path.
28+
*
29+
* @example
30+
* console.log(nthPathPart(2)('a/b/c/d')) // 'c'
31+
*/
32+
const nthPathPart = (n) => (pathStr) => path.normalize(pathStr).split(path.sep)[n];
33+
34+
/**
35+
* Returns array as comma-separated string.
36+
*/
37+
const csv = (array) => (array.length ? array.join(', ') : '<none>');
38+
39+
/**
40+
* Case-insensitive string equality check.
41+
*/
42+
const ciEquals = (a, b) => a.localeCompare(b, undefined, { sensitivity: 'accent' }) === 0;
43+
44+
/**
45+
* Expected change prefixes, in form of `[predicate, getPrefix]` functions:
46+
* - `predicate`: `(modifiedFile: string) => boolean` - should return `true` if `modifiedFile` should have the prefix
47+
* - `getPrefix`: `(modifiedFile: string) => string` - should return prefix value
48+
*/
49+
const VALID_PREFIXES = [
50+
// packages/sdk-core/file.ts => sdk-core
51+
// tools/cli/file.ts => cli
52+
[oneOf(startsWith('packages/'), startsWith('tools/')), nthPathPart(1)],
53+
54+
// smoketests/dir/file.ts => smoketests
55+
// scripts/dir/file.ts => scripts
56+
[oneOf(startsWith('smoketests/'), startsWith('scripts/')), nthPathPart(0)],
57+
];
58+
59+
/**
60+
* Retrieves change prefix from file path, e.g. "sdk-core" for "packages/sdk-core/file.ts".
61+
*/
62+
function getChangePrefix(filePath) {
63+
for (const [test, prefix] of VALID_PREFIXES) {
64+
if (test(filePath)) {
65+
return prefix(filePath);
66+
}
67+
}
68+
}
69+
70+
/**
71+
* Returns unique values of `values`.
72+
*/
73+
function unique(...values) {
74+
return [...new Set(...values)];
75+
}
76+
77+
/**
78+
* Returns all expected prefixes for `modifiedFiles`.
79+
*/
80+
function getExpectedPrefixes(modifiedFiles) {
81+
return unique(modifiedFiles.split('\n').map(getChangePrefix)).filter((f) => !!f);
82+
}
83+
84+
/**
85+
* Returns all used prefixes in `message`.
86+
*/
87+
function getActualPrefixes(message) {
88+
if (!message.includes(':')) {
89+
return [[], message];
90+
}
91+
92+
const [prefix, rest] = message.split(':');
93+
if (!prefix) {
94+
return [[], message];
95+
}
96+
97+
return [prefix.split(', '), (rest && rest.trim()) || ''];
98+
}
99+
100+
/**
101+
* Returns all prefixes that are in `expected`, but not in `actual`.
102+
*/
103+
function getMissingPrefixes(expected, actual) {
104+
const missing = [];
105+
106+
for (const prefix of expected) {
107+
if (!actual.includes(prefix)) {
108+
missing.push(prefix);
109+
}
110+
}
111+
112+
return missing;
113+
}
114+
115+
/**
116+
* Returns normalized expected prefixes based on what is missing and what was provided.
117+
* Normalization changes the case to match expected.
118+
*/
119+
function normalizeExpectedPrefixes(expected, missing, actual) {
120+
const normalizedActual = actual.map((a) => expected.find((e) => ciEquals(a, e)) || a);
121+
const missingFromNormalized = missing.filter((m) => !normalizedActual.includes(m));
122+
return [...normalizedActual, ...missingFromNormalized];
123+
}
124+
125+
/**
126+
* Displays error banner in format of:
127+
* ```
128+
* ===============
129+
* ==== error ====
130+
* ===============
131+
* ```
132+
*/
133+
function banner(str) {
134+
const repeat = (n, what) => [...new Array(n)].map(() => what).join('');
135+
136+
console.error(chalk.red(repeat(str.length + 10, '=')));
137+
console.error(chalk.red(`${repeat(4, '=')} ${str.toUpperCase()} ${repeat(4, '=')}`));
138+
console.error(chalk.red(repeat(str.length + 10, '=')));
139+
}
140+
141+
const message = process.argv[2];
142+
const modifiedFiles = execSync('git diff --cached --name-only').toString('utf-8');
143+
const expected = getExpectedPrefixes(modifiedFiles);
144+
const [actual, rest] = getActualPrefixes(message);
145+
146+
if (!actual.length) {
147+
banner('Commit message is invalid - missing prefix');
148+
149+
console.error('To better describe commit messages, we require including prefixes based on file changes:');
150+
console.error('');
151+
console.error(chalk.gray('>'), 'prefix1, prefix2: brief description of change');
152+
console.error('');
153+
console.error('Suggested message:');
154+
console.error('');
155+
console.error(chalk.red(`- ${message}`));
156+
console.error(chalk.green(`+ ${expected.length ? csv(expected) : 'prefix'}: ${message}`));
157+
158+
process.exit(1);
159+
}
160+
161+
const missing = getMissingPrefixes(expected, actual);
162+
if (missing.length) {
163+
banner('Commit message is invalid - invalid prefixes');
164+
165+
console.error('Some prefixes are missing based on changed files:');
166+
console.error(chalk.red(`- ${csv(missing)}`));
167+
console.error('');
168+
console.error('Suggested message:');
169+
console.error(chalk.red(`- ${csv(actual)}: ${rest}`));
170+
console.error(chalk.green(`+ ${csv(normalizeExpectedPrefixes(expected, missing, actual))}: ${rest}`));
171+
172+
process.exit(1);
173+
}

0 commit comments

Comments
 (0)