Long hashes and state addresses in change titles are shown by their ends 62cdf34daec1
A change title (change rows, the change page, a file's last change) shows any hash or '+'-joined state address longer than 24 characters by its two ends and head count, the whole in the hover/focus tip #198 uses; change rows wrap anything unbroken (overflow-wrap: anywhere) so nothing pushes them wider than the card. Diffusion's bump change is titled by the upstream's first head and head count, the whole address in its body. A D2 test assertion that raced the bump's own runs is now about the reconciler only. Closes #216
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
Claude Code <[email protected]> · delegate for changes, until 2026-11-13 19:00Z · key ea5f78160088
- When
- · 2026-10-16 02:00 UTC
- Signature
- ✔ signed by Claude Code
- Hash
62cdf34daec16747d6b0ff9afff338b14420b7fb80a9d9dfd00c00550db1b7ec- Depends on
- 0e04dfc20211 1d634f698ecd 42fa95540468 6ae3fa0af4f6 a7e6e8e6094c addec2bd6423 c5f3bde466d1 d23464c0ac60 e71583501b1d f677d6270298
- Depended on by
- 3e868a1f94e1 3b1bc27fe805
- In bookmarks
- api-unknown-fields cancel-landed-runs diffusion-pin-bookmark draft-ready main oh-comment-body-file proposal-list-fast
- In tags
- 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
- 16 atoms, 0 tree operations, 7 files
Browse the tree at this change
modified crates/hub/src/diffusion.rs +27 −1
| … | 601 unchanged lines |
||
| 602 | 602 | } |
|
| 603 | 603 | ||
| 604 | 604 | /// The bookmark a consumer's bump for the subrepo at `path` lives on. |
|
| 605 | + | /// A bump change's message: its title names the upstream state by its |
|
| 606 | + | /// first head and head count, so a state of many heads does not make it |
|
| 607 | + | /// run on (oxygenhub#216); the whole address is in the body. |
|
| 608 | + | pub fn bump_message(path: &str, upstream: &str, heads: &[String]) -> String { |
|
| 609 | + | let first = heads.first().map(String::as_str).unwrap_or(""); |
|
| 610 | + | match heads.len() { |
|
| 611 | + | 0 | 1 => format!("Diffusion: move {path} to {upstream} {first}"), |
|
| 612 | + | n => format!("Diffusion: move {path} to {upstream} {first} (+{} heads)\n\nPins {upstream} at {}", n - 1, heads.join("+")), |
|
| 613 | + | } |
|
| 614 | + | } |
|
| 615 | + | ||
| 605 | 616 | pub fn bump_bookmark(path: &str) -> String { |
|
| 606 | 617 | format!("diffusion/{}", path.trim_matches('/')) |
|
| 607 | 618 | } |
|
| … | 122 unchanged lines |
||
| 730 | 741 | let pin = Pin::Oxy { repo, heads: new_heads.clone(), bookmark }; |
|
| 731 | 742 | let old = old.clone(); |
|
| 732 | 743 | let short: Vec<String> = new_heads.iter().map(|h| h.to_hex()[..12].to_string()).collect(); |
|
| 733 | - | let message = format!("Diffusion: move {p} to {upstream_label} {}", short.join("+")); |
|
| 744 | + | let message = bump_message(&p, &upstream_label, &short); |
|
| 734 | 745 | app.repos |
|
| 735 | 746 | .write(&cname, move |r, _| { |
|
| 736 | 747 | let base = heads_of(r, &t)?; |
|
| … | 768 unchanged lines |
||
| 1505 | 1516 | let r = rule("green", &row(""), None, "rstar-api"); |
|
| 1506 | 1517 | assert_eq!((r.follow, r.from), (Follow::Green, "site")); |
|
| 1507 | 1518 | assert_eq!(rule("nonsense", &row(""), None, "x").follow, Follow::Off); |
|
| 1519 | + | } |
|
| 1520 | + | } |
|
| 1521 | + | ||
| 1522 | + | #[cfg(test)] |
|
| 1523 | + | mod message_tests { |
|
| 1524 | + | use super::bump_message; |
|
| 1525 | + | ||
| 1526 | + | #[test] |
|
| 1527 | + | fn a_bump_of_many_heads_has_a_short_title_and_the_whole_address_below() { |
|
| 1528 | + | let heads: Vec<String> = (0..20).map(|i| format!("{i:02x}a6c9d4746a")).collect(); |
|
| 1529 | + | let m = bump_message("rstar-api", "doomsdayone/rstar-api", &heads); |
|
| 1530 | + | let (title, body) = m.split_once("\n\n").unwrap(); |
|
| 1531 | + | assert_eq!(title, "Diffusion: move rstar-api to doomsdayone/rstar-api 00a6c9d4746a (+19 heads)"); |
|
| 1532 | + | assert_eq!(body, format!("Pins doomsdayone/rstar-api at {}", heads.join("+"))); |
|
| 1533 | + | assert_eq!(bump_message("api", "andy/api", &heads[..1]), "Diffusion: move api to andy/api 00a6c9d4746a"); |
|
| 1508 | 1534 | } |
|
| 1509 | 1535 | } |
modified crates/hub/src/web/filters.rs +80 −0
| … | 70 unchanged lines |
||
| 71 | 71 | format!("{start}…{end}") |
|
| 72 | 72 | } |
|
| 73 | 73 | ||
| 74 | + | /// Text with every long hash or state address in it (heads joined by `+`) |
|
| 75 | + | /// shown by its two ends and head count, the whole in a tip on hover or |
|
| 76 | + | /// focus (oxygenhub#216, as #198 did for a lone hash); the rest escaped. |
|
| 77 | + | pub fn addresses(value: &str, _: &dyn askama::Values) -> askama::Result<askama::filters::Safe<String>> { |
|
| 78 | + | Ok(askama::filters::Safe(shorten_addresses(value))) |
|
| 79 | + | } |
|
| 80 | + | ||
| 81 | + | fn escape(s: &str) -> String { |
|
| 82 | + | let mut out = String::with_capacity(s.len()); |
|
| 83 | + | for c in s.chars() { |
|
| 84 | + | match c { |
|
| 85 | + | '&' => out.push_str("&"), |
|
| 86 | + | '<' => out.push_str("<"), |
|
| 87 | + | '>' => out.push_str(">"), |
|
| 88 | + | '"' => out.push_str("""), |
|
| 89 | + | '\'' => out.push_str("'"), |
|
| 90 | + | c => out.push(c), |
|
| 91 | + | } |
|
| 92 | + | } |
|
| 93 | + | out |
|
| 94 | + | } |
|
| 95 | + | ||
| 96 | + | /// Whether a word is a hash or state address: hex parts of 8 or more |
|
| 97 | + | /// joined by `+`, longer than [`cut_ends`] leaves alone. |
|
| 98 | + | fn is_address(word: &str) -> bool { |
|
| 99 | + | word.chars().count() > 24 && word.split('+').all(|p| p.len() >= 8 && p.chars().all(|c| c.is_ascii_hexdigit())) |
|
| 100 | + | } |
|
| 101 | + | ||
| 102 | + | pub fn shorten_addresses(text: &str) -> String { |
|
| 103 | + | let mut out = String::with_capacity(text.len()); |
|
| 104 | + | let mut word = String::new(); |
|
| 105 | + | let flush = |word: &mut String, out: &mut String| { |
|
| 106 | + | // Trailing punctuation stays outside the address. |
|
| 107 | + | let core = word.trim_end_matches(['.', ',', ';', ':', ')']); |
|
| 108 | + | if is_address(core) { |
|
| 109 | + | let heads = core.split('+').count(); |
|
| 110 | + | let count = if heads > 1 { format!(" ({heads} heads)") } else { String::new() }; |
|
| 111 | + | let full = escape(core); |
|
| 112 | + | out.push_str(&format!( |
|
| 113 | + | "<span class=\"hash-tip\" tabindex=\"0\" data-full=\"{full}\" aria-label=\"{full}\">{}{count}</span>", |
|
| 114 | + | escape(&cut_ends(core)) |
|
| 115 | + | )); |
|
| 116 | + | out.push_str(&escape(&word[core.len()..])); |
|
| 117 | + | } else { |
|
| 118 | + | out.push_str(&escape(word)); |
|
| 119 | + | } |
|
| 120 | + | word.clear(); |
|
| 121 | + | }; |
|
| 122 | + | for c in text.chars() { |
|
| 123 | + | if c.is_whitespace() || c == '(' { |
|
| 124 | + | flush(&mut word, &mut out); |
|
| 125 | + | out.push_str(&escape(&c.to_string())); |
|
| 126 | + | } else { |
|
| 127 | + | word.push(c); |
|
| 128 | + | } |
|
| 129 | + | } |
|
| 130 | + | flush(&mut word, &mut out); |
|
| 131 | + | out |
|
| 132 | + | } |
|
| 133 | + | ||
| 74 | 134 | /// Markdown to HTML (already sanitised), marked safe for the template. |
|
| 75 | 135 | pub fn markdown(text: &str, _: &dyn askama::Values, base_file: &str, base_raw: &str) -> askama::Result<askama::filters::Safe<String>> { |
|
| 76 | 136 | Ok(askama::filters::Safe(render::markdown::render(text, base_file, base_raw))) |
|
| … | 26 unchanged lines |
||
| 103 | 163 | assert_eq!(super::cut_ends(h), "0826ac0c…99445564"); |
|
| 104 | 164 | let two = format!("{h}+1e470ca931015aa55dbb9514cf34e9e5c40bede11241eedf23c6576d23d2609e"); |
|
| 105 | 165 | assert_eq!(super::cut_ends(&two), "0826ac0c…23d2609e"); |
|
| 166 | + | } |
|
| 167 | + | } |
|
| 168 | + | ||
| 169 | + | #[cfg(test)] |
|
| 170 | + | mod address_tests { |
|
| 171 | + | use super::shorten_addresses; |
|
| 172 | + | ||
| 173 | + | #[test] |
|
| 174 | + | fn long_addresses_in_text_are_shown_by_their_ends() { |
|
| 175 | + | let heads: Vec<String> = (0..20).map(|i| format!("{i:02x}a6c9d4746a")).collect(); |
|
| 176 | + | let address = heads.join("+"); |
|
| 177 | + | let out = shorten_addresses(&format!("Diffusion: move rstar-api to doomsdayone/rstar-api {address}.")); |
|
| 178 | + | assert!(out.starts_with("Diffusion: move rstar-api to doomsdayone/rstar-api <span class=\"hash-tip\""), "{out}"); |
|
| 179 | + | assert!(out.contains("(20 heads)</span>.") && out.contains(&format!("data-full=\"{address}\"")), "{out}"); |
|
| 180 | + | assert!(out.contains(">00a6c9d4…a6c9d4746a (20 heads)<") || out.contains("…"), "{out}"); |
|
| 181 | + | // A short hash, words and markup are left (escaped) as they are. |
|
| 182 | + | assert_eq!(shorten_addresses("fix <b> in 02a6c9d4746a"), "fix <b> in 02a6c9d4746a"); |
|
| 183 | + | let one = "02a6c9d4746a1ac2fb7fa2f8e79a3356"; |
|
| 184 | + | assert!(shorten_addresses(one).contains(">02a6c9d4…e79a3356</span>")); |
|
| 185 | + | assert_eq!(shorten_addresses("deadbeefdeadbeefdeadbeefdeadbeef-not"), "deadbeefdeadbeefdeadbeefdeadbeef-not"); |
|
| 106 | 186 | } |
|
| 107 | 187 | } |
modified crates/hub/tests/flow.rs +3 −2
| … | 3556 unchanged lines |
||
| 3557 | 3557 | assert!(caught.body.contains("api v5") && caught.title.contains("1 change"), "{:?} {:?}", caught.title, caught.body); |
|
| 3558 | 3558 | // A bump someone closed is a "no": the reconciler does not open it again. |
|
| 3559 | 3559 | sqlx::query("UPDATE proposals SET state = 'closed' WHERE id = ?").bind(caught.id).execute(&h.app.db).await.unwrap(); |
|
| 3560 | + | // (The bump's own runs finishing may still propose again, as Diffusion |
|
| 3561 | + | // does on an upstream's moves; the reconciler itself opens nothing.) |
|
| 3560 | 3562 | let r = oxygenhub::diffusion::reconcile(&h.app).await; |
|
| 3561 | - | assert_eq!(r.caught_up, 0, "{r:?}"); |
|
| 3562 | - | assert!(db::proposals::list(&h.app.db, g2_row.id, &["open", "draft"]).await.unwrap().is_empty(), "not reopened"); |
|
| 3563 | + | assert_eq!(r.caught_up, 0, "the reconciler respects the closed bump: {r:?}"); |
|
| 3563 | 3564 | } |
|
| 3564 | 3565 | ||
| 3565 | 3566 | /// Whether any job is queued or running. |
|
| … | 546 unchanged lines |
modified templates/change.html +1 −1
| … | 5 unchanged lines |
||
| 6 | 6 | {% block mainclass %} class="wide"{% endblock %} |
|
| 7 | 7 | {% block content %} |
|
| 8 | 8 | <article class="change"> |
|
| 9 | - | <div class="head"><h1>{{ c.view.title }} <small class="px">{{ c.view.short }}</small>{% if c.view.resolution %} <span class="tag" title="Resolves conflicts; depends on every change that wrote a side">resolution</span>{% endif %}</h1></div> |
|
| 9 | + | <div class="head"><h1>{{ c.view.title|addresses }} <small class="px">{{ c.view.short }}</small>{% if c.view.resolution %} <span class="tag" title="Resolves conflicts; depends on every change that wrote a side">resolution</span>{% endif %}</h1></div> |
|
| 10 | 10 | {{ body_html|safe }} |
|
| 11 | 11 | <dl class="facts"> |
|
| 12 | 12 | <dt>Author</dt><dd>{% if let Some(a) = c.view.author.account %}<a href="/{{ a }}">{{ c.view.author.name }}</a> <span class="tag">@{{ a }}</span>{% else %}{{ c.view.author.name }}{% endif %}{% if !c.view.author.email.is_empty() %} <span class="mute"><{{ c.view.author.email }}></span>{% endif %}{% if !c.view.author.key.is_empty() && c.view.author.account.is_none() %} <span class="tag" title="The key that signed this change is not linked to any account.">key not linked</span>{% if shell.user.is_some() %} <a class="small" href="/settings/keys">link your key</a>{% endif %}{% endif %}</dd>{% if let Some(v) = c.view.via %} |
|
| … | 16 unchanged lines |
modified templates/file.html +1 −1
| … | 31 unchanged lines |
||
| 32 | 32 | </nav> |
|
| 33 | 33 | <section class="filebox file-main"> |
|
| 34 | 34 | <header> |
|
| 35 | - | <span class="small mute">{{ f.size|bytes }}{% if let Some(l) = language %} · {{ l }}{% endif %}{% if f.exec %} · executable{% endif %}{% if f.kind == crate::repos::view::EntryKind::Symlink %} · symlink{% endif %}{% if f.kind == crate::repos::view::EntryKind::Subrepo %} · subrepository pin{% endif %}{% if let Some(c) = f.last_change %} · last changed <a href="{{ repo.url }}/change/{{ c.hash }}">{{ c.title }}</a> {{ c.when_ms|ago }}{% endif %}</span> |
|
| 35 | + | <span class="small mute">{{ f.size|bytes }}{% if let Some(l) = language %} · {{ l }}{% endif %}{% if f.exec %} · executable{% endif %}{% if f.kind == crate::repos::view::EntryKind::Symlink %} · symlink{% endif %}{% if f.kind == crate::repos::view::EntryKind::Subrepo %} · subrepository pin{% endif %}{% if let Some(c) = f.last_change %} · last changed <a href="{{ repo.url }}/change/{{ c.hash }}">{{ c.title|addresses }}</a> {{ c.when_ms|ago }}{% endif %}</span> |
|
| 36 | 36 | <span class="modes"> |
|
| 37 | 37 | {% if is_markdown %}<a href="{{ repo.url }}/file/{{ f.target.label }}/{{ f.path }}"{% if markdown_html.is_some() %} class="on"{% endif %}>rendered</a><a href="{{ repo.url }}/file/{{ f.target.label }}/{{ f.path }}?view=source"{% if blame.is_none() && markdown_html.is_none() %} class="on"{% endif %}>source</a> |
|
| 38 | 38 | {% else %}<a href="{{ repo.url }}/file/{{ f.target.label }}/{{ f.path }}"{% if blame.is_none() %} class="on"{% endif %}>source</a>{% endif %} |
|
| … | 36 unchanged lines |
modified templates/repo_tabs.html +1 −1
| … | 28 unchanged lines |
||
| 29 | 29 | {% macro change_row(repo, c) %} |
|
| 30 | 30 | <div class="chg"> |
|
| 31 | 31 | <div class="chg-main"> |
|
| 32 | - | <a class="title" href="{{ repo.url }}/change/{{ c.hash }}">{{ c.title }}</a> |
|
| 32 | + | <a class="title" href="{{ repo.url }}/change/{{ c.hash }}">{{ c.title|addresses }}</a> |
|
| 33 | 33 | {% call labels(c) %} |
|
| 34 | 34 | {% if c.resolution %}<span class="tag">resolution</span>{% endif %} |
|
| 35 | 35 | {% if c.head %}<span class="tag head">head</span>{% endif %} |
|
| … | 43 unchanged lines |
modified templates/style.css +2 −0
| … | 266 unchanged lines |
||
| 267 | 267 | .chg + .chg, .chg:only-child { border-bottom: 1px solid var(--line); } |
|
| 268 | 268 | .hrow .chg { border-bottom: 0; } |
|
| 269 | 269 | .chg .title { color: var(--ink); font-weight: 500; } |
|
| 270 | + | /* Nothing unbroken in a change row may push it wider than its card (#216). */ |
|
| 271 | + | .chg-main { min-width: 0; overflow-wrap: anywhere; } |
|
| 270 | 272 | .chg .meta { color: var(--mute); font-size: 12px; margin-top: 2px; } |
|
| 271 | 273 | .chg-side { display: flex; align-items: center; gap: 10px; white-space: nowrap; } |
|
| 272 | 274 | .chg .hash { font-size: 9px; } |
|
| … | 980 unchanged lines |