1use 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
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 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 Some(tag.strip_prefix('v').unwrap_or(tag).to_string())
221}
222
223fn 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
231fn 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
245fn 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 #[cfg(not(any(
268 all(target_arch = "x86_64", target_os = "macos"),
269 all(target_arch = "aarch64", target_os = "macos"),
270 all(target_arch = "x86_64", target_os = "windows"),
271 all(target_arch = "x86_64", target_os = "linux"),
272 all(target_arch = "aarch64", target_os = "linux"),
273 )))]
274 compile_error!(
275 "unsupported target: gw self-update is not available for this platform/architecture combination"
276 );
277}
278
279fn archive_ext() -> &'static str {
281 if cfg!(windows) {
282 "zip"
283 } else {
284 "tar.gz"
285 }
286}
287
288fn download_and_extract(version: &str) -> Result<PathBuf, String> {
291 if !which_exists("curl") {
292 return Err("curl is required for gw upgrade but was not found in PATH".to_string());
293 }
294 let target = current_target();
295 let asset_name = format!("gw-{}.{}", target, archive_ext());
296 let url = format!(
297 "https://github.com/{}/{}/releases/download/v{}/{}",
298 REPO_OWNER, REPO_NAME, version, asset_name
299 );
300
301 let tmp_dir = tempfile::tempdir().map_err(|e| format!("Failed to create temp dir: {}", e))?;
303 let archive_path = tmp_dir.path().join(&asset_name);
304
305 let user_agent = format!("User-Agent: gw/{}", CURRENT_VERSION);
306 let archive_path_str = archive_path.to_string_lossy().to_string();
307 let token = gh_auth_token();
308 let auth_header = token
309 .as_ref()
310 .map(|t| format!("Authorization: Bearer {}", t));
311
312 let progress_flag = if std::io::stderr().is_terminal() {
313 "--progress-bar"
314 } else {
315 "-sS" };
317
318 let mut args = vec![
319 "-L", "--fail", progress_flag,
322 "--max-time",
323 "300",
324 "-H",
325 &user_agent,
326 "-o",
327 &archive_path_str,
328 ];
329
330 if let Some(ref h) = auth_header {
331 args.push("-H");
332 args.push(h);
333 }
334
335 args.push(&url);
336
337 let status = Command::new("curl")
338 .args(&args)
339 .stdin(std::process::Stdio::null())
340 .status()
341 .map_err(|e| format!("Failed to run curl: {}", e))?;
342
343 if !status.success() {
344 return Err(format!(
345 "Download failed: curl exited with {} for {}",
346 status
347 .code()
348 .map_or("signal".to_string(), |c| c.to_string()),
349 asset_name
350 ));
351 }
352
353 let downloaded = std::fs::read(&archive_path)
354 .map_err(|e| format!("Failed to read downloaded archive: {}", e))?;
355 let bin_name = if cfg!(windows) { "gw.exe" } else { "gw" };
356
357 if cfg!(windows) {
358 extract_zip(&downloaded, tmp_dir.path(), bin_name)?;
359 } else {
360 extract_tar_gz(&downloaded, tmp_dir.path(), bin_name)?;
361 }
362
363 let extracted_bin = tmp_dir.path().join(bin_name);
364 if !extracted_bin.exists() {
365 return Err(format!(
366 "Binary '{}' not found in archive. Contents may have unexpected layout.",
367 bin_name
368 ));
369 }
370
371 let persistent_path =
374 std::env::temp_dir().join(format!("gw-update-{}-{}", version, std::process::id()));
375 std::fs::copy(&extracted_bin, &persistent_path)
376 .map_err(|e| format!("Failed to copy binary: {}", e))?;
377
378 #[cfg(unix)]
379 {
380 use std::os::unix::fs::PermissionsExt;
381 let _ = std::fs::set_permissions(&persistent_path, std::fs::Permissions::from_mode(0o755));
382 }
383
384 Ok(persistent_path)
385}
386
387#[cfg(not(windows))]
389fn extract_tar_gz(data: &[u8], dest: &std::path::Path, bin_name: &str) -> Result<(), String> {
390 let gz = flate2::read::GzDecoder::new(data);
391 let mut archive = tar::Archive::new(gz);
392
393 for entry in archive.entries().map_err(|e| format!("tar error: {}", e))? {
394 let mut entry = entry.map_err(|e| format!("tar entry error: {}", e))?;
395 let path = entry
396 .path()
397 .map_err(|e| format!("tar path error: {}", e))?
398 .into_owned();
399
400 if path.file_name().and_then(|n| n.to_str()) == Some(bin_name) {
402 let out_path = dest.join(bin_name);
403 let mut out_file = std::fs::File::create(&out_path)
404 .map_err(|e| format!("Failed to create file: {}", e))?;
405 std::io::copy(&mut entry, &mut out_file)
406 .map_err(|e| format!("Failed to write file: {}", e))?;
407 return Ok(());
408 }
409 }
410 Err(format!("'{}' not found in tar.gz archive", bin_name))
411}
412
413#[cfg(windows)]
415fn extract_zip(data: &[u8], dest: &std::path::Path, bin_name: &str) -> Result<(), String> {
416 let cursor = std::io::Cursor::new(data);
417 let mut archive = zip::ZipArchive::new(cursor).map_err(|e| format!("zip error: {}", e))?;
418
419 for i in 0..archive.len() {
420 let mut file = archive
421 .by_index(i)
422 .map_err(|e| format!("zip entry error: {}", e))?;
423 let name = file.name().to_string();
424
425 if name.ends_with(bin_name)
426 || std::path::Path::new(&name)
427 .file_name()
428 .and_then(|n| n.to_str())
429 == Some(bin_name)
430 {
431 let out_path = dest.join(bin_name);
432 let mut out_file = std::fs::File::create(&out_path)
433 .map_err(|e| format!("Failed to create file: {}", e))?;
434 std::io::copy(&mut file, &mut out_file)
435 .map_err(|e| format!("Failed to write file: {}", e))?;
436 return Ok(());
437 }
438 }
439 Err(format!("'{}' not found in zip archive", bin_name))
440}
441
442#[cfg(windows)]
445fn extract_tar_gz(_data: &[u8], _dest: &std::path::Path, _bin_name: &str) -> Result<(), String> {
446 Err("tar.gz extraction not supported on Windows".to_string())
447}
448
449#[cfg(not(windows))]
450fn extract_zip(_data: &[u8], _dest: &std::path::Path, _bin_name: &str) -> Result<(), String> {
451 Err("zip extraction not used on Unix".to_string())
452}
453
454pub fn upgrade(yes: bool) {
456 println!("git-worktree-manager v{}", CURRENT_VERSION);
457
458 if is_homebrew_install() {
460 println!(
461 "{}",
462 style("Installed via Homebrew. Use brew to upgrade:").yellow()
463 );
464 println!(" brew upgrade git-worktree-manager");
465 return;
466 }
467
468 let latest_version = match fetch_latest_version() {
469 Some(v) => v,
470 None => {
471 let msg = if which_exists("curl") {
472 "Could not check for updates. Check your internet connection."
473 } else {
474 "Could not check for updates. curl is required but was not found in PATH."
475 };
476 println!("{}", style(msg).red());
477 return;
478 }
479 };
480
481 let cache = UpdateCache {
483 last_check_ts: now_ts(),
484 latest_version: Some(latest_version.clone()),
485 ..Default::default()
486 };
487 save_cache(&cache);
488
489 if !is_newer(&latest_version, CURRENT_VERSION) {
490 println!("{}", style("Already up to date.").green());
491 return;
492 }
493
494 println!(
495 "New version available: {} → {}",
496 style(format!("v{}", CURRENT_VERSION)).dim(),
497 style(format!("v{}", latest_version)).green().bold()
498 );
499
500 if !yes {
504 if !std::io::stdin().is_terminal() {
505 println!(
506 "Re-run with {} to upgrade non-interactively, or download manually:",
507 style("--yes").cyan()
508 );
509 println!(
510 " https://github.com/{}/{}/releases/latest",
511 REPO_OWNER, REPO_NAME
512 );
513 return;
514 }
515
516 let confirm = dialoguer::Confirm::new()
517 .with_prompt("Upgrade now?")
518 .default(true)
519 .interact()
520 .unwrap_or(false);
521
522 if !confirm {
523 println!("Upgrade cancelled.");
524 return;
525 }
526 }
527
528 println!("Downloading v{}...", latest_version);
529
530 match download_and_extract(&latest_version) {
531 Ok(new_binary) => {
532 if let Err(e) = self_replace::self_replace(&new_binary) {
534 println!(
535 "{}",
536 style(format!("Failed to replace binary: {}", e)).red()
537 );
538 println!(
539 "Download manually: https://github.com/{}/{}/releases/latest",
540 REPO_OWNER, REPO_NAME
541 );
542 let _ = std::fs::remove_file(&new_binary);
543 return;
544 }
545
546 let _ = std::fs::remove_file(&new_binary);
548
549 println!(
550 "{}",
551 style(format!("Upgraded to v{}!", latest_version))
552 .green()
553 .bold()
554 );
555 }
556 Err(e) => {
557 println!("{}", style(format!("Upgrade failed: {}", e)).red());
558 println!(
559 "Download manually: https://github.com/{}/{}/releases/latest",
560 REPO_OWNER, REPO_NAME
561 );
562 }
563 }
564}
565
566#[cfg(test)]
567mod tests {
568 use super::*;
569
570 #[test]
571 fn test_is_newer() {
572 assert!(is_newer("0.2.0", "0.1.0"));
573 assert!(is_newer("1.0.0", "0.10.0"));
574 assert!(!is_newer("0.1.0", "0.1.0"));
575 assert!(!is_newer("0.1.0", "0.2.0"));
576 }
577
578 #[test]
579 fn test_is_homebrew_install() {
580 assert!(!is_homebrew_install());
581 }
582
583 #[test]
584 fn test_cache_freshness() {
585 let fresh = UpdateCache {
586 last_check_ts: now_ts(),
587 latest_version: Some("1.0.0".into()),
588 ..Default::default()
589 };
590 assert!(cache_is_fresh(&fresh));
591
592 let stale = UpdateCache {
593 last_check_ts: now_ts() - CHECK_INTERVAL_SECS - 1,
594 latest_version: Some("1.0.0".into()),
595 ..Default::default()
596 };
597 assert!(!cache_is_fresh(&stale));
598
599 let empty = UpdateCache::default();
600 assert!(!cache_is_fresh(&empty));
601 }
602
603 #[test]
604 fn test_current_target() {
605 let target = current_target();
606 assert!(!target.is_empty());
607 let valid = [
609 "x86_64-apple-darwin",
610 "aarch64-apple-darwin",
611 "x86_64-pc-windows-msvc",
612 "x86_64-unknown-linux-musl",
613 "aarch64-unknown-linux-musl",
614 ];
615 assert!(valid.contains(&target));
616 }
617
618 #[test]
619 fn test_archive_ext() {
620 let ext = archive_ext();
621 if cfg!(windows) {
622 assert_eq!(ext, "zip");
623 } else {
624 assert_eq!(ext, "tar.gz");
625 }
626 }
627}