Skip to content

Commit 1c118d7

Browse files
authored
proper pre-push hook implementation (#2811)
1 parent 7747d82 commit 1c118d7

File tree

7 files changed

+935
-107
lines changed

7 files changed

+935
-107
lines changed

asyncgit/src/sync/hooks.rs

Lines changed: 261 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,19 @@
11
use super::{repository::repo, RepoPath};
2-
use crate::error::Result;
3-
pub use git2_hooks::PrepareCommitMsgSource;
2+
use crate::{
3+
error::Result,
4+
sync::{
5+
branch::get_branch_upstream_merge,
6+
config::{
7+
push_default_strategy_config_repo,
8+
PushDefaultStrategyConfig,
9+
},
10+
remotes::{proxy_auto, tags::tags_missing_remote, Callbacks},
11+
},
12+
};
13+
use git2::{BranchType, Direction, Oid};
14+
pub use git2_hooks::{PrePushRef, PrepareCommitMsgSource};
415
use scopetime::scope_time;
16+
use std::collections::HashMap;
517

618
///
719
#[derive(Debug, PartialEq, Eq)]
@@ -15,17 +27,91 @@ pub enum HookResult {
1527
impl From<git2_hooks::HookResult> for HookResult {
1628
fn from(v: git2_hooks::HookResult) -> Self {
1729
match v {
18-
git2_hooks::HookResult::Ok { .. }
19-
| git2_hooks::HookResult::NoHookFound => Self::Ok,
20-
git2_hooks::HookResult::RunNotSuccessful {
21-
stdout,
22-
stderr,
23-
..
24-
} => Self::NotOk(format!("{stdout}{stderr}")),
30+
git2_hooks::HookResult::NoHookFound => Self::Ok,
31+
git2_hooks::HookResult::Run(response) => {
32+
if response.is_successful() {
33+
Self::Ok
34+
} else {
35+
Self::NotOk(if response.stderr.is_empty() {
36+
response.stdout
37+
} else if response.stdout.is_empty() {
38+
response.stderr
39+
} else {
40+
format!(
41+
"{}\n{}",
42+
response.stdout, response.stderr
43+
)
44+
})
45+
}
46+
}
2547
}
2648
}
2749
}
2850

51+
/// Retrieve advertised refs from the remote for the upcoming push.
52+
fn advertised_remote_refs(
53+
repo_path: &RepoPath,
54+
remote: Option<&str>,
55+
url: &str,
56+
basic_credential: Option<crate::sync::cred::BasicAuthCredential>,
57+
) -> Result<HashMap<String, Oid>> {
58+
let repo = repo(repo_path)?;
59+
let mut remote_handle = if let Some(name) = remote {
60+
repo.find_remote(name)?
61+
} else {
62+
repo.remote_anonymous(url)?
63+
};
64+
65+
let callbacks = Callbacks::new(None, basic_credential);
66+
let conn = remote_handle.connect_auth(
67+
Direction::Push,
68+
Some(callbacks.callbacks()),
69+
Some(proxy_auto()),
70+
)?;
71+
72+
let mut map = HashMap::new();
73+
for head in conn.list()? {
74+
map.insert(head.name().to_string(), head.oid());
75+
}
76+
77+
Ok(map)
78+
}
79+
80+
/// Determine the remote ref name for a branch push.
81+
///
82+
/// Respects `push.default=upstream` config when set and upstream is configured.
83+
/// Otherwise defaults to `refs/heads/{branch}`. Delete operations always use
84+
/// the simple ref name.
85+
fn get_remote_ref_for_push(
86+
repo_path: &RepoPath,
87+
branch: &str,
88+
delete: bool,
89+
) -> Result<String> {
90+
// For delete operations, always use the simple ref name
91+
// regardless of push.default configuration
92+
if delete {
93+
return Ok(format!("refs/heads/{branch}"));
94+
}
95+
96+
let repo = repo(repo_path)?;
97+
let push_default_strategy =
98+
push_default_strategy_config_repo(&repo)?;
99+
100+
// When push.default=upstream, use the configured upstream ref if available
101+
if push_default_strategy == PushDefaultStrategyConfig::Upstream {
102+
if let Ok(Some(upstream_ref)) =
103+
get_branch_upstream_merge(repo_path, branch)
104+
{
105+
return Ok(upstream_ref);
106+
}
107+
// If upstream strategy is set but no upstream is configured,
108+
// fall through to default behavior
109+
}
110+
111+
// Default: push to remote branch with same name as local
112+
Ok(format!("refs/heads/{branch}"))
113+
}
114+
29115
/// see `git2_hooks::hooks_commit_msg`
30116
pub fn hooks_commit_msg(
31117
repo_path: &RepoPath,
@@ -73,12 +159,133 @@ pub fn hooks_prepare_commit_msg(
73159
}
74160

75161
/// see `git2_hooks::hooks_pre_push`
76-
pub fn hooks_pre_push(repo_path: &RepoPath) -> Result<HookResult> {
162+
pub fn hooks_pre_push(
163+
repo_path: &RepoPath,
164+
remote: &str,
165+
push: &PrePushTarget<'_>,
166+
basic_credential: Option<crate::sync::cred::BasicAuthCredential>,
167+
) -> Result<HookResult> {
77168
scope_time!("hooks_pre_push");
78169

79170
let repo = repo(repo_path)?;
171+
if !git2_hooks::hook_available(
172+
&repo,
173+
None,
174+
git2_hooks::HOOK_PRE_PUSH,
175+
)? {
176+
return Ok(HookResult::Ok);
177+
}
178+
179+
let git_remote = repo.find_remote(remote)?;
180+
let url = git_remote
181+
.pushurl()
182+
.or_else(|| git_remote.url())
183+
.ok_or_else(|| {
184+
crate::error::Error::Generic(format!(
185+
"remote '{remote}' has no URL configured"
186+
))
187+
})?
188+
.to_string();
189+
190+
let advertised = advertised_remote_refs(
191+
repo_path,
192+
Some(remote),
193+
&url,
194+
basic_credential,
195+
)?;
196+
let updates = match push {
197+
PrePushTarget::Branch { branch, delete } => {
198+
let remote_ref =
199+
get_remote_ref_for_push(repo_path, branch, *delete)?;
200+
vec![pre_push_branch_update(
201+
repo_path,
202+
branch,
203+
&remote_ref,
204+
*delete,
205+
&advertised,
206+
)?]
207+
}
208+
PrePushTarget::Tags => {
209+
pre_push_tag_updates(repo_path, remote, &advertised)?
210+
}
211+
};
212+
213+
Ok(git2_hooks::hooks_pre_push(
214+
&repo,
215+
None,
216+
Some(remote),
217+
&url,
218+
&updates,
219+
)?
220+
.into())
221+
}
222+
223+
/// Build a single pre-push update line for a branch.
224+
fn pre_push_branch_update(
225+
repo_path: &RepoPath,
226+
branch_name: &str,
227+
remote_ref: &str,
228+
delete: bool,
229+
advertised: &HashMap<String, Oid>,
230+
) -> Result<PrePushRef> {
231+
let repo = repo(repo_path)?;
232+
let local_ref = format!("refs/heads/{branch_name}");
233+
let local_oid = (!delete)
234+
.then(|| {
235+
repo.find_branch(branch_name, BranchType::Local)
236+
.ok()
237+
.and_then(|branch| branch.get().peel_to_commit().ok())
238+
.map(|commit| commit.id())
239+
})
240+
.flatten();
241+
242+
let remote_oid = advertised.get(remote_ref).copied();
243+
244+
Ok(PrePushRef::new(
245+
local_ref, local_oid, remote_ref, remote_oid,
246+
))
247+
}
248+
249+
/// Build pre-push updates for tags that are missing on the remote.
250+
fn pre_push_tag_updates(
251+
repo_path: &RepoPath,
252+
remote: &str,
253+
advertised: &HashMap<String, Oid>,
254+
) -> Result<Vec<PrePushRef>> {
255+
let repo = repo(repo_path)?;
256+
let tags = tags_missing_remote(repo_path, remote, None)?;
257+
let mut updates = Vec::with_capacity(tags.len());
258+
259+
for tag_ref in tags {
260+
if let Ok(reference) = repo.find_reference(&tag_ref) {
261+
let tag_oid = reference.target().or_else(|| {
262+
reference.peel_to_commit().ok().map(|c| c.id())
263+
});
264+
let remote_ref = tag_ref.clone();
265+
let advertised_oid = advertised.get(&remote_ref).copied();
266+
updates.push(PrePushRef::new(
267+
tag_ref.clone(),
268+
tag_oid,
269+
remote_ref,
270+
advertised_oid,
271+
));
272+
}
273+
}
274+
275+
Ok(updates)
276+
}
80277

81-
Ok(git2_hooks::hooks_pre_push(&repo, None)?.into())
278+
/// What is being pushed.
279+
pub enum PrePushTarget<'a> {
280+
/// Push a single branch.
281+
Branch {
282+
/// Local branch name being pushed.
283+
branch: &'a str,
284+
/// Whether this is a delete push.
285+
delete: bool,
286+
},
287+
/// Push tags.
288+
Tags,
82289
}
83290

84291
#[cfg(test)]
@@ -248,4 +455,47 @@ mod tests {
248455

249456
assert_eq!(msg, String::from("msg\n"));
250457
}
458+
459+
#[test]
460+
fn test_pre_push_hook_rejects_based_on_stdin() {
461+
let (_td, repo) = repo_init().unwrap();
462+
463+
let hook = b"#!/bin/sh
464+
cat
465+
exit 1
466+
";
467+
468+
git2_hooks::create_hook(
469+
&repo,
470+
git2_hooks::HOOK_PRE_PUSH,
471+
hook,
472+
);
473+
474+
let commit_id = repo.head().unwrap().target().unwrap();
475+
let update = git2_hooks::PrePushRef::new(
476+
"refs/heads/master",
477+
Some(commit_id),
478+
"refs/heads/master",
479+
None,
480+
);
481+
482+
let expected_stdin =
483+
git2_hooks::PrePushRef::to_stdin(&[update.clone()]);
484+
485+
let res = git2_hooks::hooks_pre_push(
486+
&repo,
487+
None,
488+
Some("origin"),
489+
"https://github.com/test/repo.git",
490+
&[update],
491+
)
492+
.unwrap();
493+
494+
let git2_hooks::HookResult::Run(response) = res else {
495+
panic!("Expected Run result");
496+
};
497+
assert!(!response.is_successful());
498+
assert_eq!(response.stdout, expected_stdin);
499+
assert!(expected_stdin.contains("refs/heads/master"));
500+
}
251501
}

asyncgit/src/sync/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ pub use git2::BranchType;
6868
pub use hooks::{
6969
hooks_commit_msg, hooks_post_commit, hooks_pre_commit,
7070
hooks_pre_push, hooks_prepare_commit_msg, HookResult,
71-
PrepareCommitMsgSource,
71+
PrePushTarget, PrepareCommitMsgSource,
7272
};
7373
pub use hunks::{reset_hunk, stage_hunk, unstage_hunk};
7474
pub use ignore::add_to_ignore;

git2-hooks/src/error.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ pub enum HooksError {
1414

1515
#[error("shellexpand error:{0}")]
1616
ShellExpand(#[from] shellexpand::LookupError<std::env::VarError>),
17+
18+
#[error("hook process terminated by signal without exit code")]
19+
NoExitCode,
1720
}
1821

1922
/// crate specific `Result` type

0 commit comments

Comments
 (0)