Lines
100 %
Functions
66.67 %
Branches
use std::fmt::{Display, Formatter, Result};
use std::fs::File;
use std::io::Write;
use std::path::Path;
use std::str::FromStr;
use anyhow::{bail, Context};
/// Hook types used in git.
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub enum GitHookType {
PostMerge,
PostRewrite,
}
/// Converts HookType to a string. This is used to create the hook file name.
/// For example, HookType::PreCommit will be converted to "pre-commit".
impl Display for GitHookType {
fn fmt(&self, f: &mut Formatter<'_>) -> Result {
match self {
GitHookType::PostMerge => write!(f, "post-merge"),
GitHookType::PostRewrite => write!(f, "post-rewrite"),
impl FromStr for GitHookType {
type Err = ();
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
match s {
"post-merge" => Ok(GitHookType::PostMerge),
"post-rewrite" => Ok(GitHookType::PostRewrite),
_ => Err(()),
/// Representation of a git hook script.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct GitHook(String);
impl GitHook {
/// Create a new instance of GitHook.
#[allow(dead_code)]
pub fn new<S: Into<String>>(hook: S) -> Self {
Self(hook.into())
/// Convert the hook script to a byte slice.
pub fn as_bytes(&self) -> &[u8] {
self.0.as_bytes()
/// Return the hook as a String, consuming self.
pub fn into_string(self) -> String {
self.0
impl Default for GitHook {
/// Returns the standard git-snip hook script.
fn default() -> Self {
Self(
r#"#!/bin/sh
HEAD_BRANCH=$(git rev-parse --abbrev-ref HEAD)
case "$HEAD_BRANCH" in
'main'|'master'|'develop') ;;
*) exit ;;
esac
git snip --yes
"#
.to_string(),
)
impl AsRef<str> for GitHook {
fn as_ref(&self) -> &str {
&self.0
impl std::ops::Deref for GitHook {
type Target = str;
fn deref(&self) -> &Self::Target {
impl Display for GitHook {
self.0.fmt(f)
/// Install a hook script in the given git directory.
pub fn install(git_dir: &Path, hook: &GitHook, hook_type: GitHookType) -> anyhow::Result<()> {
let hook_path = git_dir.join("hooks").join(hook_type.to_string());
if hook_path.exists() {
bail!("Hook already exists at {}", hook_path.to_string_lossy());
let mut file = File::create(&hook_path).context("Failed to create hook file")?;
file.write_all(hook.as_bytes())
.context("Failed to write hook script")?;
#[cfg(unix)]
{
use std::fs::Permissions;
use std::os::unix::fs::PermissionsExt;
file.set_permissions(Permissions::from_mode(0o755))
.context("Failed to set permissions on hook file")?;
Ok(())
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_hook_type_display() {
// GIVEN hook types
// WHEN converting to string
// THEN the string representation is correct
assert_eq!(GitHookType::PostMerge.to_string(), "post-merge");
assert_eq!(GitHookType::PostRewrite.to_string(), "post-rewrite");
fn test_hook_type_from_str() {
// GIVEN string representations of hook types
// WHEN parsing them
// THEN the correct hook types are returned
assert_eq!("post-merge".parse(), Ok(GitHookType::PostMerge));
assert_eq!("post-rewrite".parse(), Ok(GitHookType::PostRewrite));
assert!("other-hook".parse::<GitHookType>().is_err());
fn test_hook_as_bytes() {
// GIVEN a GitHook instance
// WHEN converting to bytes
let hook = GitHook::new("echo 'Hello, world!'");
// THEN the bytes match the expected string
assert_eq!(hook.as_bytes(), b"echo 'Hello, world!'");
fn test_into_string() {
// WHEN converting to String
// THEN the string matches the expected value
assert_eq!(hook.into_string(), "echo 'Hello, world!'");
fn test_as_ref_and_deref() {
let hook = GitHook::new("echo 'Hi'");
// WHEN using as_ref and deref
// THEN the results are as expected
assert_eq!(hook.as_ref(), "echo 'Hi'");
assert_eq!(&*hook, "echo 'Hi'");
fn test_display() {
// WHEN formatting it as a string
// THEN the display output is correct
assert_eq!(hook.to_string(), "echo 'Hi'");
fn test_default() {
// GIVEN the default GitHook
let hook = GitHook::default();
// THEN it contains the expected script
assert!(hook.as_ref().contains("git snip --yes"));
fn test_install() {
// GIVEN a directory with a hooks subdirectory
let tempdir = tempfile::tempdir().unwrap();
let hooks_dir = tempdir.path().join("hooks");
std::fs::create_dir(&hooks_dir).unwrap();
let mock_script = String::from("echo 'Hello, world!'");
// WHEN installing the hook
let hook = GitHook::new(mock_script);
let result = install(tempdir.path(), &hook, GitHookType::PostMerge);
// THEN the installation should be successful
assert!(result.is_ok());
let hook_path = hooks_dir.join(GitHookType::PostMerge.to_string());
let hook_script = std::fs::read_to_string(hook_path).unwrap();
assert_eq!(hook_script, hook.into_string());
fn test_install_already_exists() {
// GIVEN a directory with an existing hook file
std::fs::File::create(&hook_path).unwrap();
// THEN the installation should fail
assert!(result.is_err());