Skip to main content

jj_lib/
absorb.rs

1// Copyright 2024 The Jujutsu Authors
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// https://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Algorithm to split changes in a single source commit into its most relevant
16//! ancestors, 'absorbing' them away.
17
18use std::cmp;
19use std::collections::HashMap;
20use std::ops::Range;
21use std::sync::Arc;
22
23use bstr::BString;
24use futures::StreamExt as _;
25use itertools::Itertools as _;
26use thiserror::Error;
27
28use crate::annotate::FileAnnotator;
29use crate::backend::BackendError;
30use crate::backend::BackendResult;
31use crate::backend::CommitId;
32use crate::backend::TreeValue;
33use crate::commit::Commit;
34use crate::commit::conflict_label_for_commits;
35use crate::conflicts::MaterializedFileValue;
36use crate::conflicts::MaterializedTreeValue;
37use crate::conflicts::materialized_diff_stream;
38use crate::copies::CopyRecords;
39use crate::diff::ContentDiff;
40use crate::diff::DiffHunkKind;
41use crate::matchers::Matcher;
42use crate::merge::Diff;
43use crate::merge::Merge;
44use crate::merged_tree::MergedTree;
45use crate::merged_tree_builder::MergedTreeBuilder;
46use crate::repo::MutableRepo;
47use crate::repo::Repo;
48use crate::repo_path::RepoPathBuf;
49use crate::revset::ResolvedRevsetExpression;
50use crate::revset::RevsetEvaluationError;
51
52/// The source commit to absorb into its ancestry.
53#[derive(Clone, Debug)]
54pub struct AbsorbSource {
55    commit: Commit,
56    parents: Vec<Commit>,
57    parent_tree: MergedTree,
58    source_tree: MergedTree,
59}
60
61impl AbsorbSource {
62    /// Create an absorb source from a single commit.
63    pub async fn from_commit(repo: &dyn Repo, commit: Commit) -> BackendResult<Self> {
64        let parent_tree = commit.parent_tree(repo).await?;
65        let source_tree = commit.tree();
66        Self::from_tree(commit, source_tree, parent_tree).await
67    }
68
69    /// Create an absorb source from a commit and a tree derived from it.
70    pub async fn from_tree(
71        commit: Commit,
72        source_tree: MergedTree,
73        parent_tree: MergedTree,
74    ) -> BackendResult<Self> {
75        let parents = commit.parents().await?;
76        Ok(Self {
77            commit,
78            parents,
79            parent_tree,
80            source_tree,
81        })
82    }
83}
84
85/// Error splitting an absorb source into modified ancestry trees.
86#[derive(Debug, Error)]
87pub enum AbsorbError {
88    /// Error while contacting the Backend.
89    #[error(transparent)]
90    Backend(#[from] BackendError),
91    /// Error resolving commit ancestry.
92    #[error(transparent)]
93    RevsetEvaluation(#[from] RevsetEvaluationError),
94}
95
96/// An absorb 'plan' indicating which commits should be modified and what they
97/// should be modified to.
98#[derive(Default)]
99pub struct SelectedTrees {
100    /// Commits to be modified, to be passed to `absorb_hunks`.
101    pub target_commits: HashMap<CommitId, MergedTreeBuilder>,
102    /// Paths that were not absorbed for various error reasons.
103    pub skipped_paths: Vec<(RepoPathBuf, String)>,
104}
105
106/// Builds trees to be merged into destination commits by splitting source
107/// changes based on file annotation.
108pub async fn split_hunks_to_trees(
109    repo: &dyn Repo,
110    source: &AbsorbSource,
111    destinations: &Arc<ResolvedRevsetExpression>,
112    matcher: &dyn Matcher,
113) -> Result<SelectedTrees, AbsorbError> {
114    let mut selected_trees = SelectedTrees::default();
115
116    let left_tree = &source.parent_tree;
117    let right_tree = &source.source_tree;
118    // TODO: enable copy tracking if we add support for annotate and merge
119    let copy_records = CopyRecords::default();
120    let tree_diff = left_tree.diff_stream_with_copies(right_tree, matcher, &copy_records);
121    let mut diff_stream = materialized_diff_stream(
122        repo.store(),
123        tree_diff,
124        Diff::new(left_tree.labels(), right_tree.labels()),
125    );
126    while let Some(entry) = diff_stream.next().await {
127        let left_path = entry.path.source();
128        let right_path = entry.path.target();
129        let values = entry.values?;
130        let (left_text, executable, copy_id) = match to_file_value(values.before) {
131            Ok(Some(mut value)) => (
132                value.read_all(left_path).await?,
133                value.executable,
134                value.copy_id,
135            ),
136            // New file should have no destinations
137            Ok(None) => continue,
138            Err(reason) => {
139                selected_trees
140                    .skipped_paths
141                    .push((left_path.to_owned(), reason));
142                continue;
143            }
144        };
145        let (right_text, deleted) = match to_file_value(values.after) {
146            Ok(Some(mut value)) => (value.read_all(right_path).await?, false),
147            Ok(None) => (vec![], true),
148            Err(reason) => {
149                selected_trees
150                    .skipped_paths
151                    .push((right_path.to_owned(), reason));
152                continue;
153            }
154        };
155
156        // Compute annotation of parent (= left) content to map right hunks
157        let mut annotator =
158            FileAnnotator::with_file_content(source.commit.id(), left_path, left_text.clone());
159        annotator.compute(repo, destinations).await?;
160        let annotation = annotator.to_annotation();
161        let annotation_ranges = annotation
162            .compact_line_ranges()
163            .filter_map(|(commit_id, range)| Some((commit_id.ok()?, range)))
164            .collect_vec();
165        let diff = ContentDiff::by_line([&left_text, &right_text]);
166        let selected_ranges = split_file_hunks(&annotation_ranges, &diff);
167        // Build trees containing parent (= left) contents + selected hunks
168        for (&commit_id, ranges) in &selected_ranges {
169            let tree_builder = selected_trees
170                .target_commits
171                .entry(commit_id.clone())
172                .or_insert_with(|| MergedTreeBuilder::new(left_tree.clone()));
173            let new_text = combine_texts(&left_text, &right_text, ranges);
174            // Since changes to be absorbed are represented as diffs relative to
175            // the source parent, we can propagate file deletion only if the
176            // whole file content is deleted at a single destination commit.
177            let new_tree_value = if new_text.is_empty() && deleted {
178                Merge::absent()
179            } else {
180                let id = repo
181                    .store()
182                    .write_file(left_path, &mut new_text.as_slice())
183                    .await?;
184                Merge::normal(TreeValue::File {
185                    id,
186                    executable,
187                    copy_id: copy_id.clone(),
188                })
189            };
190            tree_builder.set_or_remove(left_path.to_owned(), new_tree_value);
191        }
192    }
193
194    Ok(selected_trees)
195}
196
197type SelectedRange = (Range<usize>, Range<usize>);
198
199/// Maps `diff` hunks to commits based on the left `annotation_ranges`. The
200/// `annotation_ranges` should be compacted.
201fn split_file_hunks<'a>(
202    mut annotation_ranges: &[(&'a CommitId, Range<usize>)],
203    diff: &ContentDiff,
204) -> HashMap<&'a CommitId, Vec<SelectedRange>> {
205    debug_assert!(annotation_ranges.iter().all(|(_, range)| !range.is_empty()));
206    let mut selected_ranges: HashMap<&CommitId, Vec<_>> = HashMap::new();
207    let mut diff_hunk_ranges = diff
208        .hunk_ranges()
209        .filter(|hunk| hunk.kind == DiffHunkKind::Different);
210    while !annotation_ranges.is_empty() {
211        let Some(hunk) = diff_hunk_ranges.next() else {
212            break;
213        };
214        let [left_range, right_range]: &[_; 2] = hunk.ranges[..].try_into().unwrap();
215        assert!(!left_range.is_empty() || !right_range.is_empty());
216        if right_range.is_empty() {
217            // If the hunk is pure deletion, it can be mapped to multiple
218            // overlapped annotation ranges unambiguously.
219            let skip = annotation_ranges
220                .iter()
221                .take_while(|(_, range)| range.end <= left_range.start)
222                .count();
223            annotation_ranges = &annotation_ranges[skip..];
224            let pre_overlap = annotation_ranges
225                .iter()
226                .take_while(|(_, range)| range.end < left_range.end)
227                .count();
228            let maybe_overlapped_ranges = annotation_ranges.get(..pre_overlap + 1);
229            annotation_ranges = &annotation_ranges[pre_overlap..];
230            let Some(overlapped_ranges) = maybe_overlapped_ranges else {
231                continue;
232            };
233            // Ensure that the ranges are contiguous and include the start.
234            let all_covered = overlapped_ranges
235                .iter()
236                .try_fold(left_range.start, |prev_end, (_, cur)| {
237                    (cur.start <= prev_end).then_some(cur.end)
238                })
239                .inspect(|&last_end| assert!(left_range.end <= last_end))
240                .is_some();
241            if all_covered {
242                for (commit_id, cur_range) in overlapped_ranges {
243                    let start = cmp::max(cur_range.start, left_range.start);
244                    let end = cmp::min(cur_range.end, left_range.end);
245                    assert!(start < end);
246                    let selected = selected_ranges.entry(commit_id).or_default();
247                    selected.push((start..end, right_range.clone()));
248                }
249            }
250        } else {
251            // In other cases, the hunk should be included in an annotation
252            // range to map it unambiguously. Skip any pre-overlapped ranges.
253            let skip = annotation_ranges
254                .iter()
255                .take_while(|(_, range)| range.end < left_range.end)
256                .count();
257            annotation_ranges = &annotation_ranges[skip..];
258            let Some((commit_id, cur_range)) = annotation_ranges.first() else {
259                continue;
260            };
261            let contained = cur_range.start <= left_range.start && left_range.end <= cur_range.end;
262            // If the hunk is pure insertion, it can be mapped to two distinct
263            // annotation ranges, which is ambiguous.
264            let ambiguous = cur_range.end == left_range.start
265                && annotation_ranges
266                    .get(1)
267                    .is_some_and(|(_, next_range)| next_range.start == left_range.end);
268            if contained && !ambiguous {
269                let selected = selected_ranges.entry(commit_id).or_default();
270                selected.push((left_range.clone(), right_range.clone()));
271            }
272        }
273    }
274    selected_ranges
275}
276
277/// Constructs new text by replacing `text1` range with `text2` range for each
278/// selected `(range1, range2)` pairs.
279fn combine_texts(text1: &[u8], text2: &[u8], selected_ranges: &[SelectedRange]) -> BString {
280    itertools::chain!(
281        [(0..0, 0..0)],
282        selected_ranges.iter().cloned(),
283        [(text1.len()..text1.len(), text2.len()..text2.len())],
284    )
285    .tuple_windows()
286    // Copy unchanged hunk from text1 and current hunk from text2
287    .map(|((prev1, _), (cur1, cur2))| (prev1.end..cur1.start, cur2))
288    .flat_map(|(range1, range2)| [&text1[range1], &text2[range2]])
289    .collect()
290}
291
292/// Describes changes made by [`absorb_hunks()`].
293#[derive(Clone, Debug)]
294pub struct AbsorbStats {
295    /// Rewritten source commit which the absorbed hunks were removed, or `None`
296    /// if the source commit was abandoned or no hunks were moved.
297    pub rewritten_source: Option<Commit>,
298    /// Rewritten commits which the source hunks were absorbed into, in forward
299    /// topological order.
300    pub rewritten_destinations: Vec<Commit>,
301    /// Number of descendant commits which were rebased. The number of rewritten
302    /// destination commits are not included.
303    pub num_rebased: usize,
304}
305
306/// Merges selected trees into the specified commits. Abandons the source commit
307/// if it becomes discardable.
308pub async fn absorb_hunks(
309    repo: &mut MutableRepo,
310    source: &AbsorbSource,
311    mut selected_trees: HashMap<CommitId, MergedTreeBuilder>,
312) -> BackendResult<AbsorbStats> {
313    let mut rewritten_source = None;
314    let mut rewritten_destinations = Vec::new();
315    let mut num_rebased = 0;
316    let parents_label = conflict_label_for_commits(&source.parents);
317    let source_commit_label = source.commit.conflict_label();
318    // Rewrite commits in topological order so that descendant commits wouldn't
319    // be rewritten multiple times.
320    repo.transform_descendants(selected_trees.keys().cloned().collect(), async |rewriter| {
321        // Remove selected hunks from the source commit by reparent()
322        if rewriter.old_commit().id() == source.commit.id() {
323            let commit_builder = rewriter.reparent();
324            if commit_builder.is_discardable().await? {
325                commit_builder.abandon();
326            } else {
327                rewritten_source = Some(commit_builder.write().await?);
328                num_rebased += 1;
329            }
330            return Ok(());
331        }
332        let Some(tree_builder) = selected_trees.remove(rewriter.old_commit().id()) else {
333            rewriter.rebase().await?.write().await?;
334            num_rebased += 1;
335            return Ok(());
336        };
337        // Merge hunks between source parent tree and selected tree
338        let selected_tree = tree_builder.write_tree().await?;
339        let destination_label = rewriter.old_commit().conflict_label();
340        let commit_builder = rewriter.rebase().await?;
341        let destination_tree = commit_builder.tree();
342        let new_tree = MergedTree::merge(Merge::from_vec(vec![
343            (
344                destination_tree,
345                format!("{destination_label} (absorb destination)"),
346            ),
347            (
348                source.parent_tree.clone(),
349                format!("{parents_label} (parents of absorbed revision)"),
350            ),
351            (
352                selected_tree,
353                format!("absorbed changes (from {source_commit_label})"),
354            ),
355        ]))
356        .await?;
357        let mut predecessors = commit_builder.predecessors().to_vec();
358        predecessors.push(source.commit.id().clone());
359        let new_commit = commit_builder
360            .set_tree(new_tree)
361            .set_predecessors(predecessors)
362            .write()
363            .await?;
364        rewritten_destinations.push(new_commit);
365        Ok(())
366    })
367    .await?;
368    Ok(AbsorbStats {
369        rewritten_source,
370        rewritten_destinations,
371        num_rebased,
372    })
373}
374
375fn to_file_value(value: MaterializedTreeValue) -> Result<Option<MaterializedFileValue>, String> {
376    match value {
377        MaterializedTreeValue::Absent => Ok(None), // New or deleted file
378        MaterializedTreeValue::AccessDenied(err) => Err(format!("Access is denied: {err}")),
379        MaterializedTreeValue::File(file) => Ok(Some(file)),
380        MaterializedTreeValue::Symlink { .. } => Err("Is a symlink".into()),
381        MaterializedTreeValue::FileConflict(_) | MaterializedTreeValue::OtherConflict { .. } => {
382            Err("Is a conflict".into())
383        }
384        MaterializedTreeValue::GitSubmodule(_) => Err("Is a Git submodule".into()),
385        MaterializedTreeValue::Tree(_) => panic!("diff should not contain trees"),
386    }
387}
388
389#[cfg(test)]
390mod tests {
391    use maplit::hashmap;
392
393    use super::*;
394
395    #[test]
396    fn test_split_file_hunks_empty_or_single_line() {
397        let commit_id1 = &CommitId::from_hex("111111");
398
399        // unchanged
400        assert_eq!(
401            split_file_hunks(&[], &ContentDiff::by_line(["", ""])),
402            hashmap! {}
403        );
404
405        // insert single line
406        assert_eq!(
407            split_file_hunks(&[], &ContentDiff::by_line(["", "2X\n"])),
408            hashmap! {}
409        );
410        // delete single line
411        assert_eq!(
412            split_file_hunks(&[(commit_id1, 0..3)], &ContentDiff::by_line(["1a\n", ""])),
413            hashmap! { commit_id1 => vec![(0..3, 0..0)] }
414        );
415        // modify single line
416        assert_eq!(
417            split_file_hunks(
418                &[(commit_id1, 0..3)],
419                &ContentDiff::by_line(["1a\n", "1AA\n"])
420            ),
421            hashmap! { commit_id1 => vec![(0..3, 0..4)] }
422        );
423    }
424
425    #[test]
426    fn test_split_file_hunks_single_range() {
427        let commit_id1 = &CommitId::from_hex("111111");
428
429        // insert first, middle, and last lines
430        assert_eq!(
431            split_file_hunks(
432                &[(commit_id1, 0..6)],
433                &ContentDiff::by_line(["1a\n1b\n", "1X\n1a\n1Y\n1b\n1Z\n"])
434            ),
435            hashmap! {
436                commit_id1 => vec![(0..0, 0..3), (3..3, 6..9), (6..6, 12..15)],
437            }
438        );
439        // delete first, middle, and last lines
440        assert_eq!(
441            split_file_hunks(
442                &[(commit_id1, 0..15)],
443                &ContentDiff::by_line(["1a\n1b\n1c\n1d\n1e\n1f\n", "1b\n1d\n1f\n"])
444            ),
445            hashmap! {
446                commit_id1 => vec![(0..3, 0..0), (6..9, 3..3), (12..15, 6..6)],
447            }
448        );
449        // modify non-contiguous lines
450        assert_eq!(
451            split_file_hunks(
452                &[(commit_id1, 0..12)],
453                &ContentDiff::by_line(["1a\n1b\n1c\n1d\n", "1A\n1b\n1C\n1d\n"])
454            ),
455            hashmap! { commit_id1 => vec![(0..3, 0..3), (6..9, 6..9)] }
456        );
457    }
458
459    #[test]
460    fn test_split_file_hunks_contiguous_ranges_insert() {
461        let commit_id1 = &CommitId::from_hex("111111");
462        let commit_id2 = &CommitId::from_hex("222222");
463
464        // insert first line
465        assert_eq!(
466            split_file_hunks(
467                &[(commit_id1, 0..6), (commit_id2, 6..12)],
468                &ContentDiff::by_line(["1a\n1b\n2a\n2b\n", "1X\n1a\n1b\n2a\n2b\n"])
469            ),
470            hashmap! { commit_id1 => vec![(0..0, 0..3)] }
471        );
472        // insert middle line to first range
473        assert_eq!(
474            split_file_hunks(
475                &[(commit_id1, 0..6), (commit_id2, 6..12)],
476                &ContentDiff::by_line(["1a\n1b\n2a\n2b\n", "1a\n1X\n1b\n2a\n2b\n"])
477            ),
478            hashmap! { commit_id1 => vec![(3..3, 3..6)] }
479        );
480        // insert middle line between ranges (ambiguous)
481        assert_eq!(
482            split_file_hunks(
483                &[(commit_id1, 0..6), (commit_id2, 6..12)],
484                &ContentDiff::by_line(["1a\n1b\n2a\n2b\n", "1a\n1b\n3X\n2a\n2b\n"])
485            ),
486            hashmap! {}
487        );
488        // insert middle line to second range
489        assert_eq!(
490            split_file_hunks(
491                &[(commit_id1, 0..6), (commit_id2, 6..12)],
492                &ContentDiff::by_line(["1a\n1b\n2a\n2b\n", "1a\n1b\n2a\n2X\n2b\n"])
493            ),
494            hashmap! { commit_id2 => vec![(9..9, 9..12)] }
495        );
496        // insert last line
497        assert_eq!(
498            split_file_hunks(
499                &[(commit_id1, 0..6), (commit_id2, 6..12)],
500                &ContentDiff::by_line(["1a\n1b\n2a\n2b\n", "1a\n1b\n2a\n2b\n2X\n"])
501            ),
502            hashmap! { commit_id2 => vec![(12..12, 12..15)] }
503        );
504    }
505
506    #[test]
507    fn test_split_file_hunks_contiguous_ranges_delete() {
508        let commit_id1 = &CommitId::from_hex("111111");
509        let commit_id2 = &CommitId::from_hex("222222");
510
511        // delete first line
512        assert_eq!(
513            split_file_hunks(
514                &[(commit_id1, 0..6), (commit_id2, 6..12)],
515                &ContentDiff::by_line(["1a\n1b\n2a\n2b\n", "1b\n2a\n2b\n"])
516            ),
517            hashmap! { commit_id1 => vec![(0..3, 0..0)] }
518        );
519        // delete middle line from first range
520        assert_eq!(
521            split_file_hunks(
522                &[(commit_id1, 0..6), (commit_id2, 6..12)],
523                &ContentDiff::by_line(["1a\n1b\n2a\n2b\n", "1a\n2a\n2b\n"])
524            ),
525            hashmap! { commit_id1 => vec![(3..6, 3..3)] }
526        );
527        // delete middle line from second range
528        assert_eq!(
529            split_file_hunks(
530                &[(commit_id1, 0..6), (commit_id2, 6..12)],
531                &ContentDiff::by_line(["1a\n1b\n2a\n2b\n", "1a\n1b\n2b\n"])
532            ),
533            hashmap! { commit_id2 => vec![(6..9, 6..6)] }
534        );
535        // delete last line
536        assert_eq!(
537            split_file_hunks(
538                &[(commit_id1, 0..6), (commit_id2, 6..12)],
539                &ContentDiff::by_line(["1a\n1b\n2a\n2b\n", "1a\n1b\n2a\n"])
540            ),
541            hashmap! { commit_id2 => vec![(9..12, 9..9)] }
542        );
543        // delete first and last lines
544        assert_eq!(
545            split_file_hunks(
546                &[(commit_id1, 0..6), (commit_id2, 6..12)],
547                &ContentDiff::by_line(["1a\n1b\n2a\n2b\n", "1b\n2a\n"])
548            ),
549            hashmap! {
550                commit_id1 => vec![(0..3, 0..0)],
551                commit_id2 => vec![(9..12, 6..6)],
552            }
553        );
554
555        // delete across ranges (split first annotation range)
556        assert_eq!(
557            split_file_hunks(
558                &[(commit_id1, 0..6), (commit_id2, 6..12)],
559                &ContentDiff::by_line(["1a\n1b\n2a\n2b\n", "1a\n"])
560            ),
561            hashmap! {
562                commit_id1 => vec![(3..6, 3..3)],
563                commit_id2 => vec![(6..12, 3..3)],
564            }
565        );
566        // delete middle lines across ranges (split both annotation ranges)
567        assert_eq!(
568            split_file_hunks(
569                &[(commit_id1, 0..6), (commit_id2, 6..12)],
570                &ContentDiff::by_line(["1a\n1b\n2a\n2b\n", "1a\n2b\n"])
571            ),
572            hashmap! {
573                commit_id1 => vec![(3..6, 3..3)],
574                commit_id2 => vec![(6..9, 3..3)],
575            }
576        );
577        // delete across ranges (split second annotation range)
578        assert_eq!(
579            split_file_hunks(
580                &[(commit_id1, 0..6), (commit_id2, 6..12)],
581                &ContentDiff::by_line(["1a\n1b\n2a\n2b\n", "2b\n"])
582            ),
583            hashmap! {
584                commit_id1 => vec![(0..6, 0..0)],
585                commit_id2 => vec![(6..9, 0..0)],
586            }
587        );
588
589        // delete all
590        assert_eq!(
591            split_file_hunks(
592                &[(commit_id1, 0..6), (commit_id2, 6..12)],
593                &ContentDiff::by_line(["1a\n1b\n2a\n2b\n", ""])
594            ),
595            hashmap! {
596                commit_id1 => vec![(0..6, 0..0)],
597                commit_id2 => vec![(6..12, 0..0)],
598            }
599        );
600    }
601
602    #[test]
603    fn test_split_file_hunks_contiguous_ranges_modify() {
604        let commit_id1 = &CommitId::from_hex("111111");
605        let commit_id2 = &CommitId::from_hex("222222");
606
607        // modify first line
608        assert_eq!(
609            split_file_hunks(
610                &[(commit_id1, 0..6), (commit_id2, 6..12)],
611                &ContentDiff::by_line(["1a\n1b\n2a\n2b\n", "1A\n1b\n2a\n2b\n"])
612            ),
613            hashmap! { commit_id1 => vec![(0..3, 0..3)] }
614        );
615        // modify middle line of first range
616        assert_eq!(
617            split_file_hunks(
618                &[(commit_id1, 0..6), (commit_id2, 6..12)],
619                &ContentDiff::by_line(["1a\n1b\n2a\n2b\n", "1a\n1B\n2a\n2b\n"])
620            ),
621            hashmap! { commit_id1 => vec![(3..6, 3..6)] }
622        );
623        // modify middle lines of both ranges (ambiguous)
624        // ('hg absorb' accepts this)
625        assert_eq!(
626            split_file_hunks(
627                &[(commit_id1, 0..6), (commit_id2, 6..12)],
628                &ContentDiff::by_line(["1a\n1b\n2a\n2b\n", "1a\n1B\n2A\n2b\n"])
629            ),
630            hashmap! {}
631        );
632        // modify middle line of second range
633        assert_eq!(
634            split_file_hunks(
635                &[(commit_id1, 0..6), (commit_id2, 6..12)],
636                &ContentDiff::by_line(["1a\n1b\n2a\n2b\n", "1a\n1b\n2A\n2b\n"])
637            ),
638            hashmap! { commit_id2 => vec![(6..9, 6..9)] }
639        );
640        // modify last line
641        assert_eq!(
642            split_file_hunks(
643                &[(commit_id1, 0..6), (commit_id2, 6..12)],
644                &ContentDiff::by_line(["1a\n1b\n2a\n2b\n", "1a\n1b\n2a\n2B\n"])
645            ),
646            hashmap! { commit_id2 => vec![(9..12, 9..12)] }
647        );
648        // modify first and last lines
649        assert_eq!(
650            split_file_hunks(
651                &[(commit_id1, 0..6), (commit_id2, 6..12)],
652                &ContentDiff::by_line(["1a\n1b\n2a\n2b\n", "1A\n1b\n2a\n2B\n"])
653            ),
654            hashmap! {
655                commit_id1 => vec![(0..3, 0..3)],
656                commit_id2 => vec![(9..12, 9..12)],
657            }
658        );
659    }
660
661    #[test]
662    fn test_split_file_hunks_contiguous_ranges_modify_insert() {
663        let commit_id1 = &CommitId::from_hex("111111");
664        let commit_id2 = &CommitId::from_hex("222222");
665
666        // modify first range, insert adjacent middle line
667        assert_eq!(
668            split_file_hunks(
669                &[(commit_id1, 0..6), (commit_id2, 6..12)],
670                &ContentDiff::by_line(["1a\n1b\n2a\n2b\n", "1A\n1B\n1X\n2a\n2b\n"])
671            ),
672            hashmap! { commit_id1 => vec![(0..6, 0..9)] }
673        );
674        // modify second range, insert adjacent middle line
675        assert_eq!(
676            split_file_hunks(
677                &[(commit_id1, 0..6), (commit_id2, 6..12)],
678                &ContentDiff::by_line(["1a\n1b\n2a\n2b\n", "1a\n1b\n2X\n2A\n2B\n"])
679            ),
680            hashmap! { commit_id2 => vec![(6..12, 6..15)] }
681        );
682        // modify second range, insert last line
683        assert_eq!(
684            split_file_hunks(
685                &[(commit_id1, 0..6), (commit_id2, 6..12)],
686                &ContentDiff::by_line(["1a\n1b\n2a\n2b\n", "1a\n1b\n2A\n2B\n2X\n"])
687            ),
688            hashmap! { commit_id2 => vec![(6..12, 6..15)] }
689        );
690        // modify first and last lines (unambiguous), insert middle line between
691        // ranges (ambiguous)
692        assert_eq!(
693            split_file_hunks(
694                &[(commit_id1, 0..6), (commit_id2, 6..12)],
695                &ContentDiff::by_line(["1a\n1b\n2a\n2b\n", "1A\n1b\n3X\n2a\n2B\n"])
696            ),
697            hashmap! {
698                commit_id1 => vec![(0..3, 0..3)],
699                commit_id2 => vec![(9..12, 12..15)],
700            }
701        );
702    }
703
704    #[test]
705    fn test_split_file_hunks_contiguous_ranges_modify_delete() {
706        let commit_id1 = &CommitId::from_hex("111111");
707        let commit_id2 = &CommitId::from_hex("222222");
708
709        // modify first line, delete adjacent middle line
710        assert_eq!(
711            split_file_hunks(
712                &[(commit_id1, 0..6), (commit_id2, 6..12)],
713                &ContentDiff::by_line(["1a\n1b\n2a\n2b\n", "1A\n2a\n2b\n"])
714            ),
715            hashmap! { commit_id1 => vec![(0..6, 0..3)] }
716        );
717        // modify last line, delete adjacent middle line
718        assert_eq!(
719            split_file_hunks(
720                &[(commit_id1, 0..6), (commit_id2, 6..12)],
721                &ContentDiff::by_line(["1a\n1b\n2a\n2b\n", "1a\n1b\n2B\n"])
722            ),
723            hashmap! { commit_id2 => vec![(6..12, 6..9)] }
724        );
725        // modify first and last lines, delete middle line from first range
726        assert_eq!(
727            split_file_hunks(
728                &[(commit_id1, 0..6), (commit_id2, 6..12)],
729                &ContentDiff::by_line(["1a\n1b\n2a\n2b\n", "1A\n2a\n2B\n"])
730            ),
731            hashmap! {
732                commit_id1 => vec![(0..6, 0..3)],
733                commit_id2 => vec![(9..12, 6..9)],
734            }
735        );
736        // modify first and last lines, delete middle line from second range
737        assert_eq!(
738            split_file_hunks(
739                &[(commit_id1, 0..6), (commit_id2, 6..12)],
740                &ContentDiff::by_line(["1a\n1b\n2a\n2b\n", "1A\n1b\n2B\n"])
741            ),
742            hashmap! {
743                commit_id1 => vec![(0..3, 0..3)],
744                commit_id2 => vec![(6..12, 6..9)],
745            }
746        );
747        // modify middle line, delete adjacent middle line (ambiguous)
748        assert_eq!(
749            split_file_hunks(
750                &[(commit_id1, 0..6), (commit_id2, 6..12)],
751                &ContentDiff::by_line(["1a\n1b\n2a\n2b\n", "1a\n1B\n2b\n"])
752            ),
753            hashmap! {}
754        );
755    }
756
757    #[test]
758    fn test_split_file_hunks_non_contiguous_ranges_insert() {
759        let commit_id1 = &CommitId::from_hex("111111");
760        let commit_id2 = &CommitId::from_hex("222222");
761
762        // insert middle line to first range
763        assert_eq!(
764            split_file_hunks(
765                &[(commit_id1, 0..6), /* 6..9, */ (commit_id2, 9..15)],
766                &ContentDiff::by_line(["1a\n1b\n0a\n2a\n2b\n", "1a\n1b\n1X\n0a\n2a\n2b\n"])
767            ),
768            hashmap! { commit_id1 => vec![(6..6, 6..9)] }
769        );
770        // insert middle line to second range
771        assert_eq!(
772            split_file_hunks(
773                &[(commit_id1, 0..6), /* 6..9, */ (commit_id2, 9..15)],
774                &ContentDiff::by_line(["1a\n1b\n0a\n2a\n2b\n", "1a\n1b\n0a\n2X\n2a\n2b\n"])
775            ),
776            hashmap! { commit_id2 => vec![(9..9, 9..12)] }
777        );
778        // insert middle lines to both ranges
779        assert_eq!(
780            split_file_hunks(
781                &[(commit_id1, 0..6), /* 6..9, */ (commit_id2, 9..15)],
782                &ContentDiff::by_line(["1a\n1b\n0a\n2a\n2b\n", "1a\n1b\n1X\n0a\n2X\n2a\n2b\n"])
783            ),
784            hashmap! {
785                commit_id1 => vec![(6..6, 6..9)],
786                commit_id2 => vec![(9..9, 12..15)],
787            }
788        );
789    }
790
791    #[test]
792    fn test_split_file_hunks_non_contiguous_ranges_insert_modify_masked() {
793        let commit_id1 = &CommitId::from_hex("111111");
794        let commit_id2 = &CommitId::from_hex("222222");
795
796        // insert middle line to first range, modify masked line (ambiguous)
797        assert_eq!(
798            split_file_hunks(
799                &[(commit_id1, 0..6), /* 6..9, */ (commit_id2, 9..15)],
800                &ContentDiff::by_line(["1a\n1b\n0a\n2a\n2b\n", "1a\n1b\n1X\n0A\n2a\n2b\n"])
801            ),
802            hashmap! {}
803        );
804        // insert middle line to second range, modify masked line (ambiguous)
805        assert_eq!(
806            split_file_hunks(
807                &[(commit_id1, 0..6), /* 6..9, */ (commit_id2, 9..15)],
808                &ContentDiff::by_line(["1a\n1b\n0a\n2a\n2b\n", "1a\n1b\n0A\n2X\n2a\n2b\n"])
809            ),
810            hashmap! {}
811        );
812        // insert middle lines to both ranges, modify masked line (ambiguous)
813        assert_eq!(
814            split_file_hunks(
815                &[(commit_id1, 0..6), /* 6..9, */ (commit_id2, 9..15)],
816                &ContentDiff::by_line(["1a\n1b\n0a\n2a\n2b\n", "1a\n1b\n1X\n0A\n2X\n2a\n2b\n"])
817            ),
818            hashmap! {}
819        );
820    }
821
822    #[test]
823    fn test_split_file_hunks_non_contiguous_ranges_delete() {
824        let commit_id1 = &CommitId::from_hex("111111");
825        let commit_id2 = &CommitId::from_hex("222222");
826
827        // delete middle line from first range
828        assert_eq!(
829            split_file_hunks(
830                &[(commit_id1, 0..6), /* 6..9, */ (commit_id2, 9..15)],
831                &ContentDiff::by_line(["1a\n1b\n0a\n2a\n2b\n", "1a\n0a\n2a\n2b\n"])
832            ),
833            hashmap! { commit_id1 => vec![(3..6, 3..3)] }
834        );
835        // delete middle line from second range
836        assert_eq!(
837            split_file_hunks(
838                &[(commit_id1, 0..6), /* 6..9, */ (commit_id2, 9..15)],
839                &ContentDiff::by_line(["1a\n1b\n0a\n2a\n2b\n", "1a\n1b\n0a\n2b\n"])
840            ),
841            hashmap! { commit_id2 => vec![(9..12, 9..9)] }
842        );
843        // delete middle lines from both ranges
844        assert_eq!(
845            split_file_hunks(
846                &[(commit_id1, 0..6), /* 6..9, */ (commit_id2, 9..15)],
847                &ContentDiff::by_line(["1a\n1b\n0a\n2a\n2b\n", "1a\n0a\n2b\n"])
848            ),
849            hashmap! {
850                commit_id1 => vec![(3..6, 3..3)],
851                commit_id2 => vec![(9..12, 6..6)],
852            }
853        );
854    }
855
856    #[test]
857    fn test_split_file_hunks_non_contiguous_ranges_delete_modify_masked() {
858        let commit_id1 = &CommitId::from_hex("111111");
859        let commit_id2 = &CommitId::from_hex("222222");
860
861        // delete middle line from first range, modify masked line (ambiguous)
862        assert_eq!(
863            split_file_hunks(
864                &[(commit_id1, 0..6), /* 6..9, */ (commit_id2, 9..15)],
865                &ContentDiff::by_line(["1a\n1b\n0a\n2a\n2b\n", "1a\n0A\n2a\n2b\n"])
866            ),
867            hashmap! {}
868        );
869        // delete middle line from second range, modify masked line (ambiguous)
870        assert_eq!(
871            split_file_hunks(
872                &[(commit_id1, 0..6), /* 6..9, */ (commit_id2, 9..15)],
873                &ContentDiff::by_line(["1a\n1b\n0a\n2a\n2b\n", "1a\n1b\n0A\n2b\n"])
874            ),
875            hashmap! {}
876        );
877        // delete middle lines from both ranges, modify masked line (ambiguous)
878        assert_eq!(
879            split_file_hunks(
880                &[(commit_id1, 0..6), /* 6..9, */ (commit_id2, 9..15)],
881                &ContentDiff::by_line(["1a\n1b\n0a\n2a\n2b\n", "1a\n0A\n2b\n"])
882            ),
883            hashmap! {}
884        );
885    }
886
887    #[test]
888    fn test_split_file_hunks_non_contiguous_ranges_delete_delete_masked() {
889        let commit_id1 = &CommitId::from_hex("111111");
890        let commit_id2 = &CommitId::from_hex("222222");
891
892        // 'hg absorb' accepts these, but it seems better to reject them as
893        // ambiguous. Masked lines cannot be deleted.
894
895        // delete middle line from first range, delete masked line (ambiguous)
896        assert_eq!(
897            split_file_hunks(
898                &[(commit_id1, 0..6), /* 6..9, */ (commit_id2, 9..15)],
899                &ContentDiff::by_line(["1a\n1b\n0a\n2a\n2b\n", "1a\n2a\n2b\n"])
900            ),
901            hashmap! {}
902        );
903        // delete middle line from second range, delete masked line (ambiguous)
904        assert_eq!(
905            split_file_hunks(
906                &[(commit_id1, 0..6), /* 6..9, */ (commit_id2, 9..15)],
907                &ContentDiff::by_line(["1a\n1b\n0a\n2a\n2b\n", "1a\n1b\n2b\n"])
908            ),
909            hashmap! {}
910        );
911        // delete middle lines from both ranges, delete masked line (ambiguous)
912        assert_eq!(
913            split_file_hunks(
914                &[(commit_id1, 0..6), /* 6..9, */ (commit_id2, 9..15)],
915                &ContentDiff::by_line(["1a\n1b\n0a\n2a\n2b\n", "1a\n2b\n"])
916            ),
917            hashmap! {}
918        );
919    }
920
921    #[test]
922    fn test_split_file_hunks_non_contiguous_ranges_modify() {
923        let commit_id1 = &CommitId::from_hex("111111");
924        let commit_id2 = &CommitId::from_hex("222222");
925
926        // modify middle line of first range
927        assert_eq!(
928            split_file_hunks(
929                &[(commit_id1, 0..6), /* 6..9, */ (commit_id2, 9..15)],
930                &ContentDiff::by_line(["1a\n1b\n0a\n2a\n2b\n", "1a\n1B\n0a\n2a\n2b\n"])
931            ),
932            hashmap! { commit_id1 => vec![(3..6, 3..6)] }
933        );
934        // modify middle line of second range
935        assert_eq!(
936            split_file_hunks(
937                &[(commit_id1, 0..6), /* 6..9, */ (commit_id2, 9..15)],
938                &ContentDiff::by_line(["1a\n1b\n0a\n2a\n2b\n", "1a\n1b\n0a\n2A\n2b\n"])
939            ),
940            hashmap! { commit_id2 => vec![(9..12, 9..12)] }
941        );
942        // modify middle lines of both ranges
943        assert_eq!(
944            split_file_hunks(
945                &[(commit_id1, 0..6), /* 6..9, */ (commit_id2, 9..15)],
946                &ContentDiff::by_line(["1a\n1b\n0a\n2a\n2b\n", "1a\n1B\n0a\n2A\n2b\n"])
947            ),
948            hashmap! {
949                commit_id1 => vec![(3..6, 3..6)],
950                commit_id2 => vec![(9..12, 9..12)],
951            }
952        );
953    }
954
955    #[test]
956    fn test_split_file_hunks_non_contiguous_ranges_modify_modify_masked() {
957        let commit_id1 = &CommitId::from_hex("111111");
958        let commit_id2 = &CommitId::from_hex("222222");
959
960        // modify middle line of first range, modify masked line (ambiguous)
961        assert_eq!(
962            split_file_hunks(
963                &[(commit_id1, 0..6), /* 6..9, */ (commit_id2, 9..15)],
964                &ContentDiff::by_line(["1a\n1b\n0a\n2a\n2b\n", "1a\n1B\n0A\n2a\n2b\n"])
965            ),
966            hashmap! {}
967        );
968        // modify middle line of second range, modify masked line (ambiguous)
969        assert_eq!(
970            split_file_hunks(
971                &[(commit_id1, 0..6), /* 6..9, */ (commit_id2, 9..15)],
972                &ContentDiff::by_line(["1a\n1b\n0a\n2a\n2b\n", "1a\n1b\n0A\n2A\n2b\n"])
973            ),
974            hashmap! {}
975        );
976        // modify middle lines to both ranges, modify masked line (ambiguous)
977        assert_eq!(
978            split_file_hunks(
979                &[(commit_id1, 0..6), /* 6..9, */ (commit_id2, 9..15)],
980                &ContentDiff::by_line(["1a\n1b\n0a\n2a\n2b\n", "1a\n1B\n0A\n2A\n2b\n"])
981            ),
982            hashmap! {}
983        );
984    }
985
986    #[test]
987    fn test_split_file_hunks_non_contiguous_tail_range_insert() {
988        let commit_id1 = &CommitId::from_hex("111111");
989
990        // insert middle line to range
991        assert_eq!(
992            split_file_hunks(
993                &[(commit_id1, 0..6) /* , 6..9 */],
994                &ContentDiff::by_line(["1a\n1b\n0a\n", "1a\n1b\n1X\n0a\n"])
995            ),
996            hashmap! { commit_id1 => vec![(6..6, 6..9)] }
997        );
998    }
999
1000    #[test]
1001    fn test_split_file_hunks_non_contiguous_tail_range_insert_modify_masked() {
1002        let commit_id1 = &CommitId::from_hex("111111");
1003
1004        // insert middle line to range, modify masked line (ambiguous)
1005        assert_eq!(
1006            split_file_hunks(
1007                &[(commit_id1, 0..6) /* , 6..9 */],
1008                &ContentDiff::by_line(["1a\n1b\n0a\n", "1a\n1b\n1X\n0A\n"])
1009            ),
1010            hashmap! {}
1011        );
1012    }
1013
1014    #[test]
1015    fn test_split_file_hunks_non_contiguous_tail_range_delete() {
1016        let commit_id1 = &CommitId::from_hex("111111");
1017
1018        // delete middle line from range
1019        assert_eq!(
1020            split_file_hunks(
1021                &[(commit_id1, 0..6) /* , 6..9 */],
1022                &ContentDiff::by_line(["1a\n1b\n0a\n", "1a\n0a\n"])
1023            ),
1024            hashmap! { commit_id1 => vec![(3..6, 3..3)] }
1025        );
1026        // delete all lines from range
1027        assert_eq!(
1028            split_file_hunks(
1029                &[(commit_id1, 0..6) /* , 6..9 */],
1030                &ContentDiff::by_line(["1a\n1b\n0a\n", "0a\n"])
1031            ),
1032            hashmap! { commit_id1 => vec![(0..6, 0..0)] }
1033        );
1034    }
1035
1036    #[test]
1037    fn test_split_file_hunks_non_contiguous_tail_range_delete_modify_masked() {
1038        let commit_id1 = &CommitId::from_hex("111111");
1039
1040        // delete middle line from range, modify masked line (ambiguous)
1041        assert_eq!(
1042            split_file_hunks(
1043                &[(commit_id1, 0..6) /* , 6..9 */],
1044                &ContentDiff::by_line(["1a\n1b\n0a\n", "1a\n0A\n"])
1045            ),
1046            hashmap! {}
1047        );
1048        // delete all lines from range, modify masked line (ambiguous)
1049        assert_eq!(
1050            split_file_hunks(
1051                &[(commit_id1, 0..6) /* , 6..9 */],
1052                &ContentDiff::by_line(["1a\n1b\n0a\n", "0A\n"])
1053            ),
1054            hashmap! {}
1055        );
1056    }
1057
1058    #[test]
1059    fn test_split_file_hunks_non_contiguous_tail_range_delete_delete_masked() {
1060        let commit_id1 = &CommitId::from_hex("111111");
1061
1062        // 'hg absorb' accepts these, but it seems better to reject them as
1063        // ambiguous. Masked lines cannot be deleted.
1064
1065        // delete middle line from range, delete masked line (ambiguous)
1066        assert_eq!(
1067            split_file_hunks(
1068                &[(commit_id1, 0..6) /* , 6..9 */],
1069                &ContentDiff::by_line(["1a\n1b\n0a\n", "1a\n"])
1070            ),
1071            hashmap! {}
1072        );
1073        // delete all lines from range, delete masked line (ambiguous)
1074        assert_eq!(
1075            split_file_hunks(
1076                &[(commit_id1, 0..6) /* , 6..9 */],
1077                &ContentDiff::by_line(["1a\n1b\n0a\n", ""])
1078            ),
1079            hashmap! {}
1080        );
1081    }
1082
1083    #[test]
1084    fn test_split_file_hunks_non_contiguous_tail_range_modify() {
1085        let commit_id1 = &CommitId::from_hex("111111");
1086
1087        // modify middle line of range
1088        assert_eq!(
1089            split_file_hunks(
1090                &[(commit_id1, 0..6) /* , 6..9 */],
1091                &ContentDiff::by_line(["1a\n1b\n0a\n", "1a\n1B\n0a\n"])
1092            ),
1093            hashmap! { commit_id1 => vec![(3..6, 3..6)] }
1094        );
1095    }
1096
1097    #[test]
1098    fn test_split_file_hunks_non_contiguous_tail_range_modify_modify_masked() {
1099        let commit_id1 = &CommitId::from_hex("111111");
1100
1101        // modify middle line of range, modify masked line (ambiguous)
1102        assert_eq!(
1103            split_file_hunks(
1104                &[(commit_id1, 0..6) /* , 6..9 */],
1105                &ContentDiff::by_line(["1a\n1b\n0a\n", "1a\n1B\n0A\n"])
1106            ),
1107            hashmap! {}
1108        );
1109    }
1110
1111    #[test]
1112    fn test_split_file_hunks_multiple_edits() {
1113        let commit_id1 = &CommitId::from_hex("111111");
1114        let commit_id2 = &CommitId::from_hex("222222");
1115        let commit_id3 = &CommitId::from_hex("333333");
1116
1117        assert_eq!(
1118            split_file_hunks(
1119                &[
1120                    (commit_id1, 0..3),   // 1a       => 1A
1121                    (commit_id2, 3..6),   // 2a       => 2a
1122                    (commit_id1, 6..15),  // 1b 1c 1d => 1B 1d
1123                    (commit_id3, 15..21), // 3a 3b    => 3X 3A 3b 3Y
1124                ],
1125                &ContentDiff::by_line([
1126                    "1a\n2a\n1b\n1c\n1d\n3a\n3b\n",
1127                    "1A\n2a\n1B\n1d\n3X\n3A\n3b\n3Y\n"
1128                ])
1129            ),
1130            hashmap! {
1131                commit_id1 => vec![(0..3, 0..3), (6..12, 6..9)],
1132                commit_id3 => vec![(15..18, 12..18), (21..21, 21..24)],
1133            }
1134        );
1135    }
1136
1137    #[test]
1138    fn test_combine_texts() {
1139        assert_eq!(combine_texts(b"", b"", &[]), "");
1140        assert_eq!(combine_texts(b"foo", b"bar", &[]), "foo");
1141        assert_eq!(combine_texts(b"foo", b"bar", &[(0..3, 0..3)]), "bar");
1142
1143        assert_eq!(
1144            combine_texts(
1145                b"1a\n2a\n1b\n1c\n1d\n3a\n3b\n",
1146                b"1A\n2a\n1B\n1d\n3X\n3A\n3b\n3Y\n",
1147                &[(0..3, 0..3), (6..12, 6..9)]
1148            ),
1149            "1A\n2a\n1B\n1d\n3a\n3b\n"
1150        );
1151        assert_eq!(
1152            combine_texts(
1153                b"1a\n2a\n1b\n1c\n1d\n3a\n3b\n",
1154                b"1A\n2a\n1B\n1d\n3X\n3A\n3b\n3Y\n",
1155                &[(15..18, 12..18), (21..21, 21..24)]
1156            ),
1157            "1a\n2a\n1b\n1c\n1d\n3X\n3A\n3b\n3Y\n"
1158        );
1159    }
1160}