forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
35.7 KiB
1//! Page: repository home at `/{owner}/{repo}`. Shows the default branch's root tree, a Clone
2//! popover and the README, with a branch aside. An empty repo shows the clone popover and push
3//! instructions instead.
4
5use std::collections::{HashMap, HashSet};
6use std::path::PathBuf;
7use std::time::Duration;
8
9use forge_core::Config;
10use forge_core::Repo as RepoRecord;
11use forge_core::WatchLevel;
12use forge_core::time::stamp_before;
13use forge_git::browse;
14use forge_git::repo::repo_path;
15use topcoat::Result;
16use topcoat::context::{Cx, app_context};
17use topcoat::router::error::not_found;
18use topcoat::router::{page, path_param, route};
19use topcoat::view::{View, component, view};
20
21use crate::i18n::code::{self as g, K};
22use crate::i18n::{self, Lang};
23use crate::pages::dashboard::badge_class;
24use crate::pages::issue_parts::stamp_unix;
25use crate::pages::notifications::watch_form;
26use crate::pages::refs::load_refs;
27use crate::pages::releases::latest_card;
28use crate::pages::repo_ctx::{RepoCtx, RepoHead, load_repo};
29use crate::pages::tree::{
30 entry_rows, last_commits, load_readme, read_dir, readme_card, tree_table,
31};
32use crate::state::AppState;
33use crate::views::{now_unix, relative_time, repo_href, repo_tabs, shell, user_link};
34
35path_param!(owner);
36path_param!(repo);
37
38/// HTTPS clone URL: `{base_url}/{owner}/{repo}.git`.
39pub fn https_clone_url(config: &Config, owner: &str, name: &str) -> String {
40 format!(
41 "{}/{owner}/{name}.git",
42 config.base_url.trim_end_matches('/')
43 )
44}
45
46/// SSH clone URL. Port 22 uses the scp-like form `git@host:owner/repo.git`.
47pub fn ssh_clone_url(config: &Config, owner: &str, name: &str) -> String {
48 let host = &config.ssh_public_host;
49 if config.ssh_public_port == 22 {
50 format!("git@{host}:{owner}/{name}.git")
51 } else {
52 format!(
53 "ssh://git@{host}:{}/{owner}/{name}.git",
54 config.ssh_public_port
55 )
56 }
57}
58
59/// Header shared by the repo pages: title, visibility, the fork line, the fork and star controls,
60/// the archived notice, description and the header tabs. Built by `RepoCtx::head`; every repo page
61/// passes one.
62#[component]
63pub async fn repo_head(head: RepoHead) -> Result<impl View> {
64 let RepoHead {
65 owner,
66 name,
67 default_branch,
68 visibility,
69 description,
70 archived,
71 active,
72 can_admin,
73 open_issues,
74 open_pulls,
75 forked_from,
76 can_fork,
77 stars,
78 star,
79 watchers,
80 watch,
81 mirror,
82 discussions,
83 wiki,
84 security,
85 lang,
86 } = head;
87 // The title gets its own copies: the view below moves `owner` and `name` into `repo_tabs`.
88 let title_owner = owner.clone();
89 let title_name = name.clone();
90 let badge = badge_class(visibility);
91 let vis = i18n::visibility_label(lang, visibility);
92 let active = active.to_owned();
93 // The fork button posts with the same session CSRF token as the star form.
94 let fork_control = match (can_fork, star.as_ref()) {
95 (true, Some(star)) => Some((repo_href(&owner, &name, "fork", ""), star.csrf.clone())),
96 _ => None,
97 };
98 let fork_line = forked_from.map(|(source_owner, source_name)| {
99 (
100 repo_href(&source_owner, &source_name, "", ""),
101 format!("{source_owner}/{source_name}"),
102 )
103 });
104 // Signed-in viewers get a button that stars or unstars; anonymous viewers see the count only.
105 let star_control = match star {
106 Some(star) => {
107 let (path, label) = if star.starred {
108 ("unstar", g::star_label(lang, true, stars))
109 } else {
110 ("star", g::star_label(lang, false, stars))
111 };
112 Some((repo_href(&owner, &name, path, ""), label, star.csrf))
113 }
114 None => None,
115 };
116 let anon_count = format!("\u{2605} {stars}");
117 // Signed-in viewers get the watch level select; everyone gets the watcher count.
118 let watch_control = watch.map(|watch| {
119 let level = match watch.level {
120 WatchLevel::Participating => "participating",
121 WatchLevel::Watching => "watching",
122 WatchLevel::Ignoring => "ignoring",
123 };
124 (repo_href(&owner, &name, "watch", ""), watch.csrf, level)
125 });
126 let watch_count = g::watching_count(lang, watchers);
127 let search_action = repo_href(&owner, &name, "search", "");
128 let mirror_line = mirror.as_ref().map(|source| source.url.clone());
129 let mirror_status = match mirror {
130 Some(source) => match (source.last_sync, source.last_ok) {
131 (Some(at), Some(true)) => g::mirror_synced(lang, &at),
132 (Some(at), _) => g::mirror_sync_failed(lang, &at),
133 (None, _) => g::t(lang, K::NotSyncedYet).to_owned(),
134 },
135 None => String::new(),
136 };
137 Ok(view! {
138 <section class="page-head">
139 <div>
140 <div class="repo-title-row">
141 <h1 class="repo-title">user_link(shown: title_owner.as_str(), login: title_owner.as_str()) "/" (title_name.as_str())</h1>
142 <span class=(badge)>(vis)</span>
143 </div>
144 if let Some((href, label)) = fork_line {
145 <p class="muted small">(g::t(lang, K::ForkedFrom)) <a href=(href.as_str())>(label.as_str())</a></p>
146 }
147 if let Some(source) = mirror_line {
148 <p class="muted small mirror-line">(g::t(lang, K::MirrorOf)) <code>(source.as_str())</code> ", " (mirror_status.as_str())</p>
149 }
150 </div>
151 <div class="header-actions">
152 if let Some((action, csrf)) = fork_control {
153 <form method="post" action=(action.as_str()) class="fork-form">
154 <input type="hidden" name="csrf" value=(csrf.as_str())>
155 <button type="submit" class="fork-button">(g::t(lang, K::ForkButton))</button>
156 </form>
157 }
158 if let Some((action, csrf, level)) = watch_control {
159 watch_form(lang: lang, action: action, csrf: csrf, level: level, watchers: watchers)
160 } else {
161 <span class="muted small">(watch_count.as_str())</span>
162 }
163 <div class="star-control">
164 if let Some((action, label, csrf)) = star_control {
165 <form method="post" action=(action.as_str()) class="star-form">
166 <input type="hidden" name="csrf" value=(csrf.as_str())>
167 <button type="submit" class="star-button">(label.as_str())</button>
168 </form>
169 } else {
170 <span class="muted" aria-label=(g::t(lang, K::StarsAria))>(anon_count.as_str())</span>
171 }
172 </div>
173 </div>
174 </section>
175 if archived {
176 <section class="card notice" role="status">
177 (g::t(lang, K::ArchivedNotice))
178 </section>
179 }
180 // The description and code search belong to the Code tab only; sub-pages keep the compact head.
181 if active == "code" && !description.is_empty() {
182 <p class="lead">(description.as_str())</p>
183 }
184 if active == "code" {
185 <form method="get" action=(search_action.as_str()) class="repo-search" role="search">
186 <input type="search" name="q" placeholder=(g::t(lang, K::SearchCodePlaceholder)) aria-label=(g::t(lang, K::SearchCodePlaceholder))>
187 </form>
188 }
189 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)
190 })
191}
192
193/// Clone button that opens a popover with both clone URLs as selectable read-only fields.
194#[component]
195async fn clone_popover(https: String, ssh: String, lang: Lang) -> Result<impl View> {
196 Ok(view! {
197 <details class="clone pop">
198 <summary class="button primary">
199 <svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M8 7l-5 5 5 5M16 7l5 5-5 5"/></svg>
200 (g::t(lang, K::CloneTitle))
201 </summary>
202 <div class="pop-panel">
203 <label for="clone-https">"HTTPS"</label>
204 <input id="clone-https" class="mono" type="text" readonly=(true) value=(https.as_str())>
205 <label for="clone-ssh">"SSH"</label>
206 <input id="clone-ssh" class="mono" type="text" readonly=(true) value=(ssh.as_str())>
207 <p class="muted small">(g::t(lang, K::SshRequiresKey))</p>
208 </div>
209 </details>
210 })
211}
212
213/// Shown for an empty repo (or one whose default branch is missing): the branches that exist,
214/// or push instructions when there are none.
215#[component]
216async fn branches_card(
217 branches: Vec<String>,
218 push_https: String,
219 push_ssh: String,
220 lang: Lang,
221) -> Result<impl View> {
222 let empty = branches.is_empty();
223 Ok(view! {
224 <section class="card">
225 <h2>(g::t(lang, K::BranchesTitle))</h2>
226 if empty {
227 <p class="muted">(g::t(lang, K::EmptyRepoPush))</p>
228 <p class="muted small">(g::t(lang, K::OverHttps))</p>
229 <pre class="code"><code>(push_https)</code></pre>
230 <p class="muted small">(g::t(lang, K::OverSsh))</p>
231 <pre class="code"><code>(push_ssh)</code></pre>
232 } else {
233 <ul class="branch-list">
234 for branch in branches.into_iter() {
235 <li><code>(branch)</code></li>
236 }
237 </ul>
238 }
239 </section>
240 })
241}
242
243/// Download links for the archive of `ref_name`.
244#[component]
245async fn archive_links(
246 owner: String,
247 repo: String,
248 ref_name: String,
249 lang: Lang,
250) -> Result<impl View> {
251 let tar = repo_href(&owner, &repo, "archive", &format!("{ref_name}.tar.gz"));
252 let zip = repo_href(&owner, &repo, "archive", &format!("{ref_name}.zip"));
253 Ok(view! {
254 <nav class="archive-links" aria-label=(g::t(lang, K::DownloadNav))>
255 <a href=(zip)>(g::t(lang, K::DownloadZip))</a>
256 <a href=(tar)>(g::t(lang, K::DownloadTarGz))</a>
257 </nav>
258 })
259}
260
261async fn default_commit(path: PathBuf, branch: String) -> Result<Option<String>> {
262 Ok(
263 tokio::task::spawn_blocking(move || browse::default_branch_commit(&path, &branch))
264 .await??,
265 )
266}
267
268/// Most recent-push banners the repo home shows.
269const MAX_PUSH_BANNERS: usize = 3;
270/// How far back a push still counts as recent.
271const RECENT_PUSH_WINDOW: Duration = Duration::from_secs(60 * 60);
272
273/// One "had recent pushes" banner: a branch the viewer pushed to, with its compare link.
274struct PushBanner {
275 branch: String,
276 at_unix: i64,
277 href: String,
278}
279
280/// Recent-push banners for the signed-in viewer. Candidates are the branches the viewer pushed to
281/// in this repo and in their own forks of it, pushed within the last hour, newest first. A branch
282/// is skipped when it is the default branch, is gone, or already has an open pull request. A fork
283/// links to its parent's compare page when the parent is visible (`forked_from`); otherwise the
284/// compare page is this repo's, with the head as `owner:branch` for a fork.
285async fn recent_push_banners(
286 state: &AppState,
287 ctx: &RepoCtx,
288 forked_from: Option<(String, String)>,
289) -> Result<Vec<PushBanner>> {
290 let Some((user, _)) = &ctx.viewer else {
291 return Ok(Vec::new());
292 };
293 let repo = &ctx.repo;
294 let base = match forked_from {
295 Some((owner, name)) => state
296 .store
297 .repo_by_owner_and_name(&owner, &name)
298 .await?
299 .unwrap_or_else(|| repo.clone()),
300 None => repo.clone(),
301 };
302
303 // Forks the viewer owns, or that an organization they hold a role in owns.
304 let mut forks: Vec<RepoRecord> = Vec::new();
305 for fork in state.store.list_forks(&repo.id).await? {
306 let mine = fork.owner_id == user.id
307 || state
308 .store
309 .org_repo_role(&fork.owner_id, &user.id, &fork.id)
310 .await?
311 .is_some();
312 if mine {
313 forks.push(fork);
314 }
315 }
316 let mut candidates: Vec<&RepoRecord> = vec![repo];
317 candidates.extend(forks.iter());
318 let ids: Vec<&str> = candidates.iter().map(|r| r.id.as_str()).collect();
319 let since = stamp_before(RECENT_PUSH_WINDOW);
320 let pushes = state
321 .store
322 .recent_branch_pushes(&ids, &user.id, &since)
323 .await?;
324
325 let mut existing: HashMap<String, HashSet<String>> = HashMap::new();
326 let mut banners = Vec::new();
327 for (repo_id, branch, at) in pushes {
328 if banners.len() >= MAX_PUSH_BANNERS {
329 break;
330 }
331 let Some(pushed) = candidates.iter().find(|r| r.id == repo_id) else {
332 continue;
333 };
334 if branch == pushed.default_branch {
335 continue;
336 }
337 if !existing.contains_key(&pushed.id) {
338 let path = repo_path(&state.config.data_dir, &pushed.id)?;
339 let names: HashSet<String> = load_refs(path)
340 .await?
341 .into_iter()
342 .filter(|r| r.kind == browse::RefKind::Branch)
343 .map(|r| r.name)
344 .collect();
345 existing.insert(pushed.id.clone(), names);
346 }
347 if !existing[&pushed.id].contains(&branch) {
348 continue;
349 }
350 if state
351 .store
352 .find_open_pull(&base.id, &base.default_branch, &pushed.id, &branch)
353 .await?
354 .is_some()
355 {
356 continue;
357 }
358 let head = if pushed.id == base.id {
359 branch.clone()
360 } else {
361 format!("{}:{branch}", pushed.owner_name)
362 };
363 let href = repo_href(
364 &base.owner_name,
365 &base.name,
366 "compare",
367 &format!("{}...{head}", base.default_branch),
368 );
369 banners.push(PushBanner {
370 branch,
371 at_unix: stamp_unix(&at),
372 href,
373 });
374 }
375 Ok(banners)
376}
377
378/// The "had recent pushes" banners above the code tab.
379#[component]
380async fn recent_pushes(banners: Vec<PushBanner>, now: i64, lang: Lang) -> Result<impl View> {
381 Ok(view! {
382 <section class="recent-pushes" aria-label=(g::t(lang, K::RecentPushes))>
383 for banner in banners.into_iter() {
384 <p class="recent-push">
385 <code>(banner.branch)</code>
386 (g::t(lang, K::HadRecentPushes))
387 (relative_time(lang, banner.at_unix, now))
388 " — "
389 <a href=(banner.href)>(g::t(lang, K::CompareAndPr))</a>
390 </p>
391 }
392 </section>
393 })
394}
395
396#[page("/{owner}/{repo}")]
397async fn repo_page(cx: &Cx) -> Result<impl View> {
398 let owner = path_param::<Owner>(cx);
399 let name = path_param::<Repo>(cx);
400 let ctx = load_repo(cx, owner, name).await?;
401 let state = app_context::<AppState>(cx);
402 let lang = ctx.lang;
403 let repo = &ctx.repo;
404 let branch = repo.default_branch.clone();
405
406 let refs = load_refs(ctx.path.clone()).await?;
407 let head = default_commit(ctx.path.clone(), branch.clone()).await?;
408
409 let https = https_clone_url(&state.config, &repo.owner_name, &repo.name);
410 let ssh = ssh_clone_url(&state.config, &repo.owner_name, &repo.name);
411 let push_https = format!("git remote add origin {https}\ngit push -u origin {branch}");
412 let push_ssh = format!("git remote add origin {ssh}\ngit push -u origin {branch}");
413 let title = format!("{}/{}", repo.owner_name, repo.name);
414
415 // The tree and README exist only when the default branch has a commit.
416 let mut tree = None;
417 let mut readme = None;
418 if let Some(commit) = &head
419 && let Some(entries) = read_dir(ctx.path.clone(), commit.clone(), String::new()).await?
420 {
421 let names: Vec<String> = entries.iter().map(|e| e.name.clone()).collect();
422 let last = last_commits(state, &ctx.path, commit, "", &names).await;
423 readme = load_readme(state, repo, &ctx.path, &branch, commit, "", &entries).await?;
424 tree = Some(entry_rows(
425 lang,
426 repo,
427 &branch,
428 "",
429 entries,
430 &last,
431 now_unix(),
432 ctx.viewer_tz(),
433 ));
434 }
435 let branch_names: Vec<String> = refs
436 .into_iter()
437 .filter(|r| r.kind == browse::RefKind::Branch)
438 .map(|r| r.name)
439 .collect();
440
441 let owner_name = repo.owner_name.clone();
442 let repo_name = repo.name.clone();
443 let header = ctx.head(state, "code").await?;
444 let latest = crate::pages::releases::latest_release_for_home(state, &ctx).await?;
445 let banners = recent_push_banners(state, &ctx, header.forked_from.clone()).await?;
446 let now = now_unix();
447 Ok(view! {
448 shell(title: title.as_str(), viewer: ctx.viewer.as_ref(),
449 repo_head(head: header)
450 if let Some(latest) = latest {
451 latest_card(release: latest, lang: lang)
452 }
453 if !banners.is_empty() {
454 recent_pushes(banners: banners, now: now, lang: lang)
455 }
456 if let Some(rows) = tree {
457 <div class="browse-bar">
458 <span class="muted small">(g::t(lang, K::BranchLabel))</span>
459 <code>(branch.clone())</code>
460 archive_links(owner: owner_name, repo: repo_name,
461 ref_name: branch.clone(), lang: lang)
462 clone_popover(https: https, ssh: ssh, lang: lang)
463 </div>
464 <div class="repo-grid">
465 <div class="repo-main">
466 tree_table(rows: rows, lang: lang)
467 if let Some(readme) = readme {
468 readme_card(name: readme.name, markdown: readme.markdown, body: readme.body)
469 }
470 </div>
471 <aside class="repo-meta" aria-label=(g::t(lang, K::BranchesTitle))>
472 <section>
473 <h2>(g::t(lang, K::BranchesTitle))</h2>
474 <ul class="branch-list">
475 for name in branch_names.into_iter() {
476 <li><code>(name)</code></li>
477 }
478 </ul>
479 </section>
480 </aside>
481 </div>
482 } else {
483 <div class="browse-bar">
484 clone_popover(https: https, ssh: ssh, lang: lang)
485 </div>
486 branches_card(branches: branch_names, push_https: push_https, push_ssh: push_ssh, lang: lang)
487 }
488 )
489 })
490}
491
492/// Other methods on a repo path get a plain 404, not a 405 that would reveal the route shape.
493#[route(* "/{owner}/{repo}")]
494async fn repo_other_methods() -> Result<&'static str> {
495 Err(not_found().into())
496}
497
498#[cfg(test)]
499mod tests {
500 use std::path::Path;
501 use std::process::Command;
502 use std::time::Duration;
503
504 use forge_core::Visibility;
505 use forge_core::time::stamp_before;
506
507 use forge_git::repo::{init_bare, repo_path};
508
509 use topcoat::router::request::Request;
510 use topcoat::router::{Body, Router, to_bytes};
511
512 use crate::auth::SESSION_COOKIE;
513 use crate::state::AppState;
514
515 async fn app(dir: &Path) -> (Router, AppState) {
516 let state = crate::test_support::test_state(dir).await;
517 (crate::test_support::app_router(state.clone()), state)
518 }
519
520 fn git(dir: &Path, args: &[&str]) {
521 let status = Command::new("git")
522 .current_dir(dir)
523 .env("GIT_CONFIG_NOSYSTEM", "1")
524 .args([
525 "-c",
526 "user.name=Test",
527 "-c",
528 "user.email=test@example.com",
529 "-c",
530 "commit.gpgsign=false",
531 ])
532 .args(args)
533 .status()
534 .expect("git runs");
535 assert!(status.success(), "git {args:?} failed");
536 }
537
538 /// `alice/demo` (public, default branch `main` with content) and `alice/secret` (private, empty).
539 async fn fixture(dir: &Path) -> (Router, AppState) {
540 let (router, state) = app(dir).await;
541 let alice = state
542 .store
543 .create_user("alice", None, "x", false)
544 .await
545 .unwrap();
546 let demo = state
547 .store
548 .create_repo(&alice.id, "demo", "A demo repo", Visibility::Public, "main")
549 .await
550 .unwrap();
551 let secret = state
552 .store
553 .create_repo(&alice.id, "secret", "", Visibility::Private, "main")
554 .await
555 .unwrap();
556 for repo in [&demo, &secret] {
557 let path = repo_path(&state.config.data_dir, &repo.id).unwrap();
558 init_bare(&state.git, &path, "main").await.unwrap();
559 }
560
561 let work = dir.join("work");
562 std::fs::create_dir_all(work.join("src")).unwrap();
563 std::fs::create_dir_all(work.join("docs")).unwrap();
564 std::fs::write(work.join("README.md"), "# Demo\n\nHello from the README.\n").unwrap();
565 std::fs::write(
566 work.join("src/main.rs"),
567 "fn main() {\n println!(\"hi\");\n}\n",
568 )
569 .unwrap();
570 std::fs::write(
571 work.join("docs/guide.md"),
572 "# Guide\n\n| a | b |\n|---|---|\n| 1 | 2 |\n",
573 )
574 .unwrap();
575 std::fs::write(work.join("data.bin"), [0u8, 1, 2]).unwrap();
576 git(&work, &["init", "-q", "-b", "main"]);
577 git(&work, &["add", "-A"]);
578 git(&work, &["commit", "-q", "-m", "first commit"]);
579 let demo_path = repo_path(&state.config.data_dir, &demo.id).unwrap();
580 git(
581 &work,
582 &["push", "-q", demo_path.to_str().unwrap(), "main:main"],
583 );
584 (router, state)
585 }
586
587 async fn get(router: &Router, uri: &str) -> (u16, String) {
588 let req = Request::builder()
589 .method("GET")
590 .uri(uri)
591 .body(Body::empty())
592 .unwrap();
593 let res = router.handle(req).await;
594 let status = res.status().as_u16();
595 let bytes = to_bytes(res.into_body(), usize::MAX).await.unwrap();
596 (status, String::from_utf8_lossy(&bytes).into_owned())
597 }
598
599 #[tokio::test]
600 async fn home_renders_tree_clone_popover_and_readme() {
601 let dir = tempfile::tempdir().unwrap();
602 let (router, _) = fixture(dir.path()).await;
603 let (status, body) = get(&router, "/alice/demo").await;
604 assert_eq!(status, 200);
605 assert!(body.contains("Hello from the README"), "README rendered");
606 assert!(
607 body.contains("/alice/demo/tree/main/src"),
608 "root tree listed"
609 );
610 assert!(
611 body.contains("class=\"clone pop\""),
612 "clone popover rendered"
613 );
614 assert!(body.contains("value=\"https://forge.test/alice/demo.git\""));
615 assert!(body.contains("git@forge.test:alice/demo.git"));
616 assert!(
617 body.contains("readonly"),
618 "clone URLs are selectable, read-only fields"
619 );
620 assert!(body.contains("/alice/demo/archive/main.tar.gz"));
621 assert!(body.contains("/alice/demo/branches"));
622 }
623
624 #[tokio::test]
625 async fn tree_lists_entries() {
626 let dir = tempfile::tempdir().unwrap();
627 let (router, _) = fixture(dir.path()).await;
628 let (status, body) = get(&router, "/alice/demo/tree/main/src").await;
629 assert_eq!(status, 200);
630 assert!(body.contains("main.rs"));
631 assert!(body.contains("/alice/demo/blob/main/src/main.rs"));
632 let (status, body) = get(&router, "/alice/demo/tree/main").await;
633 assert_eq!(status, 200);
634 assert!(body.contains("/alice/demo/tree/main/docs"));
635 }
636
637 #[tokio::test]
638 async fn blob_highlights_rust_source() {
639 let dir = tempfile::tempdir().unwrap();
640 let (router, _) = fixture(dir.path()).await;
641 let (status, body) = get(&router, "/alice/demo/blob/main/src/main.rs").await;
642 assert_eq!(status, 200);
643 assert!(body.contains("id=\"L1\""));
644 assert!(body.contains("href=\"#L2\""));
645 assert!(body.contains("<span class="), "syntax classes applied");
646 assert!(body.contains("/assets/highlight.css?v="));
647 }
648
649 #[tokio::test]
650 async fn markdown_blob_renders_unless_plain() {
651 let dir = tempfile::tempdir().unwrap();
652 let (router, _) = fixture(dir.path()).await;
653 let (status, body) = get(&router, "/alice/demo/blob/main/docs/guide.md").await;
654 assert_eq!(status, 200);
655 assert!(
656 body.contains("markdown-body") && body.contains("<table>"),
657 "GFM table rendered"
658 );
659 let (status, body) = get(&router, "/alice/demo/blob/main/docs/guide.md?plain=1").await;
660 assert_eq!(status, 200);
661 assert!(body.contains("file-table"), "source shown as lines");
662 assert!(!body.contains("markdown-body"), "not rendered");
663 }
664
665 #[tokio::test]
666 async fn binary_blob_shows_size() {
667 let dir = tempfile::tempdir().unwrap();
668 let (router, _) = fixture(dir.path()).await;
669 let (status, body) = get(&router, "/alice/demo/blob/main/data.bin").await;
670 assert_eq!(status, 200);
671 assert!(body.contains("Binary file, 3 bytes"));
672 }
673
674 #[tokio::test]
675 async fn unknown_refs_and_paths_are_404() {
676 let dir = tempfile::tempdir().unwrap();
677 let (router, _) = fixture(dir.path()).await;
678 for uri in [
679 "/alice/demo/tree/main/nope",
680 "/alice/demo/tree/nosuchref",
681 "/alice/demo/blob/main/nope.rs",
682 "/alice/demo/blob/main/src",
683 "/alice/demo/blob/main",
684 ] {
685 let (status, _) = get(&router, uri).await;
686 assert_eq!(status, 404, "{uri}");
687 }
688 }
689
690 #[tokio::test]
691 async fn private_repo_is_404_for_anonymous() {
692 let dir = tempfile::tempdir().unwrap();
693 let (router, _) = fixture(dir.path()).await;
694 for uri in [
695 "/alice/secret",
696 "/alice/secret/tree/main",
697 "/alice/secret/blob/main/x",
698 "/alice/secret/branches",
699 "/alice/secret/tags",
700 ] {
701 let (status, _) = get(&router, uri).await;
702 assert_eq!(status, 404, "{uri}");
703 }
704 }
705
706 #[tokio::test]
707 async fn fixed_segment_routes_win_over_the_repo_catch_all() {
708 let dir = tempfile::tempdir().unwrap();
709 let (router, _) = fixture(dir.path()).await;
710 let (status, body) = get(&router, "/alice/demo/branches").await;
711 assert_eq!(status, 200);
712 assert!(body.contains("default"), "default branch marked");
713 let (status, body) = get(&router, "/alice/demo/tags").await;
714 assert_eq!(status, 200);
715 assert!(body.contains("Nothing here yet"));
716 let (status, body) = get(&router, "/alice/demo/tree/main").await;
717 assert_eq!(status, 200);
718 assert!(body.contains("README.md"));
719 let (status, body) = get(&router, "/alice/demo").await;
720 assert_eq!(status, 200);
721 assert!(body.contains("Hello from the README"));
722 }
723
724 const ZERO_OID: &str = "0000000000000000000000000000000000000000";
725 const OID: &str = "1111111111111111111111111111111111111111";
726 const BANNER: &str = "had recent pushes";
727
728 /// Creates a session for `user_id` and returns the cookie header value.
729 async fn sign_in(state: &AppState, user_id: &str, token: &str) -> String {
730 crate::test_support::create_session(&state.store, user_id, token, "csrf").await;
731 format!("__Host-{SESSION_COOKIE}={token}")
732 }
733
734 async fn get_as(router: &Router, uri: &str, cookie: Option<&str>) -> (u16, String) {
735 let mut builder = Request::builder().method("GET").uri(uri);
736 if let Some(cookie) = cookie {
737 builder = builder.header("cookie", cookie);
738 }
739 let res = router.handle(builder.body(Body::empty()).unwrap()).await;
740 let status = res.status().as_u16();
741 let bytes = to_bytes(res.into_body(), usize::MAX).await.unwrap();
742 (status, String::from_utf8_lossy(&bytes).into_owned())
743 }
744
745 /// Points `branch` at the bare repo's `main`, so the branch exists.
746 fn make_branch(bare: &Path, branch: &str) {
747 let full = format!("refs/heads/{branch}");
748 git(bare, &["update-ref", &full, "refs/heads/main"]);
749 }
750
751 /// Records a push of `branch` to `repo_id` by `actor_id`, `age` ago.
752 async fn record_push(
753 state: &AppState,
754 repo_id: &str,
755 actor_id: &str,
756 branch: &str,
757 age: Duration,
758 ) {
759 let at = stamp_before(age);
760 let full = format!("refs/heads/{branch}");
761 state
762 .store
763 .record_push_event_at(repo_id, actor_id, &full, ZERO_OID, OID, &at)
764 .await
765 .unwrap();
766 }
767
768 async fn demo_repo(state: &AppState) -> forge_core::Repo {
769 state
770 .store
771 .repo_by_owner_and_name("alice", "demo")
772 .await
773 .unwrap()
774 .unwrap()
775 }
776
777 #[tokio::test]
778 async fn recent_push_banner_links_to_compare() {
779 let dir = tempfile::tempdir().unwrap();
780 let (router, state) = fixture(dir.path()).await;
781 let demo = demo_repo(&state).await;
782 make_branch(
783 &repo_path(&state.config.data_dir, &demo.id).unwrap(),
784 "feature",
785 );
786 record_push(
787 &state,
788 &demo.id,
789 &demo.owner_id,
790 "feature",
791 Duration::from_secs(300),
792 )
793 .await;
794 let cookie = sign_in(&state, &demo.owner_id, "alice-token").await;
795
796 let (status, body) = get_as(&router, "/alice/demo", Some(&cookie)).await;
797 assert_eq!(status, 200);
798 assert!(body.contains("had recent pushes 5 minutes ago"), "{body}");
799 assert!(body.contains("compare/main...feature"), "{body}");
800 assert!(body.contains("Compare &amp; pull request"), "{body}");
801 }
802
803 #[tokio::test]
804 async fn push_older_than_an_hour_shows_no_banner() {
805 let dir = tempfile::tempdir().unwrap();
806 let (router, state) = fixture(dir.path()).await;
807 let demo = demo_repo(&state).await;
808 make_branch(
809 &repo_path(&state.config.data_dir, &demo.id).unwrap(),
810 "feature",
811 );
812 record_push(
813 &state,
814 &demo.id,
815 &demo.owner_id,
816 "feature",
817 Duration::from_secs(7200),
818 )
819 .await;
820 let cookie = sign_in(&state, &demo.owner_id, "alice-token").await;
821
822 let (status, body) = get_as(&router, "/alice/demo", Some(&cookie)).await;
823 assert_eq!(status, 200);
824 assert!(!body.contains(BANNER), "{body}");
825 }
826
827 #[tokio::test]
828 async fn default_branch_push_shows_no_banner() {
829 let dir = tempfile::tempdir().unwrap();
830 let (router, state) = fixture(dir.path()).await;
831 let demo = demo_repo(&state).await;
832 record_push(
833 &state,
834 &demo.id,
835 &demo.owner_id,
836 "main",
837 Duration::from_secs(300),
838 )
839 .await;
840 let cookie = sign_in(&state, &demo.owner_id, "alice-token").await;
841
842 let (status, body) = get_as(&router, "/alice/demo", Some(&cookie)).await;
843 assert_eq!(status, 200);
844 assert!(!body.contains(BANNER), "{body}");
845 }
846
847 #[tokio::test]
848 async fn open_pull_request_suppresses_banner() {
849 let dir = tempfile::tempdir().unwrap();
850 let (router, state) = fixture(dir.path()).await;
851 let demo = demo_repo(&state).await;
852 make_branch(
853 &repo_path(&state.config.data_dir, &demo.id).unwrap(),
854 "feature",
855 );
856 record_push(
857 &state,
858 &demo.id,
859 &demo.owner_id,
860 "feature",
861 Duration::from_secs(300),
862 )
863 .await;
864 state
865 .store
866 .create_pull(
867 &demo.id,
868 &demo.owner_id,
869 "Add feature",
870 "",
871 "main",
872 &demo.id,
873 "feature",
874 OID,
875 OID,
876 )
877 .await
878 .unwrap();
879 let cookie = sign_in(&state, &demo.owner_id, "alice-token").await;
880
881 let (status, body) = get_as(&router, "/alice/demo", Some(&cookie)).await;
882 assert_eq!(status, 200);
883 assert!(!body.contains(BANNER), "{body}");
884 }
885
886 #[tokio::test]
887 async fn other_users_push_shows_no_banner() {
888 let dir = tempfile::tempdir().unwrap();
889 let (router, state) = fixture(dir.path()).await;
890 let demo = demo_repo(&state).await;
891 let bob = state
892 .store
893 .create_user("bob", None, "x", false)
894 .await
895 .unwrap();
896 make_branch(
897 &repo_path(&state.config.data_dir, &demo.id).unwrap(),
898 "feature",
899 );
900 record_push(
901 &state,
902 &demo.id,
903 &bob.id,
904 "feature",
905 Duration::from_secs(300),
906 )
907 .await;
908 let cookie = sign_in(&state, &demo.owner_id, "alice-token").await;
909
910 let (status, body) = get_as(&router, "/alice/demo", Some(&cookie)).await;
911 assert_eq!(status, 200);
912 assert!(!body.contains(BANNER), "{body}");
913 }
914
915 #[tokio::test]
916 async fn anonymous_viewer_sees_no_banner() {
917 let dir = tempfile::tempdir().unwrap();
918 let (router, state) = fixture(dir.path()).await;
919 let demo = demo_repo(&state).await;
920 make_branch(
921 &repo_path(&state.config.data_dir, &demo.id).unwrap(),
922 "feature",
923 );
924 record_push(
925 &state,
926 &demo.id,
927 &demo.owner_id,
928 "feature",
929 Duration::from_secs(300),
930 )
931 .await;
932
933 let (status, body) = get_as(&router, "/alice/demo", None).await;
934 assert_eq!(status, 200);
935 assert!(!body.contains(BANNER), "{body}");
936 }
937
938 #[tokio::test]
939 async fn fork_push_links_to_parent_compare() {
940 let dir = tempfile::tempdir().unwrap();
941 let (router, state) = fixture(dir.path()).await;
942 let demo = demo_repo(&state).await;
943 let bob = state
944 .store
945 .create_user("bob", None, "x", false)
946 .await
947 .unwrap();
948 let fork = state
949 .store
950 .create_fork(&demo, &bob.id, "demo")
951 .await
952 .unwrap();
953 let fork_path = repo_path(&state.config.data_dir, &fork.id).unwrap();
954 init_bare(&state.git, &fork_path, "main").await.unwrap();
955 let demo_path = repo_path(&state.config.data_dir, &demo.id).unwrap();
956 git(
957 &fork_path,
958 &[
959 "fetch",
960 "-q",
961 demo_path.to_str().unwrap(),
962 "refs/heads/main:refs/heads/main",
963 ],
964 );
965 make_branch(&fork_path, "feature");
966 record_push(
967 &state,
968 &fork.id,
969 &bob.id,
970 "feature",
971 Duration::from_secs(300),
972 )
973 .await;
974 let cookie = sign_in(&state, &bob.id, "bob-token").await;
975
976 // On the parent, the viewer's fork branch compares as owner:branch.
977 let (status, body) = get_as(&router, "/alice/demo", Some(&cookie)).await;
978 assert_eq!(status, 200);
979 assert!(body.contains("compare/main...bob%3Afeature"), "{body}");
980
981 // On the fork itself, the link goes to the parent's compare page.
982 let (status, body) = get_as(&router, "/bob/demo", Some(&cookie)).await;
983 assert_eq!(status, 200);
984 assert!(
985 body.contains("/alice/demo/compare/main...bob%3Afeature"),
986 "{body}"
987 );
988 }
989}