Skip to content

Commit 99bb0d8

Browse files
committed
feat: Implement system git CLI operations for SSH URLs to enhance compatibility with various SSH agents
1 parent cee89fb commit 99bb0d8

File tree

2 files changed

+202
-58
lines changed

2 files changed

+202
-58
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Fixed
11+
12+
- **Git SSH**: Use system git for SSH remote operations (fetch, push, pull, clone) to fix compatibility with 1Password SSH Agent, YubiKey Agent, Secretive, and other non-standard SSH agent implementations. HTTPS operations continue to use git2 as before.
13+
1014
---
1115

1216
## [0.2.24] - 2026-02-03

src/git.rs

Lines changed: 198 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -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
73162
pub 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

Comments
 (0)