Skip to content

Commit 3604ed0

Browse files
committed
feat: add shared git transport module
1 parent 3b014a2 commit 3604ed0

2 files changed

Lines changed: 244 additions & 0 deletions

File tree

src/git.rs

Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
use anyhow::{Context, Result, anyhow};
2+
use git2::Repository;
3+
use std::path::Path;
4+
5+
#[derive(Debug, Clone, PartialEq, Eq)]
6+
enum AuthAction {
7+
Username(String),
8+
SshAgent(String),
9+
CredentialHelper,
10+
Default,
11+
Unsupported,
12+
}
13+
14+
pub fn get_remote_default_branch(url: &str) -> Result<String> {
15+
let temp_dir = tempfile::tempdir().context("Failed to create temporary directory")?;
16+
17+
let repository =
18+
Repository::init(temp_dir.path()).context("Failed to initialize temp repository")?;
19+
20+
let mut remote = repository
21+
.remote_anonymous(url)
22+
.context("Failed to create remote")?;
23+
24+
let default_branch = {
25+
let connection = remote
26+
.connect_auth(git2::Direction::Fetch, Some(build_remote_callbacks()), None)
27+
.context("Failed to connect to remote")?;
28+
29+
connection.default_branch().ok()
30+
};
31+
32+
if let Some(default_branch) = default_branch {
33+
let branch_name = default_branch
34+
.as_str()
35+
.context("Failed to read default branch name")?
36+
.trim_start_matches("refs/heads/")
37+
.to_string();
38+
39+
if !branch_name.is_empty() {
40+
return Ok(branch_name);
41+
}
42+
}
43+
44+
let mut fetch_options = build_fetch_options();
45+
46+
let remote_name = remote.name().unwrap_or("origin");
47+
let refspec = format!("+refs/heads/*:refs/remotes/{}/*", remote_name);
48+
49+
remote
50+
.fetch(&[&refspec], Some(&mut fetch_options), None)
51+
.context("Failed to fetch from remote")?;
52+
53+
find_default_branch(&repository)
54+
}
55+
56+
pub fn clone_to_path(url: &str, branch: &str, target: &Path) -> Result<()> {
57+
let fetch_options = build_fetch_options();
58+
59+
let mut builder = git2::build::RepoBuilder::new();
60+
builder.fetch_options(fetch_options);
61+
62+
if !branch.is_empty() {
63+
builder.branch(branch);
64+
}
65+
66+
builder
67+
.clone(url, target)
68+
.context(format!("Failed to clone from {}", url))?;
69+
70+
Ok(())
71+
}
72+
73+
pub(crate) fn build_fetch_options() -> git2::FetchOptions<'static> {
74+
let mut fetch_options = git2::FetchOptions::new();
75+
fetch_options.download_tags(git2::AutotagOption::All);
76+
fetch_options.remote_callbacks(build_remote_callbacks());
77+
fetch_options
78+
}
79+
80+
fn build_remote_callbacks() -> git2::RemoteCallbacks<'static> {
81+
let git_config = git2::Config::open_default().ok();
82+
let mut callbacks = git2::RemoteCallbacks::new();
83+
84+
callbacks.credentials(
85+
move |url, username_from_url, allowed_types| match determine_auth_action(
86+
url,
87+
username_from_url,
88+
allowed_types,
89+
) {
90+
AuthAction::Username(username) => git2::Cred::username(&username),
91+
AuthAction::SshAgent(username) => git2::Cred::ssh_key_from_agent(&username),
92+
AuthAction::CredentialHelper => {
93+
if let Some(config) = git_config.as_ref() {
94+
if let Ok(credential) =
95+
git2::Cred::credential_helper(config, url, username_from_url)
96+
{
97+
return Ok(credential);
98+
}
99+
}
100+
101+
if allowed_types.is_default() {
102+
return git2::Cred::default();
103+
}
104+
105+
Err(git2::Error::from_str(&format!(
106+
"No credential helper credentials available for {}",
107+
url
108+
)))
109+
}
110+
AuthAction::Default => git2::Cred::default(),
111+
AuthAction::Unsupported => Err(git2::Error::from_str(&format!(
112+
"No supported authentication method available for {}",
113+
url
114+
))),
115+
},
116+
);
117+
118+
callbacks
119+
}
120+
121+
fn determine_auth_action(
122+
url: &str,
123+
username_from_url: Option<&str>,
124+
allowed_types: git2::CredentialType,
125+
) -> AuthAction {
126+
if allowed_types.is_username() {
127+
if let Some(username) = infer_ssh_username(url, username_from_url) {
128+
return AuthAction::Username(username);
129+
}
130+
}
131+
132+
if allowed_types.is_ssh_key() {
133+
if let Some(username) = infer_ssh_username(url, username_from_url) {
134+
return AuthAction::SshAgent(username);
135+
}
136+
}
137+
138+
if allowed_types.is_user_pass_plaintext() {
139+
return AuthAction::CredentialHelper;
140+
}
141+
142+
if allowed_types.is_default() {
143+
return AuthAction::Default;
144+
}
145+
146+
AuthAction::Unsupported
147+
}
148+
149+
fn infer_ssh_username(url: &str, username_from_url: Option<&str>) -> Option<String> {
150+
if let Some(username) = username_from_url {
151+
return Some(username.to_string());
152+
}
153+
154+
if url.starts_with("git@") || url.starts_with("ssh://") {
155+
return Some("git".to_string());
156+
}
157+
158+
None
159+
}
160+
161+
fn find_default_branch(repository: &Repository) -> Result<String> {
162+
let branches = repository.branches(Some(git2::BranchType::Remote))?;
163+
164+
let mut candidates: Vec<(String, bool)> = Vec::new();
165+
166+
for branch_result in branches {
167+
let (branch, _) = branch_result.context("Failed to iterate branches")?;
168+
let name_result = branch.name().context("Failed to get branch name")?;
169+
if let Some(branch_name) = name_result {
170+
if branch_name.ends_with("/HEAD") {
171+
continue;
172+
}
173+
let is_main = branch_name == "origin/main";
174+
let is_master = branch_name == "origin/master";
175+
let is_preferred = is_main || is_master;
176+
candidates.push((branch_name.to_string(), is_preferred));
177+
}
178+
}
179+
180+
candidates.sort_by(|candidate_a, candidate_b| {
181+
let (_, a_is_preferred) = candidate_a;
182+
let (_, b_is_preferred) = candidate_b;
183+
match (a_is_preferred, b_is_preferred) {
184+
(true, false) => std::cmp::Ordering::Less,
185+
(false, true) => std::cmp::Ordering::Greater,
186+
_ => candidate_a.0.cmp(&candidate_b.0),
187+
}
188+
});
189+
190+
candidates
191+
.first()
192+
.map(|(branch_name, _)| branch_name.trim_start_matches("origin/").to_string())
193+
.ok_or_else(|| anyhow!("No branches found in remote repository"))
194+
}
195+
196+
#[cfg(test)]
197+
mod tests {
198+
use super::{AuthAction, determine_auth_action};
199+
200+
#[test]
201+
fn test_determine_auth_action_uses_username_for_ssh_urls() {
202+
let action = determine_auth_action(
203+
"git@github.com:remotion-dev/skills.git",
204+
None,
205+
git2::CredentialType::USERNAME,
206+
);
207+
208+
assert_eq!(action, AuthAction::Username("git".to_string()));
209+
}
210+
211+
#[test]
212+
fn test_determine_auth_action_uses_agent_for_ssh_keys() {
213+
let action = determine_auth_action(
214+
"ssh://git@github.com/remotion-dev/skills.git",
215+
Some("git"),
216+
git2::CredentialType::SSH_KEY,
217+
);
218+
219+
assert_eq!(action, AuthAction::SshAgent("git".to_string()));
220+
}
221+
222+
#[test]
223+
fn test_determine_auth_action_uses_helper_for_https_userpass() {
224+
let action = determine_auth_action(
225+
"https://github.com/remotion-dev/skills.git",
226+
None,
227+
git2::CredentialType::USER_PASS_PLAINTEXT,
228+
);
229+
230+
assert_eq!(action, AuthAction::CredentialHelper);
231+
}
232+
233+
#[test]
234+
fn test_determine_auth_action_uses_default_when_requested() {
235+
let action = determine_auth_action(
236+
"https://github.com/remotion-dev/skills.git",
237+
None,
238+
git2::CredentialType::DEFAULT,
239+
);
240+
241+
assert_eq!(action, AuthAction::Default);
242+
}
243+
}

src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ pub mod cli;
22
pub mod clone;
33
pub mod config;
44
pub mod detect;
5+
pub mod git;
56
pub mod install;
67
pub mod sync;
78
pub mod tools;

0 commit comments

Comments
 (0)