OXYGENHUB

A fork starts green where upstream proved the same state: a job with no pass of its own reuses one from the repositories it was forked from, public or the fork owner's, and says whose (refs #296) 42cd46c9b09e

Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key d8ab353204f3
When
· 2026-10-27 18:52 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
42cd46c9b09e3eb804f933a8ff4ab4777ea35c17a8fd2a99eea4178d58264999
Depends on
0468394734e9 070a7b64b368 217814c239f0 367f34b8e590 42fa95540468 5588bb3631d2 858eca0d03c4 a7e6e8e6094c b5668108487f
Depended on by
06b756358f7e 91c306aa4706 aeed63c00ad7
In bookmarks
main
In tags
v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24
Edits
15 atoms, 0 tree operations, 5 files

Browse the tree at this change

modified CHANGELOG.md +2 −0
…
3 unchanged lines
4 4
5 5
## Unreleased
6 6
7 +
- **A fork starts green where upstream proved the same state (#296, first slice).** Pass reuse (#265) looked in the repository's own runs only. A push's job with no pass of its own on that state now reuses one from the repositories it was forked from, nearest first (up to five up): the same state is the same changes, so upstream's receipt stands for it. Only from an upstream that is public or has the fork's owner. The run page says whose pass it was and links to it, and its check reads "passed in OWNER/NAME run #N on this state, reused".
8 +
7 9
- **A proposal's conflicts are resolved on the web (#337).** A proposal whose landing would conflict has RESOLVE ON THE WEB for those who may push to its source: the hub brings the target into the source bookmark, as `oxy merge TARGET` would there (signed as the user's delegate, or unsigned where no trust policy asks for more), and opens the resolver on the first conflicted file. The resolution is committed to the source, so the proposal follows it and lands. A proposal from a fork is resolved in the fork.
8 10
9 11
- **A default bookmark must exist, and bookmarks are made on the web (#345).** Settings refused nothing, so a default named before it existed (doomsdayone/tetriblox's `develop`) left the repository's pages saying `"develop" is not a change`. Settings now refuse a new default that is not a bookmark (an empty repository may name any, for its first push) and list those that are; a missing default leaves the overview showing main, or the first bookmark, with a note; an unknown name is "not a bookmark, tag or change of this repository". Bookmarks › New bookmark makes one from a bookmark, tag or change, as the user's delegate or, without a trust policy, unsigned as a push would be, so a proposal can then target it.
…
376 unchanged lines
modified crates/hub/src/db/flow.rs +51 −3
…
188 unchanged lines
189 189
        ["image", "prepare", "run", "finish"].iter().filter_map(|p| m.get(*p).map(|ms| (p.to_string(), *ms))).collect()
190 190
    }
191 191
192 -
    /// The run whose pass this job reused (#265), if it did.
192 +
    /// The run whose pass this job reused (#265), if it did: in this
193 +
    /// repository, or in the one [`Job::reused_repo`] names (#296).
193 194
    pub fn reused_from(&self) -> Option<i64> {
194 -
        self.error.strip_prefix("reused from run #")?.parse().ok()
195 +
        self.error.strip_prefix("reused from ")?.rsplit_once("run #")?.1.parse().ok()
195 196
    }
196 197
198 +
    /// The repository (`owner/name`) whose pass this job reused, when not
199 +
    /// its own: a fork reusing what upstream proved (#296).
200 +
    pub fn reused_repo(&self) -> Option<&str> {
201 +
        let rest = self.error.strip_prefix("reused from ")?;
202 +
        let (repo, n) = rest.rsplit_once(" run #")?;
203 +
        n.parse::<i64>().ok()?;
204 +
        Some(repo).filter(|r| !r.is_empty())
205 +
    }
206 +
197 207
    pub fn depends_on(&self) -> Vec<String> {
198 208
        serde_json::from_str(&self.depends).unwrap_or_default()
199 209
    }
