From c0d0c0d7ba2c36ddd40a7d5f7542118e20bd8829 Mon Sep 17 00:00:00 2001 From: Joshua Potter Date: Sat, 8 Jan 2022 10:31:46 -0500 Subject: [PATCH] Allow pushing after staging. --- examples/config.yaml | 2 +- src/git.rs | 477 ++++++++++++++++++++++++++++++------------- src/lib.rs | 4 +- 3 files changed, 333 insertions(+), 150 deletions(-) diff --git a/examples/config.yaml b/examples/config.yaml index aa37618..f0ffeec 100644 --- a/examples/config.yaml +++ b/examples/config.yaml @@ -20,7 +20,7 @@ packages: - $HOME/.bash_profile - $HOME/.bashrc home-manager: - - $HOME/home.nix + - $HOME/.config/nixpkgs/home.nix homesync: - $HOME/.homesync.yml - $HOME/.config/homesync/homesync.yml diff --git a/src/git.rs b/src/git.rs index 09ab154..4339a37 100644 --- a/src/git.rs +++ b/src/git.rs @@ -1,10 +1,11 @@ use super::{config::PathConfig, path}; use git2::{ - Branch, BranchType, Commit, Cred, DiffOptions, Direction, FetchOptions, Index, IndexAddOption, - ObjectType, Remote, RemoteCallbacks, Repository, Signature, + BranchType, Commit, Cred, DiffOptions, Direction, FetchOptions, Index, IndexAddOption, + ObjectType, PushOptions, Remote, RemoteCallbacks, Repository, Signature, StashApplyOptions, + StashFlags, }; use path::ResPathBuf; -use simplelog::{info, paris}; +use simplelog::{info, paris, warn}; use std::{ collections::HashSet, env::VarError, @@ -76,20 +77,20 @@ impl error::Error for Error {} // ======================================== fn clone(pc: &PathConfig, expanded: &Path) -> Result { - let fetch_options = get_fetch_options(&pc)?; + let fetch_options = get_fetch_options(pc)?; let mut builder = git2::build::RepoBuilder::new(); builder.fetch_options(fetch_options); Ok(builder.clone(&pc.config.repos.remote.url, &expanded)?) } +// TODO(jrpotter): Setup a sentinel file in the given repository. This is used +// for both ensuring any remote repositories are already managed by homesync and +// for storing any persisted configurations. + /// Sets up a local github repository all configuration files will be synced to. /// If there does not exist a local repository at the requested location, we /// attempt to make it via cloning or initializing. -/// -/// TODO(jrpotter): Setup a sentinel file in the given repository. This is used -/// for both ensuring any remote repositories are already managed by homesync -/// and for storing any persisted configurations. pub fn init(pc: &PathConfig) -> Result { // Permit the use of environment variables within the local configuration // path (e.g. `$HOME`). Unlike with resolution, we want to fail if the @@ -111,7 +112,7 @@ pub fn init(pc: &PathConfig) -> Result { ); Ok(repo) } - Err(e) if e.code() == git2::ErrorCode::NotFound => match clone(&pc, &expanded) { + Err(e) if e.code() == git2::ErrorCode::NotFound => match clone(pc, &expanded) { Ok(repo) => { info!( "Cloned remote repository {}.", @@ -120,7 +121,7 @@ pub fn init(pc: &PathConfig) -> Result { Ok(repo) } Err(Error::GitError(e)) - if e.code() == git2::ErrorCode::Eof && e.class() == git2::ErrorClass::Ssh => + if e.class() == git2::ErrorClass::Ssh && e.code() == git2::ErrorCode::Eof => { info!( "Creating local repository at {}.", @@ -170,9 +171,9 @@ fn find_package_files(pc: &PathConfig) -> Vec { } pub fn stage(pc: &PathConfig, repo: &Repository) -> Result<()> { - let workdir = validate_repo(&repo)?; + let workdir = check_working_repo(repo)?; let repo_files = find_repo_files(&workdir)?; - let package_files = find_package_files(&pc); + let package_files = find_package_files(pc); // Find all files in our repository that are no longer being referenced in // our primary config file. They should be removed from the repository. @@ -207,6 +208,11 @@ pub fn stage(pc: &PathConfig, repo: &Repository) -> Result<()> { fs::copy(package_file.resolved(), copy)?; } + info!( + "Staged files. Run git -C {} status to see what changed.", + &pc.config.repos.local.display() + ); + Ok(()) } @@ -214,44 +220,268 @@ pub fn stage(pc: &PathConfig, repo: &Repository) -> Result<()> { // Syncing // ======================================== -/// Adds all files to our index. -/// -/// Checks explicitly if any changes have been detected in the newly constructed -/// index. If not, return `None`. -pub fn index_add(repo: &Repository) -> Result> { +pub fn push(pc: &PathConfig, repo: &mut Repository) -> Result<()> { + // First pull to make sure there are no conflicts when we push our changes. + // This will also perform validation and construct our local and remote + // environment. + pull(pc, repo)?; + + let refspec = format!("refs/heads/{}", &pc.config.repos.remote.branch); + repo.set_head(&refspec)?; + + // The index corresponds to our staging area. We add all files and write out + // to a tree. The resulting tree can be found using `git ls-tree `. + // https://git-scm.com/book/en/v2/Git-Internals-Git-Objects + let mut index = match index_with_all(repo)? { + Some(index) => index, + None => { + warn!("Nothing to push. Have you run `homesync stage`?"); + return Ok(()); + } + }; + let index_oid = index.write_tree()?; + // Want to also reflect this change on the working directory. + index.write()?; + let index_tree = repo.find_tree(index_oid)?; + info!("Writing index to tree `{}`.", index_oid); + + // Commit our changes and push them to our remote. + // TODO(jrpotter): Come up with a more useful message. + let signature = now_signature(pc)?; + let message = "Automated homesync commit."; + let commit_oid = if let Some(commit) = get_commit_at_head(repo) { + repo.commit( + Some(&refspec), + &signature, + &signature, + message, + &index_tree, + &[&commit], + )? + } else { + repo.commit( + Some(&refspec), + &signature, + &signature, + message, + &index_tree, + &[], + )? + }; + info!("Commited `{}` with message \"{}\".", commit_oid, message); + + let mut remote = find_remote(pc, repo)?; + let call_options = get_remote_callbacks(pc)?; + remote.connect_auth(Direction::Push, Some(call_options), None)?; + + let mut push_options = get_push_options(pc)?; + remote.push(&[&format!("{r}:{r}", r = refspec)], Some(&mut push_options))?; + info!( + "Pushed changes to remote `{}`.", + pc.config.repos.remote.tracking_branch(), + ); + + Ok(()) +} + +fn local_from_remote(pc: &PathConfig, repo: &Repository) -> Result<()> { + fetch_remote(pc, repo)?; + + let tracking_branch = pc.config.repos.remote.tracking_branch(); + let remote_branch = repo.find_branch(&tracking_branch, BranchType::Remote)?; + let remote_ref = repo.reference_to_annotated_commit(remote_branch.get())?; + + // It should never be the case this function is called when the local branch + // exists. Keep `force` to `false` to catch any misuse here. + repo.branch_from_annotated_commit(&pc.config.repos.remote.branch, &remote_ref, false)?; + info!("Created new local branch from `{}`.", &tracking_branch); + + Ok(()) +} + +fn local_rebase_remote(pc: &PathConfig, repo: &Repository) -> Result<()> { + fetch_remote(pc, repo)?; + + let tracking_branch = pc.config.repos.remote.tracking_branch(); + let remote_branch = repo.find_branch(&tracking_branch, BranchType::Remote)?; + let remote_ref = repo.reference_to_annotated_commit(remote_branch.get())?; + + // Our remote branch after fetching should exist at the fetch. We could just + // rebase onto the remote branch directly, but let's keep things local when + // we can. + let local_branch = repo.find_branch(&pc.config.repos.remote.branch, BranchType::Local)?; + let local_ref = repo.reference_to_annotated_commit(local_branch.get())?; + + let signature = now_signature(pc)?; + repo.rebase(Some(&local_ref), Some(&remote_ref), None, None)? + .finish(Some(&signature))?; + info!("Rebased local branch onto `{}`.", &tracking_branch); + + Ok(()) +} + +pub fn pull(pc: &PathConfig, repo: &mut Repository) -> Result<()> { + check_working_repo(repo)?; + + // If our local branch exists, it must also have a commit on it. Therefore + // we can apply stashes. Stow away our changes, rebase on remote, and then + // reapply those changes. + if repo + .find_branch(&pc.config.repos.remote.branch, BranchType::Local) + .is_ok() + { + return Ok(with_stash(pc, repo, |pc, repo| { + Ok(local_rebase_remote(pc, repo)?) + })?); + } + + // If our local branch does not exist yet, we are likely in an empty git + // repository. In this case, we should just try to find the remote branch + // and establish a copy locally of the same name. + // + // That said, there is a possibility our repository isn't empty. We also are + // not necessarily able to stash changes and reapply them like we normally + // would since its possible we do not have an initial commit yet. Generally + // switching would be fine but its also possible the user has a file that + // would be overwritten on change. For this reason, we just create an + // initial commit for any existing files so the user can reference it later + // if need be. + if let Some(mut index) = index_with_all(repo)? { + let index_oid = index.write_tree()?; + let index_tree = repo.find_tree(index_oid)?; + info!("Writing tree `{}`.", index_oid); + + let signature = now_signature(pc)?; + let message = "Save potentially conflicting files here."; + // If we are on a current branch, there should exist a commit we + // can just push onto. Otherwise let's create a new branch with the + // saved contents. + if let Some(parent_commit) = get_commit_at_head(repo) { + repo.commit( + Some("HEAD"), + &signature, + &signature, + message, + &index_tree, + &[&parent_commit], + )?; + info!("Saved potentially conflicting files in new commit of HEAD."); + } else { + let temp_branch = temporary_branch_name(pc, repo)?; + let refspec = format!("refs/heads/{}", &temp_branch); + repo.commit( + Some(&refspec), + &signature, + &signature, + message, + &index_tree, + &[], + )?; + info!( + "Saved potentially conflicting files on branch {}", + temp_branch + ); + } + } + + Ok(local_from_remote(pc, repo)?) +} + +// ======================================== +// Index +// ======================================== + +fn index_with_all(repo: &Repository) -> Result> { let mut index = repo.index()?; index.add_all(["."].iter(), IndexAddOption::DEFAULT, None)?; - let diff_stats = repo - .diff_index_to_workdir( - Some(&index), - Some( - DiffOptions::new() - .include_untracked(true) - .include_unreadable(true), - ), - )? - .stats()?; - if diff_stats.files_changed() == 0 - && diff_stats.insertions() == 0 - && diff_stats.deletions() == 0 - { - Ok(None) + let has_diff = if let Some(commit) = get_commit_at_head(repo) { + let diff_stats = repo + .diff_tree_to_workdir_with_index( + Some(&repo.find_tree(commit.tree_id())?), + Some( + DiffOptions::new() + .include_untracked(true) + .include_unreadable(true), + ), + )? + .stats()?; + diff_stats.files_changed() != 0 + || diff_stats.insertions() != 0 + || diff_stats.deletions() != 0 } else { + !index.is_empty() + }; + if has_diff { Ok(Some(index)) + } else { + Ok(None) } } -/// Create or retrieve the remote specified within our configuration. -/// -/// This method also configures the fetchspec for the remote, explicitly mapping -/// the remote branch against our local one. -/// -/// https://git-scm.com/book/en/v2/Git-Internals-The-Refspec -fn get_remote<'repo>(pc: &PathConfig, repo: &'repo Repository) -> Result> { +fn with_stash(pc: &PathConfig, repo: &mut Repository, function: T) -> Result<()> +where + T: Fn(&PathConfig, &mut Repository) -> Result<()>, +{ + let signature = now_signature(pc)?; + let stash_oid = match repo.stash_save( + &signature, + "Temporary stash during pull", + Some(StashFlags::INCLUDE_UNTRACKED), + ) { + Ok(oid) => { + info!("Stashing changes in `{}`.", oid); + Some(oid) + } + Err(e) if e.class() == git2::ErrorClass::Stash && e.code() == git2::ErrorCode::NotFound => { + None + } + Err(e) => Err(e)?, + }; + + function(pc, repo)?; + + if let Some(oid) = stash_oid { + // It is possible something else made changes to our stash while we were + // rebasing. To be extra cautious, search for our specific stash + // instance. + let mut stash_index = None; + repo.stash_foreach(|index, _message, each_oid| { + if *each_oid == oid { + stash_index = Some(index); + false + } else { + true + } + })?; + if let Some(index) = stash_index { + let mut checkout = git2::build::CheckoutBuilder::new(); + checkout.use_ours(true); + + let mut apply_options = StashApplyOptions::new(); + apply_options.checkout_options(checkout); + + repo.stash_apply(index, Some(&mut apply_options))?; + info!("Reapplied stash `{}`.", oid); + } else { + warn!("Could not find stash `{}`. Ignoring.", oid); + } + } + + Ok(()) +} + +// ======================================== +// Remote +// ======================================== + +fn find_remote<'repo>(pc: &PathConfig, repo: &'repo Repository) -> Result> { repo.remote_set_url(&pc.config.repos.remote.name, &pc.config.repos.remote.url)?; + // If the remote already exists, this just updates the fetchspec. We could + // go with "*" instead of {branch} for all remote branches, but choosing to + // be precise.. + // https://git-scm.com/book/en/v2/Git-Internals-The-Refspec repo.remote_add_fetch( &pc.config.repos.remote.name, - // We could go with "*" instead of {branch} for all remote branches. &format!( "+refs/heads/{}:refs/remotes/{}", pc.config.repos.remote.branch, @@ -261,119 +491,21 @@ fn get_remote<'repo>(pc: &PathConfig, repo: &'repo Repository) -> Result Result<()> { - // First pull to make sure there are no conflicts when we push our changes. - // This will also perform validation and construct our local and remote - // environment. - let _local_branch = pull(&pc, &repo)?; - - // The index corresponds to our staging area. We add all files and write out - // to a tree. The resulting tree can be found using `git ls-tree `. - // https://git-scm.com/book/en/v2/Git-Internals-Git-Objects - let mut index = match index_add(&repo)? { - Some(index) => index, - None => { - info!("Nothing to push. Have you run `homesync stage`?"); - return Ok(()); - } - }; - // Retrieve the latest commit before writing to the object database. - let parent_commit = get_commit(&repo)?; - let index_oid = index.write_tree()?; - let index_tree = repo.find_tree(index_oid)?; - info!("Writing index to tree `{}`.", index_oid); - - // Commit our changes and push them to our remote. - let refspec = format!("refs/heads/{}", &pc.config.repos.remote.branch); - let signature = get_signature(&pc)?; - repo.commit( - Some(&refspec), - &signature, - &signature, - // TODO(jrpotter): Come up with a more useful message. - "homesync push", - &index_tree, - &[&parent_commit], - )?; - - let mut remote = get_remote(&pc, &repo)?; - remote.connect(Direction::Push)?; - remote.push(&[&format!("{r}:{r}", r = refspec)], None)?; - info!( - "Pushed changes to remote `{}`.", - pc.config.repos.remote.tracking_branch(), - ); - - Ok(()) -} - -pub fn pull<'repo>(pc: &PathConfig, repo: &'repo Repository) -> Result> { - validate_repo(&repo)?; - - // TODO(jrpotter): Configure our remote to point to the same URL mentioned - // in our config. - // TODO(jrpotter): If changes are available, need to stage them and then - // reapply. - - // Establish our remote. If the remote already exists, re-configure it - // blindly to point to the appropriate url. Our results should now exist - // in a branch called `remotes/origin/`. - // https://git-scm.com/book/it/v2/Git-Basics-Working-with-Remotes - let mut remote = get_remote(&pc, &repo)?; - let mut fetch_options = get_fetch_options(&pc)?; +fn fetch_remote<'repo>(pc: &PathConfig, repo: &'repo Repository) -> Result> { + let mut remote = find_remote(pc, repo)?; + let mut fetch_options = get_fetch_options(pc)?; remote.fetch( &[&pc.config.repos.remote.branch], Some(&mut fetch_options), None, )?; - let remote_branch_name = pc.config.repos.remote.tracking_branch(); - let remote_branch = repo.find_branch(&remote_branch_name, BranchType::Remote)?; - info!("Fetched remote branch `{}`.", &remote_branch_name); + let tracking_branch = pc.config.repos.remote.tracking_branch(); + info!("Fetched remote branch `{}`.", &tracking_branch); - // There are two cases we need to consider: - // - // 1. Our local branch actually exists, in which case there are commits - // available. These should be rebased relative to remote (our upstream). - // 2. Our repository has been initialized in an empty state. The branch we - // are interested in is unborn, so we can just copy the branch from remote. - let remote_ref = repo.reference_to_annotated_commit(remote_branch.get())?; - if let Ok(local_branch) = repo.find_branch(&pc.config.repos.remote.branch, BranchType::Local) { - let local_ref = repo.reference_to_annotated_commit(local_branch.get())?; - let signature = get_signature(&pc)?; - repo.rebase(Some(&local_ref), Some(&remote_ref), None, None)? - .finish(Some(&signature))?; - info!("Rebased local branch onto `{}`.", remote_branch_name); - Ok(local_branch) - } else { - let local_branch = - repo.branch_from_annotated_commit(&pc.config.repos.remote.branch, &remote_ref, false)?; - info!("Created new local branch from `{}`.", remote_branch_name); - Ok(local_branch) - } + Ok(remote) } -// ======================================== -// Utility -// ======================================== - -/// Verify the repository we are working in supports the operations we want to -/// apply to it. -fn validate_repo(repo: &Repository) -> Result { - Ok(repo.workdir().ok_or(Error::InvalidBareRepo)?.to_path_buf()) -} - -/// Return the latest commit off of HEAD. -fn get_commit(repo: &Repository) -> Result { - Ok(repo - .head()? - .resolve()? - .peel(ObjectType::Commit)? - .into_commit() - .map_err(|_| git2::Error::from_str("Couldn't find commit"))?) -} - -/// Construct callbacks to supply authentication information on fetch/clone/etc. -fn get_fetch_options(pc: &PathConfig) -> Result { +fn get_remote_callbacks(pc: &PathConfig) -> Result { let public_path = match &pc.config.ssh.public { Some(p) => Some(path::resolve(p)?), None => None, @@ -390,12 +522,63 @@ fn get_fetch_options(pc: &PathConfig) -> Result { ) }); + Ok(callbacks) +} + +fn get_fetch_options(pc: &PathConfig) -> Result { + let callbacks = get_remote_callbacks(pc)?; let mut fetch_options = FetchOptions::new(); fetch_options.remote_callbacks(callbacks); Ok(fetch_options) } -/// Generate a new signature at the current time. -fn get_signature(pc: &PathConfig) -> Result { +fn get_push_options(pc: &PathConfig) -> Result { + let callbacks = get_remote_callbacks(pc)?; + let mut push_options = PushOptions::new(); + push_options.remote_callbacks(callbacks); + Ok(push_options) +} + +// ======================================== +// Miscellaneous +// ======================================== + +fn check_working_repo(repo: &Repository) -> Result { + Ok(repo.workdir().ok_or(Error::InvalidBareRepo)?.to_path_buf()) +} + +fn get_commit_at_head(repo: &Repository) -> Option { + let peel = || -> Result { + Ok(repo + .head()? + .resolve()? + .peel(ObjectType::Commit)? + .into_commit() + .map_err(|_| git2::Error::from_str("Couldn't find commit"))?) + }; + peel().ok() +} + +fn now_signature(pc: &PathConfig) -> Result { Ok(Signature::now(&pc.config.user.name, &pc.config.user.email)?) } + +fn temporary_branch_name(pc: &PathConfig, repo: &Repository) -> Result { + let mut branch_names = HashSet::new(); + for b in repo.branches(Some(BranchType::Local))? { + if let Ok((branch, _branch_type)) = b { + if let Some(name) = branch.name()? { + branch_names.insert(name.to_owned()); + } + } + } + + let mut count = 1; + let mut temp_name = format!("{}-tmp", &pc.config.repos.remote.branch); + while branch_names.contains(&temp_name) { + temp_name = format!("{}-tmp-{}", &pc.config.repos.remote.branch, count); + count += 1; + } + + Ok(temp_name) +} diff --git a/src/lib.rs b/src/lib.rs index 5850b04..e64efec 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -26,8 +26,8 @@ pub fn run_push(config: PathConfig) -> Result { } pub fn run_pull(config: PathConfig) -> Result { - let repo = git::init(&config)?; - git::pull(&config, &repo)?; + let mut repo = git::init(&config)?; + git::pull(&config, &mut repo)?; Ok(()) }