diff --git a/Cargo.lock b/Cargo.lock index 1a5fb8d..11843d1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -334,6 +334,8 @@ dependencies = [ "bstr", "derive_more", "eyre", + "humantime", + "humantime-serde", "proc-exit", "schemars", "serde", @@ -448,6 +450,22 @@ dependencies = [ "uuid", ] +[[package]] +name = "humantime" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" + +[[package]] +name = "humantime-serde" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac34a56cfd4acddb469cc7fff187ed5ac36f498ba085caf8bbc725e3ff474058" +dependencies = [ + "humantime", + "serde", +] + [[package]] name = "idna" version = "0.2.3" diff --git a/crates/git-fixture/Cargo.toml b/crates/git-fixture/Cargo.toml index 51b5cb4..6a9a9d6 100644 --- a/crates/git-fixture/Cargo.toml +++ b/crates/git-fixture/Cargo.toml @@ -11,6 +11,8 @@ edition = "2018" [dependencies] serde = { version = "1", features = ["derive"] } +humantime = "2" +humantime-serde = "1" bstr = { version = "0.2", features = ["serde1"] } derive_more = "0.99.0" eyre = "0.6" diff --git a/crates/git-fixture/src/lib.rs b/crates/git-fixture/src/lib.rs index 04f7435..e694b0c 100644 --- a/crates/git-fixture/src/lib.rs +++ b/crates/git-fixture/src/lib.rs @@ -44,24 +44,26 @@ impl Dag { } let mut marks: std::collections::HashMap = Default::default(); - Self::run_events(self.events, cwd, &self.import_root, &mut marks)?; + self.run_events(&self.events, cwd, &self.import_root, &mut marks)?; Ok(()) } // Note: shelling out to git to minimize programming bugs fn run_events( - events: Vec, + &self, + events: &[Event], cwd: &std::path::Path, import_root: &std::path::Path, marks: &mut std::collections::HashMap, ) -> eyre::Result<()> { - for event in events.into_iter() { + for event in events.iter() { match event { Event::Import(path) => { let path = import_root.join(path); let mut child_dag = Dag::load(&path)?; child_dag.init = false; + child_dag.sleep = child_dag.sleep.or(self.sleep); child_dag.run(cwd).wrap_err_with(|| { format!("Failed when running imported fixcture {}", path.display()) })?; @@ -113,6 +115,9 @@ impl Dag { p.arg("--author").arg(author); } p.ok()?; + if let Some(sleep) = self.sleep { + std::thread::sleep(sleep); + } if let Some(branch) = tree.branch.as_ref() { let _ = std::process::Command::new("git") @@ -135,15 +140,11 @@ impl Dag { } } } - Event::Children(mut events) => { + Event::Children(events) => { let start_commit = current_oid(cwd)?; - let last_run = events.pop(); for run in events { - Self::run_events(run, cwd, import_root, marks)?; checkout(cwd, &start_commit)?; - } - if let Some(last_run) = last_run { - Self::run_events(last_run, cwd, import_root, marks)?; + self.run_events(run, cwd, import_root, marks)?; } } Event::Head(reference) => { diff --git a/crates/git-fixture/src/main.rs b/crates/git-fixture/src/main.rs index ddd3452..ce2aa00 100644 --- a/crates/git-fixture/src/main.rs +++ b/crates/git-fixture/src/main.rs @@ -10,6 +10,9 @@ struct Args { input: Option, #[structopt(short, long)] output: Option, + /// Sleep between commits + #[structopt(long, parse(try_from_str))] + sleep: Option, #[structopt(short, long, group = "mode")] schema: Option, @@ -24,11 +27,13 @@ fn run() -> proc_exit::ExitResult { let args = Args::from_args(); let output = args .output + .clone() .unwrap_or_else(|| std::env::current_dir().unwrap()); if let Some(input) = args.input.as_deref() { std::fs::create_dir_all(&output)?; - let dag = git_fixture::Dag::load(input).with_code(proc_exit::Code::CONFIG_ERR)?; + let mut dag = git_fixture::Dag::load(input).with_code(proc_exit::Code::CONFIG_ERR)?; + dag.sleep = dag.sleep.or_else(|| args.sleep.map(|s| s.into())); dag.run(&output).with_code(proc_exit::Code::FAILURE)?; } else if let Some(schema_path) = args.schema.as_deref() { let schema = schemars::schema_for!(git_fixture::Dag); diff --git a/crates/git-fixture/src/model.rs b/crates/git-fixture/src/model.rs index d556471..993430b 100644 --- a/crates/git-fixture/src/model.rs +++ b/crates/git-fixture/src/model.rs @@ -5,6 +5,10 @@ pub struct Dag { #[serde(default = "init_default")] pub init: bool, #[serde(default)] + #[serde(serialize_with = "humantime_serde::serialize")] + #[serde(deserialize_with = "humantime_serde::deserialize")] + pub sleep: Option, + #[serde(default)] pub events: Vec, #[serde(skip)] pub import_root: std::path::PathBuf, diff --git a/src/bin/git-stack/stack.rs b/src/bin/git-stack/stack.rs index a31d8b7..c4ebc1c 100644 --- a/src/bin/git-stack/stack.rs +++ b/src/bin/git-stack/stack.rs @@ -340,11 +340,16 @@ pub fn stack(args: &crate::args::Args, colored_stdout: bool) -> proc_exit::ExitR fn plan_rebase(state: &State, stack: &StackState) -> eyre::Result { let mut graphed_branches = stack.graphed_branches(); - let mut root = git_stack::graph::Node::new(state.head_commit.clone(), &mut graphed_branches); + let base_commit = state + .repo + .find_commit(stack.base.id) + .expect("base branch is valid"); + let mut root = git_stack::graph::Node::new(base_commit, &mut graphed_branches); root = root.extend_branches(&state.repo, graphed_branches)?; git_stack::graph::protect_branches(&mut root, &state.repo, &state.protected_branches); git_stack::graph::rebase_branches(&mut root, stack.onto.id); + git_stack::graph::drop_by_tree_id(&mut root); let script = git_stack::graph::to_script(&root); @@ -374,14 +379,18 @@ fn show(state: &State, colored_stdout: bool) -> eyre::Result<()> { .iter() .map(|stack| -> eyre::Result { let mut graphed_branches = stack.graphed_branches(); - let mut root = - git_stack::graph::Node::new(state.head_commit.clone(), &mut graphed_branches); + let base_commit = state + .repo + .find_commit(stack.base.id) + .expect("base branch is valid"); + let mut root = git_stack::graph::Node::new(base_commit, &mut graphed_branches); root = root.extend_branches(&state.repo, graphed_branches)?; git_stack::graph::protect_branches(&mut root, &state.repo, &state.protected_branches); if state.dry_run { // Show as-if we performed all mutations git_stack::graph::rebase_branches(&mut root, stack.onto.id); + git_stack::graph::drop_by_tree_id(&mut root); } eyre::Result::Ok(root) @@ -680,7 +689,7 @@ fn drop_branches( if branch.name == potential_head { continue; } else if head_branch_name == Some(branch.name.as_str()) { - // Dom't leave HEAD detached but instead switch to the branch we pulled + // Don't leave HEAD detached but instead switch to the branch we pulled log::trace!("git switch {}", potential_head); if !dry_run { repo.switch(potential_head)?; @@ -1187,7 +1196,7 @@ fn format_commit_status<'d>( if node.action.is_protected() { format!("") } else if node.action.is_delete() { - format!("{} ", palette.warn.paint("(drop)")) + format!("{} ", palette.error.paint("(drop)")) } else if 1 < repo .raw() .find_commit(node.local_commit.id) diff --git a/src/git/repo.rs b/src/git/repo.rs index 389fa32..d1c606d 100644 --- a/src/git/repo.rs +++ b/src/git/repo.rs @@ -13,11 +13,17 @@ pub trait Repo { &self, head_id: git2::Oid, ) -> Box> + '_>; + fn contains_commit( + &self, + haystack_id: git2::Oid, + needle_id: git2::Oid, + ) -> Result; fn cherry_pick( &mut self, head_id: git2::Oid, cherry_id: git2::Oid, ) -> Result; + fn squash(&mut self, head_id: git2::Oid, into_id: git2::Oid) -> Result; fn branch(&mut self, name: &str, id: git2::Oid) -> Result<(), git2::Error>; fn delete_branch(&mut self, name: &str) -> Result<(), git2::Error>; @@ -38,6 +44,7 @@ pub struct Branch { #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct Commit { pub id: git2::Oid, + pub tree_id: git2::Oid, pub summary: bstr::BString, } @@ -70,6 +77,13 @@ impl Commit { .next() } } + + pub fn revert_summary(&self) -> Option<&bstr::BStr> { + self.summary + .strip_prefix(b"Revert ") + .and_then(|s| s.strip_suffix(b"\"")) + .map(ByteSlice::as_bstr) + } } pub struct GitRepo { @@ -146,6 +160,7 @@ impl GitRepo { let summary: bstr::BString = commit.summary_bytes().unwrap().into(); let commit = std::rc::Rc::new(Commit { id: commit.id(), + tree_id: commit.tree_id(), summary, }); commits.insert(id, std::rc::Rc::clone(&commit)); @@ -212,6 +227,62 @@ impl GitRepo { .filter_map(move |oid| self.find_commit(oid)) } + pub fn contains_commit( + &self, + haystack_id: git2::Oid, + needle_id: git2::Oid, + ) -> Result { + let needle_commit = self.repo.find_commit(needle_id)?; + let needle_ann_commit = self.repo.find_annotated_commit(needle_id)?; + let haystack_ann_commit = self.repo.find_annotated_commit(haystack_id)?; + + let parent_ann_commit = if 0 < needle_commit.parent_count() { + let parent_commit = needle_commit.parent(0)?; + Some(self.repo.find_annotated_commit(parent_commit.id())?) + } else { + None + }; + + let mut rebase = self.repo.rebase( + Some(&needle_ann_commit), + parent_ann_commit.as_ref(), + Some(&haystack_ann_commit), + Some(git2::RebaseOptions::new().inmemory(true)), + )?; + + if let Some(op) = rebase.next() { + op.map_err(|e| { + let _ = rebase.abort(); + e + })?; + let inmemory_index = rebase.inmemory_index().unwrap(); + if inmemory_index.has_conflicts() { + return Ok(false); + } + + let sig = self.repo.signature().unwrap(); + match rebase.commit(None, &sig, None).map_err(|e| { + let _ = rebase.abort(); + e + }) { + // Created commit, must be unique + Ok(_) => Ok(false), + Err(err) => { + if err.class() == git2::ErrorClass::Rebase + && err.code() == git2::ErrorCode::Applied + { + return Ok(true); + } + Err(err) + } + } + } else { + // No commit created, must exist somehow + rebase.finish(None)?; + Ok(true) + } + } + fn cherry_pick( &mut self, head_id: git2::Oid, @@ -286,6 +357,68 @@ impl GitRepo { Ok(tip_id) } + pub fn squash( + &mut self, + head_id: git2::Oid, + into_id: git2::Oid, + ) -> Result { + // Based on https://www.pygit2.org/recipes/git-cherry-pick.html + let base_id = self.repo.merge_base(head_id, into_id)?; + let base_commit = self.repo.find_commit(base_id)?; + let base_tree = self.repo.find_tree(base_commit.tree_id())?; + + let head_commit = self.repo.find_commit(head_id)?; + let head_tree = self.repo.find_tree(head_commit.tree_id())?; + + let into_commit = self.repo.find_commit(into_id)?; + let into_tree = self.repo.find_tree(into_commit.tree_id())?; + + let onto_commit; + let onto_commits; + let onto_commits: &[&git2::Commit] = if 0 < into_commit.parent_count() { + onto_commit = into_commit.parent(0)?; + onto_commits = [&onto_commit]; + &onto_commits + } else { + &[] + }; + + let mut result_index = self + .repo + .merge_trees(&base_tree, &into_tree, &head_tree, None)?; + if result_index.has_conflicts() { + let conflicts = result_index + .conflicts()? + .map(|conflict| { + let conflict = conflict.unwrap(); + let our_path = conflict + .our + .as_ref() + .map(|c| bytes2path(&c.path)) + .or_else(|| conflict.their.as_ref().map(|c| bytes2path(&c.path))) + .unwrap(); + format!("{}", our_path.display()) + }) + .join("\n "); + return Err(git2::Error::new( + git2::ErrorCode::Unmerged, + git2::ErrorClass::Index, + format!("cherry-pick conflicts:\n {}\n", conflicts), + )); + } + let result_id = result_index.write_tree_to(&self.repo)?; + let result_tree = self.repo.find_tree(result_id)?; + let new_id = self.repo.commit( + None, + &into_commit.author(), + &into_commit.committer(), + into_commit.message().unwrap(), + &result_tree, + onto_commits, + )?; + Ok(new_id) + } + pub fn branch(&mut self, name: &str, id: git2::Oid) -> Result<(), git2::Error> { let commit = self.repo.find_commit(id)?; self.repo.branch(name, &commit, true)?; @@ -428,6 +561,14 @@ impl Repo for GitRepo { Box::new(self.commits_from(head_id)) } + fn contains_commit( + &self, + haystack_id: git2::Oid, + needle_id: git2::Oid, + ) -> Result { + self.contains_commit(haystack_id, needle_id) + } + fn cherry_pick( &mut self, head_id: git2::Oid, @@ -436,6 +577,10 @@ impl Repo for GitRepo { self.cherry_pick(head_id, cherry_id) } + fn squash(&mut self, head_id: git2::Oid, into_id: git2::Oid) -> Result { + self.squash(head_id, into_id) + } + fn branch(&mut self, name: &str, id: git2::Oid) -> Result<(), git2::Error> { self.branch(name, id) } @@ -557,6 +702,22 @@ impl InMemoryRepo { } } + pub fn contains_commit( + &self, + haystack_id: git2::Oid, + needle_id: git2::Oid, + ) -> Result { + // Because we don't have the information for likeness matches, just checking for Oid + let mut next = Some(haystack_id); + while let Some(current) = next { + if current == needle_id { + return Ok(true); + } + next = self.commits.get(¤t).and_then(|c| c.0); + } + Ok(false) + } + pub fn cherry_pick( &mut self, head_id: git2::Oid, @@ -577,6 +738,37 @@ impl InMemoryRepo { Ok(new_id) } + pub fn squash( + &mut self, + head_id: git2::Oid, + into_id: git2::Oid, + ) -> Result { + self.commits.get(&head_id).cloned().ok_or_else(|| { + git2::Error::new( + git2::ErrorCode::NotFound, + git2::ErrorClass::Reference, + format!("could not find commit {:?}", head_id), + ) + })?; + let (intos_parent, into_commit) = self.commits.get(&into_id).cloned().ok_or_else(|| { + git2::Error::new( + git2::ErrorCode::NotFound, + git2::ErrorClass::Reference, + format!("could not find commit {:?}", into_id), + ) + })?; + let intos_parent = intos_parent.unwrap(); + + let mut squashed_commit = Commit::clone(&into_commit); + let new_id = self.gen_id(); + squashed_commit.id = new_id; + self.commits.insert( + new_id, + (Some(intos_parent), std::rc::Rc::new(squashed_commit)), + ); + Ok(new_id) + } + fn branch(&mut self, name: &str, id: git2::Oid) -> Result<(), git2::Error> { self.branches.insert( name.to_owned(), @@ -678,6 +870,14 @@ impl Repo for InMemoryRepo { Box::new(self.commits_from(head_id)) } + fn contains_commit( + &self, + haystack_id: git2::Oid, + needle_id: git2::Oid, + ) -> Result { + self.contains_commit(haystack_id, needle_id) + } + fn cherry_pick( &mut self, head_id: git2::Oid, @@ -686,6 +886,10 @@ impl Repo for InMemoryRepo { self.cherry_pick(head_id, cherry_id) } + fn squash(&mut self, head_id: git2::Oid, into_id: git2::Oid) -> Result { + self.squash(head_id, into_id) + } + fn head_branch(&self) -> Option { self.head_branch() } diff --git a/src/graph/ops.rs b/src/graph/ops.rs index 0009abe..6539539 100644 --- a/src/graph/ops.rs +++ b/src/graph/ops.rs @@ -127,6 +127,95 @@ fn pushable_node(node: &mut Node, mut cause: Option<&str>) { } } +/// Quick pass for what is droppable +/// +/// We get into this state when a branch is squashed. The id would be different due to metadata +/// but the tree_id, associated with the repo, is the same if your branch is up-to-date. +/// +/// The big risk is if a commit was reverted. To protect against this, we only look at the final +/// state of the branch and then check if it looks like a revert. +/// +/// To avoid walking too much of the tree, we are going to assume only the first branch in a stack +/// could have been squash-merged. +/// +/// This assumes that the Node was rebased onto all of the new potentially squash-merged Nodes and +/// we extract the potential tree_id's from those protected commits. +pub fn drop_by_tree_id(node: &mut Node) { + if node.action.is_protected() { + track_protected_tree_id(node, std::collections::HashSet::new()); + } +} + +fn track_protected_tree_id( + node: &mut Node, + mut protected_tree_ids: std::collections::HashSet, +) { + assert!(node.action.is_protected()); + protected_tree_ids.insert(node.local_commit.tree_id); + + match node.children.len() { + 0 => (), + 1 => { + let child = node.children.values_mut().next().unwrap(); + if child.action.is_protected() { + track_protected_tree_id(child, protected_tree_ids); + } else { + drop_first_branch_by_tree_id(child, protected_tree_ids); + } + } + _ => { + for child in node.children.values_mut() { + if child.action.is_protected() { + track_protected_tree_id(child, protected_tree_ids.clone()); + } else { + drop_first_branch_by_tree_id(child, protected_tree_ids.clone()); + } + } + } + } +} + +fn drop_first_branch_by_tree_id( + node: &mut Node, + protected_tree_ids: std::collections::HashSet, +) -> bool { + #![allow(clippy::if_same_then_else)] + + assert!(!node.action.is_protected()); + if node.branches.is_empty() { + match node.children.len() { + 0 => false, + 1 => { + let child = node.children.values_mut().next().unwrap(); + let all_dropped = drop_first_branch_by_tree_id(child, protected_tree_ids); + if all_dropped { + node.action = crate::graph::Action::Delete; + } + all_dropped + } + _ => { + let mut all_dropped = true; + for child in node.children.values_mut() { + all_dropped &= drop_first_branch_by_tree_id(child, protected_tree_ids.clone()); + } + if all_dropped { + node.action = crate::graph::Action::Delete; + } + all_dropped + } + } + } else if !protected_tree_ids.contains(&node.local_commit.tree_id) { + false + } else if node.local_commit.revert_summary().is_some() { + // Might not *actually* be a revert or something more complicated might be going on. Let's + // just be cautious. + false + } else { + node.action = crate::graph::Action::Delete; + true + } +} + pub fn to_script(node: &Node) -> crate::git::Script { let mut script = crate::git::Script::new(); @@ -197,12 +286,22 @@ fn node_to_script(node: &Node) -> Option { } } crate::graph::Action::Delete => { - assert!(node.children.is_empty()); for branch in node.branches.iter() { script .commands .push(crate::git::Command::DeleteBranch(branch.name.clone())); } + + let node_dependents: Vec<_> = node + .children + .values() + .filter_map(|child| node_to_script(child)) + .collect(); + if !node_dependents.is_empty() { + // End the transaction on branch boundaries + let transaction = !node.branches.is_empty(); + extend_dependents(node, &mut script, node_dependents, transaction); + } } } diff --git a/tests/fixture.rs b/tests/fixture.rs index 67b2312..324dd76 100644 --- a/tests/fixture.rs +++ b/tests/fixture.rs @@ -33,6 +33,7 @@ fn populate_event( let summary = message.lines().next().unwrap().to_owned(); let commit = git_stack::git::Commit { id: commit_id, + tree_id: commit_id, summary: bstr::BString::from(summary), }; repo.push_commit(parent_id, commit); diff --git a/tests/fixtures/git_rebase_existing.yml b/tests/fixtures/git_rebase_existing.yml new file mode 100644 index 0000000..6d554f4 --- /dev/null +++ b/tests/fixtures/git_rebase_existing.yml @@ -0,0 +1,35 @@ +init: true +events: +- tree: + tracked: + "file_a.txt": "1" + message: "1" + branch: initial +- tree: + tracked: + "file_a.txt": "2" + message: "2" +- tree: + tracked: + "file_a.txt": "3" + message: "3" + branch: master +- children: + - - tree: + tracked: + "file_a.txt": "3" + "file_b.txt": "1" + message: "7" + - tree: + tracked: + "file_a.txt": "3" + "file_b.txt": "2" + message: "8" + branch: feature2 + # `git rebase master` caused the history to split + - - tree: + tracked: + "file_a.txt": "3" + "file_b.txt": "1" + message: "7" + branch: feature1 diff --git a/tests/fixtures/git_rebase_new.yml b/tests/fixtures/git_rebase_new.yml new file mode 100644 index 0000000..55aff7f --- /dev/null +++ b/tests/fixtures/git_rebase_new.yml @@ -0,0 +1,36 @@ +init: true +events: +- tree: + tracked: + "file_a.txt": "1" + message: "1" + branch: initial +- tree: + tracked: + "file_a.txt": "2" + message: "2" +- tree: + tracked: + "file_a.txt": "3" + message: "3" + branch: master +- children: + - - tree: + tracked: + "file_a.txt": "3" + "file_b.txt": "1" + message: "7" + - tree: + tracked: + "file_a.txt": "3" + "file_b.txt": "1" + "file_c.txt": "1" + message: "8" + branch: feature2 + # `git rebase master` caused the history to split + - - tree: + tracked: + "file_a.txt": "3" + "file_b.txt": "1" + message: "7" + branch: feature1 diff --git a/tests/fixtures/pr-semi-linear-merge.yml b/tests/fixtures/pr-semi-linear-merge.yml new file mode 100644 index 0000000..fd64e48 --- /dev/null +++ b/tests/fixtures/pr-semi-linear-merge.yml @@ -0,0 +1,81 @@ +init: true +events: +- tree: + tracked: + "file_a.txt": "1" + message: "1" + branch: initial +- tree: + tracked: + "file_a.txt": "2" + message: "2" +- tree: + tracked: + "file_a.txt": "3" + message: "3" + branch: base +- children: + - - tree: + tracked: + "file_a.txt": "3" + "file_b.txt": "1" + message: "4" + - tree: + tracked: + "file_a.txt": "3" + "file_b.txt": "2" + message: "5" + - tree: + tracked: + "file_a.txt": "3" + "file_b.txt": "3" + message: "6" + branch: old_master + # AzDO has the "semi-linear merge" type which rebases the branch before merging + - tree: + tracked: + "file_a.txt": "3" + "file_b.txt": "3" + "file_c.txt": "1" + message: "7" + - tree: + tracked: + "file_a.txt": "3" + "file_b.txt": "3" + "file_c.txt": "2" + message: "8" + - tree: + tracked: + "file_a.txt": "3" + "file_b.txt": "3" + "file_c.txt": "3" + message: "9" + - tree: + tracked: + "file_a.txt": "3" + "file_b.txt": "3" + "file_c.txt": "4" + message: "10" + branch: master + - - tree: + tracked: + "file_a.txt": "3" + "file_c.txt": "1" + message: "7" + branch: feature1 + - tree: + tracked: + "file_a.txt": "3" + "file_c.txt": "2" + message: "8" + - tree: + tracked: + "file_a.txt": "3" + "file_c.txt": "3" + message: "9" + - tree: + tracked: + "file_a.txt": "3" + "file_c.txt": "4" + message: "10" + branch: feature2 diff --git a/tests/fixtures/pr-squash.yml b/tests/fixtures/pr-squash.yml new file mode 100644 index 0000000..dbf2a7c --- /dev/null +++ b/tests/fixtures/pr-squash.yml @@ -0,0 +1,63 @@ +init: true +events: +- tree: + tracked: + "file_a.txt": "1" + message: "1" + branch: initial +- tree: + tracked: + "file_a.txt": "2" + message: "2" +- tree: + tracked: + "file_a.txt": "3" + message: "3" + branch: base +- children: + - - tree: + tracked: + "file_a.txt": "3" + "file_b.txt": "1" + message: "4" + - tree: + tracked: + "file_a.txt": "3" + "file_b.txt": "2" + message: "5" + - tree: + tracked: + "file_a.txt": "3" + "file_b.txt": "3" + message: "6" + branch: old_master + # Squashed "feature2" into a single commit and committed it onto master + - tree: + tracked: + "file_a.txt": "3" + "file_b.txt": "3" + "file_c.txt": "4" + message: "Merged #10" + branch: master + - - tree: + tracked: + "file_a.txt": "3" + "file_c.txt": "1" + message: "7" + branch: feature1 + - tree: + tracked: + "file_a.txt": "3" + "file_c.txt": "2" + message: "8" + - tree: + tracked: + "file_a.txt": "3" + "file_c.txt": "3" + message: "9" + - tree: + tracked: + "file_a.txt": "3" + "file_c.txt": "4" + message: "10" + branch: feature2 diff --git a/tests/repo.rs b/tests/repo.rs index c4b2959..d3024f6 100644 --- a/tests/repo.rs +++ b/tests/repo.rs @@ -3,7 +3,7 @@ use assert_fs::prelude::*; use git_stack::git::*; #[test] -fn shared_fixture() { +fn shared_branches_fixture() { let temp = assert_fs::TempDir::new().unwrap(); let plan = git_fixture::Dag::load(std::path::Path::new("tests/fixtures/branches.yml")).unwrap(); plan.run(temp.path()).unwrap(); @@ -37,7 +37,7 @@ fn shared_fixture() { { { let one = repo.find_local_branch("feature1").unwrap(); - let two = repo.find_local_branch("feature1").unwrap(); + let two = repo.find_local_branch("feature2").unwrap(); let actual = repo.merge_base(one.id, two.id).unwrap(); assert_eq!(actual, one.id); @@ -124,6 +124,131 @@ fn shared_fixture() { temp.close().unwrap(); } +#[test] +fn contains_commit_not_with_independent_branches() { + let temp = assert_fs::TempDir::new().unwrap(); + let plan = git_fixture::Dag::load(std::path::Path::new("tests/fixtures/branches.yml")).unwrap(); + plan.run(temp.path()).unwrap(); + + let repo = git2::Repository::discover(temp.path()).unwrap(); + let repo = GitRepo::new(repo); + + let feature = repo.find_local_branch("feature2").unwrap(); + let master = repo.find_local_branch("master").unwrap(); + + let feature_in_master = repo.contains_commit(master.id, feature.id).unwrap(); + assert!(!feature_in_master); + + temp.close().unwrap(); +} + +#[test] +fn contains_commit_rebased_branches_with_disjoint_commit() { + let temp = assert_fs::TempDir::new().unwrap(); + let plan = + git_fixture::Dag::load(std::path::Path::new("tests/fixtures/git_rebase_new.yml")).unwrap(); + plan.run(temp.path()).unwrap(); + + let repo = git2::Repository::discover(temp.path()).unwrap(); + let repo = GitRepo::new(repo); + + let feature1 = repo.find_local_branch("feature1").unwrap(); + let feature2 = repo.find_local_branch("feature2").unwrap(); + + let feature1_in_feature2 = repo.contains_commit(feature2.id, feature1.id).unwrap(); + assert!(feature1_in_feature2); + + let feature2_in_feature1 = repo.contains_commit(feature1.id, feature2.id).unwrap(); + assert!(!feature2_in_feature1); + + temp.close().unwrap(); +} + +#[test] +#[ignore] // Not correctly detecting the commit already exists earlier in history +fn contains_commit_rebased_branches_with_overlapping_commit() { + let temp = assert_fs::TempDir::new().unwrap(); + let plan = git_fixture::Dag::load(std::path::Path::new( + "tests/fixtures/git_rebase_existing.yml", + )) + .unwrap(); + plan.run(temp.path()).unwrap(); + + let repo = git2::Repository::discover(temp.path()).unwrap(); + let repo = GitRepo::new(repo); + + let feature1 = repo.find_local_branch("feature1").unwrap(); + let feature2 = repo.find_local_branch("feature2").unwrap(); + + let feature1_in_feature2 = repo.contains_commit(feature2.id, feature1.id).unwrap(); + assert!(feature1_in_feature2); + + let feature2_in_feature1 = repo.contains_commit(feature1.id, feature2.id).unwrap(); + assert!(!feature2_in_feature1); + + temp.close().unwrap(); +} + +#[test] +#[ignore] // Not correctly detecting the commit already exists earlier in history +fn contains_commit_semi_linear_merge() { + let temp = assert_fs::TempDir::new().unwrap(); + let plan = git_fixture::Dag::load(std::path::Path::new( + "tests/fixtures/pr-semi-linear-merge.yml", + )) + .unwrap(); + plan.run(temp.path()).unwrap(); + + let repo = git2::Repository::discover(temp.path()).unwrap(); + let repo = GitRepo::new(repo); + + let old_master = repo.find_local_branch("old_master").unwrap(); + let master = repo.find_local_branch("master").unwrap(); + let feature1 = repo.find_local_branch("feature1").unwrap(); + let feature2 = repo.find_local_branch("feature2").unwrap(); + + let feature1_in_master = repo.contains_commit(master.id, feature1.id).unwrap(); + assert!(feature1_in_master); + let feature2_in_master = repo.contains_commit(master.id, feature2.id).unwrap(); + assert!(feature2_in_master); + + let feature1_in_old_master = repo.contains_commit(old_master.id, feature1.id).unwrap(); + assert!(feature1_in_old_master); + let feature2_in_old_master = repo.contains_commit(old_master.id, feature2.id).unwrap(); + assert!(feature2_in_old_master); + + temp.close().unwrap(); +} + +#[test] +#[ignore] // Not correctly detecting the commit already exists earlier in history +fn contains_commit_pr_squashed() { + let temp = assert_fs::TempDir::new().unwrap(); + let plan = + git_fixture::Dag::load(std::path::Path::new("tests/fixtures/pr-squash.yml")).unwrap(); + plan.run(temp.path()).unwrap(); + + let repo = git2::Repository::discover(temp.path()).unwrap(); + let repo = GitRepo::new(repo); + + let old_master = repo.find_local_branch("old_master").unwrap(); + let master = repo.find_local_branch("master").unwrap(); + let feature1 = repo.find_local_branch("feature1").unwrap(); + let feature2 = repo.find_local_branch("feature2").unwrap(); + + let feature1_in_master = repo.contains_commit(master.id, feature1.id).unwrap(); + assert!(feature1_in_master); + let feature2_in_master = repo.contains_commit(master.id, feature2.id).unwrap(); + assert!(feature2_in_master); + + let feature1_in_old_master = repo.contains_commit(old_master.id, feature1.id).unwrap(); + assert!(feature1_in_old_master); + let feature2_in_old_master = repo.contains_commit(old_master.id, feature2.id).unwrap(); + assert!(feature2_in_old_master); + + temp.close().unwrap(); +} + #[test] fn cherry_pick_clean() { let temp = assert_fs::TempDir::new().unwrap(); @@ -177,6 +302,29 @@ fn cherry_pick_conflict() { temp.close().unwrap(); } +#[test] +fn squash_clean() { + let temp = assert_fs::TempDir::new().unwrap(); + let plan = git_fixture::Dag::load(std::path::Path::new("tests/fixtures/branches.yml")).unwrap(); + plan.run(temp.path()).unwrap(); + + let repo = git2::Repository::discover(temp.path()).unwrap(); + let mut repo = GitRepo::new(repo); + + { + assert!(!repo.is_dirty()); + + let base = repo.find_local_branch("master").unwrap(); + let source = repo.find_local_branch("feature2").unwrap(); + let dest_id = repo.squash(source.id, base.id).unwrap(); + + repo.branch("squashed", dest_id).unwrap(); + assert!(!repo.is_dirty()); + } + + temp.close().unwrap(); +} + #[test] fn branch() { let temp = assert_fs::TempDir::new().unwrap();