Skip to content

Add timeouts for commit hooks #2547

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* 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))
* dx: `make check` checks Cargo.toml dependency ordering using `cargo sort` [[@naseschwarz](https://github.com/naseschwarz)]
* 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))
* add timeouts for hooks [[@DaRacci](https://github.com/DaRacci)] ([#2547](https://github.com/gitui-org/gitui/pull/2547))

### Changed
* improve error messages [[@acuteenvy](https://github.com/acuteenvy)] ([#2617](https://github.com/gitui-org/gitui/pull/2617))
23 changes: 21 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

223 changes: 215 additions & 8 deletions asyncgit/src/sync/hooks.rs
Original file line number Diff line number Diff line change
@@ -2,6 +2,7 @@ use super::{repository::repo, RepoPath};
use crate::error::Result;
pub use git2_hooks::PrepareCommitMsgSource;
use scopetime::scope_time;
use std::time::Duration;

///
#[derive(Debug, PartialEq, Eq)]
@@ -10,6 +11,13 @@ pub enum HookResult {
Ok,
/// Hook returned error
NotOk(String),
/// Hook timed out
TimedOut {
/// Stdout
stdout: String,
/// Stderr
stderr: String,
},
}

impl From<git2_hooks::HookResult> for HookResult {
@@ -22,6 +30,11 @@ impl From<git2_hooks::HookResult> for HookResult {
stderr,
..
} => Self::NotOk(format!("{stdout}{stderr}")),
git2_hooks::HookResult::TimedOut {
stdout,
stderr,
..
} => Self::TimedOut { stdout, stderr },
}
}
}
@@ -30,30 +43,66 @@ impl From<git2_hooks::HookResult> for HookResult {
pub fn hooks_commit_msg(
repo_path: &RepoPath,
msg: &mut String,
) -> Result<HookResult> {
hooks_commit_msg_with_timeout(repo_path, msg, None)
}

/// see `git2_hooks::hooks_commit_msg`
#[allow(unused)]
pub fn hooks_commit_msg_with_timeout(
repo_path: &RepoPath,
msg: &mut String,
timeout: Option<Duration>,
) -> Result<HookResult> {
scope_time!("hooks_commit_msg");

let repo = repo(repo_path)?;

Ok(git2_hooks::hooks_commit_msg(&repo, None, msg)?.into())
Ok(git2_hooks::hooks_commit_msg_with_timeout(
&repo, None, msg, timeout,
)?
.into())
}

/// see `git2_hooks::hooks_pre_commit`
pub fn hooks_pre_commit(repo_path: &RepoPath) -> Result<HookResult> {
hooks_pre_commit_with_timeout(repo_path, None)
}

/// see `git2_hooks::hooks_pre_commit`
#[allow(unused)]
pub fn hooks_pre_commit_with_timeout(
repo_path: &RepoPath,
timeout: Option<Duration>,
) -> Result<HookResult> {
scope_time!("hooks_pre_commit");

let repo = repo(repo_path)?;

Ok(git2_hooks::hooks_pre_commit(&repo, None)?.into())
Ok(git2_hooks::hooks_pre_commit_with_timeout(
&repo, None, timeout,
)?
.into())
}

/// see `git2_hooks::hooks_post_commit`
pub fn hooks_post_commit(repo_path: &RepoPath) -> Result<HookResult> {
hooks_post_commit_with_timeout(repo_path, None)
}

/// see `git2_hooks::hooks_post_commit`
#[allow(unused)]
pub fn hooks_post_commit_with_timeout(
repo_path: &RepoPath,
timeout: Option<Duration>,
) -> Result<HookResult> {
scope_time!("hooks_post_commit");

let repo = repo(repo_path)?;

Ok(git2_hooks::hooks_post_commit(&repo, None)?.into())
Ok(git2_hooks::hooks_post_commit_with_timeout(
&repo, None, timeout,
)?
.into())
}

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

let repo = repo(repo_path)?;

Ok(git2_hooks::hooks_prepare_commit_msg(
&repo, None, source, msg,
Ok(git2_hooks::hooks_prepare_commit_msg_with_timeout(
&repo, None, source, msg, None,
)?
.into())
}

/// see `git2_hooks::hooks_prepare_commit_msg`
#[allow(unused)]
pub fn hooks_prepare_commit_msg_with_timeout(
repo_path: &RepoPath,
source: PrepareCommitMsgSource,
msg: &mut String,
timeout: Option<Duration>,
) -> Result<HookResult> {
scope_time!("hooks_prepare_commit_msg");

let repo = repo(repo_path)?;

Ok(git2_hooks::hooks_prepare_commit_msg_with_timeout(
&repo, None, source, msg, timeout,
)?
.into())
}
@@ -77,7 +144,7 @@ mod tests {
use std::{ffi::OsString, io::Write as _, path::Path};

use git2::Repository;
use tempfile::TempDir;
use tempfile::{tempdir, TempDir};

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

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

assert_eq!(msg, String::from("msg\n"));
}

#[test]
fn test_hooks_respect_timeout() {
let (_td, repo) = repo_init().unwrap();
let root = repo.path().parent().unwrap();

let hook = b"#!/usr/bin/env sh
sleep 0.250
";

git2_hooks::create_hook(
&repo,
git2_hooks::HOOK_PRE_COMMIT,
hook,
);

let res = hooks_pre_commit_with_timeout(
&root.to_str().unwrap().into(),
Some(Duration::from_millis(200)),
)
.unwrap();

assert_eq!(
res,
HookResult::TimedOut {
stdout: String::new(),
stderr: String::new()
}
);
}

#[test]
fn test_hooks_faster_than_timeout() {
let (_td, repo) = repo_init().unwrap();
let root = repo.path().parent().unwrap();

let hook = b"#!/usr/bin/env sh
sleep 0.1
";

git2_hooks::create_hook(
&repo,
git2_hooks::HOOK_PRE_COMMIT,
hook,
);

let res = hooks_pre_commit_with_timeout(
&root.to_str().unwrap().into(),
Some(Duration::from_millis(150)),
)
.unwrap();

assert_eq!(res, HookResult::Ok);
}

#[test]
fn test_hooks_timeout_zero() {
let (_td, repo) = repo_init().unwrap();
let root = repo.path().parent().unwrap();

let hook = b"#!/usr/bin/env sh
sleep 1
";

git2_hooks::create_hook(
&repo,
git2_hooks::HOOK_POST_COMMIT,
hook,
);

let res = hooks_post_commit_with_timeout(
&root.to_str().unwrap().into(),
Some(Duration::ZERO),
)
.unwrap();

assert_eq!(res, HookResult::Ok);
}

#[test]
fn test_run_with_timeout_kills() {
let (_td, repo) = repo_init().unwrap();
let root = repo.path().parent().unwrap();

let temp_dir = tempdir().expect("temp dir");
let file = temp_dir.path().join("test");
let hook = format!(
"#!/usr/bin/env sh
sleep 5
echo 'after sleep' > {}
",
file.as_path().to_str().unwrap()
);

git2_hooks::create_hook(
&repo,
git2_hooks::HOOK_PRE_COMMIT,
hook.as_bytes(),
);

let res = hooks_pre_commit_with_timeout(
&root.to_str().unwrap().into(),
Some(Duration::from_millis(100)),
);

assert!(res.is_ok());
assert!(!file.exists());
}

#[test]
#[cfg(unix)]
fn test_ensure_group_kill_works() {
let (_td, repo) = repo_init().unwrap();
let root = repo.path().parent().unwrap();

let hook = b"#!/usr/bin/env sh
sleep 30
";

git2_hooks::create_hook(
&repo,
git2_hooks::HOOK_PRE_COMMIT,
hook,
);

let time_start = std::time::Instant::now();
let res = hooks_pre_commit_with_timeout(
&root.to_str().unwrap().into(),
Some(Duration::from_millis(150)),
)
.unwrap();
let time_end = std::time::Instant::now();
let elapsed = time_end.duration_since(time_start);

log::info!("elapsed: {:?}", elapsed);
// If the children didn't get killed this would
// have taken the full 30 seconds.
assert!(elapsed.as_secs() < 15);
assert!(matches!(res, HookResult::TimedOut { .. }))
}
}
7 changes: 5 additions & 2 deletions asyncgit/src/sync/mod.rs
Original file line number Diff line number Diff line change
@@ -66,8 +66,11 @@ pub use config::{
pub use diff::get_diff_commit;
pub use git2::BranchType;
pub use hooks::{
hooks_commit_msg, hooks_post_commit, hooks_pre_commit,
hooks_prepare_commit_msg, HookResult, PrepareCommitMsgSource,
hooks_commit_msg, hooks_commit_msg_with_timeout,
hooks_post_commit, hooks_post_commit_with_timeout,
hooks_pre_commit, hooks_pre_commit_with_timeout,
hooks_prepare_commit_msg, hooks_prepare_commit_msg_with_timeout,
HookResult, PrepareCommitMsgSource,
};
pub use hunks::{reset_hunk, stage_hunk, unstage_hunk};
pub use ignore::add_to_ignore;
1 change: 1 addition & 0 deletions git2-hooks/Cargo.toml
Original file line number Diff line number Diff line change
@@ -18,6 +18,7 @@ gix-path = "0.10"
log = "0.4"
shellexpand = "3.1"
thiserror = "2.0"
nix = { version = "0.30.1", features = ["process", "signal"], default-features = false }

[dev-dependencies]
git2-testing = { path = "../git2-testing" }
385 changes: 317 additions & 68 deletions git2-hooks/src/hookspath.rs

Large diffs are not rendered by default.

176 changes: 151 additions & 25 deletions git2-hooks/src/lib.rs
Original file line number Diff line number Diff line change
@@ -31,6 +31,7 @@ use std::{
fs::File,
io::{Read, Write},
path::{Path, PathBuf},
time::Duration,
};

pub use error::HooksError;
@@ -59,7 +60,16 @@ pub enum HookResult {
RunNotSuccessful {
/// exit code as reported back from process calling the hook
code: Option<i32>,
/// stdout output emitted by hook
stdout: String,
/// stderr output emitted by hook
stderr: String,
/// path of the hook that was run
hook: PathBuf,
},
/// Hook took too long to execute and was killed
TimedOut {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Docs are missing.
Probably the crate could use a deny(missing_docs) as well.

Copy link
Author

@DaRacci DaRacci Mar 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I looked at adding deny(missing_docs) to the crate but there are a number of errors that are outside the scope of this PR.

/// stdout output emitted by hook
stdout: String,
/// stderr output emitted by hook
stderr: String,
@@ -78,6 +88,11 @@ impl HookResult {
pub const fn is_not_successful(&self) -> bool {
matches!(self, Self::RunNotSuccessful { .. })
}

/// helper to check if result was a timeout
pub const fn is_timeout(&self) -> bool {
matches!(self, Self::TimedOut { .. })
}
}

/// helper method to create git hooks programmatically (heavy used in unittests)
@@ -112,6 +127,16 @@ fn create_hook_in_path(path: &Path, hook_script: &[u8]) {
}
}

macro_rules! find_hook {
($repo:expr, $other_paths:expr, $hook_type:expr) => {{
let hook = HookPaths::new($repo, $other_paths, $hook_type)?;
if !hook.found() {
return Ok(HookResult::NoHookFound);
}
hook
}};
}

/// Git hook: `commit_msg`
///
/// This hook is documented here <https://git-scm.com/docs/githooks#_commit_msg>.
@@ -123,16 +148,25 @@ pub fn hooks_commit_msg(
other_paths: Option<&[&str]>,
msg: &mut String,
) -> Result<HookResult> {
let hook = HookPaths::new(repo, other_paths, HOOK_COMMIT_MSG)?;
hooks_commit_msg_with_timeout(repo, other_paths, msg, None)
}

if !hook.found() {
return Ok(HookResult::NoHookFound);
}
/// Git hook: `commit_msg`
///
/// See [`hooks_commit_msg`] for more details.
pub fn hooks_commit_msg_with_timeout(
repo: &Repository,
other_paths: Option<&[&str]>,
msg: &mut String,
timeout: Option<Duration>,
) -> Result<HookResult> {
let hook = find_hook!(repo, other_paths, HOOK_COMMIT_MSG);

let temp_file = hook.git.join(HOOK_COMMIT_MSG_TEMP_FILE);
File::create(&temp_file)?.write_all(msg.as_bytes())?;

let res = hook.run_hook_os_str([&temp_file])?;
let res =
hook.run_hook_with_timeout_os_str(&temp_file, timeout)?;

// load possibly altered msg
msg.clear();
@@ -146,29 +180,40 @@ pub fn hooks_pre_commit(
repo: &Repository,
other_paths: Option<&[&str]>,
) -> Result<HookResult> {
let hook = HookPaths::new(repo, other_paths, HOOK_PRE_COMMIT)?;
hooks_pre_commit_with_timeout(repo, other_paths, None)
}

if !hook.found() {
return Ok(HookResult::NoHookFound);
}
/// this hook is documented here <https://git-scm.com/docs/githooks#_pre_commit>
pub fn hooks_pre_commit_with_timeout(
repo: &Repository,
other_paths: Option<&[&str]>,
timeout: Option<Duration>,
) -> Result<HookResult> {
let hook = find_hook!(repo, other_paths, HOOK_PRE_COMMIT);

hook.run_hook(&[])
hook.run_hook_with_timeout(&[], timeout)
}

/// this hook is documented here <https://git-scm.com/docs/githooks#_post_commit>
pub fn hooks_post_commit(
repo: &Repository,
other_paths: Option<&[&str]>,
) -> Result<HookResult> {
let hook = HookPaths::new(repo, other_paths, HOOK_POST_COMMIT)?;
hooks_post_commit_with_timeout(repo, other_paths, None)
}

if !hook.found() {
return Ok(HookResult::NoHookFound);
}
/// this hook is documented here <https://git-scm.com/docs/githooks#_post_commit>
pub fn hooks_post_commit_with_timeout(
repo: &Repository,
other_paths: Option<&[&str]>,
timeout: Option<Duration>,
) -> Result<HookResult> {
let hook = find_hook!(repo, other_paths, HOOK_POST_COMMIT);

hook.run_hook(&[])
hook.run_hook_with_timeout(&[], timeout)
}

#[derive(Clone, Copy)]
pub enum PrepareCommitMsgSource {
Message,
Template,
@@ -185,12 +230,24 @@ pub fn hooks_prepare_commit_msg(
source: PrepareCommitMsgSource,
msg: &mut String,
) -> Result<HookResult> {
let hook =
HookPaths::new(repo, other_paths, HOOK_PREPARE_COMMIT_MSG)?;
hooks_prepare_commit_msg_with_timeout(
repo,
other_paths,
source,
msg,
None,
)
}

if !hook.found() {
return Ok(HookResult::NoHookFound);
}
/// this hook is documented here <https://git-scm.com/docs/githooks#_prepare_commit_msg>
pub fn hooks_prepare_commit_msg_with_timeout(
repo: &Repository,
other_paths: Option<&[&str]>,
source: PrepareCommitMsgSource,
msg: &mut String,
timeout: Option<Duration>,
) -> Result<HookResult> {
let hook = find_hook!(repo, other_paths, HOOK_PREPARE_COMMIT_MSG);

let temp_file = hook.git.join(HOOK_COMMIT_MSG_TEMP_FILE);
File::create(&temp_file)?.write_all(msg.as_bytes())?;
@@ -219,7 +276,7 @@ pub fn hooks_prepare_commit_msg(
args.push(id);
}

let res = hook.run_hook(args.as_slice())?;
let res = hook.run_hook_with_timeout(args.as_slice(), timeout)?;

// load possibly altered msg
msg.clear();
@@ -233,7 +290,7 @@ mod tests {
use super::*;
use git2_testing::{repo_init, repo_init_bare};
use pretty_assertions::assert_eq;
use tempfile::TempDir;
use tempfile::{tempdir, TempDir};

#[test]
fn test_smoke() {
@@ -372,7 +429,7 @@ exit 0
}

#[test]
fn test_other_path_precendence() {
fn test_other_path_precedence() {
let (td, repo) = repo_init();

{
@@ -493,7 +550,7 @@ exit 1
fn test_pre_commit_py() {
let (_td, repo) = repo_init();

// mirror how python pre-commmit sets itself up
// mirror how python pre-commit sets itself up
#[cfg(not(windows))]
let hook = b"#!/usr/bin/env python
import sys
@@ -514,7 +571,7 @@ sys.exit(0)
fn test_pre_commit_fail_py() {
let (_td, repo) = repo_init();

// mirror how python pre-commmit sets itself up
// mirror how python pre-commit sets itself up
#[cfg(not(windows))]
let hook = b"#!/usr/bin/env python
import sys
@@ -657,4 +714,73 @@ exit 2
)
);
}

#[test]
fn test_hooks_timeout_kills() {
let (_td, repo) = repo_init();

let hook = b"#!/usr/bin/env sh
sleep 0.25
";

create_hook(&repo, HOOK_PRE_COMMIT, hook);

let res = hooks_pre_commit_with_timeout(
&repo,
None,
Some(Duration::from_millis(200)),
)
.unwrap();

assert!(res.is_timeout());
}

#[test]
fn test_hooks_timeout_with_zero() {
let (_td, repo) = repo_init();

let hook = b"#!/usr/bin/env sh
sleep 0.25
";

create_hook(&repo, HOOK_PRE_COMMIT, hook);

let res = hooks_pre_commit_with_timeout(
&repo,
None,
Some(Duration::ZERO),
);

assert!(res.is_ok());
}

#[test]
fn test_hooks_timeout_faster_than_timeout() {
let (_td, repo) = repo_init();

let temp_dir = tempdir().expect("temp dir");
let file = temp_dir.path().join("test");
let hook = format!(
"#!/usr/bin/env sh
sleep 0.1
echo 'after sleep' > {}
",
file.to_str().unwrap()
);

create_hook(&repo, HOOK_PRE_COMMIT, hook.as_bytes());

let res = hooks_pre_commit_with_timeout(
&repo,
None,
Some(Duration::from_millis(150)),
);

assert!(res.is_ok());
assert!(file.exists());

let mut str = String::new();
File::open(file).unwrap().read_to_string(&mut str).unwrap();
assert!(str == "after sleep\n");
}
}
1 change: 1 addition & 0 deletions src/app.rs
Original file line number Diff line number Diff line change
@@ -846,6 +846,7 @@ impl App {
| AppOption::DiffInterhunkLines => {
self.status_tab.update_diff()?;
}
AppOption::HookTimeout => {}
}

flags.insert(NeedsUpdate::ALL);
13 changes: 13 additions & 0 deletions src/options.rs
Original file line number Diff line number Diff line change
@@ -14,6 +14,7 @@ use std::{
io::{Read, Write},
path::PathBuf,
rc::Rc,
time::Duration,
};

#[derive(Default, Clone, Serialize, Deserialize)]
@@ -22,6 +23,7 @@ struct OptionsData {
pub diff: DiffOptions,
pub status_show_untracked: Option<ShowUntrackedFilesConfig>,
pub commit_msgs: Vec<String>,
pub hook_timeout: Option<Duration>,
}

const COMMIT_MSG_HISTORY_LENGTH: usize = 20;
@@ -66,6 +68,11 @@ impl Options {
self.data.diff
}

#[allow(unused)]
pub const fn hook_timeout(&self) -> Option<Duration> {
self.data.hook_timeout
}

pub const fn status_show_untracked(
&self,
) -> Option<ShowUntrackedFilesConfig> {
@@ -137,6 +144,12 @@ impl Options {
}
}

#[allow(unused)]
pub fn set_hook_timeout(&mut self, timeout: Option<Duration>) {
self.data.hook_timeout = timeout;
self.save();
}

fn save(&self) {
if let Err(e) = self.save_failable() {
log::error!("options save error: {}", e);
117 changes: 89 additions & 28 deletions src/popups/commit.rs
Original file line number Diff line number Diff line change
@@ -28,6 +28,7 @@ use ratatui::{
Frame,
};

use std::time::Duration;
use std::{
fmt::Write as _,
fs::{read_to_string, File},
@@ -237,14 +238,32 @@ impl CommitPopup {

if verify {
// run pre commit hook - can reject commit
if let HookResult::NotOk(e) =
sync::hooks_pre_commit(&self.repo.borrow())?
{
log::error!("pre-commit hook error: {}", e);
self.queue.push(InternalEvent::ShowErrorMsg(
format!("pre-commit hook error:\n{e}"),
));
return Ok(CommitResult::Aborted);
match sync::hooks_pre_commit_with_timeout(
&self.repo.borrow(),
self.get_hook_timeout(),
)? {
HookResult::NotOk(e) => {
log::error!("pre-commit hook error: {}", e);
self.queue.push(InternalEvent::ShowErrorMsg(
format!("pre-commit hook error:\n{e}"),
));
return Ok(CommitResult::Aborted);
}
HookResult::TimedOut { stdout, stderr } => {
log::error!("pre-commit hook timed out");
self.queue.push(InternalEvent::ShowErrorMsg(
format!(
"pre-commit hook timed out after {} seconds, see output below.\n{}\n{}",
self.get_hook_timeout()
.unwrap_or(Duration::ZERO)
.as_secs(),
stdout,
stderr
),
));
return Ok(CommitResult::Aborted);
}
HookResult::Ok => {}
}
}

@@ -253,25 +272,61 @@ impl CommitPopup {

if verify {
// run commit message check hook - can reject commit
if let HookResult::NotOk(e) =
sync::hooks_commit_msg(&self.repo.borrow(), &mut msg)?
{
log::error!("commit-msg hook error: {}", e);
self.queue.push(InternalEvent::ShowErrorMsg(
format!("commit-msg hook error:\n{e}"),
));
return Ok(CommitResult::Aborted);
match sync::hooks_commit_msg_with_timeout(
&self.repo.borrow(),
&mut msg,
self.get_hook_timeout(),
)? {
HookResult::NotOk(e) => {
log::error!("commit-msg hook error: {}", e);
self.queue.push(InternalEvent::ShowErrorMsg(
format!("commit-msg hook error:\n{e}"),
));
return Ok(CommitResult::Aborted);
}
HookResult::TimedOut { stdout, stderr } => {
log::error!("commit-msg hook timed out");
self.queue.push(InternalEvent::ShowErrorMsg(
format!(
"commit-msg hook timed out after {} seconds, see output below.\n{}\n{}",
self.get_hook_timeout()
.unwrap_or(Duration::ZERO)
.as_secs(),
stdout,
stderr
),
));
return Ok(CommitResult::Aborted);
}
HookResult::Ok => {}
}
}
self.do_commit(&msg)?;

if let HookResult::NotOk(e) =
sync::hooks_post_commit(&self.repo.borrow())?
{
log::error!("post-commit hook error: {}", e);
self.queue.push(InternalEvent::ShowErrorMsg(format!(
"post-commit hook error:\n{e}"
)));
match sync::hooks_post_commit_with_timeout(
&self.repo.borrow(),
self.get_hook_timeout(),
)? {
HookResult::NotOk(e) => {
log::error!("post-commit hook error: {}", e);
self.queue.push(InternalEvent::ShowErrorMsg(
format!("post-commit hook error:\n{e}"),
));
}
HookResult::TimedOut { stdout, stderr } => {
log::error!("post-commit hook timed out");
self.queue.push(InternalEvent::ShowErrorMsg(
format!(
"post-commit hook timed out after {} seconds, see output below.\n{}\n{}",
self.get_hook_timeout()
.unwrap_or(Duration::ZERO)
.as_secs(),
stdout,
stderr
),
));
}
HookResult::Ok => {}
}

Ok(CommitResult::CommitDone)
@@ -441,11 +496,13 @@ impl CommitPopup {
self.mode = mode;

let mut msg = self.input.get_text().to_string();
if let HookResult::NotOk(e) = sync::hooks_prepare_commit_msg(
&self.repo.borrow(),
msg_source,
&mut msg,
)? {
if let HookResult::NotOk(e) =
sync::hooks_prepare_commit_msg_with_timeout(
&self.repo.borrow(),
msg_source,
&mut msg,
self.get_hook_timeout(),
)? {
log::error!("prepare-commit-msg hook rejection: {e}",);
}
self.input.set_text(msg);
@@ -477,6 +534,10 @@ impl CommitPopup {

Ok(msg)
}

fn get_hook_timeout(&self) -> Option<Duration> {
self.options.borrow().hook_timeout()
}
}

impl DrawableComponent for CommitPopup {
49 changes: 47 additions & 2 deletions src/popups/options.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
use std::time::Duration;

use crate::{
app::Environment,
components::{
@@ -27,6 +29,7 @@ pub enum AppOption {
DiffIgnoreWhitespaces,
DiffContextLines,
DiffInterhunkLines,
HookTimeout,
}

pub struct OptionsPopup {
@@ -99,6 +102,19 @@ impl OptionsPopup {
&diff.interhunk_lines.to_string(),
self.is_select(AppOption::DiffInterhunkLines),
);
Self::add_header(txt, "");

Self::add_header(txt, "Hooks");
self.add_entry(
txt,
width,
"Timeout",
&self.options.borrow().hook_timeout().map_or_else(
|| "None".to_string(),
|d| format!("{d:?}"),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using fmt::Debug straight into the UI seems smelly.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was thinking the same thing originally but writing some redundant code or adding a new library to handle it seems like overkill for a single use.

),
self.is_select(AppOption::HookTimeout),
);
}

fn is_select(&self, kind: AppOption) -> bool {
@@ -138,7 +154,7 @@ impl OptionsPopup {
if up {
self.selection = match self.selection {
AppOption::StatusShowUntracked => {
AppOption::DiffInterhunkLines
AppOption::HookTimeout
}
AppOption::DiffIgnoreWhitespaces => {
AppOption::StatusShowUntracked
@@ -149,6 +165,9 @@ impl OptionsPopup {
AppOption::DiffInterhunkLines => {
AppOption::DiffContextLines
}
AppOption::HookTimeout => {
AppOption::DiffInterhunkLines
}
};
} else {
self.selection = match self.selection {
@@ -162,6 +181,9 @@ impl OptionsPopup {
AppOption::DiffInterhunkLines
}
AppOption::DiffInterhunkLines => {
AppOption::HookTimeout
}
AppOption::HookTimeout => {
AppOption::StatusShowUntracked
}
};
@@ -207,6 +229,14 @@ impl OptionsPopup {
.borrow_mut()
.diff_hunk_lines_change(true);
}
AppOption::HookTimeout => {
let current =
self.options.borrow().hook_timeout();
let inc = Duration::from_secs(1);
let new = current.map(|d| d + inc).or(Some(inc));

self.options.borrow_mut().set_hook_timeout(new);
}
}
} else {
match self.selection {
@@ -246,6 +276,21 @@ impl OptionsPopup {
.borrow_mut()
.diff_hunk_lines_change(false);
}
AppOption::HookTimeout => {
let current =
self.options.borrow().hook_timeout();
let dec = Duration::from_secs(1);

let new = current.and_then(|d| {
if d > dec {
Some(d - dec)
} else {
None
}
});

self.options.borrow_mut().set_hook_timeout(new);
}
}
}

@@ -257,7 +302,7 @@ impl OptionsPopup {
impl DrawableComponent for OptionsPopup {
fn draw(&self, f: &mut Frame, area: Rect) -> Result<()> {
if self.is_visible() {
const SIZE: (u16, u16) = (50, 10);
const SIZE: (u16, u16) = (50, 12);
let area =
ui::centered_rect_absolute(SIZE.0, SIZE.1, area);