This commit is contained in:
Shupp 2026-05-11 11:08:47 -04:00 committed by GitHub
commit e751eb6c0c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 606 additions and 38 deletions

View file

@ -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))

View file

@ -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,
@ -36,14 +37,23 @@ pub struct CliArgs {
}
pub fn process_cmdline() -> Result<CliArgs> {
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);
}
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);
}
std::process::exit(0);
}
if arg_matches.get_flag(LOGGING_FLAG_ID) {
let logfile = arg_matches.get_one::<String>(LOG_FILE_FLAG_ID);
setup_logging(logfile.map(PathBuf::from))?;
@ -81,7 +91,7 @@ pub fn process_cmdline() -> Result<CliArgs> {
})?;
let theme = confpath.join(arg_theme);
let notify_watcher: bool =
let notify_watcher =
*arg_matches.get_one(WATCHER_FLAG_ID).unwrap_or(&false);
let key_bindings_path = arg_matches
@ -118,7 +128,7 @@ fn app() -> ClapApp {
{all-args}{after-help}
",
)
.arg(
.arg(
Arg::new(KEY_BINDINGS_FLAG_ID)
.help("Use a custom keybindings file")
.short('k')
@ -126,7 +136,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')
@ -145,19 +155,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),
)
@ -190,49 +202,46 @@ 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("nightly")
.help("Include pre-release versions")
.short('n')
.long("nightly")
.action(clap::ArgAction::SetTrue),
),
)
}
fn setup_logging(path_override: Option<PathBuf>) -> 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)?,
)?;
Ok(())
}
fn get_app_cache_path() -> Result<PathBuf> {
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<PathBuf> {
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)

View file

@ -78,6 +78,7 @@ mod string_utils;
mod strings;
mod tabs;
mod ui;
mod update;
mod watcher;
use crate::{

127
src/update/commands.rs Normal file
View file

@ -0,0 +1,127 @@
//! 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())
}

193
src/update/detector.rs Normal file
View file

@ -0,0 +1,193 @@
//! 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
}
#[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"));
}
}

231
src/update/mod.rs Normal file
View file

@ -0,0 +1,231 @@
//! 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(&current) {
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<String> {
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<String> {
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<bool> {
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"))
}
#[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"));
}
}