Skip to main content

git_stk/
upgrade.rs

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
12/// Source repository used for `--head` installs.
13const REPO_URL: &str = "https://github.com/lararosekelley/git-stk";
14
15/// Stamp file next to the install receipt; one release check per day.
16const UPDATE_CHECK_FILE: &str = "update-check";
17const CHECK_INTERVAL_SECS: u64 = 24 * 60 * 60;
18
19/// Once a day, after a common command: print one dim line when a newer
20/// release exists. Best effort with a hard time cap; anything unusual (no
21/// receipt, offline, piped stderr, opt-out) prints nothing.
22pub 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    // The query runs on a thread the process is free to abandon: the
41    // command's work is already done, so cap the wait.
42    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    // On a timeout, leave the stamp untouched so the next command retries
51    // instead of waiting out the daily window on a check that never answered.
52    if let Ok(behind) = receiver.recv_timeout(Duration::from_secs(5)) {
53        // Stamp only once the check actually finished, so a slow network
54        // retries on the next command rather than going quiet for a day.
55        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
71fn update_check_path() -> Option<PathBuf> {
72    let base = env::var_os("XDG_CONFIG_HOME")
73        .map(PathBuf::from)
74        // Windows has no HOME; %LOCALAPPDATA% is the home for app state.
75        .or_else(|| env::var_os("LOCALAPPDATA").map(PathBuf::from))
76        .or_else(|| env::var_os("HOME").map(|home| PathBuf::from(home).join(".config")))?;
77    Some(base.join("git-stk").join(UPDATE_CHECK_FILE))
78}
79
80/// Whether the daily window has passed (or the stamp is missing/garbled).
81fn should_check(cache: Option<&str>, now: u64) -> bool {
82    let Some(cache) = cache else {
83        return true;
84    };
85    cache
86        .lines()
87        .find_map(|line| line.strip_prefix("checked="))
88        .and_then(|value| value.trim().parse::<u64>().ok())
89        .is_none_or(|checked| now.saturating_sub(checked) >= CHECK_INTERVAL_SECS)
90}
91
92pub fn upgrade(head: bool, force: bool, yes: bool) -> Result<()> {
93    if head {
94        upgrade_to_head(yes)
95    } else {
96        upgrade_to_latest_release(force)
97    }
98}
99
100fn upgrade_to_head(yes: bool) -> Result<()> {
101    println!("--head builds and installs the latest unreleased commit from {REPO_URL}");
102    println!("HEAD is a pre-release snapshot: it may be broken or untested");
103
104    if !yes && !confirm("continue? [y/N] ")? {
105        println!("upgrade cancelled");
106        return Ok(());
107    }
108
109    let status = Command::new("cargo")
110        .args(["install", "--git", REPO_URL, "--locked", "git-stk"])
111        .status()
112        .context("failed to run cargo; --head requires a Rust toolchain")?;
113
114    if !status.success() {
115        bail!("cargo install exited with status {status}");
116    }
117
118    println!("installed git-stk from HEAD");
119    println!("to return to the latest release, run: git stk upgrade --force");
120    refresh_assets_with_new_binary();
121    Ok(())
122}
123
124/// Re-render generated assets (man page) after an upgrade, using the newly
125/// installed binary so the assets match its version rather than the running
126/// (pre-upgrade) one. Failure is a warning, not an error: the upgrade itself
127/// already succeeded.
128fn refresh_assets_with_new_binary() {
129    let refreshed = Command::new("git-stk")
130        .args(["setup", "--refresh"])
131        .status()
132        .map(|status| status.success())
133        .unwrap_or(false);
134
135    if !refreshed {
136        anstream::eprintln!(
137            "{} failed to refresh generated assets; run `git stk setup` manually",
138            crate::style::paint(crate::style::WARN, "warning:")
139        );
140    }
141}
142
143fn upgrade_to_latest_release(force: bool) -> Result<()> {
144    let mut updater = AxoUpdater::new_for("git-stk");
145    updater
146        .load_receipt()
147        .map_err(anyhow::Error::from)
148        .context(
149            "no usable install receipt found; if git-stk was installed with cargo, \
150             upgrade with `cargo install git-stk --locked` instead",
151        )?;
152    updater.always_update(force);
153
154    match updater
155        .run_sync()
156        .context("failed to upgrade to the latest release")?
157    {
158        Some(result) => {
159            let old = result
160                .old_version
161                .map(|version| version.to_string())
162                .unwrap_or_else(|| "unknown".to_owned());
163            anstream::println!(
164                "{}",
165                crate::style::success(&format!("upgraded git-stk {old} -> {}", result.new_version))
166            );
167            refresh_assets_with_new_binary();
168        }
169        None => println!(
170            "git-stk {} is already the latest release",
171            env!("CARGO_PKG_VERSION")
172        ),
173    }
174
175    Ok(())
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181
182    #[test]
183    fn should_check_when_stamp_is_missing_or_garbled() {
184        assert!(should_check(None, 1_000_000));
185        assert!(should_check(Some(""), 1_000_000));
186        assert!(should_check(Some("checked=not-a-number\n"), 1_000_000));
187    }
188
189    #[test]
190    fn should_check_once_per_day() {
191        let stamp = format!("checked={}\n", 1_000_000);
192        assert!(!should_check(Some(&stamp), 1_000_000 + 60));
193        assert!(!should_check(
194            Some(&stamp),
195            1_000_000 + CHECK_INTERVAL_SECS - 1
196        ));
197        assert!(should_check(Some(&stamp), 1_000_000 + CHECK_INTERVAL_SECS));
198    }
199}