Tip: Share a Git Hooks Directory Across Your Repositories

TL;DR

git config --global core.hookspath '<path to hooks directory>'

Sharing Hooks Across Repos

I posted before about using a pre-commit hook to check that I’m not committing anything that I really shouldn’t be (anything I’ve tagged with //DONOTCOMMIT).

Hooks are specified in the .git/hooks directory. That’s great, a git repository is completely contained within its parent folder, you can copy it somewhere else and all of the code, history and config come with it.

It’s not so convenient if you want to create some hooks that apply across multiple repositories though. You can just copy your hook files between all of your repos, or it turns out that there is a smarter way. Git config has a core.hookspath key. You can create a folder somewhere with the hooks that you want to apply to all repos and set this key.

Use git config --global to set the value of a key in the global config file and git config --global --list to list the config keys and their current values.

git config --global core.hookspath '<path to hooks directory>'

Spare Your Blushes with Pre-Commit Hooks

It’s Summer (at least in the northern hemisphere), hooray. You’ve booked some time off, wrapped up what you were working on as best you can, committed and pushed all your code, set your out-of-office and switched off Teams. Beautiful.

When you come back you flick through your messages to catch back up. What’s this? Some muppet commented out some vital code and pushed their changes? Who? Why?

It happens happened. That muppet was me.

There are good reasons why you might remove or add some code in your local environment but it is really important that those changes don’t end up in anyone else’s copy.

You can either:

  • Plan A: back yourself never to accidentally commit and push those changes
  • Plan B: add a pre-commit Git hook as an extra line of defense

I’ve played around with Git hooks before but still haven’t actually used them for anything serious. I think I’m going to start now.

Pre-Commit Hook

Open the (hidden) .git folder inside your repository and rename pre-commit.sample to pre-commit.

As the comments at the top of the file say, if you want to stop the commit then this script should echo some explanatory comment and return non-zero. This is mine:

if git diff --staged | grep 'DONOTCOMMIT' -qE; then
    echo "Your staged changes include DONOTCOMMIT"
    exit 1
fi

Before committing, Git looks for a pre-commit file in the hooks folder and executes it if it finds it.

git diff --staged gets a string of the changes which are staged i.e. going to be included in this commit. This string is piped to grep to match a regular expression – I’m keeping it simple and searching for the string ‘DONOTCOMMIT’ but you could get fancier if you wanted.

If DONOTCOMMIT is found in the staged changes then a message to that effect is shown and the scripts exit with 1 (which tells Git not to continue with the commit).

VS Code error dialog thrown by pre-commit hook

Next time I add or remove some code that is for my eyes only I’ll add a //DONOTCOMMIT comment alongside to remind me to undo it again when I push the code.

Tip: List-Commits

function List-Commits {
  cd 'C:\Git'
  $Commits = @()
  Get-ChildItem . -Directory | % {
    cd "$_"
    if (Test-Path (Join-Path (Get-Location) '.git')) {
      $Commits += git log --all --format="$($_)~%h~%ai~%s~%an" | ConvertFrom-Csv -Delimiter '~' -Header ('Project,Hash,Date,Message,Author'.Split(','))
    }
    cd ..
  }
  $Commits | ? Author -EQ "$(git config --get user.name)" | sort Date -Descending | Out-GridView -Title 'Commits'
}

This function iterates through Git repositories under the same parent folder (C:\Git in my case), builds a list of all the commits that you’ve authored (i.e. that match your user.name in Git config) and displays them in descending date order in a grid view.

Change the path in the second line to suit, or just remove it to have it search for repositories under the current directory.

Sample output in a PowerShell grid view

I use it to remind myself what I’ve been working on over the last few days. Mostly for fun and only occasionally because I’m late filling in my timesheets… 🙄

Tip: Remove-BranchesWithUpstreamGone

Wait, I Thought I Deleted That Branch?

One of the things that I found counter-intuitive when I was getting started with Git is that when branches are deleted on the server they are still present in your local repository, even after you have fetched from the server.

We typically delete the source branch when completing a pull request, so this happens a lot. Usually, once the PR has been completed I want to:

  1. remove the reference to the deleted remote branch
  2. remove the corresponding local branch

Removing Remote Reference

The reference to the remote branch is removed when you run git fetch with the prune switch.

git fetch --prune
Fetching origin
From <remote url>
- [deleted] (none) -> origin/test

Removing Local Branches

Local branches can be removed with the git branch command. Adding -d first checks for unmerged commits and will not delete the branch if there are any commits which are only in the branch that is being deleted. Adding -D overrides the check and deletes the branch anyway.

git branch -d test
Deleted branch test (was <commit hash>)

Remove-BranchesWithUpstreamGone

I’ve added a couple of PowerShell functions to my profile file – which means they are always available in my terminal. If I’m working on an app and I know that some PR’s have been merged I can clean up my workspace running Remove-BranchesWithUpstreamGone in VS Code’s terminal.

As a rule, I don’t need to keep any branches which used to have a copy of the server, but don’t any more (indicated by [gone] in the list of branches). Obviously, local branches which have never been pushed to the server won’t be deleted.

function Remove-BranchesWithUpstreamGone {
  (Get-BranchesWithUpstreamGone) | ForEach-Object {
    Write-Host "Removing branch $_"
    git branch $_ -D
  }
}

function Get-BranchesWithUpstreamGone {
  git fetch --all --prune | Out-Null
  $Branches = git branch -v | Where-Object { $_.Contains('[gone]') }
  $BranchNames += $Branches | ForEach-Object {
    if ($_.Split(' ').Item(1) -ne '') {
      $_.Split(' ').Item(1)
    }
    else {
      $_.Split(' ').Item(2)
    }
  }

  $BranchNames
}

Tip: Octopus Merges in Git

If you occasionally glance at my blog you might have noticed that I am a big fan of pull requests as served up by Azure DevOps (exhibit A). I briefly described our typical branching strategy here, including how and why we use pull requests.

I love it. Writing, testing and reviewing discrete pieces of development independently of one another helps spread the work around the team and prevent work getting blocked by some unrelated development in the same project.

However, occasionally we’ll have several pull requests open for the same project which I want to publish to some testing environment (either a local Docker container or SaaS sandbox) all together. Maybe it made sense to develop those work items separately but it’s hard to test them in isolation.

I tend to create a new local branch called testing or something equally banal, merge all the changes into that branch and publish. Here’s where the octopus comes in. We usually use git merge to merge a single other branch into the current branch e.g.

git checkout master
git merge release/1.2.0 --ff-only

To merge the commits in the release branch into the master branch (but only if it is possible to fast-forward merge).

Git doesn’t restrict you to a single other branch though. You can add as many as you like. Say we’ve got 3 feature branches on the go which I want to push to a SaaS sandbox for a consultant to test.

git checkout -b testing
git merge feature/one feature/two feature/three

This will create and checkout a new testing branch and then merge the three feature branches into it. Octopuses for the win.