trisync 0.1.3

A friendly CLI Tool for automating synchronization of multiple TRIRIGA environment by using the OM API
Documentation
use std::env;
use std::io;
use std::io::Write;
use std::str;

use std::path::Path;

use git2::{
	Commit, Cred, Error, Index, IndexAddOption, ObjectType, Oid, PushOptions, RemoteCallbacks,
	Repository, ResetType, Signature,
};
pub struct Repo {
	path: String,
}

impl Repo {
	pub fn new(path: &str) -> Repo {
		Repo {
			path: path.to_owned(),
		}
	}

	/// Look for SSH key in `SPM_SSH_KEY` variable. If it is not specifed then use SSH key from `$HOME/.ssh/id_rsa`
	fn git_credentials_callback(
		_user: &str,
		_user_from_url: Option<&str>,
		_cred: git2::CredentialType,
	) -> Result<git2::Cred, git2::Error> {
		let user = _user_from_url.unwrap_or("git");
		if _cred.contains(git2::CredentialType::USERNAME) {
			return git2::Cred::username(user);
		}
		match env::var("GPM_SSH_KEY") {
			Ok(k) => {
				println!(
					"authenticate with user {} and private key located in {}",
					user, k
				);
				git2::Cred::ssh_key(user, None, std::path::Path::new(&k), None)
			}
			_ => {
				let ssh_path = Path::new(&dirs::home_dir().unwrap())
					.join(Path::new(".ssh/id_rsa"));
				if !ssh_path.exists() {
					panic!("No SSH Key found in ~/.ssh")
				}

				Cred::ssh_key(_user_from_url.unwrap(), None, &ssh_path, None)
			}
		}
	}

