@@ -69,6 +69,95 @@ pub fn url_has_credentials(url: &str) -> bool {
6969 false
7070}
7171
72+ /// Check if a URL is an SSH-based git URL.
73+ ///
74+ /// Returns true for URLs like:
75+ /// - `git@github.com:user/repo.git`
76+ /// - `ssh://git@github.com/user/repo.git`
77+ #[ must_use]
78+ pub fn is_ssh_url ( url : & str ) -> bool {
79+ url. starts_with ( "git@" ) || url. starts_with ( "ssh://" )
80+ }
81+
82+ /// Fetch from remote using system git CLI.
83+ ///
84+ /// This is used for SSH URLs where libssh2 (used by git2) has compatibility
85+ /// issues with certain SSH agent implementations (e.g., 1Password, `YubiKey`).
86+ /// System git uses OpenSSH which handles all agent types correctly.
87+ fn fetch_via_cli ( repo_path : & Path , remote_name : & str , branch : & str ) -> Result < ( ) > {
88+ info ! ( "Using system git for SSH fetch: {} {}" , remote_name, branch) ;
89+ let output = Command :: new ( "git" )
90+ . args ( [ "fetch" , remote_name, branch] )
91+ . current_dir ( repo_path)
92+ . stdin ( std:: process:: Stdio :: null ( ) )
93+ . stdout ( std:: process:: Stdio :: piped ( ) )
94+ . stderr ( std:: process:: Stdio :: piped ( ) )
95+ . output ( )
96+ . context ( "Failed to run 'git fetch'. Is git installed?" ) ?;
97+
98+ if !output. status . success ( ) {
99+ let stderr = String :: from_utf8_lossy ( & output. stderr ) ;
100+ anyhow:: bail!(
101+ "Failed to fetch from remote '{}': {}" ,
102+ remote_name,
103+ stderr. trim( )
104+ ) ;
105+ }
106+ Ok ( ( ) )
107+ }
108+
109+ /// Push to remote using system git CLI.
110+ ///
111+ /// Used for SSH URLs to ensure compatibility with all SSH agent implementations.
112+ fn push_via_cli ( repo_path : & Path , remote_name : & str , refspec : & str ) -> Result < ( ) > {
113+ info ! ( "Using system git for SSH push: {} {}" , remote_name, refspec) ;
114+ let output = Command :: new ( "git" )
115+ . args ( [ "push" , remote_name, refspec] )
116+ . current_dir ( repo_path)
117+ . stdin ( std:: process:: Stdio :: null ( ) )
118+ . stdout ( std:: process:: Stdio :: piped ( ) )
119+ . stderr ( std:: process:: Stdio :: piped ( ) )
120+ . output ( )
121+ . context ( "Failed to run 'git push'. Is git installed?" ) ?;
122+
123+ if !output. status . success ( ) {
124+ let stderr = String :: from_utf8_lossy ( & output. stderr ) ;
125+ anyhow:: bail!(
126+ "Failed to push to remote '{}': {}" ,
127+ remote_name,
128+ stderr. trim( )
129+ ) ;
130+ }
131+ Ok ( ( ) )
132+ }
133+
134+ /// Clone a repository using system git CLI.
135+ ///
136+ /// Used for SSH URLs to ensure compatibility with all SSH agent implementations.
137+ fn clone_via_cli ( url : & str , path : & Path ) -> Result < ( ) > {
138+ info ! (
139+ "Using system git for SSH clone: {}" ,
140+ redact_credentials( url)
141+ ) ;
142+ let output = Command :: new ( "git" )
143+ . args ( [ "clone" , url, & path. to_string_lossy ( ) ] )
144+ . stdin ( std:: process:: Stdio :: null ( ) )
145+ . stdout ( std:: process:: Stdio :: piped ( ) )
146+ . stderr ( std:: process:: Stdio :: piped ( ) )
147+ . output ( )
148+ . context ( "Failed to run 'git clone'. Is git installed?" ) ?;
149+
150+ if !output. status . success ( ) {
151+ let stderr = String :: from_utf8_lossy ( & output. stderr ) ;
152+ anyhow:: bail!(
153+ "Failed to clone repository from {}: {}" ,
154+ redact_credentials( url) ,
155+ stderr. trim( )
156+ ) ;
157+ }
158+ Ok ( ( ) )
159+ }
160+
72161/// Git operations for managing the dotfiles repository
73162pub struct GitManager {
74163 repo : Repository ,
@@ -111,6 +200,27 @@ impl GitManager {
111200 Ok ( Self { repo } )
112201 }
113202
203+ /// Get the working directory of the repository.
204+ ///
205+ /// Returns the repo workdir path, or an error if the repo is bare.
206+ fn repo_workdir ( & self ) -> Result < & Path > {
207+ self . repo
208+ . workdir ( )
209+ . ok_or_else ( || anyhow:: anyhow!( "Repository has no working directory (bare repo)" ) )
210+ }
211+
212+ /// Get the remote URL for a given remote name.
213+ fn get_remote_url ( & self , remote_name : & str ) -> Result < String > {
214+ let remote = self
215+ . repo
216+ . find_remote ( remote_name)
217+ . with_context ( || format ! ( "Remote '{remote_name}' not found" ) ) ?;
218+ remote
219+ . url ( )
220+ . map ( String :: from)
221+ . ok_or_else ( || anyhow:: anyhow!( "Remote '{remote_name}' has no URL" ) )
222+ }
223+
114224 /// Ensure .gitignore exists with common patterns for frequently changing files
115225 fn ensure_gitignore ( repo_path : & Path ) -> Result < ( ) > {
116226 use std:: fs;
@@ -468,19 +578,39 @@ impl GitManager {
468578 use tracing:: info;
469579 info ! ( "Pushing to remote: {} (branch: {})" , remote_name, branch) ;
470580
581+ let remote_url = self . get_remote_url ( remote_name) ?;
582+
583+ // Use system git for SSH URLs (libssh2 has compatibility issues with
584+ // some SSH agents like 1Password, `YubiKey`, Secretive)
585+ if is_ssh_url ( & remote_url) {
586+ let repo_path = self . repo_workdir ( ) ?;
587+
588+ // Handle branch that doesn't exist locally
589+ let branch_ref = format ! ( "refs/heads/{branch}" ) ;
590+ let refspec = if self . repo . find_reference ( & branch_ref) . is_err ( ) {
591+ if let Some ( current_branch) = self . get_current_branch ( ) {
592+ format ! ( "refs/heads/{current_branch}:refs/heads/{branch}" )
593+ } else {
594+ anyhow:: bail!( "No branch '{branch}' exists and no current branch found" ) ;
595+ }
596+ } else {
597+ format ! ( "refs/heads/{branch}:refs/heads/{branch}" )
598+ } ;
599+
600+ push_via_cli ( repo_path, remote_name, & refspec) ?;
601+ info ! ( "Successfully pushed to {}:{}" , remote_name, branch) ;
602+ return Ok ( ( ) ) ;
603+ }
604+
471605 let mut remote = self
472606 . repo
473607 . find_remote ( remote_name)
474608 . with_context ( || format ! ( "Remote '{remote_name}' not found" ) ) ?;
475609
476- let remote_url = remote
477- . url ( )
478- . ok_or_else ( || anyhow:: anyhow!( "Remote '{remote_name}' has no URL" ) ) ?;
479-
480610 let mut callbacks = RemoteCallbacks :: new ( ) ;
481611 let token_to_use = token
482612 . map ( std:: string:: ToString :: to_string)
483- . or_else ( || Self :: extract_token_from_url ( remote_url) ) ;
613+ . or_else ( || Self :: extract_token_from_url ( & remote_url) ) ;
484614 Self :: setup_credentials ( & mut callbacks, token_to_use) ;
485615
486616 // Capture push errors from server-side hooks/rejections
@@ -547,18 +677,15 @@ impl GitManager {
547677 remote
548678 . push ( & [ & refspec] , Some ( & mut push_options) )
549679 . with_context ( || {
550- // Get more detailed error information - redact credentials for safety
551- let remote_url = remote
552- . url ( )
553- . map_or_else ( || "unknown" . to_string ( ) , redact_credentials) ;
554680 format ! (
555- "Failed to push to remote '{remote_name}' (URL: {remote_url }).\n \n \
681+ "Failed to push to remote '{remote_name}' (URL: {}).\n \n \
556682 Check token permissions:\n \
557683 • Classic tokens (ghp_): needs 'repo' scope\n \
558684 • Fine-grained tokens (github_pat_): needs 'Contents' set to 'Read and write'\n \n \
559685 Also verify:\n \
560686 • Remote branch exists\n \
561- • You have push access to this repository"
687+ • You have push access to this repository",
688+ redact_credentials( & remote_url)
562689 )
563690 } ) ?;
564691
@@ -720,26 +847,30 @@ impl GitManager {
720847 use tracing:: info;
721848 info ! ( "Pulling from remote: {} (branch: {})" , remote_name, branch) ;
722849
723- let mut remote = self
724- . repo
725- . find_remote ( remote_name)
726- . with_context ( || format ! ( "Remote '{remote_name}' not found" ) ) ?;
850+ let remote_url = self . get_remote_url ( remote_name) ?;
727851
728- let mut callbacks = RemoteCallbacks :: new ( ) ;
729- let remote_url = remote
730- . url ( )
731- . ok_or_else ( || anyhow :: anyhow! ( "Remote '{remote_name}' has no URL" ) ) ? ;
732- let token_to_use = token
733- . map ( std :: string :: ToString :: to_string )
734- . or_else ( || Self :: extract_token_from_url ( remote_url ) ) ;
735- Self :: setup_credentials ( & mut callbacks , token_to_use ) ;
852+ // Fetch step: use system git for SSH URLs, git2 for HTTPS
853+ if is_ssh_url ( & remote_url) {
854+ fetch_via_cli ( self . repo_workdir ( ) ? , remote_name , branch ) ? ;
855+ } else {
856+ let mut remote = self
857+ . repo
858+ . find_remote ( remote_name )
859+ . with_context ( || format ! ( "Remote '{remote_name}' not found" ) ) ? ;
736860
737- let mut fetch_options = FetchOptions :: new ( ) ;
738- fetch_options. remote_callbacks ( callbacks) ;
861+ let mut callbacks = RemoteCallbacks :: new ( ) ;
862+ let token_to_use = token
863+ . map ( std:: string:: ToString :: to_string)
864+ . or_else ( || Self :: extract_token_from_url ( & remote_url) ) ;
865+ Self :: setup_credentials ( & mut callbacks, token_to_use) ;
739866
740- remote
741- . fetch ( & [ branch] , Some ( & mut fetch_options) , None )
742- . with_context ( || format ! ( "Failed to fetch from remote '{remote_name}'" ) ) ?;
867+ let mut fetch_options = FetchOptions :: new ( ) ;
868+ fetch_options. remote_callbacks ( callbacks) ;
869+
870+ remote
871+ . fetch ( & [ branch] , Some ( & mut fetch_options) , None )
872+ . with_context ( || format ! ( "Failed to fetch from remote '{remote_name}'" ) ) ?;
873+ }
743874
744875 // Check if FETCH_HEAD exists (remote might not have the branch yet)
745876 let fetch_head = match self . repo . find_reference ( "FETCH_HEAD" ) {
@@ -850,26 +981,30 @@ impl GitManager {
850981 remote_name, branch
851982 ) ;
852983
853- let mut remote = self
854- . repo
855- . find_remote ( remote_name)
856- . with_context ( || format ! ( "Remote '{remote_name}' not found" ) ) ?;
984+ let remote_url = self . get_remote_url ( remote_name) ?;
857985
858- let mut callbacks = RemoteCallbacks :: new ( ) ;
859- let remote_url = remote
860- . url ( )
861- . ok_or_else ( || anyhow :: anyhow! ( "Remote '{remote_name}' has no URL" ) ) ? ;
862- let token_to_use = token
863- . map ( std :: string :: ToString :: to_string )
864- . or_else ( || Self :: extract_token_from_url ( remote_url ) ) ;
865- Self :: setup_credentials ( & mut callbacks , token_to_use ) ;
986+ // Fetch step: use system git for SSH URLs, git2 for HTTPS
987+ if is_ssh_url ( & remote_url) {
988+ fetch_via_cli ( self . repo_workdir ( ) ? , remote_name , branch ) ? ;
989+ } else {
990+ let mut remote = self
991+ . repo
992+ . find_remote ( remote_name )
993+ . with_context ( || format ! ( "Remote '{remote_name}' not found" ) ) ? ;
866994
867- let mut fetch_options = FetchOptions :: new ( ) ;
868- fetch_options. remote_callbacks ( callbacks) ;
995+ let mut callbacks = RemoteCallbacks :: new ( ) ;
996+ let token_to_use = token
997+ . map ( std:: string:: ToString :: to_string)
998+ . or_else ( || Self :: extract_token_from_url ( & remote_url) ) ;
999+ Self :: setup_credentials ( & mut callbacks, token_to_use) ;
8691000
870- remote
871- . fetch ( & [ branch] , Some ( & mut fetch_options) , None )
872- . with_context ( || format ! ( "Failed to fetch from remote '{remote_name}'" ) ) ?;
1001+ let mut fetch_options = FetchOptions :: new ( ) ;
1002+ fetch_options. remote_callbacks ( callbacks) ;
1003+
1004+ remote
1005+ . fetch ( & [ branch] , Some ( & mut fetch_options) , None )
1006+ . with_context ( || format ! ( "Failed to fetch from remote '{remote_name}'" ) ) ?;
1007+ }
8731008
8741009 // Check if FETCH_HEAD exists (remote might not have the branch yet)
8751010 let fetch_head = if let Ok ( ref_) = self . repo . find_reference ( "FETCH_HEAD" ) {
@@ -1088,18 +1223,23 @@ impl GitManager {
10881223 use tracing:: debug;
10891224 debug ! ( "Fetching from remote: {} (branch: {})" , remote_name, branch) ;
10901225
1226+ let remote_url = self . get_remote_url ( remote_name) ?;
1227+
1228+ // Use system git for SSH URLs (libssh2 has compatibility issues with
1229+ // some SSH agents like 1Password, `YubiKey`, Secretive)
1230+ if is_ssh_url ( & remote_url) {
1231+ return fetch_via_cli ( self . repo_workdir ( ) ?, remote_name, branch) ;
1232+ }
1233+
10911234 let mut remote = self
10921235 . repo
10931236 . find_remote ( remote_name)
10941237 . with_context ( || format ! ( "Remote '{remote_name}' not found" ) ) ?;
10951238
10961239 let mut callbacks = RemoteCallbacks :: new ( ) ;
1097- let remote_url = remote
1098- . url ( )
1099- . ok_or_else ( || anyhow:: anyhow!( "Remote '{remote_name}' has no URL" ) ) ?;
11001240 let token_to_use = token
11011241 . map ( std:: string:: ToString :: to_string)
1102- . or_else ( || Self :: extract_token_from_url ( remote_url) ) ;
1242+ . or_else ( || Self :: extract_token_from_url ( & remote_url) ) ;
11031243 Self :: setup_credentials ( & mut callbacks, token_to_use) ;
11041244
11051245 let mut fetch_options = FetchOptions :: new ( ) ;
@@ -1648,6 +1788,15 @@ impl GitManager {
16481788 token : Option < & str > ,
16491789 embed_credentials : bool ,
16501790 ) -> Result < Self > {
1791+ // Use system git for SSH URLs (libssh2 has compatibility issues with
1792+ // some SSH agents like 1Password, `YubiKey`, Secretive)
1793+ if is_ssh_url ( url) {
1794+ clone_via_cli ( url, path) ?;
1795+ let repo = Repository :: open ( path)
1796+ . with_context ( || format ! ( "Failed to open cloned repository at {path:?}" ) ) ?;
1797+ return Ok ( Self { repo } ) ;
1798+ }
1799+
16511800 // Optionally embed token directly in URL to bypass gitconfig URL rewrites
16521801 // This prevents issues when users have .gitconfig settings like:
16531802 // [url "git@github.com:"]
@@ -1789,15 +1938,6 @@ impl GitManager {
17891938 Ok ( Some ( String :: from_utf8_lossy ( & diff_buf) . to_string ( ) ) )
17901939 }
17911940
1792- /// Get the remote URL for a given remote name
1793- #[ allow( dead_code) ]
1794- pub fn get_remote_url ( & self , remote_name : & str ) -> Result < Option < String > > {
1795- match self . repo . find_remote ( remote_name) {
1796- Ok ( remote) => Ok ( remote. url ( ) . map ( std:: string:: ToString :: to_string) ) ,
1797- Err ( _) => Ok ( None ) ,
1798- }
1799- }
1800-
18011941 /// Check if a remote exists
18021942 #[ must_use]
18031943 pub fn has_remote ( & self , remote_name : & str ) -> bool {
0 commit comments