Skip to content

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.jsonscripts 中添加命令

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.jsonscripts 中加一个 preparehook 使得其他人可自动注册已配置的 git hooks

bash
"prepare": "pnpm run update-git-hooks",

锦上添花

说是锦上添花,实则多此一举,哈哈。
如果日常使用的是 vscode 自带的 Source Control 功能,上面步骤已基本可以满足提交信息的 lint 功能。下面是通过交互式命令行来生成满足 lint 规则的配置。
可选的工具包有 @commitlint/promptcommitizen,个人认为 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 中的一个对象)
  • 轻量

源码

源码目录很简单,就几个关键文件。因为此包与工程化关联,这里详细聊一下工程化的点

工程化

  1. prepost 钩子,参考
    package.json 文件中的 scripts 配置的某个脚本 doSomething,可以额外配置其 pre 钩子 predoSomethingpost 钩子 postdoSomething, 表示在执行 npm run doSomething 脚本时,会自动按照 npm run predoSomething && npm run doSomething && npm run postdoSomething 顺序执行

    npm 默认有内置的脚本和内置的钩子,且如果使用其他包管理器像 pnpm,默认是不支持钩子的,需要额外配置,参考。 本项目的钩子有 postinstall,表示在其被安装后,要执行特定的逻辑,具体逻辑下文聊

  2. bin 字段
    如果项目要支持 CLI 方式使用,需要在 package.json 中配置 bin 字段来指向打包后(有些项目不进行打包)的 (c|m)?js 文件。一般地,项目也会通过代码导入的方式使用,所以很多项目都是在 cli 中导入其他文件进行逻辑处理以达到代码复用
    本项目的配置为 "bin": "./cli.js"
  3. main, module, exports
    mainmodule 对应 cjsesm 两种模块格式规范,趋势是逐渐偏向 esm,但又要兼容支持 cjs,故大多打包工具都支持两种模块格式。

    多目录、带类型的导出可以通过 exports 定义。例子

  4. 该包就是工程化中与 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)
}