| 1 | //! Synthetic change identity for commits that carry no jj change id. | |
| 2 | //! | |
| 3 | //! Spec §4: commits arriving from a plain `git push` still need identity, so | |
| 4 | //! that `git` users are not second-class. The identity is derived from the | |
| 5 | //! commit's *patch* rather than its SHA, so that rebasing the same work | |
| 6 | //! continues to map to the same change — which is the whole point, since a | |
| 7 | //! rebase changes the commit id but not the work. | |
| 8 | //! | |
| 9 | //! These ids are marked `synthetic: true` in the database and rendered without | |
| 10 | //! a change chip. Dogfood never presents one as a real jj change id. | |
| 11 | ||
| 12 | use sha2::{Digest, Sha256}; | |
| 13 | ||
| 14 | /// Inputs to a synthetic identity. | |
| 15 | /// | |
| 16 | /// Deliberately excludes the commit id, the tree id, the parent ids, and the | |
| 17 | /// committer — all of which change under a rebase. It also excludes the commit | |
| 18 | /// message, so that amending a typo in the description does not orphan the | |
| 19 | /// review. | |
| 20 | #[derive(Debug, Clone)] | |
| 21 | pub struct PatchIdentity<'a> { | |
| 22 | /// The diff of this commit against its first parent, normalised. | |
| 23 | pub diff: &'a [u8], | |
| 24 | /// Author email — stable across rebase, unlike the committer. | |
| 25 | pub author_email: &'a str, | |
| 26 | /// Author date as a Unix timestamp. Git preserves this across a rebase; | |
| 27 | /// the committer date does not survive. | |
| 28 | pub author_date: i64, | |
| 29 | } | |
| 30 | ||
| 31 | /// A synthetic change identity, rendered in the same 32-character reverse-hex | |
| 32 | /// alphabet as a real jj change id so that column widths and URL shapes match. | |
| 33 | /// | |
| 34 | /// It is *not* a jj change id and is never displayed as one. | |
| 35 | #[derive(Debug, Clone, PartialEq, Eq, Hash)] | |
| 36 | pub struct SyntheticId(String); | |
| 37 | ||
| 38 | impl SyntheticId { | |
| 39 | pub fn as_str(&self) -> &str { | |
| 40 | &self.0 | |
| 41 | } | |
| 42 | } | |
| 43 | ||
| 44 | impl std::fmt::Display for SyntheticId { | |
| 45 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | |
| 46 | f.write_str(&self.0) | |
| 47 | } | |
| 48 | } | |
| 49 | ||
| 50 | /// Map a nibble to jj's reverse-hex alphabet: `0..=15` -> `z..=k`. | |
| 51 | fn reverse_hex_nibble(n: u8) -> u8 { | |
| 52 | debug_assert!(n < 16); | |
| 53 | b'z' - n | |
| 54 | } | |
| 55 | ||
| 56 | /// Compute a stable synthetic identity for a plain-git commit. | |
| 57 | /// | |
| 58 | /// Two commits with the same patch, author, and author date produce the same | |
| 59 | /// id regardless of their commit ids or position in history — so a rebase, a | |
| 60 | /// cherry-pick onto an updated base, or a re-push after an unrelated upstream | |
| 61 | /// change all continue to resolve to the same Dogfood change. | |
| 62 | pub fn synthetic_change_id(p: &PatchIdentity<'_>) -> SyntheticId { | |
| 63 | let mut h = Sha256::new(); | |
| 64 | // Domain separation: this hash must never collide with any other digest | |
| 65 | // Dogfood computes, and the version tag lets the scheme change later | |
| 66 | // without silently re-identifying every existing synthetic change. | |
| 67 | h.update(b"dogfood/synthetic-change-id/v1\0"); | |
| 68 | h.update(p.author_email.as_bytes()); | |
| 69 | h.update(b"\0"); | |
| 70 | h.update(p.author_date.to_be_bytes()); | |
| 71 | h.update(b"\0"); | |
| 72 | h.update(p.diff); | |
| 73 | ||
| 74 | let digest = h.finalize(); | |
| 75 | // Take 16 bytes, matching a real change id's width. | |
| 76 | let mut s = String::with_capacity(32); | |
| 77 | for byte in digest.iter().take(16) { | |
| 78 | s.push(reverse_hex_nibble(byte >> 4) as char); | |
| 79 | s.push(reverse_hex_nibble(byte & 0x0f) as char); | |
| 80 | } | |
| 81 | SyntheticId(s) | |
| 82 | } | |
| 83 | ||
| 84 | /// Normalise a diff before hashing. | |
| 85 | /// | |
| 86 | /// Strips the parts that change under rebase without the work changing: the | |
| 87 | /// `index <old>..<new>` lines carrying blob OIDs, and hunk header line numbers | |
| 88 | /// (`@@ -a,b +c,d @@`), whose offsets shift when unrelated earlier hunks move. | |
| 89 | /// The hunk's trailing context label is kept, since it is part of the content. | |
| 90 | pub fn normalise_diff(raw: &[u8]) -> Vec<u8> { | |
| 91 | let mut out = Vec::with_capacity(raw.len()); | |
| 92 | for line in raw.split(|&b| b == b'\n') { | |
| 93 | if line.starts_with(b"index ") { | |
| 94 | continue; | |
| 95 | } | |
| 96 | if line.starts_with(b"@@") { | |
| 97 | // Keep the marker and any trailing context, drop the line numbers. | |
| 98 | if let Some(end) = find(line, b"@@", 2) { | |
| 99 | out.extend_from_slice(b"@@"); | |
| 100 | out.extend_from_slice(&line[end + 2..]); | |
| 101 | out.push(b'\n'); | |
| 102 | } | |
| 103 | continue; | |
| 104 | } | |
| 105 | out.extend_from_slice(line); | |
| 106 | out.push(b'\n'); | |
| 107 | } | |
| 108 | out | |
| 109 | } | |
| 110 | ||
| 111 | /// Find `needle` in `haystack` starting at `from`. | |
| 112 | fn find(haystack: &[u8], needle: &[u8], from: usize) -> Option<usize> { | |
| 113 | if from >= haystack.len() { | |
| 114 | return None; | |
| 115 | } | |
| 116 | haystack[from..] | |
| 117 | .windows(needle.len()) | |
| 118 | .position(|w| w == needle) | |
| 119 | .map(|i| i + from) | |
| 120 | } | |
| 121 | ||
| 122 | #[cfg(test)] | |
| 123 | mod tests { | |
| 124 | use super::*; | |
| 125 | ||
| 126 | fn id_for(diff: &str, email: &str, date: i64) -> String { | |
| 127 | synthetic_change_id(&PatchIdentity { | |
| 128 | diff: diff.as_bytes(), | |
| 129 | author_email: email, | |
| 130 | author_date: date, | |
| 131 | }) | |
| 132 | .as_str() | |
| 133 | .to_owned() | |
| 134 | } | |
| 135 | ||
| 136 | #[test] | |
| 137 | fn is_deterministic() { | |
| 138 | assert_eq!(id_for("a", "x@y.z", 100), id_for("a", "x@y.z", 100)); | |
| 139 | } | |
| 140 | ||
| 141 | #[test] | |
| 142 | fn uses_the_reverse_hex_alphabet_and_correct_width() { | |
| 143 | let id = id_for("a", "x@y.z", 100); | |
| 144 | assert_eq!(id.len(), 32); | |
| 145 | assert!( | |
| 146 | id.bytes().all(|b| (b'k'..=b'z').contains(&b)), | |
| 147 | "synthetic ids must share the change-id alphabet, got {id}" | |
| 148 | ); | |
| 149 | } | |
| 150 | ||
| 151 | #[test] | |
| 152 | fn differs_on_different_patches() { | |
| 153 | assert_ne!(id_for("a", "x@y.z", 100), id_for("b", "x@y.z", 100)); | |
| 154 | } | |
| 155 | ||
| 156 | #[test] | |
| 157 | fn differs_on_different_authors() { | |
| 158 | assert_ne!(id_for("a", "x@y.z", 100), id_for("a", "q@y.z", 100)); | |
| 159 | } | |
| 160 | ||
| 161 | #[test] | |
| 162 | fn differs_on_different_author_dates() { | |
| 163 | assert_ne!(id_for("a", "x@y.z", 100), id_for("a", "x@y.z", 101)); | |
| 164 | } | |
| 165 | ||
| 166 | #[test] | |
| 167 | fn survives_a_rebase() { | |
| 168 | // The behaviour that justifies patch-based identity: the same work | |
| 169 | // rebased onto a new base has different blob OIDs and different hunk | |
| 170 | // offsets, but must keep its identity. | |
| 171 | let before = b"\ | |
| 172 | diff --git a/f.txt b/f.txt | |
| 173 | index 1111111..2222222 100644 | |
| 174 | --- a/f.txt | |
| 175 | +++ b/f.txt | |
| 176 | @@ -10,3 +10,4 @@ fn main() { | |
| 177 | context | |
| 178 | -old | |
| 179 | +new | |
| 180 | "; | |
| 181 | let after = b"\ | |
| 182 | diff --git a/f.txt b/f.txt | |
| 183 | index 3333333..4444444 100644 | |
| 184 | --- a/f.txt | |
| 185 | +++ b/f.txt | |
| 186 | @@ -42,3 +42,4 @@ fn main() { | |
| 187 | context | |
| 188 | -old | |
| 189 | +new | |
| 190 | "; | |
| 191 | let a = synthetic_change_id(&PatchIdentity { | |
| 192 | diff: &normalise_diff(before), | |
| 193 | author_email: "x@y.z", | |
| 194 | author_date: 100, | |
| 195 | }); | |
| 196 | let b = synthetic_change_id(&PatchIdentity { | |
| 197 | diff: &normalise_diff(after), | |
| 198 | author_email: "x@y.z", | |
| 199 | author_date: 100, | |
| 200 | }); | |
| 201 | assert_eq!(a, b, "a rebase must not change the synthetic identity"); | |
| 202 | } | |
| 203 | ||
| 204 | #[test] | |
| 205 | fn normalisation_drops_index_and_hunk_offsets_only() { | |
| 206 | let out = normalise_diff(b"index abc..def 100644\n@@ -1,2 +3,4 @@ fn main()\n-a\n+b\n"); | |
| 207 | let s = String::from_utf8(out).unwrap(); | |
| 208 | assert!(!s.contains("index abc"), "index line must be dropped"); | |
| 209 | assert!(s.contains("@@ fn main()"), "hunk context must be kept: {s:?}"); | |
| 210 | assert!(!s.contains("-1,2"), "hunk offsets must be dropped: {s:?}"); | |
| 211 | assert!(s.contains("-a\n+b"), "content must be kept"); | |
| 212 | } | |
| 213 | ||
| 214 | #[test] | |
| 215 | fn genuinely_different_work_still_differs_after_normalisation() { | |
| 216 | let a = normalise_diff(b"@@ -1,1 +1,1 @@\n-a\n+b\n"); | |
| 217 | let b = normalise_diff(b"@@ -1,1 +1,1 @@\n-a\n+c\n"); | |
| 218 | assert_ne!(a, b); | |
| 219 | } | |
| 220 | } |
220 lines · Rust