use crate::auto_push_single_flight::{
PushAttempt, PushGit, SingleFlightError, git_command, git_output, run_query,
};
use crate::auto_push_dirty_paths::{diff_paths, listed, overlapping_paths, status_paths};
use crate::bounded_push::{STOP_GRACE, push_patience, run_bounded_push};
use crate::lfs_bounded_push::run_push;
use crate::diagnostic_log::{debug, warn};
use crate::merge_second_pass::{GitReply, GitRunner, Merged, conflicts_of_merge_tree};
use crate::journald_native::{CLIENT_IDENTIFIER, IDENTIFIER};
use crate::merge_second_pass_tree::{AttributeProbe, announce, attribute_tree_variables, report, resolve_tree};
use crate::path_bytes::path_message_text;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use crate::inner_git_words::{record_streams, record_text};
use std::ffi::OsString;
const TAG: &str = "cli-git";
pub const RECONCILE_LINE_PREFIX: &str = "cli-git: auto-push: ";
pub const RECONCILE_FAILED_PREFIX: &str = "cli-git: auto-push: not published: ";
pub const MAX_RECONCILE_ATTEMPTS: u32 = 10;
const REJECTED_MARKER: &str = "[rejected]";
const REMOTE_REJECTED_MARKER: &str = "[remote rejected]";
pub use crate::auto_push_destination::Destination;
use crate::auto_push_destination::read_destination;
use crate::iso_time::now_iso;
use crate::merge_isolation_journal::{MergeIntent, remove_merge_intent, write_merge_intent};
use crate::merge_isolation_recovery::{MergeRecoveryAction, RecoveryPlace, settle_failed_merge};
use crate::random_id::random_uuid;
use crate::transaction_git::GitContext;
use crate::transaction_owner::current_transaction_owner;
use crate::push_retry::CONFLICT_PHRASE;
pub fn was_rejected_as_stale(output: &str) -> bool {
return output.contains(REJECTED_MARKER) || output.contains(REMOTE_REJECTED_MARKER);
}
fn unpublished(failed_prefix: &str, previous: &str, exit_code: i64, reason: &str) -> PushAttempt {
return PushAttempt {
exit_code: if exit_code == 0 { 1 } else { exit_code },
output: format!("{previous}\n{failed_prefix}{reason}"),
};
}
fn argv(parts: &[&str]) -> Vec<OsString> {
return parts.iter().map(OsString::from).collect();
}
fn is_ancestor(
git: &PushGit,
ancestor: &str,
descendant: &str,
) -> Result<Option<bool>, SingleFlightError> {
let output = run_query(
git,
&argv(&["merge-base", "--is-ancestor", ancestor, descendant]),
)?;
return Ok(match output.code {
Some(0) => Some(true),
Some(1) => Some(false),
_ => None,
});
}
fn fetch_remote_tip(
git: &PushGit,
destination: &Destination,
) -> Result<Result<String, PushAttempt>, SingleFlightError> {
let private_ref = format!("refs/cli-git/auto-push/fetch-{}", std::process::id());
let refspec = format!("+{}:{private_ref}", destination.merge_ref);
let arguments = argv(&[
"fetch",
"--no-tags",
&destination.remote,
&refspec,
]);
let command = git_command(git, &arguments);
let fetched = run_bounded_push(command, push_patience(&git.environment), STOP_GRACE, git.environment.as_slice())?;
if fetched.exit_code != 0 {
return Ok(Err(fetched));
}
record_text(&["fetch", "--no-tags", destination.remote.as_str(), refspec.as_str()], fetched.output.as_str());
let tip = git_output(
git,
&argv(&[
"rev-parse",
"--verify",
&format!("{private_ref}^{{commit}}"),
]),
);
let _deleted = run_query(git, &argv(&["update-ref", "-d", &private_ref]));
return Ok(Ok(tip?));
}
pub fn journal_identifier(explicit: bool) -> &'static str {
return if explicit { CLIENT_IDENTIFIER } else { IDENTIFIER };
}
struct PushRunner<'a> {
git: &'a PushGit,
}
impl GitRunner for PushRunner<'_> {
fn run(&self, arguments: &[&str], input: Option<&[u8]>, index_file: Option<&Path>) -> Result<GitReply, String> {
let mut command = git_command(self.git, &argv(arguments));
if let Some(index) = index_file {
command.env("GIT_INDEX_FILE", index);
}
command.stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped());
let mut child = command.spawn().map_err(|error| { return format!("git {} could not start: {error}", arguments.join(" ")); })?;
let pipe = child.stdin.take();
let bytes: Vec<u8> = input.map(<[u8]>::to_vec).unwrap_or_default();
let writer = std::thread::spawn(move || {
if let Some(mut open) = pipe
&& let Err(error) = open.write_all(bytes.as_slice())
{
debug(TAG, &format!("the input of a second-pass command was not taken: {error}"));
}
});
let output = child.wait_with_output().map_err(|error| { return format!("git {} did not finish: {error}", arguments.join(" ")); })?;
if writer.join().is_err() {
return Err(format!("the input thread of git {} ended abnormally", arguments.join(" ")));
}
return Ok(GitReply { code: output.status.code(), stdout: output.stdout, stderr: output.stderr });
}
}
struct CommittedMerge {
oid: String,
merged: Vec<Merged>,
}
fn commit_merge(
git: &PushGit,
local: &str,
remote: &str,
message: &str,
) -> Result<Result<CommittedMerge, Vec<String>>, SingleFlightError> {
let probe = AttributeProbe { directory: git.cwd.as_path(), global_prefix: &[], variables: git.environment.as_slice(), runner: &PushRunner { git } };
let attributed = PushGit {
real_git: git.real_git.clone(),
cwd: git.cwd.clone(),
environment: git.environment.iter().cloned().chain(attribute_tree_variables(local, &probe)).collect(),
lfs: git.lfs,
};
let runner = PushRunner { git: &attributed };
let merge_arguments = [
"merge-tree",
"--write-tree",
"--no-messages",
"-z",
"--allow-unrelated-histories",
local,
remote,
];
let merged = runner.run(&merge_arguments, None, None).map_err(|reason| {
return SingleFlightError::Git {
arguments: format!("merge-tree --write-tree {local} {remote}"),
code: None,
stderr: reason,
};
})?;
let (mut tree, conflicts) = conflicts_of_merge_tree(&merged.stdout);
let mut second_pass_files: Vec<Merged> = Vec::new();
if merged.code == Some(1) {
let common = common_dir_of(git)?;
let top = PathBuf::from(git_output(git, &argv(&["rev-parse", "--show-toplevel"]))?);
let from_top = PushGit {
real_git: attributed.real_git.clone(),
cwd: top,
environment: attributed.environment.clone(),
lfs: attributed.lfs,
};
match resolve_tree(&PushRunner { git: &from_top }, common.as_path(), &tree, &conflicts) {
Some(resolved) => {
tree = resolved.tree;
second_pass_files = resolved.merged;
}
None => {
let paths: Vec<String> = conflicts.iter().map(|conflict| { return path_message_text(&conflict.path); }).collect();
return Ok(Err(paths));
}
}
} else if merged.code != Some(0) {
return Err(SingleFlightError::Git {
arguments: format!("merge-tree --write-tree {local} {remote}"),
code: merged.code,
stderr: String::from_utf8_lossy(&merged.stderr).into_owned(),
});
}
let mut full_message = message.to_owned();
if !second_pass_files.is_empty() {
full_message.push_str("\n\n");
full_message.push_str(report("", &second_pass_files).join("\n").as_str());
}
let mut commit_tree = vec!["commit-tree"];
if signs_commits(git)? {
commit_tree.push("-S");
}
commit_tree.extend(["-p", local, "-p", remote, "-m", &full_message, &tree]);
let oid = git_output(git, &argv(&commit_tree))?;
return Ok(Ok(CommittedMerge { oid, merged: second_pass_files }));
}
fn common_dir_of(git: &PushGit) -> Result<PathBuf, SingleFlightError> {
let answered = git_output(git, &argv(&["rev-parse", "--git-common-dir"]))?;
return Ok(git.cwd.join(answered));
}
fn signs_commits(git: &PushGit) -> Result<bool, SingleFlightError> {
let asked = run_query(
git,
&argv(&["config", "--type=bool", "--get", "commit.gpgsign"]),
)?;
if asked.code == Some(1) {
return Ok(false);
}
if asked.code != Some(0) {
return Err(SingleFlightError::Git {
arguments: String::from("config --type=bool --get commit.gpgsign"),
code: asked.code,
stderr: String::from_utf8_lossy(&asked.stderr).into_owned(),
});
}
return Ok(asked.stdout.trim_ascii() == b"true");
}
fn status_differs(before: &[u8], after: &[u8]) -> bool {
return before != after;
}
fn fast_forward_recorded(git: &PushGit, branch_ref: &str, local: &str, merge_oid: &str) -> Result<Result<(), String>, SingleFlightError> {
let moved_arguments = argv(&["merge", "--ff-only", merge_oid]);
let git_dir: Option<PathBuf> = {
let named = run_query(git, &argv(&["rev-parse", "--absolute-git-dir"]))?;
(named.code == Some(0)).then(|| { return PathBuf::from(String::from_utf8_lossy(&named.stdout).trim()); })
};
let recorded: Option<(PathBuf, MergeIntent)> = git_dir.as_deref().and_then(|dir| {
let owner = current_transaction_owner(random_uuid().ok()?.as_str(), now_iso().as_str()).ok()?;
let intent: MergeIntent = MergeIntent {
owner,
repository_root: git.cwd.to_string_lossy().into_owned(),
target_ref: Some(branch_ref.to_owned()),
old_oid: local.to_owned(),
new_oid: merge_oid.to_owned(),
};
let record: PathBuf = write_merge_intent(dir, &intent).ok()?;
return Some((record, intent));
});
let moved = run_query(git, &moved_arguments)?;
if moved.code == Some(0) {
record_streams(&["merge", "--ff-only", merge_oid], &moved.stdout, &moved.stderr);
}
let Some((record, intent)) = recorded else {
return Ok(if moved.code == Some(0) { Ok(()) } else { Err(refusal_of(&moved.stderr)) });
};
if moved.code == Some(0) {
if let Err(error) = remove_merge_intent(record.as_path()) {
debug(TAG, &format!("the merge record could not be removed: {error}"));
}
return Ok(Ok(()));
}
let place: RecoveryPlace = RecoveryPlace {
git: GitContext { real_git: git.real_git.clone(), overlay: git.environment.clone(), global_prefix: Vec::new(), common_dir: git_dir.clone() },
cwd: git.cwd.clone(),
common_dir: git_dir.unwrap_or_default(),
};
return Ok(match settle_failed_merge(&place, record.as_path(), &intent) {
Ok(MergeRecoveryAction::Completed) => Ok(()),
Ok(_) => Err(refusal_of(&moved.stderr)),
Err(error) => Err(format!("{} ({})", refusal_of(&moved.stderr), error.0)),
});
}
fn refusal_of(stderr: &[u8]) -> String {
let detail = String::from_utf8_lossy(stderr);
return format!("Git refused the fast-forward: {}", detail.lines().next().unwrap_or_default().trim());
}
fn advance_local_branch(
git: &PushGit,
branch_ref: &str,
local: &str,
merge_oid: &str,
) -> Result<Result<bool, String>, SingleFlightError> {
let checked_out = run_query(git, &argv(&["symbolic-ref", "--quiet", "HEAD"]))?;
if checked_out.code != Some(0) {
return Ok(Err(String::from("HEAD is detached")));
}
if String::from_utf8_lossy(&checked_out.stdout).trim() != branch_ref {
return Ok(Err(String::from(
"this worktree has another branch checked out",
)));
}
let head = run_query(git, &argv(&["rev-parse", "--verify", "HEAD^{commit}"]))?;
if String::from_utf8_lossy(&head.stdout).trim() != local {
return Ok(Err(String::from(
"the branch moved after the commit landed",
)));
}
let status_arguments = argv(&[
"--no-optional-locks",
"status",
"--porcelain=v1",
"-z",
"--untracked-files=no",
]);
let status = run_query(git, &status_arguments)?;
if status.code != Some(0) {
return Ok(Err(String::from("the worktree status could not be read")));
}
let dirty = status_paths(&status.stdout);
let kept: bool = !dirty.is_empty();
if kept {
let incoming = run_query(
git,
&argv(&["diff", "--name-only", "-z", "--no-renames", local, merge_oid]),
)?;
if incoming.code != Some(0) {
return Ok(Err(String::from(
"the files the merge changes could not be read",
)));
}
let overlap = overlapping_paths(&dirty, &diff_paths(&incoming.stdout));
if !overlap.is_empty() {
return Ok(Err(format!(
"the worktree has uncommitted changes to files the merge changes: {}",
listed(&overlap)
)));
}
}
if let Err(reason) = fast_forward_recorded(git, branch_ref, local, merge_oid)? {
return Ok(Err(reason));
}
if kept {
let after = run_query(git, &status_arguments)?;
if status_differs(&status.stdout, &after.stdout) {
warn(
TAG,
"the worktree status differs after the fast-forward; compare it with the status before the commit",
);
}
}
return Ok(Ok(kept));
}
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 = |target: &Destination, merge_oid: &str| -> Result<PushAttempt, SingleFlightError> {
let arguments = argv(&[
"push",
&target.remote,
&format!("{merge_oid}:{}", target.merge_ref),
]);
return run_push(git, &arguments);
};
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 fn integrate_and_push(
git: &PushGit,
plan: &Reconcile<'_>,
rejected: PushAttempt,
push_merge: &mut dyn FnMut(&Destination, &str) -> Result<PushAttempt, SingleFlightError>,
) -> Result<Integrated, SingleFlightError> {
let destination = plan.destination.clone();
let local = plan.local.clone();
let label = plan.label;
let line_prefix = format!("cli-git: {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 cli-git {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(&destination, &merge_oid)?;
if pushed.exit_code == 0 {
let branch = plan
.branch_ref
.map(|reference| {
return reference.strip_prefix("refs/heads/").unwrap_or(reference);
})
.unwrap_or("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 merged_parts: String = announce(&line_prefix, (journal_identifier(plan.explicit), label), &committed.merged)
.iter()
.map(|line| { return format!("{line}\n"); })
.collect();
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;