use super::auto_push_dirty_paths::{diff_paths, overlapping_paths, status_paths};
use super::child_environment::environment_value;
use super::diagnostic_log::debug;
use super::diagnostics::{EngineFailureCode, render_engine_failure_with_hint};
use super::forwarded_command::ResolvedCommand;
use super::merge_isolation_message::{clean_message, default_merge_message, describe_merge_source};
use super::merge_isolation_journal::{MergeIntent, remove_merge_intent, write_merge_intent};
use super::merge_isolation_recovery::{
MergeRecoveryAction, recover_interrupted_merges, settle_failed_merge,
};
use super::merge_isolation_report::{conflict_finding, not_applied_finding, overlap_finding};
use super::merge_options::{FastForwardChoice, MergeShape, ParsedMerge, Signing, parse_merge_shape};
use super::recovery_landing::RecoveryPlace;
use super::transaction_phase::{Phase, reach_transaction_phase};
use super::transaction_git::{GitContext, GitOutput, GitRequest, run_git};
use super::transaction_owner::current_transaction_owner;
use super::iso_time::now_iso;
use super::random_id::random_uuid;
use super::worktree_identity::WorktreeIdentity;
use std::ffi::OsString;
use std::path::{Path, PathBuf};
const TAG: &str = "isolatedMerge";
const IN_PROGRESS_MARKERS: [&str; 6] = [
"MERGE_HEAD",
"CHERRY_PICK_HEAD",
"REVERT_HEAD",
"rebase-merge",
"rebase-apply",
"sequencer",
];
pub struct IsolatedMergeInput<'a> {
pub real_git: &'a Path,
pub overlay: &'a [(OsString, OsString)],
pub identity: &'a WorktreeIdentity,
pub command: &'a ResolvedCommand,
pub environment: &'a [(OsString, OsString)],
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum IsolatedMerge {
Forward {
notice: Option<String>,
},
Finished {
code: i32,
stdout: String,
stderr: String,
},
}
enum Bail {
Forward(Option<String>),
Done(IsolatedMerge),
}
impl From<String> for Bail {
fn from(reason: String) -> Self {
debug(TAG, format!("forwarding to native Git: {reason}").as_str());
return Bail::Forward(None);
}
}
#[derive(Debug, Default)]
struct MergeConfig {
fast_forward: Option<bool>,
fast_forward_only: bool,
stat: bool,
sign_commits: bool,
deferred: bool,
}
pub fn parse_maybe_bool(text: &str) -> Option<bool> {
match text.to_ascii_lowercase().as_str() {
"true" | "yes" | "on" | "1" => return Some(true),
"false" | "no" | "off" | "0" | "" => return Some(false),
_ => return None,
}
}
pub fn editor_would_open(auto_edit: Option<&str>, same_terminal: bool) -> Option<bool> {
match auto_edit {
Some(text) => return parse_maybe_bool(text),
None => return Some(same_terminal),
}
}
fn stdin_and_stdout_are_one_terminal() -> bool {
use std::io::IsTerminal;
if !std::io::stdin().is_terminal() || !std::io::stdout().is_terminal() {
return false;
}
#[cfg(unix)]
{
use std::os::fd::AsFd;
use std::os::unix::fs::MetadataExt;
let identity = |descriptor: std::os::fd::BorrowedFd<'_>| -> Option<(u64, u64)> {
let file = std::fs::File::from(descriptor.try_clone_to_owned().ok()?);
let metadata = file.metadata().ok()?;
return Some((metadata.dev(), metadata.ino()));
};
return identity(std::io::stdin().as_fd()) == identity(std::io::stdout().as_fd());
}
#[cfg(not(unix))]
{
return true;
}
}
struct Session {
context: GitContext,
}
impl Session {
fn run(&self, arguments: &[&str]) -> Result<GitOutput, String> {
return run_git(&self.context, &GitRequest::new(Path::new("."), arguments))
.map_err(|error| { return format!("git {} could not start: {error}", arguments.join(" ")); });
}
fn run_with_input(&self, arguments: &[&str], input: Vec<u8>) -> Result<GitOutput, String> {
let mut request: GitRequest = GitRequest::new(Path::new("."), arguments);
request.input = Some(input);
return run_git(&self.context, &request)
.map_err(|error| { return format!("git {} could not start: {error}", arguments.join(" ")); });
}
fn text(&self, arguments: &[&str]) -> Result<String, String> {
let output: GitOutput = self.run(arguments)?;
if !output.succeeded() {
return Err(format!("git {} failed: {}", arguments.join(" "), output.error_text()));
}
return Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned());
}
}
fn read_config(session: &Session) -> Result<MergeConfig, String> {
let output: GitOutput = session.run(&[
"config",
"--null",
"--get-regexp",
"^(merge\\.(ff|stat|verifysignatures|autostash)|branch\\..*\\.mergeoptions|commit\\.gpgsign)$",
])?;
if output.code != Some(0) && output.code != Some(1) {
return Err(format!("git config failed: {}", output.error_text()));
}
let mut config: MergeConfig = MergeConfig { stat: true, ..MergeConfig::default() };
for entry in output.stdout.split(|byte| { return *byte == 0; }).filter(|entry| { return !entry.is_empty(); }) {
let text: String = String::from_utf8_lossy(entry).into_owned();
let (key, value) = text.split_once('\n').unwrap_or((text.as_str(), "true"));
match key {
"merge.ff" if value.eq_ignore_ascii_case("only") => config.fast_forward_only = true,
"merge.ff" => config.fast_forward = parse_maybe_bool(value),
"merge.stat" => config.stat = parse_maybe_bool(value).unwrap_or(true),
"commit.gpgsign" => config.sign_commits = parse_maybe_bool(value).unwrap_or(false),
"merge.verifysignatures" | "merge.autostash" => {
config.deferred |= parse_maybe_bool(value).unwrap_or(true);
}
other if other.starts_with("branch.") => config.deferred = true,
_ => {}
}
}
return Ok(config);
}
fn lines_of(output: &GitOutput) -> Vec<String> {
return String::from_utf8_lossy(&output.stdout)
.lines()
.map(str::trim)
.filter(|line| { return !line.is_empty(); })
.map(String::from)
.collect();
}
fn unsaved_paths(session: &Session) -> Result<Vec<Vec<u8>>, Bail> {
let status: GitOutput = session.run(&[
"--no-optional-locks",
"status",
"--porcelain=v1",
"-z",
"--untracked-files=no",
])?;
if !status.succeeded() {
return Err(Bail::from(format!("git status failed: {}", status.error_text())));
}
let mut entries = status.stdout.split(|byte| { return *byte == 0; });
while let Some(entry) = entries.next() {
if entry.len() >= 2 && (entry[0] == b'U' || entry[1] == b'U' || entry[..2] == *b"AA" || entry[..2] == *b"DD") {
return Err(Bail::Forward(None));
}
if entry.len() >= 2 && (matches!(entry[0], b'R' | b'C') || matches!(entry[1], b'R' | b'C')) {
entries.next();
}
}
return Ok(status_paths(status.stdout.as_slice()));
}
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 },
}
}
fn refused(code: i32, stderr: String) -> Bail {
return Bail::Done(IsolatedMerge::Finished { code, stdout: String::new(), stderr });
}
fn attempt(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_under_lock(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));
}
};
if IN_PROGRESS_MARKERS.iter().any(|name| { return 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", "--"])?;
if staged.code != Some(1) {
return Err(Bail::Forward(None));
}
let auto_edit: Option<String> = environment_value(input.environment, "GIT_MERGE_AUTOEDIT")
.map(|value| { return value.to_string_lossy().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 {
return Err(Bail::Forward(Some(String::from(
"cli-git: 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 both = session.run(&["rev-parse", "HEAD^{commit}", format!("{reference}^{{commit}}").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 {
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: String = merge_tree(&session, &shape, head, target, reference.as_str())?;
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() {
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!("cli-git: 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(),
};
return apply(input, &session, &plan);
}
fn recover_under_lock(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),
));
}
}
}
fn merge_tree(session: &Session, shape: &MergeShape, head: &str, target: &str, reference: &str) -> Result<String, Bail> {
let mut arguments: Vec<&str> = vec!["merge-tree", "--write-tree", "--name-only", "--no-messages"];
if shape.allow_unrelated_histories {
arguments.push("--allow-unrelated-histories");
}
arguments.push(head);
arguments.push(target);
let merged: GitOutput = session.run(arguments.as_slice())?;
let lines: Vec<String> = String::from_utf8_lossy(&merged.stdout).lines().map(String::from).collect();
match merged.code {
Some(0) => return lines.first().cloned().ok_or_else(|| { return Bail::from(String::from("merge-tree printed no tree")); }),
Some(1) => {
let paths: Vec<String> = lines.iter().skip(1).take_while(|line| { return !line.is_empty(); }).cloned().collect();
return Err(refused(2, conflict_finding(reference, paths.as_slice())));
}
_ => return Err(Bail::from(format!("git merge-tree failed: {}", merged.error_text()))),
}
}
fn commit_merge(
session: &Session,
shape: &MergeShape,
config: &MergeConfig,
ends: (&str, &str, &str),
message: Vec<u8>,
) -> Result<String, String> {
let (head, target, tree): (&str, &str, &str) = ends;
let signing: Option<String> = match &shape.signing {
Signing::Never => None,
Signing::Sign(None) => Some(String::from("-S")),
Signing::Sign(Some(key)) => Some(format!("-S{}", String::from_utf8_lossy(key))),
Signing::Default => if config.sign_commits { Some(String::from("-S")) } else { None },
};
let mut arguments: Vec<&str> = vec!["commit-tree", tree, "-p", head, "-p", target];
if let Some(flag) = signing.as_deref() {
arguments.push(flag);
}
let created: GitOutput = session.run_with_input(arguments.as_slice(), message)?;
if !created.succeeded() {
return Err(created.error_text());
}
return Ok(String::from_utf8_lossy(&created.stdout).trim().to_owned());
}
fn apply(input: &IsolatedMergeInput<'_>, session: &Session, plan: &Plan<'_>) -> Result<IsolatedMerge, Bail> {
let (git_dir, root): (&Path, &Path) = (plan.git_dir, plan.root);
let (old_oid, new_oid): (&str, &str) = (plan.old_oid, plan.new_oid);
let shape: &MergeShape = plan.shape;
let reference: String = shape.reference.to_string_lossy().into_owned();
let target_ref: Option<String> = {
let named: GitOutput = session.run(&["symbolic-ref", "--quiet", "HEAD"])?;
if named.succeeded() { Some(String::from_utf8_lossy(&named.stdout).trim().to_owned()) } else { None }
};
let transaction_id: String = random_uuid().map_err(|error| { return Bail::from(error.0); })?;
let owner = current_transaction_owner(transaction_id.as_str(), now_iso().as_str())
.map_err(|error| { return Bail::from(error.to_string()); })?;
let intent: MergeIntent = MergeIntent {
owner,
repository_root: root.to_string_lossy().into_owned(),
target_ref,
old_oid: String::from(old_oid),
new_oid: String::from(new_oid),
};
let record: PathBuf = write_merge_intent(git_dir, &intent)
.map_err(|error| { return Bail::from(format!("the merge record could not be written: {error}")); })?;
reach_phase(Phase::MergeIntent)?;
let mut overlay: Vec<(OsString, OsString)> = input.overlay.to_vec();
overlay.push((OsString::from("GIT_REFLOG_ACTION"), OsString::from(format!("merge {reference}"))));
let mover: Session = Session {
context: GitContext { overlay, ..session.context.clone() },
};
let moved: GitOutput = mover.run(&["merge", "--ff-only", "--quiet", "--no-edit", new_oid]).unwrap_or_else(|reason| {
return GitOutput { code: None, stdout: Vec::new(), stderr: reason.into_bytes() };
});
let place: RecoveryPlace = RecoveryPlace {
git: GitContext { global_prefix: Vec::new(), ..session.context.clone() },
cwd: root.to_path_buf(),
common_dir: git_dir.to_path_buf(),
};
if !moved.succeeded() {
let detail: String = moved.error_text();
return Err(match settle_failed_merge(&place, record.as_path(), &intent) {
Ok(MergeRecoveryAction::Completed) => Bail::Done(IsolatedMerge::Finished {
code: 0,
stdout: String::new(),
stderr: format!(
"cli-git: git merge {reference}: Git reported an error ({detail}) after it had started, \
and the wrapper completed the merge.\n"
),
}),
Ok(_) => refused(
moved.code.unwrap_or(2),
format!("{}{}", String::from_utf8_lossy(&moved.stderr), not_applied_finding(reference.as_str(), detail.as_str())),
),
Err(error) => refused(2, render_engine_failure_with_hint(0, EngineFailureCode::ContentUnavailable, error.0.as_str(), None)),
});
}
reach_phase(Phase::MergeApplied)?;
if let Err(error) = remove_merge_intent(record.as_path()) {
debug(TAG, format!("the merge record could not be removed: {error}").as_str());
}
let mut stdout: String = String::from_utf8_lossy(&moved.stdout).into_owned();
if !shape.quiet {
stdout.push_str("Merge made by the 'ort' strategy.\n");
if shape.stat.unwrap_or(plan.config.stat)
&& let Ok(stat) = session.run(&["diff", "--stat", "--summary", old_oid, new_oid])
{
stdout.push_str(String::from_utf8_lossy(&stat.stdout).as_ref());
}
}
return Ok(IsolatedMerge::Finished {
code: 0,
stdout,
stderr: String::from_utf8_lossy(&moved.stderr).into_owned(),
});
}
struct Plan<'a> {
shape: &'a MergeShape,
config: &'a MergeConfig,
git_dir: &'a Path,
root: &'a Path,
old_oid: &'a str,
new_oid: &'a str,
}
fn reach_phase(phase: Phase) -> Result<(), Bail> {
return reach_transaction_phase(phase, None).map_err(|error| {
return refused(2, format!("cli-git: {error}\n"));
});
}
#[cfg(test)]
#[path = "merge_isolation_tests.rs"]
mod tests;
#[cfg(test)]
#[path = "merge_isolation_repository_tests.rs"]
mod repository_tests;