simple-git-hooks
使用
安装
bash
pnpm add simple-git-hooks -D
配置
在 package.json
中添加
bash
#* 这里配置通过commitlint校验commit message
#* 这里通过相对路径使用commitlint,pnpm中直接使用有问题
#* 且必须添加--edit选项来读取commit message
"simple-git-hooks": {
"commit-msg": "./node_modules/.bin/commitlint --edit"
}
注册
在 package.json
的 scripts
中添加命令
bash
"update-git-hooks": "simple-git-hooks",
然后执行该命令,其会把上一步配置的 hooks
同步到 git
中
bash
#* 将配置的hooks注册到git中
pnpm run update-git-hooks
注意:每次修改
package.json
中的simple-git-hooks
,都需要重新执行pnpm run update-git-hooks
来更新hooks
commitlint
bash
#* 安装依赖
pnpm add @commitlint/cli @commitlint/config-conventional -D
#* 添加配置文件
echo "module.exports = { extends: ['@commitlint/config-conventional'] };" > commitlint.config.js
团队合作
如果是多人合作的项目,可以在 package.json
的 scripts
中加一个 prepare
的 hook
使得其他人可自动注册已配置的 git hooks
bash
"prepare": "pnpm run update-git-hooks",
锦上添花
说是锦上添花,实则多此一举,哈哈。
如果日常使用的是 vscode
自带的 Source Control
功能,上面步骤已基本可以满足提交信息的 lint
功能。下面是通过交互式命令行来生成满足 lint
规则的配置。
可选的工具包有 @commitlint/prompt
和 commitizen
,个人认为 commitizen
更好用
- 配置
commitizen
安装bash在pnpm add commitizen cz-conventional-changelog -D
package.json
中配置bash"config": { "commitizen": { "path": "./node_modules/cz-conventional-changelog" } } #* scripts添加如下命令,即可通过 `pnpm run cz` 进入交互式终端 "cz": "cz",
- 配置
@commitlint/prompt
安装bash在pnpm add @commitlint/prompt -D
package.json
中配置bash#* scripts添加如下命令,即可通过 `pnpm run commit` 进入交互式终端 "commit": "commit",
解析
特性
- 无依赖
- 极简配置(
package.json
中的一个对象)- 轻量
源码
源码目录很简单,就几个关键文件。因为此包与工程化关联,这里详细聊一下工程化的点
工程化
pre
和post
钩子,参考
在package.json
文件中的scripts
配置的某个脚本doSomething
,可以额外配置其pre
钩子predoSomething
和post
钩子postdoSomething
, 表示在执行npm run doSomething
脚本时,会自动按照npm run predoSomething && npm run doSomething && npm run postdoSomething
顺序执行npm
默认有内置的脚本和内置的钩子,且如果使用其他包管理器像pnpm
,默认是不支持钩子的,需要额外配置,参考。 本项目的钩子有postinstall
,表示在其被安装后,要执行特定的逻辑,具体逻辑下文聊bin
字段
如果项目要支持CLI
方式使用,需要在package.json
中配置bin
字段来指向打包后(有些项目不进行打包)的(c|m)?js
文件。一般地,项目也会通过代码导入的方式使用,所以很多项目都是在cli
中导入其他文件进行逻辑处理以达到代码复用
本项目的配置为"bin": "./cli.js"
main, module, exports
main
和module
对应cjs
和esm
两种模块格式规范,趋势是逐渐偏向esm
,但又要兼容支持cjs
,故大多打包工具都支持两种模块格式。多目录、带类型的导出可以通过
exports
定义。例子- 该包就是工程化中与
git
工作流结合实现代码格式化,提交信息验证等功能的
simple-git-hooks.js
代码注释基本把逻辑和思想表达的清晰了,这里简要的增添下自己的理解。
js
const fs = require('fs')
const path = require('path')
//* 有效的git钩子
const VALID_GIT_HOOKS = [
'applypatch-msg',
'pre-applypatch',
'post-applypatch',
'pre-commit', //* 提交前,可进行代码lint
'pre-merge-commit',
'prepare-commit-msg',
'commit-msg', //* 提交信息,可规范提交信息
'post-commit',
'pre-rebase',
'post-checkout',
'post-merge',
'pre-push',
'pre-receive',
'update',
'proc-receive',
'post-receive',
'post-update',
'reference-transaction',
'push-to-checkout',
'pre-auto-gc',
'post-rewrite',
'sendemail-validate',
'fsmonitor-watchman',
'p4-changelist',
'p4-prepare-changelist',
'p4-post-changelist',
'p4-pre-submit',
'post-index-change',
]
const VALID_OPTIONS = ['preserveUnused']
/**
* Recursively gets the .git folder path from provided directory
* @param {string} directory
* @return {string | undefined} .git folder path or undefined if it was not found
*/
function getGitProjectRoot(directory=process.cwd()) {
//* 根据提供的目录,递归的获取.git目录
let start = directory
if (typeof start === 'string') {
//* 保证最后一个字符是分隔符
if (start[start.length - 1] !== path.sep) {
start += path.sep
}
//* 路径规范化
start = path.normalize(start)
//* 根据分隔符拆分,此时start是一个数组了
start = start.split(path.sep)
}
//* 空数组,即路径不对
if (!start.length) {
return undefined
}
//* 上一层目录
start.pop()
let dir = start.join(path.sep)
//* 上一次目录的git目录
let fullPath = path.join(dir, '.git')
if (fs.existsSync(fullPath)) {
if(!fs.lstatSync(fullPath).isDirectory()) {
//* 不是目录,则是文件
let content = fs.readFileSync(fullPath, { encoding: 'utf-8' })
//* 读取文件,获取gitdir
let match = /^gitdir: (.*)\s*$/.exec(content)
if (match) {
return path.normalize(match[1])
}
}
//* 是目录,直接返回
return path.normalize(fullPath)
} else {
//* 不存在,则继续递归往上
return getGitProjectRoot(start)
}
}
/**
* Transforms the <project>/node_modules/simple-git-hooks to <project>
* @param projectPath - path to the simple-git-hooks in node modules
* @return {string | undefined} - an absolute path to the project or undefined if projectPath is not in node_modules
*/
function getProjectRootDirectoryFromNodeModules(projectPath) {
//* 转换node_modules下的此包路径成项目路径
function _arraysAreEqual(a1, a2) {
return JSON.stringify(a1) === JSON.stringify(a2)
}
const projDir = projectPath.split(/[\\/]/) // <- would split both on '/' and '\'
const indexOfPnpmDir = projDir.indexOf('.pnpm')
//* pnpm支持
if (indexOfPnpmDir > -1) {
//* 目录是这样 node_modules/.pnpm/simple-git-hooks,所以要减1
return projDir.slice(0, indexOfPnpmDir - 1).join('/');
}
// A yarn2 STAB
//* yarn2 STAB 的特殊情况
if (projDir.includes('.yarn') && projDir.includes('unplugged')) {
return undefined
}
//* 如果长度大于2,且最后2项刚好是 node_modules/simple-git-hooks,则截取前面的内容即可
if (projDir.length > 2 &&
_arraysAreEqual(projDir.slice(projDir.length - 2, projDir.length), [
'node_modules',
'simple-git-hooks'
])) {
return projDir.slice(0, projDir.length - 2).join('/')
}
return undefined
}
/**
* Checks the 'simple-git-hooks' in dependencies of the project
* @param {string} projectRootPath
* @throws TypeError if packageJsonData not an object
* @return {Boolean}
*/
function checkSimpleGitHooksInDependencies(projectRootPath) {
//* 验证本包是否在项目的依赖里
if (typeof projectRootPath !== 'string') {
throw TypeError("Package json path is not a string!")
}
const {packageJsonContent} = _getPackageJson(projectRootPath)
// if simple-git-hooks in dependencies -> note user that he should remove move it to devDeps!
//* 建议放到devDeps中
if ('dependencies' in packageJsonContent && 'simple-git-hooks' in packageJsonContent.dependencies) {
console.log('[WARN] You should move simple-git-hooks to the devDependencies!')
return true // We only check that we are in the correct package, e.g not in a dependency of a dependency
}
if (!('devDependencies' in packageJsonContent)) {
return false
}
return 'simple-git-hooks' in packageJsonContent.devDependencies
}
/**
* Parses the config and sets git hooks
* @param {string} projectRootPath
* @param {string[]} [argv]
*/
function setHooksFromConfig(projectRootPath=process.cwd(), argv=process.argv) {
//* 解析配置并设置git钩子
const customConfigPath = _getCustomConfigPath(argv)
const config = _getConfig(projectRootPath, customConfigPath)
if (!config) {
throw('[ERROR] Config was not found! Please add `.simple-git-hooks.js` or `simple-git-hooks.js` or `.simple-git-hooks.json` or `simple-git-hooks.json` or `simple-git-hooks` entry in package.json.\r\nCheck README for details')
}
//* 保留未使用的钩子
const preserveUnused = Array.isArray(config.preserveUnused) ? config.preserveUnused : config.preserveUnused ? VALID_GIT_HOOKS: []
for (let hook of VALID_GIT_HOOKS) {
if (Object.prototype.hasOwnProperty.call(config, hook)) {
//* 已配置的钩子,则绑定到git上
_setHook(hook, config[hook], projectRootPath)
} else if (!preserveUnused.includes(hook)) {
//* 这里是 else if 哦,意思是:当前钩子未配置,且不在保留列表里,则删除
//* 可能是针对其他工具产生的钩子冲突或其他情况而做的
//* 比如其他工具已经配置了某个钩子,那么可以将该钩子配置到preserveUnused中
_removeHook(hook, projectRootPath)
}
}
}
/**
* Creates or replaces an existing executable script in .git/hooks/<hook> with provided command
* @param {string} hook
* @param {string} command
* @param {string} projectRoot
* @private
*/
function _setHook(hook, command, projectRoot=process.cwd()) {
//* 将配置的钩子同步到git中
const gitRoot = getGitProjectRoot(projectRoot)
if (!gitRoot) {
console.log('[INFO] No `.git` root folder found, skipping')
return
}
//* hashbang
const hookCommand = "#!/bin/sh\n" + command
const hookDirectory = gitRoot + '/hooks/'
const hookPath = path.normalize(hookDirectory + hook)
const normalizedHookDirectory = path.normalize(hookDirectory)
//* 确保hook目录存在
if (!fs.existsSync(normalizedHookDirectory)) {
fs.mkdirSync(normalizedHookDirectory)
}
//* 写入文件
fs.writeFileSync(hookPath, hookCommand)
//* 可执行权限
fs.chmodSync(hookPath, 0o0755)
console.log(`[INFO] Successfully set the ${hook} with command: ${command}`)
}
/**
* Deletes all git hooks
* @param {string} projectRoot
*/
function removeHooks(projectRoot=process.cwd()) {
//* 删除所有git钩子
for (let configEntry of VALID_GIT_HOOKS) {
_removeHook(configEntry, projectRoot)
}
}
/**
* Removes the pre-commit hook from .git/hooks
* @param {string} hook
* @param {string} projectRoot
* @private
*/
function _removeHook(hook, projectRoot=process.cwd()) {
const gitRoot = getGitProjectRoot(projectRoot)
const hookPath = path.normalize(gitRoot + '/hooks/' + hook)
if (fs.existsSync(hookPath)) {
//* 从文件系统中同步删除文件或符号链接
fs.unlinkSync(hookPath)
}
}
/** Reads package.json file, returns package.json content and path
* @param {string} projectPath - a path to the project, defaults to process.cwd
* @return {{packageJsonContent: any, packageJsonPath: string}}
* @throws TypeError if projectPath is not a string
* @throws Error if cant read package.json
* @private
*/
function _getPackageJson(projectPath = process.cwd()) {
//* 获取项目的package.json文件
if (typeof projectPath !== "string") {
throw TypeError("projectPath is not a string")
}
const targetPackageJson = path.normalize(projectPath + '/package.json')
if (!fs.statSync(targetPackageJson).isFile()) {
throw Error("Package.json doesn't exist")
}
const packageJsonDataRaw = fs.readFileSync(targetPackageJson)
return { packageJsonContent: JSON.parse(packageJsonDataRaw), packageJsonPath: targetPackageJson }
}
/**
* Takes the first argument from current process argv and returns it
* Returns empty string when argument wasn't passed
* @param {string[]} [argv]
* @returns {string}
*/
function _getCustomConfigPath(argv=[]) {
//* 获取自定义配置路径
//* We'll run as one of the following:
//* npx simple-git-hooks ./config.js
//* node path/to/simple-git-hooks/cli.js ./config.js
return argv[2] || ''
}
/**
* Gets user-set command either from sources
* First try to get command from .simple-pre-commit.json
* If not found -> try to get command from package.json
* @param {string} projectRootPath
* @param {string} [configFileName]
* @throws TypeError if projectRootPath is not string
* @return {{string: string} | undefined}
* @private
*/
function _getConfig(projectRootPath, configFileName='') {
//* 获取配置
if (typeof projectRootPath !== 'string') {
throw TypeError("Check project root path! Expected a string, but got " + typeof projectRootPath)
}
// every function here should accept projectRootPath as first argument and return object
const sources = [
() => _getConfigFromFile(projectRootPath, '.simple-git-hooks.cjs'),
() => _getConfigFromFile(projectRootPath, '.simple-git-hooks.js'),
() => _getConfigFromFile(projectRootPath, 'simple-git-hooks.cjs'),
() => _getConfigFromFile(projectRootPath, 'simple-git-hooks.js'),
() => _getConfigFromFile(projectRootPath, '.simple-git-hooks.json'),
() => _getConfigFromFile(projectRootPath, 'simple-git-hooks.json'),
() => _getConfigFromPackageJson(projectRootPath),
]
// if user pass his-own config path prepend custom path before the default ones
//* 如果给定了配置文件名,则放到第一项
if (configFileName) {
sources.unshift(() => _getConfigFromFile(projectRootPath, configFileName))
}
for (let executeSource of sources) {
//* 依次获取配置并验证
//* 存在配置文件且配置有效,则返回配置
let config = executeSource()
if (config && _validateHooks(config)) {
return config
}
else if (config && !_validateHooks(config)) {
throw('[ERROR] Config was not in correct format. Please check git hooks or options name')
}
}
return undefined
}
/**
* Gets current config from package.json[simple-pre-commit]
* @param {string} projectRootPath
* @throws TypeError if packageJsonPath is not a string
* @throws Error if package.json couldn't be read or was not validated
* @return {{string: string} | undefined}
*/
function _getConfigFromPackageJson(projectRootPath = process.cwd()) {
//* 从package.json中获取配置信息
const {packageJsonContent} = _getPackageJson(projectRootPath)
const config = packageJsonContent['simple-git-hooks'];
//* 如果是字符串,则是配置文件的路径,否则直接是配置对象
return typeof config === 'string' ? _getConfig(config) : packageJsonContent['simple-git-hooks']
}
/**
* Gets user-set config from file
* Since the file is not required in node.js projects it returns undefined if something is off
* @param {string} projectRootPath
* @param {string} fileName
* @return {{string: string} | undefined}
*/
function _getConfigFromFile(projectRootPath, fileName) {
//* 根据配置文件读取真正的配置信息
if (typeof projectRootPath !== "string") {
throw TypeError("projectRootPath is not a string")
}
if (typeof fileName !== "string") {
throw TypeError("fileName is not a string")
}
try {
const filePath = path.isAbsolute(fileName)
? fileName
: path.normalize(projectRootPath + '/' + fileName)
//* 排除本文件,哈哈哈(这里确实是有可能的,极端场景)
if (filePath === __filename) {
return undefined
}
//* .js | .cjs | .json
return require(filePath) // handle `.js` and `.json`
} catch (err) {
return undefined
}
}
/**
* Validates the config, checks that every git hook or option is named correctly
* @return {boolean}
* @param {{string: string}} config
*/
function _validateHooks(config) {
//* 验证钩子
for (let hookOrOption in config) {
//* 不是有效的钩子,且不是有效的额外配置
if (!VALID_GIT_HOOKS.includes(hookOrOption) && !VALID_OPTIONS.includes(hookOrOption)) {
return false
}
}
return true
}
module.exports = {
checkSimpleGitHooksInDependencies,
setHooksFromConfig,
getProjectRootDirectoryFromNodeModules,
getGitProjectRoot,
removeHooks,
}
钩子和 CLI
js
#!/usr/bin/env node
//* 安装完此包后会自动执行该文件,逻辑已在上面分析过
const {checkSimpleGitHooksInDependencies, getProjectRootDirectoryFromNodeModules, setHooksFromConfig} = require("./simple-git-hooks");
/**
* Creates the pre-commit from command in config by default
*/
function postinstall() {
let projectDirectory;
/* When script is run after install, the process.cwd() would be like <project_folder>/node_modules/simple-git-hooks
Here we try to get the original project directory by going upwards by 2 levels
If we were not able to get new directory we assume, we are already in the project root */
const parsedProjectDirectory = getProjectRootDirectoryFromNodeModules(process.cwd())
if (parsedProjectDirectory !== undefined) {
projectDirectory = parsedProjectDirectory
} else {
projectDirectory = process.cwd()
}
if (checkSimpleGitHooksInDependencies(projectDirectory)) {
try {
setHooksFromConfig(projectDirectory)
} catch (err) {
console.log('[ERROR] Was not able to set git hooks. Reason: ' + err)
}
}
}
postinstall()
js
#!/usr/bin/env node
//* 卸载完此包后会自动执行该文件
const {removeHooks} = require("./simple-git-hooks");
/**
* Removes the pre-commit from command in config by default
*/
function uninstall() {
console.log("[INFO] Removing git hooks from .git/hooks")
try {
removeHooks()
console.log("[INFO] Successfully removed all git hooks")
} catch (e) {
console.log("[INFO] Couldn't remove git hooks. Reason: " + e)
}
}
uninstall()
js
#!/usr/bin/env node
//* 执行 npx simple-git-hooks 就是运行的该文件
/**
* A CLI tool to change the git hooks to commands from config
*/
const {setHooksFromConfig} = require('./simple-git-hooks')
try {
setHooksFromConfig(process.cwd(), process.argv)
console.log('[INFO] Successfully set all git hooks')
} catch (e) {
console.log('[ERROR], Was not able to set git hooks. Error: ' + e)
}