1use anyhow::{Context, Result};
16use std::path::{Path, PathBuf};
17use std::process::Command;
18
19use robonix_cli::output;
20
21#[derive(Debug, Clone)]
23pub struct RemoteProvider {
24 pub name: String,
25 pub branch: Option<String>,
26 pub dir: PathBuf,
28}
29
30#[derive(Debug, Clone)]
32pub struct RemoteStatus {
33 pub name: String,
34 pub dir: PathBuf,
35 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 pub note: Option<String>,
44}
45
46impl RemoteStatus {
47 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
55fn 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
74pub 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; };
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 dir: cache_root.join(super::deploy::repo_dir_name(url)),
113 name,
114 branch,
115 });
116 }
117 }
118 Ok(providers)
119}
120
121fn 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
132pub 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 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 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 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
222pub 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 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 output::boot_skip(&p.name, note);
274 } else {
275 output::boot_ok(&p.name, "up to date");
276 }
277 }
278
279 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}