Quickstart for empty repositories 4edcf24025cf
An empty repository's overview shows the exact commands for this hub: add a remote named after the host, fetch and trust the self-signed certificate when there is one, set a token, push; start from nothing; import a Git branch. Readers without write access are told nothing has been pushed yet. The token stays a placeholder.
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-09-27 23:16 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
4edcf24025cf5a00e617c4270280efb55b88a5805bc90221e7741b22e4188d35- Depends on
- 18e381ea88c2 25ab8c73514a 3f4b0ea6eabf 93be9ce4653e d23464c0ac60
- Depended on by
- 84e3c09656b2 ffb67a81188c c9aacc0e7dc1 757195ad7a5d f677d6270298 64caed18236a 1553bfc9ae53 07b1a5bbf481 c40c2d3edb51
- 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
- 13 atoms, 1 tree operation, 7 files
Browse the tree at this change
added templates/quickstart.html +26 −0
| 1 | + | <section class="quickstart" aria-labelledby="qs-title"> |
|
| 2 | + | <h2 id="qs-title">This repository is empty</h2> |
|
| 3 | + | {% if q.can_push %} |
|
| 4 | + | <p>Push to it from any Oxygen checkout. It takes the id of the first repository pushed, so clones made later share it. |
|
| 5 | + | You need a personal access token with <code>repo:write</code>: <a href="/settings/tokens">create one</a> and put it where the commands say <code>YOUR-TOKEN</code>.</p> |
|
| 6 | + | {% if q.ca_url.is_some() %}<p class="small mute">This hub uses a self-signed certificate, so the commands save it and point the remote at it.</p>{% endif %} |
|
| 7 | + | ||
| 8 | + | <h3>Push an existing checkout</h3> |
|
| 9 | + | {% let cmd = q.connect(repo.clone_url) %} |
|
| 10 | + | <div class="cmds"><pre><code>{{ cmd }}</code></pre><button class="small" data-copy="{{ cmd }}">copy</button></div> |
|
| 11 | + | ||
| 12 | + | <h3>Start from nothing</h3> |
|
| 13 | + | {% let cmd = q.fresh(repo.name) %} |
|
| 14 | + | <div class="cmds"><pre><code>{{ cmd }}</code></pre><button class="small" data-copy="{{ cmd }}">copy</button></div> |
|
| 15 | + | <p class="small">Then run the commands above.</p> |
|
| 16 | + | ||
| 17 | + | <h3>Bring a Git branch</h3> |
|
| 18 | + | {% let cmd = q.git_import(repo.name) %} |
|
| 19 | + | <div class="cmds"><pre><code>{{ cmd }}</code></pre><button class="small" data-copy="{{ cmd }}">copy</button></div> |
|
| 20 | + | <p class="small">Then run the commands above. Every Git commit becomes a change.</p> |
|
| 21 | + | ||
| 22 | + | {% if repo.can(crate::auth::Access::Write) %}<p>Or <a href="{{ repo.url }}/newfile/{{ repo.default_bookmark }}">create the first file</a> in the browser.</p>{% endif %} |
|
| 23 | + | {% else %} |
|
| 24 | + | <p>Nothing has been pushed yet. Watch it to hear when something is.</p> |
|
| 25 | + | {% endif %} |
|
| 26 | + | </section> |
modified crates/hub/src/web/repo.rs +83 −1
| … | 18 unchanged lines |
||
| 19 | 19 | readme_html: Option<String>, |
|
| 20 | 20 | watching: bool, |
|
| 21 | 21 | latest_release: Option<crate::db::releases::Release>, |
|
| 22 | + | /// Set while the repository has no changes (hub spec §6). |
|
| 23 | + | quickstart: Option<Quickstart>, |
|
| 24 | + | } |
|
| 25 | + | ||
| 26 | + | /// A remote name from the hub's host: its first label when that is a |
|
| 27 | + | /// plain lowercase word (`earth` for `https://earth.example.org:7343`), |
|
| 28 | + | /// otherwise `hub`. |
|
| 29 | + | fn remote_name(base_url: &str) -> String { |
|
| 30 | + | let host = base_url.split("://").nth(1).unwrap_or("").split(['/', ':']).next().unwrap_or(""); |
|
| 31 | + | let first = host.split('.').next().unwrap_or(""); |
|
| 32 | + | let plain = first.len() <= 20 |
|
| 33 | + | && first.starts_with(|c: char| c.is_ascii_lowercase()) |
|
| 34 | + | && first.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit()); |
|
| 35 | + | if plain && first != "www" && first != "localhost" { |
|
| 36 | + | first.to_string() |
|
| 37 | + | } else { |
|
| 38 | + | "hub".to_string() |
|
| 39 | + | } |
|
| 40 | + | } |
|
| 41 | + | ||
| 42 | + | /// The commands that get a first push into an empty repository, filled |
|
| 43 | + | /// in for this hub and this repository. |
|
| 44 | + | pub struct Quickstart { |
|
| 45 | + | /// A remote name taken from the hub's host name (`earth` for |
|
| 46 | + | /// `earth.example.org`), `hub` when that is not a plain word. |
|
| 47 | + | pub remote: String, |
|
| 48 | + | /// Where to download the hub's certificate, when it is self-signed. |
|
| 49 | + | pub ca_url: Option<String>, |
|
| 50 | + | /// Where the commands save that certificate. |
|
| 51 | + | pub ca_file: String, |
|
| 52 | + | /// Whether the viewer may push here. |
|
| 53 | + | pub can_push: bool, |
|
| 54 | + | } |
|
| 55 | + | ||
| 56 | + | impl Quickstart { |
|
| 57 | + | pub fn new(app: &Shared, can_push: bool) -> Quickstart { |
|
| 58 | + | let base = app.config.base_url().trim_end_matches('/').to_string(); |
|
| 59 | + | let remote = remote_name(&base); |
|
| 60 | + | let ca_file = format!("~/.config/oxy/{remote}-ca.pem"); |
|
| 61 | + | let ca_url = app.config.tls.self_signed.then(|| format!("{base}/.well-known/oxyhub-ca.pem")); |
|
| 62 | + | Quickstart { remote, ca_url, ca_file, can_push } |
|
| 63 | + | } |
|
| 64 | + | ||
| 65 | + | /// Connect a checkout to this repository and push; the token line |
|
| 66 | + | /// stays a placeholder so no secret is ever printed. |
|
| 67 | + | pub fn connect(&self, clone_url: &str) -> String { |
|
| 68 | + | let r = &self.remote; |
|
| 69 | + | let mut s = format!("oxy remote add {r} {clone_url}\n"); |
|
| 70 | + | if let Some(ca) = &self.ca_url { |
|
| 71 | + | s += &format!("curl -fsSL {ca} -o {}\n", self.ca_file); |
|
| 72 | + | s += &format!("oxy config remote.{r}.ca {}\n", self.ca_file); |
|
| 73 | + | } |
|
| 74 | + | s += &format!("oxy config remote.{r}.token YOUR-TOKEN\n"); |
|
| 75 | + | s += &format!("oxy push {r}"); |
|
| 76 | + | s |
|
| 77 | + | } |
|
| 78 | + | ||
| 79 | + | /// Start a repository from nothing. |
|
| 80 | + | pub fn fresh(&self, name: &str) -> String { |
|
| 81 | + | format!("mkdir {name} && cd {name}\noxy init\necho \"# {name}\" > README.md\noxy commit -m \"First change\"") |
|
| 82 | + | } |
|
| 83 | + | ||
| 84 | + | /// Bring a Git branch across. |
|
| 85 | + | pub fn git_import(&self, name: &str) -> String { |
|
| 86 | + | format!("oxy git import --branch main path/to/git-checkout {name}\ncd {name}") |
|
| 87 | + | } |
|
| 22 | 88 | } |
|
| 23 | 89 | ||
| 24 | 90 | pub async fn overview( |
|
| … | 13 unchanged lines |
||
| 38 | 104 | None => false, |
|
| 39 | 105 | }; |
|
| 40 | 106 | let latest_release = crate::db::releases::latest(&app.db, row.id).await.ok().flatten(); |
|
| 41 | - | Ok(Page(OverviewPage { shell, repo: rs, o, readme_html, watching, latest_release })) |
|
| 107 | + | 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 })) |
|
| 42 | 109 | } |
|
| 43 | 110 | ||
| 44 | 111 | impl view::Overview { |
|
| … | 277 unchanged lines |
||
| 322 | 389 | error, |
|
| 323 | 390 | })) |
|
| 324 | 391 | } |
|
| 392 | + | ||
| 393 | + | #[cfg(test)] |
|
| 394 | + | mod tests { |
|
| 395 | + | use super::remote_name; |
|
| 396 | + | ||
| 397 | + | #[test] |
|
| 398 | + | fn remote_names_come_from_the_host() { |
|
| 399 | + | assert_eq!(remote_name("https://earth.andymccall.xyz:7343"), "earth"); |
|
| 400 | + | assert_eq!(remote_name("https://hub.example.org"), "hub"); |
|
| 401 | + | assert_eq!(remote_name("http://127.0.0.1:7300"), "hub"); |
|
| 402 | + | assert_eq!(remote_name("http://localhost:7300"), "hub"); |
|
| 403 | + | assert_eq!(remote_name("https://www.example.org"), "hub"); |
|
| 404 | + | assert_eq!(remote_name("https://My-Host.example"), "hub"); |
|
| 405 | + | } |
|
| 406 | + | } |
modified crates/hub/tests/issues.rs +40 −0
| … | 311 unchanged lines |
||
| 312 | 312 | // Explore still lists every visible repository. |
|
| 313 | 313 | assert!(get(app, "/explore", Some(&bob)).await.body.contains("andy /")); |
|
| 314 | 314 | } |
|
| 315 | + | ||
| 316 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 317 | + | async fn empty_repository_shows_a_quickstart() { |
|
| 318 | + | let h = hub().await; |
|
| 319 | + | let app = &h.app; |
|
| 320 | + | let andy = register(app, "andy").await; |
|
| 321 | + | let bob = register(app, "bob").await; |
|
| 322 | + | app.repos.create(&RepoName::parse("andy/fresh").unwrap(), None).unwrap(); |
|
| 323 | + | db::repos::create(&app.db, "andy", "fresh", "", hub_api::Visibility::Public).await.unwrap(); |
|
| 324 | + | ||
| 325 | + | let page = get(app, "/andy/fresh", Some(&andy)).await; |
|
| 326 | + | assert!(page.body.contains("This repository is empty"), "{}", page.body); |
|
| 327 | + | let clone_url = format!("{}/andy/fresh", h.base); |
|
| 328 | + | assert!(page.body.contains(&format!("oxy remote add hub {clone_url}")), "{}", page.body); |
|
| 329 | + | assert!(page.body.contains("oxy config remote.hub.token YOUR-TOKEN")); |
|
| 330 | + | assert!(page.body.contains("oxy push hub")); |
|
| 331 | + | assert!( |
|
| 332 | + | page.body.contains("mkdir fresh && cd fresh") || page.body.contains("mkdir fresh && cd fresh"), |
|
| 333 | + | "{}", |
|
| 334 | + | page.body |
|
| 335 | + | ); |
|
| 336 | + | assert!(page.body.contains("oxy git import --branch main")); |
|
| 337 | + | assert!(!page.body.contains("oxyhub-ca.pem"), "no certificate step without a self-signed certificate"); |
|
| 338 | + | ||
| 339 | + | // A reader without write access is not told to push. |
|
| 340 | + | let page = get(app, "/andy/fresh", Some(&bob)).await; |
|
| 341 | + | assert!(page.body.contains("Nothing has been pushed yet") && !page.body.contains("oxy push hub"), "{}", page.body); |
|
| 342 | + | ||
| 343 | + | // After a first push the quickstart gives way to the tree. |
|
| 344 | + | let work = tempfile::tempdir().unwrap(); |
|
| 345 | + | let mut client = Repo::init(work.path()).unwrap(); |
|
| 346 | + | client.config.set("remote.hub.url", &clone_url).unwrap(); |
|
| 347 | + | client.config.set("remote.hub.token", "site").unwrap(); |
|
| 348 | + | client.save_config().unwrap(); |
|
| 349 | + | write(work.path(), "README.md", b"# fresh\n"); |
|
| 350 | + | client.commit("First change", &[]).unwrap(); |
|
| 351 | + | assert!(client.push("hub", false).unwrap().iter().all(|r| r.ok)); |
|
| 352 | + | let page = get(app, "/andy/fresh", Some(&andy)).await; |
|
| 353 | + | assert!(!page.body.contains("This repository is empty") && page.body.contains("README.md"), "{}", page.body); |
|
| 354 | + | } |
modified docs/spec.md +1 −1
| … | 225 unchanged lines |
||
| 226 | 226 | | `/` | Signed in: dashboard (your repositories, proposals awaiting your review, assigned issues, recent activity). Anonymous: explore. | |
|
| 227 | 227 | | `/explore` | Public repositories, sorted by activity; language and topic filters | |
|
| 228 | 228 | | `/{owner}` | Profile or organisation: repositories, linked keys with fingerprints, activity | |
|
| 229 | - | | `/{owner}/{name}` | Repository overview (§6.4) | |
|
| 229 | + | | `/{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) | |
|
| 230 | 230 | | `/{owner}/{name}/tree/{ref}/{path}` | Directory listing at a bookmark, tag or change (§6.5) | |
|
| 231 | 231 | | `/{owner}/{name}/file/{ref}/{path}` | File view, `?plain=1` raw, `?blame=1` blame (§6.6) | |
|
| 232 | 232 | | `/{owner}/{name}/changes/{ref}` | History (§6.7) | |
|
| … | 310 unchanged lines |
modified templates/overview.html +4 −0
| … | 15 unchanged lines |
||
| 16 | 16 | <form class="find inline-find" method="get" action="{{ repo.url }}/search"><input type="search" name="q" placeholder="Search code…"></form> |
|
| 17 | 17 | {% if let Some(c) = o.latest %}<span class="small mute">latest <a href="{{ repo.url }}/change/{{ c.hash }}">{{ c.title }}</a> · {{ c.when_ms|ago }}</span>{% endif %} |
|
| 18 | 18 | </div> |
|
| 19 | + | {% if let Some(q) = quickstart %} |
|
| 20 | + | {% include "quickstart.html" %} |
|
| 21 | + | {% else %} |
|
| 19 | 22 | {% include "tree_table.html" %} |
|
| 23 | + | {% endif %} |
|
| 20 | 24 | {% if let Some(html) = readme_html %} |
|
| 21 | 25 | <article class="doc readme"><h2 class="small mute">{{ o.tree.readme.as_ref().unwrap().path }}</h2>{{ html|safe }}</article> |
|
| 22 | 26 | {% endif %} |
|
| … | 22 unchanged lines |
modified templates/style.css +5 −0
| … | 143 unchanged lines |
||
| 144 | 144 | ul.feed .who { font-weight: 600; } |
|
| 145 | 145 | .clone { display: flex; gap: 6px; align-items: center; } |
|
| 146 | 146 | .clone code { background: var(--bg); border: 1px solid var(--line); padding: 4px 8px; flex: 1; overflow-x: auto; white-space: nowrap; } |
|
| 147 | + | .quickstart { background: var(--panel); border: 1px solid var(--line); padding: 16px 20px; } |
|
| 148 | + | .quickstart h2 { margin-top: 0; } |
|
| 149 | + | .quickstart h3 { font-size: 14px; margin: 18px 0 6px; } |
|
| 150 | + | .cmds { display: flex; gap: 6px; align-items: flex-start; } |
|
| 151 | + | .cmds pre { flex: 1; margin: 0; background: var(--bg); border: 1px solid var(--line); padding: 8px 10px; overflow-x: auto; } |
|
| 147 | 152 | .bar { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; margin-bottom: 10px; } |
|
| 148 | 153 | .ref { color: var(--acc); } |
|
| 149 | 154 | .crumbs a { color: var(--ink); } |
|
| … | 162 unchanged lines |
modified templates/tree_rows.html +1 −1
| 1 | 1 | {% if t.entries.is_empty() %} |
|
| 2 | - | <p class="empty">This repository is empty. From an existing checkout: <code>oxy remote add hub {{ repo.clone_url }}</code> then <code>oxy push hub</code>. It takes the id of the first repository pushed, so clones made later share it.</p> |
|
| 2 | + | <p class="empty">Nothing here yet. <a href="{{ repo.url }}">The overview</a> shows how to make a first push.</p> |
|
| 3 | 3 | {% else %} |
|
| 4 | 4 | <table class="tree" data-last="{{ repo.url }}/last/{{ t.target.label }}" data-change="{{ repo.url }}/change/"> |
|
| 5 | 5 | {% if !t.path.is_empty() %} |
|
| … | 17 unchanged lines |