Skip to main content

rbnx/cmd/
update.rs

1// SPDX-License-Identifier: MulanPSL-2.0
2//! `rbnx update` — pull remote (`url:`) providers to their latest upstream
3//! commit. Two modes, both gated on a y/N confirmation after an overview:
4//!
5//!   * deploy dir (cwd has `robonix_manifest.yaml`, or `-f <manifest>`):
6//!     update every cloned `url:` provider in that deploy.
7//!   * package dir (`-p <dir>`, or cwd is itself a git checkout): update just
8//!     that one repo.
9//!
10//! Equivalent to a `git pull` (fast-forward) of each checkout. Diverged
11//! checkouts are reported and skipped, never force-reset.
12
13use anyhow::Result;
14use std::io::{self, Write};
15use std::path::{Path, PathBuf};
16use std::process::Command;
17
18use robonix_cli::{Config, output};
19
20use super::check_remotes::{self, RemoteProvider, RemoteStatus};
21
22/// Entry point for `rbnx update [-p <dir>] [-f <manifest>]`.
23pub async fn execute(_config: Config, path: Option<PathBuf>, file: Option<PathBuf>) -> Result<()> {
24    if let Some(p) = path {
25        return update_single(&p);
26    }
27    let manifest = match file {
28        Some(f) => f,
29        None => {
30            let cwd = std::env::current_dir()?;
31            let m = cwd.join("robonix_manifest.yaml");
32            if m.is_file() {
33                m
34            } else if cwd.join(".git").is_dir() {
35                return update_single(&cwd);
36            } else {
37                anyhow::bail!(
38                    "no robonix_manifest.yaml in {} and cwd is not a git checkout.\n\
39                     Run from a deploy dir, or pass -f <manifest> / -p <package dir>.",
40                    cwd.display()
41                );
42            }
43        }
44    };
45    update_deploy(&manifest)
46}
47
48/// Run git in `dir`, returning trimmed stdout on success.
49fn git(dir: &Path, args: &[&str]) -> Option<String> {
50    let out = Command::new("git")
51        .arg("-C")
52        .arg(dir)
53        .args(args)
54        .output()
55        .ok()?;
56    if !out.status.success() {
57        return None;
58    }
59    Some(String::from_utf8_lossy(&out.stdout).trim().to_string())
60}
61
62/// Fetch the remote branch tip into FETCH_HEAD (deepened so behind-counts work
63/// on the shallow clones boot creates). Returns false when fetch fails.
64fn fetch(dir: &Path, branch: &str) -> bool {
65    Command::new("git")
66        .arg("-C")
67        .arg(dir)
68        .args(["fetch", "--quiet", "--depth", "200", "origin", branch])
69        .status()
70        .map(|s| s.success())
71        .unwrap_or(false)
72}
73
74fn prompt_yes(question: &str) -> Result<bool> {
75    print!("{question} [y/N]: ");
76    io::stdout().flush()?;
77    let mut input = String::new();
78    io::stdin().read_line(&mut input)?;
79    let a = input.trim().to_lowercase();
80    Ok(a == "y" || a == "yes")
81}
82
83/// Fast-forward `dir` to FETCH_HEAD. Returns Ok(false) when it cannot (diverged).
84fn fast_forward(dir: &Path) -> Result<bool> {
85    let ok = Command::new("git")
86        .arg("-C")
87        .arg(dir)
88        .args(["merge", "--ff-only", "FETCH_HEAD"])
89        .status()?
90        .success();
91    Ok(ok)
92}
93
94/// Update a single package checkout (overview → confirm → fast-forward).
95fn update_single(dir: &Path) -> Result<()> {
96    if !dir.join(".git").is_dir() {
97        anyhow::bail!("{} is not a git checkout", dir.display());
98    }
99    let branch = git(dir, &["rev-parse", "--abbrev-ref", "HEAD"]).unwrap_or_else(|| "HEAD".into());
100    let local = git(dir, &["rev-parse", "--short", "HEAD"]).unwrap_or_default();
101
102    output::boot_section(&format!("update {}", dir.display()));
103    output::sub_step(&format!("branch {branch}  local {local}"));
104    output::action("fetch", "origin");
105    if !fetch(dir, &branch) {
106        anyhow::bail!("git fetch failed (offline?)");
107    }
108
109    let remote = git(dir, &["rev-parse", "--short", "FETCH_HEAD"]).unwrap_or_default();
110    if remote.is_empty() || remote == local {
111        output::success("already up to date");
112        return Ok(());
113    }
114    let behind = git(dir, &["rev-list", "--count", "HEAD..FETCH_HEAD"]);
115    let subject = git(dir, &["log", "-1", "--format=%s", "FETCH_HEAD"]).unwrap_or_default();
116    let date = git(
117        dir,
118        &["log", "-1", "--format=%cd", "--date=relative", "FETCH_HEAD"],
119    )
120    .unwrap_or_default();
121    output::sub_step(&format!(
122        "remote {remote} ({date}): {subject}{}",
123        behind
124            .map(|n| format!("   [{n} commit(s) behind]"))
125            .unwrap_or_default()
126    ));
127
128    if !prompt_yes("Pull to latest?")? {
129        output::info("skipped");
130        return Ok(());
131    }
132    if fast_forward(dir)? {
133        output::success(&format!("updated → {remote}"));
134    } else {
135        anyhow::bail!("fast-forward failed — local checkout diverged; resolve manually");
136    }
137    Ok(())
138}
139
140/// Update every outdated cloned `url:` provider in a deploy manifest.
141fn update_deploy(manifest: &Path) -> Result<()> {
142    let cloned: Vec<RemoteProvider> = check_remotes::collect_remote_providers(manifest)?
143        .into_iter()
144        .filter(|p| p.dir.join(".git").is_dir())
145        .collect();
146    if cloned.is_empty() {
147        output::info("no cloned remote providers in this deploy — nothing to update");
148        return Ok(());
149    }
150
151    output::boot_section(&format!("update remote providers — {}", manifest.display()));
152    let statuses: Vec<RemoteStatus> = cloned.iter().map(check_remotes::status_of).collect();
153    for st in &statuses {
154        let detail = match (&st.note, st.behind) {
155            (Some(note), _) => note.clone(),
156            (None, Some(0)) => "up to date".to_string(),
157            (None, Some(n)) => format!(
158                "{n} behind → {} ({}): {}",
159                st.remote_short, st.remote_date, st.remote_subject
160            ),
161            (None, None) if st.outdated() => format!(
162                "behind → {} ({}): {}",
163                st.remote_short, st.remote_date, st.remote_subject
164            ),
165            (None, None) => "up to date".to_string(),
166        };
167        output::boot_note(&st.name, &detail);
168    }
169
170    let outdated: Vec<&RemoteStatus> = statuses.iter().filter(|s| s.outdated()).collect();
171    if outdated.is_empty() {
172        output::success("all remote providers up to date");
173        return Ok(());
174    }
175    if !prompt_yes(&format!("Pull {} package(s) to latest?", outdated.len()))? {
176        output::info("skipped");
177        return Ok(());
178    }
179    for st in outdated {
180        output::action("pull", &st.name);
181        match fast_forward(&st.dir) {
182            Ok(true) => output::success(&format!("{} → {}", st.name, st.remote_short)),
183            Ok(false) => output::warning(&format!(
184                "{}: fast-forward failed (diverged) — skipped",
185                st.name
186            )),
187            Err(e) => output::warning(&format!("{}: {e}", st.name)),
188        }
189    }
190    Ok(())
191}