use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::ui;
const STALE_SECS: u64 = 24 * 60 * 60;
const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
const RELEASES_URL: &str = "https://api.github.com/repos/driftsys/git-std/releases/latest";
#[derive(Debug, Serialize, Deserialize)]
struct UpdateCache {
latest_version: String,
checked_at: u64,
}
pub fn maybe_spawn_background_check() {
if is_disabled() || !ui::is_tty() {
return;
}
let Some(path) = cache_path() else { return };
if let Some(cache) = read_cache_from(&path)
&& !is_stale(cache.checked_at)
{
return; }
let Some(exe) = std::env::current_exe().ok() else {
return;
};
let _ = std::process::Command::new(exe)
.arg("--update-check-bg")
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn(); }
pub fn run_background_check() {
let Some(path) = cache_path() else { return };
let Some(version) = fetch_latest_version() else {
return;
};
let cache = UpdateCache {
latest_version: version,
checked_at: now_epoch_secs(),
};
let _ = write_cache_to(&path, &cache);
}
pub fn print_update_hint() {
if is_disabled() {
return;
}
let Some(path) = cache_path() else { return };
let Some(cache) = read_cache_from(&path) else {
return;
};
if is_stale(cache.checked_at) {
return; }
if let Some(msg) = build_hint_message(CURRENT_VERSION, &cache.latest_version) {
ui::blank();
for line in msg.lines() {
ui::hint(line);
}
}
}
pub fn run_self_update() -> i32 {
ui::info("checking for updates…");
let Some(latest) = fetch_latest_version() else {
ui::error("could not fetch latest release from GitHub");
ui::hint("check your network connection and try again");
return 1;
};
let cur = match semver::Version::parse(CURRENT_VERSION) {
Ok(v) => v,
Err(_) => {
ui::error(&format!("cannot parse current version: {CURRENT_VERSION}"));
return 1;
}
};
let lat = match semver::Version::parse(&latest) {
Ok(v) => v,
Err(_) => {
ui::error(&format!("cannot parse latest version: {latest}"));
return 1;
}
};
if lat <= cur {
ui::info(&format!(
"{} already up to date ({CURRENT_VERSION})",
ui::pass()
));
return 0;
}
ui::info(&format!("updating git-std {CURRENT_VERSION} → {latest}…"));
let method = detect_install_method();
let (cmd, args) = update_command_for_method(&method);
let status = std::process::Command::new(cmd).args(&args).status();
match status {
Ok(s) if s.success() => {
if let Some(path) = cache_path() {
let cache = UpdateCache {
latest_version: latest.clone(),
checked_at: now_epoch_secs(),
};
let _ = write_cache_to(&path, &cache);
}
ui::info(&format!("{} git-std updated to {latest}", ui::pass()));
0
}
Ok(s) => {
ui::error("update command failed");
ui::hint(&format!("try running manually: {method}"));
s.code().unwrap_or(1)
}
Err(e) => {
ui::error(&format!("could not run update command: {e}"));
ui::hint(&format!("try running manually: {method}"));
1
}
}
}
fn is_disabled() -> bool {
std::env::var("GIT_STD_NO_UPDATE_CHECK")
.map(|v| v == "1")
.unwrap_or(false)
}
fn now_epoch_secs() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
fn is_stale(checked_at: u64) -> bool {
now_epoch_secs().saturating_sub(checked_at) >= STALE_SECS
}
fn cache_path() -> Option<PathBuf> {
let base = std::env::var("XDG_CONFIG_HOME")
.ok()
.filter(|s| !s.is_empty())
.or_else(|| std::env::var("HOME").ok().map(|h| format!("{h}/.config")))?;
Some(PathBuf::from(base).join("git-std/update-check.json"))
}
fn read_cache_from(path: &Path) -> Option<UpdateCache> {
let data = std::fs::read_to_string(path).ok()?;
serde_json::from_str(&data).ok()
}
fn write_cache_to(path: &Path, cache: &UpdateCache) -> Result<(), std::io::Error> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let json = serde_json::to_string(cache).map_err(std::io::Error::other)?;
std::fs::write(path, json)
}
fn fetch_latest_version() -> Option<String> {
let output = std::process::Command::new("curl")
.args(["-sSf", "--max-time", "10", RELEASES_URL])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let body: serde_json::Value = serde_json::from_slice(&output.stdout).ok()?;
let tag = body.get("tag_name")?.as_str()?;
let version = tag.strip_prefix('v').unwrap_or(tag);
semver::Version::parse(version).ok()?;
Some(version.to_string())
}
fn build_hint_message(current: &str, latest: &str) -> Option<String> {
let cur = semver::Version::parse(current).ok()?;
let lat = semver::Version::parse(latest).ok()?;
if lat <= cur {
return None;
}
let cmd = detect_install_method();
Some(format!(
"a new release of git-std is available: {current} \u{2192} {latest}\nto update, run: {cmd}"
))
}
fn detect_install_method() -> String {
let path = std::env::current_exe()
.ok()
.and_then(|p| p.to_str().map(String::from))
.unwrap_or_default();
install_method_for_path(&path)
}
fn install_method_for_path(path: &str) -> String {
if path.contains("/.cargo/bin/") {
"cargo install git-std".to_string()
} else if path.contains("/.local/bin/") {
"curl -fsSL https://raw.githubusercontent.com/driftsys/git-std/main/install.sh | sh"
.to_string()
} else if path.contains("/nix/store/") {
"nix profile upgrade git-std".to_string()
} else {
"visit https://github.com/driftsys/git-std/releases".to_string()
}
}
fn update_command_for_method(method: &str) -> (&str, Vec<&str>) {
if method.starts_with("cargo install") {
("cargo", vec!["install", "git-std"])
} else if method.starts_with("curl") {
("sh", vec!["-c", method])
} else if method.starts_with("nix") {
("nix", vec!["profile", "upgrade", "git-std"])
} else {
(
"echo",
vec!["visit https://github.com/driftsys/git-std/releases"],
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn stale_after_24h() {
let old = now_epoch_secs() - 25 * 3600;
assert!(is_stale(old));
}
#[test]
fn fresh_within_24h() {
let recent = now_epoch_secs() - 3600;
assert!(!is_stale(recent));
}
#[test]
fn cache_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("update-check.json");
let cache = UpdateCache {
latest_version: "1.2.3".to_string(),
checked_at: now_epoch_secs(),
};
write_cache_to(&path, &cache).unwrap();
let loaded = read_cache_from(&path).unwrap();
assert_eq!(loaded.latest_version, "1.2.3");
}
#[test]
fn read_cache_missing_file() {
let dir = tempfile::tempdir().unwrap();
assert!(read_cache_from(&dir.path().join("nope.json")).is_none());
}
#[test]
fn read_cache_corrupt_json() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("bad.json");
std::fs::write(&path, "not json!!!").unwrap();
assert!(read_cache_from(&path).is_none());
}
#[test]
fn hint_when_newer() {
assert!(build_hint_message("0.9.0", "1.0.0").is_some());
}
#[test]
fn no_hint_when_current() {
assert!(build_hint_message("1.0.0", "1.0.0").is_none());
}
#[test]
fn no_hint_when_ahead() {
assert!(build_hint_message("2.0.0", "1.0.0").is_none());
}
#[test]
fn method_cargo() {
let m = install_method_for_path("/home/user/.cargo/bin/git-std");
assert!(m.contains("cargo install"));
}
#[test]
fn method_local_bin() {
let m = install_method_for_path("/home/user/.local/bin/git-std");
assert!(m.contains("curl"));
}
#[test]
fn method_nix() {
let m = install_method_for_path("/nix/store/abc/bin/git-std");
assert!(m.contains("nix profile"));
}
#[test]
fn method_other() {
let m = install_method_for_path("/usr/local/bin/git-std");
assert!(m.contains("github.com"));
}
#[test]
fn update_cmd_cargo() {
let (cmd, args) = update_command_for_method("cargo install git-std");
assert_eq!(cmd, "cargo");
assert_eq!(args, vec!["install", "git-std"]);
}
#[test]
fn update_cmd_curl() {
let method =
"curl -fsSL https://raw.githubusercontent.com/driftsys/git-std/main/install.sh | sh";
let (cmd, args) = update_command_for_method(method);
assert_eq!(cmd, "sh");
assert_eq!(args, vec!["-c", method]);
}
#[test]
fn update_cmd_nix() {
let (cmd, args) = update_command_for_method("nix profile upgrade git-std");
assert_eq!(cmd, "nix");
assert_eq!(args, vec!["profile", "upgrade", "git-std"]);
}
#[test]
fn update_cmd_fallback() {
let (cmd, _) =
update_command_for_method("visit https://github.com/driftsys/git-std/releases");
assert_eq!(cmd, "echo");
}
}