use crate::auto_push_single_flight::{
PushAttempt, PushGit, SingleFlightError, git_output,
};
use crate::lfs_bounded_push::run_push;
use crate::diagnostic_log::debug;
use crate::merge_second_pass_tree::announce;
pub use crate::auto_push_destination::Destination;
use crate::auto_push_destination::read_destination;
use crate::push_retry::CONFLICT_PHRASE;
use crate::auto_push_reconcile_basics_and_limits::{TAG, argv, fetch_remote_tip, is_ancestor, unpublished};
use crate::auto_push_reconcile_branch_advance::advance_local_branch;
use crate::auto_push_reconcile_merge_commit::commit_merge;
pub use crate::auto_push_reconcile_basics_and_limits::{MAX_RECONCILE_ATTEMPTS, RECONCILE_FAILED_PREFIX, RECONCILE_LINE_PREFIX, journal_identifier, was_rejected_as_stale};
pub fn reconcile_divergent_push(
git: &PushGit,
branch_ref: &str,
rejected: PushAttempt,
) -> Result<PushAttempt, SingleFlightError> {
if !was_rejected_as_stale(&rejected.output) {
return Ok(rejected);
}
let Some(destination) = read_destination(git, branch_ref)? else {
debug(
TAG,
"the push destination cannot be resolved to a remote branch; no reconcile",
);
return Ok(rejected);
};
let local = git_output(
git,
&argv(&["rev-parse", "--verify", &format!("{branch_ref}^{{commit}}")]),
)?;
let plan = Reconcile {
destination,
local,
branch_ref: Some(branch_ref),
label: "auto-push",
explicit: false,
};
let mut push_merge: PushMergeThroughGit<'_> = PushMergeThroughGit { git };
return Ok(match integrate_and_push(git, &plan, rejected, &mut push_merge)? {
Integrated::NotDivergent(attempt) | Integrated::Finished(attempt) => attempt,
});
}
pub struct Reconcile<'a> {
pub destination: Destination,
pub local: String,
pub branch_ref: Option<&'a str>,
pub label: &'a str,
pub explicit: bool,
}
pub enum Integrated {
NotDivergent(PushAttempt),
Finished(PushAttempt),
}
pub trait MergePusher {
fn push_merge(&mut self, destination: &Destination, merge_oid: &str) -> Result<PushAttempt, SingleFlightError>;
}
struct PushMergeThroughGit<'a> {
git: &'a PushGit,
}
impl MergePusher for PushMergeThroughGit<'_> {
fn push_merge(&mut self, target: &Destination, merge_oid: &str) -> Result<PushAttempt, SingleFlightError> {
let arguments = argv(&[
"push",
&target.remote,
&format!("{merge_oid}:{}", target.merge_ref),
]);
return run_push(self.git, &arguments);
}
}
pub fn integrate_and_push(
git: &PushGit,
plan: &Reconcile<'_>,
rejected: PushAttempt,
push_merge: &mut dyn MergePusher,
) -> Result<Integrated, SingleFlightError> {
let destination = plan.destination.clone();
let local = plan.local.clone();
let label = plan.label;
let line_prefix = format!("monochromatic-git-policy-cli: {label}: ");
let failed_prefix = format!("{line_prefix}not published: ");
let short = destination
.merge_ref
.strip_prefix("refs/heads/")
.unwrap_or(&destination.merge_ref)
.to_owned();
let message = format!(
"Merge {}/{short} into {short}\n\nCreated by monochromatic-git-policy-cli {label} because {}/{short} advanced after the commit landed.",
destination.remote, destination.remote
);
let mut previous = rejected.output.clone();
let mut refused_against: Option<(String, PushAttempt)> = None;
for attempt in 1..=MAX_RECONCILE_ATTEMPTS {
let remote_tip = match fetch_remote_tip(git, &destination)? {
Ok(tip) => tip,
Err(failed) => {
if plan.explicit && refused_against.is_none() {
debug(
TAG,
"the remote could not be fetched after an explicit push failed; its own report stands",
);
return Ok(Integrated::NotDivergent(rejected));
}
return Ok(Integrated::Finished(unpublished(
&failed_prefix,
&format!("{previous}\n{}", failed.output),
failed.exit_code,
&format!(
"{}/{short} has commits this branch lacks, and fetching them failed; nothing was pushed and the worktree was not touched.",
destination.remote
),
)));
}
};
if let Some((tip, refusal)) = refused_against.take()
&& tip == remote_tip
{
debug(
TAG,
"the remote did not move after it refused the merge; the refusal is final",
);
return Ok(Integrated::Finished(refusal));
}
match (
is_ancestor(git, &remote_tip, &local)?,
is_ancestor(git, &local, &remote_tip)?,
) {
(Some(true), _) => {
debug(
TAG,
"the remote tip is behind the branch; the rejection was not a divergence",
);
return Ok(Integrated::NotDivergent(rejected));
}
(_, Some(true)) if plan.explicit => {
debug(
TAG,
"the remote already contains the pushed commit; the explicit push failed for another reason",
);
return Ok(Integrated::NotDivergent(rejected));
}
(_, Some(true)) => {
return Ok(Integrated::Finished(PushAttempt {
exit_code: 0,
output: format!(
"{line_prefix}{}/{short} already contains {local}; nothing to push.",
destination.remote
),
}));
}
(Some(false), Some(false)) => {}
_ => {
if plan.explicit {
return Ok(Integrated::NotDivergent(rejected));
}
return Ok(Integrated::Finished(unpublished(
&failed_prefix,
&previous,
rejected.exit_code,
"Git could not relate the branch to the remote tip; nothing was pushed and the worktree was not touched.",
)));
}
}
let committed = match commit_merge(git, &local, &remote_tip, &message)? {
Ok(found) => found,
Err(paths) => {
return Ok(Integrated::Finished(unpublished(
&failed_prefix,
&previous,
rejected.exit_code,
&format!(
"merging {}/{short} into {short}{CONFLICT_PHRASE}{}. Nothing was pushed and the worktree was not touched; \
merge {}/{short} in a separate worktree, resolve the conflicts and push.",
destination.remote,
paths.join(", "),
destination.remote
),
)));
}
};
let merge_oid = committed.oid.clone();
debug(
TAG,
&format!(
"attempt {attempt}: pushing the merge {merge_oid} of {local} and {remote_tip}"
),
);
let pushed = push_merge.push_merge(&destination, &merge_oid)?;
if pushed.exit_code == 0 {
let branch = match plan.branch_ref {
Some(reference) => reference.strip_prefix("refs/heads/").unwrap_or(reference),
None => "HEAD",
};
let advanced = match plan.branch_ref {
Some(branch_ref) => advance_local_branch(git, branch_ref, &local, &merge_oid)?,
None => Err(String::from("HEAD is detached")),
};
let followed = match advanced {
Ok(false) => format!("{branch} and the worktree were fast-forwarded to it."),
Ok(true) => format!(
"{branch} and the worktree were fast-forwarded to it; your uncommitted changes were left as they are."
),
Err(reason) => {
debug(TAG, &format!("{branch} was not fast-forwarded: {reason}"));
format!(
"{branch} itself and the worktree are unchanged ({reason}); update {branch} with `git merge --ff-only {}/{short}` \
when the worktree allows.",
destination.remote
)
}
};
let mut merged_parts: String = String::new();
for line in announce(&line_prefix, (journal_identifier(plan.explicit), label), &committed.merged) {
merged_parts.push_str(format!("{line}\n").as_str());
}
return Ok(Integrated::Finished(PushAttempt {
exit_code: 0,
output: format!(
"{}\n{merged_parts}{line_prefix}{}/{short} had new commits, so the merge {merge_oid} of {branch} and {}/{short} \
was published. {followed}",
pushed.output, destination.remote, destination.remote
)
.trim_start_matches('\n')
.to_owned(),
}));
}
if !plan.explicit && !was_rejected_as_stale(&pushed.output) {
return Ok(Integrated::Finished(pushed));
}
previous = pushed.output.clone();
refused_against = Some((remote_tip, pushed));
}
return Ok(Integrated::Finished(unpublished(
&failed_prefix,
&previous,
rejected.exit_code,
&format!(
"{}/{short} advanced again during each of {MAX_RECONCILE_ATTEMPTS} attempts, so nothing was pushed and the worktree was not touched. \
Push again when the remote is quiet.",
destination.remote
),
)));
}
#[cfg(test)]
#[path = "auto_push_reconcile_tests.rs"]
mod tests;