proper pre-push hook implementation (#2811)

This commit is contained in:
extrawurst 2026-01-18 17:37:45 +01:00 committed by GitHub
parent 7747d829cc
commit 1c118d75f3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 934 additions and 106 deletions

View file

@ -1,7 +1,19 @@
use super::{repository::repo, RepoPath};
use crate::error::Result;
pub use git2_hooks::PrepareCommitMsgSource;
use crate::{
error::Result,
sync::{
branch::get_branch_upstream_merge,
config::{
push_default_strategy_config_repo,
PushDefaultStrategyConfig,
},
remotes::{proxy_auto, tags::tags_missing_remote, Callbacks},
},
};
use git2::{BranchType, Direction, Oid};
pub use git2_hooks::{PrePushRef, PrepareCommitMsgSource};
use scopetime::scope_time;
use std::collections::HashMap;
///
#[derive(Debug, PartialEq, Eq)]
@ -15,17 +27,91 @@ pub enum HookResult {
impl From<git2_hooks::HookResult> for HookResult {
fn from(v: git2_hooks::HookResult) -> Self {
match v {
git2_hooks::HookResult::Ok { .. }
| git2_hooks::HookResult::NoHookFound => Self::Ok,
git2_hooks::HookResult::RunNotSuccessful {
stdout,
stderr,
..
} => Self::NotOk(format!("{stdout}{stderr}")),
git2_hooks::HookResult::NoHookFound => Self::Ok,
git2_hooks::HookResult::Run(response) => {
if response.is_successful() {
Self::Ok
} else {
Self::NotOk(if response.stderr.is_empty() {
response.stdout
} else if response.stdout.is_empty() {
response.stderr
} else {
format!(
"{}\n{}",
response.stdout, response.stderr
)
})
}
}
}
}
}
/// Retrieve advertised refs from the remote for the upcoming push.
fn advertised_remote_refs(
repo_path: &RepoPath,
remote: Option<&str>,
url: &str,
basic_credential: Option<crate::sync::cred::BasicAuthCredential>,
) -> Result<HashMap<String, Oid>> {
let repo = repo(repo_path)?;
let mut remote_handle = if let Some(name) = remote {
repo.find_remote(name)?
} else {
repo.remote_anonymous(url)?
};
let callbacks = Callbacks::new(None, basic_credential);
let conn = remote_handle.connect_auth(
Direction::Push,
Some(callbacks.callbacks()),
Some(proxy_auto()),
)?;
let mut map = HashMap::new();
for head in conn.list()? {
map.insert(head.name().to_string(), head.oid());
}
Ok(map)
}
/// Determine the remote ref name for a branch push.
///
/// Respects `push.default=upstream` config when set and upstream is configured.
/// Otherwise defaults to `refs/heads/{branch}`. Delete operations always use
/// the simple ref name.
fn get_remote_ref_for_push(
repo_path: &RepoPath,
branch: &str,
delete: bool,
) -> Result<String> {
// For delete operations, always use the simple ref name
// regardless of push.default configuration
if delete {
return Ok(format!("refs/heads/{branch}"));
}
let repo = repo(repo_path)?;
let push_default_strategy =
push_default_strategy_config_repo(&repo)?;
// When push.default=upstream, use the configured upstream ref if available
if push_default_strategy == PushDefaultStrategyConfig::Upstream {
if let Ok(Some(upstream_ref)) =
get_branch_upstream_merge(repo_path, branch)
{
return Ok(upstream_ref);
}
// If upstream strategy is set but no upstream is configured,
// fall through to default behavior
}
// Default: push to remote branch with same name as local
Ok(format!("refs/heads/{branch}"))
}
/// see `git2_hooks::hooks_commit_msg`
pub fn hooks_commit_msg(
repo_path: &RepoPath,
@ -73,12 +159,133 @@ pub fn hooks_prepare_commit_msg(
}
/// see `git2_hooks::hooks_pre_push`
pub fn hooks_pre_push(repo_path: &RepoPath) -> Result<HookResult> {
pub fn hooks_pre_push(
repo_path: &RepoPath,
remote: &str,
push: &PrePushTarget<'_>,
basic_credential: Option<crate::sync::cred::BasicAuthCredential>,
) -> Result<HookResult> {
scope_time!("hooks_pre_push");
let repo = repo(repo_path)?;
if !git2_hooks::hook_available(
&repo,
None,
git2_hooks::HOOK_PRE_PUSH,
)? {
return Ok(HookResult::Ok);
}
Ok(git2_hooks::hooks_pre_push(&repo, None)?.into())
let git_remote = repo.find_remote(remote)?;
let url = git_remote
.pushurl()
.or_else(|| git_remote.url())
.ok_or_else(|| {
crate::error::Error::Generic(format!(
"remote '{remote}' has no URL configured"
))
})?
.to_string();
let advertised = advertised_remote_refs(
repo_path,
Some(remote),
&url,
basic_credential,
)?;
let updates = match push {
PrePushTarget::Branch { branch, delete } => {
let remote_ref =
get_remote_ref_for_push(repo_path, branch, *delete)?;
vec![pre_push_branch_update(
repo_path,
branch,
&remote_ref,
*delete,
&advertised,
)?]
}
PrePushTarget::Tags => {
pre_push_tag_updates(repo_path, remote, &advertised)?
}
};
Ok(git2_hooks::hooks_pre_push(
&repo,
None,
Some(remote),
&url,
&updates,
)?
.into())
}
/// Build a single pre-push update line for a branch.
fn pre_push_branch_update(
repo_path: &RepoPath,
branch_name: &str,
remote_ref: &str,
delete: bool,
advertised: &HashMap<String, Oid>,
) -> Result<PrePushRef> {
let repo = repo(repo_path)?;
let local_ref = format!("refs/heads/{branch_name}");
let local_oid = (!delete)
.then(|| {
repo.find_branch(branch_name, BranchType::Local)
.ok()
.and_then(|branch| branch.get().peel_to_commit().ok())
.map(|commit| commit.id())
})
.flatten();
let remote_oid = advertised.get(remote_ref).copied();
Ok(PrePushRef::new(
local_ref, local_oid, remote_ref, remote_oid,
))
}
/// Build pre-push updates for tags that are missing on the remote.
fn pre_push_tag_updates(
repo_path: &RepoPath,
remote: &str,
advertised: &HashMap<String, Oid>,
) -> Result<Vec<PrePushRef>> {
let repo = repo(repo_path)?;
let tags = tags_missing_remote(repo_path, remote, None)?;
let mut updates = Vec::with_capacity(tags.len());
for tag_ref in tags {
if let Ok(reference) = repo.find_reference(&tag_ref) {
let tag_oid = reference.target().or_else(|| {
reference.peel_to_commit().ok().map(|c| c.id())
});
let remote_ref = tag_ref.clone();
let advertised_oid = advertised.get(&remote_ref).copied();
updates.push(PrePushRef::new(
tag_ref.clone(),
tag_oid,
remote_ref,
advertised_oid,
));
}
}
Ok(updates)
}
/// What is being pushed.
pub enum PrePushTarget<'a> {
/// Push a single branch.
Branch {
/// Local branch name being pushed.
branch: &'a str,
/// Whether this is a delete push.
delete: bool,
},
/// Push tags.
Tags,
}
#[cfg(test)]
@ -248,4 +455,47 @@ mod tests {
assert_eq!(msg, String::from("msg\n"));
}
#[test]
fn test_pre_push_hook_rejects_based_on_stdin() {
let (_td, repo) = repo_init().unwrap();
let hook = b"#!/bin/sh
cat
exit 1
";
git2_hooks::create_hook(
&repo,
git2_hooks::HOOK_PRE_PUSH,
hook,
);
let commit_id = repo.head().unwrap().target().unwrap();
let update = git2_hooks::PrePushRef::new(
"refs/heads/master",
Some(commit_id),
"refs/heads/master",
None,
);
let expected_stdin =
git2_hooks::PrePushRef::to_stdin(&[update.clone()]);
let res = git2_hooks::hooks_pre_push(
&repo,
None,
Some("origin"),
"https://github.com/test/repo.git",
&[update],
)
.unwrap();
let git2_hooks::HookResult::Run(response) = res else {
panic!("Expected Run result");
};
assert!(!response.is_successful());
assert_eq!(response.stdout, expected_stdin);
assert!(expected_stdin.contains("refs/heads/master"));
}
}

View file

@ -68,7 +68,7 @@ pub use git2::BranchType;
pub use hooks::{
hooks_commit_msg, hooks_post_commit, hooks_pre_commit,
hooks_pre_push, hooks_prepare_commit_msg, HookResult,
PrepareCommitMsgSource,
PrePushTarget, PrepareCommitMsgSource,
};
pub use hunks::{reset_hunk, stage_hunk, unstage_hunk};
pub use ignore::add_to_ignore;

View file

@ -14,6 +14,9 @@ pub enum HooksError {
#[error("shellexpand error:{0}")]
ShellExpand(#[from] shellexpand::LookupError<std::env::VarError>),
#[error("hook process terminated by signal without exit code")]
NoExitCode,
}
/// crate specific `Result` type

View file

@ -141,6 +141,20 @@ impl HookPaths {
/// this function calls hook scripts based on conventions documented here
/// see <https://git-scm.com/docs/githooks>
pub fn run_hook_os_str<I, S>(&self, args: I) -> Result<HookResult>
where
I: IntoIterator<Item = S> + Copy,
S: AsRef<OsStr>,
{
self.run_hook_os_str_with_stdin(args, None)
}
/// this function calls hook scripts with stdin input based on conventions documented here
/// see <https://git-scm.com/docs/githooks>
pub fn run_hook_os_str_with_stdin<I, S>(
&self,
args: I,
stdin: Option<&[u8]>,
) -> Result<HookResult>
where
I: IntoIterator<Item = S> + Copy,
S: AsRef<OsStr>,
@ -153,11 +167,42 @@ impl HookPaths {
);
let run_command = |command: &mut Command| {
command
let mut child = command
.args(args)
.current_dir(&self.pwd)
.with_no_window()
.output()
.stdin(if stdin.is_some() {
std::process::Stdio::piped()
} else {
std::process::Stdio::null()
})
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()?;
if let (Some(mut stdin_handle), Some(input)) =
(child.stdin.take(), stdin)
{
use std::io::{ErrorKind, Write};
// Write stdin to hook process
// Ignore broken pipe - hook may exit early without reading all input
let _ =
stdin_handle.write_all(input).inspect_err(|e| {
match e.kind() {
ErrorKind::BrokenPipe => {
log::debug!(
"Hook closed stdin early"
);
}
_ => log::warn!(
"Failed to write stdin to hook: {e}"
),
}
});
}
child.wait_with_output()
};
let output = if cfg!(windows) {
@ -210,21 +255,21 @@ impl HookPaths {
}
}?;
if output.status.success() {
Ok(HookResult::Ok { hook })
} else {
let stderr =
String::from_utf8_lossy(&output.stderr).to_string();
let stdout =
String::from_utf8_lossy(&output.stdout).to_string();
let stderr =
String::from_utf8_lossy(&output.stderr).to_string();
let stdout =
String::from_utf8_lossy(&output.stdout).to_string();
Ok(HookResult::RunNotSuccessful {
code: output.status.code(),
stdout,
stderr,
hook,
})
}
// Get exit code, or fail if process was killed by signal
let code =
output.status.code().ok_or(HooksError::NoExitCode)?;
Ok(HookResult::Run(crate::HookRunResponse {
hook,
stdout,
stderr,
code,
}))
}
}

View file

@ -38,7 +38,7 @@ pub use error::HooksError;
use error::Result;
use hookspath::HookPaths;
use git2::Repository;
use git2::{Oid, Repository};
pub const HOOK_POST_COMMIT: &str = "post-commit";
pub const HOOK_PRE_COMMIT: &str = "pre-commit";
@ -48,37 +48,98 @@ pub const HOOK_PRE_PUSH: &str = "pre-push";
const HOOK_COMMIT_MSG_TEMP_FILE: &str = "COMMIT_EDITMSG";
/// Check if a given hook is present considering config/paths and optional extra paths.
pub fn hook_available(
repo: &Repository,
other_paths: Option<&[&str]>,
hook: &str,
) -> Result<bool> {
let hook = HookPaths::new(repo, other_paths, hook)?;
Ok(hook.found())
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PrePushRef {
pub local_ref: String,
pub local_oid: Option<Oid>,
pub remote_ref: String,
pub remote_oid: Option<Oid>,
}
impl PrePushRef {
pub fn new(
local_ref: impl Into<String>,
local_oid: Option<Oid>,
remote_ref: impl Into<String>,
remote_oid: Option<Oid>,
) -> Self {
Self {
local_ref: local_ref.into(),
local_oid,
remote_ref: remote_ref.into(),
remote_oid,
}
}
fn format_oid(oid: Option<Oid>) -> String {
// "If the foreign ref does not yet exist the <remote-object-name> will be the all-zeroes object name"
// see https://git-scm.com/docs/githooks#_pre_push
oid.map_or_else(|| "0".repeat(40), |id| id.to_string())
}
pub fn to_line(&self) -> String {
format!(
"{} {} {} {}",
self.local_ref,
Self::format_oid(self.local_oid),
self.remote_ref,
Self::format_oid(self.remote_oid)
)
}
/// Build stdin content from a slice of updates (for pre-push hook)
pub fn to_stdin(updates: &[Self]) -> String {
let mut stdin = String::new();
for update in updates {
stdin.push_str(&update.to_line());
stdin.push('\n');
}
stdin
}
}
/// Response from running a hook
#[derive(Debug, PartialEq, Eq)]
pub struct HookRunResponse {
/// path of the hook that was run
pub hook: PathBuf,
/// stdout output emitted by hook
pub stdout: String,
/// stderr output emitted by hook
pub stderr: String,
/// exit code as reported back from process calling the hook (0 = success)
pub code: i32,
}
#[derive(Debug, PartialEq, Eq)]
pub enum HookResult {
/// No hook found
NoHookFound,
/// Hook executed with non error return code
Ok {
/// path of the hook that was run
hook: PathBuf,
},
/// Hook executed and returned an error code
RunNotSuccessful {
/// exit code as reported back from process calling the hook
code: Option<i32>,
/// stderr output emitted by hook
stdout: String,
/// stderr output emitted by hook
stderr: String,
/// path of the hook that was run
hook: PathBuf,
},
/// Hook executed (check `HookRunResponse.code` for success/failure)
Run(HookRunResponse),
}
impl HookResult {
/// helper to check if result is ok
pub const fn is_ok(&self) -> bool {
matches!(self, Self::Ok { .. })
/// helper to check if hook ran successfully (found and exit code 0)
pub const fn is_successful(&self) -> bool {
matches!(self, Self::Run(response) if response.is_successful())
}
}
/// helper to check if result was run and not rejected
pub const fn is_not_successful(&self) -> bool {
matches!(self, Self::RunNotSuccessful { .. })
impl HookRunResponse {
/// Check if the hook succeeded (exit code 0)
pub const fn is_successful(&self) -> bool {
self.code == 0
}
}
@ -172,9 +233,23 @@ pub fn hooks_post_commit(
}
/// this hook is documented here <https://git-scm.com/docs/githooks#_pre_push>
///
/// According to git documentation, pre-push hook receives:
/// - remote name as first argument (or URL if remote is not named)
/// - remote URL as second argument
/// - information about refs being pushed via stdin in format:
/// `<local-ref> SP <local-object-name> SP <remote-ref> SP <remote-object-name> LF`
///
/// If `remote` is `None` or empty, the `url` is used for both arguments as per Git spec.
///
/// Note: The hook is called even when `updates` is empty (matching Git's behavior).
/// This can occur when pushing tags that already exist on the remote.
pub fn hooks_pre_push(
repo: &Repository,
other_paths: Option<&[&str]>,
remote: Option<&str>,
url: &str,
updates: &[PrePushRef],
) -> Result<HookResult> {
let hook = HookPaths::new(repo, other_paths, HOOK_PRE_PUSH)?;
@ -182,7 +257,18 @@ pub fn hooks_pre_push(
return Ok(HookResult::NoHookFound);
}
hook.run_hook(&[])
// If a remote is not named (None or empty), the URL is passed for both arguments
let remote_name = match remote {
Some(r) if !r.is_empty() => r,
_ => url,
};
let stdin_data = PrePushRef::to_stdin(updates);
hook.run_hook_os_str_with_stdin(
[remote_name, url],
Some(stdin_data.as_bytes()),
)
}
pub enum PrepareCommitMsgSource {
@ -251,6 +337,110 @@ mod tests {
use pretty_assertions::assert_eq;
use tempfile::TempDir;
fn branch_update(
repo: &Repository,
remote: Option<&str>,
branch: &str,
remote_branch: Option<&str>,
delete: bool,
) -> PrePushRef {
let local_ref = format!("refs/heads/{branch}");
let local_oid = (!delete).then(|| {
repo.find_branch(branch, git2::BranchType::Local)
.unwrap()
.get()
.peel_to_commit()
.unwrap()
.id()
});
let remote_branch = remote_branch.unwrap_or(branch);
let remote_ref = format!("refs/heads/{remote_branch}");
let remote_oid = remote.and_then(|remote_name| {
repo.find_reference(&format!(
"refs/remotes/{remote_name}/{remote_branch}"
))
.ok()
.and_then(|r| r.peel_to_commit().ok())
.map(|c| c.id())
});
PrePushRef::new(local_ref, local_oid, remote_ref, remote_oid)
}
fn head_branch(repo: &Repository) -> String {
repo.head().unwrap().shorthand().unwrap().to_string()
}
#[test]
fn test_pre_push_ref_format() {
let zero_oid = "0".repeat(40);
let oid_a = "a".repeat(40);
let oid_b = "b".repeat(40);
// Both oids present
let update = PrePushRef::new(
"refs/heads/main",
Some(git2::Oid::from_str(&oid_a).unwrap()),
"refs/heads/main",
Some(git2::Oid::from_str(&oid_b).unwrap()),
);
assert_eq!(
update.to_line(),
format!(
"refs/heads/main {oid_a} refs/heads/main {oid_b}"
)
);
// No remote oid (new branch)
let update = PrePushRef::new(
"refs/heads/feature",
Some(git2::Oid::from_str(&oid_a).unwrap()),
"refs/heads/feature",
None,
);
assert_eq!(
update.to_line(),
format!("refs/heads/feature {oid_a} refs/heads/feature {zero_oid}")
);
// No local oid (delete)
let update = PrePushRef::new(
"refs/heads/old",
None,
"refs/heads/old",
Some(git2::Oid::from_str(&oid_b).unwrap()),
);
assert_eq!(
update.to_line(),
format!(
"refs/heads/old {zero_oid} refs/heads/old {oid_b}"
)
);
// to_stdin adds newlines
let updates = [
PrePushRef::new(
"refs/heads/a",
Some(git2::Oid::from_str(&oid_a).unwrap()),
"refs/heads/a",
None,
),
PrePushRef::new(
"refs/heads/b",
Some(git2::Oid::from_str(&oid_b).unwrap()),
"refs/heads/b",
None,
),
];
assert_eq!(
PrePushRef::to_stdin(&updates),
format!(
"refs/heads/a {oid_a} refs/heads/a {zero_oid}\nrefs/heads/b {oid_b} refs/heads/b {zero_oid}\n"
)
);
}
#[test]
fn test_smoke() {
let (_td, repo) = repo_init();
@ -268,7 +458,7 @@ exit 0
let res = hooks_post_commit(&repo, None).unwrap();
assert!(res.is_ok());
assert!(res.is_successful());
}
#[test]
@ -284,7 +474,7 @@ exit 0
let mut msg = String::from("test");
let res = hooks_commit_msg(&repo, None, &mut msg).unwrap();
assert!(res.is_ok());
assert!(res.is_successful());
assert_eq!(msg, String::from("test"));
}
@ -304,7 +494,7 @@ exit 0
let mut msg = String::from("test_sth");
let res = hooks_commit_msg(&repo, None, &mut msg).unwrap();
assert!(res.is_ok());
assert!(res.is_successful());
assert_eq!(msg, String::from("test_shell_command"));
}
@ -319,7 +509,7 @@ exit 0
create_hook(&repo, HOOK_PRE_COMMIT, hook);
let res = hooks_pre_commit(&repo, None).unwrap();
assert!(res.is_ok());
assert!(res.is_successful());
}
#[test]
@ -339,22 +529,16 @@ exit 0
let result = hook.run_hook(&[TEXT]).unwrap();
let HookResult::RunNotSuccessful {
code,
stdout,
stderr,
hook: h,
} = result
else {
unreachable!("run_hook should've failed");
let HookResult::Run(response) = result else {
unreachable!("run_hook should've run");
};
let stdout = stdout.as_str().trim_ascii_end();
let stdout = response.stdout.as_str().trim_ascii_end();
assert_eq!(code, Some(42));
assert_eq!(h, hook.hook);
assert_eq!(response.code, 42);
assert_eq!(response.hook, hook.hook);
assert_eq!(stdout, TEXT, "{:?} != {TEXT:?}", stdout);
assert!(stderr.is_empty());
assert!(response.stderr.is_empty());
}
#[test]
@ -384,7 +568,7 @@ exit 0
let res =
hooks_pre_commit(&repo, Some(&["../.myhooks"])).unwrap();
assert!(res.is_ok());
assert!(res.is_successful());
}
#[test]
@ -417,7 +601,7 @@ exit 1
let res =
hooks_pre_commit(&repo, Some(&["../.myhooks"])).unwrap();
assert!(res.is_ok());
assert!(res.is_successful());
}
#[test]
@ -431,7 +615,7 @@ exit 1
create_hook(&repo, HOOK_PRE_COMMIT, hook);
let res = hooks_pre_commit(&repo, None).unwrap();
assert!(res.is_not_successful());
assert!(!res.is_successful());
}
#[test]
@ -448,15 +632,17 @@ exit 1
create_hook(&repo, HOOK_PRE_COMMIT, hook);
let res = hooks_pre_commit(&repo, None).unwrap();
let HookResult::RunNotSuccessful { stdout, .. } = res else {
let HookResult::Run(response) = res else {
unreachable!()
};
assert!(
stdout
response
.stdout
.lines()
.any(|line| line.starts_with(PATH_EXPORT)),
"Could not find line starting with {PATH_EXPORT:?} in: {stdout:?}"
"Could not find line starting with {PATH_EXPORT:?} in: {:?}",
response.stdout
);
}
@ -482,13 +668,12 @@ exit 1
let res = hooks_pre_commit(&repo, None).unwrap();
let HookResult::RunNotSuccessful { code, stdout, .. } = res
else {
let HookResult::Run(response) = res else {
unreachable!()
};
assert_eq!(code.unwrap(), 1);
assert_eq!(&stdout, "rejected\n");
assert_eq!(response.code, 1);
assert_eq!(&response.stdout, "rejected\n");
}
#[test]
@ -502,7 +687,7 @@ exit 1
create_hook(&repo, HOOK_PRE_COMMIT, hook);
let res = hooks_pre_commit(&repo, None).unwrap();
assert!(res.is_not_successful());
assert!(!res.is_successful());
}
#[test]
@ -523,7 +708,7 @@ sys.exit(0)
create_hook(&repo, HOOK_PRE_COMMIT, hook);
let res = hooks_pre_commit(&repo, None).unwrap();
assert!(res.is_ok(), "{res:?}");
assert!(res.is_successful(), "{res:?}");
}
#[test]
@ -544,7 +729,7 @@ sys.exit(1)
create_hook(&repo, HOOK_PRE_COMMIT, hook);
let res = hooks_pre_commit(&repo, None).unwrap();
assert!(res.is_not_successful());
assert!(!res.is_successful());
}
#[test]
@ -562,13 +747,12 @@ sys.exit(1)
let mut msg = String::from("test");
let res = hooks_commit_msg(&repo, None, &mut msg).unwrap();
let HookResult::RunNotSuccessful { code, stdout, .. } = res
else {
let HookResult::Run(response) = res else {
unreachable!()
};
assert_eq!(code.unwrap(), 1);
assert_eq!(&stdout, "rejected\n");
assert_eq!(response.code, 1);
assert_eq!(&response.stdout, "rejected\n");
assert_eq!(msg, String::from("msg\n"));
}
@ -587,7 +771,7 @@ exit 0
let mut msg = String::from("test");
let res = hooks_commit_msg(&repo, None, &mut msg).unwrap();
assert!(res.is_ok());
assert!(res.is_successful());
assert_eq!(msg, String::from("msg\n"));
}
@ -633,7 +817,7 @@ exit 0
)
.unwrap();
assert!(matches!(res, HookResult::Ok { .. }));
assert!(res.is_successful());
assert_eq!(msg, String::from("msg:message\n"));
}
@ -658,13 +842,12 @@ exit 2
)
.unwrap();
let HookResult::RunNotSuccessful { code, stdout, .. } = res
else {
let HookResult::Run(response) = res else {
unreachable!()
};
assert_eq!(code.unwrap(), 2);
assert_eq!(&stdout, "rejected\n");
assert_eq!(response.code, 2);
assert_eq!(&response.stdout, "rejected\n");
assert_eq!(
msg,
@ -684,9 +867,25 @@ exit 0
create_hook(&repo, HOOK_PRE_PUSH, hook);
let res = hooks_pre_push(&repo, None).unwrap();
let branch = head_branch(&repo);
let updates = [branch_update(
&repo,
Some("origin"),
&branch,
None,
false,
)];
assert!(matches!(res, HookResult::Ok { .. }));
let res = hooks_pre_push(
&repo,
None,
Some("origin"),
"https://example.com/repo.git",
&updates,
)
.unwrap();
assert!(res.is_successful());
}
#[test]
@ -698,12 +897,331 @@ echo 'failed'
exit 3
";
create_hook(&repo, HOOK_PRE_PUSH, hook);
let res = hooks_pre_push(&repo, None).unwrap();
let HookResult::RunNotSuccessful { code, stdout, .. } = res
else {
let branch = head_branch(&repo);
let updates = [branch_update(
&repo,
Some("origin"),
&branch,
None,
false,
)];
let res = hooks_pre_push(
&repo,
None,
Some("origin"),
"https://example.com/repo.git",
&updates,
)
.unwrap();
let HookResult::Run(response) = res else {
unreachable!()
};
assert_eq!(code.unwrap(), 3);
assert_eq!(&stdout, "failed\n");
assert_eq!(response.code, 3);
assert_eq!(&response.stdout, "failed\n");
}
#[test]
fn test_pre_push_no_remote_name() {
let (_td, repo) = repo_init();
let hook = b"#!/bin/sh
# Verify that when remote is None, URL is passed for both arguments
echo \"arg1=$1 arg2=$2\"
exit 0
";
create_hook(&repo, HOOK_PRE_PUSH, hook);
let branch = head_branch(&repo);
let updates =
[branch_update(&repo, None, &branch, None, false)];
let res = hooks_pre_push(
&repo,
None,
None,
"https://example.com/repo.git",
&updates,
)
.unwrap();
let HookResult::Run(response) = res else {
panic!("Expected Run result, got: {res:?}");
};
assert!(response.is_successful());
// When remote is None, URL should be passed for both arguments
assert_eq!(
response.stdout,
"arg1=https://example.com/repo.git arg2=https://example.com/repo.git\n"
);
}
#[test]
fn test_pre_push_with_arguments() {
let (_td, repo) = repo_init();
let hook = b"#!/bin/sh
echo \"remote_name=$1\"
echo \"remote_url=$2\"
exit 0
";
create_hook(&repo, HOOK_PRE_PUSH, hook);
let branch = head_branch(&repo);
let updates = [branch_update(
&repo,
Some("origin"),
&branch,
None,
false,
)];
let res = hooks_pre_push(
&repo,
None,
Some("origin"),
"https://example.com/repo.git",
&updates,
)
.unwrap();
let HookResult::Run(response) = res else {
unreachable!("Expected Run result, got: {res:?}")
};
assert!(response.is_successful());
assert_eq!(
response.stdout,
"remote_name=origin\nremote_url=https://example.com/repo.git\n"
);
}
#[test]
fn test_pre_push_multiple_updates() {
let (_td, repo) = repo_init();
let hook = b"#!/bin/sh
cat
exit 0
";
create_hook(&repo, HOOK_PRE_PUSH, hook);
let branch = head_branch(&repo);
let branch_update = branch_update(
&repo,
Some("origin"),
&branch,
None,
false,
);
// create a tag to add a second refspec
let head_commit =
repo.head().unwrap().peel_to_commit().unwrap();
repo.tag_lightweight("v1", head_commit.as_object(), false)
.unwrap();
let tag_ref = repo.find_reference("refs/tags/v1").unwrap();
let tag_oid = tag_ref.target().unwrap();
let tag_update = PrePushRef::new(
"refs/tags/v1",
Some(tag_oid),
"refs/tags/v1",
None,
);
let updates = [branch_update, tag_update];
let expected_stdin = PrePushRef::to_stdin(&updates);
let res = hooks_pre_push(
&repo,
None,
Some("origin"),
"https://example.com/repo.git",
&updates,
)
.unwrap();
let HookResult::Run(response) = res else {
unreachable!("Expected Run result, got: {res:?}")
};
assert!(
response.is_successful(),
"Hook should succeed: stdout {} stderr {}",
response.stdout,
response.stderr
);
assert_eq!(
response.stdout, expected_stdin,
"stdin should include all refspec lines"
);
}
#[test]
fn test_pre_push_delete_ref_uses_zero_oid() {
let (_td, repo) = repo_init();
let hook = b"#!/bin/sh
cat
exit 0
";
create_hook(&repo, HOOK_PRE_PUSH, hook);
let branch = head_branch(&repo);
let updates = [branch_update(
&repo,
Some("origin"),
&branch,
None,
true,
)];
let expected_stdin = PrePushRef::to_stdin(&updates);
let res = hooks_pre_push(
&repo,
None,
Some("origin"),
"https://example.com/repo.git",
&updates,
)
.unwrap();
let HookResult::Run(response) = res else {
unreachable!("Expected Run result, got: {res:?}")
};
assert!(response.is_successful());
assert_eq!(response.stdout, expected_stdin);
}
#[test]
fn test_pre_push_stdin() {
let (_td, repo) = repo_init();
let hook = b"#!/bin/sh
cat
exit 0
";
create_hook(&repo, HOOK_PRE_PUSH, hook);
let branch = head_branch(&repo);
let updates = [branch_update(
&repo,
Some("origin"),
&branch,
None,
false,
)];
let expected_stdin = PrePushRef::to_stdin(&updates);
let res = hooks_pre_push(
&repo,
None,
Some("origin"),
"https://github.com/user/repo.git",
&updates,
)
.unwrap();
let HookResult::Run(response) = res else {
unreachable!("Expected Run result, got: {res:?}")
};
assert!(response.is_successful());
assert_eq!(response.stdout, expected_stdin);
}
#[test]
fn test_pre_push_uses_push_target_remote_not_upstream() {
let (_td, repo) = repo_init();
// repo_init() already creates an initial commit on master
let head = repo.head().unwrap();
let local_commit = head.target().unwrap();
// Set up scenario:
// - Local master is at local_commit (latest)
// - origin/master exists at local_commit (fully synced - upstream)
// - backup/master exists at old_commit (behind/different)
// - Branch tracks origin/master as upstream
// - We push to "backup" remote
// - Expected: remote SHA should be old_commit (not origin/master)
// Create origin/master tracking branch (at same commit as local)
repo.reference(
"refs/remotes/origin/master",
local_commit,
true,
"create origin/master",
)
.unwrap();
// Create backup/master at a different commit
let sig = repo.signature().unwrap();
let tree_id = {
let mut index = repo.index().unwrap();
index.write_tree().unwrap()
};
let tree = repo.find_tree(tree_id).unwrap();
let old_commit = repo
.commit(None, &sig, &sig, "old backup commit", &tree, &[])
.unwrap();
repo.reference(
"refs/remotes/backup/master",
old_commit,
true,
"create backup/master at old commit",
)
.unwrap();
// Configure upstream to origin
{
let mut config = repo.config().unwrap();
config.set_str("branch.master.remote", "origin").unwrap();
config
.set_str("branch.master.merge", "refs/heads/master")
.unwrap();
}
let hook = b"#!/bin/sh
cat
exit 0
";
create_hook(&repo, HOOK_PRE_PUSH, hook);
let branch = head_branch(&repo);
let updates = [branch_update(
&repo,
Some("backup"),
&branch,
None,
false,
)];
let expected_stdin = PrePushRef::to_stdin(&updates);
let res = hooks_pre_push(
&repo,
None,
Some("backup"),
"https://github.com/user/backup-repo.git",
&updates,
)
.unwrap();
let HookResult::Run(response) = res else {
panic!("Expected Run result, got: {res:?}")
};
assert!(response.is_successful());
assert_eq!(response.stdout, expected_stdin);
}
}

