OXYGENHUB

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