Skip to main content

gunnar_server/
merge_engine.rs

1//! **Gunnar's merge engine** (GUN-MERGE-ENGINE, 2026-11-01): the three-way merge a pull
2//! request needs when its branch does not descend from its target.
3//!
4//! Until this module, `PullRequests.Merge` could only fast-forward, and a pull request
5//! whose branch had fallen behind its target was refused with "gunnar has no merge engine
6//! yet". This is that engine, and it is deliberately small:
7//!
8//! * **The merge base** ([`merge_base`]): git's own "paint down to common" walk, ordered by
9//!   committer time, with redundant candidates removed. A criss-cross history (more than
10//!   one best base) and unrelated histories are refused by name rather than guessed at;
11//!   git answers the first with a recursive virtual base, which is a merge of merges and
12//!   belongs on a person's machine.
13//! * **The tree merge** ([`Merger::merge_trees`]): per path, the side that changed wins;
14//!   where both changed a regular file, the file is merged by line ([`merge_text`]); where
15//!   both changed anything else differently (modify/delete, file/directory, a symlink, a
16//!   submodule, an add/add of different content, a binary file) the path is a **conflict**.
17//!   Every conflict is collected, so a refusal names all of them at once.
18//! * **The line merge** ([`merge_text`]): diff3 over [`crate::diff::diff_ops`], the same
19//!   Myers walk `Refs.Diff` and blame use (one implementation). Changes from the two sides
20//!   that overlap or touch are a conflict unless both sides made the same change: the
21//!   conservative reading, never a guess.
22//!
23//! # Why it is written here, not taken from `gix-merge`
24//!
25//! `gix-merge` has a tree merge, and the serving tier links no gix porcelain: the engine
26//! seam (`xtask/src/engine_seam.rs`) keeps `gix`, `gix-object` and their tail out of
27//! `gunnar-server`, and `gix-merge` would bring `gix-diff`, `gix-filter`, `gix-worktree`
28//! and more back into the appliance. Everything a merge needs from a repository is
29//! `GitServe::read`, which every engine has; the objects it makes are git's own formats,
30//! written as one pack through the same `put_pack` a push lands through.
31//!
32//! **Nothing is written by this module.** It answers objects in memory ([`NewObjects`]);
33//! the control plane decides whether they are stored, after every gate has run over them
34//! through [`Overlay`].
35
36use std::collections::{BTreeMap, BinaryHeap, HashMap, HashSet};
37
38use gix_hash::{Kind as HashKind, ObjectId};
39use gunnar_store::git_storage_trait::{
40    self as gst, Caps, GcReport, GitOps, GitServe, ObjType, PackStats, ReachSet, RefCas, RefRow,
41    RefUpdate, Stored, TxId,
42};
43use gunnar_store::object::{self, TreeIter};
44
45/// The largest file the line merge reads, per side. Past it the path is a conflict: a
46/// line merge of a 16 MiB file is not something a person reviewed line by line either.
47pub const MAX_TEXT_MERGE_BYTES: usize = 16 * 1024 * 1024;
48
49/// How many trees and blobs one merge may read before it stops and refuses. Large enough
50/// for any honest repository's changed paths (only differing subtrees are opened), small
51/// enough that a crafted history cannot keep a control-plane call busy for minutes.
52pub const MAX_OBJECT_READS: usize = 500_000;
53
54/// git's `S_IFMT` mask and the four formats a tree entry can have.
55const IFMT: u32 = 0o170000;
56const IFDIR: u32 = 0o040000;
57const IFREG: u32 = 0o100000;
58
59/// A failure that is not a conflict: an object missing, a budget hit, a decode error.
60/// The control plane refuses on it, never merges.
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct EngineError(pub String);
63
64impl std::fmt::Display for EngineError {
65    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66        f.write_str(&self.0)
67    }
68}
69
70type Result<T> = std::result::Result<T, EngineError>;
71
72fn err(what: impl Into<String>) -> EngineError {
73    EngineError(what.into())
74}
75
76/// One path both sides changed in a way gunnar will not merge, and why.
77#[derive(Debug, Clone, PartialEq, Eq)]
78pub struct Conflict {
79    /// The path from the repository root, `/`-separated (lossy UTF-8).
80    pub path: String,
81    /// One short phrase: "both sides changed the same lines", "deleted on one side,
82    /// changed on the other", …
83    pub why: &'static str,
84}
85
86impl std::fmt::Display for Conflict {
87    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
88        write!(f, "{} ({})", self.path, self.why)
89    }
90}
91
92/// The objects a merge made, in memory, in the order they must be stored (blobs and
93/// trees before the commit that names them). Ids already in the repository are skipped
94/// by the writer, not here.
95#[derive(Debug, Default)]
96pub struct NewObjects {
97    order: Vec<ObjectId>,
98    objects: HashMap<Vec<u8>, (ObjType, Vec<u8>)>,
99}
100
101impl NewObjects {
102    fn add(&mut self, hash: HashKind, kind: ObjType, content: Vec<u8>) -> Result<ObjectId> {
103        let id = crate::first_commit::object_id(hash, word(kind), &content).map_err(err)?;
104        if !self.objects.contains_key(id.as_bytes()) {
105            self.order.push(id);
106            self.objects.insert(id.as_bytes().to_vec(), (kind, content));
107        }
108        Ok(id)
109    }
110
111    /// One object by id, if this merge made it.
112    pub fn get(&self, id: &[u8]) -> Option<&(ObjType, Vec<u8>)> {
113        self.objects.get(id)
114    }
115
116    /// How many objects the merge made.
117    pub fn len(&self) -> usize {
118        self.order.len()
119    }
120
121    /// Whether the merge made none (a fast-forward, or a result that is a side's tree).
122    pub fn is_empty(&self) -> bool {
123        self.order.is_empty()
124    }
125
126    /// The objects as one version-2 pack in `hash`, leaving out every id `git` already
127    /// holds. `None` when nothing is new.
128    pub fn pack(&self, hash: HashKind, git: &dyn GitServe) -> Result<Option<Vec<u8>>> {
129        let mut items: Vec<(u8, &[u8])> = Vec::new();
130        for id in &self.order {
131            if git
132                .has(id.as_bytes())
133                .map_err(|e| err(format!("asking the store for {id}: {e:#}")))?
134            {
135                continue;
136            }
137            let (kind, content) = &self.objects[id.as_bytes()];
138            items.push((kind.code(), content));
139        }
140        if items.is_empty() {
141            return Ok(None);
142        }
143        crate::first_commit::pack_of(hash, &items)
144            .map(Some)
145            .map_err(err)
146    }
147}
148
149fn word(kind: ObjType) -> &'static str {
150    match kind {
151        ObjType::Commit => "commit",
152        ObjType::Tree => "tree",
153        ObjType::Tag => "tag",
154        _ => "blob",
155    }
156}
157
158fn hex(id: &ObjectId) -> String {
159    id.to_hex().to_string()
160}
161
162/// Read one object, as the type the caller expects.
163fn read_as(git: &dyn GitServe, id: &ObjectId, want: ObjType) -> Result<Vec<u8>> {
164    match git.read(id.as_bytes()) {
165        Ok(Some((kind, bytes))) if kind == want => Ok(bytes),
166        Ok(Some((kind, _))) => Err(err(format!(
167            "{} is a {kind:?}, not the {want:?} the merge expected",
168            hex(id)
169        ))),
170        Ok(None) => Err(err(format!("{} is not in the repository", hex(id)))),
171        Err(e) => Err(err(format!("reading {}: {e:#}", hex(id)))),
172    }
173}
174
175/// A commit's parents and committer time.
176fn commit_facts(git: &dyn GitServe, hash: HashKind, id: &ObjectId) -> Result<(Vec<ObjectId>, i64)> {
177    let bytes = read_as(git, id, ObjType::Commit)?;
178    let mut parents = Vec::new();
179    let (_, at) = object::commit_facts(&bytes, hash, &mut parents)
180        .map_err(|e| err(format!("decoding commit {}: {e}", hex(id))))?;
181    Ok((parents, at))
182}
183
184/// A commit's root tree.
185pub fn commit_tree(git: &dyn GitServe, hash: HashKind, id: &ObjectId) -> Result<ObjectId> {
186    let bytes = read_as(git, id, ObjType::Commit)?;
187    let mut parents = Vec::new();
188    object::commit_links(&bytes, hash, &mut parents)
189        .map_err(|e| err(format!("decoding commit {}: {e}", hex(id))))
190}
191
192// ───────────────────────────── the merge base ────────────────────────────────
193
194const P1: u8 = 1;
195const P2: u8 = 2;
196const STALE: u8 = 4;
197const RESULT: u8 = 8;
198
199/// **The one best common ancestor of `a` and `b`** — `git merge-base a b`, refusing where
200/// git would have to invent a virtual base.
201///
202/// git's paint walk: `a` is painted P1, `b` P2, commits are taken newest first and pass
203/// their paint to their parents; a commit holding both paints is a candidate, and its
204/// ancestors are painted STALE so the walk stops below it. Candidates that turn out to be
205/// ancestors of other candidates are dropped.
206///
207/// # Errors
208///
209/// No common ancestor (unrelated histories), more than one best one (criss-cross), a
210/// commit missing, or more than `budget` commits walked: each by name.
211pub fn merge_base(
212    git: &dyn GitServe,
213    hash: HashKind,
214    a: &ObjectId,
215    b: &ObjectId,
216    budget: usize,
217) -> Result<ObjectId> {
218    if a == b {
219        return Ok(*a);
220    }
221    let mut flags: HashMap<ObjectId, u8> = HashMap::new();
222    let mut queue: BinaryHeap<(i64, ObjectId)> = BinaryHeap::new();
223    let mut facts: HashMap<ObjectId, (Vec<ObjectId>, i64)> = HashMap::new();
224    let load =
225        |id: &ObjectId, facts: &mut HashMap<ObjectId, (Vec<ObjectId>, i64)>| -> Result<i64> {
226            if let Some((_, at)) = facts.get(id) {
227                return Ok(*at);
228            }
229            let f = commit_facts(git, hash, id)?;
230            let at = f.1;
231            facts.insert(*id, f);
232            Ok(at)
233        };
234    for (id, paint) in [(a, P1), (b, P2)] {
235        let at = load(id, &mut facts)?;
236        *flags.entry(*id).or_default() |= paint;
237        queue.push((at, *id));
238    }
239    let mut results: Vec<ObjectId> = Vec::new();
240    let mut walked = 0usize;
241    while queue
242        .iter()
243        .any(|(_, id)| flags.get(id).copied().unwrap_or(0) & STALE == 0)
244    {
245        let Some((_, id)) = queue.pop() else { break };
246        walked += 1;
247        if walked > budget {
248            return Err(err(format!(
249                "the merge base of {} and {} is more than {budget} commits away; refusing \
250                 rather than walking further",
251                hex(a),
252                hex(b)
253            )));
254        }
255        let mut paint = flags[&id] & (P1 | P2 | STALE);
256        if paint == P1 | P2 {
257            if flags[&id] & RESULT == 0 {
258                *flags.get_mut(&id).expect("painted") |= RESULT;
259                results.push(id);
260            }
261            paint |= STALE;
262        }
263        load(&id, &mut facts)?;
264        let parents = facts[&id].0.clone();
265        for parent in parents {
266            let have = flags.get(&parent).copied().unwrap_or(0);
267            if have & paint == paint {
268                continue;
269            }
270            let at = load(&parent, &mut facts)?;
271            *flags.entry(parent).or_default() |= paint;
272            queue.push((at, parent));
273        }
274    }
275    // A candidate later painted STALE sits below another candidate.
276    results.retain(|id| flags[id] & STALE == 0);
277    let mut best: Vec<ObjectId> = Vec::new();
278    for candidate in &results {
279        let redundant = results.iter().any(|other| {
280            other != candidate
281                && gunnar_policy::ancestry::is_descendant_of(git, other, candidate, budget)
282                    .unwrap_or(false)
283        });
284        if !redundant {
285            best.push(*candidate);
286        }
287    }
288    match best.as_slice() {
289        [] => Err(err(format!(
290            "{} and {} share no history: there is no merge base to merge from",
291            hex(a),
292            hex(b)
293        ))),
294        [one] => Ok(*one),
295        many => Err(err(format!(
296            "{} and {} have {} best merge bases ({}): a criss-cross history, which gunnar \
297             does not merge on the server — merge locally and push the result",
298            hex(a),
299            hex(b),
300            many.len(),
301            many.iter()
302                .map(|id| hex(id)[..12].to_owned())
303                .collect::<Vec<_>>()
304                .join(", ")
305        ))),
306    }
307}
308
309// ───────────────────────────── the tree merge ────────────────────────────────
310
311/// One tree entry: its raw mode and the object it names.
312#[derive(Debug, Clone, Copy, PartialEq, Eq)]
313struct Ent {
314    mode: u32,
315    oid: ObjectId,
316}
317
318impl Ent {
319    fn is_tree(&self) -> bool {
320        self.mode & IFMT == IFDIR
321    }
322    fn is_regular(&self) -> bool {
323        self.mode & IFMT == IFREG
324    }
325}
326
327/// What a tree merge answers.
328#[derive(Debug)]
329pub enum TreeMerge {
330    /// The merged root tree; its new objects are in the [`Merger`].
331    Clean(ObjectId),
332    /// Every path that conflicts. Nothing to store.
333    Conflicts(Vec<Conflict>),
334}
335
336/// The tree merge over one repository.
337pub struct Merger<'a> {
338    git: &'a dyn GitServe,
339    hash: HashKind,
340    /// Whether the repository is encrypted: its blobs are ciphertext, and a line merge
341    /// of ciphertext is garbage, so a both-sides content change is a conflict there.
342    encrypted: bool,
343    /// What the merge made.
344    pub new: NewObjects,
345    conflicts: Vec<Conflict>,
346    reads: usize,
347}
348
349impl<'a> Merger<'a> {
350    /// A merger over `git`, in `hash`.
351    pub fn new(git: &'a dyn GitServe, hash: HashKind, encrypted: bool) -> Self {
352        Merger {
353            git,
354            hash,
355            encrypted,
356            new: NewObjects::default(),
357            conflicts: Vec::new(),
358            reads: 0,
359        }
360    }
361
362    fn read(&mut self, id: &ObjectId, want: ObjType) -> Result<Vec<u8>> {
363        self.reads += 1;
364        if self.reads > MAX_OBJECT_READS {
365            return Err(err(format!(
366                "the merge read more than {MAX_OBJECT_READS} objects; refusing rather than \
367                 reading further"
368            )));
369        }
370        read_as(self.git, id, want)
371    }
372
373    fn entries(&mut self, tree: Option<&ObjectId>) -> Result<BTreeMap<Vec<u8>, Ent>> {
374        let mut out = BTreeMap::new();
375        let Some(tree) = tree else { return Ok(out) };
376        let bytes = self.read(tree, ObjType::Tree)?;
377        for entry in TreeIter::from_bytes(&bytes, self.hash).named() {
378            let entry = entry.map_err(|e| err(format!("decoding tree {}: {e}", hex(tree))))?;
379            out.insert(
380                entry.name.to_vec(),
381                Ent {
382                    mode: entry.mode,
383                    oid: entry.oid,
384                },
385            );
386        }
387        Ok(out)
388    }
389
390    /// Write a tree of `entries` (git's order: a subtree sorts as its name plus `/`).
391    /// `None` for an empty subtree, which git does not keep.
392    fn write_tree(&mut self, entries: &BTreeMap<Vec<u8>, Ent>) -> Result<ObjectId> {
393        let mut sorted: Vec<(Vec<u8>, &Ent)> = entries
394            .iter()
395            .map(|(name, ent)| {
396                let mut key = name.clone();
397                if ent.is_tree() {
398                    key.push(b'/');
399                }
400                (key, ent)
401            })
402            .collect();
403        sorted.sort_by(|a, b| a.0.cmp(&b.0));
404        let mut body = Vec::new();
405        for (key, ent) in sorted {
406            let name = if ent.is_tree() {
407                &key[..key.len() - 1]
408            } else {
409                &key[..]
410            };
411            let mode = if ent.is_tree() { IFDIR } else { ent.mode };
412            body.extend_from_slice(format!("{mode:o} ").as_bytes());
413            body.extend_from_slice(name);
414            body.push(0);
415            body.extend_from_slice(ent.oid.as_bytes());
416        }
417        self.new.add(self.hash, ObjType::Tree, body)
418    }
419
420    /// **Merge `theirs` into `ours` over `base`** (root trees). `base` is `None` for an
421    /// add/add of a whole directory.
422    pub fn merge_trees(
423        &mut self,
424        base: Option<&ObjectId>,
425        ours: &ObjectId,
426        theirs: &ObjectId,
427    ) -> Result<TreeMerge> {
428        let root = self.merge_dir(base, Some(ours), Some(theirs), "")?;
429        if !self.conflicts.is_empty() {
430            return Ok(TreeMerge::Conflicts(std::mem::take(&mut self.conflicts)));
431        }
432        let root = match root {
433            Some(id) => id,
434            None => self.write_tree(&BTreeMap::new())?,
435        };
436        Ok(TreeMerge::Clean(root))
437    }
438
439    fn conflict(&mut self, path: String, why: &'static str) {
440        self.conflicts.push(Conflict { path, why });
441    }
442
443    /// Merge one directory; `None` when the result is empty.
444    fn merge_dir(
445        &mut self,
446        base: Option<&ObjectId>,
447        ours: Option<&ObjectId>,
448        theirs: Option<&ObjectId>,
449        prefix: &str,
450    ) -> Result<Option<ObjectId>> {
451        let b = self.entries(base)?;
452        let o = self.entries(ours)?;
453        let t = self.entries(theirs)?;
454        let names: std::collections::BTreeSet<&Vec<u8>> =
455            b.keys().chain(o.keys()).chain(t.keys()).collect();
456        let mut out: BTreeMap<Vec<u8>, Ent> = BTreeMap::new();
457        for name in names {
458            let path = if prefix.is_empty() {
459                String::from_utf8_lossy(name).into_owned()
460            } else {
461                format!("{prefix}/{}", String::from_utf8_lossy(name))
462            };
463            let (be, oe, te) = (
464                b.get(name).copied(),
465                o.get(name).copied(),
466                t.get(name).copied(),
467            );
468            if let Some(ent) = self.merge_entry(be, oe, te, &path)? {
469                out.insert(name.clone(), ent);
470            }
471        }
472        if out.is_empty() {
473            return Ok(None);
474        }
475        // A directory that came out exactly as one side had it is that side's tree.
476        if ours.is_some() && out == o {
477            return Ok(ours.copied());
478        }
479        if theirs.is_some() && out == t {
480            return Ok(theirs.copied());
481        }
482        self.write_tree(&out).map(Some)
483    }
484
485    fn merge_entry(
486        &mut self,
487        b: Option<Ent>,
488        o: Option<Ent>,
489        t: Option<Ent>,
490        path: &str,
491    ) -> Result<Option<Ent>> {
492        // The side that changed wins; both the same is that.
493        if o == t {
494            return Ok(o);
495        }
496        if b == o {
497            return Ok(t);
498        }
499        if b == t {
500            return Ok(o);
501        }
502        // Both changed, differently.
503        match (b, o, t) {
504            (b, Some(o), Some(t)) if o.is_tree() && t.is_tree() => {
505                let base = b.filter(Ent::is_tree).map(|e| e.oid);
506                if b.is_some() && base.is_none() {
507                    self.conflict(
508                        path.to_owned(),
509                        "a file on one side became a directory on both",
510                    );
511                    return Ok(Some(o));
512                }
513                let merged = self.merge_dir(base.as_ref(), Some(&o.oid), Some(&t.oid), path)?;
514                Ok(merged.map(|oid| Ent { mode: IFDIR, oid }))
515            }
516            (Some(b), Some(o), Some(t)) if b.is_regular() && o.is_regular() && t.is_regular() => {
517                let mode = if o.mode == t.mode || b.mode == t.mode {
518                    o.mode
519                } else if b.mode == o.mode {
520                    t.mode
521                } else {
522                    self.conflict(path.to_owned(), "both sides changed the file mode");
523                    return Ok(Some(o));
524                };
525                if o.oid == t.oid {
526                    return Ok(Some(Ent { mode, oid: o.oid }));
527                }
528                if b.oid == o.oid {
529                    return Ok(Some(Ent { mode, oid: t.oid }));
530                }
531                if b.oid == t.oid {
532                    return Ok(Some(Ent { mode, oid: o.oid }));
533                }
534                if self.encrypted {
535                    self.conflict(
536                        path.to_owned(),
537                        "both sides changed an encrypted file, which the server cannot read",
538                    );
539                    return Ok(Some(o));
540                }
541                let base = self.read(&b.oid, ObjType::Blob)?;
542                let ours = self.read(&o.oid, ObjType::Blob)?;
543                let theirs = self.read(&t.oid, ObjType::Blob)?;
544                match merge_text(&base, &ours, &theirs) {
545                    Ok(merged) => {
546                        let oid = self.new.add(self.hash, ObjType::Blob, merged)?;
547                        Ok(Some(Ent { mode, oid }))
548                    }
549                    Err(why) => {
550                        self.conflict(path.to_owned(), why.phrase());
551                        Ok(Some(o))
552                    }
553                }
554            }
555            (None, Some(_), Some(_)) => {
556                self.conflict(
557                    path.to_owned(),
558                    "added on both sides with different content",
559                );
560                Ok(o)
561            }
562            (Some(_), None, Some(_)) | (Some(_), Some(_), None) => {
563                self.conflict(path.to_owned(), "deleted on one side, changed on the other");
564                Ok(o)
565            }
566            (_, Some(o), Some(t)) if o.is_tree() != t.is_tree() => {
567                self.conflict(
568                    path.to_owned(),
569                    "a file on one side, a directory on the other",
570                );
571                Ok(Some(o))
572            }
573            _ => {
574                self.conflict(
575                    path.to_owned(),
576                    "both sides changed it, and it is not a regular file (a symlink, a \
577                     submodule or a type change)",
578                );
579                Ok(o)
580            }
581        }
582    }
583}
584
585// ───────────────────────────── the line merge ────────────────────────────────
586
587/// Why a file could not be merged by line.
588#[derive(Debug, Clone, Copy, PartialEq, Eq)]
589pub enum TextConflict {
590    /// Both sides changed the same (or touching) lines differently.
591    Overlap,
592    /// One of the three versions is binary.
593    Binary,
594    /// One of the three is larger than [`MAX_TEXT_MERGE_BYTES`].
595    TooLarge,
596}
597
598impl TextConflict {
599    /// The phrase a conflict list shows.
600    pub fn phrase(self) -> &'static str {
601        match self {
602            TextConflict::Overlap => "both sides changed the same lines",
603            TextConflict::Binary => "both sides changed a binary file",
604            TextConflict::TooLarge => "both sides changed a file too large to merge by line",
605        }
606    }
607}
608
609/// One changed region of a side against the base: base lines `b0..b1` became side lines
610/// `s0..s1`.
611#[derive(Debug, Clone, Copy, PartialEq, Eq)]
612struct Hunk {
613    b0: usize,
614    b1: usize,
615    s0: usize,
616    s1: usize,
617}
618
619fn split_keep(bytes: &[u8]) -> Vec<&[u8]> {
620    bytes.split_inclusive(|b| *b == b'\n').collect()
621}
622
623/// The hunks that turn `base` into `side`. Common prefix and suffix are stripped first, so
624/// a small change to a large file is a small Myers walk.
625fn hunks(base: &[&[u8]], side: &[&[u8]]) -> Vec<Hunk> {
626    let mut start = 0;
627    while start < base.len() && start < side.len() && base[start] == side[start] {
628        start += 1;
629    }
630    let mut end = 0;
631    while end < base.len() - start
632        && end < side.len() - start
633        && base[base.len() - 1 - end] == side[side.len() - 1 - end]
634    {
635        end += 1;
636    }
637    let (bm, sm) = (
638        &base[start..base.len() - end],
639        &side[start..side.len() - end],
640    );
641    if bm.is_empty() && sm.is_empty() {
642        return Vec::new();
643    }
644    let mut out: Vec<Hunk> = Vec::new();
645    let (mut bi, mut si) = (0usize, 0usize);
646    let flush = |bi: usize, b_to: usize, si: usize, s_to: usize, out: &mut Vec<Hunk>| {
647        if bi < b_to || si < s_to {
648            out.push(Hunk {
649                b0: start + bi,
650                b1: start + b_to,
651                s0: start + si,
652                s1: start + s_to,
653            });
654        }
655    };
656    for op in crate::diff::diff_ops(bm, sm) {
657        if let crate::diff::Op::Equal { old, new, count } = op {
658            flush(bi, old, si, new, &mut out);
659            bi = old + count;
660            si = new + count;
661        }
662    }
663    flush(bi, bm.len(), si, sm.len(), &mut out);
664    out
665}
666
667/// **diff3**: merge `theirs` into `ours` over `base`, line by line.
668///
669/// Regions only one side changed take that side; regions both changed take the change
670/// when both made the same one, and are an [`TextConflict::Overlap`] otherwise. Two
671/// changes that merely *touch* (one ends where the other begins) are an overlap too:
672/// that is git's reading as well, and the safe one.
673pub fn merge_text(
674    base: &[u8],
675    ours: &[u8],
676    theirs: &[u8],
677) -> std::result::Result<Vec<u8>, TextConflict> {
678    if [base, ours, theirs]
679        .iter()
680        .any(|b| b.len() > MAX_TEXT_MERGE_BYTES)
681    {
682        return Err(TextConflict::TooLarge);
683    }
684    if [base, ours, theirs]
685        .iter()
686        .any(|b| crate::diff::is_binary(b))
687    {
688        return Err(TextConflict::Binary);
689    }
690    let bl = split_keep(base);
691    let ol = split_keep(ours);
692    let tl = split_keep(theirs);
693    let mut all: Vec<(u8, Hunk)> = hunks(&bl, &ol)
694        .into_iter()
695        .map(|h| (0u8, h))
696        .chain(hunks(&bl, &tl).into_iter().map(|h| (1u8, h)))
697        .collect();
698    all.sort_by_key(|(side, h)| (h.b0, h.b1, *side));
699    let sides = [&ol, &tl];
700    let mut out: Vec<u8> = Vec::with_capacity(ours.len().max(theirs.len()));
701    let mut pos = 0usize;
702    let mut i = 0usize;
703    while i < all.len() {
704        let lo = all[i].1.b0;
705        let mut hi = all[i].1.b1;
706        // The group: every later hunk, of either side, that overlaps or touches it.
707        let mut j = i + 1;
708        while j < all.len() && all[j].1.b0 <= hi {
709            hi = hi.max(all[j].1.b1);
710            j += 1;
711        }
712        for line in &bl[pos..lo] {
713            out.extend_from_slice(line);
714        }
715        let group = &all[i..j];
716        let region = |side: u8| -> Option<(usize, usize)> {
717            let mine: Vec<&Hunk> = group
718                .iter()
719                .filter(|(s, _)| *s == side)
720                .map(|(_, h)| h)
721                .collect();
722            let first = mine.first()?;
723            let last = mine.last()?;
724            Some((first.s0 - (first.b0 - lo), last.s1 + (hi - last.b1)))
725        };
726        match (region(0), region(1)) {
727            (Some((s0, s1)), None) => {
728                for line in &sides[0][s0..s1] {
729                    out.extend_from_slice(line);
730                }
731            }
732            (None, Some((s0, s1))) => {
733                for line in &sides[1][s0..s1] {
734                    out.extend_from_slice(line);
735                }
736            }
737            (Some((o0, o1)), Some((t0, t1))) => {
738                if sides[0][o0..o1] != sides[1][t0..t1] {
739                    return Err(TextConflict::Overlap);
740                }
741                for line in &sides[0][o0..o1] {
742                    out.extend_from_slice(line);
743                }
744            }
745            (None, None) => unreachable!("a group holds at least one hunk"),
746        }
747        pos = hi;
748        i = j;
749    }
750    for line in &bl[pos..] {
751        out.extend_from_slice(line);
752    }
753    Ok(out)
754}
755
756// ───────────────────────────── commits ───────────────────────────────────────
757
758/// A name and an address, as a commit's `author` / `committer` line spells them.
759#[derive(Debug, Clone, PartialEq, Eq)]
760pub struct Ident {
761    /// The name.
762    pub name: String,
763    /// The address, without angle brackets.
764    pub email: String,
765}
766
767impl Ident {
768    /// `name <email>`, with the characters git's grammar forbids taken out.
769    pub fn line(&self) -> String {
770        let clean = |s: &str| -> String {
771            s.chars()
772                .filter(|c| !matches!(c, '<' | '>' | '\n' | '\0'))
773                .collect::<String>()
774                .trim()
775                .to_owned()
776        };
777        format!("{} <{}>", clean(&self.name), clean(&self.email))
778    }
779}
780
781/// A commit object's bytes.
782pub fn commit_bytes(
783    tree: &ObjectId,
784    parents: &[ObjectId],
785    author: &Ident,
786    committer: &Ident,
787    when_unix: i64,
788    message: &str,
789) -> Vec<u8> {
790    let mut body = format!("tree {}\n", hex(tree));
791    for parent in parents {
792        body.push_str(&format!("parent {}\n", hex(parent)));
793    }
794    body.push_str(&format!("author {} {when_unix} +0000\n", author.line()));
795    body.push_str(&format!(
796        "committer {} {when_unix} +0000\n\n",
797        committer.line()
798    ));
799    body.push_str(message.trim_end());
800    body.push('\n');
801    body.into_bytes()
802}
803
804/// **Sign a commit gunnar wrote** (GUN-MERGE-SIGN): an SSH signature (namespace `git`)
805/// over `bytes` by `key`, folded into the header git reads it from (`gpgsig`, or
806/// `gpgsig-sha256` in a SHA-256 repository) after the last header, exactly as
807/// `git commit -S` with `gpg.format=ssh` writes one.
808pub fn sign_commit(
809    bytes: &[u8],
810    hash: HashKind,
811    key: &gunnar_core::ssh_key::PrivateKey,
812) -> Result<Vec<u8>> {
813    let header = match hash {
814        HashKind::Sha256 => "gpgsig-sha256",
815        _ => "gpgsig",
816    };
817    let end = bytes
818        .windows(2)
819        .position(|w| w == b"\n\n")
820        .ok_or_else(|| err("the commit to sign has no end of headers"))?;
821    if bytes[..end]
822        .split(|&b| b == b'\n')
823        .any(|line| line.starts_with(b"gpgsig ") || line.starts_with(b"gpgsig-sha256 "))
824    {
825        return Err(err("the commit to sign is already signed"));
826    }
827    let signature = key
828        .sign(
829            gunnar_policy::signature::GIT_NAMESPACE,
830            gunnar_core::ssh_key::HashAlg::Sha512,
831            bytes,
832        )
833        .map_err(|e| err(format!("signing the commit: {e}")))?;
834    let armor = signature
835        .to_pem(gunnar_core::ssh_key::LineEnding::LF)
836        .map_err(|e| err(format!("armouring the commit's signature: {e}")))?;
837    let mut folded = header.to_owned();
838    for (i, line) in armor.trim_end_matches('\n').split('\n').enumerate() {
839        folded.push_str(if i == 0 { " " } else { "\n " });
840        folded.push_str(line);
841    }
842    folded.push('\n');
843    let mut out = Vec::with_capacity(bytes.len() + folded.len());
844    out.extend_from_slice(&bytes[..=end]);
845    out.extend_from_slice(folded.as_bytes());
846    out.extend_from_slice(&bytes[end + 1..]);
847    Ok(out)
848}
849
850impl NewObjects {
851    /// Add a commit and answer its id.
852    pub fn add_commit(&mut self, hash: HashKind, bytes: Vec<u8>) -> Result<ObjectId> {
853        self.add(hash, ObjType::Commit, bytes)
854    }
855}
856
857// ───────────────────────────── the overlay ───────────────────────────────────
858
859/// **The repository as it would be after the merge**, for the gates: every read sees the
860/// merge's new objects on top of the store, and nothing can be written through it.
861///
862/// The gates (`BranchProtectionGate`, `SignedCommitsGate`, `AiTrailerGate`) read commits
863/// through `GitServe::read`; running them here is what lets `CheckMerge` judge a merge
864/// commit that does not exist yet — and the real merge judge it before it is stored.
865pub struct Overlay<'a> {
866    inner: &'a dyn GitServe,
867    new: &'a NewObjects,
868}
869
870impl<'a> Overlay<'a> {
871    /// `inner` with `new` on top.
872    pub fn new(inner: &'a dyn GitServe, new: &'a NewObjects) -> Self {
873        Overlay { inner, new }
874    }
875}
876
877fn read_only<T>() -> anyhow::Result<T> {
878    anyhow::bail!("the merge overlay is read-only: nothing is written before every gate has run")
879}
880
881impl GitOps for Overlay<'_> {
882    fn put(&self, _pack: &[u8], _refs: &[RefUpdate]) -> anyhow::Result<TxId> {
883        read_only()
884    }
885    fn put_pack(&self, _bytes: &[u8]) -> anyhow::Result<TxId> {
886        read_only()
887    }
888    fn put_refs(&self, _updates: &[RefUpdate]) -> anyhow::Result<TxId> {
889        read_only()
890    }
891    fn get(&self, oid: gst::Oid<'_>) -> anyhow::Result<Option<Stored>> {
892        self.inner.get(oid)
893    }
894    fn has(&self, oid: gst::Oid<'_>) -> anyhow::Result<bool> {
895        Ok(self.new.get(oid).is_some() || self.inner.has(oid)?)
896    }
897    fn size(&self, oid: gst::Oid<'_>) -> anyhow::Result<Option<u64>> {
898        match self.new.get(oid) {
899            Some((_, bytes)) => Ok(Some(bytes.len() as u64)),
900            None => self.inner.size(oid),
901        }
902    }
903    fn extents(&self, oids: &[gst::Oid<'_>]) -> anyhow::Result<Vec<Option<gst::Extent>>> {
904        self.inner.extents(oids)
905    }
906    fn refs(&self) -> anyhow::Result<Vec<RefRow>> {
907        self.inner.refs()
908    }
909    fn update_ref(
910        &self,
911        _name: &str,
912        _old: Option<gst::Oid<'_>>,
913        _new: Option<gst::Oid<'_>>,
914    ) -> anyhow::Result<TxId> {
915        read_only()
916    }
917    fn put_refs_cas(&self, _edits: &[RefCas<'_>]) -> anyhow::Result<TxId> {
918        read_only()
919    }
920    fn reachable(
921        &self,
922        want: &[gst::Oid<'_>],
923        have: &[gst::Oid<'_>],
924    ) -> anyhow::Result<Vec<Vec<u8>>> {
925        self.inner.reachable(want, have)
926    }
927    fn gc(&self) -> anyhow::Result<GcReport> {
928        read_only()
929    }
930}
931
932impl GitServe for Overlay<'_> {
933    fn read(&self, oid: gst::Oid<'_>) -> anyhow::Result<Option<(ObjType, Vec<u8>)>> {
934        match self.new.get(oid) {
935            Some((kind, bytes)) => Ok(Some((*kind, bytes.clone()))),
936            None => self.inner.read(oid),
937        }
938    }
939    fn header(&self, oid: gst::Oid<'_>) -> anyhow::Result<Option<(ObjType, u64)>> {
940        match self.new.get(oid) {
941            Some((kind, bytes)) => Ok(Some((*kind, bytes.len() as u64))),
942            None => self.inner.header(oid),
943        }
944    }
945    fn sizes(&self, oids: &[gst::Oid<'_>]) -> anyhow::Result<Vec<Option<u64>>> {
946        oids.iter().map(|oid| self.size(oid)).collect()
947    }
948    fn head(&self) -> anyhow::Result<Option<RefRow>> {
949        self.inner.head()
950    }
951    fn set_head(&self, _target: &str) -> anyhow::Result<TxId> {
952        read_only()
953    }
954    fn emit_pack(
955        &self,
956        objects: &[gst::Oid<'_>],
957        have: &[gst::Oid<'_>],
958        caps: &Caps,
959        out: &mut dyn std::io::Write,
960    ) -> anyhow::Result<PackStats> {
961        self.inner.emit_pack(objects, have, caps, out)
962    }
963    fn select(
964        &self,
965        want: &[gst::Oid<'_>],
966        have: &[gst::Oid<'_>],
967    ) -> anyhow::Result<Option<ReachSet>> {
968        self.inner.select(want, have)
969    }
970}
971
972/// Every commit subject in `commits`, for a squash's message; unreadable ones skipped.
973pub fn subjects(git: &dyn GitServe, hash: HashKind, commits: &[ObjectId]) -> Vec<String> {
974    let mut out = Vec::new();
975    let mut seen = HashSet::new();
976    for id in commits {
977        if !seen.insert(*id) {
978            continue;
979        }
980        let Ok(bytes) = read_as(git, id, ObjType::Commit) else {
981            continue;
982        };
983        let Ok(commit) = object::commit(&bytes, hash) else {
984            continue;
985        };
986        let subject = String::from_utf8_lossy(&commit.message)
987            .lines()
988            .next()
989            .unwrap_or("")
990            .trim()
991            .to_owned();
992        out.push(subject);
993    }
994    out
995}
996
997#[cfg(test)]
998mod tests;