GB-700: support user triggered pre commit hook (#1960)

* run pre commit hook
* unittest
* support custom search path: `.husky`
This commit is contained in:
extrawurst 2023-12-12 17:15:35 +01:00 committed by GitHub
parent 043e0de45b
commit 8d917494ed
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 118 additions and 0 deletions

22
Cargo.lock generated
View File

@ -1763,6 +1763,18 @@ dependencies = [
"url", "url",
] ]
[[package]]
name = "git2-hooks"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fa76beea92a16bbdf05842f0c6d9611bbb7eef764b34dde223359077463b800"
dependencies = [
"git2",
"log",
"shellexpand",
"thiserror",
]
[[package]] [[package]]
name = "gitbutler" name = "gitbutler"
version = "0.0.0" version = "0.0.0"
@ -1778,6 +1790,7 @@ dependencies = [
"filetime", "filetime",
"futures", "futures",
"git2", "git2",
"git2-hooks",
"governor", "governor",
"itertools 0.12.0", "itertools 0.12.0",
"lazy_static", "lazy_static",
@ -4596,6 +4609,15 @@ dependencies = [
"lazy_static", "lazy_static",
] ]
[[package]]
name = "shellexpand"
version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da03fa3b94cc19e3ebfc88c4229c49d8f08cdbd1228870a45f0ffdf84988e14b"
dependencies = [
"dirs",
]
[[package]] [[package]]
name = "signal-hook" name = "signal-hook"
version = "0.3.17" version = "0.3.17"

View File

@ -32,6 +32,7 @@ diffy = "0.3.0"
filetime = "0.2.22" filetime = "0.2.22"
futures = "0.3" futures = "0.3"
git2 = { version = "0.18.1", features = ["vendored-openssl", "vendored-libgit2"] } git2 = { version = "0.18.1", features = ["vendored-openssl", "vendored-libgit2"] }
git2-hooks = "0.3"
governor = "0.6.0" governor = "0.6.0"
itertools = "0.12" itertools = "0.12"
lazy_static = "1.4.0" lazy_static = "1.4.0"

View File

@ -13,6 +13,7 @@ pub enum Code {
ProjectConflict, ProjectConflict,
ProjectHead, ProjectHead,
Menu, Menu,
Hook,
} }
impl fmt::Display for Code { impl fmt::Display for Code {
@ -27,6 +28,7 @@ impl fmt::Display for Code {
Code::ProjectGitRemote => write!(f, "errors.projects.git.remote"), Code::ProjectGitRemote => write!(f, "errors.projects.git.remote"),
Code::ProjectHead => write!(f, "errors.projects.head"), Code::ProjectHead => write!(f, "errors.projects.head"),
Code::ProjectConflict => write!(f, "errors.projects.conflict"), Code::ProjectConflict => write!(f, "errors.projects.conflict"),
Code::Hook => write!(f, "errors.hook"),
} }
} }
} }

View File

