//! Page: repository home at `/{owner}/{repo}`. Shows the default branch's root tree, a Clone //! popover and the README, with a branch aside. An empty repo shows the clone popover and push //! instructions instead. use std::collections::{HashMap, HashSet}; use std::path::PathBuf; use std::time::Duration; use forge_core::Config; use forge_core::Repo as RepoRecord; use forge_core::WatchLevel; use forge_core::time::stamp_before; use forge_git::browse; use forge_git::repo::repo_path; use topcoat::Result; use topcoat::context::{Cx, app_context}; use topcoat::router::error::not_found; use topcoat::router::{page, path_param, route}; use topcoat::view::{View, component, view}; use crate::i18n::code::{self as g, K}; use crate::i18n::{self, Lang}; use crate::pages::dashboard::badge_class; use crate::pages::issue_parts::stamp_unix; use crate::pages::notifications::watch_form; use crate::pages::refs::load_refs; use crate::pages::releases::latest_card; use crate::pages::repo_ctx::{RepoCtx, RepoHead, load_repo}; use crate::pages::tree::{ entry_rows, last_commits, load_readme, read_dir, readme_card, tree_table, }; use crate::state::AppState; use crate::views::{now_unix, relative_time, repo_href, repo_tabs, shell, user_link}; path_param!(owner); path_param!(repo); /// HTTPS clone URL: `{base_url}/{owner}/{repo}.git`. pub fn https_clone_url(config: &Config, owner: &str, name: &str) -> String { format!( "{}/{owner}/{name}.git", config.base_url.trim_end_matches('/') ) } /// SSH clone URL. Port 22 uses the scp-like form `git@host:owner/repo.git`. pub fn ssh_clone_url(config: &Config, owner: &str, name: &str) -> String { let host = &config.ssh_public_host; if config.ssh_public_port == 22 { format!("git@{host}:{owner}/{name}.git") } else { format!( "ssh://git@{host}:{}/{owner}/{name}.git", config.ssh_public_port ) } } /// Header shared by the repo pages: title, visibility, the fork line, the fork and star controls, /// the archived notice, description and the header tabs. Built by `RepoCtx::head`; every repo page /// passes one. #[component] pub async fn repo_head(head: RepoHead) -> Result { let RepoHead { owner, name, default_branch, visibility, description, archived, active, can_admin, open_issues, open_pulls, forked_from, can_fork, stars, star, watchers, watch, mirror, discussions, wiki, security, lang, } = head; // The title gets its own copies: the view below moves `owner` and `name` into `repo_tabs`. let title_owner = owner.clone(); let title_name = name.clone(); let badge = badge_class(visibility); let vis = i18n::visibility_label(lang, visibility); let active = active.to_owned(); // The fork button posts with the same session CSRF token as the star form. let fork_control = match (can_fork, star.as_ref()) { (true, Some(star)) => Some((repo_href(&owner, &name, "fork", ""), star.csrf.clone())), _ => None, }; let fork_line = forked_from.map(|(source_owner, source_name)| { ( repo_href(&source_owner, &source_name, "", ""), format!("{source_owner}/{source_name}"), ) }); // Signed-in viewers get a button that stars or unstars; anonymous viewers see the count only. let star_control = match star { Some(star) => { let (path, label) = if star.starred { ("unstar", g::star_label(lang, true, stars)) } else { ("star", g::star_label(lang, false, stars)) }; Some((repo_href(&owner, &name, path, ""), label, star.csrf)) } None => None, }; let anon_count = format!("\u{2605} {stars}"); // Signed-in viewers get the watch level select; everyone gets the watcher count. let watch_control = watch.map(|watch| { let level = match watch.level { WatchLevel::Participating => "participating", WatchLevel::Watching => "watching", WatchLevel::Ignoring => "ignoring", }; (repo_href(&owner, &name, "watch", ""), watch.csrf, level) }); let watch_count = g::watching_count(lang, watchers); let search_action = repo_href(&owner, &name, "search", ""); let mirror_line = mirror.as_ref().map(|source| source.url.clone()); let mirror_status = match mirror { Some(source) => match (source.last_sync, source.last_ok) { (Some(at), Some(true)) => g::mirror_synced(lang, &at), (Some(at), _) => g::mirror_sync_failed(lang, &at), (None, _) => g::t(lang, K::NotSyncedYet).to_owned(), }, None => String::new(), }; Ok(view! {

