diff --git a/Cargo.lock b/Cargo.lock index 884e64ca..3a04ec0c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5019,6 +5019,7 @@ dependencies = [ "gix-bitmap", "gix-hash", "gix-pack", + "gix-rebase", "knot-cache", "knot-fixtures", "knot-resource", diff --git a/knot2/crates/knot-git/Cargo.toml b/knot2/crates/knot-git/Cargo.toml index 24ed6ab3..066b5749 100644 --- a/knot2/crates/knot-git/Cargo.toml +++ b/knot2/crates/knot-git/Cargo.toml @@ -15,6 +15,7 @@ gix = { workspace = true } gix-archive = { workspace = true } gix-bitmap = "0.3.2" gix-pack = { workspace = true } +gix-rebase = { workspace = true } gix-hash = { workspace = true } flate2 = { workspace = true } knot-cache = { workspace = true } diff --git a/knot2/crates/knot-git/src/lib.rs b/knot2/crates/knot-git/src/lib.rs index 83fbd753..03c98281 100644 --- a/knot2/crates/knot-git/src/lib.rs +++ b/knot2/crates/knot-git/src/lib.rs @@ -10,6 +10,7 @@ mod patch; mod patch_apply; mod patch_parse; mod reads; +mod rebase; mod repo; mod staging; @@ -38,6 +39,7 @@ pub use reads::{ AnnotatedTag, BranchInfo, BranchTip, LastCommit, LogLimit, LogSkip, PathEntry, SizedEntry, Submodule, TagInfo, }; +pub use rebase::Rebased; pub use repo::{ AdvertScope, HeadRef, Layout, PackHash, PackfileUri, PackfileUrl, RefRecord, RefTxn, RefUpdate, ReflogUpdate, Repo, is_branch, is_public_ref, is_reserved, knot_shard, repo_shard, diff --git a/knot2/crates/knot-git/src/patch_apply.rs b/knot2/crates/knot-git/src/patch_apply.rs index 30b1dec9..c513ac19 100644 --- a/knot2/crates/knot-git/src/patch_apply.rs +++ b/knot2/crates/knot-git/src/patch_apply.rs @@ -13,6 +13,7 @@ pub enum ConflictReason { AlreadyExists, DoesNotExist, DoesNotApply, + Conflicts, } impl ConflictReason { @@ -21,6 +22,7 @@ impl ConflictReason { ConflictReason::AlreadyExists => "file already exists", ConflictReason::DoesNotExist => "file doesn't exist", ConflictReason::DoesNotApply => "patch doesn't apply", + ConflictReason::Conflicts => "changes conflict", } } } diff --git a/knot2/crates/knot-git/src/rebase.rs b/knot2/crates/knot-git/src/rebase.rs new file mode 100644 index 00000000..1a184b5e --- /dev/null +++ b/knot2/crates/knot-git/src/rebase.rs @@ -0,0 +1,146 @@ +//! Replay commits onto a new base, the way `git rebase` does with its merge backend. + +use std::collections::{HashMap, HashSet}; + +use knot_types::Oid; + +use crate::error::GitError; +use crate::objects::{Haves, Identity, Wants, signature}; +use crate::patch_apply::{Conflict, ConflictReason}; +use crate::repo::Repo; + +/// The result of replaying commits onto a new base. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Rebased { + /// Every commit was replayed or deliberately dropped. + Done { + /// The new tip, which is `onto` itself when there was nothing to replay. + tip: Oid, + /// How many commits were written. + rewritten: usize, + /// How many commits were dropped because replaying them changed nothing. + dropped: usize, + }, + /// A commit couldn't be replayed. Nothing reachable was written. + Conflicted(Vec), +} + +impl Repo { + /// Replay `source` and the ancestors of it that `onto` doesn't already have on top of `onto`, + /// writing the new commits into this repository. + /// + /// Author, message and extra headers are preserved, so a `change-id` survives; only the tree, the + /// parent and the committer change. + pub fn rebase_onto( + &self, + onto: Oid, + source: Oid, + committer: &Identity, + ) -> Result { + self.rebase_into(self, onto, source, committer) + } + + /// Like [`Repo::rebase_onto`], but write the new objects into `into` — typically a + /// [`Staging`](crate::Staging) quarantine, so that a conflict or a lost race leaves nothing + /// behind in the live repository. + /// + /// How files merge is still decided by `self`: its config and the `.gitattributes` of its `HEAD`. + /// A staging repository has no `HEAD` of its own, so driving the merge from there would silently + /// ignore every attribute, and a file marked `-merge` would be merged anyway. + pub fn rebase_into( + &self, + into: &Repo, + onto: Oid, + source: Oid, + committer: &Identity, + ) -> Result { + let picks: Vec = self + .replay_list(onto, source)? + .into_iter() + .map(Oid::object_id) + .collect(); + + let git = self.git(); + let options = gix_rebase::replay::Options { + tree_merge: git.tree_merge_options().map_err(rebase_error)?.into(), + ..Default::default() + }; + let mut blob_merge = git + .merge_resource_cache(Default::default()) + .map_err(rebase_error)?; + let mut diff_cache = git + .diff_resource_cache_for_tree_diff() + .map_err(rebase_error)?; + let mut diff_state = gix::diff::tree::State::default(); + let treat_as_unresolved = options.treat_as_unresolved; + + let outcome = gix_rebase::replay( + &onto.object_id(), + &picks, + &signature(committer), + Default::default(), + into.git(), + &mut diff_state, + &mut diff_cache, + &mut blob_merge, + options, + ) + .map_err(rebase_error)?; + + Ok(match outcome { + gix_rebase::replay::Outcome::Complete(done) => Rebased::Done { + tip: Oid::from(done.tip), + rewritten: done.rewritten().count(), + dropped: done.dropped(), + }, + gix_rebase::replay::Outcome::Conflict(conflict) => Rebased::Conflicted( + conflict + .unresolved_paths(treat_as_unresolved) + .map(|path| Conflict { + path: path.to_string(), + reason: ConflictReason::Conflicts, + }) + .collect(), + ), + }) + } + + /// The commits to replay, oldest first: everything reachable from `source` that `onto` doesn't + /// have, without the merge commits, which is the set `git rebase` puts on its todo list. + fn replay_list(&self, onto: Oid, source: Oid) -> Result, GitError> { + let mut parents_of = HashMap::new(); + for id in self.rev_walk(Wants::new(&[source]), Haves::new(&[onto]))? { + parents_of.insert(id, self.find_commit(id)?.parents); + } + + // `rev_walk` doesn't promise a topological order, and replaying a commit before its own + // parent would rebase it onto the wrong tree, so lay the picks out with an explicit + // post-order walk. Iterative, because a branch can be far deeper than the stack. + let mut order = Vec::with_capacity(parents_of.len()); + let mut seen = HashSet::new(); + let mut stack = vec![(source, false)]; + while let Some((id, parents_done)) = stack.pop() { + let Some(parents) = parents_of.get(&id) else { + continue; + }; + if parents_done { + // Merge commits are left out, exactly as `git rebase` does without + // `--rebase-merges`; the commits they brought in are replayed on their own. + if parents.len() < 2 { + order.push(id); + } + continue; + } + if !seen.insert(id) { + continue; + } + stack.push((id, true)); + stack.extend(parents.iter().map(|parent| (*parent, false))); + } + Ok(order) + } +} + +fn rebase_error(error: impl std::fmt::Display) -> GitError { + GitError::Backend(format!("rebase: {error}")) +} diff --git a/knot2/crates/knot-git/tests/rebase.rs b/knot2/crates/knot-git/tests/rebase.rs new file mode 100644 index 00000000..c246175a --- /dev/null +++ b/knot2/crates/knot-git/tests/rebase.rs @@ -0,0 +1,276 @@ +use std::path::Path; + +use knot_git::{ConflictReason, Identity, Layout, Rebased, Repo}; +use knot_types::{AuthorName, Email, Oid, RefName, RepoDid, UnixSeconds}; + +mod common; +use common::{git_ok as git, seeded}; + +fn committer() -> Identity { + Identity { + name: AuthorName::new("Tangled"), + email: Email::new("noreply@tangled.sh"), + time: UnixSeconds::new(1_700_000_000), + offset_seconds: 0, + } +} + +fn commit(work: &Path, file: &str, contents: &str, message: &str) { + std::fs::write(work.join(file), contents).unwrap(); + git(work, &["add", "-A"]); + git(work, &["commit", "-q", "-m", message]); +} + +fn push(work: &Path, layout: &Layout, did: &RepoDid, refspec: &str) { + let bare = layout.repo_path(did).unwrap(); + git(work, &["push", "-q", bare.to_str().unwrap(), refspec]); +} + +fn tip(bare: &Repo, name: &str) -> Oid { + bare.find_ref(&RefName::new(name).unwrap()) + .unwrap() + .unwrap() +} + +/// `main` gains a commit while `feature` gains two, the first carrying a `change-id` header. +fn diverged() -> (tempfile::TempDir, tempfile::TempDir, Layout, RepoDid) { + let (scan, work, layout, did) = seeded(); + let w = work.path(); + commit(w, "base.txt", "base\n", "base"); + git(w, &["branch", "feature"]); + commit(w, "main.txt", "main\n", "main moves on"); + push(w, &layout, &did, "main"); + + git(w, &["checkout", "-q", "feature"]); + // A commit carrying a `change-id` has to be hand-written; `git commit` can't make one. + std::fs::write(w.join("a.txt"), "a\n").unwrap(); + git(w, &["add", "-A"]); + let tree = git(w, &["write-tree"]); + let parent = git(w, &["rev-parse", "HEAD"]); + let raw = format!( + "tree {tree}\nparent {parent}\n\ + author A 1700000060 +0000\n\ + committer C 1700000060 +0000\n\ + change-id zzzzkzkzkzkzkzkzkzkzkzkzkzkz\n\n\ + pick with change-id\n" + ); + let (ok, id) = knot_fixtures::feed( + w, + &["hash-object", "-t", "commit", "-w", "--stdin"], + raw.as_bytes(), + ); + assert!(ok, "hash-object failed: {id}"); + git(w, &["update-ref", "refs/heads/feature", id.trim()]); + git(w, &["reset", "-q", "--hard", "feature"]); + commit(w, "b.txt", "b\n", "plain follow-up"); + push(w, &layout, &did, "feature"); + (scan, work, layout, did) +} + +fn conflicting() -> (tempfile::TempDir, tempfile::TempDir, Layout, RepoDid) { + let (scan, work, layout, did) = seeded(); + let w = work.path(); + commit(w, "file.txt", "one\ntwo\nthree\n", "base"); + git(w, &["branch", "feature"]); + commit(w, "file.txt", "one\nMAIN\nthree\n", "main edits the middle"); + push(w, &layout, &did, "main"); + git(w, &["checkout", "-q", "feature"]); + commit( + w, + "file.txt", + "one\nFEATURE\nthree\n", + "feature edits the middle", + ); + push(w, &layout, &did, "feature"); + (scan, work, layout, did) +} + +#[test] +fn rebase_onto_replays_and_keeps_the_change_id() { + if !common::git_available() { + return; + } + let (_scan, _work, layout, did) = diverged(); + let bare = layout.open(&did).unwrap(); + let (onto, source) = ( + tip(&bare, "refs/heads/main"), + tip(&bare, "refs/heads/feature"), + ); + + let Rebased::Done { + tip: new_tip, + rewritten, + dropped, + } = bare.rebase_onto(onto, source, &committer()).unwrap() + else { + panic!("expected a clean rebase") + }; + assert_eq!((rewritten, dropped), (2, 0)); + + let head = bare.find_commit(new_tip).unwrap(); + assert_eq!(head.message.trim(), "plain follow-up"); + let first = bare.find_commit(head.parents[0]).unwrap(); + assert_eq!(first.parents, vec![onto], "replayed onto the target tip"); + assert_eq!( + first.change_id().map(|id| id.as_str().to_string()), + Some("zzzzkzkzkzkzkzkzkzkzkzkzkzkz".to_string()), + "the change-id has to survive the rebase" + ); + assert_eq!( + first.author.email.as_str(), + "a@example.com", + "the author is preserved" + ); + assert_eq!( + first.committer.email.as_str(), + "noreply@tangled.sh", + "the committer is ours" + ); +} + +#[test] +fn rebase_onto_produces_the_tree_git_rebase_does() { + if !common::git_available() { + return; + } + let (_scan, work, layout, did) = diverged(); + let bare = layout.open(&did).unwrap(); + let (onto, source) = ( + tip(&bare, "refs/heads/main"), + tip(&bare, "refs/heads/feature"), + ); + let Rebased::Done { tip: ours, .. } = bare.rebase_onto(onto, source, &committer()).unwrap() + else { + panic!("expected a clean rebase") + }; + + // The commits themselves can't match: git drops the `change-id` and picks its own committer. The + // trees must. + let w = work.path(); + git(w, &["checkout", "-q", "feature"]); + git(w, &["rebase", "-q", "main"]); + assert_eq!( + bare.find_commit(ours).unwrap().tree.to_hex(), + git(w, &["rev-parse", "HEAD^{tree}"]), + "the rebased tree must be what git computes" + ); +} + +#[test] +fn rebase_onto_reports_conflicting_paths_and_moves_nothing() { + if !common::git_available() { + return; + } + let (_scan, _work, layout, did) = conflicting(); + let bare = layout.open(&did).unwrap(); + let (onto, source) = ( + tip(&bare, "refs/heads/main"), + tip(&bare, "refs/heads/feature"), + ); + + match bare.rebase_onto(onto, source, &committer()).unwrap() { + Rebased::Conflicted(conflicts) => { + assert_eq!(conflicts.len(), 1); + assert_eq!(conflicts[0].path, "file.txt"); + assert_eq!(conflicts[0].reason, ConflictReason::Conflicts); + } + Rebased::Done { .. } => panic!("expected a conflict"), + } + assert_eq!( + tip(&bare, "refs/heads/main"), + onto, + "a conflicting rebase must not move anything" + ); +} + +#[test] +fn rebase_into_a_staging_repo_honours_the_live_gitattributes() { + if !common::git_available() { + return; + } + let (_scan, work, layout, did) = seeded(); + let w = work.path(); + // `-merge` marks the file unmergeable, so a two-sided change has to conflict rather than be + // merged line by line. A staging repo has no HEAD of its own, so this only works if the + // attributes are read from the live repository. + commit(w, ".gitattributes", "precious.txt -merge\n", "attributes"); + commit(w, "precious.txt", "one\ntwo\nthree\n", "base"); + git(w, &["branch", "feature"]); + commit( + w, + "precious.txt", + "MAIN\ntwo\nthree\n", + "main edits the top", + ); + push(w, &layout, &did, "main"); + git(w, &["checkout", "-q", "feature"]); + commit( + w, + "precious.txt", + "one\ntwo\nFEATURE\n", + "feature edits the bottom", + ); + push(w, &layout, &did, "feature"); + + let bare = layout.open(&did).unwrap(); + let (onto, source) = ( + tip(&bare, "refs/heads/main"), + tip(&bare, "refs/heads/feature"), + ); + let staging = knot_git::Staging::new(&bare).unwrap(); + + match bare + .rebase_into(staging.repo(), onto, source, &committer()) + .unwrap() + { + Rebased::Conflicted(conflicts) => { + assert_eq!(conflicts[0].path, "precious.txt"); + } + Rebased::Done { .. } => { + panic!("`-merge` has to make this conflict; the live attributes were not consulted") + } + } + + // Driving the same rebase from the staging repo shows the difference: with no HEAD there are no + // attributes, so the edits merge cleanly. + let staging = knot_git::Staging::new(&bare).unwrap(); + assert!( + matches!( + staging.repo().rebase_onto(onto, source, &committer()), + Ok(Rebased::Done { .. }) + ), + "sanity check for the assertion above" + ); +} + +#[test] +fn a_rebase_staged_and_migrated_leaves_the_commits_in_the_live_repo() { + if !common::git_available() { + return; + } + let (_scan, _work, layout, did) = diverged(); + let bare = layout.open(&did).unwrap(); + let (onto, source) = ( + tip(&bare, "refs/heads/main"), + tip(&bare, "refs/heads/feature"), + ); + + let staging = knot_git::Staging::new(&bare).unwrap(); + let Rebased::Done { tip: new_tip, .. } = bare + .rebase_into(staging.repo(), onto, source, &committer()) + .unwrap() + else { + panic!("expected a clean rebase") + }; + assert!( + !bare.contains(new_tip), + "the new commits are quarantined until they are migrated" + ); + + staging.migrate_into(&bare).unwrap(); + assert!(bare.contains(new_tip)); + assert_eq!( + bare.find_commit(new_tip).unwrap().message.trim(), + "plain follow-up" + ); +} diff --git a/knot2/crates/knot-xrpc/src/lib.rs b/knot2/crates/knot-xrpc/src/lib.rs index 36d2332d..47810f96 100644 --- a/knot2/crates/knot-xrpc/src/lib.rs +++ b/knot2/crates/knot-xrpc/src/lib.rs @@ -16,6 +16,7 @@ mod merge; mod patchtext; mod query; mod reads; +mod rebase; mod receive; mod repos; mod reservations; @@ -273,8 +274,13 @@ pub fn router(state: Arc>) -> Router .route(merge::MERGE_ROUTE, post(merge::merge::)) .route(merge::MERGE_CHECK_ROUTE, post(merge::merge_check::)) .layer(DefaultBodyLimit::max(state.byte_limits.patch.get())); + let rebase_routes = Router::new().route( + rebase::MERGE_COMMIT_ROUTE, + post(rebase::merge_commit::), + ); Router::new() .merge(merge_routes) + .merge(rebase_routes) .route(members::ADD_ROUTE, post(members::add_member::)) .route(members::REMOVE_ROUTE, post(members::remove_member::)) .route(blocklist::BAN_ROUTE, post(blocklist::ban::)) diff --git a/knot2/crates/knot-xrpc/src/merge.rs b/knot2/crates/knot-xrpc/src/merge.rs index 69094aaf..7b067a98 100644 --- a/knot2/crates/knot-xrpc/src/merge.rs +++ b/knot2/crates/knot-xrpc/src/merge.rs @@ -27,7 +27,7 @@ use crate::{XrpcState, decode, ok_empty, run_blocking}; pub(crate) const MERGE_ROUTE: &str = "/xrpc/sh.tangled.repo.merge"; pub(crate) const MERGE_CHECK_ROUTE: &str = "/xrpc/sh.tangled.repo.mergeCheck"; -const MERGE_RETRIES: u32 = 3; +pub(crate) const MERGE_RETRIES: u32 = 3; const CONFLICT_MESSAGE: &str = "patch cannot be applied cleanly"; #[derive(Debug, Clone, PartialEq, Eq)] @@ -161,7 +161,7 @@ pub(crate) fn resolve_by_name( resolve_repo(state, &RepoArg::OwnerRkey { owner, rkey }) } -fn branch_tip(repo: &Repo, refname: &RefName) -> Result { +pub(crate) fn branch_tip(repo: &Repo, refname: &RefName) -> Result { repo.find_ref(refname)? .ok_or_else(|| XrpcError::invalid_request("no such branch to merge into")) } diff --git a/knot2/crates/knot-xrpc/src/rebase.rs b/knot2/crates/knot-xrpc/src/rebase.rs new file mode 100644 index 00000000..5b021248 --- /dev/null +++ b/knot2/crates/knot-xrpc/src/rebase.rs @@ -0,0 +1,253 @@ +//! `sh.tangled.git.mergeCommit`, `rebase` style. + +use std::sync::Arc; + +use axum::extract::State; +use http::{HeaderMap, StatusCode}; + +use jacquard_axum::{ExtractXrpc, XrpcResponse}; +use knot_events::Reservation; +use knot_git::{Conflict, Identity, Rebased, RefUpdate, Repo, Staging}; +use knot_postreceive::{Actor, Ci}; +use knot_runtime::{Clock, HttpTransport}; +use knot_types::{AccountDid, BranchName, Oid, RefName, RepoDid}; + +use lexicons::sh_tangled::git::merge_commit; + +use crate::error::XrpcError; +use crate::merge::{MERGE_RETRIES, branch_tip}; +use crate::reads::{HostedRepo, open, require_hosted}; +use crate::{XrpcState, run_blocking}; + +pub(crate) const MERGE_COMMIT_ROUTE: &str = "/xrpc/sh.tangled.git.mergeCommit"; +const MERGE_COMMIT_DENIED: &str = "only repository owner or a collaborator may merge"; +const REBASE_STYLE: &str = "rebase"; +const REBASE_CONFLICT_MESSAGE: &str = "commits cannot be replayed onto the target branch cleanly"; + +enum RebaseAttempt { + Done { + old: Oid, + new: Oid, + reservation: Reservation, + }, + Conflicted(Vec), + /// The branch already contains everything, so there is nothing to do. + UpToDate, + /// The target branch moved while we were working; worth another go. + Raced, +} + +/// Replay the source commit and the ancestors of it that the target branch doesn't have on top of +/// that branch. +/// +/// Only the `rebase` style is implemented; the others create a merge commit, which is a separate +/// piece of work. `mergeCommit.author`/`mergeCommit.message` only ever feed such a commit and are +/// therefore ignored, exactly as the Go knot ignores them for this style. +/// +/// The source has to be a commit in the target repository. Fetching one from a fork is deliberately +/// not supported: it would make a merge depend on another knot being reachable, and the fork's +/// commits can be brought over with the fork endpoints first. +pub(crate) async fn merge_commit( + State(state): State>>, + headers: HeaderMap, + method: crate::Method, + ExtractXrpc(input): ExtractXrpc, +) -> Result, XrpcError> { + let actor = state.authenticate(&headers, &method).await?; + + let style: &str = input.style.as_ref(); + if style != REBASE_STYLE { + return Err(XrpcError::invalid_request(format!( + "this knot only implements the {REBASE_STYLE:?} merge style, not {style:?}" + ))); + } + + let target_repo = RepoDid::new(input.target.repo.as_str()) + .map_err(|_| XrpcError::invalid_request("target.repo must be a DID"))?; + let source_repo = RepoDid::new(input.source.repo.as_str()) + .map_err(|_| XrpcError::invalid_request("source.repo must be a DID"))?; + let source_commit_hex: &str = input.source.commit.as_ref(); + let source_commit = Oid::from_hex(source_commit_hex) + .map_err(|_| XrpcError::invalid_request("source.commit must be an object id"))?; + let branch_name: &str = input.target.branch.as_ref(); + let branch = BranchName::new(branch_name) + .map_err(|_| XrpcError::invalid_request("target.branch must be a branch name"))?; + + let target = require_hosted(&state, target_repo)?; + crate::authorize_push(&state, &actor, &target, MERGE_COMMIT_DENIED).await?; + + if source_repo != *target { + return Err(XrpcError::invalid_request( + "source.repo must be the target repository; this knot doesn't merge from a fork", + )); + } + + let refname = branch.head_ref(); + let committer = state.committer.clone(); + let now = state.now(); + let layout = state.layout.clone(); + let events = Arc::clone(&state.events); + let opened = target.clone(); + let attempt_ref = refname.clone(); + let outcome = run_blocking(move || { + let repo = open(&layout, &opened)?; + let committer = Identity { + name: committer.name.clone(), + email: committer.email.clone(), + time: now, + offset_seconds: 0, + }; + repo.find_commit(source_commit).map_err(|_| { + XrpcError::invalid_request("source.commit doesn't name a commit in this repository") + })?; + let reserve = || events.reserve(); + rebase_with_retry( + &repo, + &attempt_ref, + source_commit, + &committer, + MERGE_RETRIES, + &reserve, + ) + }) + .await?; + + match outcome { + RebaseAttempt::Conflicted(conflicts) => Err(rebase_conflict(&conflicts)), + RebaseAttempt::Raced => Err(XrpcError::conflict( + "the target branch changed while merging; retry the merge", + )), + RebaseAttempt::UpToDate => Ok(XrpcResponse(())), + RebaseAttempt::Done { + old, + new, + reservation, + } => { + announce_ref_update(&state, target, actor, refname, old, new, reservation).await; + Ok(XrpcResponse(())) + } + } +} + +fn rebase_with_retry( + repo: &Repo, + refname: &RefName, + source: Oid, + committer: &Identity, + attempts: u32, + reserve: &dyn Fn() -> Reservation, +) -> Result { + let mut last = RebaseAttempt::Raced; + for _ in 0..attempts.max(1) { + last = attempt_rebase(repo, refname, source, committer, reserve)?; + if !matches!(last, RebaseAttempt::Raced) { + return Ok(last); + } + } + Ok(last) +} + +fn attempt_rebase( + repo: &Repo, + refname: &RefName, + source: Oid, + committer: &Identity, + reserve: &dyn Fn() -> Reservation, +) -> Result { + let tip = branch_tip(repo, refname)?; + let staging = Staging::new(repo).map_err(XrpcError::from)?; + // Driven from the live repository so its config and `.gitattributes` decide how files merge; the + // new objects land in the quarantine until we know we want them. + let new_tip = match repo + .rebase_into(staging.repo(), tip, source, committer) + .map_err(XrpcError::from)? + { + Rebased::Conflicted(conflicts) => return Ok(RebaseAttempt::Conflicted(conflicts)), + Rebased::Done { tip, .. } => tip, + }; + if new_tip == tip { + return Ok(RebaseAttempt::UpToDate); + } + + match repo.find_ref(refname).map_err(XrpcError::from)? { + Some(current) if current == tip => {} + _ => return Ok(RebaseAttempt::Raced), + } + staging.migrate_into(repo).map_err(XrpcError::from)?; + match repo.update_ref_sealed( + &RefUpdate::Update { + name: refname.clone(), + old: tip, + new: new_tip, + }, + reserve, + ) { + Ok(reservation) => Ok(RebaseAttempt::Done { + old: tip, + new: new_tip, + reservation, + }), + Err(error) => match repo.find_ref(refname) { + Ok(Some(current)) if current != tip => Ok(RebaseAttempt::Raced), + _ => Err(error.into()), + }, + } +} + +fn rebase_conflict(conflicts: &[Conflict]) -> XrpcError { + let detail = conflicts + .first() + .map(|conflict| format!("{REBASE_CONFLICT_MESSAGE}: {}", conflict.path)) + .unwrap_or_else(|| REBASE_CONFLICT_MESSAGE.to_string()); + XrpcError::named( + StatusCode::CONFLICT, + "MergeConflict", + format!("Merge failed due to conflicts: {detail}"), + ) +} + +/// Fire the in-process replacement for the repository's post-receive hook. +async fn announce_ref_update( + state: &Arc>, + target: HostedRepo, + actor: AccountDid, + refname: RefName, + old: Oid, + new: Oid, + reservation: Reservation, +) { + let owner = crate::current_owner(state, &target); + let layout = state.layout.clone(); + let languages_push_budget = state.budgets.languages_push; + let catalog = Arc::clone(&state.catalog); + let repo_label = target.as_str().to_string(); + let repo_did = target.into_did(); + if let Err(error) = run_blocking(move || -> Result<(), XrpcError> { + let repo = open(&layout, &repo_did)?; + let update = RefUpdate::Update { + name: refname, + old, + new, + }; + let post_actor = Actor { + committer: actor, + owner, + repo: repo_did, + }; + knot_postreceive::post_receive( + &repo, + &post_actor, + vec![(update, reservation)], + &Ci::Skip, + &knot_types::PushOptions::default(), + None, + languages_push_budget, + &catalog.push, + ); + Ok(()) + }) + .await + { + tracing::warn!(repo = %repo_label, %error, "post-receive after mergeCommit failed"); + } +} diff --git a/knot2/crates/knot-xrpc/src/tests.rs b/knot2/crates/knot-xrpc/src/tests.rs index dd367f48..0ffd4d1c 100644 --- a/knot2/crates/knot-xrpc/src/tests.rs +++ b/knot2/crates/knot-xrpc/src/tests.rs @@ -2709,6 +2709,421 @@ mod merge_endpoints { } } +mod merge_commit_endpoint { + use super::*; + + use knot_git::{EntryKind, Identity, NewCommit, RefUpdate, StagedAction, StagedChange}; + use knot_types::{Oid, RefName, UnixSeconds}; + + const MERGE_COMMIT: &str = "sh.tangled.git.mergeCommit"; + const EMPTY_TREE: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"; + + fn author() -> Identity { + Identity { + name: AuthorName::new("nel"), + email: Email::new("nel@oyster.cafe"), + time: UnixSeconds::new(1_000), + offset_seconds: 0, + } + } + + /// Write a commit on top of `parent` with the given files replacing the whole tree. + fn commit_on( + world: &World, + repo_did: &RepoDid, + parent: Option, + files: &[(&str, &str)], + message: &str, + extra_headers: Vec<(String, Vec)>, + ) -> Oid { + let repo = world.layout.open(repo_did).unwrap(); + let staged: Vec = files + .iter() + .map(|(path, content)| StagedChange { + path: knot_types::RepoPath::new(*path).unwrap(), + action: StagedAction::Put { + content: content.as_bytes().to_vec(), + kind: EntryKind::Blob, + }, + }) + .collect(); + let tree = repo + .write_staged_tree(Oid::from_hex(EMPTY_TREE).unwrap(), &staged) + .unwrap(); + repo.write_commit(&NewCommit { + tree, + parents: parent.into_iter().collect(), + author: author(), + committer: author(), + message: message.to_string(), + extra_headers, + }) + .unwrap() + } + + fn set_ref(world: &World, repo_did: &RepoDid, name: &str, target: Oid) { + let repo = world.layout.open(repo_did).unwrap(); + let name = RefName::new(name).unwrap(); + let update = match repo.find_ref(&name).unwrap() { + Some(old) => RefUpdate::Update { + name, + old, + new: target, + }, + None => RefUpdate::Create { name, new: target }, + }; + repo.update_refs(std::slice::from_ref(&update)).unwrap(); + } + + fn tip_of(world: &World, repo_did: &RepoDid, name: &str) -> Oid { + world + .layout + .open(repo_did) + .unwrap() + .find_ref(&RefName::new(name).unwrap()) + .unwrap() + .unwrap() + } + + /// `main` and a source commit that diverged from a shared base, the source carrying a `change-id`. + struct Diverged { + repo_did: RepoDid, + main: Oid, + source: Oid, + } + + async fn diverged(world: &World) -> Diverged { + add_member_helper(world).await; + let repo_did = create_repo_helper(world, "limpet").await; + let base = commit_on( + world, + &repo_did, + None, + &[("base.txt", "base\n")], + "base", + Vec::new(), + ); + let main = commit_on( + world, + &repo_did, + Some(base), + &[("base.txt", "base\n"), ("main.txt", "main\n")], + "main moves on", + Vec::new(), + ); + let source = commit_on( + world, + &repo_did, + Some(base), + &[("base.txt", "base\n"), ("feature.txt", "feature\n")], + "the feature", + vec![("change-id".to_string(), b"zzzzkzkzkzkzkzkzkz".to_vec())], + ); + set_ref(world, &repo_did, "refs/heads/main", main); + set_ref(world, &repo_did, "refs/heads/feature", source); + Diverged { + repo_did, + main, + source, + } + } + + /// `mergeCommit` takes its input through the lexicon extractor rather than raw bytes, so it needs + /// its own little caller. + async fn merge_commit_as( + world: &World, + actor: &super::Actor, + value: serde_json::Value, + ) -> Response { + let token = mint(actor, MERGE_COMMIT); + let input = serde_json::from_value(value).expect("input matches the lexicon"); + let result = crate::rebase::merge_commit( + world.state(), + bearer(&token), + crate::Method::from_nsid(MERGE_COMMIT), + jacquard_axum::ExtractXrpc(input), + ) + .await; + into_response(result.map(IntoResponse::into_response)) + } + + fn rebase_input(target: &RepoDid, source: &RepoDid, commit: Oid) -> serde_json::Value { + json!({ + "target": { "repo": target, "branch": "main" }, + "source": { "repo": source, "commit": commit.to_hex() }, + "style": "rebase", + }) + } + + #[tokio::test] + async fn a_rebase_moves_the_branch_and_keeps_the_change_id() { + let world = World::new(); + let Diverged { + repo_did, + main, + source, + } = diverged(&world).await; + + let response = merge_commit_as( + &world, + &world.member, + rebase_input(&repo_did, &repo_did, source), + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + + let repo = world.layout.open(&repo_did).unwrap(); + let tip = tip_of(&world, &repo_did, "refs/heads/main"); + assert_ne!(tip, main, "the branch moved"); + let replayed = repo.find_commit(tip).unwrap(); + assert_eq!(replayed.parents, vec![main], "replayed onto the old tip"); + assert_eq!(replayed.message.trim(), "the feature"); + assert_eq!( + replayed.change_id().map(|id| id.as_str().to_string()), + Some("zzzzkzkzkzkzkzkzkz".to_string()), + "the change-id survives the rebase" + ); + assert_eq!( + replayed.author.email.as_str(), + "nel@oyster.cafe", + "the author is preserved" + ); + // Both sides' files are present, which is the point of the rebase. + for path in ["base.txt", "main.txt", "feature.txt"] { + assert!( + repo.find_tree(replayed.tree) + .unwrap() + .entries + .iter() + .any(|entry| entry.name == path), + "{path} should be in the rebased tree" + ); + } + } + + #[tokio::test] + async fn a_source_in_another_repo_is_rejected() { + let world = World::new(); + add_member_helper(&world).await; + let target_did = create_repo_helper(&world, "limpet").await; + let fork_did = create_repo_helper(&world, "limpet-fork").await; + + let base = commit_on( + &world, + &target_did, + None, + &[("base.txt", "base\n")], + "base", + Vec::new(), + ); + let main = commit_on( + &world, + &target_did, + Some(base), + &[("base.txt", "base\n"), ("main.txt", "main\n")], + "main moves on", + Vec::new(), + ); + set_ref(&world, &target_did, "refs/heads/main", main); + + // The fork's commit shares the base but the target repository has never seen it. Fetching it + // is deliberately not this endpoint's job. + let fork_base = commit_on( + &world, + &fork_did, + None, + &[("base.txt", "base\n")], + "base", + Vec::new(), + ); + assert_eq!(fork_base, base, "the same content hashes the same"); + let source = commit_on( + &world, + &fork_did, + Some(fork_base), + &[("base.txt", "base\n"), ("fork.txt", "fork\n")], + "the fork's commit", + Vec::new(), + ); + set_ref(&world, &fork_did, "refs/heads/main", source); + + let response = merge_commit_as( + &world, + &world.member, + rebase_input(&target_did, &fork_did, source), + ) + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_eq!( + tip_of(&world, &target_did, "refs/heads/main"), + main, + "a rejected merge must not move the branch" + ); + } + + #[tokio::test] + async fn a_conflicting_rebase_is_a_409_and_moves_nothing() { + let world = World::new(); + add_member_helper(&world).await; + let repo_did = create_repo_helper(&world, "limpet").await; + let base = commit_on( + &world, + &repo_did, + None, + &[("reef.txt", "one\ntwo\nthree\n")], + "base", + Vec::new(), + ); + let main = commit_on( + &world, + &repo_did, + Some(base), + &[("reef.txt", "one\nMAIN\nthree\n")], + "main edits the middle", + Vec::new(), + ); + let source = commit_on( + &world, + &repo_did, + Some(base), + &[("reef.txt", "one\nFEATURE\nthree\n")], + "feature edits the middle", + Vec::new(), + ); + set_ref(&world, &repo_did, "refs/heads/main", main); + + let response = merge_commit_as( + &world, + &world.member, + rebase_input(&repo_did, &repo_did, source), + ) + .await; + assert_eq!(response.status(), StatusCode::CONFLICT); + let body = json_of(response).await; + assert_eq!(body["error"], "MergeConflict", "{body}"); + assert!( + body["message"].as_str().unwrap().contains("reef.txt"), + "the conflicting path should be named: {body}" + ); + assert_eq!( + tip_of(&world, &repo_did, "refs/heads/main"), + main, + "a conflicting rebase must not move the branch" + ); + } + + #[tokio::test] + async fn replaying_nothing_is_a_no_op() { + let world = World::new(); + let Diverged { repo_did, main, .. } = diverged(&world).await; + + // The source is already the branch tip, so there is nothing to replay. + let response = merge_commit_as( + &world, + &world.member, + rebase_input(&repo_did, &repo_did, main), + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(tip_of(&world, &repo_did, "refs/heads/main"), main); + } + + #[tokio::test] + async fn only_the_rebase_style_is_accepted() { + let world = World::new(); + let Diverged { + repo_did, + main, + source, + } = diverged(&world).await; + + for style in [ + "merge", + "rebase-merge", + "squash-rebase", + "fast-forward-only", + "", + ] { + let mut input = rebase_input(&repo_did, &repo_did, source); + input["style"] = json!(style); + let response = merge_commit_as(&world, &world.member, input).await; + assert_eq!( + response.status(), + StatusCode::BAD_REQUEST, + "style {style:?} should be rejected" + ); + } + assert_eq!(tip_of(&world, &repo_did, "refs/heads/main"), main); + } + + #[tokio::test] + async fn a_stranger_cannot_merge() { + let world = World::new(); + let Diverged { + repo_did, + main, + source, + } = diverged(&world).await; + + let response = merge_commit_as( + &world, + &world.stranger, + rebase_input(&repo_did, &repo_did, source), + ) + .await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert_eq!(tip_of(&world, &repo_did, "refs/heads/main"), main); + } + + #[tokio::test] + async fn bad_input_is_rejected() { + let world = World::new(); + let Diverged { + repo_did, source, .. + } = diverged(&world).await; + + let mut unknown_branch = rebase_input(&repo_did, &repo_did, source); + unknown_branch["target"]["branch"] = json!("no-such-branch"); + assert_eq!( + merge_commit_as(&world, &world.member, unknown_branch) + .await + .status(), + StatusCode::BAD_REQUEST, + "merging into a branch that isn't there" + ); + + let mut bad_commit = rebase_input(&repo_did, &repo_did, source); + bad_commit["source"]["commit"] = json!("not-an-oid"); + assert_eq!( + merge_commit_as(&world, &world.member, bad_commit) + .await + .status(), + StatusCode::BAD_REQUEST, + "source.commit has to be an object id" + ); + + let mut not_a_commit = rebase_input(&repo_did, &repo_did, source); + not_a_commit["source"]["commit"] = json!(EMPTY_TREE); + assert_eq!( + merge_commit_as(&world, &world.member, not_a_commit) + .await + .status(), + StatusCode::BAD_REQUEST, + "an oid that exists but isn't a commit" + ); + + let mut unknown_repo = rebase_input(&repo_did, &repo_did, source); + unknown_repo["target"]["repo"] = json!("did:web:nowhere.example"); + assert_eq!( + merge_commit_as(&world, &world.member, unknown_repo) + .await + .status(), + StatusCode::NOT_FOUND, + "the target repository has to be hosted here" + ); + } +} + mod fork_endpoints { use super::*;