Krolyxon
Tag v0.2.0

ferreniumsrcrenderdiff.rs

484 lines · 16.2 KB · Rust

1//! Commit diffs: the cached, render-ready model behind the commit page,
2//! and plain-text patches.
3
4use std::fmt::Write;
5use std::sync::Arc;
6
7use crate::cache::Cache;
8use crate::git::diff::{self, Changes, FileChange, LineKind, Side, Status};
9use crate::git::tree::{self, EntryKind};
10use crate::git::{CommitDetail, Handle, ObjectId};
11use crate::render::highlight;
12
13/// Knobs for [`build`]; everything that changes the output is part of the
14/// cache key.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
16pub struct Options {
17 pub context: u32,
18 pub ignore_ws: bool,
19 /// Above this many files only the file list is shown.
20 pub max_files: usize,
21 /// Changed lines rendered for the whole commit, and per file.
22 pub max_lines: usize,
23 pub max_file_lines: usize,
24 /// Blobs larger than this are not diffed.
25 pub max_blob_bytes: u64,
26 pub max_highlight_bytes: u64,
27}
28
29pub struct CommitDiff {
30 pub files: Vec<FileDiff>,
31 /// Totals over the files that were diffed.
32 pub added: u64,
33 pub removed: u64,
34 /// More than `max_files` files: no per-file diffs at all.
35 pub too_many_files: bool,
36}
37
38impl CommitDiff {
39 pub fn weight(&self) -> usize {
40 64 + self.files.iter().map(FileDiff::weight).sum::<usize>()
41 }
42}
43
44pub struct FileDiff {
45 pub change: FileChange,
46 /// Lines added and removed; `None` when the file wasn't diffed.
47 pub stat: Option<(u32, u32)>,
48 /// `(old, new)` modes when only the mode or type changed alongside.
49 pub mode_change: Option<(&'static str, &'static str)>,
50 pub body: Body,
51}
52
53impl FileDiff {
54 fn weight(&self) -> usize {
55 let body = match &self.body {
56 Body::Hunks(hunks) => hunks
57 .iter()
58 .map(|h| h.header.len() + h.lines.iter().map(|l| l.html.len() + 32).sum::<usize>())
59 .sum(),
60 _ => 0,
61 };
62 128 + self.change.path().len() * 2 + body
63 }
64}
65
66pub enum Body {
67 Hunks(Vec<Hunk>),
68 /// Content unchanged (pure rename, mode change, whitespace-only with
69 /// `ignore_ws`).
70 Unchanged,
71 Binary,
72 Submodule {
73 old: Option<ObjectId>,
74 new: Option<ObjectId>,
75 },
76 /// Over a size limit: the blob, the per-file or the per-commit lines.
77 TooLarge,
78 /// Not diffed because the commit has too many files.
79 NotShown,
80}
81
82pub struct Hunk {
83 pub header: String,
84 pub lines: Vec<Line>,
85}
86
87pub struct Line {
88 pub kind: LineKind,
89 /// 1-based line numbers.
90 pub old: Option<u32>,
91 pub new: Option<u32>,
92 pub html: String,
93}
94
95impl Line {
96 pub fn sign(&self) -> &'static str {
97 match self.kind {
98 LineKind::Context => " ",
99 LineKind::Added => "+",
100 LineKind::Removed => "\u{2212}",
101 }
102 }
103
104 pub fn class(&self) -> &'static str {
105 match self.kind {
106 LineKind::Context => "ctx",
107 LineKind::Added => "add",
108 LineKind::Removed => "del",
109 }
110 }
111}
112
113enum Content {
114 Text(String),
115 Binary,
116 TooLarge,
117 Missing,
118}
119
120impl Content {
121 fn text(&self) -> &str {
122 match self {
123 Content::Text(t) => t,
124 _ => "",
125 }
126 }
127}
128
129fn load(repo: &Handle, side: Option<&Side>, max_bytes: Option<u64>) -> anyhow::Result<Content> {
130 let Some(side) = side else {
131 return Ok(Content::Missing);
132 };
133 if let Some(max) = max_bytes
134 && tree::size(repo, side.oid)? > max
135 {
136 return Ok(Content::TooLarge);
137 }
138 let data = tree::blob(repo, side.oid)?;
139 if data[..data.len().min(8000)].contains(&0) {
140 return Ok(Content::Binary);
141 }
142 Ok(Content::Text(String::from_utf8_lossy(&data).into_owned()))
143}
144
145fn split(text: &str) -> Vec<&str> {
146 text.split_inclusive('\n').collect()
147}
148
149fn mode_change(change: &FileChange) -> Option<(&'static str, &'static str)> {
150 let (old, new) = (change.old.as_ref()?, change.new.as_ref()?);
151 (old.kind != new.kind).then(|| (diff::mode(old.kind), diff::mode(new.kind)))
152}
153
154fn is_submodule(side: Option<&Side>) -> bool {
155 submodule(side).is_some()
156}
157
158fn submodule(side: Option<&Side>) -> Option<&Side> {
159 side.filter(|s| s.kind == EntryKind::Submodule)
160}
161
162/// Diff every changed file, highlighting both sides as whole files.
163pub fn build(
164 repo: &Handle,
165 cache: &Cache,
166 changes: &Changes,
167 options: &Options,
168) -> anyhow::Result<CommitDiff> {
169 let too_many_files = changes.files.len() > options.max_files;
170 let mut out = CommitDiff {
171 files: Vec::with_capacity(changes.files.len()),
172 added: 0,
173 removed: 0,
174 too_many_files,
175 };
176 let mut budget = options.max_lines;
177 for change in &changes.files {
178 let mut file = FileDiff {
179 change: change.clone(),
180 stat: None,
181 mode_change: mode_change(change),
182 body: Body::NotShown,
183 };
184 if too_many_files {
185 out.files.push(file);
186 continue;
187 }
188 let (old_side, new_side) = (change.old.as_ref(), change.new.as_ref());
189 if is_submodule(old_side) || is_submodule(new_side) {
190 file.body = Body::Submodule {
191 old: submodule(old_side).map(|s| s.oid),
192 new: submodule(new_side).map(|s| s.oid),
193 };
194 out.files.push(file);
195 continue;
196 }
197 if old_side.map(|s| s.oid) == new_side.map(|s| s.oid) {
198 file.stat = Some((0, 0));
199 file.body = Body::Unchanged;
200 out.files.push(file);
201 continue;
202 }
203 let max = Some(options.max_blob_bytes);
204 let (old, new) = (load(repo, old_side, max)?, load(repo, new_side, max)?);
205 file.body = match (&old, &new) {
206 (Content::TooLarge, _) | (_, Content::TooLarge) => Body::TooLarge,
207 (Content::Binary, _) | (_, Content::Binary) => Body::Binary,
208 _ => {
209 let (old_text, new_text) = (old.text(), new.text());
210 let (old_lines, new_lines) = (split(old_text), split(new_text));
211 let lines = diff::lines(&old_lines, &new_lines, options.context, options.ignore_ws);
212 file.stat = Some((lines.added, lines.removed));
213 out.added += u64::from(lines.added);
214 out.removed += u64::from(lines.removed);
215 let changed = (lines.added + lines.removed) as usize;
216 if lines.hunks.is_empty() {
217 Body::Unchanged
218 } else if changed > options.max_file_lines || changed > budget {
219 Body::TooLarge
220 } else {
221 budget -= changed;
222 let html = |side: Option<&Side>, text: &str| match side {
223 Some(side) => {
224 highlight::cached_lines(
225 cache,
226 side.oid,
227 &String::from_utf8_lossy(&side.path),
228 text,
229 options.max_highlight_bytes,
230 )
231 .0
232 }
233 None => Arc::new(Vec::new()),
234 };
235 let old_html = html(old_side, old_text);
236 let new_html = html(new_side, new_text);
237 let get = |lines: &[String], i: Option<u32>| {
238 i.and_then(|i| lines.get(i as usize))
239 .cloned()
240 .unwrap_or_default()
241 };
242 Body::Hunks(
243 lines
244 .hunks
245 .iter()
246 .map(|h| Hunk {
247 header: h.header(),
248 lines: h
249 .lines
250 .iter()
251 .map(|l| Line {
252 kind: l.kind,
253 old: l.old.map(|n| n + 1),
254 new: l.new.map(|n| n + 1),
255 html: match l.kind {
256 LineKind::Removed => get(&old_html, l.old),
257 _ => get(&new_html, l.new),
258 },
259 })
260 .collect(),
261 })
262 .collect(),
263 )
264 }
265 }
266 };
267 out.files.push(file);
268 }
269 Ok(out)
270}
271
272/// A `git format-patch`-style patch of `commit` against its first parent.
273pub fn patch(repo: &Handle, commit: &CommitDetail, changes: &Changes) -> anyhow::Result<String> {
274 let mut stat = String::new();
275 let mut body = String::new();
276 let (mut files, mut added, mut removed) = (0, 0, 0);
277 let width = changes
278 .files
279 .iter()
280 .map(|f| display_path(f).chars().count())
281 .max()
282 .unwrap_or(0);
283 let mut stats = Vec::new();
284 for change in &changes.files {
285 files += 1;
286 let (a, r, bin) = file_patch(repo, change, &mut body)?;
287 added += a;
288 removed += r;
289 stats.push((display_path(change), a, r, bin));
290 }
291 let max = stats
292 .iter()
293 .map(|(_, a, r, _)| a + r)
294 .max()
295 .unwrap_or(0)
296 .max(1);
297 for (path, a, r, bin) in &stats {
298 let _ = write!(stat, " {path:<width$} | ");
299 match bin {
300 Some(sizes) => {
301 let _ = writeln!(stat, "{sizes}");
302 }
303 None => {
304 // Scale the bar like git (to at most 50 columns).
305 let scale = |n: u32| -> usize {
306 if max <= 50 {
307 n as usize
308 } else {
309 ((n as u64 * 50).div_ceil(max as u64)) as usize
310 }
311 };
312 let _ = writeln!(
313 stat,
314 "{} {}{}",
315 a + r,
316 "+".repeat(scale(*a)),
317 "-".repeat(scale(*r))
318 );
319 }
320 }
321 }
322 let plural = |n: u32, one: &str, many: &str| format!("{n} {}", if n == 1 { one } else { many });
323 let mut summary = format!(" {} changed", plural(files, "file", "files"));
324 if added > 0 || removed == 0 {
325 let _ = write!(summary, ", {}(+)", plural(added, "insertion", "insertions"));
326 }
327 if removed > 0 {
328 let _ = write!(summary, ", {}(-)", plural(removed, "deletion", "deletions"));
329 }
330
331 let (subject, rest) = commit
332 .message
333 .split_once('\n')
334 .unwrap_or((commit.message.as_str(), ""));
335 let mut out = String::new();
336 let _ = writeln!(out, "From {} Mon Sep 17 00:00:00 2001", commit.info.id);
337 let _ = writeln!(
338 out,
339 "From: {} <{}>",
340 commit.author.name, commit.author.email
341 );
342 let _ = writeln!(
343 out,
344 "Date: {}",
345 crate::render::time::rfc2822(commit.author.time, commit.author.offset)
346 );
347 let _ = writeln!(out, "Subject: [PATCH] {}", subject.trim());
348 out.push('\n');
349 let rest = rest.trim();
350 if !rest.is_empty() {
351 out.push_str(rest);
352 out.push('\n');
353 }
354 out.push_str("---\n");
355 out.push_str(&stat);
356 out.push_str(&summary);
357 out.push_str("\n\n");
358 out.push_str(&body);
359 let _ = write!(out, "-- \nferrenium {}\n\n", env!("CARGO_PKG_VERSION"));
360 Ok(out)
361}
362
363fn display_path(change: &FileChange) -> String {
364 let new = String::from_utf8_lossy(change.path()).into_owned();
365 match (&change.status, &change.old) {
366 (Status::Renamed | Status::Copied, Some(old)) => {
367 format!("{} => {new}", String::from_utf8_lossy(&old.path))
368 }
369 _ => new,
370 }
371}
372
373fn short(oid: &ObjectId) -> String {
374 oid.to_hex_with_len(7).to_string()
375}
376
377/// Append one file's `diff --git` section; returns lines added, removed,
378/// and the stat text for binary files.
379fn file_patch(
380 repo: &Handle,
381 change: &FileChange,
382 out: &mut String,
383) -> anyhow::Result<(u32, u32, Option<String>)> {
384 let path = |s: Option<&Side>| s.map(|s| String::from_utf8_lossy(&s.path).into_owned());
385 let (old, new) = (change.old.as_ref(), change.new.as_ref());
386 let a = path(old).unwrap_or_else(|| path(new).unwrap_or_default());
387 let b = path(new).unwrap_or_else(|| a.clone());
388 let _ = writeln!(out, "diff --git a/{a} b/{b}");
389 match (old, new) {
390 (None, Some(n)) => {
391 let _ = writeln!(out, "new file mode {}", diff::mode(n.kind));
392 }
393 (Some(o), None) => {
394 let _ = writeln!(out, "deleted file mode {}", diff::mode(o.kind));
395 }
396 (Some(o), Some(n)) => {
397 if o.kind != n.kind {
398 let _ = writeln!(out, "old mode {}", diff::mode(o.kind));
399 let _ = writeln!(out, "new mode {}", diff::mode(n.kind));
400 }
401 if let (Some(similarity), true) = (
402 change.similarity,
403 matches!(change.status, Status::Renamed | Status::Copied),
404 ) {
405 let verb = if change.status == Status::Copied {
406 "copy"
407 } else {
408 "rename"
409 };
410 let _ = writeln!(out, "similarity index {similarity}%");
411 let _ = writeln!(out, "{verb} from {a}");
412 let _ = writeln!(out, "{verb} to {b}");
413 }
414 }
415 (None, None) => {}
416 }
417 let old_id = old.map_or_else(|| "0000000".to_string(), |s| short(&s.oid));
418 let new_id = new.map_or_else(|| "0000000".to_string(), |s| short(&s.oid));
419 if old.map(|s| s.oid) == new.map(|s| s.oid) {
420 return Ok((0, 0, None));
421 }
422 match (old, new) {
423 (Some(o), Some(n)) if o.kind == n.kind => {
424 let _ = writeln!(out, "index {old_id}..{new_id} {}", diff::mode(n.kind));
425 }
426 _ => {
427 let _ = writeln!(out, "index {old_id}..{new_id}");
428 }
429 }
430 let minus = old.map_or_else(|| "/dev/null".to_string(), |_| format!("a/{a}"));
431 let plus = new.map_or_else(|| "/dev/null".to_string(), |_| format!("b/{b}"));
432
433 if is_submodule(old) || is_submodule(new) {
434 let _ = writeln!(out, "--- {minus}\n+++ {plus}");
435 let (o, n) = (submodule(old), submodule(new));
436 let _ = writeln!(
437 out,
438 "@@ -{} +{} @@",
439 if o.is_some() { "1" } else { "0,0" },
440 if n.is_some() { "1" } else { "0,0" }
441 );
442 if let Some(o) = o {
443 let _ = writeln!(out, "-Subproject commit {}", o.oid);
444 }
445 if let Some(n) = n {
446 let _ = writeln!(out, "+Subproject commit {}", n.oid);
447 }
448 return Ok((u32::from(n.is_some()), u32::from(o.is_some()), None));
449 }
450
451 let (old_c, new_c) = (load(repo, old, None)?, load(repo, new, None)?);
452 if matches!(old_c, Content::Binary) || matches!(new_c, Content::Binary) {
453 let _ = writeln!(out, "Binary files {minus} and {plus} differ");
454 let size = |s: Option<&Side>| -> anyhow::Result<u64> {
455 s.map_or(Ok(0), |s| tree::size(repo, s.oid))
456 };
457 let sizes = format!("Bin {} -> {} bytes", size(old)?, size(new)?);
458 return Ok((0, 0, Some(sizes)));
459 }
460 let (old_l, new_l) = (split(old_c.text()), split(new_c.text()));
461 let lines = diff::lines(&old_l, &new_l, 3, false);
462 // Like git, an empty file added or removed has no `---`/`+++` lines.
463 if lines.hunks.is_empty() {
464 return Ok((0, 0, None));
465 }
466 let _ = writeln!(out, "--- {minus}\n+++ {plus}");
467 for hunk in &lines.hunks {
468 out.push_str(&hunk.header());
469 out.push('\n');
470 for line in &hunk.lines {
471 let (sign, text) = match line.kind {
472 LineKind::Context => (' ', old_l[line.old.unwrap_or(0) as usize]),
473 LineKind::Removed => ('-', old_l[line.old.unwrap_or(0) as usize]),
474 LineKind::Added => ('+', new_l[line.new.unwrap_or(0) as usize]),
475 };
476 out.push(sign);
477 out.push_str(text);
478 if !text.ends_with('\n') {
479 out.push_str("\n\\ No newline at end of file\n");
480 }
481 }
482 }
483 Ok((lines.added, lines.removed, None))
484}