fabrica

hanna/fabrica

feat(git): server-side merge — merge, squash, and rebase strategies

807d171 · hanna committed on 2026-07-25

Add git::merge: analyze() classifies mergeability (clean/already-merged/
conflicts) with merge-tree + merge-base without mutating anything, and merge()
integrates a head branch into its base, advancing the base ref with a compare-
and-swap. Merge and squash are worktree-free (merge-tree + commit-tree); rebase
fast-forwards a linear branch and otherwise replays commits in a throwaway
detached worktree that is always torn down. Commits carry the acting user's
identity via an isolated environment mirroring the pack transport.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
2 files changed · +542 −0UnifiedSplit
crates/git/src/lib.rs +11 −0
@@ -28,6 +28,7 @@
2828 //! name→path authority; [`repo_path`] is the one place that mapping is computed.
2929
3030 mod attributes;
31+pub mod merge;
3132 pub mod pack;
3233 mod pubkey;
3334 mod repo;
@@ -91,6 +92,16 @@ pub enum GitError {
9192 /// A human-readable parse failure reason.
9293 reason: String,
9394 },
95+
96+ /// A server-side merge failed for a reason other than a conflict (a
97+ /// subprocess error, a lost compare-and-swap race, malformed output).
98+ #[error("merge failed: {0}")]
99+ Merge(String),
100+
101+ /// A server-side merge could not apply cleanly: the three-way merge or the
102+ /// rebase replay hit conflicts.
103+ #[error("merge has conflicts")]
104+ MergeConflict,
94105 }
95106
96107 /// Compute the on-disk path of a repository from its id: `{root}/{id[0..2]}/{id}.git`.
crates/git/src/merge.rs +531 −0
@@ -0,0 +1,531 @@
1+// This Source Code Form is subject to the terms of the Mozilla Public
2+// License, v. 2.0. If a copy of the MPL was not distributed with this
3+// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4+
5+//! Server-side merge: integrate a pull request's head branch into its base.
6+//!
7+//! libgit2 has no high-level merge that writes refs, so — as with pack transport
8+//! (§3.1) — merges shell out to the `git` binary. A merge or squash needs no
9+//! working tree (`merge-tree` computes the tree, `commit-tree` seals it, then
10+//! `update-ref` advances the base with a compare-and-swap on its old value). A
11+//! rebase must replay commits, so it uses a throwaway detached worktree that is
12+//! always removed afterwards.
13+//!
14+//! Every subprocess is spawned with explicit args (never a shell) and an isolated
15+//! environment mirroring [`crate::pack`]: `HOME` is a scratch dir, system config is
16+//! off, and the author/committer identity is injected so the merge commit is
17+//! attributed to the user who pressed the button. Callers **MUST** have authorized
18+//! the merge first; this module makes no access decision.
19+
20+use std::path::Path;
21+use std::process::Command;
22+
23+use crate::GitError;
24+
25+/// How to integrate the head branch into the base.
26+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27+pub enum MergeStrategy {
28+ /// A `--no-ff` merge commit with both tips as parents.
29+ Merge,
30+ /// A single commit with the base tip as its only parent (history flattened).
31+ Squash,
32+ /// Replay the head commits onto the base tip, then advance the base.
33+ Rebase,
34+}
35+
36+impl MergeStrategy {
37+ /// The stable token stored on the pull request row.
38+ #[must_use]
39+ pub fn as_str(self) -> &'static str {
40+ match self {
41+ Self::Merge => "merge",
42+ Self::Squash => "squash",
43+ Self::Rebase => "rebase",
44+ }
45+ }
46+
47+ /// Parse a stored token, defaulting to [`MergeStrategy::Merge`] on anything
48+ /// unrecognized.
49+ #[must_use]
50+ pub fn from_token(token: &str) -> Self {
51+ match token {
52+ "squash" => Self::Squash,
53+ "rebase" => Self::Rebase,
54+ _ => Self::Merge,
55+ }
56+ }
57+}
58+
59+/// Whether a head branch can be merged into a base, computed without mutating
60+/// anything.
61+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62+pub enum Mergeability {
63+ /// The merge would apply cleanly.
64+ Clean,
65+ /// The head is already contained in the base — nothing to merge.
66+ AlreadyMerged,
67+ /// The three-way merge has conflicts a server merge cannot resolve.
68+ Conflicts,
69+}
70+
71+/// The isolated environment and identity a merge runs under.
72+#[derive(Debug, Clone)]
73+pub struct MergeIdentity<'a> {
74+ /// `HOME` for every child, so git never reads a real user's global config.
75+ pub home: &'a Path,
76+ /// The display name recorded as author and committer.
77+ pub name: &'a str,
78+ /// The email recorded as author and committer.
79+ pub email: &'a str,
80+}
81+
82+/// A merge request: what to integrate, how, and as whom.
83+#[derive(Debug, Clone)]
84+pub struct MergeRequest<'a> {
85+ /// The resolved `git` binary.
86+ pub binary: &'a str,
87+ /// The bare repository's path (its `GIT_DIR`).
88+ pub repo_path: &'a Path,
89+ /// A non-existent path at which to create the scratch worktree (rebase only);
90+ /// it is created and removed within the call.
91+ pub workdir: &'a Path,
92+ /// The base branch short name (e.g. `main`); advanced on success.
93+ pub base_branch: &'a str,
94+ /// The head ref to integrate (branch short name or oid).
95+ pub head_ref: &'a str,
96+ /// How to integrate.
97+ pub strategy: MergeStrategy,
98+ /// The commit message for the merge/squash commit (unused by a clean rebase).
99+ pub message: &'a str,
100+ /// The identity and isolation for the child processes.
101+ pub identity: MergeIdentity<'a>,
102+}
103+
104+/// Resolve a ref (or oid) to a full commit oid in `repo_path`.
105+fn rev_parse(binary: &str, repo_path: &Path, home: &Path, rev: &str) -> Result<String, GitError> {
106+ // `--verify` ensures a single object; `^{commit}` peels tags to commits.
107+ let out = base_command(binary, repo_path, home)
108+ .args(["rev-parse", "--verify", "--quiet"])
109+ .arg(format!("{rev}^{{commit}}"))
110+ .output()
111+ .map_err(|source| GitError::Io {
112+ path: repo_path.to_path_buf(),
113+ source,
114+ })?;
115+ if !out.status.success() {
116+ return Err(GitError::RevNotFound(rev.to_string()));
117+ }
118+ Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
119+}
120+
121+/// A `git` command against the bare repo with the isolated pack environment.
122+fn base_command(binary: &str, repo_path: &Path, home: &Path) -> Command {
123+ let mut cmd = Command::new(binary);
124+ cmd.env("GIT_DIR", repo_path);
125+ cmd.env("GIT_TERMINAL_PROMPT", "0");
126+ cmd.env("GIT_CONFIG_NOSYSTEM", "1");
127+ cmd.env("HOME", home);
128+ cmd.env_remove("GIT_ALTERNATE_OBJECT_DIRECTORIES");
129+ cmd.env_remove("GIT_OBJECT_DIRECTORY");
130+ cmd
131+}
132+
133+/// Analyze whether `head_ref` merges cleanly into `base_ref` without mutating the
134+/// repository.
135+///
136+/// # Errors
137+///
138+/// Returns [`GitError::RevNotFound`] if either ref is unknown, or [`GitError::Io`]
139+/// if the `git` binary cannot be run.
140+pub fn analyze(
141+ binary: &str,
142+ repo_path: &Path,
143+ home: &Path,
144+ base_ref: &str,
145+ head_ref: &str,
146+) -> Result<Mergeability, GitError> {
147+ let base = rev_parse(binary, repo_path, home, base_ref)?;
148+ let head = rev_parse(binary, repo_path, home, head_ref)?;
149+
150+ // Head already in base (fast-forward-behind or equal): nothing to do.
151+ let contained = base_command(binary, repo_path, home)
152+ .args(["merge-base", "--is-ancestor", &head, &base])
153+ .status()
154+ .map_err(|source| GitError::Io {
155+ path: repo_path.to_path_buf(),
156+ source,
157+ })?;
158+ if contained.success() {
159+ return Ok(Mergeability::AlreadyMerged);
160+ }
161+
162+ // `merge-tree --write-tree` performs a real three-way merge in memory: exit 0
163+ // means clean, exit 1 means conflicts. It writes objects into the repo but no
164+ // ref, so an unmerged PR leaves only unreferenced (gc-able) trees behind.
165+ let out = base_command(binary, repo_path, home)
166+ .args(["merge-tree", "--write-tree", &base, &head])
167+ .output()
168+ .map_err(|source| GitError::Io {
169+ path: repo_path.to_path_buf(),
170+ source,
171+ })?;
172+ match out.status.code() {
173+ Some(0) => Ok(Mergeability::Clean),
174+ Some(1) => Ok(Mergeability::Conflicts),
175+ _ => Err(GitError::Merge(
176+ String::from_utf8_lossy(&out.stderr).trim().to_string(),
177+ )),
178+ }
179+}
180+
181+/// Perform the merge, advancing the base branch, and return the new commit oid.
182+///
183+/// The base ref is updated with a compare-and-swap against the tip observed at the
184+/// start, so a concurrent push between analysis and merge is rejected rather than
185+/// silently clobbered.
186+///
187+/// # Errors
188+///
189+/// Returns [`GitError::MergeConflict`] if the merge does not apply cleanly,
190+/// [`GitError::RevNotFound`] for an unknown ref, or [`GitError::Merge`] /
191+/// [`GitError::Io`] on a subprocess failure.
192+pub fn merge(req: &MergeRequest) -> Result<String, GitError> {
193+ let base = rev_parse(
194+ req.binary,
195+ req.repo_path,
196+ req.identity.home,
197+ req.base_branch,
198+ )?;
199+ let head = rev_parse(req.binary, req.repo_path, req.identity.home, req.head_ref)?;
200+
201+ let new_tip = match req.strategy {
202+ MergeStrategy::Merge => plumbing_commit(req, &base, &[&base, &head])?,
203+ MergeStrategy::Squash => plumbing_commit(req, &base, &[&base])?,
204+ MergeStrategy::Rebase => rebase(req, &base, &head)?,
205+ };
206+
207+ // Advance the base branch, refusing if it moved since we read it.
208+ let status = identified(req)
209+ .args([
210+ "update-ref",
211+ &format!("refs/heads/{}", req.base_branch),
212+ &new_tip,
213+ &base,
214+ ])
215+ .status()
216+ .map_err(|source| GitError::Io {
217+ path: req.repo_path.to_path_buf(),
218+ source,
219+ })?;
220+ if !status.success() {
221+ return Err(GitError::Merge(format!(
222+ "base branch {} moved during merge",
223+ req.base_branch
224+ )));
225+ }
226+ Ok(new_tip)
227+}
228+
229+/// A merge or squash: compute the merged tree with `merge-tree`, then seal it with
230+/// `commit-tree` under the given parents.
231+fn plumbing_commit(req: &MergeRequest, base: &str, parents: &[&str]) -> Result<String, GitError> {
232+ let head = rev_parse(req.binary, req.repo_path, req.identity.home, req.head_ref)?;
233+ let tree_out = base_command(req.binary, req.repo_path, req.identity.home)
234+ .args(["merge-tree", "--write-tree", base, &head])
235+ .output()
236+ .map_err(|source| GitError::Io {
237+ path: req.repo_path.to_path_buf(),
238+ source,
239+ })?;
240+ match tree_out.status.code() {
241+ Some(0) => {}
242+ Some(1) => return Err(GitError::MergeConflict),
243+ _ => {
244+ return Err(GitError::Merge(
245+ String::from_utf8_lossy(&tree_out.stderr).trim().to_string(),
246+ ));
247+ }
248+ }
249+ let tree = String::from_utf8_lossy(&tree_out.stdout).trim().to_string();
250+
251+ let mut cmd = identified(req);
252+ cmd.args(["commit-tree", &tree]);
253+ for parent in parents {
254+ cmd.args(["-p", parent]);
255+ }
256+ cmd.args(["-m", req.message]);
257+ let out = cmd.output().map_err(|source| GitError::Io {
258+ path: req.repo_path.to_path_buf(),
259+ source,
260+ })?;
261+ if !out.status.success() {
262+ return Err(GitError::Merge(
263+ String::from_utf8_lossy(&out.stderr).trim().to_string(),
264+ ));
265+ }
266+ Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
267+}
268+
269+/// A rebase: replay the head commits onto the base in a throwaway detached
270+/// worktree, returning the rebased tip. The worktree is always removed.
271+fn rebase(req: &MergeRequest, base: &str, head: &str) -> Result<String, GitError> {
272+ // A clean fast-forwardable rebase (base is an ancestor of head) needs no
273+ // replay — the rebased tip is head itself.
274+ let linear = base_command(req.binary, req.repo_path, req.identity.home)
275+ .args(["merge-base", "--is-ancestor", base, head])
276+ .status()
277+ .map_err(|source| GitError::Io {
278+ path: req.repo_path.to_path_buf(),
279+ source,
280+ })?;
281+ if linear.success() {
282+ return Ok(head.to_string());
283+ }
284+
285+ let result = rebase_in_worktree(req, base, head);
286+ // Always tear the worktree down, then let git forget it.
287+ let _ = std::fs::remove_dir_all(req.workdir);
288+ let _ = base_command(req.binary, req.repo_path, req.identity.home)
289+ .args(["worktree", "prune"])
290+ .status();
291+ result
292+}
293+
294+/// The fallible body of [`rebase`], run between worktree creation and teardown.
295+fn rebase_in_worktree(req: &MergeRequest, base: &str, head: &str) -> Result<String, GitError> {
296+ let add = base_command(req.binary, req.repo_path, req.identity.home)
297+ .args(["worktree", "add", "--detach"])
298+ .arg(req.workdir)
299+ .arg(head)
300+ .output()
301+ .map_err(|source| GitError::Io {
302+ path: req.workdir.to_path_buf(),
303+ source,
304+ })?;
305+ if !add.status.success() {
306+ return Err(GitError::Merge(
307+ String::from_utf8_lossy(&add.stderr).trim().to_string(),
308+ ));
309+ }
310+
311+ let mut cmd = identified(req);
312+ cmd.current_dir(req.workdir)
313+ .env_remove("GIT_DIR")
314+ .args(["rebase", base]);
315+ let out = cmd.output().map_err(|source| GitError::Io {
316+ path: req.workdir.to_path_buf(),
317+ source,
318+ })?;
319+ if !out.status.success() {
320+ // Leave no half-applied rebase state in the worktree.
321+ let _ = identified(req)
322+ .current_dir(req.workdir)
323+ .env_remove("GIT_DIR")
324+ .args(["rebase", "--abort"])
325+ .status();
326+ return Err(GitError::MergeConflict);
327+ }
328+
329+ let tip = identified(req)
330+ .current_dir(req.workdir)
331+ .env_remove("GIT_DIR")
332+ .args(["rev-parse", "HEAD"])
333+ .output()
334+ .map_err(|source| GitError::Io {
335+ path: req.workdir.to_path_buf(),
336+ source,
337+ })?;
338+ Ok(String::from_utf8_lossy(&tip.stdout).trim().to_string())
339+}
340+
341+/// A [`base_command`] carrying the author/committer identity for commit creation.
342+fn identified(req: &MergeRequest) -> Command {
343+ let mut cmd = base_command(req.binary, req.repo_path, req.identity.home);
344+ cmd.env("GIT_AUTHOR_NAME", req.identity.name);
345+ cmd.env("GIT_AUTHOR_EMAIL", req.identity.email);
346+ cmd.env("GIT_COMMITTER_NAME", req.identity.name);
347+ cmd.env("GIT_COMMITTER_EMAIL", req.identity.email);
348+ cmd
349+}
350+
351+#[cfg(test)]
352+mod tests {
353+ #![allow(clippy::unwrap_used, clippy::expect_used)]
354+
355+ use std::path::{Path, PathBuf};
356+
357+ use git2::{Repository, Signature, Time};
358+ use tempfile::TempDir;
359+
360+ use super::*;
361+ use crate::{create_bare, repo_path};
362+
363+ const ID: &str = "01hzxk9m2n8p7q6r5s4t3v2w1x";
364+
365+ /// Whether `git` is on PATH; merge is subprocess-only, so tests skip cleanly
366+ /// when it is absent (matching the pack transport tests).
367+ fn git_present() -> bool {
368+ Command::new("git").arg("--version").output().is_ok()
369+ }
370+
371+ /// A bare repo with `main` (one commit) and `feature` (a child of `main`'s tip
372+ /// adding `feature.txt`), plus a temp `HOME`.
373+ struct Fixture {
374+ _dir: TempDir,
375+ home: TempDir,
376+ path: PathBuf,
377+ }
378+
379+ fn fixture() -> Fixture {
380+ let dir = tempfile::tempdir().unwrap();
381+ create_bare(dir.path(), ID, "main", Path::new("/usr/bin/fabrica")).unwrap();
382+ let path = repo_path(dir.path(), ID).unwrap();
383+ let raw = Repository::open_bare(&path).unwrap();
384+
385+ let base = commit(&raw, "main", &[], &[("README.md", "hello\n")], "init", 1);
386+ commit(
387+ &raw,
388+ "feature",
389+ &[base],
390+ &[("README.md", "hello\n"), ("feature.txt", "new\n")],
391+ "add feature",
392+ 2,
393+ );
394+ Fixture {
395+ _dir: dir,
396+ home: tempfile::tempdir().unwrap(),
397+ path,
398+ }
399+ }
400+
401+ fn commit(
402+ raw: &Repository,
403+ branch: &str,
404+ parents: &[git2::Oid],
405+ files: &[(&str, &str)],
406+ message: &str,
407+ t: i64,
408+ ) -> git2::Oid {
409+ let mut root = raw.treebuilder(None).unwrap();
410+ for (name, content) in files {
411+ let blob = raw.blob(content.as_bytes()).unwrap();
412+ root.insert(name, blob, 0o100_644).unwrap();
413+ }
414+ let tree = raw.find_tree(root.write().unwrap()).unwrap();
415+ let sig = Signature::new("Ada", "ada@example.com", &Time::new(t, 0)).unwrap();
416+ let parent_commits: Vec<_> = parents
417+ .iter()
418+ .map(|p| raw.find_commit(*p).unwrap())
419+ .collect();
420+ let parent_refs: Vec<&git2::Commit> = parent_commits.iter().collect();
421+ raw.commit(
422+ Some(&format!("refs/heads/{branch}")),
423+ &sig,
424+ &sig,
425+ message,
426+ &tree,
427+ &parent_refs,
428+ )
429+ .unwrap()
430+ }
431+
432+ fn request<'a>(
433+ fx: &'a Fixture,
434+ strategy: MergeStrategy,
435+ workdir: &'a Path,
436+ ) -> MergeRequest<'a> {
437+ MergeRequest {
438+ binary: "git",
439+ repo_path: &fx.path,
440+ workdir,
441+ base_branch: "main",
442+ head_ref: "feature",
443+ strategy,
444+ message: "Merge feature",
445+ identity: MergeIdentity {
446+ home: fx.home.path(),
447+ name: "Ada",
448+ email: "ada@example.com",
449+ },
450+ }
451+ }
452+
453+ /// The oid `main` currently points at, via the subprocess (so we observe what
454+ /// the merge wrote).
455+ fn tip(fx: &Fixture, rev: &str) -> String {
456+ rev_parse("git", &fx.path, fx.home.path(), rev).unwrap()
457+ }
458+
459+ #[test]
460+ fn analyze_reports_clean_and_already_merged() {
461+ if !git_present() {
462+ return;
463+ }
464+ let fx = fixture();
465+ assert_eq!(
466+ analyze("git", &fx.path, fx.home.path(), "main", "feature").unwrap(),
467+ Mergeability::Clean,
468+ );
469+ // main is contained in feature, so the reverse merge is a no-op.
470+ assert_eq!(
471+ analyze("git", &fx.path, fx.home.path(), "feature", "main").unwrap(),
472+ Mergeability::AlreadyMerged,
473+ );
474+ }
475+
476+ #[test]
477+ fn merge_commit_has_two_parents_and_the_feature_file() {
478+ if !git_present() {
479+ return;
480+ }
481+ let fx = fixture();
482+ let base = tip(&fx, "main");
483+ let head = tip(&fx, "feature");
484+ let wt = fx.path.parent().unwrap().join("wt-merge");
485+ let sha = merge(&request(&fx, MergeStrategy::Merge, &wt)).unwrap();
486+
487+ assert_eq!(tip(&fx, "main"), sha, "base advanced to the merge commit");
488+ let raw = Repository::open_bare(&fx.path).unwrap();
489+ let merge_commit = raw.find_commit(git2::Oid::from_str(&sha).unwrap()).unwrap();
490+ let parents: Vec<String> = merge_commit.parent_ids().map(|o| o.to_string()).collect();
491+ assert_eq!(parents, vec![base, head]);
492+ // The merged tree carries feature.txt.
493+ assert!(
494+ merge_commit
495+ .tree()
496+ .unwrap()
497+ .get_name("feature.txt")
498+ .is_some()
499+ );
500+ }
501+
502+ #[test]
503+ fn squash_has_a_single_parent() {
504+ if !git_present() {
505+ return;
506+ }
507+ let fx = fixture();
508+ let base = tip(&fx, "main");
509+ let wt = fx.path.parent().unwrap().join("wt-squash");
510+ let sha = merge(&request(&fx, MergeStrategy::Squash, &wt)).unwrap();
511+ let raw = Repository::open_bare(&fx.path).unwrap();
512+ let commit = raw.find_commit(git2::Oid::from_str(&sha).unwrap()).unwrap();
513+ let parents: Vec<String> = commit.parent_ids().map(|o| o.to_string()).collect();
514+ assert_eq!(parents, vec![base]);
515+ assert!(commit.tree().unwrap().get_name("feature.txt").is_some());
516+ }
517+
518+ #[test]
519+ fn rebase_of_a_linear_branch_fast_forwards() {
520+ if !git_present() {
521+ return;
522+ }
523+ let fx = fixture();
524+ let head = tip(&fx, "feature");
525+ let wt = fx.path.parent().unwrap().join("wt-rebase");
526+ let sha = merge(&request(&fx, MergeStrategy::Rebase, &wt)).unwrap();
527+ // feature is a linear descendant of main, so the rebase is a fast-forward.
528+ assert_eq!(sha, head);
529+ assert_eq!(tip(&fx, "main"), head);
530+ }
531+}