OXYGENHUB

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 &amp;&amp; cd fresh") || page.body.contains("mkdir fresh &#38;&#38; 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