11use 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 } ;
415use scopetime:: scope_time;
16+ use std:: collections:: HashMap ;
517
618///
719#[ derive( Debug , PartialEq , Eq ) ]
@@ -15,17 +27,91 @@ pub enum HookResult {
1527impl 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`
30116pub 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}
0 commit comments