1use super::auto_push_dirty_paths::{diff_paths, overlapping_paths, status_paths};
23use super::child_environment::environment_value;
25use super::diagnostic_log::debug;
27use super::diagnostics::{EngineFailureCode, render_engine_failure_with_hint};
29use super::forwarded_command::ResolvedCommand;
31use super::merge_isolation_message::{clean_message, default_merge_message, describe_merge_source};
33use super::merge_isolation_journal::{MergeIntent, remove_merge_intent, write_merge_intent};
35use super::merge_isolation_recovery::{
37 MergeRecoveryAction, recover_interrupted_merges, settle_failed_merge,
38};
39use super::merge_isolation_report::{conflict_finding, not_applied_finding, overlap_finding};
41use super::merge_options::{FastForwardChoice, MergeShape, ParsedMerge, Signing, parse_merge_shape};
43use super::merge_isolation_recovery::RecoveryPlace;
45use super::transaction_phase::{Phase, reach_transaction_phase};
47use super::transaction_git::{GitContext, GitOutput, GitRequest, run_git};
49use super::transaction_owner::current_transaction_owner;
51use super::iso_time::now_iso;
53use super::random_id::random_uuid;
54use super::worktree_identity::WorktreeIdentity;
56use std::ffi::OsString;
58use std::path::{Path, PathBuf};
60
61const TAG: &str = "isolatedMerge";
63
64const IN_PROGRESS_MARKERS: [&str; 6] = [
67 "MERGE_HEAD",
68 "CHERRY_PICK_HEAD",
69 "REVERT_HEAD",
70 "rebase-merge",
71 "rebase-apply",
72 "sequencer",
73];
74
75pub struct IsolatedMergeInput<'a> {
82 pub real_git: &'a Path,
84 pub overlay: &'a [(OsString, OsString)],
86 pub identity: &'a WorktreeIdentity,
88 pub command: &'a ResolvedCommand,
90 pub environment: &'a [(OsString, OsString)],
92}
93
94#[derive(Clone, Debug, Eq, PartialEq)]
101pub enum IsolatedMerge {
102 Forward {
104 notice: Option<String>,
106 },
107 Finished {
109 code: i32,
111 stdout: String,
113 stderr: String,
115 },
116}
117
118enum Bail {
120 Forward(Option<String>),
122 Done(IsolatedMerge),
124}
125
126impl From<String> for Bail {
128 fn from(reason: String) -> Self {
129 debug(TAG, format!("forwarding to native Git: {reason}").as_str());
130 return Bail::Forward(None);
131 }
132}
133
134#[derive(Debug, Default)]
136struct MergeConfig {
137 fast_forward: Option<bool>,
139 fast_forward_only: bool,
141 stat: bool,
143 sign_commits: bool,
145 deferred: bool,
147}
148
149pub fn parse_maybe_bool(text: &str) -> Option<bool> {
159 match text.to_ascii_lowercase().as_str() {
160 "true" | "yes" | "on" | "1" => return Some(true),
161 "false" | "no" | "off" | "0" | "" => return Some(false),
162 _ => return None,
163 }
164}
165
166pub fn editor_would_open(auto_edit: Option<&str>, same_terminal: bool) -> Option<bool> {
178 match auto_edit {
179 Some(text) => return parse_maybe_bool(text),
180 None => return Some(same_terminal),
181 }
182}
183
184fn stdin_and_stdout_are_one_terminal() -> bool {
194 use std::io::IsTerminal;
195 if !std::io::stdin().is_terminal() || !std::io::stdout().is_terminal() {
196 return false;
197 }
198 #[cfg(unix)]
199 {
200 use std::os::fd::AsFd;
201 use std::os::unix::fs::MetadataExt;
202 let identity = |descriptor: std::os::fd::BorrowedFd<'_>| -> Option<(u64, u64)> {
203 let file = std::fs::File::from(descriptor.try_clone_to_owned().ok()?);
204 let metadata = file.metadata().ok()?;
205 return Some((metadata.dev(), metadata.ino()));
206 };
207 return identity(std::io::stdin().as_fd()) == identity(std::io::stdout().as_fd());
208 }
209 #[cfg(not(unix))]
211 {
212 return true;
213 }
214}
215
216struct Session {
218 context: GitContext,
220}
221
222impl Session {
224 fn run(&self, arguments: &[&str]) -> Result<GitOutput, String> {
226 return run_git(&self.context, &GitRequest::new(Path::new("."), arguments))
227 .map_err(|error| { return format!("git {} could not start: {error}", arguments.join(" ")); });
228 }
229
230 fn run_with_input(&self, arguments: &[&str], input: Vec<u8>) -> Result<GitOutput, String> {
232 let mut request: GitRequest = GitRequest::new(Path::new("."), arguments);
233 request.input = Some(input);
234 return run_git(&self.context, &request)
235 .map_err(|error| { return format!("git {} could not start: {error}", arguments.join(" ")); });
236 }
237
238 fn text(&self, arguments: &[&str]) -> Result<String, String> {
240 let output: GitOutput = self.run(arguments)?;
241 if !output.succeeded() {
242 return Err(format!("git {} failed: {}", arguments.join(" "), output.error_text()));
243 }
244 return Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned());
245 }
246}
247
248fn read_config(session: &Session) -> Result<MergeConfig, String> {
259 let output: GitOutput = session.run(&[
260 "config",
261 "--null",
262 "--get-regexp",
263 "^(merge\\.(ff|stat|verifysignatures|autostash)|branch\\..*\\.mergeoptions|commit\\.gpgsign)$",
264 ])?;
265 if output.code != Some(0) && output.code != Some(1) {
266 return Err(format!("git config failed: {}", output.error_text()));
267 }
268 let mut config: MergeConfig = MergeConfig { stat: true, ..MergeConfig::default() };
269 for entry in output.stdout.split(|byte| { return *byte == 0; }).filter(|entry| { return !entry.is_empty(); }) {
270 let text: String = String::from_utf8_lossy(entry).into_owned();
271 let (key, value) = text.split_once('\n').unwrap_or((text.as_str(), "true"));
272 match key {
273 "merge.ff" if value.eq_ignore_ascii_case("only") => config.fast_forward_only = true,
274 "merge.ff" => config.fast_forward = parse_maybe_bool(value),
275 "merge.stat" => config.stat = parse_maybe_bool(value).unwrap_or(true),
276 "commit.gpgsign" => config.sign_commits = parse_maybe_bool(value).unwrap_or(false),
277 "merge.verifysignatures" | "merge.autostash" => {
278 config.deferred |= parse_maybe_bool(value).unwrap_or(true);
279 }
280 other if other.starts_with("branch.") => config.deferred = true,
281 _ => {}
282 }
283 }
284 return Ok(config);
285}
286
287fn lines_of(output: &GitOutput) -> Vec<String> {
297 return String::from_utf8_lossy(&output.stdout)
298 .lines()
299 .map(str::trim)
300 .filter(|line| { return !line.is_empty(); })
301 .map(String::from)
302 .collect();
303}
304
305fn unsaved_paths(session: &Session) -> Result<Vec<Vec<u8>>, Bail> {
315 let status: GitOutput = session.run(&[
316 "--no-optional-locks",
317 "status",
318 "--porcelain=v1",
319 "-z",
320 "--untracked-files=no",
321 ])?;
322 if !status.succeeded() {
323 return Err(Bail::from(format!("git status failed: {}", status.error_text())));
324 }
325 let mut entries = status.stdout.split(|byte| { return *byte == 0; });
326 while let Some(entry) = entries.next() {
327 if entry.len() >= 2 && (entry[0] == b'U' || entry[1] == b'U' || entry[..2] == *b"AA" || entry[..2] == *b"DD") {
328 return Err(Bail::Forward(None));
329 }
330 if entry.len() >= 2 && (matches!(entry[0], b'R' | b'C') || matches!(entry[1], b'R' | b'C')) {
332 entries.next();
333 }
334 }
335 return Ok(status_paths(status.stdout.as_slice()));
336}
337
338pub fn run_isolated_merge(input: &IsolatedMergeInput<'_>) -> IsolatedMerge {
349 match attempt(input) {
350 Ok(done) | Err(Bail::Done(done)) => return done,
351 Err(Bail::Forward(notice)) => return IsolatedMerge::Forward { notice },
352 }
353}
354
355fn refused(code: i32, stderr: String) -> Bail {
357 return Bail::Done(IsolatedMerge::Finished { code, stdout: String::new(), stderr });
358}
359
360fn attempt(input: &IsolatedMergeInput<'_>) -> Result<IsolatedMerge, Bail> {
370 if !input.command.is_builtin() || input.command.word() != b"merge" {
371 return Err(Bail::Forward(None));
372 }
373 let (git_dir, root, common_dir): (&Path, &Path, &Path) = match input.identity {
374 WorktreeIdentity::MainWorktree { git_dir, worktree_root, common_dir, .. }
375 | WorktreeIdentity::LinkedWorktree { git_dir, worktree_root, common_dir, .. } => {
376 (git_dir.as_path(), worktree_root.as_path(), common_dir.as_path())
377 }
378 _ => return Err(Bail::Forward(None)),
379 };
380 recover_dead_merges(input, git_dir, root)?;
381 let shape: MergeShape = match parse_merge_shape(input.command.region()) {
382 ParsedMerge::Supported(found) => found,
383 ParsedMerge::Unsupported(reason) => {
384 debug(TAG, format!("native Git runs this merge: {reason}").as_str());
385 return Err(Bail::Forward(None));
386 }
387 };
388 if IN_PROGRESS_MARKERS.iter().any(|name| { return git_dir.join(name).exists(); }) {
389 return Err(Bail::Forward(None));
390 }
391 let session: Session = Session {
392 context: GitContext {
393 real_git: input.real_git.to_path_buf(),
394 overlay: input.overlay.to_vec(),
395 global_prefix: input.command.arguments[..input.command.subcommand_index].to_vec(),
396 common_dir: Some(common_dir.to_path_buf()),
398 },
399 };
400 let Some(reference) = shape.reference.to_str().map(String::from) else {
401 return Err(Bail::Forward(None));
402 };
403 let staged: GitOutput = session.run(&["diff-index", "--cached", "--quiet", "HEAD", "--"])?;
405 if staged.code != Some(1) {
406 return Err(Bail::Forward(None));
407 }
408 let auto_edit: Option<String> = environment_value(input.environment, "GIT_MERGE_AUTOEDIT")
409 .map(|value| { return value.to_string_lossy().into_owned(); });
410 let opens_editor: bool = !shape.no_edit
411 && editor_would_open(auto_edit.as_deref(), stdin_and_stdout_are_one_terminal()).unwrap_or(true);
412 if opens_editor {
413 return Err(Bail::Forward(Some(String::from(
414 "cli-git: this merge would open an editor, so the wrapper does not complete it in isolation while the \
415 index holds staged changes; native Git refuses it. Run `git merge --no-edit` (or set \
416 GIT_MERGE_AUTOEDIT=no) and the wrapper completes the merge without touching the staged changes.\n",
417 ))));
418 }
419 let config: MergeConfig = read_config(&session)?;
420 if config.deferred {
421 return Err(Bail::Forward(None));
422 }
423 let both = session.run(&["rev-parse", "HEAD^{commit}", format!("{reference}^{{commit}}").as_str()])?;
424 let ids: Vec<String> = lines_of(&both);
425 if both.code != Some(0) || ids.len() != 2 {
426 return Err(Bail::Forward(None));
427 }
428 let (head, target): (&str, &str) = (ids[0].as_str(), ids[1].as_str());
429 let bases: Vec<String> = lines_of(&session.run(&["merge-base", "--all", head, target])?);
430 let fast_forwardable: bool = bases.len() == 1 && bases[0] == head;
431 if bases.len() == 1 && bases[0] == target {
432 return Err(Bail::Forward(None));
433 }
434 if bases.is_empty() && !shape.allow_unrelated_histories {
435 return Err(Bail::Forward(None));
436 }
437 let merge_commit_wanted: bool = match shape.fast_forward {
438 FastForwardChoice::Never => true,
439 FastForwardChoice::Allowed => false,
440 FastForwardChoice::Default => config.fast_forward == Some(false),
441 };
442 if config.fast_forward_only && shape.fast_forward == FastForwardChoice::Default {
443 return Err(Bail::Forward(None));
444 }
445 if fast_forwardable && !merge_commit_wanted {
446 return Err(Bail::Forward(None));
447 }
448 let description: Option<String> = if shape.message.is_some() {
449 None
450 } else {
451 let full_name: String = session.text(&["rev-parse", "--symbolic-full-name", reference.as_str()])?;
452 Some(describe_merge_source(reference.as_str(), full_name.as_str(), target).ok_or(Bail::Forward(None))?)
453 };
454 let tree: String = merge_tree(&session, &shape, head, target, reference.as_str())?;
455 let incoming: Vec<Vec<u8>> = diff_paths(
456 session
457 .run(&["diff-tree", "-r", "--name-only", "-z", "--no-renames", head, tree.as_str()])?
458 .stdout
459 .as_slice(),
460 );
461 let overlap: Vec<Vec<u8>> = overlapping_paths(unsaved_paths(&session)?.as_slice(), incoming.as_slice());
462 if !overlap.is_empty() {
463 return Err(refused(2, overlap_finding(reference.as_str(), overlap.as_slice())));
464 }
465 let message: Vec<u8> = match (&shape.message, description) {
466 (Some(given), _) => clean_message(&session.context, given.as_slice())?,
467 (None, Some(line)) => {
468 let subject: Vec<u8> = default_merge_message(&session.context, line.as_str(), shape.log)?;
469 clean_message(&session.context, subject.as_slice())?
470 }
471 (None, None) => return Err(Bail::Forward(None)),
472 };
473 let new_oid: String = match commit_merge(&session, &shape, &config, (head, target, tree.as_str()), message) {
474 Ok(created) => created,
475 Err(reason) => {
476 return Err(refused(
477 128,
478 format!("cli-git: git merge {reference}: the merge commit could not be created, and nothing was changed: {reason}\n"),
479 ));
480 }
481 };
482 let plan: Plan<'_> = Plan {
483 shape: &shape,
484 config: &config,
485 git_dir,
486 root,
487 old_oid: head,
488 new_oid: new_oid.as_str(),
489 };
490 return apply(input, &session, &plan);
491}
492
493fn recover_dead_merges(input: &IsolatedMergeInput<'_>, git_dir: &Path, root: &Path) -> Result<(), Bail> {
504 let common_dir: PathBuf = match input.identity {
505 WorktreeIdentity::MainWorktree { common_dir, .. } | WorktreeIdentity::LinkedWorktree { common_dir, .. } => {
506 common_dir.clone()
507 }
508 _ => return Ok(()),
509 };
510 let place: RecoveryPlace = RecoveryPlace {
511 git: GitContext {
512 real_git: input.real_git.to_path_buf(),
513 overlay: input.overlay.to_vec(),
514 global_prefix: Vec::new(),
515 common_dir: Some(common_dir.clone()),
516 },
517 cwd: root.to_path_buf(),
518 common_dir,
519 };
520 match recover_interrupted_merges(&place, git_dir) {
521 Ok(_) => return Ok(()),
522 Err(error) => {
523 return Err(refused(
524 2,
525 render_engine_failure_with_hint(0, EngineFailureCode::ContentUnavailable, error.0.as_str(), None),
526 ));
527 }
528 }
529}
530
531fn merge_tree(session: &Session, shape: &MergeShape, head: &str, target: &str, reference: &str) -> Result<String, Bail> {
542 let mut arguments: Vec<&str> = vec!["merge-tree", "--write-tree", "--name-only", "--no-messages"];
543 if shape.allow_unrelated_histories {
544 arguments.push("--allow-unrelated-histories");
545 }
546 arguments.push(head);
547 arguments.push(target);
548 let merged: GitOutput = session.run(arguments.as_slice())?;
549 let lines: Vec<String> = String::from_utf8_lossy(&merged.stdout).lines().map(String::from).collect();
550 match merged.code {
551 Some(0) => return lines.first().cloned().ok_or_else(|| { return Bail::from(String::from("merge-tree printed no tree")); }),
552 Some(1) => {
553 let paths: Vec<String> = lines.iter().skip(1).take_while(|line| { return !line.is_empty(); }).cloned().collect();
554 return Err(refused(2, conflict_finding(reference, paths.as_slice())));
555 }
556 _ => return Err(Bail::from(format!("git merge-tree failed: {}", merged.error_text()))),
557 }
558}
559
560fn commit_merge(
571 session: &Session,
572 shape: &MergeShape,
573 config: &MergeConfig,
574 ends: (&str, &str, &str),
575 message: Vec<u8>,
576) -> Result<String, String> {
577 let (head, target, tree): (&str, &str, &str) = ends;
578 let signing: Option<String> = match &shape.signing {
579 Signing::Never => None,
580 Signing::Sign(None) => Some(String::from("-S")),
581 Signing::Sign(Some(key)) => Some(format!("-S{}", String::from_utf8_lossy(key))),
582 Signing::Default => if config.sign_commits { Some(String::from("-S")) } else { None },
583 };
584 let mut arguments: Vec<&str> = vec!["commit-tree", tree, "-p", head, "-p", target];
585 if let Some(flag) = signing.as_deref() {
586 arguments.push(flag);
587 }
588 let created: GitOutput = session.run_with_input(arguments.as_slice(), message)?;
589 if !created.succeeded() {
590 return Err(created.error_text());
591 }
592 return Ok(String::from_utf8_lossy(&created.stdout).trim().to_owned());
593}
594
595fn apply(input: &IsolatedMergeInput<'_>, session: &Session, plan: &Plan<'_>) -> Result<IsolatedMerge, Bail> {
606 let (git_dir, root): (&Path, &Path) = (plan.git_dir, plan.root);
607 let (old_oid, new_oid): (&str, &str) = (plan.old_oid, plan.new_oid);
608 let shape: &MergeShape = plan.shape;
609 let reference: String = shape.reference.to_string_lossy().into_owned();
610 let target_ref: Option<String> = {
611 let named: GitOutput = session.run(&["symbolic-ref", "--quiet", "HEAD"])?;
612 if named.succeeded() { Some(String::from_utf8_lossy(&named.stdout).trim().to_owned()) } else { None }
613 };
614 let transaction_id: String = random_uuid().map_err(|error| { return Bail::from(error.0); })?;
615 let owner = current_transaction_owner(transaction_id.as_str(), now_iso().as_str())
616 .map_err(|error| { return Bail::from(error.to_string()); })?;
617 let intent: MergeIntent = MergeIntent {
618 owner,
619 repository_root: root.to_string_lossy().into_owned(),
620 target_ref,
621 old_oid: String::from(old_oid),
622 new_oid: String::from(new_oid),
623 };
624 let record: PathBuf = write_merge_intent(git_dir, &intent)
625 .map_err(|error| { return Bail::from(format!("the merge record could not be written: {error}")); })?;
626 reach_phase(Phase::MergeIntent)?;
627 let mut overlay: Vec<(OsString, OsString)> = input.overlay.to_vec();
628 overlay.push((OsString::from("GIT_REFLOG_ACTION"), OsString::from(format!("merge {reference}"))));
629 let mover: Session = Session {
630 context: GitContext { overlay, ..session.context.clone() },
631 };
632 let moved: GitOutput = mover.run(&["merge", "--ff-only", "--quiet", "--no-edit", new_oid]).unwrap_or_else(|reason| {
634 return GitOutput { code: None, stdout: Vec::new(), stderr: reason.into_bytes() };
635 });
636 let place: RecoveryPlace = RecoveryPlace {
637 git: GitContext { global_prefix: Vec::new(), ..session.context.clone() },
638 cwd: root.to_path_buf(),
639 common_dir: git_dir.to_path_buf(),
640 };
641 if !moved.succeeded() {
642 let detail: String = moved.error_text();
643 return Err(match settle_failed_merge(&place, record.as_path(), &intent) {
644 Ok(MergeRecoveryAction::Completed) => Bail::Done(IsolatedMerge::Finished {
645 code: 0,
646 stdout: String::new(),
647 stderr: format!(
648 "cli-git: git merge {reference}: Git reported an error ({detail}) after it had started, \
649 and the wrapper completed the merge.\n"
650 ),
651 }),
652 Ok(_) => refused(
653 moved.code.unwrap_or(2),
654 format!("{}{}", String::from_utf8_lossy(&moved.stderr), not_applied_finding(reference.as_str(), detail.as_str())),
655 ),
656 Err(error) => refused(2, render_engine_failure_with_hint(0, EngineFailureCode::ContentUnavailable, error.0.as_str(), None)),
657 });
658 }
659 reach_phase(Phase::MergeApplied)?;
660 if let Err(error) = remove_merge_intent(record.as_path()) {
661 debug(TAG, format!("the merge record could not be removed: {error}").as_str());
662 }
663 let mut stdout: String = String::from_utf8_lossy(&moved.stdout).into_owned();
664 if !shape.quiet {
665 stdout.push_str("Merge made by the 'ort' strategy.\n");
666 if shape.stat.unwrap_or(plan.config.stat)
667 && let Ok(stat) = session.run(&["diff", "--stat", "--summary", old_oid, new_oid])
668 {
669 stdout.push_str(String::from_utf8_lossy(&stat.stdout).as_ref());
670 }
671 }
672 return Ok(IsolatedMerge::Finished {
673 code: 0,
674 stdout,
675 stderr: String::from_utf8_lossy(&moved.stderr).into_owned(),
676 });
677}
678
679struct Plan<'a> {
681 shape: &'a MergeShape,
683 config: &'a MergeConfig,
685 git_dir: &'a Path,
687 root: &'a Path,
689 old_oid: &'a str,
691 new_oid: &'a str,
693}
694
695fn reach_phase(phase: Phase) -> Result<(), Bail> {
705 return reach_transaction_phase(phase, None).map_err(|error| {
706 return refused(2, format!("cli-git: {error}\n"));
707 });
708}
709
710#[cfg(test)]
712#[path = "merge_isolation_tests.rs"]
713mod tests;
714
715#[cfg(test)]
717#[path = "merge_isolation_repository_tests.rs"]
718mod repository_tests;