Forks 07b1a5bbf481
Anyone who can read a repository can fork it under an owner they may create repositories for. The fork is a repository of its own (its own id, the same visibility and description), its objects are hard-linked from the source (copied across file systems; the store never writes a file in place, so sharing is safe), its bookmarks are the source's, and its trust policy starts empty. Migration 0009 records forked_from. The overview shows a fork button, "forked from" and the number of forks; /forks lists them; on a fork, "propose to" opens a proposal upstream with the fork's bookmark as its source, which proposals already carried across by hash.
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-09-28 08:44 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
07b1a5bbf48143f80d47c65ddb869293b6e4cd55d5b78b0c2534c5e430a6e77b- Depends on
- 18e381ea88c2 19bb31ff1f5c 3f4b0ea6eabf 4edcf24025cf 93be9ce4653e 9df855f29948 b64e2537d702 d23464c0ac60
- Depended on by
- 40a43921ef42 166f6e9bf4f1 7c06f5b28ade 5d90e0e045e2 7f283f8e8b97 c9aacc0e7dc1 1f78655a0466 d0fbea2eb46e 9008bf6c2b8c 83d5052bf76c 43e8ca33bc5d 42fa95540468 6ae41081ccd3 1553bfc9ae53
- In bookmarks
- api-unknown-fields cancel-landed-runs clear-waiting close-proposal-140 diffusion-pin-bookmark draft-ready image-check job-waits main oh-comment-body-file proposal-list-fast run-one-job wiki-says-so
- In tags
- v0.1.0 v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.4.1 v0.4.2 v0.5.0 v0.5.1 v0.5.2 v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
- Edits
- 21 atoms, 4 tree operations, 13 files
Browse the tree at this change
added crates/hub/migrations/0009_forks.sql +3 −0
| 1 | + | -- Forks (hub spec §4): the repository a fork was made from. |
|
| 2 | + | ALTER TABLE repositories ADD COLUMN forked_from INTEGER REFERENCES repositories(id) ON DELETE SET NULL; |
|
| 3 | + | CREATE INDEX repositories_forks ON repositories (forked_from) WHERE forked_from IS NOT NULL; |
added crates/hub/src/web/forks.rs +127 −0
| 1 | + | //! Forks (hub spec §4): making one, listing them, and proposing from one |
|
| 2 | + | //! back to the repository it came from. |
|
| 3 | + | ||
| 4 | + | use super::settings::{csrf, require}; |
|
| 5 | + | use super::{filters, lookup, Error, Page, RepoShell, Shell}; |
|
| 6 | + | use crate::auth::{roles, Viewer}; |
|
| 7 | + | use crate::db::{self, accounts}; |
|
| 8 | + | use crate::repos::RepoName; |
|
| 9 | + | use crate::Shared; |
|
| 10 | + | use askama::Template; |
|
| 11 | + | use axum::extract::{Form, Path, State}; |
|
| 12 | + | use axum::response::{IntoResponse, Redirect, Response}; |
|
| 13 | + | use serde::Deserialize; |
|
| 14 | + | ||
| 15 | + | #[derive(Template)] |
|
| 16 | + | #[template(path = "fork_new.html")] |
|
| 17 | + | pub struct ForkPage { |
|
| 18 | + | shell: Shell, |
|
| 19 | + | repo: RepoShell, |
|
| 20 | + | owners: Vec<String>, |
|
| 21 | + | name: String, |
|
| 22 | + | error: Option<String>, |
|
| 23 | + | } |
|
| 24 | + | ||
| 25 | + | pub async fn form(State(app): State<Shared>, viewer: Viewer, Path((owner, name)): Path<(String, String)>) -> Result<Page<ForkPage>, Error> { |
|
| 26 | + | let shell = Shell::new(&app, &format!("/{owner}/{name}/fork"), &viewer); |
|
| 27 | + | let me = require(&viewer, &shell)?; |
|
| 28 | + | let (_, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?; |
|
| 29 | + | let owners = super::repo_settings::owners_for(&app, &me.account).await; |
|
| 30 | + | Ok(Page(ForkPage { repo: RepoShell::new(&app, &row, "code").with_role(role), shell, owners, name, error: None })) |
|
| 31 | + | } |
|
| 32 | + | ||
| 33 | + | #[derive(Deserialize)] |
|
| 34 | + | pub struct ForkForm { |
|
| 35 | + | #[serde(rename = "_csrf")] |
|
| 36 | + | csrf_token: String, |
|
| 37 | + | owner: String, |
|
| 38 | + | name: String, |
|
| 39 | + | } |
|
| 40 | + | ||
| 41 | + | /// Anyone who can read a repository may fork it under an owner they may |
|
| 42 | + | /// create repositories for; the fork keeps the source's visibility. |
|
| 43 | + | pub async fn create( |
|
| 44 | + | State(app): State<Shared>, |
|
| 45 | + | viewer: Viewer, |
|
| 46 | + | Path((owner, name)): Path<(String, String)>, |
|
| 47 | + | Form(f): Form<ForkForm>, |
|
| 48 | + | ) -> Result<Response, Error> { |
|
| 49 | + | let shell = Shell::new(&app, &format!("/{owner}/{name}/fork"), &viewer); |
|
| 50 | + | let me = require(&viewer, &shell)?; |
|
| 51 | + | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 52 | + | let (source, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?; |
|
| 53 | + | let owners = super::repo_settings::owners_for(&app, &me.account).await; |
|
| 54 | + | let page = |msg: &str| { |
|
| 55 | + | Page(ForkPage { |
|
| 56 | + | shell: shell.clone(), |
|
| 57 | + | repo: RepoShell::new(&app, &row, "code").with_role(role), |
|
| 58 | + | owners: owners.clone(), |
|
| 59 | + | name: f.name.clone(), |
|
| 60 | + | error: Some(msg.into()), |
|
| 61 | + | }) |
|
| 62 | + | .into_response() |
|
| 63 | + | }; |
|
| 64 | + | let Some(dest) = RepoName::new(f.owner.trim(), f.name.trim()) else { |
|
| 65 | + | return Ok(page("A repository name is letters, digits, dots, dashes and underscores.")); |
|
| 66 | + | }; |
|
| 67 | + | if dest == source { |
|
| 68 | + | return Ok(page("Pick another owner or name: a repository cannot be its own fork.")); |
|
| 69 | + | } |
|
| 70 | + | if !roles::may_create_under(&app.db, &me.account, &dest.owner).await.map_err(|e| Error::from_db(shell.clone(), e))? { |
|
| 71 | + | return Ok(page("You cannot create repositories under that owner.")); |
|
| 72 | + | } |
|
| 73 | + | if db::repos::get(&app.db, &dest.owner, &dest.name).await.map_err(|e| Error::from_db(shell.clone(), e))?.is_some() |
|
| 74 | + | || app.repos.exists(&dest) |
|
| 75 | + | { |
|
| 76 | + | return Ok(page("That repository exists.")); |
|
| 77 | + | } |
|
| 78 | + | app.repos.fork(&source, &dest).await.map_err(|e| Error::from_repo(shell.clone(), e))?; |
|
| 79 | + | let created = db::repos::create(&app.db, &dest.owner, &dest.name, &row.description, row.visibility()) |
|
| 80 | + | .await |
|
| 81 | + | .map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 82 | + | db::repos::set_forked_from(&app.db, created.id, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 83 | + | accounts::audit(&app.db, Some(&me.account), "repo.forked", &dest.to_string(), &source.to_string()).await.ok(); |
|
| 84 | + | Ok(Redirect::to(&dest.url()).into_response()) |
|
| 85 | + | } |
|
| 86 | + | ||
| 87 | + | #[derive(Template)] |
|
| 88 | + | #[template(path = "forks.html")] |
|
| 89 | + | pub struct ForksPage { |
|
| 90 | + | shell: Shell, |
|
| 91 | + | repo: RepoShell, |
|
| 92 | + | forks: Vec<db::repos::RepoRow>, |
|
| 93 | + | } |
|
| 94 | + | ||
| 95 | + | pub async fn list( |
|
| 96 | + | State(app): State<Shared>, |
|
| 97 | + | viewer: Viewer, |
|
| 98 | + | Path((owner, name)): Path<(String, String)>, |
|
| 99 | + | ) -> Result<Page<ForksPage>, Error> { |
|
| 100 | + | let shell = Shell::new(&app, &format!("/{owner}/{name}/forks"), &viewer); |
|
| 101 | + | let (_, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?; |
|
| 102 | + | let all = db::repos::forks_of(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 103 | + | let forks = super::home::visible(&app, &shell, &viewer, all).await?; |
|
| 104 | + | Ok(Page(ForksPage { repo: RepoShell::new(&app, &row, "code").with_role(role), shell, forks })) |
|
| 105 | + | } |
|
| 106 | + | ||
| 107 | + | /// What the overview says about forks: where this one came from (when the |
|
| 108 | + | /// viewer may see it) and how many forks it has. |
|
| 109 | + | pub struct ForkInfo { |
|
| 110 | + | pub upstream: Option<db::repos::RepoRow>, |
|
| 111 | + | pub forks: usize, |
|
| 112 | + | } |
|
| 113 | + | ||
| 114 | + | pub async fn info(app: &Shared, shell: &Shell, viewer: &Viewer, row: &db::repos::RepoRow) -> ForkInfo { |
|
| 115 | + | let upstream = match row.forked_from { |
|
| 116 | + | Some(id) => match db::repos::get_by_id(&app.db, id).await { |
|
| 117 | + | Ok(Some(up)) => super::home::visible(app, shell, viewer, vec![up]).await.ok().and_then(|mut v| v.pop()), |
|
| 118 | + | _ => None, |
|
| 119 | + | }, |
|
| 120 | + | None => None, |
|
| 121 | + | }; |
|
| 122 | + | let forks = match db::repos::forks_of(&app.db, row.id).await { |
|
| 123 | + | Ok(all) => super::home::visible(app, shell, viewer, all).await.map(|v| v.len()).unwrap_or(0), |
|
| 124 | + | Err(_) => 0, |
|
| 125 | + | }; |
|
| 126 | + | ForkInfo { upstream, forks } |
|
| 127 | + | } |
added templates/fork_new.html +16 −0
| 1 | + | {% extends "base.html" %} |
|
| 2 | + | {% import "repo_tabs.html" as m %} |
|
| 3 | + | {% block title %}Fork {{ repo.owner }}/{{ repo.name }}{% endblock %} |
|
| 4 | + | {% block tabs %}{% call m::tabs(repo, "code") %}{% endblock %} |
|
| 5 | + | {% block content %} |
|
| 6 | + | <div class="head"><h1>Fork <small>{{ repo.owner }}/{{ repo.name }}</small></h1></div> |
|
| 7 | + | {% if let Some(e) = error %}<p class="error">{{ e }}</p>{% endif %} |
|
| 8 | + | <form class="card" method="post" action="{{ repo.url }}/fork"> |
|
| 9 | + | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 10 | + | <label for="owner">Owner</label> |
|
| 11 | + | <select id="owner" name="owner">{% for o in owners %}<option value="{{ o }}">{{ o }}</option>{% endfor %}</select> |
|
| 12 | + | <label for="name">Name</label><input type="text" id="name" name="name" value="{{ name }}" required> |
|
| 13 | + | <p class="small mute">The fork is a repository of its own, with every change and bookmark of this one and the same visibility. Its trust policy starts empty. Propose its changes back here from its page.</p> |
|
| 14 | + | <button type="submit">FORK</button> |
|
| 15 | + | </form> |
|
| 16 | + | {% endblock %} |
added templates/forks.html +17 −0
| 1 | + | {% extends "base.html" %} |
|
| 2 | + | {% import "repo_tabs.html" as m %} |
|
| 3 | + | {% block title %}Forks · {{ repo.owner }}/{{ repo.name }}{% endblock %} |
|
| 4 | + | {% block tabs %}{% call m::tabs(repo, "code") %}{% endblock %} |
|
| 5 | + | {% block content %} |
|
| 6 | + | <div class="head"><h1>Forks <small>{{ forks.len() }}</small></h1></div> |
|
| 7 | + | {% if forks.is_empty() %}<p class="empty">No forks you can see.</p>{% else %} |
|
| 8 | + | <ul class="rows repos"> |
|
| 9 | + | {% for r in forks %} |
|
| 10 | + | <li> |
|
| 11 | + | <div><img class="avatar xs" src="/avatar/{{ r.owner }}" alt=""> <a class="name" href="/{{ r.owner }}/{{ r.name }}"><span class="mute">{{ r.owner }} /</span> <strong>{{ r.name }}</strong></a>{% if r.visibility != "public" %} <span class="tag">{{ r.visibility }}</span>{% endif %}</div> |
|
| 12 | + | <div class="right small">updated <time title="{{ r.updated_ms|when_i }}">{{ r.updated_ms|ago_i }}</time></div> |
|
| 13 | + | </li> |
|
| 14 | + | {% endfor %} |
|
| 15 | + | </ul> |
|
| 16 | + | {% endif %} |
|
| 17 | + | {% endblock %} |
modified crates/hub/src/db/repos.rs +14 −0
| … | 17 unchanged lines |
||
| 18 | 18 | pub git_mirror: bool, |
|
| 19 | 19 | #[sqlx(default)] |
|
| 20 | 20 | pub git_mirror_state: String, |
|
| 21 | + | /// The repository this one was forked from (migration 0009). |
|
| 22 | + | #[sqlx(default)] |
|
| 23 | + | pub forked_from: Option<i64>, |
|
| 21 | 24 | } |
|
| 22 | 25 | ||
| 23 | 26 | impl RepoRow { |
|
| … | 117 unchanged lines |
||
| 141 | 144 | .await |
|
| 142 | 145 | } |
|
| 143 | 146 | ||
| 147 | + | /// Record that `id` is a fork of `from`. |
|
| 148 | + | pub async fn set_forked_from(pool: &Pool, id: i64, from: i64) -> sqlx::Result<()> { |
|
| 149 | + | sqlx::query("UPDATE repositories SET forked_from = ? WHERE id = ?").bind(from).bind(id).execute(pool).await?; |
|
| 150 | + | Ok(()) |
|
| 151 | + | } |
|
| 152 | + | ||
| 153 | + | /// The forks of `id`, newest activity first. |
|
| 154 | + | pub async fn forks_of(pool: &Pool, id: i64) -> sqlx::Result<Vec<RepoRow>> { |
|
| 155 | + | sqlx::query_as("SELECT * FROM repositories WHERE forked_from = ? ORDER BY updated_ms DESC").bind(id).fetch_all(pool).await |
|
| 156 | + | } |
|
| 157 | + | ||
| 144 | 158 | pub async fn set_git_mirror(pool: &Pool, id: i64, on: bool) -> sqlx::Result<()> { |
|
| 145 | 159 | sqlx::query("UPDATE repositories SET git_mirror = ? WHERE id = ?").bind(on).bind(id).execute(pool).await?; |
|
| 146 | 160 | Ok(()) |
|
| … | 6 unchanged lines |
modified crates/hub/src/repos/mod.rs +63 −0
| … | 87 unchanged lines |
||
| 88 | 88 | }) |
|
| 89 | 89 | } |
|
| 90 | 90 | ||
| 91 | + | /// Fork `src` into `dest` (hub spec §4): a new bare repository with its |
|
| 92 | + | /// own id, the source's objects hard-linked where the file system allows |
|
| 93 | + | /// (copied otherwise; objects are immutable, so sharing them is safe), |
|
| 94 | + | /// and the source's bookmarks. The trust policy is not copied: the |
|
| 95 | + | /// fork's owner sets their own. Runs under the source's read lock. |
|
| 96 | + | pub async fn fork(&self, src: &RepoName, dest: &RepoName) -> Result<(), RepoError> { |
|
| 97 | + | let guard = self.lock(src).read_owned().await; |
|
| 98 | + | let (from, to) = (self.dir(src), self.dir(dest)); |
|
| 99 | + | if !self.exists(src) { |
|
| 100 | + | return Err(RepoError::NotFound); |
|
| 101 | + | } |
|
| 102 | + | if to.exists() { |
|
| 103 | + | return Err(RepoError::Exists(dest.to_string())); |
|
| 104 | + | } |
|
| 105 | + | tokio::task::spawn_blocking(move || { |
|
| 106 | + | let _guard = guard; |
|
| 107 | + | let made = (|| -> Result<(), RepoError> { |
|
| 108 | + | drop(Repo::init_bare(&to)?); |
|
| 109 | + | let objects = |d: &Path| d.join(oxy_core::repo::OXY_DIR).join("objects"); |
|
| 110 | + | link_tree(&objects(&from), &objects(&to))?; |
|
| 111 | + | let source = Repo::open(&from)?; |
|
| 112 | + | let fork = Repo::open(&to)?; |
|
| 113 | + | let existing: Vec<String> = fork.bookmarks()?.into_iter().map(|b| b.name).collect(); |
|
| 114 | + | for b in source.bookmarks()? { |
|
| 115 | + | if existing.contains(&b.name) { |
|
| 116 | + | fork.bookmark_set(&b.name, &b.heads)?; |
|
| 117 | + | } else { |
|
| 118 | + | fork.bookmark_create(&b.name, Some(&b.heads))?; |
|
| 119 | + | } |
|
| 120 | + | } |
|
| 121 | + | Ok(()) |
|
| 122 | + | })(); |
|
| 123 | + | if made.is_err() { |
|
| 124 | + | let _ = std::fs::remove_dir_all(&to); |
|
| 125 | + | } |
|
| 126 | + | made |
|
| 127 | + | }) |
|
| 128 | + | .await |
|
| 129 | + | .map_err(|e| RepoError::Io(std::io::Error::other(e.to_string())))? |
|
| 130 | + | } |
|
| 131 | + | ||
| 91 | 132 | /// Move a repository to the trash (hub spec §4.1); a job empties it later. |
|
| 92 | 133 | pub fn trash(&self, name: &RepoName) -> Result<PathBuf, RepoError> { |
|
| 93 | 134 | let dir = self.dir(name); |
|
| … | 67 unchanged lines |
||
| 161 | 202 | self.lock(name).read_owned().await |
|
| 162 | 203 | } |
|
| 163 | 204 | } |
|
| 205 | + | ||
| 206 | + | /// Hard-link every file under `from` into the same place under `to`, |
|
| 207 | + | /// copying when a link is not possible (another file system). Temporary |
|
| 208 | + | /// files of writers in progress are skipped. |
|
| 209 | + | fn link_tree(from: &Path, to: &Path) -> std::io::Result<()> { |
|
| 210 | + | std::fs::create_dir_all(to)?; |
|
| 211 | + | for e in std::fs::read_dir(from)? { |
|
| 212 | + | let e = e?; |
|
| 213 | + | let name = e.file_name(); |
|
| 214 | + | let n = name.to_string_lossy(); |
|
| 215 | + | if n.starts_with("tmp-") || n.starts_with("sigs-tmp-") || n.contains(".tmp") { |
|
| 216 | + | continue; |
|
| 217 | + | } |
|
| 218 | + | let (src, dst) = (e.path(), to.join(&name)); |
|
| 219 | + | if e.file_type()?.is_dir() { |
|
| 220 | + | link_tree(&src, &dst)?; |
|
| 221 | + | } else if !dst.exists() && std::fs::hard_link(&src, &dst).is_err() { |
|
| 222 | + | std::fs::copy(&src, &dst)?; |
|
| 223 | + | } |
|
| 224 | + | } |
|
| 225 | + | Ok(()) |
|
| 226 | + | } |
modified crates/hub/src/web/home.rs +6 −1
| … | 142 unchanged lines |
||
| 143 | 143 | ||
| 144 | 144 | /// The repositories among `rows` the viewer may see: public ones, internal |
|
| 145 | 145 | /// ones when signed in, and those the viewer owns or collaborates on. |
|
| 146 | - | async fn visible(app: &Shared, shell: &Shell, viewer: &Viewer, rows: Vec<db::repos::RepoRow>) -> Result<Vec<db::repos::RepoRow>, Error> { |
|
| 146 | + | pub(crate) async fn visible( |
|
| 147 | + | app: &Shared, |
|
| 148 | + | shell: &Shell, |
|
| 149 | + | viewer: &Viewer, |
|
| 150 | + | rows: Vec<db::repos::RepoRow>, |
|
| 151 | + | ) -> Result<Vec<db::repos::RepoRow>, Error> { |
|
| 147 | 152 | let mine: Vec<i64> = match viewer.account() { |
|
| 148 | 153 | Some(a) if a.is_admin => rows.iter().map(|r| r.id).collect(), |
|
| 149 | 154 | Some(a) => db::accounts::visible_repo_ids(&app.db, a.id).await.map_err(|e| Error::from_db(shell.clone(), e))?, |
|
| … | 31 unchanged lines |
modified crates/hub/src/web/mod.rs +3 −0
| … | 6 unchanged lines |
||
| 7 | 7 | mod avatar; |
|
| 8 | 8 | pub mod edit; |
|
| 9 | 9 | pub mod filters; |
|
| 10 | + | mod forks; |
|
| 10 | 11 | pub mod history; |
|
| 11 | 12 | mod home; |
|
| 12 | 13 | mod inbox; |
|
| … | 77 unchanged lines |
||
| 90 | 91 | .route("/{owner}/{name}/conflicts", get(refs::conflicts)) |
|
| 91 | 92 | .route("/{owner}/{name}/trust", get(refs::trust)) |
|
| 92 | 93 | .route("/{owner}/{name}/pushes", get(refs::pushes)) |
|
| 94 | + | .route("/{owner}/{name}/fork", get(forks::form).post(forks::create)) |
|
| 95 | + | .route("/{owner}/{name}/forks", get(forks::list)) |
|
| 93 | 96 | .route("/{owner}/{name}/settings", get(repo_settings::settings).post(repo_settings::update)) |
|
| 94 | 97 | .route("/{owner}/{name}/settings/collaborators", post(repo_settings::collaborator)) |
|
| 95 | 98 | .route("/{owner}/{name}/settings/delete", post(repo_settings::delete)) |
|
| … | 397 unchanged lines |
modified crates/hub/src/web/repo.rs +3 −1
| … | 20 unchanged lines |
||
| 21 | 21 | latest_release: Option<crate::db::releases::Release>, |
|
| 22 | 22 | /// Set while the repository has no changes (hub spec §6). |
|
| 23 | 23 | quickstart: Option<Quickstart>, |
|
| 24 | + | fork: super::forks::ForkInfo, |
|
| 24 | 25 | } |
|
| 25 | 26 | ||
| 26 | 27 | /// A remote name from the hub's host: its first label when that is a |
|
| … | 78 unchanged lines |
||
| 105 | 106 | }; |
|
| 106 | 107 | let latest_release = crate::db::releases::latest(&app.db, row.id).await.ok().flatten(); |
|
| 107 | 108 | let quickstart = (o.changes == 0).then(|| Quickstart::new(&app, rs.can(crate::auth::Access::Write))); |
|
| 108 | - | Ok(Page(OverviewPage { shell, repo: rs, o, readme_html, watching, latest_release, quickstart })) |
|
| 109 | + | let fork = super::forks::info(&app, &shell, &viewer, &row).await; |
|
| 110 | + | Ok(Page(OverviewPage { shell, repo: rs, o, readme_html, watching, latest_release, quickstart, fork })) |
|
| 109 | 111 | } |
|
| 110 | 112 | ||
| 111 | 113 | impl view::Overview { |
|
| … | 295 unchanged lines |
modified crates/hub/src/web/repo_settings.rs +1 −1
| … | 19 unchanged lines |
||
| 20 | 20 | error: Option<String>, |
|
| 21 | 21 | } |
|
| 22 | 22 | ||
| 23 | - | async fn owners_for(app: &Shared, me: &accounts::Account) -> Vec<String> { |
|
| 23 | + | pub(crate) async fn owners_for(app: &Shared, me: &accounts::Account) -> Vec<String> { |
|
| 24 | 24 | let mut owners = vec![me.username.clone()]; |
|
| 25 | 25 | if let Ok(orgs) = accounts::orgs_of(&app.db, me.id).await { |
|
| 26 | 26 | owners.extend(orgs.into_iter().map(|(o, _)| o.name)); |
|
| … | 429 unchanged lines |
modified crates/hub/tests/issues.rs +62 −0
| … | 464 unchanged lines |
||
| 465 | 465 | ||
| 466 | 466 | assert!(quiet.body.contains("?w=0"), "the toggle leads back"); |
|
| 467 | 467 | } |
|
| 468 | + | ||
| 469 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 470 | + | async fn fork_and_propose_upstream() { |
|
| 471 | + | let h = hub().await; |
|
| 472 | + | let app = &h.app; |
|
| 473 | + | let andy = register(app, "andy").await; |
|
| 474 | + | let bob = register(app, "bob").await; |
|
| 475 | + | app.repos.create(&RepoName::parse("andy/demo").unwrap(), None).unwrap(); |
|
| 476 | + | db::repos::create(&app.db, "andy", "demo", "The demo", hub_api::Visibility::Public).await.unwrap(); |
|
| 477 | + | let work = tempfile::tempdir().unwrap(); |
|
| 478 | + | let mut client = Repo::init(work.path()).unwrap(); |
|
| 479 | + | client.config.set("remote.hub.url", &format!("{}/andy/demo", h.base)).unwrap(); |
|
| 480 | + | client.config.set("remote.hub.token", "site").unwrap(); |
|
| 481 | + | client.save_config().unwrap(); |
|
| 482 | + | write(work.path(), "README.md", b"# demo\n"); |
|
| 483 | + | client.commit("first", &[]).unwrap(); |
|
| 484 | + | assert!(client.push("hub", false).unwrap().iter().all(|r| r.ok)); |
|
| 485 | + | ||
| 486 | + | // Bob forks it; he cannot fork into Andy's name. |
|
| 487 | + | let page = get(app, "/andy/demo", Some(&bob)).await; |
|
| 488 | + | assert!(page.body.contains("/andy/demo/fork"), "a fork button: {}", page.body); |
|
| 489 | + | let csrf = csrf_of(&get(app, "/andy/demo/fork", Some(&bob)).await.body); |
|
| 490 | + | let r = post(app, "/andy/demo/fork", Some(&bob), &format!("_csrf={csrf}&owner=andy&name=other")).await; |
|
| 491 | + | assert!(r.body.contains("cannot create repositories under that owner"), "{}", r.body); |
|
| 492 | + | let r = post(app, "/andy/demo/fork", Some(&bob), &format!("_csrf={csrf}&owner=bob&name=demo")).await; |
|
| 493 | + | assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body); |
|
| 494 | + | assert_eq!(r.location.as_deref(), Some("/bob/demo")); |
|
| 495 | + | let forked = get(app, "/bob/demo", Some(&bob)).await; |
|
| 496 | + | assert!(forked.body.contains("README.md") && forked.body.contains("forked from"), "{}", forked.body); |
|
| 497 | + | assert!(forked.body.contains("propose to andy/demo")); |
|
| 498 | + | assert!(get(app, "/andy/demo", None).await.body.contains("1 fork")); |
|
| 499 | + | assert!(get(app, "/andy/demo/forks", None).await.body.contains("/bob/demo")); |
|
| 500 | + | let fork_row = db::repos::get(&app.db, "bob", "demo").await.unwrap().unwrap(); |
|
| 501 | + | let up_row = db::repos::get(&app.db, "andy", "demo").await.unwrap().unwrap(); |
|
| 502 | + | assert_eq!(fork_row.forked_from, Some(up_row.id)); |
|
| 503 | + | assert_eq!(fork_row.description, "The demo"); |
|
| 504 | + | let fork_repo = app.repos.open(&RepoName::parse("bob/demo").unwrap()).unwrap(); |
|
| 505 | + | let up_repo = app.repos.open(&RepoName::parse("andy/demo").unwrap()).unwrap(); |
|
| 506 | + | assert_ne!(fork_repo.id().unwrap(), up_repo.id().unwrap(), "a fork is a repository of its own"); |
|
| 507 | + | assert_eq!(fork_repo.bookmark_heads().unwrap(), up_repo.bookmark_heads().unwrap()); |
|
| 508 | + | assert_eq!(fork_repo.fsck().unwrap().errors(), 0); |
|
| 509 | + | ||
| 510 | + | // Bob works in a clone of his fork, pushes a bookmark, and proposes it upstream. |
|
| 511 | + | let bw = tempfile::tempdir().unwrap(); |
|
| 512 | + | let mut mine = Repo::clone(&format!("{}/bob/demo", h.base), &bw.path().join("c")).unwrap(); |
|
| 513 | + | mine.config.set("remote.origin.token", "site").unwrap(); |
|
| 514 | + | mine.save_config().unwrap(); |
|
| 515 | + | mine.bookmark_create("feat", None).unwrap(); |
|
| 516 | + | mine.bookmark_switch("feat", false).unwrap(); |
|
| 517 | + | write(&bw.path().join("c"), "feature.txt", b"new\n"); |
|
| 518 | + | mine.commit("Add a feature", &[]).unwrap(); |
|
| 519 | + | assert!(mine.push_bookmark("origin", "feat", false).unwrap().iter().all(|r| r.ok)); |
|
| 520 | + | let csrf = csrf_of(&get(app, "/andy/demo/proposals/new?source=bob/demo:feat", Some(&bob)).await.body); |
|
| 521 | + | let r = |
|
| 522 | + | post(app, "/andy/demo/proposals/new", Some(&bob), &format!("_csrf={csrf}&title=From+my+fork&target=main&source=bob%2Fdemo%3Afeat")) |
|
| 523 | + | .await; |
|
| 524 | + | assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body); |
|
| 525 | + | let p = get(app, &r.location.unwrap(), Some(&andy)).await; |
|
| 526 | + | assert!(p.body.contains("From my fork") && p.body.contains("fork bob/demo"), "{}", p.body); |
|
| 527 | + | let files = get(app, "/andy/demo/proposal/1?tab=files", Some(&andy)).await; |
|
| 528 | + | assert!(files.body.contains("feature.txt"), "{}", files.body); |
|
| 529 | + | } |
modified docs/spec.md +1 −0
| … | 228 unchanged lines |
||
| 229 | 229 | | `/explore` | Public repositories, sorted by activity; language and topic filters | |
|
| 230 | 230 | | `/{owner}` | A person: identicon, display name, bio, location, website, linked signing keys, organisations, repositories the viewer can see, and their recent activity (never the email). An organisation: identicon, description, members with roles, teams with sizes, repositories, recent activity in them. `/avatar/{name}` serves the identicon (SVG, a mirrored 5×5 pattern and hue from BLAKE3 of the lower-cased name). | |
|
| 231 | 231 | | `/{owner}/{name}` | Repository overview (§6.4); while empty, a quickstart with the exact commands for this hub (remote, certificate, token, push; start from nothing; import from Git) | |
|
| 232 | + | | `/{owner}/{name}/fork`, `/{owner}/{name}/forks` | Fork under an owner the viewer may create repositories for (same visibility, own repository id, objects hard-linked, bookmarks copied, policy not copied); the list of forks the viewer can see. The overview shows "forked from", the fork count, and on a fork a link that opens a proposal upstream with `owner/name:bookmark` as its source | |
|
| 232 | 233 | | `/{owner}/{name}/tree/{ref}/{path}` | Directory listing at a bookmark, tag or change (§6.5) | |
|
| 233 | 234 | | `/{owner}/{name}/file/{ref}/{path}` | File view, `?plain=1` raw, `?blame=1` blame (§6.6) | |
|
| 234 | 235 | | `/{owner}/{name}/changes/{ref}` | History (§6.7) | |
|
| … | 314 unchanged lines |
modified templates/overview.html +7 −1
| … | 27 unchanged lines |
||
| 28 | 28 | <aside class="side"> |
|
| 29 | 29 | <h3>About</h3> |
|
| 30 | 30 | {% if repo.description.is_empty() %}<p class="mute small">No description.</p>{% else %}<p>{{ repo.description }}</p>{% endif %} |
|
| 31 | - | {% if shell.user.is_some() %}<form method="post" action="{{ repo.url }}/watch"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button type="submit" class="small">{% if watching %}UNWATCH{% else %}WATCH{% endif %}</button></form>{% endif %} |
|
| 31 | + | {% if let Some(up) = fork.upstream %}<p class="small">forked from <a href="/{{ up.owner }}/{{ up.name }}">{{ up.owner }}/{{ up.name }}</a></p>{% endif %} |
|
| 32 | + | <div class="bar"> |
|
| 33 | + | {% if shell.user.is_some() %}<form class="inline" method="post" action="{{ repo.url }}/watch"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button type="submit" class="small">{% if watching %}UNWATCH{% else %}WATCH{% endif %}</button></form> |
|
| 34 | + | <a class="go small" href="{{ repo.url }}/fork">fork</a>{% endif %} |
|
| 35 | + | {% if fork.forks > 0 %}<a class="small" href="{{ repo.url }}/forks">{{ fork.forks|plural("fork", "forks") }}</a>{% endif %} |
|
| 36 | + | </div> |
|
| 37 | + | {% if let Some(up) = fork.upstream %}{% if shell.user.is_some() %}<a class="go small" href="/{{ up.owner }}/{{ up.name }}/proposals/new?source={{ repo.owner }}/{{ repo.name }}:{{ repo.default_bookmark }}">propose to {{ up.owner }}/{{ up.name }}</a>{% endif %}{% endif %} |
|
| 32 | 38 | <h3>Clone</h3> |
|
| 33 | 39 | <div class="clone"><code>oxy clone {{ repo.clone_url }}</code> <button class="small" data-copy="oxy clone {{ repo.clone_url }}">copy</button></div> |
|
| 34 | 40 | <h3>Bookmarks</h3> |
|
| … | 14 unchanged lines |