fabrica

hanna/fabrica

20989 bytes
Raw
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) => {
243 // On a conflict, stdout is the (partial) tree oid followed by the
244 // conflicted-file information; surface that as the detail.
245 let out = String::from_utf8_lossy(&tree_out.stdout);
246 let detail = out.lines().skip(1).collect::<Vec<_>>().join("; ");
247 return Err(GitError::MergeConflict(detail.trim().to_string()));
248 }
249 _ => {
250 return Err(GitError::Merge(
251 String::from_utf8_lossy(&tree_out.stderr).trim().to_string(),
252 ));
253 }
254 }
255 let tree = String::from_utf8_lossy(&tree_out.stdout).trim().to_string();
256
257 let mut cmd = identified(req);
258 cmd.args(["commit-tree", &tree]);
259 for parent in parents {
260 cmd.args(["-p", parent]);
261 }
262 cmd.args(["-m", req.message]);
263 let out = cmd.output().map_err(|source| GitError::Io {
264 path: req.repo_path.to_path_buf(),
265 source,
266 })?;
267 if !out.status.success() {
268 return Err(GitError::Merge(
269 String::from_utf8_lossy(&out.stderr).trim().to_string(),
270 ));
271 }
272 Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
273}
274
275/// A rebase: replay the head commits onto the base in a throwaway detached
276/// worktree, returning the rebased tip. The worktree is always removed.
277fn rebase(req: &MergeRequest, base: &str, head: &str) -> Result<String, GitError> {
278 // A clean fast-forwardable rebase (base is an ancestor of head) needs no
279 // replay — the rebased tip is head itself.
280 let linear = base_command(req.binary, req.repo_path, req.identity.home)
281 .args(["merge-base", "--is-ancestor", base, head])
282 .status()
283 .map_err(|source| GitError::Io {
284 path: req.repo_path.to_path_buf(),
285 source,
286 })?;
287 if linear.success() {
288 return Ok(head.to_string());
289 }
290
291 let result = rebase_in_worktree(req, base, head);
292 // Always tear the worktree down, then let git forget it.
293 let _ = std::fs::remove_dir_all(req.workdir);
294 let _ = base_command(req.binary, req.repo_path, req.identity.home)
295 .args(["worktree", "prune"])
296 .status();
297 result
298}
299
300/// The fallible body of [`rebase`], run between worktree creation and teardown.
301fn rebase_in_worktree(req: &MergeRequest, base: &str, head: &str) -> Result<String, GitError> {
302 let add = base_command(req.binary, req.repo_path, req.identity.home)
303 .args(["worktree", "add", "--detach"])
304 .arg(req.workdir)
305 .arg(head)
306 .output()
307 .map_err(|source| GitError::Io {
308 path: req.workdir.to_path_buf(),
309 source,
310 })?;
311 if !add.status.success() {
312 return Err(GitError::Merge(
313 String::from_utf8_lossy(&add.stderr).trim().to_string(),
314 ));
315 }
316
317 let mut cmd = identified(req);
318 cmd.current_dir(req.workdir)
319 .env_remove("GIT_DIR")
320 .args(["rebase", base]);
321 let out = cmd.output().map_err(|source| GitError::Io {
322 path: req.workdir.to_path_buf(),
323 source,
324 })?;
325 if !out.status.success() {
326 // Capture the reason before aborting (git prints it to stdout/stderr).
327 let mut detail = String::from_utf8_lossy(&out.stdout).trim().to_string();
328 let err = String::from_utf8_lossy(&out.stderr);
329 if !err.trim().is_empty() {
330 detail.push_str(err.trim());
331 }
332 // Leave no half-applied rebase state in the worktree.
333 let _ = identified(req)
334 .current_dir(req.workdir)
335 .env_remove("GIT_DIR")
336 .args(["rebase", "--abort"])
337 .status();
338 return Err(GitError::MergeConflict(detail));
339 }
340
341 let tip = identified(req)
342 .current_dir(req.workdir)
343 .env_remove("GIT_DIR")
344 .args(["rev-parse", "HEAD"])
345 .output()
346 .map_err(|source| GitError::Io {
347 path: req.workdir.to_path_buf(),
348 source,
349 })?;
350 Ok(String::from_utf8_lossy(&tip.stdout).trim().to_string())
351}
352
353/// A [`base_command`] carrying the author/committer identity for commit creation.
354fn identified(req: &MergeRequest) -> Command {
355 let mut cmd = base_command(req.binary, req.repo_path, req.identity.home);
356 cmd.env("GIT_AUTHOR_NAME", req.identity.name);
357 cmd.env("GIT_AUTHOR_EMAIL", req.identity.email);
358 cmd.env("GIT_COMMITTER_NAME", req.identity.name);
359 cmd.env("GIT_COMMITTER_EMAIL", req.identity.email);
360 cmd
361}
362
363#[cfg(test)]
364mod tests {
365 #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
366
367 use std::path::{Path, PathBuf};
368
369 use git2::{Repository, Signature, Time};
370 use tempfile::TempDir;
371
372 use super::*;
373 use crate::{create_bare, repo_path};
374
375 const ID: &str = "01hzxk9m2n8p7q6r5s4t3v2w1x";
376
377 /// Whether `git` is on PATH; merge is subprocess-only, so tests skip cleanly
378 /// when it is absent (matching the pack transport tests).
379 fn git_present() -> bool {
380 Command::new("git").arg("--version").output().is_ok()
381 }
382
383 /// A bare repo with `main` (one commit) and `feature` (a child of `main`'s tip
384 /// adding `feature.txt`), plus a temp `HOME`.
385 struct Fixture {
386 _dir: TempDir,
387 home: TempDir,
388 path: PathBuf,
389 }
390
391 fn fixture() -> Fixture {
392 let dir = tempfile::tempdir().unwrap();
393 create_bare(dir.path(), ID, "main", Path::new("/usr/bin/fabrica")).unwrap();
394 let path = repo_path(dir.path(), ID).unwrap();
395 let raw = Repository::open_bare(&path).unwrap();
396
397 let base = commit(&raw, "main", &[], &[("README.md", "hello\n")], "init", 1);
398 commit(
399 &raw,
400 "feature",
401 &[base],
402 &[("README.md", "hello\n"), ("feature.txt", "new\n")],
403 "add feature",
404 2,
405 );
406 Fixture {
407 _dir: dir,
408 home: tempfile::tempdir().unwrap(),
409 path,
410 }
411 }
412
413 fn commit(
414 raw: &Repository,
415 branch: &str,
416 parents: &[git2::Oid],
417 files: &[(&str, &str)],
418 message: &str,
419 t: i64,
420 ) -> git2::Oid {
421 let mut root = raw.treebuilder(None).unwrap();
422 for (name, content) in files {
423 let blob = raw.blob(content.as_bytes()).unwrap();
424 root.insert(name, blob, 0o100_644).unwrap();
425 }
426 let tree = raw.find_tree(root.write().unwrap()).unwrap();
427 let sig = Signature::new("Ada", "ada@example.com", &Time::new(t, 0)).unwrap();
428 let parent_commits: Vec<_> = parents
429 .iter()
430 .map(|p| raw.find_commit(*p).unwrap())
431 .collect();
432 let parent_refs: Vec<&git2::Commit> = parent_commits.iter().collect();
433 raw.commit(
434 Some(&format!("refs/heads/{branch}")),
435 &sig,
436 &sig,
437 message,
438 &tree,
439 &parent_refs,
440 )
441 .unwrap()
442 }
443
444 fn request<'a>(
445 fx: &'a Fixture,
446 strategy: MergeStrategy,
447 workdir: &'a Path,
448 ) -> MergeRequest<'a> {
449 MergeRequest {
450 binary: "git",
451 repo_path: &fx.path,
452 workdir,
453 base_branch: "main",
454 head_ref: "feature",
455 strategy,
456 message: "Merge feature",
457 identity: MergeIdentity {
458 home: fx.home.path(),
459 name: "Ada",
460 email: "ada@example.com",
461 },
462 }
463 }
464
465 /// The oid `main` currently points at, via the subprocess (so we observe what
466 /// the merge wrote).
467 fn tip(fx: &Fixture, rev: &str) -> String {
468 rev_parse("git", &fx.path, fx.home.path(), rev).unwrap()
469 }
470
471 #[test]
472 fn merge_and_rebase_diverged_non_conflicting() {
473 if !git_present() {
474 return;
475 }
476 let fx = fixture();
477 let raw = Repository::open_bare(&fx.path).unwrap();
478 // Advance main with a *different* file, so main and feature diverge from
479 // their common base but do not conflict.
480 let base = raw
481 .find_reference("refs/heads/main")
482 .unwrap()
483 .target()
484 .unwrap();
485 commit(
486 &raw,
487 "main",
488 &[base],
489 &[("README.md", "hello\n"), ("main.txt", "m\n")],
490 "advance main",
491 3,
492 );
493
494 // analyze must agree it is clean...
495 assert_eq!(
496 analyze("git", &fx.path, fx.home.path(), "main", "feature").unwrap(),
497 Mergeability::Clean,
498 );
499 // ...and each strategy must succeed, not report a phantom conflict.
500 for (strategy, dir) in [
501 (MergeStrategy::Merge, "wt-div-merge"),
502 (MergeStrategy::Squash, "wt-div-squash"),
503 (MergeStrategy::Rebase, "wt-div-rebase"),
504 ] {
505 let wt = fx.path.parent().unwrap().join(dir);
506 let sha = merge(&request(&fx, strategy, &wt))
507 .unwrap_or_else(|e| panic!("{strategy:?} failed: {e:?}"));
508 let raw = Repository::open_bare(&fx.path).unwrap();
509 assert!(
510 raw.find_commit(git2::Oid::from_str(&sha).unwrap())
511 .unwrap()
512 .tree()
513 .unwrap()
514 .get_name("feature.txt")
515 .is_some(),
516 "{strategy:?}: merged tree keeps feature.txt"
517 );
518 }
519 }
520
521 #[test]
522 fn analyze_reports_clean_and_already_merged() {
523 if !git_present() {
524 return;
525 }
526 let fx = fixture();
527 assert_eq!(
528 analyze("git", &fx.path, fx.home.path(), "main", "feature").unwrap(),
529 Mergeability::Clean,
530 );
531 // main is contained in feature, so the reverse merge is a no-op.
532 assert_eq!(
533 analyze("git", &fx.path, fx.home.path(), "feature", "main").unwrap(),
534 Mergeability::AlreadyMerged,
535 );
536 }
537
538 #[test]
539 fn merge_commit_has_two_parents_and_the_feature_file() {
540 if !git_present() {
541 return;
542 }
543 let fx = fixture();
544 let base = tip(&fx, "main");
545 let head = tip(&fx, "feature");
546 let wt = fx.path.parent().unwrap().join("wt-merge");
547 let sha = merge(&request(&fx, MergeStrategy::Merge, &wt)).unwrap();
548
549 assert_eq!(tip(&fx, "main"), sha, "base advanced to the merge commit");
550 let raw = Repository::open_bare(&fx.path).unwrap();
551 let merge_commit = raw.find_commit(git2::Oid::from_str(&sha).unwrap()).unwrap();
552 let parents: Vec<String> = merge_commit.parent_ids().map(|o| o.to_string()).collect();
553 assert_eq!(parents, vec![base, head]);
554 // The merged tree carries feature.txt.
555 assert!(
556 merge_commit
557 .tree()
558 .unwrap()
559 .get_name("feature.txt")
560 .is_some()
561 );
562 }
563
564 #[test]
565 fn squash_has_a_single_parent() {
566 if !git_present() {
567 return;
568 }
569 let fx = fixture();
570 let base = tip(&fx, "main");
571 let wt = fx.path.parent().unwrap().join("wt-squash");
572 let sha = merge(&request(&fx, MergeStrategy::Squash, &wt)).unwrap();
573 let raw = Repository::open_bare(&fx.path).unwrap();
574 let commit = raw.find_commit(git2::Oid::from_str(&sha).unwrap()).unwrap();
575 let parents: Vec<String> = commit.parent_ids().map(|o| o.to_string()).collect();
576 assert_eq!(parents, vec![base]);
577 assert!(commit.tree().unwrap().get_name("feature.txt").is_some());
578 }
579
580 #[test]
581 fn rebase_of_a_linear_branch_fast_forwards() {
582 if !git_present() {
583 return;
584 }
585 let fx = fixture();
586 let head = tip(&fx, "feature");
587 let wt = fx.path.parent().unwrap().join("wt-rebase");
588 let sha = merge(&request(&fx, MergeStrategy::Rebase, &wt)).unwrap();
589 // feature is a linear descendant of main, so the rebase is a fast-forward.
590 assert_eq!(sha, head);
591 assert_eq!(tip(&fx, "main"), head);
592 }
593}