diff --git a/README.md b/README.md index 009ae82..c3557f2 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ > Recommended to install as a dev dependency ```bash -npm install --save-dev fist-bump +npm install --save-dev fistbump ``` ## usage @@ -31,11 +31,21 @@ npx fistbump # the updated commit message => '[1.1.0] feature: added new feature' ``` -*good to know:* `fist-bump` will skip any commits that have '[skip]' and '[wip]' or an existing version tag in the commit message. +You can also run `fistbump` automatically after every commit by installing it as a git hook: + +```bash +# install fistbump as a git hook (recommended) +npx fistbump --hook + +# uninstall fistbump as a git hook +npx fistbump --unhook +``` + +> **good to know**: `fistbump` will skip any commits that have '[skip]' and '[wip]' or an existing version tag in the commit message. ## configuration -To customize this `fist-bump`, all you have to do is add a `fistbump` property to your `package.json` file. +To customize this `fistbump`, all you have to do is add a `fistbump` property to your `package.json` file. ```json { diff --git a/scripts/bump.sh b/scripts/bump.sh deleted file mode 100755 index b32b283..0000000 --- a/scripts/bump.sh +++ /dev/null @@ -1,69 +0,0 @@ -#!/bin/sh - -# get latest commit message -commit_msg=$(git log -1 --pretty=%B) -echo "[fist-bump] commit message: $commit_msg" - -# exit with success if the commit message is empty -if [ -z "$commit_msg" ]; then - echo "[fist-bump] no commit message found." - exit 0 -fi - -# exit with success if the commit message is a merge -if echo "$commit_msg" | grep -qE '^Merge'; then - echo "[fist-bump] bump not needed (merge commit)" - exit 0 -fi - -# exit with success if the commit message has already been bumped -# by checking for the presence of a version number in square brackets -if echo "$commit_msg" | grep -qE '\[[0-9]+\.[0-9]+\.[0-9]+\]'; then - echo "[fist-bump] bump not needed (already bumped)" - exit 0 -fi - -# determine the version type -if echo "$commit_msg" | grep -qE '(\[)?\s*(feature|config)\s*(\])?\s*:'; then - version_type="minor" -elif echo "$commit_msg" | grep -qE '(\[)?\s*(patch|fix)\s*(\])?\s*:'; then - version_type="patch" -else - echo "[fist-bump] bump not needed (no bump type)" - exit 0 -fi - -# update version with npm or pnpm depending on which is installed -if command -v pnpm >/dev/null 2>&1; then - pnpm version $version_type --no-git-tag-version -elif command -v npm >/dev/null 2>&1; then - npm version $version_type --no-git-tag-version -else - echo "[fist-bump] no package manager found." - exit 1 -fi - -# get the new version number -version=$(node -p "require('./package.json').version") - -# stage package.json -git add package.json - -# stage pnpm-lock.yaml or package-lock.json if they exist -if [ -f pnpm-lock.yaml ]; then - git add pnpm-lock.yaml -elif [ -f package-lock.json ]; then - git add package-lock.json -fi - -# add [$version] to the beginning of the commit message -new_commit_msg="[$version] $commit_msg" - -# ammend changes to the latest commit -# note: `--no-verify` is used to skip the pre-commit hook which lints and tests the code -# note: `-q` is used to suppress the output of the command -git commit --amend --no-edit --no-verify -q -m "$new_commit_msg" - -# exit with success status -echo "[fist-bump] version bump to $version_type [$version]" -exit 0 diff --git a/scripts/hooks/post-commit.sh b/scripts/hooks/post-commit.sh new file mode 100644 index 0000000..3c6e322 --- /dev/null +++ b/scripts/hooks/post-commit.sh @@ -0,0 +1,38 @@ +#!/bin/sh + +# post-commit.sh +# +# The purpose of this script is to add `fist-bump` to git hooks. This ensures that +# the version number is bumped before every commit automatically instead of having +# to (remember) to do it manually every time. +# +# how does it work? +# +# The script adds `fist-bump` to the `post-commit` git hook. However, in order to +# avoid an infinite loop, the script sets checks for an env var called `FIST_BUMP` +# (which is set after the first `post-commit` hook call) prior to running +# `fist-bump`. If the env var is set, `fist-bump` will not run. + +# get root directory of project +root_dir=$(git rev-parse --show-toplevel) + +# function to remove the flag environment variable +cleanup() { + unset FIST_BUMP +} + +# set a trap to always cleanup on exit +trap cleanup EXIT + +# Only run if the environment variable is not set +if [ "$FIST_BUMP" != "1" ]; then + + # run fist-bump + ./lib/index.js + + # set environment variable + export FIST_BUMP=1 + + # amend the commit with the changes made by your script + git commit --amend --no-edit +fi diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000..11e5884 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,42 @@ +#!/bin/sh + +# install.sh +# +# The purpose of this script is to add `post-commit.sh` to the end of the +# post-commit git hook (.git/hooks/post-commit). This ensures that the +# version it can run after every commit. This ensures that the version +# number is bumped before every commit automatically instead of having +# to (remember) to do it manually every time. + +DELIMITER_START="# BEGIN FISTBUMP" +DELIMITER_END="# END FISTBUMP" + +# get root directory of project +root_dir=$(git rev-parse --show-toplevel) + +# create .git/hooks/post-commit if it doesn't exist +if [ ! -f "$root_dir/.git/hooks/post-commit" ]; then + touch "$root_dir/.git/hooks/post-commit" + echo "[fist-bump] .git/hooks/post-commit created" +fi + +# get contents of post-commit.sh (minus the shebang) +post_commit=$(tail -n +2 "$root_dir/scripts/hooks/post-commit.sh") + +# wrap contents in a delimiter comment (BEGIN/END FISTBUMP) +content="$DELIMITER_START\n$post_commit\n$DELIMITER_END" + +# exit with success if hook has a delimiter comment +if grep -q "$DELIMITER_START" "$root_dir/.git/hooks/post-commit"; then + echo "[fist-bump] fist-bump already installed to .git/hooks/post-commit" + exit 0 +fi + +# add contents of post-commit.sh to the end of .git/hooks/post-commit +# overwrite .git/hooks/post-commit with extracted content +if echo "$content" >> "$root_dir/.git/hooks/post-commit"; then + echo "[fist-bump] fist-bump added to .git/hooks/post-commit" +else + echo "[fist-bump] Error: Failed to write to .git/hooks/post-commit (use sudo)" + exit 1 +fi diff --git a/scripts/uninstall.sh b/scripts/uninstall.sh new file mode 100755 index 0000000..158a49e --- /dev/null +++ b/scripts/uninstall.sh @@ -0,0 +1,35 @@ +#!/bin/sh + +# uninstall.sh +# +# The purpose of this script is to remove the `post-commit.sh` +# script from the `post-commit` git hook. + +DELIMITER_START="# BEGIN FISTBUMP" +DELIMITER_END="# END FISTBUMP" + +# get root directory of project +root_dir=$(git rev-parse --show-toplevel) + +# get contents of .git/hooks/post-commit +hook=$(cat "$root_dir/.git/hooks/post-commit") + +# get post-commit.sh content +post_commit=$(tail -n +2 "$root_dir/scripts/hooks/post-commit.sh") + +# exit with success if hook doesn't have a delimiter comment +if ! grep -q "$DELIMITER_START" "$root_dir/.git/hooks/post-commit"; then + echo "[fist-bump] fist-bump not installed to .git/hooks/post-commit" + exit 0 +fi + +# get contents of .git/hooks/post-commit excluding fist-bump content +content=$(echo "$hook" | sed "/$DELIMITER_START/,/$DELIMITER_END/d") + +# overwrite .git/hooks/post-commit with extracted content +if echo "$content" > "$root_dir/.git/hooks/post-commit"; then + echo "[fist-bump] fist-bump removed from .git/hooks/post-commit" +else + echo "[fist-bump] Error: Failed to write to .git/hooks/post-commit (use sudo)" + exit 1 +fi \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 94ffbcf..fd1e652 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,111 @@ #!/usr/bin/env node +import { execute, getProjectRoot, logMessage } from "./utils"; import { bump } from "./bump"; -bump(); \ No newline at end of file +const FLAG = { + INSTALL: [ "-I", "--install" ], + UNINSTALL: [ "-U", "--uninstall" ] +} + +const MAXIMUM_ARGS = 3; + +/** + * Returns true if a string is in the args + * + * @param string - sample arg + * @returns boolean + */ +function _inArgs(string: string): boolean { + return process.argv.includes(string) +} + +/** + * Returns true if the install flag has been passed + * + * Valid flags: + * - `-I` + * - `--install` + */ +function _isInstall(): boolean { + let set = false; + + for(const flag of FLAG.INSTALL){ + if(_inArgs(flag)){ + set = true; + break; + } + } + + return set; +} + +/** + * Returns true if the uninstall flag has been passed. + * + * Valid flags: + * - `-U` + * - `--uninstall` + */ +function _isUninstall(): boolean { + let set = false; + + for(const flag of FLAG.UNINSTALL){ + if(_inArgs(flag)){ + set = true; + break; + } + } + + return set; +} + +/** + * Returns true if invalid argument is passed. + * + * Valid arguments: + * - `./lib/cli.js`, + * - `./lib/cli.js -H` + * - `./lib/cli.js --hook`, + */ +function _isInvalidArg(): boolean { + if (process.argv.length > MAXIMUM_ARGS) { + return true; + } else if (process.argv.length === MAXIMUM_ARGS && !(_isInstall() || _isUninstall())) { + return true; + } else { + return false; + } +} + +/** + * Runs a script to install/uninstall a command that executes the + * fist-bump command when the post-commit git hook is triggered. + * + * @param type - `install` or `uninstall` + * @param silent - if true, the script will not log any messages (default: false) + */ +function _executeHookScript(type: "install" | "uninstall"): void { + const rootDir = getProjectRoot() || process.cwd(); + const scriptPath = `${rootDir}/scripts/${type}.sh`; + + try { + execute(`chmod +x ${scriptPath}`); + execute(`${scriptPath}`); + } catch (error) { + logMessage(`Failed to ${type} git hook. ${error}`, "error"); + } +} + +if(_isInvalidArg()) { + logMessage("Invalid argument passed. Only `--hook` or `-H` is allowed.", "error") + process.exit(1); +} else if(_isInstall()) { + _executeHookScript("install"); + logMessage("Git hook installed successfully."); +} else if(_isUninstall()) { + _executeHookScript("uninstall"); + logMessage("Git hook uninstalled successfully."); +} else { + bump(); +} diff --git a/src/utils/shell.ts b/src/utils/shell.ts index defa823..5bd06fb 100644 --- a/src/utils/shell.ts +++ b/src/utils/shell.ts @@ -19,14 +19,32 @@ interface ExecuteOptions { * If true, the command will not be logged to the console */ silent?: boolean; + + /** + * Exit the process if the command fails + */ + fatal?: boolean; } /** * Executes a shell command and returns the result */ export function execute(command: string, options?: ExecuteOptions): string { - // default to silent - return shell.exec(command, { silent: options?.silent ?? true }).stdout; + let output: string; + + try { + // default to silent + output = shell.exec(command, { silent: options?.silent ?? true, fatal: options?.fatal ?? false }).stdout; + } catch (error) { + if((error as Error).message.includes("Permission denied")){ + throw new Error("Permission denied. Try running the command with sudo."); + } + + throw error; + } + + return output; + } /**