1use std::io::IsTerminal;
2use std::path::PathBuf;
3use std::process::Command;
4use std::time::{Duration, SystemTime, UNIX_EPOCH};
5use std::{env, fs, sync::mpsc, thread};
6
7use anyhow::{Context, Result, bail};
8use axoupdater::{AxoUpdater, UpdateRequest};
9
10use crate::prompt::confirm;
11
12const REPO_URL: &str = "https://github.com/lararosekelley/git-stk";
14
15const MIN_DOWNGRADE_VERSION: &str = "0.9.17";
20
21const UPDATE_CHECK_FILE: &str = "update-check";
23const CHECK_INTERVAL_SECS: u64 = 24 * 60 * 60;
24
25pub fn maybe_hint_update() {
29 if !std::io::stderr().is_terminal() {
30 return;
31 }
32 let Some(path) = update_check_path() else {
33 return;
34 };
35 let now = SystemTime::now()
36 .duration_since(UNIX_EPOCH)
37 .map(|elapsed| elapsed.as_secs())
38 .unwrap_or(0);
39 if !should_check(fs::read_to_string(&path).ok().as_deref(), now) {
40 return;
41 }
42 if crate::settings::bool_setting(crate::settings::NO_UPDATE_CHECK_KEY).unwrap_or(false) {
43 return;
44 }
45
46 let (sender, receiver) = mpsc::channel();
49 thread::spawn(move || {
50 let mut updater = AxoUpdater::new_for("git-stk");
51 let behind =
52 updater.load_receipt().is_ok() && updater.is_update_needed_sync().unwrap_or(false);
53 let _ = sender.send(behind);
54 });
55
56 if let Ok(behind) = receiver.recv_timeout(Duration::from_secs(5)) {
59 if let Some(parent) = path.parent() {
62 let _ = fs::create_dir_all(parent);
63 }
64 let _ = fs::write(&path, format!("checked={now}\n"));
65 if behind {
66 anstream::eprintln!(
67 "{}",
68 crate::style::paint(
69 crate::style::DIM,
70 "a newer git-stk release is available - run `git stk upgrade`"
71 )
72 );
73 }
74 }
75}
76
77pub(crate) fn config_dir() -> Option<PathBuf> {
81 let base = env::var_os("XDG_CONFIG_HOME")
82 .map(PathBuf::from)
83 .or_else(|| env::var_os("LOCALAPPDATA").map(PathBuf::from))
85 .or_else(|| env::var_os("HOME").map(|home| PathBuf::from(home).join(".config")))?;
86 Some(base.join("git-stk"))
87}
88
89fn update_check_path() -> Option<PathBuf> {
90 Some(config_dir()?.join(UPDATE_CHECK_FILE))
91}
92
93fn should_check(cache: Option<&str>, now: u64) -> bool {
95 let Some(cache) = cache else {
96 return true;
97 };
98 cache
99 .lines()
100 .find_map(|line| line.strip_prefix("checked="))
101 .and_then(|value| value.trim().parse::<u64>().ok())
102 .is_none_or(|checked| now.saturating_sub(checked) >= CHECK_INTERVAL_SECS)
103}
104
105pub fn upgrade(head: bool, force: bool, yes: bool) -> Result<()> {
106 if head {
107 upgrade_to_head(yes)
108 } else {
109 upgrade_to_latest_release(force)
110 }
111}
112
113fn upgrade_to_head(yes: bool) -> Result<()> {
114 anstream::println!("--head builds and installs the latest unreleased commit from {REPO_URL}");
115 anstream::println!("HEAD is a pre-release snapshot: it may be broken or untested");
116
117 if !yes && !confirm("continue? [y/N] ")? {
118 anstream::println!("upgrade cancelled");
119 return Ok(());
120 }
121
122 let status = Command::new("cargo")
123 .args(["install", "--git", REPO_URL, "--locked", "git-stk"])
124 .status()
125 .context("failed to run cargo; --head requires a Rust toolchain")?;
126
127 if !status.success() {
128 bail!("cargo install exited with status {status}");
129 }
130
131 anstream::println!("installed git-stk from HEAD");
132 anstream::println!("to return to the latest release, run: git stk upgrade --force");
133 refresh_assets_with_new_binary();
134 Ok(())
135}
136
137fn refresh_assets_with_new_binary() {
142 let refreshed = Command::new("git-stk")
143 .args(["setup", "--refresh"])
144 .status()
145 .map(|status| status.success())
146 .unwrap_or(false);
147
148 if !refreshed {
149 anstream::eprintln!(
150 "{} failed to refresh generated assets; run `git stk setup` manually",
151 crate::style::paint(crate::style::WARN, "warning:")
152 );
153 }
154}
155
156fn upgrade_to_latest_release(force: bool) -> Result<()> {
157 let mut updater = AxoUpdater::new_for("git-stk");
158 updater
159 .load_receipt()
160 .map_err(anyhow::Error::from)
161 .context(
162 "no usable install receipt found; if git-stk was installed with cargo, \
163 upgrade with `cargo install git-stk --locked` instead",
164 )?;
165 updater.always_update(force);
166
167 match updater
168 .run_sync()
169 .context("failed to upgrade to the latest release")?
170 {
171 Some(result) => {
172 let old = result
173 .old_version
174 .map(|version| version.to_string())
175 .unwrap_or_else(|| "unknown".to_owned());
176 anstream::println!(
177 "{}",
178 crate::style::success(&format!("upgraded git-stk {old} -> {}", result.new_version))
179 );
180 refresh_assets_with_new_binary();
181 }
182 None => anstream::println!(
183 "git-stk {} is already the latest release",
184 env!("CARGO_PKG_VERSION")
185 ),
186 }
187
188 Ok(())
189}
190
191pub fn downgrade(to: Option<String>, yes: bool) -> Result<()> {
195 let installed = env!("CARGO_PKG_VERSION");
196 let installed_version = parse_version(installed)
197 .with_context(|| format!("could not parse the installed version {installed}"))?;
198 let floor = parse_version(MIN_DOWNGRADE_VERSION).expect("floor is a valid version");
199
200 if installed_version <= floor {
201 anstream::println!(
202 "git-stk {installed} is the earliest release `downgrade` can reach; \
203 nothing older to downgrade to"
204 );
205 return Ok(());
206 }
207
208 let requested = match &to {
209 Some(to) => Some(parse_version(to).with_context(|| format!("not a version: {to}"))?),
210 None => None,
211 };
212 let available = match requested {
214 Some(_) => Vec::new(),
215 None => remote_release_versions()?,
216 };
217 let target = version_string(resolve_target(
218 installed_version,
219 floor,
220 requested,
221 &available,
222 )?);
223
224 let mut updater = AxoUpdater::new_for("git-stk");
225 updater
226 .load_receipt()
227 .map_err(anyhow::Error::from)
228 .context(
229 "no usable install receipt found; if git-stk was installed with cargo, \
230 downgrade with `cargo install git-stk@<version> --locked` instead",
231 )?;
232
233 anstream::println!("downgrade git-stk {installed} -> {target}");
234 anstream::println!(
235 "a release older than {installed} may not understand state a newer one wrote \
236 (PR ledger, branch metadata, the shared metadata ref)"
237 );
238 if !yes && !confirm("continue? [y/N] ")? {
239 anstream::println!("downgrade cancelled");
240 return Ok(());
241 }
242
243 updater.configure_version_specifier(UpdateRequest::SpecificVersion(target.clone()));
244 updater.always_update(true);
247
248 match updater
249 .run_sync()
250 .context("failed to downgrade to the requested release")?
251 {
252 Some(result) => {
253 anstream::println!(
254 "{}",
255 crate::style::success(&format!(
256 "downgraded git-stk {installed} -> {}",
257 result.new_version
258 ))
259 );
260 anstream::println!("to move forward again, run: git stk upgrade");
261 refresh_assets_with_new_binary();
262 }
263 None => anstream::println!("git-stk is already at {target}"),
264 }
265 Ok(())
266}
267
268type Version3 = (u64, u64, u64);
269
270fn resolve_target(
274 installed: Version3,
275 floor: Version3,
276 requested: Option<Version3>,
277 available: &[Version3],
278) -> Result<Version3> {
279 match requested {
280 Some(target) => {
281 if target >= installed {
282 bail!(
283 "{} is not older than the installed {}; use `git stk upgrade` to move forward",
284 version_string(target),
285 version_string(installed)
286 );
287 }
288 if target < floor {
289 bail!(
290 "{} is below {}, the earliest release `downgrade` can reach",
291 version_string(target),
292 version_string(floor)
293 );
294 }
295 Ok(target)
296 }
297 None => available
298 .iter()
299 .copied()
300 .filter(|version| *version < installed && *version >= floor)
301 .max()
302 .with_context(|| {
303 format!(
304 "no release between {} and {} to downgrade to",
305 version_string(floor),
306 version_string(installed)
307 )
308 }),
309 }
310}
311
312fn parse_version(text: &str) -> Option<Version3> {
315 let mut parts = text.trim().split('.');
316 let major = parts.next()?.parse().ok()?;
317 let minor = parts.next()?.parse().ok()?;
318 let patch = parts.next()?.parse().ok()?;
319 if parts.next().is_some() {
320 return None;
321 }
322 Some((major, minor, patch))
323}
324
325fn version_string((major, minor, patch): Version3) -> String {
326 format!("{major}.{minor}.{patch}")
327}
328
329fn remote_release_versions() -> Result<Vec<Version3>> {
331 let output = Command::new("git")
332 .args(["ls-remote", "--tags", REPO_URL])
333 .output()
334 .context("failed to list releases; check your network connection")?;
335 if !output.status.success() {
336 bail!("failed to fetch the release list from {REPO_URL}");
337 }
338
339 let text = String::from_utf8_lossy(&output.stdout);
340 Ok(text
341 .lines()
342 .filter_map(|line| line.split("refs/tags/").nth(1))
343 .filter(|tag| !tag.ends_with("^{}"))
344 .filter_map(|tag| parse_version(tag.strip_prefix('v').unwrap_or(tag)))
345 .collect())
346}
347
348#[cfg(test)]
349mod tests {
350 use super::*;
351
352 #[test]
353 fn parse_version_accepts_plain_xyz_and_rejects_the_rest() {
354 assert_eq!(parse_version("0.9.16"), Some((0, 9, 16)));
355 assert_eq!(parse_version("10.0.3"), Some((10, 0, 3)));
356 assert_eq!(parse_version("0.9"), None);
357 assert_eq!(parse_version("0.9.16.1"), None);
358 assert_eq!(parse_version("0.9.0-rc.1"), None);
359 assert_eq!(parse_version("v0.9.16"), None);
360 }
361
362 #[test]
363 fn resolve_target_requires_explicit_to_be_older() {
364 let (installed, floor) = ((0, 9, 18), (0, 9, 17));
365 assert!(resolve_target(installed, floor, Some((0, 9, 18)), &[]).is_err());
366 assert!(resolve_target(installed, floor, Some((0, 9, 19)), &[]).is_err());
367 }
368
369 #[test]
370 fn resolve_target_refuses_explicit_below_the_floor() {
371 let (installed, floor) = ((0, 9, 18), (0, 9, 17));
372 assert!(resolve_target(installed, floor, Some((0, 9, 16)), &[]).is_err());
373 assert_eq!(
374 resolve_target(installed, floor, Some((0, 9, 17)), &[]).unwrap(),
375 (0, 9, 17)
376 );
377 }
378
379 #[test]
380 fn resolve_target_default_picks_the_previous_release() {
381 let (installed, floor) = ((0, 9, 20), (0, 9, 17));
382 let available = [(0, 9, 17), (0, 9, 18), (0, 9, 19), (0, 9, 20)];
383 assert_eq!(
384 resolve_target(installed, floor, None, &available).unwrap(),
385 (0, 9, 19)
386 );
387 }
388
389 #[test]
390 fn resolve_target_default_never_crosses_the_floor() {
391 let (installed, floor) = ((0, 9, 18), (0, 9, 17));
392 let available = [(0, 9, 15), (0, 9, 16), (0, 9, 17), (0, 9, 18)];
393 assert_eq!(
395 resolve_target(installed, floor, None, &available).unwrap(),
396 (0, 9, 17)
397 );
398 }
399
400 #[test]
401 fn resolve_target_default_errors_when_nothing_older_in_range() {
402 let (installed, floor) = ((0, 9, 18), (0, 9, 17));
403 assert!(resolve_target(installed, floor, None, &[(0, 9, 18), (0, 9, 19)]).is_err());
404 }
405
406 #[test]
407 fn should_check_when_stamp_is_missing_or_garbled() {
408 assert!(should_check(None, 1_000_000));
409 assert!(should_check(Some(""), 1_000_000));
410 assert!(should_check(Some("checked=not-a-number\n"), 1_000_000));
411 }
412
413 #[test]
414 fn should_check_once_per_day() {
415 let stamp = format!("checked={}\n", 1_000_000);
416 assert!(!should_check(Some(&stamp), 1_000_000 + 60));
417 assert!(!should_check(
418 Some(&stamp),
419 1_000_000 + CHECK_INTERVAL_SECS - 1
420 ));
421 assert!(should_check(Some(&stamp), 1_000_000 + CHECK_INTERVAL_SECS));
422 }
423}