git2_ext/
hooks.rs

1#[derive(Clone, Debug)]
2pub struct Hooks {
3    root: std::path::PathBuf,
4}
5
6impl Hooks {
7    pub fn new(hook_root: impl Into<std::path::PathBuf>) -> Self {
8        Self {
9            root: hook_root.into(),
10        }
11    }
12
13    pub fn with_repo(repo: &git2::Repository) -> Result<Self, git2::Error> {
14        let config = repo.config()?;
15        let root = config
16            .get_path("core.hooksPath")
17            .unwrap_or_else(|_| repo.path().join("hooks"));
18        Ok(Self::new(root))
19    }
20
21    pub fn root(&self) -> &std::path::Path {
22        &self.root
23    }
24
25    pub fn find_hook(&self, _repo: &git2::Repository, name: &str) -> Option<std::path::PathBuf> {
26        let mut hook_path = self.root().join(name);
27        if is_executable(&hook_path) {
28            return Some(hook_path);
29        }
30
31        if !std::env::consts::EXE_SUFFIX.is_empty() {
32            hook_path.set_extension(std::env::consts::EXE_SUFFIX);
33            if is_executable(&hook_path) {
34                return Some(hook_path);
35            }
36        }
37
38        // Technically, we should check `advice.ignoredHook` and warn users if the hook is present
39        // but not executable.  Supporting this in the future is why we accept `repo`.
40
41        None
42    }
43
44    pub fn run_hook(
45        &self,
46        repo: &git2::Repository,
47        name: &str,
48        args: &[&str],
49        stdin: Option<&[u8]>,
50        env: &[(&str, &str)],
51    ) -> Result<i32, std::io::Error> {
52        const SIGNAL_EXIT_CODE: i32 = 1;
53
54        let hook_path = if let Some(hook_path) = self.find_hook(repo, name) {
55            hook_path
56        } else {
57            return Ok(0);
58        };
59        let bin_name = hook_path
60            .file_name()
61            .expect("find_hook always returns a bin name")
62            .to_str()
63            .expect("find_hook always returns a utf-8 bin name");
64
65        let path = {
66            let mut path_components: Vec<std::path::PathBuf> =
67                vec![std::fs::canonicalize(self.root())?];
68            if let Some(path) = std::env::var_os(std::ffi::OsStr::new("PATH")) {
69                path_components.extend(std::env::split_paths(&path));
70            }
71            std::env::join_paths(path_components)
72                .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?
73        };
74
75        let sh_path = crate::utils::git_sh().ok_or_else(|| {
76            std::io::Error::new(std::io::ErrorKind::NotFound, "No `sh` for running hooks")
77        })?;
78
79        // From `githooks(5)`:
80        // > Before Git invokes a hook, it changes its working directory to either $GIT_DIR in a bare
81        // > repository or the root of the working tree in a non-bare repository. An exception are
82        // > hooks triggered during a push (pre-receive, update, post-receive, post-update,
83        // > push-to-checkout) which are always executed in $GIT_DIR.
84        let cwd = if PUSH_HOOKS.contains(&name) {
85            repo.path()
86        } else {
87            repo.workdir().unwrap_or_else(|| repo.path())
88        };
89
90        let mut cmd = std::process::Command::new(sh_path);
91        cmd.arg("-c")
92            .arg(format!("{bin_name} \"$@\""))
93            .arg(bin_name) // "$@" expands "$1" "$2" "$3" ... but we also must specify $0.
94            .args(args)
95            .env("PATH", path)
96            .current_dir(cwd)
97            // Technically, git maps stdout to stderr when running hooks
98            .stdin(std::process::Stdio::piped());
99        for (key, value) in env.iter().copied() {
100            cmd.env(key, value);
101        }
102        let mut process = cmd.spawn()?;
103        if let Some(stdin) = stdin {
104            use std::io::Write;
105
106            process.stdin.as_mut().unwrap().write_all(stdin)?;
107        }
108        let exit = process.wait()?;
109
110        Ok(exit.code().unwrap_or(SIGNAL_EXIT_CODE))
111    }
112
113    /// Run `post-rewrite` hook as if called by `git rebase`
114    ///
115    /// The hook should be run after any automatic note copying (see `notes.rewrite.<command>` in
116    /// git-config(1)) has happened, and thus has access to these notes.
117    ///
118    /// **`changed_shas` (old, new):**
119    /// - For the squash and fixup operation, all commits that were squashed are listed as being rewritten to the squashed commit. This means
120    ///   that there will be several lines sharing the same new-sha1.
121    /// - The commits are must be listed in the order that they were processed by rebase.
122    /// - `git` doesn't include entries for dropped commits
123    pub fn run_post_rewrite_rebase(
124        &self,
125        repo: &git2::Repository,
126        changed_oids: &[(git2::Oid, git2::Oid)],
127    ) {
128        let name = "post-rewrite";
129        let command = "rebase";
130        let args = [command];
131        let mut stdin = String::new();
132        for (old_oid, new_oid) in changed_oids {
133            use std::fmt::Write;
134            writeln!(stdin, "{old_oid} {new_oid}").expect("Always writeable");
135        }
136
137        match self.run_hook(repo, name, &args, Some(stdin.as_bytes()), &[]) {
138            Ok(0) => {}
139            Ok(code) => {
140                log::trace!("Hook `{}` failed with code {}", name, code);
141            }
142            Err(err) => {
143                log::trace!("Hook `{}` failed with {}", name, err);
144            }
145        }
146    }
147
148    /// Run `reference-transaction` hook to signal that all reference updates have been queued to the transaction.
149    ///
150    /// **`changed_refs` (old, new, name):**
151    /// - `name` is the full name of the ref
152    /// - `old` is zeroed out when force updating the reference regardless of its current value or
153    ///   when the reference is to be created anew
154    pub fn run_reference_transaction<'t>(
155        &'t self,
156        repo: &'t git2::Repository,
157        changed_refs: &'t [(git2::Oid, git2::Oid, &'t str)],
158    ) -> Result<ReferenceTransaction<'t>, std::io::Error> {
159        self.run_reference_transaction_prepare(repo, changed_refs)?;
160
161        Ok(ReferenceTransaction {
162            hook: self,
163            repo,
164            changed_refs,
165        })
166    }
167
168    /// Run `reference-transaction` hook to signal that all reference updates have been queued to the transaction.
169    ///
170    /// **`changed_refs` (old, new, name):**
171    /// - `name` is the full name of the ref
172    /// - `old` is zeroed out when force updating the reference regardless of its current value or
173    ///   when the reference is to be created anew
174    ///
175    /// On success, call either
176    /// - `run_reference_transaction_committed`
177    /// - `run_reference_transaction_aborted`.
178    ///
179    /// On failure, the transaction is considered aborted
180    pub fn run_reference_transaction_prepare(
181        &self,
182        repo: &git2::Repository,
183        changed_refs: &[(git2::Oid, git2::Oid, &str)],
184    ) -> Result<(), std::io::Error> {
185        let name = "reference-transaction";
186        let state = "prepare";
187        let args = [state];
188        let mut stdin = String::new();
189        for (old_oid, new_oid, ref_name) in changed_refs {
190            use std::fmt::Write;
191            writeln!(stdin, "{old_oid} {new_oid} {ref_name}").expect("Always writeable");
192        }
193
194        let code = self.run_hook(repo, name, &args, Some(stdin.as_bytes()), &[])?;
195        if code == 0 {
196            Ok(())
197        } else {
198            log::trace!("Hook `{}` failed with code {}", name, code);
199            Err(std::io::Error::new(
200                std::io::ErrorKind::Interrupted,
201                format!("`{name}` hook failed with code {code}"),
202            ))
203        }
204    }
205
206    /// Run `reference-transaction` hook to signal that all reference updates have been applied
207    ///
208    /// **`changed_refs` (old, new, name):**
209    /// - `name` is the full name of the ref
210    /// - `old` is zeroed out when force updating the reference regardless of its current value or
211    ///   when the reference is to be created anew
212    pub fn run_reference_transaction_committed(
213        &self,
214        repo: &git2::Repository,
215        changed_refs: &[(git2::Oid, git2::Oid, &str)],
216    ) {
217        let name = "reference-transaction";
218        let state = "committed";
219        let args = [state];
220        let mut stdin = String::new();
221        for (old_oid, new_oid, ref_name) in changed_refs {
222            use std::fmt::Write;
223            writeln!(stdin, "{old_oid} {new_oid} {ref_name}").expect("Always writeable");
224        }
225
226        match self.run_hook(repo, name, &args, Some(stdin.as_bytes()), &[]) {
227            Ok(0) => {}
228            Ok(code) => {
229                log::trace!("Hook `{}` failed with code {}", name, code);
230            }
231            Err(err) => {
232                log::trace!("Hook `{}` failed with {}", name, err);
233            }
234        }
235    }
236
237    /// Run `reference-transaction` hook to signal that no changes have been made
238    ///
239    /// **`changed_refs` (old, new, name):**
240    /// - `name` is the full name of the ref
241    /// - `old` is zeroed out when force updating the reference regardless of its current value or
242    ///   when the reference is to be created anew
243    pub fn run_reference_transaction_aborted(
244        &self,
245        repo: &git2::Repository,
246        changed_refs: &[(git2::Oid, git2::Oid, &str)],
247    ) {
248        let name = "reference-transaction";
249        let state = "aborted";
250        let args = [state];
251        let mut stdin = String::new();
252        for (old_oid, new_oid, ref_name) in changed_refs {
253            use std::fmt::Write;
254            writeln!(stdin, "{old_oid} {new_oid} {ref_name}").expect("Always writeable");
255        }
256
257        match self.run_hook(repo, name, &args, Some(stdin.as_bytes()), &[]) {
258            Ok(0) => {}
259            Ok(code) => {
260                log::trace!("Hook `{}` failed with code {}", name, code);
261            }
262            Err(err) => {
263                log::trace!("Hook `{}` failed with {}", name, err);
264            }
265        }
266    }
267}
268
269pub struct ReferenceTransaction<'t> {
270    hook: &'t Hooks,
271    repo: &'t git2::Repository,
272    changed_refs: &'t [(git2::Oid, git2::Oid, &'t str)],
273}
274
275impl ReferenceTransaction<'_> {
276    pub fn committed(self) {
277        let Self {
278            hook,
279            repo,
280            changed_refs,
281        } = self;
282        hook.run_reference_transaction_committed(repo, changed_refs);
283    }
284
285    pub fn aborted(self) {
286        let Self {
287            hook,
288            repo,
289            changed_refs,
290        } = self;
291        hook.run_reference_transaction_aborted(repo, changed_refs);
292    }
293}
294
295impl Drop for ReferenceTransaction<'_> {
296    fn drop(&mut self) {
297        self.hook
298            .run_reference_transaction_aborted(self.repo, self.changed_refs);
299    }
300}
301
302const PUSH_HOOKS: &[&str] = &[
303    "pre-receive",
304    "update",
305    "post-receive",
306    "post-update",
307    "push-to-checkout",
308];
309
310#[cfg(unix)]
311fn is_executable(path: &std::path::Path) -> bool {
312    use std::os::unix::fs::PermissionsExt;
313
314    let metadata = match path.metadata() {
315        Ok(metadata) => metadata,
316        Err(_) => return false,
317    };
318    let permissions = metadata.permissions();
319    metadata.is_file() && permissions.mode() & 0o111 != 0
320}
321
322#[cfg(not(unix))]
323fn is_executable(path: &std::path::Path) -> bool {
324    path.is_file()
325}