…
29 unchanged lines
229 239
/// A job's earlier pass on the same state, spec and sandbox (#265).
230 240
#[derive(Clone, Debug)]
231 241
pub struct Reused {
242 +
    /// The repository it passed in, `owner/name`, when not this one (#296).
243 +
    pub repo: Option<String>,
232 244
    /// The run it passed in.
233 245
    pub run_number: i64,
234 246
    pub receipt: String,
…
6 unchanged lines
241 253
impl Reused {
242 254
    /// What the reused job says, and how [`Job::reused_from`] reads it back.
243 255
    pub fn note(&self) -> String {
244 -
        format!("reused from run #{}", self.run_number)
256 +
        match &self.repo {
257 +
            Some(r) => format!("reused from {r} run #{}", self.run_number),
258 +
            None => format!("reused from run #{}", self.run_number),
259 +
        }
245 260
    }
246 261
}
247 262
…
21 unchanged lines
269 284
    .fetch_optional(pool)
270 285
    .await?;
271 286
    Ok(row.map(|(run_number, receipt, receipt_hash, receipt_attest, receipt_signer, receipt_note)| Reused {
287 +
        repo: None,
272 288
        run_number,
273 289
        receipt,
274 290
        receipt_hash,
…
1 unchanged lines
276 292
        receipt_signer,
277 293
        receipt_note,
278 294
    }))
295 +
}
296 +
297 +
/// How far up a fork's line [`passed_upstream`] looks.
298 +
const UPSTREAM_DEPTH: usize = 5;
299 +
300 +
/// A pass of job `name` with this spec and sandbox on `state` in a
301 +
/// repository `repo` was forked from, nearest first (#296): the same state
302 +
/// is the same changes, so what upstream proved holds for the fork. Only
303 +
/// from an upstream that is public or has the fork's owner, so a private
304 +
/// repository's receipts never show in another owner's fork.
305 +
pub async fn passed_upstream(
306 +
    pool: &Pool,
307 +
    repo: &super::repos::RepoRow,
308 +
    state: &str,
309 +
    name: &str,
310 +
    spec: &str,
311 +
    sandbox: &str,
312 +
) -> sqlx::Result<Option<Reused>> {
313 +
    let mut parent = repo.forked_from;
314 +
    for _ in 0..UPSTREAM_DEPTH {
315 +
        let Some(id) = parent else { break };
316 +
        let Some(up) = super::repos::get_by_id(pool, id).await? else { break };
317 +
        parent = up.forked_from;
318 +
        if !(up.visibility == "public" || up.owner == repo.owner) {
319 +
            continue;
320 +
        }
321 +
        if let Some(mut r) = passed_on_state(pool, up.id, state, name, spec, sandbox).await? {
322 +
            r.repo = Some(format!("{}/{}", up.owner, up.name));
323 +
            return Ok(Some(r));
324 +
        }
325 +
    }
326 +
    Ok(None)
279 327
}
280 328
281 329
/// Runs of a job whose peaks set its next need.
…
1257 unchanged lines
modified crates/hub/src/flow.rs +9 −3
…
837 unchanged lines
838 838
                    }
839 839
                    j.reused =
840 840
                        fdb::passed_on_state(&app.db, row.id, state, &j.name, &j.spec, &j.sandbox).await.map_err(|e| e.to_string())?;
841 +
                    // A fork starts green where upstream proved this state (#296).
842 +
                    if j.reused.is_none() && row.forked_from.is_some() {
843 +
                        j.reused =
844 +
                            fdb::passed_upstream(&app.db, row, state, &j.name, &j.spec, &j.sandbox).await.map_err(|e| e.to_string())?;
845 +
                    }
841 846
                }
842 847
            }
843 848
            let hash = hex::encode(p.hash());
…
1636 unchanged lines
2480 2485
    }
