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;
9
10use crate::prompt::confirm;
11
12const REPO_URL: &str = "https://github.com/lararosekelley/git-stk";
14
15const UPDATE_CHECK_FILE: &str = "update-check";
17const CHECK_INTERVAL_SECS: u64 = 24 * 60 * 60;
18
19pub fn maybe_hint_update() {
23 if !std::io::stderr().is_terminal() {
24 return;
25 }
26 let Some(path) = update_check_path() else {
27 return;
28 };
29 let now = SystemTime::now()
30 .duration_since(UNIX_EPOCH)
31 .map(|elapsed| elapsed.as_secs())
32 .unwrap_or(0);
33 if !should_check(fs::read_to_string(&path).ok().as_deref(), now) {
34 return;
35 }
36 if crate::settings::bool_setting(crate::settings::NO_UPDATE_CHECK_KEY).unwrap_or(false) {
37 return;
38 }
39
40 let (sender, receiver) = mpsc::channel();
43 thread::spawn(move || {
44 let mut updater = AxoUpdater::new_for("git-stk");
45 let behind =
46 updater.load_receipt().is_ok() && updater.is_update_needed_sync().unwrap_or(false);
47 let _ = sender.send(behind);
48 });
49
50 if let Ok(behind) = receiver.recv_timeout(Duration::from_secs(5)) {
53 if let Some(parent) = path.parent() {
56 let _ = fs::create_dir_all(parent);
57 }
58 let _ = fs::write(&path, format!("checked={now}\n"));
59 if behind {
60 anstream::eprintln!(
61 "{}",
62 crate::style::paint(
63 crate::style::DIM,
64 "a newer git-stk release is available - run `git stk upgrade`"
65 )
66 );
67 }
68 }
69}
70
71pub(crate) fn config_dir() -> Option<PathBuf> {
75 let base = env::var_os("XDG_CONFIG_HOME")
76 .map(PathBuf::from)
77 .or_else(|| env::var_os("LOCALAPPDATA").map(PathBuf::from))
79 .or_else(|| env::var_os("HOME").map(|home| PathBuf::from(home).join(".config")))?;
80 Some(base.join("git-stk"))
81}
82
83fn update_check_path() -> Option<PathBuf> {
84 Some(config_dir()?.join(UPDATE_CHECK_FILE))
85}
86
87fn should_check(cache: Option<&str>, now: u64) -> bool {
89 let Some(cache) = cache else {
90 return true;
91 };
92 cache
93 .lines()
94 .find_map(|line| line.strip_prefix("checked="))
95 .and_then(|value| value.trim().parse::<u64>().ok())
96 .is_none_or(|checked| now.saturating_sub(checked) >= CHECK_INTERVAL_SECS)
97}
98
99pub fn upgrade(head: bool, force: bool, yes: bool) -> Result<()> {
100 if head {
101 upgrade_to_head(yes)
102 } else {
103 upgrade_to_latest_release(force)
104 }
105}
106
107fn upgrade_to_head(yes: bool) -> Result<()> {
108 anstream::println!("--head builds and installs the latest unreleased commit from {REPO_URL}");
109 anstream::println!("HEAD is a pre-release snapshot: it may be broken or untested");
110
111 if !yes && !confirm("continue? [y/N] ")? {
112 anstream::println!("upgrade cancelled");
113 return Ok(());
114 }
115
116 let status = Command::new("cargo")
117 .args(["install", "--git", REPO_URL, "--locked", "git-stk"])
118 .status()
119 .context("failed to run cargo; --head requires a Rust toolchain")?;
120
121 if !status.success() {
122 bail!("cargo install exited with status {status}");
123 }
124
125 anstream::println!("installed git-stk from HEAD");
126 anstream::println!("to return to the latest release, run: git stk upgrade --force");
127 refresh_assets_with_new_binary();
128 Ok(())
129}
130
131fn refresh_assets_with_new_binary() {
136 let refreshed = Command::new("git-stk")
137 .args(["setup", "--refresh"])
138 .status()
139 .map(|status| status.success())
140 .unwrap_or(false);
141
142 if !refreshed {
143 anstream::eprintln!(
144 "{} failed to refresh generated assets; run `git stk setup` manually",
145 crate::style::paint(crate::style::WARN, "warning:")
146 );
147 }
148}
149
150fn upgrade_to_latest_release(force: bool) -> Result<()> {
151 let mut updater = AxoUpdater::new_for("git-stk");
152 updater
153 .load_receipt()
154 .map_err(anyhow::Error::from)
155 .context(
156 "no usable install receipt found; if git-stk was installed with cargo, \
157 upgrade with `cargo install git-stk --locked` instead",
158 )?;
159 updater.always_update(force);
160
161 match updater
162 .run_sync()
163 .context("failed to upgrade to the latest release")?
164 {
165 Some(result) => {
166 let old = result
167 .old_version
168 .map(|version| version.to_string())
169 .unwrap_or_else(|| "unknown".to_owned());
170 anstream::println!(
171 "{}",
172 crate::style::success(&format!("upgraded git-stk {old} -> {}", result.new_version))
173 );
174 refresh_assets_with_new_binary();
175 }
176 None => anstream::println!(
177 "git-stk {} is already the latest release",
178 env!("CARGO_PKG_VERSION")
179 ),
180 }
181
182 Ok(())
183}
184
185#[cfg(test)]
186mod tests {
187 use super::*;
188
189 #[test]
190 fn should_check_when_stamp_is_missing_or_garbled() {
191 assert!(should_check(None, 1_000_000));
192 assert!(should_check(Some(""), 1_000_000));
193 assert!(should_check(Some("checked=not-a-number\n"), 1_000_000));
194 }
195
196 #[test]
197 fn should_check_once_per_day() {
198 let stamp = format!("checked={}\n", 1_000_000);
199 assert!(!should_check(Some(&stamp), 1_000_000 + 60));
200 assert!(!should_check(
201 Some(&stamp),
202 1_000_000 + CHECK_INTERVAL_SECS - 1
203 ));
204 assert!(should_check(Some(&stamp), 1_000_000 + CHECK_INTERVAL_SECS));
205 }
206}