use anyhow::Context;
use similar::DiffOp;
use std::collections::HashMap;
use std::ops::Range;
use std::path::{Component, Path, PathBuf};
use std::str::FromStr;
use unidiff::{Line, PatchSet, PatchedFile};
#[derive(Debug, Eq, PartialEq)]
pub struct LineChange {
pub line: usize,
pub ranges: Option<Vec<Range<usize>>>, }
pub fn line_changes_from_diff(
patch_diff: &str,
) -> anyhow::Result<HashMap<PathBuf, Vec<LineChange>>> {
let patch_set = PatchSet::from_str(patch_diff)?;
let mut result = HashMap::new();
for patched_file in patch_set {
if patched_file.is_removed_file() {
continue;
}
let target_path: PathBuf = patched_file.target_file.trim_start_matches("b/").into();
if !is_within_repo_root(&target_path) {
anyhow::bail!(
"diff target path \"{}\" escapes the repository root folder",
target_path.display()
);
}
let changes = line_changes(&patched_file).with_context(|| {
format!(
"failed to extract line changes from the diff for \"{}\"",
target_path.display()
)
})?;
result.insert(target_path, changes);
}
Ok(result)
}
fn is_within_repo_root(path: &Path) -> bool {
path.components()
.all(|c| matches!(c, Component::CurDir | Component::Normal(_)))
}
fn line_changes(patched_file: &PatchedFile) -> anyhow::Result<Vec<LineChange>> {
let mut line_changes = Vec::new();
let mut removed: Vec<&Line> = Vec::new();
let mut added: Vec<&Line> = Vec::new();
for hunk in patched_file.hunks() {
let mut last_target_line = if hunk.target_length == 0 {
hunk.target_start
} else {
hunk.target_start.saturating_sub(1)
};
for line in hunk.lines() {
if line.is_added() {
added.push(line);
last_target_line = line.target_line_no.unwrap();
} else if line.is_removed() {
removed.push(line);
} else if line.is_context() {
flush_group(
&mut removed,
&mut added,
last_target_line + 1,
&mut line_changes,
)?;
last_target_line = line.target_line_no.unwrap();
}
}
flush_group(
&mut removed,
&mut added,
last_target_line + 1,
&mut line_changes,
)?;
}
Ok(line_changes)
}
fn push_line_change(
line_changes: &mut Vec<LineChange>,
line_change: LineChange,
) -> anyhow::Result<()> {
if let Some(last) = line_changes.last()
&& last.line > line_change.line
{
anyhow::bail!(
"diff hunks are out of order: a change at line {} follows a change at line {}",
line_change.line,
last.line
);
}
line_changes.push(line_change);
Ok(())
}
fn flush_group(
removed: &mut Vec<&Line>,
added: &mut Vec<&Line>,
deletion_anchor_line: usize,
line_changes: &mut Vec<LineChange>,
) -> anyhow::Result<()> {
if added.is_empty() {
if !removed.is_empty() {
push_line_change(
line_changes,
LineChange {
line: deletion_anchor_line,
ranges: None,
},
)?;
}
} else {
let matched_removed_idxes = align_added_to_removed(removed, added);
for (i, added_line) in added.iter().enumerate() {
let matched = matched_removed_idxes.as_ref().and_then(|idxes| idxes[i]);
push_line_change(
line_changes,
LineChange {
line: added_line.target_line_no.unwrap(),
ranges: matched.map(|j| line_diff(&removed[j].value, &added_line.value)),
},
)?;
}
}
removed.clear();
added.clear();
Ok(())
}
const MAX_SIMILARITY_PAIRING_PAIRS: usize = 10_000;
const MAX_SIMILARITY_PAIRING_BYTES: usize = 1 << 16;
fn align_added_to_removed(removed: &[&Line], added: &[&Line]) -> Option<Vec<Option<usize>>> {
if removed.is_empty() {
return None;
}
let total_bytes: usize = removed
.iter()
.chain(added.iter())
.map(|line| line.value.len())
.sum();
if removed.len() * added.len() > MAX_SIMILARITY_PAIRING_PAIRS
|| total_bytes > MAX_SIMILARITY_PAIRING_BYTES
{
return Some(align_positionally(removed.len(), added.len()));
}
let mut result = vec![None; added.len()];
let ratios: Vec<Vec<f32>> = removed
.iter()
.map(|removed_line| {
added
.iter()
.map(|added_line| {
similar::TextDiff::from_chars(
removed_line.value.as_str(),
added_line.value.as_str(),
)
.ratio()
})
.collect()
})
.collect();
anchor_best_pairs(&ratios, 0..removed.len(), 0..added.len(), &mut result);
Some(result)
}
fn align_positionally(removed_count: usize, added_count: usize) -> Vec<Option<usize>> {
(0..added_count)
.map(|i| (i < removed_count).then_some(i))
.collect()
}
fn anchor_best_pairs(
ratios: &[Vec<f32>],
removed_range: Range<usize>,
added_range: Range<usize>,
result: &mut [Option<usize>],
) {
if removed_range.is_empty() || added_range.is_empty() {
return;
}
let (mut best_removed, mut best_added, mut best_ratio) =
(removed_range.start, added_range.start, -1f32);
for i in removed_range.clone() {
for j in added_range.clone() {
if ratios[i][j] > best_ratio {
(best_removed, best_added, best_ratio) = (i, j, ratios[i][j]);
}
}
}
result[best_added] = Some(best_removed);
anchor_best_pairs(
ratios,
removed_range.start..best_removed,
added_range.start..best_added,
result,
);
anchor_best_pairs(
ratios,
best_removed + 1..removed_range.end,
best_added + 1..added_range.end,
result,
);
}
fn line_diff(old: &str, new: &str) -> Vec<Range<usize>> {
let mut result = Vec::new();
let diff = similar::TextDiff::from_chars(old, new);
let mut prev_op = None;
for op in diff.ops() {
match op {
DiffOp::Delete { new_index, .. } => {
if prev_op.is_none_or(|c: &DiffOp| !matches!(c, DiffOp::Delete { .. })) {
let idx = new.len().saturating_sub(1).min(*new_index);
push_or_merge_range(&mut result, idx..idx + 1);
}
}
DiffOp::Insert {
new_index, new_len, ..
} => {
push_or_merge_range(&mut result, *new_index..(new_index + new_len));
}
DiffOp::Replace {
new_index, new_len, ..
} => {
push_or_merge_range(&mut result, *new_index..(new_index + new_len));
}
DiffOp::Equal { .. } => {}
}
prev_op = Some(op);
}
result
}
fn push_or_merge_range(ranges: &mut Vec<Range<usize>>, mut new: Range<usize>) {
if let Some(overlapping) =
ranges.pop_if(|range| new.start <= range.end && new.end >= range.start)
{
let start = new.start.min(overlapping.start);
let end = new.end.max(overlapping.end);
new = start..end;
}
ranges.push(new);
let mut i = ranges.len() - 1;
while i > 0 && ranges[i].start < ranges[i - 1].start {
ranges.swap(i, i - 1);
i -= 1;
}
}
#[cfg(test)]
mod modified_line_ranges_tests {
use super::*;
#[test]
fn equal_lines_returns_empty_ranges() {
let ranges = line_diff("box", "box");
assert!(ranges.is_empty());
}
#[test]
fn replaced_nonconsecutive_characters_returns_separate_ranges() {
let ranges = line_diff("box", "for");
assert_eq!(ranges, vec![0..1, 2..3]);
}
#[test]
fn replaced_consecutive_characters_returns_merged_ranges() {
let ranges = line_diff("boxes", "faxed");
assert_eq!(ranges, vec![0..2, 4..5]);
}
#[test]
fn inserted_nonconsecutive_characters_returns_separate_ranges() {
let ranges = line_diff("box", "aboxa");
assert_eq!(ranges, vec![0..1, 4..5]);
}
#[test]
fn inserted_consecutive_characters_returns_merged_ranges() {
let ranges = line_diff("box", "2 boxes");
assert_eq!(ranges, vec![0..2, 5..7]);
}
#[test]
fn deleted_consecutive_characters_in_the_beginning_are_treated_as_single() {
let ranges = line_diff("abracadabra", "cadabra");
assert_eq!(ranges, vec![0..1]);
}
#[test]
fn deleted_consecutive_characters_in_the_end_are_treated_as_single() {
let ranges = line_diff("abracadabra", "abra");
assert_eq!(ranges, vec![3..4]);
}
#[test]
fn deleted_consecutive_characters_are_treated_as_single() {
let ranges = line_diff("abracadabra", "cdar");
assert_eq!(ranges, vec![0..2, 3..4]);
}
#[test]
fn mixed_ops_returns_correct_ranges() {
let ranges = line_diff("there was three", "there is thora");
assert_eq!(ranges, vec![6..7, 11..12, 13..14]);
}
}
#[cfg(test)]
#[allow(clippy::single_range_in_vec_init)]
mod tests {
use super::*;
use std::collections::HashSet;
fn line_change(line: usize) -> LineChange {
LineChange { line, ranges: None }
}
#[test]
fn single_file_diff_extracts_ranges_for_single_file() -> anyhow::Result<()> {
let ranges = line_changes_from_diff(
r#"diff --git a/Cargo.toml b/Cargo.toml
index 8c34c48..23ddd69 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -9,6 +9,7 @@ tree-sitter = "0.25.3"
tree-sitter-rust = "0.23"
tree-sitter-java = "0.23.5"
quick-xml = "0.37.2"
+diffy = "0.4.2"
[build-dependencies]
cc="1.2.16"
\ No newline at end of file"#,
)?;
assert_eq!(ranges.keys().collect::<Vec<_>>(), vec!["Cargo.toml"]);
Ok(())
}
#[test]
fn multiple_files_diff_extracts_ranges_for_multiple_files() -> anyhow::Result<()> {
let line_changes = line_changes_from_diff(
r#"diff --git a/Cargo.toml b/Cargo.toml
index 8c34c48..23ddd69 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -9,6 +9,7 @@ tree-sitter = "0.25.3"
tree-sitter-rust = "0.23"
tree-sitter-java = "0.23.5"
quick-xml = "0.37.2"
+diffy = "0.4.2"
[build-dependencies]
cc="1.2.16"
\ No newline at end of file
diff --git a/src/main.rs b/src/main.rs
index 63c5842..34d1d3f 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,4 +1,5 @@
mod parsers;
+mod differ;
fn main() {
println!("Hello, world!");
diff --git a/src/differ.rs b/src/differ.rs
index e69de29..215ed53 100644
--- a/src/differ.rs
+++ b/src/differ.rs
@@ -0,0 +1,1 @@
+use std::collections::HashMap;
"#,
)?;
assert_eq!(
line_changes
.keys()
.map(|k| k.to_str().unwrap())
.collect::<HashSet<_>>(),
HashSet::from(["Cargo.toml", "src/main.rs", "src/differ.rs"])
);
Ok(())
}
#[test]
fn single_new_line_diff_returns_single_line_change() -> anyhow::Result<()> {
let line_changes = line_changes_from_diff(
r#"diff --git a/a.txt b/a.txt
index f384549..b4b0c67 100644
--- a/a.txt
+++ b/a.txt
@@ -1,4 +1,5 @@
one
two
three
+three and a half
four"#,
)?;
assert_eq!(line_changes[&PathBuf::from("a.txt")], vec![line_change(4)]);
Ok(())
}
#[test]
fn single_first_new_line_diff_returns_single_line_change() -> anyhow::Result<()> {
let ranges = line_changes_from_diff(
r#"diff --git a/a.txt b/a.txt
index f384549..fa220f8 100644
--- a/a.txt
+++ b/a.txt
@@ -1,3 +1,4 @@
+zero
one
two
three"#,
)?;
assert_eq!(ranges[&PathBuf::from("a.txt")], vec![line_change(1)]);
Ok(())
}
#[test]
fn multiple_contiguous_new_lines_diff_returns_multiple_line_changes() -> anyhow::Result<()> {
let line_changes = line_changes_from_diff(
r#"diff --git a/a.txt b/a.txt
index f384549..3a7bc2a 100644
--- a/a.txt
+++ b/a.txt
@@ -1,4 +1,6 @@
one
two
three
+three and a half
+almost four
four"#,
)?;
assert_eq!(
line_changes[&PathBuf::from("a.txt")],
vec![line_change(4), line_change(5),]
);
Ok(())
}
#[test]
fn multiple_first_contiguous_new_lines_diff_returns_multiple_line_changes() -> anyhow::Result<()>
{
let line_changes = line_changes_from_diff(
r#"diff --git a/a.txt b/a.txt
index f384549..3ccae75 100644
--- a/a.txt
+++ b/a.txt
@@ -1,3 +1,5 @@
+sub-zero
+zero
one
two
three"#,
)?;
assert_eq!(
line_changes[&PathBuf::from("a.txt")],
vec![line_change(1), line_change(2),]
);
Ok(())
}
#[test]
fn multiple_non_contiguous_new_lines_diff_returns_multiple_line_changes() -> anyhow::Result<()>
{
let line_changes = line_changes_from_diff(
r#"diff --git a/a.txt b/a.txt
index f384549..e797e7c 100644
--- a/a.txt
+++ b/a.txt
@@ -1,4 +1,6 @@
one
two
+two and a half
three
+three and a half
four"#,
)?;
assert_eq!(
line_changes[&PathBuf::from("a.txt")],
vec![line_change(3), line_change(5)]
);
Ok(())
}
#[test]
fn multiple_contiguous_new_line_groups_diff_returns_multiple_line_changes() -> anyhow::Result<()>
{
let line_changes = line_changes_from_diff(
r#"diff --git a/a.txt b/a.txt
index f384549..ab47fb2 100644
--- a/a.txt
+++ b/a.txt
@@ -1,4 +1,8 @@
one
two
+two and a half
+almost three
three
+three and a half
+almost four
four"#,
)?;
assert_eq!(
line_changes[&PathBuf::from("a.txt")],
vec![
line_change(3),
line_change(4),
line_change(6),
line_change(7),
]
);
Ok(())
}
#[test]
fn modified_line_returns_single_line_change_with_ranges() -> anyhow::Result<()> {
let line_changes = line_changes_from_diff(
r#"diff --git a/a.txt b/a.txt
index f384549..e4c2829 100644
--- a/a.txt
+++ b/a.txt
@@ -1,4 +1,4 @@
one
two
-there was three
+there is thora
four"#,
)?;
assert_eq!(
line_changes[&PathBuf::from("a.txt")],
vec![LineChange {
line: 3,
ranges: Some(vec![6..7, 11..12, 13..14])
}]
);
Ok(())
}
#[test]
fn multiple_non_consecutive_modified_line_returns_separate_line_changes_with_ranges()
-> anyhow::Result<()> {
let line_changes = line_changes_from_diff(
r#"diff --git a/a.txt b/a.txt
index f384549..46c7533 100644
--- a/a.txt
+++ b/a.txt
@@ -1,5 +1,5 @@
-one
+modified one
two
-three white rabbits
+three rabbits
four
-five brown foxes
+five own boxes
"#,
)?;
assert_eq!(
line_changes[&PathBuf::from("a.txt")],
vec![
LineChange {
line: 1,
ranges: Some(vec![0..9])
},
LineChange {
line: 3,
ranges: Some(vec![6..7])
},
LineChange {
line: 5,
ranges: Some(vec![5..6, 9..10])
}
]
);
Ok(())
}
#[test]
fn multiple_consecutive_modified_lines_returns_single_line_changes_with_ranges()
-> anyhow::Result<()> {
let line_changes = line_changes_from_diff(
r#"diff --git a/a.txt b/a.txt
index f384549..676cbb7 100644
--- a/a.txt
+++ b/a.txt
@@ -1,4 +1,4 @@
one
-two
-three
+modified two
+modified three
four"#,
)?;
assert_eq!(
line_changes[&PathBuf::from("a.txt")],
vec![
LineChange {
line: 2,
ranges: Some(vec![0..9])
},
LineChange {
line: 3,
ranges: Some(vec![0..9])
}
]
);
Ok(())
}
#[test]
fn all_lines_replaced_returns_single_line_changes_with_ranges() -> anyhow::Result<()> {
let line_changes = line_changes_from_diff(
r#"diff --git a/a.txt b/a.txt
index f384549..676cbb7 100644
--- a/a.txt
+++ b/a.txt
@@ -1,4 +1,2 @@
-one
-two
-three
-four
+modified one
+modified two"#,
)?;
assert_eq!(
line_changes[&PathBuf::from("a.txt")],
vec![
LineChange {
line: 1,
ranges: Some(vec![0..9])
},
LineChange {
line: 2,
ranges: Some(vec![0..9])
}
]
);
Ok(())
}
#[test]
fn single_deleted_line_returns_single_line_change() -> anyhow::Result<()> {
let line_changes = line_changes_from_diff(
r#"diff --git a/a.txt b/a.txt
index f384549..87a123c 100644
--- a/a.txt
+++ b/a.txt
@@ -1,4 +1,3 @@
one
two
-three
four"#,
)?;
assert_eq!(line_changes[&PathBuf::from("a.txt")], vec![line_change(3)]);
Ok(())
}
#[test]
fn single_first_deleted_line_returns_single_line_change() -> anyhow::Result<()> {
let line_changes = line_changes_from_diff(
r#"diff --git a/a.txt b/a.txt
index f384549..58ac960 100644
--- a/a.txt
+++ b/a.txt
@@ -1,4 +1,3 @@
-one
two
three
four"#,
)?;
assert_eq!(line_changes[&PathBuf::from("a.txt")], vec![line_change(1)]);
Ok(())
}
#[test]
fn single_last_deleted_line_returns_single_line_change() -> anyhow::Result<()> {
let line_changes = line_changes_from_diff(
r#"diff --git a/a.txt b/a.txt
index f384549..4cb29ea 100644
--- a/a.txt
+++ b/a.txt
@@ -1,4 +1,3 @@
one
two
three
-four"#,
)?;
assert_eq!(line_changes[&PathBuf::from("a.txt")], vec![line_change(4)]);
Ok(())
}
#[test]
fn multiple_non_consecutive_deleted_lines_returns_separate_line_changes() -> anyhow::Result<()>
{
let line_changes = line_changes_from_diff(
r#"diff --git a/a.txt b/a.txt
index f384549..8c05df4 100644
--- a/a.txt
+++ b/a.txt
@@ -1,4 +1,2 @@
-one
two
-three
four"#,
)?;
assert_eq!(
line_changes[&PathBuf::from("a.txt")],
vec![line_change(1), line_change(2)]
);
Ok(())
}
#[test]
fn multiple_consecutive_deleted_lines_returns_single_line_change() -> anyhow::Result<()> {
let line_changes = line_changes_from_diff(
r#"diff --git a/a.txt b/a.txt
index f384549..a9c7698 100644
--- a/a.txt
+++ b/a.txt
@@ -1,4 +1,2 @@
one
-two
-three
four"#,
)?;
assert_eq!(line_changes[&PathBuf::from("a.txt")], vec![line_change(2)]);
Ok(())
}
#[test]
fn all_lines_deleted_treated_as_deleted_file() -> anyhow::Result<()> {
let line_changes = line_changes_from_diff(
r#"diff --git a/a.txt b/a.txt
index f384549..e69de29 100644
--- a/a.txt
+++ b/a.txt
@@ -1,4 +0,0 @@
-one
-two
-three
-four"#,
)?;
assert!(line_changes.is_empty());
Ok(())
}
#[test]
fn mixed_changes_returns_correct_line_changes() -> anyhow::Result<()> {
let line_changes = line_changes_from_diff(
r#"diff --git a/a.txt b/a.txt
index f384549..58a279e 100644
--- a/a.txt
+++ b/a.txt
@@ -1,4 +1,5 @@
-one
+modified one
two
-three
-four
+modified three
+modified four
+added five"#,
)?;
assert_eq!(
line_changes[&PathBuf::from("a.txt")],
vec![
LineChange {
line: 1,
ranges: Some(vec![0..9])
},
LineChange {
line: 3,
ranges: Some(vec![0..9])
},
LineChange {
line: 4,
ranges: Some(vec![0..9])
},
line_change(5)
]
);
Ok(())
}
#[test]
fn diff_with_more_added_than_deleted_lines_pairs_modified_lines_by_similarity()
-> anyhow::Result<()> {
let line_changes = line_changes_from_diff(
r#"diff --git a/deps.py b/deps.py
index abc123..def456 100644
--- a/deps.py
+++ b/deps.py
@@ -1,2 +1,3 @@
-# Project dependencies.
-# <block name="deps" affects=":deps-docs">
+# Project dependencies, kept sorted
+# and unique.
+# <block name="deps" affects=":deps-docs" keep-sorted="asc">
"#,
)?;
let changes = &line_changes[&PathBuf::from("deps.py")];
assert_eq!(changes.len(), 3);
assert_eq!(changes[0].line, 1);
assert!(changes[0].ranges.is_some());
assert_eq!(changes[1], line_change(2));
assert_eq!(changes[2].line, 3);
let tag_line = r#"# <block name="deps" affects=":deps-docs" keep-sorted="asc">"#;
let tag_ranges = changes[2]
.ranges
.as_ref()
.expect("tag line should pair with its old version");
assert!(
tag_ranges.iter().all(|r| r.end < tag_line.len()),
"ranges {tag_ranges:?} must end before the closing `>` at {}",
tag_line.len() - 1
);
Ok(())
}
#[test]
fn out_of_order_hunks_returns_error() {
let err = line_changes_from_diff(
r#"diff --git a/a.txt b/a.txt
index f384549..b4b0c67 100644
--- a/a.txt
+++ b/a.txt
@@ -5 +5 @@
-five
+FIVE
@@ -1 +1 @@
-one
+ONE
"#,
)
.unwrap_err();
assert!(
err.chain().any(|e| e.to_string().contains("out of order")),
"unexpected error: {err:#}"
);
}
#[test]
fn oversized_group_falls_back_to_positional_pairing() -> anyhow::Result<()> {
let line_len = MAX_SIMILARITY_PAIRING_BYTES / 3 + 1;
let long_line = "a".repeat(line_len);
let diff = format!(
"diff --git a/a.txt b/a.txt\n\
index f384549..b4b0c67 100644\n\
--- a/a.txt\n\
+++ b/a.txt\n\
@@ -1 +1,2 @@\n\
-{long_line}\n\
+{long_line}b\n\
+{long_line}\n"
);
let line_changes = line_changes_from_diff(&diff)?;
let changes = &line_changes[&PathBuf::from("a.txt")];
assert_eq!(changes.len(), 2);
assert_eq!(changes[0].line, 1);
assert_eq!(changes[0].ranges, Some(vec![line_len..line_len + 1]));
assert_eq!(changes[1], line_change(2));
Ok(())
}
#[test]
fn deletion_after_earlier_insertions_uses_target_line_number() -> anyhow::Result<()> {
let line_changes = line_changes_from_diff(
r#"diff --git a/a.txt b/a.txt
index f384549..b4b0c67 100644
--- a/a.txt
+++ b/a.txt
@@ -0,0 +1,5 @@
+pad one
+pad two
+pad three
+pad four
+pad five
@@ -3,1 +7,0 @@
-deleted content
"#,
)?;
let changes = &line_changes[&PathBuf::from("a.txt")];
assert_eq!(
changes,
&vec![
line_change(1),
line_change(2),
line_change(3),
line_change(4),
line_change(5),
line_change(8),
]
);
Ok(())
}
#[test]
fn modified_last_line_without_trailing_newline_returns_single_line_change_with_ranges()
-> anyhow::Result<()> {
let line_changes = line_changes_from_diff(
r#"diff --git a/a.txt b/a.txt
index f384549..b4b0c67 100644
--- a/a.txt
+++ b/a.txt
@@ -5 +5 @@
-# </block>
\ No newline at end of file
+# </block>
\ No newline at end of file"#,
)?;
let changes = &line_changes[&PathBuf::from("a.txt")];
assert_eq!(changes.len(), 1, "unexpected changes: {changes:?}");
assert_eq!(changes[0].line, 5);
assert!(changes[0].ranges.is_some());
Ok(())
}
#[test]
fn new_file_diff_returns_line_changes_for_every_line() -> anyhow::Result<()> {
let line_changes = line_changes_from_diff(
r#"diff --git a/example.rs b/example.rs
new file mode 100644
index 0000000..710d1d9
--- /dev/null
+++ b/example.rs
@@ -0,0 +1,3 @@
+fn main() {
+ println!("New file");
+}
"#,
)?;
assert_eq!(
line_changes[&PathBuf::from("example.rs")],
vec![line_change(1), line_change(2), line_change(3)]
);
Ok(())
}
#[test]
fn deleted_file_diff_is_ignored() -> anyhow::Result<()> {
let line_changes = line_changes_from_diff(
r#"diff --git a/a.txt b/a.txt
deleted file mode 100644
index f384549..0000000
--- a/a.txt
+++ /dev/null
@@ -1,4 +0,0 @@
-one
-two
-three
-four"#,
)?;
assert!(line_changes.is_empty());
Ok(())
}
#[test]
fn diff_with_parent_dir_traversal_target_path_returns_error() {
let err = line_changes_from_diff(
r#"diff --git a/../../../etc/passwd b/../../../etc/passwd
new file mode 100644
index 0000000..710d1d9
--- /dev/null
+++ b/../../../etc/passwd
@@ -0,0 +1,1 @@
+pwned
"#,
)
.unwrap_err();
assert!(
err.to_string().contains("escapes the repository root"),
"unexpected error: {err}"
);
}
#[test]
fn diff_with_absolute_target_path_returns_error() {
let err = line_changes_from_diff(
r#"diff --git a/etc/passwd b/etc/passwd
new file mode 100644
index 0000000..710d1d9
--- /dev/null
+++ /etc/passwd
@@ -0,0 +1,1 @@
+pwned
"#,
)
.unwrap_err();
assert!(
err.to_string().contains("escapes the repository root"),
"unexpected error: {err}"
);
}
}