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
24
    fn fmt(&self, f: &mut Formatter<'_>) -> Result {
20
24
        match self {
21
21
            GitHookType::PostMerge => write!(f, "post-merge"),
22
3
            GitHookType::PostRewrite => write!(f, "post-rewrite"),
23
        }
24
24
    }
25
}
26

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

            
30
9
    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
31
9
        match s {
32
9
            "post-merge" => Ok(GitHookType::PostMerge),
33
6
            "post-rewrite" => Ok(GitHookType::PostRewrite),
34
3
            _ => Err(()),
35
        }
36
9
    }
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
18
    pub fn new<S: Into<String>>(hook: S) -> Self {
47
18
        Self(hook.into())
48
18
    }
49

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

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

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

            
73
6
git snip run --yes
74
6

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

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

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

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

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

            
109
9
    if !force && hook_path.exists() {
110
3
        bail!("Hook already exists at {}", hook_path.to_string_lossy());
111
6
    }
112

            
113
6
    let mut file = File::create(&hook_path).context("Failed to create hook file")?;
114
6
    file.write_all(hook.as_bytes())
115
6
        .context("Failed to write hook script")?;
116

            
117
    #[cfg(unix)]
118
    {
119
        use std::fs::Permissions;
120
        use std::os::unix::fs::PermissionsExt;
121
6
        file.set_permissions(Permissions::from_mode(0o755))
122
6
            .context("Failed to set permissions on hook file")?;
123
    }
124

            
125
6
    Ok(())
126
9
}
127

            
128
#[cfg(test)]
129
mod tests {
130
    use super::*;
131

            
132
    #[test]
133
3
    fn test_hook_type_display() {
134
        // GIVEN hook types
135
        // WHEN converting to string
136
        // THEN the string representation is correct
137
3
        assert_eq!(GitHookType::PostMerge.to_string(), "post-merge");
138
3
        assert_eq!(GitHookType::PostRewrite.to_string(), "post-rewrite");
139
3
    }
140

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

            
151
    #[test]
152
3
    fn test_hook_as_bytes() {
153
        // GIVEN a GitHook instance
154
        // WHEN converting to bytes
155
3
        let hook = GitHook::new("echo 'Hello, world!'");
156

            
157
        // THEN the bytes match the expected string
158
3
        assert_eq!(hook.as_bytes(), b"echo 'Hello, world!'");
159
3
    }
160

            
161
    #[test]
162
3
    fn test_into_string() {
163
        // GIVEN a GitHook instance
164
        // WHEN converting to String
165
3
        let hook = GitHook::new("echo 'Hello, world!'");
166
        // THEN the string matches the expected value
167
3
        assert_eq!(hook.into_string(), "echo 'Hello, world!'");
168
3
    }
169

            
170
    #[test]
171
3
    fn test_as_ref_and_deref() {
172
        // GIVEN a GitHook instance
173
3
        let hook = GitHook::new("echo 'Hi'");
174
        // WHEN using as_ref and deref
175
        // THEN the results are as expected
176
3
        assert_eq!(hook.as_ref(), "echo 'Hi'");
177
3
        assert_eq!(&*hook, "echo 'Hi'");
178
3
    }
179

            
180
    #[test]
181
3
    fn test_display() {
182
        // GIVEN a GitHook instance
183
        // WHEN formatting it as a string
184
3
        let hook = GitHook::new("echo 'Hi'");
185

            
186
        // THEN the display output is correct
187
3
        assert_eq!(hook.to_string(), "echo 'Hi'");
188
3
    }
189

            
190
    #[test]
191
3
    fn test_default() {
192
        // GIVEN the default GitHook
193
3
        let hook = GitHook::default();
194

            
195
        // THEN it contains the expected script
196
3
        assert!(hook.as_ref().contains("git snip run --yes"));
197
3
    }
198

            
199
    #[test]
200
3
    fn test_install() {
201
        // GIVEN a directory with a hooks subdirectory
202
3
        let tempdir = tempfile::tempdir().unwrap();
203
3
        let hooks_dir = tempdir.path().join("hooks");
204
3
        std::fs::create_dir(&hooks_dir).unwrap();
205
3
        let mock_script = String::from("echo 'Hello, world!'");
206

            
207
        // WHEN installing the hook
208
3
        let hook = GitHook::new(mock_script);
209
3
        let result = install(tempdir.path(), &hook, GitHookType::PostMerge, false);
210

            
211
        // THEN the installation should be successful
212
3
        assert!(result.is_ok());
213
3
        let hook_path = hooks_dir.join(GitHookType::PostMerge.to_string());
214
3
        let hook_script = std::fs::read_to_string(hook_path).unwrap();
215
3
        assert_eq!(hook_script, hook.into_string());
216
3
    }
217

            
218
    #[test]
219
3
    fn test_install_already_exists() {
220
        // GIVEN a directory with an existing hook file
221
3
        let tempdir = tempfile::tempdir().unwrap();
222
3
        let hooks_dir = tempdir.path().join("hooks");
223
3
        std::fs::create_dir(&hooks_dir).unwrap();
224
3
        let hook_path = hooks_dir.join(GitHookType::PostMerge.to_string());
225
3
        std::fs::File::create(&hook_path).unwrap();
226

            
227
        // WHEN installing the hook without force
228
3
        let hook = GitHook::default();
229
3
        let result = install(tempdir.path(), &hook, GitHookType::PostMerge, false);
230

            
231
        // THEN the installation should fail
232
3
        assert!(result.is_err());
233
3
    }
234

            
235
    #[test]
236
3
    fn test_install_force_overwrites_existing() {
237
        // GIVEN a directory with an existing hook file
238
3
        let tempdir = tempfile::tempdir().unwrap();
239
3
        let hooks_dir = tempdir.path().join("hooks");
240
3
        std::fs::create_dir(&hooks_dir).unwrap();
241
3
        let hook_path = hooks_dir.join(GitHookType::PostMerge.to_string());
242
3
        std::fs::write(&hook_path, "old content").unwrap();
243

            
244
        // WHEN installing the hook with force
245
3
        let hook = GitHook::new("new content");
246
3
        let result = install(tempdir.path(), &hook, GitHookType::PostMerge, true);
247

            
248
        // THEN the installation should succeed and overwrite the existing hook
249
3
        assert!(result.is_ok());
250
3
        let hook_script = std::fs::read_to_string(hook_path).unwrap();
251
3
        assert_eq!(hook_script, "new content");
252
3
    }
253
}