Skip to main content

grit_lib/diff/
hunk_id.rs

1//! Stable identifiers for structured diff hunks (patch selection in later steps).
2//!
3//! Ids hash side, path, and hunk line bodies (not line numbers) so they stay stable
4//! when earlier hunks are staged and later hunks shift in the file.
5
6use std::collections::{HashMap, HashSet};
7use std::fmt;
8use std::str::FromStr;
9
10use thiserror::Error;
11
12use super::structured::{StructuredHunk, StructuredLine, StructuredLineKind};
13use crate::diff::structured::{
14    hash_hunk_body_from_unified_slice, hunk_body_digest_from_unified_slice,
15};
16use crate::diff::unified_hunk_header_counts;
17use crate::hash::ChecksumHasher;
18use crate::objects::HashAlgo;
19
20const HUNK_ID_HEX_LEN: usize = 12;
21
22/// Which tree/index/worktree sides a hunk was produced from.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
24pub enum DiffSide {
25    /// Index compared to worktree (unstaged changes).
26    Unstaged,
27    /// `HEAD` tree compared to index (staged changes).
28    Staged,
29    /// Commit, tree, or other comparisons (ids are computed but not selectable yet).
30    Other,
31}
32
33impl DiffSide {
34    fn tag_byte(self) -> u8 {
35        match self {
36            Self::Unstaged => 0,
37            Self::Staged => 1,
38            Self::Other => 2,
39        }
40    }
41}
42
43/// Twelve lowercase hex characters identifying one structured hunk within a side and path.
44#[derive(Debug, Clone, PartialEq, Eq, Hash)]
45pub struct HunkId([u8; HUNK_ID_HEX_LEN / 2]);
46
47/// Parse or format failure for [`HunkId`].
48#[derive(Debug, Error, PartialEq, Eq)]
49pub enum HunkIdError {
50    /// Wrong length or non-hex character.
51    #[error("hunk id must be {HUNK_ID_HEX_LEN} lowercase hex characters")]
52    Invalid,
53}
54
55impl HunkId {
56    fn from_digest(digest: &[u8]) -> Self {
57        let mut raw = [0u8; HUNK_ID_HEX_LEN / 2];
58        raw.copy_from_slice(&digest[..HUNK_ID_HEX_LEN / 2]);
59        Self(raw)
60    }
61
62    /// Construct from exactly six raw digest bytes (used in tests).
63    #[must_use]
64    pub fn from_bytes(bytes: [u8; HUNK_ID_HEX_LEN / 2]) -> Self {
65        Self(bytes)
66    }
67
68    /// View as lowercase hex (twelve characters).
69    #[must_use]
70    pub fn as_str(&self) -> String {
71        self.to_string()
72    }
73}
74
75impl fmt::Display for HunkId {
76    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
77        for b in self.0 {
78            write!(f, "{b:02x}")?;
79        }
80        Ok(())
81    }
82}
83
84impl FromStr for HunkId {
85    type Err = HunkIdError;
86
87    fn from_str(s: &str) -> Result<Self, Self::Err> {
88        if s.len() != HUNK_ID_HEX_LEN
89            || !s
90                .bytes()
91                .all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(&c))
92        {
93            return Err(HunkIdError::Invalid);
94        }
95        let mut raw = [0u8; HUNK_ID_HEX_LEN / 2];
96        for (i, chunk) in s.as_bytes().chunks(2).enumerate() {
97            let hi = hex_nibble(chunk[0]).ok_or(HunkIdError::Invalid)?;
98            let lo = hex_nibble(chunk[1]).ok_or(HunkIdError::Invalid)?;
99            raw[i] = (hi << 4) | lo;
100        }
101        Ok(Self(raw))
102    }
103}
104
105fn hex_nibble(b: u8) -> Option<u8> {
106    match b {
107        b'0'..=b'9' => Some(b - b'0'),
108        b'a'..=b'f' => Some(b - b'a' + 10),
109        _ => None,
110    }
111}
112
113fn hash_hunk_id(
114    side: DiffSide,
115    path: &str,
116    hunk: &StructuredHunk,
117    duplicate_rank: Option<u32>,
118) -> HunkId {
119    let mut hasher = HashAlgo::Sha1.checksum_hasher();
120    hasher.update(&[side.tag_byte()]);
121    hasher.update(path.as_bytes());
122    hasher.update(&[0]);
123    hash_hunk_body(&mut hasher, hunk);
124    if let Some(rank) = duplicate_rank {
125        hasher.update(&rank.to_le_bytes());
126    }
127    let digest = hasher.finalize();
128    HunkId::from_digest(&digest)
129}
130
131#[derive(Eq, PartialEq, Ord, PartialOrd)]
132struct DuplicateSortKey {
133    old_start: usize,
134    new_start: usize,
135    is_peer: bool,
136    index: usize,
137}
138
139fn duplicate_ranks(hunks: &[StructuredHunk], peer_hunks: &[StructuredHunk]) -> Vec<Option<u32>> {
140    let own_digests: Vec<Vec<u8>> = hunks.iter().map(hunk_body_digest).collect();
141    let peer_digests: Vec<Vec<u8>> = peer_hunks.iter().map(hunk_body_digest).collect();
142    let body_counts = body_count_map_from_digests(&own_digests, &peer_digests);
143    own_digests
144        .iter()
145        .enumerate()
146        .map(|(i, body)| {
147            duplicate_rank_for_body(i, body, hunks, peer_hunks, body_counts.get(body).copied())
148        })
149        .collect()
150}
151
152fn duplicate_rank_for_body(
153    index: usize,
154    body: &[u8],
155    hunks: &[StructuredHunk],
156    peer_hunks: &[StructuredHunk],
157    body_count: Option<u32>,
158) -> Option<u32> {
159    if body_count.unwrap_or(0) <= 1 {
160        return None;
161    }
162    let mut keys: Vec<DuplicateSortKey> = Vec::new();
163    for (j, x) in hunks.iter().enumerate() {
164        if hunk_body_digest(x) == body {
165            keys.push(DuplicateSortKey {
166                old_start: x.old_start,
167                new_start: x.new_start,
168                is_peer: false,
169                index: j,
170            });
171        }
172    }
173    for (j, x) in peer_hunks.iter().enumerate() {
174        if hunk_body_digest(x) == body {
175            keys.push(DuplicateSortKey {
176                old_start: x.old_start,
177                new_start: x.new_start,
178                is_peer: true,
179                index: j,
180            });
181        }
182    }
183    keys.sort();
184    keys.iter()
185        .position(|k| !k.is_peer && k.index == index)
186        .map(|p| p as u32)
187}
188
189fn unified_slice_starts(slice: &str) -> (usize, usize) {
190    slice
191        .split('\n')
192        .next()
193        .and_then(unified_hunk_header_counts)
194        .map(|(o, _, n, _)| (o, n))
195        .unwrap_or((0, 0))
196}
197
198#[allow(clippy::too_many_arguments)]
199fn duplicate_ranks_unified_slices(
200    slices: &[&str],
201    before_count: usize,
202    after_count: usize,
203    before_ends_nl: bool,
204    after_ends_nl: bool,
205    peer_hunks: &[StructuredHunk],
206) -> Vec<Option<u32>> {
207    let bodies: Vec<Option<Vec<u8>>> = slices
208        .iter()
209        .map(|slice| {
210            hunk_body_digest_from_unified_slice(
211                slice,
212                before_count,
213                after_count,
214                before_ends_nl,
215                after_ends_nl,
216            )
217        })
218        .collect();
219    let mut body_counts: std::collections::HashMap<Vec<u8>, u32> = std::collections::HashMap::new();
220    for body in bodies.iter().flatten() {
221        *body_counts.entry(body.clone()).or_insert(0) += 1;
222    }
223    for h in peer_hunks {
224        let body = hunk_body_digest(h);
225        *body_counts.entry(body).or_insert(0) += 1;
226    }
227    bodies
228        .iter()
229        .enumerate()
230        .map(|(i, body)| {
231            let body = body.as_ref()?;
232            if body_counts.get(body).copied().unwrap_or(0) <= 1 {
233                return None;
234            }
235            let mut keys: Vec<DuplicateSortKey> = Vec::new();
236            for (j, other) in slices.iter().enumerate() {
237                let Some(other_body) = hunk_body_digest_from_unified_slice(
238                    other,
239                    before_count,
240                    after_count,
241                    before_ends_nl,
242                    after_ends_nl,
243                ) else {
244                    continue;
245                };
246                if &other_body != body {
247                    continue;
248                }
249                let (o, n) = unified_slice_starts(other);
250                keys.push(DuplicateSortKey {
251                    old_start: o,
252                    new_start: n,
253                    is_peer: false,
254                    index: j,
255                });
256            }
257            for (j, x) in peer_hunks.iter().enumerate() {
258                if hunk_body_digest(x) == *body {
259                    keys.push(DuplicateSortKey {
260                        old_start: peer_hunks[j].old_start,
261                        new_start: peer_hunks[j].new_start,
262                        is_peer: true,
263                        index: j,
264                    });
265                }
266            }
267            keys.sort();
268            keys.iter()
269                .position(|k| !k.is_peer && k.index == i)
270                .map(|p| p as u32)
271        })
272        .collect()
273}
274
275#[allow(clippy::too_many_arguments)]
276fn hash_hunk_id_from_unified_slice(
277    side: DiffSide,
278    path: &str,
279    slice: &str,
280    before_count: usize,
281    after_count: usize,
282    before_ends_nl: bool,
283    after_ends_nl: bool,
284    duplicate_rank: Option<u32>,
285) -> Option<HunkId> {
286    let mut hasher = HashAlgo::Sha1.checksum_hasher();
287    hasher.update(&[side.tag_byte()]);
288    hasher.update(path.as_bytes());
289    hasher.update(&[0]);
290    if !hash_hunk_body_from_unified_slice(
291        &mut hasher,
292        slice,
293        before_count,
294        after_count,
295        before_ends_nl,
296        after_ends_nl,
297    ) {
298        return None;
299    }
300    if let Some(rank) = duplicate_rank {
301        hasher.update(&rank.to_le_bytes());
302    }
303    let digest = hasher.finalize();
304    Some(HunkId::from_digest(&digest))
305}
306
307fn body_count_map_from_digests(own: &[Vec<u8>], peer: &[Vec<u8>]) -> HashMap<Vec<u8>, u32> {
308    let mut counts: HashMap<Vec<u8>, u32> = HashMap::new();
309    for body in own.iter().chain(peer.iter()) {
310        *counts.entry(body.clone()).or_insert(0) += 1;
311    }
312    counts
313}
314
315fn hash_hunk_body(hasher: &mut ChecksumHasher, hunk: &StructuredHunk) {
316    for line in &hunk.lines {
317        hash_line(hasher, line);
318    }
319}
320
321fn hash_line(hasher: &mut ChecksumHasher, line: &StructuredLine) {
322    let kind = match line.kind {
323        StructuredLineKind::Context => b' ',
324        StructuredLineKind::Add => b'+',
325        StructuredLineKind::Del => b'-',
326    };
327    hasher.update(&[kind]);
328    for seg in &line.segments {
329        hasher.update(seg.text.as_bytes());
330    }
331    hasher.update(&[if line.no_newline_at_eof { 1 } else { 0 }]);
332}
333
334fn hunk_body_digest(hunk: &StructuredHunk) -> Vec<u8> {
335    let mut hasher = HashAlgo::Sha1.checksum_hasher();
336    hash_hunk_body(&mut hasher, hunk);
337    hasher.finalize()
338}
339
340/// Stable ids for `hunks` in file order (0-based index matches vector position).
341#[must_use]
342pub fn hunk_ids(side: DiffSide, path: &str, hunks: &[StructuredHunk]) -> Vec<HunkId> {
343    hunk_ids_with_duplicate_peers(side, path, hunks, &[])
344}
345
346/// True when a diff entry's displayed or old path is listed in the primary change set.
347///
348/// Used to limit opposite-side peer loading to paths that can affect duplicate hunk ids.
349#[must_use]
350pub fn peer_scope_includes_path(
351    display_path: &str,
352    old_path: Option<&str>,
353    primary: &HashSet<&str>,
354) -> bool {
355    if primary.contains(display_path) {
356        return true;
357    }
358    if let Some(old) = old_path {
359        if old != display_path && primary.contains(old) {
360            return true;
361        }
362    }
363    false
364}
365
366/// Keep structured peer hunks only for paths present in the primary side.
367#[must_use]
368pub fn peer_hunks_by_primary_path(
369    primary: &HashSet<&str>,
370    peer_by_path: impl IntoIterator<Item = (String, Vec<StructuredHunk>)>,
371) -> HashMap<String, Vec<StructuredHunk>> {
372    peer_by_path
373        .into_iter()
374        .filter(|(path, _)| primary.contains(path.as_str()))
375        .collect()
376}
377
378/// Like [`hunk_ids`], but `peer_hunks` on the same path (typically the other index/worktree
379/// side) participate in duplicate-body detection so ids stay stable when an earlier identical
380/// hunk moves to the staged side.
381#[must_use]
382pub fn hunk_ids_with_duplicate_peers(
383    side: DiffSide,
384    path: &str,
385    hunks: &[StructuredHunk],
386    peer_hunks: &[StructuredHunk],
387) -> Vec<HunkId> {
388    let ranks = duplicate_ranks(hunks, peer_hunks);
389    hunks
390        .iter()
391        .zip(ranks)
392        .map(|(h, rank)| hash_hunk_id(side, path, h, rank))
393        .collect()
394}
395
396/// Stable ids for unified hunk slices (`@@` …) in the same order as [`super::core::imara_unified_hunk_slices`].
397#[must_use]
398#[allow(clippy::too_many_arguments)]
399pub(crate) fn hunk_ids_for_unified_slices(
400    side: DiffSide,
401    path: &str,
402    slices: &[&str],
403    before_count: usize,
404    after_count: usize,
405    before_ends_nl: bool,
406    after_ends_nl: bool,
407    peer_hunks: &[StructuredHunk],
408) -> Vec<HunkId> {
409    let ranks = duplicate_ranks_unified_slices(
410        slices,
411        before_count,
412        after_count,
413        before_ends_nl,
414        after_ends_nl,
415        peer_hunks,
416    );
417    slices
418        .iter()
419        .zip(ranks)
420        .filter_map(|(slice, rank)| {
421            hash_hunk_id_from_unified_slice(
422                side,
423                path,
424                slice,
425                before_count,
426                after_count,
427                before_ends_nl,
428                after_ends_nl,
429                rank,
430            )
431        })
432        .collect()
433}
434
435#[cfg(test)]
436mod tests {
437    use super::*;
438    use crate::diff::structured::{StructuredLineKind, StructuredSegment};
439
440    fn line(kind: StructuredLineKind, text: &str) -> StructuredLine {
441        StructuredLine {
442            kind,
443            old: None,
444            new: None,
445            segments: vec![StructuredSegment {
446                text: text.to_owned(),
447                emphasis: false,
448            }],
449            no_newline_at_eof: false,
450        }
451    }
452
453    fn hunk(lines: Vec<StructuredLine>) -> StructuredHunk {
454        StructuredHunk {
455            old_start: 1,
456            new_start: 1,
457            old_lines: lines.len(),
458            new_lines: lines.len(),
459            context: None,
460            lines,
461        }
462    }
463
464    #[test]
465    fn hunk_id_stable_across_line_shift() {
466        let body = vec![
467            line(StructuredLineKind::Context, "ctx"),
468            line(StructuredLineKind::Add, "new"),
469        ];
470        let mut a = hunk(body.clone());
471        a.old_start = 10;
472        a.new_start = 10;
473        let mut b = hunk(body);
474        b.old_start = 20;
475        b.new_start = 22;
476        let ids_a = hunk_ids(DiffSide::Unstaged, "file.txt", &[a]);
477        let ids_b = hunk_ids(DiffSide::Unstaged, "file.txt", &[b]);
478        assert_eq!(ids_a, ids_b);
479    }
480
481    #[test]
482    fn hunk_id_changes_on_content_change() {
483        let a = hunk(vec![line(StructuredLineKind::Add, "one")]);
484        let b = hunk(vec![line(StructuredLineKind::Add, "two")]);
485        let ids_a = hunk_ids(DiffSide::Unstaged, "f", &[a]);
486        let ids_b = hunk_ids(DiffSide::Unstaged, "f", &[b]);
487        assert_ne!(ids_a[0], ids_b[0]);
488    }
489
490    #[test]
491    fn hunk_id_differs_by_side_and_path() {
492        let h = hunk(vec![line(StructuredLineKind::Add, "x")]);
493        let unstaged = hunk_ids(DiffSide::Unstaged, "a", &[h.clone()]);
494        let staged = hunk_ids(DiffSide::Staged, "a", &[h.clone()]);
495        let other_path = hunk_ids(DiffSide::Unstaged, "b", &[h]);
496        assert_ne!(unstaged[0], staged[0]);
497        assert_ne!(unstaged[0], other_path[0]);
498    }
499
500    #[test]
501    fn hunk_id_duplicate_hunks_disambiguated() {
502        let body = vec![line(StructuredLineKind::Add, "dup")];
503        let mut a = hunk(body.clone());
504        a.old_start = 1;
505        a.new_start = 1;
506        let mut b = hunk(body);
507        b.old_start = 5;
508        b.new_start = 5;
509        let ids = hunk_ids(DiffSide::Staged, "x", &[a, b]);
510        assert_ne!(ids[0], ids[1]);
511    }
512
513    #[test]
514    fn hunk_id_fromstr_rejects_uppercase() {
515        assert_eq!(HunkId::from_str("ABCDEF123456"), Err(HunkIdError::Invalid));
516    }
517
518    #[test]
519    fn hunk_id_stable_when_earlier_duplicate_on_peer_side() {
520        let body = vec![
521            line(StructuredLineKind::Context, "ctx"),
522            line(StructuredLineKind::Add, "same"),
523        ];
524        let mut first = hunk(body.clone());
525        first.old_start = 1;
526        first.new_start = 1;
527        let mut second = hunk(body);
528        second.old_start = 10;
529        second.new_start = 10;
530
531        let both = hunk_ids(DiffSide::Unstaged, "f", &[first.clone(), second.clone()]);
532        assert_ne!(both[0], both[1]);
533
534        let with_peer =
535            hunk_ids_with_duplicate_peers(DiffSide::Unstaged, "f", &[second.clone()], &[first]);
536        assert_eq!(with_peer[0], both[1]);
537    }
538
539    #[test]
540    fn hunk_id_stable_when_insertion_coordinates_shift_after_staging() {
541        let body = vec![line(StructuredLineKind::Add, "insert")];
542        let mut first = hunk(body.clone());
543        first.old_start = 19;
544        first.new_start = 19;
545        let mut second = hunk(body);
546        second.old_start = 19;
547        second.new_start = 20;
548
549        let both = hunk_ids(DiffSide::Unstaged, "f", &[first.clone(), second.clone()]);
550        assert_ne!(both[0], both[1]);
551        let id_later = both[1].clone();
552
553        let mut shifted = second;
554        shifted.old_start = 20;
555        shifted.new_start = 20;
556
557        let after = hunk_ids_with_duplicate_peers(DiffSide::Unstaged, "f", &[shifted], &[first]);
558        assert_eq!(after[0], id_later);
559    }
560
561    #[test]
562    fn hunk_ids_unified_slices_match_structured_hunks() {
563        use crate::diff::imara_unified_hunk_slices;
564        use crate::diff::unified_hunk_body_for_diff_options;
565        use crate::diff::DiffOptions;
566
567        let old = "a\nb\nc\n";
568        let new = "a\nB\nc\nd\n";
569        let opts = DiffOptions::default();
570        let body = unified_hunk_body_for_diff_options(old, new, &opts);
571        let slices: Vec<&str> = imara_unified_hunk_slices(&body);
572        let before_count = 3;
573        let after_count = 4;
574        let from_slices = hunk_ids_for_unified_slices(
575            DiffSide::Unstaged,
576            "f.txt",
577            &slices,
578            before_count,
579            after_count,
580            true,
581            true,
582            &[],
583        );
584        let structured: Vec<StructuredHunk> = slices
585            .iter()
586            .filter_map(|slice| {
587                crate::diff::structured::structured_hunk_for_id_from_unified_slice(
588                    slice,
589                    before_count,
590                    after_count,
591                    true,
592                    true,
593                )
594            })
595            .collect();
596        let from_structured = hunk_ids(DiffSide::Unstaged, "f.txt", &structured);
597        assert_eq!(from_slices, from_structured);
598    }
599
600    #[test]
601    fn hunk_id_roundtrips_display_fromstr() {
602        let id = hunk_ids(
603            DiffSide::Other,
604            "p",
605            &[hunk(vec![line(StructuredLineKind::Del, "z")])],
606        )[0]
607        .clone();
608        let s = id.to_string();
609        assert_eq!(s.len(), HUNK_ID_HEX_LEN);
610        assert!(
611            s.bytes()
612                .all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(&c)),
613            "expected lowercase hex: {s}"
614        );
615        assert_eq!(HunkId::from_str(&s).expect("parse"), id);
616    }
617}