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    if !which_exists("curl") {
184        return None;
185    }
186    let url = format!(
187        "https://api.github.com/repos/{}/{}/releases/latest",
188        REPO_OWNER, REPO_NAME
189    );
190
191    let mut args = vec![
192        "-s".to_string(),
193        "--fail".to_string(),
194        "--max-time".to_string(),
195        "10".to_string(),
196        "-H".to_string(),
197        format!("User-Agent: gw/{}", CURRENT_VERSION),
198        "-H".to_string(),
199        "Accept: application/vnd.github+json".to_string(),
200    ];
201
202    if let Some(token) = gh_auth_token() {
203        args.push("-H".to_string());
204        args.push(format!("Authorization: Bearer {}", token));
205    }
206
207    args.push(url);
208
209    let output = Command::new("curl").args(&args).output().ok()?;
210
211    if !output.status.success() {
212        return None;
213    }
214
215    let body = String::from_utf8_lossy(&output.stdout);
216    let json: serde_json::Value = serde_json::from_str(&body).ok()?;
217    let tag = json.get("tag_name")?.as_str()?;
218
219    // Strip tag prefix: "v0.0.3" → "0.0.3"
220    Some(tag.strip_prefix('v').unwrap_or(tag).to_string())
221}
222
223/// Compare version strings (simple semver).
224fn is_newer(latest: &str, current: &str) -> bool {
225    let parse = |s: &str| -> Vec<u32> { s.split('.').filter_map(|p| p.parse().ok()).collect() };
226    let l = parse(latest);
227    let c = parse(current);
228    l > c
229}
230
231/// Detect if the binary was installed via Homebrew.
232fn is_homebrew_install() -> bool {
233    let exe = match std::env::current_exe() {
234        Ok(p) => p,
235        Err(_) => return false,
236    };
237    let real_path = match std::fs::canonicalize(&exe) {
238        Ok(p) => p,
239        Err(_) => exe,
240    };
241    let path_str = real_path.to_string_lossy();
242    path_str.contains("/Cellar/") || path_str.contains("/homebrew/")
243}
244
245/// Determine the current platform target triple.
246fn current_target() -> &'static str {
247    #[cfg(all(target_arch = "x86_64", target_os = "macos"))]
248    {
249        "x86_64-apple-darwin"
250    }
251    #[cfg(all(target_arch = "aarch64", target_os = "macos"))]
252    {
253        "aarch64-apple-darwin"
254    }
255    #[cfg(all(target_arch = "x86_64", target_os = "windows"))]
256    {
257        "x86_64-pc-windows-msvc"
258    }
259    #[cfg(all(target_arch = "x86_64", target_os = "linux"))]
260    {
261        "x86_64-unknown-linux-musl"
262    }
263    #[cfg(all(target_arch = "aarch64", target_os = "linux"))]
264    {
265        "aarch64-unknown-linux-musl"
266    }
267}
268
269/// Archive extension for the current platform.
270fn archive_ext() -> &'static str {
271    if cfg!(windows) {
272        "zip"
273    } else {
274        "tar.gz"
275    }
276}
277
278/// Download the release asset and extract the binary to a temp file.
279/// Returns the path to the extracted binary.
280fn download_and_extract(version: &str) -> Result<PathBuf, String> {
281    if !which_exists("curl") {
282        return Err("curl is required for gw upgrade but was not found in PATH".to_string());
283    }
284    let target = current_target();
285    let asset_name = format!("gw-{}.{}", target, archive_ext());
286    let url = format!(
287        "https://github.com/{}/{}/releases/download/v{}/{}",
288        REPO_OWNER, REPO_NAME, version, asset_name
289    );
290
291    // Download archive using curl (uses system TLS, works with MDM/proxy certs)
292    let tmp_dir = tempfile::tempdir().map_err(|e| format!("Failed to create temp dir: {}", e))?;
293    let archive_path = tmp_dir.path().join(&asset_name);
294
295    let user_agent = format!("User-Agent: gw/{}", CURRENT_VERSION);
296    let archive_path_str = archive_path.to_string_lossy().to_string();
297    let token = gh_auth_token();
298    let auth_header = token
299        .as_ref()
300        .map(|t| format!("Authorization: Bearer {}", t));
301
302    let progress_flag = if std::io::stderr().is_terminal() {
303        "--progress-bar"
304    } else {
305        "-sS" // silent + show errors (no noisy escape sequences in CI/pipes)
306    };
307
308    let mut args = vec![
309        "-L",     // follow redirects (GitHub → CDN)
310        "--fail", // exit non-zero on HTTP errors
311        progress_flag,
312        "--max-time",
313        "300",
314        "-H",
315        &user_agent,
316        "-o",
317        &archive_path_str,
318    ];
319
320    if let Some(ref h) = auth_header {
321        args.push("-H");
322        args.push(h);
323    }
324
325    args.push(&url);
326
327    let status = Command::new("curl")
328        .args(&args)
329        .stdin(std::process::Stdio::null())
330        .status()
331        .map_err(|e| format!("Failed to run curl: {}", e))?;
332
333    if !status.success() {
334        return Err(format!(
335            "Download failed: curl exited with {} for {}",
336            status
337                .code()
338                .map_or("signal".to_string(), |c| c.to_string()),
339            asset_name
340        ));
341    }
342
343    let downloaded = std::fs::read(&archive_path)
344        .map_err(|e| format!("Failed to read downloaded archive: {}", e))?;
345    let bin_name = if cfg!(windows) { "gw.exe" } else { "gw" };
346
347    if cfg!(windows) {
348        extract_zip(&downloaded, tmp_dir.path(), bin_name)?;
349    } else {
350        extract_tar_gz(&downloaded, tmp_dir.path(), bin_name)?;
351    }
352
353    let extracted_bin = tmp_dir.path().join(bin_name);
354    if !extracted_bin.exists() {
355        return Err(format!(
356            "Binary '{}' not found in archive. Contents may have unexpected layout.",
357            bin_name
358        ));
359    }
360
361    // Move to a persistent temp file (tempdir would delete on drop)
362    let persistent_path = std::env::temp_dir().join(format!("gw-update-{}", version));
363    std::fs::copy(&extracted_bin, &persistent_path)
364        .map_err(|e| format!("Failed to copy binary: {}", e))?;
365
366    #[cfg(unix)]
367    {
368        use std::os::unix::fs::PermissionsExt;
369        let _ = std::fs::set_permissions(&persistent_path, std::fs::Permissions::from_mode(0o755));
370    }
371
372    Ok(persistent_path)
373}
374
375/// Extract a tar.gz archive and find the binary.
376#[cfg(not(windows))]
377fn extract_tar_gz(data: &[u8], dest: &std::path::Path, bin_name: &str) -> Result<(), String> {
378    let gz = flate2::read::GzDecoder::new(data);
379    let mut archive = tar::Archive::new(gz);
380
381    for entry in archive.entries().map_err(|e| format!("tar error: {}", e))? {
382        let mut entry = entry.map_err(|e| format!("tar entry error: {}", e))?;
383        let path = entry
384            .path()
385            .map_err(|e| format!("tar path error: {}", e))?
386            .into_owned();
387
388        // Extract only the binary (may be at root or in a subdirectory)
389        if path.file_name().and_then(|n| n.to_str()) == Some(bin_name) {
390            let out_path = dest.join(bin_name);
391            let mut out_file = std::fs::File::create(&out_path)
392                .map_err(|e| format!("Failed to create file: {}", e))?;
393            std::io::copy(&mut entry, &mut out_file)
394                .map_err(|e| format!("Failed to write file: {}", e))?;
395            return Ok(());
396        }
397    }
398    Err(format!("'{}' not found in tar.gz archive", bin_name))
399}
400
401/// Extract a zip archive and find the binary.
402#[cfg(windows)]
403fn extract_zip(data: &[u8], dest: &std::path::Path, bin_name: &str) -> Result<(), String> {
404    let cursor = std::io::Cursor::new(data);
405    let mut archive = zip::ZipArchive::new(cursor).map_err(|e| format!("zip error: {}", e))?;
406
407    for i in 0..archive.len() {
408        let mut file = archive
409            .by_index(i)
410            .map_err(|e| format!("zip entry error: {}", e))?;
411        let name = file.name().to_string();
412
413        if name.ends_with(bin_name)
414            || std::path::Path::new(&name)
415                .file_name()
416                .and_then(|n| n.to_str())
417                == Some(bin_name)
418        {
419            let out_path = dest.join(bin_name);
420            let mut out_file = std::fs::File::create(&out_path)
421                .map_err(|e| format!("Failed to create file: {}", e))?;
422            std::io::copy(&mut file, &mut out_file)
423                .map_err(|e| format!("Failed to write file: {}", e))?;
424            return Ok(());
425        }
426    }
427    Err(format!("'{}' not found in zip archive", bin_name))
428}
429
430// Provide stub functions for platforms where the other archive format isn't used,
431// so the code compiles on all targets (they're never called).
432#[cfg(windows)]
433fn extract_tar_gz(_data: &[u8], _dest: &std::path::Path, _bin_name: &str) -> Result<(), String> {
434    Err("tar.gz extraction not supported on Windows".to_string())
435}
436
437#[cfg(not(windows))]
438fn extract_zip(_data: &[u8], _dest: &std::path::Path, _bin_name: &str) -> Result<(), String> {
439    Err("zip extraction not used on Unix".to_string())
440}
441
442/// Manual upgrade command — downloads and installs the latest version.
443pub fn upgrade() {
444    println!("git-worktree-manager v{}", CURRENT_VERSION);
445
446    // Check for Homebrew installation
447    if is_homebrew_install() {
448        println!(
449            "{}",
450            style("Installed via Homebrew. Use brew to upgrade:").yellow()
451        );
452        println!("  brew upgrade git-worktree-manager");
453        return;
454    }
455
456    let latest_version = match fetch_latest_version() {
457        Some(v) => v,
458        None => {
459            let msg = if which_exists("curl") {
460                "Could not check for updates. Check your internet connection."
461            } else {
462                "Could not check for updates. curl is required but was not found in PATH."
463            };
464            println!("{}", style(msg).red());
465            return;
466        }
467    };
468
469    // Update cache with fresh data
470    let cache = UpdateCache {
471        last_check_ts: now_ts(),
472        latest_version: Some(latest_version.clone()),
473        ..Default::default()
474    };
475    save_cache(&cache);
476
477    if !is_newer(&latest_version, CURRENT_VERSION) {
478        println!("{}", style("Already up to date.").green());
479        return;
480    }
481
482    println!(
483        "New version available: {} → {}",
484        style(format!("v{}", CURRENT_VERSION)).dim(),
485        style(format!("v{}", latest_version)).green().bold()
486    );
487
488    // Non-interactive: just print the info
489    if !std::io::stdin().is_terminal() {
490        println!(
491            "Download from: https://github.com/{}/{}/releases/latest",
492            REPO_OWNER, REPO_NAME
493        );
494        return;
495    }
496
497    // Prompt user
498    let confirm = dialoguer::Confirm::new()
499        .with_prompt("Upgrade now?")
500        .default(true)
501        .interact()
502        .unwrap_or(false);
503
504    if !confirm {
505        println!("Upgrade cancelled.");
506        return;
507    }
508
509    println!("Downloading v{}...", latest_version);
510
511    match download_and_extract(&latest_version) {
512        Ok(new_binary) => {
513            // Update companion (cw↔gw) BEFORE self_replace, using the downloaded
514            // binary directly. This ensures both binaries get the new version even
515            // when upgrading from old versions that had a broken companion update.
516            update_companion_from(&new_binary);
517
518            // Replace the running binary
519            if let Err(e) = self_replace::self_replace(&new_binary) {
520                println!(
521                    "{}",
522                    style(format!("Failed to replace binary: {}", e)).red()
523                );
524                println!(
525                    "Download manually: https://github.com/{}/{}/releases/latest",
526                    REPO_OWNER, REPO_NAME
527                );
528                let _ = std::fs::remove_file(&new_binary);
529                return;
530            }
531
532            // Clean up temp file
533            let _ = std::fs::remove_file(&new_binary);
534
535            println!(
536                "{}",
537                style(format!("Upgraded to v{}!", latest_version))
538                    .green()
539                    .bold()
540            );
541        }
542        Err(e) => {
543            println!("{}", style(format!("Upgrade failed: {}", e)).red());
544            println!(
545                "Download manually: https://github.com/{}/{}/releases/latest",
546                REPO_OWNER, REPO_NAME
547            );
548        }
549    }
550}
551
552/// Update the companion binary (`cw` or `gw`) from the downloaded new binary.
553///
554/// Copies `new_binary` directly to the companion path, so both `gw` and `cw`
555/// get the new version regardless of which one is currently running.
556/// Called BEFORE `self_replace` to avoid depending on already-replaced binary state.
557fn update_companion_from(new_binary: &std::path::Path) {
558    let current_exe = match std::env::current_exe() {
559        Ok(p) => p,
560        Err(_) => return,
561    };
562    let bin_dir = match current_exe.parent() {
563        Some(d) => d,
564        None => return,
565    };
566
567    let bin_ext = if cfg!(windows) { ".exe" } else { "" };
568    let exe_name = current_exe
569        .file_stem()
570        .and_then(|n| n.to_str())
571        .unwrap_or("gw");
572
573    // Determine companion: gw↔cw
574    let companion_name = if exe_name == "cw" { "gw" } else { "cw" };
575    let companion_path = bin_dir.join(format!("{}{}", companion_name, bin_ext));
576
577    if companion_path.exists() {
578        let _ = std::fs::copy(new_binary, &companion_path);
579    }
580}
581
582#[cfg(test)]
583mod tests {
584    use super::*;
585
586    #[test]
587    fn test_is_newer() {
588        assert!(is_newer("0.2.0", "0.1.0"));
589        assert!(is_newer("1.0.0", "0.10.0"));
590        assert!(!is_newer("0.1.0", "0.1.0"));
591        assert!(!is_newer("0.1.0", "0.2.0"));
592    }
593
594    #[test]
595    fn test_is_homebrew_install() {
596        assert!(!is_homebrew_install());
597    }
598
599    #[test]
600    fn test_cache_freshness() {
601        let fresh = UpdateCache {
602            last_check_ts: now_ts(),
603            latest_version: Some("1.0.0".into()),
604            ..Default::default()
605        };
606        assert!(cache_is_fresh(&fresh));
607
608        let stale = UpdateCache {
609            last_check_ts: now_ts() - CHECK_INTERVAL_SECS - 1,
610            latest_version: Some("1.0.0".into()),
611            ..Default::default()
612        };
613        assert!(!cache_is_fresh(&stale));
614
615        let empty = UpdateCache::default();
616        assert!(!cache_is_fresh(&empty));
617    }
618
619    #[test]
620    fn test_current_target() {
621        let target = current_target();
622        assert!(!target.is_empty());
623        // Should match one of our supported targets
624        let valid = [
625            "x86_64-apple-darwin",
626            "aarch64-apple-darwin",
627            "x86_64-pc-windows-msvc",
628            "x86_64-unknown-linux-musl",
629            "aarch64-unknown-linux-musl",
630        ];
631        assert!(valid.contains(&target));
632    }
633
634    #[test]
635    fn test_archive_ext() {
636        let ext = archive_ext();
637        if cfg!(windows) {
638            assert_eq!(ext, "zip");
639        } else {
640            assert_eq!(ext, "tar.gz");
641        }
642    }
643}