1
use std::fmt::{Display, Formatter, Result};
2
use std::fs::File;
3
use std::io::Write;
4
use std::path::Path;
5
use std::str::FromStr;
6

            
7
use anyhow::{bail, Context};
8

            
9
/// Hook types used in git.
10
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
11
pub enum GitHookType {
12
    PostMerge,
13
    PostRewrite,
14
}
15

            
16
/// Converts HookType to a string. This is used to create the hook file name.
17
/// For example, HookType::PreCommit will be converted to "pre-commit".
18
impl Display for GitHookType {
19
12
    fn fmt(&self, f: &mut Formatter<'_>) -> Result {
20
12
        match self {
21
10
            GitHookType::PostMerge => write!(f, "post-merge"),
22
2
            GitHookType::PostRewrite => write!(f, "post-rewrite"),
23
        }
24
12
    }
25
}
26

            
27
impl FromStr for GitHookType {
28
    type Err = ();
29

            
30
6
    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
31
6
        match s {
32
6
            "post-merge" => Ok(GitHookType::PostMerge),
33
4
            "post-rewrite" => Ok(GitHookType::PostRewrite),
34
2
            _ => Err(()),
35
        }
36
6
    }
37
}
38

            
39
/// Representation of a git hook script.
40
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
41
pub struct GitHook(String);
42

            
43
impl GitHook {
44
    /// Create a new instance of GitHook.
45
    #[allow(dead_code)]
46
10
    pub fn new<S: Into<String>>(hook: S) -> Self {
47
10
        Self(hook.into())
48
10
    }
49

            
50
    /// Convert the hook script to a byte slice.
51
4
    pub fn as_bytes(&self) -> &[u8] {
52
4
        self.0.as_bytes()
53
4
    }
54

            
55
    /// Return the hook as a String, consuming self.
56
    #[allow(dead_code)]
57
4
    pub fn into_string(self) -> String {
58
4
        self.0
59
4
    }
60
}
61

            
62
impl Default for GitHook {
63
    /// Returns the standard git-snip hook script.
64
4
    fn default() -> Self {
65
4
        Self(
66
4
            r#"#!/bin/sh
67
4
HEAD_BRANCH=$(git rev-parse --abbrev-ref HEAD)
68
4
case "$HEAD_BRANCH" in
69
4
    'main'|'master'|'develop') ;;
70
4
        *) exit ;;
71
4
esac
72
4

            
73
4
git snip --yes
74
4

            
75
4
"#
76
4
            .to_string(),
77
4
        )
78
4
    }
