From 179bcb941bed098137946d6e794de8d166c28f72 Mon Sep 17 00:00:00 2001 From: Mattias Granlund Date: Fri, 18 Oct 2024 10:22:05 +0100 Subject: [PATCH] New crate for calculating hunk dependencies --- Cargo.toml | 2 + crates/gitbutler-hunk-dependency/Cargo.toml | 23 ++ .../gitbutler-hunk-dependency/src/builder.rs | 125 +++++++++ crates/gitbutler-hunk-dependency/src/diff.rs | 76 +++++ crates/gitbutler-hunk-dependency/src/hunk.rs | 24 ++ crates/gitbutler-hunk-dependency/src/lib.rs | 5 + crates/gitbutler-hunk-dependency/src/path.rs | 183 ++++++++++++ crates/gitbutler-hunk-dependency/src/stack.rs | 52 ++++ .../tests/builder.rs | 53 ++++ .../gitbutler-hunk-dependency/tests/diff.rs | 42 +++ crates/gitbutler-hunk-dependency/tests/mod.rs | 3 + .../gitbutler-hunk-dependency/tests/stack.rs | 265 ++++++++++++++++++ 12 files changed, 853 insertions(+) create mode 100644 crates/gitbutler-hunk-dependency/Cargo.toml create mode 100644 crates/gitbutler-hunk-dependency/src/builder.rs create mode 100644 crates/gitbutler-hunk-dependency/src/diff.rs create mode 100644 crates/gitbutler-hunk-dependency/src/hunk.rs create mode 100644 crates/gitbutler-hunk-dependency/src/lib.rs create mode 100644 crates/gitbutler-hunk-dependency/src/path.rs create mode 100644 crates/gitbutler-hunk-dependency/src/stack.rs create mode 100644 crates/gitbutler-hunk-dependency/tests/builder.rs create mode 100644 crates/gitbutler-hunk-dependency/tests/diff.rs create mode 100644 crates/gitbutler-hunk-dependency/tests/mod.rs create mode 100644 crates/gitbutler-hunk-dependency/tests/stack.rs diff --git a/Cargo.toml b/Cargo.toml index ef5ab5a241..ccfae315e8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,6 +35,7 @@ members = [ "crates/gitbutler-stack-api", "crates/gitbutler-stack", "crates/gitbutler-patch-reference", + "crates/gitbutler-hunk-dependency", ] resolver = "2" @@ -92,6 +93,7 @@ gitbutler-oxidize = { path = "crates/gitbutler-oxidize" } gitbutler-stack-api = { path = "crates/gitbutler-stack-api" } gitbutler-stack = { path = "crates/gitbutler-stack" } gitbutler-patch-reference = { path = "crates/gitbutler-patch-reference" } +gitbutler-hunk-dependency = { path = "crates/gitbutler-hunk-dependency" } [profile.release] codegen-units = 1 # Compile crates one after another so the compiler can optimize better diff --git a/crates/gitbutler-hunk-dependency/Cargo.toml b/crates/gitbutler-hunk-dependency/Cargo.toml new file mode 100644 index 0000000000..35447b78e6 --- /dev/null +++ b/crates/gitbutler-hunk-dependency/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "gitbutler-hunk-dependency" +version = "0.0.0" +edition = "2021" +authors = ["GitButler "] +publish = false + +[dependencies] +anyhow = "1.0.86" +git2.workspace = true +gix = { workspace = true, features = [] } +gitbutler-reference.workspace = true +gitbutler-stack.workspace = true +gitbutler-id.workspace = true +itertools = "0.13" +serde = { workspace = true, features = ["std"] } +bstr.workspace = true +tokio.workspace = true +uuid = { workspace = true, features = ["v4", "fast-rng"] } + +[[test]] +name = "blame" +path = "tests/mod.rs" diff --git a/crates/gitbutler-hunk-dependency/src/builder.rs b/crates/gitbutler-hunk-dependency/src/builder.rs new file mode 100644 index 0000000000..9d90e53f80 --- /dev/null +++ b/crates/gitbutler-hunk-dependency/src/builder.rs @@ -0,0 +1,125 @@ +use std::{ + collections::HashMap, + path::{Path, PathBuf}, +}; + +use gitbutler_stack::StackId; + +use crate::{diff::Diff, hunk::DependencyHunk, stack::DependencyStack}; + +/// Calculates dependencies between workspace changes and workspace commits. +/// +/// What we ultimately want to understand is, given an uncommitted change +/// in some file, do the old line numbers intersect with any commmit(s) in +/// the workspace? +/// +/// The problem we have to overcome is that we the workspace changes are +/// produced by diffing the working directory against the workspace commit. +/// It means changes from one stack can offset line numbers in changes from +/// a different stack. The most intuitive way of checking if they touch +/// the same lines is to use regular git blame, but it suffers from two +/// problems, 1) speed and 2) lack of --reverse flag in git2. The latter +/// means we can't detect intersections with deleted lines. +/// +/// If we don't calculate these dependencies correctly it means a user +/// might be able to move a hunk into a stack where it cannot be committed. +/// +/// So the solution here is that we build up the same information we would +/// get from blame by adding diffs together. +#[derive(Debug, Default, PartialEq, Clone)] +pub struct HunkDependencyBuilder { + stacks: HashMap, +} + +impl HunkDependencyBuilder { + pub fn add( + &mut self, + stack_id: StackId, + commit_id: git2::Oid, + path: &PathBuf, + diffs: Vec, + ) -> anyhow::Result<()> { + if let Some(lane) = self.stacks.get_mut(&stack_id) { + lane.add(stack_id, commit_id, path, diffs)?; + } else { + let mut lane_deps = DependencyStack::default(); + lane_deps.add(stack_id, commit_id, path, diffs)?; + self.stacks.insert(stack_id, lane_deps); + } + Ok(()) + } + + /// Gets an object that can be used to lookup dependencies for a given path. + /// + /// The reasoning for combining the stacks/lanes here, rather than including + /// it where diffs are combined within the branch, is/was to keep the logic + /// simple. In iterating on the code, however, it feels like it might make + /// more sense to go directly to "global" line numbers. + /// + /// The constraint we would need to introduce is that diffs from different + /// stacks cannot intersect with each other. Doing so would mean the workspace + /// is corrupt. + /// + /// TODO: Consider moving most of the code below to path.rs + pub fn get_path(&mut self, path: &Path) -> anyhow::Result { + let paths = self + .stacks + .values() + .filter(|s| s.contains_path(path)) + .filter_map(|value| value.get_path(path)) + .collect::>(); + // Tracks the cumulative lines added/removed. + let mut line_shift = 0; + // Next hunk to consider for each branch containing path. + let mut hunk_indexes: Vec = vec![0; paths.len()]; + let mut result = vec![]; + + loop { + let start_lines = paths + .iter() + .enumerate() + .map(|(i, path_dep)| path_dep.hunks.get(hunk_indexes[i])) + .map(|hunk_dep| hunk_dep.map(|hunk_dep| hunk_dep.start as u32)) + .collect::>(); + + // Find the index of the dependency path with the lowest start line. + let path_index = start_lines + .iter() + .enumerate() // We want to filter out None values, but keep their index. + .filter(|(_, start_line)| start_line.is_some()) + .min_by_key(|&(index, &value)| value.unwrap() + start_lines[index].unwrap_or(0)) + .map(|(index, _)| index); + + if path_index.is_none() { + break; // No more items to process. + } + let path_index = path_index.unwrap(); + let hunk_index = hunk_indexes[path_index]; + hunk_indexes[path_index] += 1; + + let path_dep = &paths[path_index]; + let hunk_dep = &path_dep.hunks[hunk_index]; + + result.push(DependencyHunk { + start: hunk_dep.start + line_shift, + ..hunk_dep.clone() + }); + line_shift += hunk_dep.line_shift; + } + Ok(PathDependencyLookup { hunk_deps: result }) + } +} + +#[derive(Debug, Default, PartialEq, Clone)] +pub struct PathDependencyLookup { + hunk_deps: Vec, +} + +impl PathDependencyLookup { + pub fn find(self, start: i32, lines: i32) -> Vec { + self.hunk_deps + .into_iter() + .filter(|hunk| hunk.intersects(start, lines)) + .collect::>() + } +} diff --git a/crates/gitbutler-hunk-dependency/src/diff.rs b/crates/gitbutler-hunk-dependency/src/diff.rs new file mode 100644 index 0000000000..2006d94723 --- /dev/null +++ b/crates/gitbutler-hunk-dependency/src/diff.rs @@ -0,0 +1,76 @@ +use anyhow::{anyhow, Context}; + +#[derive(Debug, PartialEq, Clone)] +pub struct Diff { + pub old_start: i32, + pub old_lines: i32, + pub new_start: i32, + pub new_lines: i32, +} + +impl Diff { + pub fn net_lines(&self) -> i32 { + self.new_lines - self.old_lines + } +} + +fn count_context_lines(iter: I) -> i32 +where + I: Iterator, + S: AsRef, +{ + iter.take_while(|line| { + let line_ref = line.as_ref(); // Convert to &str + !line_ref.starts_with('-') && !line_ref.starts_with('+') + }) + .fold(0i32, |acc, _| acc + 1) +} + +impl TryFrom for Diff { + fn try_from(value: String) -> Result { + parse_unidiff(value) + } + + type Error = anyhow::Error; +} + +impl TryFrom<&str> for Diff { + fn try_from(value: &str) -> Result { + parse_unidiff(value) + } + + type Error = anyhow::Error; +} + +fn parse_unidiff(value: impl AsRef) -> Result { + let value = value.as_ref(); + let header = value.lines().next().context("No header found")?; + if !header.starts_with("@@") { + return Err(anyhow!("Malformed undiff")); + } + let parts: Vec<&str> = header.split_whitespace().collect(); + let (old_start, old_lines) = parse_hunk_info(parts[1]); + let (new_start, new_lines) = parse_hunk_info(parts[2]); + let head_context_lines = count_context_lines(value.lines().skip(1).take(3)); + let tail_context_lines = count_context_lines(value.rsplit_terminator('\n').take(3)); + let context_lines = head_context_lines + tail_context_lines; + + Ok(Diff { + old_start: old_start + head_context_lines, + old_lines: old_lines - context_lines, + new_start: new_start + head_context_lines, + new_lines: new_lines - context_lines, + }) +} + +fn parse_hunk_info(hunk_info: &str) -> (i32, i32) { + let hunk_info = hunk_info.trim_start_matches(&['-', '+'][..]); // Remove the leading '-' or '+' + let parts: Vec<&str> = hunk_info.split(',').collect(); + let start = parts[0].parse().unwrap(); + let lines = if parts.len() > 1 { + parts[1].parse().unwrap() + } else { + 1 + }; + (start, lines) +} diff --git a/crates/gitbutler-hunk-dependency/src/hunk.rs b/crates/gitbutler-hunk-dependency/src/hunk.rs new file mode 100644 index 0000000000..f06c733a41 --- /dev/null +++ b/crates/gitbutler-hunk-dependency/src/hunk.rs @@ -0,0 +1,24 @@ +use gitbutler_stack::StackId; + +#[derive(Debug, PartialEq, Clone)] +pub struct DependencyHunk { + pub stack_id: StackId, + pub commit_id: git2::Oid, + pub start: i32, + pub lines: i32, + pub line_shift: i32, +} + +impl DependencyHunk { + fn end(&self) -> i32 { + self.start + self.lines - 1 + } + + pub fn intersects(&self, start: i32, lines: i32) -> bool { + self.end() >= start && self.start < start + lines + } + + pub fn contains(&self, start: i32, lines: i32) -> bool { + start > self.start && start + lines <= self.end() + } +} diff --git a/crates/gitbutler-hunk-dependency/src/lib.rs b/crates/gitbutler-hunk-dependency/src/lib.rs new file mode 100644 index 0000000000..312268994a --- /dev/null +++ b/crates/gitbutler-hunk-dependency/src/lib.rs @@ -0,0 +1,5 @@ +pub mod builder; +pub mod diff; +pub mod hunk; +pub mod path; +pub mod stack; diff --git a/crates/gitbutler-hunk-dependency/src/path.rs b/crates/gitbutler-hunk-dependency/src/path.rs new file mode 100644 index 0000000000..f5aef82ed2 --- /dev/null +++ b/crates/gitbutler-hunk-dependency/src/path.rs @@ -0,0 +1,183 @@ +use std::collections::HashSet; + +use anyhow::bail; +use gitbutler_stack::StackId; + +use crate::{diff::Diff, hunk::DependencyHunk}; + +#[derive(Debug, Default, PartialEq, Clone)] +pub struct DependencyPath { + pub hunks: Vec, + commit_ids: HashSet, +} + +impl DependencyPath { + pub fn find(&mut self, start: i32, lines: i32) -> Vec<&mut DependencyHunk> { + self.hunks + .iter_mut() + .filter(|hunk| hunk.intersects(start, lines)) + .collect() + } + + pub fn add( + &mut self, + stack_id: StackId, + commit_id: git2::Oid, + diffs: Vec, + ) -> anyhow::Result<()> { + if !self.commit_ids.insert(commit_id) { + bail!("Commit ID already in stack: {}", commit_id) + } + + let mut line_shift = 0; + let mut new_hunks: Vec = vec![]; + let mut last_hunk: Option = None; + + let [mut i, mut j] = [0, 0]; + + while i < diffs.len() || j < self.hunks.len() { + // If the old start is smaller than existing new_start, or if only have + // new diffs left to process. + let mut hunks = if (i < diffs.len() + && j < self.hunks.len() + && diffs[i].old_start < self.hunks[j].start) + || (i < diffs.len() && j >= self.hunks.len()) + { + i += 1; + // TODO: Should we add line shift before or after? + line_shift += diffs[i - 1].net_lines(); + add_new(&diffs[i - 1], last_hunk, stack_id, commit_id) + } else { + j += 1; + add_existing(&self.hunks[j - 1], last_hunk, line_shift) + }; + // Last node is needed when adding new one, so we delay inserting it. + last_hunk = hunks.pop(); + new_hunks.extend(hunks); + } + + if let Some(last_hunk) = last_hunk { + new_hunks.push(last_hunk); + }; + + self.hunks = new_hunks; + Ok(()) + } +} + +fn add_new( + new_diff: &Diff, + last_hunk: Option, + stack_id: StackId, + commit_id: git2::Oid, +) -> Vec { + // If we have nothing to compare against we just return the new diff. + if last_hunk.is_none() { + return vec![DependencyHunk { + stack_id, + commit_id, + start: new_diff.new_start, + lines: new_diff.new_lines, + line_shift: new_diff.net_lines(), + }]; + } + + // TODO: Is the above early return idiomatic? Using unwrap here to avoid nesting. + let last_hunk = last_hunk.unwrap(); + + if last_hunk.start + last_hunk.lines < new_diff.old_start { + // Diffs do not overlap so we return them in order. + vec![ + last_hunk.clone(), + DependencyHunk { + commit_id, + stack_id, + start: new_diff.new_start, + lines: new_diff.new_lines, + line_shift: new_diff.net_lines(), + }, + ] + } else if last_hunk.contains(new_diff.old_start, new_diff.old_lines) { + // Since the diff being added is from the current commit it + // overwrites the preceding one, but we need to split it in + // two and retain the tail. + vec![ + DependencyHunk { + commit_id: last_hunk.commit_id, + stack_id: last_hunk.stack_id, + start: last_hunk.start, + lines: new_diff.new_start - last_hunk.start, + line_shift: 0, + }, + DependencyHunk { + commit_id, + stack_id, + start: new_diff.new_start, + lines: new_diff.new_lines, + line_shift: new_diff.net_lines(), + }, + DependencyHunk { + commit_id: last_hunk.commit_id, + stack_id: last_hunk.stack_id, + start: new_diff.new_start + new_diff.new_lines, + lines: last_hunk.start + last_hunk.lines + - (new_diff.new_start + new_diff.new_lines), + line_shift: last_hunk.line_shift, + }, + ] + } else { + vec![ + DependencyHunk { + commit_id: last_hunk.commit_id, + stack_id: last_hunk.stack_id, + start: last_hunk.start, + lines: last_hunk.lines, + line_shift: last_hunk.line_shift, + }, + DependencyHunk { + commit_id, + stack_id, + start: new_diff.new_start, + lines: new_diff.new_lines, + line_shift: new_diff.net_lines(), + }, + ] + } +} + +fn add_existing( + hunk: &DependencyHunk, + last_hunk: Option, + shift: i32, +) -> Vec { + if last_hunk.is_none() { + return vec![hunk.clone()]; + }; + + let last_hunk = last_hunk.unwrap(); + if hunk.start > last_hunk.start + last_hunk.lines { + vec![ + last_hunk.clone(), + DependencyHunk { + commit_id: hunk.commit_id, + stack_id: hunk.stack_id, + start: hunk.start + shift, + lines: hunk.lines, + line_shift: hunk.line_shift, + }, + ] + } else if last_hunk.contains(hunk.start, hunk.lines) { + vec![last_hunk.clone()] + } else { + vec![ + last_hunk.clone(), + DependencyHunk { + commit_id: hunk.commit_id, + stack_id: hunk.stack_id, + start: hunk.start + shift, + lines: hunk.lines - (last_hunk.start + last_hunk.lines - hunk.start), + line_shift: hunk.line_shift, + }, + ] + } +} diff --git a/crates/gitbutler-hunk-dependency/src/stack.rs b/crates/gitbutler-hunk-dependency/src/stack.rs new file mode 100644 index 0000000000..11725c44cb --- /dev/null +++ b/crates/gitbutler-hunk-dependency/src/stack.rs @@ -0,0 +1,52 @@ +use std::{ + collections::HashMap, + path::{Path, PathBuf}, +}; + +use gitbutler_stack::StackId; + +use crate::{diff::Diff, hunk::DependencyHunk, path::DependencyPath}; + +#[derive(Debug, Default, PartialEq, Clone)] +pub struct DependencyStack { + paths: HashMap, +} + +impl DependencyStack { + pub fn add( + &mut self, + stack_id: StackId, + commit_id: git2::Oid, + path: &PathBuf, + diffs: Vec, + ) -> anyhow::Result<()> { + if let Some(deps_path) = self.paths.get_mut(path) { + deps_path.add(stack_id, commit_id, diffs)?; + } else { + let mut path_deps = DependencyPath::default(); + path_deps.add(stack_id, commit_id, diffs)?; + self.paths.insert(path.clone(), path_deps); + }; + Ok(()) + } + + pub fn contains_path(&self, path: &Path) -> bool { + self.paths.contains_key(path) + } + + pub fn get_path(&self, path: &Path) -> Option { + self.paths.get(path).cloned() + } + + pub fn intersection( + &mut self, + path: &PathBuf, + start: i32, + lines: i32, + ) -> Vec<&mut DependencyHunk> { + if let Some(deps_path) = self.paths.get_mut(path) { + return deps_path.find(start, lines); + } + vec![] + } +} diff --git a/crates/gitbutler-hunk-dependency/tests/builder.rs b/crates/gitbutler-hunk-dependency/tests/builder.rs new file mode 100644 index 0000000000..e18c2433a2 --- /dev/null +++ b/crates/gitbutler-hunk-dependency/tests/builder.rs @@ -0,0 +1,53 @@ +use std::{path::PathBuf, str::FromStr}; + +use gitbutler_hunk_dependency::{builder::HunkDependencyBuilder, diff::Diff}; +use gitbutler_stack::StackId; + +#[test] +fn builder_simple() -> anyhow::Result<()> { + let path = PathBuf::from_str("/test.txt")?; + let mut builder = HunkDependencyBuilder::default(); + + let commit1_id = git2::Oid::from_str("a")?; + let stack1_id = StackId::generate(); + builder.add( + stack1_id, + commit1_id, + &path, + vec![Diff::try_from( + "@@ -1,6 +1,7 @@ +1 +2 +3 ++4 +5 +6 +7 +", + )?], + )?; + + let commit2_id = git2::Oid::from_str("b")?; + let stack2_id = StackId::generate(); + + builder.add( + stack2_id, + commit2_id, + &path, + vec![Diff::try_from( + "@@ -1,5 +1,3 @@ +-1 +-2 +3 +5 +6 +", + )?], + )?; + + let path_deps = builder.get_path(&path).unwrap(); + let lookup = path_deps.find(2, 1); + assert_eq!(lookup.len(), 1); + assert_eq!(lookup[0].commit_id, commit1_id); + Ok(()) +} diff --git a/crates/gitbutler-hunk-dependency/tests/diff.rs b/crates/gitbutler-hunk-dependency/tests/diff.rs new file mode 100644 index 0000000000..fdc1326fa2 --- /dev/null +++ b/crates/gitbutler-hunk-dependency/tests/diff.rs @@ -0,0 +1,42 @@ +use gitbutler_hunk_dependency::diff::Diff; + +#[test] +fn diff_simple() -> anyhow::Result<()> { + let header = Diff::try_from( + "@@ -1,6 +1,7 @@ +1 +2 +3 ++4 +5 +6 +7 +", + )?; + assert_eq!(header.old_start, 4); + assert_eq!(header.old_lines, 0); + assert_eq!(header.new_start, 4); + assert_eq!(header.new_lines, 1); + Ok(()) +} + +#[test] +fn diff_complex() -> anyhow::Result<()> { + let header = Diff::try_from( + "@@ -5,7 +5,6 @@ +5 +6 +7 +-8 +-9 ++a +10 +11 +", + )?; + assert_eq!(header.old_start, 8); + assert_eq!(header.old_lines, 2); + assert_eq!(header.new_start, 8); + assert_eq!(header.new_lines, 1); + Ok(()) +} diff --git a/crates/gitbutler-hunk-dependency/tests/mod.rs b/crates/gitbutler-hunk-dependency/tests/mod.rs new file mode 100644 index 0000000000..5bacc4e44b --- /dev/null +++ b/crates/gitbutler-hunk-dependency/tests/mod.rs @@ -0,0 +1,3 @@ +pub mod builder; +pub mod diff; +pub mod stack; diff --git a/crates/gitbutler-hunk-dependency/tests/stack.rs b/crates/gitbutler-hunk-dependency/tests/stack.rs new file mode 100644 index 0000000000..5e19060893 --- /dev/null +++ b/crates/gitbutler-hunk-dependency/tests/stack.rs @@ -0,0 +1,265 @@ +use std::{path::PathBuf, str::FromStr}; + +use gitbutler_hunk_dependency::{diff::Diff, stack::DependencyStack}; +use gitbutler_stack::StackId; + +#[test] +fn stack_simple() -> anyhow::Result<()> { + let diff = Diff::try_from( + "@@ -1,6 +1,7 @@ +1 +2 +3 ++4 +5 +6 +7 +", + )?; + let deps_stack = &mut DependencyStack::default(); + let stack_id = StackId::generate(); + let path = PathBuf::from_str("/test.txt")?; + let commit_id = git2::Oid::from_str("a")?; + + deps_stack.add(stack_id, commit_id, &path, vec![diff])?; + + let intersection = deps_stack.intersection(&path, 4, 1); + assert_eq!(intersection.len(), 1); + + Ok(()) +} + +#[test] +fn stack_complex() -> anyhow::Result<()> { + let diff_1 = Diff::try_from( + "@@ -1,6 +1,7 @@ +1 +2 +3 ++4 +5 +6 +7 +", + )?; + let diff_2 = Diff::try_from( + "@@ -2,6 +2,7 @@ +2 +3 +4 ++4.5 +5 +6 +7 +", + )?; + + let deps_stack = &mut DependencyStack::default(); + let stack_id = StackId::generate(); + + let path = PathBuf::from_str("/test.txt")?; + let commit_id = git2::Oid::from_str("a")?; + deps_stack.add(stack_id, commit_id, &path, vec![diff_1])?; + + let commit_id = git2::Oid::from_str("b")?; + deps_stack.add(stack_id, commit_id, &path, vec![diff_2])?; + + let intersection = deps_stack.intersection(&path, 4, 1); + assert_eq!(intersection.len(), 1); + + let intersection = deps_stack.intersection(&path, 5, 1); + assert_eq!(intersection.len(), 1); + + let intersection = deps_stack.intersection(&path, 4, 2); + assert_eq!(intersection.len(), 2); + + Ok(()) +} + +#[test] +fn stack_basic_line_shift() -> anyhow::Result<()> { + let diff_1 = Diff::try_from( + "@@ -1,4 +1,5 @@ +a ++b +a +a +a +", + )?; + let diff_2 = Diff::try_from( + "@@ -1,3 +1,4 @@ ++c +a +b +a +", + )?; + + let deps_stack = &mut DependencyStack::default(); + let stack_id = StackId::generate(); + + let path = PathBuf::from_str("/test.txt")?; + let commit_id = git2::Oid::from_str("a")?; + deps_stack.add(stack_id, commit_id, &path, vec![diff_1])?; + + let commit_id = git2::Oid::from_str("b")?; + deps_stack.add(stack_id, commit_id, &path, vec![diff_2])?; + + let overlaps = deps_stack.intersection(&path, 1, 1); + assert_eq!(overlaps.len(), 1); + assert_eq!(overlaps[0].commit_id, commit_id); + + Ok(()) +} + +#[test] +fn stack_complex_line_shift() -> anyhow::Result<()> { + let deps_stack = &mut DependencyStack::default(); + let stack_id = StackId::generate(); + let path = PathBuf::from_str("/test.txt")?; + + let commit1_id = git2::Oid::from_str("a")?; + let diff1 = Diff::try_from( + "@@ -1,4 +1,5 @@ +a ++b +a +a +a +", + )?; + deps_stack.add(stack_id, commit1_id, &path, vec![diff1])?; + + let commit2_id = git2::Oid::from_str("b")?; + let diff2 = Diff::try_from( + "@@ -1,3 +1,4 @@ ++c +a +b +a +", + )?; + + deps_stack.add(stack_id, commit2_id, &path, vec![diff2])?; + + let result = deps_stack.intersection(&path, 1, 1); + assert_eq!(result.len(), 1); + assert_eq!(result[0].commit_id, commit2_id); + + let result = deps_stack.intersection(&path, 2, 1); + assert_eq!(result.len(), 0); + + let result = deps_stack.intersection(&path, 3, 1); + assert_eq!(result.len(), 1); + assert_eq!(result[0].commit_id, commit1_id); + + Ok(()) +} + +#[test] +fn stack_multiple_overwrites() -> anyhow::Result<()> { + let deps_stack = &mut DependencyStack::default(); + let stack_id = StackId::generate(); + let path = PathBuf::from_str("/test.txt")?; + + let commit1_id = git2::Oid::from_str("a")?; + let diff_1 = Diff::try_from( + "@@ -1,0 +1,7 @@ ++a ++a ++a ++a ++a ++a ++a +", + )?; + deps_stack.add(stack_id, commit1_id, &path, vec![diff_1])?; + + let commit2_id = git2::Oid::from_str("b")?; + let diff2 = Diff::try_from( + "@@ -1,5 +1,5 @@ +a +-a ++b +a +a +a +", + )?; + deps_stack.add(stack_id, commit2_id, &path, vec![diff2])?; + + let commit3_id = git2::Oid::from_str("c")?; + let diff3 = Diff::try_from( + "@@ -1,7 +1,7 @@ +a +b +a +-a ++b +a +a +a +", + )?; + deps_stack.add(stack_id, commit3_id, &path, vec![diff3])?; + + let commit4_id = git2::Oid::from_str("d")?; + let diff4 = Diff::try_from( + "@@ -3,5 +3,5 @@ +a +b +a +-a ++b +a +", + )?; + deps_stack.add(stack_id, commit4_id, &path, vec![diff4])?; + + let result = deps_stack.intersection(&path, 1, 1); + assert_eq!(result.len(), 1); + assert_eq!(result[0].commit_id, commit1_id); + + let result = deps_stack.intersection(&path, 2, 1); + assert_eq!(result.len(), 1); + assert_eq!(result[0].commit_id, commit2_id); + + let result = deps_stack.intersection(&path, 4, 1); + assert_eq!(result.len(), 1); + assert_eq!(result[0].commit_id, commit3_id); + + let result = deps_stack.intersection(&path, 6, 1); + assert_eq!(result.len(), 1); + assert_eq!(result[0].commit_id, commit4_id); + + Ok(()) +} + +#[test] +fn stack_detect_deletion() -> anyhow::Result<()> { + let deps_stack = &mut DependencyStack::default(); + let stack_id = StackId::generate(); + let path = PathBuf::from_str("/test.txt")?; + + let commit1_id = git2::Oid::from_str("a")?; + let diff_1 = Diff::try_from( + "@@ -1,7 +1,6 @@ +a +a +a +-a +a +a +a +", + )?; + deps_stack.add(stack_id, commit1_id, &path, vec![diff_1])?; + + let result = deps_stack.intersection(&path, 3, 2); + assert_eq!(result.len(), 1); + assert_eq!(result[0].commit_id, commit1_id); + + Ok(()) +}