Skip to content

Commit c25b809

Browse files
committed
feat(asyncgit): add timeouts for hooks
1 parent fe5e780 commit c25b809

File tree

11 files changed

+861
-135
lines changed

11 files changed

+861
-135
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1515
* new command-line option to override the default log file path (`--logfile`) [[@acuteenvy](https://github.com/acuteenvy)] ([#2539](https://github.com/gitui-org/gitui/pull/2539))
1616
* dx: `make check` checks Cargo.toml dependency ordering using `cargo sort` [[@naseschwarz](https://github.com/naseschwarz)]
1717
* add `use_selection_fg` to theme file to allow customizing selection foreground color [[@Upsylonbare](https://github.com/Upsylonbare)] ([#2515](https://github.com/gitui-org/gitui/pull/2515))
18+
* add timeouts for hooks [[@DaRacci](https://github.com/DaRacci)] ([#2547](https://github.com/gitui-org/gitui/pull/2547))
1819

1920
### Changed
2021
* improve error messages [[@acuteenvy](https://github.com/acuteenvy)] ([#2617](https://github.com/gitui-org/gitui/pull/2617))

Cargo.lock

Lines changed: 21 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

asyncgit/src/sync/hooks.rs

Lines changed: 215 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ use super::{repository::repo, RepoPath};
22
use crate::error::Result;
33
pub use git2_hooks::PrepareCommitMsgSource;
44
use scopetime::scope_time;
5+
use std::time::Duration;
56

67
///
78
#[derive(Debug, PartialEq, Eq)]
@@ -10,6 +11,13 @@ pub enum HookResult {
1011
Ok,
1112
/// Hook returned error
1213
NotOk(String),
14+
/// Hook timed out
15+
TimedOut {
16+
/// Stdout
17+
stdout: String,
18+
/// Stderr
19+
stderr: String,
20+
},
1321
}
1422

1523
impl From<git2_hooks::HookResult> for HookResult {
@@ -22,6 +30,11 @@ impl From<git2_hooks::HookResult> for HookResult {
2230
stderr,
2331
..
2432
} => Self::NotOk(format!("{stdout}{stderr}")),
33+
git2_hooks::HookResult::TimedOut {
34+
stdout,
35+
stderr,
36+
..
37+
} => Self::TimedOut { stdout, stderr },
2538
}
2639
}
2740
}
@@ -30,30 +43,66 @@ impl From<git2_hooks::HookResult> for HookResult {
3043
pub fn hooks_commit_msg(
3144
repo_path: &RepoPath,
3245
msg: &mut String,
46+
) -> Result<HookResult> {
47+
hooks_commit_msg_with_timeout(repo_path, msg, None)
48+
}
49+
50+
/// see `git2_hooks::hooks_commit_msg`
51+
#[allow(unused)]
52+
pub fn hooks_commit_msg_with_timeout(
53+
repo_path: &RepoPath,
54+
msg: &mut String,
55+
timeout: Option<Duration>,
3356
) -> Result<HookResult> {
3457
scope_time!("hooks_commit_msg");
3558

3659
let repo = repo(repo_path)?;
37-
38-
Ok(git2_hooks::hooks_commit_msg(&repo, None, msg)?.into())
60+
Ok(git2_hooks::hooks_commit_msg_with_timeout(
61+
&repo, None, msg, timeout,
62+
)?
63+
.into())
3964
}
4065

4166
/// see `git2_hooks::hooks_pre_commit`
4267
pub fn hooks_pre_commit(repo_path: &RepoPath) -> Result<HookResult> {
68+
hooks_pre_commit_with_timeout(repo_path, None)
69+
}
70+
71+
/// see `git2_hooks::hooks_pre_commit`
72+
#[allow(unused)]
73+
pub fn hooks_pre_commit_with_timeout(
74+
repo_path: &RepoPath,
75+
timeout: Option<Duration>,
76+
) -> Result<HookResult> {
4377
scope_time!("hooks_pre_commit");
4478

4579
let repo = repo(repo_path)?;
4680

47-
Ok(git2_hooks::hooks_pre_commit(&repo, None)?.into())
81+
Ok(git2_hooks::hooks_pre_commit_with_timeout(
82+
&repo, None, timeout,
83+
)?
84+
.into())
4885
}
4986

5087
/// see `git2_hooks::hooks_post_commit`
5188
pub fn hooks_post_commit(repo_path: &RepoPath) -> Result<HookResult> {
89+
hooks_post_commit_with_timeout(repo_path, None)
90+
}
91+
92+
/// see `git2_hooks::hooks_post_commit`
93+
#[allow(unused)]
94+
pub fn hooks_post_commit_with_timeout(
95+
repo_path: &RepoPath,
96+
timeout: Option<Duration>,
97+
) -> Result<HookResult> {
5298
scope_time!("hooks_post_commit");
5399

54100
let repo = repo(repo_path)?;
55101

56-
Ok(git2_hooks::hooks_post_commit(&repo, None)?.into())
102+
Ok(git2_hooks::hooks_post_commit_with_timeout(
103+
&repo, None, timeout,
104+
)?
105+
.into())
57106
}
58107

59108
/// see `git2_hooks::hooks_prepare_commit_msg`
@@ -66,8 +115,26 @@ pub fn hooks_prepare_commit_msg(
66115

67116
let repo = repo(repo_path)?;
68117

69-
Ok(git2_hooks::hooks_prepare_commit_msg(
70-
&repo, None, source, msg,
118+
Ok(git2_hooks::hooks_prepare_commit_msg_with_timeout(
119+
&repo, None, source, msg, None,
120+
)?
121+
.into())
122+
}
123+
124+
/// see `git2_hooks::hooks_prepare_commit_msg`
125+
#[allow(unused)]
126+
pub fn hooks_prepare_commit_msg_with_timeout(
127+
repo_path: &RepoPath,
128+
source: PrepareCommitMsgSource,
129+
msg: &mut String,
130+
timeout: Option<Duration>,
131+
) -> Result<HookResult> {
132+
scope_time!("hooks_prepare_commit_msg");
133+
134+
let repo = repo(repo_path)?;
135+
136+
Ok(git2_hooks::hooks_prepare_commit_msg_with_timeout(
137+
&repo, None, source, msg, timeout,
71138
)?
72139
.into())
73140
}
@@ -77,7 +144,7 @@ mod tests {
77144
use std::{ffi::OsString, io::Write as _, path::Path};
78145

79146
use git2::Repository;
80-
use tempfile::TempDir;
147+
use tempfile::{tempdir, TempDir};
81148

82149
use super::*;
83150
use crate::sync::tests::repo_init_with_prefix;
@@ -125,7 +192,7 @@ mod tests {
125192
let (_td, repo) = repo_init().unwrap();
126193
let root = repo.workdir().unwrap();
127194

128-
let hook = b"#!/bin/sh
195+
let hook = b"#!/usr/bin/env sh
129196
echo 'rejected'
130197
exit 1
131198
";
@@ -239,4 +306,144 @@ mod tests {
239306

240307
assert_eq!(msg, String::from("msg\n"));
241308
}
309+
310+
#[test]
311+
fn test_hooks_respect_timeout() {
312+
let (_td, repo) = repo_init().unwrap();
313+
let root = repo.path().parent().unwrap();
314+
315+
let hook = b"#!/usr/bin/env sh
316+
sleep 0.250
317+
";
318+
319+
git2_hooks::create_hook(
320+
&repo,
321+
git2_hooks::HOOK_PRE_COMMIT,
322+
hook,
323+
);
324+
325+
let res = hooks_pre_commit_with_timeout(
326+
&root.to_str().unwrap().into(),
327+
Some(Duration::from_millis(200)),
328+
)
329+
.unwrap();
330+
331+
assert_eq!(
332+
res,
333+
HookResult::TimedOut {
334+
stdout: String::new(),
335+
stderr: String::new()
336+
}
337+
);
338+
}
339+
340+
#[test]
341+
fn test_hooks_faster_than_timeout() {
342+
let (_td, repo) = repo_init().unwrap();
343+
let root = repo.path().parent().unwrap();
344+
345+
let hook = b"#!/usr/bin/env sh
346+
sleep 0.1
347+
";
348+
349+
git2_hooks::create_hook(
350+
&repo,
351+
git2_hooks::HOOK_PRE_COMMIT,
352+
hook,
353+
);
354+
355+
let res = hooks_pre_commit_with_timeout(
356+
&root.to_str().unwrap().into(),
357+
Some(Duration::from_millis(150)),
358+
)
359+
.unwrap();
360+
361+
assert_eq!(res, HookResult::Ok);
362+
}
363+
364+
#[test]
365+
fn test_hooks_timeout_zero() {
366+
let (_td, repo) = repo_init().unwrap();
367+
let root = repo.path().parent().unwrap();
368+
369+
let hook = b"#!/usr/bin/env sh
370+
sleep 1
371+
";
372+
373+
git2_hooks::create_hook(
374+
&repo,
375+
git2_hooks::HOOK_POST_COMMIT,
376+
hook,
377+
);
378+
379+
let res = hooks_post_commit_with_timeout(
380+
&root.to_str().unwrap().into(),
381+
Some(Duration::ZERO),
382+
)
383+
.unwrap();
384+
385+
assert_eq!(res, HookResult::Ok);
386+
}
387+
388+
#[test]
389+
fn test_run_with_timeout_kills() {
390+
let (_td, repo) = repo_init().unwrap();
391+
let root = repo.path().parent().unwrap();
392+
393+
let temp_dir = tempdir().expect("temp dir");
394+
let file = temp_dir.path().join("test");
395+
let hook = format!(
396+
"#!/usr/bin/env sh
397+
sleep 5
398+
echo 'after sleep' > {}
399+
",
400+
file.as_path().to_str().unwrap()
401+
);
402+
403+
git2_hooks::create_hook(
404+
&repo,
405+
git2_hooks::HOOK_PRE_COMMIT,
406+
hook.as_bytes(),
407+
);
408+
409+
let res = hooks_pre_commit_with_timeout(
410+
&root.to_str().unwrap().into(),
411+
Some(Duration::from_millis(100)),
412+
);
413+
414+
assert!(res.is_ok());
415+
assert!(!file.exists());
416+
}
417+
418+
#[test]
419+
#[cfg(unix)]
420+
fn test_ensure_group_kill_works() {
421+
let (_td, repo) = repo_init().unwrap();
422+
let root = repo.path().parent().unwrap();
423+
424+
let hook = b"#!/usr/bin/env sh
425+
sleep 30
426+
";
427+
428+
git2_hooks::create_hook(
429+
&repo,
430+
git2_hooks::HOOK_PRE_COMMIT,
431+
hook,
432+
);
433+
434+
let time_start = std::time::Instant::now();
435+
let res = hooks_pre_commit_with_timeout(
436+
&root.to_str().unwrap().into(),
437+
Some(Duration::from_millis(150)),
438+
)
439+
.unwrap();
440+
let time_end = std::time::Instant::now();
441+
let elapsed = time_end.duration_since(time_start);
442+
443+
log::info!("elapsed: {:?}", elapsed);
444+
// If the children didn't get killed this would
445+
// have taken the full 30 seconds.
446+
assert!(elapsed.as_secs() < 15);
447+
assert!(matches!(res, HookResult::TimedOut { .. }))
448+
}
242449
}

asyncgit/src/sync/mod.rs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,11 @@ pub use config::{
6666
pub use diff::get_diff_commit;
6767
pub use git2::BranchType;
6868
pub use hooks::{
69-
hooks_commit_msg, hooks_post_commit, hooks_pre_commit,
70-
hooks_prepare_commit_msg, HookResult, PrepareCommitMsgSource,
69+
hooks_commit_msg, hooks_commit_msg_with_timeout,
70+
hooks_post_commit, hooks_post_commit_with_timeout,
71+
hooks_pre_commit, hooks_pre_commit_with_timeout,
72+
hooks_prepare_commit_msg, hooks_prepare_commit_msg_with_timeout,
73+
HookResult, PrepareCommitMsgSource,
7174
};
7275
pub use hunks::{reset_hunk, stage_hunk, unstage_hunk};
7376
pub use ignore::add_to_ignore;

git2-hooks/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ gix-path = "0.10"
1818
log = "0.4"
1919
shellexpand = "3.1"
2020
thiserror = "2.0"
21+
nix = { version = "0.30.1", features = ["process", "signal"], default-features = false }
2122

2223
[dev-dependencies]
2324
git2-testing = { path = "../git2-testing" }

0 commit comments

Comments
 (0)