use gix::prelude::ObjectIdExt;
use gix::refs::transaction::PreviousValue;
use crate::error::{Error, Result};
use crate::git_utils;
use crate::session::Session;
#[must_use]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PushOutput {
pub success: bool,
pub non_fast_forward: bool,
pub up_to_date: bool,
pub remote_name: String,
pub remote_ref: String,
pub commit_oid: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PushProgress {
CheckingLocalState,
Serializing,
SerializationSkipped,
RebasingLocal,
Pushing {
remote_name: String,
local_ref: String,
remote_ref: String,
},
FetchingRemote {
remote_name: String,
remote_ref: String,
},
HydratingRemoteTip,
MaterializingRemote,
SerializingMerged,
RebasingMerged,
}
pub fn push_once(session: &Session, remote: Option<&str>, now: i64) -> Result<PushOutput> {
push_once_with_progress(session, remote, now, |_| {})
}
pub fn push_once_with_progress(
session: &Session,
remote: Option<&str>,
now: i64,
mut progress: impl FnMut(PushProgress),
) -> Result<PushOutput> {
let repo = &session.repo;
let ns = session.namespace();
let remote_name = git_utils::resolve_meta_remote(repo, remote)?;
let local_ref = session.local_ref();
let remote_refspec = format!("refs/{ns}/main");
let remote_tracking_ref = format!("refs/{ns}/remotes/main");
progress(PushProgress::CheckingLocalState);
let mut local_oid = peeled_ref_oid(repo, &local_ref);
let remote_oid = peeled_ref_oid(repo, &remote_tracking_ref);
if should_serialize_before_push(session, local_oid.as_ref())? {
progress(PushProgress::Serializing);
let _ = crate::serialize::run(session, now, false)?;
local_oid = peeled_ref_oid(repo, &local_ref);
} else {
progress(PushProgress::SerializationSkipped);
}
if local_oid.is_none() {
return Err(Error::Other(
"nothing to push (no local metadata ref)".into(),
));
}
if let (Some(local), Some(remote_id)) = (local_oid.as_ref(), remote_oid.as_ref()) {
if local == remote_id {
return Ok(PushOutput {
success: true,
non_fast_forward: false,
up_to_date: true,
remote_name,
remote_ref: remote_refspec,
commit_oid: local.to_string(),
});
}
progress(PushProgress::RebasingLocal);
rebase_local_on_remote(repo, &local_ref, &remote_tracking_ref)?;
local_oid = repo
.find_reference(&local_ref)
.ok()
.and_then(|r| r.into_fully_peeled_id().ok())
.map(gix::Id::detach);
}
let commit_oid_str = local_oid
.as_ref()
.map(ToString::to_string)
.unwrap_or_default();
let push_refspec = format!("{local_ref}:{remote_refspec}");
progress(PushProgress::Pushing {
remote_name: remote_name.clone(),
local_ref: local_ref.clone(),
remote_ref: remote_refspec.clone(),
});
let result = git_utils::run_git(repo, &["push", &remote_name, &push_refspec]);
match result {
Ok(_) => Ok(PushOutput {
success: true,
non_fast_forward: false,
up_to_date: false,
remote_name,
remote_ref: remote_refspec,
commit_oid: commit_oid_str,
}),
Err(e) => {
let err_msg = e.to_string();
let is_non_ff = err_msg.contains("non-fast-forward")
|| err_msg.contains("rejected")
|| err_msg.contains("fetch first");
if is_non_ff {
Ok(PushOutput {
success: false,
non_fast_forward: true,
up_to_date: false,
remote_name,
remote_ref: remote_refspec,
commit_oid: commit_oid_str,
})
} else {
Err(Error::GitCommand(format!("push failed: {err_msg}")))
}
}
}
}
fn peeled_ref_oid(repo: &gix::Repository, ref_name: &str) -> Option<gix::ObjectId> {
repo.find_reference(ref_name)
.ok()
.and_then(|r| r.into_fully_peeled_id().ok())
.map(gix::Id::detach)
}
fn should_serialize_before_push(
session: &Session,
local_oid: Option<&gix::ObjectId>,
) -> Result<bool> {
if local_oid.is_none() {
return Ok(true);
};
let Some(last_materialized) = session.store.get_last_materialized()? else {
return Ok(true);
};
Ok(!session
.store
.get_modified_since(last_materialized)?
.is_empty())
}
pub fn resolve_push_conflict(session: &Session, remote: Option<&str>, now: i64) -> Result<()> {
resolve_push_conflict_with_progress(session, remote, now, |_| {})
}
pub fn resolve_push_conflict_with_progress(
session: &Session,
remote: Option<&str>,
now: i64,
mut progress: impl FnMut(PushProgress),
) -> Result<()> {
let repo = &session.repo;
let ns = session.namespace();
let remote_name = git_utils::resolve_meta_remote(repo, remote)?;
let local_ref = session.local_ref();
let remote_refspec = format!("refs/{ns}/main");
let remote_tracking_ref = format!("refs/{ns}/remotes/main");
let fetch_refspec = format!("{remote_refspec}:{remote_tracking_ref}");
progress(PushProgress::FetchingRemote {
remote_name: remote_name.clone(),
remote_ref: remote_refspec,
});
git_utils::run_git(repo, &["fetch", &remote_name, &fetch_refspec])?;
let short_ref = format!("{ns}/remotes/main");
progress(PushProgress::HydratingRemoteTip);
git_utils::hydrate_tip_blobs(repo, &remote_name, &short_ref)?;
progress(PushProgress::MaterializingRemote);
let _ = crate::materialize::run(session, None, now)?;
progress(PushProgress::SerializingMerged);
let _ = crate::serialize::run(session, now, false)?;
progress(PushProgress::RebasingMerged);
rebase_local_on_remote(repo, &local_ref, &remote_tracking_ref)?;
Ok(())
}
fn rebase_local_on_remote(repo: &gix::Repository, local_ref: &str, remote_ref: &str) -> Result<()> {
let local_ref_obj = repo
.find_reference(local_ref)
.map_err(|e| Error::Other(format!("{e}")))?;
let local_oid = local_ref_obj
.into_fully_peeled_id()
.map_err(|e| Error::Other(format!("{e}")))?
.detach();
let local_commit_obj = local_oid
.attach(repo)
.object()
.map_err(|e| Error::Other(format!("{e}")))?
.into_commit();
let local_decoded = local_commit_obj
.decode()
.map_err(|e| Error::Other(format!("{e}")))?;
let remote_ref_obj = repo
.find_reference(remote_ref)
.map_err(|e| Error::Other(format!("{e}")))?;
let remote_oid = remote_ref_obj
.into_fully_peeled_id()
.map_err(|e| Error::Other(format!("{e}")))?
.detach();
let parent_ids: Vec<gix::ObjectId> = local_decoded.parents().collect();
if parent_ids.len() == 1 && parent_ids[0] == remote_oid {
return Ok(());
}
let tree_id = local_decoded.tree();
let message = local_decoded.message.to_owned();
let author_ref = local_decoded
.author()
.map_err(|e| Error::Other(format!("{e}")))?;
let commit = gix::objs::Commit {
message,
tree: tree_id,
author: gix::actor::Signature {
name: author_ref.name.into(),
email: author_ref.email.into(),
time: author_ref
.time()
.map_err(|e| Error::Other(format!("{e}")))?,
},
committer: gix::actor::Signature {
name: author_ref.name.into(),
email: author_ref.email.into(),
time: author_ref
.time()
.map_err(|e| Error::Other(format!("{e}")))?,
},
encoding: None,
parents: vec![remote_oid].into(),
extra_headers: Default::default(),
};
let new_oid = repo
.write_object(&commit)
.map_err(|e| Error::Other(format!("{e}")))?
.detach();
repo.reference(
local_ref,
new_oid,
PreviousValue::Any,
"git-meta: rebase for push",
)
.map_err(|e| Error::Other(format!("{e}")))?;
Ok(())
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn clean_store_with_local_ref_does_not_need_serialization() {
let dir = tempfile::TempDir::new().unwrap();
let _repo = gix::init(dir.path()).unwrap();
let status = std::process::Command::new("git")
.args(["config", "user.email", "test@example.com"])
.current_dir(dir.path())
.status()
.unwrap();
assert!(status.success());
let status = std::process::Command::new("git")
.args(["config", "user.name", "Test User"])
.current_dir(dir.path())
.status()
.unwrap();
assert!(status.success());
let session = Session::open(dir.path()).unwrap();
session.store.set_last_materialized(1000).unwrap();
let local_oid =
gix::ObjectId::from_hex(b"0000000000000000000000000000000000000000").unwrap();
assert!(!should_serialize_before_push(&session, Some(&local_oid)).unwrap());
}
}