2481 2486
    for j in changed {
2482 2487
        let summary = match j.status.as_str() {
2483 -
            "passed" => match j.reused_from() {
2484 -
                Some(n) => format!("passed in run #{n} on this state, reused"),
2485 -
                None => format!("passed in {}", duration(&j)),
2488 +
            "passed" => match (j.reused_from(), j.reused_repo()) {
2489 +
                (Some(n), Some(r)) => format!("passed in {r} run #{n} on this state, reused"),
2490 +
                (Some(n), None) => format!("passed in run #{n} on this state, reused"),
2491 +
                (None, _) => format!("passed in {}", duration(&j)),
2486 2492
            },
2487 2493
            "failed" => format!("exit {} after {}", j.exit_code.unwrap_or(-1), duration(&j)),
2488 2494
            "skipped" => "skipped: a dependency did not pass".into(),
…
570 unchanged lines
modified crates/hub/tests/flow.rs +59 −0
…
1994 unchanged lines
1995 1995
    assert!(jobs.iter().all(|j| j.status == "queued" && j.reused_from().is_none()), "{jobs:?}");
1996 1996
}
1997 1997
1998 +
/// oxygenhub#296: a fork starts green where upstream proved the same state:
1999 +
/// its push reuses upstream's passes, says whose, and links to them; never
2000 +
/// a private upstream's when the fork has another owner.
2001 +
#[tokio::test(flavor = "multi_thread")]
2002 +
async fn a_fork_reuses_what_upstream_proved() {
2003 +
    let h = hub().await;
2004 +
    for who in ["andy", "bob"] {
2005 +
        db::accounts::create_account(&h.app.db, who, &format!("{who}@example.com"), "x", "salt", true).await.unwrap();
2006 +
    }
2007 +
    create(&h.app, "andy", "game").await;
2008 +
    create(&h.app, "bob", "game").await;
2009 +
    let up = db::repos::get(&h.app.db, "andy", "game").await.unwrap().unwrap();
2010 +
    let fork = db::repos::get(&h.app.db, "bob", "game").await.unwrap().unwrap();
2011 +
    db::repos::set_forked_from(&h.app.db, fork.id, up.id).await.unwrap();
2012 +
    // Flow on for the fork (the test hub allows andy's repositories only).
2013 +
    sqlx::query("UPDATE repositories SET flow = 'on' WHERE id = $1").bind(fork.id).execute(&h.app.db).await.unwrap();
2014 +
    let work = tempfile::tempdir().unwrap();
2015 +
    let mut c = client(&format!("{}/andy/game", h.base), work.path());
2016 +
    write(work.path(), ".flow/pipeline.kdl", &pipeline(""));
2017 +
    write(work.path(), "test.sh", "echo ok\n");
2018 +
    c.commit("Start", &[]).unwrap();
2019 +
    assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok));
2020 +
    let first = runs_of(&h.app, "andy", "game").await.remove(0);
2021 +
    sqlx::query("UPDATE flow_jobs SET status = 'passed', exit_code = 0, receipt_hash = 'r-' || name WHERE run_id = $1")
2022 +
        .bind(first.id)
2023 +
        .execute(&h.app.db)
2024 +
        .await
2025 +
        .unwrap();
2026 +
    // The same changes pushed to the fork: the same state.
2027 +
    c.config.set("remote.origin.url", &format!("{}/bob/game", h.base)).unwrap();
2028 +
    c.save_config().unwrap();
2029 +
    assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok));
2030 +
    let run = runs_of(&h.app, "bob", "game").await.remove(0);
2031 +
    assert_eq!(run.state, first.state);
2032 +
    let jobs = db::flow::jobs(&h.app.db, run.id).await.unwrap();
2033 +
    for j in &jobs {
2034 +
        assert_eq!((j.status.as_str(), j.reused_from(), j.reused_repo()), ("passed", Some(first.number), Some("andy/game")), "{j:?}");
2035 +
        assert_eq!(j.receipt_hash, format!("r-{}", j.name));
2036 +
    }
2037 +
    let (_, page) = call(&h.app, "GET", &format!("/bob/game/flow/{}", run.number), None, None).await;
2038 +
    assert!(page.contains(&format!("href=\"/andy/game/flow/{}\">andy/game run #{}</a>", first.number, first.number)), "{page}");
2039 +
    let checks = db::hooks::checks_for_state(&h.app.db, fork.id, &run.state).await.unwrap();
