Automating NPM and Composer with Git Hooks

If you regularly work with package managers like NPM, Yarn or Composer you’ve probably forgotten at one time or another to update the packages after checking out another branch or pulling from a remote Git repository. There can be noticeable differences between the required packages of two different branches that mean you need to fetch the relevant files when you switch between them. Similarly when you pull from a remote branch perhaps another developer has updated a package that you now need to fetch in order to continue developing.

Forgetting to run the package manager whenever you run git checkout or git pull can cause all sorts of issues. What would be great is if every time we performed one of these Git commands if the package managers could automatically run. Well we can achieve this automation by using Git hooks.

Git Hooks

I’ve previously mentioned using Git hooks on this blog to automate git commit messages with an issue number. Git hooks are just small scripts that are triggered by various Git processes and usually reside in your local copy of a repository in the .git/hooks directory. For security reasons they don’t get directly committed to the repository, but we will look at that a little more in a bit.

For what we want to achieve here we’ll want to use the post-checkout and post-merge hooks.

  • post-checkout is triggered after git checkout is successfully completed. It won’t affect the checkout process.
  • post-merge is triggered after git pull is successfully completed on a local repository. Like post-checkout it has no affect on the action that triggered it.

Automating the Package Managers

Let’s start by focusing on NPM. We want to run npm install each time we switch branches in our Git repository. To do this we could add the following to the file .git/hooks/post-checkout.

#!/bin/bash
npm install

Now if we use git checkout the post-checkout hook is going to get triggered and npm install should automatically run. Just remember to make sure that your hook script is executable, otherwise nothing is going to happen other than a normal switch of branch. We also want this to happen when we pull from a remote branch. post-checkout won’t be triggered in this case, so we need to add the code above to .git/hooks/post-merge as well.

Hopefully, this should be making sense to you. We’ve got a couple of simple Bash scripts that are triggered by Git after we successfully checkout or pull a branch; and these scripts will run npm install every time. We could also add Composer to the mix and make sure we have the required PHP packages for the current branch.

#!/bin/bash
npm install
composer install

The problem is that this is going to slow things down a bit as we’re going to have to wait for NPM and Composer to install all the packages each time we use git checkout and git pull now that we’ve set up the hooks. Automation is great, but not if it slows us down unnecessarily. It would be more ideal if we could only install packages when needed.

Better Automation

One way of achieving this is to check whether the lock files of our package managers have changed; if they have then we know we need to do something, otherwise we can just carry on like normal and not run the package managers. So let’s implement that.

We’re going to need both hooks to perform the same actions and at the moment we have just been copying the functionality between the two Bash scripts. Let’s make things a little more DRY. We’ll put everything into a new script that the two hooks can call. So we’ll create a new compile.sh executable file in our .git/hooks folder. It’s going to look like this:-

#!/bin/bash

changedFiles="$(git diff-tree -r --name-only --no-commit-id ORIG_HEAD HEAD)"

runOnChange() {
	echo "$changedFiles" | grep -q "$1" && eval "$2"
}

runOnChange package-lock.json "npm install"
runOnChange composer.lock "composer install"

What’s happening here is that we’ve defined a function called runOnChange that will only run the passed command if a given file has changed as a result of our git checkout or git pull. We utilise git diff-tree to check for content changes between two commits, in our case the before and after our Git action; this gets piped into Grep using the -q flag to suppress the output, this is where we check for the file we are interested in. For example, if package-lock.json is found in the changes we know that we’re going to want to run NPM which is where eval comes in.

We need to now make sure we call this new script from our hooks. So we need to add the following to both our post-checkout and post-merge hooks.

#!/bin/bash
./compile.sh

Even More Automation

We’ve got our package managers running and installing packages whenever their respective lock files change, but what else could we do with our script? What if we use something like Webpack or Gulp to compile assets? Ideally we’d keep compiled assets out of our repositories and only keep the source files, but that means we’re going to need to remember to run the compilers.

With our new compile.sh script this should be simple. We can use the runOnChange method to check if our source files have been modified and then run whatever task manager we’re using. So for example, if we use Webpack we could add this to the script.

runOnChange resources/assets/ "npm run production"

runOnChange will check for any changes within the passed directory and then run Webpack if needed.

Sharing the Hooks

Let’s wrap things up by looking at how we can share the hooks we’ve set up by adding them to our repository.

When we first started to talk about using these hooks I mentioned that they don’t get committed to the repository. This isn’t always ideal. If you’re working as part of a team you want everyone to be able to benefit from this automation. So we need a workaround.

If you think about it, it makes sense that Git prevents the hooks from being committed. Would you really want a collection of hidden scripts (remember they live within the hidden .git directory) being downloaded to your machine and then run the minute you do something with Git?

So what we need to do is keep the scripts in a separate and more visible folder that can be committed and then we’ll tell Git about them. The approach I take is to put my hook scripts in a folder conveniently called git-hooks. Then I can tell Git where these are using the following:

git config core.hooksPath git-hooks

Now Git will use ./git-hooks instead of ./.git/hooks as the folder for the hook scripts.

If you go down this route then it might also be nice to add a set up script to the repository so that other devs can get set up quickly (they’re going to need to run the above command in order for the hooks to get picked up).

#!/bin/bash
git config core.hooksPath hooks

Related Content

Published on