1use crate::auto_push_single_flight::{
18 PushAttempt, PushGit, SingleFlightError, git_command, git_output, run_query,
19};
20use crate::auto_push_dirty_paths::{diff_paths, listed, overlapping_paths, status_paths};
22use crate::bounded_push::{STOP_GRACE, push_patience, run_bounded_push};
23use crate::lfs_bounded_push::run_push;
25use crate::diagnostic_log::{debug, warn};
27use crate::merge_second_pass::{GitReply, GitRunner, Merged, conflicts_of_merge_tree};
29use crate::journald_native::{CLIENT_IDENTIFIER, IDENTIFIER};
30use crate::merge_second_pass_tree::{AttributeProbe, announce, attribute_tree_variables, report, resolve_tree};
31use crate::path_bytes::path_message_text;
32use std::io::Write;
33use std::path::{Path, PathBuf};
34use std::process::Stdio;
35use crate::inner_git_words::{record_streams, record_text};
37use std::ffi::OsString;
39
40const TAG: &str = "cli-git";
42
43pub const RECONCILE_LINE_PREFIX: &str = "cli-git: auto-push: ";
45
46pub const RECONCILE_FAILED_PREFIX: &str = "cli-git: auto-push: not published: ";
49
50pub const MAX_RECONCILE_ATTEMPTS: u32 = 10;
52
53const REJECTED_MARKER: &str = "[rejected]";
55
56const REMOTE_REJECTED_MARKER: &str = "[remote rejected]";
59
60pub use crate::auto_push_destination::Destination;
62use crate::auto_push_destination::read_destination;
64use crate::iso_time::now_iso;
66use crate::merge_isolation_journal::{MergeIntent, remove_merge_intent, write_merge_intent};
67use crate::merge_isolation_recovery::{MergeRecoveryAction, RecoveryPlace, settle_failed_merge};
68use crate::random_id::random_uuid;
69use crate::transaction_git::GitContext;
70use crate::transaction_owner::current_transaction_owner;
71use crate::push_retry::CONFLICT_PHRASE;
73
74pub fn was_rejected_as_stale(output: &str) -> bool {
88 return output.contains(REJECTED_MARKER) || output.contains(REMOTE_REJECTED_MARKER);
89}
90
91fn unpublished(failed_prefix: &str, previous: &str, exit_code: i64, reason: &str) -> PushAttempt {
98 return PushAttempt {
99 exit_code: if exit_code == 0 { 1 } else { exit_code },
100 output: format!("{previous}\n{failed_prefix}{reason}"),
101 };
102}
103
104fn argv(parts: &[&str]) -> Vec<OsString> {
111 return parts.iter().map(OsString::from).collect();
112}
113
114fn is_ancestor(
124 git: &PushGit,
125 ancestor: &str,
126 descendant: &str,
127) -> Result<Option<bool>, SingleFlightError> {
128 let output = run_query(
129 git,
130 &argv(&["merge-base", "--is-ancestor", ancestor, descendant]),
131 )?;
132 return Ok(match output.code {
133 Some(0) => Some(true),
134 Some(1) => Some(false),
135 _ => None,
136 });
137}
138
139fn fetch_remote_tip(
150 git: &PushGit,
151 destination: &Destination,
152) -> Result<Result<String, PushAttempt>, SingleFlightError> {
153 let private_ref = format!("refs/cli-git/auto-push/fetch-{}", std::process::id());
154 let refspec = format!("+{}:{private_ref}", destination.merge_ref);
155 let arguments = argv(&[
156 "fetch",
157 "--no-tags",
158 &destination.remote,
159 &refspec,
160 ]);
161 let command = git_command(git, &arguments);
163 let fetched = run_bounded_push(command, push_patience(&git.environment), STOP_GRACE, git.environment.as_slice())?;
164 if fetched.exit_code != 0 {
165 return Ok(Err(fetched));
166 }
167 record_text(&["fetch", "--no-tags", destination.remote.as_str(), refspec.as_str()], fetched.output.as_str());
169 let tip = git_output(
170 git,
171 &argv(&[
172 "rev-parse",
173 "--verify",
174 &format!("{private_ref}^{{commit}}"),
175 ]),
176 );
177 let _deleted = run_query(git, &argv(&["update-ref", "-d", &private_ref]));
179 return Ok(Ok(tip?));
180}
181
182pub fn journal_identifier(explicit: bool) -> &'static str {
193 return if explicit { CLIENT_IDENTIFIER } else { IDENTIFIER };
194}
195
196struct PushRunner<'a> {
198 git: &'a PushGit,
200}
201
202impl GitRunner for PushRunner<'_> {
204 fn run(&self, arguments: &[&str], input: Option<&[u8]>, index_file: Option<&Path>) -> Result<GitReply, String> {
205 let mut command = git_command(self.git, &argv(arguments));
206 if let Some(index) = index_file {
207 command.env("GIT_INDEX_FILE", index);
208 }
209 command.stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped());
210 let mut child = command.spawn().map_err(|error| { return format!("git {} could not start: {error}", arguments.join(" ")); })?;
211 let pipe = child.stdin.take();
213 let bytes: Vec<u8> = input.map(<[u8]>::to_vec).unwrap_or_default();
214 let writer = std::thread::spawn(move || {
215 if let Some(mut open) = pipe
216 && let Err(error) = open.write_all(bytes.as_slice())
217 {
218 debug(TAG, &format!("the input of a second-pass command was not taken: {error}"));
219 }
220 });
221 let output = child.wait_with_output().map_err(|error| { return format!("git {} did not finish: {error}", arguments.join(" ")); })?;
222 if writer.join().is_err() {
223 return Err(format!("the input thread of git {} ended abnormally", arguments.join(" ")));
224 }
225 return Ok(GitReply { code: output.status.code(), stdout: output.stdout, stderr: output.stderr });
226 }
227}
228
229struct CommittedMerge {
231 oid: String,
233 merged: Vec<Merged>,
235}
236
237fn commit_merge(
250 git: &PushGit,
251 local: &str,
252 remote: &str,
253 message: &str,
254) -> Result<Result<CommittedMerge, Vec<String>>, SingleFlightError> {
255 let probe = AttributeProbe { directory: git.cwd.as_path(), global_prefix: &[], variables: git.environment.as_slice(), runner: &PushRunner { git } };
256 let attributed = PushGit {
257 real_git: git.real_git.clone(),
258 cwd: git.cwd.clone(),
259 environment: git.environment.iter().cloned().chain(attribute_tree_variables(local, &probe)).collect(),
260 lfs: git.lfs,
261 };
262 let runner = PushRunner { git: &attributed };
263 let merge_arguments = [
264 "merge-tree",
265 "--write-tree",
266 "--no-messages",
267 "-z",
268 "--allow-unrelated-histories",
269 local,
270 remote,
271 ];
272 let merged = runner.run(&merge_arguments, None, None).map_err(|reason| {
273 return SingleFlightError::Git {
274 arguments: format!("merge-tree --write-tree {local} {remote}"),
275 code: None,
276 stderr: reason,
277 };
278 })?;
279 let (mut tree, conflicts) = conflicts_of_merge_tree(&merged.stdout);
280 let mut second_pass_files: Vec<Merged> = Vec::new();
281 if merged.code == Some(1) {
282 let common = common_dir_of(git)?;
283 let top = PathBuf::from(git_output(git, &argv(&["rev-parse", "--show-toplevel"]))?);
285 let from_top = PushGit {
286 real_git: attributed.real_git.clone(),
287 cwd: top,
288 environment: attributed.environment.clone(),
289 lfs: attributed.lfs,
290 };
291 match resolve_tree(&PushRunner { git: &from_top }, common.as_path(), &tree, &conflicts) {
292 Some(resolved) => {
293 tree = resolved.tree;
294 second_pass_files = resolved.merged;
295 }
296 None => {
297 let paths: Vec<String> = conflicts.iter().map(|conflict| { return path_message_text(&conflict.path); }).collect();
298 return Ok(Err(paths));
299 }
300 }
301 } else if merged.code != Some(0) {
302 return Err(SingleFlightError::Git {
303 arguments: format!("merge-tree --write-tree {local} {remote}"),
304 code: merged.code,
305 stderr: String::from_utf8_lossy(&merged.stderr).into_owned(),
306 });
307 }
308 let mut full_message = message.to_owned();
309 if !second_pass_files.is_empty() {
310 full_message.push_str("\n\n");
311 full_message.push_str(report("", &second_pass_files).join("\n").as_str());
312 }
313 let mut commit_tree = vec!["commit-tree"];
314 if signs_commits(git)? {
315 commit_tree.push("-S");
316 }
317 commit_tree.extend(["-p", local, "-p", remote, "-m", &full_message, &tree]);
318 let oid = git_output(git, &argv(&commit_tree))?;
319 return Ok(Ok(CommittedMerge { oid, merged: second_pass_files }));
320}
321
322fn common_dir_of(git: &PushGit) -> Result<PathBuf, SingleFlightError> {
324 let answered = git_output(git, &argv(&["rev-parse", "--git-common-dir"]))?;
325 return Ok(git.cwd.join(answered));
326}
327
328fn signs_commits(git: &PushGit) -> Result<bool, SingleFlightError> {
340 let asked = run_query(
341 git,
342 &argv(&["config", "--type=bool", "--get", "commit.gpgsign"]),
343 )?;
344 if asked.code == Some(1) {
345 return Ok(false);
346 }
347 if asked.code != Some(0) {
348 return Err(SingleFlightError::Git {
349 arguments: String::from("config --type=bool --get commit.gpgsign"),
350 code: asked.code,
351 stderr: String::from_utf8_lossy(&asked.stderr).into_owned(),
352 });
353 }
354 return Ok(asked.stdout.trim_ascii() == b"true");
355}
356
357fn status_differs(before: &[u8], after: &[u8]) -> bool {
368 return before != after;
369}
370
371fn fast_forward_recorded(git: &PushGit, branch_ref: &str, local: &str, merge_oid: &str) -> Result<Result<(), String>, SingleFlightError> {
386 let moved_arguments = argv(&["merge", "--ff-only", merge_oid]);
387 let git_dir: Option<PathBuf> = {
388 let named = run_query(git, &argv(&["rev-parse", "--absolute-git-dir"]))?;
389 (named.code == Some(0)).then(|| { return PathBuf::from(String::from_utf8_lossy(&named.stdout).trim()); })
390 };
391 let recorded: Option<(PathBuf, MergeIntent)> = git_dir.as_deref().and_then(|dir| {
392 let owner = current_transaction_owner(random_uuid().ok()?.as_str(), now_iso().as_str()).ok()?;
393 let intent: MergeIntent = MergeIntent {
394 owner,
395 repository_root: git.cwd.to_string_lossy().into_owned(),
396 target_ref: Some(branch_ref.to_owned()),
397 old_oid: local.to_owned(),
398 new_oid: merge_oid.to_owned(),
399 };
400 let record: PathBuf = write_merge_intent(dir, &intent).ok()?;
401 return Some((record, intent));
402 });
403 let moved = run_query(git, &moved_arguments)?;
404 if moved.code == Some(0) {
406 record_streams(&["merge", "--ff-only", merge_oid], &moved.stdout, &moved.stderr);
407 }
408 let Some((record, intent)) = recorded else {
409 return Ok(if moved.code == Some(0) { Ok(()) } else { Err(refusal_of(&moved.stderr)) });
411 };
412 if moved.code == Some(0) {
413 if let Err(error) = remove_merge_intent(record.as_path()) {
414 debug(TAG, &format!("the merge record could not be removed: {error}"));
415 }
416 return Ok(Ok(()));
417 }
418 let place: RecoveryPlace = RecoveryPlace {
419 git: GitContext { real_git: git.real_git.clone(), overlay: git.environment.clone(), global_prefix: Vec::new(), common_dir: git_dir.clone() },
420 cwd: git.cwd.clone(),
421 common_dir: git_dir.unwrap_or_default(),
422 };
423 return Ok(match settle_failed_merge(&place, record.as_path(), &intent) {
424 Ok(MergeRecoveryAction::Completed) => Ok(()),
425 Ok(_) => Err(refusal_of(&moved.stderr)),
426 Err(error) => Err(format!("{} ({})", refusal_of(&moved.stderr), error.0)),
427 });
428}
429
430fn refusal_of(stderr: &[u8]) -> String {
432 let detail = String::from_utf8_lossy(stderr);
433 return format!("Git refused the fast-forward: {}", detail.lines().next().unwrap_or_default().trim());
434}
435
436fn advance_local_branch(
456 git: &PushGit,
457 branch_ref: &str,
458 local: &str,
459 merge_oid: &str,
460) -> Result<Result<bool, String>, SingleFlightError> {
461 let checked_out = run_query(git, &argv(&["symbolic-ref", "--quiet", "HEAD"]))?;
462 if checked_out.code != Some(0) {
463 return Ok(Err(String::from("HEAD is detached")));
464 }
465 if String::from_utf8_lossy(&checked_out.stdout).trim() != branch_ref {
466 return Ok(Err(String::from(
467 "this worktree has another branch checked out",
468 )));
469 }
470 let head = run_query(git, &argv(&["rev-parse", "--verify", "HEAD^{commit}"]))?;
471 if String::from_utf8_lossy(&head.stdout).trim() != local {
472 return Ok(Err(String::from(
473 "the branch moved after the commit landed",
474 )));
475 }
476 let status_arguments = argv(&[
477 "--no-optional-locks",
478 "status",
479 "--porcelain=v1",
480 "-z",
481 "--untracked-files=no",
482 ]);
483 let status = run_query(git, &status_arguments)?;
484 if status.code != Some(0) {
485 return Ok(Err(String::from("the worktree status could not be read")));
486 }
487 let dirty = status_paths(&status.stdout);
488 let kept: bool = !dirty.is_empty();
489 if kept {
490 let incoming = run_query(
491 git,
492 &argv(&["diff", "--name-only", "-z", "--no-renames", local, merge_oid]),
493 )?;
494 if incoming.code != Some(0) {
495 return Ok(Err(String::from(
496 "the files the merge changes could not be read",
497 )));
498 }
499 let overlap = overlapping_paths(&dirty, &diff_paths(&incoming.stdout));
500 if !overlap.is_empty() {
501 return Ok(Err(format!(
502 "the worktree has uncommitted changes to files the merge changes: {}",
503 listed(&overlap)
504 )));
505 }
506 }
507 if let Err(reason) = fast_forward_recorded(git, branch_ref, local, merge_oid)? {
508 return Ok(Err(reason));
509 }
510 if kept {
511 let after = run_query(git, &status_arguments)?;
513 if status_differs(&status.stdout, &after.stdout) {
514 warn(
515 TAG,
516 "the worktree status differs after the fast-forward; compare it with the status before the commit",
517 );
518 }
519 }
520 return Ok(Ok(kept));
521}
522
523pub fn reconcile_divergent_push(
539 git: &PushGit,
540 branch_ref: &str,
541 rejected: PushAttempt,
542) -> Result<PushAttempt, SingleFlightError> {
543 if !was_rejected_as_stale(&rejected.output) {
544 return Ok(rejected);
545 }
546 let Some(destination) = read_destination(git, branch_ref)? else {
547 debug(
548 TAG,
549 "the push destination cannot be resolved to a remote branch; no reconcile",
550 );
551 return Ok(rejected);
552 };
553 let local = git_output(
554 git,
555 &argv(&["rev-parse", "--verify", &format!("{branch_ref}^{{commit}}")]),
556 )?;
557 let plan = Reconcile {
558 destination,
559 local,
560 branch_ref: Some(branch_ref),
561 label: "auto-push",
562 explicit: false,
563 };
564 let mut push_merge = |target: &Destination, merge_oid: &str| -> Result<PushAttempt, SingleFlightError> {
565 let arguments = argv(&[
566 "push",
567 &target.remote,
568 &format!("{merge_oid}:{}", target.merge_ref),
569 ]);
570 return run_push(git, &arguments);
571 };
572 return Ok(match integrate_and_push(git, &plan, rejected, &mut push_merge)? {
573 Integrated::NotDivergent(attempt) | Integrated::Finished(attempt) => attempt,
574 });
575}
576
577pub struct Reconcile<'a> {
591 pub destination: Destination,
593 pub local: String,
595 pub branch_ref: Option<&'a str>,
597 pub label: &'a str,
599 pub explicit: bool,
601}
602
603pub enum Integrated {
610 NotDivergent(PushAttempt),
612 Finished(PushAttempt),
614}
615
616pub fn integrate_and_push(
629 git: &PushGit,
630 plan: &Reconcile<'_>,
631 rejected: PushAttempt,
632 push_merge: &mut dyn FnMut(&Destination, &str) -> Result<PushAttempt, SingleFlightError>,
633) -> Result<Integrated, SingleFlightError> {
634 let destination = plan.destination.clone();
635 let local = plan.local.clone();
636 let label = plan.label;
637 let line_prefix = format!("cli-git: {label}: ");
638 let failed_prefix = format!("{line_prefix}not published: ");
639 let short = destination
640 .merge_ref
641 .strip_prefix("refs/heads/")
642 .unwrap_or(&destination.merge_ref)
643 .to_owned();
644 let message = format!(
645 "Merge {}/{short} into {short}\n\nCreated by cli-git {label} because {}/{short} advanced after the commit landed.",
646 destination.remote, destination.remote
647 );
648 let mut previous = rejected.output.clone();
649 let mut refused_against: Option<(String, PushAttempt)> = None;
652 for attempt in 1..=MAX_RECONCILE_ATTEMPTS {
653 let remote_tip = match fetch_remote_tip(git, &destination)? {
654 Ok(tip) => tip,
655 Err(failed) => {
656 if plan.explicit && refused_against.is_none() {
657 debug(
658 TAG,
659 "the remote could not be fetched after an explicit push failed; its own report stands",
660 );
661 return Ok(Integrated::NotDivergent(rejected));
662 }
663 return Ok(Integrated::Finished(unpublished(
664 &failed_prefix,
665 &format!("{previous}\n{}", failed.output),
666 failed.exit_code,
667 &format!(
668 "{}/{short} has commits this branch lacks, and fetching them failed; nothing was pushed and the worktree was not touched.",
669 destination.remote
670 ),
671 )));
672 }
673 };
674 if let Some((tip, refusal)) = refused_against.take()
675 && tip == remote_tip
676 {
677 debug(
678 TAG,
679 "the remote did not move after it refused the merge; the refusal is final",
680 );
681 return Ok(Integrated::Finished(refusal));
682 }
683 match (
684 is_ancestor(git, &remote_tip, &local)?,
685 is_ancestor(git, &local, &remote_tip)?,
686 ) {
687 (Some(true), _) => {
688 debug(
689 TAG,
690 "the remote tip is behind the branch; the rejection was not a divergence",
691 );
692 return Ok(Integrated::NotDivergent(rejected));
693 }
694 (_, Some(true)) if plan.explicit => {
695 debug(
696 TAG,
697 "the remote already contains the pushed commit; the explicit push failed for another reason",
698 );
699 return Ok(Integrated::NotDivergent(rejected));
700 }
701 (_, Some(true)) => {
702 return Ok(Integrated::Finished(PushAttempt {
703 exit_code: 0,
704 output: format!(
705 "{line_prefix}{}/{short} already contains {local}; nothing to push.",
706 destination.remote
707 ),
708 }));
709 }
710 (Some(false), Some(false)) => {}
711 _ => {
712 if plan.explicit {
713 return Ok(Integrated::NotDivergent(rejected));
714 }
715 return Ok(Integrated::Finished(unpublished(
716 &failed_prefix,
717 &previous,
718 rejected.exit_code,
719 "Git could not relate the branch to the remote tip; nothing was pushed and the worktree was not touched.",
720 )));
721 }
722 }
723 let committed = match commit_merge(git, &local, &remote_tip, &message)? {
724 Ok(found) => found,
725 Err(paths) => {
726 return Ok(Integrated::Finished(unpublished(
727 &failed_prefix,
728 &previous,
729 rejected.exit_code,
730 &format!(
731 "merging {}/{short} into {short}{CONFLICT_PHRASE}{}. Nothing was pushed and the worktree was not touched; \
732 merge {}/{short} in a separate worktree, resolve the conflicts and push.",
733 destination.remote,
734 paths.join(", "),
735 destination.remote
736 ),
737 )));
738 }
739 };
740 let merge_oid = committed.oid.clone();
741 debug(
742 TAG,
743 &format!(
744 "attempt {attempt}: pushing the merge {merge_oid} of {local} and {remote_tip}"
745 ),
746 );
747 let pushed = push_merge(&destination, &merge_oid)?;
748 if pushed.exit_code == 0 {
749 let branch = plan
750 .branch_ref
751 .map(|reference| {
752 return reference.strip_prefix("refs/heads/").unwrap_or(reference);
753 })
754 .unwrap_or("HEAD");
755 let advanced = match plan.branch_ref {
756 Some(branch_ref) => advance_local_branch(git, branch_ref, &local, &merge_oid)?,
757 None => Err(String::from("HEAD is detached")),
758 };
759 let followed = match advanced {
760 Ok(false) => format!("{branch} and the worktree were fast-forwarded to it."),
761 Ok(true) => format!(
762 "{branch} and the worktree were fast-forwarded to it; your uncommitted changes were left as they are."
763 ),
764 Err(reason) => {
765 debug(TAG, &format!("{branch} was not fast-forwarded: {reason}"));
766 format!(
767 "{branch} itself and the worktree are unchanged ({reason}); update {branch} with `git merge --ff-only {}/{short}` \
768 when the worktree allows.",
769 destination.remote
770 )
771 }
772 };
773 let merged_parts: String = announce(&line_prefix, (journal_identifier(plan.explicit), label), &committed.merged)
774 .iter()
775 .map(|line| { return format!("{line}\n"); })
776 .collect();
777 return Ok(Integrated::Finished(PushAttempt {
778 exit_code: 0,
779 output: format!(
780 "{}\n{merged_parts}{line_prefix}{}/{short} had new commits, so the merge {merge_oid} of {branch} and {}/{short} \
781 was published. {followed}",
782 pushed.output, destination.remote, destination.remote
783 )
784 .trim_start_matches('\n')
785 .to_owned(),
786 }));
787 }
788 if !plan.explicit && !was_rejected_as_stale(&pushed.output) {
789 return Ok(Integrated::Finished(pushed));
790 }
791 previous = pushed.output.clone();
792 refused_against = Some((remote_tip, pushed));
793 }
794 return Ok(Integrated::Finished(unpublished(
795 &failed_prefix,
796 &previous,
797 rejected.exit_code,
798 &format!(
799 "{}/{short} advanced again during each of {MAX_RECONCILE_ATTEMPTS} attempts, so nothing was pushed and the worktree was not touched. \
800 Push again when the remote is quiet.",
801 destination.remote
802 ),
803 )));
804}
805
806#[cfg(test)]
808#[path = "auto_push_reconcile_tests.rs"]
809mod tests;