The current project uses husky+ Commitlint + Prettier to do some formal formatting verification, but has never looked specifically at how it works. Recently I became curious about the Husky implementation and tried to implement a MyHusky tool myself.

Pre-knowledge point

Before you start, make a brief introduction to the knowledge points you may need.

How does NPM handle the scripts field

During project development, scripts are added to package.json to be used by the project. But there are some hidden features in Scripts.

Pre & Post Scripts

{
  "scripts": {
    "precompress": "{{ executes BEFORE the `compress` script }}"."compress": "{{ run command to compress files }}"."postcompress": "{{ executes AFTER `compress` script }}"}}Copy the code

If scripts are configured, the precompress, compress, and postcompress scripts are triggered in sequence when NPM run COMPRESS is triggered. The actual command is similar to NPM run precompress && NPM run compress && NPM run postcompress.

Lift Cycle Scripts

  • Except for the one abovePre & Post ScriptsAnd there are special life cycles that are triggered under specified circumstances, and these scripts are triggered in addition topre<event>.post<event>.<event>Outside.
  • prepare.prepublish.prepublishOnly.prepack.postpackThese are the built-in lifecycle hooks.

Take Prepare as an example. Prepare is a hook supported from [email protected]. It is triggered in either of the following processes:

  • Run before packaging
  • Run before the package is published
  • Run locally with no parametersnpm installwhen
  • runprepublishAfter, but runningprepublishOnlybefore
  • Note: If the package installed through Git containsprepareWhen the script,dependencies.devDependenciesWill be installed afterprepareThe script will start executing.

The function point

