| | @@ -0,0 +1,538 @@ |
| 1 | +//! The facts a repository's landing page states about itself. |
| 2 | +//! |
| 3 | +//! A read that composes several git calls, given a home in the application layer |
| 4 | +//! rather than left in a component. `architecture.md` allows a straightforward read to |
| 5 | +//! query directly; this is not one — it is five questions whose answers are gathered |
| 6 | +//! concurrently and whose licence detection is a rule rather than a lookup, and a page |
| 7 | +//! is the wrong place for either. |
| 8 | +//! |
| 9 | +//! Authorization is not re-implemented: like everything in |
| 10 | +//! [`browse`](super::browse), it goes through |
| 11 | +//! [`view_repo`](super::repo::view_repo), so an invisible repository has no facts. |
| 12 | + |
| 13 | +use crate::domain::{ |
| 14 | + Actor, CommitSummary, EntryKind, OrgName, RefName, RepoName, RepoPath, TagSummary, TreeEntry, |
| 15 | + repository::{MembershipRepository, OrgRepository, RepoRepository}, |
| 16 | +}; |
| 17 | + |
| 18 | +use super::{ |
| 19 | + browse::{MAX_BLOB_BYTES, RefList, organise_refs}, |
| 20 | + error::Result, |
| 21 | + port::GitQuery, |
| 22 | + repo::view_repo, |
| 23 | +}; |
| 24 | + |
| 25 | +/// What the About sidebar states about a repository. |
| 26 | +#[derive(Debug, Clone, PartialEq, Eq)] |
| 27 | +pub struct RepoFacts { |
| 28 | + /// The most recent commit on the revision being shown, for the commit bar. |
| 29 | + pub latest_commit: Option<CommitSummary>, |
| 30 | + pub commits: u64, |
| 31 | + /// Carried whole rather than as two counts, because the page that wants the counts |
| 32 | + /// also draws the revision switcher — and asking git for the ref list twice on one |
| 33 | + /// page would be a whole process spent on a number it already had. |
| 34 | + pub refs: RefList, |
| 35 | + pub latest_tag: Option<TagSummary>, |
| 36 | + pub licence: Option<Licence>, |
| 37 | +} |
| 38 | + |
| 39 | +/// The licence a repository appears to be under. |
| 40 | +/// |
| 41 | +/// `name` is `None` when a licence file is there but its text names nothing |
| 42 | +/// recognisable. That is deliberately not a guess: the page says "Licence" and links to |
| 43 | +/// the file, which is honest, where naming the wrong licence is a claim about someone's |
| 44 | +/// legal terms. |
| 45 | +#[derive(Debug, Clone, PartialEq, Eq)] |
| 46 | +pub struct Licence { |
| 47 | + /// The file it was found in, so the page can link to it. |
| 48 | + pub path: RepoPath, |
| 49 | + pub name: Option<&'static str>, |
| 50 | +} |
| 51 | + |
| 52 | +/// Gathers everything the repository page states about itself. |
| 53 | +/// |
| 54 | +/// Takes the revision and the root listing the page has already read, rather than |
| 55 | +/// reading them again: the caller has just browsed the root to render the file list, |
| 56 | +/// and a second `list_tree` would be a `git` process spent on bytes already in hand. |
| 57 | +/// |
| 58 | +/// The five git calls run concurrently, so the page waits for the slowest rather than |
| 59 | +/// for the sum. They are still five processes — see this step's note in |
| 60 | +/// `plans/progress.md` for the count and |
| 61 | +/// [0006](../../plans/decisions/0006-git-binary-behind-narrow-ports.md) for the bill |
| 62 | +/// that is eventually coming due. |
| 63 | +/// |
| 64 | +/// `Ok(None)` on the same terms as [`browse_repo`](super::browse::browse_repo): |
| 65 | +/// invisible and absent are one answer. |
| 66 | +#[allow(clippy::too_many_arguments)] |
| 67 | +pub async fn repo_summary( |
| 68 | + handle: &OrgName, |
| 69 | + name: &RepoName, |
| 70 | + rev: &RefName, |
| 71 | + root: &[TreeEntry], |
| 72 | + actor: &Actor, |
| 73 | + orgs: &impl OrgRepository, |
| 74 | + memberships: &impl MembershipRepository, |
| 75 | + repos: &impl RepoRepository, |
| 76 | + queries: &impl GitQuery, |
| 77 | +) -> Result<Option<RepoFacts>> { |
| 78 | + if view_repo(handle, name, actor, orgs, memberships, repos) |
| 79 | + .await? |
| 80 | + .is_none() |
| 81 | + { |
| 82 | + return Ok(None); |
| 83 | + } |
| 84 | + |
| 85 | + let licence_path = licence_file(root); |
| 86 | + |
| 87 | + let (log, commits, refs, latest_tag, licence_text) = tokio::try_join!( |
| 88 | + // One commit: the bar above the file list shows the latest, and nothing here |
| 89 | + // needs the rest of the history. |
| 90 | + queries.log(handle, name, rev, 1), |
| 91 | + queries.count_commits(handle, name, rev), |
| 92 | + queries.list_refs(handle, name), |
| 93 | + queries.latest_tag(handle, name), |
| 94 | + async { |
| 95 | + let Some(path) = &licence_path else { |
| 96 | + return Ok(None); |
| 97 | + }; |
| 98 | + |
| 99 | + // Read whole rather than capped at the sniffing window: the port has no |
| 100 | + // partial read, and a cap below a licence's real size comes back as "too |
| 101 | + // large" — which would leave Apache-2.0, at 11 KB, permanently unnamed. |
| 102 | + Ok(queries |
| 103 | + .read_blob(handle, name, rev, path, MAX_BLOB_BYTES) |
| 104 | + .await? |
| 105 | + .and_then(|blob| blob.content) |
| 106 | + .and_then(|bytes| String::from_utf8(bytes).ok())) |
| 107 | + }, |
| 108 | + )?; |
| 109 | + |
| 110 | + Ok(Some(RepoFacts { |
| 111 | + latest_commit: log.into_iter().next(), |
| 112 | + commits, |
| 113 | + refs: organise_refs(refs), |
| 114 | + latest_tag, |
| 115 | + licence: licence_path.map(|path| Licence { |
| 116 | + path, |
| 117 | + name: licence_text.as_deref().and_then(sniff_licence), |
| 118 | + }), |
| 119 | + })) |
| 120 | +} |
| 121 | + |
| 122 | +/// The filenames a licence lives under, lowercased. |
| 123 | +/// |
| 124 | +/// Filename-only, and a short list: this runs over a listing the page already has, so |
| 125 | +/// it costs nothing, whereas guessing at `LICENSE-MIT` and friends would multiply the |
| 126 | +/// blob reads. A repository with several licence files gets whichever comes first in |
| 127 | +/// this order. |
| 128 | +const LICENCE_FILES: [&str; 4] = ["license", "license.md", "license.txt", "copying"]; |
| 129 | + |
| 130 | +/// How much of a licence to look at. |
| 131 | +/// |
| 132 | +/// A kilobyte rather than the couple of hundred bytes a title needs, because BSD is |
| 133 | +/// identified by *which clauses it has* and the third one sits about 900 bytes in. Past |
| 134 | +/// that there is nothing left that distinguishes one licence from another. |
| 135 | +const SNIFF_BYTES: usize = 1024; |
| 136 | + |
| 137 | +/// The licence file in a root listing, if there is one. |
| 138 | +/// |
| 139 | +/// Case-insensitive, because all of `LICENSE`, `License` and `license` appear in the |
| 140 | +/// wild and mean the same thing. |
| 141 | +fn licence_file(entries: &[TreeEntry]) -> Option<RepoPath> { |
| 142 | + entries |
| 143 | + .iter() |
| 144 | + .filter(|entry| entry.kind == EntryKind::Blob) |
| 145 | + .filter_map(|entry| { |
| 146 | + let lowered = entry.name.to_ascii_lowercase(); |
| 147 | + let rank = LICENCE_FILES.iter().position(|name| *name == lowered)?; |
| 148 | + |
| 149 | + Some((rank, entry)) |
| 150 | + }) |
| 151 | + .min_by_key(|(rank, _)| *rank) |
| 152 | + // The entry came out of a root listing, so its name is a single component and |
| 153 | + // the only way this fails is a name `RepoPath` refuses — in which case there is |
| 154 | + // no link to offer and no licence to name. |
| 155 | + .and_then(|(_, entry)| RepoPath::new(&entry.name).ok()) |
| 156 | +} |
| 157 | + |
| 158 | +/// Which licence a text appears to be, by the phrases only that licence uses. |
| 159 | +/// |
| 160 | +/// Deliberately not a classifier. Every arm here is a phrase from the licence's own |
| 161 | +/// preamble, matched case-insensitively, and anything that matches nothing is `None` — |
| 162 | +/// see [`Licence`] for why a guess is worse than silence. |
| 163 | +/// |
| 164 | +/// Order matters where one licence's opening is a prefix of another's: ISC and the |
| 165 | +/// Unlicense both read like MIT for the first few words, and Affero opens with the GPL's |
| 166 | +/// own title. |
| 167 | +fn sniff_licence(text: &str) -> Option<&'static str> { |
| 168 | + let head: String = text |
| 169 | + .chars() |
| 170 | + .take(SNIFF_BYTES) |
| 171 | + .collect::<String>() |
| 172 | + .to_ascii_lowercase(); |
| 173 | + let has = |needle: &str| head.contains(needle); |
| 174 | + |
| 175 | + if has("gnu affero general public license") { |
| 176 | + return Some("AGPL-3.0"); |
| 177 | + } |
| 178 | + |
| 179 | + if has("gnu general public license") && has("version 3") { |
| 180 | + return Some("GPL-3.0"); |
| 181 | + } |
| 182 | + |
| 183 | + if has("apache license") && has("version 2.0") { |
| 184 | + return Some("Apache-2.0"); |
| 185 | + } |
| 186 | + |
| 187 | + if has("mozilla public license") && has("version 2.0") { |
| 188 | + return Some("MPL-2.0"); |
| 189 | + } |
| 190 | + |
| 191 | + if has("this is free and unencumbered software released into the public domain") { |
| 192 | + return Some("Unlicense"); |
| 193 | + } |
| 194 | + |
| 195 | + // ISC's grant is MIT's with "and/or distribute" in place of MIT's much longer list |
| 196 | + // of verbs, so it has to be ruled out before MIT is considered. |
| 197 | + if has("permission to use, copy, modify, and/or distribute") { |
| 198 | + return Some("ISC"); |
| 199 | + } |
| 200 | + |
| 201 | + if has("mit license") || has("permission is hereby granted, free of charge") { |
| 202 | + return Some("MIT"); |
| 203 | + } |
| 204 | + |
| 205 | + // The BSD family shares one opening and differs only in its clauses. |
| 206 | + if has("redistribution and use in source and binary forms") { |
| 207 | + return Some(if has("neither the name") { |
| 208 | + "BSD-3-Clause" |
| 209 | + } else { |
| 210 | + "BSD-2-Clause" |
| 211 | + }); |
| 212 | + } |
| 213 | + |
| 214 | + None |
| 215 | +} |
| 216 | + |
| 217 | +#[cfg(test)] |
| 218 | +mod tests { |
| 219 | + use std::time::{Duration, SystemTime, UNIX_EPOCH}; |
| 220 | + |
| 221 | + use super::*; |
| 222 | + use crate::{ |
| 223 | + domain::{ |
| 224 | + Membership, MembershipId, ObjectId, OrgId, Organization, RepoId, Repository, UserId, |
| 225 | + Visibility, |
| 226 | + }, |
| 227 | + infrastructure::{ |
| 228 | + git::InMemoryGitQuery, |
| 229 | + repository::{InMemoryMembershipRepo, InMemoryOrgRepo, InMemoryRepoRepo}, |
| 230 | + }, |
| 231 | + }; |
| 232 | + |
| 233 | + fn entry(name: &str, kind: EntryKind) -> TreeEntry { |
| 234 | + TreeEntry { |
| 235 | + name: name.to_owned(), |
| 236 | + kind, |
| 237 | + id: ObjectId::from_trusted("0".repeat(40)), |
| 238 | + size: Some(0), |
| 239 | + } |
| 240 | + } |
| 241 | + |
| 242 | + // --- finding the file --------------------------------------------------------- |
| 243 | + |
| 244 | + #[test] |
| 245 | + fn the_usual_licence_filenames_are_all_found() { |
| 246 | + for name in [ |
| 247 | + "LICENSE", |
| 248 | + "license", |
| 249 | + "License.md", |
| 250 | + "LICENSE.txt", |
| 251 | + "COPYING", |
| 252 | + "copying", |
| 253 | + ] { |
| 254 | + let entries = [entry(name, EntryKind::Blob)]; |
| 255 | + |
| 256 | + assert_eq!( |
| 257 | + licence_file(&entries).as_ref().map(RepoPath::as_str), |
| 258 | + Some(name), |
| 259 | + "{name} should be a licence" |
| 260 | + ); |
| 261 | + } |
| 262 | + } |
| 263 | + |
| 264 | + #[test] |
| 265 | + fn files_that_merely_mention_a_licence_are_not_the_licence() { |
| 266 | + for name in ["LICENSE-MIT", "licensing.md", "NOTICE", "README.md"] { |
| 267 | + let entries = [entry(name, EntryKind::Blob)]; |
| 268 | + |
| 269 | + assert!( |
| 270 | + licence_file(&entries).is_none(), |
| 271 | + "{name} should not be a licence" |
| 272 | + ); |
| 273 | + } |
| 274 | + } |
| 275 | + |
| 276 | + #[test] |
| 277 | + fn a_directory_called_license_is_not_a_licence() { |
| 278 | + // Reading it would ask git for a blob at a tree's path and get nothing. |
| 279 | + let entries = [entry("LICENSE", EntryKind::Tree)]; |
| 280 | + |
| 281 | + assert!(licence_file(&entries).is_none()); |
| 282 | + } |
| 283 | + |
| 284 | + #[test] |
| 285 | + fn the_plainest_spelling_wins_when_there_are_several() { |
| 286 | + let entries = [ |
| 287 | + entry("COPYING", EntryKind::Blob), |
| 288 | + entry("LICENSE", EntryKind::Blob), |
| 289 | + ]; |
| 290 | + |
| 291 | + assert_eq!( |
| 292 | + licence_file(&entries).as_ref().map(RepoPath::as_str), |
| 293 | + Some("LICENSE") |
| 294 | + ); |
| 295 | + } |
| 296 | + |
| 297 | + // --- naming it ---------------------------------------------------------------- |
| 298 | + |
| 299 | + #[test] |
| 300 | + fn the_common_licences_are_recognised_from_their_own_words() { |
| 301 | + let cases = [ |
| 302 | + ( |
| 303 | + " GNU AFFERO GENERAL PUBLIC LICENSE\n Version 3, 19 November 2007\n", |
| 304 | + "AGPL-3.0", |
| 305 | + ), |
| 306 | + ( |
| 307 | + " GNU GENERAL PUBLIC LICENSE\n Version 3, 29 June 2007\n", |
| 308 | + "GPL-3.0", |
| 309 | + ), |
| 310 | + ( |
| 311 | + " Apache License\n Version 2.0, January 2004\n", |
| 312 | + "Apache-2.0", |
| 313 | + ), |
| 314 | + ( |
| 315 | + "Mozilla Public License Version 2.0\n==================================\n", |
| 316 | + "MPL-2.0", |
| 317 | + ), |
| 318 | + ( |
| 319 | + "MIT License\n\nCopyright (c) 2026 Ada\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\n", |
| 320 | + "MIT", |
| 321 | + ), |
| 322 | + ( |
| 323 | + "Copyright (c) 2026 Ada\n\nPermission to use, copy, modify, and/or distribute this software for any\npurpose with or without fee is hereby granted.\n", |
| 324 | + "ISC", |
| 325 | + ), |
| 326 | + ( |
| 327 | + "This is free and unencumbered software released into the public domain.\n", |
| 328 | + "Unlicense", |
| 329 | + ), |
| 330 | + ]; |
| 331 | + |
| 332 | + for (text, expected) in cases { |
| 333 | + assert_eq!(sniff_licence(text), Some(expected), "for {text:?}"); |
| 334 | + } |
| 335 | + } |
| 336 | + |
| 337 | + #[test] |
| 338 | + fn the_bsd_licences_are_told_apart_by_their_third_clause() { |
| 339 | + let shared = "Copyright (c) 2026 Ada. All rights reserved.\n\nRedistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:\n\n1. Redistributions of source code must retain the above copyright notice.\n\n2. Redistributions in binary form must reproduce the above copyright notice.\n"; |
| 340 | + |
| 341 | + assert_eq!(sniff_licence(shared), Some("BSD-2-Clause")); |
| 342 | + assert_eq!( |
| 343 | + sniff_licence(&format!( |
| 344 | + "{shared}\n3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse products.\n" |
| 345 | + )), |
| 346 | + Some("BSD-3-Clause") |
| 347 | + ); |
| 348 | + } |
| 349 | + |
| 350 | + #[test] |
| 351 | + fn a_licence_it_does_not_know_is_left_unnamed_rather_than_guessed() { |
| 352 | + // The page still links to the file — see `Licence`. |
| 353 | + assert_eq!(sniff_licence("All rights reserved. Ask me nicely.\n"), None); |
| 354 | + assert_eq!(sniff_licence(""), None); |
| 355 | + } |
| 356 | + |
| 357 | + #[test] |
| 358 | + fn only_the_head_of_a_file_is_read() { |
| 359 | + // A licence that names another one deep in its text — Apache's appendix, a |
| 360 | + // vendored notice — must not change what the file itself is. |
| 361 | + let text = format!( |
| 362 | + "MIT License\n{}\nGNU AFFERO GENERAL PUBLIC LICENSE\n", |
| 363 | + "x".repeat(SNIFF_BYTES) |
| 364 | + ); |
| 365 | + |
| 366 | + assert_eq!(sniff_licence(&text), Some("MIT")); |
| 367 | + } |
| 368 | + |
| 369 | + // --- the whole read ----------------------------------------------------------- |
| 370 | + |
| 371 | + struct Fixture { |
| 372 | + orgs: InMemoryOrgRepo, |
| 373 | + memberships: InMemoryMembershipRepo, |
| 374 | + repos: InMemoryRepoRepo, |
| 375 | + handle: OrgName, |
| 376 | + owner: Actor, |
| 377 | + stranger: Actor, |
| 378 | + } |
| 379 | + |
| 380 | + /// One organisation with an owner, and a `steid` repository of the given visibility. |
| 381 | + async fn fixture(visibility: Visibility) -> Fixture { |
| 382 | + let orgs = InMemoryOrgRepo::new(); |
| 383 | + let memberships = InMemoryMembershipRepo::new(); |
| 384 | + let repos = InMemoryRepoRepo::new(); |
| 385 | + |
| 386 | + let org = Organization::new(OrgId::generate(), "acme", None).expect("valid org"); |
| 387 | + orgs.save(&org).await.expect("save org"); |
| 388 | + |
| 389 | + let owner = UserId::generate(); |
| 390 | + memberships |
| 391 | + .save(&Membership::new( |
| 392 | + MembershipId::generate(), |
| 393 | + org.id.clone(), |
| 394 | + owner.clone(), |
| 395 | + crate::domain::Role::Owner, |
| 396 | + )) |
| 397 | + .await |
| 398 | + .expect("save membership"); |
| 399 | + |
| 400 | + repos |
| 401 | + .save( |
| 402 | + &Repository::new( |
| 403 | + RepoId::generate(), |
| 404 | + org.id.clone(), |
| 405 | + "steid", |
| 406 | + None, |
| 407 | + visibility, |
| 408 | + SystemTime::now(), |
| 409 | + ) |
| 410 | + .expect("valid repository"), |
| 411 | + ) |
| 412 | + .await |
| 413 | + .expect("save repo"); |
| 414 | + |
| 415 | + Fixture { |
| 416 | + orgs, |
| 417 | + memberships, |
| 418 | + repos, |
| 419 | + handle: org.name, |
| 420 | + owner: Actor::User(owner), |
| 421 | + stranger: Actor::Anonymous, |
| 422 | + } |
| 423 | + } |
| 424 | + |
| 425 | + impl Fixture { |
| 426 | + async fn facts( |
| 427 | + &self, |
| 428 | + actor: &Actor, |
| 429 | + root: &[TreeEntry], |
| 430 | + queries: &InMemoryGitQuery, |
| 431 | + ) -> Result<Option<RepoFacts>> { |
| 432 | + repo_summary( |
| 433 | + &self.handle, |
| 434 | + &RepoName::new("steid").expect("valid repository name"), |
| 435 | + &RefName::from_trusted("main"), |
| 436 | + root, |
| 437 | + actor, |
| 438 | + &self.orgs, |
| 439 | + &self.memberships, |
| 440 | + &self.repos, |
| 441 | + queries, |
| 442 | + ) |
| 443 | + .await |
| 444 | + } |
| 445 | + } |
| 446 | + |
| 447 | + fn commit(summary: &str) -> CommitSummary { |
| 448 | + CommitSummary { |
| 449 | + id: ObjectId::from_trusted("a".repeat(40)), |
| 450 | + summary: summary.to_owned(), |
| 451 | + author_name: "Ada Lovelace".to_owned(), |
| 452 | + committed_at: UNIX_EPOCH + Duration::from_secs(1_700_000_000), |
| 453 | + } |
| 454 | + } |
| 455 | + |
| 456 | + #[tokio::test] |
| 457 | + async fn the_facts_come_back_composed_from_every_source() { |
| 458 | + let f = fixture(Visibility::Public).await; |
| 459 | + let root = [ |
| 460 | + entry("src", EntryKind::Tree), |
| 461 | + entry("LICENSE", EntryKind::Blob), |
| 462 | + ]; |
| 463 | + let queries = InMemoryGitQuery::new() |
| 464 | + .with_log(vec![commit("feat: a global top bar")]) |
| 465 | + .with_commit_count(128) |
| 466 | + .with_branch("main") |
| 467 | + .with_branch("next") |
| 468 | + .with_tag("v0.1.0") |
| 469 | + .with_latest_tag("v0.1.0", UNIX_EPOCH + Duration::from_secs(1_700_000_000)) |
| 470 | + .with_blob( |
| 471 | + "main", |
| 472 | + "LICENSE", |
| 473 | + b" GNU AFFERO GENERAL PUBLIC LICENSE\n Version 3, 19 November 2007\n".to_vec(), |
| 474 | + ); |
| 475 | + |
| 476 | + let facts = f |
| 477 | + .facts(&f.owner, &root, &queries) |
| 478 | + .await |
| 479 | + .expect("should read") |
| 480 | + .expect("visible"); |
| 481 | + |
| 482 | + assert_eq!( |
| 483 | + facts.latest_commit.map(|commit| commit.summary), |
| 484 | + Some("feat: a global top bar".to_owned()) |
| 485 | + ); |
| 486 | + assert_eq!(facts.commits, 128); |
| 487 | + assert_eq!(facts.refs.branches.len(), 2); |
| 488 | + assert_eq!(facts.refs.tags.len(), 1); |
| 489 | + assert_eq!( |
| 490 | + facts.latest_tag.map(|tag| tag.name.to_string()), |
| 491 | + Some("v0.1.0".to_owned()) |
| 492 | + ); |
| 493 | + assert_eq!( |
| 494 | + facts.licence, |
| 495 | + Some(Licence { |
| 496 | + path: RepoPath::from_trusted("LICENSE"), |
| 497 | + name: Some("AGPL-3.0"), |
| 498 | + }) |
| 499 | + ); |
| 500 | + } |
| 501 | + |
| 502 | + #[tokio::test] |
| 503 | + async fn a_repository_with_no_licence_file_states_no_licence() { |
| 504 | + // The sparse state: nothing is read, and nothing is claimed. |
| 505 | + let f = fixture(Visibility::Public).await; |
| 506 | + let root = [entry("src", EntryKind::Tree)]; |
| 507 | + |
| 508 | + let facts = f |
| 509 | + .facts(&f.owner, &root, &InMemoryGitQuery::new()) |
| 510 | + .await |
| 511 | + .expect("should read") |
| 512 | + .expect("visible"); |
| 513 | + |
| 514 | + assert_eq!(facts.licence, None); |
| 515 | + assert_eq!(facts.latest_commit, None); |
| 516 | + assert_eq!(facts.commits, 0); |
| 517 | + } |
| 518 | + |
| 519 | + #[tokio::test] |
| 520 | + async fn a_private_repositorys_facts_are_invisible_to_a_stranger() { |
| 521 | + // A commit count and a branch list are content. Same answer as the page. |
| 522 | + let f = fixture(Visibility::Private).await; |
| 523 | + let queries = InMemoryGitQuery::new().with_branch("secret-work"); |
| 524 | + |
| 525 | + assert!( |
| 526 | + f.facts(&f.stranger, &[], &queries) |
| 527 | + .await |
| 528 | + .expect("should read") |
| 529 | + .is_none() |
| 530 | + ); |
| 531 | + assert!( |
| 532 | + f.facts(&f.owner, &[], &queries) |
| 533 | + .await |
| 534 | + .expect("should read") |
| 535 | + .is_some() |
| 536 | + ); |
| 537 | + } |
| 538 | +} |