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, UpdateRequest};
9
10use crate::prompt::confirm;
11
12/// Source repository used for `--head` installs and release discovery.
13const REPO_URL: &str = "https://github.com/lararosekelley/git-stk";
14
15/// The first release that shipped `downgrade`, and the floor it can reach.
16/// Going below would strand the user on a binary with no `downgrade` (unable
17/// to step back again) and predates the state-format guarantees this command
18/// assumes. MUST equal the version this command ships in.
19const MIN_DOWNGRADE_VERSION: &str = "0.9.17";
20
21/// Stamp file next to the install receipt; one release check per day.
22const UPDATE_CHECK_FILE: &str = "update-check";
23const CHECK_INTERVAL_SECS: u64 = 24 * 60 * 60;
24
25/// Once a day, after a common command: print one dim line when a newer
26/// release exists. Best effort with a hard time cap; anything unusual (no
27/// receipt, offline, piped stderr, opt-out) prints nothing.
28pub 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    // The query runs on a thread the process is free to abandon: the
47    // command's work is already done, so cap the wait.
48    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    // On a timeout, leave the stamp untouched so the next command retries
57    // instead of waiting out the daily window on a check that never answered.
58    if let Ok(behind) = receiver.recv_timeout(Duration::from_secs(5)) {
59        // Stamp only once the check actually finished, so a slow network
60        // retries on the next command rather than going quiet for a day.
61        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
77/// git-stk's config/state directory, where the install receipt and the
78/// update-check stamp live: `$XDG_CONFIG_HOME/git-stk`, `%LOCALAPPDATA%\git-stk`
79/// on Windows, or `~/.config/git-stk`.
80pub(crate) fn config_dir() -> Option<PathBuf> {
81    let base = env::var_os("XDG_CONFIG_HOME")
82        .map(PathBuf::from)
83        // Windows has no HOME; %LOCALAPPDATA% is the home for app state.
84        .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
93/// Whether the daily window has passed (or the stamp is missing/garbled).
94fn 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
137/// Re-render generated assets (man page) after an upgrade, using the newly
138/// installed binary so the assets match its version rather than the running
139/// (pre-upgrade) one. Failure is a warning, not an error: the upgrade itself
140/// already succeeded.
141fn 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
191/// Step back to an earlier release. Riskier than upgrading - an older binary
192/// may not understand state a newer one wrote - so it confirms first (unless
193/// `yes`) and never goes below [`MIN_DOWNGRADE_VERSION`].
194pub 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    // Only the default (no `--to`) needs the release list.
213    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    // Going backward is never "needed" by the cur < new check; force it. The
245    // installer for the target version rewrites the receipt, keeping it honest.
246    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
270/// Choose the version to downgrade to. `available` is consulted only for the
271/// default (no `--to`); an explicit target is validated against the installed
272/// version and the floor rather than silently clamped.
273fn 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
312/// Parse a plain `X.Y.Z`. Anything else - extra parts, pre-release suffixes -
313/// yields None, so non-release tags are skipped.
314fn 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
329/// Released versions, from the repo's `vX.Y.Z` tags.
330fn 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        // Releases below the floor are out; the previous in-range one is the floor.
394        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}