From d85ed75bf6b0f7086887ff6c5f8bd1f7793d342f Mon Sep 17 00:00:00 2001 From: Seongmin Lee Date: Fri, 21 Aug 2026 13:08:39 +0900 Subject: [PATCH] crates: gix-rebase Signed-off-by: Seongmin Lee --- Cargo.lock | 14 + Cargo.toml | 7 +- crates/gix-rebase/Cargo.toml | 35 ++ crates/gix-rebase/README.md | 4 + crates/gix-rebase/src/lib.rs | 6 + crates/gix-rebase/src/replay/function.rs | 126 +++++++ crates/gix-rebase/src/replay/mod.rs | 150 ++++++++ .../tests/fixtures/rebase-baseline.sh | 192 ++++++++++ crates/gix-rebase/tests/rebase/baseline.rs | 351 ++++++++++++++++++ crates/gix-rebase/tests/rebase/main.rs | 193 ++++++++++ 10 files changed, 1077 insertions(+), 1 deletion(-) create mode 100644 crates/gix-rebase/Cargo.toml create mode 100644 crates/gix-rebase/README.md create mode 100644 crates/gix-rebase/src/lib.rs create mode 100644 crates/gix-rebase/src/replay/function.rs create mode 100644 crates/gix-rebase/src/replay/mod.rs create mode 100755 crates/gix-rebase/tests/fixtures/rebase-baseline.sh create mode 100644 crates/gix-rebase/tests/rebase/baseline.rs create mode 100644 crates/gix-rebase/tests/rebase/main.rs diff --git a/Cargo.lock b/Cargo.lock index 8d44f81cb..2ae6d5c30 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3414,6 +3414,20 @@ dependencies = [ "gix-utils", ] +[[package]] +name = "gix-rebase" +version = "0.0.1" +dependencies = [ + "gix", + "gix-actor", + "gix-diff", + "gix-hash", + "gix-merge", + "gix-object", + "tempfile", + "thiserror 2.0.18", +] + [[package]] name = "gix-ref" version = "0.64.0" diff --git a/Cargo.toml b/Cargo.toml index 8558b79ce..02c943a68 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,6 +32,7 @@ print_stderr = "warn" knot-capability = { path = "crates/knot-capability" } lexicons = { path = "crates/lexicons" } trusted-proxies = { path = "crates/trusted-proxies" } +gix-rebase = { path = "crates/gix-rebase" } bobbin-types = { path = "bobbin/crates/types" } bobbin-codesearch = { path = "bobbin/crates/codesearch" } @@ -87,11 +88,15 @@ jacquard-lexicon = { version = "0.12.1", default-features = false } jacquard-identity = { version = "0.12.1", features = ["cache"] } jacquard-repo = "0.12.1" -gix = { version = "0.84", features = ["parallel", "revision", "blob-diff", "worktree-archive", "tree-editor", "sha1", "sha256"] } +gix = { version = "0.84", features = ["parallel", "revision", "blob-diff", "worktree-archive", "tree-editor", "merge", "sha1", "sha256"] } gix-pack = { version = "0.71", default-features = false, features = ["generate", "streaming-input", "sha1", "sha256"] } gix-packetline = { version = "0.21", features = ["blocking-io"] } gix-archive = "0.33" gix-hash = { version = "0.25", features = ["sha1", "sha256"] } +gix-actor = "0.41" +gix-diff = "0.64" +gix-merge = "0.17" +gix-object = "0.61" flate2 = "1" anyhow = "1" diff --git a/crates/gix-rebase/Cargo.toml b/crates/gix-rebase/Cargo.toml new file mode 100644 index 000000000..31dd6be32 --- /dev/null +++ b/crates/gix-rebase/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "gix-rebase" +description = "A crate of the gitoxide project implementing rebase, i.e. replaying commits onto a new base" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[lints] +workspace = true + +[lib] +doctest = false + +[features] +## Enable support for the SHA-1 hash by enabling the respective feature in the `gix-hash` crate. +sha1 = ["gix-hash/sha1"] +## Enable support for the SHA-256 hash by enabling the respective feature in the `gix-hash` crate. +sha256 = ["gix-hash/sha256"] + +[dependencies] +gix-actor = { workspace = true } +gix-diff = { workspace = true } +gix-hash = { workspace = true } +gix-merge = { workspace = true } +gix-object = { workspace = true } + +thiserror = { workspace = true } + +[dev-dependencies] +gix = { workspace = true } +tempfile = { workspace = true } + +[package.metadata.docs.rs] +all-features = true diff --git a/crates/gix-rebase/README.md b/crates/gix-rebase/README.md new file mode 100644 index 000000000..cd2e3c1c3 --- /dev/null +++ b/crates/gix-rebase/README.md @@ -0,0 +1,4 @@ +# gix-rebase + +NOTE: The behavior is not byte-identical to git or jj! +It does not support patch-id/change-id based deduplication. diff --git a/crates/gix-rebase/src/lib.rs b/crates/gix-rebase/src/lib.rs new file mode 100644 index 000000000..73ae74469 --- /dev/null +++ b/crates/gix-rebase/src/lib.rs @@ -0,0 +1,6 @@ +pub mod replay; +pub use replay::function::replay; + +/// The extra commit headers that are dropped from a replayed commit because rewriting it +/// invalidates them. +pub const STALE_HEADERS: &[&str] = &["gpgsig", "gpgsig-sha256", "mergetag"]; diff --git a/crates/gix-rebase/src/replay/function.rs b/crates/gix-rebase/src/replay/function.rs new file mode 100644 index 000000000..47919da3c --- /dev/null +++ b/crates/gix-rebase/src/replay/function.rs @@ -0,0 +1,126 @@ +use gix_hash::ObjectId; +use gix_object::FindExt; + +use super::{Complete, Conflict, EmptyBehavior, Error, Options, Outcome, Pick}; + +/// Replay `picks` onto `onto`, one cherry-pick at a time, and return the new tip. +/// +/// `picks` are the commits to replay, **oldest first**, each with at most one parent. +/// +/// `labels` name the sides for conflict markers, and are only observable when `options` are relaxed +/// enough to let a marked-up blob through. +#[allow(clippy::too_many_arguments)] +pub fn replay( + onto: &gix_hash::oid, + picks: &[ObjectId], + committer: &gix_actor::Signature, + labels: gix_merge::blob::builtin_driver::text::Labels<'_>, + objects: &(impl gix_object::FindObjectOrHeader + gix_object::Write), + diff_state: &mut gix_diff::tree::State, + diff_resource_cache: &mut gix_diff::blob::Platform, + blob_merge: &mut gix_merge::blob::Platform, + options: Options, +) -> Result { + let Options { + mut tree_merge, + treat_as_unresolved, + empty, + } = options; + tree_merge.fail_on_conflict = Some(treat_as_unresolved); + + let empty_tree = ObjectId::empty_tree(onto.kind()); + let mut new_parent = onto.to_owned(); + let mut new_parent_tree = commit_tree(objects, &new_parent)?; + let mut out = Vec::with_capacity(picks.len()); + let mut buf = Vec::new(); + + for pick in picks { + let old_commit = objects.find_commit(pick, &mut buf)?.to_owned()?; + let old_parent_tree = match old_commit.parents.as_slice() { + [] => empty_tree, + [one] => commit_tree(objects, one)?, + many => { + return Err(Error::MergeCommit { + id: *pick, + parents: many.len(), + }); + } + }; + + let was_empty = old_parent_tree == old_commit.tree; + + // When the new parent's tree is already the commit's own base, the merge would have *ours* + // equal to the base and could only ever come out as *theirs*, so take that answer directly. + let new_tree = if new_parent_tree == old_parent_tree { + old_commit.tree + } else { + let merge = gix_merge::tree( + &old_parent_tree, + &new_parent_tree, + &old_commit.tree, + labels, + objects, + |buf| objects.write_buf(gix_object::Kind::Blob, buf), + diff_state, + diff_resource_cache, + blob_merge, + tree_merge.clone(), + ) + .map_err(|err| Error::TreeMerge(Box::new(err)))?; + if merge + .conflicts + .iter() + .any(|conflict| conflict.is_unresolved(treat_as_unresolved)) + { + return Ok(Outcome::Conflict(Conflict { + at: *pick, + onto: new_parent, + picks: out, + conflicts: merge.conflicts, + })); + } + let mut editor = merge.tree; + editor + .write(|tree| objects.write(tree)) + .map_err(Error::WriteTree)? + }; + + // TODO: gate for only one parent when we support `--rebase-merges` (follow jj behavior) + let is_empty = new_parent_tree == new_tree; + let should_abandon = match empty { + EmptyBehavior::Keep => false, + EmptyBehavior::AbandonWasEmpty => is_empty && was_empty, + EmptyBehavior::AbandonNewlyEmpty => is_empty && !was_empty, + EmptyBehavior::AbandonAllEmpty => is_empty, + }; + if should_abandon { + out.push(Pick::Dropped); + continue; + } + + let mut rewritten = old_commit; + rewritten.tree = new_tree; + rewritten.parents = std::iter::once(new_parent).collect(); + rewritten.committer = committer.clone(); + rewritten + .extra_headers + .retain(|(name, _)| !crate::STALE_HEADERS.iter().any(|stale| name == stale)); + + new_parent = objects.write(&rewritten).map_err(Error::WriteCommit)?; + new_parent_tree = new_tree; + out.push(Pick::Rewritten(new_parent)); + } + + Ok(Outcome::Complete(Complete { + tip: new_parent, + picks: out, + })) +} + +fn commit_tree( + objects: &impl gix_object::FindObjectOrHeader, + id: &gix_hash::oid, +) -> Result { + let mut buf = Vec::new(); + Ok(objects.find_commit(id, &mut buf)?.tree()) +} diff --git a/crates/gix-rebase/src/replay/mod.rs b/crates/gix-rebase/src/replay/mod.rs new file mode 100644 index 000000000..409b9cbaa --- /dev/null +++ b/crates/gix-rebase/src/replay/mod.rs @@ -0,0 +1,150 @@ +use gix_hash::ObjectId; + +/// What to do with a commit that changes nothing once it has been replayed. +/// [conflict](Outcome::Conflict). +#[derive(Default, Debug, Copy, Clone, Eq, PartialEq)] +pub enum EmptyBehavior { + /// Keep every commit, even one that changes nothing once replayed. + /// + /// `git rebase --keep-empty --empty=keep`. + Keep, + /// Drop commits that were already empty relative to their own parent, but keep ones that only + /// became empty by being replayed. + /// + /// `git rebase --no-keep-empty --empty=keep`. This is the one case jj cannot express. + AbandonWasEmpty, + /// Drop commits that had changes of their own but lost them by being replayed, and keep ones that + /// were empty to begin with — making one of those took `git commit --allow-empty`, so it was + /// deliberate. + /// + /// `git rebase --keep-empty --empty=drop`, which is `git rebase`'s default, and ours. + #[default] + AbandonNewlyEmpty, + /// Drop every commit that changes nothing once replayed, however it came to be that way. + /// + /// `git rebase --no-keep-empty --empty=drop`. + AbandonAllEmpty, +} + +/// Options for use in [`replay()`](crate::replay()). +#[derive(Debug, Clone)] +pub struct Options { + /// The options to use for each individual tree-merge. + /// + /// Note that [`fail_on_conflict`](gix_merge::tree::Options::fail_on_conflict) is always + /// overridden with [`Self::treat_as_unresolved`], as there is no point in merging the rest of a + /// tree that is about to be thrown away. + /// + /// Keep [`rewrites`](gix_merge::tree::Options::rewrites) enabled to match `git rebase`, whose + /// merge backend performs rename and directory-rename detection. + pub tree_merge: gix_merge::tree::Options, + /// Which conflicts are bad enough to stop the replay. + /// + /// [`TreatAsUnresolved::git()`](gix_merge::tree::TreatAsUnresolved::git()) matches what `git` + /// itself would refuse to commit. + pub treat_as_unresolved: gix_merge::tree::TreatAsUnresolved, + /// What to do with a commit whose replay changes nothing. + pub empty: EmptyBehavior, +} + +impl Default for Options { + fn default() -> Self { + Options { + tree_merge: gix_merge::tree::Options::default(), + treat_as_unresolved: gix_merge::tree::TreatAsUnresolved::git(), + empty: EmptyBehavior::default(), + } + } +} + +/// What happened to a single commit that was handed to [`replay()`](crate::replay()). +#[derive(Debug, Copy, Clone, Eq, PartialEq)] +pub enum Pick { + /// The commit was replayed as a new commit with the given id. + Rewritten(ObjectId), + /// The commit was dropped, as replaying it wouldn't have changed anything. + Dropped, +} + +/// The result of replaying every commit without hitting a conflict. +#[derive(Debug, Clone)] +pub struct Complete { + /// The id of the last commit that was written, or the `onto` commit if every commit was dropped. + pub tip: ObjectId, + /// One entry per input commit, in input order. + pub picks: Vec, +} + +impl Complete { + /// Return the ids of all commits that were actually written, oldest first. + pub fn rewritten(&self) -> impl Iterator + '_ { + self.picks.iter().filter_map(|pick| match pick { + Pick::Rewritten(id) => Some(*id), + Pick::Dropped => None, + }) + } + + /// Return the amount of commits that were dropped. + pub fn dropped(&self) -> usize { + self.picks + .iter() + .filter(|pick| **pick == Pick::Dropped) + .count() + } +} + +/// The conflict that made a replay stop. +#[derive(Debug, Clone)] +pub struct Conflict { + /// The commit that couldn't be replayed. + pub at: ObjectId, + /// The tip it was being replayed onto. + pub onto: ObjectId, + /// The commits that were written before the conflict was hit, oldest first. + pub picks: Vec, + /// The conflicts that were encountered, including those that could be resolved automatically. + pub conflicts: Vec, +} + +impl Conflict { + /// Return the paths of all conflicts that `how` considers unresolved, in the order they were + /// encountered. + pub fn unresolved_paths( + &self, + how: gix_merge::tree::TreatAsUnresolved, + ) -> impl Iterator + '_ { + self.conflicts + .iter() + .filter(move |conflict| conflict.is_unresolved(how)) + .map(|conflict| conflict.changes_in_resolution().1.location()) + } +} + +/// The outcome of [`replay()`](crate::replay()). +#[derive(Debug, Clone)] +pub enum Outcome { + Complete(Complete), + Conflict(Conflict), +} + +/// The error returned by [`replay()`](crate::replay()). +#[derive(Debug, thiserror::Error)] +#[allow(missing_docs)] +pub enum Error { + #[error("Could not find a commit to replay, the commit it is based on, or the tip")] + FindCommit(#[from] gix_object::find::existing_object::Error), + #[error("Could not decode a commit that is to be replayed")] + DecodeCommit(#[from] gix_object::decode::Error), + #[error( + "Commit {id} has {parents} parents, but only commits with a single parent can be replayed" + )] + MergeCommit { id: ObjectId, parents: usize }, + #[error(transparent)] + TreeMerge(#[from] Box), + #[error("Could not write the merged tree of a replayed commit into the object database: {0}")] + WriteTree(gix_object::write::Error), + #[error("Could not write a replayed commit into the object database: {0}")] + WriteCommit(gix_object::write::Error), +} + +pub(super) mod function; diff --git a/crates/gix-rebase/tests/fixtures/rebase-baseline.sh b/crates/gix-rebase/tests/fixtures/rebase-baseline.sh new file mode 100755 index 000000000..6b5ce3e1d --- /dev/null +++ b/crates/gix-rebase/tests/fixtures/rebase-baseline.sh @@ -0,0 +1,192 @@ +#!/usr/bin/env bash +# Build one repository per rebase case and record what `git rebase` makes of it, so the Rust side can +# be held to the real thing. +# +# Each case ends up as a directory containing a repository with these refs: +# +# onto the commit to replay onto +# source the tip of the commits to replay +# expected what `git rebase onto source` produced, absent if git stopped on a conflict +# +# A line per case is appended to `baseline.cases`: +set -eu -o pipefail + +export GIT_CONFIG_GLOBAL=/dev/null +export GIT_CONFIG_SYSTEM=/dev/null +export GIT_AUTHOR_NAME=author +export GIT_AUTHOR_EMAIL=author@example.com +export GIT_COMMITTER_NAME=committer +export GIT_COMMITTER_EMAIL=committer@example.com +export GIT_TERMINAL_PROMPT=0 + +# Keep this in sync with `REBASE_COMMITTER_*` in tests/rebase/main.rs: the replayed commits must be +# byte-identical to git's, which means the committer has to match down to the second. +readonly REBASE_DATE="1700009999 +0000" + +tick_time=1700000000 +function tick() { + tick_time=$((tick_time + 60)) + export GIT_AUTHOR_DATE="$tick_time +0000" + export GIT_COMMITTER_DATE="$tick_time +0000" +} + +function new_case() { + local name=${1:?case name} + mkdir -p "$name" + cd "$name" + tick_time=1700000000 + tick + git init -q -b main . + git config core.autocrlf false +} + +# Run the reference rebase and record the result. Must be called from inside a case directory, with +# `onto` and `source` already pointing somewhere. +# +# The rebase runs on a *detached* HEAD on purpose: rebasing the `source` branch would move it to the +# result, and then `onto..source` would be git's output rather than the input we want to replay. +function record() { + local name=${1:?case name} + shift + git symbolic-ref HEAD refs/heads/source + git reset -q --hard source + git clean -qfdx + git checkout -q --detach source + local conflicted=0 + if GIT_COMMITTER_DATE="$REBASE_DATE" git rebase "$@" onto >/dev/null 2>&1; then + git branch -f expected HEAD + else + conflicted=1 + git rebase --abort >/dev/null 2>&1 || true + fi + echo "$name $conflicted" >> ../baseline.cases + cd .. +} + +function commit() { + tick + local message=${1:?message} + git add -A + git commit -q --allow-empty -m "$message" +} + +# A commit carrying arbitrary extra headers, written by hand because `git commit` cannot make one. +# Usage: commit_with_headers ... +function commit_with_headers() { + tick + local message=${1:?message} + shift + git add -A + local tree parent + tree="$(git write-tree)" + parent="$(git rev-parse HEAD)" + { + echo "tree $tree" + echo "parent $parent" + echo "author $GIT_AUTHOR_NAME <$GIT_AUTHOR_EMAIL> $GIT_AUTHOR_DATE" + echo "committer $GIT_COMMITTER_NAME <$GIT_COMMITTER_EMAIL> $GIT_COMMITTER_DATE" + local header + for header in "$@"; do echo "$header"; done + echo + echo "$message" + } > .commit-raw + local id + id="$(git hash-object -t commit -w --stdin < .commit-raw)" + rm -f .commit-raw + git update-ref HEAD "$id" +} + +# --- linear: three commits replayed onto an advanced base ------------------------------------- +new_case linear + echo base > base.txt && commit base + git branch fork + echo one > one.txt && commit "onto one" + echo two > two.txt && commit "onto two" + git branch onto + git symbolic-ref HEAD refs/heads/fork && git reset -q --hard fork && git clean -qfdx + echo a > a.txt && commit "pick a" + echo b > b.txt && commit "pick b" + echo c > c.txt && commit "pick c" + git branch source +record linear + +# --- extra headers: git drops them, we must not ----------------------------------------------- +new_case extra-headers + echo base > base.txt && commit base + git branch fork + echo one > one.txt && commit "onto one" + git branch onto + git symbolic-ref HEAD refs/heads/fork && git reset -q --hard fork && git clean -qfdx + echo a > a.txt && commit_with_headers "pick with change-id" \ + "change-id zzzzkzkzkzkzkzkzkzkzkzkzkzkz" "x-custom just because" + echo b > b.txt && commit "plain follow-up" + git branch source +record extra-headers + +# --- a stale signature must be stripped ------------------------------------------------------- +new_case stale-signature + echo base > base.txt && commit base + git branch fork + echo one > one.txt && commit "onto one" + git branch onto + git symbolic-ref HEAD refs/heads/fork && git reset -q --hard fork && git clean -qfdx + echo a > a.txt && commit_with_headers "signed pick" \ + "gpgsig -----BEGIN PGP SIGNATURE-----" \ + "mergetag not-really-a-tag" + git branch source +record stale-signature + +# --- content conflict on the same line -------------------------------------------------------- +new_case conflict + printf 'one\ntwo\nthree\n' > file.txt && commit base + git branch fork + printf 'one\nONTO\nthree\n' > file.txt && commit "onto changes the middle" + git branch onto + git symbolic-ref HEAD refs/heads/fork && git reset -q --hard fork && git clean -qfdx + printf 'one\nSOURCE\nthree\n' > file.txt && commit "source changes the middle" + git branch source +record conflict + +# --- already applied upstream: becomes empty, so git drops it --------------------------------- +new_case becomes-empty + echo base > base.txt && commit base + git branch fork + echo shared > shared.txt && commit "onto adds shared" + git branch onto + git symbolic-ref HEAD refs/heads/fork && git reset -q --hard fork && git clean -qfdx + echo shared > shared.txt && commit "source adds the very same shared" + echo after > after.txt && commit "and something else" + git branch source +record becomes-empty + +# --- started empty: git keeps it -------------------------------------------------------------- +new_case starts-empty + echo base > base.txt && commit base + git branch fork + echo one > one.txt && commit "onto one" + git branch onto + git symbolic-ref HEAD refs/heads/fork && git reset -q --hard fork && git clean -qfdx + commit "deliberately empty" + echo a > a.txt && commit "pick a" + git branch source +record starts-empty + +# --- rename on one side, edit on the other: rename detection has to kick in -------------------- +new_case rename-and-edit + printf 'alpha\nbeta\ngamma\ndelta\nepsilon\nzeta\neta\ntheta\n' > old-name.txt && commit base + git branch fork + git mv old-name.txt new-name.txt && commit "onto renames the file" + git branch onto + git symbolic-ref HEAD refs/heads/fork && git reset -q --hard fork && git clean -qfdx + printf 'alpha\nbeta\ngamma\ndelta\nepsilon\nzeta\neta\nTHETA\n' > old-name.txt && commit "source edits the file" + git branch source +record rename-and-edit + +# --- the source is already based on the tip: pure reparenting ---------------------------------- +new_case already-based + echo base > base.txt && commit base + git branch onto + echo a > a.txt && commit "pick a" + echo b > b.txt && commit "pick b" + git branch source +record already-based --no-ff diff --git a/crates/gix-rebase/tests/rebase/baseline.rs b/crates/gix-rebase/tests/rebase/baseline.rs new file mode 100644 index 000000000..aa675e39f --- /dev/null +++ b/crates/gix-rebase/tests/rebase/baseline.rs @@ -0,0 +1,351 @@ +use gix_rebase::replay::{self, EmptyBehavior, Pick}; + +use crate::{Case, complete, conflict, fixture}; + +/// `git rebase` throws every extra commit header away, which is the one thing this crate exists not +/// to do. Those cases get their own assertions below instead of an oid comparison. +const DIVERGES_FROM_GIT: &[&str] = &["extra-headers"]; + +#[test] +fn every_case_matches_git() -> crate::Result { + let cases = std::fs::read_to_string(fixture().join("baseline.cases"))?; + let mut seen = 0; + for line in cases.lines().filter(|line| !line.trim().is_empty()) { + let (name, conflicted) = line + .split_once(' ') + .expect("` ` per line"); + let conflicted = conflicted.trim() == "1"; + seen += 1; + + let case = Case::open(name); + let outcome = case.replay(case.git_default_options())?; + + if conflicted { + let stopped = conflict(outcome); + assert!( + case.expected().is_empty(), + "{name}: git conflicted, so it cannot have recorded a result" + ); + assert!( + stopped + .unresolved_paths(replay::Options::default().treat_as_unresolved) + .next() + .is_some(), + "{name}: a conflict must name at least one path" + ); + continue; + } + + let ours = complete(outcome); + let theirs = case.expected(); + + if DIVERGES_FROM_GIT.contains(&name) { + assert_eq!( + ours.rewritten().count(), + theirs.len(), + "{name}: we must replay the same commits as git, even where the objects differ" + ); + continue; + } + + assert_eq!( + ours.rewritten().collect::>(), + theirs, + "{name}: the replayed commits must be byte-identical to git's" + ); + assert_eq!( + ours.tip, + theirs.last().copied().unwrap_or(case.onto), + "{name}: the tip must be git's tip" + ); + } + assert_eq!(seen, 8, "all fixture cases ran; adjust when adding one"); + Ok(()) +} + +#[test] +fn extra_headers_survive_even_though_git_drops_them() -> crate::Result { + let case = Case::open("extra-headers"); + let ours = complete(case.replay(case.git_default_options())?); + let theirs = case.expected(); + let picks = case.picks(); + + let (ours_first, theirs_first) = (ours.rewritten().next().expect("a commit"), theirs[0]); + assert_ne!( + ours_first, theirs_first, + "keeping the headers necessarily changes the object id" + ); + + assert_eq!( + case.header(ours_first, "change-id").as_deref(), + Some("zzzzkzkzkzkzkzkzkzkzkzkzkzkz"), + "the `change-id` is carried over verbatim" + ); + assert_eq!( + case.header(ours_first, "x-custom").as_deref(), + Some("just because"), + "headers we know nothing about are carried over too" + ); + assert_eq!( + case.header(theirs_first, "change-id"), + None, + "git, for comparison, drops them" + ); + + // Everything else has to be exactly what git produced. + let (mine, gits, original) = ( + case.commit(ours_first), + case.commit(theirs_first), + case.commit(picks[0]), + ); + assert_eq!(mine.tree, gits.tree, "same merge result"); + assert_eq!(mine.parents, gits.parents, "same new parent"); + assert_eq!(mine.author, original.author, "the author is preserved"); + assert_eq!(mine.author, gits.author, "git preserves it the same way"); + assert_eq!(mine.committer, gits.committer, "same fresh committer"); + assert_eq!(mine.message, gits.message, "same message"); + + // The follow-up commit carries no headers of its own, so everything about it matches git's -- + // except its parent, and therefore its id, because our first commit is a different object. + let (plain, gits_plain) = ( + case.commit(ours.rewritten().nth(1).expect("a second commit")), + case.commit(theirs[1]), + ); + assert_eq!(plain.tree, gits_plain.tree); + assert_eq!(plain.author, gits_plain.author); + assert_eq!(plain.committer, gits_plain.committer); + assert_eq!(plain.message, gits_plain.message); + assert!(plain.extra_headers.is_empty()); + assert_eq!( + plain.parents.as_slice(), + [ours_first], + "it follows our commit, which is why its own id differs from git's" + ); + Ok(()) +} + +#[test] +fn a_stale_signature_is_stripped() -> crate::Result { + let case = Case::open("stale-signature"); + let ours = complete(case.replay(case.git_default_options())?); + let rewritten = ours.rewritten().next().expect("a commit"); + + assert_eq!( + case.commit(rewritten).extra_headers, + Vec::new(), + "a signature and a mergetag cannot survive a rewrite, so they go" + ); + assert_eq!( + vec![rewritten], + case.expected(), + "which makes the commit byte-identical to git's" + ); + Ok(()) +} + +#[test] +fn a_commit_that_becomes_empty_is_dropped_but_can_be_kept() -> crate::Result { + let case = Case::open("becomes-empty"); + let picks = case.picks(); + assert_eq!(picks.len(), 2, "the fixture replays two commits"); + + let dropped = complete(case.replay(case.git_default_options())?); + assert_eq!( + dropped.picks[0], + Pick::Dropped, + "the commit whose changes are already upstream goes, as `--empty=drop` is git's default" + ); + assert_eq!(dropped.dropped(), 1); + assert_eq!( + dropped.rewritten().collect::>(), + case.expected(), + "leaving exactly what git left" + ); + + let kept = complete(case.replay(replay::Options { + empty: EmptyBehavior::Keep, + ..case.git_default_options() + })?); + assert_eq!(kept.dropped(), 0, "`--empty=keep` keeps it"); + assert_eq!(kept.rewritten().count(), 2); + Ok(()) +} + +#[test] +fn a_commit_that_started_empty_is_kept_but_can_be_dropped() -> crate::Result { + let case = Case::open("starts-empty"); + let kept = complete(case.replay(case.git_default_options())?); + assert_eq!( + kept.rewritten().collect::>(), + case.expected(), + "an intentionally empty commit is kept, byte-identical to git's" + ); + assert_eq!(kept.dropped(), 0); + + let dropped = complete(case.replay(replay::Options { + empty: EmptyBehavior::AbandonWasEmpty, + ..case.git_default_options() + })?); + assert_eq!(dropped.dropped(), 1, "`--no-keep-empty` drops it"); + Ok(()) +} + +/// The four variants are the four combinations of git's `--empty` and `--keep-empty`, so pin every +/// one of them against both shapes of empty commit. `becomes-empty` replays a commit whose changes +/// are already upstream alongside one that isn't; `starts-empty` replays a deliberately empty commit +/// alongside one that isn't. Each fixture therefore has exactly one droppable commit, and which +/// variants drop it is the whole behaviour. +#[test] +fn each_empty_behaviour_is_one_pair_of_git_flags() -> crate::Result { + let grid = [ + // (behaviour, dropped in `becomes-empty`, dropped in `starts-empty`) + (EmptyBehavior::Keep, 0, 0), + (EmptyBehavior::AbandonWasEmpty, 0, 1), + (EmptyBehavior::AbandonNewlyEmpty, 1, 0), + (EmptyBehavior::AbandonAllEmpty, 1, 1), + ]; + for (empty, becomes_empty, starts_empty) in grid { + for (name, want) in [ + ("becomes-empty", becomes_empty), + ("starts-empty", starts_empty), + ] { + let case = Case::open(name); + let ours = complete(case.replay(replay::Options { + empty, + ..case.git_default_options() + })?); + assert_eq!(ours.dropped(), want, "{name} under {empty:?}"); + assert_eq!( + ours.rewritten().count() + ours.dropped(), + case.picks().len(), + "{name} under {empty:?}: every pick has to be accounted for" + ); + } + } + + assert_eq!( + EmptyBehavior::default(), + EmptyBehavior::AbandonNewlyEmpty, + "our default is git's: keep a deliberately empty commit, drop a redundant one" + ); + Ok(()) +} + +#[test] +fn renames_are_detected_like_git_does() -> crate::Result { + let case = Case::open("rename-and-edit"); + let ours = complete(case.replay(case.git_default_options())?); + assert_eq!( + ours.rewritten().collect::>(), + case.expected(), + "the edit has to follow the rename, or the trees won't match git's" + ); + + // Guard the guard: with rename tracking off this is a modify/delete conflict, so a regression in + // how the options are wired up cannot pass silently. + let without_rewrites = conflict(case.replay(replay::Options { + tree_merge: gix_merge::tree::Options { + rewrites: None, + ..case.git_default_options().tree_merge + }, + ..case.git_default_options() + })?); + assert_eq!( + without_rewrites + .unresolved_paths(replay::Options::default().treat_as_unresolved) + .map(ToString::to_string) + .collect::>(), + vec!["old-name.txt".to_string()], + "without rename tracking the edit has nowhere to go" + ); + Ok(()) +} + +#[test] +fn a_source_already_based_on_the_tip_is_only_reparented() -> crate::Result { + let case = Case::open("already-based"); + let ours = complete(case.replay(case.git_default_options())?); + assert_eq!( + ours.rewritten().collect::>(), + case.expected(), + "git rewrites them too, because the committer changes" + ); + + let picks = case.picks(); + for (original, rewritten) in picks.iter().zip(ours.rewritten()) { + let (before, after) = (case.commit(*original), case.commit(rewritten)); + assert_eq!( + after.tree, before.tree, + "no merge happened, so the tree is untouched" + ); + assert_eq!(after.author, before.author); + assert_eq!( + after.committer, + crate::rebase_committer(), + "only the committer moved" + ); + } + Ok(()) +} + +#[test] +fn a_conflict_reports_the_offending_path_and_writes_no_commit() -> crate::Result { + let case = Case::open("conflict"); + let picks = case.picks(); + let stopped = conflict(case.replay(case.git_default_options())?); + + assert_eq!( + stopped.at, picks[0], + "it stops at the commit that conflicted" + ); + assert_eq!(stopped.onto, case.onto, "having written nothing before it"); + assert!(stopped.picks.is_empty()); + assert_eq!( + stopped + .unresolved_paths(replay::Options::default().treat_as_unresolved) + .map(ToString::to_string) + .collect::>(), + vec!["file.txt".to_string()], + ); + Ok(()) +} + +#[test] +fn a_merge_commit_cannot_be_replayed() -> crate::Result { + // Build a merge commit out of the two diverged sides the `conflict` fixture already has. + let case = Case::open("conflict"); + let merge = { + let mut commit = case.commit(case.source); + commit.parents.push(case.onto); + gix_object::Write::write(&case.repo, &commit).expect("the merge commit can be written") + }; + + let err = case + .replay_onto(case.onto, &[merge], case.git_default_options()) + .expect_err("a merge commit is refused"); + assert!( + matches!(err, replay::Error::MergeCommit { parents: 2, .. }), + "got {err:?}" + ); + Ok(()) +} + +#[test] +fn a_root_commit_is_replayed_against_the_empty_tree() -> crate::Result { + // `linear`'s first commit has no parent; replaying it onto `onto` is what `git rebase --root` + // would do, and its changes are already there, so it drops. + let case = Case::open("linear"); + let root = { + let mut ids: Vec<_> = case + .repo + .rev_walk([case.onto]) + .all()? + .map(|info| info.expect("commit is present").id) + .collect(); + ids.pop().expect("a root commit") + }; + let ours = complete(case.replay_onto(case.onto, &[root], case.git_default_options())?); + assert_eq!(ours.picks, vec![Pick::Dropped]); + assert_eq!(ours.tip, case.onto, "nothing was written"); + Ok(()) +} diff --git a/crates/gix-rebase/tests/rebase/main.rs b/crates/gix-rebase/tests/rebase/main.rs new file mode 100644 index 000000000..d1b2ef061 --- /dev/null +++ b/crates/gix-rebase/tests/rebase/main.rs @@ -0,0 +1,193 @@ +//! Everything here is held against the real `git` binary: `tests/fixtures/rebase-baseline.sh` builds +//! a repository per case and records what `git rebase` made of it, and the tests replay the same +//! input through [`gix_rebase::replay`] and compare. +use std::path::{Path, PathBuf}; + +use gix_hash::ObjectId; +use gix_rebase::replay::{self, Outcome}; + +mod baseline; + +/// What every test returns, mirroring how the `gix` crates do it. +pub type Result = std::result::Result<(), Box>; + +/// The committer that `git rebase` was run with in the fixture. It has to match to the second, or +/// the replayed commits cannot be byte-identical to git's. +/// +/// Keep in sync with `REBASE_DATE` in `tests/fixtures/rebase-baseline.sh`. +const REBASE_COMMITTER_SECONDS: i64 = 1_700_009_999; + +pub fn rebase_committer() -> gix_actor::Signature { + gix_actor::Signature { + name: "committer".into(), + email: "committer@example.com".into(), + time: gix::date::Time { + seconds: REBASE_COMMITTER_SECONDS, + offset: 0, + }, + } +} + +/// Run the fixture script once per test binary and hand out the directory it produced. +pub fn fixture() -> &'static Path { + static FIXTURE: std::sync::OnceLock = std::sync::OnceLock::new(); + FIXTURE.get_or_init(|| { + let script = + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/rebase-baseline.sh"); + // Not a tempdir: keeping it around makes a failure inspectable with plain `git`. + let root = Path::new(env!("CARGO_TARGET_TMPDIR")).join("rebase-baseline"); + if root.exists() { + std::fs::remove_dir_all(&root).expect("fixture directory can be removed"); + } + std::fs::create_dir_all(&root).expect("fixture directory can be created"); + let status = std::process::Command::new("bash") + .arg(&script) + .current_dir(&root) + .status() + .expect("`bash` is available to run the fixture"); + assert!(status.success(), "fixture script failed: {script:?}"); + root + }) +} + +pub struct Case { + pub repo: gix::Repository, + pub onto: ObjectId, + pub source: ObjectId, +} + +impl Case { + pub fn open(name: &str) -> Self { + let repo = gix::open(fixture().join(name)).expect("fixture repository opens"); + let onto = single_ref(&repo, "onto"); + let source = single_ref(&repo, "source"); + Case { repo, onto, source } + } + + /// The commits `git rebase` would have put on its todo-list: everything in `onto..source` that + /// isn't a merge, oldest first. + pub fn picks(&self) -> Vec { + let mut picks: Vec<_> = self + .repo + .rev_walk([self.source]) + .with_hidden([self.onto]) + .all() + .expect("revwalk starts") + .map(|info| info.expect("commit is present")) + .filter(|info| info.parent_ids.len() < 2) + .map(|info| info.id) + .collect(); + picks.reverse(); + picks + } + + /// The commits `git rebase` actually produced, oldest first. Empty if the case conflicted. + pub fn expected(&self) -> Vec { + let Ok(expected) = self.repo.rev_parse_single("expected") else { + return Vec::new(); + }; + let mut ids: Vec<_> = self + .repo + .rev_walk([expected.detach()]) + .with_hidden([self.onto]) + .all() + .expect("revwalk starts") + .map(|info| info.expect("commit is present").id) + .collect(); + ids.reverse(); + ids + } + + pub fn replay(&self, options: replay::Options) -> std::result::Result { + self.replay_onto(self.onto, &self.picks(), options) + } + + pub fn replay_onto( + &self, + onto: ObjectId, + picks: &[ObjectId], + options: replay::Options, + ) -> std::result::Result { + let mut diff_state = gix_diff::tree::State::default(); + let mut diff_resource_cache = self + .repo + .diff_resource_cache_for_tree_diff() + .expect("diff cache can be created"); + let mut blob_merge = self + .repo + .merge_resource_cache(Default::default()) + .expect("merge cache can be created"); + gix_rebase::replay( + &onto, + picks, + &rebase_committer(), + Default::default(), + &self.repo, + &mut diff_state, + &mut diff_resource_cache, + &mut blob_merge, + options, + ) + } + + /// The options to use to match `git rebase`'s defaults, taken from the repository's own config. + pub fn git_default_options(&self) -> replay::Options { + replay::Options { + tree_merge: self + .repo + .tree_merge_options() + .expect("merge options can be read from config") + .into(), + ..Default::default() + } + } + + pub fn commit(&self, id: ObjectId) -> gix_object::Commit { + self.repo + .find_object(id) + .expect("object is present") + .into_commit() + .decode() + .expect("commit decodes") + .to_owned() + .expect("commit can be owned") + } + + pub fn header(&self, id: ObjectId, name: &str) -> Option { + self.commit(id) + .extra_headers + .into_iter() + .find(|(field, _)| field == name) + .map(|(_, value)| value.to_string()) + } +} + +fn single_ref(repo: &gix::Repository, name: &str) -> ObjectId { + repo.rev_parse_single(name) + .unwrap_or_else(|err| panic!("fixture has a `{name}` ref: {err}")) + .detach() +} + +pub fn complete(outcome: Outcome) -> replay::Complete { + match outcome { + Outcome::Complete(complete) => complete, + Outcome::Conflict(conflict) => { + panic!( + "expected the replay to complete, but it conflicted at {}", + conflict.at + ) + } + } +} + +pub fn conflict(outcome: Outcome) -> replay::Conflict { + match outcome { + Outcome::Conflict(conflict) => conflict, + Outcome::Complete(complete) => { + panic!( + "expected a conflict, but the replay completed at {}", + complete.tip + ) + } + } +} -- 2.51.2