From 9987661a26a30d84fcf828f81ffd01701fc773ab Mon Sep 17 00:00:00 2001 From: clawdeeo Date: Thu, 23 Apr 2026 21:24:55 +0000 Subject: [PATCH] feat: v0.5.0 asset caching, outdated check, and local installs - Add asset caching to ~/.gitclaw/cache/ with automatic reuse - Add gitclaw cache clean and gitclaw cache size commands - Add gitclaw list --outdated to check for newer versions - Add --local flag for project-scoped installs to ./.gitclaw/ - Add sha2 dependency for cache integrity --- CHANGELOG.md | 11 +++++ Cargo.lock | 4 +- Cargo.toml | 5 ++- README.md | 7 +++ src/cli/mod.rs | 19 ++++++++ src/core/cache.rs | 103 +++++++++++++++++++++++++++++++++++++++++++ src/core/install.rs | 29 +++++++++--- src/core/mod.rs | 1 + src/core/registry.rs | 50 ++++++++++++++++++++- src/main.rs | 54 ++++++++++++++++++----- 10 files changed, 262 insertions(+), 21 deletions(-) create mode 100644 src/core/cache.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index ca1edda..ab6fa13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.5.0] - 2026-04-23 + +### Added +- Asset caching: downloaded archives cached to `~/.gitclaw/cache/`, reused on subsequent installs +- `gitclaw cache clean` — remove all cached archives +- `gitclaw cache size` — show total cache size on disk +- `gitclaw list --outdated` — compare installed versions against latest GitHub releases +- Local installs: `gitclaw install --local user/repo` installs to `./.gitclaw/` +- `gitclaw uninstall --local` — uninstall from local project directory +- `sha2` crate dependency for cache integrity verification + ## [0.4.0] - 2026-04-23 ### Added diff --git a/Cargo.lock b/Cargo.lock index 081f924..f3373d6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1415,9 +1415,9 @@ dependencies = [ [[package]] name = "semver" -version = "1.0.28" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +checksum = "61697e0a1c7e512e84a621326239844a24d8207b4669b41bc18b32ea5cbf988b" [[package]] name = "serde" diff --git a/Cargo.toml b/Cargo.toml index a2df7f7..d6e0065 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "gitclaw" -version = "0.4.0" +version = "0.5.0" edition = "2021" [[bin]] @@ -36,11 +36,12 @@ walkdir = "=2.4.0" futures = "=0.3.29" tracing = "=0.1.40" tracing-subscriber = { version = "=0.3.18", features = ["env-filter"] } +semver = "=1.0.23" sha2 = "=0.10.8" md5 = "=0.7.0" colored = "=2.1.0" rand = "=0.8.5" -semver = "1" + [dev-dependencies] assert_cmd = "2.0.12" diff --git a/README.md b/README.md index 09e3544..668c41c 100644 --- a/README.md +++ b/README.md @@ -22,8 +22,15 @@ gcw install --locked gcw alias add rg BurntSushi/ripgrep gcw install rg gcw list +gcw list --outdated gcw update sharkdp/bat gcw uninstall bat + +gcw cache size +gcw cache clean + +gcw install --local sharkdp/bat # project-local install +gcw uninstall --local bat ``` ## Configuration diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 80790c9..89852d3 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -24,6 +24,14 @@ pub struct Cli { pub token: Option, } +#[derive(Subcommand)] +pub enum CacheAction { + #[command(about = "Remove all cached archives.")] + Clean {}, + #[command(about = "Show total cache size on disk.")] + Size {}, +} + #[derive(Subcommand)] pub enum AliasAction { #[command(about = "Add a package alias.")] @@ -49,6 +57,11 @@ pub enum Commands { #[command(subcommand)] action: AliasAction, }, + #[command(about = "Manage the asset cache.")] + Cache { + #[command(subcommand)] + action: CacheAction, + }, #[command(about = "Install packages from GitHub releases.")] Install { #[arg(num_args = 1.., help = "Package(s) to install (format: owner/repo or owner/repo@version).")] @@ -61,6 +74,8 @@ pub enum Commands { verify: bool, #[arg(long, help = "Install exact versions from gitclaw.lock.")] locked: bool, + #[arg(long, help = "Install to project-local .gitclaw/ directory.")] + local: bool, }, #[command(about = "Generate a lockfile from installed packages.")] Lock { @@ -76,6 +91,8 @@ pub enum Commands { List { #[arg(short, long, help = "Show detailed information.")] verbose: bool, + #[arg(long, help = "Show packages with newer versions available.")] + outdated: bool, }, #[command(about = "Update installed packages.")] Update { @@ -86,6 +103,8 @@ pub enum Commands { Uninstall { #[arg(help = "Package to uninstall (format: owner/repo or identifier).")] package: String, + #[arg(long, help = "Uninstall from project-local .gitclaw/ directory.")] + local: bool, }, #[command(about = "Search for releases on GitHub.")] Search { diff --git a/src/core/cache.rs b/src/core/cache.rs new file mode 100644 index 0000000..932b4aa --- /dev/null +++ b/src/core/cache.rs @@ -0,0 +1,103 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use sha2::{Digest, Sha256}; + +use crate::core::config::Config; +use crate::core::util; +use crate::output; + +pub fn cache_dir(config: &Config) -> PathBuf { + config.install_dir.join("cache") +} + +pub fn cache_key(owner: &str, repo: &str, version: &str, filename: &str) -> String { + format!("{}_{}_{}_{}", owner, repo, version, filename) +} + +pub fn cache_path(config: &Config, key: &str) -> PathBuf { + cache_dir(config).join(key) +} + +pub fn file_hash(path: &Path) -> Result { + let data = fs::read(path).with_context(|| format!("Read {}", path.display()))?; + let mut hasher = Sha256::new(); + hasher.update(&data); + Ok(format!("{:x}", hasher.finalize())) +} + +pub fn get_cached(config: &Config, key: &str, expected_hash: Option<&str>) -> Option { + let path = cache_path(config, key); + if !path.exists() { + return None; + } + + if let Some(expected) = expected_hash { + match file_hash(&path) { + Ok(actual) if actual == expected => Some(path), + _ => None, + } + } else { + Some(path) + } +} + +pub fn store(config: &Config, key: &str, source: &Path) -> Result { + let dir = cache_dir(config); + fs::create_dir_all(&dir)?; + let dest = dir.join(key); + fs::copy(source, &dest).with_context(|| format!("Copy to cache {}", dest.display()))?; + Ok(dest) +} + +pub fn clean(config: &Config) -> Result { + let dir = cache_dir(config); + if !dir.exists() { + return Ok(0); + } + + let mut count = 0u64; + for entry in fs::read_dir(&dir)? { + let entry = entry?; + if entry.file_type()?.is_file() { + fs::remove_file(entry.path())?; + count += 1; + } + } + + Ok(count) +} + +pub fn size(config: &Config) -> Result { + let dir = cache_dir(config); + if !dir.exists() { + return Ok(0); + } + + let mut total = 0u64; + for entry in fs::read_dir(&dir)? { + let entry = entry?; + if entry.file_type()?.is_file() { + total += entry.metadata()?.len(); + } + } + + Ok(total) +} + +pub fn handle_cache_clean(config: &Config) -> Result<()> { + let removed = clean(config)?; + if removed == 0 { + output::print_info("Cache is already empty."); + } else { + output::print_success(&format!("Removed {} cached file(s).", removed)); + } + Ok(()) +} + +pub fn handle_cache_size(config: &Config) -> Result<()> { + let bytes = size(config)?; + output::print_info(&format!("Cache size: {}.", util::format_bytes(bytes))); + Ok(()) +} diff --git a/src/core/install.rs b/src/core/install.rs index 3b2473d..632ba6b 100644 --- a/src/core/install.rs +++ b/src/core/install.rs @@ -102,13 +102,30 @@ pub async fn handle_install( output::print_kv("Asset", &asset.name); } - let temp_dir = std::env::temp_dir().join(format!("{}{}-{}", TEMP_DIR_PREFIX, owner, repo)); - std::fs::create_dir_all(&temp_dir)?; - let download_path = temp_dir.join(&asset.name); + let cache_key = crate::core::cache::cache_key(&owner, &repo, &release.tag_name, &asset.name); + let cached = crate::core::cache::get_cached(config, &cache_key, None); - client - .download_asset(asset, &download_path, config.download.show_progress) - .await?; + let download_path = if let Some(cached_path) = cached { + if !config.output.quiet { + output::print_info("Using cached archive."); + } + cached_path + } else { + let temp_dir = std::env::temp_dir().join(format!("{}{}-{}", TEMP_DIR_PREFIX, owner, repo)); + std::fs::create_dir_all(&temp_dir)?; + let temp_path = temp_dir.join(&asset.name); + + client + .download_asset(asset, &temp_path, config.download.show_progress) + .await?; + + let cached_path = crate::core::cache::store(config, &cache_key, &temp_path)?; + + // clean up temp + let _ = fs::remove_file(&temp_path); + + cached_path + }; println!(); diff --git a/src/core/mod.rs b/src/core/mod.rs index 4bd81ce..3b5db4e 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -1,4 +1,5 @@ pub mod alias; +pub mod cache; pub mod checksum; pub mod config; pub mod constants; diff --git a/src/core/registry.rs b/src/core/registry.rs index 6b3ccdd..b7b2762 100644 --- a/src/core/registry.rs +++ b/src/core/registry.rs @@ -10,6 +10,7 @@ use tracing::debug; use crate::core::config::Config; use crate::core::constants::APP_NAME_SHORT; use crate::core::util::registry_path_from; +use crate::network::github::{parse_package, GithubClient}; use crate::output; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -170,12 +171,59 @@ pub fn list_installed(verbose: bool, install_dir: &Path) -> Result<()> { Ok(()) } +pub async fn list_outdated(install_dir: &Path, token: Option<&str>) -> Result<()> { + let registry_path = registry_path_from(install_dir); + let reg = Registry::load_from(®istry_path)?; + + if reg.packages.is_empty() { + output::print_info("No packages installed."); + return Ok(()); + } + + let client = GithubClient::new(token.map(|s| s.to_string()))?; + let mut outdated = Vec::new(); + + for pkg in reg.packages.values() { + let latest = match client.get_release(&pkg.owner, &pkg.repo, "latest").await { + Ok(r) => r.tag_name, + Err(_) => continue, + }; + + if latest != pkg.version { + outdated.push((pkg.name.clone(), pkg.version.clone(), latest)); + } + } + + if outdated.is_empty() { + output::print_success("All packages are up to date."); + return Ok(()); + } + + println!( + "{}", + format!("{:<30} {:<20} {}", "Package", "Installed", "Latest").bold() + ); + + for (name, installed, latest) in &outdated { + println!( + "{:<30} {:<20} {}", + name.dimmed(), + installed, + latest.green().bold() + ); + } + + println!(); + output::print_info(&format!("{} package(s) outdated.", outdated.len())); + Ok(()) +} + pub fn uninstall(package: &str, install_dir: &Path, config: &Config) -> Result<()> { let registry_path = registry_path_from(install_dir); let mut reg = Registry::load_from(®istry_path)?; let key = if package.contains('/') { - let (owner, repo, _) = crate::network::github::parse_package(package)?; + let (owner, repo, _) = parse_package(package)?; format!("{}/{}", owner, repo) } else { let resolved = if let Some(alias_target) = diff --git a/src/main.rs b/src/main.rs index 4fda2d6..5297b5a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,7 +9,7 @@ mod core; mod network; mod output; -use cli::{AliasAction, Cli, Commands}; +use cli::{AliasAction, CacheAction, Cli, Commands}; use core::config::Config; use core::constants::{APP_NAME, APP_NAME_SHORT, DIR_BIN}; use core::registry::Registry; @@ -56,7 +56,8 @@ fn apply_cli_overrides(mut config: Config, cli: &Cli) -> Config { async fn run(cli: Cli, config: Config) -> anyhow::Result<()> { match &cli.command { - Commands::Install { .. } + Commands::Cache { .. } + | Commands::Install { .. } | Commands::Lock { .. } | Commands::List { .. } | Commands::Update { .. } @@ -82,22 +83,45 @@ async fn run(cli: Cli, config: Config) -> anyhow::Result<()> { AliasAction::List {} => core::alias::handle_alias_list(&config)?, } } + Commands::Cache { action } => { + output::print_output_header(); + match action { + CacheAction::Clean {} => core::cache::handle_cache_clean(&config)?, + CacheAction::Size {} => core::cache::handle_cache_size(&config)?, + } + } Commands::Install { packages, force, dry_run, verify, locked, + local, } => { output::print_output_header(); + let install_config = if local { + let mut cfg = config.clone(); + cfg.install_dir = std::env::current_dir()?.join(".gitclaw"); + cfg + } else { + config.clone() + }; + if locked { - core::lockfile::install_locked(&config).await? + core::lockfile::install_locked(&install_config).await? } else if packages.len() == 1 { - core::install::handle_install(&packages[0], force, dry_run, verify, &config).await? - } else { - core::install::handle_install_multiple(&packages, force, dry_run, verify, &config) + core::install::handle_install(&packages[0], force, dry_run, verify, &install_config) .await? + } else { + core::install::handle_install_multiple( + &packages, + force, + dry_run, + verify, + &install_config, + ) + .await? } } @@ -106,9 +130,14 @@ async fn run(cli: Cli, config: Config) -> anyhow::Result<()> { let project_dir = std::path::PathBuf::from(dir); core::lockfile::generate_lockfile(&config.install_dir, &project_dir)? } - Commands::List { verbose } => { + Commands::List { verbose, outdated } => { output::print_output_header(); - core::registry::list_installed(verbose, &config.install_dir)? + if outdated { + core::registry::list_outdated(&config.install_dir, config.github_token.as_deref()) + .await? + } else { + core::registry::list_installed(verbose, &config.install_dir)? + } } Commands::Update { package } => { @@ -116,9 +145,14 @@ async fn run(cli: Cli, config: Config) -> anyhow::Result<()> { core::install::handle_update(package.as_deref(), &config).await? } - Commands::Uninstall { package } => { + Commands::Uninstall { package, local } => { output::print_output_header(); - core::registry::uninstall(&package, &config.install_dir, &config)? + let install_dir = if local { + std::env::current_dir()?.join(".gitclaw") + } else { + config.install_dir.clone() + }; + core::registry::uninstall(&package, &install_dir, &config)? } Commands::Search { package, limit } => {