After the introduction of the knowledge points involved, the following function points need to be implemented are summarized following The example of Husky:

  • Command line invocation is supportedmyhusky.
    • Installation commands are supported:myhusky install [dir] (default: .husky).
    • Add supporthookCommand:myhusky add <file> [cmd]
  • installThe command is manually triggered. After the command is triggered, the logic is as follows:
    • Create the correspondinggit hooksPathFolder.
    • callgit config core.hooksPathSetting up the projectgit hooksPath [that is, Settingsgit hookDirectory where the script is stored. If this command is triggered, find the corresponding script in this directory.
  • addThe command takes two arguments, one to pass in the new file name and the other to trigger the corresponding command.
    • Such asmyhusky add .myhusky/commit-msg 'npx commitlint -e $1' To addcommit-msgHook, going oncommitTrigger during operationnpx commitlint -e $1Command.

test-first

Organize what needs to be tested in conjunction with function points

  • testinstallCommand.
    • After the command is executed successfully, the corresponding store is generated in the working directorygit hooksFolder;
    • .git/configA new configuration will be added inside the filehooksPath = .myhusky
  • testaddCommand.
    • togit hooksAdd a foldercommit-msg hookUsed in progressgit commitTrigger local inspection;
    • tocommit-msg hookThe written check content isexit 1, fail to quit;
    • forgit commitOperation, at this timegit commitThe operation fails to be submitted. The failure status code is 1.

A simple implementation of the test case

The Husky library is tested using shell scripts. The main flow is as follows:

  • npm run testActualizesh test/all.sh
  • all.shThe operations are: ExecuteThe build command, and is called again when the first script is executedfunctions.shfunction.shIt definessetupInitialize the function and implement it on its ownexpect.expect_hooksPath_to_beUtility functions used to do test acceptance.

We mainly use JEST for single test:

const cp = require('child_process')
const fs = require('fs')
const path = require('path')
const { install, add } = require('.. /lib/index')
const shell = require('shelljs')
const hooksDir = '.myhusky'
const removeFile = p= > shell.rm('-rf', p)
const git = args= > cp.spawnSync('git', args, { stdio: 'inherit' });
const reset = () = > {
  const cwd = process.cwd()
  const absPath = path.resolve(cwd, hooksDir)

  removeFile(absPath)
  git(['config'.'--unset'.'core.hooksPath'])
}

beforeAll(() = > {
  process.chdir(path.resolve(__dirname, '.. / '))})// The function that is executed for each single test
beforeEach(() = > {
  reset()
  install(hooksDir) / / install
})
Test the install command
test('install cmd'.async() = > {// Install logic is executed before each single test starts, so the current test case only needs to check the results
  const pwd = process.cwd()
  const huskyDirP = path.resolve(pwd, hooksDir)
  const hasHuskyDir = fs.existsSync(huskyDirP) && fs.statSync(huskyDirP).isDirectory()
  Git config file
  const gitConfigFile = fs.readFileSync(path.resolve(pwd, '.git/config'), 'utf-8')
  The git config file needs to contain the hooksPath configuration
  expect(gitConfigFile).toContain('hooksPath = .myhusky')
  // Expect to have the newly created Git hooks folder
  expect(hasHuskyDir).toBeTruthy()
})
// Test the add command
test('add cmd work'.async() = > {const hookFile = `${hooksDir}/commit-msg`
  const recordCurCommit = git(['rev-parse'.'--short'.'HEAD'])
  // Write the script content to commit- MSG, exit 1
  add(hookFile, 'exit 1')
  git(['add'.'. '])
  // Execute git commit to trigger the hook
  const std = git(['commit'.'-m'.'fail commit msg'])
  // Check the status code returned by the process
  expect(std.status).toBe(1)
  // Clear the side effects of the current test case
  git(['reset'.'--hard', recordCurCommit])
  removeFile(hookFile)
})
Copy the code

implementation

  • Configure package.json by adding the executable command myhusky and the corresponding command-triggered file./lib/bin.js

    {
      "name": "myhusky"."version": "1.0.0"."description": ""."main": "index.js"."scripts"
      "bin": {
        "myhusky": "./lib/bin.js"}}Copy the code
  • Code implementation:

    #! /usr/bin/env node
    // lib/bin.js
    const { install, add } = require('/')
    const [cmdType, ...args] = process.argv.slice(2);
    const ln = args.length;
    const cmds = { // Set of commands
    	install,
    	add
    }
    const cmd = cmds[cmdType]
    
    if(cmd) { cmd(... args) }Copy the code
    // lib/index.js
    const cp = require('child_process')
    const git = (args) = > cp.spawnSync('git', args, { stdio: 'inherit' });
    const fs = require('fs');
    const path = require('path');
    const cwd = process.cwd();
    
    // Initialize the installation command
    exports.install = function install (dir = '.myhusky') {
    	const huskyP = path.resolve(cwd, dir)
    	if(! fs.existsSync(path.join(cwd,'.git'))) {
    		throw new Error('cannot find .git directory');
    	}
    
    	// Create the hooksPath folder
    	fs.mkdirSync(huskyP)
    
    	// git config core.hooksPath dir Sets the directory where git hooks trigger files
    	git(['config'.'core.hooksPath', dir])
    }
    
    // Add hook command
    exports.add = function add (file, cmd) {
    	// Append command functions
    	// Add the 'commit- MSG' script to the git hooks directory
    	fs.writeFileSync(
    		path.join(cwd, file), ` #! /bin/sh${cmd}`, { mode: 0o0755})}Copy the code

test

  • Project NPM link, add myhusky package to NPM global

  • Enter a test project, NPM Link MyHusky, and install local Myhusky dependencies into the project

  • The test project installs commitLint and adds the COMMITlint.config.js configuration

    // commitlint.config.js
    module.exports = {
        rules: {
    		'body-leading-blank': [1.'always'].'body-max-line-length': [2.'always'.100].'footer-leading-blank': [1.'always'].'footer-max-line-length': [2.'always'.100].'header-max-length': [2.'always'.100].'subject-case': [
    			2.'never'['sentence-case'.'start-case'.'pascal-case'.'upper-case']],'subject-empty': [2.'never'].'subject-full-stop': [2.'never'.'. '].'type-case': [2.'always'.'lower-case'].'type-empty': [2.'never'].'type-enum': [
    			2.'always'[// Enumeration of supported types
    				'build'.'chore'.'ci'.'docs'.'feat'.'fix'.'perf'.'refactor'.'revert'.'style'.'test',],],}};Copy the code
  • NPX myhusky add. Myhusky /commit-msg ‘NPX commitlint-e $1’

What does $1 mean here? Git /COMMIT_EDITMSG. This file holds the current commit information. Pass this as a configuration item for commitlint-e. Commitlint reads commit information based on this configuration item and authenticates it according to the configured rules. If it fails, the commitlint exits and the commit fails.

  • At this point, the configuration to test is complete, add a file and commitgit commit -m 'try: add pkg', the interception check fails

The verification fails:

Verification passed:

.

conclusion

At this point, a simple Git hook tool is basically complete. By understanding husky’s internal implementation and implementing a replica example by hand, you can learn:

  • git hooksHow it works;
  • scriptsSome hidden hooks;
  • husky commitlintHow to cooperate with the realization;

The resources

  • Code address: github.com/manchixue/m…
  • scripts lift cycle hook
  • Git Hooks

Pay attention to our

Everyone’s support is our motivation to continue to move forward, come to pay attention to our deep trust in the front end team ~

In the meantime, if you are interested, please join us and send your resume to [email protected].