Jump to…
snowinitial commitqoxwzsukwmkx1mo
1//! Landing a change onto a bookmark (decided §13.2).
2//!
3//! > **§13.2 merge semantics** — server-side fast-forward + merge commit.
4//! > Rebase-and-merge deferred.
5//!
6//! Two outcomes, chosen by the graph rather than by the caller:
7//!
8//! * The bookmark is an ancestor of the change → **fast-forward**. No commit is
9//! written; the ref moves. This is the case that matters for jj users, whose
10//! changes are usually already on top of the target.
11//! * Otherwise → a three-way **merge commit**, with the bookmark as first parent
12//! and the change as second, exactly as `git merge --no-ff` would order them.
13//!
14//! A textual conflict refuses the merge rather than writing conflict markers
15//! into the target. Spec §4 is explicit that conflict resolution happens in the
16//! user's working copy, and a server that resolves by writing `<<<<<<<` into
17//! `main` would be doing the opposite of that.
18//!
19//! The ref update is a **compare-and-swap** against the bookmark's expected
20//! value. Two merges racing on one bookmark must not silently lose one of them,
21//! and the pre-receive hook is not involved here because this write does not go
22//! through the Git wire protocol.
23
24use gix::refs::transaction::{Change, PreviousValue, RefEdit};
25use gix::refs::{FullName, Target};
26
27use crate::{MergeOutcome, Result, RevId, Signature, StoreError};
28
29/// Merge `rev` into `bookmark`.
30pub fn merge(
31 repo: &gix::Repository,
32 bookmark: &str,
33 rev: &RevId,
34 message: &str,
35 author: &Signature,
36) -> Result<MergeOutcome> {
37 let full = format!("refs/heads/{bookmark}");
38 let name: FullName = full
39 .as_str()
40 .try_into()
41 .map_err(|_| StoreError::Path(crate::path::PathError::Control))?;
42
43 let target_ref = repo
44 .find_reference(&full)
45 .map_err(|_| StoreError::NoSuchRevision)?;
46 let target_id = target_ref
47 .try_id()
48 .ok_or_else(|| StoreError::Other(anyhow::anyhow!("{bookmark} is a symbolic reference")))?
49 .detach();
50
51 let source_id = super::convert::parse_oid(repo, rev)?;
52
53 // Already landed. Reported rather than treated as an error: two people
54 // clicking merge is a race, not a mistake, and the second one should be told
55 // it is done, not shown a failure.
56 if target_id == source_id
57 || is_ancestor(repo, source_id, target_id)?
58 {
59 return Ok(MergeOutcome::AlreadyMerged);
60 }
61
62 // ── fast-forward ─────────────────────────────────────────────────────────
63 if is_ancestor(repo, target_id, source_id)? {
64 set_ref(repo, name, target_id, source_id, "merge: fast-forward")?;
65 return Ok(MergeOutcome::FastForward {
66 new_target: RevId::from_stored(source_id.to_string()),
67 });
68 }
69
70 // ── three-way merge ──────────────────────────────────────────────────────
71 let ours = tree_of(repo, target_id)?;
72 let theirs = tree_of(repo, source_id)?;
73
74 // No merge base means unrelated histories. Git needs a flag for that case
75 // and so do we — silently merging two unrelated trees is how a repository
76 // ends up with somebody else's files in it.
77 let base = repo
78 .merge_base(target_id, source_id)
79 .map_err(|_| StoreError::Other(anyhow::anyhow!("no common ancestor")))?
80 .detach();
81 let base_tree = tree_of(repo, base)?;
82
83 let options = repo
84 .tree_merge_options()
85 .map_err(|e| StoreError::Other(anyhow::anyhow!("merge options: {e}")))?;
86
87 let labels = gix::merge::blob::builtin_driver::text::Labels {
88 ancestor: Some("base".into()),
89 current: Some(bookmark.into()),
90 other: Some("change".into()),
91 };
92
93 let mut outcome = repo
94 .merge_trees(base_tree, ours, theirs, labels, options)
95 .map_err(|e| StoreError::Other(anyhow::anyhow!("merging trees: {e}")))?;
96
97 // `ConflictMarkers` is the strictest reading: anything that would have
98 // needed a marker written into the file counts as unresolved. A rename
99 // conflict that gix can auto-resolve is allowed through, exactly as a
100 // client-side `git merge` would.
101 if outcome.has_unresolved_conflicts(gix::merge::tree::TreatAsUnresolved::ConflictMarkers) {
102 return Ok(MergeOutcome::Conflicted);
103 }
104
105 let merged_tree = outcome
106 .tree
107 .write()
108 .map_err(|e| StoreError::Other(anyhow::anyhow!("writing merged tree: {e}")))?
109 .detach();
110
111 let sig = gix::actor::Signature {
112 name: author.name.clone().into(),
113 email: author.email.clone().into(),
114 time: gix::date::Time::new(author.when.timestamp(), 0),
115 };
116
117 // `commit_as` writes the commit *and* moves the reference in one call, with
118 // the reference update constrained to the value we read above.
119 let sig_ref = sig.to_ref();
120 let new_commit = repo
121 .commit_as(
122 sig_ref,
123 sig_ref,
124 name.as_ref().as_bstr(),
125 message,
126 merged_tree,
127 // Bookmark first, change second — the ordering `git log --first-parent`
128 // relies on to follow the target branch's own history.
129 [target_id, source_id],
130 )
131 .map_err(|e| StoreError::Other(anyhow::anyhow!("writing merge commit: {e}")))?
132 .detach();
133
134 Ok(MergeOutcome::Merged {
135 new_target: RevId::from_stored(new_commit.to_string()),
136 })
137}
138
139fn tree_of(repo: &gix::Repository, commit: gix::ObjectId) -> Result<gix::ObjectId> {
140 Ok(repo
141 .find_object(commit)
142 .map_err(|_| StoreError::NoSuchRevision)?
143 .try_into_commit()
144 .map_err(|_| StoreError::NoSuchRevision)?
145 .tree_id()
146 .map_err(|e| StoreError::Other(anyhow::anyhow!("reading tree: {e}")))?
147 .detach())
148}
149
150fn is_ancestor(
151 repo: &gix::Repository,
152 ancestor: gix::ObjectId,
153 descendant: gix::ObjectId,
154) -> Result<bool> {
155 if ancestor == descendant {
156 return Ok(true);
157 }
158 // `merge_base(a, b) == a` is the definition of "a is an ancestor of b".
159 Ok(repo
160 .merge_base(ancestor, descendant)
161 .map(|b| b.detach() == ancestor)
162 .unwrap_or(false))
163}
164
165/// Move a reference, refusing if it has changed since we read it.
166fn set_ref(
167 repo: &gix::Repository,
168 name: FullName,
169 expected: gix::ObjectId,
170 new: gix::ObjectId,
171 reason: &str,
172) -> Result<()> {
173 repo.edit_reference(RefEdit {
174 change: Change::Update {
175 log: gix::refs::transaction::LogChange {
176 mode: gix::refs::transaction::RefLog::AndReference,
177 force_create_reflog: false,
178 message: reason.into(),
179 },
180 // The compare-and-swap. If another push moved the bookmark between
181 // the read and this write, the transaction fails and the caller is
182 // told to retry rather than silently discarding that push.
183 expected: PreviousValue::MustExistAndMatch(Target::Object(expected)),
184 new: Target::Object(new),
185 },
186 name,
187 deref: false,
188 })
189 .map_err(|e| {
190 StoreError::Other(anyhow::anyhow!(
191 "the bookmark moved while merging; nothing was changed ({e})"
192 ))
193 })?;
194 Ok(())
195}

195 lines · Rust