From fa18327cb9c7e9120c0d3f3aea3ac45060d669e3 Mon Sep 17 00:00:00 2001 From: Erikk Shupp Date: Mon, 11 May 2026 10:32:14 -0400 Subject: [PATCH 1/6] [feature] - allows for user to build using "gitui update" or "gitui -U" as well as nightly and pre-release support. Supports cargo, homerew, dnf, apt, pacman, scoop, scoop bucket, chocolatey and winodws UI. --- src/args.rs | 624 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 624 insertions(+) diff --git a/src/args.rs b/src/args.rs index 22c6cc8d92..ec80e81deb 100644 --- a/src/args.rs +++ b/src/args.rs @@ -22,6 +22,7 @@ const GIT_DIR_FLAG_ID: &str = "directory"; const WATCHER_FLAG_ID: &str = "watcher"; const KEY_BINDINGS_FLAG_ID: &str = "key_bindings"; const KEY_SYMBOLS_FLAG_ID: &str = "key_symbols"; +const UPDATE_NIGHTLY_FLAG_ID: &str = "nightly"; const DEFAULT_THEME: &str = "theme.ron"; const DEFAULT_GIT_DIR: &str = "."; @@ -44,6 +45,19 @@ pub fn process_cmdline() -> Result { bug_report::generate_bugreport(); std::process::exit(0); } + + // Handle update subcommand + if let Some(update_cmd) = arg_matches.subcommand_matches("update") + { + let include_prerelease = + update_cmd.get_flag(UPDATE_NIGHTLY_FLAG_ID); + if let Err(e) = self_update(include_prerelease) { + eprintln!("Update failed: {}", e); + std::process::exit(1); + } + std::process::exit(0); + } + if arg_matches.get_flag(LOGGING_FLAG_ID) { let logfile = arg_matches.get_one::(LOG_FILE_FLAG_ID); setup_logging(logfile.map(PathBuf::from))?; @@ -190,6 +204,616 @@ fn app() -> ClapApp { .env("GIT_WORK_TREE") .num_args(1), ) + .subcommand( + ClapApp::new("update") + .about("Update gitui to the latest version") + .visible_short_flag_alias('U') + .arg( + Arg::new(UPDATE_NIGHTLY_FLAG_ID) + .help("Allow updating to pre-release versions (nightly, rc, beta, dev)") + .short('n') + .long("nightly") + .action(clap::ArgAction::SetTrue), + ), + ) +} + +/// Represents the installation method of gitui +#[derive(Debug, Clone, PartialEq)] +#[allow(dead_code)] +pub enum InstallMethod { + Cargo, + Homebrew, + Apt, + Dnf, + Pacman, + Windows, + Scoop, + Chocolatey, + ScoopBucket, + Unknown, +} + +impl std::fmt::Display for InstallMethod { + fn fmt( + &self, + f: &mut std::fmt::Formatter<'_>, + ) -> std::fmt::Result { + match self { + InstallMethod::Cargo => write!(f, "cargo"), + InstallMethod::Homebrew => write!(f, "homebrew"), + InstallMethod::Apt => write!(f, "apt"), + InstallMethod::Dnf => write!(f, "dnf"), + InstallMethod::Pacman => write!(f, "pacman"), + InstallMethod::Windows => write!(f, "windows"), + InstallMethod::Scoop => write!(f, "scoop"), + InstallMethod::Chocolatey => write!(f, "chocolatey"), + InstallMethod::ScoopBucket => write!(f, "scoop-bucket"), + InstallMethod::Unknown => write!(f, "unknown"), + } + } +} + +/// Detect how gitui was installed +fn detect_install_method() -> InstallMethod { + use std::path::Path; + + let current_exe = std::env::current_exe().ok(); + let exe_path = current_exe.as_ref().map(|p| p.as_path()); + + // Check if running from cargo install or cargo build + let is_cargo_build = if let Some(path) = &exe_path { + let path_str = path.to_string_lossy(); + path_str.contains(".cargo/bin") + || path_str.contains("cargo/registry") + || path_str.contains("target/release") + || path_str.contains("target/debug") + } else { + false + }; + + // Even if running from cargo build, check if there's a system-installed gitui + // that the user might want to update instead + if is_cargo_build { + // Check if there's a dnf-installed gitui in the system + if Path::new("/usr/bin/dnf").exists() + || Path::new("/usr/bin/rpm").exists() + { + if is_installed_via_dnf() { + // There's a dnf-installed gitui - prefer updating that + return InstallMethod::Dnf; + } + } + // Check for apt-installed gitui + if Path::new("/usr/bin/apt").exists() + || Path::new("/usr/bin/dpkg").exists() + { + if is_installed_via_apt() { + return InstallMethod::Apt; + } + } + // Check for pacman-installed gitui + if Path::new("/usr/bin/pacman").exists() { + if is_installed_via_pacman() { + return InstallMethod::Pacman; + } + } + // No system installation found, use cargo + return InstallMethod::Cargo; + } + + // Check for homebrew (macOS/Linux) + if let Some(path) = &exe_path { + let path_str = path.to_string_lossy(); + if path_str.contains("homebrew") + || path_str.contains("Cellar") + { + return InstallMethod::Homebrew; + } + } + + // Check for Windows package managers + if let Some(path) = &exe_path { + let path_str = path.to_string_lossy(); + if path_str.contains("scoop") { + if path_str.contains("scoop-bucket") { + return InstallMethod::ScoopBucket; + } + return InstallMethod::Scoop; + } + if path_str.contains("chocolatey") { + return InstallMethod::Chocolatey; + } + // Generic Windows binary + if cfg!(target_os = "windows") { + return InstallMethod::Windows; + } + } + + // Check for Linux package managers + if let Some(path) = &exe_path { + let path_str = path.to_string_lossy(); + + // Check various package manager paths + if path_str.contains("/usr/bin") + || path_str.contains("/usr/local/bin") + { + // Could be APT, DNF, or Pacman - try to detect via package managers + if Path::new("/usr/bin/apt").exists() + || Path::new("/usr/bin/dpkg").exists() + { + // Check if installed via apt + if is_installed_via_apt() { + return InstallMethod::Apt; + } + } + if Path::new("/usr/bin/dnf").exists() + || Path::new("/usr/bin/rpm").exists() + { + // Check if installed via dnf + if is_installed_via_dnf() { + return InstallMethod::Dnf; + } + } + if Path::new("/usr/bin/pacman").exists() { + // Check if installed via pacman + if is_installed_via_pacman() { + return InstallMethod::Pacman; + } + } + } + } + + InstallMethod::Unknown +} + +#[cfg(target_os = "linux")] +fn is_installed_via_apt() -> bool { + use std::process::Command; + Command::new("dpkg") + .args(["-l", "gitui"]) + .output() + .map(|output| output.status.success()) + .unwrap_or(false) +} + +#[cfg(not(target_os = "linux"))] +fn is_installed_via_apt() -> bool { + false +} + +#[cfg(target_os = "linux")] +fn is_installed_via_dnf() -> bool { + use std::process::Command; + Command::new("rpm") + .args(["-q", "gitui"]) + .output() + .map(|output| output.status.success()) + .unwrap_or(false) +} + +#[cfg(not(target_os = "linux"))] +fn is_installed_via_dnf() -> bool { + false +} + +#[cfg(target_os = "linux")] +fn is_installed_via_pacman() -> bool { + use std::process::Command; + Command::new("pacman") + .args(["-Q", "gitui"]) + .output() + .map(|output| output.status.success()) + .unwrap_or(false) +} + +#[cfg(not(target_os = "linux"))] +fn is_installed_via_pacman() -> bool { + false +} + +/// Fetch the latest version from GitHub releases +fn fetch_latest_version() -> Option { + use std::process::Command; + + // Try to use git to check the latest tag + let output = Command::new("git") + .args([ + "ls-remote", + "--tags", + "--sort=-v:refname", + "https://github.com/extrawurst/gitui.git", + ]) + .output() + .ok()?; + + if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout); + // Parse the first tag line + for line in stdout.lines() { + // Format: \trefs/tags/ + if let Some(tag_part) = line.split('\t').nth(1) { + if let Some(tag) = tag_part.strip_prefix("refs/tags/") + { + // Extract just the version number (e.g., "v0.28.0" -> "0.28.0") + let version = + tag.trim_start_matches('v').to_string(); + return Some(version); + } + } + } + } + + None +} + +/// Fetch the latest stable version (non pre-release) +fn fetch_latest_stable_version() -> Option { + use std::process::Command; + + // Fetch more tags to find a stable one + let output = Command::new("git") + .args([ + "ls-remote", + "--tags", + "--sort=-v:refname", + "https://github.com/extrawurst/gitui.git", + ]) + .output() + .ok()?; + + if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout); + // Parse all tag lines and find the first stable one + for line in stdout.lines() { + // Format: \trefs/tags/ + if let Some(tag_part) = line.split('\t').nth(1) { + if let Some(tag) = tag_part.strip_prefix("refs/tags/") + { + let version = + tag.trim_start_matches('v').to_string(); + // Skip pre-release versions + if !is_prerelease(&version) { + return Some(version); + } + } + } + } + } + + // Fallback: if no stable version found in first batch, return None + // In production, you'd want to query the GitHub API for releases + println!( + "Warning: Could not find a stable release in recent tags." + ); + println!("Consider using --update-nightly to update to a pre-release version."); + None +} + +/// Get the current gitui version +fn get_current_version() -> String { + // env!("GITUI_BUILD_NAME") contains version info like "0.28.1" + // Extract just the version number + let build_name = env!("GITUI_BUILD_NAME"); + build_name + .split_whitespace() + .next() + .unwrap_or(build_name) + .to_string() +} + +/// Check if a version is a pre-release (contains nightly, rc, beta, alpha, dev) +fn is_prerelease(version: &str) -> bool { + let version_lower = version.to_lowercase(); + version_lower.contains("nightly") + || version_lower.contains("-rc") + || version_lower.contains("-beta") + || version_lower.contains("-alpha") + || version_lower.contains("-dev") + || version_lower.contains("preview") + || version_lower.contains("snapshot") +} + +/// Perform self-update based on installation method +fn self_update(include_prerelease: bool) -> Result<()> { + let current_version = get_current_version(); + let install_method = detect_install_method(); + + println!("gitui version: {}", current_version); + + // Warn if on a pre-release version + if is_prerelease(¤t_version) { + println!("⚠️ Warning: You are running a pre-release version ({}).", current_version); + if !include_prerelease { + println!(" Use 'gitui update -n' to include pre-releases."); + println!(" Or use 'gitui update' to switch to the latest stable version."); + } + } + + println!("Installation method: {}", install_method); + + // Check for updates + println!("Checking for updates..."); + let latest_version = fetch_latest_version(); + + let latest_version = match latest_version { + Some(latest) => { + let is_latest_prerelease = is_prerelease(&latest); + + if !include_prerelease && is_latest_prerelease { + // Find the latest stable version instead + println!( + "Latest pre-release found: {} (use --update-nightly to upgrade)", + latest + ); + // For now, we don't have a way to find the latest stable from git tags alone + // In production, you'd query the GitHub API for releases + println!("Searching for latest stable version..."); + // Try to find a stable version by fetching more tags + fetch_latest_stable_version() + } else { + Some(latest) + } + } + None => { + println!("Could not check for latest version. Proceeding with update anyway..."); + None + } + }; + + match &latest_version { + Some(latest) => { + if latest == ¤t_version { + println!( + "You're already up to date! ({})", + current_version + ); + return Ok(()); + } + + let is_latest_prerelease = is_prerelease(latest); + if is_latest_prerelease { + println!( + "⚠️ Pre-release update available: {} -> {}", + current_version, latest + ); + } else { + println!( + "Update available: {} -> {}", + current_version, latest + ); + } + } + None => { + println!("Could not determine latest version."); + } + } + + // Confirm update + print!("Do you want to update gitui? [y/N]: "); + use std::io::{self, Write}; + io::stdout().flush()?; + + let mut input = String::new(); + io::stdin().read_line(&mut input)?; + + if !input.trim().eq_ignore_ascii_case("y") { + println!("Update cancelled."); + return Ok(()); + } + + println!("Updating gitui via {}...", install_method); + + // Perform the update based on installation method + let result = match install_method { + InstallMethod::Cargo => update_via_cargo(), + InstallMethod::Homebrew => update_via_homebrew(), + InstallMethod::Dnf => update_via_dnf(), + InstallMethod::Apt => update_via_apt(), + InstallMethod::Pacman => update_via_pacman(), + InstallMethod::Scoop => update_via_scoop(), + InstallMethod::Chocolatey => update_via_chocolatey(), + InstallMethod::ScoopBucket => update_via_scoop_bucket(), + InstallMethod::Windows => { + Err("Windows binary update not supported. Please download the latest release from GitHub.".to_string()) + } + InstallMethod::Unknown => { + Err("Could not detect installation method. Please update manually.".to_string()) + } + }; + + match result { + Ok(_) => { + println!("Update complete! Please restart gitui."); + Ok(()) + } + Err(e) => Err(anyhow!("Update failed: {}", e)), + } +} + +fn update_via_cargo() -> Result<(), String> { + use std::process::Command; + + println!("Running: cargo install gitui --force"); + + let output = Command::new("cargo") + .args(["install", "gitui", "--force"]) + .output() + .map_err(|e| format!("Failed to run cargo install: {}", e))?; + + if output.status.success() { + println!("Successfully updated via cargo!"); + Ok(()) + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + Err(format!("Cargo install failed:\n{}", stderr)) + } +} + +#[cfg(target_os = "macos")] +fn update_via_homebrew() -> Result<(), String> { + use std::process::Command; + + println!("Running: brew upgrade gitui"); + + let output = Command::new("brew") + .args(["upgrade", "gitui"]) + .output() + .map_err(|e| format!("Failed to run brew upgrade: {}", e))?; + + if output.status.success() { + println!("Successfully updated via homebrew!"); + Ok(()) + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + // Check if already up to date + if stderr.contains("already installed") { + println!("Already up to date!"); + Ok(()) + } else { + Err(format!("Brew upgrade failed:\n{}", stderr)) + } + } +} + +#[cfg(not(target_os = "macos"))] +fn update_via_homebrew() -> Result<(), String> { + Err("Homebrew is only supported on macOS".to_string()) +} + +fn update_via_dnf() -> Result<(), String> { + use std::process::Command; + + println!("Running: sudo dnf upgrade gitui -y"); + + let output = Command::new("sudo") + .args(["dnf", "upgrade", "gitui", "-y"]) + .output() + .map_err(|e| format!("Failed to run dnf upgrade: {}", e))?; + + if output.status.success() { + println!("Successfully updated via dnf!"); + Ok(()) + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + Err(format!("DNF upgrade failed:\n{}", stderr)) + } +} + +fn update_via_apt() -> Result<(), String> { + use std::process::Command; + + println!("Running: sudo apt update && sudo apt upgrade gitui -y"); + + // First update package list + let _ = Command::new("sudo").args(["apt", "update"]).output(); + + let output = Command::new("sudo") + .args(["apt", "upgrade", "gitui", "-y"]) + .output() + .map_err(|e| format!("Failed to run apt upgrade: {}", e))?; + + if output.status.success() { + println!("Successfully updated via apt!"); + Ok(()) + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + Err(format!("APT upgrade failed:\n{}", stderr)) + } +} + +fn update_via_pacman() -> Result<(), String> { + use std::process::Command; + + println!("Running: sudo pacman -Syu gitui --noconfirm"); + + let output = Command::new("sudo") + .args(["pacman", "-Syu", "gitui", "--noconfirm"]) + .output() + .map_err(|e| format!("Failed to run pacman -Syu: {}", e))?; + + if output.status.success() { + println!("Successfully updated via pacman!"); + Ok(()) + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + Err(format!("Pacman upgrade failed:\n{}", stderr)) + } +} + +#[cfg(target_os = "windows")] +fn update_via_scoop() -> Result<(), String> { + use std::process::Command; + + println!("Running: scoop update gitui"); + + let output = Command::new("scoop") + .args(["update", "gitui"]) + .output() + .map_err(|e| format!("Failed to run scoop update: {}", e))?; + + if output.status.success() { + println!("Successfully updated via scoop!"); + Ok(()) + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + Err(format!("Scoop update failed:\n{}", stderr)) + } +} + +#[cfg(not(target_os = "windows"))] +fn update_via_scoop() -> Result<(), String> { + Err("Scoop is only supported on Windows".to_string()) +} + +#[cfg(target_os = "windows")] +fn update_via_scoop_bucket() -> Result<(), String> { + use std::process::Command; + + println!("Running: scoop update gitui (from bucket)"); + + let output = Command::new("scoop") + .args(["update", "gitui"]) + .output() + .map_err(|e| format!("Failed to run scoop update: {}", e))?; + + if output.status.success() { + println!("Successfully updated via scoop bucket!"); + Ok(()) + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + Err(format!("Scoop bucket update failed:\n{}", stderr)) + } +} + +#[cfg(not(target_os = "windows"))] +fn update_via_scoop_bucket() -> Result<(), String> { + Err("Scoop is only supported on Windows".to_string()) +} + +#[cfg(target_os = "windows")] +fn update_via_chocolatey() -> Result<(), String> { + use std::process::Command; + + println!("Running: choco upgrade gitui -y"); + + let output = Command::new("choco") + .args(["upgrade", "gitui", "-y"]) + .output() + .map_err(|e| format!("Failed to run choco upgrade: {}", e))?; + + if output.status.success() { + println!("Successfully updated via chocolatey!"); + Ok(()) + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + Err(format!("Chocolatey upgrade failed:\n{}", stderr)) + } +} + +#[cfg(not(target_os = "windows"))] +fn update_via_chocolatey() -> Result<(), String> { + Err("Chocolatey is only supported on Windows".to_string()) } fn setup_logging(path_override: Option) -> Result<()> { From 36e74f82589ce8677b2d9fd18474f92b2ea2d5fe Mon Sep 17 00:00:00 2001 From: Erikk Shupp Date: Mon, 11 May 2026 10:54:36 -0400 Subject: [PATCH 2/6] broke down the file into a modular system for easier changes down stream. reduced noise by removing self comments added high level comments for better identification of what code does macroed the update_via feature into a function removed duplicate feature. --- src/args.rs | 690 ++--------------------------------------- src/update/commands.rs | 125 ++++++++ src/update/detector.rs | 146 +++++++++ src/update/mod.rs | 140 +++++++++ 4 files changed, 442 insertions(+), 659 deletions(-) create mode 100644 src/update/commands.rs create mode 100644 src/update/detector.rs create mode 100644 src/update/mod.rs diff --git a/src/args.rs b/src/args.rs index ec80e81deb..053b0010d2 100644 --- a/src/args.rs +++ b/src/args.rs @@ -1,5 +1,6 @@ use crate::bug_report; -use anyhow::{anyhow, Context, Result}; +use crate::update::self_update; +use anyhow::{Context, Result}; use asyncgit::sync::RepoPath; use clap::{ builder::ArgPredicate, crate_authors, crate_description, @@ -22,7 +23,6 @@ const GIT_DIR_FLAG_ID: &str = "directory"; const WATCHER_FLAG_ID: &str = "watcher"; const KEY_BINDINGS_FLAG_ID: &str = "key_bindings"; const KEY_SYMBOLS_FLAG_ID: &str = "key_symbols"; -const UPDATE_NIGHTLY_FLAG_ID: &str = "nightly"; const DEFAULT_THEME: &str = "theme.ron"; const DEFAULT_GIT_DIR: &str = "."; @@ -37,20 +37,15 @@ pub struct CliArgs { } pub fn process_cmdline() -> Result { - let app = app(); - - let arg_matches = app.get_matches(); + let arg_matches = app().get_matches(); if arg_matches.get_flag(BUG_REPORT_FLAG_ID) { bug_report::generate_bugreport(); std::process::exit(0); } - // Handle update subcommand - if let Some(update_cmd) = arg_matches.subcommand_matches("update") - { - let include_prerelease = - update_cmd.get_flag(UPDATE_NIGHTLY_FLAG_ID); + if let Some(update_cmd) = arg_matches.subcommand_matches("update") { + let include_prerelease = update_cmd.get_flag("nightly"); if let Err(e) = self_update(include_prerelease) { eprintln!("Update failed: {}", e); std::process::exit(1); @@ -66,11 +61,9 @@ pub fn process_cmdline() -> Result { let workdir = arg_matches .get_one::(WORKDIR_FLAG_ID) .map(PathBuf::from); - let gitdir = - arg_matches.get_one::(GIT_DIR_FLAG_ID).map_or_else( - || PathBuf::from(DEFAULT_GIT_DIR), - PathBuf::from, - ); + let gitdir = arg_matches + .get_one::(GIT_DIR_FLAG_ID) + .map_or_else(|| PathBuf::from(DEFAULT_GIT_DIR), PathBuf::from); let select_file = arg_matches .get_one::(FILE_FLAG_ID) @@ -88,15 +81,11 @@ pub fn process_cmdline() -> Result { let confpath = get_app_config_path()?; fs::create_dir_all(&confpath).with_context(|| { - format!( - "failed to create config directory: {}", - confpath.display() - ) + format!("failed to create config directory: {}", confpath.display()) })?; let theme = confpath.join(arg_theme); - let notify_watcher: bool = - *arg_matches.get_one(WATCHER_FLAG_ID).unwrap_or(&false); + let notify_watcher = *arg_matches.get_one(WATCHER_FLAG_ID).unwrap_or(&false); let key_bindings_path = arg_matches .get_one::(KEY_BINDINGS_FLAG_ID) @@ -132,7 +121,7 @@ fn app() -> ClapApp { {all-args}{after-help} ", ) - .arg( + .arg( Arg::new(KEY_BINDINGS_FLAG_ID) .help("Use a custom keybindings file") .short('k') @@ -140,7 +129,7 @@ fn app() -> ClapApp { .value_name("KEY_LIST_FILENAME") .num_args(1), ) - .arg( + .arg( Arg::new(KEY_SYMBOLS_FLAG_ID) .help("Use a custom symbols file") .short('s') @@ -159,19 +148,21 @@ fn app() -> ClapApp { ) .arg( Arg::new(LOGGING_FLAG_ID) - .help("Store logging output into a file (in the cache directory by default)") + .help("Store logging output into a file") .short('l') .long("logging") - .default_value_if("logfile", ArgPredicate::IsPresent, "true") + .default_value_if(LOG_FILE_FLAG_ID, ArgPredicate::IsPresent, "true") .action(clap::ArgAction::SetTrue), ) - .arg(Arg::new(LOG_FILE_FLAG_ID) - .help("Store logging output into the specified file (implies --logging)") - .long("logfile") - .value_name("LOG_FILE")) + .arg( + Arg::new(LOG_FILE_FLAG_ID) + .help("Store logging output into the specified file") + .long("logfile") + .value_name("LOG_FILE"), + ) .arg( Arg::new(WATCHER_FLAG_ID) - .help("Use notify-based file system watcher instead of tick-based update. This is more performant, but can cause issues on some platforms. See https://github.com/gitui-org/gitui/blob/master/FAQ.md#watcher for details.") + .help("Use notify-based file system watcher") .long("watcher") .action(clap::ArgAction::SetTrue), ) @@ -209,8 +200,8 @@ fn app() -> ClapApp { .about("Update gitui to the latest version") .visible_short_flag_alias('U') .arg( - Arg::new(UPDATE_NIGHTLY_FLAG_ID) - .help("Allow updating to pre-release versions (nightly, rc, beta, dev)") + Arg::new("nightly") + .help("Include pre-release versions") .short('n') .long("nightly") .action(clap::ArgAction::SetTrue), @@ -218,645 +209,26 @@ fn app() -> ClapApp { ) } -/// Represents the installation method of gitui -#[derive(Debug, Clone, PartialEq)] -#[allow(dead_code)] -pub enum InstallMethod { - Cargo, - Homebrew, - Apt, - Dnf, - Pacman, - Windows, - Scoop, - Chocolatey, - ScoopBucket, - Unknown, -} - -impl std::fmt::Display for InstallMethod { - fn fmt( - &self, - f: &mut std::fmt::Formatter<'_>, - ) -> std::fmt::Result { - match self { - InstallMethod::Cargo => write!(f, "cargo"), - InstallMethod::Homebrew => write!(f, "homebrew"), - InstallMethod::Apt => write!(f, "apt"), - InstallMethod::Dnf => write!(f, "dnf"), - InstallMethod::Pacman => write!(f, "pacman"), - InstallMethod::Windows => write!(f, "windows"), - InstallMethod::Scoop => write!(f, "scoop"), - InstallMethod::Chocolatey => write!(f, "chocolatey"), - InstallMethod::ScoopBucket => write!(f, "scoop-bucket"), - InstallMethod::Unknown => write!(f, "unknown"), - } - } -} - -/// Detect how gitui was installed -fn detect_install_method() -> InstallMethod { - use std::path::Path; - - let current_exe = std::env::current_exe().ok(); - let exe_path = current_exe.as_ref().map(|p| p.as_path()); - - // Check if running from cargo install or cargo build - let is_cargo_build = if let Some(path) = &exe_path { - let path_str = path.to_string_lossy(); - path_str.contains(".cargo/bin") - || path_str.contains("cargo/registry") - || path_str.contains("target/release") - || path_str.contains("target/debug") - } else { - false - }; - - // Even if running from cargo build, check if there's a system-installed gitui - // that the user might want to update instead - if is_cargo_build { - // Check if there's a dnf-installed gitui in the system - if Path::new("/usr/bin/dnf").exists() - || Path::new("/usr/bin/rpm").exists() - { - if is_installed_via_dnf() { - // There's a dnf-installed gitui - prefer updating that - return InstallMethod::Dnf; - } - } - // Check for apt-installed gitui - if Path::new("/usr/bin/apt").exists() - || Path::new("/usr/bin/dpkg").exists() - { - if is_installed_via_apt() { - return InstallMethod::Apt; - } - } - // Check for pacman-installed gitui - if Path::new("/usr/bin/pacman").exists() { - if is_installed_via_pacman() { - return InstallMethod::Pacman; - } - } - // No system installation found, use cargo - return InstallMethod::Cargo; - } - - // Check for homebrew (macOS/Linux) - if let Some(path) = &exe_path { - let path_str = path.to_string_lossy(); - if path_str.contains("homebrew") - || path_str.contains("Cellar") - { - return InstallMethod::Homebrew; - } - } - - // Check for Windows package managers - if let Some(path) = &exe_path { - let path_str = path.to_string_lossy(); - if path_str.contains("scoop") { - if path_str.contains("scoop-bucket") { - return InstallMethod::ScoopBucket; - } - return InstallMethod::Scoop; - } - if path_str.contains("chocolatey") { - return InstallMethod::Chocolatey; - } - // Generic Windows binary - if cfg!(target_os = "windows") { - return InstallMethod::Windows; - } - } - - // Check for Linux package managers - if let Some(path) = &exe_path { - let path_str = path.to_string_lossy(); - - // Check various package manager paths - if path_str.contains("/usr/bin") - || path_str.contains("/usr/local/bin") - { - // Could be APT, DNF, or Pacman - try to detect via package managers - if Path::new("/usr/bin/apt").exists() - || Path::new("/usr/bin/dpkg").exists() - { - // Check if installed via apt - if is_installed_via_apt() { - return InstallMethod::Apt; - } - } - if Path::new("/usr/bin/dnf").exists() - || Path::new("/usr/bin/rpm").exists() - { - // Check if installed via dnf - if is_installed_via_dnf() { - return InstallMethod::Dnf; - } - } - if Path::new("/usr/bin/pacman").exists() { - // Check if installed via pacman - if is_installed_via_pacman() { - return InstallMethod::Pacman; - } - } - } - } - - InstallMethod::Unknown -} - -#[cfg(target_os = "linux")] -fn is_installed_via_apt() -> bool { - use std::process::Command; - Command::new("dpkg") - .args(["-l", "gitui"]) - .output() - .map(|output| output.status.success()) - .unwrap_or(false) -} - -#[cfg(not(target_os = "linux"))] -fn is_installed_via_apt() -> bool { - false -} - -#[cfg(target_os = "linux")] -fn is_installed_via_dnf() -> bool { - use std::process::Command; - Command::new("rpm") - .args(["-q", "gitui"]) - .output() - .map(|output| output.status.success()) - .unwrap_or(false) -} - -#[cfg(not(target_os = "linux"))] -fn is_installed_via_dnf() -> bool { - false -} - -#[cfg(target_os = "linux")] -fn is_installed_via_pacman() -> bool { - use std::process::Command; - Command::new("pacman") - .args(["-Q", "gitui"]) - .output() - .map(|output| output.status.success()) - .unwrap_or(false) -} - -#[cfg(not(target_os = "linux"))] -fn is_installed_via_pacman() -> bool { - false -} - -/// Fetch the latest version from GitHub releases -fn fetch_latest_version() -> Option { - use std::process::Command; - - // Try to use git to check the latest tag - let output = Command::new("git") - .args([ - "ls-remote", - "--tags", - "--sort=-v:refname", - "https://github.com/extrawurst/gitui.git", - ]) - .output() - .ok()?; - - if output.status.success() { - let stdout = String::from_utf8_lossy(&output.stdout); - // Parse the first tag line - for line in stdout.lines() { - // Format: \trefs/tags/ - if let Some(tag_part) = line.split('\t').nth(1) { - if let Some(tag) = tag_part.strip_prefix("refs/tags/") - { - // Extract just the version number (e.g., "v0.28.0" -> "0.28.0") - let version = - tag.trim_start_matches('v').to_string(); - return Some(version); - } - } - } - } - - None -} - -/// Fetch the latest stable version (non pre-release) -fn fetch_latest_stable_version() -> Option { - use std::process::Command; - - // Fetch more tags to find a stable one - let output = Command::new("git") - .args([ - "ls-remote", - "--tags", - "--sort=-v:refname", - "https://github.com/extrawurst/gitui.git", - ]) - .output() - .ok()?; - - if output.status.success() { - let stdout = String::from_utf8_lossy(&output.stdout); - // Parse all tag lines and find the first stable one - for line in stdout.lines() { - // Format: \trefs/tags/ - if let Some(tag_part) = line.split('\t').nth(1) { - if let Some(tag) = tag_part.strip_prefix("refs/tags/") - { - let version = - tag.trim_start_matches('v').to_string(); - // Skip pre-release versions - if !is_prerelease(&version) { - return Some(version); - } - } - } - } - } - - // Fallback: if no stable version found in first batch, return None - // In production, you'd want to query the GitHub API for releases - println!( - "Warning: Could not find a stable release in recent tags." - ); - println!("Consider using --update-nightly to update to a pre-release version."); - None -} - -/// Get the current gitui version -fn get_current_version() -> String { - // env!("GITUI_BUILD_NAME") contains version info like "0.28.1" - // Extract just the version number - let build_name = env!("GITUI_BUILD_NAME"); - build_name - .split_whitespace() - .next() - .unwrap_or(build_name) - .to_string() -} - -/// Check if a version is a pre-release (contains nightly, rc, beta, alpha, dev) -fn is_prerelease(version: &str) -> bool { - let version_lower = version.to_lowercase(); - version_lower.contains("nightly") - || version_lower.contains("-rc") - || version_lower.contains("-beta") - || version_lower.contains("-alpha") - || version_lower.contains("-dev") - || version_lower.contains("preview") - || version_lower.contains("snapshot") -} - -/// Perform self-update based on installation method -fn self_update(include_prerelease: bool) -> Result<()> { - let current_version = get_current_version(); - let install_method = detect_install_method(); - - println!("gitui version: {}", current_version); - - // Warn if on a pre-release version - if is_prerelease(¤t_version) { - println!("⚠️ Warning: You are running a pre-release version ({}).", current_version); - if !include_prerelease { - println!(" Use 'gitui update -n' to include pre-releases."); - println!(" Or use 'gitui update' to switch to the latest stable version."); - } - } - - println!("Installation method: {}", install_method); - - // Check for updates - println!("Checking for updates..."); - let latest_version = fetch_latest_version(); - - let latest_version = match latest_version { - Some(latest) => { - let is_latest_prerelease = is_prerelease(&latest); - - if !include_prerelease && is_latest_prerelease { - // Find the latest stable version instead - println!( - "Latest pre-release found: {} (use --update-nightly to upgrade)", - latest - ); - // For now, we don't have a way to find the latest stable from git tags alone - // In production, you'd query the GitHub API for releases - println!("Searching for latest stable version..."); - // Try to find a stable version by fetching more tags - fetch_latest_stable_version() - } else { - Some(latest) - } - } - None => { - println!("Could not check for latest version. Proceeding with update anyway..."); - None - } - }; - - match &latest_version { - Some(latest) => { - if latest == ¤t_version { - println!( - "You're already up to date! ({})", - current_version - ); - return Ok(()); - } - - let is_latest_prerelease = is_prerelease(latest); - if is_latest_prerelease { - println!( - "⚠️ Pre-release update available: {} -> {}", - current_version, latest - ); - } else { - println!( - "Update available: {} -> {}", - current_version, latest - ); - } - } - None => { - println!("Could not determine latest version."); - } - } - - // Confirm update - print!("Do you want to update gitui? [y/N]: "); - use std::io::{self, Write}; - io::stdout().flush()?; - - let mut input = String::new(); - io::stdin().read_line(&mut input)?; - - if !input.trim().eq_ignore_ascii_case("y") { - println!("Update cancelled."); - return Ok(()); - } - - println!("Updating gitui via {}...", install_method); - - // Perform the update based on installation method - let result = match install_method { - InstallMethod::Cargo => update_via_cargo(), - InstallMethod::Homebrew => update_via_homebrew(), - InstallMethod::Dnf => update_via_dnf(), - InstallMethod::Apt => update_via_apt(), - InstallMethod::Pacman => update_via_pacman(), - InstallMethod::Scoop => update_via_scoop(), - InstallMethod::Chocolatey => update_via_chocolatey(), - InstallMethod::ScoopBucket => update_via_scoop_bucket(), - InstallMethod::Windows => { - Err("Windows binary update not supported. Please download the latest release from GitHub.".to_string()) - } - InstallMethod::Unknown => { - Err("Could not detect installation method. Please update manually.".to_string()) - } - }; - - match result { - Ok(_) => { - println!("Update complete! Please restart gitui."); - Ok(()) - } - Err(e) => Err(anyhow!("Update failed: {}", e)), - } -} - -fn update_via_cargo() -> Result<(), String> { - use std::process::Command; - - println!("Running: cargo install gitui --force"); - - let output = Command::new("cargo") - .args(["install", "gitui", "--force"]) - .output() - .map_err(|e| format!("Failed to run cargo install: {}", e))?; - - if output.status.success() { - println!("Successfully updated via cargo!"); - Ok(()) - } else { - let stderr = String::from_utf8_lossy(&output.stderr); - Err(format!("Cargo install failed:\n{}", stderr)) - } -} - -#[cfg(target_os = "macos")] -fn update_via_homebrew() -> Result<(), String> { - use std::process::Command; - - println!("Running: brew upgrade gitui"); - - let output = Command::new("brew") - .args(["upgrade", "gitui"]) - .output() - .map_err(|e| format!("Failed to run brew upgrade: {}", e))?; - - if output.status.success() { - println!("Successfully updated via homebrew!"); - Ok(()) - } else { - let stderr = String::from_utf8_lossy(&output.stderr); - // Check if already up to date - if stderr.contains("already installed") { - println!("Already up to date!"); - Ok(()) - } else { - Err(format!("Brew upgrade failed:\n{}", stderr)) - } - } -} - -#[cfg(not(target_os = "macos"))] -fn update_via_homebrew() -> Result<(), String> { - Err("Homebrew is only supported on macOS".to_string()) -} - -fn update_via_dnf() -> Result<(), String> { - use std::process::Command; - - println!("Running: sudo dnf upgrade gitui -y"); - - let output = Command::new("sudo") - .args(["dnf", "upgrade", "gitui", "-y"]) - .output() - .map_err(|e| format!("Failed to run dnf upgrade: {}", e))?; - - if output.status.success() { - println!("Successfully updated via dnf!"); - Ok(()) - } else { - let stderr = String::from_utf8_lossy(&output.stderr); - Err(format!("DNF upgrade failed:\n{}", stderr)) - } -} - -fn update_via_apt() -> Result<(), String> { - use std::process::Command; - - println!("Running: sudo apt update && sudo apt upgrade gitui -y"); - - // First update package list - let _ = Command::new("sudo").args(["apt", "update"]).output(); - - let output = Command::new("sudo") - .args(["apt", "upgrade", "gitui", "-y"]) - .output() - .map_err(|e| format!("Failed to run apt upgrade: {}", e))?; - - if output.status.success() { - println!("Successfully updated via apt!"); - Ok(()) - } else { - let stderr = String::from_utf8_lossy(&output.stderr); - Err(format!("APT upgrade failed:\n{}", stderr)) - } -} - -fn update_via_pacman() -> Result<(), String> { - use std::process::Command; - - println!("Running: sudo pacman -Syu gitui --noconfirm"); - - let output = Command::new("sudo") - .args(["pacman", "-Syu", "gitui", "--noconfirm"]) - .output() - .map_err(|e| format!("Failed to run pacman -Syu: {}", e))?; - - if output.status.success() { - println!("Successfully updated via pacman!"); - Ok(()) - } else { - let stderr = String::from_utf8_lossy(&output.stderr); - Err(format!("Pacman upgrade failed:\n{}", stderr)) - } -} - -#[cfg(target_os = "windows")] -fn update_via_scoop() -> Result<(), String> { - use std::process::Command; - - println!("Running: scoop update gitui"); - - let output = Command::new("scoop") - .args(["update", "gitui"]) - .output() - .map_err(|e| format!("Failed to run scoop update: {}", e))?; - - if output.status.success() { - println!("Successfully updated via scoop!"); - Ok(()) - } else { - let stderr = String::from_utf8_lossy(&output.stderr); - Err(format!("Scoop update failed:\n{}", stderr)) - } -} - -#[cfg(not(target_os = "windows"))] -fn update_via_scoop() -> Result<(), String> { - Err("Scoop is only supported on Windows".to_string()) -} - -#[cfg(target_os = "windows")] -fn update_via_scoop_bucket() -> Result<(), String> { - use std::process::Command; - - println!("Running: scoop update gitui (from bucket)"); - - let output = Command::new("scoop") - .args(["update", "gitui"]) - .output() - .map_err(|e| format!("Failed to run scoop update: {}", e))?; - - if output.status.success() { - println!("Successfully updated via scoop bucket!"); - Ok(()) - } else { - let stderr = String::from_utf8_lossy(&output.stderr); - Err(format!("Scoop bucket update failed:\n{}", stderr)) - } -} - -#[cfg(not(target_os = "windows"))] -fn update_via_scoop_bucket() -> Result<(), String> { - Err("Scoop is only supported on Windows".to_string()) -} - -#[cfg(target_os = "windows")] -fn update_via_chocolatey() -> Result<(), String> { - use std::process::Command; - - println!("Running: choco upgrade gitui -y"); - - let output = Command::new("choco") - .args(["upgrade", "gitui", "-y"]) - .output() - .map_err(|e| format!("Failed to run choco upgrade: {}", e))?; - - if output.status.success() { - println!("Successfully updated via chocolatey!"); - Ok(()) - } else { - let stderr = String::from_utf8_lossy(&output.stderr); - Err(format!("Chocolatey upgrade failed:\n{}", stderr)) - } -} - -#[cfg(not(target_os = "windows"))] -fn update_via_chocolatey() -> Result<(), String> { - Err("Chocolatey is only supported on Windows".to_string()) -} - fn setup_logging(path_override: Option) -> Result<()> { - let path = if let Some(path) = path_override { - path - } else { - let mut path = get_app_cache_path()?; - path.push("gitui.log"); - path - }; + let path = path_override.unwrap_or_else(|| { + let mut p = dirs::cache_dir().expect("cache dir"); + p.push("gitui"); + p.push("gitui.log"); + p + }); println!("Logging enabled. Log written to: {}", path.display()); - - WriteLogger::init( - LevelFilter::Trace, - Config::default(), - File::create(path)?, - )?; - + WriteLogger::init(LevelFilter::Trace, Config::default(), File::create(path)?)?; Ok(()) } -fn get_app_cache_path() -> Result { - let mut path = dirs::cache_dir() - .ok_or_else(|| anyhow!("failed to find os cache dir."))?; - - path.push("gitui"); - fs::create_dir_all(&path).with_context(|| { - format!( - "failed to create cache directory: {}", - path.display() - ) - })?; - Ok(path) -} - pub fn get_app_config_path() -> Result { let mut path = if cfg!(target_os = "macos") { dirs::home_dir().map(|h| h.join(".config")) } else { dirs::config_dir() } - .ok_or_else(|| anyhow!("failed to find os config dir."))?; + .ok_or_else(|| anyhow::anyhow!("failed to find os config dir."))?; path.push("gitui"); Ok(path) diff --git a/src/update/commands.rs b/src/update/commands.rs new file mode 100644 index 0000000000..fdcf49eba3 --- /dev/null +++ b/src/update/commands.rs @@ -0,0 +1,125 @@ +//! Executes update commands for supported package managers (cargo, dnf, apt, +//! etc.) using a macro to generate consistent command patterns. + +use std::process::Command; + +/// Generates an update function for a specific package manager. +/// +/// The generated function: +/// - Executes the specified command with given arguments +/// - Checks for success or "already installed" states +/// - Returns descriptive error messages on failure +/// +/// # Macro Parameters +/// +/// - `$name` - Function name (e.g., `update_via_dnf`) +/// - `$cmd` - Command to execute (e.g., `"sudo"`) +/// - `$args` - Arguments array (e.g., `["dnf", "upgrade", "gitui", "-y"]`) +/// - `$success_msg` - Message printed on successful update +macro_rules! update_via { + ($name:ident, $cmd:expr, $args:expr, $success_msg:literal) => { + pub fn $name() -> Result<(), String> { + let output = Command::new($cmd) + .args($args) + .output() + .map_err(|e| format!("Failed to run {}: {}", $cmd, e))?; + + if output.status.success() { + println!($success_msg); + Ok(()) + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + if stderr.contains("already installed") || stderr.contains("already up-to-date") { + println!("Already up to date!"); + Ok(()) + } else { + Err(format!("{} failed:\n{}", $cmd, stderr)) + } + } + } + }; +} + +update_via!( + update_via_cargo, + "cargo", + ["install", "gitui", "--force"], + "Successfully updated via cargo!" +); + +update_via!( + update_via_dnf, + "sudo", + ["dnf", "upgrade", "gitui", "-y"], + "Successfully updated via dnf!" +); + +update_via!( + update_via_apt, + "sudo", + ["apt", "upgrade", "gitui", "-y"], + "Successfully updated via apt!" +); + +update_via!( + update_via_pacman, + "sudo", + ["pacman", "-Syu", "gitui", "--noconfirm"], + "Successfully updated via pacman!" +); + +#[cfg(target_os = "macos")] +update_via!( + update_via_homebrew, + "brew", + ["upgrade", "gitui"], + "Successfully updated via homebrew!" +); + +#[cfg(not(target_os = "macos"))] +pub fn update_via_homebrew() -> Result<(), String> { + Err("Homebrew is only supported on macOS".to_string()) +} + +#[cfg(target_os = "windows")] +update_via!( + update_via_scoop, + "scoop", + ["update", "gitui"], + "Successfully updated via scoop!" +); + +#[cfg(not(target_os = "windows"))] +pub fn update_via_scoop() -> Result<(), String> { + Err("Scoop is only supported on Windows".to_string()) +} + +#[cfg(target_os = "windows")] +update_via!( + update_via_scoop_bucket, + "scoop", + ["update", "gitui"], + "Successfully updated via scoop bucket!" +); + +#[cfg(not(target_os = "windows"))] +pub fn update_via_scoop_bucket() -> Result<(), String> { + Err("Scoop is only supported on Windows".to_string()) +} + +#[cfg(target_os = "windows")] +update_via!( + update_via_chocolatey, + "choco", + ["upgrade", "gitui", "-y"], + "Successfully updated via chocolatey!" +); + +#[cfg(not(target_os = "windows"))] +pub fn update_via_chocolatey() -> Result<(), String> { + Err("Chocolatey is only supported on Windows".to_string()) +} + +pub fn update_via_windows() -> Result<(), String> { + Err("Windows binary update not supported. Please download the latest release from GitHub.".to_string()) +} diff --git a/src/update/detector.rs b/src/update/detector.rs new file mode 100644 index 0000000000..dc2155a259 --- /dev/null +++ b/src/update/detector.rs @@ -0,0 +1,146 @@ +//! Detects how gitui was installed by examining the executable path and +//! querying system package managers (dnf, apt, pacman, etc.). + +use std::path::Path; +use std::process::Command; + +/// Installation methods supported by the self-update system. +#[derive(Debug, Clone, PartialEq)] +pub enum InstallMethod { + Cargo, + Homebrew, + Apt, + Dnf, + Pacman, + Windows, + Scoop, + Chocolatey, + ScoopBucket, + Unknown, +} + +impl std::fmt::Display for InstallMethod { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + InstallMethod::Cargo => write!(f, "cargo"), + InstallMethod::Homebrew => write!(f, "homebrew"), + InstallMethod::Apt => write!(f, "apt"), + InstallMethod::Dnf => write!(f, "dnf"), + InstallMethod::Pacman => write!(f, "pacman"), + InstallMethod::Windows => write!(f, "windows"), + InstallMethod::Scoop => write!(f, "scoop"), + InstallMethod::Chocolatey => write!(f, "chocolatey"), + InstallMethod::ScoopBucket => write!(f, "scoop-bucket"), + InstallMethod::Unknown => write!(f, "unknown"), + } + } +} + +pub fn detect_install_method() -> InstallMethod { + let current_exe = std::env::current_exe().ok(); + let exe_path = current_exe.as_ref().map(|p| p.as_path()); + + let is_cargo_build = exe_path.map_or(false, |p| { + let s = p.to_string_lossy(); + s.contains(".cargo/bin") + || s.contains("cargo/registry") + || s.contains("target/release") + || s.contains("target/debug") + }); + + if is_cargo_build { + if has_dnf_installation() { + return InstallMethod::Dnf; + } + if has_apt_installation() { + return InstallMethod::Apt; + } + if has_pacman_installation() { + return InstallMethod::Pacman; + } + return InstallMethod::Cargo; + } + + exe_path.map_or(InstallMethod::Unknown, |p| { + let s = p.to_string_lossy(); + + if s.contains("homebrew") || s.contains("Cellar") { + return InstallMethod::Homebrew; + } + + if s.contains("scoop") { + return if s.contains("scoop-bucket") { + InstallMethod::ScoopBucket + } else { + InstallMethod::Scoop + }; + } + + if s.contains("chocolatey") { + return InstallMethod::Chocolatey; + } + + if cfg!(target_os = "windows") { + return InstallMethod::Windows; + } + + if s.contains("/usr/bin") || s.contains("/usr/local/bin") { + if has_dnf_installation() { + return InstallMethod::Dnf; + } + if has_apt_installation() { + return InstallMethod::Apt; + } + if has_pacman_installation() { + return InstallMethod::Pacman; + } + } + + InstallMethod::Unknown + }) +} + +#[cfg(target_os = "linux")] +fn has_dnf_installation() -> bool { + Path::new("/usr/bin/rpm").exists() + && Command::new("rpm") + .args(["-q", "gitui"]) + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + +#[cfg(not(target_os = "linux"))] +fn has_dnf_installation() -> bool { + false +} + +#[cfg(target_os = "linux")] +fn has_apt_installation() -> bool { + Path::new("/usr/bin/dpkg").exists() + && Command::new("dpkg") + .args(["-l", "gitui"]) + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + +#[cfg(not(target_os = "linux"))] +fn has_apt_installation() -> bool { + false +} + +#[cfg(target_os = "linux")] +fn has_pacman_installation() -> bool { + Path::new("/usr/bin/pacman").exists() + && Command::new("pacman") + .args(["-Q", "gitui"]) + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + +#[cfg(not(target_os = "linux"))] +fn has_pacman_installation() -> bool { + false +} diff --git a/src/update/mod.rs b/src/update/mod.rs new file mode 100644 index 0000000000..a64f183537 --- /dev/null +++ b/src/update/mod.rs @@ -0,0 +1,140 @@ +//! Self-update functionality for gitui. Orchestrates version checking, +//! installation method detection, and update execution. + +mod commands; +mod detector; + +use anyhow::{anyhow, Result}; +use commands::*; +use detector::{detect_install_method, InstallMethod}; +use std::io::{self, Write}; +use std::process::Command; + +pub fn self_update(include_prerelease: bool) -> Result<()> { + let current = get_current_version(); + let method = detect_install_method(); + + println!("gitui version: {}", current); + + if is_prerelease(¤t) { + println!("⚠️ Pre-release version detected."); + if !include_prerelease { + println!(" Use 'gitui update -n' to include pre-releases."); + } + } + + println!("Installation method: {}", method); + println!("Checking for updates..."); + + let latest = if include_prerelease { + fetch_latest_version() + } else { + fetch_latest_stable() + }; + + match latest { + Some(v) if v == current => { + println!("Already up to date ({})", current); + return Ok(()); + } + Some(v) => { + let kind = if is_prerelease(&v) { "Pre-release" } else { "Stable" }; + println!("{} update available: {} -> {}", kind, current, v); + } + None => println!("Could not determine latest version."), + } + + if !confirm("Do you want to update gitui?")? { + println!("Update cancelled."); + return Ok(()); + } + + println!("Updating via {}...", method); + + let result = match method { + InstallMethod::Cargo => update_via_cargo(), + InstallMethod::Homebrew => update_via_homebrew(), + InstallMethod::Dnf => update_via_dnf(), + InstallMethod::Apt => update_via_apt(), + InstallMethod::Pacman => update_via_pacman(), + InstallMethod::Scoop => update_via_scoop(), + InstallMethod::Chocolatey => update_via_chocolatey(), + InstallMethod::ScoopBucket => update_via_scoop_bucket(), + InstallMethod::Windows => update_via_windows(), + InstallMethod::Unknown => Err("Unknown installation method".to_string()), + }; + + match result { + Ok(_) => { + println!("Update complete! Please restart gitui."); + Ok(()) + } + Err(e) => Err(anyhow!("Update failed: {}", e)), + } +} + +fn get_current_version() -> String { + let build = env!("GITUI_BUILD_NAME"); + build.split_whitespace().next().unwrap_or(build).to_string() +} + +fn is_prerelease(v: &str) -> bool { + let lower = v.to_lowercase(); + ["nightly", "-rc", "-beta", "-alpha", "-dev", "preview", "snapshot"] + .iter() + .any(|&s| lower.contains(s)) +} + +fn fetch_latest_version() -> Option { + let output = Command::new("git") + .args(["ls-remote", "--tags", "--sort=-v:refname", "https://github.com/extrawurst/gitui.git"]) + .output() + .ok()?; + + if !output.status.success() { + return None; + } + + String::from_utf8_lossy(&output.stdout) + .lines() + .filter_map(|line| { + line.split('\t').nth(1)?.strip_prefix("refs/tags/")?.strip_prefix('v') + }) + .next() + .map(String::from) +} + +fn fetch_latest_stable() -> Option { + let output = Command::new("git") + .args(["ls-remote", "--tags", "--sort=-v:refname", "https://github.com/extrawurst/gitui.git"]) + .output() + .ok()?; + + if !output.status.success() { + return None; + } + + let version = String::from_utf8_lossy(&output.stdout) + .lines() + .filter_map(|line| { + line.split('\t').nth(1)?.strip_prefix("refs/tags/")?.strip_prefix('v') + }) + .find(|&v| !is_prerelease(v)) + .map(String::from); + + if version.is_none() { + println!("Warning: No stable release found. Use -n for pre-releases."); + } + + version +} + +fn confirm(prompt: &str) -> Result { + print!("{} [y/N]: ", prompt); + io::stdout().flush()?; + + let mut input = String::new(); + io::stdin().read_line(&mut input)?; + + Ok(input.trim().eq_ignore_ascii_case("y")) +} From d3e11e1284a9ad0fa1c8f5984053a87d541c1388 Mon Sep 17 00:00:00 2001 From: Erikk Shupp Date: Mon, 11 May 2026 10:55:00 -0400 Subject: [PATCH 3/6] add mod update to main.rs --- src/main.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main.rs b/src/main.rs index fd662950a2..3e6ec14713 100644 --- a/src/main.rs +++ b/src/main.rs @@ -62,6 +62,7 @@ mod app; mod args; mod bug_report; +mod update; mod clipboard; mod cmdbar; mod components; From 71cd862b785193fbdb7b2dae431c48341aea6a5a Mon Sep 17 00:00:00 2001 From: Erikk Shupp Date: Mon, 11 May 2026 10:56:26 -0400 Subject: [PATCH 4/6] Add changelog entry for self-update command --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3bdad9ba8f..675be5c874 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Added +* self-update command `gitui update` (or `gitui -U`) to update gitui via CLI before opening TUI [[@shuppel](https://github.com/shuppel)] + * supports updating via multiple package managers: cargo, dnf, apt, pacman, homebrew, scoop, chocolatey + * automatically detects installation method by examining binary path and querying system package managers + * `--nightly` / `-n` flag to include pre-release versions (nightly, rc, beta) in update checks + * filters stable vs pre-release versions so users can choose update stability + ### Changed * use [tombi](https://github.com/tombi-toml/tombi) for all toml file formatting * open the external editor from the status diff view [[@WaterWhisperer](https://github.com/WaterWhisperer)] ([#2805](https://github.com/gitui-org/gitui/issues/2805)) From bdae3231b508cb4fc19a88e28fe5b2c73cece4fb Mon Sep 17 00:00:00 2001 From: Erikk Shupp Date: Mon, 11 May 2026 11:02:52 -0400 Subject: [PATCH 5/6] Apply cargo fmt formatting --- src/args.rs | 29 +++-- src/main.rs | 2 +- src/update/commands.rs | 118 ++++++++++---------- src/update/detector.rs | 215 +++++++++++++++++------------------ src/update/mod.rs | 248 +++++++++++++++++++++++------------------ 5 files changed, 330 insertions(+), 282 deletions(-) diff --git a/src/args.rs b/src/args.rs index 053b0010d2..dd7c17afd9 100644 --- a/src/args.rs +++ b/src/args.rs @@ -44,7 +44,8 @@ pub fn process_cmdline() -> Result { std::process::exit(0); } - if let Some(update_cmd) = arg_matches.subcommand_matches("update") { + if let Some(update_cmd) = arg_matches.subcommand_matches("update") + { let include_prerelease = update_cmd.get_flag("nightly"); if let Err(e) = self_update(include_prerelease) { eprintln!("Update failed: {}", e); @@ -61,9 +62,11 @@ pub fn process_cmdline() -> Result { let workdir = arg_matches .get_one::(WORKDIR_FLAG_ID) .map(PathBuf::from); - let gitdir = arg_matches - .get_one::(GIT_DIR_FLAG_ID) - .map_or_else(|| PathBuf::from(DEFAULT_GIT_DIR), PathBuf::from); + let gitdir = + arg_matches.get_one::(GIT_DIR_FLAG_ID).map_or_else( + || PathBuf::from(DEFAULT_GIT_DIR), + PathBuf::from, + ); let select_file = arg_matches .get_one::(FILE_FLAG_ID) @@ -81,11 +84,15 @@ pub fn process_cmdline() -> Result { let confpath = get_app_config_path()?; fs::create_dir_all(&confpath).with_context(|| { - format!("failed to create config directory: {}", confpath.display()) + format!( + "failed to create config directory: {}", + confpath.display() + ) })?; let theme = confpath.join(arg_theme); - let notify_watcher = *arg_matches.get_one(WATCHER_FLAG_ID).unwrap_or(&false); + let notify_watcher = + *arg_matches.get_one(WATCHER_FLAG_ID).unwrap_or(&false); let key_bindings_path = arg_matches .get_one::(KEY_BINDINGS_FLAG_ID) @@ -218,7 +225,11 @@ fn setup_logging(path_override: Option) -> Result<()> { }); println!("Logging enabled. Log written to: {}", path.display()); - WriteLogger::init(LevelFilter::Trace, Config::default(), File::create(path)?)?; + WriteLogger::init( + LevelFilter::Trace, + Config::default(), + File::create(path)?, + )?; Ok(()) } @@ -228,7 +239,9 @@ pub fn get_app_config_path() -> Result { } else { dirs::config_dir() } - .ok_or_else(|| anyhow::anyhow!("failed to find os config dir."))?; + .ok_or_else(|| { + anyhow::anyhow!("failed to find os config dir.") + })?; path.push("gitui"); Ok(path) diff --git a/src/main.rs b/src/main.rs index 3e6ec14713..2656936e7c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -62,7 +62,6 @@ mod app; mod args; mod bug_report; -mod update; mod clipboard; mod cmdbar; mod components; @@ -79,6 +78,7 @@ mod string_utils; mod strings; mod tabs; mod ui; +mod update; mod watcher; use crate::{ diff --git a/src/update/commands.rs b/src/update/commands.rs index fdcf49eba3..e0ad2e15f7 100644 --- a/src/update/commands.rs +++ b/src/update/commands.rs @@ -17,109 +17,111 @@ use std::process::Command; /// - `$args` - Arguments array (e.g., `["dnf", "upgrade", "gitui", "-y"]`) /// - `$success_msg` - Message printed on successful update macro_rules! update_via { - ($name:ident, $cmd:expr, $args:expr, $success_msg:literal) => { - pub fn $name() -> Result<(), String> { - let output = Command::new($cmd) - .args($args) - .output() - .map_err(|e| format!("Failed to run {}: {}", $cmd, e))?; - - if output.status.success() { - println!($success_msg); - Ok(()) - } else { - let stderr = String::from_utf8_lossy(&output.stderr); - if stderr.contains("already installed") || stderr.contains("already up-to-date") { - println!("Already up to date!"); - Ok(()) - } else { - Err(format!("{} failed:\n{}", $cmd, stderr)) - } - } - } - }; + ($name:ident, $cmd:expr, $args:expr, $success_msg:literal) => { + pub fn $name() -> Result<(), String> { + let output = + Command::new($cmd).args($args).output().map_err( + |e| format!("Failed to run {}: {}", $cmd, e), + )?; + + if output.status.success() { + println!($success_msg); + Ok(()) + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + if stderr.contains("already installed") + || stderr.contains("already up-to-date") + { + println!("Already up to date!"); + Ok(()) + } else { + Err(format!("{} failed:\n{}", $cmd, stderr)) + } + } + } + }; } update_via!( - update_via_cargo, - "cargo", - ["install", "gitui", "--force"], - "Successfully updated via cargo!" + update_via_cargo, + "cargo", + ["install", "gitui", "--force"], + "Successfully updated via cargo!" ); update_via!( - update_via_dnf, - "sudo", - ["dnf", "upgrade", "gitui", "-y"], - "Successfully updated via dnf!" + update_via_dnf, + "sudo", + ["dnf", "upgrade", "gitui", "-y"], + "Successfully updated via dnf!" ); update_via!( - update_via_apt, - "sudo", - ["apt", "upgrade", "gitui", "-y"], - "Successfully updated via apt!" + update_via_apt, + "sudo", + ["apt", "upgrade", "gitui", "-y"], + "Successfully updated via apt!" ); update_via!( - update_via_pacman, - "sudo", - ["pacman", "-Syu", "gitui", "--noconfirm"], - "Successfully updated via pacman!" + update_via_pacman, + "sudo", + ["pacman", "-Syu", "gitui", "--noconfirm"], + "Successfully updated via pacman!" ); #[cfg(target_os = "macos")] update_via!( - update_via_homebrew, - "brew", - ["upgrade", "gitui"], - "Successfully updated via homebrew!" + update_via_homebrew, + "brew", + ["upgrade", "gitui"], + "Successfully updated via homebrew!" ); #[cfg(not(target_os = "macos"))] pub fn update_via_homebrew() -> Result<(), String> { - Err("Homebrew is only supported on macOS".to_string()) + Err("Homebrew is only supported on macOS".to_string()) } #[cfg(target_os = "windows")] update_via!( - update_via_scoop, - "scoop", - ["update", "gitui"], - "Successfully updated via scoop!" + update_via_scoop, + "scoop", + ["update", "gitui"], + "Successfully updated via scoop!" ); #[cfg(not(target_os = "windows"))] pub fn update_via_scoop() -> Result<(), String> { - Err("Scoop is only supported on Windows".to_string()) + Err("Scoop is only supported on Windows".to_string()) } #[cfg(target_os = "windows")] update_via!( - update_via_scoop_bucket, - "scoop", - ["update", "gitui"], - "Successfully updated via scoop bucket!" + update_via_scoop_bucket, + "scoop", + ["update", "gitui"], + "Successfully updated via scoop bucket!" ); #[cfg(not(target_os = "windows"))] pub fn update_via_scoop_bucket() -> Result<(), String> { - Err("Scoop is only supported on Windows".to_string()) + Err("Scoop is only supported on Windows".to_string()) } #[cfg(target_os = "windows")] update_via!( - update_via_chocolatey, - "choco", - ["upgrade", "gitui", "-y"], - "Successfully updated via chocolatey!" + update_via_chocolatey, + "choco", + ["upgrade", "gitui", "-y"], + "Successfully updated via chocolatey!" ); #[cfg(not(target_os = "windows"))] pub fn update_via_chocolatey() -> Result<(), String> { - Err("Chocolatey is only supported on Windows".to_string()) + Err("Chocolatey is only supported on Windows".to_string()) } pub fn update_via_windows() -> Result<(), String> { - Err("Windows binary update not supported. Please download the latest release from GitHub.".to_string()) + Err("Windows binary update not supported. Please download the latest release from GitHub.".to_string()) } diff --git a/src/update/detector.rs b/src/update/detector.rs index dc2155a259..20727e7fbe 100644 --- a/src/update/detector.rs +++ b/src/update/detector.rs @@ -7,140 +7,143 @@ use std::process::Command; /// Installation methods supported by the self-update system. #[derive(Debug, Clone, PartialEq)] pub enum InstallMethod { - Cargo, - Homebrew, - Apt, - Dnf, - Pacman, - Windows, - Scoop, - Chocolatey, - ScoopBucket, - Unknown, + Cargo, + Homebrew, + Apt, + Dnf, + Pacman, + Windows, + Scoop, + Chocolatey, + ScoopBucket, + Unknown, } impl std::fmt::Display for InstallMethod { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - InstallMethod::Cargo => write!(f, "cargo"), - InstallMethod::Homebrew => write!(f, "homebrew"), - InstallMethod::Apt => write!(f, "apt"), - InstallMethod::Dnf => write!(f, "dnf"), - InstallMethod::Pacman => write!(f, "pacman"), - InstallMethod::Windows => write!(f, "windows"), - InstallMethod::Scoop => write!(f, "scoop"), - InstallMethod::Chocolatey => write!(f, "chocolatey"), - InstallMethod::ScoopBucket => write!(f, "scoop-bucket"), - InstallMethod::Unknown => write!(f, "unknown"), - } - } + fn fmt( + &self, + f: &mut std::fmt::Formatter<'_>, + ) -> std::fmt::Result { + match self { + InstallMethod::Cargo => write!(f, "cargo"), + InstallMethod::Homebrew => write!(f, "homebrew"), + InstallMethod::Apt => write!(f, "apt"), + InstallMethod::Dnf => write!(f, "dnf"), + InstallMethod::Pacman => write!(f, "pacman"), + InstallMethod::Windows => write!(f, "windows"), + InstallMethod::Scoop => write!(f, "scoop"), + InstallMethod::Chocolatey => write!(f, "chocolatey"), + InstallMethod::ScoopBucket => write!(f, "scoop-bucket"), + InstallMethod::Unknown => write!(f, "unknown"), + } + } } pub fn detect_install_method() -> InstallMethod { - let current_exe = std::env::current_exe().ok(); - let exe_path = current_exe.as_ref().map(|p| p.as_path()); - - let is_cargo_build = exe_path.map_or(false, |p| { - let s = p.to_string_lossy(); - s.contains(".cargo/bin") - || s.contains("cargo/registry") - || s.contains("target/release") - || s.contains("target/debug") - }); - - if is_cargo_build { - if has_dnf_installation() { - return InstallMethod::Dnf; - } - if has_apt_installation() { - return InstallMethod::Apt; - } - if has_pacman_installation() { - return InstallMethod::Pacman; - } - return InstallMethod::Cargo; - } - - exe_path.map_or(InstallMethod::Unknown, |p| { - let s = p.to_string_lossy(); - - if s.contains("homebrew") || s.contains("Cellar") { - return InstallMethod::Homebrew; - } - - if s.contains("scoop") { - return if s.contains("scoop-bucket") { - InstallMethod::ScoopBucket - } else { - InstallMethod::Scoop - }; - } - - if s.contains("chocolatey") { - return InstallMethod::Chocolatey; - } - - if cfg!(target_os = "windows") { - return InstallMethod::Windows; - } - - if s.contains("/usr/bin") || s.contains("/usr/local/bin") { - if has_dnf_installation() { - return InstallMethod::Dnf; - } - if has_apt_installation() { - return InstallMethod::Apt; - } - if has_pacman_installation() { - return InstallMethod::Pacman; - } - } - - InstallMethod::Unknown - }) + let current_exe = std::env::current_exe().ok(); + let exe_path = current_exe.as_ref().map(|p| p.as_path()); + + let is_cargo_build = exe_path.map_or(false, |p| { + let s = p.to_string_lossy(); + s.contains(".cargo/bin") + || s.contains("cargo/registry") + || s.contains("target/release") + || s.contains("target/debug") + }); + + if is_cargo_build { + if has_dnf_installation() { + return InstallMethod::Dnf; + } + if has_apt_installation() { + return InstallMethod::Apt; + } + if has_pacman_installation() { + return InstallMethod::Pacman; + } + return InstallMethod::Cargo; + } + + exe_path.map_or(InstallMethod::Unknown, |p| { + let s = p.to_string_lossy(); + + if s.contains("homebrew") || s.contains("Cellar") { + return InstallMethod::Homebrew; + } + + if s.contains("scoop") { + return if s.contains("scoop-bucket") { + InstallMethod::ScoopBucket + } else { + InstallMethod::Scoop + }; + } + + if s.contains("chocolatey") { + return InstallMethod::Chocolatey; + } + + if cfg!(target_os = "windows") { + return InstallMethod::Windows; + } + + if s.contains("/usr/bin") || s.contains("/usr/local/bin") { + if has_dnf_installation() { + return InstallMethod::Dnf; + } + if has_apt_installation() { + return InstallMethod::Apt; + } + if has_pacman_installation() { + return InstallMethod::Pacman; + } + } + + InstallMethod::Unknown + }) } #[cfg(target_os = "linux")] fn has_dnf_installation() -> bool { - Path::new("/usr/bin/rpm").exists() - && Command::new("rpm") - .args(["-q", "gitui"]) - .output() - .map(|o| o.status.success()) - .unwrap_or(false) + Path::new("/usr/bin/rpm").exists() + && Command::new("rpm") + .args(["-q", "gitui"]) + .output() + .map(|o| o.status.success()) + .unwrap_or(false) } #[cfg(not(target_os = "linux"))] fn has_dnf_installation() -> bool { - false + false } #[cfg(target_os = "linux")] fn has_apt_installation() -> bool { - Path::new("/usr/bin/dpkg").exists() - && Command::new("dpkg") - .args(["-l", "gitui"]) - .output() - .map(|o| o.status.success()) - .unwrap_or(false) + Path::new("/usr/bin/dpkg").exists() + && Command::new("dpkg") + .args(["-l", "gitui"]) + .output() + .map(|o| o.status.success()) + .unwrap_or(false) } #[cfg(not(target_os = "linux"))] fn has_apt_installation() -> bool { - false + false } #[cfg(target_os = "linux")] fn has_pacman_installation() -> bool { - Path::new("/usr/bin/pacman").exists() - && Command::new("pacman") - .args(["-Q", "gitui"]) - .output() - .map(|o| o.status.success()) - .unwrap_or(false) + Path::new("/usr/bin/pacman").exists() + && Command::new("pacman") + .args(["-Q", "gitui"]) + .output() + .map(|o| o.status.success()) + .unwrap_or(false) } #[cfg(not(target_os = "linux"))] fn has_pacman_installation() -> bool { - false + false } diff --git a/src/update/mod.rs b/src/update/mod.rs index a64f183537..b217d928b7 100644 --- a/src/update/mod.rs +++ b/src/update/mod.rs @@ -11,130 +11,160 @@ use std::io::{self, Write}; use std::process::Command; pub fn self_update(include_prerelease: bool) -> Result<()> { - let current = get_current_version(); - let method = detect_install_method(); - - println!("gitui version: {}", current); - - if is_prerelease(¤t) { - println!("⚠️ Pre-release version detected."); - if !include_prerelease { - println!(" Use 'gitui update -n' to include pre-releases."); - } - } - - println!("Installation method: {}", method); - println!("Checking for updates..."); - - let latest = if include_prerelease { - fetch_latest_version() - } else { - fetch_latest_stable() - }; - - match latest { - Some(v) if v == current => { - println!("Already up to date ({})", current); - return Ok(()); - } - Some(v) => { - let kind = if is_prerelease(&v) { "Pre-release" } else { "Stable" }; - println!("{} update available: {} -> {}", kind, current, v); - } - None => println!("Could not determine latest version."), - } - - if !confirm("Do you want to update gitui?")? { - println!("Update cancelled."); - return Ok(()); - } - - println!("Updating via {}...", method); - - let result = match method { - InstallMethod::Cargo => update_via_cargo(), - InstallMethod::Homebrew => update_via_homebrew(), - InstallMethod::Dnf => update_via_dnf(), - InstallMethod::Apt => update_via_apt(), - InstallMethod::Pacman => update_via_pacman(), - InstallMethod::Scoop => update_via_scoop(), - InstallMethod::Chocolatey => update_via_chocolatey(), - InstallMethod::ScoopBucket => update_via_scoop_bucket(), - InstallMethod::Windows => update_via_windows(), - InstallMethod::Unknown => Err("Unknown installation method".to_string()), - }; - - match result { - Ok(_) => { - println!("Update complete! Please restart gitui."); - Ok(()) - } - Err(e) => Err(anyhow!("Update failed: {}", e)), - } + let current = get_current_version(); + let method = detect_install_method(); + + println!("gitui version: {}", current); + + if is_prerelease(¤t) { + println!("⚠️ Pre-release version detected."); + if !include_prerelease { + println!( + " Use 'gitui update -n' to include pre-releases." + ); + } + } + + println!("Installation method: {}", method); + println!("Checking for updates..."); + + let latest = if include_prerelease { + fetch_latest_version() + } else { + fetch_latest_stable() + }; + + match latest { + Some(v) if v == current => { + println!("Already up to date ({})", current); + return Ok(()); + } + Some(v) => { + let kind = if is_prerelease(&v) { + "Pre-release" + } else { + "Stable" + }; + println!( + "{} update available: {} -> {}", + kind, current, v + ); + } + None => println!("Could not determine latest version."), + } + + if !confirm("Do you want to update gitui?")? { + println!("Update cancelled."); + return Ok(()); + } + + println!("Updating via {}...", method); + + let result = match method { + InstallMethod::Cargo => update_via_cargo(), + InstallMethod::Homebrew => update_via_homebrew(), + InstallMethod::Dnf => update_via_dnf(), + InstallMethod::Apt => update_via_apt(), + InstallMethod::Pacman => update_via_pacman(), + InstallMethod::Scoop => update_via_scoop(), + InstallMethod::Chocolatey => update_via_chocolatey(), + InstallMethod::ScoopBucket => update_via_scoop_bucket(), + InstallMethod::Windows => update_via_windows(), + InstallMethod::Unknown => { + Err("Unknown installation method".to_string()) + } + }; + + match result { + Ok(_) => { + println!("Update complete! Please restart gitui."); + Ok(()) + } + Err(e) => Err(anyhow!("Update failed: {}", e)), + } } fn get_current_version() -> String { - let build = env!("GITUI_BUILD_NAME"); - build.split_whitespace().next().unwrap_or(build).to_string() + let build = env!("GITUI_BUILD_NAME"); + build.split_whitespace().next().unwrap_or(build).to_string() } fn is_prerelease(v: &str) -> bool { - let lower = v.to_lowercase(); - ["nightly", "-rc", "-beta", "-alpha", "-dev", "preview", "snapshot"] - .iter() - .any(|&s| lower.contains(s)) + let lower = v.to_lowercase(); + [ + "nightly", "-rc", "-beta", "-alpha", "-dev", "preview", + "snapshot", + ] + .iter() + .any(|&s| lower.contains(s)) } fn fetch_latest_version() -> Option { - let output = Command::new("git") - .args(["ls-remote", "--tags", "--sort=-v:refname", "https://github.com/extrawurst/gitui.git"]) - .output() - .ok()?; - - if !output.status.success() { - return None; - } - - String::from_utf8_lossy(&output.stdout) - .lines() - .filter_map(|line| { - line.split('\t').nth(1)?.strip_prefix("refs/tags/")?.strip_prefix('v') - }) - .next() - .map(String::from) + let output = Command::new("git") + .args([ + "ls-remote", + "--tags", + "--sort=-v:refname", + "https://github.com/extrawurst/gitui.git", + ]) + .output() + .ok()?; + + if !output.status.success() { + return None; + } + + String::from_utf8_lossy(&output.stdout) + .lines() + .filter_map(|line| { + line.split('\t') + .nth(1)? + .strip_prefix("refs/tags/")? + .strip_prefix('v') + }) + .next() + .map(String::from) } fn fetch_latest_stable() -> Option { - let output = Command::new("git") - .args(["ls-remote", "--tags", "--sort=-v:refname", "https://github.com/extrawurst/gitui.git"]) - .output() - .ok()?; - - if !output.status.success() { - return None; - } - - let version = String::from_utf8_lossy(&output.stdout) - .lines() - .filter_map(|line| { - line.split('\t').nth(1)?.strip_prefix("refs/tags/")?.strip_prefix('v') - }) - .find(|&v| !is_prerelease(v)) - .map(String::from); - - if version.is_none() { - println!("Warning: No stable release found. Use -n for pre-releases."); - } - - version + let output = Command::new("git") + .args([ + "ls-remote", + "--tags", + "--sort=-v:refname", + "https://github.com/extrawurst/gitui.git", + ]) + .output() + .ok()?; + + if !output.status.success() { + return None; + } + + let version = String::from_utf8_lossy(&output.stdout) + .lines() + .filter_map(|line| { + line.split('\t') + .nth(1)? + .strip_prefix("refs/tags/")? + .strip_prefix('v') + }) + .find(|&v| !is_prerelease(v)) + .map(String::from); + + if version.is_none() { + println!("Warning: No stable release found. Use -n for pre-releases."); + } + + version } fn confirm(prompt: &str) -> Result { - print!("{} [y/N]: ", prompt); - io::stdout().flush()?; + print!("{} [y/N]: ", prompt); + io::stdout().flush()?; - let mut input = String::new(); - io::stdin().read_line(&mut input)?; + let mut input = String::new(); + io::stdin().read_line(&mut input)?; - Ok(input.trim().eq_ignore_ascii_case("y")) + Ok(input.trim().eq_ignore_ascii_case("y")) } From ad37dc6be425d8ff58e0d47f3a7de8f0090064b4 Mon Sep 17 00:00:00 2001 From: Erikk Shupp Date: Mon, 11 May 2026 11:07:15 -0400 Subject: [PATCH 6/6] Add unit tests for update module --- src/update/detector.rs | 44 ++++++++++++++++++++++++++++++ src/update/mod.rs | 61 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+) diff --git a/src/update/detector.rs b/src/update/detector.rs index 20727e7fbe..e5b924727e 100644 --- a/src/update/detector.rs +++ b/src/update/detector.rs @@ -147,3 +147,47 @@ fn has_pacman_installation() -> bool { fn has_pacman_installation() -> bool { false } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_install_method_display() { + assert_eq!(InstallMethod::Cargo.to_string(), "cargo"); + assert_eq!(InstallMethod::Homebrew.to_string(), "homebrew"); + assert_eq!(InstallMethod::Apt.to_string(), "apt"); + assert_eq!(InstallMethod::Dnf.to_string(), "dnf"); + assert_eq!(InstallMethod::Pacman.to_string(), "pacman"); + assert_eq!(InstallMethod::Windows.to_string(), "windows"); + assert_eq!(InstallMethod::Scoop.to_string(), "scoop"); + assert_eq!( + InstallMethod::Chocolatey.to_string(), + "chocolatey" + ); + assert_eq!( + InstallMethod::ScoopBucket.to_string(), + "scoop-bucket" + ); + assert_eq!(InstallMethod::Unknown.to_string(), "unknown"); + } + + #[test] + fn test_install_method_equality() { + assert_eq!(InstallMethod::Cargo, InstallMethod::Cargo); + assert_ne!(InstallMethod::Cargo, InstallMethod::Dnf); + } + + #[test] + fn test_install_method_clone() { + let method = InstallMethod::Dnf; + let cloned = method.clone(); + assert_eq!(method, cloned); + } + + #[test] + fn test_install_method_debug() { + let debug_str = format!("{:?}", InstallMethod::Cargo); + assert!(debug_str.contains("Cargo")); + } +} diff --git a/src/update/mod.rs b/src/update/mod.rs index b217d928b7..2077eff4a4 100644 --- a/src/update/mod.rs +++ b/src/update/mod.rs @@ -168,3 +168,64 @@ fn confirm(prompt: &str) -> Result { Ok(input.trim().eq_ignore_ascii_case("y")) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_is_prerelease_nightly() { + assert!(is_prerelease("0.28.1-nightly")); + assert!(is_prerelease("0.28.1-NIGHTLY")); + } + + #[test] + fn test_is_prerelease_rc() { + assert!(is_prerelease("0.28.1-rc.1")); + assert!(is_prerelease("0.28.1-RC.1")); + } + + #[test] + fn test_is_prerelease_beta() { + assert!(is_prerelease("0.28.1-beta")); + assert!(is_prerelease("0.28.0-beta.2")); + } + + #[test] + fn test_is_prerelease_alpha() { + assert!(is_prerelease("0.28.1-alpha")); + assert!(is_prerelease("0.28.0-alpha.1")); + } + + #[test] + fn test_is_prerelease_dev() { + assert!(is_prerelease("0.28.1-dev")); + assert!(is_prerelease("0.28.0-dev.20240101")); + } + + #[test] + fn test_is_prerelease_preview() { + assert!(is_prerelease("0.28.1-preview")); + assert!(is_prerelease("0.28.0-preview.3")); + } + + #[test] + fn test_is_prerelease_snapshot() { + assert!(is_prerelease("0.28.1-snapshot")); + } + + #[test] + fn test_is_not_prerelease_stable() { + assert!(!is_prerelease("0.28.1")); + assert!(!is_prerelease("0.28.0")); + assert!(!is_prerelease("1.0.0")); + } + + #[test] + fn test_is_not_prerelease_version_with_prerelease_substring() { + // Ensure we don't false-positive on versions that contain prerelease keywords + // but aren't actually prereleases (e.g., "0.28.1-nightly-feature" wouldn't be valid anyway) + assert!(is_prerelease("0.28.1-nightly")); + assert!(!is_prerelease("0.28.1")); + } +}