Skip to main content

gitversion_rs/
remote.rs

1//! Dynamic remote repository cloning.
2//!
3//! Corresponds to the dynamic-repository behaviour in `GitVersion.Core/Core/GitPreparer.cs`.
4//! Clones a remote repository to a temporary (or specified) location via `--url`
5//! and computes the version from the clone.
6//!
7//! Transport: uses gix's blocking client. Supports https/file and SSH
8//! (`ssh://` and scp-style `git@host:path`). SSH authentication relies on the
9//! system `ssh` command (key files and agent).
10
11use anyhow::{bail, Context, Result};
12use rust_i18n::t;
13use sha1::{Digest, Sha1};
14use std::path::PathBuf;
15use std::sync::atomic::AtomicBool;
16
17/// Options for a dynamic clone.
18pub struct DynamicRepoOptions {
19    pub url: String,
20    pub branch: Option<String>,
21    pub username: Option<String>,
22    pub password: Option<String>,
23    pub commit: Option<String>,
24    pub location: Option<PathBuf>,
25}
26
27/// Clone a remote repository (always fresh) and check out the target branch/commit,
28/// then return the path to the working tree.
29pub fn prepare(opts: &DynamicRepoOptions) -> Result<PathBuf> {
30    if opts.branch.is_none() {
31        bail!("{}", t!("remote.branch_required"));
32    }
33    let branch = opts.branch.as_deref().unwrap();
34
35    // Clone destination: <location|%tmp%>/<url-hash>.
36    let base = opts.location.clone().unwrap_or_else(std::env::temp_dir);
37    let mut hasher = Sha1::new();
38    hasher.update(opts.url.as_bytes());
39    let hash: String = hasher
40        .finalize()
41        .iter()
42        .map(|b| format!("{b:02x}"))
43        .collect();
44    let dest = base.join(format!("gitversion-dynamic-{hash}"));
45
46    // Always clone fresh (correctness over performance).
47    if dest.exists() {
48        std::fs::remove_dir_all(&dest)
49            .with_context(|| t!("remote.remove_failed", path = dest.display()))?;
50    }
51    std::fs::create_dir_all(&dest)?;
52
53    // Inject credentials into the https URL if provided.
54    let url = inject_credentials(
55        &opts.url,
56        opts.username.as_deref(),
57        opts.password.as_deref(),
58    );
59
60    log::info!(
61        "{}",
62        t!(
63            "remote.cloning",
64            url = opts.url,
65            branch = branch,
66            dest = dest.display()
67        )
68    );
69
70    let should_interrupt = AtomicBool::new(false);
71    let mut prepare = gix::prepare_clone(url.as_str(), &dest)
72        .with_context(|| t!("remote.clone_prepare_failed", url = opts.url))?
73        .with_ref_name(Some(branch))
74        .with_context(|| t!("remote.set_ref_failed").to_string())?;
75
76    let (mut checkout, _) = prepare
77        .fetch_then_checkout(gix::progress::Discard, &should_interrupt)
78        .with_context(|| t!("remote.fetch_failed").to_string())?;
79    let (_repo, _) = checkout
80        .main_worktree(gix::progress::Discard, &should_interrupt)
81        .with_context(|| t!("remote.checkout_failed").to_string())?;
82
83    // If a specific commit (/c) is given, detach HEAD to that commit.
84    if let Some(commit) = &opts.commit {
85        detach_head_to_commit(&dest, commit)?;
86    }
87
88    Ok(dest)
89}
90
91/// Inject credentials into the URL.
92/// - https: prepend `user[:pass]@`
93/// - ssh (`ssh://host`): prepend `user@` if no user is already present (SSH uses key/agent auth)
94/// - scp-style (`git@host:path`) or URLs that already contain a user are returned unchanged.
95fn inject_credentials(url: &str, user: Option<&str>, pass: Option<&str>) -> String {
96    let Some(user) = user.filter(|u| !u.is_empty()) else {
97        return url.to_string();
98    };
99    if let Some(rest) = url.strip_prefix("https://") {
100        let cred = match pass.filter(|p| !p.is_empty()) {
101            Some(p) => format!("{user}:{p}"),
102            None => user.to_string(),
103        };
104        return format!("https://{cred}@{rest}");
105    }
106    if let Some(rest) = url.strip_prefix("ssh://") {
107        // Leave unchanged if user@ is already present.
108        let host_part = rest.split('/').next().unwrap_or(rest);
109        if !host_part.contains('@') {
110            return format!("ssh://{user}@{rest}");
111        }
112    }
113    url.to_string()
114}
115
116/// Detach HEAD of the cloned repository to the specified commit (writes the SHA to `.git/HEAD`).
117fn detach_head_to_commit(dest: &std::path::Path, commit: &str) -> Result<()> {
118    let repo = gix::open(dest).with_context(|| t!("remote.open_failed").to_string())?;
119    let id = repo
120        .rev_parse_single(commit)
121        .with_context(|| t!("git.commit_not_found", commit = commit))?;
122    let full_sha = id.detach().to_string();
123    let head_path = repo.git_dir().join("HEAD");
124    std::fs::write(&head_path, format!("{full_sha}\n"))
125        .with_context(|| t!("remote.head_write_failed", path = head_path.display()))?;
126    log::info!("{}", t!("remote.head_set", sha = full_sha));
127    Ok(())
128}
129
130#[cfg(test)]
131mod tests {
132    use super::inject_credentials;
133
134    #[test]
135    fn https_injects_user_and_pass() {
136        assert_eq!(
137            inject_credentials("https://host/r.git", Some("u"), Some("p")),
138            "https://u:p@host/r.git"
139        );
140        assert_eq!(
141            inject_credentials("https://host/r.git", Some("u"), None),
142            "https://u@host/r.git"
143        );
144    }
145
146    #[test]
147    fn ssh_injects_user_when_absent() {
148        assert_eq!(
149            inject_credentials("ssh://host/r.git", Some("git"), None),
150            "ssh://git@host/r.git"
151        );
152        // Already has user@ — leave unchanged.
153        assert_eq!(
154            inject_credentials("ssh://git@host/r.git", Some("other"), None),
155            "ssh://git@host/r.git"
156        );
157    }
158
159    #[test]
160    fn scp_like_and_no_user_unchanged() {
161        assert_eq!(
162            inject_credentials("git@host:r.git", Some("u"), None),
163            "git@host:r.git"
164        );
165        assert_eq!(
166            inject_credentials("https://host/r.git", None, None),
167            "https://host/r.git"
168        );
169    }
170}