Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
437584aac7 | ||
|
|
960724685c | ||
|
|
639bcdf643 | ||
|
|
ddb4b70234 | ||
|
|
32155bc1d2 | ||
|
|
e4cc280f28 | ||
|
|
868540b92a | ||
|
|
0af46bd6a5 | ||
|
|
ba3f923781 |
+2
-2
@@ -53,7 +53,7 @@ trigger:
|
|||||||
event:
|
event:
|
||||||
- tag
|
- tag
|
||||||
ref:
|
ref:
|
||||||
- refs/tags/v*
|
- refs/tags/[0-9]*
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: build-x86_64
|
- name: build-x86_64
|
||||||
@@ -79,7 +79,7 @@ steps:
|
|||||||
# Read DRONE_TAG via ENVIRON inside awk to avoid Drone's ${VAR} substitution
|
# Read DRONE_TAG via ENVIRON inside awk to avoid Drone's ${VAR} substitution
|
||||||
# which would replace ${TAG} with an empty string before the shell runs.
|
# which would replace ${TAG} with an empty string before the shell runs.
|
||||||
BODY=$(awk '
|
BODY=$(awk '
|
||||||
BEGIN { tag = ENVIRON["DRONE_TAG"]; gsub(/^v/, "", tag) }
|
BEGIN { tag = ENVIRON["DRONE_TAG"] }
|
||||||
/^## \[/ { in_section = (index($0, "[" tag "]") > 0); next }
|
/^## \[/ { in_section = (index($0, "[" tag "]") > 0); next }
|
||||||
in_section && /^## \[/ { exit }
|
in_section && /^## \[/ { exit }
|
||||||
in_section { print }
|
in_section { print }
|
||||||
|
|||||||
@@ -4,6 +4,24 @@ All notable changes to this project will be documented in this file.
|
|||||||
|
|
||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||||
|
|
||||||
|
## [0.4.2] - 2026-03-01
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Version mismatch: bumped Cargo.toml version to match release tag, fixing `--update` false positive
|
||||||
|
|
||||||
|
## [0.4.1] - 2026-03-01
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Self-update feature (`tmuxido --update`) to update binary from latest GitHub release
|
||||||
|
|
||||||
|
## [0.4.0] - 2026-03-01
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Self-update feature (`tmuxido --update`) to update binary from latest GitHub release
|
||||||
|
- New `self_update` module with version comparison and atomic binary replacement
|
||||||
|
- `--update` CLI flag for in-place binary updates
|
||||||
|
- Backup and rollback mechanism if update fails
|
||||||
|
|
||||||
## [0.3.0] - 2026-03-01
|
## [0.3.0] - 2026-03-01
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -53,3 +71,4 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
- Initial release of tmuxido
|
- Initial release of tmuxido
|
||||||
|
|
||||||
|
|||||||
Generated
+1
-1
@@ -542,7 +542,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tmuxido"
|
name = "tmuxido"
|
||||||
version = "0.2.4"
|
version = "0.4.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"clap",
|
"clap",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "tmuxido"
|
name = "tmuxido"
|
||||||
version = "0.2.4"
|
version = "0.4.3"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="docs/assets/tmuxido-logo.png" alt="tmuxido logo" width="200"/>
|
<img src="docs/assets/tmuxido-logo.png" alt="tmuxido logo" width="200"/>
|
||||||
</div>
|
</div>
|
||||||
|
<div align="center">
|
||||||
# tmuxido
|
|
||||||
|
|
||||||
[](https://drone.cincoeuzebio.com/cinco/Tmuxido)
|
[](https://drone.cincoeuzebio.com/cinco/Tmuxido)
|
||||||
[](https://drone.cincoeuzebio.com/cinco/Tmuxido)
|
[](https://drone.cincoeuzebio.com/cinco/Tmuxido)
|
||||||
[](https://git.cincoeuzebio.com/cinco/Tmuxido/releases)
|
[](https://git.cincoeuzebio.com/cinco/Tmuxido/releases)
|
||||||

|

|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
# tmuxido
|
||||||
|
|
||||||
A Rust-based tool to quickly find and open projects in tmux using fzf. No external dependencies except tmux and fzf!
|
A Rust-based tool to quickly find and open projects in tmux using fzf. No external dependencies except tmux and fzf!
|
||||||
|
|
||||||
@@ -21,6 +24,7 @@ A Rust-based tool to quickly find and open projects in tmux using fzf. No extern
|
|||||||
- TOML-based configuration
|
- TOML-based configuration
|
||||||
- Smart caching system for fast subsequent runs
|
- Smart caching system for fast subsequent runs
|
||||||
- Configurable cache TTL
|
- Configurable cache TTL
|
||||||
|
- Self-update capability (`tmuxido --update`)
|
||||||
- Zero external dependencies (except tmux and fzf)
|
- Zero external dependencies (except tmux and fzf)
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
@@ -97,6 +101,11 @@ Check cache status:
|
|||||||
tmuxido --cache-status
|
tmuxido --cache-status
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Update tmuxido to the latest version:
|
||||||
|
```bash
|
||||||
|
tmuxido --update
|
||||||
|
```
|
||||||
|
|
||||||
View help:
|
View help:
|
||||||
```bash
|
```bash
|
||||||
tmuxido --help
|
tmuxido --help
|
||||||
@@ -161,3 +170,15 @@ Each window can have multiple panes with commands that run automatically:
|
|||||||
- First pane is the main window pane
|
- First pane is the main window pane
|
||||||
- Additional panes are created by splitting
|
- Additional panes are created by splitting
|
||||||
- Empty panes array = just open the window in the project directory
|
- Empty panes array = just open the window in the project directory
|
||||||
|
|
||||||
|
## Author
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<a href="https://github.com/cinco">
|
||||||
|
<img src="https://github.com/cinco.png" width="100" height="100" style="border-radius: 50%;" alt="Cinco avatar"/>
|
||||||
|
</a>
|
||||||
|
<br><br>
|
||||||
|
<strong>Cinco</strong>
|
||||||
|
<br>
|
||||||
|
<a href="https://github.com/cinco">@cinco</a>
|
||||||
|
</div>
|
||||||
|
|||||||
+270
-8
@@ -1,6 +1,7 @@
|
|||||||
pub mod cache;
|
pub mod cache;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod deps;
|
pub mod deps;
|
||||||
|
pub mod self_update;
|
||||||
pub mod session;
|
pub mod session;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
@@ -37,28 +38,45 @@ pub fn show_cache_status(config: &Config) -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_projects(config: &Config, force_refresh: bool) -> Result<Vec<PathBuf>> {
|
pub fn get_projects(config: &Config, force_refresh: bool) -> Result<Vec<PathBuf>> {
|
||||||
|
get_projects_internal(
|
||||||
|
config,
|
||||||
|
force_refresh,
|
||||||
|
&ProjectCache::load,
|
||||||
|
&|cache| cache.save(),
|
||||||
|
&scan_all_roots,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::type_complexity)]
|
||||||
|
fn get_projects_internal(
|
||||||
|
config: &Config,
|
||||||
|
force_refresh: bool,
|
||||||
|
cache_loader: &dyn Fn() -> Result<Option<ProjectCache>>,
|
||||||
|
cache_saver: &dyn Fn(&ProjectCache) -> Result<()>,
|
||||||
|
scanner: &dyn Fn(&Config) -> Result<(Vec<PathBuf>, HashMap<PathBuf, u64>)>,
|
||||||
|
) -> Result<Vec<PathBuf>> {
|
||||||
if !config.cache_enabled || force_refresh {
|
if !config.cache_enabled || force_refresh {
|
||||||
let (projects, fingerprints) = scan_all_roots(config)?;
|
let (projects, fingerprints) = scanner(config)?;
|
||||||
let cache = ProjectCache::new(projects.clone(), fingerprints);
|
let cache = ProjectCache::new(projects.clone(), fingerprints);
|
||||||
cache.save()?;
|
cache_saver(&cache)?;
|
||||||
eprintln!("Cache updated with {} projects", projects.len());
|
eprintln!("Cache updated with {} projects", projects.len());
|
||||||
return Ok(projects);
|
return Ok(projects);
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(mut cache) = ProjectCache::load()? {
|
if let Some(mut cache) = cache_loader()? {
|
||||||
// Cache no formato antigo (sem dir_mtimes) → atualizar com rescan completo
|
// Cache no formato antigo (sem dir_mtimes) → atualizar com rescan completo
|
||||||
if cache.dir_mtimes.is_empty() {
|
if cache.dir_mtimes.is_empty() {
|
||||||
eprintln!("Upgrading cache, scanning for projects...");
|
eprintln!("Upgrading cache, scanning for projects...");
|
||||||
let (projects, fingerprints) = scan_all_roots(config)?;
|
let (projects, fingerprints) = scanner(config)?;
|
||||||
let new_cache = ProjectCache::new(projects.clone(), fingerprints);
|
let new_cache = ProjectCache::new(projects.clone(), fingerprints);
|
||||||
new_cache.save()?;
|
cache_saver(&new_cache)?;
|
||||||
eprintln!("Cache updated with {} projects", projects.len());
|
eprintln!("Cache updated with {} projects", projects.len());
|
||||||
return Ok(projects);
|
return Ok(projects);
|
||||||
}
|
}
|
||||||
|
|
||||||
let changed = cache.validate_and_update(&|root| scan_from_root(root, config))?;
|
let changed = cache.validate_and_update(&|root| scan_from_root(root, config))?;
|
||||||
if changed {
|
if changed {
|
||||||
cache.save()?;
|
cache_saver(&cache)?;
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"Cache updated incrementally ({} projects)",
|
"Cache updated incrementally ({} projects)",
|
||||||
cache.projects.len()
|
cache.projects.len()
|
||||||
@@ -71,9 +89,9 @@ pub fn get_projects(config: &Config, force_refresh: bool) -> Result<Vec<PathBuf>
|
|||||||
|
|
||||||
// Sem cache ainda — scan completo inicial
|
// Sem cache ainda — scan completo inicial
|
||||||
eprintln!("No cache found, scanning for projects...");
|
eprintln!("No cache found, scanning for projects...");
|
||||||
let (projects, fingerprints) = scan_all_roots(config)?;
|
let (projects, fingerprints) = scanner(config)?;
|
||||||
let cache = ProjectCache::new(projects.clone(), fingerprints);
|
let cache = ProjectCache::new(projects.clone(), fingerprints);
|
||||||
cache.save()?;
|
cache_saver(&cache)?;
|
||||||
eprintln!("Cache updated with {} projects", projects.len());
|
eprintln!("Cache updated with {} projects", projects.len());
|
||||||
Ok(projects)
|
Ok(projects)
|
||||||
}
|
}
|
||||||
@@ -161,3 +179,247 @@ pub fn launch_tmux_session(selected: &Path, config: &Config) -> Result<()> {
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::cell::RefCell;
|
||||||
|
|
||||||
|
fn create_test_config(cache_enabled: bool) -> Config {
|
||||||
|
Config {
|
||||||
|
paths: vec!["/tmp/test".to_string()],
|
||||||
|
max_depth: 3,
|
||||||
|
cache_enabled,
|
||||||
|
cache_ttl_hours: 24,
|
||||||
|
default_session: session::SessionConfig { windows: vec![] },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_scan_when_cache_disabled() {
|
||||||
|
let config = create_test_config(false);
|
||||||
|
let projects = vec![PathBuf::from("/tmp/test/project1")];
|
||||||
|
let fingerprints = HashMap::new();
|
||||||
|
let expected_projects = projects.clone();
|
||||||
|
|
||||||
|
let scanner_called = RefCell::new(false);
|
||||||
|
let saver_called = RefCell::new(false);
|
||||||
|
|
||||||
|
let result = get_projects_internal(
|
||||||
|
&config,
|
||||||
|
false,
|
||||||
|
&|| panic!("should not load cache when disabled"),
|
||||||
|
&|_| {
|
||||||
|
*saver_called.borrow_mut() = true;
|
||||||
|
Ok(())
|
||||||
|
},
|
||||||
|
&|_| {
|
||||||
|
*scanner_called.borrow_mut() = true;
|
||||||
|
Ok((expected_projects.clone(), fingerprints.clone()))
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(result.is_ok());
|
||||||
|
assert!(scanner_called.into_inner());
|
||||||
|
assert!(saver_called.into_inner());
|
||||||
|
assert_eq!(result.unwrap(), projects);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_scan_when_force_refresh() {
|
||||||
|
let config = create_test_config(true);
|
||||||
|
let projects = vec![PathBuf::from("/tmp/test/project1")];
|
||||||
|
let fingerprints = HashMap::new();
|
||||||
|
let expected_projects = projects.clone();
|
||||||
|
|
||||||
|
let scanner_called = RefCell::new(false);
|
||||||
|
let saver_called = RefCell::new(false);
|
||||||
|
|
||||||
|
let result = get_projects_internal(
|
||||||
|
&config,
|
||||||
|
true,
|
||||||
|
&|| panic!("should not load cache when force refresh"),
|
||||||
|
&|_| {
|
||||||
|
*saver_called.borrow_mut() = true;
|
||||||
|
Ok(())
|
||||||
|
},
|
||||||
|
&|_| {
|
||||||
|
*scanner_called.borrow_mut() = true;
|
||||||
|
Ok((expected_projects.clone(), fingerprints.clone()))
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(result.is_ok());
|
||||||
|
assert!(scanner_called.into_inner());
|
||||||
|
assert!(saver_called.into_inner());
|
||||||
|
assert_eq!(result.unwrap(), projects);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_do_initial_scan_when_no_cache_exists() {
|
||||||
|
let config = create_test_config(true);
|
||||||
|
let projects = vec![PathBuf::from("/tmp/test/project1")];
|
||||||
|
let fingerprints = HashMap::new();
|
||||||
|
let expected_projects = projects.clone();
|
||||||
|
|
||||||
|
let loader_called = RefCell::new(false);
|
||||||
|
let scanner_called = RefCell::new(false);
|
||||||
|
let saver_called = RefCell::new(false);
|
||||||
|
|
||||||
|
let result = get_projects_internal(
|
||||||
|
&config,
|
||||||
|
false,
|
||||||
|
&|| {
|
||||||
|
*loader_called.borrow_mut() = true;
|
||||||
|
Ok(None)
|
||||||
|
},
|
||||||
|
&|_| {
|
||||||
|
*saver_called.borrow_mut() = true;
|
||||||
|
Ok(())
|
||||||
|
},
|
||||||
|
&|_| {
|
||||||
|
*scanner_called.borrow_mut() = true;
|
||||||
|
Ok((expected_projects.clone(), fingerprints.clone()))
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(result.is_ok());
|
||||||
|
assert!(loader_called.into_inner());
|
||||||
|
assert!(scanner_called.into_inner());
|
||||||
|
assert!(saver_called.into_inner());
|
||||||
|
assert_eq!(result.unwrap(), projects);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_upgrade_old_cache_format() {
|
||||||
|
let config = create_test_config(true);
|
||||||
|
let old_projects = vec![PathBuf::from("/old/project")];
|
||||||
|
let new_projects = vec![
|
||||||
|
PathBuf::from("/new/project1"),
|
||||||
|
PathBuf::from("/new/project2"),
|
||||||
|
];
|
||||||
|
let new_fingerprints = HashMap::from([(PathBuf::from("/new"), 12345u64)]);
|
||||||
|
|
||||||
|
// Use RefCell<Option<>> to allow moving into closure multiple times
|
||||||
|
let old_cache = RefCell::new(Some(ProjectCache::new(old_projects, HashMap::new())));
|
||||||
|
|
||||||
|
let loader_called = RefCell::new(false);
|
||||||
|
let scanner_called = RefCell::new(false);
|
||||||
|
let saver_count = RefCell::new(0);
|
||||||
|
|
||||||
|
let result = get_projects_internal(
|
||||||
|
&config,
|
||||||
|
false,
|
||||||
|
&|| {
|
||||||
|
*loader_called.borrow_mut() = true;
|
||||||
|
// Take the cache out of the RefCell
|
||||||
|
Ok(old_cache.borrow_mut().take())
|
||||||
|
},
|
||||||
|
&|_| {
|
||||||
|
*saver_count.borrow_mut() += 1;
|
||||||
|
Ok(())
|
||||||
|
},
|
||||||
|
&|_| {
|
||||||
|
*scanner_called.borrow_mut() = true;
|
||||||
|
Ok((new_projects.clone(), new_fingerprints.clone()))
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(result.is_ok());
|
||||||
|
assert!(loader_called.into_inner());
|
||||||
|
assert!(scanner_called.into_inner());
|
||||||
|
assert_eq!(*saver_count.borrow(), 1);
|
||||||
|
assert_eq!(result.unwrap(), new_projects);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_use_cached_projects_when_nothing_changed() {
|
||||||
|
let config = create_test_config(true);
|
||||||
|
let cached_projects = vec![
|
||||||
|
PathBuf::from("/nonexistent/project1"),
|
||||||
|
PathBuf::from("/nonexistent/project2"),
|
||||||
|
];
|
||||||
|
// Use a path that doesn't exist - validate_and_update will skip rescan
|
||||||
|
// because it can't check mtime of non-existent directory
|
||||||
|
let cached_fingerprints =
|
||||||
|
HashMap::from([(PathBuf::from("/definitely_nonexistent_path_xyz"), 12345u64)]);
|
||||||
|
|
||||||
|
// Use RefCell<Option<>> to allow moving into closure multiple times
|
||||||
|
let cache = RefCell::new(Some(ProjectCache::new(
|
||||||
|
cached_projects.clone(),
|
||||||
|
cached_fingerprints,
|
||||||
|
)));
|
||||||
|
|
||||||
|
let loader_called = RefCell::new(false);
|
||||||
|
let scanner_called = RefCell::new(false);
|
||||||
|
let saver_count = RefCell::new(0);
|
||||||
|
|
||||||
|
let result = get_projects_internal(
|
||||||
|
&config,
|
||||||
|
false,
|
||||||
|
&|| {
|
||||||
|
*loader_called.borrow_mut() = true;
|
||||||
|
// Take the cache out of the RefCell
|
||||||
|
Ok(cache.borrow_mut().take())
|
||||||
|
},
|
||||||
|
&|_| {
|
||||||
|
*saver_count.borrow_mut() += 1;
|
||||||
|
Ok(())
|
||||||
|
},
|
||||||
|
&|_| {
|
||||||
|
*scanner_called.borrow_mut() = true;
|
||||||
|
panic!("should not do full scan when cache is valid")
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(result.is_ok());
|
||||||
|
assert!(loader_called.into_inner());
|
||||||
|
// Note: When the directory in dir_mtimes doesn't exist, validate_and_update
|
||||||
|
// treats it as "changed" and removes projects under that path.
|
||||||
|
// This test verifies the flow completes - the specific behavior of
|
||||||
|
// validate_and_update is tested separately in cache.rs
|
||||||
|
let result_projects = result.unwrap();
|
||||||
|
// Projects were removed because the tracked directory doesn't exist
|
||||||
|
assert!(result_projects.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_update_incrementally_when_cache_changed() {
|
||||||
|
let config = create_test_config(true);
|
||||||
|
let initial_projects = vec![PathBuf::from("/nonexistent/project1")];
|
||||||
|
// Use a path that doesn't exist - validate_and_update will treat missing
|
||||||
|
// directory as a change (unwrap_or(true) in the mtime check)
|
||||||
|
let mut dir_mtimes = HashMap::new();
|
||||||
|
dir_mtimes.insert(PathBuf::from("/definitely_nonexistent_path_abc"), 0u64);
|
||||||
|
|
||||||
|
// Use RefCell<Option<>> to allow moving into closure multiple times
|
||||||
|
let cache = RefCell::new(Some(ProjectCache::new(initial_projects, dir_mtimes)));
|
||||||
|
|
||||||
|
let loader_called = RefCell::new(false);
|
||||||
|
let saver_called = RefCell::new(false);
|
||||||
|
|
||||||
|
let result = get_projects_internal(
|
||||||
|
&config,
|
||||||
|
false,
|
||||||
|
&|| {
|
||||||
|
*loader_called.borrow_mut() = true;
|
||||||
|
// Take the cache out of the RefCell
|
||||||
|
Ok(cache.borrow_mut().take())
|
||||||
|
},
|
||||||
|
&|_| {
|
||||||
|
*saver_called.borrow_mut() = true;
|
||||||
|
Ok(())
|
||||||
|
},
|
||||||
|
&|_| panic!("full scan should not happen with incremental update"),
|
||||||
|
);
|
||||||
|
|
||||||
|
// validate_and_update is called internally. Since the directory doesn't exist,
|
||||||
|
// it treats it as "changed" and will try to rescan using scan_from_root.
|
||||||
|
// We verify the flow completes without panicking.
|
||||||
|
|
||||||
|
assert!(result.is_ok());
|
||||||
|
assert!(loader_called.into_inner());
|
||||||
|
// Note: The saver may or may not be called depending on whether
|
||||||
|
// validate_and_update detects changes (missing dir = change)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+10
@@ -5,6 +5,7 @@ use std::path::PathBuf;
|
|||||||
use std::process::{Command, Stdio};
|
use std::process::{Command, Stdio};
|
||||||
use tmuxido::config::Config;
|
use tmuxido::config::Config;
|
||||||
use tmuxido::deps::ensure_dependencies;
|
use tmuxido::deps::ensure_dependencies;
|
||||||
|
use tmuxido::self_update;
|
||||||
use tmuxido::{get_projects, launch_tmux_session, show_cache_status};
|
use tmuxido::{get_projects, launch_tmux_session, show_cache_status};
|
||||||
|
|
||||||
#[derive(Parser, Debug)]
|
#[derive(Parser, Debug)]
|
||||||
@@ -24,11 +25,20 @@ struct Args {
|
|||||||
/// Show cache status and exit
|
/// Show cache status and exit
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
cache_status: bool,
|
cache_status: bool,
|
||||||
|
|
||||||
|
/// Update tmuxido to the latest version
|
||||||
|
#[arg(long)]
|
||||||
|
update: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn main() -> Result<()> {
|
fn main() -> Result<()> {
|
||||||
let args = Args::parse();
|
let args = Args::parse();
|
||||||
|
|
||||||
|
// Handle self-update before anything else
|
||||||
|
if args.update {
|
||||||
|
return self_update::self_update();
|
||||||
|
}
|
||||||
|
|
||||||
// Check that fzf and tmux are installed; offer to install if missing
|
// Check that fzf and tmux are installed; offer to install if missing
|
||||||
ensure_dependencies()?;
|
ensure_dependencies()?;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
use anyhow::{Context, Result};
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
const REPO: &str = "cinco/Tmuxido";
|
||||||
|
const BASE_URL: &str = "https://git.cincoeuzebio.com";
|
||||||
|
|
||||||
|
/// Check if running from cargo (development mode)
|
||||||
|
fn is_dev_build() -> bool {
|
||||||
|
option_env!("CARGO_PKG_NAME").is_none()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get current version from cargo
|
||||||
|
pub fn current_version() -> &'static str {
|
||||||
|
env!("CARGO_PKG_VERSION")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Detect system architecture
|
||||||
|
fn detect_arch() -> Result<&'static str> {
|
||||||
|
let arch = std::env::consts::ARCH;
|
||||||
|
match arch {
|
||||||
|
"x86_64" => Ok("x86_64-linux"),
|
||||||
|
"aarch64" => Ok("aarch64-linux"),
|
||||||
|
_ => Err(anyhow::anyhow!("Unsupported architecture: {}", arch)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch latest release tag from Gitea API
|
||||||
|
fn fetch_latest_tag() -> Result<String> {
|
||||||
|
let url = format!("{}/api/v1/repos/{}/releases?limit=1&page=1", BASE_URL, REPO);
|
||||||
|
|
||||||
|
let output = Command::new("curl")
|
||||||
|
.args(["-fsSL", &url])
|
||||||
|
.output()
|
||||||
|
.context("Failed to execute curl. Make sure curl is installed.")?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"Failed to fetch latest release: {}",
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = String::from_utf8_lossy(&output.stdout);
|
||||||
|
|
||||||
|
// Parse JSON response to extract tag_name
|
||||||
|
let tag: serde_json::Value =
|
||||||
|
serde_json::from_str(&response).context("Failed to parse release API response")?;
|
||||||
|
|
||||||
|
tag.get(0)
|
||||||
|
.and_then(|r| r.get("tag_name"))
|
||||||
|
.and_then(|t| t.as_str())
|
||||||
|
.map(|t| t.to_string())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Could not extract tag_name from release"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get path to current executable
|
||||||
|
fn get_current_exe() -> Result<PathBuf> {
|
||||||
|
std::env::current_exe().context("Failed to get current executable path")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Download binary to a temporary location
|
||||||
|
fn download_binary(tag: &str, arch: &str, temp_path: &std::path::Path) -> Result<()> {
|
||||||
|
let url = format!("{}/{}/releases/download/{}/{}", BASE_URL, REPO, tag, arch);
|
||||||
|
|
||||||
|
println!("Downloading {}...", url);
|
||||||
|
|
||||||
|
let output = Command::new("curl")
|
||||||
|
.args(["-fsSL", &url, "-o", &temp_path.to_string_lossy()])
|
||||||
|
.output()
|
||||||
|
.context("Failed to execute curl for download")?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"Failed to download binary: {}",
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make executable
|
||||||
|
let mut perms = std::fs::metadata(temp_path)?.permissions();
|
||||||
|
perms.set_mode(0o755);
|
||||||
|
std::fs::set_permissions(temp_path, perms)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Perform self-update
|
||||||
|
pub fn self_update() -> Result<()> {
|
||||||
|
if is_dev_build() {
|
||||||
|
println!("Development build detected. Skipping self-update.");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let current = current_version();
|
||||||
|
println!("Current version: {}", current);
|
||||||
|
|
||||||
|
let latest = fetch_latest_tag()?;
|
||||||
|
let latest_clean = latest.trim_start_matches('v');
|
||||||
|
println!("Latest version: {}", latest);
|
||||||
|
|
||||||
|
// Compare versions (simple string comparison for semver without 'v' prefix)
|
||||||
|
if latest_clean == current {
|
||||||
|
println!("Already up to date!");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if latest is actually newer
|
||||||
|
match version_compare(latest_clean, current) {
|
||||||
|
std::cmp::Ordering::Less => {
|
||||||
|
println!("Current version is newer than release. Skipping update.");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
std::cmp::Ordering::Equal => {
|
||||||
|
println!("Already up to date!");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
let arch = detect_arch()?;
|
||||||
|
let exe_path = get_current_exe()?;
|
||||||
|
|
||||||
|
// Create temporary file in same directory as target (for atomic rename)
|
||||||
|
let exe_dir = exe_path
|
||||||
|
.parent()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Could not determine executable directory"))?;
|
||||||
|
let temp_path = exe_dir.join(".tmuxido.new");
|
||||||
|
|
||||||
|
println!("Downloading update...");
|
||||||
|
download_binary(&latest, arch, &temp_path)?;
|
||||||
|
|
||||||
|
// Verify the downloaded binary works
|
||||||
|
let verify = Command::new(&temp_path).arg("--version").output();
|
||||||
|
if let Err(e) = verify {
|
||||||
|
let _ = std::fs::remove_file(&temp_path);
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"Downloaded binary verification failed: {}",
|
||||||
|
e
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atomic replace: rename old to .old, rename new to target
|
||||||
|
let backup_path = exe_path.with_extension("old");
|
||||||
|
|
||||||
|
// Remove old backup if exists
|
||||||
|
let _ = std::fs::remove_file(&backup_path);
|
||||||
|
|
||||||
|
// Rename current to backup
|
||||||
|
std::fs::rename(&exe_path, &backup_path)
|
||||||
|
.context("Failed to backup current binary (is tmuxido running?)")?;
|
||||||
|
|
||||||
|
// Move new to current location
|
||||||
|
if let Err(e) = std::fs::rename(&temp_path, &exe_path) {
|
||||||
|
// Restore backup on failure
|
||||||
|
let _ = std::fs::rename(&backup_path, &exe_path);
|
||||||
|
return Err(anyhow::anyhow!("Failed to install new binary: {}", e));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove backup on success
|
||||||
|
let _ = std::fs::remove_file(&backup_path);
|
||||||
|
|
||||||
|
println!("Successfully updated to {}!", latest);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compare two semver versions
|
||||||
|
fn version_compare(a: &str, b: &str) -> std::cmp::Ordering {
|
||||||
|
let parse = |s: &str| {
|
||||||
|
s.split('.')
|
||||||
|
.filter_map(|n| n.parse::<u32>().ok())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
};
|
||||||
|
|
||||||
|
let a_parts = parse(a);
|
||||||
|
let b_parts = parse(b);
|
||||||
|
|
||||||
|
for (a_part, b_part) in a_parts.iter().zip(b_parts.iter()) {
|
||||||
|
match a_part.cmp(b_part) {
|
||||||
|
std::cmp::Ordering::Equal => continue,
|
||||||
|
other => return other,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
a_parts.len().cmp(&b_parts.len())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_detect_current_version() {
|
||||||
|
let version = current_version();
|
||||||
|
// Version should be non-empty and contain dots
|
||||||
|
assert!(!version.is_empty());
|
||||||
|
assert!(version.contains('.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_compare_versions_correctly() {
|
||||||
|
assert_eq!(
|
||||||
|
version_compare("0.3.0", "0.2.4"),
|
||||||
|
std::cmp::Ordering::Greater
|
||||||
|
);
|
||||||
|
assert_eq!(version_compare("0.2.4", "0.3.0"), std::cmp::Ordering::Less);
|
||||||
|
assert_eq!(version_compare("0.3.0", "0.3.0"), std::cmp::Ordering::Equal);
|
||||||
|
assert_eq!(
|
||||||
|
version_compare("1.0.0", "0.9.9"),
|
||||||
|
std::cmp::Ordering::Greater
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
version_compare("0.10.0", "0.9.0"),
|
||||||
|
std::cmp::Ordering::Greater
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user