Jump to…
snowinitial commitqoxwzsukwmkx1mo
1//! Patch identity for commits authored by plain git (spec §4).
2//!
3//! "Commits without a change ID still need identity so that `git push` users
4//! are not second-class. Synthesize a stable identity from the commit's
5//! *patch* rather than its SHA — a patch-id style digest over the diff plus the
6//! author date — so that a rebase of the same work continues to map to the
7//! same change."
8//!
9//! The serialisation below is the contract: two commits produce the same
10//! identity exactly when this byte stream matches. It deliberately excludes
11//! everything a rebase changes — blob OIDs, hunk offsets, parent ids, the
12//! commit id, and the committer.
13
14use df_store::{ChangeKind, Diff};
15
16/// Render a diff into a canonical byte form for hashing.
17///
18/// Files are sorted by path so that a change in tree-walk order cannot alter
19/// the identity. Hunk line numbers are omitted entirely — only the sequence of
20/// added and removed content matters, which is what makes the digest survive a
21/// rebase that shifts everything down by ten lines.
22pub fn canonical_patch(diff: &Diff) -> Vec<u8> {
23 let mut files: Vec<&df_store::FileDiff> = diff.files.iter().collect();
24 files.sort_by(|a, b| a.path.cmp(&b.path));
25
26 let mut out = Vec::new();
27 for f in files {
28 out.extend_from_slice(b"--- file ");
29 out.extend_from_slice(f.path.as_bytes());
30 out.push(b'\n');
31
32 // A rename with no content change must still be a distinct patch from
33 // no change at all, so the kind is part of the identity.
34 out.extend_from_slice(match f.kind {
35 ChangeKind::Added => b"kind added\n".as_slice(),
36 ChangeKind::Deleted => b"kind deleted\n".as_slice(),
37 ChangeKind::Modified => b"kind modified\n".as_slice(),
38 ChangeKind::Renamed => b"kind renamed\n".as_slice(),
39 });
40 if let Some(old) = &f.old_path {
41 out.extend_from_slice(b"from ");
42 out.extend_from_slice(old.as_bytes());
43 out.push(b'\n');
44 }
45
46 if f.binary {
47 // Binary content is not diffed; record only that it changed, so the
48 // identity stays stable without reading megabytes.
49 out.extend_from_slice(b"binary\n");
50 continue;
51 }
52
53 for h in &f.hunks {
54 // NOTE: no @@ header. Hunk offsets move under a rebase while the
55 // work stays the same, and including them would defeat the purpose.
56 for l in &h.lines {
57 match l.kind {
58 df_store::DiffLineKind::Added => out.push(b'+'),
59 df_store::DiffLineKind::Deleted => out.push(b'-'),
60 // Context lines shift with surrounding edits, so they are
61 // excluded: only the actual change identifies the patch.
62 df_store::DiffLineKind::Context => continue,
63 }
64 out.extend_from_slice(l.content.as_bytes());
65 out.push(b'\n');
66 }
67 }
68 }
69 out
70}
71
72#[cfg(test)]
73mod tests {
74 use super::*;
75 use df_store::{DiffLine, DiffLineKind, FileDiff, Hunk};
76
77 fn line(kind: DiffLineKind, content: &str) -> DiffLine {
78 DiffLine {
79 kind,
80 old_lineno: None,
81 new_lineno: None,
82 content: content.into(),
83 // The canonical patch is line-based; the intra-line spans are a
84 // rendering concern and must not affect the synthetic identity.
85 // Populated here anyway so the fixture matches what the store
86 // actually produces.
87 spans: vec![df_store::DiffSpan { text: content.into(), emphasis: false }],
88 }
89 }
90
91 fn file(path: &str, lines: Vec<DiffLine>) -> FileDiff {
92 FileDiff {
93 path: path.into(),
94 old_path: None,
95 kind: ChangeKind::Modified,
96 binary: false,
97 additions: 0,
98 deletions: 0,
99 hunks: vec![Hunk {
100 old_start: 1,
101 old_lines: 1,
102 new_start: 1,
103 new_lines: 1,
104 lines,
105 }],
106 }
107 }
108
109 fn diff(files: Vec<FileDiff>) -> Diff {
110 Diff {
111 files,
112 truncated: false,
113 total_additions: 0,
114 total_deletions: 0,
115 }
116 }
117
118 #[test]
119 fn identical_patches_serialise_identically() {
120 let a = diff(vec![file("a.txt", vec![line(DiffLineKind::Added, "hello")])]);
121 let b = diff(vec![file("a.txt", vec![line(DiffLineKind::Added, "hello")])]);
122 assert_eq!(canonical_patch(&a), canonical_patch(&b));
123 }
124
125 #[test]
126 fn context_lines_do_not_affect_identity() {
127 // The property that makes this survive a rebase: surrounding code moved
128 // or changed, but the edit itself is the same.
129 let a = diff(vec![file(
130 "a.txt",
131 vec![
132 line(DiffLineKind::Context, "fn main() {"),
133 line(DiffLineKind::Added, "hello"),
134 ],
135 )]);
136 let b = diff(vec![file(
137 "a.txt",
138 vec![
139 line(DiffLineKind::Context, "fn completely_different() {"),
140 line(DiffLineKind::Added, "hello"),
141 ],
142 )]);
143 assert_eq!(canonical_patch(&a), canonical_patch(&b));
144 }
145
146 #[test]
147 fn file_order_does_not_affect_identity() {
148 let a = diff(vec![
149 file("a.txt", vec![line(DiffLineKind::Added, "x")]),
150 file("b.txt", vec![line(DiffLineKind::Added, "y")]),
151 ]);
152 let b = diff(vec![
153 file("b.txt", vec![line(DiffLineKind::Added, "y")]),
154 file("a.txt", vec![line(DiffLineKind::Added, "x")]),
155 ]);
156 assert_eq!(canonical_patch(&a), canonical_patch(&b));
157 }
158
159 #[test]
160 fn different_content_differs() {
161 let a = diff(vec![file("a.txt", vec![line(DiffLineKind::Added, "hello")])]);
162 let b = diff(vec![file("a.txt", vec![line(DiffLineKind::Added, "world")])]);
163 assert_ne!(canonical_patch(&a), canonical_patch(&b));
164 }
165
166 #[test]
167 fn an_addition_differs_from_a_deletion_of_the_same_text() {
168 let a = diff(vec![file("a.txt", vec![line(DiffLineKind::Added, "x")])]);
169 let b = diff(vec![file("a.txt", vec![line(DiffLineKind::Deleted, "x")])]);
170 assert_ne!(canonical_patch(&a), canonical_patch(&b));
171 }
172
173 #[test]
174 fn the_same_edit_in_a_different_file_differs() {
175 let a = diff(vec![file("a.txt", vec![line(DiffLineKind::Added, "x")])]);
176 let b = diff(vec![file("b.txt", vec![line(DiffLineKind::Added, "x")])]);
177 assert_ne!(canonical_patch(&a), canonical_patch(&b));
178 }
179
180 #[test]
181 fn a_rename_is_distinguishable_from_a_modification() {
182 let mut renamed = file("new.txt", vec![]);
183 renamed.kind = ChangeKind::Renamed;
184 renamed.old_path = Some("old.txt".into());
185
186 let modified = file("new.txt", vec![]);
187
188 assert_ne!(
189 canonical_patch(&diff(vec![renamed])),
190 canonical_patch(&diff(vec![modified]))
191 );
192 }
193
194 #[test]
195 fn binary_files_are_recorded_without_their_content() {
196 let mut f = file("image.png", vec![]);
197 f.binary = true;
198 let bytes = canonical_patch(&diff(vec![f]));
199 let text = String::from_utf8_lossy(&bytes);
200 assert!(text.contains("binary"));
201 assert!(text.contains("image.png"));
202 }
203
204 #[test]
205 fn an_empty_diff_yields_an_empty_patch() {
206 assert!(canonical_patch(&diff(vec![])).is_empty());
207 }
208}

208 lines · Rust