git_worktree_manager/
update.rs1use 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 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
239pub fn upgrade() {
241 println!("git-worktree-manager v{}", CURRENT_VERSION);
242
243 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 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 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 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 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
336fn 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}