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 let cb = generate_callbacks()?;
125
126 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
156pub 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 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 }
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 }
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}