diff --git a/scripts/dossiers-ngi/README.md b/scripts/dossiers-ngi/README.md
index 718e1ca..064b830 100644
--- a/scripts/dossiers-ngi/README.md
+++ b/scripts/dossiers-ngi/README.md
@@ -58,6 +58,7 @@ Per branch in JSON / sticky bar:
| `tip_time` | committer time of tip (`%cI`) |
| `built_at` | when this edition freeze was written |
| `build_seconds` | wall time of that freeze |
+| `vs_main_plus` / `vs_main_minus` | `git diff --numstat` tip vs `main` tip (sticky bar +grün/−rot; omitted on `main`) |
Rebuild runs **only when** `.edition-tip` ≠ current tip (timer never forces).
Each run also: `git fetch --prune`, `worktree prune`, drop stale
diff --git a/scripts/dossiers-ngi/branch-status-hourly.py b/scripts/dossiers-ngi/branch-status-hourly.py
index 16b0a3f..9bed2c3 100755
--- a/scripts/dossiers-ngi/branch-status-hourly.py
+++ b/scripts/dossiers-ngi/branch-status-hourly.py
@@ -234,6 +234,30 @@ def tip_recorded(editions: Path, slug: str) -> str:
return p.read_text(encoding="utf-8").strip()
+
+def diff_vs_main(repo: Path, main_tip: str, tip: str) -> tuple[int, int]:
+ """Insertions/deletions of tip vs main tip (git diff --numstat)."""
+ if not main_tip or not tip or main_tip == tip:
+ return 0, 0
+ cp = git(repo, "diff", "--numstat", main_tip, tip, check=False)
+ if cp.returncode != 0:
+ return 0, 0
+ plus = minus = 0
+ for line in (cp.stdout or "").splitlines():
+ parts = line.split(" ")
+ if len(parts) < 3:
+ continue
+ a, d = parts[0], parts[1]
+ if a == "-" or d == "-":
+ continue
+ try:
+ plus += int(a)
+ minus += int(d)
+ except ValueError:
+ continue
+ return plus, minus
+
+
def tip_commit_time(repo: Path, tip: str) -> str:
"""Committer ISO time of tip (empty on failure)."""
cp = git(repo, "log", "-1", "--format=%cI", tip, check=False)
@@ -531,6 +555,10 @@ def build_payload(repo: Path, tips: dict[str, str], rebuild: bool) -> dict:
sync_flask_alias(ed_root, tips)
tip_times = {name: tip_commit_time(repo, tip) for name, tip in tips.items()}
+ main_tip = tips.get("main", "")
+ vs_main = {
+ name: diff_vs_main(repo, main_tip, tip) for name, tip in tips.items()
+ }
branches_out = []
for row in rows:
name = row["branch"]
@@ -556,6 +584,11 @@ def build_payload(repo: Path, tips: dict[str, str], rebuild: bool) -> dict:
entry["built_at"] = meta["built_at"]
if "build_seconds" in meta:
entry["build_seconds"] = meta["build_seconds"]
+ # sticky bar: +grün / −rot vs main (omit on main itself)
+ if name != "main" and main_tip:
+ plus, minus = vs_main.get(name, (0, 0))
+ entry["vs_main_plus"] = plus
+ entry["vs_main_minus"] = minus
branches_out.append(entry)
payload = {
diff --git a/sites/dossiers.ngi.hacktivism.ch/_cascade/bar.css b/sites/dossiers.ngi.hacktivism.ch/_cascade/bar.css
index df4d2d9..ae291e2 100644
--- a/sites/dossiers.ngi.hacktivism.ch/_cascade/bar.css
+++ b/sites/dossiers.ngi.hacktivism.ch/_cascade/bar.css
@@ -76,3 +76,12 @@ a.cascade-tree-row.is-active:hover {
font-size: 10px;
}
}
+
+.cascade-bar .diff-plus {
+ color: #3fb950;
+ font-weight: 600;
+}
+.cascade-bar .diff-minus {
+ color: #f85149;
+ font-weight: 600;
+}
diff --git a/sites/dossiers.ngi.hacktivism.ch/_cascade/bar.js b/sites/dossiers.ngi.hacktivism.ch/_cascade/bar.js
index 9ad8924..a4f4ea4 100644
--- a/sites/dossiers.ngi.hacktivism.ch/_cascade/bar.js
+++ b/sites/dossiers.ngi.hacktivism.ch/_cascade/bar.js
@@ -37,23 +37,49 @@
return Math.round(n) + "s";
}
- function metaBits(row) {
+ function metaHtml(row) {
var tip = row.tip || "";
var tipTime = row.tip_time || "";
var builtAt = row.built_at || "";
var buildSec = row.build_seconds;
- var bits = [];
- if (tip) bits.push("@" + tip);
- if (tipTime) bits.push("tip " + shortTime(tipTime));
+ var parts = [];
+ if (tip) parts.push(esc("@" + tip));
+ if (tipTime) parts.push(esc("tip " + shortTime(tipTime)));
if (buildSec != null && buildSec !== "")
- bits.push("build " + fmtBuildSec(buildSec));
- if (builtAt) bits.push("at " + shortTime(builtAt));
- return bits;
+ parts.push(esc("build " + fmtBuildSec(buildSec)));
+ if (builtAt) parts.push(esc("at " + shortTime(builtAt)));
+ // vs main: +grün / −rot (omit on main / when both zero absent)
+ var plus = row.vs_main_plus;
+ var minus = row.vs_main_minus;
+ if (plus != null || minus != null) {
+ var p = plus == null ? 0 : Number(plus);
+ var m = minus == null ? 0 : Number(minus);
+ if (isFinite(p) && isFinite(m) && (p > 0 || m > 0 || row.branch !== "main")) {
+ parts.push(
+ '+' +
+ esc(String(p)) +
+ ' −' +
+ esc(String(m)) +
+ ""
+ );
+ }
+ }
+ return parts.join(" · ");
}
function rowTitle(row, buildSec) {
var tip = row.tip || "";
var tipTime = row.tip_time || "";
+ var plus = row.vs_main_plus;
+ var minus = row.vs_main_minus;
+ var vs = "";
+ if (plus != null || minus != null) {
+ vs =
+ " vs main +" +
+ String(plus == null ? 0 : plus) +
+ "/−" +
+ String(minus == null ? 0 : minus);
+ }
return (
"Switch build to " +
(row.branch || "") +
@@ -61,7 +87,8 @@
(tipTime ? " tip=" + tipTime : "") +
(buildSec != null && buildSec !== ""
? " build=" + fmtBuildSec(buildSec)
- : "")
+ : "") +
+ vs
);
}
@@ -125,15 +152,15 @@
var a = rows[i];
if (a.getAttribute("data-edition") !== (row.edition || "")) return false;
if (a.getAttribute("data-branch") !== (row.branch || "")) return false;
- var bits = metaBits(row);
+ var tipHtml = metaHtml(row);
var tipEl = a.querySelector(".tip");
- var wantTip = bits.length ? " " + bits.join(" · ") : "";
+ var wantTip = tipHtml ? " " + tipHtml : "";
if (tipEl) {
- if (tipEl.textContent !== wantTip) tipEl.textContent = wantTip;
+ if (tipEl.innerHTML !== wantTip) tipEl.innerHTML = wantTip;
} else if (wantTip) {
tipEl = document.createElement("span");
tipEl.className = "tip";
- tipEl.textContent = wantTip;
+ tipEl.innerHTML = wantTip;
a.appendChild(tipEl);
}
var defEl = a.querySelector(".cascade-default");
@@ -189,15 +216,13 @@
var tipTime = row.tip_time || "";
var buildSec = row.build_seconds;
var isDefault = branch === defaultBranch;
- var bits = metaBits(row);
+ var tipHtml = metaHtml(row);
var label =
esc(prefix) +
"" +
esc(branch) +
"" +
- (bits.length
- ? ' ' + esc(bits.join(" · ")) + ""
- : "") +
+ (tipHtml ? ' ' + tipHtml + "" : "") +
(isDefault ? ' · default' : "");
html.push(
' 0 || m > 0 || row.branch !== "main")) {
+ parts.push(
+ '+' +
+ esc(String(p)) +
+ ' −' +
+ esc(String(m)) +
+ ""
+ );
+ }
+ }
+ return parts.join(" · ");
}
function rowTitle(row, buildSec) {
var tip = row.tip || "";
var tipTime = row.tip_time || "";
+ var plus = row.vs_main_plus;
+ var minus = row.vs_main_minus;
+ var vs = "";
+ if (plus != null || minus != null) {
+ vs =
+ " vs main +" +
+ String(plus == null ? 0 : plus) +
+ "/−" +
+ String(minus == null ? 0 : minus);
+ }
return (
"Switch build to " +
(row.branch || "") +
@@ -61,7 +87,8 @@
(tipTime ? " tip=" + tipTime : "") +
(buildSec != null && buildSec !== ""
? " build=" + fmtBuildSec(buildSec)
- : "")
+ : "") +
+ vs
);
}
@@ -125,15 +152,15 @@
var a = rows[i];
if (a.getAttribute("data-edition") !== (row.edition || "")) return false;
if (a.getAttribute("data-branch") !== (row.branch || "")) return false;
- var bits = metaBits(row);
+ var tipHtml = metaHtml(row);
var tipEl = a.querySelector(".tip");
- var wantTip = bits.length ? " " + bits.join(" · ") : "";
+ var wantTip = tipHtml ? " " + tipHtml : "";
if (tipEl) {
- if (tipEl.textContent !== wantTip) tipEl.textContent = wantTip;
+ if (tipEl.innerHTML !== wantTip) tipEl.innerHTML = wantTip;
} else if (wantTip) {
tipEl = document.createElement("span");
tipEl.className = "tip";
- tipEl.textContent = wantTip;
+ tipEl.innerHTML = wantTip;
a.appendChild(tipEl);
}
var defEl = a.querySelector(".cascade-default");
@@ -189,15 +216,13 @@
var tipTime = row.tip_time || "";
var buildSec = row.build_seconds;
var isDefault = branch === defaultBranch;
- var bits = metaBits(row);
+ var tipHtml = metaHtml(row);
var label =
esc(prefix) +
"" +
esc(branch) +
"" +
- (bits.length
- ? ' ' + esc(bits.join(" · ")) + ""
- : "") +
+ (tipHtml ? ' ' + tipHtml + "" : "") +
(isDefault ? ' · default' : "");
html.push(
'