Skip to content

Commit a61c8bb

Browse files
committed
Introduce a range-diff viewer for GitHub force-push
1 parent 349bce4 commit a61c8bb

File tree

6 files changed

+355
-2
lines changed

6 files changed

+355
-2
lines changed

Cargo.lock

Lines changed: 23 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ clap = { version = "4", features = ["derive"] }
4848
hmac = "0.12.1"
4949
subtle = "2.6.1"
5050
sha2 = "0.10.9"
51+
imara-diff = "0.2.0"
52+
pulldown-cmark-escape = "0.11.0"
5153

5254
[dependencies.serde]
5355
version = "1"

src/gh_range_diff.rs

Lines changed: 313 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,313 @@
1+
use std::collections::HashSet;
2+
use std::fmt::{self, Write};
3+
use std::sync::Arc;
4+
5+
use anyhow::Context as _;
6+
use axum::{
7+
extract::{Path, State},
8+
http::HeaderValue,
9+
response::IntoResponse,
10+
};
11+
use hyper::header::CACHE_CONTROL;
12+
use hyper::{
13+
HeaderMap, StatusCode,
14+
header::{CONTENT_SECURITY_POLICY, CONTENT_TYPE},
15+
};
16+
use imara_diff::{
17+
Algorithm, Diff, InternedInput, Interner, Token, UnifiedDiffConfig, UnifiedDiffPrinter,
18+
};
19+
use pulldown_cmark_escape::FmtWriter;
20+
21+
use crate::{github, handlers::Context, utils::AppError};
22+
23+
pub async fn gh_range_diff(
24+
Path((owner, repo, basehead)): Path<(String, String, String)>,
25+
State(ctx): State<Arc<Context>>,
26+
) -> axum::response::Result<impl IntoResponse, AppError> {
27+
let Some((oldhead, newhead)) = basehead.split_once("..") else {
28+
return Ok((
29+
StatusCode::BAD_REQUEST,
30+
HeaderMap::new(),
31+
format!("`{basehead}` is not in the form `base..head`"),
32+
));
33+
};
34+
35+
// Configure unified diff
36+
let config = UnifiedDiffConfig::default();
37+
38+
let repos = ctx
39+
.team
40+
.repos()
41+
.await
42+
.context("unable to retrieve team repos")?;
43+
44+
// Verify that the request org is part of the Rust project
45+
let Some(repos) = repos.repos.get(&owner) else {
46+
return Ok((
47+
StatusCode::BAD_REQUEST,
48+
HeaderMap::new(),
49+
format!("organization `{owner}` is not part of the Rust Project team repos"),
50+
));
51+
};
52+
53+
// Verify that the request repo is part of the Rust project
54+
if !repos.iter().any(|r| r.name == repo) {
55+
return Ok((
56+
StatusCode::BAD_REQUEST,
57+
HeaderMap::new(),
58+
format!("repository `{owner}` is not part of the Rust Project team repos"),
59+
));
60+
}
61+
62+
let repo = github::IssueRepository {
63+
organization: owner.to_string(),
64+
repository: repo.to_string(),
65+
};
66+
67+
// Determine the oldbase and get the comparison for the old diff
68+
let old = async {
69+
// We need to determine the oldbase (ie. the parent sha of all the commits of old).
70+
// Fortunatly GitHub compare API returns the the merge base commit when comparing
71+
// two different sha.
72+
//
73+
// Unformtunatly for us we don't know in which tree the parent is (could be master, beta, stable, ...)
74+
// so for now we assume that the parent is in the default branch (that we hardcore for now to "master").
75+
//
76+
// We therefore compare those the master and oldhead to get a guess of the oldbase.
77+
//
78+
// As an optimization we compare them in reverse to speed up things. The resulting
79+
// patches won't be correct, but we only care about the merge base commit which
80+
// is always correct no matter the order.
81+
let oldbase = ctx
82+
.github
83+
.compare(&repo, "master", oldhead)
84+
.await
85+
.context("failed to retrive the comparison between newhead and oldhead")?
86+
.merge_base_commit
87+
.sha;
88+
89+
// Get the comparison between the oldbase..oldhead
90+
let mut old = ctx
91+
.github
92+
.compare(&repo, &oldbase, oldhead)
93+
.await
94+
.with_context(|| {
95+
format!("failed to retrive the comparison between {oldbase} and {oldhead}")
96+
})?;
97+
98+
// Sort by filename, so it's consistent with GitHub UI
99+
old.files
100+
.sort_unstable_by(|f1, f2| f1.filename.cmp(&f2.filename));
101+
102+
anyhow::Result::<_>::Ok((oldbase, old))
103+
};
104+
105+
// Determine the newbase and get the comparison for the new diff
106+
let new = async {
107+
// Get the newbase from comparing master and newhead.
108+
//
109+
// See the comment above on old for more details.
110+
let newbase = ctx
111+
.github
112+
.compare(&repo, "master", newhead)
113+
.await
114+
.context("failed to retrive the comparison between master and newhead")?
115+
.merge_base_commit
116+
.sha;
117+
118+
// Get the comparison between the newbase..newhead
119+
let mut new = ctx
120+
.github
121+
.compare(&repo, &newbase, newhead)
122+
.await
123+
.with_context(|| {
124+
format!("failed to retrive the comparison between {newbase} and {newhead}")
125+
})?;
126+
127+
// Sort by filename, so it's consistent with GitHub UI
128+
new.files
129+
.sort_unstable_by(|f1, f2| f1.filename.cmp(&f2.filename));
130+
131+
anyhow::Result::<_>::Ok((newbase, new))
132+
};
133+
134+
// Wait for both futures and early exit if there is an error
135+
let ((oldbase, old), (newbase, new)) = futures::try_join!(old, new)?;
136+
137+
// Create the HTML buffer with a very rough approximation for the capacity
138+
let mut html: String = String::with_capacity(800 + old.files.len() * 100);
139+
140+
// Write HTML header, style, ...
141+
writeln!(
142+
&mut html,
143+
r#"<!DOCTYPE html>
144+
<html lang="en" translate="no">
145+
<head>
146+
<meta charset="UTF-8">
147+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
148+
<link rel="icon" sizes="32x32" type="image/png" href="https://www.rust-lang.org/static/images/favicon-32x32.png">
149+
<title>range-diff of {oldbase}...{oldhead} {newbase}...{newhead}</title>
150+
<style>
151+
body {{
152+
font: 14px SFMono-Regular, Consolas, Liberation Mono, Menlo, monospace;
153+
}}
154+
@media (prefers-color-scheme: dark) {{
155+
body {{
156+
background: #0C0C0C;
157+
color: #CCC;
158+
}}
159+
a {{
160+
color: #41a6ff;
161+
}}
162+
}}
163+
details {{
164+
white-space: pre;
165+
}}
166+
summary {{
167+
font-weight: 800;
168+
}}
169+
</style>
170+
</head>
171+
<body>
172+
<h3>range-diff of {oldbase}..{oldhead} {newbase}..{newhead}</h3>
173+
"#
174+
)?;
175+
176+
let mut process_diffs = |filename, old_patch, new_patch| -> anyhow::Result<()> {
177+
// Prepare input
178+
let input: InternedInput<&str> = InternedInput::new(old_patch, new_patch);
179+
180+
// Compute the diff
181+
let mut diff = Diff::compute(Algorithm::Histogram, &input);
182+
183+
// Run postprocessing to improve hunk boundaries
184+
diff.postprocess_lines(&input);
185+
186+
// Determine if there are any differences
187+
let has_hunks = diff.hunks().next().is_some();
188+
189+
if has_hunks {
190+
let printer = HtmlDiffPrinter(&input.interner);
191+
let diff = diff.unified_diff(&printer, config.clone(), &input);
192+
193+
writeln!(
194+
html,
195+
"<details open=\"\"><summary>{filename}</summary>\n{diff}\n</details>"
196+
)?;
197+
}
198+
Ok(())
199+
};
200+
201+
let mut seen_files = HashSet::<&str>::new();
202+
203+
// Process the old files
204+
for old_file in &old.files {
205+
let filename = &*old_file.filename;
206+
207+
let new_file_patch = new
208+
.files
209+
.iter()
210+
.find(|f| f.filename == filename)
211+
.map(|f| &*f.patch)
212+
.unwrap_or_default();
213+
214+
seen_files.insert(filename);
215+
216+
process_diffs(filename, &*old_file.patch, new_file_patch)?;
217+
}
218+
219+
// Process the not yet seen new files
220+
for new_file in &new.files {
221+
let filename = &*new_file.filename;
222+
223+
if seen_files.contains(filename) {
224+
continue;
225+
}
226+
227+
process_diffs(filename, "", &*new_file.patch)?;
228+
}
229+
230+
writeln!(
231+
html,
232+
r#"
233+
</body>
234+
</html>
235+
"#
236+
)?;
237+
238+
let mut headers = HeaderMap::new();
239+
headers.insert(
240+
CONTENT_TYPE,
241+
HeaderValue::from_static("text/html; charset=utf-8"),
242+
);
243+
headers.insert(
244+
CACHE_CONTROL,
245+
HeaderValue::from_static("public, max-age=15552000, immutable"),
246+
);
247+
headers.insert(
248+
CONTENT_SECURITY_POLICY,
249+
HeaderValue::from_static(
250+
"default-src 'none'; style-src 'unsafe-inline'; img-src www.rust-lang.org",
251+
),
252+
);
253+
254+
Ok((StatusCode::OK, headers, html))
255+
}
256+
257+
struct HtmlDiffPrinter<'a>(pub &'a Interner<&'a str>);
258+
259+
impl UnifiedDiffPrinter for HtmlDiffPrinter<'_> {
260+
fn display_header(
261+
&self,
262+
_f: impl fmt::Write,
263+
_start_before: u32,
264+
_start_after: u32,
265+
_len_before: u32,
266+
_len_after: u32,
267+
) -> fmt::Result {
268+
// ignore the header as does not represent anything meaningful for the range-diff
269+
Ok(())
270+
}
271+
272+
fn display_context_token(&self, mut f: impl fmt::Write, token: Token) -> fmt::Result {
273+
let token = self.0[token];
274+
write!(f, " ")?;
275+
pulldown_cmark_escape::escape_html(FmtWriter(&mut f), token)?;
276+
if !token.ends_with('\n') {
277+
writeln!(f)?;
278+
}
279+
Ok(())
280+
}
281+
282+
fn display_hunk(
283+
&self,
284+
mut f: impl fmt::Write,
285+
before: &[Token],
286+
after: &[Token],
287+
) -> fmt::Result {
288+
if let Some(&last) = before.last() {
289+
for &token in before {
290+
let token = self.0[token];
291+
write!(f, r#"<span style="color:red;">-"#)?;
292+
pulldown_cmark_escape::escape_html(FmtWriter(&mut f), token)?;
293+
write!(f, "</span>")?;
294+
}
295+
if !self.0[last].ends_with('\n') {
296+
writeln!(f)?;
297+
}
298+
}
299+
300+
if let Some(&last) = after.last() {
301+
for &token in after {
302+
let token = self.0[token];
303+
write!(f, r#"<span style="color:green;">+"#)?;
304+
pulldown_cmark_escape::escape_html(FmtWriter(&mut f), token)?;
305+
write!(f, "</span>")?;
306+
}
307+
if !self.0[last].ends_with('\n') {
308+
writeln!(f)?;
309+
}
310+
}
311+
Ok(())
312+
}
313+
}

0 commit comments

Comments
 (0)