use crate::auto_push_dirty_paths::{diff_paths, overlapping_paths};
use crate::child_environment::environment_value;
use crate::diagnostic_log::debug;
use crate::diagnostics::{EngineFailureCode, render_engine_failure_with_hint};
use crate::merge_isolation_message::{clean_message, default_merge_message, describe_merge_source};
use crate::merge_second_pass::Merged;
use crate::merge_isolation_recovery::recover_interrupted_merges;
use crate::merge_isolation_report::overlap_finding;
use crate::merge_options::{FastForwardChoice, MergeShape, ParsedMerge, parse_merge_shape};
use crate::merge_isolation_recovery::RecoveryPlace;
use crate::transaction_git::{GitContext, GitOutput};
use crate::worktree_identity::WorktreeIdentity;
pub use crate::merge_isolation_run_types::{IsolatedMerge, IsolatedMergeInput};
use crate::merge_isolation_run_types::{Bail, MAX_ROUNDS, MergeConfig, Plan, TAG, IN_PROGRESS_MARKERS, refused};
use crate::merge_isolation_git_reads::{Session, editor_would_open, stdin_and_stdout_are_one_terminal, lines_of, read_config, read_fetch_head, unsaved_paths};
use crate::merge_isolation_apply::{apply, commit_merge, merge_tree, with_second_pass_report};
use std::io::Write;
use std::path::{Path, PathBuf};
pub fn run_isolated_merge(input: &IsolatedMergeInput<'_>) -> IsolatedMerge {
match attempt(input) {
Ok(done) | Err(Bail::Done(done)) => return done,
Err(Bail::Forward(notice)) => return IsolatedMerge::Forward { notice },
Err(Bail::Again(reason)) => {
debug(TAG, format!("a round escaped the attempt ({reason}); native Git runs the merge").as_str());
return IsolatedMerge::Forward { notice: None };
}
}
}
fn attempt(input: &IsolatedMergeInput<'_>) -> Result<IsolatedMerge, Bail> {
for round in 1..=MAX_ROUNDS {
match attempt_round(input) {
Err(Bail::Again(reason)) => {
debug(TAG, format!("round {round} of {MAX_ROUNDS}: {reason}").as_str());
let _noted = std::io::stderr().write_all(format!("monochromatic-git-policy-cli: git merge: {reason}; merging again on the current branch.\n").as_bytes());
}
finished => return finished,
}
}
return Err(refused(
2,
format!(
"monochromatic-git-policy-cli: git merge: the branch moved or the index lock was taken in {MAX_ROUNDS} merges in a row, so the wrapper \
stopped; nothing was changed. Run the merge again.\n"
),
));
}
fn attempt_round(input: &IsolatedMergeInput<'_>) -> Result<IsolatedMerge, Bail> {
if !input.command.is_builtin() || input.command.word() != b"merge" {
return Err(Bail::Forward(None));
}
let (git_dir, root, common_dir): (&Path, &Path, &Path) = match input.identity {
WorktreeIdentity::MainWorktree { git_dir, worktree_root, common_dir, .. }
| WorktreeIdentity::LinkedWorktree { git_dir, worktree_root, common_dir, .. } => {
(git_dir.as_path(), worktree_root.as_path(), common_dir.as_path())
}
_ => return Err(Bail::Forward(None)),
};
recover_dead_merges(input, git_dir, root)?;
let shape: MergeShape = match parse_merge_shape(input.command.region()) {
ParsedMerge::Supported(found) => found,
ParsedMerge::Unsupported(reason) => {
debug(TAG, format!("native Git runs this merge: {reason}").as_str());
return Err(Bail::Forward(None));
}
};
for name in IN_PROGRESS_MARKERS {
if git_dir.join(name).exists() {
return Err(Bail::Forward(None));
}
}
let session: Session = Session {
context: GitContext {
real_git: input.real_git.to_path_buf(),
overlay: input.overlay.to_vec(),
global_prefix: input.command.arguments[..input.command.subcommand_index].to_vec(),
common_dir: Some(common_dir.to_path_buf()),
},
};
let Some(reference) = shape.reference.to_str().map(String::from) else {
return Err(Bail::Forward(None));
};
let staged: GitOutput = session.run(&["diff-index", "--cached", "--quiet", "HEAD", "--"])?;
let staged_work: bool = match staged.code {
Some(1) => true,
Some(0) => false,
_ => return Err(Bail::Forward(None)),
};
let auto_edit: Option<String> = environment_value(input.environment, "GIT_MERGE_AUTOEDIT")
.as_deref()
.map(std::ffi::OsStr::to_string_lossy)
.map(std::borrow::Cow::into_owned);
let opens_editor: bool = !shape.no_edit
&& editor_would_open(auto_edit.as_deref(), stdin_and_stdout_are_one_terminal()).unwrap_or(true);
if opens_editor && !staged_work {
return Err(Bail::Forward(None));
}
if opens_editor {
return Err(Bail::Forward(Some(String::from(
"monochromatic-git-policy-cli: this merge would open an editor, so the wrapper does not complete it in isolation while the \
index holds staged changes; native Git refuses it. Run `git merge --no-edit` (or set \
GIT_MERGE_AUTOEDIT=no) and the wrapper completes the merge without touching the staged changes.\n",
))));
}
let config: MergeConfig = read_config(&session)?;
if config.deferred {
return Err(Bail::Forward(None));
}
let fetched: Option<(String, String)> = if reference == "FETCH_HEAD" {
Some(read_fetch_head(git_dir)?)
} else {
None
};
let target_spec: String = match &fetched {
Some((oid, _)) => format!("{oid}^{{commit}}"),
None => format!("{reference}^{{commit}}"),
};
let both = session.run(&["rev-parse", "HEAD^{commit}", target_spec.as_str()])?;
let ids: Vec<String> = lines_of(&both);
if both.code != Some(0) || ids.len() != 2 {
return Err(Bail::Forward(None));
}
let (head, target): (&str, &str) = (ids[0].as_str(), ids[1].as_str());
let bases: Vec<String> = lines_of(&session.run(&["merge-base", "--all", head, target])?);
let fast_forwardable: bool = bases.len() == 1 && bases[0] == head;
if bases.len() == 1 && bases[0] == target {
return Err(Bail::Forward(None));
}
if bases.is_empty() && !shape.allow_unrelated_histories {
return Err(Bail::Forward(None));
}
let merge_commit_wanted: bool = match shape.fast_forward {
FastForwardChoice::Never => true,
FastForwardChoice::Allowed => false,
FastForwardChoice::Default => config.fast_forward == Some(false),
};
if config.fast_forward_only && shape.fast_forward == FastForwardChoice::Default {
return Err(Bail::Forward(None));
}
if fast_forwardable && !merge_commit_wanted {
return Err(Bail::Forward(None));
}
let description: Option<String> = if shape.message.is_some() {
None
} else if let Some((_, lines)) = fetched {
Some(lines)
} else {
let full_name: String = session.text(&["rev-parse", "--symbolic-full-name", reference.as_str()])?;
Some(describe_merge_source(reference.as_str(), full_name.as_str(), target).ok_or(Bail::Forward(None))?)
};
let (tree, second_pass_files): (String, Vec<Merged>) = merge_tree(
&session,
&shape,
(head, target, reference.as_str()),
(common_dir, root, input.environment),
staged_work,
)?;
let incoming: Vec<Vec<u8>> = diff_paths(
session
.run(&["diff-tree", "-r", "--name-only", "-z", "--no-renames", head, tree.as_str()])?
.stdout
.as_slice(),
);
let overlap: Vec<Vec<u8>> = overlapping_paths(unsaved_paths(&session)?.as_slice(), incoming.as_slice());
if !overlap.is_empty() && !staged_work {
return Err(Bail::Forward(None));
}
if !overlap.is_empty() {
return Err(refused(2, overlap_finding(reference.as_str(), overlap.as_slice())));
}
let message: Vec<u8> = match (&shape.message, description) {
(Some(given), _) => clean_message(&session.context, given.as_slice())?,
(None, Some(line)) => {
let subject: Vec<u8> = default_merge_message(&session.context, line.as_str(), shape.log)?;
clean_message(&session.context, subject.as_slice())?
}
(None, None) => return Err(Bail::Forward(None)),
};
let new_oid: String = match commit_merge(&session, &shape, &config, (head, target, tree.as_str()), message) {
Ok(created) => created,
Err(reason) => {
return Err(refused(
128,
format!("monochromatic-git-policy-cli: git merge {reference}: the merge commit could not be created, and nothing was changed: {reason}\n"),
));
}
};
let plan: Plan<'_> = Plan {
shape: &shape,
config: &config,
git_dir,
root,
old_oid: head,
new_oid: new_oid.as_str(),
};
let done: IsolatedMerge = apply(input, &session, &plan)?;
return Ok(with_second_pass_report(done, second_pass_files.as_slice()));
}
fn recover_dead_merges(input: &IsolatedMergeInput<'_>, git_dir: &Path, root: &Path) -> Result<(), Bail> {
let common_dir: PathBuf = match input.identity {
WorktreeIdentity::MainWorktree { common_dir, .. } | WorktreeIdentity::LinkedWorktree { common_dir, .. } => {
common_dir.clone()
}
_ => return Ok(()),
};
let place: RecoveryPlace = RecoveryPlace {
git: GitContext {
real_git: input.real_git.to_path_buf(),
overlay: input.overlay.to_vec(),
global_prefix: Vec::new(),
common_dir: Some(common_dir.clone()),
},
cwd: root.to_path_buf(),
common_dir,
};
match recover_interrupted_merges(&place, git_dir) {
Ok(_) => return Ok(()),
Err(error) => {
return Err(refused(
2,
render_engine_failure_with_hint(0, EngineFailureCode::ContentUnavailable, error.0.as_str(), None),
));
}
}
}
#[cfg(test)]
#[path = "merge_isolation_tests.rs"]
mod tests;
#[cfg(test)]
#[path = "merge_isolation_repository_tests.rs"]
mod repository_tests;