1use 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
22pub 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
48fn 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
62fn 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
83fn 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
94fn 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
140fn 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}