Skip to main content

git_worktree_manager/
update.rs

1/// Auto-update check and self-upgrade via GitHub Releases.
2///
3use std::io::IsTerminal;
4use std::path::PathBuf;
5use std::process::Command;
6
7use console::style;
8use serde::{Deserialize, Serialize};
9
10use crate::constants::home_dir_or_fallback;
11
12const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
13const REPO_OWNER: &str = "DaveDev42";
14const REPO_NAME: &str = "git-worktree-manager";
15
16/// How often to check for updates (in seconds). Default: 6 hours.
17const CHECK_INTERVAL_SECS: u64 = 6 * 60 * 60;
18
19/// Cache for update check results.
20#[derive(Debug, Serialize, Deserialize, Default)]
21struct UpdateCache {
22    /// Unix timestamp of last check.
23    #[serde(default)]
24    last_check_ts: u64,
25    /// Legacy date string (for backward compat).
26    #[serde(default)]
27    last_check: String,
28    latest_version: Option<String>,
29}
30
31fn get_cache_path() -> PathBuf {
32    dirs::cache_dir()
33        .unwrap_or_else(home_dir_or_fallback)
34        .join("git-worktree-manager")
35        .join("update_check.json")
36}
37
38fn load_cache() -> UpdateCache {
39    let path = get_cache_path();
40    if !path.exists() {
41        return UpdateCache::default();
42    }
43    std::fs::read_to_string(&path)
44        .ok()
45        .and_then(|c| serde_json::from_str(&c).ok())
46        .unwrap_or_default()
47}
48
49fn save_cache(cache: &UpdateCache) {
50    let path = get_cache_path();
51    if let Some(parent) = path.parent() {
52        let _ = std::fs::create_dir_all(parent);
53    }
54    if let Ok(content) = serde_json::to_string_pretty(cache) {
55        let _ = std::fs::write(&path, content);
56    }
57}
58
59fn now_ts() -> u64 {
60    std::time::SystemTime::now()
61        .duration_since(std::time::UNIX_EPOCH)
62        .map(|d| d.as_secs())
63        .unwrap_or(0)
64}
65
66fn cache_is_fresh(cache: &UpdateCache) -> bool {
67    let age = now_ts().saturating_sub(cache.last_check_ts);
68    age < CHECK_INTERVAL_SECS
69}
70
71/// Check for updates (called on startup).
72///
73/// Phase 1 (instant, no I/O): show notification from cache if newer version known.
74/// Phase 2 (background): if cache is stale, fork a background process to refresh it.
75pub fn check_for_update_if_needed() {
76    let config = crate::config::load_config().unwrap_or_default();
77    if !config.update.auto_check {
78        return;
79    }
80
81    let cache = load_cache();
82
83    // Phase 1: instant notification from cache (zero latency)
84    if let Some(ref latest) = cache.latest_version {
85        if is_newer(latest, CURRENT_VERSION) {
86            eprintln!(
87                "\n{} {} is available (current: {})",
88                style("git-worktree-manager").bold(),
89                style(format!("v{}", latest)).green(),
90                style(format!("v{}", CURRENT_VERSION)).dim(),
91            );
92            eprintln!("Run '{}' to update.\n", style("gw upgrade").cyan().bold());
93        }
94    }
95
96    // Phase 2: if cache is stale, refresh in background
97    if !cache_is_fresh(&cache) {
98        spawn_background_check();
99    }
100}
101
102/// Spawn a background process to check for updates without blocking startup.
103fn spawn_background_check() {
104    let exe = match std::env::current_exe() {
105        Ok(p) => p,
106        Err(_) => return,
107    };
108    // Use a hidden subcommand to do the actual check
109    let _ = Command::new(exe)
110        .arg("_update-cache")
111        .stdin(std::process::Stdio::null())
112        .stdout(std::process::Stdio::null())
113        .stderr(std::process::Stdio::null())
114        .spawn();
115}
116
117/// Refresh the update cache (called by background process).
118pub fn refresh_cache() {
119    if let Some(latest) = fetch_latest_version() {
120        let cache = UpdateCache {
121            last_check_ts: now_ts(),
122            latest_version: Some(latest),
123            ..Default::default()
124        };
125        save_cache(&cache);
126    } else {
127        // Save timestamp even on failure to avoid hammering
128        let cache = UpdateCache {
129            last_check_ts: now_ts(),
130            latest_version: load_cache().latest_version, // keep previous
131            ..Default::default()
132        };
133        save_cache(&cache);
134    }
135}
136
137/// Get a GitHub auth token if available.
138/// Checks GITHUB_TOKEN env var first, then falls back to `gh auth token`.
139fn gh_auth_token() -> Option<String> {
140    // 1. Environment variable (fast, no subprocess)
141    if let Ok(token) = std::env::var("GITHUB_TOKEN") {
142        if !token.is_empty() {
143            return Some(token);
144        }
145    }
146    if let Ok(token) = std::env::var("GH_TOKEN") {
147        if !token.is_empty() {
148            return Some(token);
149        }
150    }
151
152    // 2. gh CLI (only if binary exists)
153    if which_exists("gh") {
154        return Command::new("gh")
155            .args(["auth", "token"])
156            .stdin(std::process::Stdio::null())
157            .stderr(std::process::Stdio::null())
158            .output()
159            .ok()
160            .filter(|o| o.status.success())
161            .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
162            .filter(|t| !t.is_empty());
163    }
164
165    None
166}
167
168/// Check if a command exists in PATH without running it.
169fn which_exists(cmd: &str) -> bool {
170    std::env::var_os("PATH")
171        .map(|paths| {
172            std::env::split_paths(&paths).any(|dir| {
173                let full = dir.join(cmd);
174                full.is_file() || (cfg!(windows) && dir.join(format!("{}.exe", cmd)).is_file())
175            })
176        })
177        .unwrap_or(false)
178}
179
180/// Fetch latest version string from GitHub Releases API.
181/// Uses gh auth token if available to avoid unauthenticated rate limits (60/hr).
182fn fetch_latest_version() -> Option<String> {
183    let url = format!(
184        "https://api.github.com/repos/{}/{}/releases/latest",
185        REPO_OWNER, REPO_NAME
186    );
187
188    let mut args = vec![
189        "-s".to_string(),
190        "--max-time".to_string(),
191        "10".to_string(),
192        "-H".to_string(),
193        "Accept: application/vnd.github+json".to_string(),
194    ];
195
196    if let Some(token) = gh_auth_token() {
197        args.push("-H".to_string());
198        args.push(format!("Authorization: Bearer {}", token));
199    }
200
201    args.push(url);
202
203    let output = Command::new("curl").args(&args).output().ok()?;
204
205    if !output.status.success() {
206        return None;
207    }
208
209    let body = String::from_utf8_lossy(&output.stdout);
210    let json: serde_json::Value = serde_json::from_str(&body).ok()?;
211    let tag = json.get("tag_name")?.as_str()?;
212
213    // Strip tag prefix: "v0.0.3" → "0.0.3"
214    Some(tag.strip_prefix('v').unwrap_or(tag).to_string())
215}
216
217/// Compare version strings (simple semver).
218fn is_newer(latest: &str, current: &str) -> bool {
219    let parse = |s: &str| -> Vec<u32> { s.split('.').filter_map(|p| p.parse().ok()).collect() };
220    let l = parse(latest);
221    let c = parse(current);
222    l > c
223}
224
225/// Detect if the binary was installed via Homebrew.
226fn is_homebrew_install() -> bool {
227    let exe = match std::env::current_exe() {
228        Ok(p) => p,
229        Err(_) => return false,
230    };
231    let real_path = match std::fs::canonicalize(&exe) {
232        Ok(p) => p,
233        Err(_) => exe,
234    };
235    let path_str = real_path.to_string_lossy();
236    path_str.contains("/Cellar/") || path_str.contains("/homebrew/")
237}
238
239/// Manual upgrade command — downloads and installs the latest version.
240pub fn upgrade() {
241    println!("git-worktree-manager v{}", CURRENT_VERSION);
242
243    // Check for Homebrew installation
244    if is_homebrew_install() {
245        println!(
246            "{}",
247            style("Installed via Homebrew. Use brew to upgrade:").yellow()
248        );
249        println!("  brew upgrade git-worktree-manager");
250        return;
251    }
252
253    let latest_version = match fetch_latest_version() {
254        Some(v) => v,
255        None => {
256            println!(
257                "{}",
258                style("Could not check for updates. Check your internet connection.").red()
259            );
260            return;
261        }
262    };
263
264    // Update cache with fresh data
265    let cache = UpdateCache {
266        last_check_ts: now_ts(),
267        latest_version: Some(latest_version.clone()),
268        ..Default::default()
269    };
270    save_cache(&cache);
271
272    if !is_newer(&latest_version, CURRENT_VERSION) {
273        println!("{}", style("Already up to date.").green());
274        return;
275    }
276
277    println!(
278        "New version available: {} → {}",
279        style(format!("v{}", CURRENT_VERSION)).dim(),
280        style(format!("v{}", latest_version)).green().bold()
281    );
282
283    // Non-interactive: just print the info
284    if !std::io::stdin().is_terminal() {
285        println!(
286            "Download from: https://github.com/{}/{}/releases/latest",
287            REPO_OWNER, REPO_NAME
288        );
289        return;
290    }
291
292    // Prompt user
293    let confirm = dialoguer::Confirm::new()
294        .with_prompt("Upgrade now?")
295        .default(true)
296        .interact()
297        .unwrap_or(false);
298
299    if !confirm {
300        println!("Upgrade cancelled.");
301        return;
302    }
303
304    // Use self_update to download and replace
305    println!("Downloading and installing...");
306    match self_update::backends::github::Update::configure()
307        .repo_owner(REPO_OWNER)
308        .repo_name(REPO_NAME)
309        .bin_name("gw")
310        .current_version(CURRENT_VERSION)
311        .target_version_tag(&format!("v{}", latest_version))
312        .show_download_progress(true)
313        .no_confirm(true)
314        .build()
315        .and_then(|updater| updater.update())
316    {
317        Ok(status) => {
318            update_companion_binary();
319            println!(
320                "{}",
321                style(format!("Upgraded to v{}!", status.version()))
322                    .green()
323                    .bold()
324            );
325        }
326        Err(e) => {
327            println!("{}", style(format!("Upgrade failed: {}", e)).red());
328            println!(
329                "Download manually: https://github.com/{}/{}/releases/latest",
330                REPO_OWNER, REPO_NAME
331            );
332        }
333    }
334}
335
336/// Update the `cw` companion binary alongside `gw`.
337fn update_companion_binary() {
338    let current_exe = match std::env::current_exe() {
339        Ok(p) => p,
340        Err(_) => return,
341    };
342    let bin_dir = match current_exe.parent() {
343        Some(d) => d,
344        None => return,
345    };
346
347    let bin_ext = if cfg!(windows) { ".exe" } else { "" };
348    let gw_path = bin_dir.join(format!("gw{}", bin_ext));
349    let cw_path = bin_dir.join(format!("cw{}", bin_ext));
350
351    if cw_path.exists() {
352        let _ = std::fs::copy(&gw_path, &cw_path);
353    }
354}
355
356#[cfg(test)]
357mod tests {
358    use super::*;
359
360    #[test]
361    fn test_is_newer() {
362        assert!(is_newer("0.2.0", "0.1.0"));
363        assert!(is_newer("1.0.0", "0.10.0"));
364        assert!(!is_newer("0.1.0", "0.1.0"));
365        assert!(!is_newer("0.1.0", "0.2.0"));
366    }
367
368    #[test]
369    fn test_is_homebrew_install() {
370        assert!(!is_homebrew_install());
371    }
372
373    #[test]
374    fn test_cache_freshness() {
375        let fresh = UpdateCache {
376            last_check_ts: now_ts(),
377            latest_version: Some("1.0.0".into()),
378            ..Default::default()
379        };
380        assert!(cache_is_fresh(&fresh));
381
382        let stale = UpdateCache {
383            last_check_ts: now_ts() - CHECK_INTERVAL_SECS - 1,
384            latest_version: Some("1.0.0".into()),
385            ..Default::default()
386        };
387        assert!(!cache_is_fresh(&stale));
388
389        let empty = UpdateCache::default();
390        assert!(!cache_is_fresh(&empty));
391    }
392}