steid

@jamesgill /

15.5 KBCode·Blame·Raw
1//! Reading one commit, and comparing two revisions.
2//!
3//! Two use cases that share a renderer and a shape: both end in a [`Diff`], and both
4//! reach it through the same authorization every other read does — see
5//! [`view_repo`](super::repo::view_repo). A repository invisible on its page has no
6//! commits and nothing to compare, by construction.
7//!
8//! # Why compare is three-dot
9//!
10//! `compare_revisions` diffs the **merge base** of the two revisions against the head,
11//! not the base against the head. That is what `git diff base...head` means and it is
12//! the only definition that answers the question anybody is actually asking: *what does
13//! this branch add?* A two-dot diff also undoes everything the base gained since the
14//! branch left it, which shows up as a wall of deletions nobody made. It is also
15//! precisely what a pull request will need, which is why it is settled here rather than
16//! at the point a pull request exists.
17
18use crate::domain::{
19 Actor, CommitDetail, CommitSummary, ObjectId, OrgName, RefName, RepoName,
20 repository::{MembershipRepository, OrgRepository, RepoRepository},
21};
22
23use super::{
24 browse::MAX_RAW_BYTES,
25 diff::{Diff, parse_diff},
26 error::Result,
27 port::GitQuery,
28 repo::view_repo,
29};
30
31/// How many commits a comparison lists.
32///
33/// The same order of magnitude as the log's limit, and for the same reason: nobody
34/// reads past it, and a branch that is a thousand commits ahead needs a different page,
35/// not a longer one.
36pub const COMPARE_LOG_LIMIT: usize = 100;
37
38/// One commit and what it changed.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct CommitPage {
41 pub commit: CommitDetail,
42 pub diff: Diff,
43}
44
45/// Reads a commit and its diff.
46///
47/// The diff is against the **first parent**, which is what a commit page means by "what
48/// this commit changed" — for a merge, the other parents' changes belong to the commits
49/// that made them, and diffing against all of them would show a merge as either empty
50/// or as the whole of the branch it merged. A root commit has no parent and is diffed
51/// against nothing, so the first commit in a history shows everything it introduced
52/// rather than an empty page.
53///
54/// `Ok(None)` on the usual terms: invisible, absent, or a revision that names no commit.
55///
56/// **Three `git` processes** — two to resolve and read the commit, one for the diff.
57/// The diff takes the id the commit already resolved to, so nothing is resolved twice.
58#[allow(clippy::too_many_arguments)]
59pub async fn view_commit(
60 handle: &OrgName,
61 name: &RepoName,
62 rev: &RefName,
63 actor: &Actor,
64 orgs: &impl OrgRepository,
65 memberships: &impl MembershipRepository,
66 repos: &impl RepoRepository,
67 queries: &impl GitQuery,
68) -> Result<Option<CommitPage>> {
69 if view_repo(handle, name, actor, orgs, memberships, repos)
70 .await?
71 .is_none()
72 {
73 return Ok(None);
74 }
75
76 let Some(commit) = queries.commit(handle, name, rev).await? else {
77 return Ok(None);
78 };
79
80 let diff = diff_between(handle, name, commit.parents.first(), &commit.id, queries).await?;
81
82 Ok(Some(CommitPage { commit, diff }))
83}
84
85/// What comparing two revisions produced.
86///
87/// A parsed answer rather than an error for each way it can go nowhere: an unknown ref
88/// re-renders the form, identical refs say so, and unrelated histories are a real state
89/// of a real repository. None of the three is a fault, and turning any of them into one
90/// would put a 500 in front of a typo.
91#[derive(Debug, Clone, PartialEq, Eq)]
92pub enum Compared {
93 /// One of the two revisions names nothing in this repository.
94 UnknownRef {
95 rev: RefName,
96 },
97 /// Both revisions resolve to the same commit.
98 Identical,
99 /// The two commits share no ancestor, so there is nothing a diff could be relative
100 /// to. Happens when two histories are pushed into one repository.
101 Unrelated,
102 Ready(Box<Comparison>),
103}
104
105/// Two revisions, and what separates them.
106#[derive(Debug, Clone, PartialEq, Eq)]
107pub struct Comparison {
108 pub base: RefName,
109 pub head: RefName,
110 pub base_id: ObjectId,
111 pub head_id: ObjectId,
112 pub merge_base: ObjectId,
113 /// The commits on `head` that are not on `base`, newest first, capped at
114 /// [`COMPARE_LOG_LIMIT`].
115 pub commits: Vec<CommitSummary>,
116 /// How many there are in total, so a capped list can say what it is not showing.
117 pub total_commits: usize,
118 pub diff: Diff,
119}
120
121impl Comparison {
122 /// Whether `head` is already contained in `base` — nothing to bring across.
123 ///
124 /// The merge base being the head itself is exactly that: every commit on `head` is
125 /// reachable from `base`. The page offers the comparison the other way round rather
126 /// than showing an empty one, because that is almost always what was meant.
127 pub fn head_is_behind(&self) -> bool {
128 self.merge_base == self.head_id
129 }
130
131 /// Whether the list of commits was cut short by [`COMPARE_LOG_LIMIT`].
132 pub fn truncated(&self) -> bool {
133 self.total_commits > self.commits.len()
134 }
135}
136
137/// Compares two revisions, three-dot.
138///
139/// `Ok(None)` means the repository is invisible or absent — the same answer as
140/// everywhere else. Everything that is wrong with the *revisions* comes back as a
141/// [`Compared`] variant instead, because the page's answer to those is a rendered
142/// explanation and not a 404.
143///
144/// **Five `git` processes** at most: two to resolve, one for the merge base, then the
145/// commit list and the diff. The two resolutions run concurrently, as do the last two,
146/// so the page waits on three round trips rather than five.
147#[allow(clippy::too_many_arguments)]
148pub async fn compare_revisions(
149 handle: &OrgName,
150 name: &RepoName,
151 base: &RefName,
152 head: &RefName,
153 actor: &Actor,
154 orgs: &impl OrgRepository,
155 memberships: &impl MembershipRepository,
156 repos: &impl RepoRepository,
157 queries: &impl GitQuery,
158) -> Result<Option<Compared>> {
159 if view_repo(handle, name, actor, orgs, memberships, repos)
160 .await?
161 .is_none()
162 {
163 return Ok(None);
164 }
165
166 let (base_id, head_id) = tokio::try_join!(
167 queries.resolve(handle, name, base),
168 queries.resolve(handle, name, head),
169 )?;
170
171 // Reported one at a time, and the base first, so the form can point at the field
172 // that is wrong rather than at both.
173 let Some(base_id) = base_id else {
174 return Ok(Some(Compared::UnknownRef { rev: base.clone() }));
175 };
176 let Some(head_id) = head_id else {
177 return Ok(Some(Compared::UnknownRef { rev: head.clone() }));
178 };
179
180 if base_id == head_id {
181 return Ok(Some(Compared::Identical));
182 }
183
184 let Some(merge_base) = queries.merge_base(handle, name, &base_id, &head_id).await? else {
185 return Ok(Some(Compared::Unrelated));
186 };
187
188 // One past the cap, so the page can say the list is cut short without a second
189 // process spent counting.
190 let (commits, diff) = tokio::try_join!(
191 queries.log_between(
192 handle,
193 name,
194 Some(&merge_base),
195 &head_id,
196 COMPARE_LOG_LIMIT + 1,
197 ),
198 diff_between(handle, name, Some(&merge_base), &head_id, queries),
199 )?;
200
201 let total_commits = commits.len();
202 let mut commits = commits;
203 commits.truncate(COMPARE_LOG_LIMIT);
204
205 Ok(Some(Compared::Ready(Box::new(Comparison {
206 base: base.clone(),
207 head: head.clone(),
208 base_id,
209 head_id,
210 merge_base,
211 commits,
212 total_commits,
213 diff,
214 }))))
215}
216
217/// Reads a patch and parses it.
218///
219/// The byte cap is [`MAX_RAW_BYTES`] rather than a new number: it is already the answer
220/// to "how much of a repository may one request hold in memory", and a patch is
221/// governed by exactly that question. A diff over it is reported as truncated, with the
222/// per-file counts intact — see [`Diff`].
223async fn diff_between(
224 handle: &OrgName,
225 name: &RepoName,
226 base: Option<&ObjectId>,
227 head: &ObjectId,
228 queries: &impl GitQuery,
229) -> std::result::Result<Diff, super::port::GitQueryError> {
230 let raw = queries
231 .diff(handle, name, base, head, MAX_RAW_BYTES)
232 .await?;
233
234 Ok(parse_diff(&raw.numstat, &raw.patch, raw.truncated))
235}
236
237#[cfg(test)]
238mod tests {
239 use std::time::SystemTime;
240
241 use super::*;
242 use crate::{
243 domain::{
244 Membership, MembershipId, OrgId, Organization, RepoId, Repository, UserId, Visibility,
245 },
246 infrastructure::{
247 git::InMemoryGitQuery,
248 repository::{InMemoryMembershipRepo, InMemoryOrgRepo, InMemoryRepoRepo},
249 },
250 };
251
252 struct Fixture {
253 orgs: InMemoryOrgRepo,
254 memberships: InMemoryMembershipRepo,
255 repos: InMemoryRepoRepo,
256 handle: OrgName,
257 owner: Actor,
258 stranger: Actor,
259 }
260
261 async fn fixture(visibility: Visibility) -> Fixture {
262 let orgs = InMemoryOrgRepo::new();
263 let memberships = InMemoryMembershipRepo::new();
264 let repos = InMemoryRepoRepo::new();
265
266 let org = Organization::new(OrgId::generate(), "acme", None).expect("valid org");
267 orgs.save(&org).await.expect("save org");
268
269 let owner = UserId::generate();
270 memberships
271 .save(&Membership::new(
272 MembershipId::generate(),
273 org.id.clone(),
274 owner.clone(),
275 crate::domain::Role::Owner,
276 ))
277 .await
278 .expect("save membership");
279
280 repos
281 .save(
282 &Repository::new(
283 RepoId::generate(),
284 org.id.clone(),
285 "steid",
286 None,
287 visibility,
288 SystemTime::now(),
289 )
290 .expect("valid repository"),
291 )
292 .await
293 .expect("save repo");
294
295 Fixture {
296 orgs,
297 memberships,
298 repos,
299 handle: org.name,
300 owner: Actor::User(owner),
301 stranger: Actor::Anonymous,
302 }
303 }
304
305 fn repo_name() -> RepoName {
306 RepoName::new("steid").expect("valid repository name")
307 }
308
309 fn rev(value: &str) -> RefName {
310 RefName::new(value).expect("valid revision")
311 }
312
313 fn detail() -> CommitDetail {
314 CommitDetail {
315 id: ObjectId::from_trusted("a".repeat(40)),
316 tree: ObjectId::from_trusted("b".repeat(40)),
317 parents: vec![ObjectId::from_trusted("c".repeat(40))],
318 summary: "feat: a thing".to_owned(),
319 body: "why it was done".to_owned(),
320 author_name: "Ada Lovelace".to_owned(),
321 author_email: "ada@example.com".to_owned(),
322 authored_at: SystemTime::UNIX_EPOCH,
323 committer_name: "Ada Lovelace".to_owned(),
324 committer_email: "ada@example.com".to_owned(),
325 committed_at: SystemTime::UNIX_EPOCH,
326 }
327 }
328
329 const PATCH: &str = "\
330diff --git a/a.txt b/a.txt
331--- a/a.txt
332+++ b/a.txt
333@@ -1,1 +1,1 @@
334-old
335+new
336";
337
338 impl Fixture {
339 async fn commit(
340 &self,
341 actor: &Actor,
342 queries: &InMemoryGitQuery,
343 ) -> Result<Option<CommitPage>> {
344 view_commit(
345 &self.handle,
346 &repo_name(),
347 &rev("main"),
348 actor,
349 &self.orgs,
350 &self.memberships,
351 &self.repos,
352 queries,
353 )
354 .await
355 }
356
357 async fn compare(
358 &self,
359 actor: &Actor,
360 queries: &InMemoryGitQuery,
361 ) -> Result<Option<Compared>> {
362 compare_revisions(
363 &self.handle,
364 &repo_name(),
365 &rev("main"),
366 &rev("next"),
367 actor,
368 &self.orgs,
369 &self.memberships,
370 &self.repos,
371 queries,
372 )
373 .await
374 }
375 }
376
377 // --- view_commit --------------------------------------------------------------
378
379 #[tokio::test]
380 async fn a_commit_comes_back_with_its_parsed_diff() {
381 let f = fixture(Visibility::Public).await;
382 let queries = InMemoryGitQuery::new()
383 .with_commit(detail())
384 .with_diff(PATCH, "1\t1\ta.txt\n");
385
386 let page = f
387 .commit(&f.owner, &queries)
388 .await
389 .expect("should read")
390 .expect("found");
391
392 assert_eq!(page.commit.summary, "feat: a thing");
393 assert_eq!(page.diff.files_changed(), 1);
394 assert_eq!((page.diff.added(), page.diff.removed()), (1, 1));
395 assert_eq!(page.diff.files[0].rows.len(), 3);
396 }
397
398 #[tokio::test]
399 async fn a_revision_that_names_no_commit_is_not_found() {
400 let f = fixture(Visibility::Public).await;
401
402 assert!(
403 f.commit(&f.owner, &InMemoryGitQuery::new())
404 .await
405 .expect("should read")
406 .is_none()
407 );
408 }
409
410 #[tokio::test]
411 async fn a_private_repositorys_commits_are_invisible_to_a_stranger() {
412 // A commit message and a diff are content. Same answer as every other read.
413 let f = fixture(Visibility::Private).await;
414 let queries = InMemoryGitQuery::new()
415 .with_commit(detail())
416 .with_diff(PATCH, "1\t1\ta.txt\n");
417
418 assert!(
419 f.commit(&f.stranger, &queries)
420 .await
421 .expect("should read")
422 .is_none()
423 );
424 assert!(
425 f.commit(&f.owner, &queries)
426 .await
427 .expect("should read")
428 .is_some()
429 );
430 }
431
432 // --- compare_revisions --------------------------------------------------------
433
434 #[tokio::test]
435 async fn identical_revisions_have_nothing_to_compare() {
436 // The fake resolves every revision to the same id, which is exactly this case.
437 let f = fixture(Visibility::Public).await;
438
439 let compared = f
440 .compare(&f.owner, &InMemoryGitQuery::new())
441 .await
442 .expect("should read")
443 .expect("visible");
444
445 assert_eq!(compared, Compared::Identical);
446 }
447
448 #[tokio::test]
449 async fn an_unknown_revision_is_reported_rather_than_being_a_404() {
450 // The page re-renders its form with the reason; a 404 would discard what was
451 // typed and say nothing about which of the two was wrong.
452 let f = fixture(Visibility::Public).await;
453
454 let compared = f
455 .compare(&f.owner, &InMemoryGitQuery::empty())
456 .await
457 .expect("should read")
458 .expect("visible");
459
460 assert_eq!(compared, Compared::UnknownRef { rev: rev("main") });
461 }
462
463 #[tokio::test]
464 async fn a_private_repository_cannot_be_compared_by_a_stranger() {
465 let f = fixture(Visibility::Private).await;
466
467 assert!(
468 f.compare(&f.stranger, &InMemoryGitQuery::new())
469 .await
470 .expect("should read")
471 .is_none()
472 );
473 }
474
475 #[test]
476 fn a_head_contained_in_its_base_is_behind_it() {
477 let id = |value: &str| ObjectId::from_trusted(value.repeat(40));
478
479 let comparison = Comparison {
480 base: rev("main"),
481 head: rev("next"),
482 base_id: id("a"),
483 head_id: id("b"),
484 merge_base: id("b"),
485 commits: Vec::new(),
486 total_commits: 0,
487 diff: Diff::default(),
488 };
489
490 assert!(comparison.head_is_behind());
491 assert!(!comparison.truncated());
492 }
493}