Allow pushing after staging.

pull/3/head
Joshua Potter 2022-01-08 10:31:46 -05:00
parent 014a7d72e5
commit c0d0c0d7ba
3 changed files with 333 additions and 150 deletions

View File

@ -20,7 +20,7 @@ packages:
- $HOME/.bash_profile - $HOME/.bash_profile
- $HOME/.bashrc - $HOME/.bashrc
home-manager: home-manager:
- $HOME/home.nix - $HOME/.config/nixpkgs/home.nix
homesync: homesync:
- $HOME/.homesync.yml - $HOME/.homesync.yml
- $HOME/.config/homesync/homesync.yml - $HOME/.config/homesync/homesync.yml

View File

@ -1,10 +1,11 @@
use super::{config::PathConfig, path}; use super::{config::PathConfig, path};
use git2::{ use git2::{
Branch, BranchType, Commit, Cred, DiffOptions, Direction, FetchOptions, Index, IndexAddOption, BranchType, Commit, Cred, DiffOptions, Direction, FetchOptions, Index, IndexAddOption,
ObjectType, Remote, RemoteCallbacks, Repository, Signature, ObjectType, PushOptions, Remote, RemoteCallbacks, Repository, Signature, StashApplyOptions,
StashFlags,
}; };
use path::ResPathBuf; use path::ResPathBuf;
use simplelog::{info, paris}; use simplelog::{info, paris, warn};
use std::{ use std::{
collections::HashSet, collections::HashSet,
env::VarError, env::VarError,
@ -76,20 +77,20 @@ impl error::Error for Error {}
// ======================================== // ========================================
fn clone(pc: &PathConfig, expanded: &Path) -> Result<Repository> { fn clone(pc: &PathConfig, expanded: &Path) -> Result<Repository> {
let fetch_options = get_fetch_options(&pc)?; let fetch_options = get_fetch_options(pc)?;
let mut builder = git2::build::RepoBuilder::new(); let mut builder = git2::build::RepoBuilder::new();
builder.fetch_options(fetch_options); builder.fetch_options(fetch_options);
Ok(builder.clone(&pc.config.repos.remote.url, &expanded)?) 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. /// 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 /// If there does not exist a local repository at the requested location, we
/// attempt to make it via cloning or initializing. /// 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<Repository> { pub fn init(pc: &PathConfig) -> Result<Repository> {
// Permit the use of environment variables within the local configuration // Permit the use of environment variables within the local configuration
// path (e.g. `$HOME`). Unlike with resolution, we want to fail if the // path (e.g. `$HOME`). Unlike with resolution, we want to fail if the
@ -111,7 +112,7 @@ pub fn init(pc: &PathConfig) -> Result<Repository> {
); );
Ok(repo) 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) => { Ok(repo) => {
info!( info!(
"Cloned remote repository <green>{}</>.", "Cloned remote repository <green>{}</>.",
@ -120,7 +121,7 @@ pub fn init(pc: &PathConfig) -> Result<Repository> {
Ok(repo) Ok(repo)
} }
Err(Error::GitError(e)) 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!( info!(
"Creating local repository at <green>{}</>.", "Creating local repository at <green>{}</>.",
@ -170,9 +171,9 @@ fn find_package_files(pc: &PathConfig) -> Vec<ResPathBuf> {
} }
pub fn stage(pc: &PathConfig, repo: &Repository) -> Result<()> { 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 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 // Find all files in our repository that are no longer being referenced in
// our primary config file. They should be removed from the repository. // 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)?; fs::copy(package_file.resolved(), copy)?;
} }
info!(
"Staged files. Run <italic>git -C <green>{}</> <italic>status</> to see what changed.",
&pc.config.repos.local.display()
);
Ok(()) Ok(())
} }
@ -214,16 +220,184 @@ pub fn stage(pc: &PathConfig, repo: &Repository) -> Result<()> {
// Syncing // Syncing
// ======================================== // ========================================
/// Adds all files to our index. pub fn push(pc: &PathConfig, repo: &mut Repository) -> Result<()> {
/// // First pull to make sure there are no conflicts when we push our changes.
/// Checks explicitly if any changes have been detected in the newly constructed // This will also perform validation and construct our local and remote
/// index. If not, return `None`. // environment.
pub fn index_add(repo: &Repository) -> Result<Option<Index>> { 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 <oid>`.
// 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 <yellow>{}</>",
temp_branch
);
}
}
Ok(local_from_remote(pc, repo)?)
}
// ========================================
// Index
// ========================================
fn index_with_all(repo: &Repository) -> Result<Option<Index>> {
let mut index = repo.index()?; let mut index = repo.index()?;
index.add_all(["."].iter(), IndexAddOption::DEFAULT, None)?; index.add_all(["."].iter(), IndexAddOption::DEFAULT, None)?;
let has_diff = if let Some(commit) = get_commit_at_head(repo) {
let diff_stats = repo let diff_stats = repo
.diff_index_to_workdir( .diff_tree_to_workdir_with_index(
Some(&index), Some(&repo.find_tree(commit.tree_id())?),
Some( Some(
DiffOptions::new() DiffOptions::new()
.include_untracked(true) .include_untracked(true)
@ -231,27 +405,83 @@ pub fn index_add(repo: &Repository) -> Result<Option<Index>> {
), ),
)? )?
.stats()?; .stats()?;
if diff_stats.files_changed() == 0 diff_stats.files_changed() != 0
&& diff_stats.insertions() == 0 || diff_stats.insertions() != 0
&& diff_stats.deletions() == 0 || diff_stats.deletions() != 0
{
Ok(None)
} else { } else {
!index.is_empty()
};
if has_diff {
Ok(Some(index)) Ok(Some(index))
} else {
Ok(None)
} }
} }
/// Create or retrieve the remote specified within our configuration. fn with_stash<T>(pc: &PathConfig, repo: &mut Repository, function: T) -> Result<()>
/// where
/// This method also configures the fetchspec for the remote, explicitly mapping T: Fn(&PathConfig, &mut Repository) -> Result<()>,
/// the remote branch against our local one. {
/// let signature = now_signature(pc)?;
/// https://git-scm.com/book/en/v2/Git-Internals-The-Refspec let stash_oid = match repo.stash_save(
fn get_remote<'repo>(pc: &PathConfig, repo: &'repo Repository) -> Result<Remote<'repo>> { &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<Remote<'repo>> {
repo.remote_set_url(&pc.config.repos.remote.name, &pc.config.repos.remote.url)?; 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( repo.remote_add_fetch(
&pc.config.repos.remote.name, &pc.config.repos.remote.name,
// We could go with "*" instead of {branch} for all remote branches.
&format!( &format!(
"+refs/heads/{}:refs/remotes/{}", "+refs/heads/{}:refs/remotes/{}",
pc.config.repos.remote.branch, pc.config.repos.remote.branch,
@ -261,119 +491,21 @@ fn get_remote<'repo>(pc: &PathConfig, repo: &'repo Repository) -> Result<Remote<
Ok(repo.find_remote(&pc.config.repos.remote.name)?) Ok(repo.find_remote(&pc.config.repos.remote.name)?)
} }
pub fn push(pc: &PathConfig, repo: &mut Repository) -> Result<()> { fn fetch_remote<'repo>(pc: &PathConfig, repo: &'repo Repository) -> Result<Remote<'repo>> {
// First pull to make sure there are no conflicts when we push our changes. let mut remote = find_remote(pc, repo)?;
// This will also perform validation and construct our local and remote let mut fetch_options = get_fetch_options(pc)?;
// 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 <oid>`.
// 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<Branch<'repo>> {
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/<branch>`.
// 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)?;
remote.fetch( remote.fetch(
&[&pc.config.repos.remote.branch], &[&pc.config.repos.remote.branch],
Some(&mut fetch_options), Some(&mut fetch_options),
None, None,
)?; )?;
let remote_branch_name = pc.config.repos.remote.tracking_branch(); let tracking_branch = pc.config.repos.remote.tracking_branch();
let remote_branch = repo.find_branch(&remote_branch_name, BranchType::Remote)?; info!("Fetched remote branch `{}`.", &tracking_branch);
info!("Fetched remote branch `{}`.", &remote_branch_name);
// There are two cases we need to consider: Ok(remote)
//
// 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)
}
} }
// ======================================== fn get_remote_callbacks(pc: &PathConfig) -> Result<RemoteCallbacks> {
// Utility
// ========================================
/// Verify the repository we are working in supports the operations we want to
/// apply to it.
fn validate_repo(repo: &Repository) -> Result<PathBuf> {
Ok(repo.workdir().ok_or(Error::InvalidBareRepo)?.to_path_buf())
}
/// Return the latest commit off of HEAD.
fn get_commit(repo: &Repository) -> Result<Commit> {
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<FetchOptions> {
let public_path = match &pc.config.ssh.public { let public_path = match &pc.config.ssh.public {
Some(p) => Some(path::resolve(p)?), Some(p) => Some(path::resolve(p)?),
None => None, None => None,
@ -390,12 +522,63 @@ fn get_fetch_options(pc: &PathConfig) -> Result<FetchOptions> {
) )
}); });
Ok(callbacks)
}
fn get_fetch_options(pc: &PathConfig) -> Result<FetchOptions> {
let callbacks = get_remote_callbacks(pc)?;
let mut fetch_options = FetchOptions::new(); let mut fetch_options = FetchOptions::new();
fetch_options.remote_callbacks(callbacks); fetch_options.remote_callbacks(callbacks);
Ok(fetch_options) Ok(fetch_options)
} }
/// Generate a new signature at the current time. fn get_push_options(pc: &PathConfig) -> Result<PushOptions> {
fn get_signature(pc: &PathConfig) -> Result<Signature> { 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<PathBuf> {
Ok(repo.workdir().ok_or(Error::InvalidBareRepo)?.to_path_buf())
}
fn get_commit_at_head(repo: &Repository) -> Option<Commit> {
let peel = || -> Result<Commit> {
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<Signature> {
Ok(Signature::now(&pc.config.user.name, &pc.config.user.email)?) Ok(Signature::now(&pc.config.user.name, &pc.config.user.email)?)
} }
fn temporary_branch_name(pc: &PathConfig, repo: &Repository) -> Result<String> {
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)
}

View File

@ -26,8 +26,8 @@ pub fn run_push(config: PathConfig) -> Result {
} }
pub fn run_pull(config: PathConfig) -> Result { pub fn run_pull(config: PathConfig) -> Result {
let repo = git::init(&config)?; let mut repo = git::init(&config)?;
git::pull(&config, &repo)?; git::pull(&config, &mut repo)?;
Ok(()) Ok(())
} }