jointhedots/git/
operations.rs

1use std::io::{stdin, stdout, Write};
2use std::{error::Error, path::Path, sync::RwLock};
3
4use console::style;
5use dialoguer::{Input, Password};
6use git2::build::CheckoutBuilder;
7use git2::{
8    AnnotatedCommit, Commit, Direction, PushOptions, RemoteCallbacks, Repository, Signature,
9};
10use git2::{Error as Git2Error, IndexAddOption, MergeOptions};
11use git2_credentials::{CredentialHandler, CredentialUI};
12
13use crate::utils::get_theme;
14use lazy_static::lazy_static;
15
16pub fn get_head(repo: &Repository) -> Result<Commit, Box<dyn Error>> {
17    let commit = repo
18        .head()?
19        .resolve()?
20        .peel(git2::ObjectType::Commit)?
21        .into_commit()
22        .unwrap();
23    Ok(commit)
24}
25
26pub fn get_head_hash(repo: &Repository) -> Result<String, Box<dyn Error>> {
27    Ok(get_head(repo)?.id().to_string())
28}
29
30pub fn checkout_ref(repo: &Repository, reference: &str) -> Result<(), Box<dyn Error>> {
31    let (object, reference) = repo
32        .revparse_ext(reference)
33        .map_err(|err| format!("Ref not found: {}", err))?;
34
35    repo.checkout_tree(&object, None)?;
36
37    if let Some(gref) = reference {
38        repo.set_head(gref.name().unwrap())
39    } else {
40        repo.set_head_detached(object.id())
41    }
42    .map_err(|err| format!("Failed to set HEAD: {}", err).into())
43}
44
45pub fn get_commit<'a>(repo: &'a Repository, commit_hash: &str) -> Result<Commit<'a>, Git2Error> {
46    let (object, _) = repo.revparse_ext(commit_hash)?;
47    object.peel_to_commit()
48}
49
50lazy_static! {
51    static ref CREDENTIAL_CACHE: RwLock<(Option<String>, Option<String>)> =
52        RwLock::new((None, None));
53}
54
55pub struct CredentialUIDialoguer;
56
57impl CredentialUI for CredentialUIDialoguer {
58    fn ask_user_password(&self, username: &str) -> Result<(String, String), Box<dyn Error>> {
59        let theme = get_theme();
60
61        let mut credential_cache = CREDENTIAL_CACHE.write()?;
62
63        let user = match &credential_cache.0 {
64            Some(username) => username.to_owned(),
65            None => {
66                let user = Input::with_theme(&theme)
67                    .default(username.to_owned())
68                    .with_prompt("Username")
69                    .interact()?;
70                credential_cache.0 = Some(user.to_owned());
71                user
72            }
73        };
74
75        let password = match &credential_cache.1 {
76            Some(password) => password.to_owned(),
77            None => {
78                let pass = Password::with_theme(&theme)
79                    .with_prompt("Password (hidden)")
80                    .allow_empty_password(true)
81                    .interact()?;
82                credential_cache.1 = Some(pass.to_owned());
83                pass
84            }
85        };
86
87        Ok((user, password))
88    }
89
90    fn ask_ssh_passphrase(&self, passphrase_prompt: &str) -> Result<String, Box<dyn Error>> {
91        let mut credential_cache = CREDENTIAL_CACHE.write()?;
92
93        let passphrase = match &credential_cache.1 {
94            Some(passphrase) => passphrase.to_owned(),
95            None => {
96                let pass = Password::with_theme(&get_theme())
97                    .with_prompt(format!(
98                        "{} (leave blank for no password): ",
99                        passphrase_prompt
100                    ))
101                    .allow_empty_password(true)
102                    .interact()?;
103                credential_cache.1 = Some(pass.to_owned());
104                pass
105            }
106        };
107
108        Ok(passphrase)
109    }
110}
111
112pub fn generate_callbacks() -> Result<RemoteCallbacks<'static>, Box<dyn Error>> {
113    let mut cb = git2::RemoteCallbacks::new();
114    let git_config = git2::Config::open_default()
115        .map_err(|err| format!("Could not open default git config: {}", err))?;
116    let mut ch = CredentialHandler::new_with_ui(git_config, Box::new(CredentialUIDialoguer {}));
117    cb.credentials(move |url, username, allowed| ch.try_next_credential(url, username, allowed));
118
119    Ok(cb)
120}
121
122pub fn clone_repo(url: &str, target_dir: &Path) -> Result<git2::Repository, Box<dyn Error>> {
123    // Clone the project.
124    let cb = generate_callbacks()?;
125
126    // clone a repository
127    let mut fo = git2::FetchOptions::new();
128    fo.remote_callbacks(cb)
129        .download_tags(git2::AutotagOption::All)
130        .update_fetchhead(true);
131    let repo = git2::build::RepoBuilder::new()
132        .fetch_options(fo)
133        .clone(url, target_dir)
134        .map_err(|err| format!("Could not clone repo: {}", &err))?;
135
136    success!("Successfully cloned repository!");
137
138    Ok(repo)
139}
140
141pub fn generate_signature() -> Result<Signature<'static>, Git2Error> {
142    Signature::now("Jointhedots Sync", "jtd@danielobr.ie")
143}
144
145pub fn add_all(repo: &Repository, file_paths: Option<Vec<&Path>>) -> Result<(), Box<dyn Error>> {
146    let mut index = repo.index()?;
147    if let Some(file_paths) = file_paths {
148        index.add_all(file_paths.iter(), IndexAddOption::DEFAULT, None)?;
149    } else {
150        index.add_all(["*"].iter(), IndexAddOption::DEFAULT, None)?;
151    }
152    index.write()?;
153    Ok(())
154}
155
156/// Add and commit the specified files to the repository index.
157///
158/// # Arguments
159///
160/// * `repo` - The repository object
161/// * `file_paths` - Optionally the paths of the files to commit. If `None`, all changes are
162/// committed.
163/// * `message` - The commit message to use
164/// * `parents` - Optionally the parent commits for the new commit. If None, `HEAD` is used
165/// * `update_head` - Optionally whether to update the commit the `HEAD` reference points at.
166///
167/// # Returns
168///
169/// The new commit in the repository
170pub fn add_and_commit<'a>(
171    repo: &'a Repository,
172    file_paths: Option<Vec<&Path>>,
173    message: &str,
174    maybe_parents: Option<Vec<&Commit>>,
175    update_ref: Option<&str>,
176) -> Result<Commit<'a>, Box<dyn Error>> {
177    add_all(&repo, file_paths)?;
178
179    let mut index = repo.index()?;
180    let oid = index.write_tree()?;
181    let tree = repo.find_tree(oid)?;
182    let signature = generate_signature()?;
183
184    let head;
185    let parents = match maybe_parents {
186        Some(parent_vec) => parent_vec,
187        None => {
188            head = get_head(repo)?;
189            vec![&head]
190        }
191    };
192    let oid = repo.commit(update_ref, &signature, &signature, message, &tree, &parents)?;
193
194    repo.find_commit(oid)
195        .map_err(|err| format!("Failed to commit to repo: {}", err.to_string()).into())
196}
197
198pub fn normal_merge<'a>(
199    repo: &'a Repository,
200    main_tip: &AnnotatedCommit,
201    feature_tip: &AnnotatedCommit,
202) -> Result<Commit<'a>, Box<dyn Error>> {
203    let mut options = MergeOptions::new();
204    options
205        .standard_style(true)
206        .minimal(true)
207        .fail_on_conflict(false);
208    repo.merge(&[feature_tip], Some(&mut options), None)?;
209
210    let mut idx = repo.index()?;
211    idx.read(false)?;
212    if idx.has_conflicts() {
213        let repo_dir = repo.path().to_string_lossy().replace(".git/", "");
214        repo.checkout_index(
215            Some(&mut idx),
216            Some(
217                CheckoutBuilder::default()
218                    .allow_conflicts(true)
219                    .conflict_style_merge(true),
220            ),
221        )?;
222        error!(
223            "Merge conficts detected. Resolve them manually with the following steps:\n\n  \
224             1. Open the temporary repository (located in {}),\n  \
225             2. Resolve any merge conflicts as you would with any other repository\n  \
226             3. Adding the changed files but NOT committing them\n  \
227             4. Returning to this terminal and pressing the \"Enter\" key\n",
228            repo_dir
229        );
230        loop {
231            print!(
232                "{}",
233                style("Press ENTER when conflicts are resolved")
234                    .blue()
235                    .italic()
236            );
237            let _ = stdout().flush();
238
239            let mut _newline = String::new();
240            stdin().read_line(&mut _newline).unwrap_or(0);
241
242            idx.read(false)?;
243
244            if !idx.has_conflicts() {
245                break;
246            } else {
247                error!("Conflicts not resolved");
248            }
249        }
250    }
251
252    let tree = repo.find_tree(repo.index()?.write_tree()?)?;
253    let signature = generate_signature()?;
254    repo.commit(
255        Some("HEAD"),
256        &signature,
257        &signature,
258        "Merge",
259        &tree,
260        &[
261            &repo.find_commit(main_tip.id())?,
262            &repo.find_commit(feature_tip.id())?,
263        ],
264    )?;
265    repo.cleanup_state()?;
266    Ok(get_head(&repo)?)
267}
268
269pub fn get_repo_dir(repo: &Repository) -> &Path {
270    // Safe to unwrap here, repo.path() points to .git folder. Path will always
271    // have a component before .git
272    repo.path().parent().unwrap()
273}
274
275pub fn push(repo: &Repository) -> Result<(), Box<dyn Error>> {
276    let mut remote = repo.find_remote("origin")?;
277
278    remote.connect_auth(Direction::Push, Some(generate_callbacks()?), None)?;
279    let mut options = PushOptions::new();
280    options.remote_callbacks(generate_callbacks()?);
281    remote
282        .push(&["refs/heads/master:refs/heads/master"], Some(&mut options))
283        .map_err(|err| format!("Could not push to remote repo: {}", err).into())
284}
285
286#[cfg(test)]
287mod tests {
288    use std::fs::File;
289
290    use tempfile::tempdir;
291
292    use super::*;
293
294    #[test]
295    fn test_get_head() {
296        let repo_dir = tempdir().expect("Could not create temporary repo dir");
297        let repo = Repository::init(&repo_dir).expect("Could not initialise repository");
298
299        let commit = add_and_commit(&repo, None, "", Some(vec![]), Some("HEAD")).unwrap();
300
301        assert_eq!(commit.id(), get_head(&repo).unwrap().id());
302    }
303
304    #[test]
305    fn test_get_head_hash() {
306        let repo_dir = tempdir().unwrap();
307        let repo = Repository::init(&repo_dir).unwrap();
308
309        let commit = add_and_commit(&repo, None, "", Some(vec![]), Some("HEAD")).unwrap();
310
311        assert_eq!(commit.id().to_string(), get_head_hash(&repo).unwrap());
312    }
313
314    #[test]
315    fn test_checkout_ref() {
316        let repo_dir = tempdir().expect("Could not create temporary repo dir");
317        let repo = Repository::init(&repo_dir).expect("Could not initialise repository");
318
319        let first_commit = add_and_commit(&repo, None, "", Some(vec![]), Some("HEAD")).unwrap();
320        let second_commit =
321            add_and_commit(&repo, None, "", Some(vec![&first_commit]), Some("HEAD")).unwrap();
322
323        assert_eq!(
324            repo.head().unwrap().peel_to_commit().unwrap().id(),
325            second_commit.id()
326        );
327
328        checkout_ref(&repo, &first_commit.id().to_string())
329            .expect("Failed to checkout first commit");
330
331        assert_eq!(get_head_hash(&repo).unwrap(), first_commit.id().to_string());
332    }
333
334    #[test]
335    fn test_get_commit() {
336        let repo_dir = tempdir().unwrap();
337        let repo = Repository::init(&repo_dir).unwrap();
338
339        let commit = add_and_commit(&repo, None, "", Some(vec![]), Some("HEAD")).unwrap();
340        let hash = commit.id().to_string();
341
342        assert_eq!(
343            get_commit(&repo, &hash).unwrap().id().to_string(),
344            commit.id().to_string()
345        );
346    }
347
348    #[test]
349    fn test_ask_user_password_with_cache() {
350        {
351            let mut credential_cache = CREDENTIAL_CACHE
352                .write()
353                .expect("Could not get write handle on credential cache");
354            credential_cache.0 = Some("username".to_string());
355            credential_cache.1 = Some("password".to_string());
356        }
357
358        let credential_ui = CredentialUIDialoguer;
359
360        let credentials = credential_ui
361            .ask_user_password("")
362            .expect("Could not get user password");
363        assert_eq!(
364            ("username".to_string(), "password".to_string()),
365            credentials
366        );
367    }
368
369    #[test]
370    fn test_ask_ssh_passphrase_with_cache() {
371        {
372            let mut credential_cache = CREDENTIAL_CACHE
373                .write()
374                .expect("Could not get write handle on credential cache");
375            credential_cache.1 = Some("password".to_string());
376        }
377
378        let credential_ui = CredentialUIDialoguer;
379
380        let credentials = credential_ui
381            .ask_ssh_passphrase("")
382            .expect("Could not get user password");
383        assert_eq!("password".to_string(), credentials);
384    }
385
386    #[test]
387    fn test_generate_callbacks() {
388        let _callbacks = generate_callbacks().expect("Failed to generate callbacks");
389        // FIXME: Find some way to assert the return type of callbacks
390    }
391
392    #[test]
393    fn test_clone_repo() {
394        let repo_dir = tempdir().expect("Failed to create tempdir");
395
396        let _repo = clone_repo("https://github.com/dob9601/dotfiles.git", repo_dir.path())
397            .expect("Failed to clone repo");
398
399        assert!(Path::exists(
400            &repo_dir.path().to_owned().join(Path::new("jtd.yaml"))
401        ));
402    }
403
404    #[test]
405    fn test_add_and_commit() {
406        let repo_dir = tempdir().expect("Could not create temporary repo dir");
407        let repo = Repository::init(&repo_dir).expect("Could not initialise repository");
408
409        let mut filepath = repo_dir.path().to_owned();
410        filepath.push(Path::new("file.rs"));
411        File::create(filepath.to_owned()).expect("Could not create file in repo");
412
413        add_and_commit(
414            &repo,
415            Some(vec![&filepath]),
416            "commit message",
417            Some(vec![]),
418            Some("HEAD"),
419        )
420        .expect("Failed to commit to repository");
421        assert_eq!(
422            "commit message",
423            get_head(&repo)
424                .unwrap()
425                .message()
426                .expect("No commit message found")
427        );
428    }
429
430    #[test]
431    fn test_normal_merge() {
432        let repo_dir = tempdir().expect("Could not create temporary repo dir");
433        let repo = Repository::init(&repo_dir).expect("Could not initialise repository");
434
435        let first_commit = add_and_commit(
436            &repo,
437            Some(vec![]),
438            "1st commit",
439            Some(vec![]),
440            Some("HEAD"),
441        )
442        .expect("Failed to create 1st commit");
443
444        let _second_commit = add_and_commit(
445            &repo,
446            Some(vec![]),
447            "2nd commit",
448            Some(vec![&first_commit]),
449            Some("HEAD"),
450        )
451        .expect("Failed to create 2nd commit");
452
453        let head_ref = &repo.head().unwrap();
454        let head_ref_name = head_ref.name().unwrap();
455        let annotated_main_head = repo.reference_to_annotated_commit(&head_ref).unwrap();
456
457        let _branch = repo
458            .branch("branch", &first_commit, true)
459            .expect("Failed to create branch");
460        checkout_ref(&repo, "branch").expect("Failed to checkout new branch");
461
462        let annotated_branch_head = repo
463            .reference_to_annotated_commit(&repo.head().unwrap())
464            .unwrap();
465
466        checkout_ref(&repo, head_ref_name).expect("Failed to checkout new branch");
467
468        normal_merge(&repo, &annotated_main_head, &annotated_branch_head)
469            .expect("Failed to merge branch");
470
471        // FIXME: Some assertion on the repo state after this
472    }
473
474    #[test]
475    fn test_generate_signature() {
476        let signature = generate_signature().unwrap();
477
478        assert_eq!(signature.email().unwrap(), "jtd@danielobr.ie");
479        assert_eq!(signature.name().unwrap(), "Jointhedots Sync");
480    }
481}