Monorepo for Tangled tangled.org
1.4k

Configure Feed

Select the types of activity you want to include in your feed.

knot2,appview: put binary payloads in patches

Lewis: May this revision serve well! <did:plc:3fwecdnvtcscjnrx2p4n7alz>

author oyster.cafe date (Aug 12, 2026, 5:19 PM EEST) commit ad9724b3 parent d266005b change-id oxnmpqsl
Verified
+918 -318
+1
types/repo.go
··· 39 39 FormatPatchRaw string `json:"patch,omitempty"` 40 40 CombinedPatch []*gitdiff.File `json:"combined_patch,omitempty"` 41 41 CombinedPatchRaw string `json:"combined_patch_raw,omitempty"` 42 + BinaryOmitted bool `json:"binary_omitted,omitempty"` 42 43 } 43 44 44 45 type TagReference struct {
+8
appview/pulls/create.go
··· 68 68 return 69 69 } 70 70 71 + if comparison.BinaryOmitted { 72 + l.Warn("knot left binary payloads out of the compare, so this patch won't apply cleanly", "knot", repo.Knot, "repo", repo.RepoIdentifier()) 73 + } 74 + 71 75 sourceRev := comparison.Rev2 72 76 patch := comparison.FormatPatchRaw 73 77 combined := comparison.CombinedPatchRaw ··· 178 182 if len(comparison.FormatPatch) == 0 { 179 183 s.pages.Notice(w, "pull", "No commits between target and source.") 180 184 return 185 + } 186 + 187 + if comparison.BinaryOmitted { 188 + l.Warn("knot left binary payloads out of the compare, so this patch won't apply cleanly", "knot", fork.Knot, "repo", fork.RepoIdentifier(), "hidden_ref", hiddenRef) 181 189 } 182 190 183 191 sourceRev := comparison.Rev2
+154
knot2/crates/knot-git/src/base85.rs
··· 1 + use std::sync::LazyLock; 2 + 3 + const MARKERS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; 4 + 5 + const BYTES_PER_LINE: usize = MARKERS.len(); 6 + 7 + const ALPHABET: &[u8] = 8 + b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz!#$%&()*+-;<=>?@^_`{|}~"; 9 + 10 + static DIGITS: LazyLock<[Option<u8>; 256]> = LazyLock::new(|| { 11 + std::array::from_fn(|byte| { 12 + ALPHABET 13 + .iter() 14 + .position(|&candidate| candidate as usize == byte) 15 + .map(|digit| digit as u8) 16 + }) 17 + }); 18 + 19 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 20 + pub(crate) struct Malformed; 21 + 22 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 23 + struct LineLength(u8); 24 + 25 + impl LineLength { 26 + fn of(bytes: usize) -> Option<Self> { 27 + bytes 28 + .checked_sub(1) 29 + .filter(|index| *index < MARKERS.len()) 30 + .map(|index| Self(index as u8)) 31 + } 32 + 33 + fn from_marker(marker: u8) -> Option<Self> { 34 + MARKERS 35 + .iter() 36 + .position(|&candidate| candidate == marker) 37 + .map(|index| Self(index as u8)) 38 + } 39 + 40 + fn marker(self) -> char { 41 + MARKERS[self.0 as usize] as char 42 + } 43 + 44 + fn get(self) -> usize { 45 + self.0 as usize + 1 46 + } 47 + } 48 + 49 + pub(crate) fn encode(packed: &[u8], out: &mut String) { 50 + out.reserve(encoded_len(packed.len() as u64) as usize); 51 + packed.chunks(BYTES_PER_LINE).for_each(|chunk| { 52 + let length = LineLength::of(chunk.len()) 53 + .expect("chunking by the line width yields 1..=BYTES_PER_LINE bytes"); 54 + out.push(length.marker()); 55 + chunk.chunks(4).for_each(|group| { 56 + let word = group 57 + .iter() 58 + .fold(0u32, |acc, &byte| (acc << 8) | byte as u32) 59 + << (8 * (4 - group.len())); 60 + (0..5).rev().for_each(|power| { 61 + let digit = (word / 85u32.pow(power)) % 85; 62 + out.push(ALPHABET[digit as usize] as char); 63 + }); 64 + }); 65 + out.push('\n'); 66 + }); 67 + } 68 + 69 + pub(crate) fn encoded_len(packed: u64) -> u64 { 70 + packed.div_ceil(4) * 5 + packed.div_ceil(BYTES_PER_LINE as u64) * 2 71 + } 72 + 73 + pub(crate) fn decode_line(line: &str, out: &mut Vec<u8>) -> Result<(), Malformed> { 74 + let (marker, data) = line.as_bytes().split_first().ok_or(Malformed)?; 75 + let length = LineLength::from_marker(*marker).ok_or(Malformed)?; 76 + if data.len() != length.get().div_ceil(4) * 5 { 77 + return Err(Malformed); 78 + } 79 + data.chunks(5).enumerate().try_for_each(|(group, chunk)| { 80 + let word = chunk 81 + .iter() 82 + .try_fold(0u64, |acc, &character| { 83 + DIGITS[character as usize].map(|digit| acc * 85 + digit as u64) 84 + }) 85 + .filter(|&word| word <= u32::MAX as u64) 86 + .ok_or(Malformed)?; 87 + let take = (length.get() - group * 4).min(4); 88 + out.extend_from_slice(&(word as u32).to_be_bytes()[..take]); 89 + Ok(()) 90 + }) 91 + } 92 + 93 + #[cfg(test)] 94 + mod tests { 95 + use super::*; 96 + 97 + #[test] 98 + fn encoding_round_trips_at_every_payload_length() { 99 + (0..=260usize).for_each(|len| { 100 + let bytes: Vec<u8> = (0..len) 101 + .map(|index| (index as u8).wrapping_mul(37) ^ 0x5a) 102 + .collect(); 103 + let mut encoded = String::new(); 104 + encode(&bytes, &mut encoded); 105 + assert_eq!( 106 + encoded_len(len as u64), 107 + encoded.len() as u64, 108 + "encoded length of a {len} byte payload" 109 + ); 110 + let decoded = encoded.lines().fold(Vec::new(), |mut out, line| { 111 + decode_line(line, &mut out).unwrap(); 112 + out 113 + }); 114 + assert_eq!(decoded, bytes, "round trip of a {len} byte payload"); 115 + }); 116 + } 117 + 118 + #[test] 119 + fn line_markers_map_both_ways() { 120 + (1..=BYTES_PER_LINE).for_each(|len| { 121 + let length = LineLength::of(len).unwrap(); 122 + assert_eq!( 123 + LineLength::from_marker(length.marker() as u8), 124 + Some(length), 125 + "marker for a line of {len} bytes" 126 + ); 127 + }); 128 + assert_eq!(LineLength::of(0), None); 129 + assert_eq!(LineLength::of(BYTES_PER_LINE + 1), None); 130 + assert_eq!(LineLength::of(1).unwrap().marker(), 'A'); 131 + assert_eq!(LineLength::of(26).unwrap().marker(), 'Z'); 132 + assert_eq!(LineLength::of(27).unwrap().marker(), 'a'); 133 + assert_eq!(LineLength::of(BYTES_PER_LINE).unwrap().marker(), 'z'); 134 + let mut encoded = String::new(); 135 + encode(&[0u8; BYTES_PER_LINE + 1], &mut encoded); 136 + assert_eq!( 137 + encoded.lines().map(|line| &line[..1]).collect::<Vec<_>>(), 138 + vec!["z", "A"], 139 + "a payload past the line width gets a second marker" 140 + ); 141 + } 142 + 143 + #[test] 144 + fn the_marker_sets_the_line_length_and_anything_else_is_malformed() { 145 + let mut out = Vec::new(); 146 + decode_line("D00000", &mut out).unwrap(); 147 + decode_line("B00000", &mut out).unwrap(); 148 + assert_eq!(out, vec![0, 0, 0, 0, 0, 0]); 149 + assert_eq!(decode_line("D0000", &mut Vec::new()), Err(Malformed)); 150 + assert_eq!(decode_line("D0\"000", &mut Vec::new()), Err(Malformed)); 151 + assert_eq!(decode_line("?00000", &mut Vec::new()), Err(Malformed)); 152 + assert_eq!(decode_line("", &mut Vec::new()), Err(Malformed)); 153 + } 154 + }
+4 -3
knot2/crates/knot-git/src/lib.rs
··· 1 1 mod archive; 2 + mod base85; 2 3 mod bitmap; 3 4 mod error; 4 5 #[cfg(feature = "instrument")] ··· 22 23 ShallowPlan, Tree, TreeDepth, TreeEntry, Wants, 23 24 }; 24 25 pub use patch::{ 25 - FilePatch, Hunk, HunkLine, LineCount, LineNumber, LineOp, MAX_DIFF_BLOB_BYTES, PatchRange, 26 - PatchStatus, 26 + BinaryBudget, BinaryDiff, BinarySizes, FilePatch, Hunk, HunkLine, LineCount, LineNumber, 27 + LineOp, MAX_DIFF_BLOB_BYTES, PatchBody, PatchRange, PatchStatus, 27 28 }; 28 29 pub use patch_apply::{ 29 30 ApplyError, ApplyOutcome, Conflict, ConflictReason, NewCommit, PatchApplier, StagedAction, ··· 31 32 }; 32 33 pub use patch_parse::{ 33 34 FileIntent, MailPatch, ParsedFile, PatchParseError, PatchPayload, is_format_patch, 34 - parse_mailbox, parse_mailbox_bounded, parse_patch, parse_patch_bounded, 35 + parse_mailbox, parse_mailbox_bounded, parse_patch, parse_patch_bounded, quote_path, 35 36 }; 36 37 pub use reads::{ 37 38 AnnotatedTag, BranchInfo, BranchTip, LastCommit, LogLimit, LogSkip, PathEntry, SizedEntry,
+231 -26
knot2/crates/knot-git/src/patch.rs
··· 1 1 use std::convert::Infallible; 2 + use std::fmt::Write as _; 3 + use std::io::Write; 2 4 use std::ops::ControlFlow; 3 5 6 + use flate2::Compression; 7 + use flate2::write::ZlibEncoder; 4 8 use gix::diff::blob::unified_diff::{ConsumeHunk, ContextSize, DiffLineKind, HunkHeader}; 5 9 use gix::diff::blob::{Algorithm, Diff, InternedInput, UnifiedDiff}; 6 10 use knot_types::{ChangedFiles, ChangedFilesBudget, Listing, Oid, RepoPath}; 7 11 12 + use crate::base85; 8 13 use crate::error::{GitError, backend}; 9 14 use crate::objects::EntryKind; 10 15 use crate::repo::Repo; 11 16 12 17 const BINARY_SNIFF_BYTES: usize = 8000; 18 + const BLOCK_HEADER_MAX: usize = "literal 18446744073709551615\n".len(); 19 + const BINARY_PATCH_HEADER: &str = "GIT binary patch\n"; 13 20 pub const MAX_DIFF_BLOB_BYTES: u64 = 25 * 1024 * 1024; 14 21 15 22 #[derive(Debug, Clone, Copy, PartialEq, Eq)] ··· 74 81 Modified, 75 82 } 76 83 84 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 85 + pub struct BinarySizes { 86 + pub old: u64, 87 + pub new: u64, 88 + } 89 + 90 + impl BinarySizes { 91 + fn of(old: &[u8], new: &[u8]) -> Self { 92 + Self { 93 + old: old.len() as u64, 94 + new: new.len() as u64, 95 + } 96 + } 97 + 98 + fn wire_bound(self) -> u64 { 99 + let block = |inflated: u64| { 100 + let deflated = inflated 101 + .saturating_add(inflated.div_ceil(8)) 102 + .saturating_add(inflated.div_ceil(64)) 103 + .saturating_add(11); 104 + base85::encoded_len(deflated).saturating_add(BLOCK_HEADER_MAX as u64 + 1) 105 + }; 106 + block(self.old) 107 + .saturating_add(block(self.new)) 108 + .saturating_add(BINARY_PATCH_HEADER.len() as u64) 109 + } 110 + } 111 + 112 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 113 + pub enum BinaryBudget { 114 + Omit, 115 + Spend { remaining: u64, omitted: bool }, 116 + } 117 + 118 + impl BinaryBudget { 119 + pub fn new(bytes: u64) -> Self { 120 + Self::Spend { 121 + remaining: bytes, 122 + omitted: false, 123 + } 124 + } 125 + 126 + pub fn omitted(self) -> bool { 127 + matches!(self, Self::Spend { omitted: true, .. }) 128 + } 129 + 130 + fn admit(&mut self, sizes: BinarySizes) -> bool { 131 + match self { 132 + Self::Omit => false, 133 + Self::Spend { remaining, omitted } => match remaining.checked_sub(sizes.wire_bound()) { 134 + Some(rest) => { 135 + *remaining = rest; 136 + true 137 + } 138 + None => { 139 + *omitted = true; 140 + false 141 + } 142 + }, 143 + } 144 + } 145 + } 146 + 147 + fn literal_block(content: &[u8], out: &mut String) -> Result<(), GitError> { 148 + let mut encoder = ZlibEncoder::new(Vec::new(), Compression::fast()); 149 + encoder.write_all(content).map_err(backend)?; 150 + writeln!(out, "literal {}", content.len()).expect("formatting into a String never fails"); 151 + base85::encode(&encoder.finish().map_err(backend)?, out); 152 + out.push('\n'); 153 + Ok(()) 154 + } 155 + 156 + fn encode_binary(old: &[u8], new: &[u8]) -> Result<BinaryDiff, GitError> { 157 + let mut text = String::from(BINARY_PATCH_HEADER); 158 + literal_block(new, &mut text)?; 159 + literal_block(old, &mut text)?; 160 + Ok(BinaryDiff::Encoded { 161 + sizes: BinarySizes::of(old, new), 162 + text, 163 + }) 164 + } 165 + 166 + #[derive(Debug, Clone, PartialEq, Eq)] 167 + pub enum BinaryDiff { 168 + Encoded { sizes: BinarySizes, text: String }, 169 + Omitted(BinarySizes), 170 + Unchanged(u64), 171 + } 172 + 173 + impl BinaryDiff { 174 + pub fn sizes(&self) -> BinarySizes { 175 + match self { 176 + Self::Encoded { sizes, .. } | Self::Omitted(sizes) => *sizes, 177 + Self::Unchanged(bytes) => BinarySizes { 178 + old: *bytes, 179 + new: *bytes, 180 + }, 181 + } 182 + } 183 + } 184 + 185 + #[derive(Debug, Clone, PartialEq, Eq)] 186 + pub enum PatchBody { 187 + Text(Vec<Hunk>), 188 + Binary(BinaryDiff), 189 + } 190 + 77 191 #[derive(Debug, Clone, PartialEq, Eq)] 78 192 pub struct FilePatch { 79 193 pub status: PatchStatus, ··· 82 196 pub new_oid: Oid, 83 197 pub old_kind: Option<EntryKind>, 84 198 pub new_kind: Option<EntryKind>, 85 - pub is_binary: bool, 86 - pub hunks: Vec<Hunk>, 199 + pub body: PatchBody, 200 + } 201 + 202 + impl FilePatch { 203 + pub fn is_binary(&self) -> bool { 204 + matches!(self.body, PatchBody::Binary(_)) 205 + } 206 + 207 + pub fn hunks(&self) -> &[Hunk] { 208 + match &self.body { 209 + PatchBody::Text(hunks) => hunks, 210 + PatchBody::Binary(_) => &[], 211 + } 212 + } 87 213 } 88 214 89 215 fn is_binary(content: &[u8]) -> bool { ··· 169 295 } 170 296 } 171 297 298 + fn subproject_line(oid: Oid) -> Vec<u8> { 299 + format!("Subproject commit {}\n", oid.to_hex()).into_bytes() 300 + } 301 + 172 302 impl Repo { 173 303 fn patch_content(&self, side: &Side) -> Result<Vec<u8>, GitError> { 174 304 match side { 175 305 Side::Absent => Ok(Vec::new()), 176 306 Side::Present { oid, kind } => match kind { 177 - EntryKind::Commit => { 178 - Ok(format!("Subproject commit {}\n", oid.to_hex()).into_bytes()) 179 - } 307 + EntryKind::Commit => Ok(subproject_line(*oid)), 180 308 EntryKind::Tree => Ok(Vec::new()), 181 309 _ => self.read_blob(*oid), 182 310 }, 183 311 } 184 312 } 185 313 186 - fn side_within_diff_budget(&self, side: &Side) -> Result<bool, GitError> { 187 - match side { 314 + fn sides_past_diff_budget( 315 + &self, 316 + old: &Side, 317 + new: &Side, 318 + ) -> Result<Option<BinarySizes>, GitError> { 319 + let size = |side: &Side| match side { 320 + Side::Absent 321 + | Side::Present { 322 + kind: EntryKind::Tree, 323 + .. 324 + } => Ok(None), 188 325 Side::Present { 189 326 oid, 190 - kind: EntryKind::Blob | EntryKind::BlobExecutable | EntryKind::Link, 191 - } => Ok(self.blob_size(*oid)? <= MAX_DIFF_BLOB_BYTES), 192 - _ => Ok(true), 193 - } 327 + kind: EntryKind::Commit, 328 + } => Ok(Some(subproject_line(*oid).len() as u64)), 329 + Side::Present { oid, .. } => self.blob_size(*oid).map(Some), 330 + }; 331 + let (old, new) = (size(old)?, size(new)?); 332 + let past = |bytes: Option<u64>| bytes.is_some_and(|bytes| bytes > MAX_DIFF_BLOB_BYTES); 333 + Ok((past(old) || past(new)).then(|| BinarySizes { 334 + old: old.unwrap_or(0), 335 + new: new.unwrap_or(0), 336 + })) 194 337 } 195 338 196 339 fn file_patch( ··· 199 342 path: RepoPath, 200 343 old: Side, 201 344 new: Side, 345 + budget: &mut BinaryBudget, 202 346 ) -> Result<FilePatch, GitError> { 203 - let within_budget = 204 - self.side_within_diff_budget(&old)? && self.side_within_diff_budget(&new)?; 205 - let (binary, hunks) = match within_budget { 206 - false => (true, Vec::new()), 207 - true => { 347 + let same_content = matches!( 348 + (&old, &new), 349 + (Side::Present { oid: before, .. }, Side::Present { oid: after, .. }) 350 + if before == after 351 + ); 352 + let body = match self.sides_past_diff_budget(&old, &new)? { 353 + Some(sizes) => PatchBody::Binary(BinaryDiff::Omitted(sizes)), 354 + None => { 208 355 let old_content = self.patch_content(&old)?; 209 356 let new_content = self.patch_content(&new)?; 210 - let binary = is_binary(&old_content) || is_binary(&new_content); 211 - let hunks = match binary { 212 - true => Vec::new(), 213 - false => text_hunks(&old_content, &new_content)?, 214 - }; 215 - (binary, hunks) 357 + match is_binary(&old_content) || is_binary(&new_content) { 358 + true => { 359 + let sizes = BinarySizes::of(&old_content, &new_content); 360 + PatchBody::Binary(match same_content { 361 + true => BinaryDiff::Unchanged(sizes.new), 362 + false => match budget.admit(sizes) { 363 + true => encode_binary(&old_content, &new_content)?, 364 + false => BinaryDiff::Omitted(sizes), 365 + }, 366 + }) 367 + } 368 + false => PatchBody::Text(text_hunks(&old_content, &new_content)?), 369 + } 216 370 } 217 371 }; 218 372 Ok(FilePatch { ··· 222 376 new_oid: new.oid(self.object_format().null_oid()), 223 377 old_kind: old.kind(), 224 378 new_kind: new.kind(), 225 - is_binary: binary, 226 - hunks, 379 + body, 227 380 }) 228 381 } 229 382 ··· 296 449 } 297 450 } 298 451 299 - pub fn commit_patches(&self, range: PatchRange) -> Result<Vec<FilePatch>, GitError> { 452 + pub fn commit_patches( 453 + &self, 454 + range: PatchRange, 455 + budget: &mut BinaryBudget, 456 + ) -> Result<Vec<FilePatch>, GitError> { 300 457 let (old_tree, new_tree) = self.diff_trees(range)?; 301 458 let mut sides: Vec<(PatchStatus, String, Side, Side)> = Vec::new(); 302 459 old_tree ··· 383 540 .map(|(status, path, old, new)| { 384 541 let path = 385 542 RepoPath::new(path).map_err(|error| GitError::Decode(error.to_string()))?; 386 - self.file_patch(status, path, old, new) 543 + self.file_patch(status, path, old, new, budget) 387 544 }) 388 545 .collect() 546 + } 547 + } 548 + 549 + #[cfg(test)] 550 + mod tests { 551 + use super::*; 552 + 553 + #[test] 554 + fn the_wire_bound_covers_every_byte_the_patch_writes() { 555 + let payload = |len: usize, fill: fn(usize) -> u8| (0..len).map(fill).collect::<Vec<u8>>(); 556 + [0usize, 1, 3, 4, 51, 52, 53, 1000, 65_536] 557 + .into_iter() 558 + .flat_map(|len| { 559 + let noise = payload(len, |index| (index as u8).wrapping_mul(37) ^ 0x5a); 560 + [(payload(len, |_| 0), noise.clone()), (noise, Vec::new())] 561 + }) 562 + .for_each(|(old, new)| { 563 + let BinaryDiff::Encoded { sizes, text } = encode_binary(&old, &new).unwrap() else { 564 + panic!("encode_binary returns Encoded for every payload"); 565 + }; 566 + assert!( 567 + text.len() as u64 <= sizes.wire_bound(), 568 + "payload of {} bytes: expected at most {}, wrote {}", 569 + old.len().max(new.len()), 570 + sizes.wire_bound(), 571 + text.len() 572 + ); 573 + }); 574 + } 575 + 576 + #[test] 577 + fn the_budget_reports_the_first_payload_it_refuses() { 578 + let sizes = BinarySizes { old: 0, new: 4096 }; 579 + let mut budget = BinaryBudget::new(sizes.wire_bound()); 580 + assert!(budget.admit(sizes)); 581 + assert!( 582 + !budget.omitted(), 583 + "omitted is false while admit returns true" 584 + ); 585 + assert!(!budget.admit(sizes)); 586 + assert!(budget.omitted(), "omitted is true once admit returns false"); 587 + 588 + let mut omit = BinaryBudget::Omit; 589 + assert!(!omit.admit(sizes)); 590 + assert!( 591 + !omit.omitted(), 592 + "omitted is false under Omit, where admit always returns false" 593 + ); 389 594 } 390 595 }
+142 -62
knot2/crates/knot-git/src/patch_parse.rs
··· 1 1 use std::io::Read; 2 - use std::sync::LazyLock; 3 2 4 3 use base64::Engine; 5 4 use knot_types::{AuthorName, Email, Oid}; 6 5 6 + use crate::base85; 7 7 use crate::objects::{CommitChangeId, EntryKind}; 8 8 use crate::patch::{Hunk, HunkLine, LineCount, LineNumber, LineOp, MAX_DIFF_BLOB_BYTES}; 9 9 ··· 200 200 } 201 201 } 202 202 203 + const PRINTABLE_ASCII: std::ops::Range<u8> = 0x20..0x7f; 204 + 205 + fn needs_quoting(byte: u8) -> bool { 206 + !PRINTABLE_ASCII.contains(&byte) || matches!(byte, b'"' | b'\\') 207 + } 208 + 209 + pub fn quote_path(path: &str) -> String { 210 + match path.bytes().any(needs_quoting) { 211 + false => path.to_string(), 212 + true => { 213 + let mut quoted = path.bytes().fold(String::from("\""), |mut out, byte| { 214 + match byte { 215 + b'\n' => out.push_str("\\n"), 216 + b'\t' => out.push_str("\\t"), 217 + b'"' => out.push_str("\\\""), 218 + b'\\' => out.push_str("\\\\"), 219 + other if !PRINTABLE_ASCII.contains(&other) => { 220 + out.push_str(&format!("\\{other:03o}")) 221 + } 222 + other => out.push(other as char), 223 + } 224 + out 225 + }); 226 + quoted.push('"'); 227 + quoted 228 + } 229 + } 230 + } 231 + 203 232 fn strip_level(path: &str) -> String { 204 233 path.split_once('/') 205 234 .map(|(_, rest)| rest.to_string()) ··· 224 253 let (new, _) = take_path_token(after.strip_prefix(' ')?)?; 225 254 Some((strip_level(&old), strip_level(&new))) 226 255 } 227 - false => { 228 - let split = rest.rfind(" b/")?; 229 - let old = rest.get(..split)?.strip_prefix("a/")?; 230 - let new = rest.get(split + 3..)?; 231 - Some((old.to_string(), new.to_string())) 232 - } 256 + false => unquoted_diff_paths(rest), 233 257 } 258 + } 259 + 260 + fn unquoted_diff_paths(rest: &str) -> Option<(String, String)> { 261 + let split_at = |at: usize| { 262 + let old = rest.get(..at)?.strip_prefix("a/")?; 263 + let new = rest.get(at + " b/".len()..)?; 264 + Some((old, new)) 265 + }; 266 + rest.match_indices(" b/") 267 + .filter_map(|(at, _)| split_at(at)) 268 + .find(|(old, new)| old == new) 269 + .or_else(|| split_at(rest.rfind(" b/")?)) 270 + .map(|(old, new)| (old.to_string(), new.to_string())) 234 271 } 235 272 236 273 fn take_path_token(rest: &str) -> Option<(String, &str)> { ··· 248 285 } 249 286 250 287 fn full_oid(hex: &str) -> Option<Oid> { 251 - (hex.len() == 40).then(|| Oid::from_hex(hex).ok()).flatten() 288 + Oid::from_hex(hex).ok() 252 289 } 253 290 254 291 fn parse_hunk_header(line: &str) -> Option<(LineNumber, LineCount, LineNumber, LineCount)> { ··· 344 381 .collect() 345 382 } 346 383 347 - static BASE85: LazyLock<[i16; 256]> = LazyLock::new(|| { 348 - const ALPHABET: &[u8] = 349 - b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz!#$%&()*+-;<=>?@^_`{|}~"; 350 - std::array::from_fn(|byte| { 351 - ALPHABET 352 - .iter() 353 - .position(|&c| c as usize == byte) 354 - .map(|digit| digit as i16) 355 - .unwrap_or(-1) 356 - }) 357 - }); 358 - 359 - fn decode_base85_line(line: &str, out: &mut Vec<u8>) -> Result<(), PatchParseError> { 360 - let bad = || malformed("bad base85 line in binary patch"); 361 - let (len_char, data) = line.as_bytes().split_first().ok_or_else(bad)?; 362 - let line_len = match len_char { 363 - b'A'..=b'Z' => (len_char - b'A' + 1) as usize, 364 - b'a'..=b'z' => (len_char - b'a' + 27) as usize, 365 - _ => return Err(bad()), 366 - }; 367 - if data.len() != line_len.div_ceil(4) * 5 { 368 - return Err(bad()); 369 - } 370 - data.chunks(5) 371 - .enumerate() 372 - .try_for_each(|(group, chunk)| -> Result<(), PatchParseError> { 373 - let acc = chunk 374 - .iter() 375 - .try_fold(0u64, |acc, &c| { 376 - let digit = BASE85[c as usize]; 377 - (digit >= 0).then(|| acc * 85 + digit as u64) 378 - }) 379 - .filter(|&acc| acc <= u32::MAX as u64) 380 - .ok_or_else(bad)?; 381 - let take = (line_len - group * 4).min(4); 382 - out.extend_from_slice(&(acc as u32).to_be_bytes()[..take]); 383 - Ok(()) 384 - }) 385 - } 386 - 387 384 fn parse_binary_block( 388 385 cursor: &mut Cursor<'_>, 389 386 budget: &mut Budget, ··· 414 411 .is_some_and(|line| !line.is_empty()) 415 412 .then(|| cursor.next().expect("peeked line is present")) 416 413 }) 417 - .try_for_each(|line| decode_base85_line(line, &mut packed))?; 414 + .try_for_each(|line| { 415 + base85::decode_line(line, &mut packed) 416 + .map_err(|_| malformed("invalid base85 line in binary patch")) 417 + })?; 418 418 cursor.next(); 419 419 let mut inflated: Vec<u8> = Vec::new(); 420 420 flate2::read::ZlibDecoder::new(packed.as_slice()) ··· 971 971 } 972 972 973 973 #[test] 974 + fn a_plain_path_stays_bare_and_a_quoted_one_unquotes_back() { 975 + [ 976 + "a/reef.txt", 977 + "a/~tilde", 978 + "a/{brace}", 979 + "a/sp ace.txt", 980 + "a/b/ b/c", 981 + ] 982 + .into_iter() 983 + .for_each(|plain| { 984 + assert_eq!(quote_path(plain), plain, "git leaves {plain} bare too"); 985 + }); 986 + [ 987 + "a/quote\".txt", 988 + "a/back\\slash", 989 + "a/tab\there", 990 + "a/new\nline", 991 + "a/é", 992 + "a/\u{7f}del", 993 + ] 994 + .into_iter() 995 + .for_each(|awkward| { 996 + let quoted = quote_path(awkward); 997 + assert!(quoted.starts_with('"'), "{awkward} must come out quoted"); 998 + assert_eq!( 999 + unquote(&quoted).unwrap(), 1000 + awkward, 1001 + "{awkward} must unquote back to itself" 1002 + ); 1003 + }); 1004 + } 1005 + 1006 + #[test] 1007 + fn diff_paths_splits_a_header_whose_path_holds_the_separator() { 1008 + [ 1009 + ("a/b/ b/c.bin b/b/ b/c.bin", "b/ b/c.bin", "b/ b/c.bin"), 1010 + ( 1011 + "\"a/quote\\\".bin\" \"b/quote\\\".bin\"", 1012 + "quote\".bin", 1013 + "quote\".bin", 1014 + ), 1015 + ( 1016 + "a/old name.txt b/new name.txt", 1017 + "old name.txt", 1018 + "new name.txt", 1019 + ), 1020 + ] 1021 + .into_iter() 1022 + .for_each(|(header, old, new)| { 1023 + assert_eq!( 1024 + diff_paths(header), 1025 + Some((old.to_string(), new.to_string())), 1026 + "the a-side and b-side must agree on the split: {header}" 1027 + ); 1028 + }); 1029 + } 1030 + 1031 + #[test] 1032 + fn a_binary_file_named_around_the_separator_keeps_its_path() { 1033 + let patch = concat!( 1034 + "diff --git a/b/ b/c.bin b/b/ b/c.bin\n", 1035 + "index 1111111111111111111111111111111111111111..2222222222222222222222222222222222222222 100644\n", 1036 + "GIT binary patch\n", 1037 + "literal 4\n", 1038 + "LcmZRms;UA20^k8}\n", 1039 + "\n", 1040 + "literal 5\n", 1041 + "Mcmb<mNK8rw00gH2p8x;=\n", 1042 + "\n", 1043 + ); 1044 + assert_eq!( 1045 + parse_patch(patch).unwrap()[0].path.as_str(), 1046 + "b/ b/c.bin", 1047 + "a binary file has no --- or +++ label, so the header is all the parser gets" 1048 + ); 1049 + } 1050 + 1051 + #[test] 1052 + fn an_index_line_yields_an_oid_at_either_hash_width() { 1053 + [40usize, 64].into_iter().for_each(|width| { 1054 + assert!( 1055 + full_oid(&"a".repeat(width)).is_some(), 1056 + "{width} hex digits is a full oid" 1057 + ); 1058 + }); 1059 + assert_eq!( 1060 + full_oid("1111111"), 1061 + None, 1062 + "7 hex digits is short of a full oid" 1063 + ); 1064 + } 1065 + 1066 + #[test] 974 1067 fn a_mailbox_splits_into_individual_patches() { 975 1068 let mbox = concat!( 976 1069 "From 1111111111111111111111111111111111111111 Mon Sep 17 00:00:00 2001\n", ··· 1020 1113 Some(CommitChangeId::new("I0123456789abcdef").unwrap()) 1021 1114 ); 1022 1115 assert_eq!(mails[1].files[0].intent, FileIntent::Modify); 1023 - } 1024 - 1025 - #[test] 1026 - fn base85_decodes_lengths_and_rejects_garbage() { 1027 - let mut out = Vec::new(); 1028 - decode_base85_line("D00000", &mut out).unwrap(); 1029 - assert_eq!(out, vec![0, 0, 0, 0]); 1030 - let mut out = Vec::new(); 1031 - decode_base85_line("B00000", &mut out).unwrap(); 1032 - assert_eq!(out, vec![0, 0]); 1033 - assert!(decode_base85_line("D0000", &mut Vec::new()).is_err()); 1034 - assert!(decode_base85_line("D0\"000", &mut Vec::new()).is_err()); 1035 - assert!(decode_base85_line("?00000", &mut Vec::new()).is_err()); 1036 1116 } 1037 1117 1038 1118 #[test]
+69 -46
knot2/crates/knot-git/tests/reads.rs
··· 1 1 use std::path::Path; 2 2 3 - use knot_git::{CommitRange, EntryKind, FileChange, Layout, LineCount, LogLimit, LogSkip, Repo}; 3 + use knot_git::{ 4 + BinaryBudget, BinaryDiff, CommitRange, EntryKind, FileChange, FilePatch, Layout, LineCount, 5 + LogLimit, LogSkip, PatchBody, Repo, 6 + }; 4 7 use knot_types::{Listing, Oid, RefName, RepoDid, RepoPath}; 5 8 6 9 fn rp(path: &str) -> RepoPath { 7 10 RepoPath::new(path).unwrap() 11 + } 12 + 13 + fn patches_of( 14 + bare: &Repo, 15 + base: Option<Oid>, 16 + head: Oid, 17 + budget: &mut BinaryBudget, 18 + ) -> Vec<FilePatch> { 19 + bare.commit_patches(knot_git::PatchRange { base, head }, budget) 20 + .unwrap() 21 + } 22 + 23 + fn named<'a>(patches: &'a [FilePatch], path: &str) -> &'a FilePatch { 24 + patches 25 + .iter() 26 + .find(|patch| patch.path.as_str() == path) 27 + .unwrap() 28 + } 29 + 30 + fn binary_diff(patch: &FilePatch) -> &BinaryDiff { 31 + match &patch.body { 32 + PatchBody::Binary(diff) => diff, 33 + PatchBody::Text(_) => panic!("expected a binary patch for {}, found text", patch.path), 34 + } 8 35 } 9 36 10 37 mod common; ··· 289 316 git(work, &["log", "-1", "--format=%H", "--", "src/lib.rs"]) 290 317 ); 291 318 292 - let patches = bare 293 - .commit_patches(knot_git::PatchRange { 294 - base: Some(parent), 295 - head, 296 - }) 297 - .unwrap(); 319 + let patches = patches_of(&bare, Some(parent), head, &mut BinaryBudget::Omit); 298 320 assert_eq!(patches.len(), 1); 299 321 let patch = &patches[0]; 300 322 assert_eq!(patch.path.as_str(), "src/lib.rs"); 301 323 assert_eq!(patch.status, knot_git::PatchStatus::Modified); 302 - assert!(!patch.is_binary); 303 - assert_eq!(patch.hunks.len(), 1); 304 - let hunk = &patch.hunks[0]; 324 + assert!(!patch.is_binary()); 325 + assert_eq!(patch.hunks().len(), 1); 326 + let hunk = &patch.hunks()[0]; 305 327 assert_eq!( 306 328 ( 307 329 hunk.old_start.get(), ··· 321 343 vec!["pub fn nel() {}\n", "pub fn teq() {}\n"] 322 344 ); 323 345 324 - let initial = bare 325 - .commit_patches(knot_git::PatchRange { 326 - base: None, 327 - head: root, 328 - }) 329 - .unwrap(); 346 + let initial = patches_of(&bare, None, root, &mut BinaryBudget::Omit); 330 347 assert_eq!(initial.len(), 1); 331 348 assert_eq!(initial[0].status, knot_git::PatchStatus::Added); 332 349 assert_eq!( 333 - initial[0].hunks[0].old_start.get(), 350 + initial[0].hunks()[0].old_start.get(), 334 351 0, 335 352 "added file hunk starts at -0,0" 336 353 ); 337 - assert_eq!(initial[0].hunks[0].old_lines.get(), 0); 354 + assert_eq!(initial[0].hunks()[0].old_lines.get(), 0); 338 355 339 356 let tag_commit = bare.peel_to_commit(tag_object).unwrap(); 340 357 assert_eq!( ··· 595 612 let bare = layout.open(&did).unwrap(); 596 613 let head = Oid::from_hex(&git(work, &["rev-parse", "HEAD"])).unwrap(); 597 614 let parent = Oid::from_hex(&git(work, &["rev-parse", "HEAD~1"])).unwrap(); 598 - let patches = bare 599 - .commit_patches(knot_git::PatchRange { 600 - base: Some(parent), 601 - head, 602 - }) 615 + let patches = patches_of(&bare, Some(parent), head, &mut BinaryBudget::new(1024)); 616 + let blob = binary_diff(named(&patches, "blob.bin")); 617 + assert!( 618 + matches!(blob, BinaryDiff::Encoded { .. }), 619 + "a blob under the budget is encoded" 620 + ); 621 + let mut thin = BinaryBudget::new(5); 622 + let thinned = patches_of(&bare, Some(parent), head, &mut thin); 623 + let starved = binary_diff(named(&thinned, "blob.bin")); 624 + assert!( 625 + matches!(starved, BinaryDiff::Omitted(_)), 626 + "a blob past the budget is omitted" 627 + ); 628 + assert!(thin.omitted(), "omitted is true once a blob is left out"); 629 + assert_eq!( 630 + [blob.sizes(), starved.sizes()].map(|sizes| (sizes.old, sizes.new)), 631 + [(0, 6); 2], 632 + "a new file has an empty pre-image, encoded or omitted" 633 + ); 634 + let last = named(&patches, "noeol.txt").hunks()[0] 635 + .lines 636 + .last() 603 637 .unwrap(); 604 - let binary = patches 605 - .iter() 606 - .find(|patch| patch.path.as_str() == "blob.bin") 607 - .unwrap(); 608 - assert!(binary.is_binary); 609 - assert!(binary.hunks.is_empty()); 610 - let noeol = patches 611 - .iter() 612 - .find(|patch| patch.path.as_str() == "noeol.txt") 613 - .unwrap(); 614 - let last = noeol.hunks[0].lines.last().unwrap(); 615 638 assert_eq!(last.text, b"no newline at end".to_vec()); 616 639 } 617 640 ··· 764 787 let bare = layout.open(&did).unwrap(); 765 788 let head = Oid::from_hex(&git(&clone, &["rev-parse", "HEAD"])).unwrap(); 766 789 let parent = Oid::from_hex(&git(&clone, &["rev-parse", "HEAD~1"])).unwrap(); 767 - let patches = bare 768 - .commit_patches(knot_git::PatchRange { 769 - base: Some(parent), 770 - head, 771 - }) 772 - .unwrap(); 773 - let huge = patches 774 - .iter() 775 - .find(|patch| patch.path.as_str() == "huge.txt") 776 - .unwrap(); 790 + let patches = patches_of(&bare, Some(parent), head, &mut BinaryBudget::new(u64::MAX)); 791 + let huge = named(&patches, "huge.txt"); 777 792 assert!( 778 - huge.is_binary, 793 + huge.is_binary() && huge.hunks().is_empty(), 779 794 "blob past diff budget falls back to binary instead of being loaded" 780 795 ); 781 - assert!(huge.hunks.is_empty()); 796 + assert!( 797 + matches!(binary_diff(huge), BinaryDiff::Omitted(_)), 798 + "a blob nobody read has no bytes to encode, whatever the budget allows" 799 + ); 800 + assert_eq!( 801 + binary_diff(huge).sizes().new, 802 + knot_git::MAX_DIFF_BLOB_BYTES + 1, 803 + "the size comes from the blob header the knot did read" 804 + ); 782 805 }
+11
knot2/crates/knot-xrpc/src/lib.rs
··· 129 129 } 130 130 } 131 131 132 + const BINARY_RESPONSE_SHARE: u64 = 4; 133 + const BINARY_WIRE_COPIES: u64 = 3; 134 + 135 + impl ByteLimits { 136 + pub fn binary_patch(self) -> knot_git::BinaryBudget { 137 + knot_git::BinaryBudget::new( 138 + self.response.get() as u64 / BINARY_RESPONSE_SHARE / BINARY_WIRE_COPIES, 139 + ) 140 + } 141 + } 142 + 132 143 #[derive(Debug, Clone, Copy, PartialEq, Eq)] 133 144 pub struct Budgets { 134 145 pub tree_last_commit: TreeReadBudget,
+77 -84
knot2/crates/knot-xrpc/src/patchtext.rs
··· 1 - use knot_git::{Commit, FilePatch, Hunk, LineCount, LineNumber, LineOp, PatchStatus}; 1 + use knot_git::{ 2 + BinaryDiff, Commit, EntryKind, FilePatch, Hunk, LineCount, LineNumber, LineOp, PatchBody, 3 + PatchStatus, quote_path, 4 + }; 2 5 3 6 use crate::wire::{entry_mode_octal, fold_subject, message_body, rfc2822}; 4 7 5 8 const GRAPH_WIDTH: usize = 60; 9 + 10 + fn entry_mode(kind: Option<EntryKind>) -> String { 11 + kind.map(entry_mode_octal).unwrap_or_default() 12 + } 6 13 7 14 fn span(start: LineNumber, lines: LineCount) -> String { 8 15 match lines.get() { ··· 31 38 } 32 39 33 40 fn render_file(out: &mut String, patch: &FilePatch) { 34 - let (a, b) = (&patch.path, &patch.path); 35 - out.push_str(&format!("diff --git a/{a} b/{b}\n")); 41 + let old_side = quote_path(&format!("a/{}", patch.path)); 42 + let new_side = quote_path(&format!("b/{}", patch.path)); 43 + let index = format!( 44 + "index {}..{}", 45 + patch.old_oid.to_hex(), 46 + patch.new_oid.to_hex() 47 + ); 48 + out.push_str(&format!("diff --git {old_side} {new_side}\n")); 36 49 match patch.status { 37 - PatchStatus::Added => { 38 - let mode = patch.new_kind.map(entry_mode_octal).unwrap_or_default(); 39 - out.push_str(&format!("new file mode {mode}\n")); 40 - out.push_str(&format!( 41 - "index {}..{}\n", 42 - patch.old_oid.to_hex(), 43 - patch.new_oid.to_hex() 44 - )); 45 - } 46 - PatchStatus::Deleted => { 47 - let mode = patch.old_kind.map(entry_mode_octal).unwrap_or_default(); 48 - out.push_str(&format!("deleted file mode {mode}\n")); 49 - out.push_str(&format!( 50 - "index {}..{}\n", 51 - patch.old_oid.to_hex(), 52 - patch.new_oid.to_hex() 53 - )); 50 + PatchStatus::Added => out.push_str(&format!( 51 + "new file mode {}\n{index}\n", 52 + entry_mode(patch.new_kind) 53 + )), 54 + PatchStatus::Deleted => out.push_str(&format!( 55 + "deleted file mode {}\n{index}\n", 56 + entry_mode(patch.old_kind) 57 + )), 58 + PatchStatus::Modified if patch.old_kind == patch.new_kind => { 59 + out.push_str(&format!("{index} {}\n", entry_mode(patch.old_kind))) 54 60 } 55 61 PatchStatus::Modified => { 56 - if patch.old_kind == patch.new_kind { 57 - let mode = patch.old_kind.map(entry_mode_octal).unwrap_or_default(); 58 - out.push_str(&format!( 59 - "index {}..{} {mode}\n", 60 - patch.old_oid.to_hex(), 61 - patch.new_oid.to_hex() 62 - )); 63 - } else { 64 - let old = patch.old_kind.map(entry_mode_octal).unwrap_or_default(); 65 - let new = patch.new_kind.map(entry_mode_octal).unwrap_or_default(); 66 - out.push_str(&format!("old mode {old}\nnew mode {new}\n")); 67 - out.push_str(&format!( 68 - "index {}..{}\n", 69 - patch.old_oid.to_hex(), 70 - patch.new_oid.to_hex() 71 - )); 62 + out.push_str(&format!( 63 + "old mode {}\nnew mode {}\n", 64 + entry_mode(patch.old_kind), 65 + entry_mode(patch.new_kind) 66 + )); 67 + match patch.old_oid == patch.new_oid { 68 + true => {} 69 + false => out.push_str(&format!("{index}\n")), 72 70 } 73 71 } 74 72 } 75 73 let old_label = match patch.status { 76 - PatchStatus::Added => "/dev/null".to_string(), 77 - _ => format!("a/{a}"), 74 + PatchStatus::Added => "/dev/null", 75 + _ => old_side.as_str(), 78 76 }; 79 77 let new_label = match patch.status { 80 - PatchStatus::Deleted => "/dev/null".to_string(), 81 - _ => format!("b/{b}"), 78 + PatchStatus::Deleted => "/dev/null", 79 + _ => new_side.as_str(), 82 80 }; 83 - if patch.is_binary { 84 - out.push_str(&format!( 81 + match &patch.body { 82 + PatchBody::Binary(BinaryDiff::Encoded { text, .. }) => out.push_str(text), 83 + PatchBody::Binary(BinaryDiff::Omitted(_)) => out.push_str(&format!( 85 84 "Binary files {old_label} and {new_label} differ\n" 86 - )); 87 - return; 85 + )), 86 + PatchBody::Binary(BinaryDiff::Unchanged(_)) => {} 87 + PatchBody::Text(hunks) if hunks.is_empty() => {} 88 + PatchBody::Text(hunks) => { 89 + out.push_str(&format!("--- {old_label}\n+++ {new_label}\n")); 90 + hunks.iter().for_each(|hunk| render_hunk(out, hunk)); 91 + } 88 92 } 89 - if patch.hunks.is_empty() { 90 - return; 91 - } 92 - out.push_str(&format!("--- {old_label}\n+++ {new_label}\n")); 93 - patch.hunks.iter().for_each(|hunk| render_hunk(out, hunk)); 94 93 } 95 94 96 95 pub(crate) fn render_patches(patches: &[FilePatch]) -> String { ··· 101 100 } 102 101 103 102 fn stat_counts(patch: &FilePatch) -> (usize, usize) { 104 - patch.hunks.iter().fold((0, 0), |(added, deleted), hunk| { 103 + patch.hunks().iter().fold((0, 0), |(added, deleted), hunk| { 105 104 ( 106 105 added + hunk.added().get() as usize, 107 106 deleted + hunk.deleted().get() as usize, ··· 120 119 } 121 120 122 121 fn diffstat(patches: &[FilePatch]) -> String { 123 - let width = patches 122 + let named: Vec<(String, &FilePatch)> = patches 124 123 .iter() 125 - .map(|patch| patch.path.as_str().len()) 126 - .max() 127 - .unwrap_or(0); 128 - let rows: String = patches 124 + .map(|patch| (quote_path(patch.path.as_str()), patch)) 125 + .collect(); 126 + let width = named.iter().map(|(name, _)| name.len()).max().unwrap_or(0); 127 + let rows: String = named 129 128 .iter() 130 - .map(|patch| { 131 - if patch.is_binary { 132 - format!(" {:<width$} | Bin\n", patch.path) 133 - } else { 134 - let (added, deleted) = stat_counts(patch); 129 + .map(|(name, patch)| match &patch.body { 130 + PatchBody::Binary(BinaryDiff::Unchanged(_)) => format!(" {name:<width$} | Bin\n"), 131 + PatchBody::Binary(binary) => { 132 + let sizes = binary.sizes(); 135 133 format!( 136 - " {:<width$} | {} {}\n", 137 - patch.path, 138 - added + deleted, 139 - graph(added, deleted) 134 + " {name:<width$} | Bin {} -> {} bytes\n", 135 + sizes.old, sizes.new 140 136 ) 137 + } 138 + PatchBody::Text(_) => { 139 + let (added, deleted) = stat_counts(patch); 140 + let total = added + deleted; 141 + format!(" {name:<width$} | {total} {}\n", graph(added, deleted)) 141 142 } 142 143 }) 143 144 .collect(); ··· 160 161 )); 161 162 } 162 163 summary.push('\n'); 163 - let created: String = patches 164 + let modes: String = named 164 165 .iter() 165 - .filter(|patch| patch.status == PatchStatus::Added) 166 - .map(|patch| { 167 - format!( 168 - " create mode {} {}\n", 169 - patch.new_kind.map(entry_mode_octal).unwrap_or_default(), 170 - patch.path 171 - ) 166 + .filter_map(|(name, patch)| { 167 + let (old, new) = (entry_mode(patch.old_kind), entry_mode(patch.new_kind)); 168 + match patch.status { 169 + PatchStatus::Added => Some(format!(" create mode {new} {name}\n")), 170 + PatchStatus::Deleted => Some(format!(" delete mode {old} {name}\n")), 171 + PatchStatus::Modified if patch.old_kind != patch.new_kind => { 172 + Some(format!(" mode change {old} => {new} {name}\n")) 173 + } 174 + PatchStatus::Modified => None, 175 + } 172 176 }) 173 177 .collect(); 174 - let deleted_rows: String = patches 175 - .iter() 176 - .filter(|patch| patch.status == PatchStatus::Deleted) 177 - .map(|patch| { 178 - format!( 179 - " delete mode {} {}\n", 180 - patch.old_kind.map(entry_mode_octal).unwrap_or_default(), 181 - patch.path 182 - ) 183 - }) 184 - .collect(); 185 - format!("{rows}{summary}{created}{deleted_rows}") 178 + format!("{rows}{summary}{modes}") 186 179 } 187 180 188 181 pub(crate) fn render_format_patch(commit: &Commit, patches: &[FilePatch]) -> String {
+34 -16
knot2/crates/knot-xrpc/src/reads.rs
··· 13 13 14 14 use knot_cobs::RepoRef; 15 15 use knot_git::{ 16 - ArchiveFormat, Commit, CommitRange, EntryKind, Layout, LogLimit, LogSkip, Repo, SizedEntry, 17 - is_public_ref, screens_reserved, 16 + ArchiveFormat, BinaryBudget, Commit, CommitRange, EntryKind, Layout, LogLimit, LogSkip, Repo, 17 + SizedEntry, is_public_ref, screens_reserved, 18 18 }; 19 19 use knot_index::{Coverage, Resolved}; 20 20 use knot_runtime::{Clock, HttpTransport}; ··· 901 901 let repo = open(&layout, &did)?; 902 902 let target = commit_for(&repo, &params.refspec)?; 903 903 let commit = repo.find_commit(target)?; 904 - let patches = repo.commit_patches(knot_git::PatchRange { 905 - base: commit.parents.first().copied(), 906 - head: target, 907 - })?; 904 + let patches = repo.commit_patches( 905 + knot_git::PatchRange { 906 + base: commit.parents.first().copied(), 907 + head: target, 908 + }, 909 + &mut BinaryBudget::Omit, 910 + )?; 908 911 json( 909 912 DiffOut { 910 913 refspec: params.refspec.as_str().to_string(), ··· 939 942 combined_patch: Option<Vec<FileWire>>, 940 943 #[serde(skip_serializing_if = "Option::is_none")] 941 944 combined_patch_raw: Option<String>, 945 + #[serde(skip_serializing_if = "Option::is_none")] 946 + binary_omitted: Option<bool>, 942 947 } 943 948 944 949 fn format_patch_entry( ··· 1009 1014 } 1010 1015 let layout = state.layout.clone(); 1011 1016 let limit = state.byte_limits.response.get(); 1017 + let mut series_binary = state.byte_limits.binary_patch(); 1018 + let mut combined_binary = state.byte_limits.binary_patch(); 1012 1019 run_blocking(move || { 1013 1020 let repo = open(&layout, &did)?; 1014 1021 let resolve = |rev: &str| { ··· 1069 1076 let entries: Vec<(FormatPatchWire, String)> = commits 1070 1077 .iter() 1071 1078 .map(|commit| { 1072 - repo.commit_patches(knot_git::PatchRange { 1073 - base: commit.parents.first().copied(), 1074 - head: commit.id, 1075 - }) 1079 + repo.commit_patches( 1080 + knot_git::PatchRange { 1081 + base: commit.parents.first().copied(), 1082 + head: commit.id, 1083 + }, 1084 + &mut series_binary, 1085 + ) 1076 1086 .map(|patches| { 1077 1087 let raw = render_format_patch(commit, &patches); 1078 1088 (format_patch_entry(commit, &patches, &raw), raw) ··· 1080 1090 }) 1081 1091 .collect::<Result<Vec<_>, _>>() 1082 1092 .map_err(compare_error)?; 1083 - let patch_raw: String = entries.iter().map(|(_, raw)| format!("{raw}\n")).collect(); 1093 + let patch_raw: String = entries 1094 + .iter() 1095 + .flat_map(|(_, raw)| [raw.as_str(), "\n"]) 1096 + .collect(); 1084 1097 let merge_base = repo.merge_base(base, head).ok().flatten(); 1085 - let (combined_patch, combined_patch_raw) = match (entries.len() >= 2, merge_base) { 1098 + let (combined_patch, combined_patch_raw) = match (commits.len() >= 2, merge_base) { 1086 1099 (true, Some(merge_base)) => repo 1087 - .commit_patches(knot_git::PatchRange { 1088 - base: Some(merge_base), 1089 - head, 1090 - }) 1100 + .commit_patches( 1101 + knot_git::PatchRange { 1102 + base: Some(merge_base), 1103 + head, 1104 + }, 1105 + &mut combined_binary, 1106 + ) 1091 1107 .ok() 1092 1108 .map(|patches| { 1093 1109 ( ··· 1107 1123 patch_raw, 1108 1124 combined_patch, 1109 1125 combined_patch_raw, 1126 + binary_omitted: (series_binary.omitted() || combined_binary.omitted()) 1127 + .then_some(true), 1110 1128 }, 1111 1129 limit, 1112 1130 )
+20
knot2/crates/knot-xrpc/src/tests.rs
··· 3408 3408 "past the burst the knot sheds the guess flood before it reaches the secret comparison, got {statuses:?}" 3409 3409 ); 3410 3410 } 3411 + 3412 + #[test] 3413 + fn every_wire_copy_of_an_embedded_payload_fits_a_quarter_of_the_response() { 3414 + let limits = crate::ByteLimits::default(); 3415 + assert_eq!(limits.response.get(), 5 * 1024 * 1024); 3416 + let response = limits.response.get() as u64; 3417 + assert_eq!( 3418 + limits.binary_patch(), 3419 + knot_git::BinaryBudget::new(response / 4 / 3), 3420 + "a compare serialises the series twice and the combined patch once" 3421 + ); 3422 + let per_pass = match limits.binary_patch() { 3423 + knot_git::BinaryBudget::Spend { remaining, .. } => remaining, 3424 + knot_git::BinaryBudget::Omit => panic!("binary_patch spends, it doesn't omit"), 3425 + }; 3426 + assert!( 3427 + per_pass * 3 <= response / 4, 3428 + "three copies of {per_pass} bytes must stay inside a quarter of {response}" 3429 + ); 3430 + } 3411 3431 }
+6 -6
knot2/crates/knot-xrpc/src/wire.rs
··· 394 394 impl DiffWire { 395 395 pub fn of(patch: &FilePatch) -> Self { 396 396 let fragments: Vec<TextFragmentWire> = 397 - patch.hunks.iter().map(TextFragmentWire::of).collect(); 397 + patch.hunks().iter().map(TextFragmentWire::of).collect(); 398 398 Self { 399 399 name: DiffNameWire { 400 400 old: match patch.status { ··· 407 407 }, 408 408 }, 409 409 text_fragments: (!fragments.is_empty()).then_some(fragments), 410 - is_binary: patch.is_binary, 410 + is_binary: patch.is_binary(), 411 411 is_new: patch.status == PatchStatus::Added, 412 412 is_delete: patch.status == PatchStatus::Deleted, 413 413 is_copy: false, ··· 435 435 let stat = DiffStatWire { 436 436 insertions: patches 437 437 .iter() 438 - .flat_map(|patch| patch.hunks.iter()) 438 + .flat_map(|patch| patch.hunks().iter()) 439 439 .map(|hunk| hunk.added().get() as i64) 440 440 .sum(), 441 441 deletions: patches 442 442 .iter() 443 - .flat_map(|patch| patch.hunks.iter()) 443 + .flat_map(|patch| patch.hunks().iter()) 444 444 .map(|hunk| hunk.deleted().get() as i64) 445 445 .sum(), 446 446 files_changed: patches.len() as i64, ··· 558 558 impl FileWire { 559 559 pub fn of(patch: &FilePatch) -> Self { 560 560 let fragments: Vec<TextFragmentWire> = 561 - patch.hunks.iter().map(TextFragmentWire::of).collect(); 561 + patch.hunks().iter().map(TextFragmentWire::of).collect(); 562 562 let same_mode = patch.old_kind.is_some() && patch.old_kind == patch.new_kind; 563 563 Self { 564 564 old_name: match patch.status { ··· 589 589 new_oid_prefix: patch.new_oid.to_hex(), 590 590 score: 0, 591 591 text_fragments: (!fragments.is_empty()).then_some(fragments), 592 - is_binary: patch.is_binary, 592 + is_binary: patch.is_binary(), 593 593 binary_fragment: None, 594 594 reverse_binary_fragment: None, 595 595 }
+87 -39
knot2/crates/knot-xrpc/tests/reads.rs
··· 10 10 use tokio_tungstenite::tungstenite; 11 11 12 12 use knot_events::{EventCursor, GitRefUpdate}; 13 - use knot_types::{AccountDid, ObjectFormat, Oid, OwnerDid, RepoDid}; 13 + use knot_types::{AccountDid, Oid, OwnerDid, RepoDid}; 14 14 use knot_xrpc::{ArchiveLimit, ResponseLimit}; 15 15 16 16 use common::{ 17 17 OWNER, World, archive_full, assert_immutable_round_trip, assert_post_rejected, assert_warming, 18 18 commit_file, empty_repo, get, get_error, get_json, get_with_headers, git_run, post_authed, 19 - post_json, ref_names, repo_dids, seeded, seeded_feature_branch, seeded_with_format, sh_git, 20 - sh_git_at, 19 + post_json, ref_names, repo_dids, seeded, seeded_feature_branch, sh_git, sh_git_at, 21 20 }; 22 21 23 22 #[tokio::test] ··· 790 789 #[tokio::test] 791 790 async fn archive_serves_a_sha256_repo_with_a_stable_etag() { 792 791 let world = World::sha256(); 793 - let (did, _work) = seeded_with_format(&world, "nautilus", ObjectFormat::SHA256); 792 + let (did, _work) = seeded(&world, "nautilus"); 794 793 795 794 let (status, headers, full) = get( 796 795 &world, ··· 1588 1587 1589 1588 #[tokio::test] 1590 1589 async fn a_compare_patch_round_trips_through_merge_check() { 1591 - let world = World::new(); 1592 - let (_did, main_sha, feature_sha) = seeded_feature_branch(&world, "periwinkle"); 1593 - let registered = RepoDid::new("did:plc:periwinklefixture").unwrap(); 1590 + compare_round_trip(World::new()).await; 1591 + } 1592 + 1593 + #[tokio::test] 1594 + async fn a_sha256_compare_patch_round_trips_through_merge_check() { 1595 + compare_round_trip(World::sha256()).await; 1596 + } 1597 + 1598 + async fn compare_round_trip(world: World) { 1599 + let (did, main_sha, feature_sha) = seeded_feature_branch(&world, "periwinkle"); 1594 1600 1595 1601 let compared = get_json( 1596 1602 &world, 1597 - &format!( 1598 - "/xrpc/sh.tangled.repo.compare?repo={registered}&rev1={main_sha}&rev2={feature_sha}" 1599 - ), 1603 + &format!("/xrpc/sh.tangled.repo.compare?repo={did}&rev1={main_sha}&rev2={feature_sha}"), 1600 1604 ) 1601 1605 .await; 1602 1606 let patch = compared["patch"].as_str().unwrap(); 1607 + let combined = compared["combined_patch_raw"].as_str().unwrap(); 1603 1608 1604 - let (status, check) = post_json( 1605 - &world, 1606 - "/xrpc/sh.tangled.repo.mergeCheck", 1607 - serde_json::json!({ 1608 - "repo": registered, 1609 - "branch": "main", 1610 - "patch": patch, 1611 - }), 1612 - ) 1613 - .await; 1614 - assert_eq!(status, StatusCode::OK); 1609 + let checks = stream::iter([("main", patch), ("main", combined), ("feature", patch)]) 1610 + .then(|(branch, candidate)| { 1611 + post_json( 1612 + &world, 1613 + "/xrpc/sh.tangled.repo.mergeCheck", 1614 + serde_json::json!({ 1615 + "repo": did, 1616 + "branch": branch, 1617 + "patch": candidate, 1618 + }), 1619 + ) 1620 + }) 1621 + .collect::<Vec<_>>() 1622 + .await; 1615 1623 assert_eq!( 1616 - check["is_conflicted"], 1617 - serde_json::Value::Bool(false), 1618 - "knot's own compare output must pass its own merge check: {check}" 1624 + checks 1625 + .iter() 1626 + .map(|(status, check)| (*status, check["is_conflicted"].clone())) 1627 + .collect::<Vec<_>>(), 1628 + [false, false, true] 1629 + .map(|conflicted| (StatusCode::OK, serde_json::Value::Bool(conflicted))) 1630 + .to_vec(), 1631 + "main takes both patches and feature already has them: {checks:?}" 1619 1632 ); 1620 1633 1621 - let (status, stale) = post_json( 1622 - &world, 1623 - "/xrpc/sh.tangled.repo.mergeCheck", 1624 - serde_json::json!({ 1625 - "repo": registered, 1626 - "branch": "feature", 1627 - "patch": patch, 1628 - }), 1629 - ) 1630 - .await; 1631 - assert_eq!(status, StatusCode::OK); 1634 + [ 1635 + "GIT binary patch", 1636 + " shell.bin | Bin 4096 -> 4096 bytes\n", 1637 + "deleted file mode 100644\n", 1638 + " delete mode 100644 anchor.bin\n", 1639 + "diff --git a/deep water.bin b/deep water.bin\n", 1640 + "old mode 100644\nnew mode 100755\n", 1641 + " mode change 100644 => 100755 hull.bin\n", 1642 + " hull.bin | Bin\n", 1643 + ] 1644 + .into_iter() 1645 + .for_each(|needle| { 1646 + assert!( 1647 + patch.contains(needle), 1648 + "the format patch is missing {needle:?}: {patch}" 1649 + ) 1650 + }); 1651 + assert!( 1652 + combined.contains("GIT binary patch"), 1653 + "the combined patch is missing its binary payloads: {combined}" 1654 + ); 1655 + assert!( 1656 + !patch.contains("new mode 100755\nindex "), 1657 + "a mode change leaves both sides at the same oid, so git prints no index line: {patch}" 1658 + ); 1632 1659 assert_eq!( 1633 - stale["is_conflicted"], 1634 - serde_json::Value::Bool(true), 1635 - "re-applying an already-landed patch must conflict: {stale}" 1660 + compared.get("binary_omitted"), 1661 + None, 1662 + "binary_omitted is absent when every payload was embedded: {compared}" 1663 + ); 1664 + 1665 + let applied = tempfile::tempdir().unwrap(); 1666 + let bare = world.layout.repo_path(&did).unwrap(); 1667 + sh_git( 1668 + applied.path(), 1669 + &["clone", "-q", bare.to_str().unwrap(), "."], 1670 + ); 1671 + sh_git(applied.path(), &["checkout", "-q", "main"]); 1672 + std::fs::write(applied.path().join("knot.patch"), patch).unwrap(); 1673 + sh_git(applied.path(), &["am", "knot.patch"]); 1674 + assert_eq!( 1675 + sh_git(applied.path(), &["rev-parse", "HEAD^{tree}"]), 1676 + sh_git(applied.path(), &["rev-parse", "origin/feature^{tree}"]), 1677 + "git am of the knot's own patch rebuilds the tree the branch already has" 1678 + ); 1679 + assert!( 1680 + !applied.path().join("anchor.bin").exists() 1681 + && applied.path().join("deep water.bin").exists() 1682 + && sh_git(applied.path(), &["ls-files", "-s", "hull.bin"]).starts_with("100755 "), 1683 + "git am dropped anchor.bin, wrote deep water.bin and kept the exec bit on hull.bin" 1636 1684 ); 1637 1685 } 1638 1686 ··· 2047 2095 } 2048 2096 2049 2097 #[tokio::test] 2050 - async fn merge_applies_a_plain_patch_under_the_supplied_author() { 2098 + async fn merge_applies_a_patch_under_the_supplied_author() { 2051 2099 let world = World::new(); 2052 2100 let (_did, main_sha, feature_sha) = seeded_feature_branch(&world, "mussel"); 2053 2101 let registered = RepoDid::new("did:plc:musselfixture").unwrap();
+68 -36
knot2/crates/knot-xrpc/tests/common/mod.rs
··· 1 1 #![allow(dead_code)] 2 2 3 3 use std::collections::BTreeSet; 4 + use std::os::unix::fs::PermissionsExt; 4 5 use std::path::Path; 5 6 use std::sync::Arc; 6 7 ··· 448 449 } 449 450 450 451 pub fn seeded(world: &World, rkey: &str) -> (RepoDid, tempfile::TempDir) { 451 - seeded_with_format(world, rkey, ObjectFormat::SHA1) 452 - } 453 - 454 - pub fn seeded_with_format( 455 - world: &World, 456 - rkey: &str, 457 - object_format: ObjectFormat, 458 - ) -> (RepoDid, tempfile::TempDir) { 459 452 let did = RepoDid::new(format!("did:plc:{rkey}fixture")).unwrap(); 460 - world.layout.create(&did).unwrap(); 453 + let object_format = world.layout.create(&did).unwrap().object_format(); 461 454 world.register(&did, rkey); 462 455 let bare = world.layout.repo_path(&did).unwrap(); 463 456 let work_dir = tempfile::tempdir().unwrap(); 464 457 let work = work_dir.path(); 465 - let init = match object_format == ObjectFormat::SHA256 { 466 - true => vec!["init", "-q", "--object-format=sha256", "-b", "main"], 467 - false => vec!["init", "-q", "-b", "main"], 468 - }; 469 - sh_git(work, &init); 458 + sh_git(work, &init_args(object_format)); 470 459 commit_file( 471 460 work, 472 461 "README.md", ··· 508 497 (did, work_dir) 509 498 } 510 499 500 + fn init_args(object_format: ObjectFormat) -> Vec<&'static str> { 501 + match object_format == ObjectFormat::SHA256 { 502 + true => vec!["init", "-q", "--object-format=sha256", "-b", "main"], 503 + false => vec!["init", "-q", "-b", "main"], 504 + } 505 + } 506 + 511 507 pub fn empty_repo(world: &World, rkey: &str) -> (RepoDid, String, tempfile::TempDir) { 512 508 let did = RepoDid::new(format!("did:plc:{rkey}fixture")).unwrap(); 513 - world.layout.create(&did).unwrap(); 509 + let object_format = world.layout.create(&did).unwrap().object_format(); 514 510 world.register(&did, rkey); 515 511 let bare = world 516 512 .layout ··· 520 516 .unwrap() 521 517 .to_string(); 522 518 let work_dir = tempfile::tempdir().unwrap(); 523 - sh_git(work_dir.path(), &["init", "-q", "-b", "main"]); 519 + sh_git(work_dir.path(), &init_args(object_format)); 524 520 (did, bare, work_dir) 521 + } 522 + 523 + fn shell_bytes(seed: u8) -> Vec<u8> { 524 + let stream = std::iter::successors(Some(seed as u32), |state| { 525 + Some(state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223)) 526 + }) 527 + .map(|state| (state >> 16) as u8); 528 + std::iter::once(0).chain(stream).take(4096).collect() 529 + } 530 + 531 + fn at(clock: &str) -> String { 532 + format!("2026-06-01T{clock}+02:00") 525 533 } 526 534 527 535 pub fn seeded_feature_branch(world: &World, rkey: &str) -> (RepoDid, Oid, Oid) { 528 536 let (did, bare, work_dir) = empty_repo(world, rkey); 529 537 let work = work_dir.path(); 530 - commit_file( 531 - work, 532 - "reef.txt", 533 - b"one\ntwo\n", 534 - "base", 535 - "2026-06-01T12:30:00+02:00", 536 - ); 538 + let commits = |list: Vec<(&str, Vec<u8>, &str, &str)>| { 539 + list.into_iter().for_each(|(file, bytes, message, minute)| { 540 + commit_file(work, file, &bytes, message, &at(minute)) 541 + }); 542 + }; 543 + commits(vec![ 544 + ("reef.txt", b"one\ntwo\n".into(), "base", "12:30:00"), 545 + ("shell.bin", shell_bytes(0), "add shell", "12:30:10"), 546 + ("anchor.bin", shell_bytes(211), "add anchor", "12:30:20"), 547 + ("hull.bin", shell_bytes(159), "add hull", "12:30:30"), 548 + ]); 537 549 sh_git(work, &["checkout", "-q", "-b", "feature"]); 538 - commit_file( 550 + std::fs::set_permissions( 551 + work.join("hull.bin"), 552 + std::fs::Permissions::from_mode(0o755), 553 + ) 554 + .unwrap(); 555 + sh_git_at(work, &at("12:30:40"), &["add", "-A"]); 556 + sh_git_at( 539 557 work, 540 - "reef.txt", 541 - b"one\nTWO\n", 542 - "capitalize two\n\nbecause waves", 543 - "2026-06-01T12:31:00+02:00", 558 + &at("12:30:40"), 559 + &["commit", "-q", "-m", "make hull runnable"], 544 560 ); 545 - commit_file( 561 + commits(vec![ 562 + ( 563 + "reef.txt", 564 + b"one\nTWO\n".into(), 565 + "capitalize two\n\nbecause waves", 566 + "12:31:00", 567 + ), 568 + ("kelp.txt", b"frond\n".into(), "add kelp", "12:31:10"), 569 + ("shell.bin", shell_bytes(97), "reshape shell", "12:31:20"), 570 + ("pearl.bin", vec![0, 255, 12, 0, 9], "add pearl", "12:31:30"), 571 + ( 572 + "deep water.bin", 573 + shell_bytes(43), 574 + "add deep water", 575 + "12:31:40", 576 + ), 577 + ]); 578 + std::fs::remove_file(work.join("anchor.bin")).unwrap(); 579 + sh_git_at(work, &at("12:32:00"), &["add", "-A"]); 580 + sh_git_at( 546 581 work, 547 - "kelp.txt", 548 - b"frond\n", 549 - "add kelp", 550 - "2026-06-01T12:32:00+02:00", 582 + &at("12:32:00"), 583 + &["commit", "-q", "-m", "drop anchor"], 551 584 ); 552 585 sh_git(work, &["push", "-q", &bare, "main", "feature"]); 553 - let main = Oid::from_hex(&sh_git(work, &["rev-parse", "main"])).unwrap(); 554 - let feature = Oid::from_hex(&sh_git(work, &["rev-parse", "feature"])).unwrap(); 555 - (did, main, feature) 586 + let tip = |name: &str| Oid::from_hex(&sh_git(work, &["rev-parse", name])).unwrap(); 587 + (did, tip("main"), tip("feature")) 556 588 } 557 589 558 590 pub async fn get_with_headers(
+6
appview/pages/templates/repo/pulls/fragments/pullStepReview.html
··· 9 9 {{ end }} 10 10 </div> 11 11 {{ else }} 12 + {{ if .Comparison.BinaryOmitted }} 13 + <div class="flex items-start gap-2 p-4 border border-amber-200 dark:border-amber-500 rounded bg-amber-50 dark:bg-amber-900 text-sm text-amber-600 dark:text-amber-50"> 14 + {{ i "triangle-alert" "w-4 h-4 flex-shrink-0 mt-0.5" }} 15 + <span>Some binary files are too large to carry in this patch. The pull request will open, but merging it will leave those files untouched.</span> 16 + </div> 17 + {{ end }} 12 18 {{ $commits := .Comparison.FormatPatch }} 13 19 {{ if $commits }} 14 20 <div class="flex flex-col gap-2">