Skip to content

Commit d445c9a

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

File tree

6 files changed

+358
-2
lines changed

6 files changed

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

0 commit comments

Comments
 (0)