user_link(shown: title_owner.as_str(), login: title_owner.as_str()) "/" (title_name.as_str())

(vis)
if let Some((href, label)) = fork_line {

(g::t(lang, K::ForkedFrom)) (label.as_str())

} if let Some(source) = mirror_line {

(g::t(lang, K::MirrorOf)) (source.as_str()) ", " (mirror_status.as_str())

}
if let Some((action, csrf)) = fork_control {
} if let Some((action, csrf, level)) = watch_control { watch_form(lang: lang, action: action, csrf: csrf, level: level, watchers: watchers) } else { (watch_count.as_str()) }
if let Some((action, label, csrf)) = star_control {
} else { (anon_count.as_str()) }
if archived {
(g::t(lang, K::ArchivedNotice))
} // The description and code search belong to the Code tab only; sub-pages keep the compact head. if active == "code" && !description.is_empty() {

(description.as_str())

} if active == "code" { } repo_tabs(owner: owner, repo: name, default_branch: default_branch, active: active, can_admin: can_admin, lang: lang, open_issues: Some(open_issues), open_pulls: Some(open_pulls), discussions: discussions, wiki: wiki, security: security) }) } /// Clone button that opens a popover with both clone URLs as selectable read-only fields. #[component] async fn clone_popover(https: String, ssh: String, lang: Lang) -> Result { Ok(view! {
(g::t(lang, K::CloneTitle))

(g::t(lang, K::SshRequiresKey))

}) } /// Shown for an empty repo (or one whose default branch is missing): the branches that exist, /// or push instructions when there are none. #[component] async fn branches_card( branches: Vec, push_https: String, push_ssh: String, lang: Lang, ) -> Result { let empty = branches.is_empty(); Ok(view! {

(g::t(lang, K::BranchesTitle))

