From e92d51a73825c62a43983c366da4bedd91f2ad7e Mon Sep 17 00:00:00 2001 From: clawdeeo Date: Thu, 23 Apr 2026 22:40:13 +0000 Subject: [PATCH 1/3] docs: add v0.7.0 platform integration spec --- .specs/v0.7.0-platform.md | 73 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 .specs/v0.7.0-platform.md diff --git a/.specs/v0.7.0-platform.md b/.specs/v0.7.0-platform.md new file mode 100644 index 0000000..a4ba153 --- /dev/null +++ b/.specs/v0.7.0-platform.md @@ -0,0 +1,73 @@ +# v0.7.0 — Platform Integration + +## Goal + +Make gitclaw aware of the system it runs on: detect existing package manager installs, support macOS, and improve the update workflow. + +--- + +## Feature 1: Package Manager Awareness + +**What:** Warn when a package is already available via a system package manager. + +**Spec:** +- New module: `src/core/system.rs` +- On `gitclaw install`, check if the binary is already available via: + - `dpkg -l` (Debian/Ubuntu) + - `rpm -q` (Fedora/RHEL) + - `pacman -Q` (Arch) + - `brew list` (macOS, if available) +- If found, print a warning: `Package 'ripgrep' is available via apt (ripgrep). Install with: sudo apt install ripgrep` +- Do not block the install — just warn +- Mapping: binary name -> package name (many share the same name, some differ: `rg` -> `ripgrep`) +- Known mappings stored in a static lookup table in `system.rs` +- Skip check if `--no-system-check` flag is passed, or if `system_check = false` in config +- Only runs on Linux/macOS; no-op on other platforms + +**Checkpoint:** `gitclaw install BurntSushi/ripgrep` warns if `ripgrep` is already installed via apt. + +--- + +## Feature 2: macOS Support + +**What:** Add macOS as a supported platform. + +**Spec:** +- Extend `Platform` enum in `github.rs` with `MacosArm64` and `MacosX86_64` +- Add macOS architecture aliases: `macos-arm64`, `macos-aarch64`, `darwin-arm64`, `aarch64-apple-darwin`, etc. +- macOS binaries already work with tar.gz/zip extraction +- Symlinks use `std::os::unix::fs::symlink` (already works on macOS) +- Update `README.md` supported platforms table + +**Checkpoint:** Platform detection returns macOS variants; asset matching selects macOS assets. + +--- + +## Feature 3: Update with Channel + +**What:** Allow `gitclaw update` to respect the channel a package was installed from. + +**Spec:** +- Add optional `channel` field to `InstalledPackage` (with `#[serde(default)]`) +- When a package is installed with `--channel`, store the channel in the registry +- `gitclaw update` reads the stored channel and uses it when checking for updates +- If no channel stored, current behavior (latest stable) + +**Checkpoint:** Install with `--channel nightly`, then `gitclaw update` fetches the newest nightly. + +--- + +## Implementation Order + +1. macOS support (widens the user base, extends existing Platform code) +2. Package manager awareness (new system detection logic) +3. Update with channel (small registry + update change) + +## Test Plan + +- Unit tests for package name mappings in `src/core/system.rs` +- Unit tests for macOS platform aliases in `src/network/github.rs` +- Unit tests for `InstalledPackage` with optional channel field +- Integration tests in `tests/system.rs` for warning output +- Integration tests in `tests/platform.rs` for macOS detection +- `cargo fmt && cargo clippy -- -D warnings && cargo test` must pass \ No newline at end of file From dac4bbea359f09ce5baf8f351c3f1963df53fae9 Mon Sep 17 00:00:00 2001 From: clawdeeo Date: Thu, 23 Apr 2026 22:44:44 +0000 Subject: [PATCH 2/3] docs: simplify v0.7.0 spec to channel persistence only --- .specs/v0.7.0-platform.md | 59 +++++---------------------------------- ROADMAP.md | 2 +- 2 files changed, 8 insertions(+), 53 deletions(-) diff --git a/.specs/v0.7.0-platform.md b/.specs/v0.7.0-platform.md index a4ba153..fc1df11 100644 --- a/.specs/v0.7.0-platform.md +++ b/.specs/v0.7.0-platform.md @@ -2,72 +2,27 @@ ## Goal -Make gitclaw aware of the system it runs on: detect existing package manager installs, support macOS, and improve the update workflow. +Persist the install channel in the registry so `gitclaw update` respects it. --- -## Feature 1: Package Manager Awareness +## Feature: Update with Channel -**What:** Warn when a package is already available via a system package manager. +**What:** Store the channel a package was installed from, and use it when updating. **Spec:** -- New module: `src/core/system.rs` -- On `gitclaw install`, check if the binary is already available via: - - `dpkg -l` (Debian/Ubuntu) - - `rpm -q` (Fedora/RHEL) - - `pacman -Q` (Arch) - - `brew list` (macOS, if available) -- If found, print a warning: `Package 'ripgrep' is available via apt (ripgrep). Install with: sudo apt install ripgrep` -- Do not block the install — just warn -- Mapping: binary name -> package name (many share the same name, some differ: `rg` -> `ripgrep`) -- Known mappings stored in a static lookup table in `system.rs` -- Skip check if `--no-system-check` flag is passed, or if `system_check = false` in config -- Only runs on Linux/macOS; no-op on other platforms - -**Checkpoint:** `gitclaw install BurntSushi/ripgrep` warns if `ripgrep` is already installed via apt. - ---- - -## Feature 2: macOS Support - -**What:** Add macOS as a supported platform. - -**Spec:** -- Extend `Platform` enum in `github.rs` with `MacosArm64` and `MacosX86_64` -- Add macOS architecture aliases: `macos-arm64`, `macos-aarch64`, `darwin-arm64`, `aarch64-apple-darwin`, etc. -- macOS binaries already work with tar.gz/zip extraction -- Symlinks use `std::os::unix::fs::symlink` (already works on macOS) -- Update `README.md` supported platforms table - -**Checkpoint:** Platform detection returns macOS variants; asset matching selects macOS assets. - ---- - -## Feature 3: Update with Channel - -**What:** Allow `gitclaw update` to respect the channel a package was installed from. - -**Spec:** -- Add optional `channel` field to `InstalledPackage` (with `#[serde(default)]`) -- When a package is installed with `--channel`, store the channel in the registry +- Add optional `channel` field to `InstalledPackage` with `#[serde(default)]` +- When a package is installed with `--channel`, store the channel string in the registry - `gitclaw update` reads the stored channel and uses it when checking for updates - If no channel stored, current behavior (latest stable) +- Backward compatible: existing registries without `channel` field load normally **Checkpoint:** Install with `--channel nightly`, then `gitclaw update` fetches the newest nightly. --- -## Implementation Order - -1. macOS support (widens the user base, extends existing Platform code) -2. Package manager awareness (new system detection logic) -3. Update with channel (small registry + update change) - ## Test Plan -- Unit tests for package name mappings in `src/core/system.rs` -- Unit tests for macOS platform aliases in `src/network/github.rs` - Unit tests for `InstalledPackage` with optional channel field -- Integration tests in `tests/system.rs` for warning output -- Integration tests in `tests/platform.rs` for macOS detection +- Integration tests for install + update roundtrip with channel - `cargo fmt && cargo clippy -- -D warnings && cargo test` must pass \ No newline at end of file diff --git a/ROADMAP.md b/ROADMAP.md index 1a5d8c8..25ec5a4 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** From 3a7cd236b5855134184cef2ced696a049852520a Mon Sep 17 00:00:00 2001 From: clawdeeo Date: Thu, 23 Apr 2026 22:57:18 +0000 Subject: [PATCH 3/3] feat: persist install channel in registry for update-aware releases - Add optional channel field to InstalledPackage with #[serde(default)] - gitclaw install --channel nightly stores channel in registry - gitclaw update reads stored channel, fetches matching release - Backward compatible: existing registries load without channel - 4 new tests in tests/channel_persist.rs (239 total) - Version bumped to 0.7.0, CHANGELOG and ROADMAP updated - Fix clippy lint in tests/registry.rs and tests/extract.rs --- .specs/v0.7.0-platform.md | 28 ----------- CHANGELOG.md | 8 +++ Cargo.lock | 2 +- Cargo.toml | 2 +- ROADMAP.md | 2 +- src/core/install.rs | 21 +++++++- src/core/lockfile.rs | 1 + src/core/registry.rs | 2 + tests/channel_persist.rs | 100 ++++++++++++++++++++++++++++++++++++++ tests/export_import.rs | 1 + tests/extract.rs | 2 +- tests/local.rs | 1 + tests/lockfile.rs | 1 + tests/registry.rs | 10 +++- 14 files changed, 146 insertions(+), 35 deletions(-) delete mode 100644 .specs/v0.7.0-platform.md create mode 100644 tests/channel_persist.rs diff --git a/.specs/v0.7.0-platform.md b/.specs/v0.7.0-platform.md deleted file mode 100644 index fc1df11..0000000 --- a/.specs/v0.7.0-platform.md +++ /dev/null @@ -1,28 +0,0 @@ -# v0.7.0 — Platform Integration - -## Goal - -Persist the install channel in the registry so `gitclaw update` respects it. - ---- - -## Feature: Update with Channel - -**What:** Store the channel a package was installed from, and use it when updating. - -**Spec:** -- Add optional `channel` field to `InstalledPackage` with `#[serde(default)]` -- When a package is installed with `--channel`, store the channel string in the registry -- `gitclaw update` reads the stored channel and uses it when checking for updates -- If no channel stored, current behavior (latest stable) -- Backward compatible: existing registries without `channel` field load normally - -**Checkpoint:** Install with `--channel nightly`, then `gitclaw update` fetches the newest nightly. - ---- - -## Test Plan - -- Unit tests for `InstalledPackage` with optional channel field -- Integration tests for install + update roundtrip with channel -- `cargo fmt && cargo clippy -- -D warnings && cargo test` must pass \ No newline at end of file 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 25ec5a4..4d7719e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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"));