@ -14,6 +14,8 @@ pub enum Error {
Io(#[from] std::io::Error), Io(#[from] std::io::Error),
#[error("network error: {0}")] #[error("network error: {0}")]
Network(git2::Error), Network(git2::Error),
#[error("hook error: {0}")]
Hooks(#[from] git2_hooks::HooksError),
#[error(transparent)] #[error(transparent)]
Other(git2::Error), Other(git2::Error),
} }

View File

@ -1,6 +1,7 @@
use std::{path, str}; use std::{path, str};
use git2::Submodule; use git2::Submodule;
use git2_hooks::HookResult;
use crate::keys; use crate::keys;
@ -424,6 +425,11 @@ impl Repository {
.map(|iter| iter.map(|reference| reference.map(Into::into).map_err(Into::into))) .map(|iter| iter.map(|reference| reference.map(Into::into).map_err(Into::into)))
.map_err(Into::into) .map_err(Into::into)
} }
pub fn run_hook_pre_commit(&self) -> Result<HookResult> {
let res = git2_hooks::hooks_pre_commit(&self.0, Some(&["../.husky"]))?;
Ok(res)
}
} }
pub struct CheckoutTreeBuidler<'a> { pub struct CheckoutTreeBuidler<'a> {

View File

@ -144,6 +144,8 @@ pub enum CommitError {
DefaultTargetNotSet(DefaultTargetNotSetError), DefaultTargetNotSet(DefaultTargetNotSetError),
#[error("will not commit conflicted files")] #[error("will not commit conflicted files")]
Conflicted(ProjectConflictError), Conflicted(ProjectConflictError),
#[error("commit hook rejected")]
CommitHookRejected(String),
#[error(transparent)] #[error(transparent)]
Other(#[from] anyhow::Error), Other(#[from] anyhow::Error),
} }
@ -410,6 +412,10 @@ impl From<CommitError> for Error {
CommitError::BranchNotFound(error) => error.into(), CommitError::BranchNotFound(error) => error.into(),
CommitError::DefaultTargetNotSet(error) => error.into(), CommitError::DefaultTargetNotSet(error) => error.into(),
CommitError::Conflicted(error) => error.into(), CommitError::Conflicted(error) => error.into(),
CommitError::CommitHookRejected(error) => Error::UserError {
code: crate::error::Code::Hook,
message: error,
},
CommitError::Other(error) => { CommitError::Other(error) => {
tracing::error!(?error, "commit error"); tracing::error!(?error, "commit error");
Error::Unknown Error::Unknown

View File

@ -14,6 +14,7 @@ use pretty_assertions::{assert_eq, assert_ne};
use crate::{ use crate::{
gb_repository, git, project_repository, reader, sessions, gb_repository, git, project_repository, reader, sessions,
test_utils::{self, empty_bare_repository, Case, Suite}, test_utils::{self, empty_bare_repository, Case, Suite},
virtual_branches::errors::CommitError,
}; };
use super::*; use super::*;
@ -2740,3 +2741,71 @@ fn test_verify_branch_not_integration() -> Result<()> {
Ok(()) Ok(())
} }
#[test]
fn test_pre_commit_hook_rejection() -> Result<()> {
let suite = Suite::default();
let Case {
project,
gb_repository,
project_repository,
..
} = suite.new_case_with_files(HashMap::from([
(
path::PathBuf::from("test.txt"),
"line1\nline2\nline3\nline4\n",
),
(
path::PathBuf::from("test2.txt"),
"line5\nline6\nline7\nline8\n",
),
]));
set_test_target(&gb_repository, &project_repository)?;
let branch1_id = create_virtual_branch(
&gb_repository,
&project_repository,
&BranchCreateRequest::default(),
)
.expect("failed to create virtual branch")
.id;
std::fs::write(
std::path::Path::new(&project.path).join("test.txt"),
"line0\nline1\nline2\nline3\nline4\n",
)?;
let hook = b"#!/bin/sh
echo 'rejected'
exit 1
";
git2_hooks::create_hook(
(&project_repository.git_repository).into(),
git2_hooks::HOOK_PRE_COMMIT,
hook,
);
let res = commit(
&gb_repository,
&project_repository,
&branch1_id,
"test commit",
None,
Some(suite.keys.get_or_create()?).as_ref(),
None,
);
let error = res.unwrap_err();
assert!(matches!(error, CommitError::CommitHookRejected(_)));
let CommitError::CommitHookRejected(output) = error else {
unreachable!()
};
assert_eq!(&output, "rejected\n");
Ok(())
}

View File

@ -6,6 +6,7 @@ use std::{
use anyhow::{bail, Context, Result}; use anyhow::{bail, Context, Result};
use diffy::{apply_bytes, Patch}; use diffy::{apply_bytes, Patch};
use git2_hooks::HookResult;
use serde::Serialize; use serde::Serialize;
use slug::slugify; use slug::slugify;
@ -2009,6 +2010,15 @@ pub fn commit(
signing_key: Option<&keys::PrivateKey>, signing_key: Option<&keys::PrivateKey>,
user: Option<&users::User>, user: Option<&users::User>,
) -> Result<git::Oid, errors::CommitError> { ) -> Result<git::Oid, errors::CommitError> {
let hook_result = project_repository
.git_repository
.run_hook_pre_commit()
.context("failed to run hook")?;
if let HookResult::RunNotSuccessful { stdout, .. } = hook_result {
return Err(errors::CommitError::CommitHookRejected(stdout));
}
let default_target = gb_repository let default_target = gb_repository
.default_target() .default_target()
.context("failed to get default target")? .context("failed to get default target")?