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 @@
28//! name→path authority; [`repo_path`] is the one place that mapping is computed.28//! name→path authority; [`repo_path`] is the one place that mapping is computed.
2929
30mod attributes;30mod attributes;
31pub mod merge;
31pub mod pack;32pub mod pack;
32mod pubkey;33mod pubkey;
33mod repo;34mod repo;
@@ -91,6 +92,16 @@ pub enum GitError {
91 /// A human-readable parse failure reason.92 /// A human-readable parse failure reason.
92 reason: String,93 reason: String,
93 },94 },
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,
94}105}
95106
96/// Compute the on-disk path of a repository from its id: `{root}/{id[0..2]}/{id}.git`.107/// 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
20use std::path::Path;
21use std::process::Command;
22
23use crate::GitError;
24
25/// How to integrate the head branch into the base.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub 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
36impl 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)]
62pub 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)]
73pub 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)]
84pub 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`.
105fn 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.
122fn 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.
140pub 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.
192pub 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.
231fn 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.
271fn 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.
295fn 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.
342fn 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)]
352mod 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}