// This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. //! Server-side merge: integrate a pull request's head branch into its base. //! //! libgit2 has no high-level merge that writes refs, so — as with pack transport //! (§3.1) — merges shell out to the `git` binary. A merge or squash needs no //! working tree (`merge-tree` computes the tree, `commit-tree` seals it, then //! `update-ref` advances the base with a compare-and-swap on its old value). A //! rebase must replay commits, so it uses a throwaway detached worktree that is //! always removed afterwards. //! //! Every subprocess is spawned with explicit args (never a shell) and an isolated //! environment mirroring [`crate::pack`]: `HOME` is a scratch dir, system config is //! off, and the author/committer identity is injected so the merge commit is //! attributed to the user who pressed the button. Callers **MUST** have authorized //! the merge first; this module makes no access decision. use std::path::Path; use std::process::Command; use crate::GitError; /// How to integrate the head branch into the base. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum MergeStrategy { /// A `--no-ff` merge commit with both tips as parents. Merge, /// A single commit with the base tip as its only parent (history flattened). Squash, /// Replay the head commits onto the base tip, then advance the base. Rebase, } impl MergeStrategy { /// The stable token stored on the pull request row. #[must_use] pub fn as_str(self) -> &'static str { match self { Self::Merge => "merge", Self::Squash => "squash", Self::Rebase => "rebase", } } /// Parse a stored token, defaulting to [`MergeStrategy::Merge`] on anything /// unrecognized. #[must_use] pub fn from_token(token: &str) -> Self { match token { "squash" => Self::Squash, "rebase" => Self::Rebase, _ => Self::Merge, } } } /// Whether a head branch can be merged into a base, computed without mutating /// anything. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Mergeability { /// The merge would apply cleanly. Clean, /// The head is already contained in the base — nothing to merge. AlreadyMerged, /// The three-way merge has conflicts a server merge cannot resolve. Conflicts, } /// The isolated environment and identity a merge runs under. #[derive(Debug, Clone)] pub struct MergeIdentity<'a> { /// `HOME` for every child, so git never reads a real user's global config. pub home: &'a Path, /// The display name recorded as author and committer. pub name: &'a str, /// The email recorded as author and committer. pub email: &'a str, } /// A merge request: what to integrate, how, and as whom. #[derive(Debug, Clone)] pub struct MergeRequest<'a> { /// The resolved `git` binary. pub binary: &'a str, /// The bare repository's path (its `GIT_DIR`). pub repo_path: &'a Path, /// A non-existent path at which to create the scratch worktree (rebase only); /// it is created and removed within the call. pub workdir: &'a Path, /// The base branch short name (e.g. `main`); advanced on success. pub base_branch: &'a str, /// The head ref to integrate (branch short name or oid). pub head_ref: &'a str, /// How to integrate. pub strategy: MergeStrategy, /// The commit message for the merge/squash commit (unused by a clean rebase). pub message: &'a str, /// The identity and isolation for the child processes. pub identity: MergeIdentity<'a>, } /// Resolve a ref (or oid) to a full commit oid in `repo_path`. fn rev_parse(binary: &str, repo_path: &Path, home: &Path, rev: &str) -> Result { // `--verify` ensures a single object; `^{commit}` peels tags to commits. let out = base_command(binary, repo_path, home) .args(["rev-parse", "--verify", "--quiet"]) .arg(format!("{rev}^{{commit}}")) .output() .map_err(|source| GitError::Io { path: repo_path.to_path_buf(), source, })?; if !out.status.success() { return Err(GitError::RevNotFound(rev.to_string())); } Ok(String::from_utf8_lossy(&out.stdout).trim().to_string()) } /// A `git` command against the bare repo with the isolated pack environment. fn base_command(binary: &str, repo_path: &Path, home: &Path) -> Command { let mut cmd = Command::new(binary); cmd.env("GIT_DIR", repo_path); cmd.env("GIT_TERMINAL_PROMPT", "0"); cmd.env("GIT_CONFIG_NOSYSTEM", "1"); cmd.env("HOME", home); cmd.env_remove("GIT_ALTERNATE_OBJECT_DIRECTORIES"); cmd.env_remove("GIT_OBJECT_DIRECTORY"); cmd } /// Analyze whether `head_ref` merges cleanly into `base_ref` without mutating the /// repository. /// /// # Errors /// /// Returns [`GitError::RevNotFound`] if either ref is unknown, or [`GitError::Io`] /// if the `git` binary cannot be run. pub fn analyze( binary: &str, repo_path: &Path, home: &Path, base_ref: &str, head_ref: &str, ) -> Result { let base = rev_parse(binary, repo_path, home, base_ref)?; let head = rev_parse(binary, repo_path, home, head_ref)?; // Head already in base (fast-forward-behind or equal): nothing to do. let contained = base_command(binary, repo_path, home) .args(["merge-base", "--is-ancestor", &head, &base]) .status() .map_err(|source| GitError::Io { path: repo_path.to_path_buf(), source, })?; if contained.success() { return Ok(Mergeability::AlreadyMerged); } // `merge-tree --write-tree` performs a real three-way merge in memory: exit 0 // means clean, exit 1 means conflicts. It writes objects into the repo but no // ref, so an unmerged PR leaves only unreferenced (gc-able) trees behind. let out = base_command(binary, repo_path, home) .args(["merge-tree", "--write-tree", &base, &head]) .output() .map_err(|source| GitError::Io { path: repo_path.to_path_buf(), source, })?; match out.status.code() { Some(0) => Ok(Mergeability::Clean), Some(1) => Ok(Mergeability::Conflicts), _ => Err(GitError::Merge( String::from_utf8_lossy(&out.stderr).trim().to_string(), )), } } /// Perform the merge, advancing the base branch, and return the new commit oid. /// /// The base ref is updated with a compare-and-swap against the tip observed at the /// start, so a concurrent push between analysis and merge is rejected rather than /// silently clobbered. /// /// # Errors /// /// Returns [`GitError::MergeConflict`] if the merge does not apply cleanly, /// [`GitError::RevNotFound`] for an unknown ref, or [`GitError::Merge`] / /// [`GitError::Io`] on a subprocess failure. pub fn merge(req: &MergeRequest) -> Result { let base = rev_parse( req.binary, req.repo_path, req.identity.home, req.base_branch, )?; let head = rev_parse(req.binary, req.repo_path, req.identity.home, req.head_ref)?; let new_tip = match req.strategy { MergeStrategy::Merge => plumbing_commit(req, &base, &[&base, &head])?, MergeStrategy::Squash => plumbing_commit(req, &base, &[&base])?, MergeStrategy::Rebase => rebase(req, &base, &head)?, }; // Advance the base branch, refusing if it moved since we read it. let status = identified(req) .args([ "update-ref", &format!("refs/heads/{}", req.base_branch), &new_tip, &base, ]) .status() .map_err(|source| GitError::Io { path: req.repo_path.to_path_buf(), source, })?; if !status.success() { return Err(GitError::Merge(format!( "base branch {} moved during merge", req.base_branch ))); } Ok(new_tip) } /// A merge or squash: compute the merged tree with `merge-tree`, then seal it with /// `commit-tree` under the given parents. fn plumbing_commit(req: &MergeRequest, base: &str, parents: &[&str]) -> Result { let head = rev_parse(req.binary, req.repo_path, req.identity.home, req.head_ref)?; let tree_out = base_command(req.binary, req.repo_path, req.identity.home) .args(["merge-tree", "--write-tree", base, &head]) .output() .map_err(|source| GitError::Io { path: req.repo_path.to_path_buf(), source, })?; match tree_out.status.code() { Some(0) => {} Some(1) => { // On a conflict, stdout is the (partial) tree oid followed by the // conflicted-file information; surface that as the detail. let out = String::from_utf8_lossy(&tree_out.stdout); let detail = out.lines().skip(1).collect::>().join("; "); return Err(GitError::MergeConflict(detail.trim().to_string())); } _ => { return Err(GitError::Merge( String::from_utf8_lossy(&tree_out.stderr).trim().to_string(), )); } } let tree = String::from_utf8_lossy(&tree_out.stdout).trim().to_string(); let mut cmd = identified(req); cmd.args(["commit-tree", &tree]); for parent in parents { cmd.args(["-p", parent]); } cmd.args(["-m", req.message]); let out = cmd.output().map_err(|source| GitError::Io { path: req.repo_path.to_path_buf(), source, })?; if !out.status.success() { return Err(GitError::Merge( String::from_utf8_lossy(&out.stderr).trim().to_string(), )); } Ok(String::from_utf8_lossy(&out.stdout).trim().to_string()) } /// A rebase: replay the head commits onto the base in a throwaway detached /// worktree, returning the rebased tip. The worktree is always removed. fn rebase(req: &MergeRequest, base: &str, head: &str) -> Result { // A clean fast-forwardable rebase (base is an ancestor of head) needs no // replay — the rebased tip is head itself. let linear = base_command(req.binary, req.repo_path, req.identity.home) .args(["merge-base", "--is-ancestor", base, head]) .status() .map_err(|source| GitError::Io { path: req.repo_path.to_path_buf(), source, })?; if linear.success() { return Ok(head.to_string()); } let result = rebase_in_worktree(req, base, head); // Always tear the worktree down, then let git forget it. let _ = std::fs::remove_dir_all(req.workdir); let _ = base_command(req.binary, req.repo_path, req.identity.home) .args(["worktree", "prune"]) .status(); result } /// The fallible body of [`rebase`], run between worktree creation and teardown. fn rebase_in_worktree(req: &MergeRequest, base: &str, head: &str) -> Result { let add = base_command(req.binary, req.repo_path, req.identity.home) .args(["worktree", "add", "--detach"]) .arg(req.workdir) .arg(head) .output() .map_err(|source| GitError::Io { path: req.workdir.to_path_buf(), source, })?; if !add.status.success() { return Err(GitError::Merge( String::from_utf8_lossy(&add.stderr).trim().to_string(), )); } let mut cmd = identified(req); cmd.current_dir(req.workdir) .env_remove("GIT_DIR") .args(["rebase", base]); let out = cmd.output().map_err(|source| GitError::Io { path: req.workdir.to_path_buf(), source, })?; if !out.status.success() { // Capture the reason before aborting (git prints it to stdout/stderr). let mut detail = String::from_utf8_lossy(&out.stdout).trim().to_string(); let err = String::from_utf8_lossy(&out.stderr); if !err.trim().is_empty() { detail.push_str(err.trim()); } // Leave no half-applied rebase state in the worktree. let _ = identified(req) .current_dir(req.workdir) .env_remove("GIT_DIR") .args(["rebase", "--abort"]) .status(); return Err(GitError::MergeConflict(detail)); } let tip = identified(req) .current_dir(req.workdir) .env_remove("GIT_DIR") .args(["rev-parse", "HEAD"]) .output() .map_err(|source| GitError::Io { path: req.workdir.to_path_buf(), source, })?; Ok(String::from_utf8_lossy(&tip.stdout).trim().to_string()) } /// A [`base_command`] carrying the author/committer identity for commit creation. fn identified(req: &MergeRequest) -> Command { let mut cmd = base_command(req.binary, req.repo_path, req.identity.home); cmd.env("GIT_AUTHOR_NAME", req.identity.name); cmd.env("GIT_AUTHOR_EMAIL", req.identity.email); cmd.env("GIT_COMMITTER_NAME", req.identity.name); cmd.env("GIT_COMMITTER_EMAIL", req.identity.email); cmd } #[cfg(test)] mod tests { #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use std::path::{Path, PathBuf}; use git2::{Repository, Signature, Time}; use tempfile::TempDir; use super::*; use crate::{create_bare, repo_path}; const ID: &str = "01hzxk9m2n8p7q6r5s4t3v2w1x"; /// Whether `git` is on PATH; merge is subprocess-only, so tests skip cleanly /// when it is absent (matching the pack transport tests). fn git_present() -> bool { Command::new("git").arg("--version").output().is_ok() } /// A bare repo with `main` (one commit) and `feature` (a child of `main`'s tip /// adding `feature.txt`), plus a temp `HOME`. struct Fixture { _dir: TempDir, home: TempDir, path: PathBuf, } fn fixture() -> Fixture { let dir = tempfile::tempdir().unwrap(); create_bare(dir.path(), ID, "main", Path::new("/usr/bin/fabrica")).unwrap(); let path = repo_path(dir.path(), ID).unwrap(); let raw = Repository::open_bare(&path).unwrap(); let base = commit(&raw, "main", &[], &[("README.md", "hello\n")], "init", 1); commit( &raw, "feature", &[base], &[("README.md", "hello\n"), ("feature.txt", "new\n")], "add feature", 2, ); Fixture { _dir: dir, home: tempfile::tempdir().unwrap(), path, } } fn commit( raw: &Repository, branch: &str, parents: &[git2::Oid], files: &[(&str, &str)], message: &str, t: i64, ) -> git2::Oid { let mut root = raw.treebuilder(None).unwrap(); for (name, content) in files { let blob = raw.blob(content.as_bytes()).unwrap(); root.insert(name, blob, 0o100_644).unwrap(); } let tree = raw.find_tree(root.write().unwrap()).unwrap(); let sig = Signature::new("Ada", "ada@example.com", &Time::new(t, 0)).unwrap(); let parent_commits: Vec<_> = parents .iter() .map(|p| raw.find_commit(*p).unwrap()) .collect(); let parent_refs: Vec<&git2::Commit> = parent_commits.iter().collect(); raw.commit( Some(&format!("refs/heads/{branch}")), &sig, &sig, message, &tree, &parent_refs, ) .unwrap() } fn request<'a>( fx: &'a Fixture, strategy: MergeStrategy, workdir: &'a Path, ) -> MergeRequest<'a> { MergeRequest { binary: "git", repo_path: &fx.path, workdir, base_branch: "main", head_ref: "feature", strategy, message: "Merge feature", identity: MergeIdentity { home: fx.home.path(), name: "Ada", email: "ada@example.com", }, } } /// The oid `main` currently points at, via the subprocess (so we observe what /// the merge wrote). fn tip(fx: &Fixture, rev: &str) -> String { rev_parse("git", &fx.path, fx.home.path(), rev).unwrap() } #[test] fn merge_and_rebase_diverged_non_conflicting() { if !git_present() { return; } let fx = fixture(); let raw = Repository::open_bare(&fx.path).unwrap(); // Advance main with a *different* file, so main and feature diverge from // their common base but do not conflict. let base = raw .find_reference("refs/heads/main") .unwrap() .target() .unwrap(); commit( &raw, "main", &[base], &[("README.md", "hello\n"), ("main.txt", "m\n")], "advance main", 3, ); // analyze must agree it is clean... assert_eq!( analyze("git", &fx.path, fx.home.path(), "main", "feature").unwrap(), Mergeability::Clean, ); // ...and each strategy must succeed, not report a phantom conflict. for (strategy, dir) in [ (MergeStrategy::Merge, "wt-div-merge"), (MergeStrategy::Squash, "wt-div-squash"), (MergeStrategy::Rebase, "wt-div-rebase"), ] { let wt = fx.path.parent().unwrap().join(dir); let sha = merge(&request(&fx, strategy, &wt)) .unwrap_or_else(|e| panic!("{strategy:?} failed: {e:?}")); let raw = Repository::open_bare(&fx.path).unwrap(); assert!( raw.find_commit(git2::Oid::from_str(&sha).unwrap()) .unwrap() .tree() .unwrap() .get_name("feature.txt") .is_some(), "{strategy:?}: merged tree keeps feature.txt" ); } } #[test] fn analyze_reports_clean_and_already_merged() { if !git_present() { return; } let fx = fixture(); assert_eq!( analyze("git", &fx.path, fx.home.path(), "main", "feature").unwrap(), Mergeability::Clean, ); // main is contained in feature, so the reverse merge is a no-op. assert_eq!( analyze("git", &fx.path, fx.home.path(), "feature", "main").unwrap(), Mergeability::AlreadyMerged, ); } #[test] fn merge_commit_has_two_parents_and_the_feature_file() { if !git_present() { return; } let fx = fixture(); let base = tip(&fx, "main"); let head = tip(&fx, "feature"); let wt = fx.path.parent().unwrap().join("wt-merge"); let sha = merge(&request(&fx, MergeStrategy::Merge, &wt)).unwrap(); assert_eq!(tip(&fx, "main"), sha, "base advanced to the merge commit"); let raw = Repository::open_bare(&fx.path).unwrap(); let merge_commit = raw.find_commit(git2::Oid::from_str(&sha).unwrap()).unwrap(); let parents: Vec = merge_commit.parent_ids().map(|o| o.to_string()).collect(); assert_eq!(parents, vec![base, head]); // The merged tree carries feature.txt. assert!( merge_commit .tree() .unwrap() .get_name("feature.txt") .is_some() ); } #[test] fn squash_has_a_single_parent() { if !git_present() { return; } let fx = fixture(); let base = tip(&fx, "main"); let wt = fx.path.parent().unwrap().join("wt-squash"); let sha = merge(&request(&fx, MergeStrategy::Squash, &wt)).unwrap(); let raw = Repository::open_bare(&fx.path).unwrap(); let commit = raw.find_commit(git2::Oid::from_str(&sha).unwrap()).unwrap(); let parents: Vec = commit.parent_ids().map(|o| o.to_string()).collect(); assert_eq!(parents, vec![base]); assert!(commit.tree().unwrap().get_name("feature.txt").is_some()); } #[test] fn rebase_of_a_linear_branch_fast_forwards() { if !git_present() { return; } let fx = fixture(); let head = tip(&fx, "feature"); let wt = fx.path.parent().unwrap().join("wt-rebase"); let sha = merge(&request(&fx, MergeStrategy::Rebase, &wt)).unwrap(); // feature is a linear descendant of main, so the rebase is a fast-forward. assert_eq!(sha, head); assert_eq!(tip(&fx, "main"), head); } }