Skip to content

Commit

Permalink
feat: reuse a git repository when it was previously cloned (#158)
Browse files Browse the repository at this point in the history
* feat: reuse a git repository when it was previously cloned

* fix: avoid pulling the repository twice when fetching remote branches
  • Loading branch information
Angelmmiguel authored Jun 20, 2023
1 parent 8741b53 commit 8be904c
Show file tree
Hide file tree
Showing 3 changed files with 120 additions and 43 deletions.
2 changes: 1 addition & 1 deletion crates/project/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ pub enum ProjectType {
pub async fn prepare_project(
location: &Path,
force_type: Option<ProjectType>,
options: Option<Options>,
options: Options,
) -> Result<PathBuf> {
let project_type = if force_type.is_some() {
force_type.unwrap()
Expand Down
159 changes: 118 additions & 41 deletions crates/project/src/types/git.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,10 @@

use crate::options::{GitReference, Options};
use anyhow::{anyhow, bail, Result};
use git2::{Oid, Repository};
use git2::{build::CheckoutBuilder, FetchOptions, Oid, Repository};
use sha256::digest as sha256_digest;
use std::{
env::temp_dir,
fs::remove_dir_all,
path::{Path, PathBuf},
};

Expand All @@ -16,58 +15,136 @@ static DEFAULT_REMOTE: &str = "origin";

/// Prepare a project based on a git repository. This method
/// clones the repo locally and returns the path in which it's located.
pub fn prepare_git_project(location: &Path, options: Option<Options>) -> Result<PathBuf> {
pub fn prepare_git_project(location: &Path, options: Options) -> Result<PathBuf> {
let project_url = location
.to_str()
.ok_or(anyhow!("The project URL cannot be retrieved"))?;
let (folder, git_ref) = parse_options(options);
// By default, we use temporary dirs
let mut dir = temp_dir().join(sha256_digest(project_url));

if dir.exists() {
// Clean up a previous download
remove_dir_all(&dir)?;
}

let repo = match Repository::clone(project_url, &dir) {
Ok(repo) => repo,
Err(e) => bail!("There was an error cloning the repository: {e}"),
let repo = if dir.exists() {
// Reuse the same repository.
Repository::open(&dir)
.map_err(|e| anyhow!("There was an error opening the repository: {e}"))?
} else {
// clone it
Repository::clone(project_url, &dir)
.map_err(|e| anyhow!("There was an error cloning the repository: {e}"))?
};

if let Some(options) = options {
if let Some(git) = options.git {
if let Some(git_ref) = git.git_ref {
match git_ref {
GitReference::Commit(commit) => {
let oid = Oid::from_str(&commit)?;
let commit = repo.find_commit(oid)?;
repo.checkout_tree(commit.as_object(), None)?;
}
GitReference::Tag(tag) => {
let mut remote = repo.find_remote(DEFAULT_REMOTE)?;
let tag_remote = format!("refs/tags/{tag}:refs/tags/{tag}");
remote.fetch(&[&tag_remote], None, None)?;

let oid = Oid::from_str(&tag)?;
let tag = repo.find_tag(oid)?;
repo.checkout_tree(tag.as_object(), None)?;
}
GitReference::Branch(branch) => {
let mut remote = repo.find_remote(DEFAULT_REMOTE)?;
let head_remote = format!("refs/heads/{branch}:refs/heads/{branch}");
remote.fetch(&[&head_remote], None, None)?;

let branch = repo.find_branch(&branch, git2::BranchType::Local)?;
let reference = branch.into_reference();
repo.checkout_tree(&reference.peel(git2::ObjectType::Tree)?, None)?;
}
}
if let Some(git_ref) = git_ref.as_ref() {
match git_ref {
GitReference::Commit(commit) => {
pull_default_branch(&repo)?;

let oid = Oid::from_str(commit)?;
repo.set_head_detached(oid)?;
repo.checkout_head(Some(&mut default_checkout()))?;
}
GitReference::Tag(tag) => {
let mut remote = repo.find_remote(DEFAULT_REMOTE)?;
let tag_remote = format!("refs/tags/{tag}:refs/tags/{tag}");
remote.fetch(&[&tag_remote], None, None)?;

repo.set_head(&format!("refs/tags/{tag}"))?;
repo.checkout_head(Some(&mut default_checkout()))?;
}
GitReference::Branch(branch) => {
let mut remote = repo.find_remote(DEFAULT_REMOTE)?;
let head_remote = format!("refs/heads/{branch}:refs/heads/{branch}");
remote.fetch(&[&head_remote], None, None)?;

if let Some(folder) = git.folder {
dir = dir.join(folder);
repo.set_head(&format!("refs/heads/{branch}"))?;
repo.checkout_head(Some(&mut default_checkout()))?;
}
}
} else {
pull_default_branch(&repo)?;
}

if let Some(folder) = folder {
dir = dir.join(folder);
}

Ok(dir)
}

/// Generates a default configuration to checkout the git repository
fn default_checkout<'cb>() -> CheckoutBuilder<'cb> {
let mut checkout_builder = CheckoutBuilder::default();

checkout_builder
.allow_conflicts(true)
.conflict_style_merge(true)
.force();

checkout_builder
}

/// Parse the different configuration parameters from the given Options
fn parse_options(options: Options) -> (Option<String>, Option<GitReference>) {
if let Some(git) = options.git {
(git.folder, git.git_ref)
} else {
(None, None)
}
}

/// Pull the changes from the default branch
fn pull_default_branch(repo: &Repository) -> Result<()> {
let branch = detect_main_branch(repo)?;
pull_repository(repo, branch)
}

/// Detech the main branch of this repository
fn detect_main_branch(repo: &Repository) -> Result<&str> {
// For now, we only distinguish between the two most common branch names.
// Ask the user to set the branch in any other case.
if repo.find_branch("main", git2::BranchType::Local).is_ok() {
Ok("main")
} else if repo.find_branch("master", git2::BranchType::Local).is_ok() {
Ok("master")
} else {
bail!("Couldn't find the default main branch. Please, set the Git branch you want to use.")
}
}

/// Fetch the latest references from a repository and pull all mising
/// objects. This method ensures an existing repo is not stale
fn pull_repository(repo: &Repository, branch: &str) -> Result<()> {
let mut remote = repo.find_remote(DEFAULT_REMOTE)?;
let mut fo = FetchOptions::new();

remote.fetch(&[branch], Some(&mut fo), None)?;

let fetch_head = repo.find_reference("FETCH_HEAD")?;
let fetch_commit = fetch_head.peel_to_commit()?;

// Follow a fast-forward merge by default. These repositories shouldn't be
// modified. In any other case, it will fail.
let refname = format!("refs/heads/{}", branch);

match repo.find_reference(&refname) {
Ok(mut reference) => {
// Get the reference name
let name = match reference.name() {
Some(s) => s.to_string(),
None => String::from_utf8_lossy(reference.name_bytes()).to_string(),
};

// Perform the pull
reference.set_target(fetch_commit.id(), "")?;
repo.set_head(&name)?;
repo.checkout_head(Some(&mut default_checkout()))?;
}
Err(_) => {
// The branch doesn't exist
repo.reference(&refname, fetch_commit.id(), true, "")?;
repo.set_head(&refname)?;
repo.checkout_head(Some(&mut default_checkout()))?;
}
};

Ok(())
}
2 changes: 1 addition & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ async fn main() -> std::io::Result<()> {
let project_opts = options::build_project_options(&args);

println!("⚙️ Preparing the project from: {}", &args.path.display());
let project_path = match prepare_project(&args.path, None, Some(project_opts)).await {
let project_path = match prepare_project(&args.path, None, project_opts).await {
Ok(p) => p,
Err(err) => {
eprintln!("❌ There was an error preparing the project: {err}");
Expand Down

0 comments on commit 8be904c

Please sign in to comment.