forge
ExploreLog in

Tahinli/forge

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