2040 +
    assert!(checks.iter().any(|c| c.summary.contains("passed in andy/game run #")), "{checks:?}");
2041 +
2042 +
    // A private upstream of another owner: a new fork runs its own.
2043 +
    sqlx::query("UPDATE repositories SET visibility = 'private' WHERE id = $1").bind(up.id).execute(&h.app.db).await.unwrap();
2044 +
    create(&h.app, "bob", "game2").await;
2045 +
    let fork2 = db::repos::get(&h.app.db, "bob", "game2").await.unwrap().unwrap();
2046 +
    db::repos::set_forked_from(&h.app.db, fork2.id, up.id).await.unwrap();
2047 +
    sqlx::query("UPDATE repositories SET flow = 'on' WHERE id = $1").bind(fork2.id).execute(&h.app.db).await.unwrap();
2048 +
    c.config.set("remote.origin.url", &format!("{}/bob/game2", h.base)).unwrap();
2049 +
    c.save_config().unwrap();
2050 +
    assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok));
2051 +
    let run2 = runs_of(&h.app, "bob", "game2").await.remove(0);
2052 +
    assert_eq!(run2.state, first.state);
2053 +
    let jobs = db::flow::jobs(&h.app.db, run2.id).await.unwrap();
2054 +
    assert!(jobs.iter().all(|j| j.status == "queued" && j.reused_from().is_none()), "{jobs:?}");
2055 +
}
2056 +
1998 2057
#[test]
1999 2058
fn skip_markers_are_the_forges_spellings_in_any_case() {
2000 2059
    use oxygenhub::flow::skip_marker;
…
4989 unchanged lines
modified templates/flow_run.html +1 −1
…
63 unchanged lines
64 64
{% endfor %}</pre>
65 65
  {% if !l.j.placed.is_empty() %}<p class="small mute pad">Ran on {{ l.j.runner }}: {{ l.j.placed }}.</p>{% endif %}
66 66
  {% let phases = l.j.phase_list() %}{% if !phases.is_empty() %}<p class="small mute pad">Where its time went: {% for (p, ms) in phases %}{{ p }} {{ crate::web::flow::secs(ms.clone() as i64) }}{% if !loop.last %}, {% endif %}{% endfor %}.</p>{% endif %}
67 -
  {% if l.j.status == "passed" %}{% if let Some(n) = l.j.reused_from() %}<p class="small mute pad" data-reused>Not run again: this job, with the same spec and sandbox, passed on this very state in <a href="{{ repo.url }}/flow/{{ n }}">run #{{ n }}</a>, whose log it would repeat; that pass's receipt stands for it here.</p>{% endif %}{% endif %}
67 +
  {% if l.j.status == "passed" %}{% if let Some(n) = l.j.reused_from() %}{% if let Some(up) = l.j.reused_repo() %}<p class="small mute pad" data-reused>Not run again: this job, with the same spec and sandbox, passed on this very state upstream, in <a href="/{{ up }}/flow/{{ n }}">{{ up }} run #{{ n }}</a>; the same state is the same changes, so that pass's receipt stands for it here.</p>{% else %}<p class="small mute pad" data-reused>Not run again: this job, with the same spec and sandbox, passed on this very state in <a href="{{ repo.url }}/flow/{{ n }}">run #{{ n }}</a>, whose log it would repeat; that pass's receipt stands for it here.</p>{% endif %}{% endif %}{% endif %}
68 68
  {% if l.j.status == "queued" && !l.j.waiting.is_empty() %}<p class="small warn pad" data-waiting data-queued-note>Waiting: {{ l.j.waiting }}.</p>{% else if l.lines.is_empty() && l.j.status == "queued" %}<p class="small mute pad" data-queued-note>Waiting for a runner{% if !l.j.depends_on().is_empty() %} and for {{ l.j.depends_on().join(", ") }}{% endif %}.</p>{% endif %}
69 69
  {% if l.lines.is_empty() && l.j.status == "skipped" %}<p class="small mute pad">Skipped: a job it depends on did not pass.</p>{% endif %}
70 70
</details>
…
3 unchanged lines