Jump to…
snowinitial commitqoxwzsukwmkx1mo
Matt W1//! Synthetic change identity for commits that carry no jj change id.
Matt W2//!
Matt W3//! Spec §4: commits arriving from a plain `git push` still need identity, so
Matt W4//! that `git` users are not second-class. The identity is derived from the
Matt W5//! commit's *patch* rather than its SHA, so that rebasing the same work
Matt W6//! continues to map to the same change — which is the whole point, since a
Matt W7//! rebase changes the commit id but not the work.
Matt W8//!
Matt W9//! These ids are marked `synthetic: true` in the database and rendered without
Matt W10//! a change chip. Dogfood never presents one as a real jj change id.
Matt W11
Matt W12use sha2::{Digest, Sha256};
Matt W13
Matt W14/// Inputs to a synthetic identity.
Matt W15///
Matt W16/// Deliberately excludes the commit id, the tree id, the parent ids, and the
Matt W17/// committer — all of which change under a rebase. It also excludes the commit
Matt W18/// message, so that amending a typo in the description does not orphan the
Matt W19/// review.
Matt W20#[derive(Debug, Clone)]
Matt W21pub struct PatchIdentity<'a> {
Matt W22 /// The diff of this commit against its first parent, normalised.
Matt W23 pub diff: &'a [u8],
Matt W24 /// Author email — stable across rebase, unlike the committer.
Matt W25 pub author_email: &'a str,
Matt W26 /// Author date as a Unix timestamp. Git preserves this across a rebase;
Matt W27 /// the committer date does not survive.
Matt W28 pub author_date: i64,
Matt W29}
Matt W30
Matt W31/// A synthetic change identity, rendered in the same 32-character reverse-hex
Matt W32/// alphabet as a real jj change id so that column widths and URL shapes match.
Matt W33///
Matt W34/// It is *not* a jj change id and is never displayed as one.
Matt W35#[derive(Debug, Clone, PartialEq, Eq, Hash)]
Matt W36pub struct SyntheticId(String);
Matt W37
Matt W38impl SyntheticId {
Matt W39 pub fn as_str(&self) -> &str {
Matt W40 &self.0
Matt W41 }
Matt W42}
Matt W43
Matt W44impl std::fmt::Display for SyntheticId {
Matt W45 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
Matt W46 f.write_str(&self.0)
Matt W47 }
Matt W48}
Matt W49
Matt W50/// Map a nibble to jj's reverse-hex alphabet: `0..=15` -> `z..=k`.
Matt W51fn reverse_hex_nibble(n: u8) -> u8 {
Matt W52 debug_assert!(n < 16);
Matt W53 b'z' - n
Matt W54}
Matt W55
Matt W56/// Compute a stable synthetic identity for a plain-git commit.
Matt W57///
Matt W58/// Two commits with the same patch, author, and author date produce the same
Matt W59/// id regardless of their commit ids or position in history — so a rebase, a
Matt W60/// cherry-pick onto an updated base, or a re-push after an unrelated upstream
Matt W61/// change all continue to resolve to the same Dogfood change.
Matt W62pub fn synthetic_change_id(p: &PatchIdentity<'_>) -> SyntheticId {
Matt W63 let mut h = Sha256::new();
Matt W64 // Domain separation: this hash must never collide with any other digest
Matt W65 // Dogfood computes, and the version tag lets the scheme change later
Matt W66 // without silently re-identifying every existing synthetic change.
Matt W67 h.update(b"dogfood/synthetic-change-id/v1\0");
Matt W68 h.update(p.author_email.as_bytes());
Matt W69 h.update(b"\0");
Matt W70 h.update(p.author_date.to_be_bytes());
Matt W71 h.update(b"\0");
Matt W72 h.update(p.diff);
Matt W73
Matt W74 let digest = h.finalize();
Matt W75 // Take 16 bytes, matching a real change id's width.
Matt W76 let mut s = String::with_capacity(32);
Matt W77 for byte in digest.iter().take(16) {
Matt W78 s.push(reverse_hex_nibble(byte >> 4) as char);
Matt W79 s.push(reverse_hex_nibble(byte & 0x0f) as char);
Matt W80 }
Matt W81 SyntheticId(s)
Matt W82}
Matt W83
Matt W84/// Normalise a diff before hashing.
Matt W85///
Matt W86/// Strips the parts that change under rebase without the work changing: the
Matt W87/// `index <old>..<new>` lines carrying blob OIDs, and hunk header line numbers
Matt W88/// (`@@ -a,b +c,d @@`), whose offsets shift when unrelated earlier hunks move.
Matt W89/// The hunk's trailing context label is kept, since it is part of the content.
Matt W90pub fn normalise_diff(raw: &[u8]) -> Vec<u8> {
Matt W91 let mut out = Vec::with_capacity(raw.len());
Matt W92 for line in raw.split(|&b| b == b'\n') {
Matt W93 if line.starts_with(b"index ") {
Matt W94 continue;
Matt W95 }
Matt W96 if line.starts_with(b"@@") {
Matt W97 // Keep the marker and any trailing context, drop the line numbers.
Matt W98 if let Some(end) = find(line, b"@@", 2) {
Matt W99 out.extend_from_slice(b"@@");
Matt W100 out.extend_from_slice(&line[end + 2..]);
Matt W101 out.push(b'\n');
Matt W102 }
Matt W103 continue;
Matt W104 }
Matt W105 out.extend_from_slice(line);
Matt W106 out.push(b'\n');
Matt W107 }
Matt W108 out
Matt W109}
Matt W110
Matt W111/// Find `needle` in `haystack` starting at `from`.
Matt W112fn find(haystack: &[u8], needle: &[u8], from: usize) -> Option<usize> {
Matt W113 if from >= haystack.len() {
Matt W114 return None;
Matt W115 }
Matt W116 haystack[from..]
Matt W117 .windows(needle.len())
Matt W118 .position(|w| w == needle)
Matt W119 .map(|i| i + from)
Matt W120}
Matt W121
Matt W122#[cfg(test)]
Matt W123mod tests {
Matt W124 use super::*;
Matt W125
Matt W126 fn id_for(diff: &str, email: &str, date: i64) -> String {
Matt W127 synthetic_change_id(&PatchIdentity {
Matt W128 diff: diff.as_bytes(),
Matt W129 author_email: email,
Matt W130 author_date: date,
Matt W131 })
Matt W132 .as_str()
Matt W133 .to_owned()
Matt W134 }
Matt W135
Matt W136 #[test]
Matt W137 fn is_deterministic() {
Matt W138 assert_eq!(id_for("a", "x@y.z", 100), id_for("a", "x@y.z", 100));
Matt W139 }
Matt W140
Matt W141 #[test]
Matt W142 fn uses_the_reverse_hex_alphabet_and_correct_width() {
Matt W143 let id = id_for("a", "x@y.z", 100);
Matt W144 assert_eq!(id.len(), 32);
Matt W145 assert!(
Matt W146 id.bytes().all(|b| (b'k'..=b'z').contains(&b)),
Matt W147 "synthetic ids must share the change-id alphabet, got {id}"
Matt W148 );
Matt W149 }
Matt W150
Matt W151 #[test]
Matt W152 fn differs_on_different_patches() {
Matt W153 assert_ne!(id_for("a", "x@y.z", 100), id_for("b", "x@y.z", 100));
Matt W154 }
Matt W155
Matt W156 #[test]
Matt W157 fn differs_on_different_authors() {
Matt W158 assert_ne!(id_for("a", "x@y.z", 100), id_for("a", "q@y.z", 100));
Matt W159 }
Matt W160
Matt W161 #[test]
Matt W162 fn differs_on_different_author_dates() {
Matt W163 assert_ne!(id_for("a", "x@y.z", 100), id_for("a", "x@y.z", 101));
Matt W164 }
Matt W165
Matt W166 #[test]
Matt W167 fn survives_a_rebase() {
Matt W168 // The behaviour that justifies patch-based identity: the same work
Matt W169 // rebased onto a new base has different blob OIDs and different hunk
Matt W170 // offsets, but must keep its identity.
Matt W171 let before = b"\
Matt W172diff --git a/f.txt b/f.txt
Matt W173index 1111111..2222222 100644
Matt W174--- a/f.txt
Matt W175+++ b/f.txt
Matt W176@@ -10,3 +10,4 @@ fn main() {
Matt W177 context
Matt W178-old
Matt W179+new
Matt W180";
Matt W181 let after = b"\
Matt W182diff --git a/f.txt b/f.txt
Matt W183index 3333333..4444444 100644
Matt W184--- a/f.txt
Matt W185+++ b/f.txt
Matt W186@@ -42,3 +42,4 @@ fn main() {
Matt W187 context
Matt W188-old
Matt W189+new
Matt W190";
Matt W191 let a = synthetic_change_id(&PatchIdentity {
Matt W192 diff: &normalise_diff(before),
Matt W193 author_email: "x@y.z",
Matt W194 author_date: 100,
Matt W195 });
Matt W196 let b = synthetic_change_id(&PatchIdentity {
Matt W197 diff: &normalise_diff(after),
Matt W198 author_email: "x@y.z",
Matt W199 author_date: 100,
Matt W200 });
Matt W201 assert_eq!(a, b, "a rebase must not change the synthetic identity");
Matt W202 }
Matt W203
Matt W204 #[test]
Matt W205 fn normalisation_drops_index_and_hunk_offsets_only() {
Matt W206 let out = normalise_diff(b"index abc..def 100644\n@@ -1,2 +3,4 @@ fn main()\n-a\n+b\n");
Matt W207 let s = String::from_utf8(out).unwrap();
Matt W208 assert!(!s.contains("index abc"), "index line must be dropped");
Matt W209 assert!(s.contains("@@ fn main()"), "hunk context must be kept: {s:?}");
Matt W210 assert!(!s.contains("-1,2"), "hunk offsets must be dropped: {s:?}");
Matt W211 assert!(s.contains("-a\n+b"), "content must be kept");
Matt W212 }
Matt W213
Matt W214 #[test]
Matt W215 fn genuinely_different_work_still_differs_after_normalisation() {
Matt W216 let a = normalise_diff(b"@@ -1,1 +1,1 @@\n-a\n+b\n");
Matt W217 let b = normalise_diff(b"@@ -1,1 +1,1 @@\n-a\n+c\n");
Matt W218 assert_ne!(a, b);
Matt W219 }
Matt W220}

220 lines · Rust