Skip to main content

git_policy_cli/
merge_isolation.rs

1//! What:
2//!  A true `git merge <branch>` completed in isolation when the index holds staged changes that make native Git
3//!  refuse it: the merge is computed from objects, committed, and applied with Git's own two-way tree update, which
4//!  keeps every entry and every file the merge does not change.
5//! Why:
6//!  Several sessions share one checkout.
7//!  Native Git refuses every merge that creates a commit while the index differs from `HEAD` at all (measured in
8//!  `doc/planning/cli-git-merge-with-staged-index.md`), although the merge may touch none of the staged paths.
9//!  A fast-forward is not refused for unrelated paths, because it is a two-way update that keeps entries it does not
10//!  write; a merge commit whose first parent is the current commit is exactly such an update.
11//!  So the merge tree comes from `git merge-tree --write-tree`, the commit from `git commit-tree`, and the checkout
12//!  moves with `git merge --ff-only <commit>` under the landing lock, after a check that no changed path is staged
13//!  or modified by someone else.
14//!  An intent record, written before Git starts, lets recovery finish or discard a merge whose process died.
15//!
16//! In TS you'd write (pseudocode):
17//! ```ts
18//! const outcome = await runIsolatedMerge({ command, identity, environment }); // 'forward' | { code, stdout, stderr }
19//! ```
20
21/// Names and sets of unsaved work.
22use super::auto_push_dirty_paths::{diff_paths, overlapping_paths, status_paths};
23/// Reading an environment value.
24use super::child_environment::environment_value;
25/// Diagnostics for the log.
26use super::diagnostic_log::debug;
27/// The failure rendering for state that cannot be read.
28use super::diagnostics::{EngineFailureCode, render_engine_failure_with_hint};
29/// The resolved command.
30use super::forwarded_command::ResolvedCommand;
31/// The merge messages.
32use super::merge_isolation_message::{clean_message, default_merge_message, describe_merge_source};
33/// The intent record.
34use super::merge_isolation_journal::{MergeIntent, remove_merge_intent, write_merge_intent};
35/// Recovery of records.
36use super::merge_isolation_recovery::{
37    MergeRecoveryAction, recover_interrupted_merges, settle_failed_merge,
38};
39/// The refusal findings.
40use super::merge_isolation_report::{conflict_finding, not_applied_finding, overlap_finding};
41/// The command-line reading.
42use super::merge_options::{FastForwardChoice, MergeShape, ParsedMerge, Signing, parse_merge_shape};
43/// The place recovery runs in.
44use super::recovery_landing::RecoveryPlace;
45/// Marker phases for the crash controls.
46use super::transaction_phase::{Phase, reach_transaction_phase};
47/// Real Git.
48use super::transaction_git::{GitContext, GitOutput, GitRequest, run_git};
49/// The owner of the intent record, and its identifier.
50use super::transaction_owner::current_transaction_owner;
51/// Unique identifiers and the clock.
52use super::iso_time::now_iso;
53use super::random_id::random_uuid;
54/// The repository shape.
55use super::worktree_identity::WorktreeIdentity;
56/// Owned operating-system text.
57use std::ffi::OsString;
58/// `Path` and `PathBuf` are borrowed and owned filesystem paths.
59use std::path::{Path, PathBuf};
60
61/// Module tag for diagnostics.
62const TAG: &str = "isolatedMerge";
63
64/// Files and directories of the Git directory that show another operation is in progress; native Git reports those
65/// itself, so the command is forwarded.
66const 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
75/// Everything one isolated merge reads from its invocation.
76///
77/// In TS you'd write (pseudocode):
78/// ```ts
79/// type IsolatedMergeInput = { realGit; overlay; identity; command; environment };
80/// ```
81pub struct IsolatedMergeInput<'a> {
82    /// Real Git executable.
83    pub real_git: &'a Path,
84    /// Variables added to every Git child, the landing lease included.
85    pub overlay: &'a [(OsString, OsString)],
86    /// The repository shape of the invocation.
87    pub identity: &'a WorktreeIdentity,
88    /// The merge command after alias resolution.
89    pub command: &'a ResolvedCommand,
90    /// The environment the invocation inherited.
91    pub environment: &'a [(OsString, OsString)],
92}
93
94/// How an isolated merge attempt ended.
95///
96/// In TS you'd write (pseudocode):
97/// ```ts
98/// type IsolatedMerge = { kind: 'forward'; notice?: string } | { kind: 'finished'; code: number; stdout: string; stderr: string };
99/// ```
100#[derive(Clone, Debug, Eq, PartialEq)]
101pub enum IsolatedMerge {
102    /// The command is not for the wrapper to complete: native Git runs it unchanged, after the notice if any.
103    Forward {
104        /// A line for standard error before Git runs.
105        notice: Option<String>,
106    },
107    /// The wrapper completed or refused the merge.
108    Finished {
109        /// Exit status.
110        code: i32,
111        /// Text for standard output.
112        stdout: String,
113        /// Text for standard error.
114        stderr: String,
115    },
116}
117
118/// Why the attempt stopped before it finished.
119enum Bail {
120    /// Forward to native Git.
121    Forward(Option<String>),
122    /// Done, with this result.
123    Done(IsolatedMerge),
124}
125
126/// A failed query is a reason to forward, with the cause in the diagnostic log.
127impl 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/// The merge-related settings the isolated path honors or defers on.
135#[derive(Debug, Default)]
136struct MergeConfig {
137    /// `merge.ff`: `Some(false)` is `--no-ff`; `Some(true)` allows; absent is unset.
138    fast_forward: Option<bool>,
139    /// `merge.ff=only`.
140    fast_forward_only: bool,
141    /// `merge.stat`: false hides the diffstat.
142    stat: bool,
143    /// `commit.gpgSign`.
144    sign_commits: bool,
145    /// Any setting that changes the merge in a way this path does not reproduce.
146    deferred: bool,
147}
148
149/// What:
150///  Read Git's boolean spellings.
151/// Why:
152///   Settings accept `true`, `yes`, `on`, `1` and their opposites in any letter case.
153///
154/// In TS you'd write (pseudocode):
155/// ```ts
156/// function parseMaybeBool(text: string): boolean | undefined;
157/// ```
158pub 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
166/// What:
167///  Whether `git merge` would open an editor for the message.
168/// Why:
169///   `GIT_MERGE_AUTOEDIT` decides when it is set; otherwise the editor opens when standard input and output are one
170///  terminal.
171///  The editor is not reproduced here, so a merge that would open one is forwarded; `--no-edit` avoids it.
172///
173/// In TS you'd write (pseudocode):
174/// ```ts
175/// function editorWouldOpen({ autoEdit, sameTerminal }): boolean | undefined;
176/// ```
177pub 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
184/// What:
185///  Whether standard input and standard output are the same terminal.
186/// Why:
187///   Git's `default_edit_option` asks exactly this.
188///
189/// In TS you'd write (pseudocode):
190/// ```ts
191/// process.stdin.isTTY && process.stdout.isTTY && sameDevice(0, 1)
192/// ```
193fn 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    // UNTESTED ON WINDOWS: no gate compiles or runs this branch (the crate does not build for Windows; README, Requirements).
210    #[cfg(not(unix))]
211    {
212        return true;
213    }
214}
215
216/// One session of queries against the invocation's repository.
217struct Session {
218    /// Real Git with the invocation's global options as a prefix.
219    context: GitContext,
220}
221
222/// Queries.
223impl Session {
224    /// Run Git and return its output whatever the exit status.
225    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    /// Run Git with bytes on standard input.
231    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    /// Run Git, require success, and return its trimmed standard output.
239    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
248/// What:
249///  Read the settings in one Git call.
250/// Why:
251///   The settings that change what a merge does but that this path does not reproduce defer the whole command to
252///  native Git; the others (`merge.ff`, `merge.stat`, `commit.gpgSign`) are honored.
253///
254/// In TS you'd write (pseudocode):
255/// ```ts
256/// async function readMergeConfig(session): Promise<MergeConfig>;
257/// ```
258fn 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
287/// What:
288///  The object IDs on a Git output's lines.
289/// Why:
290///   `rev-parse` and `merge-base` print one ID per line.
291///
292/// In TS you'd write (pseudocode):
293/// ```ts
294/// output.stdout.toString().split('\n').filter(Boolean)
295/// ```
296fn 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
305/// What:
306///  The result of the staged-work check: the paths of everyone else's unsaved work, or a reason to forward.
307/// Why:
308///   An unmerged path means a conflict is in progress, which native Git reports itself.
309///
310/// In TS you'd write (pseudocode):
311/// ```ts
312/// async function unsavedPaths(session): Promise<Uint8Array[]>;
313/// ```
314fn 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        // A rename or copy is followed by its original path, which carries no status letters.
331        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
338/// What:
339///  Complete or refuse one merge, or hand the command back to native Git.
340/// Why:
341///   The wrapper acts only on the exact failing case: a supported merge command, a staged index, a true merge.
342///  Everything else is native Git's, including its own errors.
343///
344/// In TS you'd write (pseudocode):
345/// ```ts
346/// async function runIsolatedMerge(input: IsolatedMergeInput): Promise<IsolatedMerge>;
347/// ```
348pub 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
355/// The text a finding or failure puts on standard error, as a finished merge.
356fn refused(code: i32, stderr: String) -> Bail {
357    return Bail::Done(IsolatedMerge::Finished { code, stdout: String::new(), stderr });
358}
359
360/// What:
361///  The whole attempt, with `Bail` standing for every early ending.
362/// Why:
363///   Early endings are many and mostly mean "forward"; the question mark keeps the sequence readable.
364///
365/// In TS you'd write (pseudocode):
366/// ```ts
367/// async function attempt(input): Promise<IsolatedMerge>; // throws Bail
368/// ```
369fn 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_under_lock(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            // The merge message passes through `git stripspace` and `git fmt-merge-msg` as input.
397            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    // The one process a merge into a clean index costs: native Git runs unchanged when nothing is staged.
404    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
493/// What:
494///  Recover merges that died, under the landing lock the caller holds.
495/// Why:
496///   A merge killed between Git's checkout update and the branch move leaves the checkout half done, and the
497///  checks that follow would read it as another session's staged work.
498///
499/// In TS you'd write (pseudocode):
500/// ```ts
501/// await recoverInterruptedMerges({ place, gitDir });
502/// ```
503fn recover_under_lock(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
531/// What:
532///  Compute the merge from objects and return its tree, or refuse a conflict.
533/// Why:
534///   `merge-tree --write-tree` exits 1 for a conflicted merge and names the paths; nothing is written to the
535///  worktree or the index.
536///
537/// In TS you'd write (pseudocode):
538/// ```ts
539/// async function mergeTree({ session, shape, head, target }): Promise<string>;
540/// ```
541fn 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
560/// What:
561///  Create the merge commit with its parents in Git's order, signing it as the options and settings ask.
562/// Why:
563///   `commit-tree` signs only when given `-S` and never reads `commit.gpgSign`; a merge on a signed branch must not
564///  be the one unsigned commit.
565///
566/// In TS you'd write (pseudocode):
567/// ```ts
568/// async function commitMerge({ session, shape, config, parents, tree, message }): Promise<string>;
569/// ```
570fn 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
595/// What:
596///  Record the intent, let Git move the checkout to the merge commit, and report.
597/// Why:
598///   Git's fast-forward is the two-way update that keeps every other staged entry and unstaged change, refuses an
599///  untracked file in the way, and moves the branch only after the checkout is updated.
600///
601/// In TS you'd write (pseudocode):
602/// ```ts
603/// async function apply({ session, shape, locations, commits }): Promise<IsolatedMerge>;
604/// ```
605fn 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    // A start failure is handled like a refusal: the record settles whatever Git began.
633    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
679/// What the apply step needs to know about the merge it applies.
680struct Plan<'a> {
681    /// The command line.
682    shape: &'a MergeShape,
683    /// The settings read for the merge.
684    config: &'a MergeConfig,
685    /// The worktree's Git directory.
686    git_dir: &'a Path,
687    /// The worktree top level.
688    root: &'a Path,
689    /// The commit the branch holds before the merge.
690    old_oid: &'a str,
691    /// The merge commit.
692    new_oid: &'a str,
693}
694
695/// What:
696///  Reach a marker phase, failing the invocation when the marker variable is malformed.
697/// Why:
698///   The crash controls arm a phase by environment; a malformed value must not be ignored.
699///
700/// In TS you'd write (pseudocode):
701/// ```ts
702/// await reachTransactionPhase({ phase });
703/// ```
704fn 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/// Boolean settings, the editor choice and the markers of other operations, which need no repository.
711#[cfg(test)]
712#[path = "merge_isolation_tests.rs"]
713mod tests;
714
715/// The settings, the staged-work check and the first decisions of an attempt, each in a real repository.
716#[cfg(test)]
717#[path = "merge_isolation_repository_tests.rs"]
718mod repository_tests;