Skip to main content

rbnx/cmd/
check_remotes.rs

1// SPDX-License-Identifier: MulanPSL-2.0
2//! Remote-provider freshness check.
3//!
4//! Deploy manifests pull some providers from a git `url:` into
5//! `rbnx-boot/cache/<name>/`. Once cloned, that checkout is reused on every
6//! boot/build and never advances on its own — so a teammate can be running
7//! a stale copy of an upstream package without noticing. This module compares
8//! each cloned provider against its remote branch tip and reports how far
9//! behind it is.
10//!
11//! `rbnx boot` and `rbnx build` call [`report_outdated`] (print-only, never
12//! fatal — a missing network must not block a deploy). `rbnx update` calls
13//! [`collect_remote_providers`] + [`status_of`] to act on the same data.
14
15use anyhow::{Context, Result};
16use std::path::{Path, PathBuf};
17use std::process::Command;
18
19use robonix_cli::output;
20
21/// A remote-backed provider declared in a deploy manifest.
22#[derive(Debug, Clone)]
23pub struct RemoteProvider {
24    pub name: String,
25    pub branch: Option<String>,
26    /// Local cache checkout (`rbnx-boot/cache/<name>`); may not exist yet.
27    pub dir: PathBuf,
28}
29
30/// How a local checkout compares to its remote branch tip.
31#[derive(Debug, Clone)]
32pub struct RemoteStatus {
33    pub name: String,
34    pub dir: PathBuf,
35    /// Commits the local checkout is behind the remote tip. `None` when the
36    /// count could not be determined (e.g. shallow history, fetch failed).
37    pub behind: Option<u32>,
38    pub local_short: String,
39    pub remote_short: String,
40    pub remote_date: String,
41    pub remote_subject: String,
42    /// Non-fatal explanation when the check is incomplete.
43    pub note: Option<String>,
44}
45
46impl RemoteStatus {
47    /// True when the remote tip differs from the local HEAD (i.e. an update
48    /// is available). Behind-count may be unknown but still outdated.
49    pub fn outdated(&self) -> bool {
50        self.behind.map(|b| b > 0).unwrap_or(false)
51            || (!self.remote_short.is_empty() && self.remote_short != self.local_short)
52    }
53}
54
55/// Run a git command in `dir`, returning trimmed stdout on success.
56fn git(dir: &Path, args: &[&str]) -> Option<String> {
57    let out = Command::new("git")
58        .arg("-C")
59        .arg(dir)
60        .args(args)
61        .output()
62        .ok()?;
63    if !out.status.success() {
64        return None;
65    }
66    let s = String::from_utf8(out.stdout).ok()?.trim().to_string();
67    Some(s)
68}
69
70fn origin_url(dir: &Path) -> Option<String> {
71    git(dir, &["config", "--get", "remote.origin.url"]).filter(|s| !s.trim().is_empty())
72}
73
74/// Parse a deploy manifest and list its `url:` providers (cloned or not).
75pub fn collect_remote_providers(manifest_path: &Path) -> Result<Vec<RemoteProvider>> {
76    let raw = std::fs::read_to_string(manifest_path)
77        .with_context(|| format!("read deploy manifest {}", manifest_path.display()))?;
78    let doc: serde_yaml::Value = serde_yaml::from_str(&raw)
79        .with_context(|| format!("parse deploy manifest {}", manifest_path.display()))?;
80
81    let manifest_dir = manifest_path.parent().unwrap_or_else(|| Path::new("."));
82    let cache_root = manifest_dir.join("rbnx-boot").join("cache");
83
84    let mut providers = Vec::new();
85    for section in ["primitive", "service", "skill"] {
86        let Some(entries) = doc.get(section).and_then(|v| v.as_sequence()) else {
87            continue;
88        };
89        for entry in entries {
90            let Some(url) = entry.get("url").and_then(|v| v.as_str()) else {
91                continue; // local `path:` provider — nothing to track
92            };
93            let name = entry
94                .get("name")
95                .and_then(|v| v.as_str())
96                .map(|s| s.to_string())
97                .filter(|s| !s.is_empty())
98                .unwrap_or_else(|| {
99                    url.trim_end_matches(".git")
100                        .rsplit('/')
101                        .next()
102                        .unwrap_or("pkg")
103                        .to_string()
104                });
105            let branch = entry
106                .get("branch")
107                .and_then(|v| v.as_str())
108                .map(|s| s.to_string());
109            providers.push(RemoteProvider {
110                // Cache dir = git repo name (one clone per repo), not the
111                // per-instance provider id. See deploy::repo_dir_name.
112                dir: cache_root.join(super::deploy::repo_dir_name(url)),
113                name,
114                branch,
115            });
116        }
117    }
118    Ok(providers)
119}
120
121/// Resolve the branch to compare against — the manifest's `branch:` or the
122/// remote's default branch (`origin/HEAD`).
123fn target_branch(p: &RemoteProvider) -> String {
124    if let Some(b) = &p.branch {
125        return b.clone();
126    }
127    git(&p.dir, &["rev-parse", "--abbrev-ref", "origin/HEAD"])
128        .and_then(|s| s.rsplit('/').next().map(|s| s.to_string()))
129        .unwrap_or_else(|| "main".to_string())
130}
131
132/// Fetch the remote branch and compare it to the local checkout. Best-effort:
133/// network / shallow-history problems surface as a `note`, not an error.
134pub fn status_of(p: &RemoteProvider) -> RemoteStatus {
135    let mut st = RemoteStatus {
136        name: p.name.clone(),
137        dir: p.dir.clone(),
138        behind: None,
139        local_short: String::new(),
140        remote_short: String::new(),
141        remote_date: String::new(),
142        remote_subject: String::new(),
143        note: None,
144    };
145
146    if !p.dir.join(".git").is_dir() {
147        st.note = Some("not cloned yet (will clone on next boot/build)".into());
148        return st;
149    }
150
151    st.local_short = git(&p.dir, &["rev-parse", "--short", "HEAD"]).unwrap_or_default();
152    if origin_url(&p.dir).is_none() {
153        st.note = Some("local checkout has no origin remote — skipped".into());
154        return st;
155    }
156    let branch = target_branch(p);
157
158    // Deepen the fetch so HEAD..origin/branch is computable even on the
159    // --depth 1 clones boot creates. 200 covers any realistic drift.
160    //
161    // HARD time bound: this freshness check is a best-effort, non-fatal
162    // notice, but a plain `git fetch` against an unreachable/slow remote
163    // can block on TCP connect for ~130s — and since boot/build call this
164    // for EVERY url-provider, an offline or throttled github stalls the
165    // whole boot before atlas even starts. Wrap in `timeout(1)` so a
166    // wedged fetch is abandoned in seconds and the check just reports
167    // "skipped". `http.lowSpeedLimit/Time` additionally kills a transfer
168    // that connects but then stalls mid-stream. If the `timeout` binary
169    // isn't present (e.g. non-coreutils host), fall back to a bare fetch.
170    let fetch_args = [
171        "-C",
172        p.dir.to_str().unwrap_or("."),
173        "-c",
174        "http.lowSpeedLimit=1000",
175        "-c",
176        "http.lowSpeedTime=8",
177        "fetch",
178        "--quiet",
179        "--depth",
180        "200",
181        "origin",
182        &branch,
183    ];
184    // Keep this short: the freshness notice is cosmetic and must never
185    // noticeably delay boot. 6s is plenty for a reachable remote; an
186    // unreachable one fails fast and the check is skipped.
187    let timed = Command::new("timeout")
188        .arg("6")
189        .arg("git")
190        .args(fetch_args)
191        .status();
192    let fetched = match timed {
193        Ok(s) => s.success(),
194        // `timeout` unavailable — fall back to a direct fetch.
195        Err(_) => Command::new("git")
196            .args(fetch_args)
197            .status()
198            .map(|s| s.success())
199            .unwrap_or(false),
200    };
201    if !fetched {
202        st.note = Some("git fetch timed out / failed (offline?) — skipped".into());
203        return st;
204    }
205
206    let remote_ref = "FETCH_HEAD";
207    st.remote_short = git(&p.dir, &["rev-parse", "--short", remote_ref]).unwrap_or_default();
208    st.remote_date = git(
209        &p.dir,
210        &["log", "-1", "--format=%cd", "--date=relative", remote_ref],
211    )
212    .unwrap_or_default();
213    st.remote_subject = git(&p.dir, &["log", "-1", "--format=%s", remote_ref]).unwrap_or_default();
214    st.behind = git(
215        &p.dir,
216        &["rev-list", "--count", &format!("HEAD..{remote_ref}")],
217    )
218    .and_then(|s| s.parse::<u32>().ok());
219    st
220}
221
222/// boot/build hook: print a one-line notice for every outdated remote provider
223/// plus how to update. Never fails the caller.
224pub fn report_outdated(manifest_path: &Path) {
225    let providers = match collect_remote_providers(manifest_path) {
226        Ok(p) => p,
227        Err(_) => return,
228    };
229    let remote: Vec<_> = providers
230        .into_iter()
231        .filter(|p| p.dir.join(".git").is_dir())
232        .collect();
233    if remote.is_empty() {
234        return;
235    }
236
237    // Each provider triggers a real `git fetch` (up to 6s on a slow/offline
238    // remote), serially. Show an in-place spinner per package while its fetch
239    // runs, then overwrite it with a definite verdict — `[ OK ] up to date`
240    // or `[ ↑ ] N behind …` — so nothing is left dangling on a `…`. Skip the
241    // whole phase with `rbnx boot --no-update-check`.
242    output::boot_section("checking remote providers for updates (skip: --no-update-check)");
243    let mut outdated = Vec::new();
244    for p in &remote {
245        if origin_url(&p.dir).is_none() {
246            let st = status_of(p);
247            output::boot_skip(
248                &p.name,
249                st.note
250                    .as_deref()
251                    .unwrap_or("local checkout has no origin remote — skipped"),
252            );
253            continue;
254        }
255        output::boot_progress(&p.name, "fetching origin…", 0);
256        let st = status_of(p);
257        if st.outdated() {
258            let behind = match st.behind {
259                Some(n) => format!("{n} behind"),
260                None => "behind".to_string(),
261            };
262            output::boot_update_avail(
263                &p.name,
264                &format!(
265                    "{behind}; remote {} ({}): {}",
266                    st.remote_short, st.remote_date, st.remote_subject
267                ),
268            );
269            outdated.push(st);
270        } else if let Some(note) = &st.note {
271            // Couldn't determine (offline / shallow / fetch failed) — say so
272            // plainly rather than implying it's up to date.
273            output::boot_skip(&p.name, note);
274        } else {
275            output::boot_ok(&p.name, "up to date");
276        }
277    }
278
279    // Final, unambiguous verdict line.
280    if outdated.is_empty() {
281        output::sub_step(&format!(
282            "{} remote provider(s) up to date — nothing to update",
283            remote.len()
284        ));
285        return;
286    }
287    output::sub_step(&format!(
288        "{} of {} provider(s) have updates available:",
289        outdated.len(),
290        remote.len()
291    ));
292    output::info(&format!(
293        "update all:  rbnx update -f {}",
294        manifest_path.display()
295    ));
296    output::info("update one:  cd <pkg dir> && rbnx update   (or: rbnx update -p <pkg dir>)");
297}