diff options
| author | Max Audron <audron@cocaine.farm> | 2024-03-27 12:57:30 +0100 |
|---|---|---|
| committer | Max Audron <audron@cocaine.farm> | 2024-03-27 12:57:30 +0100 |
| commit | 3a88527328952ddffef0bf228f0832e81fcfdf19 (patch) | |
| tree | 8de19b16c5038537a714d2bfe34892d20e6d5626 /src | |
| parent | release 1.0.4 (diff) | |
implement basic cloning and updating with gix
Diffstat (limited to 'src')
| -rw-r--r-- | src/git/mod.rs | 46 | ||||
| -rw-r--r-- | src/repo/aggregate.rs | 22 | ||||
| -rw-r--r-- | src/repo/mod.rs | 325 | ||||
| -rw-r--r-- | src/sync/mod.rs | 32 | ||||
| -rw-r--r-- | src/update/mod.rs | 77 |
5 files changed, 307 insertions, 195 deletions
diff --git a/src/git/mod.rs b/src/git/mod.rs index 60d0b46..0653072 100644 --- a/src/git/mod.rs +++ b/src/git/mod.rs @@ -1,27 +1,27 @@ -pub fn git_credentials_callback( - _user: &str, - user_from_url: Option<&str>, - _cred: git2::CredentialType, -) -> Result<git2::Cred, git2::Error> { - if let Some(user) = user_from_url { - git2::Cred::ssh_key_from_agent(user) - } else { - Err(git2::Error::from_str("no url username found")) - } -} +// pub fn git_credentials_callback( +// _user: &str, +// user_from_url: Option<&str>, +// _cred: git2::CredentialType, +// ) -> Result<git2::Cred, git2::Error> { +// if let Some(user) = user_from_url { +// git2::Cred::ssh_key_from_agent(user) +// } else { +// Err(git2::Error::from_str("no url username found")) +// } +// } -pub fn callbacks<'g>() -> git2::RemoteCallbacks<'g> { - let mut callbacks = git2::RemoteCallbacks::new(); - callbacks.credentials(git_credentials_callback); +// pub fn callbacks<'g>() -> git2::RemoteCallbacks<'g> { +// let mut callbacks = git2::RemoteCallbacks::new(); +// callbacks.credentials(git_credentials_callback); - callbacks -} +// callbacks +// } -#[tracing::instrument(level = "trace")] -pub fn fetch_options<'g>() -> git2::FetchOptions<'g> { - let mut opts = git2::FetchOptions::new(); - opts.remote_callbacks(callbacks()); - opts.download_tags(git2::AutotagOption::All); +// #[tracing::instrument(level = "trace")] +// pub fn fetch_options<'g>() -> git2::FetchOptions<'g> { +// let mut opts = git2::FetchOptions::new(); +// opts.remote_callbacks(callbacks()); +// opts.download_tags(git2::AutotagOption::All); - opts -} +// opts +// } diff --git a/src/repo/aggregate.rs b/src/repo/aggregate.rs index d5a59ba..f8e44a5 100644 --- a/src/repo/aggregate.rs +++ b/src/repo/aggregate.rs @@ -1,11 +1,7 @@ -use std::{ - collections::HashMap, - sync::RwLock, -}; +use std::{collections::HashMap, os::unix::ffi::OsStrExt, sync::RwLock}; -use git2::Repository; - -use tracing::error; +use gix::bstr::ByteSlice; +use tracing::{debug, error}; use walkdir::WalkDir; use crate::forge::Project; @@ -25,7 +21,7 @@ impl Aggregator for Repos { fn from_local(root: &str, scope: &str) -> Repos { let mut repos = HashMap::new(); - let path: std::path::PathBuf = [root, scope].iter().collect(); + let path: std::path::PathBuf = root.into(); if !path.exists() { return repos; @@ -40,7 +36,8 @@ impl Aggregator for Repos { Some(Ok(entry)) => entry, }; - if entry.file_type().is_dir() { + if entry.file_type().is_dir() && entry.path().as_os_str().as_bytes().contains_str(scope) + { let mut dir = std::fs::read_dir(entry.path()).unwrap(); if dir.any(|dir| { @@ -52,7 +49,9 @@ impl Aggregator for Repos { }) { walker.skip_current_dir(); - match Repository::open(entry.path()) { + debug!("found git repo {:?} trying to open...", entry.path()); + + match gix::open(entry.path()) { Ok(repo) => { let name = entry .path() @@ -104,8 +103,7 @@ impl Aggregator for Repos { local = local .into_iter() .map(|(left_name, left)| { - if let Some(right) = remote.remove(&left_name) - { + if let Some(right) = remote.remove(&left_name) { left.write().unwrap().forge = right.into_inner().unwrap().forge; } diff --git a/src/repo/mod.rs b/src/repo/mod.rs index aaf0177..4eb9043 100644 --- a/src/repo/mod.rs +++ b/src/repo/mod.rs @@ -1,14 +1,18 @@ -use std::{ - collections::HashMap, - fmt::Debug, - path::PathBuf, - sync::RwLock, -}; +use std::{collections::HashMap, fmt::Debug, path::PathBuf, sync::RwLock}; +use anyhow::Context; use thiserror::Error; -use git2::{Branch, Remote, Repository}; -use tracing::{debug, trace}; +use gix::{ + bstr::BString, + clone::checkout::main_worktree::ProgressId, + refs::{ + transaction::{LogChange, PreviousValue, RefEdit}, + FullName, + }, + remote, Id, ObjectId, Progress, Remote, Repository, +}; +use tracing::{debug, error}; use crate::forge::Project; @@ -30,125 +34,216 @@ pub struct Repo { } impl Repo { - pub fn is_clean(&self) -> Result<bool, RepoError> { - if let Some(repo) = &self.repo { - debug!("repo state: {:?}", repo.state()); - let statuses: Vec<git2::Status> = repo - .statuses(None)? - .iter() - .filter_map(|status| { - if status.status().is_ignored() { - None - } else { - Some(status.status()) - } - }) - .collect(); - - debug!("got repo statuses: {:?}", statuses); - - if repo.state() == git2::RepositoryState::Clean && statuses.is_empty() { - Ok(true) - } else { - Ok(false) - } - } else { - Err(RepoError::NoLocalRepo) + pub fn repo(&self) -> Result<&Repository, RepoError> { + match &self.repo { + Some(repo) => Ok(repo), + None => Err(RepoError::NoLocalRepo), + } + } + + pub fn repo_mut(&mut self) -> Result<&mut Repository, RepoError> { + match &mut self.repo { + Some(repo) => Ok(repo), + None => Err(RepoError::NoLocalRepo), } } - pub fn main_remote<'a>(&self, repo: &'a Repository) -> Result<git2::Remote<'a>, RepoError> { - let remotes = repo.remotes()?; + #[tracing::instrument(level = "debug")] + pub fn is_clean(&self) -> Result<LocalRepoState, RepoError> { + let repo = self.repo()?; - let remote = if remotes.iter().any(|x| x == Some("origin")) { - "origin" - } else if let Some(remote) = remotes.get(0) { - remote + if let Some(state) = repo.state() { + Ok(LocalRepoState::InProgress(state)) } else { - return Err(RepoError::NoRemoteFound); - }; + let head = repo.head().unwrap(); + + if head.is_detached() { + return Ok(LocalRepoState::DetachedHead); + } + + if head.is_unborn() { + return Ok(LocalRepoState::UnbornHead); + } - return Ok(repo.find_remote(remote)?); + Ok(LocalRepoState::Clean) + } } - #[tracing::instrument(level = "trace", skip(remote))] - pub fn fetch<'a>(&self, remote: &mut Remote) -> Result<(), RepoError> { - // Pass an empty array as the refspec to fetch to fetch the "default" refspecs - // Type annotation is needed because type can't be guessed from the empty array - remote.fetch::<&str>( - &[], - Some(&mut crate::git::fetch_options()), - Some("gtree fetch"), - )?; + pub fn default_remote(&self) -> Result<Remote, RepoError> { + Ok(self + .repo()? + .find_default_remote(gix::remote::Direction::Fetch) + .ok_or(RepoError::NoRemoteFound)? + .context("fetch: failed to find default remote")?) + } - Ok(()) + pub fn default_branch(&self) -> Result<BString, RepoError> { + let repo = self.repo()?; + let remote = self.default_remote()?; + let remote_name = remote.name().context("remote does not have name")?; + + let origin_ref = repo + .find_reference(&format!("remotes/{}/HEAD", remote_name.as_bstr())) + .context("the remotes HEAD references does not exist")?; + + if let Some(origin_ref) = origin_ref.target().try_name() { + Ok(origin_ref.shorten().to_owned()) + } else { + Err(RepoError::NoDefaultBranch) + } } #[tracing::instrument(level = "trace")] - pub fn clone(&self, url: &str) -> Result<Repository, RepoError> { - let mut builder = git2::build::RepoBuilder::new(); - builder.fetch_options(crate::git::fetch_options()); + pub fn clone(&self, url: &str) -> Result<(), RepoError> { + std::fs::create_dir_all(&self.path).unwrap(); + + let mut prepare_clone = gix::prepare_clone(url, &self.path).unwrap(); + + let (mut prepare_checkout, _) = prepare_clone + .fetch_then_checkout(gix::progress::Discard, &gix::interrupt::IS_INTERRUPTED) + .unwrap(); - builder.clone(url, &self.path).map_err(RepoError::GitError) + let (_repo, _) = prepare_checkout + .main_worktree(gix::progress::Discard, &gix::interrupt::IS_INTERRUPTED) + .unwrap(); + + Ok(()) } #[tracing::instrument(level = "trace")] - pub fn checkout(&self) -> Result<(), RepoError> { - if let Some(repo) = &self.repo { - repo.checkout_head(None).map_err(|e| e.into()) - } else { - Err(RepoError::NoLocalRepo) + pub fn fetch<'a>(&mut self) -> Result<bool, RepoError> { + let remote = self.default_remote()?; + let conn = remote.connect(gix::remote::Direction::Fetch).unwrap(); + let outcome = conn + .prepare_fetch( + gix::progress::Discard, + gix::remote::ref_map::Options::default(), + ) + .context("fetch: failed to prepare patch")? + .receive(gix::progress::Discard, &gix::interrupt::IS_INTERRUPTED) + .context("fetch: failed to receive")?; + + match outcome.status { + gix::remote::fetch::Status::NoPackReceived { + dry_run: _, + negotiate: _, + update_refs: _, + } => Ok(false), + gix::remote::fetch::Status::Change { + negotiate: _, + write_pack_bundle: _, + update_refs: _, + } => Ok(true), } } - pub fn branch_name(branch: &Branch) -> String { - match branch.name().unwrap() { - Some(s) => s.to_string(), - None => String::from_utf8_lossy(branch.name_bytes().unwrap()).to_string(), + pub fn refedit(target: ObjectId, name: &str, message: &str) -> RefEdit { + RefEdit { + change: gix::refs::transaction::Change::Update { + log: LogChange { + mode: gix::refs::transaction::RefLog::AndReference, + force_create_reflog: false, + message: message.into(), + }, + expected: PreviousValue::Any, + new: gix::refs::Target::Peeled(target), + }, + name: FullName::try_from(name).unwrap(), + deref: true, } } - #[tracing::instrument(level = "trace", skip(repo, local, upstream))] - pub fn merge( + pub fn update_default_branch_ref( &self, - repo: &Repository, - local: &mut Branch, - upstream: &Branch, - ) -> Result<bool, RepoError> { - let local_name = Repo::branch_name(local); - let upstream_name = Repo::branch_name(upstream); - - let local_ref = local.get_mut(); - let upstream_ref = upstream.get(); - - let analysis = repo.merge_analysis_for_ref( - local_ref, - &[&repo.reference_to_annotated_commit(upstream_ref)?], - )?; - - if analysis.0.is_fast_forward() { - trace!("Doing a fast forward"); - - let msg = format!( - "gtree: update repo branch: {} to {}", - local_name, upstream_name - ); - debug!("{}", msg); - - // sets the branch to target the new commit - // of the remote branch it's tracking - local_ref.set_target(upstream_ref.target().unwrap(), &msg)?; - // Apply these changes in the working dir if the branch is currently checked out. - if format!("refs/heads/{}", local_name) == self.default_branch { - repo.checkout_head(Some(git2::build::CheckoutBuilder::default().force()))? - } + remote: remote::Name, + head: Id, + ) -> Result<(), RepoError> { + let default_branch = self.default_branch()?; + let repo = self.repo()?; + + repo.edit_reference(Repo::refedit( + head.into(), + &format!("heads/{}", default_branch), + &format!("checkout: {}/HEAD with gtree", remote.as_bstr()), + )) + .context("checkout: failed to edit ref")?; - Ok(true) - } else if analysis.0.is_up_to_date() { - Ok(false) - } else { - Err(RepoError::NoFF) - } + Ok(()) + } + + pub fn default_remote_head(&self) -> Result<(remote::Name, Id), RepoError> { + let repo = self.repo()?; + + let remote = repo + .find_fetch_remote(None) + .context("could not find remote to fetch")?; + let remote = remote.name().context("remote does not have name")?; + + let head_ref = repo + .find_reference(&format!("remotes/{}/HEAD", remote.as_bstr())) + .context("the remotes HEAD references does not exist")?; + let head = head_ref + .into_fully_peeled_id() + .context("failed to peel ref")?; + + Ok((remote.to_owned(), head.to_owned())) + } + + #[tracing::instrument(level = "trace", skip(progress))] + pub fn checkout( + &self, + remote: remote::Name, + head: Id, + progress: &mut dyn gix::progress::DynNestedProgress, + ) -> Result<(), RepoError> { + let repo = self.repo()?; + + let workdir = repo.work_dir().ok_or(RepoError::NoWorktree)?; + let head_tree = head + .object() + .context("could not find object HEAD points to")? + .peel_to_tree() + .context("failed to peel HEAD object")? + .id(); + + let index = + gix_index::State::from_tree(&head_tree, &repo.objects).context("index from tree")?; + let mut index = gix_index::File::from_state(index, repo.index_path()); + + let mut files = + progress.add_child_with_id("checkout".to_string(), ProgressId::CheckoutFiles.into()); + let mut bytes = + progress.add_child_with_id("writing".to_string(), ProgressId::BytesWritten.into()); + + files.init(Some(index.entries().len()), gix::progress::count("files")); + bytes.init(None, gix::progress::bytes()); + + let start = std::time::Instant::now(); + + debug!("workdir: {:?}", workdir); + let opts = gix_worktree_state::checkout::Options::default(); + let outcome = gix_worktree_state::checkout( + &mut index, + workdir, + repo.objects.clone().into_arc().unwrap(), + &files, + &bytes, + &gix::interrupt::IS_INTERRUPTED, + opts, + ) + .context("checkout: failed"); + + files.show_throughput(start); + bytes.show_throughput(start); + + debug!("outcome: {:?}", outcome); + debug!("is interrupted: {:?}", &gix::interrupt::IS_INTERRUPTED); + + index + .write(Default::default()) + .context("checkout: write index")?; + + Ok(()) } } @@ -158,16 +253,32 @@ pub enum RepoError { NoLocalRepo, #[error("local git repo does not have a remote")] NoRemoteFound, - #[error("repository is dirty")] - Dirty, + #[error("could not determine default branch based on remote HEAD")] + NoDefaultBranch, + #[error("repo is not checked out")] + NoWorktree, + #[error("repository is dirty: {0}")] + Dirty(LocalRepoState), #[error("fast-forward merge was not possible")] NoFF, - #[error("error during git operation {0}")] - GitError(#[from] git2::Error), + #[error("error: {0}")] + Anyhow(#[from] anyhow::Error), #[error("unknown repo error")] Unknown, } +#[derive(Error, Debug, PartialEq)] +pub enum LocalRepoState { + #[error("operation in progress: {0:?}")] + InProgress(gix::state::InProgress), + #[error("head is detached")] + DetachedHead, + #[error("head is unborn")] + UnbornHead, + #[error("repo is clean")] + Clean, +} + impl Ord for Repo { fn cmp(&self, other: &Self) -> std::cmp::Ordering { self.name.cmp(&other.name) diff --git a/src/sync/mod.rs b/src/sync/mod.rs index 4bfe322..a1dab53 100644 --- a/src/sync/mod.rs +++ b/src/sync/mod.rs @@ -2,7 +2,7 @@ use std::fmt::{Debug, Display}; use crate::{ batch::batch, - repo::{Repo, RepoError, Repos}, + repo::{LocalRepoState, Repo, RepoError, Repos}, }; impl crate::GTree { @@ -22,12 +22,11 @@ impl Repo { pub fn sync(&mut self) -> Result<SyncResult, SyncResult> { let repo_name = self.name.clone(); - if self.repo.is_some() - && !self - .is_clean() - .map_err(|err| SyncResult::err(repo_name.clone(), err))? - { - return Ok(SyncResult::dirty(repo_name)); + let repo_state = self + .is_clean() + .map_err(|err| SyncResult::err(repo_name.clone(), err))?; + if self.repo.is_some() && repo_state != LocalRepoState::Clean { + return Ok(SyncResult::dirty(repo_name, repo_state)); }; if self.repo.is_some() && self.forge.is_some() { @@ -44,14 +43,12 @@ impl Repo { .as_ref() .ok_or_else(|| SyncResult::err(self.name.clone(), RepoError::NoRemoteFound))?; - let repo = self - .clone(url) + self.clone(url) .map_err(|err| SyncResult::err(repo_name.clone(), err))?; // TODO detect moved repos based on first commit // ???? how to detect and not move forks? - self.repo = Some(repo); Ok(SyncResult::cloned(repo_name)) } else { Ok(SyncResult::no_changes(repo_name)) @@ -62,7 +59,7 @@ impl Repo { #[derive(Debug)] pub enum SyncResult { NoChanges { name: String }, - Dirty { name: String }, + Dirty { name: String, state: LocalRepoState }, Cloned { name: String }, Pushed { name: String }, Error { name: String, error: RepoError }, @@ -81,8 +78,8 @@ impl SyncResult { SyncResult::Pushed { name } } - pub fn dirty(name: String) -> SyncResult { - SyncResult::Dirty { name } + pub fn dirty(name: String, state: LocalRepoState) -> SyncResult { + SyncResult::Dirty { name, state } } pub fn no_changes(name: String) -> SyncResult { @@ -98,9 +95,12 @@ impl Display for SyncResult { SyncResult::NoChanges { name } => { f.write_fmt(format_args!("{} {}", Blue.paint("NOCHANGE"), name)) } - SyncResult::Dirty { name } => { - f.write_fmt(format_args!("{} {}", Yellow.paint("DIRTY "), name)) - } + SyncResult::Dirty { name, state } => f.write_fmt(format_args!( + "{} {} [{}]", + Yellow.paint("DIRTY "), + name, + state + )), SyncResult::Cloned { name } => { f.write_fmt(format_args!("{} {}", Green.paint("CLONED "), name)) } diff --git a/src/update/mod.rs b/src/update/mod.rs index 8f10663..7b51136 100644 --- a/src/update/mod.rs +++ b/src/update/mod.rs @@ -1,11 +1,10 @@ use std::fmt::{Debug, Display}; -use git2::BranchType; use tracing::debug; use crate::{ batch::batch, - repo::{Repo, RepoError, Repos}, + repo::{LocalRepoState, Repo, RepoError, Repos}, }; impl crate::GTree { @@ -35,51 +34,52 @@ impl Repo { } fn update_inner(&mut self) -> Result<UpdateResult, RepoError> { - let repo = self.repo.as_ref().unwrap(); - let mut remote = self.main_remote(repo)?; + let repo_state = self.is_clean()?; + if self.repo.is_some() && repo_state != LocalRepoState::Clean { + return Ok(UpdateResult::dirty(self.name.clone(), repo_state)); + }; - self.fetch(&mut remote)?; + debug!("repo is clean"); - self.default_branch = remote.default_branch()?.as_str().unwrap().to_string(); + let mut progress = gix::progress::Discard {}; - debug!("default branch: {}", self.default_branch); + let _fetched = self.fetch()?; + let (remote, head) = self.default_remote_head()?; + self.update_default_branch_ref(remote.clone(), head)?; + self.checkout(remote, head, &mut progress)?; - if self.is_clean()? { - debug!("repo is clean"); + // let merged = repo.branches(Some(BranchType::Local))? + // .filter_map(|x| x.ok()) + // .try_fold(false, |mut merged, (mut branch, _)| { + // let name = format!("refs/heads/{}", Repo::branch_name(&branch)); - let merged = repo.branches(Some(BranchType::Local))? - .filter_map(|x| x.ok()) - .try_fold(false, |mut merged, (mut branch, _)| { - let name = format!("refs/heads/{}", Repo::branch_name(&branch)); + // if branch.upstream().is_ok() { + // let upstream = branch.upstream().unwrap(); - if branch.upstream().is_ok() { - let upstream = branch.upstream().unwrap(); + // debug!("branch: {}", name); - debug!("branch: {}", name); + // merged |= self.merge(repo, &mut branch, &upstream)?; + // Ok::<bool, RepoError>(merged) + // } else { + // debug!("not updating branch: {}: branch does not have upstream tracking branch set", name); + // Ok(merged) + // } + // })?; - merged |= self.merge(repo, &mut branch, &upstream)?; - Ok::<bool, RepoError>(merged) - } else { - debug!("not updating branch: {}: branch does not have upstream tracking branch set", name); - Ok(merged) - } - })?; + // if merged { + // Ok(UpdateResult::merged(self.name.clone())) + // } else { + // Ok(UpdateResult::no_changes(self.name.clone())) + // } - if merged { - Ok(UpdateResult::merged(self.name.clone())) - } else { - Ok(UpdateResult::no_changes(self.name.clone())) - } - } else { - Ok(UpdateResult::dirty(self.name.clone())) - } + Ok(UpdateResult::no_changes(self.name.clone())) } } #[derive(Debug)] pub enum UpdateResult { NoChanges { name: String }, - Dirty { name: String }, + Dirty { name: String, state: LocalRepoState }, Merged { name: String }, Error { name: String, error: RepoError }, } @@ -93,8 +93,8 @@ impl UpdateResult { UpdateResult::Merged { name } } - pub fn dirty(name: String) -> UpdateResult { - UpdateResult::Dirty { name } + pub fn dirty(name: String, state: LocalRepoState) -> UpdateResult { + UpdateResult::Dirty { name, state } } pub fn no_changes(name: String) -> UpdateResult { @@ -110,9 +110,12 @@ impl Display for UpdateResult { UpdateResult::NoChanges { name } => { f.write_fmt(format_args!("{} {}", Blue.paint("FETCHED"), name)) } - UpdateResult::Dirty { name } => { - f.write_fmt(format_args!("{} {}", Yellow.paint("DIRTY "), name)) - } + UpdateResult::Dirty { name, state } => f.write_fmt(format_args!( + "{} {} [{}]", + Yellow.paint("DIRTY "), + name, + state + )), UpdateResult::Merged { name } => { f.write_fmt(format_args!("{} {}", Green.paint("PULLED "), name)) } |
