diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ee531b..f0dbca7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.7.0] - 2026-04-23 + +### Added +- Install channel persisted in registry: `--channel` stored per package +- `gitclaw update` respects stored channel when checking for newer releases +- Optional `channel` field on `InstalledPackage` (backward compatible) +- New tests in `tests/channel_persist.rs` + ## [0.6.0] - 2026-04-23 ### Added diff --git a/Cargo.lock b/Cargo.lock index a5e0fdd..6185461 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -641,7 +641,7 @@ checksum = "4271d37baee1b8c7e4b708028c57d816cf9d2434acb33a549475f78c181f6253" [[package]] name = "gitclaw" -version = "0.6.0" +version = "0.7.0" dependencies = [ "anyhow", "assert_cmd", diff --git a/Cargo.toml b/Cargo.toml index eaa8feb..8df10df 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "gitclaw" -version = "0.6.0" +version = "0.7.0" edition = "2021" [[bin]] diff --git a/ROADMAP.md b/ROADMAP.md index 1a5d8c8..4d7719e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -58,7 +58,7 @@ Project-scoped installation isolated from the global registry: gitclaw install --local user/repo ``` -## 0.6.0 — Advanced Features ✅ +## 0.6.0 — Advanced Features ✅ ✅ **Release channels** @@ -76,7 +76,7 @@ gitclaw export > deps.toml gitclaw import deps.toml ``` -## 0.7.0 — Platform Integration +## 0.7.0 — Platform Integration ✅ **Package manager awareness** diff --git a/src/core/install.rs b/src/core/install.rs index 1e26857..e8103b5 100644 --- a/src/core/install.rs +++ b/src/core/install.rs @@ -186,6 +186,7 @@ pub async fn handle_install( install_dir: pkg_install_dir.clone(), asset_name: asset.name.clone(), identifier: repo.clone(), + channel: channel.map(|c| c.to_string()), }; reg.add(pkg); @@ -230,7 +231,23 @@ async fn update_one(package: &str, config: &Config) -> Result<()> { } let client = GithubClient::new(config.github_token.clone())?; - let latest = client.get_release(&owner, &repo, "latest").await?; + + let ch = match installed.channel.as_deref() { + Some(c) => Some(c.parse::()?), + None => None, + }; + + let latest = match ch { + Some(channel) => { + let releases = client.get_releases(&owner, &repo).await?; + let filtered = crate::core::channel::filter_releases(&releases, channel, None); + if filtered.is_empty() { + bail!("No {} release found for {}/{}.", channel, owner, repo); + } + filtered.into_iter().next().unwrap() + } + None => client.get_release(&owner, &repo, "latest").await?, + }; if latest.tag_name == installed.version { if !config.output.quiet { @@ -248,7 +265,7 @@ async fn update_one(package: &str, config: &Config) -> Result<()> { } crate::core::registry::uninstall(package, &config.install_dir, config)?; - handle_install(package, false, false, false, config, None).await + handle_install(package, false, false, false, config, ch).await } async fn update_all(config: &Config) -> Result<()> { diff --git a/src/core/lockfile.rs b/src/core/lockfile.rs index b8936ed..819ef48 100644 --- a/src/core/lockfile.rs +++ b/src/core/lockfile.rs @@ -133,6 +133,7 @@ mod tests { install_dir: PathBuf::from("/tmp/test"), asset_name: asset.to_string(), identifier: repo.to_string(), + channel: None, } } diff --git a/src/core/registry.rs b/src/core/registry.rs index b7b2762..8ddc034 100644 --- a/src/core/registry.rs +++ b/src/core/registry.rs @@ -25,6 +25,8 @@ pub struct InstalledPackage { pub asset_name: String, #[serde(default)] pub identifier: String, + #[serde(default)] + pub channel: Option, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] diff --git a/tests/channel_persist.rs b/tests/channel_persist.rs new file mode 100644 index 0000000..1b472a0 --- /dev/null +++ b/tests/channel_persist.rs @@ -0,0 +1,100 @@ +use std::path::PathBuf; + +use gitclaw::registry::{InstalledPackage, Registry}; +use tempfile::TempDir; + +fn make_pkg_with_channel( + name: &str, + owner: &str, + repo: &str, + version: &str, + channel: Option<&str>, +) -> InstalledPackage { + InstalledPackage { + name: name.to_string(), + owner: owner.to_string(), + repo: repo.to_string(), + version: version.to_string(), + installed_at: "2026-01-01T00:00:00Z".to_string(), + binary_path: PathBuf::from("/tmp/test"), + install_dir: PathBuf::from("/tmp/test"), + asset_name: format!("{}.tar.gz", repo), + identifier: repo.to_string(), + channel: channel.map(|s| s.to_string()), + } +} + +#[test] +fn test_installed_package_with_channel() { + let pkg = make_pkg_with_channel( + "user/repo", + "user", + "repo", + "1.0.0-nightly", + Some("nightly"), + ); + assert_eq!(pkg.channel, Some("nightly".to_string())); +} + +#[test] +fn test_installed_package_without_channel() { + let pkg = make_pkg_with_channel("user/repo", "user", "repo", "1.0.0", None); + assert_eq!(pkg.channel, None); +} + +#[test] +fn test_registry_save_load_with_channel() { + let dir = TempDir::new().unwrap(); + let reg_path = dir.path().join("registry.toml"); + std::fs::create_dir_all(dir.path()).unwrap(); + + let mut reg = Registry::load_from(®_path).unwrap(); + reg.add(make_pkg_with_channel( + "BurntSushi/ripgrep", + "BurntSushi", + "ripgrep", + "14.0.0-nightly", + Some("nightly"), + )); + reg.add(make_pkg_with_channel( + "sharkdp/fd", + "sharkdp", + "fd", + "8.7.0", + None, + )); + reg.save().unwrap(); + + let loaded = Registry::load_from(®_path).unwrap(); + let rg = loaded.packages.get("BurntSushi/ripgrep").unwrap(); + assert_eq!(rg.channel, Some("nightly".to_string())); + + let fd = loaded.packages.get("sharkdp/fd").unwrap(); + assert_eq!(fd.channel, None); +} + +#[test] +fn test_registry_backward_compat_no_channel_field() { + let dir = TempDir::new().unwrap(); + let reg_path = dir.path().join("registry.toml"); + + let toml_str = r#" +[packages."user/repo"] +name = "user/repo" +owner = "user" +repo = "repo" +version = "1.0.0" +installed_at = "2026-01-01T00:00:00Z" +binary_path = "/tmp/test" +install_dir = "/tmp/test" +asset_name = "repo.tar.gz" +identifier = "repo" +"#; + + std::fs::create_dir_all(dir.path()).unwrap(); + std::fs::write(®_path, toml_str).unwrap(); + + let reg = Registry::load_from(®_path).unwrap(); + let pkg = reg.packages.get("user/repo").unwrap(); + assert_eq!(pkg.channel, None); +} diff --git a/tests/export_import.rs b/tests/export_import.rs index 39759aa..66d7b46 100644 --- a/tests/export_import.rs +++ b/tests/export_import.rs @@ -27,6 +27,7 @@ fn sample_pkg(name: &str, owner: &str, repo: &str, version: &str) -> InstalledPa install_dir: PathBuf::from("/tmp/install"), asset_name: format!("{}-{}.tar.gz", repo, version), identifier: repo.to_string(), + channel: None, } } diff --git a/tests/extract.rs b/tests/extract.rs index af83a90..3e7444c 100644 --- a/tests/extract.rs +++ b/tests/extract.rs @@ -30,7 +30,7 @@ fn create_test_zip(dir: &TempDir, files: &[(&str, &[u8])]) -> std::path::PathBuf for (name, content) in files { writer.start_file(*name, options).unwrap(); - writer.write_all(*content).unwrap(); + writer.write_all(content).unwrap(); } writer.finish().unwrap(); diff --git a/tests/local.rs b/tests/local.rs index be8e34a..0b049e5 100644 --- a/tests/local.rs +++ b/tests/local.rs @@ -75,6 +75,7 @@ fn test_local_registry_load_save() { install_dir: local_dir.join("packages").join("sharkdp").join("bat"), asset_name: "bat-v0.24.0-x86_64-linux.tar.gz".to_string(), identifier: "bat".to_string(), + channel: None, }); reg.save().unwrap(); diff --git a/tests/lockfile.rs b/tests/lockfile.rs index 383b3d0..1b5fe97 100644 --- a/tests/lockfile.rs +++ b/tests/lockfile.rs @@ -16,6 +16,7 @@ fn make_pkg(name: &str, owner: &str, repo: &str, version: &str, asset: &str) -> install_dir: PathBuf::from("/tmp/test"), asset_name: asset.to_string(), identifier: repo.to_string(), + channel: None, } } diff --git a/tests/registry.rs b/tests/registry.rs index c881ef0..3d2068f 100644 --- a/tests/registry.rs +++ b/tests/registry.rs @@ -12,6 +12,7 @@ fn test_installed_package_struct() { install_dir: PathBuf::from("/home/user/.gitclaw/packages/BurntSushi/ripgrep"), asset_name: "ripgrep-13.0.0-x86_64-unknown-linux-musl.tar.gz".to_string(), identifier: "ripgrep".to_string(), + channel: None, }; assert_eq!(pkg.name, "BurntSushi/ripgrep"); @@ -45,6 +46,7 @@ fn test_registry_add() { install_dir: PathBuf::from("/home/user/.gitclaw/packages/test/package"), asset_name: "package-1.0.0.tar.gz".to_string(), identifier: "package".to_string(), + channel: None, }; registry.packages.insert(pkg.name.clone(), pkg); @@ -68,6 +70,7 @@ fn test_registry_remove() { install_dir: PathBuf::from("/home/user/.gitclaw/packages/user1/pkg1"), asset_name: "pkg1.tar.gz".to_string(), identifier: "pkg1".to_string(), + channel: None, }, ); @@ -83,6 +86,7 @@ fn test_registry_remove() { install_dir: PathBuf::from("/home/user/.gitclaw/packages/user2/pkg2"), asset_name: "pkg2.tar.gz".to_string(), identifier: "pkg2".to_string(), + channel: None, }, ); @@ -108,6 +112,7 @@ fn test_registry_get() { install_dir: PathBuf::from("/home/user/.gitclaw/packages/test/pkg"), asset_name: "pkg.tar.gz".to_string(), identifier: "pkg".to_string(), + channel: None, }; registry.packages.insert(pkg.name.clone(), pkg); @@ -116,7 +121,7 @@ fn test_registry_get() { assert!(retrieved.is_some()); assert_eq!(retrieved.unwrap().version, "1.0.0"); - assert!(registry.packages.get("nonexistent/pkg").is_none()); + assert!(!registry.packages.contains_key("nonexistent/pkg")); } #[test] @@ -137,6 +142,7 @@ fn test_registry_is_installed() { install_dir: PathBuf::from("/home/user/.gitclaw/packages/test/package"), asset_name: "package.tar.gz".to_string(), identifier: "package".to_string(), + channel: None, }, ); @@ -159,6 +165,7 @@ fn test_serialize_deserialize() { install_dir: PathBuf::from("/home/user/.gitclaw/packages/test/pkg"), asset_name: "pkg.tar.gz".to_string(), identifier: "pkg".to_string(), + channel: None, }, ); @@ -221,6 +228,7 @@ fn test_registry_crud() { install_dir: PathBuf::from("/tmp/install"), asset_name: "tool.tar.gz".to_string(), identifier: "repo".to_string(), + channel: None, }; assert!(!reg.is_installed("user/repo"));