Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "gitclaw"
version = "0.4.0"
version = "0.5.0"
edition = "2021"

[[bin]]
Expand Down Expand Up @@ -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"
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions src/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,14 @@ pub struct Cli {
pub token: Option<String>,
}

#[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.")]
Expand All @@ -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).")]
Expand All @@ -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 {
Expand All @@ -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 {
Expand All @@ -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 {
Expand Down
103 changes: 103 additions & 0 deletions src/core/cache.rs
Original file line number Diff line number Diff line change
@@ -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<String> {
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<PathBuf> {
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<PathBuf> {
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<u64> {
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<u64> {
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(())
}
29 changes: 23 additions & 6 deletions src/core/install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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!();

Expand Down
1 change: 1 addition & 0 deletions src/core/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
pub mod alias;
pub mod cache;
pub mod checksum;
pub mod config;
pub mod constants;
Expand Down
50 changes: 49 additions & 1 deletion src/core/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -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(&registry_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(&registry_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) =
Expand Down
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ pub mod network;
pub mod output;

pub use core::alias;
pub use core::cache;
pub use core::checksum;
pub use core::config;
pub use core::constants;
Expand Down
Loading
Loading