79
}
80

            
81
impl AsRef<str> for GitHook {
82
4
    fn as_ref(&self) -> &str {
83
4
        &self.0
84
4
    }
85
}
86

            
87
impl std::ops::Deref for GitHook {
88
    type Target = str;
89
2
    fn deref(&self) -> &Self::Target {
90
2
        &self.0
91
2
    }
92
}
93

            
94
impl Display for GitHook {
95
2
    fn fmt(&self, f: &mut Formatter<'_>) -> Result {
96
2
        self.0.fmt(f)
97
2
    }
98
}
99

            
100
/// Install a hook script in the given git directory.
101
4
pub fn install(git_dir: &Path, hook: &GitHook, hook_type: GitHookType) -> anyhow::Result<()> {
102
4
    let hook_path = git_dir.join("hooks").join(hook_type.to_string());
103

            
104
4
    if hook_path.exists() {
105
2
        bail!("Hook already exists at {}", hook_path.to_string_lossy());
106
2
    }
107

            
108
2
    let mut file = File::create(&hook_path).context("Failed to create hook file")?;
109
2
    file.write_all(hook.as_bytes())
110
2
        .context("Failed to write hook script")?;
111

            
112
    #[cfg(unix)]
113
    {
114
        use std::fs::Permissions;
115
        use std::os::unix::fs::PermissionsExt;
116
2
        file.set_permissions(Permissions::from_mode(0o755))
117
2
            .context("Failed to set permissions on hook file")?;
118
    }
119

            
120
2
    Ok(())
121
4
}
122

            
123
#[cfg(test)]
124
mod tests {
125
    use super::*;
126

            
127
    #[test]
128
2
    fn test_hook_type_display() {
129
        // GIVEN hook types
130
        // WHEN converting to string
131
        // THEN the string representation is correct
132
2
        assert_eq!(GitHookType::PostMerge.to_string(), "post-merge");
133
2
        assert_eq!(GitHookType::PostRewrite.to_string(), "post-rewrite");
134
2
    }
135

            
136
    #[test]
137
2
    fn test_hook_type_from_str() {
138
        // GIVEN string representations of hook types
139
        // WHEN parsing them
140
        // THEN the correct hook types are returned
141
2
        assert_eq!("post-merge".parse(), Ok(GitHookType::PostMerge));
142
2
        assert_eq!("post-rewrite".parse(), Ok(GitHookType::PostRewrite));
143
2
        assert!("other-hook".parse::<GitHookType>().is_err());
144
2
    }
145

            
146
    #[test]
147
2
    fn test_hook_as_bytes() {
148
        // GIVEN a GitHook instance
149
        // WHEN converting to bytes
150
2
        let hook = GitHook::new("echo 'Hello, world!'");
151

            
152
        // THEN the bytes match the expected string
153
2
        assert_eq!(hook.as_bytes(), b"echo 'Hello, world!'");
154
2
    }
155

            
156
    #[test]
157
2
    fn test_into_string() {
158
        // GIVEN a GitHook instance
159
        // WHEN converting to String
160
2
        let hook = GitHook::new("echo 'Hello, world!'");
161
        // THEN the string matches the expected value
162
2
        assert_eq!(hook.into_string(), "echo 'Hello, world!'");
163
2
    }
164

            
165
    #[test]
166
2
    fn test_as_ref_and_deref() {
167
        // GIVEN a GitHook instance
168
2
        let hook = GitHook::new("echo 'Hi'");
169
        // WHEN using as_ref and deref
170
        // THEN the results are as expected
171
2
        assert_eq!(hook.as_ref(), "echo 'Hi'");
172
2
        assert_eq!(&*hook, "echo 'Hi'");
173
2
    }
174

            
175
    #[test]
176
2
    fn test_display() {
177
        // GIVEN a GitHook instance
178
        // WHEN formatting it as a string
179
2
        let hook = GitHook::new("echo 'Hi'");
180

            
181
        // THEN the display output is correct
182
2
        assert_eq!(hook.to_string(), "echo 'Hi'");
183
2
    }
184

            
185
    #[test]
186
2
    fn test_default() {
187
        // GIVEN the default GitHook
188
2
        let hook = GitHook::default();
189

            
190
        // THEN it contains the expected script
191
2
        assert!(hook.as_ref().contains("git snip --yes"));
192
2
    }
193

            
194
    #[test]
195
2
    fn test_install() {
196
        // GIVEN a directory with a hooks subdirectory
197
2
        let tempdir = tempfile::tempdir().unwrap();
198
2
        let hooks_dir = tempdir.path().join("hooks");
199
2
        std::fs::create_dir(&hooks_dir).unwrap();
200
2
        let mock_script = String::from("echo 'Hello, world!'");
201

            
202
        // WHEN installing the hook
203
2
        let hook = GitHook::new(mock_script);
204
2
        let result = install(tempdir.path(), &hook, GitHookType::PostMerge);
205

            
206
        // THEN the installation should be successful
207
2
        assert!(result.is_ok());
208
2
        let hook_path = hooks_dir.join(GitHookType::PostMerge.to_string());
209
2
        let hook_script = std::fs::read_to_string(hook_path).unwrap();
210
2
        assert_eq!(hook_script, hook.into_string());
211
2
    }
212

            
213
    #[test]
214
2
    fn test_install_already_exists() {
215
        // GIVEN a directory with an existing hook file
216
2
        let tempdir = tempfile::tempdir().unwrap();
217
2
        let hooks_dir = tempdir.path().join("hooks");
218
2
        std::fs::create_dir(&hooks_dir).unwrap();
219
2
        let hook_path = hooks_dir.join(GitHookType::PostMerge.to_string());
220
2
        std::fs::File::create(&hook_path).unwrap();
221

            
222
        // WHEN installing the hook
223
2
        let hook = GitHook::default();
224
2
        let result = install(tempdir.path(), &hook, GitHookType::PostMerge);
225

            
226
        // THEN the installation should fail
227
2
        assert!(result.is_err());
228
2
    }
229
}