View file

@ -145,9 +145,16 @@ impl PushPopup {
};
// run pre push hook - can reject push
if let HookResult::NotOk(e) =
hooks_pre_push(&self.repo.borrow())?
{
let repo = self.repo.borrow();
if let HookResult::NotOk(e) = hooks_pre_push(
&repo,
&remote,
&asyncgit::sync::PrePushTarget::Branch {
branch: &self.branch,
delete: self.modifier.delete(),
},
cred.clone(),
)? {
log::error!("pre-push hook failed: {e}");
self.queue.push(InternalEvent::ShowErrorMsg(format!(
"pre-push hook failed:\n{e}"

View file

@ -84,10 +84,15 @@ impl PushTagsPopup {
&mut self,
cred: Option<BasicAuthCredential>,
) -> Result<()> {
// run pre push hook - can reject push
if let HookResult::NotOk(e) =
hooks_pre_push(&self.repo.borrow())?
{
let remote = get_default_remote(&self.repo.borrow())?;
let repo = self.repo.borrow();
if let HookResult::NotOk(e) = hooks_pre_push(
&repo,
&remote,
&asyncgit::sync::PrePushTarget::Tags,
cred.clone(),
)? {
log::error!("pre-push hook failed: {e}");
self.queue.push(InternalEvent::ShowErrorMsg(format!(
"pre-push hook failed:\n{e}"
@ -100,7 +105,7 @@ impl PushTagsPopup {
self.pending = true;
self.progress = None;
self.git_push.request(PushTagsRequest {
remote: get_default_remote(&self.repo.borrow())?,
remote,
basic_credential: cred,
})?;
Ok(())