From fa18327cb9c7e9120c0d3f3aea3ac45060d669e3 Mon Sep 17 00:00:00 2001 From: Erikk Shupp Date: Mon, 11 May 2026 10:32:14 -0400 Subject: [PATCH] [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 22c6cc8d..ec80e81d 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<()> {