1use std::io::{IsTerminal, Read};
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
16const CHECK_INTERVAL_SECS: u64 = 6 * 60 * 60;
18
19#[derive(Debug, Serialize, Deserialize, Default)]
21struct UpdateCache {
22 #[serde(default)]
24 last_check_ts: u64,
25 #[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
71pub 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 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 if !cache_is_fresh(&cache) {
98 spawn_background_check();
99 }
100}
101
102fn spawn_background_check() {
104 let exe = match std::env::current_exe() {
105 Ok(p) => p,
106 Err(_) => return,
107 };
108 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
117pub 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 let cache = UpdateCache {
129 last_check_ts: now_ts(),
130 latest_version: load_cache().latest_version, ..Default::default()
132 };
133 save_cache(&cache);
134 }
135}
136
137fn gh_auth_token() -> Option<String> {
140 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 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
168fn 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
180fn 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 Some(tag.strip_prefix('v').unwrap_or(tag).to_string())
215}
216
217fn 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
225fn 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
239fn current_target() -> &'static str {
241 #[cfg(all(target_arch = "x86_64", target_os = "macos"))]
242 {
243 "x86_64-apple-darwin"
244 }
245 #[cfg(all(target_arch = "aarch64", target_os = "macos"))]
246 {
247 "aarch64-apple-darwin"
248 }
249 #[cfg(all(target_arch = "x86_64", target_os = "windows"))]
250 {
251 "x86_64-pc-windows-msvc"
252 }
253 #[cfg(all(target_arch = "x86_64", target_os = "linux"))]
254 {
255 "x86_64-unknown-linux-musl"
256 }
257 #[cfg(all(target_arch = "aarch64", target_os = "linux"))]
258 {
259 "aarch64-unknown-linux-musl"
260 }
261}
262
263fn archive_ext() -> &'static str {
265 if cfg!(windows) {
266 "zip"
267 } else {
268 "tar.gz"
269 }
270}
271
272fn download_and_extract(version: &str) -> Result<PathBuf, String> {
275 let target = current_target();
276 let asset_name = format!("gw-{}.{}", target, archive_ext());
277 let url = format!(
278 "https://github.com/{}/{}/releases/download/v{}/{}",
279 REPO_OWNER, REPO_NAME, version, asset_name
280 );
281
282 let client = reqwest::blocking::Client::builder()
284 .user_agent(format!("gw/{}", CURRENT_VERSION))
285 .build()
286 .map_err(|e| format!("Failed to create HTTP client: {}", e))?;
287
288 let response = client
289 .get(&url)
290 .send()
291 .map_err(|e| format!("Download failed: {}", e))?;
292
293 if !response.status().is_success() {
294 return Err(format!(
295 "Download failed: HTTP {} for {}",
296 response.status(),
297 asset_name
298 ));
299 }
300
301 let total_size = response.content_length().unwrap_or(0);
302
303 let pb = if total_size > 0 {
305 let pb = indicatif::ProgressBar::new(total_size);
306 pb.set_style(
307 indicatif::ProgressStyle::default_bar()
308 .template("{bar:40.cyan/blue} {bytes}/{total_bytes} ({eta})")
309 .unwrap_or_else(|_| indicatif::ProgressStyle::default_bar())
310 .progress_chars("█▓░"),
311 );
312 pb
313 } else {
314 let pb = indicatif::ProgressBar::new_spinner();
315 pb.set_style(
316 indicatif::ProgressStyle::default_spinner()
317 .template("{spinner} {bytes} downloaded")
318 .unwrap_or_else(|_| indicatif::ProgressStyle::default_spinner()),
319 );
320 pb
321 };
322
323 let mut downloaded: Vec<u8> = Vec::with_capacity(total_size as usize);
325 let mut reader = response;
326 let mut buf = [0u8; 8192];
327 loop {
328 let n = reader
329 .read(&mut buf)
330 .map_err(|e| format!("Read error: {}", e))?;
331 if n == 0 {
332 break;
333 }
334 downloaded.extend_from_slice(&buf[..n]);
335 pb.set_position(downloaded.len() as u64);
336 }
337 pb.finish_and_clear();
338
339 let tmp_dir = tempfile::tempdir().map_err(|e| format!("Failed to create temp dir: {}", e))?;
341 let bin_name = if cfg!(windows) { "gw.exe" } else { "gw" };
342
343 if cfg!(windows) {
344 extract_zip(&downloaded, tmp_dir.path(), bin_name)?;
345 } else {
346 extract_tar_gz(&downloaded, tmp_dir.path(), bin_name)?;
347 }
348
349 let extracted_bin = tmp_dir.path().join(bin_name);
350 if !extracted_bin.exists() {
351 return Err(format!(
352 "Binary '{}' not found in archive. Contents may have unexpected layout.",
353 bin_name
354 ));
355 }
356
357 let persistent_path = std::env::temp_dir().join(format!("gw-update-{}", version));
359 std::fs::copy(&extracted_bin, &persistent_path)
360 .map_err(|e| format!("Failed to copy binary: {}", e))?;
361
362 #[cfg(unix)]
363 {
364 use std::os::unix::fs::PermissionsExt;
365 let _ = std::fs::set_permissions(&persistent_path, std::fs::Permissions::from_mode(0o755));
366 }
367
368 Ok(persistent_path)
369}
370
371#[cfg(not(windows))]
373fn extract_tar_gz(data: &[u8], dest: &std::path::Path, bin_name: &str) -> Result<(), String> {
374 let gz = flate2::read::GzDecoder::new(data);
375 let mut archive = tar::Archive::new(gz);
376
377 for entry in archive.entries().map_err(|e| format!("tar error: {}", e))? {
378 let mut entry = entry.map_err(|e| format!("tar entry error: {}", e))?;
379 let path = entry
380 .path()
381 .map_err(|e| format!("tar path error: {}", e))?
382 .into_owned();
383
384 if path.file_name().and_then(|n| n.to_str()) == Some(bin_name) {
386 let out_path = dest.join(bin_name);
387 let mut out_file = std::fs::File::create(&out_path)
388 .map_err(|e| format!("Failed to create file: {}", e))?;
389 std::io::copy(&mut entry, &mut out_file)
390 .map_err(|e| format!("Failed to write file: {}", e))?;
391 return Ok(());
392 }
393 }
394 Err(format!("'{}' not found in tar.gz archive", bin_name))
395}
396
397#[cfg(windows)]
399fn extract_zip(data: &[u8], dest: &std::path::Path, bin_name: &str) -> Result<(), String> {
400 let cursor = std::io::Cursor::new(data);
401 let mut archive = zip::ZipArchive::new(cursor).map_err(|e| format!("zip error: {}", e))?;
402
403 for i in 0..archive.len() {
404 let mut file = archive
405 .by_index(i)
406 .map_err(|e| format!("zip entry error: {}", e))?;
407 let name = file.name().to_string();
408
409 if name.ends_with(bin_name)
410 || std::path::Path::new(&name)
411 .file_name()
412 .and_then(|n| n.to_str())
413 == Some(bin_name)
414 {
415 let out_path = dest.join(bin_name);
416 let mut out_file = std::fs::File::create(&out_path)
417 .map_err(|e| format!("Failed to create file: {}", e))?;
418 std::io::copy(&mut file, &mut out_file)
419 .map_err(|e| format!("Failed to write file: {}", e))?;
420 return Ok(());
421 }
422 }
423 Err(format!("'{}' not found in zip archive", bin_name))
424}
425
426#[cfg(windows)]
429fn extract_tar_gz(_data: &[u8], _dest: &std::path::Path, _bin_name: &str) -> Result<(), String> {
430 Err("tar.gz extraction not supported on Windows".to_string())
431}
432
433#[cfg(not(windows))]
434fn extract_zip(_data: &[u8], _dest: &std::path::Path, _bin_name: &str) -> Result<(), String> {
435 Err("zip extraction not used on Unix".to_string())
436}
437
438pub fn upgrade() {
440 println!("git-worktree-manager v{}", CURRENT_VERSION);
441
442 if is_homebrew_install() {
444 println!(
445 "{}",
446 style("Installed via Homebrew. Use brew to upgrade:").yellow()
447 );
448 println!(" brew upgrade git-worktree-manager");
449 return;
450 }
451
452 let latest_version = match fetch_latest_version() {
453 Some(v) => v,
454 None => {
455 println!(
456 "{}",
457 style("Could not check for updates. Check your internet connection.").red()
458 );
459 return;
460 }
461 };
462
463 let cache = UpdateCache {
465 last_check_ts: now_ts(),
466 latest_version: Some(latest_version.clone()),
467 ..Default::default()
468 };
469 save_cache(&cache);
470
471 if !is_newer(&latest_version, CURRENT_VERSION) {
472 println!("{}", style("Already up to date.").green());
473 return;
474 }
475
476 println!(
477 "New version available: {} → {}",
478 style(format!("v{}", CURRENT_VERSION)).dim(),
479 style(format!("v{}", latest_version)).green().bold()
480 );
481
482 if !std::io::stdin().is_terminal() {
484 println!(
485 "Download from: https://github.com/{}/{}/releases/latest",
486 REPO_OWNER, REPO_NAME
487 );
488 return;
489 }
490
491 let confirm = dialoguer::Confirm::new()
493 .with_prompt("Upgrade now?")
494 .default(true)
495 .interact()
496 .unwrap_or(false);
497
498 if !confirm {
499 println!("Upgrade cancelled.");
500 return;
501 }
502
503 println!("Downloading v{}...", latest_version);
504
505 match download_and_extract(&latest_version) {
506 Ok(new_binary) => {
507 update_companion_from(&new_binary);
511
512 if let Err(e) = self_replace::self_replace(&new_binary) {
514 println!(
515 "{}",
516 style(format!("Failed to replace binary: {}", e)).red()
517 );
518 println!(
519 "Download manually: https://github.com/{}/{}/releases/latest",
520 REPO_OWNER, REPO_NAME
521 );
522 let _ = std::fs::remove_file(&new_binary);
523 return;
524 }
525
526 let _ = std::fs::remove_file(&new_binary);
528
529 println!(
530 "{}",
531 style(format!("Upgraded to v{}!", latest_version))
532 .green()
533 .bold()
534 );
535 }
536 Err(e) => {
537 println!("{}", style(format!("Upgrade failed: {}", e)).red());
538 println!(
539 "Download manually: https://github.com/{}/{}/releases/latest",
540 REPO_OWNER, REPO_NAME
541 );
542 }
543 }
544}
545
546fn update_companion_from(new_binary: &std::path::Path) {
552 let current_exe = match std::env::current_exe() {
553 Ok(p) => p,
554 Err(_) => return,
555 };
556 let bin_dir = match current_exe.parent() {
557 Some(d) => d,
558 None => return,
559 };
560
561 let bin_ext = if cfg!(windows) { ".exe" } else { "" };
562 let exe_name = current_exe
563 .file_stem()
564 .and_then(|n| n.to_str())
565 .unwrap_or("gw");
566
567 let companion_name = if exe_name == "cw" { "gw" } else { "cw" };
569 let companion_path = bin_dir.join(format!("{}{}", companion_name, bin_ext));
570
571 if companion_path.exists() {
572 let _ = std::fs::copy(new_binary, &companion_path);
573 }
574}
575
576#[cfg(test)]
577mod tests {
578 use super::*;
579
580 #[test]
581 fn test_is_newer() {
582 assert!(is_newer("0.2.0", "0.1.0"));
583 assert!(is_newer("1.0.0", "0.10.0"));
584 assert!(!is_newer("0.1.0", "0.1.0"));
585 assert!(!is_newer("0.1.0", "0.2.0"));
586 }
587
588 #[test]
589 fn test_is_homebrew_install() {
590 assert!(!is_homebrew_install());
591 }
592
593 #[test]
594 fn test_cache_freshness() {
595 let fresh = UpdateCache {
596 last_check_ts: now_ts(),
597 latest_version: Some("1.0.0".into()),
598 ..Default::default()
599 };
600 assert!(cache_is_fresh(&fresh));
601
602 let stale = UpdateCache {
603 last_check_ts: now_ts() - CHECK_INTERVAL_SECS - 1,
604 latest_version: Some("1.0.0".into()),
605 ..Default::default()
606 };
607 assert!(!cache_is_fresh(&stale));
608
609 let empty = UpdateCache::default();
610 assert!(!cache_is_fresh(&empty));
611 }
612
613 #[test]
614 fn test_current_target() {
615 let target = current_target();
616 assert!(!target.is_empty());
617 let valid = [
619 "x86_64-apple-darwin",
620 "aarch64-apple-darwin",
621 "x86_64-pc-windows-msvc",
622 "x86_64-unknown-linux-musl",
623 "aarch64-unknown-linux-musl",
624 ];
625 assert!(valid.contains(&target));
626 }
627
628 #[test]
629 fn test_archive_ext() {
630 let ext = archive_ext();
631 if cfg!(windows) {
632 assert_eq!(ext, "zip");
633 } else {
634 assert_eq!(ext, "tar.gz");
635 }
636 }
637}