libwally/
git_util.rs

1//! Contains helpers for wrangling git2.
2//!
3//! A good source of ideas for how we should be interacting with Git is
4//! contained in Cargo's `git/utils.rs`:
5//! https://github.com/rust-lang/cargo/blob/master/src/cargo/sources/git/utils.rs
6
7use std::io;
8use std::path::Path;
9
10use anyhow::{bail, format_err, Context};
11use git2::build::RepoBuilder;
12use git2::{
13    Cred, CredentialType, FetchOptions, RemoteCallbacks, Repository, RepositoryInitOptions,
14};
15use url::Url;
16use walkdir::WalkDir;
17
18/// Based roughly on Cargo's approach to handling authentication, but very pared
19/// down.
20///
21/// https://github.com/rust-lang/cargo/blob/79b397d72c557eb6444a2ba0dc00a211a226a35a/src/cargo/sources/git/utils.rs#L588
22fn make_credentials_callback(
23    access_token: Option<String>,
24    config: &git2::Config,
25) -> impl (FnMut(&str, Option<&str>, CredentialType) -> Result<Cred, git2::Error>) + '_ {
26    let mut cred_helper_tried = false;
27    let mut token_tried = false;
28
29    move |url, username, allowed_types| {
30        if allowed_types.contains(CredentialType::USER_PASS_PLAINTEXT) {
31            if let Some(token) = &access_token {
32                if !token_tried {
33                    token_tried = true;
34                    return Cred::userpass_plaintext(&token, "");
35                }
36            } else {
37                if !cred_helper_tried {
38                    cred_helper_tried = true;
39                    return Cred::credential_helper(config, url, username);
40                }
41            }
42        }
43
44        if allowed_types.contains(CredentialType::DEFAULT) {
45            return Cred::default();
46        }
47
48        Err(git2::Error::from_str("no authentication available"))
49    }
50}
51
52/// We want to use a mock repo in tests but we don't want have to manually initialise it
53/// or manage commiting new files. This method will ensure the test repo is a valid git repo
54/// with all files commited to the main branch. Typically test-registries/primary-registry/index.
55pub fn init_test_repo(path: &Path) -> anyhow::Result<()> {
56    // If tests previous ran then this may already be a repo however we want to start fresh
57    if path.join(".git").exists() {
58        fs_err::remove_dir_all(path.join(".git"))?;
59    }
60
61    let repository = Repository::init_opts(
62        path,
63        RepositoryInitOptions::new().initial_head("refs/heads/main"),
64    )?;
65    let mut git_index = repository.index()?;
66
67    for entry in WalkDir::new(path).min_depth(1) {
68        let entry = entry?;
69        let relative_path = entry.path().strip_prefix(path)?;
70
71        if !relative_path.starts_with(".git") && entry.file_type().is_file() {
72            // git add $file
73            git_index.add_path(relative_path)?;
74        }
75    }
76
77    // git commit -m "..."
78    let sig = git2::Signature::now("PackageUser", "PackageUser@localhost")?;
79    let tree = repository.find_tree(git_index.write_tree()?)?;
80
81    repository.commit(
82        Some("HEAD"),
83        &sig,
84        &sig,
85        "Commit all files in test repo",
86        &tree,
87        &[],
88    )?;
89
90    Ok(())
91}
92
93pub fn open_or_clone(
94    access_token: Option<String>,
95    url: &Url,
96    path: &Path,
97) -> anyhow::Result<Repository> {
98    let repo = match Repository::open(path) {
99        Ok(repo) => repo,
100        Err(_) => {
101            if let Err(err) = fs_err::remove_dir_all(path) {
102                // If we were unable to remove the directory because it
103                // wasn't found, that's fine! We didn't want it here
104                // anyways.
105                if err.kind() != io::ErrorKind::NotFound {
106                    return Err(err.into());
107                }
108            }
109
110            fs_err::create_dir_all(path)?;
111            clone(access_token, url, path)
112                .with_context(|| format!("Error cloning Git repository {}", url))?
113        }
114    };
115
116    Ok(repo)
117}
118
119pub fn clone(access_token: Option<String>, url: &Url, into: &Path) -> anyhow::Result<Repository> {
120    let git_config = git2::Config::open_default()?;
121
122    let mut callbacks = RemoteCallbacks::new();
123    callbacks.credentials(make_credentials_callback(access_token, &git_config));
124
125    let mut fetch_options = FetchOptions::new();
126    fetch_options.remote_callbacks(callbacks);
127
128    let mut builder = RepoBuilder::new();
129    builder.fetch_options(fetch_options);
130
131    let repo = builder.clone(url.as_str(), into)?;
132    Ok(repo)
133}
134
135pub fn commit_and_push(
136    repository: &Repository,
137    access_token: Option<String>,
138    message: &str,
139    index_path: &Path,
140    modified_file: &Path,
141) -> anyhow::Result<()> {
142    let git_config = git2::Config::open_default()?;
143
144    // libgit2 only accepts a relative path
145    let relative_path = modified_file.strip_prefix(&index_path).with_context(|| {
146        format!(
147            "Path {} was not relative to package path {}",
148            modified_file.display(),
149            index_path.display()
150        )
151    })?;
152
153    // git add $file
154    let mut index = repository.index()?;
155    index.add_path(relative_path)?;
156    index.write()?;
157    let tree_id = index.write_tree()?;
158    let tree = repository.find_tree(tree_id)?;
159
160    // git commit -m "..."
161    let head = repository.head()?;
162    let parent = repository.find_commit(head.target().unwrap())?;
163    let sig = git2::Signature::now("PackageUser", "PackageUser@localhost")?;
164    repository.commit(Some("HEAD"), &sig, &sig, &message, &tree, &[&parent])?;
165
166    // git push
167    let mut ref_status = Ok(());
168    let mut callback_called = false;
169    {
170        let mut origin = repository.find_remote("origin")?;
171        let mut callbacks = RemoteCallbacks::new();
172        callbacks.credentials(make_credentials_callback(access_token, &git_config));
173        callbacks.push_update_reference(|refname, status| {
174            assert_eq!(refname, "refs/heads/main");
175            if let Some(s) = status {
176                ref_status = Err(format_err!("failed to push a ref: {}", s))
177            }
178            callback_called = true;
179            Ok(())
180        });
181        let mut opts = git2::PushOptions::new();
182        opts.remote_callbacks(callbacks);
183        origin.push(&["refs/heads/main"], Some(&mut opts))?;
184    }
185
186    if !callback_called {
187        bail!("update_reference callback was not called");
188    }
189
190    ref_status
191}
192
193pub fn update_index(access_token: Option<String>, repository: &Repository) -> anyhow::Result<()> {
194    let git_config = git2::Config::open_default()?;
195
196    let mut callbacks = RemoteCallbacks::new();
197    callbacks.credentials(make_credentials_callback(access_token, &git_config));
198
199    let mut fetch_options = FetchOptions::new();
200    fetch_options.remote_callbacks(callbacks);
201
202    repository
203        .find_remote("origin")?
204        .fetch(&["main"], Some(&mut fetch_options), None)
205        .with_context(|| format!("could not fetch Git repository"))?;
206
207    let mut options = git2::build::CheckoutBuilder::new();
208    options.force();
209
210    // "git reset --hard" to the latest commit in the remote repo
211    let commit = repository.find_reference("FETCH_HEAD")?.peel_to_commit()?;
212    repository
213        .reset(
214            &commit.into_object(),
215            git2::ResetType::Hard,
216            Some(&mut options),
217        )
218        .with_context(|| format!("could not reset git repo to fetch_head"))?;
219
220    Ok(())
221}