if empty {

(g::t(lang, K::EmptyRepoPush))

(g::t(lang, K::OverHttps))

(push_https)

(g::t(lang, K::OverSsh))

(push_ssh)
} else {
    for branch in branches.into_iter() {
  • (branch)
  • }
}
}) } /// Download links for the archive of `ref_name`. #[component] async fn archive_links( owner: String, repo: String, ref_name: String, lang: Lang, ) -> Result { let tar = repo_href(&owner, &repo, "archive", &format!("{ref_name}.tar.gz")); let zip = repo_href(&owner, &repo, "archive", &format!("{ref_name}.zip")); Ok(view! { }) } async fn default_commit(path: PathBuf, branch: String) -> Result> { Ok( tokio::task::spawn_blocking(move || browse::default_branch_commit(&path, &branch)) .await??, ) } /// Most recent-push banners the repo home shows. const MAX_PUSH_BANNERS: usize = 3; /// How far back a push still counts as recent. const RECENT_PUSH_WINDOW: Duration = Duration::from_secs(60 * 60); /// One "had recent pushes" banner: a branch the viewer pushed to, with its compare link. struct PushBanner { branch: String, at_unix: i64, href: String, } /// Recent-push banners for the signed-in viewer. Candidates are the branches the viewer pushed to /// in this repo and in their own forks of it, pushed within the last hour, newest first. A branch /// is skipped when it is the default branch, is gone, or already has an open pull request. A fork /// links to its parent's compare page when the parent is visible (`forked_from`); otherwise the /// compare page is this repo's, with the head as `owner:branch` for a fork. async fn recent_push_banners( state: &AppState, ctx: &RepoCtx, forked_from: Option<(String, String)>, ) -> Result> { let Some((user, _)) = &ctx.viewer else { return Ok(Vec::new()); }; let repo = &ctx.repo; let base = match forked_from { Some((owner, name)) => state .store .repo_by_owner_and_name(&owner, &name) .await? .unwrap_or_else(|| repo.clone()), None => repo.clone(), }; // Forks the viewer owns, or that an organization they hold a role in owns. let mut forks: Vec = Vec::new(); for fork in state.store.list_forks(&repo.id).await? { let mine = fork.owner_id == user.id || state .store .org_repo_role(&fork.owner_id, &user.id, &fork.id) .await? .is_some(); if mine { forks.push(fork); } } let mut candidates: Vec<&RepoRecord> = vec![repo]; candidates.extend(forks.iter()); let ids: Vec<&str> = candidates.iter().map(|r| r.id.as_str()).collect(); let since = stamp_before(RECENT_PUSH_WINDOW); let pushes = state .store .recent_branch_pushes(&ids, &user.id, &since) .await?; let mut existing: HashMap> = HashMap::new(); let mut banners = Vec::new(); for (repo_id, branch, at) in pushes { if banners.len() >= MAX_PUSH_BANNERS { break; } let Some(pushed) = candidates.iter().find(|r| r.id == repo_id) else { continue; }; if branch == pushed.default_branch { continue; } if !existing.contains_key(&pushed.id) { let path = repo_path(&state.config.data_dir, &pushed.id)?; let names: HashSet = load_refs(path) .await? .into_iter() .filter(|r| r.kind == browse::RefKind::Branch) .map(|r| r.name) .collect(); existing.insert(pushed.id.clone(), names); } if !existing[&pushed.id].contains(&branch) { continue; } if state .store .find_open_pull(&base.id, &base.default_branch, &pushed.id, &branch) .await? .is_some() { continue; } let head = if pushed.id == base.id { branch.clone() } else { format!("{}:{branch}", pushed.owner_name) }; let href = repo_href( &base.owner_name, &base.name, "compare", &format!("{}...{head}", base.default_branch), ); banners.push(PushBanner { branch, at_unix: stamp_unix(&at), href, }); } Ok(banners) } /// The "had recent pushes" banners above the code tab. #[component] async fn recent_pushes(banners: Vec, now: i64, lang: Lang) -> Result { Ok(view! {
for banner in banners.into_iter() {

(banner.branch) (g::t(lang, K::HadRecentPushes)) (relative_time(lang, banner.at_unix, now)) " — " (g::t(lang, K::CompareAndPr))

}
}) } #[page("/{owner}/{repo}")] async fn repo_page(cx: &Cx) -> Result { let owner = path_param::(cx); let name = path_param::(cx); let ctx = load_repo(cx, owner, name).await?; let state = app_context::(cx); let lang = ctx.lang; let repo = &ctx.repo; let branch = repo.default_branch.clone(); let refs = load_refs(ctx.path.clone()).await?; let head = default_commit(ctx.path.clone(), branch.clone()).await?; let https = https_clone_url(&state.config, &repo.owner_name, &repo.name); let ssh = ssh_clone_url(&state.config, &repo.owner_name, &repo.name); let push_https = format!("git remote add origin {https}\ngit push -u origin {branch}"); let push_ssh = format!("git remote add origin {ssh}\ngit push -u origin {branch}"); let title = format!("{}/{}", repo.owner_name, repo.name); // The tree and README exist only when the default branch has a commit. let mut tree = None; let mut readme = None; if let Some(commit) = &head && let Some(entries) = read_dir(ctx.path.clone(), commit.clone(), String::new()).await? { let names: Vec = entries.iter().map(|e| e.name.clone()).collect(); let last = last_commits(state, &ctx.path, commit, "", &names).await; readme = load_readme(state, repo, &ctx.path, &branch, commit, "", &entries).await?; tree = Some(entry_rows( lang, repo, &branch, "", entries, &last, now_unix(), ctx.viewer_tz(), )); } let branch_names: Vec = refs .into_iter() .filter(|r| r.kind == browse::RefKind::Branch) .map(|r| r.name) .collect(); let owner_name = repo.owner_name.clone(); let repo_name = repo.name.clone(); let header = ctx.head(state, "code").await?; let latest = crate::pages::releases::latest_release_for_home(state, &ctx).await?; let banners = recent_push_banners(state, &ctx, header.forked_from.clone()).await?; let now = now_unix(); Ok(view! { shell(title: title.as_str(), viewer: ctx.viewer.as_ref(), repo_head(head: header) if let Some(latest) = latest { latest_card(release: latest, lang: lang) } if !banners.is_empty() { recent_pushes(banners: banners, now: now, lang: lang) } if let Some(rows) = tree {
(g::t(lang, K::BranchLabel)) (branch.clone()) archive_links(owner: owner_name, repo: repo_name, ref_name: branch.clone(), lang: lang) clone_popover(https: https, ssh: ssh, lang: lang)
tree_table(rows: rows, lang: lang) if let Some(readme) = readme { readme_card(name: readme.name, markdown: readme.markdown, body: readme.body) }
} else {
clone_popover(https: https, ssh: ssh, lang: lang)
branches_card(branches: branch_names, push_https: push_https, push_ssh: push_ssh, lang: lang) } ) }) } /// Other methods on a repo path get a plain 404, not a 405 that would reveal the route shape. #[route(* "/{owner}/{repo}")] async fn repo_other_methods() -> Result<&'static str> { Err(not_found().into()) } #[cfg(test)] mod tests { use std::path::Path; use std::process::Command; use std::time::Duration; use forge_core::Visibility; use forge_core::time::stamp_before; use forge_git::repo::{init_bare, repo_path}; use topcoat::router::request::Request; use topcoat::router::{Body, Router, to_bytes}; use crate::auth::SESSION_COOKIE; use crate::state::AppState; async fn app(dir: &Path) -> (Router, AppState) { let state = crate::test_support::test_state(dir).await; (crate::test_support::app_router(state.clone()), state) } fn git(dir: &Path, args: &[&str]) { let status = Command::new("git") .current_dir(dir) .env("GIT_CONFIG_NOSYSTEM", "1") .args([ "-c", "user.name=Test", "-c", "user.email=test@example.com", "-c", "commit.gpgsign=false", ]) .args(args) .status() .expect("git runs"); assert!(status.success(), "git {args:?} failed"); } /// `alice/demo` (public, default branch `main` with content) and `alice/secret` (private, empty). async fn fixture(dir: &Path) -> (Router, AppState) { let (router, state) = app(dir).await; let alice = state .store .create_user("alice", None, "x", false) .await .unwrap(); let demo = state .store .create_repo(&alice.id, "demo", "A demo repo", Visibility::Public, "main") .await .unwrap(); let secret = state .store .create_repo(&alice.id, "secret", "", Visibility::Private, "main") .await .unwrap(); for repo in [&demo, &secret] { let path = repo_path(&state.config.data_dir, &repo.id).unwrap(); init_bare(&state.git, &path, "main").await.unwrap(); } let work = dir.join("work"); std::fs::create_dir_all(work.join("src")).unwrap(); std::fs::create_dir_all(work.join("docs")).unwrap(); std::fs::write(work.join("README.md"), "# Demo\n\nHello from the README.\n").unwrap(); std::fs::write( work.join("src/main.rs"), "fn main() {\n println!(\"hi\");\n}\n", ) .unwrap(); std::fs::write( work.join("docs/guide.md"), "# Guide\n\n| a | b |\n|---|---|\n| 1 | 2 |\n", ) .unwrap(); std::fs::write(work.join("data.bin"), [0u8, 1, 2]).unwrap(); git(&work, &["init", "-q", "-b", "main"]); git(&work, &["add", "-A"]); git(&work, &["commit", "-q", "-m", "first commit"]); let demo_path = repo_path(&state.config.data_dir, &demo.id).unwrap(); git( &work, &["push", "-q", demo_path.to_str().unwrap(), "main:main"], ); (router, state) } async fn get(router: &Router, uri: &str) -> (u16, String) { let req = Request::builder() .method("GET") .uri(uri) .body(Body::empty()) .unwrap(); let res = router.handle(req).await; let status = res.status().as_u16(); let bytes = to_bytes(res.into_body(), usize::MAX).await.unwrap(); (status, String::from_utf8_lossy(&bytes).into_owned()) } #[tokio::test] async fn home_renders_tree_clone_popover_and_readme() { let dir = tempfile::tempdir().unwrap(); let (router, _) = fixture(dir.path()).await; let (status, body) = get(&router, "/alice/demo").await; assert_eq!(status, 200); assert!(body.contains("Hello from the README"), "README rendered"); assert!( body.contains("/alice/demo/tree/main/src"), "root tree listed" ); assert!( body.contains("class=\"clone pop\""), "clone popover rendered" ); assert!(body.contains("value=\"https://forge.test/alice/demo.git\"")); assert!(body.contains("git@forge.test:alice/demo.git")); assert!( body.contains("readonly"), "clone URLs are selectable, read-only fields" ); assert!(body.contains("/alice/demo/archive/main.tar.gz")); assert!(body.contains("/alice/demo/branches")); } #[tokio::test] async fn tree_lists_entries() { let dir = tempfile::tempdir().unwrap(); let (router, _) = fixture(dir.path()).await; let (status, body) = get(&router, "/alice/demo/tree/main/src").await; assert_eq!(status, 200); assert!(body.contains("main.rs")); assert!(body.contains("/alice/demo/blob/main/src/main.rs")); let (status, body) = get(&router, "/alice/demo/tree/main").await; assert_eq!(status, 200); assert!(body.contains("/alice/demo/tree/main/docs")); } #[tokio::test] async fn blob_highlights_rust_source() { let dir = tempfile::tempdir().unwrap(); let (router, _) = fixture(dir.path()).await; let (status, body) = get(&router, "/alice/demo/blob/main/src/main.rs").await; assert_eq!(status, 200); assert!(body.contains("id=\"L1\"")); assert!(body.contains("href=\"#L2\"")); assert!(body.contains(""), "GFM table rendered" ); let (status, body) = get(&router, "/alice/demo/blob/main/docs/guide.md?plain=1").await; assert_eq!(status, 200); assert!(body.contains("file-table"), "source shown as lines"); assert!(!body.contains("markdown-body"), "not rendered"); } #[tokio::test] async fn binary_blob_shows_size() { let dir = tempfile::tempdir().unwrap(); let (router, _) = fixture(dir.path()).await; let (status, body) = get(&router, "/alice/demo/blob/main/data.bin").await; assert_eq!(status, 200); assert!(body.contains("Binary file, 3 bytes")); } #[tokio::test] async fn unknown_refs_and_paths_are_404() { let dir = tempfile::tempdir().unwrap(); let (router, _) = fixture(dir.path()).await; for uri in [ "/alice/demo/tree/main/nope", "/alice/demo/tree/nosuchref", "/alice/demo/blob/main/nope.rs", "/alice/demo/blob/main/src", "/alice/demo/blob/main", ] { let (status, _) = get(&router, uri).await; assert_eq!(status, 404, "{uri}"); } } #[tokio::test] async fn private_repo_is_404_for_anonymous() { let dir = tempfile::tempdir().unwrap(); let (router, _) = fixture(dir.path()).await; for uri in [ "/alice/secret", "/alice/secret/tree/main", "/alice/secret/blob/main/x", "/alice/secret/branches", "/alice/secret/tags", ] { let (status, _) = get(&router, uri).await; assert_eq!(status, 404, "{uri}"); } } #[tokio::test] async fn fixed_segment_routes_win_over_the_repo_catch_all() { let dir = tempfile::tempdir().unwrap(); let (router, _) = fixture(dir.path()).await; let (status, body) = get(&router, "/alice/demo/branches").await; assert_eq!(status, 200); assert!(body.contains("default"), "default branch marked"); let (status, body) = get(&router, "/alice/demo/tags").await; assert_eq!(status, 200); assert!(body.contains("Nothing here yet")); let (status, body) = get(&router, "/alice/demo/tree/main").await; assert_eq!(status, 200); assert!(body.contains("README.md")); let (status, body) = get(&router, "/alice/demo").await; assert_eq!(status, 200); assert!(body.contains("Hello from the README")); } const ZERO_OID: &str = "0000000000000000000000000000000000000000"; const OID: &str = "1111111111111111111111111111111111111111"; const BANNER: &str = "had recent pushes"; /// Creates a session for `user_id` and returns the cookie header value. async fn sign_in(state: &AppState, user_id: &str, token: &str) -> String { crate::test_support::create_session(&state.store, user_id, token, "csrf").await; format!("__Host-{SESSION_COOKIE}={token}") } async fn get_as(router: &Router, uri: &str, cookie: Option<&str>) -> (u16, String) { let mut builder = Request::builder().method("GET").uri(uri); if let Some(cookie) = cookie { builder = builder.header("cookie", cookie); } let res = router.handle(builder.body(Body::empty()).unwrap()).await; let status = res.status().as_u16(); let bytes = to_bytes(res.into_body(), usize::MAX).await.unwrap(); (status, String::from_utf8_lossy(&bytes).into_owned()) } /// Points `branch` at the bare repo's `main`, so the branch exists. fn make_branch(bare: &Path, branch: &str) { let full = format!("refs/heads/{branch}"); git(bare, &["update-ref", &full, "refs/heads/main"]); } /// Records a push of `branch` to `repo_id` by `actor_id`, `age` ago. async fn record_push( state: &AppState, repo_id: &str, actor_id: &str, branch: &str, age: Duration, ) { let at = stamp_before(age); let full = format!("refs/heads/{branch}"); state .store .record_push_event_at(repo_id, actor_id, &full, ZERO_OID, OID, &at) .await .unwrap(); } async fn demo_repo(state: &AppState) -> forge_core::Repo { state .store .repo_by_owner_and_name("alice", "demo") .await .unwrap() .unwrap() } #[tokio::test] async fn recent_push_banner_links_to_compare() { let dir = tempfile::tempdir().unwrap(); let (router, state) = fixture(dir.path()).await; let demo = demo_repo(&state).await; make_branch( &repo_path(&state.config.data_dir, &demo.id).unwrap(), "feature", ); record_push( &state, &demo.id, &demo.owner_id, "feature", Duration::from_secs(300), ) .await; let cookie = sign_in(&state, &demo.owner_id, "alice-token").await; let (status, body) = get_as(&router, "/alice/demo", Some(&cookie)).await; assert_eq!(status, 200); assert!(body.contains("had recent pushes 5 minutes ago"), "{body}"); assert!(body.contains("compare/main...feature"), "{body}"); assert!(body.contains("Compare & pull request"), "{body}"); } #[tokio::test] async fn push_older_than_an_hour_shows_no_banner() { let dir = tempfile::tempdir().unwrap(); let (router, state) = fixture(dir.path()).await; let demo = demo_repo(&state).await; make_branch( &repo_path(&state.config.data_dir, &demo.id).unwrap(), "feature", ); record_push( &state, &demo.id, &demo.owner_id, "feature", Duration::from_secs(7200), ) .await; let cookie = sign_in(&state, &demo.owner_id, "alice-token").await; let (status, body) = get_as(&router, "/alice/demo", Some(&cookie)).await; assert_eq!(status, 200); assert!(!body.contains(BANNER), "{body}"); } #[tokio::test] async fn default_branch_push_shows_no_banner() { let dir = tempfile::tempdir().unwrap(); let (router, state) = fixture(dir.path()).await; let demo = demo_repo(&state).await; record_push( &state, &demo.id, &demo.owner_id, "main", Duration::from_secs(300), ) .await; let cookie = sign_in(&state, &demo.owner_id, "alice-token").await; let (status, body) = get_as(&router, "/alice/demo", Some(&cookie)).await; assert_eq!(status, 200); assert!(!body.contains(BANNER), "{body}"); } #[tokio::test] async fn open_pull_request_suppresses_banner() { let dir = tempfile::tempdir().unwrap(); let (router, state) = fixture(dir.path()).await; let demo = demo_repo(&state).await; make_branch( &repo_path(&state.config.data_dir, &demo.id).unwrap(), "feature", ); record_push( &state, &demo.id, &demo.owner_id, "feature", Duration::from_secs(300), ) .await; state .store .create_pull( &demo.id, &demo.owner_id, "Add feature", "", "main", &demo.id, "feature", OID, OID, ) .await .unwrap(); let cookie = sign_in(&state, &demo.owner_id, "alice-token").await; let (status, body) = get_as(&router, "/alice/demo", Some(&cookie)).await; assert_eq!(status, 200); assert!(!body.contains(BANNER), "{body}"); } #[tokio::test] async fn other_users_push_shows_no_banner() { let dir = tempfile::tempdir().unwrap(); let (router, state) = fixture(dir.path()).await; let demo = demo_repo(&state).await; let bob = state .store .create_user("bob", None, "x", false) .await .unwrap(); make_branch( &repo_path(&state.config.data_dir, &demo.id).unwrap(), "feature", ); record_push( &state, &demo.id, &bob.id, "feature", Duration::from_secs(300), ) .await; let cookie = sign_in(&state, &demo.owner_id, "alice-token").await; let (status, body) = get_as(&router, "/alice/demo", Some(&cookie)).await; assert_eq!(status, 200); assert!(!body.contains(BANNER), "{body}"); } #[tokio::test] async fn anonymous_viewer_sees_no_banner() { let dir = tempfile::tempdir().unwrap(); let (router, state) = fixture(dir.path()).await; let demo = demo_repo(&state).await; make_branch( &repo_path(&state.config.data_dir, &demo.id).unwrap(), "feature", ); record_push( &state, &demo.id, &demo.owner_id, "feature", Duration::from_secs(300), ) .await; let (status, body) = get_as(&router, "/alice/demo", None).await; assert_eq!(status, 200); assert!(!body.contains(BANNER), "{body}"); } #[tokio::test] async fn fork_push_links_to_parent_compare() { let dir = tempfile::tempdir().unwrap(); let (router, state) = fixture(dir.path()).await; let demo = demo_repo(&state).await; let bob = state .store .create_user("bob", None, "x", false) .await .unwrap(); let fork = state .store .create_fork(&demo, &bob.id, "demo") .await .unwrap(); let fork_path = repo_path(&state.config.data_dir, &fork.id).unwrap(); init_bare(&state.git, &fork_path, "main").await.unwrap(); let demo_path = repo_path(&state.config.data_dir, &demo.id).unwrap(); git( &fork_path, &[ "fetch", "-q", demo_path.to_str().unwrap(), "refs/heads/main:refs/heads/main", ], ); make_branch(&fork_path, "feature"); record_push( &state, &fork.id, &bob.id, "feature", Duration::from_secs(300), ) .await; let cookie = sign_in(&state, &bob.id, "bob-token").await; // On the parent, the viewer's fork branch compares as owner:branch. let (status, body) = get_as(&router, "/alice/demo", Some(&cookie)).await; assert_eq!(status, 200); assert!(body.contains("compare/main...bob%3Afeature"), "{body}"); // On the fork itself, the link goes to the parent's compare page. let (status, body) = get_as(&router, "/bob/demo", Some(&cookie)).await; assert_eq!(status, 200); assert!( body.contains("/alice/demo/compare/main...bob%3Afeature"), "{body}" ); } }