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/// Cache for update check results.
17#[derive(Debug, Serialize, Deserialize, Default)]
18struct UpdateCache {
19    last_check: String,
20    latest_version: Option<String>,
21}
22
23fn get_cache_path() -> PathBuf {
24    dirs::cache_dir()
25        .unwrap_or_else(home_dir_or_fallback)
26        .join("git-worktree-manager")
27        .join("update_check.json")
28}
29
30fn load_cache() -> UpdateCache {
31    let path = get_cache_path();
32    if !path.exists() {
33        return UpdateCache::default();
34    }
35    std::fs::read_to_string(&path)
36        .ok()
37        .and_then(|c| serde_json::from_str(&c).ok())
38        .unwrap_or_default()
39}
40
41fn save_cache(cache: &UpdateCache) {
42    let path = get_cache_path();
43    if let Some(parent) = path.parent() {
44        let _ = std::fs::create_dir_all(parent);
45    }
46    if let Ok(content) = serde_json::to_string_pretty(cache) {
47        let _ = std::fs::write(&path, content);
48    }
49}
50
51fn today_str() -> String {
52    crate::session::chrono_now_iso_pub()
53        .split('T')
54        .next()
55        .unwrap_or("")
56        .to_string()
57}
58
59fn should_check() -> bool {
60    let config = crate::config::load_config().unwrap_or_default();
61    if !config.update.auto_check {
62        return false;
63    }
64    let cache = load_cache();
65    cache.last_check != today_str()
66}
67
68/// Check for updates (called on startup, non-blocking).
69pub fn check_for_update_if_needed() {
70    if !should_check() {
71        return;
72    }
73
74    if let Some(latest) = fetch_latest_version() {
75        let cache = UpdateCache {
76            last_check: today_str(),
77            latest_version: Some(latest.clone()),
78        };
79        save_cache(&cache);
80
81        if is_newer(&latest, CURRENT_VERSION) {
82            eprintln!(
83                "\ngit-worktree-manager {} is available (current: {})",
84                latest, CURRENT_VERSION
85            );
86            eprintln!("Run 'gw upgrade' to update.\n");
87        }
88    } else {
89        let cache = UpdateCache {
90            last_check: today_str(),
91            latest_version: None,
92        };
93        save_cache(&cache);
94    }
95}
96
97/// Fetch latest version string from GitHub Releases API (lightweight, no heavy deps).
98fn fetch_latest_version() -> Option<String> {
99    let output = Command::new("curl")
100        .args([
101            "-s",
102            "-H",
103            "Accept: application/vnd.github+json",
104            &format!(
105                "https://api.github.com/repos/{}/{}/releases/latest",
106                REPO_OWNER, REPO_NAME
107            ),
108        ])
109        .output()
110        .ok()?;
111
112    if !output.status.success() {
113        return None;
114    }
115
116    let body = String::from_utf8_lossy(&output.stdout);
117    let json: serde_json::Value = serde_json::from_str(&body).ok()?;
118    let tag = json.get("tag_name")?.as_str()?;
119
120    // Strip tag prefix: "v0.0.3" → "0.0.3"
121    Some(tag.strip_prefix('v').unwrap_or(tag).to_string())
122}
123
124/// Compare version strings (simple semver).
125fn is_newer(latest: &str, current: &str) -> bool {
126    let parse = |s: &str| -> Vec<u32> { s.split('.').filter_map(|p| p.parse().ok()).collect() };
127    let l = parse(latest);
128    let c = parse(current);
129    l > c
130}
131
132/// Detect if the binary was installed via Homebrew.
133fn is_homebrew_install() -> bool {
134    let exe = match std::env::current_exe() {
135        Ok(p) => p,
136        Err(_) => return false,
137    };
138    // Resolve symlinks to get the real path
139    let real_path = match std::fs::canonicalize(&exe) {
140        Ok(p) => p,
141        Err(_) => exe,
142    };
143    let path_str = real_path.to_string_lossy();
144    // Homebrew installs to /opt/homebrew/Cellar/... or /usr/local/Cellar/...
145    path_str.contains("/Cellar/") || path_str.contains("/homebrew/")
146}
147
148/// Manual upgrade command — downloads and installs the latest version.
149pub fn upgrade() {
150    println!("git-worktree-manager v{}", CURRENT_VERSION);
151
152    // Check for Homebrew installation
153    if is_homebrew_install() {
154        println!(
155            "{}",
156            style("Installed via Homebrew. Use brew to upgrade:").yellow()
157        );
158        println!("  brew upgrade git-worktree-manager");
159        return;
160    }
161
162    let latest_version = match fetch_latest_version() {
163        Some(v) => v,
164        None => {
165            println!(
166                "{}",
167                style("Could not check for updates. Check your internet connection.").red()
168            );
169            return;
170        }
171    };
172
173    if !is_newer(&latest_version, CURRENT_VERSION) {
174        println!("{}", style("Already up to date.").green());
175        return;
176    }
177
178    println!(
179        "New version available: {} → {}",
180        style(format!("v{}", CURRENT_VERSION)).dim(),
181        style(format!("v{}", latest_version)).green().bold()
182    );
183
184    // Non-interactive: just print the info
185    if !std::io::stdin().is_terminal() {
186        println!(
187            "Download from: https://github.com/{}/{}/releases/latest",
188            REPO_OWNER, REPO_NAME
189        );
190        return;
191    }
192
193    // Prompt user
194    let confirm = dialoguer::Confirm::new()
195        .with_prompt("Upgrade now?")
196        .default(true)
197        .interact()
198        .unwrap_or(false);
199
200    if !confirm {
201        println!("Upgrade cancelled.");
202        return;
203    }
204
205    // Use self_update to download and replace
206    println!("Downloading and installing...");
207    match self_update::backends::github::Update::configure()
208        .repo_owner(REPO_OWNER)
209        .repo_name(REPO_NAME)
210        .bin_name("gw")
211        .current_version(CURRENT_VERSION)
212        .target_version_tag(&format!("v{}", latest_version))
213        .show_download_progress(true)
214        .no_confirm(true) // We already confirmed above
215        .build()
216        .and_then(|updater| updater.update())
217    {
218        Ok(status) => {
219            // Also update the cw binary if it exists alongside gw
220            update_companion_binary();
221            println!(
222                "{}",
223                style(format!("Upgraded to v{}!", status.version()))
224                    .green()
225                    .bold()
226            );
227        }
228        Err(e) => {
229            println!("{}", style(format!("Upgrade failed: {}", e)).red());
230            println!(
231                "Download manually: https://github.com/{}/{}/releases/latest",
232                REPO_OWNER, REPO_NAME
233            );
234        }
235    }
236}
237
238/// Update the `cw` companion binary alongside `gw`.
239///
240/// self_update only replaces the running binary (gw). Since cw is the same
241/// binary, we copy the newly installed gw to cw.
242fn update_companion_binary() {
243    let current_exe = match std::env::current_exe() {
244        Ok(p) => p,
245        Err(_) => return,
246    };
247    let bin_dir = match current_exe.parent() {
248        Some(d) => d,
249        None => return,
250    };
251
252    let bin_ext = if cfg!(windows) { ".exe" } else { "" };
253    let gw_path = bin_dir.join(format!("gw{}", bin_ext));
254    let cw_path = bin_dir.join(format!("cw{}", bin_ext));
255
256    if cw_path.exists() {
257        let _ = std::fs::copy(&gw_path, &cw_path);
258    }
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264
265    #[test]
266    fn test_is_newer() {
267        assert!(is_newer("0.2.0", "0.1.0"));
268        assert!(is_newer("1.0.0", "0.10.0"));
269        assert!(!is_newer("0.1.0", "0.1.0"));
270        assert!(!is_newer("0.1.0", "0.2.0"));
271    }
272
273    #[test]
274    fn test_is_homebrew_install() {
275        // Current binary is not from Homebrew in test context
276        assert!(!is_homebrew_install());
277    }
278}