Skip to main content

mkit_cli/commands/
merge.rs

1//! `mkit merge <branch> | --continue | --abort` — merge a branch into
2//! HEAD, with a resolvable-conflict workflow (#177).
3//!
4//! Behaviour:
5//!
6//! 1. Resolve HEAD (ours) and the target ref (theirs).
7//! 2. If equal → "already up to date".
8//! 3. Find the merge base; if `base == ours`, fast-forward HEAD to
9//!    theirs and restore the worktree to theirs' tree.
10//! 4. Otherwise run a 3-way tree merge. On conflict, materialise the
11//!    conflict material (markers for text, ours-side for binary/special)
12//!    into the worktree + index, persist `MERGE_HEAD`/`MERGE_MSG`/
13//!    `ORIG_HEAD` and the `mkit-conflicts` sidecar, and exit non-zero
14//!    with resolve instructions. The user resolves, `mkit add`s, then
15//!    runs `mkit merge --continue`.
16//! 5. Clean merge: sign a new merge commit with two parents and advance
17//!    the current branch.
18//!
19//! `--continue` refuses unless `MERGE_HEAD` exists and no marker-bearing
20//! conflicting file remains; it builds the final tree from the resolved
21//! index (NOT the conflict-time ours-wins tree). `--abort` restores
22//! HEAD/ref/index/worktree to `ORIG_HEAD` and clears all state.
23
24use std::io::Write;
25use std::time::{SystemTime, UNIX_EPOCH};
26
27use clap::{Parser, ValueEnum};
28use mkit_core::hash::Hash;
29use mkit_core::layout::RepoLayout;
30use mkit_core::object::{Commit, Object};
31use mkit_core::ops::conflict_state::{self, MergeState, in_progress_op_name, is_merge_in_progress};
32use mkit_core::ops::merge::{find_merge_base, merge_trees};
33use mkit_core::refs;
34use mkit_core::serialize;
35use mkit_core::store::ObjectStore;
36use mkit_core::worktree;
37
38use super::{advance_head, error as emit_err, load_tree_hash};
39use crate::clap_shim;
40use crate::config;
41use crate::exit;
42use crate::format::{self, JsonObject, json_string_array};
43
44#[derive(Debug, Clone, Copy, ValueEnum)]
45enum MergeFormat {
46    Default,
47    Json,
48}
49
50#[derive(Debug, Parser)]
51#[command(name = "mkit merge", about = "Three-way merge a branch into HEAD.")]
52struct MergeOpts {
53    /// Continue an in-progress merge after resolving conflicts.
54    #[arg(long = "continue", conflicts_with_all = ["abort", "branch"])]
55    cont: bool,
56    /// Abort the in-progress merge and restore the original HEAD.
57    #[arg(long, conflicts_with_all = ["cont", "branch"])]
58    abort: bool,
59    /// Perform the merge but stop before creating the merge commit (like
60    /// `git merge --no-commit`): stage the merged tree and record
61    /// `MERGE_HEAD`. Finish with `mkit commit` (a two-parent merge commit)
62    /// or `mkit merge --continue`. Fast-forward updates create no commit,
63    /// so `--no-commit` does not affect them.
64    #[arg(long = "no-commit", conflicts_with_all = ["cont", "abort"])]
65    no_commit: bool,
66    /// Override the merge commit message (default `Merge branch '<name>'`).
67    #[arg(short = 'm', long = "message", conflicts_with_all = ["cont", "abort"])]
68    message: Option<String>,
69    /// Emit a machine-readable JSON result object to stdout describing
70    /// the outcome: a clean merge/fast-forward, `--no-commit` staging, a
71    /// conflict pause (`"conflicts":[<path>,...]`), or an error.
72    #[arg(long, value_enum, default_value = "default")]
73    format: MergeFormat,
74    /// Branch to merge into HEAD.
75    branch: Option<String>,
76}
77
78#[must_use]
79pub fn run(args: &[String]) -> u8 {
80    let opts = match clap_shim::parse::<MergeOpts>("mkit merge", args) {
81        Ok(o) => o,
82        Err(code) => return code,
83    };
84    let json = matches!(opts.format, MergeFormat::Json);
85    let cwd = match std::env::current_dir() {
86        Ok(p) => p,
87        Err(e) => return emit_err(&format!("cwd: {e}"), exit::NOINPUT),
88    };
89    let layout = match super::resolve_layout(&cwd) {
90        Ok(layout) => layout,
91        Err(code) => return code,
92    };
93    let store = match ObjectStore::open(&layout) {
94        Ok(s) => s,
95        Err(e) => return emit_err(&format!("not a mkit repo: {e}"), exit::GENERAL_ERROR),
96    };
97    let _lock = match super::acquire_worktree_lock(&layout) {
98        Ok(l) => l,
99        Err(code) => return code,
100    };
101
102    if opts.abort {
103        abort(&layout, &store, json)
104    } else if opts.cont {
105        cont(&layout, &store, json)
106    } else if let Some(branch) = opts.branch.as_deref() {
107        start(
108            &layout,
109            &store,
110            branch,
111            opts.no_commit,
112            opts.message.as_deref(),
113            json,
114        )
115    } else {
116        super::usage_error("usage: mkit merge <branch> | --continue | --abort")
117    }
118}
119
120/// `error(msg, code)` plus, when `json` is set, a `{"ok":false,...}`
121/// line on stdout — so every exit path leaves `--format=json` callers
122/// with a self-contained payload, not just the documented conflict
123/// shape.
124fn emit_err_json(msg: &str, code: u8, json: bool) -> u8 {
125    if json {
126        let mut obj = JsonObject::new();
127        obj.field_bool("ok", false).field_str("error", msg);
128        let mut stdout = std::io::stdout().lock();
129        let _ = writeln!(stdout, "{}", obj.finish());
130    }
131    emit_err(msg, code)
132}
133
134#[allow(clippy::too_many_lines)]
135fn start(
136    layout: &RepoLayout,
137    store: &ObjectStore,
138    branch: &str,
139    no_commit: bool,
140    message: Option<&str>,
141    json: bool,
142) -> u8 {
143    // Shadow the module-level `emit_err` for the rest of this function:
144    // every early-return error now also prints a `{"ok":false,...}` line
145    // to stdout when `--format=json` is set, without touching each call
146    // site below individually.
147    let emit_err = |msg: &str, code: u8| emit_err_json(msg, code, json);
148
149    if let Some(op) = in_progress_op_name(layout) {
150        return emit_err(
151            &format!("a {op} is already in progress (use --continue or --abort)"),
152            exit::GENERAL_ERROR,
153        );
154    }
155
156    let ours = match refs::resolve_head(layout) {
157        Ok(Some(h)) => h,
158        Ok(None) => return emit_err("no commits on current branch", exit::GENERAL_ERROR),
159        Err(e) => return emit_err(&format!("resolve HEAD: {e}"), exit::GENERAL_ERROR),
160    };
161    // Accept any revspec — branches, tags, remote-tracking refs
162    // (`<remote>/<branch>`), hashes — peeling annotated tags to the
163    // commit. Branch names keep their historical precedence because
164    // resolve_revision checks refs/heads first.
165    let theirs = match super::revspec::resolve_revision(store, layout, branch) {
166        Ok(h) => super::log::peel_tags(store, h),
167        Err(e) => return emit_err(&format!("merge target: {e}"), exit::GENERAL_ERROR),
168    };
169
170    if ours == theirs {
171        let mut stderr = std::io::stderr().lock();
172        let _ = writeln!(stderr, "Already up to date.");
173        drop(stderr);
174        if json {
175            let mut obj = JsonObject::new();
176            obj.field_bool("ok", true)
177                .field_str("kind", "up-to-date")
178                .field_hash("hash", &ours);
179            let mut stdout = std::io::stdout().lock();
180            let _ = writeln!(stdout, "{}", obj.finish());
181        }
182        return exit::OK;
183    }
184
185    let base = match find_merge_base(store, ours, theirs) {
186        Ok(b) => b,
187        Err(e) => return emit_err(&format!("find merge base: {e}"), exit::GENERAL_ERROR),
188    };
189
190    // Fast-forward when base == ours.
191    if let Some(bh) = base
192        && bh == ours
193    {
194        let theirs_tree = match load_tree_hash(store, theirs) {
195            Ok(t) => t,
196            Err(code) => return code,
197        };
198        if let Err(e) = super::ensure_restore_safe(layout, store, theirs_tree) {
199            return emit_err(&e, exit::GENERAL_ERROR);
200        }
201        if let Err(e) = super::restore_worktree_and_index(layout, store, theirs_tree) {
202            return emit_err(&e, exit::GENERAL_ERROR);
203        }
204        if let Err(e) = advance_head(layout, &theirs) {
205            return emit_err(&e, exit::CANTCREAT);
206        }
207        // git-shaped fast-forward report: `Updating <old>..<new>` +
208        // `Fast-forward` + the diffstat.
209        let mut stderr = std::io::stderr().lock();
210        let _ = writeln!(
211            stderr,
212            "Updating {}..{}",
213            format::short_hash(&ours, format::SUMMARY_ABBREV),
214            format::short_hash(&theirs, format::SUMMARY_ABBREV),
215        );
216        let _ = writeln!(stderr, "Fast-forward");
217        drop(stderr);
218        print_merge_stat(store, ours, theirs);
219        if json {
220            let mut obj = JsonObject::new();
221            obj.field_bool("ok", true)
222                .field_str("kind", "fast-forward")
223                .field_hash("old", &ours)
224                .field_hash("new", &theirs);
225            let mut stdout = std::io::stdout().lock();
226            let _ = writeln!(stdout, "{}", obj.finish());
227        }
228        return exit::OK;
229    }
230
231    let ours_tree = match load_tree_hash(store, ours) {
232        Ok(t) => t,
233        Err(code) => return code,
234    };
235    let theirs_tree = match load_tree_hash(store, theirs) {
236        Ok(t) => t,
237        Err(code) => return code,
238    };
239    let base_tree: Option<Hash> = match base {
240        Some(b) => match load_tree_hash(store, b) {
241            Ok(t) => Some(t),
242            Err(code) => return code,
243        },
244        None => None,
245    };
246
247    let result = match merge_trees(store, base_tree, Some(ours_tree), Some(theirs_tree)) {
248        Ok(r) => r,
249        Err(e) => return emit_err(&format!("merge: {e}"), exit::GENERAL_ERROR),
250    };
251
252    // git's convention distinguishes the source kind in the message; `-m`
253    // overrides it outright.
254    let msg = match message {
255        Some(m) => m.to_string(),
256        None if merge_source_is_remote_tracking(layout, branch) => {
257            let short = branch.strip_prefix("refs/remotes/").unwrap_or(branch);
258            format!("Merge remote-tracking branch '{short}'")
259        }
260        None => format!("Merge branch '{branch}'"),
261    };
262
263    if result.has_conflicts() {
264        // Guard: never clobber dirty tracked / untracked collisions.
265        if let Err(e) = super::ensure_restore_safe(layout, store, result.tree_hash) {
266            return emit_err(&e, exit::GENERAL_ERROR);
267        }
268        let records = match super::conflict::materialize_conflicts(
269            layout,
270            store,
271            result.tree_hash,
272            &result.conflicts,
273        ) {
274            Ok(r) => r,
275            Err(e) => return emit_err(&e, exit::GENERAL_ERROR),
276        };
277        let state = MergeState {
278            merge_head: theirs,
279            orig_head: ours,
280            message: msg.into_bytes(),
281        };
282        if let Err(e) = conflict_state::write_merge_state(layout, &state, &records) {
283            return emit_err(&format!("write merge state: {e}"), exit::CANTCREAT);
284        }
285        // Record the merge result tree so `--abort` treats the operation's
286        // clean hunks (not just conflict paths) as discardable.
287        if let Err(e) =
288            conflict_state::write_result_tree(layout.worktree_state_dir(), &result.tree_hash)
289        {
290            return emit_err(&format!("write merge state: {e}"), exit::CANTCREAT);
291        }
292        let mut stderr = std::io::stderr().lock();
293        // git-shaped conflict lines, additive — followed by mkit's own
294        // resumable-flow hint.
295        for rec in &records {
296            let _ = writeln!(stderr, "CONFLICT (content): Merge conflict in {}", rec.path);
297        }
298        let _ = writeln!(
299            stderr,
300            "Automatic merge failed; fix conflicts and then commit the result."
301        );
302        let _ = writeln!(
303            stderr,
304            "hint: resolve the files above, `mkit add` them, then run \
305             `mkit merge --continue` (or `mkit merge --abort`)"
306        );
307        drop(stderr);
308        if json {
309            let paths: Vec<&str> = records.iter().map(|r| r.path.as_str()).collect();
310            let mut obj = JsonObject::new();
311            obj.field_bool("ok", false)
312                .field_str("kind", "conflict")
313                .field_raw("conflicts", &json_string_array(&paths))
314                .field_str(
315                    "error",
316                    "automatic merge failed; fix conflicts and then commit the result",
317                );
318            let mut stdout = std::io::stdout().lock();
319            let _ = writeln!(stdout, "{}", obj.finish());
320        }
321        return exit::GENERAL_ERROR;
322    }
323
324    if let Err(e) = super::ensure_restore_safe(layout, store, result.tree_hash) {
325        return emit_err(&e, exit::GENERAL_ERROR);
326    }
327
328    // `--no-commit`: stage the merged tree and record `MERGE_HEAD` with no
329    // conflicts, then stop. The next `mkit commit` records a two-parent
330    // merge commit (it consumes `MERGE_HEAD`); `mkit merge --continue`
331    // does the same.
332    if no_commit {
333        if let Err(e) = super::restore_worktree_and_index(layout, store, result.tree_hash) {
334            return emit_err(&e, exit::GENERAL_ERROR);
335        }
336        // A tree can't encode deletions, so staging the result tree drops
337        // them. Re-stage Removed tombstones (like cherry-pick/revert -n) so an
338        // all-deletions merge leaves a non-empty index: otherwise `commit`'s
339        // index reads as empty and `merge --continue`/`merge --abort` (which
340        // seed an empty index from HEAD) would build/judge against the OLD
341        // tree, dropping the deletions.
342        if let Err(e) =
343            super::stage_removed_tombstones(layout, store, Some(ours_tree), result.tree_hash)
344        {
345            return emit_err(&e, exit::GENERAL_ERROR);
346        }
347        let state = MergeState {
348            merge_head: theirs,
349            orig_head: ours,
350            message: msg.into_bytes(),
351        };
352        if let Err(e) = conflict_state::write_merge_state(layout, &state, &[]) {
353            return emit_err(&format!("write merge state: {e}"), exit::CANTCREAT);
354        }
355        // Record the merge result tree so `--abort` can tell the staged merge
356        // (discardable) from genuine user work staged on top of it.
357        if let Err(e) =
358            conflict_state::write_result_tree(layout.worktree_state_dir(), &result.tree_hash)
359        {
360            return emit_err(&format!("write merge state: {e}"), exit::CANTCREAT);
361        }
362        let mut stderr = std::io::stderr().lock();
363        let _ = writeln!(
364            stderr,
365            "automatic merge went well; stopped before committing as requested\n\
366             commit the result with `mkit commit` (or `mkit merge --continue`)"
367        );
368        drop(stderr);
369        if json {
370            let mut obj = JsonObject::new();
371            obj.field_bool("ok", true)
372                .field_str("kind", "no-commit")
373                .field_hash("tree", &result.tree_hash);
374            let mut stdout = std::io::stdout().lock();
375            let _ = writeln!(stdout, "{}", obj.finish());
376        }
377        return exit::OK;
378    }
379
380    // Clean merge — build a merge commit with two parents.
381    let commit_hash = match create_merge_commit(
382        layout,
383        store,
384        result.tree_hash,
385        ours,
386        theirs,
387        msg.as_bytes(),
388    ) {
389        Ok(h) => h,
390        Err(code) => return code,
391    };
392    if let Err(e) = super::restore_worktree_and_index(layout, store, result.tree_hash) {
393        return emit_err(&e, exit::GENERAL_ERROR);
394    }
395    if let Err(e) = advance_head(layout, &commit_hash) {
396        return emit_err(&e, exit::CANTCREAT);
397    }
398    // git-shaped true-merge report: `Merge made by the 'ort' strategy.` +
399    // the diffstat (ours → merged tree).
400    {
401        let mut stderr = std::io::stderr().lock();
402        let _ = writeln!(stderr, "Merge made by the 'ort' strategy.");
403    }
404    print_merge_stat_trees(store, Some(ours_tree), Some(result.tree_hash));
405    if json {
406        let mut obj = JsonObject::new();
407        obj.field_bool("ok", true)
408            .field_str("kind", "merge-commit")
409            .field_hash("hash", &commit_hash)
410            .field_raw(
411                "parents",
412                &json_string_array(&[format::hex_hash(&ours), format::hex_hash(&theirs)]),
413            )
414            .field_hash("tree", &result.tree_hash);
415        let mut stdout = std::io::stdout().lock();
416        let _ = writeln!(stdout, "{}", obj.finish());
417    }
418    exit::OK
419}
420
421/// Best-effort `Fast-forward` / merge diffstat between two commits' trees,
422/// reusing `diff`'s renderer. Failures are silent (the headline already
423/// printed).
424fn print_merge_stat(store: &ObjectStore, old: Hash, new: Hash) {
425    let old_tree = load_tree_hash(store, old).ok();
426    let new_tree = load_tree_hash(store, new).ok();
427    print_merge_stat_trees(store, old_tree, new_tree);
428}
429
430fn print_merge_stat_trees(store: &ObjectStore, old_tree: Option<Hash>, new_tree: Option<Hash>) {
431    if let Ok(result) = mkit_core::ops::diff_trees(store, old_tree, new_tree) {
432        let mut stderr = std::io::stderr().lock();
433        // `render_stat` hoists its own `DisplaySource` wrapping (#625).
434        let _ = super::diff::render_stat(&mut stderr, store, result.entries.iter());
435    }
436}
437
438fn cont(layout: &RepoLayout, store: &ObjectStore, json: bool) -> u8 {
439    let emit_err = |msg: &str, code: u8| emit_err_json(msg, code, json);
440    if !is_merge_in_progress(layout) {
441        return emit_err("no merge in progress", exit::GENERAL_ERROR);
442    }
443    let state = match conflict_state::read_merge_state(layout) {
444        Ok(Some(s)) => s,
445        Ok(None) => return emit_err("no merge in progress", exit::GENERAL_ERROR),
446        Err(e) => return emit_err(&format!("read merge state: {e}"), exit::GENERAL_ERROR),
447    };
448    let records = match conflict_state::read_conflicts(layout.worktree_state_dir()) {
449        Ok(r) => r,
450        Err(e) => return emit_err(&format!("read conflicts: {e}"), exit::GENERAL_ERROR),
451    };
452    match super::conflict::first_unresolved_marker(layout.worktree_root(), &records) {
453        Ok(Some(path)) => {
454            return emit_err(
455                &format!(
456                    "unresolved conflict markers remain in '{path}'; resolve and `mkit add` it"
457                ),
458                exit::GENERAL_ERROR,
459            );
460        }
461        Ok(None) => {}
462        Err(e) => return emit_err(&e, exit::GENERAL_ERROR),
463    }
464    if let Err(e) = super::conflict::ensure_conflict_paths_staged(layout, store, &records) {
465        return emit_err(&e, exit::GENERAL_ERROR);
466    }
467
468    // Build the final tree from the resolved index — NOT the
469    // conflict-time ours-wins tree.
470    let idx = match super::read_or_seed_index_from_head(layout, store) {
471        Ok(i) => i,
472        Err(e) => return emit_err(&e, exit::GENERAL_ERROR),
473    };
474    let tree_hash = match worktree::build_tree_from_index(store, &idx) {
475        Ok(t) => t,
476        Err(e) => return emit_err(&format!("build tree from index: {e}"), exit::GENERAL_ERROR),
477    };
478
479    let commit_hash = match create_merge_commit(
480        layout,
481        store,
482        tree_hash,
483        state.orig_head,
484        state.merge_head,
485        &state.message,
486    ) {
487        Ok(h) => h,
488        Err(code) => return code,
489    };
490    // Sync the index to the committed tree WITHOUT rewriting the worktree.
491    // The tree was built from the index, so the worktree already holds the
492    // resolved content; restoring it would clobber any unstaged edits the
493    // user made after staging — `git commit` (and `mkit commit`) leave the
494    // worktree untouched here.
495    if let Err(e) = super::sync_index_to_tree(layout, store, tree_hash) {
496        return emit_err(&e, exit::GENERAL_ERROR);
497    }
498    if let Err(e) = advance_head(layout, &commit_hash) {
499        return emit_err(&e, exit::CANTCREAT);
500    }
501    if let Err(e) = conflict_state::clear_merge_state(layout) {
502        return emit_err(&format!("clear merge state: {e}"), exit::GENERAL_ERROR);
503    }
504    let mut stderr = std::io::stderr().lock();
505    let _ = writeln!(
506        stderr,
507        "merge {} into HEAD ({})",
508        format::short_hash(&state.merge_head, 8),
509        format::short_hash(&commit_hash, 8)
510    );
511    drop(stderr);
512    if json {
513        let mut obj = JsonObject::new();
514        obj.field_bool("ok", true)
515            .field_str("kind", "merge-commit")
516            .field_hash("hash", &commit_hash)
517            .field_raw(
518                "parents",
519                &json_string_array(&[
520                    format::hex_hash(&state.orig_head),
521                    format::hex_hash(&state.merge_head),
522                ]),
523            )
524            .field_hash("tree", &tree_hash);
525        let mut stdout = std::io::stdout().lock();
526        let _ = writeln!(stdout, "{}", obj.finish());
527    }
528    exit::OK
529}
530
531fn abort(layout: &RepoLayout, store: &ObjectStore, json: bool) -> u8 {
532    let emit_err = |msg: &str, code: u8| emit_err_json(msg, code, json);
533    if !is_merge_in_progress(layout) {
534        return emit_err("no merge in progress", exit::GENERAL_ERROR);
535    }
536    let state = match conflict_state::read_merge_state(layout) {
537        Ok(Some(s)) => s,
538        Ok(None) => return emit_err("no merge in progress", exit::GENERAL_ERROR),
539        Err(e) => return emit_err(&format!("read merge state: {e}"), exit::GENERAL_ERROR),
540    };
541    let records = match conflict_state::read_conflicts(layout.worktree_state_dir()) {
542        Ok(r) => r,
543        Err(e) => return emit_err(&format!("read conflicts: {e}"), exit::GENERAL_ERROR),
544    };
545    if let Err(code) = restore_to(layout, store, state.orig_head, &records) {
546        return code;
547    }
548    if let Err(e) = conflict_state::clear_merge_state(layout) {
549        return emit_err(&format!("clear merge state: {e}"), exit::GENERAL_ERROR);
550    }
551    let mut stderr = std::io::stderr().lock();
552    let _ = writeln!(stderr, "merge aborted; HEAD restored");
553    drop(stderr);
554    if json {
555        let mut obj = JsonObject::new();
556        obj.field_bool("ok", true)
557            .field_str("kind", "aborted")
558            .field_hash("hash", &state.orig_head);
559        let mut stdout = std::io::stdout().lock();
560        let _ = writeln!(stdout, "{}", obj.finish());
561    }
562    exit::OK
563}
564
565/// Restore worktree + index + HEAD/ref to `target` (the pre-op HEAD).
566/// Routes the branch advance through the history-MMR helper.
567fn restore_to(
568    layout: &RepoLayout,
569    store: &ObjectStore,
570    target: Hash,
571    records: &[mkit_core::ops::conflict_state::ConflictRecord],
572) -> Result<(), u8> {
573    let target_tree = load_tree_hash(store, target)?;
574    // The operation's result tree — written for BOTH conflict merges and a
575    // clean `merge --no-commit` — lets the guards treat the merge's own,
576    // user-untouched output as discardable while protecting genuine work the
577    // user staged or edited on top of it (a conflict resolution, an unrelated
578    // `mkit add`, or an edit to a cleanly-merged file).
579    let op_result = conflict_state::read_result_tree(layout.worktree_state_dir())
580        .ok()
581        .flatten();
582    // Pre-flight: refuse *before* any mutation when restoring would clobber
583    // genuine user work on a non-discardable path (the reset below discards
584    // the operation material).
585    if let Err(e) =
586        super::conflict::ensure_abort_safe(layout, store, records, target_tree, op_result)
587    {
588        return Err(emit_err(&e, exit::GENERAL_ERROR));
589    }
590    // Discard the operation material on the discardable paths so the guarded
591    // restore doesn't see it as user "local changes" (it still protects
592    // unrelated dirty/untracked work).
593    if let Err(e) =
594        super::conflict::reset_conflict_paths(layout, store, records, target_tree, op_result)
595    {
596        return Err(emit_err(&e, exit::GENERAL_ERROR));
597    }
598    if let Err(e) = super::ensure_restore_safe(layout, store, target_tree) {
599        return Err(emit_err(&e, exit::GENERAL_ERROR));
600    }
601    if let Err(e) = super::restore_worktree_and_index(layout, store, target_tree) {
602        return Err(emit_err(&e, exit::GENERAL_ERROR));
603    }
604    super::restore_head_ref(layout, &target)
605}
606
607fn create_merge_commit(
608    layout: &RepoLayout,
609    store: &ObjectStore,
610    tree_hash: Hash,
611    parent_ours: Hash,
612    parent_theirs: Hash,
613    message: &[u8],
614) -> Result<Hash, u8> {
615    let cfg = config::read_or_default(layout)
616        .map_err(|e| emit_err(&format!("config: {e}"), exit::CONFIG_ERROR))?;
617    let mut signer = super::commit::load_commit_signer(layout, &cfg)
618        .map_err(|(msg, code)| emit_err(&msg, code))?;
619    let signer_public = signer
620        .public_key()
621        .map_err(|(msg, code)| emit_err(&msg, code))?;
622    let author = super::commit::resolve_author(None, &cfg.user_identity, &signer_public)
623        .map_err(|e| emit_err(&format!("author: {e}"), exit::CONFIG_ERROR))?;
624    let timestamp = SystemTime::now()
625        .duration_since(UNIX_EPOCH)
626        .map_or(0, |d| d.as_secs());
627    let mut unsigned = Commit::new_unannotated(
628        tree_hash,
629        vec![parent_ours, parent_theirs],
630        author,
631        signer_public,
632        message.to_vec(),
633        timestamp,
634        [0u8; 64],
635    );
636    let sig = signer
637        .sign_commit(&unsigned)
638        .map_err(|(msg, code)| emit_err(&msg, code))?;
639    unsigned.signature = sig;
640    let bytes = serialize::serialize(&Object::Commit(unsigned))
641        .map_err(|e| emit_err(&format!("serialize: {e}"), exit::DATAERR))?;
642    store
643        .write(&bytes)
644        .map_err(|e| emit_err(&format!("store commit: {e}"), exit::CANTCREAT))
645}
646
647/// Whether `spec` names a remote-tracking ref under revspec
648/// precedence (local branches and tags win over `<remote>/<branch>`).
649fn merge_source_is_remote_tracking(layout: &RepoLayout, spec: &str) -> bool {
650    let rel = spec.strip_prefix("refs/remotes/").map_or(spec, |r| r);
651    let Some((remote, branch)) = rel.split_once('/') else {
652        return false;
653    };
654    if refs::read_ref(layout, spec).is_ok_and(|r| r.is_some())
655        || refs::read_tag(layout, spec).is_ok_and(|r| r.is_some())
656    {
657        return false; // a local ref of the same spelling shadows it
658    }
659    refs::read_remote_ref(layout, remote, branch).is_ok_and(|r| r.is_some())
660}