Skip to main content

grit_lib/porcelain/
merge.rs

1//! `git merge` index/head algorithm core.
2//!
3//! The full merge command in the `grit` binary is a large stateful sequencer:
4//! it parses args, launches the merge-message editor, runs hooks, prints
5//! progress (`"Trying merge strategy ..."`, `"Already up to date."`), and
6//! writes the working tree. Those responsibilities — argv parsing, terminal
7//! output, editor/hook subprocess dispatch, and exit-code mapping — stay in the
8//! CLI.
9//!
10//! What lives here is the self-contained, presentation-free part of that
11//! sequencer: the pure data transforms over trees, indexes, and the commit
12//! graph that the CLI calls but that compute results from repository data
13//! alone.
14//!
15//! # What this module owns
16//!
17//! - [`reduce_octopus_merge_heads`] — Git's "reduce parents": drop any merge
18//!   head that is an ancestor of another listed head, preserving input order.
19//! - [`compose_fast_forward_index`] — build the post-fast-forward index from the
20//!   target tree, carrying forward staged additions that the fast-forward does
21//!   not touch.
22//! - [`compose_octopus_final_index`] — fold staged paths from before an octopus
23//!   merge back into the merge result.
24//! - [`tree_to_index_entries`] / [`tree_to_map`] — flatten a tree object into a
25//!   recursive list (or path-keyed map) of [`IndexEntry`] values; the shared
26//!   building block the three transforms above (and most of the CLI merge code)
27//!   are written against.
28//! - [`resolve_merge_source`] — turn a merge argument (branch, tag, object id or any
29//!   revision expression) into the commit to merge.
30
31use std::collections::{BTreeSet, HashMap, HashSet};
32use std::fs;
33use std::path::Path;
34
35use crate::change_id::{change_id, ChangeId, ChangeIdPolicy};
36use crate::config::ConfigSet;
37use crate::diagnostics::Warning;
38use crate::diff::diff_trees;
39use crate::error::{Error, Result};
40use crate::hooks::{run_post_merge, PostOperationHooks};
41use crate::ident::{parse_signature_times, Signature};
42use crate::ident_resolve::{resolve_loose_committer_identity_with, IdentRole};
43use crate::index::{Index, IndexEntry};
44use crate::merge_base::{
45    is_ancestor, merge_bases_first_vs_rest, walk_commits_reachable_excluding_ancestors_of,
46    ReachableWalkLimit,
47};
48use crate::merge_file::{ConflictStyle, MergeFavor};
49use crate::merge_trees::{
50    apply_tree_merge_output_to_index, materialized_merge_result_tree, merge_trees_three_way,
51    TreeMergeConflictPresentation, TreeMergeOutput, WhitespaceMergeOptions,
52};
53use crate::objects::{parse_commit, parse_tree, CommitData, ObjectId, ObjectKind};
54use crate::path_population::validate_tree_walk_entry;
55use crate::path_validate::ProtectOptions;
56use crate::porcelain::checkout;
57use crate::porcelain::commit::{
58    normalize_commit_message, stamp_change_id_on_new_commit, write_commit_object,
59};
60use crate::porcelain::index_from_tree::{
61    index_from_tree_with_worktree_stats, refresh_stage0_index_stats_from_worktree,
62};
63use crate::porcelain::restore::conflict_style_from_config;
64use crate::porcelain::switch::{
65    checkout_prepared_tree_transition_for_merge, prepare_tree_transition_allow_local,
66    verify_tree_transition_allow_local, TreeTransitionPrepareError, TreeTransitionVerifyError,
67};
68use crate::porcelain::tree_merge_checkout::{
69    finalize_clean_tree_merge_index, write_index_after_merge_checkout,
70};
71use crate::porcelain::worktree_guard::prepare_tree_checkout;
72use crate::progress::ProgressSink;
73use crate::refs;
74use crate::refs::store::{Expected, RawRef, RefUpdate, RefUpdateFlags};
75use crate::repo::Repository;
76use crate::rev_parse_error::RevParseError;
77use crate::state::{clear_merge_state, read_merge_heads, read_merge_msg, read_orig_head};
78use crate::stripspace::{process as stripspace_process, Mode as StripspaceMode};
79use thiserror::Error;
80
81use crate::porcelain::add::stage_for_commit_on_index;
82use crate::porcelain::commit::{
83    create_commit_with_loaded_index, default_commit_scope_from_config, CommitRequest, CommitScope,
84};
85use crate::worktree_rules::WorktreeRules;
86use crate::write_tree::write_tree_from_index;
87
88/// Flatten a tree object into a recursive list of [`IndexEntry`] values.
89///
90/// Sub-trees are walked depth-first and their paths are prefixed with `prefix`
91/// joined by `/`. The returned entries carry stage 0, zeroed stat fields, and
92/// the tree's mode/oid — exactly what a freshly read-tree index holds.
93///
94/// # Errors
95///
96/// Returns an error if `oid` cannot be read or does not name a tree object.
97pub fn tree_to_index_entries(
98    repo: &Repository,
99    oid: &ObjectId,
100    prefix: &str,
101) -> Result<Vec<IndexEntry>> {
102    let protect = ProtectOptions::from_config(&*repo.config()?);
103    let prefix_bytes = crate::repo_relpath::storage_bytes(prefix);
104    tree_to_index_entries_with_protect(repo, oid, &prefix_bytes, protect)
105}
106
107fn tree_to_index_entries_with_protect(
108    repo: &Repository,
109    oid: &ObjectId,
110    prefix: &[u8],
111    protect: ProtectOptions,
112) -> Result<Vec<IndexEntry>> {
113    let obj = repo.odb().read(oid)?;
114    if obj.kind != ObjectKind::Tree {
115        return Err(Error::Message(format!("expected tree, got {}", obj.kind)));
116    }
117    let entries = parse_tree(&obj.data)?;
118    let mut result = Vec::new();
119
120    for te in entries {
121        let path_bytes = crate::repo_relpath::join_rel(prefix, &te.name);
122        validate_tree_walk_entry(prefix, &te.name, te.mode, protect)?;
123
124        if te.mode == 0o040000 {
125            let sub = tree_to_index_entries_with_protect(repo, &te.oid, &path_bytes, protect)?;
126            result.extend(sub);
127        } else {
128            result.push(IndexEntry {
129                ctime_sec: 0,
130                ctime_nsec: 0,
131                mtime_sec: 0,
132                mtime_nsec: 0,
133                dev: 0,
134                ino: 0,
135                mode: te.mode,
136                uid: 0,
137                gid: 0,
138                size: 0,
139                oid: te.oid,
140                flags: path_bytes.len().min(0xFFF) as u16,
141                flags_extended: None,
142                path: path_bytes,
143                base_index_pos: 0,
144            });
145        }
146    }
147    Ok(result)
148}
149
150/// Index a flat entry list by path, keeping the last entry for any duplicate.
151#[must_use]
152pub fn tree_to_map(entries: Vec<IndexEntry>) -> HashMap<Vec<u8>, IndexEntry> {
153    let mut out = HashMap::new();
154    for e in entries {
155        out.insert(e.path.clone(), e);
156    }
157    out
158}
159
160/// Build the index that results from fast-forwarding `current_index` to
161/// `target_tree`.
162///
163/// Starts from `target_tree`'s flattened entries and carries forward any
164/// stage-0 path that is staged in `current_index`, is absent from
165/// `target_tree`, and is also absent from `head_tree` — i.e. a staged addition
166/// that the fast-forward should not discard.
167///
168/// # Errors
169///
170/// Returns an error if `target_tree` or `head_tree` cannot be read as trees.
171pub fn compose_fast_forward_index(
172    repo: &Repository,
173    target_tree: ObjectId,
174    head_tree: ObjectId,
175    current_index: &Index,
176) -> Result<Index> {
177    let mut new_entries = tree_to_index_entries(repo, &target_tree, "")?;
178    let target_paths: BTreeSet<Vec<u8>> = new_entries.iter().map(|e| e.path.clone()).collect();
179    let head_entries = tree_to_map(tree_to_index_entries(repo, &head_tree, "")?);
180    for e in &current_index.entries {
181        if e.stage() != 0 {
182            continue;
183        }
184        if target_paths.contains(&e.path) {
185            continue;
186        }
187        // Staged addition: not in HEAD — keep alongside the fast-forwarded tree.
188        if !head_entries.contains_key(&e.path) {
189            new_entries.push(e.clone());
190        }
191    }
192    let mut index = Index::new();
193    index.entries = new_entries;
194    index.sort();
195    // A duplicate-entry tree (t4058) flattens to several identical-path entries; Git's index keeps
196    // only one per path. Restore that invariant so status/diff against HEAD is consistent.
197    index.dedup_paths_keep_last();
198    Ok(index)
199}
200
201/// Preserve staged paths from before an octopus merge that the merge result does not touch
202/// (e.g. unrelated `git add`), matching Git's index composition.
203pub fn compose_octopus_final_index(pre_merge_index: &Index, final_index: &mut Index) {
204    let final_paths: BTreeSet<Vec<u8>> = final_index
205        .entries
206        .iter()
207        .filter(|e| e.stage() == 0)
208        .map(|e| e.path.clone())
209        .collect();
210    for e in &pre_merge_index.entries {
211        if e.stage() != 0 {
212            continue;
213        }
214        if final_paths.contains(&e.path) {
215            continue;
216        }
217        final_index.push_entry_unsorted(e.clone());
218    }
219    final_index.sort();
220}
221
222/// Drop merge heads that are ancestors of another listed head (Git's "reduce parents").
223///
224/// Order of the remaining heads matches the first occurrence in the input (t7603).
225///
226/// # Errors
227///
228/// Returns an error if a commit-graph walk required for an ancestry test fails.
229pub fn reduce_octopus_merge_heads(
230    repo: &Repository,
231    merge_oids: &[ObjectId],
232    merge_names: &[String],
233) -> Result<(Vec<ObjectId>, Vec<String>)> {
234    debug_assert_eq!(merge_oids.len(), merge_names.len());
235    let mut out_oids = Vec::with_capacity(merge_oids.len());
236    let mut out_names = Vec::with_capacity(merge_names.len());
237    for i in 0..merge_oids.len() {
238        let oid = merge_oids[i];
239        let redundant = merge_oids
240            .iter()
241            .enumerate()
242            .any(|(j, &other)| j != i && is_ancestor(repo, oid, other).unwrap_or(false));
243        if !redundant {
244            out_oids.push(oid);
245            out_names.push(merge_names[i].clone());
246        }
247    }
248    Ok((out_oids, out_names))
249}
250
251pub use crate::operation::{conflicts_from_tree_merge, tree_merge_has_conflicts};
252pub use crate::operation::{Conflict as MergeConflictDetail, ConflictKind as MergeConflictKind};
253
254/// Fast-forward policy for branch integration.
255#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
256pub enum FastForwardPolicy {
257    /// Fast-forward when `other_oid` is a descendant of `into_oid`.
258    #[default]
259    Allow,
260    /// Refuse when a fast-forward is not possible; nothing changes.
261    Only,
262    /// Always record a merge commit when a real merge is required, even when a fast-forward is possible.
263    Never,
264}
265
266/// Whether integration stops before creating a commit.
267#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
268pub enum MergeCommitMode {
269    /// Create a merge commit (or fast-forward) on success without conflicts.
270    #[default]
271    Commit,
272    /// Apply the merge to the index and worktree but leave `MERGE_HEAD` for a follow-up commit.
273    NoCommit,
274    /// Apply the merged tree and write `SQUASH_MSG` without updating the branch ref.
275    Squash,
276}
277
278/// Options for [`integrate_branch_with`].
279#[derive(Clone, Debug, PartialEq, Eq, Default)]
280pub struct MergeOptions {
281    /// When to fast-forward versus creating a merge commit.
282    pub fast_forward: FastForwardPolicy,
283    /// Whether to stop before committing.
284    pub commit: MergeCommitMode,
285    /// Overrides the default merge commit / `MERGE_MSG` text when set.
286    pub message: Option<String>,
287    /// Prefer ours or theirs when both sides changed the same region (`-X ours|theirs`).
288    pub favor: MergeFavor,
289    /// Conflict marker style; when unset, [`resolve_merge_conflict_style`] reads config.
290    pub conflict_style: Option<ConflictStyle>,
291    /// When histories share no commits, use the empty tree as merge base (Git `--allow-unrelated-histories`).
292    pub allow_unrelated: bool,
293    /// Whether to run `post-merge` after a fast-forward, merge commit or squash.
294    pub hooks: PostOperationHooks,
295}
296
297/// Resolve conflict marker style: explicit option, then `merge.conflictStyle`, then `merge`.
298#[must_use]
299pub fn resolve_merge_conflict_style(
300    repo: &Repository,
301    override_style: Option<ConflictStyle>,
302) -> ConflictStyle {
303    override_style.unwrap_or_else(|| conflict_style_from_config(repo))
304}
305
306/// Options for [`integrate_branch_with_options`] (pull fast-forward toggle).
307#[derive(Clone, Copy, Debug, PartialEq, Eq)]
308pub struct IntegrateBranchOptions {
309    /// When `false`, a fast-forward is not performed even when `other_oid` is a
310    /// descendant of `into_oid`; a merge commit is recorded instead.
311    pub allow_fast_forward: bool,
312}
313
314impl Default for IntegrateBranchOptions {
315    fn default() -> Self {
316        Self {
317            allow_fast_forward: true,
318        }
319    }
320}
321
322/// Options for [`predict_merge`].
323#[derive(Clone, Debug)]
324pub struct MergePredictionOptions {
325    /// When `false`, a fast-forward is not considered even when `other_oid` is a
326    /// descendant of `into_oid`; a three-way merge is simulated instead.
327    pub allow_fast_forward: bool,
328    /// When `false`, histories with no merge base yield [`MergePrediction::Unrelated`].
329    /// When `true`, an empty merge base is treated like Git's `--allow-unrelated-histories`
330    /// (reserved for a later step; unrelated histories still report `Unrelated` today).
331    pub allow_unrelated_histories: bool,
332    /// Merge-favor policy for the simulated three-way merge (ours/theirs/normal).
333    pub favor: MergeFavor,
334    /// Whitespace handling for the simulated three-way merge.
335    pub whitespace: WhitespaceMergeOptions,
336    /// Label for the incoming side in conflict-marker metadata (presentation only).
337    pub theirs_label: String,
338}
339
340impl Default for MergePredictionOptions {
341    fn default() -> Self {
342        Self {
343            allow_fast_forward: true,
344            allow_unrelated_histories: false,
345            favor: MergeFavor::default(),
346            whitespace: WhitespaceMergeOptions::default(),
347            theirs_label: "branch".into(),
348        }
349    }
350}
351
352/// In-memory prediction of merging `other_oid` into the commit `into_oid`.
353#[derive(Debug, Clone, PartialEq, Eq)]
354pub enum MergePrediction {
355    /// The current tip already contains the other side.
356    UpToDate,
357    /// The current tip could fast-forward to the other side.
358    FastForward,
359    /// A three-way merge would succeed; `tree` is the merged result tree OID.
360    Clean {
361        /// Merged tree OID (matches `git merge-tree --write-tree` on success).
362        tree: ObjectId,
363    },
364    /// A three-way merge would stop with conflicts.
365    Conflicts {
366        /// Conflicted paths and kinds (same classification as a real merge).
367        conflicts: Vec<MergeConflictDetail>,
368    },
369    /// The two commits share no common history.
370    Unrelated,
371}
372
373/// Predict merging `other_oid` into `into_oid` without touching refs, the index,
374/// merge state files, or the working tree.
375///
376/// Object writes from the merge are routed through the ODB in-memory overlay and
377/// discarded before return, so the on-disk object store is unchanged.
378///
379/// # Errors
380///
381/// Propagates object read and merge failures. Does not require a clean worktree.
382pub fn predict_merge(
383    repo: &Repository,
384    into_oid: ObjectId,
385    other_oid: ObjectId,
386    options: &MergePredictionOptions,
387) -> Result<MergePrediction> {
388    if into_oid == other_oid || is_ancestor(repo, other_oid, into_oid)? {
389        return Ok(MergePrediction::UpToDate);
390    }
391
392    if options.allow_fast_forward && is_ancestor(repo, into_oid, other_oid)? {
393        return Ok(MergePrediction::FastForward);
394    }
395
396    let base_oid = merge_bases_first_vs_rest(repo, into_oid, &[other_oid])?
397        .into_iter()
398        .next();
399    let Some(base_oid) = base_oid else {
400        return Ok(MergePrediction::Unrelated);
401    };
402    let _ = options.allow_unrelated_histories;
403
404    let into_tree = repo.odb().commit_tree(&into_oid)?;
405    let other_tree = repo.odb().commit_tree(&other_oid)?;
406    let base_tree = repo.odb().commit_tree(&base_oid)?;
407
408    let presentation = TreeMergeConflictPresentation {
409        label_theirs: crate::merge_trees::TheirsConflictLabel::Fixed(&options.theirs_label),
410        df_directory_ours_commit: Some(into_oid),
411        df_directory_theirs_commit: Some(other_oid),
412        ..TreeMergeConflictPresentation::default()
413    };
414
415    let _overlay = MemOverlayGuard::enter(repo.odb());
416    let merged = merge_trees_three_way(
417        repo,
418        base_tree,
419        into_tree,
420        other_tree,
421        options.favor,
422        options.whitespace,
423        None,
424        presentation,
425    )?;
426    if tree_merge_has_conflicts(&merged) {
427        return Ok(MergePrediction::Conflicts {
428            conflicts: conflicts_from_tree_merge(&merged),
429        });
430    }
431    let tree = materialized_merge_result_tree(repo.odb(), &merged)?;
432    Ok(MergePrediction::Clean { tree })
433}
434
435/// Planned merge of `other_oid` into `into_oid` without touching refs, the index, merge state,
436/// or the working tree (same in-memory three-way merge as [`predict_merge`]).
437#[derive(Debug, Clone, PartialEq, Eq)]
438pub enum MergeDryRunPlan {
439    /// The current tip already contains the other side.
440    UpToDate,
441    /// The branch ref would move to `tip` (fast-forward).
442    FastForward {
443        /// Tip commit the current branch would become.
444        tip: ObjectId,
445        /// Stored `change-id` on `tip` when present.
446        change_id: Option<ChangeId>,
447    },
448    /// `--ff-only` would refuse because histories diverged.
449    RefusedNotFf,
450    /// A two-parent merge commit would be recorded (tree merge already succeeded).
451    MergeCommit {
452        /// Parent commits of the merge commit (current tip, then merged branch).
453        parents: Vec<ObjectId>,
454    },
455    /// The three-way merge would stop with conflicts.
456    Conflicts {
457        /// Conflicted paths and kinds (same classification as a real merge).
458        conflicts: Vec<MergeConflictDetail>,
459    },
460}
461
462/// Predict integrating `other_oid` into the commit at `into_oid` using [`MergeOptions`], without
463/// mutating the repository.
464///
465/// # Errors
466///
467/// Propagates object read and merge failures. Returns [`Error::Message`] when histories are
468/// unrelated and [`MergeOptions::allow_unrelated`] is false.
469pub fn plan_branch_merge(
470    repo: &Repository,
471    into_oid: ObjectId,
472    other_oid: ObjectId,
473    label: &str,
474    options: &MergeOptions,
475) -> Result<MergeDryRunPlan> {
476    if into_oid == other_oid || is_ancestor(repo, other_oid, into_oid)? {
477        return Ok(MergeDryRunPlan::UpToDate);
478    }
479
480    let ff_possible = is_ancestor(repo, into_oid, other_oid)?;
481    if matches!(options.fast_forward, FastForwardPolicy::Only) && !ff_possible {
482        return Ok(MergeDryRunPlan::RefusedNotFf);
483    }
484
485    if should_take_fast_forward(options, ff_possible) {
486        return Ok(MergeDryRunPlan::FastForward {
487            tip: other_oid,
488            change_id: stored_change_id_for_commit(repo, other_oid)?,
489        });
490    }
491
492    let prediction_opts = MergePredictionOptions {
493        allow_fast_forward: false,
494        allow_unrelated_histories: options.allow_unrelated,
495        favor: options.favor,
496        whitespace: WhitespaceMergeOptions::default(),
497        theirs_label: label.to_owned(),
498    };
499    match predict_merge(repo, into_oid, other_oid, &prediction_opts)? {
500        MergePrediction::UpToDate | MergePrediction::FastForward => Ok(MergeDryRunPlan::UpToDate),
501        MergePrediction::Clean { .. } => Ok(MergeDryRunPlan::MergeCommit {
502            parents: vec![into_oid, other_oid],
503        }),
504        MergePrediction::Conflicts { conflicts } => Ok(MergeDryRunPlan::Conflicts { conflicts }),
505        MergePrediction::Unrelated => Err(Error::Message(format!(
506            "'{label}' has no common history with the current branch"
507        ))),
508    }
509}
510
511/// What kind of name a merge source was given by ([`resolve_merge_source`]).
512#[derive(Debug, Clone, Copy, PartialEq, Eq)]
513pub enum MergeSourceKind {
514    /// A local branch (`refs/heads/<name>`).
515    LocalBranch,
516    /// A remote-tracking branch (`refs/remotes/<name>`).
517    RemoteBranch,
518    /// A tag (`refs/tags/<name>`), lightweight or annotated.
519    Tag,
520    /// Any other revision expression: an object id, `HEAD~2`, `ORIG_HEAD`, `v1.0^`, a full
521    /// refname, and so on.
522    Commit,
523}
524
525/// The commit a merge brings in, and how its name was resolved ([`resolve_merge_source`]).
526#[derive(Debug, Clone, Copy, PartialEq, Eq)]
527pub struct MergeSource {
528    /// The commit to merge (annotated tags already peeled).
529    pub oid: ObjectId,
530    /// Which namespace matched the spec.
531    pub kind: MergeSourceKind,
532}
533
534/// Resolve the argument of a merge (`spec`) to the commit it names.
535///
536/// Branch names win over other meanings: `refs/heads/<spec>` is tried first, then
537/// `refs/remotes/<spec>`, then `refs/tags/<spec>`. Anything else goes through the revision
538/// parser, so object ids (full or abbreviated), `ORIG_HEAD`, `topic~3` and `origin/main^2`
539/// all work. Annotated tags are peeled to the commit they name. Range expressions (`a..b`, `a...b`)
540/// do not name a single commit and are reported as not found. Ref names can contain literal
541/// braces: `foo{bar` is a valid source, while `foo{bar...main` is still a range.
542/// Dots within selectors such as `main^{/release..candidate}` do not form a range.
543///
544/// # Parameters
545///
546/// - `repo`: repository to resolve in.
547/// - `spec`: the merge argument as the user wrote it.
548///
549/// # Returns
550///
551/// The commit and the [`MergeSourceKind`] of the name that matched.
552///
553/// # Errors
554///
555/// - [`Error::RevisionNotFound`] when `spec` names nothing.
556/// - [`Error::UnexpectedObjectKind`] when `spec` names (or a tag peels to) a tree or blob.
557/// - Object database errors (missing or corrupt objects behind a ref) are propagated.
558pub fn resolve_merge_source(repo: &Repository, spec: &str) -> Result<MergeSource> {
559    let named = [
560        ("refs/heads/", MergeSourceKind::LocalBranch),
561        ("refs/remotes/", MergeSourceKind::RemoteBranch),
562        ("refs/tags/", MergeSourceKind::Tag),
563    ]
564    .into_iter()
565    .find_map(|(prefix, kind)| {
566        refs::resolve_ref(repo.git_dir(), &format!("{prefix}{spec}"))
567            .ok()
568            .map(|oid| (oid, kind))
569    });
570    // A full-length hex id parses without an object lookup, so a missing object only shows up
571    // when peeling; that is still "names nothing", unlike a ref pointing at a missing object.
572    let not_found = |err: Error| match err {
573        Error::ObjectNotFound(_)
574        | Error::RevParse(
575            RevParseError::InvalidObjectName { .. }
576            | RevParseError::RevisionOutOfRange { .. }
577            | RevParseError::AmbiguousArgument { .. }
578            | RevParseError::NoSuchBranch { .. },
579        ) => Error::RevisionNotFound(spec.to_owned()),
580        other => other,
581    };
582    let (peeled, actual, kind) = match named {
583        Some((oid, kind)) => {
584            let (peeled, actual) = repo.odb().peel_tags(oid)?;
585            (peeled, actual, kind)
586        }
587        None => {
588            // The revision parser resolves `a...b` to a merge base; a range never names the
589            // commit to merge.
590            if crate::rev_parse::is_top_level_range(spec) {
591                return Err(Error::RevisionNotFound(spec.to_owned()));
592            }
593            let oid = crate::rev_parse::resolve_revision_without_index_dwim(repo, spec)
594                .map_err(not_found)?;
595            let (peeled, actual) = repo.odb().peel_tags(oid).map_err(not_found)?;
596            (peeled, actual, MergeSourceKind::Commit)
597        }
598    };
599    if actual != ObjectKind::Commit {
600        return Err(Error::UnexpectedObjectKind {
601            oid: peeled,
602            expected: ObjectKind::Commit,
603            actual,
604        });
605    }
606    Ok(MergeSource { oid: peeled, kind })
607}
608
609/// Enable the ODB mem overlay for the scope of a [`predict_merge`] call without disturbing
610/// an overlay already active on the shared [`Repository`] handle.
611pub(crate) struct MemOverlayGuard<'a> {
612    odb: &'a crate::objects::odb::Odb,
613    enabled_here: bool,
614}
615
616impl<'a> MemOverlayGuard<'a> {
617    pub(crate) fn enter(odb: &'a crate::objects::odb::Odb) -> Self {
618        let already_active = odb.is_mem_overlay_active();
619        if !already_active {
620            odb.enable_mem_overlay();
621        }
622        Self {
623            odb,
624            enabled_here: !already_active,
625        }
626    }
627}
628
629impl Drop for MemOverlayGuard<'_> {
630    fn drop(&mut self) {
631        if self.enabled_here {
632            self.odb.disable_mem_overlay();
633        }
634    }
635}
636
637impl From<IntegrateBranchOptions> for MergeOptions {
638    fn from(options: IntegrateBranchOptions) -> Self {
639        Self {
640            fast_forward: if options.allow_fast_forward {
641                FastForwardPolicy::Allow
642            } else {
643                FastForwardPolicy::Never
644            },
645            ..Self::default()
646        }
647    }
648}
649
650/// Result of integrating another branch into the current one ([`integrate_branch`]).
651#[derive(Debug, Clone, PartialEq, Eq)]
652pub enum IntegrateBranchOutcome {
653    /// The current branch already contains the other tip.
654    UpToDate,
655    /// The current branch fast-forwarded to the other tip.
656    FastForward {
657        /// New branch tip after the fast-forward.
658        oid: ObjectId,
659        /// Stored `change-id` on the fast-forward tip when present.
660        change_id: Option<ChangeId>,
661    },
662    /// A merge commit was created without conflicts.
663    Merged {
664        /// New merge commit OID.
665        oid: ObjectId,
666        /// Stored `change-id` on the merge commit when present.
667        change_id: Option<ChangeId>,
668    },
669    /// The merge has conflicts; the repository is left in a merge-in-progress state.
670    Conflicts {
671        /// Conflicted paths in repository-relative form, sorted and deduplicated.
672        paths: Vec<String>,
673        /// Per-path conflict classification (same order as `paths` after sorting).
674        details: Vec<MergeConflictDetail>,
675    },
676    /// A `--squash` merge applied the merged tree and wrote `SQUASH_MSG`.
677    Squashed,
678    /// A `--no-commit` merge applied the merged tree and recorded merge state.
679    Stopped,
680}
681
682/// Integrate `other_oid` into the branch `into_ref` (currently at `into_oid`).
683///
684/// On success without conflicts, updates the branch ref, index, and worktree. On
685/// conflicts, records `MERGE_HEAD` / `ORIG_HEAD` / `MERGE_MSG`, writes conflict
686/// markers and a partially merged index, and leaves the branch ref unchanged.
687///
688/// # Errors
689///
690/// Propagates merge, checkout, ref, and I/O failures. Returns [`Error::Message`] when
691/// the histories are unrelated, the worktree is not clean, or `into_ref` no longer points
692/// at `into_oid` (checked before the worktree is touched, and again when the ref moves).
693pub fn integrate_branch(
694    repo: &Repository,
695    into_ref: &str,
696    into_oid: ObjectId,
697    other_oid: ObjectId,
698    label: &str,
699) -> Result<IntegrateBranchOutcome> {
700    integrate_branch_with(
701        repo,
702        into_ref,
703        into_oid,
704        other_oid,
705        label,
706        &MergeOptions::default(),
707    )
708}
709
710/// Integrate `other_oid` into the branch at `into_ref` with explicit merge options.
711///
712/// After a fast-forward or merge commit, runs `post-merge 0`; after a squash, `post-merge 1`
713/// (unless [`MergeOptions::hooks`] is [`PostOperationHooks::Skip`]). Like Git, the hook does not
714/// run when the branch is already up to date, when the merge stops on conflicts, or for
715/// [`MergeCommitMode::NoCommit`]. Its outcome never fails the merge.
716///
717/// # Errors
718///
719/// Same as [`integrate_branch`], plus [`Error::NotFastForward`] when
720/// [`FastForwardPolicy::Only`] cannot fast-forward.
721pub fn integrate_branch_with(
722    repo: &Repository,
723    into_ref: &str,
724    into_oid: ObjectId,
725    other_oid: ObjectId,
726    label: &str,
727    options: &MergeOptions,
728) -> Result<IntegrateBranchOutcome> {
729    let outcome =
730        integrate_branch_without_hooks(repo, into_ref, into_oid, other_oid, label, options)?;
731    if options.hooks.enabled() {
732        match outcome {
733            IntegrateBranchOutcome::FastForward { .. } | IntegrateBranchOutcome::Merged { .. } => {
734                run_post_merge(repo, false);
735            }
736            IntegrateBranchOutcome::Squashed => {
737                run_post_merge(repo, true);
738            }
739            IntegrateBranchOutcome::UpToDate
740            | IntegrateBranchOutcome::Conflicts { .. }
741            | IntegrateBranchOutcome::Stopped => {}
742        }
743    }
744    Ok(outcome)
745}
746
747fn integrate_branch_without_hooks(
748    repo: &Repository,
749    into_ref: &str,
750    into_oid: ObjectId,
751    other_oid: ObjectId,
752    label: &str,
753    options: &MergeOptions,
754) -> Result<IntegrateBranchOutcome> {
755    if into_oid == other_oid || is_ancestor(repo, other_oid, into_oid)? {
756        return Ok(IntegrateBranchOutcome::UpToDate);
757    }
758    ensure_branch_tip(repo, into_ref, Some(into_oid))?;
759
760    let into_tree = repo.odb().commit_tree(&into_oid)?;
761    let other_tree = repo.odb().commit_tree(&other_oid)?;
762    let ff_possible = is_ancestor(repo, into_oid, other_oid)?;
763
764    if matches!(options.fast_forward, FastForwardPolicy::Only) && !ff_possible {
765        return Err(Error::NotFastForward);
766    }
767
768    if should_take_fast_forward(options, ff_possible) {
769        if matches!(options.commit, MergeCommitMode::Squash) {
770            merge_checkout_tree_transition(repo, Some(&into_tree), &other_tree)?;
771            let mut snapshot = crate::porcelain::worktree_guard::load_worktree_snapshot(repo)?;
772            let ff_index =
773                compose_fast_forward_index(repo, other_tree, into_tree, &snapshot.index)?;
774            snapshot.index = ff_index;
775            repo.write_index(&mut snapshot.index)?;
776            write_squash_msg_file(repo, into_oid, other_oid)?;
777            return Ok(IntegrateBranchOutcome::Squashed);
778        }
779
780        merge_checkout_tree_transition(repo, Some(&into_tree), &other_tree)?;
781        move_branch(
782            repo,
783            into_ref,
784            into_oid,
785            other_oid,
786            &format!("merge {label}: fast-forward"),
787        )?;
788        return Ok(IntegrateBranchOutcome::FastForward {
789            oid: other_oid,
790            change_id: stored_change_id_for_commit(repo, other_oid)?,
791        });
792    }
793
794    let merge_base = merge_bases_first_vs_rest(repo, into_oid, &[other_oid])?;
795    let label_base = if let Some(base_oid) = merge_base.first().copied() {
796        crate::commit_pretty::abbrev_hex(&base_oid, 7)
797    } else if options.allow_unrelated {
798        "empty tree".to_owned()
799    } else {
800        return Err(Error::Message(format!(
801            "'{label}' has no common history with the current branch"
802        )));
803    };
804    let base_tree = if let Some(base_oid) = merge_base.into_iter().next() {
805        repo.odb().commit_tree(&base_oid)?
806    } else {
807        empty_tree_oid(repo)?
808    };
809
810    let style = resolve_merge_conflict_style(repo, options.conflict_style);
811    let presentation = TreeMergeConflictPresentation {
812        label_theirs: crate::merge_trees::TheirsConflictLabel::Fixed(label),
813        label_base: &label_base,
814        df_directory_ours_commit: Some(into_oid),
815        df_directory_theirs_commit: Some(other_oid),
816        style,
817        ..TreeMergeConflictPresentation::default()
818    };
819    let merged = merge_trees_three_way(
820        repo,
821        base_tree,
822        into_tree,
823        other_tree,
824        options.favor,
825        WhitespaceMergeOptions::default(),
826        None,
827        presentation,
828    )?;
829
830    if tree_merge_has_conflicts(&merged) {
831        let details = conflicts_from_tree_merge(&merged);
832        let paths: Vec<String> = details.iter().map(|c| c.path.clone()).collect();
833        let materialized = materialized_merge_result_tree(repo.odb(), &merged)?;
834        ensure_merge_tree_transition_allowed(repo, Some(&into_tree), &materialized)?;
835        apply_conflict_merge_worktree(repo, &into_tree, &merged)?;
836        let merge_msg = merge_message_text(label, options.message.as_deref());
837        if matches!(options.commit, MergeCommitMode::Squash) {
838            write_squash_msg_file(repo, into_oid, other_oid)?;
839            write_orig_head(repo.git_dir(), into_oid)?;
840            write_squash_conflict_merge_msg(repo.git_dir(), &paths, options.message.as_deref())?;
841        } else {
842            record_merge_in_progress(repo.git_dir(), into_oid, other_oid, merge_msg)?;
843        }
844        return Ok(IntegrateBranchOutcome::Conflicts { paths, details });
845    }
846
847    let commit_preflight = if matches!(options.commit, MergeCommitMode::Commit) {
848        let config = repo.config()?;
849        Some(MergeCommitPreflight {
850            author: strict_commit_identity(repo, IdentRole::Author)?,
851            committer: strict_commit_identity(repo, IdentRole::Committer)?,
852            config,
853        })
854    } else {
855        None
856    };
857
858    merge_checkout_tree_transition(repo, Some(&into_tree), &merged.result_tree)?;
859    let mut snapshot = crate::porcelain::worktree_guard::load_worktree_snapshot(repo)?;
860    finalize_clean_tree_merge_index(repo, &mut snapshot.index, &merged)?;
861    write_index_after_merge_checkout(repo, &mut snapshot.index)?;
862
863    match options.commit {
864        MergeCommitMode::Squash => {
865            write_squash_msg_file(repo, into_oid, other_oid)?;
866            Ok(IntegrateBranchOutcome::Squashed)
867        }
868        MergeCommitMode::NoCommit => {
869            record_merge_in_progress(
870                repo.git_dir(),
871                into_oid,
872                other_oid,
873                merge_message_text(label, options.message.as_deref()),
874            )?;
875            write_merge_mode_for_no_commit(
876                repo.git_dir(),
877                matches!(options.fast_forward, FastForwardPolicy::Never),
878            )?;
879            Ok(IntegrateBranchOutcome::Stopped)
880        }
881        MergeCommitMode::Commit => {
882            let preflight = commit_preflight
883                .ok_or_else(|| Error::Message("merge commit preflight missing".into()))?;
884            let config = preflight.config;
885            let mut commit = CommitData {
886                tree: merged.result_tree,
887                parents: vec![into_oid, other_oid],
888                author: preflight.author,
889                committer: preflight.committer,
890                author_raw: Vec::new(),
891                committer_raw: Vec::new(),
892                encoding: None,
893                message: merge_message_text(label, options.message.as_deref()),
894                raw_message: None,
895                extra_headers: Vec::new(),
896            };
897            let policy = ChangeIdPolicy::from_config(config.as_ref());
898            stamp_change_id_on_new_commit(repo, &policy, &mut commit)?;
899            let oid = write_commit_object(repo, &commit, None)?;
900            move_branch(repo, into_ref, into_oid, oid, &format!("merge {label}"))?;
901            Ok(IntegrateBranchOutcome::Merged {
902                oid,
903                change_id: change_id(&commit),
904            })
905        }
906    }
907}
908
909/// Like [`integrate_branch`], with explicit fast-forward policy for [`pull`](crate::porcelain::pull).
910///
911/// # Errors
912///
913/// Same as [`integrate_branch_with`].
914pub fn integrate_branch_with_options(
915    repo: &Repository,
916    into_ref: &str,
917    into_oid: ObjectId,
918    other_oid: ObjectId,
919    label: &str,
920    options: IntegrateBranchOptions,
921) -> Result<IntegrateBranchOutcome> {
922    integrate_branch_with(repo, into_ref, into_oid, other_oid, label, &options.into())
923}
924
925struct MergeCommitPreflight {
926    author: String,
927    committer: String,
928    config: std::sync::Arc<ConfigSet>,
929}
930
931fn should_take_fast_forward(options: &MergeOptions, ff_possible: bool) -> bool {
932    if !ff_possible {
933        return false;
934    }
935    if matches!(options.fast_forward, FastForwardPolicy::Never) {
936        return false;
937    }
938    !matches!(options.commit, MergeCommitMode::NoCommit)
939}
940
941fn merge_message_text(label: &str, override_message: Option<&str>) -> String {
942    if let Some(msg) = override_message {
943        return normalize_commit_message(msg).unwrap_or_else(|_| format!("Merge {label}\n"));
944    }
945    format!("Merge {label}\n")
946}
947
948/// Write `MERGE_MODE` for `--no-commit`, matching Git's bytes (empty file or `no-ff` without newline).
949fn write_merge_mode_for_no_commit(git_dir: &Path, no_ff: bool) -> Result<()> {
950    let bytes = if no_ff { b"no-ff".as_slice() } else { b"" };
951    fs::write(git_dir.join("MERGE_MODE"), bytes).map_err(Error::Io)
952}
953
954fn write_squash_conflict_merge_msg(
955    git_dir: &Path,
956    paths: &[String],
957    override_message: Option<&str>,
958) -> Result<()> {
959    let mut body = String::new();
960    if let Some(msg) = override_message.filter(|m| !m.trim().is_empty()) {
961        body.push_str(msg.trim());
962        if !body.ends_with('\n') {
963            body.push('\n');
964        }
965    } else {
966        body.push('\n');
967    }
968    body.push_str("# Conflicts:\n");
969    for path in paths {
970        body.push_str("#\t");
971        body.push_str(path);
972        body.push('\n');
973    }
974    fs::write(git_dir.join("MERGE_MSG"), body).map_err(Error::Io)
975}
976
977fn write_squash_msg_file(repo: &Repository, into_oid: ObjectId, other_oid: ObjectId) -> Result<()> {
978    let body = format_git_squash_msg(repo, into_oid, other_oid)?;
979    fs::write(repo.git_dir().join("SQUASH_MSG"), body).map_err(Error::Io)
980}
981
982fn format_git_squash_msg(
983    repo: &Repository,
984    into_oid: ObjectId,
985    other_oid: ObjectId,
986) -> Result<String> {
987    let (_, mut commits) = walk_commits_reachable_excluding_ancestors_of(
988        repo,
989        std::slice::from_ref(&other_oid),
990        std::slice::from_ref(&into_oid),
991        ReachableWalkLimit::Unlimited,
992    )?;
993    commits.reverse();
994    let mut out = String::from("Squashed commit of the following:\n\n");
995    for oid in commits {
996        let obj = repo.odb().read(&oid)?;
997        let commit = parse_commit(&obj.data)?;
998        out.push_str(&format!("commit {}\n", oid.to_hex()));
999        out.push_str(&format!("Author: {}\n", squash_author_line(&commit.author)));
1000        out.push_str(&format!("Date:   {}\n\n", squash_date_line(&commit.author)));
1001        for line in commit.message.lines() {
1002            out.push_str(&format!("    {line}\n"));
1003        }
1004        out.push('\n');
1005    }
1006    Ok(out)
1007}
1008
1009fn squash_author_line(author: &str) -> String {
1010    let Some(lt) = author.find('<') else {
1011        return author.trim().to_owned();
1012    };
1013    let Some(gt) = author[lt..].find('>') else {
1014        return author.trim().to_owned();
1015    };
1016    let name = author[..lt].trim();
1017    let email = &author[lt..=lt + gt];
1018    format!("{name} {email}")
1019}
1020
1021fn squash_date_line(author: &str) -> String {
1022    let Some(parsed) = parse_signature_times(author) else {
1023        return String::new();
1024    };
1025    let offset_str = &author[parsed.tz_hhmm_range.clone()];
1026    let Ok(dt) =
1027        time::OffsetDateTime::from_unix_timestamp(parsed.unix_seconds + parsed.tz_offset_secs)
1028    else {
1029        return String::new();
1030    };
1031    let weekday = match dt.weekday() {
1032        time::Weekday::Monday => "Mon",
1033        time::Weekday::Tuesday => "Tue",
1034        time::Weekday::Wednesday => "Wed",
1035        time::Weekday::Thursday => "Thu",
1036        time::Weekday::Friday => "Fri",
1037        time::Weekday::Saturday => "Sat",
1038        time::Weekday::Sunday => "Sun",
1039    };
1040    let month = match dt.month() {
1041        time::Month::January => "Jan",
1042        time::Month::February => "Feb",
1043        time::Month::March => "Mar",
1044        time::Month::April => "Apr",
1045        time::Month::May => "May",
1046        time::Month::June => "Jun",
1047        time::Month::July => "Jul",
1048        time::Month::August => "Aug",
1049        time::Month::September => "Sep",
1050        time::Month::October => "Oct",
1051        time::Month::November => "Nov",
1052        time::Month::December => "Dec",
1053    };
1054    format!(
1055        "{weekday} {month} {} {:02}:{:02}:{:02} {} {offset_str}",
1056        dt.day(),
1057        dt.hour(),
1058        dt.minute(),
1059        dt.second(),
1060        dt.year()
1061    )
1062}
1063
1064fn stored_change_id_for_commit(repo: &Repository, oid: ObjectId) -> Result<Option<ChangeId>> {
1065    let obj = repo.odb().read(&oid)?;
1066    Ok(change_id(&parse_commit(&obj.data)?))
1067}
1068
1069/// Options for [`continue_merge`].
1070#[derive(Debug, Clone)]
1071pub struct ContinueOptions {
1072    /// When set, replaces the message from `MERGE_MSG` (comment lines stripped).
1073    pub message: Option<String>,
1074    /// Author of the merge commit; `None` uses the repository's author identity
1075    /// ([`Repository::identity`]).
1076    pub author: Option<Signature>,
1077    /// Committer of the merge commit; `None` uses the repository's committer identity.
1078    pub committer: Option<Signature>,
1079}
1080
1081/// Result of [`continue_merge`].
1082#[derive(Debug, Clone, PartialEq, Eq)]
1083pub struct ContinueOutcome {
1084    /// New merge commit OID.
1085    pub oid: ObjectId,
1086    /// Stored `change-id` on the merge commit when present.
1087    pub change_id: Option<ChangeId>,
1088}
1089
1090/// Failure modes for [`continue_merge`].
1091#[derive(Debug, Error)]
1092pub enum ContinueMergeError {
1093    /// No `MERGE_HEAD` (or other merge sentinel) is recorded.
1094    #[error("there is no merge in progress")]
1095    NoMergeInProgress,
1096    /// Stage-1/2/3 entries remain in the index.
1097    #[error("you still have unmerged paths: {}", paths.join(", "))]
1098    UnmergedPaths {
1099        /// Repository-relative paths still in conflict stages.
1100        paths: Vec<String>,
1101    },
1102    /// Work tree content is resolved but the index still has conflict stages (`commit.autoStage=false`).
1103    #[error("resolved in the work tree but not staged: {}", paths.join(", "))]
1104    ResolvedNotStaged {
1105        /// Repository-relative paths cleared of conflict markers but not staged.
1106        paths: Vec<String>,
1107    },
1108    /// The resolved message would be empty after cleanup.
1109    #[error("empty commit message")]
1110    EmptyMessage,
1111    /// Underlying repository or commit failure.
1112    #[error(transparent)]
1113    Repository(#[from] Error),
1114}
1115
1116/// Finish an in-progress merge after conflicts are resolved or after `--no-commit`.
1117///
1118/// Builds a merge commit from the current index tree with parents `HEAD` and each
1119/// `MERGE_HEAD`, clears merge state (including autostash and rerere), and updates
1120/// the checked-out branch.
1121///
1122/// # Errors
1123///
1124/// Returns [`ContinueMergeError::NoMergeInProgress`] when no merge is active,
1125/// [`ContinueMergeError::UnmergedPaths`] when conflict stages remain, or propagates
1126/// commit and I/O failures as [`ContinueMergeError::Repository`].
1127pub fn continue_merge(
1128    repo: &Repository,
1129    options: &ContinueOptions,
1130    progress: &mut dyn ProgressSink,
1131) -> std::result::Result<ContinueOutcome, ContinueMergeError> {
1132    let merge_heads = read_merge_heads(repo.git_dir())?;
1133    if merge_heads.is_empty() {
1134        return Err(ContinueMergeError::NoMergeInProgress);
1135    }
1136
1137    let config = repo.config().map_err(ContinueMergeError::Repository)?;
1138    let auto_stage = default_commit_scope_from_config(config.as_ref()) == CommitScope::AutoStage;
1139
1140    let mut index = repo.load_index().map_err(ContinueMergeError::Repository)?;
1141    if auto_stage {
1142        stage_for_commit_on_index(repo, &mut index, progress, false)
1143            .map_err(ContinueMergeError::Repository)?;
1144    }
1145    if index.has_unmerged_entries() {
1146        let paths = unmerged_index_paths(&index);
1147        if !auto_stage {
1148            let resolved = resolved_not_staged_paths(repo, &index, &paths);
1149            if !resolved.is_empty() {
1150                return Err(ContinueMergeError::ResolvedNotStaged { paths: resolved });
1151            }
1152        }
1153        return Err(ContinueMergeError::UnmergedPaths { paths });
1154    }
1155
1156    let message = resolve_continue_merge_message(repo.git_dir(), options.message.as_deref())?;
1157    let req = CommitRequest {
1158        message,
1159        author: options.author.clone(),
1160        committer: options.committer.clone(),
1161        allow_empty: false,
1162        ..Default::default()
1163    };
1164    let outcome = create_commit_with_loaded_index(repo, &mut index, &req, progress)
1165        .map_err(ContinueMergeError::Repository)?;
1166    Ok(ContinueOutcome {
1167        oid: outcome.oid,
1168        change_id: outcome.change_id,
1169    })
1170}
1171
1172fn resolve_continue_merge_message(
1173    git_dir: &Path,
1174    override_message: Option<&str>,
1175) -> std::result::Result<String, ContinueMergeError> {
1176    if let Some(raw) = override_message {
1177        return normalize_commit_message(raw).map_err(|_| ContinueMergeError::EmptyMessage);
1178    }
1179
1180    let Some(raw) = read_merge_msg(git_dir).map_err(ContinueMergeError::Repository)? else {
1181        return Err(ContinueMergeError::EmptyMessage);
1182    };
1183    let processed = stripspace_process(
1184        raw.as_bytes(),
1185        &StripspaceMode::StripComments("#".to_owned()),
1186    );
1187    let message = String::from_utf8(processed).map_err(|_| ContinueMergeError::EmptyMessage)?;
1188    if message.trim().is_empty() {
1189        return Err(ContinueMergeError::EmptyMessage);
1190    }
1191    let mut message = message;
1192    if !message.ends_with('\n') {
1193        message.push('\n');
1194    }
1195    Ok(message)
1196}
1197
1198fn unmerged_index_paths(index: &Index) -> Vec<String> {
1199    let mut paths: Vec<String> = index
1200        .entries
1201        .iter()
1202        .filter(|e| e.stage() != 0)
1203        .map(|e| String::from_utf8_lossy(&e.path).into_owned())
1204        .collect();
1205    paths.sort();
1206    paths.dedup();
1207    paths
1208}
1209
1210fn resolved_not_staged_paths(repo: &Repository, index: &Index, unmerged: &[String]) -> Vec<String> {
1211    let work_tree = match repo.work_tree() {
1212        Some(wt) => wt,
1213        None => return Vec::new(),
1214    };
1215    let Ok(rules) = WorktreeRules::from_repository(repo, index) else {
1216        return Vec::new();
1217    };
1218    let mut resolved = Vec::new();
1219    for path in unmerged {
1220        let raw_path = path.as_bytes();
1221        let abs = work_tree.join(path);
1222        let Ok(meta) = std::fs::symlink_metadata(&abs) else {
1223            continue;
1224        };
1225        let idx_mode = index
1226            .entries
1227            .iter()
1228            .find(|e| e.path == raw_path && e.stage() != 0)
1229            .map(|e| e.mode)
1230            .unwrap_or(0o100644);
1231        if !crate::rerere::worktree_metadata_needs_conflict_marker_inspection(&meta, idx_mode) {
1232            continue;
1233        }
1234        let Ok(wt_bytes) = std::fs::read(&abs) else {
1235            continue;
1236        };
1237        let marker_size = rules
1238            .file_attrs(path, false)
1239            .effective_conflict_marker_size();
1240        if !crate::rerere::worktree_bytes_have_unresolved_conflict_markers(&wt_bytes, marker_size) {
1241            resolved.push(path.clone());
1242        }
1243    }
1244    resolved.sort();
1245    resolved
1246}
1247
1248/// Abort an in-progress merge, restoring index and worktree to [`read_orig_head`].
1249///
1250/// # Errors
1251///
1252/// Returns [`Error::Message`] when no merge is in progress, or propagates checkout and I/O errors.
1253pub fn abort_branch_merge(repo: &Repository) -> Result<()> {
1254    let git_dir = repo.git_dir();
1255    let in_merge = !read_merge_heads(git_dir)?.is_empty();
1256    let squash_conflict =
1257        git_dir.join("SQUASH_MSG").is_file() && git_dir.join("MERGE_MSG").is_file();
1258    if !in_merge && !squash_conflict {
1259        return Err(Error::Message(
1260            "there is no merge in progress — nothing to abort".into(),
1261        ));
1262    }
1263    restore_pre_merge_state(repo)
1264}
1265
1266fn restore_pre_merge_state(repo: &Repository) -> Result<()> {
1267    let orig = read_orig_head(repo.git_dir())?
1268        .ok_or_else(|| Error::Message("merge in progress but ORIG_HEAD is missing".into()))?;
1269    let head_tree = repo.odb().commit_tree(&orig)?;
1270
1271    let old_index = repo.load_index()?;
1272    let from_tree = write_tree_from_index(repo.odb(), &old_index, "")?;
1273    let mut clean_index = index_from_tree_with_worktree_stats(repo, &head_tree)?;
1274    repo.write_index(&mut clean_index)?;
1275    let changes = diff_trees(repo.odb(), Some(&from_tree), Some(&head_tree), "")?;
1276    checkout::checkout_tree_changes(repo, &changes)?;
1277    clear_merge_state(repo.git_dir())?;
1278    Ok(())
1279}
1280
1281fn merge_overwrite_error_message(paths: &[String]) -> Error {
1282    if paths.is_empty() {
1283        Error::dirty_worktree(crate::error::WorktreeOperation::Merge, Vec::new())
1284    } else if paths.len() == 1 {
1285        Error::Message(format!(
1286            "local changes to '{}' would be overwritten by merge",
1287            paths[0]
1288        ))
1289    } else {
1290        Error::Message(format!(
1291            "local changes would be overwritten by merge:\n  {}",
1292            paths.join("\n  ")
1293        ))
1294    }
1295}
1296
1297fn map_tree_transition_prepare_error_for_merge(err: TreeTransitionPrepareError) -> Error {
1298    match err {
1299        TreeTransitionPrepareError::OverwritePaths(paths) => merge_overwrite_error_message(&paths),
1300        TreeTransitionPrepareError::Failed(err) => err,
1301    }
1302}
1303
1304fn ensure_merge_tree_transition_allowed(
1305    repo: &Repository,
1306    from_tree: Option<&ObjectId>,
1307    to_tree: &ObjectId,
1308) -> Result<()> {
1309    prepare_tree_checkout(repo, from_tree, to_tree)?;
1310    match verify_tree_transition_allow_local(repo, from_tree, to_tree) {
1311        Ok(()) => Ok(()),
1312        Err(TreeTransitionVerifyError::WouldOverwrite(paths)) => {
1313            Err(merge_overwrite_error_message(&paths))
1314        }
1315        Err(TreeTransitionVerifyError::Failed(err)) => Err(err),
1316    }
1317}
1318
1319fn merge_checkout_tree_transition(
1320    repo: &Repository,
1321    from_tree: Option<&ObjectId>,
1322    to_tree: &ObjectId,
1323) -> Result<()> {
1324    prepare_tree_checkout(repo, from_tree, to_tree)?;
1325    let prepared = prepare_tree_transition_allow_local(repo, from_tree, to_tree, &HashSet::new())
1326        .map_err(map_tree_transition_prepare_error_for_merge)?;
1327    checkout_prepared_tree_transition_for_merge(repo, prepared)
1328}
1329
1330fn empty_tree_oid(repo: &Repository) -> Result<ObjectId> {
1331    repo.odb().empty_tree_oid()
1332}
1333
1334fn conflict_path_set(merged: &TreeMergeOutput) -> BTreeSet<Vec<u8>> {
1335    let mut set = BTreeSet::new();
1336    set.extend(merged.conflict_content.keys().cloned());
1337    for entry in merged.index.entries() {
1338        if entry.stage() != 0 {
1339            set.insert(entry.path.clone());
1340        }
1341    }
1342    set
1343}
1344
1345pub(crate) fn apply_conflict_merge_worktree(
1346    repo: &Repository,
1347    head_tree: &ObjectId,
1348    merged: &TreeMergeOutput,
1349) -> Result<()> {
1350    let work_tree = repo
1351        .work_tree()
1352        .ok_or_else(|| Error::PathError("cannot update a bare repository's working tree".into()))?;
1353    let materialized = materialized_merge_result_tree(repo.odb(), merged)?;
1354    let prepared =
1355        prepare_tree_transition_allow_local(repo, Some(head_tree), &materialized, &HashSet::new())
1356            .map_err(map_tree_transition_prepare_error_for_merge)?;
1357    let preserve_worktree_paths = prepared.preserve_worktree_paths().clone();
1358    let conflict_paths = conflict_path_set(merged);
1359    let mut index = repo.load_index()?;
1360    apply_tree_merge_output_to_index(&mut index, merged);
1361    if index.has_unmerged_entries() {
1362        index.clear_cache_tree();
1363    }
1364    repo.write_index(&mut index)?;
1365
1366    let stage0_tree = write_tree_from_index(repo.odb(), &index, "")?;
1367    let changes = diff_trees(repo.odb(), Some(head_tree), Some(&stage0_tree), "")?;
1368    let filtered: Vec<_> = changes
1369        .into_iter()
1370        .filter(|change| {
1371            let path = change
1372                .new_path
1373                .as_deref()
1374                .or(change.old_path.as_deref())
1375                .unwrap_or("");
1376            !conflict_paths.contains(path.as_bytes()) && !preserve_worktree_paths.contains(path)
1377        })
1378        .collect();
1379    checkout::checkout_tree_changes(repo, &filtered)?;
1380
1381    let rules = WorktreeRules::from_repository(repo, &index)?;
1382    for (path, oid) in &merged.conflict_content {
1383        let mode = index
1384            .get(path, 2)
1385            .or_else(|| index.get(path, 3))
1386            .map(|e| e.mode)
1387            .unwrap_or(0o100644);
1388        let rel = String::from_utf8_lossy(path);
1389        checkout::write_smudged_blob_to_worktree(repo, work_tree, &rules, rel.as_ref(), oid, mode)?;
1390    }
1391    refresh_stage0_index_stats_from_worktree(repo, work_tree, &mut index)?;
1392    repo.write_index(&mut index)?;
1393    Ok(())
1394}
1395
1396fn write_orig_head(git_dir: &Path, into_oid: ObjectId) -> Result<()> {
1397    fs::write(
1398        git_dir.join("ORIG_HEAD"),
1399        format!("{}\n", into_oid.to_hex()),
1400    )
1401    .map_err(Error::Io)
1402}
1403
1404fn record_merge_in_progress(
1405    git_dir: &Path,
1406    into_oid: ObjectId,
1407    other_oid: ObjectId,
1408    merge_msg: String,
1409) -> Result<()> {
1410    fs::write(
1411        git_dir.join("MERGE_HEAD"),
1412        format!("{}\n", other_oid.to_hex()),
1413    )
1414    .map_err(Error::Io)?;
1415    write_orig_head(git_dir, into_oid)?;
1416    fs::write(git_dir.join("MERGE_MSG"), merge_msg).map_err(Error::Io)?;
1417    Ok(())
1418}
1419
1420fn strict_commit_identity(repo: &Repository, role: IdentRole) -> Result<String> {
1421    Ok(repo.identity(role, repo.identity_now())?.header_line())
1422}
1423
1424fn move_branch(
1425    repo: &Repository,
1426    refname: &str,
1427    old: ObjectId,
1428    new: ObjectId,
1429    reason: &str,
1430) -> Result<()> {
1431    update_checked_out_branch(repo, refname, Some(old), new, reason)
1432}
1433
1434/// Refuse to touch the work tree when `branch_ref` no longer points at `expected` (`None`:
1435/// the branch must not exist), so a concurrent ref update is reported before anything changes.
1436pub(crate) fn ensure_branch_tip(
1437    repo: &Repository,
1438    branch_ref: &str,
1439    expected: Option<ObjectId>,
1440) -> Result<()> {
1441    let current = repo.refs().resolve(branch_ref).ok();
1442    refs::verify_branch_commit_cas(expected, current, branch_ref)
1443}
1444
1445/// Point the checked-out branch `branch_ref` from `expected_old` (`None`: unborn) at `new`
1446/// with a compare-and-swap through the repository's ref store, then append branch and `HEAD`
1447/// reflog lines (per `core.logAllRefUpdates`) stamped with the repository's committer identity.
1448///
1449/// The reflog lines are appended rather than written in the ref transaction, because the
1450/// files store reloads and rewrites a ref's whole reflog when a transaction logs to it. Once
1451/// the ref has moved the operation has happened, so a failed append is reported as
1452/// [`Warning::ReflogNotWritten`] on the repository's diagnostics sink instead of an error.
1453///
1454/// # Errors
1455///
1456/// Config load and compare-and-swap failures, all before the ref changes.
1457pub(crate) fn update_checked_out_branch(
1458    repo: &Repository,
1459    branch_ref: &str,
1460    expected_old: Option<ObjectId>,
1461    new: ObjectId,
1462    reason: &str,
1463) -> Result<()> {
1464    let config = repo.config()?;
1465    let identity =
1466        resolve_loose_committer_identity_with(repo.environment(), &config, repo.identity_now());
1467    refs::commit_ref_update_on_store(
1468        repo.refs(),
1469        RefUpdate {
1470            name: branch_ref.to_owned(),
1471            new_value: Some(RawRef::Direct(new)),
1472            expected: expected_old.map_or(Expected::Missing, Expected::Oid),
1473            reflog: None,
1474            flags: RefUpdateFlags::default(),
1475        },
1476    )?;
1477    let old = expected_old.unwrap_or_else(crate::diff::zero_oid);
1478    for name in [branch_ref, "HEAD"] {
1479        let autocreate = refs::should_autocreate_reflog_with_config(&config, repo.git_dir(), name);
1480        if let Err(err) = refs::append_reflog_on_store(
1481            repo.refs(),
1482            name,
1483            &old,
1484            &new,
1485            &identity,
1486            reason,
1487            autocreate,
1488            &config,
1489        ) {
1490            repo.diagnostics().warn(Warning::ReflogNotWritten {
1491                refname: name.to_owned(),
1492                detail: err.to_string(),
1493            });
1494        }
1495    }
1496    Ok(())
1497}
1498
1499#[cfg(test)]
1500mod integrate_tests {
1501    use std::collections::HashSet;
1502
1503    use super::*;
1504    use crate::environment::RepositoryOptions;
1505    use crate::index::{Index, IndexEntry, MODE_REGULAR};
1506    use std::collections::BTreeMap;
1507
1508    fn index_entry(path: &str, stage: u8) -> IndexEntry {
1509        let mut entry = IndexEntry {
1510            ctime_sec: 0,
1511            ctime_nsec: 0,
1512            mtime_sec: 0,
1513            mtime_nsec: 0,
1514            dev: 0,
1515            ino: 0,
1516            mode: MODE_REGULAR,
1517            uid: 0,
1518            gid: 0,
1519            size: 0,
1520            oid: crate::diff::zero_oid(),
1521            flags: 0,
1522            flags_extended: None,
1523            path: path.as_bytes().to_vec(),
1524            base_index_pos: 0,
1525        };
1526        entry.set_stage(stage);
1527        entry
1528    }
1529
1530    #[test]
1531    fn reduce_octopus_merge_heads_drops_ancestor() {
1532        let tmp = tempfile::tempdir().expect("tempdir");
1533        grit_test_support::git(tmp.path(), &["init", "-q"]);
1534        let repo =
1535            Repository::discover(Some(tmp.path()), &RepositoryOptions::empty()).expect("repo");
1536        std::fs::write(tmp.path().join("f.txt"), "a\n").expect("write");
1537        grit_test_support::git(tmp.path(), &["add", "f.txt"]);
1538        grit_test_support::git(tmp.path(), &["commit", "-m", "a"]);
1539        let a: ObjectId = grit_test_support::git(tmp.path(), &["rev-parse", "HEAD"])
1540            .trim()
1541            .parse()
1542            .expect("a");
1543        std::fs::write(tmp.path().join("f.txt"), "b\n").expect("write");
1544        grit_test_support::git(tmp.path(), &["add", "f.txt"]);
1545        grit_test_support::git(tmp.path(), &["commit", "-m", "b"]);
1546        let b: ObjectId = grit_test_support::git(tmp.path(), &["rev-parse", "HEAD"])
1547            .trim()
1548            .parse()
1549            .expect("b");
1550        let (oids, names) =
1551            reduce_octopus_merge_heads(&repo, &[a, b], &["a".into(), "b".into()]).expect("reduce");
1552        assert_eq!(oids, vec![b]);
1553        assert_eq!(names, vec!["b".to_owned()]);
1554    }
1555
1556    #[test]
1557    fn compose_fast_forward_index_keeps_staged_additions() {
1558        let tmp = tempfile::tempdir().expect("tempdir");
1559        grit_test_support::git(tmp.path(), &["init", "-q"]);
1560        let repo =
1561            Repository::discover(Some(tmp.path()), &RepositoryOptions::empty()).expect("repo");
1562        std::fs::write(tmp.path().join("tracked.txt"), "v1\n").expect("write");
1563        grit_test_support::git(tmp.path(), &["add", "tracked.txt"]);
1564        grit_test_support::git(tmp.path(), &["commit", "-m", "head"]);
1565        let head: ObjectId = grit_test_support::git(tmp.path(), &["rev-parse", "HEAD"])
1566            .trim()
1567            .parse()
1568            .expect("head");
1569        let head_tree = repo.odb().commit_tree(&head).expect("head tree");
1570        std::fs::write(tmp.path().join("tracked.txt"), "v2\n").expect("write");
1571        grit_test_support::git(tmp.path(), &["add", "tracked.txt"]);
1572        grit_test_support::git(tmp.path(), &["commit", "-m", "target"]);
1573        let target_tree = repo
1574            .odb()
1575            .commit_tree(
1576                &grit_test_support::git(tmp.path(), &["rev-parse", "HEAD"])
1577                    .trim()
1578                    .parse()
1579                    .expect("target"),
1580            )
1581            .expect("target tree");
1582        std::fs::write(tmp.path().join("new.txt"), "staged\n").expect("write");
1583        grit_test_support::git(tmp.path(), &["add", "new.txt"]);
1584        let index = repo.load_index().expect("index");
1585        let ff = compose_fast_forward_index(&repo, target_tree, head_tree, &index).expect("ff");
1586        assert!(ff.get(b"new.txt", 0).is_some());
1587        assert!(ff.get(b"tracked.txt", 0).is_some());
1588    }
1589
1590    #[test]
1591    fn predict_merge_mem_overlay_guard_restores_outer_scope() {
1592        let tmp = tempfile::tempdir().expect("tempdir");
1593        grit_test_support::git(tmp.path(), &["init", "-q", "-b", "main"]);
1594        let repo =
1595            Repository::discover(Some(tmp.path()), &RepositoryOptions::empty()).expect("repo");
1596        std::fs::write(tmp.path().join("f.txt"), "a\n").expect("write");
1597        grit_test_support::git(tmp.path(), &["add", "f.txt"]);
1598        grit_test_support::git(tmp.path(), &["commit", "-m", "one"]);
1599        let head: ObjectId = grit_test_support::git(tmp.path(), &["rev-parse", "HEAD"])
1600            .trim()
1601            .parse()
1602            .expect("head");
1603        grit_test_support::git(tmp.path(), &["branch", "topic"]);
1604        std::fs::write(tmp.path().join("f.txt"), "b\n").expect("write");
1605        grit_test_support::git(tmp.path(), &["add", "f.txt"]);
1606        grit_test_support::git(tmp.path(), &["commit", "-m", "two"]);
1607        let tip: ObjectId = grit_test_support::git(tmp.path(), &["rev-parse", "HEAD"])
1608            .trim()
1609            .parse()
1610            .expect("tip");
1611        grit_test_support::git(tmp.path(), &["checkout", "main"]);
1612
1613        repo.odb().enable_mem_overlay();
1614        assert!(repo.odb().is_mem_overlay_active());
1615        let _ =
1616            predict_merge(&repo, head, tip, &MergePredictionOptions::default()).expect("predict");
1617        assert!(
1618            repo.odb().is_mem_overlay_active(),
1619            "outer mem overlay must stay active after predict_merge"
1620        );
1621        repo.odb().disable_mem_overlay();
1622    }
1623
1624    #[test]
1625    fn integrate_branch_fast_forward_and_up_to_date() {
1626        let tmp = tempfile::tempdir().expect("tempdir");
1627        grit_test_support::git(tmp.path(), &["init", "-q", "-b", "main"]);
1628        let repo =
1629            Repository::discover(Some(tmp.path()), &RepositoryOptions::empty()).expect("repo");
1630        std::fs::write(tmp.path().join("f.txt"), "v1\n").expect("write");
1631        grit_test_support::git(tmp.path(), &["add", "f.txt"]);
1632        grit_test_support::git(tmp.path(), &["commit", "-m", "one"]);
1633        let head: ObjectId = grit_test_support::git(tmp.path(), &["rev-parse", "HEAD"])
1634            .trim()
1635            .parse()
1636            .expect("head");
1637        grit_test_support::git(tmp.path(), &["checkout", "-q", "-b", "topic"]);
1638        std::fs::write(tmp.path().join("f.txt"), "v2\n").expect("write");
1639        grit_test_support::git(tmp.path(), &["add", "f.txt"]);
1640        grit_test_support::git(tmp.path(), &["commit", "-m", "two"]);
1641        let tip: ObjectId = grit_test_support::git(tmp.path(), &["rev-parse", "HEAD"])
1642            .trim()
1643            .parse()
1644            .expect("tip");
1645        grit_test_support::git(tmp.path(), &["checkout", "main"]);
1646        let up = integrate_branch(&repo, "refs/heads/main", head, head, "topic").expect("up");
1647        assert_eq!(up, IntegrateBranchOutcome::UpToDate);
1648        let ff = integrate_branch(&repo, "refs/heads/main", head, tip, "topic").expect("ff");
1649        assert!(matches!(ff, IntegrateBranchOutcome::FastForward { .. }));
1650    }
1651
1652    #[test]
1653    fn compose_octopus_final_index_carries_staged_paths() {
1654        let mut pre = Index::new();
1655        pre.add_or_replace(index_entry("staged.txt", 0));
1656        let mut final_index = Index::new();
1657        final_index.add_or_replace(index_entry("merged.txt", 0));
1658        compose_octopus_final_index(&pre, &mut final_index);
1659        assert!(final_index.get(b"staged.txt", 0).is_some());
1660        assert!(final_index.get(b"merged.txt", 0).is_some());
1661    }
1662
1663    #[test]
1664    fn tree_to_index_entries_and_map_roundtrip() {
1665        let tmp = tempfile::tempdir().expect("tempdir");
1666        grit_test_support::git(tmp.path(), &["init", "-q"]);
1667        let repo =
1668            Repository::discover(Some(tmp.path()), &RepositoryOptions::empty()).expect("repo");
1669        std::fs::write(tmp.path().join("a.txt"), "a\n").expect("write");
1670        grit_test_support::git(tmp.path(), &["add", "a.txt"]);
1671        grit_test_support::git(tmp.path(), &["commit", "-m", "c"]);
1672        let tree: ObjectId = grit_test_support::git(tmp.path(), &["rev-parse", "HEAD^{tree}"])
1673            .trim()
1674            .parse()
1675            .expect("tree");
1676        let entries = tree_to_index_entries(&repo, &tree, "").expect("entries");
1677        let map = tree_to_map(entries.clone());
1678        assert_eq!(map.len(), entries.len());
1679    }
1680
1681    #[test]
1682    fn tree_merge_conflict_paths_unions_markers_and_unmerged_index() {
1683        let mut index = Index::new();
1684        index.add_or_replace(index_entry("b.txt", 1));
1685        index.add_or_replace(index_entry("b.txt", 2));
1686        let mut conflict_content = BTreeMap::new();
1687        conflict_content.insert(b"a.txt".to_vec(), crate::diff::zero_oid());
1688        let merged = TreeMergeOutput {
1689            index,
1690            conflict_content,
1691            result_tree: crate::diff::zero_oid(),
1692            touched_paths: HashSet::new(),
1693            rename_conflicts: Vec::new(),
1694        };
1695        let paths: Vec<String> = conflicts_from_tree_merge(&merged)
1696            .into_iter()
1697            .map(|c| c.path)
1698            .collect();
1699        assert_eq!(paths, vec!["a.txt".to_owned(), "b.txt".to_owned()]);
1700        assert!(tree_merge_has_conflicts(&merged));
1701    }
1702
1703    #[test]
1704    fn integrate_branch_merged_without_conflicts() {
1705        let tmp = tempfile::tempdir().expect("tempdir");
1706        grit_test_support::git(tmp.path(), &["init", "-q", "-b", "main"]);
1707        grit_test_support::git(tmp.path(), &["config", "user.name", "Test"]);
1708        grit_test_support::git(tmp.path(), &["config", "user.email", "t@example.com"]);
1709        let repo =
1710            Repository::discover(Some(tmp.path()), &RepositoryOptions::empty()).expect("repo");
1711        std::fs::write(tmp.path().join("shared.txt"), "base\n").expect("write");
1712        grit_test_support::git(tmp.path(), &["add", "shared.txt"]);
1713        grit_test_support::git(tmp.path(), &["commit", "-m", "base"]);
1714        grit_test_support::git(tmp.path(), &["switch", "-c", "topic"]);
1715        std::fs::write(tmp.path().join("shared.txt"), "topic\n").expect("write");
1716        grit_test_support::git(tmp.path(), &["add", "shared.txt"]);
1717        grit_test_support::git(tmp.path(), &["commit", "-m", "topic"]);
1718        let topic: ObjectId = grit_test_support::git(tmp.path(), &["rev-parse", "topic"])
1719            .trim()
1720            .parse()
1721            .expect("topic");
1722        grit_test_support::git(tmp.path(), &["checkout", "main"]);
1723        std::fs::write(tmp.path().join("main-only.txt"), "main\n").expect("write");
1724        grit_test_support::git(tmp.path(), &["add", "main-only.txt"]);
1725        grit_test_support::git(tmp.path(), &["commit", "-m", "main"]);
1726        let main: ObjectId = grit_test_support::git(tmp.path(), &["rev-parse", "HEAD"])
1727            .trim()
1728            .parse()
1729            .expect("main");
1730        let outcome =
1731            integrate_branch(&repo, "refs/heads/main", main, topic, "topic").expect("merge");
1732        let IntegrateBranchOutcome::Merged { oid, .. } = outcome else {
1733            panic!("expected clean merge, got {outcome:?}");
1734        };
1735        let tip: ObjectId = grit_test_support::git(tmp.path(), &["rev-parse", "refs/heads/main"])
1736            .trim()
1737            .parse()
1738            .expect("tip");
1739        assert_eq!(tip, oid);
1740        grit_test_support::git(tmp.path(), &["fsck", "--strict"]);
1741    }
1742
1743    #[test]
1744    fn integrate_branch_conflicts_then_abort() {
1745        let tmp = tempfile::tempdir().expect("tempdir");
1746        grit_test_support::git(tmp.path(), &["init", "-q", "-b", "main"]);
1747        let repo =
1748            Repository::discover(Some(tmp.path()), &RepositoryOptions::empty()).expect("repo");
1749        std::fs::write(tmp.path().join("f.txt"), "base\n").expect("write");
1750        grit_test_support::git(tmp.path(), &["add", "f.txt"]);
1751        grit_test_support::git(tmp.path(), &["commit", "-m", "base"]);
1752        grit_test_support::git(tmp.path(), &["switch", "-c", "topic"]);
1753        std::fs::write(tmp.path().join("f.txt"), "theirs\n").expect("write");
1754        grit_test_support::git(tmp.path(), &["add", "f.txt"]);
1755        grit_test_support::git(tmp.path(), &["commit", "-m", "theirs"]);
1756        let topic: ObjectId = grit_test_support::git(tmp.path(), &["rev-parse", "topic"])
1757            .trim()
1758            .parse()
1759            .expect("topic");
1760        grit_test_support::git(tmp.path(), &["checkout", "main"]);
1761        std::fs::write(tmp.path().join("f.txt"), "ours\n").expect("write");
1762        grit_test_support::git(tmp.path(), &["add", "f.txt"]);
1763        grit_test_support::git(tmp.path(), &["commit", "-m", "ours"]);
1764        let main: ObjectId = grit_test_support::git(tmp.path(), &["rev-parse", "HEAD"])
1765            .trim()
1766            .parse()
1767            .expect("main");
1768        let outcome =
1769            integrate_branch(&repo, "refs/heads/main", main, topic, "topic").expect("merge");
1770        let IntegrateBranchOutcome::Conflicts { paths, .. } = outcome else {
1771            panic!("expected conflicts, got {outcome:?}");
1772        };
1773        assert!(paths.iter().any(|p| p == "f.txt"));
1774        assert!(tmp.path().join(".git/MERGE_HEAD").exists());
1775        abort_branch_merge(&repo).expect("abort");
1776        assert!(!tmp.path().join(".git/MERGE_HEAD").exists());
1777        let tip: ObjectId = grit_test_support::git(tmp.path(), &["rev-parse", "HEAD"])
1778            .trim()
1779            .parse()
1780            .expect("tip");
1781        assert_eq!(tip, main);
1782    }
1783
1784    #[test]
1785    fn continue_merge_errors_list_paths_plainly() {
1786        let paths = vec!["f.txt".to_owned(), "dir/g h.txt".to_owned()];
1787        let unmerged = ContinueMergeError::UnmergedPaths {
1788            paths: paths.clone(),
1789        }
1790        .to_string();
1791        assert_eq!(
1792            unmerged,
1793            "you still have unmerged paths: f.txt, dir/g h.txt"
1794        );
1795        let unstaged = ContinueMergeError::ResolvedNotStaged { paths }.to_string();
1796        assert_eq!(
1797            unstaged,
1798            "resolved in the work tree but not staged: f.txt, dir/g h.txt"
1799        );
1800        for message in [unmerged, unstaged] {
1801            assert!(!message.contains(['[', ']', '"']), "{message}");
1802        }
1803    }
1804}