	#[allow(dead_code)]
	fn reset(&self) -> Result<(), Box<Error>> {
		let repo = match Repository::open(&self.path) {
			Ok(repo) => repo,
			Err(e) => panic!("Failed to open: {}", e),
		};

		repo.reset(
			&repo.revparse_single("HEAD").unwrap(),
			ResetType::Hard,
			None,
		)?;
		Ok(())
	}
	pub fn find_last_commit<'repo>(
		&self,
		repo: &'repo Repository,
	) -> Result<Commit<'repo>, Error> {
		let obj = repo.head()?.resolve()?.peel(ObjectType::Commit)?;
		match obj.into_commit() {
			Ok(c) => Ok(c),
			_ => Err(Error::from_str("commit error")),
		}
	}

	/// Determine if the current HEAD is ahead/behind its remote. The tuple
	/// returned will be in the order ahead and then behind.
	///
	/// If the remote is not set or doesn't exist (like a detached HEAD),
	/// (false, false) will be returned.
	pub fn is_ahead_behind_remote(&self) -> (bool, bool) {
		let repo = Repository::open(&self.path).unwrap();
		let mut remote = repo.find_remote("origin").unwrap();
		self.do_fetch(&repo, &["master"], &mut remote).unwrap();
		let head = repo.revparse_single("HEAD").unwrap().id();
		println!("🏠 HEAD: {:?}", head);
		if let Some((upstream, _)) = repo.revparse_ext("@{u}").ok() {
			println!("📻️ Upstream: {:?}", upstream.id());
			return match repo.graph_ahead_behind(head, upstream.id()) {
				Ok((commits_ahead, commits_behind)) => {
					(commits_ahead > 0, commits_behind > 0)
				}
				Err(_) => (false, false),
			};
		}
		(false, false)
	}

	fn do_fetch<'a>(
		&self,
		repo: &'a git2::Repository,
		refs: &[&str],
		remote: &'a mut git2::Remote,
	) -> Result<git2::AnnotatedCommit<'a>, git2::Error> {
		let mut cb = git2::RemoteCallbacks::new();
		cb.credentials(Repo::git_credentials_callback);
		// Print out our transfer progress.
		cb.transfer_progress(|stats| {
			if stats.received_objects() == stats.total_objects() {
				print!(
					"Resolving deltas {}/{}\r",
					stats.indexed_deltas(),
					stats.total_deltas()
				);
			} else if stats.total_objects() > 0 {
				print!(
					"Received {}/{} objects ({}) in {} bytes\r",
					stats.received_objects(),
					stats.total_objects(),
					stats.indexed_objects(),
					stats.received_bytes()
				);
			}
			io::stdout().flush().unwrap();
			true
		});
		let mut fo = git2::FetchOptions::new();
		fo.remote_callbacks(cb);
		// Always fetch all tags.
		// Perform a download and also update tips
		fo.download_tags(git2::AutotagOption::All);
		println!("🪂 Fetching {} for repo", remote.name().unwrap());
		remote.fetch(refs, Some(&mut fo), None)?;
		// If there are local objects (we got a thin pack), then tell the user
		// how many objects we saved from having to cross the network.
		let stats = remote.stats();
		if stats.local_objects() > 0 {
			println!(
				"\rReceived {}/{} objects in {} bytes (used {} local objects)",
				stats.indexed_objects(),
				stats.total_objects(),
				stats.received_bytes(),
				stats.local_objects()
			);
		} else {
			println!(
				"\rReceived {}/{} objects in {} bytes",
				stats.indexed_objects(),
				stats.total_objects(),
				stats.received_bytes()
			);
		}
		let fetch_head = repo.find_reference("FETCH_HEAD")?;
		Ok(repo.reference_to_annotated_commit(&fetch_head)?)
	}

	fn fast_forward(
		&self,
		repo: &Repository,
		lb: &mut git2::Reference,
		rc: &git2::AnnotatedCommit,
	) -> Result<(), git2::Error> {
		let name = match lb.name() {
			Some(s) => s.to_string(),
			None => String::from_utf8_lossy(lb.name_bytes()).to_string(),
		};
		let msg = format!("Fast-Forward: Setting {} to id: {}", name, rc.id());
		println!("{}", msg);
		lb.set_target(rc.id(), &msg)?;
		repo.set_head(&name)?;
		repo.checkout_head(Some(git2::build::CheckoutBuilder::default()
			// For some reason the force is required to make the working directory actually get updated
			// I suspect we should be adding some logic to handle dirty working directory states
			// but this is just an example so maybe not.
			.force()))?;
		Ok(())
	}

	fn normal_merge(
		&self,
		repo: &Repository,
		local: &git2::AnnotatedCommit,
		remote: &git2::AnnotatedCommit,
	) -> Result<(), git2::Error> {
		let local_tree = repo.find_commit(local.id())?.tree()?;
		let remote_tree = repo.find_commit(remote.id())?.tree()?;
		let ancestor = repo
			.find_commit(repo.merge_base(local.id(), remote.id())?)?
			.tree()?;
		let mut idx = repo.merge_trees(&ancestor, &local_tree, &remote_tree, None)?;
		if idx.has_conflicts() {
			println!("⛔ Merge conficts detected...");
			repo.checkout_index(Some(&mut idx), None)?;
			return Ok(());
		}
		let result_tree = repo.find_tree(idx.write_tree_to(repo)?)?;
		// now create the merge commit
		let msg = format!("Merge: {} into {}", remote.id(), local.id());
		let sig = repo.signature()?;
		let local_commit = repo.find_commit(local.id())?;
		let remote_commit = repo.find_commit(remote.id())?;
		// Do our merge commit and set current branch head to that commit.
		let _merge_commit = repo.commit(
			Some("HEAD"),
			&sig,
			&sig,
			&msg,
			&result_tree,
			&[&local_commit, &remote_commit],
		)?;
		// Set working tree to match head.
		repo.checkout_head(None)?;
		Ok(())
	}

	fn do_merge<'a>(
		&self,
		repo: &'a Repository,

		remote_branch: &str,
		fetch_commit: git2::AnnotatedCommit<'a>,
	) -> Result<(), git2::Error> {
		// 1. do a merge analysis
		let analysis = repo.merge_analysis(&[&fetch_commit])?;
		// 2. Do the appopriate merge
		if analysis.0.is_fast_forward() {
			println!("⏩ Doing a fast forward");
			// do a fast forward
			let refname = format!("refs/heads/{}", remote_branch);
			match repo.find_reference(&refname) {
				Ok(mut r) => {
					self.fast_forward(&repo, &mut r, &fetch_commit)?;
				}
				Err(_) => {
					// The branch doesn't exist so just set the reference to the
					// commit directly. Usually this is because you are pulling
					// into an empty repository.
					repo.reference(
						&refname,
						fetch_commit.id(),
						true,
						&format!(
							"Setting {} to {}",
							remote_branch,
							fetch_commit.id()
						),
					)?;
					repo.set_head(&refname)?;
					repo.checkout_head(Some(
						git2::build::CheckoutBuilder::default()
							.allow_conflicts(true)
							.conflict_style_merge(true)
							.force(),
					))?;
				}
			};
		} else if analysis.0.is_normal() {
			// do a normal merge
			let head_commit = repo.reference_to_annotated_commit(&repo.head()?)?;
			self.normal_merge(&repo, &head_commit, &fetch_commit)?;
		} else {
			println!("Nothing to do...");
		}
		Ok(())
	}

	/// Pull changes and return a tuple containing a boolean indicating wether there was any change and the index of the last commit (merge commit in case of change)
	pub fn pull(&self) -> Result<(), Error> {
		let repo = Repository::open(&self.path)?;
		println!("🔍 Pulling update from Github");
		let mut remote = repo.find_remote("origin")?;
		let fetch_commit = self.do_fetch(&repo, &["master"], &mut remote)?;
		self.do_merge(&repo, "master", fetch_commit)?;
		Ok(())
	}

	#[allow(dead_code)]
	pub fn check(&self) {
		let repo_path = Path::new(&self.path);

		if repo_path.exists() && repo_path.is_dir() {
			self.reset().expect("Could not reset repo");
			let _ = match self.pull() {
				Ok(idx) => idx,
				Err(e) => panic!("Failed to pull: {}", e),
			};
		}
	}

	fn get_repo(&self) -> Repository {
		let repo_path = Path::new(&self.path);
		match Repository::open(repo_path) {
			Ok(r) => r,
			Err(_) => panic!("Supplied path is not a git directory. Consider initializing it first"),

		}
	}

	#[allow(dead_code)]
	fn add_file(&self, file_path: &Path) -> Result<Index, Error> {
		let repo = self.get_repo();
		let mut index = repo.index()?;

		index.add_path(file_path)?;
		index.write().unwrap();
		return Ok(index);
	}

	/// All all file from the current directory to commit
	pub fn add_all(&self) -> Result<Index, Error> {
		let repo = self.get_repo();
		let mut index = repo.index()?;

		index.add_all(["*"].iter(), IndexAddOption::DEFAULT, None)?;
		index.write().expect("Failed to write index to disk: {}");
		return Ok(index);
	}

	/// Commit added file with a message to an index. The author is Tri-sync CLI
	pub fn commit(&self, mut index: Index, message: &str) -> Result<Oid, Error> {
		let repo = self.get_repo();
		let oid = index.write_tree_to(&repo)?;
		let signature = Signature::now("Tri-sync CLI", "tri-sync@ibm.com")?;
		let parent_commit = self.find_last_commit(&repo)?;
		let tree = repo.find_tree(oid)?;

		repo.commit(
			Some("HEAD"),
			&signature,
			&signature,
			message,
			&tree,
			&[&parent_commit],
		)
	}

	/// Find remote `origin` of the repository
	pub fn find_remote_origin_url<'repo>(&self) -> Option<String> {
		let repo = self.get_repo();
		let url = repo.find_remote("origin");
		match url {
			Ok(u) => match u.url() {
				Some(s) => Some(String::from(s)),
				None => None,
			},
			Err(_) => None,
		}
	}

	/// Return the lastest commit hash
	pub fn last_commit_id(&self) -> Result<String, Box<Error>> {
		let repo = self.get_repo();

		let commit_id = self.find_last_commit(&repo)?.id().to_string();

		Ok(commit_id)
	}

	/// Push change to remote
	pub fn push(&self) -> Result<(), Error> {
		let repo_path = Path::new(&self.path);
		let mut push_opts = PushOptions::new();
		let mut callbacks = RemoteCallbacks::new();
		callbacks.credentials(Repo::git_credentials_callback);
		push_opts.remote_callbacks(callbacks);
		let repo = match Repository::open(repo_path) {
			Ok(r) => r,
			Err(e) => panic!("Could not open repository: {}", e),
		};

		return repo.find_remote("origin")?.push(
			&["refs/heads/master:refs/heads/master"],
			Some(&mut push_opts),
		);
	}
}