Jump to…
snowinitial commitqoxwzsukwmkx1mo
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
12use 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)]
21pub 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)]
36pub struct SyntheticId(String);
37
38impl SyntheticId {
39 pub fn as_str(&self) -> &str {
40 &self.0
41 }
42}
43
44impl 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`.
51fn 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.
62pub 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.
90pub 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`.
112fn 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)]
123mod 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"\
172diff --git a/f.txt b/f.txt
173index 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"\
182diff --git a/f.txt b/f.txt
183index 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