diff --git a/README.md b/README.md index 7f380a8..9e2f791 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,9 @@ git-supervisor status [--config deployments.yaml] [--host PATTERN]... # Prepare remotes (create dirs, ensure repos) then run check-push on each host in a loop git-supervisor watch [--config deployments.yaml] [--interval SECS] [--timeout SECS] [-I | --ignore-missing] [--webhook-secret SECRET] [--webhook-port PORT] + +# Print the current version and check GitHub for a newer release +git-supervisor version ``` - Config is an optional argument to each subcommand; default: `deployments.yaml`. @@ -87,6 +90,7 @@ git-supervisor watch [--config deployments.yaml] [--interval SECS] [--timeout SE - **watch**: first prepares each remote (create dirs, init empty repos by cloning when missing unless `-I`/`--ignore-missing`). Then, on the supervisor machine, it polls upstream refs for all configured repos. It only runs remote `check-push` on hosts whose configured repos have upstream changes (first round always runs on all hosts). `--interval` (default 120) controls polling cadence, optional `--timeout` stops after SECS, `-I`/`--ignore-missing` skips cloning (only create dirs; missing repos are ignored). Run until Ctrl+C if no timeout. - Remotes must have **SSH** access (key-based) and **git** installed. For local hosts (`localhost`, `127.0.0.1`, `::1`, including forms like `user@localhost`), supervisor runs commands directly on the local machine and does not require an SSH daemon. +- **version**: print the current version and check [GitHub Releases](https://github.com/jfding/git-supervisor/releases) for the latest published release (via `git ls-remote --tags` — no extra tooling, since `git` is already required). If a newer release exists, it prints the new tag and the download URL. This is **notify-only**: it never downloads or replaces the binary. The `watch` command runs the same check once at startup (cached for 24h under `~/.cache/git-supervisor/version-check.json`) and prints a one-line notice if a newer release is available; the check is best-effort and any error is silently ignored so it never interrupts watching. ### GitHub Webhook settings diff --git a/docs/superpowers/plans/2026-06-15-version-check.md b/docs/superpowers/plans/2026-06-15-version-check.md new file mode 100644 index 0000000..a70f777 --- /dev/null +++ b/docs/superpowers/plans/2026-06-15-version-check.md @@ -0,0 +1,646 @@ +# Background Version-Check Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Tell the user when a newer GitHub release of `git-supervisor` exists, via an explicit `version` subcommand and a cached background check during `watch` — never download or replace anything. + +**Architecture:** A new single-purpose module `src/version_check.rs` holds pure helpers (`parse_release`, `compare_versions`, `notify_line`, `cache_is_stale`) plus one thin networked function (`fetch_latest_release`) and two orchestrators (`run_version_check` for the subcommand, `maybe_notify_update` for the auto path). The auto path is cache-gated (24h) and swallows all errors; the subcommand always fetches fresh and surfaces errors. + +**Tech Stack:** Rust, clap, serde_json (existing), dirs (existing), and a new dependency `ureq` (blocking HTTP, rustls TLS). + +**Design doc:** `docs/superpowers/specs/2026-06-15-version-check-design.md` + +> **Execution note (2026-06-15):** During implementation, `ureq`+rustls was +> found to break the static-musl build (its `ring` dependency needs a musl C +> compiler, a new build requirement for this otherwise pure-Rust project). +> Detection was switched to `git ls-remote --tags --refs` via the system `git` +> binary (already required) — **no new dependency, no C compiler**. Tasks 1, 4, +> and 5 below describe the original `ureq`/Releases-API approach; the shipped +> code instead uses `parse_ls_remote_tags` / `latest_stable_tag` / `fetch_latest_tag`. +> See the updated design doc and `src/version_check.rs` for what was built. The +> caching, comparison, notify, subcommand-wiring, and watch-integration tasks +> were implemented as written. + +--- + +## File Structure + +- **Create:** `src/version_check.rs` — the whole feature: parsing, version comparison, caching, network fetch, and the two entry points. +- **Modify:** `Cargo.toml` — add `ureq`. +- **Modify:** `src/lib.rs` — declare the module, re-export entry points, call `maybe_notify_update` from `run_watch`. +- **Modify:** `src/main.rs` — add the `version` subcommand. +- **Modify:** `README.md` — document the subcommand and auto-check. + +--- + +## Task 1: Module scaffold + `parse_release` + +**Files:** +- Modify: `Cargo.toml` +- Create: `src/version_check.rs` + +- [ ] **Step 1: Add the ureq dependency** + +In `Cargo.toml`, under `[dependencies]` (keep the list alphabetical — insert after `tokio`), add: + +```toml +ureq = "2" +``` + +- [ ] **Step 2: Create the module with `ReleaseInfo` + `parse_release` and a failing test** + +Create `src/version_check.rs` with exactly this content: + +```rust +//! Notify-only version check against the GitHub Releases API. +//! +//! This module never downloads or replaces the binary. It only detects whether +//! a newer published release exists and tells the user. + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use std::cmp::Ordering; +use std::path::PathBuf; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use crate::console; + +const GITHUB_REPO: &str = "jfding/git-supervisor"; +const CACHE_TTL_SECS: u64 = 24 * 60 * 60; +const HTTP_TIMEOUT_SECS: u64 = 5; + +/// A published release as we care about it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ReleaseInfo { + pub tag: String, + pub html_url: String, +} + +/// Shape of the subset of the GitHub `releases/latest` JSON we read. +#[derive(Deserialize)] +struct ApiRelease { + tag_name: String, + html_url: String, +} + +/// Parse a GitHub `releases/latest` JSON body into a `ReleaseInfo`. Pure. +pub fn parse_release(json: &str) -> Result { + let r: ApiRelease = serde_json::from_str(json).context("parsing GitHub release JSON")?; + Ok(ReleaseInfo { + tag: r.tag_name, + html_url: r.html_url, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_release_extracts_tag_and_url() { + let json = r#"{ + "tag_name": "v2.3.0", + "html_url": "https://github.com/jfding/git-supervisor/releases/tag/v2.3.0", + "name": "v2.3.0", + "draft": false, + "prerelease": false + }"#; + let info = parse_release(json).unwrap(); + assert_eq!(info.tag, "v2.3.0"); + assert_eq!( + info.html_url, + "https://github.com/jfding/git-supervisor/releases/tag/v2.3.0" + ); + } + + #[test] + fn parse_release_errors_on_malformed_json() { + assert!(parse_release("not json").is_err()); + assert!(parse_release(r#"{"name": "no tag here"}"#).is_err()); + } +} +``` + +- [ ] **Step 3: Wire the module into the crate so it compiles** + +In `src/lib.rs`, add the module declaration alongside the other `pub mod` lines (after `pub mod status;`): + +```rust +pub mod version_check; +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `cargo test --lib version_check::tests::parse_release` +Expected: 2 tests PASS. (First run also compiles `ureq`; this may take a moment.) + +- [ ] **Step 5: Commit** + +```bash +git add Cargo.toml Cargo.lock src/version_check.rs src/lib.rs +git commit -m "feat(version-check): add module scaffold and parse_release" +``` + +--- + +## Task 2: `compare_versions` + +**Files:** +- Modify: `src/version_check.rs` + +- [ ] **Step 1: Add a failing test for version comparison** + +In `src/version_check.rs`, inside the `mod tests` block, add: + +```rust + #[test] + fn compare_versions_orders_correctly() { + assert_eq!(compare_versions("2.1.8", "v2.1.9"), Ordering::Less); + assert_eq!(compare_versions("2.1.8", "v2.1.8"), Ordering::Equal); + assert_eq!(compare_versions("2.2.0", "v2.1.9"), Ordering::Greater); + // leading v on either side is ignored + assert_eq!(compare_versions("v2.1.8", "2.1.8"), Ordering::Equal); + // minor/major take precedence over patch + assert_eq!(compare_versions("2.1.10", "v2.2.0"), Ordering::Less); + // malformed components parse as 0, never panic + assert_eq!(compare_versions("garbage", "v0.0.0"), Ordering::Equal); + // trailing pre-release suffix on a component is truncated to its digits + assert_eq!(compare_versions("2.1.8", "v2.1.8-rc1"), Ordering::Equal); + } +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cargo test --lib version_check::tests::compare_versions_orders_correctly` +Expected: FAIL — `cannot find function compare_versions in this scope`. + +- [ ] **Step 3: Implement `compare_versions` and its helper** + +In `src/version_check.rs`, add after the `parse_release` function (before the `#[cfg(test)]` block): + +```rust +/// Parse a version string into a `(major, minor, patch)` tuple. +/// Strips a leading `v`/`V`. Each component keeps only its leading digits +/// (so `8-rc1` becomes `8`); anything unparseable becomes `0`. +fn parse_semver(s: &str) -> (u64, u64, u64) { + let s = s.trim().trim_start_matches(['v', 'V']); + let mut parts = s.split('.').map(|p| { + let digits: String = p.chars().take_while(|c| c.is_ascii_digit()).collect(); + digits.parse::().unwrap_or(0) + }); + ( + parts.next().unwrap_or(0), + parts.next().unwrap_or(0), + parts.next().unwrap_or(0), + ) +} + +/// Compare a current version against a release tag. Pure. +/// `Ordering::Less` means the current version is behind `latest_tag`. +pub fn compare_versions(current: &str, latest_tag: &str) -> Ordering { + parse_semver(current).cmp(&parse_semver(latest_tag)) +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `cargo test --lib version_check::tests::compare_versions_orders_correctly` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/version_check.rs +git commit -m "feat(version-check): add semver comparison" +``` + +--- + +## Task 3: `notify_line` + +**Files:** +- Modify: `src/version_check.rs` + +- [ ] **Step 1: Add a failing test** + +In `src/version_check.rs`, inside `mod tests`, add: + +```rust + #[test] + fn notify_line_only_when_behind() { + let latest = ReleaseInfo { + tag: "v2.2.0".to_string(), + html_url: "https://example.com/rel".to_string(), + }; + let line = notify_line("2.1.8", &latest).expect("should notify when behind"); + assert!(line.contains("v2.2.0")); + assert!(line.contains("2.1.8")); + + // up to date or ahead => no notice + assert!(notify_line("2.2.0", &latest).is_none()); + assert!(notify_line("2.3.0", &latest).is_none()); + } +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cargo test --lib version_check::tests::notify_line_only_when_behind` +Expected: FAIL — `cannot find function notify_line in this scope`. + +- [ ] **Step 3: Implement `notify_line`** + +In `src/version_check.rs`, add after `compare_versions`: + +```rust +/// Build a one-line update notice if `latest` is newer than `current`, +/// otherwise `None`. Pure (no I/O, no color). +pub fn notify_line(current: &str, latest: &ReleaseInfo) -> Option { + if compare_versions(current, &latest.tag) == Ordering::Less { + Some(format!( + "A new git-supervisor release is available: {} (current {}). Download: {}", + latest.tag, current, latest.html_url + )) + } else { + None + } +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `cargo test --lib version_check::tests::notify_line_only_when_behind` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/version_check.rs +git commit -m "feat(version-check): add notify_line renderer" +``` + +--- + +## Task 4: Cache (`cache_is_stale` + read/write) + +**Files:** +- Modify: `src/version_check.rs` + +- [ ] **Step 1: Add a failing test for staleness** + +In `src/version_check.rs`, inside `mod tests`, add: + +```rust + #[test] + fn cache_is_stale_respects_ttl() { + let ttl = 24 * 60 * 60; + // 1 hour old => fresh + assert!(!cache_is_stale(1_000_000, 1_000_000 + 3_600, ttl)); + // exactly ttl old => stale + assert!(cache_is_stale(1_000_000, 1_000_000 + ttl, ttl)); + // older than ttl => stale + assert!(cache_is_stale(1_000_000, 1_000_000 + ttl + 1, ttl)); + // clock skew (now < checked_at) must not panic and counts as fresh + assert!(!cache_is_stale(1_000_000, 999_000, ttl)); + } +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cargo test --lib version_check::tests::cache_is_stale_respects_ttl` +Expected: FAIL — `cannot find function cache_is_stale in this scope`. + +- [ ] **Step 3: Implement the cache helpers** + +In `src/version_check.rs`, add after `notify_line`: + +```rust +/// Cached result of a previous version check. +#[derive(Serialize, Deserialize)] +struct CacheData { + checked_at: u64, + latest_tag: String, +} + +/// Path to the cache file, or `None` if no cache dir is available. +fn cache_path() -> Option { + dirs::cache_dir().map(|d| d.join("git-supervisor").join("version-check.json")) +} + +/// Current unix time in seconds (0 on the impossible pre-epoch error). +fn now_unix() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +/// Whether a cache entry stamped at `checked_at` is older than `ttl` at `now`. Pure. +fn cache_is_stale(checked_at: u64, now: u64, ttl: u64) -> bool { + now.saturating_sub(checked_at) >= ttl +} + +/// Read the cache, returning `None` on any error (missing, unreadable, corrupt). +fn read_cache() -> Option { + let path = cache_path()?; + let data = std::fs::read_to_string(path).ok()?; + serde_json::from_str(&data).ok() +} + +/// Write the cache, ignoring all errors (best-effort). +fn write_cache(tag: &str) { + let Some(path) = cache_path() else { + return; + }; + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + let data = CacheData { + checked_at: now_unix(), + latest_tag: tag.to_string(), + }; + if let Ok(json) = serde_json::to_string(&data) { + let _ = std::fs::write(path, json); + } +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `cargo test --lib version_check::tests::cache_is_stale_respects_ttl` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/version_check.rs +git commit -m "feat(version-check): add cache read/write and staleness check" +``` + +--- + +## Task 5: Network fetch + entry points + +**Files:** +- Modify: `src/version_check.rs` + +> Note: `fetch_latest_release` makes a real network call and is intentionally kept thin (all tested logic lives in the pure helpers it calls), so there is no unit test for it. The orchestrators `run_version_check` / `maybe_notify_update` are likewise thin glue; they are exercised via the subcommand wiring test in Task 6 and manually. + +- [ ] **Step 1: Implement `fetch_latest_release`** + +In `src/version_check.rs`, add after the cache helpers: + +```rust +/// Fetch the latest published release from the GitHub API. Networked. +/// Times out after `HTTP_TIMEOUT_SECS` so it can never hang the caller. +fn fetch_latest_release() -> Result { + let url = format!( + "https://api.github.com/repos/{}/releases/latest", + GITHUB_REPO + ); + let user_agent = format!("git-supervisor/{}", env!("CARGO_PKG_VERSION")); + let agent = ureq::AgentBuilder::new() + .timeout(Duration::from_secs(HTTP_TIMEOUT_SECS)) + .build(); + let body = agent + .get(&url) + .set("User-Agent", &user_agent) + .set("Accept", "application/vnd.github+json") + .call() + .context("requesting latest release from GitHub")? + .into_string() + .context("reading GitHub response body")?; + parse_release(&body) +} +``` + +- [ ] **Step 2: Implement `maybe_notify_update` (auto path)** + +In `src/version_check.rs`, add after `fetch_latest_release`: + +```rust +/// Background check for `watch`: cache-gated and silent on every error. +/// Prints at most one highlighted line when a newer release exists. +pub fn maybe_notify_update(current: &str) { + let latest_tag = match read_cache() { + Some(c) if !cache_is_stale(c.checked_at, now_unix(), CACHE_TTL_SECS) => c.latest_tag, + _ => match fetch_latest_release() { + Ok(info) => { + write_cache(&info.tag); + info.tag + } + Err(e) => { + console::log_debug(format!("version check skipped: {}", e)); + return; + } + }, + }; + let info = ReleaseInfo { + html_url: format!("https://github.com/{}/releases/tag/{}", GITHUB_REPO, latest_tag), + tag: latest_tag, + }; + if let Some(line) = notify_line(current, &info) { + console::log_highlight(line); + } +} +``` + +- [ ] **Step 3: Implement `run_version_check` (explicit subcommand)** + +In `src/version_check.rs`, add after `maybe_notify_update`: + +```rust +/// Explicit `version` subcommand: always fetch fresh, refresh the cache, +/// and print the full result. Returns `Err` on network/parse failure. +pub fn run_version_check(current: &str) -> Result<()> { + println!("git-supervisor {}", current); + let latest = fetch_latest_release().context("checking GitHub for the latest release")?; + write_cache(&latest.tag); + match compare_versions(current, &latest.tag) { + Ordering::Less => { + println!( + "{}", + console::highlight(format!("A newer release is available: {}", latest.tag)) + ); + println!("Release page: {}", latest.html_url); + println!( + "Download binaries: https://github.com/{}/releases", + GITHUB_REPO + ); + } + Ordering::Equal => { + println!("{}", console::highlight("You are on the latest release.")); + } + Ordering::Greater => { + println!( + "You are ahead of the latest published release ({}).", + latest.tag + ); + } + } + Ok(()) +} +``` + +- [ ] **Step 4: Verify the crate compiles cleanly** + +Run: `cargo build` +Expected: builds with no errors and no warnings about the new module. + +- [ ] **Step 5: Commit** + +```bash +git add src/version_check.rs +git commit -m "feat(version-check): add network fetch and entry points" +``` + +--- + +## Task 6: `version` subcommand wiring + +**Files:** +- Modify: `src/lib.rs` +- Modify: `src/main.rs` + +- [ ] **Step 1: Re-export the entry points from the crate root** + +In `src/lib.rs`, after the existing `pub use cleanup::{run_cleanup, CleanupOpts};` line, add: + +```rust +pub use version_check::{maybe_notify_update, run_version_check}; +``` + +- [ ] **Step 2: Add a failing clap-wiring test** + +In `src/main.rs`, inside the `#[cfg(test)] mod tests` block, add: + +```rust + #[test] + fn cli_version_subcommand_parses() { + let cli = Cli::try_parse_from(["supervisor", "version"]).unwrap(); + assert!(matches!(cli.command, Command::Version)); + } +``` + +- [ ] **Step 3: Run the test to verify it fails** + +Run: `cargo test --bin git-supervisor cli_version_subcommand_parses` +Expected: FAIL — `no variant named Version found for enum Command`. + +- [ ] **Step 4: Add the subcommand variant** + +In `src/main.rs`, in the `enum Command` block, add a new variant after `PrintScript`: + +```rust + /// Print the current version and check GitHub for a newer release + Version, +``` + +- [ ] **Step 5: Import the entry point and handle the variant** + +In `src/main.rs`, update the `use git_supervisor::{...}` line (line 3) to include `run_version_check`: + +```rust +use git_supervisor::{run_check, run_cleanup, run_local_watch, run_status, run_version_check, run_watch, CentralConfig, CleanupOpts, StatusOpts, WatchOpts, CHECK_PUSH_SCRIPT}; +``` + +Then, in the `match &cli.command` block in `main()`, add an arm (place it right after the `Command::PrintScript => { ... }` arm): + +```rust + Command::Version => run_version_check(env!("CARGO_PKG_VERSION")), +``` + +- [ ] **Step 6: Run the test to verify it passes** + +Run: `cargo test --bin git-supervisor cli_version_subcommand_parses` +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add src/lib.rs src/main.rs +git commit -m "feat(version-check): add version subcommand" +``` + +--- + +## Task 7: Auto-check during `watch` + +**Files:** +- Modify: `src/lib.rs` + +- [ ] **Step 1: Fire the background check at watch startup** + +In `src/lib.rs`, inside `pub async fn run_watch`, add the following immediately after the opening lines that set up `interval`/`deadline`/`round` and before the `if !opts.skip_prepare {` block (around line 466). `opts.version` is cloned here because it is moved into the webhook server later in the function: + +```rust + // Background version check: cache-gated, never blocks the loop, swallows + // all errors. Runs on a blocking thread because the HTTP client is sync. + let current_version = opts.version.clone(); + tokio::task::spawn_blocking(move || version_check::maybe_notify_update(¤t_version)); +``` + +- [ ] **Step 2: Verify it compiles and all existing tests still pass** + +Run: `cargo test` +Expected: full test suite PASS, including the new `version_check` and `main` tests. No new warnings. + +- [ ] **Step 3: Manually sanity-check the subcommand against the live API** + +Run: `cargo run -- version` +Expected: prints `git-supervisor 2.1.8` followed by either "You are on the latest release." or a "newer release available" notice with a URL. (If offline, it prints an error and exits non-zero — that is correct behavior for the explicit command.) + +- [ ] **Step 4: Commit** + +```bash +git add src/lib.rs +git commit -m "feat(version-check): auto-check for updates on watch startup" +``` + +--- + +## Task 8: Documentation + +**Files:** +- Modify: `README.md` + +- [ ] **Step 1: Find where subcommands are documented** + +Run: `grep -n "PrintScript\|print-script\|## Usage\|### " README.md` +Expected: shows the headings/subcommand list so you can place the new section consistently with the existing ones. + +- [ ] **Step 2: Add a `version` subcommand section** + +In `README.md`, add a short section near the other subcommand docs (match the surrounding heading style). Use this content: + +```markdown +### `version` — check for updates + +```bash +git-supervisor version +``` + +Prints the current version and checks the [GitHub Releases](https://github.com/jfding/git-supervisor/releases) +API for the latest published release. If a newer release exists, it shows the +new tag and the release page URL. This is **notify-only** — it never downloads +or replaces the binary. + +The `watch` command runs the same check once at startup (cached for 24h under +`~/.cache/git-supervisor/version-check.json`) and prints a one-line notice if a +newer release is available. The check is best-effort: any network error is +ignored and never interrupts watching. +``` + +- [ ] **Step 3: Commit** + +```bash +git add README.md +git commit -m "docs: document version subcommand and watch auto-check" +``` + +--- + +## Self-Review Notes + +- **Spec coverage:** detect+notify only (no download anywhere) ✓; explicit `version` subcommand (Task 6) ✓; cached 24h auto-check in `watch` (Tasks 4 + 7) ✓; ureq blocking client (Task 1) ✓; `/releases/latest` + serde_json parse (Tasks 1, 5) ✓; numeric semver compare, no semver crate (Task 2) ✓; cache at `~/.cache/git-supervisor/version-check.json` (Task 4) ✓; auto path swallows errors / explicit path surfaces them (Task 5) ✓; tests for compare/parse/staleness + clap wiring (Tasks 1–4, 6) ✓. +- **Type consistency:** `ReleaseInfo { tag, html_url }`, `compare_versions(current, latest_tag) -> Ordering`, `notify_line(current, &ReleaseInfo) -> Option`, `cache_is_stale(checked_at, now, ttl) -> bool`, `run_version_check(current) -> Result<()>`, `maybe_notify_update(current)` — names and signatures are identical everywhere they appear. +- **No placeholders:** every code step contains complete, compilable code. diff --git a/docs/superpowers/specs/2026-06-15-version-check-design.md b/docs/superpowers/specs/2026-06-15-version-check-design.md new file mode 100644 index 0000000..075abcd --- /dev/null +++ b/docs/superpowers/specs/2026-06-15-version-check-design.md @@ -0,0 +1,121 @@ +# Design: background version-check for git-supervisor + +Date: 2026-06-15 + +## Goal + +Detect when a newer GitHub release of `git-supervisor` exists and tell the +user. This feature is **notify-only**: it never downloads, replaces, or +modifies the running binary. + +## Decisions (from brainstorming) + +- **Scope:** detect + notify only. No self-update / download. +- **Triggers:** an explicit subcommand *and* a cached background check during + `watch`. +- **Detection method:** `git ls-remote --tags --refs https://github.com/jfding/git-supervisor.git`, + using the system `git` binary that git-supervisor already requires at runtime. + This adds **no new Rust dependency, no C compiler, and no TLS stack** to the + statically-linked musl binary (git handles HTTPS). We filter the tags to + stable releases ourselves and pick the highest by semver. + + > **Why not the GitHub Releases API + an HTTP client?** The original plan was + > `ureq` + the `/releases/latest` API. `ureq`'s rustls backend pulls in `ring`, + > which compiles C/assembly and needs a musl C compiler — a new build + > dependency for a project whose build is otherwise pure Rust, and binary + > bloat. Since `git` is already a hard runtime dependency and the project + > already has a release-tag convention (`release_tag_pattern`), `git ls-remote` + > is the cleaner fit. Trade-off: no release-notes URL (we synthesize the + > releases page URL) and we must filter pre-releases ourselves. + +## New module: `src/version_check.rs` + +Single-purpose module, exported from `lib.rs`. Public surface: + +- `parse_ls_remote_tags(output: &str) -> Vec` — **pure.** Parses + `git ls-remote` output (`\trefs/tags/` lines) into a de-duplicated + list of tag names, stripping the `refs/tags/` prefix and any `^{}` peel + suffix. +- `latest_stable_tag(tags: &[String]) -> Option` — **pure.** Keeps only + stable release tags (optional `v`, then dot-separated all-numeric components, + e.g. `v2.1.8`; rejects `v2.2.0-rc1`) and returns the highest by semver. +- `compare_versions(current: &str, latest_tag: &str) -> std::cmp::Ordering` — + **pure.** Strips a leading `v` from each, parses `major.minor.patch` into a + numeric tuple, and compares. No semver crate needed. Malformed components + parse as `0`. +- `fetch_latest_tag() -> Result` — the only function with side effects. + Runs `git ls-remote --tags --refs https://github.com/jfding/git-supervisor.git` + (matching `ops::remote_refs_fingerprint`'s invocation style), with + `GIT_TERMINAL_PROMPT=0` so a credential prompt can never hang it. Parses the + output and returns the latest stable tag. +- `run_version_check(current: &str) -> Result<()>` — for the explicit + subcommand. Always fetches fresh, refreshes the cache, prints the full + result. +- `maybe_notify_update(current: &str)` — for the auto path. Cache-gated, + swallows all errors, prints at most one styled line. + +The repository slug is a module constant: `GITHUB_REPO = "jfding/git-supervisor"`. +The release page URL (`https://github.com//releases`) is synthesized for +the notice, since `git ls-remote` does not provide one. + +## Caching + +- Location: `~/.cache/git-supervisor/version-check.json` (via + `dirs::cache_dir()`; fall back to skipping the cache if no cache dir). +- Contents: `{ "checked_at": , "latest_tag": "v2.1.8" }`. +- TTL: **24 hours.** +- `cache_is_stale(checked_at, now, ttl) -> bool` is a **pure** function (clock + injected) so it is testable without real time. +- Auto-check (`maybe_notify_update`): only hits the network when the cache is + missing or stale; otherwise compares against the cached tag. After a network + fetch, rewrites the cache. +- Explicit subcommand (`run_version_check`): always fetches fresh, then + refreshes the cache. + +## Triggers + +### 1. Explicit subcommand: `git-supervisor version` + +- Prints the current version. +- Fetches the latest release (fresh). +- If behind: prints the new tag, the release `html_url`, and a line directing + the user to download from GitHub Releases. +- If up to date: prints a confirmation. +- On network/parse failure: prints a warning and exits non-zero. + +### 2. Auto-check during `watch` + +- At `watch` startup, run `maybe_notify_update` inside `tokio::spawn_blocking` + (ureq is blocking) so it never delays the first check-push round. +- If behind: prints one styled line via the `console` module. +- On any error (network, parse, cache): silent — nothing is printed. + +## Error handling + +Notify-only must never break `watch`. The auto path swallows all +network/parse/cache errors. The explicit subcommand surfaces them to the user. + +## Dependencies + +**None.** No new Rust dependency. Detection uses the system `git` binary via +`std::process::Command`. `serde`/`serde_json` (cache) and `dirs` (cache path) +are already present. + +## Testing + +Unit tests (no real network, no git invocation): + +- `compare_versions`: behind / equal / ahead / `v`-prefixed / malformed input. +- `parse_ls_remote_tags`: sample `ls-remote` output → tag list; `^{}` peel + suffix stripped; duplicates removed. +- `latest_stable_tag`: a mix of stable + pre-release + junk tags → the highest + stable tag; empty/no-stable → `None`. +- `cache_is_stale`: fresh vs expired, with an injected `now`. + +Integration / wiring: + +- A clap parse test for the new `version` subcommand, matching the existing + `main.rs` test style. + +The side-effecting `fetch_latest_tag` is kept thin; the tested logic lives in +the pure functions it calls. diff --git a/src/lib.rs b/src/lib.rs index 7688882..f34499f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,10 +13,12 @@ pub mod keys; pub mod ops; pub mod ssh; pub mod status; +pub mod version_check; pub use config::{CentralConfig, Defaults, Host, Repo}; pub use status::{run_status, StatusOpts}; pub use cleanup::{run_cleanup, CleanupOpts}; +pub use version_check::{maybe_notify_update, run_version_check}; /// Options for the watch event loop. pub struct WatchOpts { @@ -463,6 +465,12 @@ pub async fn run_watch( let mut last_remote_refs: HashMap = HashMap::new(); let mut first_timer_done = false; + // Background version check: cache-gated, never blocks the loop, swallows + // all errors. Runs on a blocking thread because it shells out to git. + // opts.version is cloned because it is moved into the webhook server below. + let current_version = opts.version.clone(); + tokio::task::spawn_blocking(move || version_check::maybe_notify_update(¤t_version)); + if !opts.skip_prepare { run_prepare(config, opts.ignore_missing)?; } diff --git a/src/main.rs b/src/main.rs index d5b26f4..480542a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,6 @@ use clap::Parser; use git_supervisor::console; -use git_supervisor::{run_check, run_cleanup, run_local_watch, run_status, run_watch, CentralConfig, CleanupOpts, StatusOpts, WatchOpts, CHECK_PUSH_SCRIPT}; +use git_supervisor::{run_check, run_cleanup, run_local_watch, run_status, run_version_check, run_watch, CentralConfig, CleanupOpts, StatusOpts, WatchOpts, CHECK_PUSH_SCRIPT}; use std::path::PathBuf; #[derive(Parser)] @@ -27,6 +27,8 @@ enum Command { Watch(WatchArgs), /// Print the embedded check-push.sh script to stdout PrintScript, + /// Print the current version and check GitHub for a newer release + Version, } #[derive(clap::Args)] @@ -117,6 +119,7 @@ fn main() { print!("{}", CHECK_PUSH_SCRIPT); Ok(()) } + Command::Version => run_version_check(env!("CARGO_PKG_VERSION")), Command::Check => { let path = config_path.unwrap_or_else(|| { console::log_error( @@ -255,6 +258,12 @@ mod tests { assert!(result.is_err()); } + #[test] + fn cli_version_subcommand_parses() { + let cli = Cli::try_parse_from(["supervisor", "version"]).unwrap(); + assert!(matches!(cli.command, Command::Version)); + } + #[test] fn cli_status_parses_no_args() { let cli = Cli::try_parse_from(["supervisor", "status"]).unwrap(); diff --git a/src/version_check.rs b/src/version_check.rs new file mode 100644 index 0000000..5a7e1ee --- /dev/null +++ b/src/version_check.rs @@ -0,0 +1,306 @@ +//! Notify-only version check against the GitHub release tags. +//! +//! This module never downloads or replaces the binary. It only detects whether +//! a newer published release exists and tells the user. +//! +//! Detection uses `git ls-remote --tags` against the GitHub repo via the system +//! `git` binary (already a hard runtime dependency), so there is no new Rust +//! dependency, no C compiler, and no TLS stack in the statically-linked binary. + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use std::cmp::Ordering; +use std::path::PathBuf; +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::console; + +const GITHUB_REPO: &str = "jfding/git-supervisor"; +const CACHE_TTL_SECS: u64 = 24 * 60 * 60; + +/// Git clone URL used for `git ls-remote`. +fn git_url() -> String { + format!("https://github.com/{}.git", GITHUB_REPO) +} + +/// Releases page URL, synthesized for the update notice. +fn releases_url() -> String { + format!("https://github.com/{}/releases", GITHUB_REPO) +} + +/// Parse `git ls-remote` output into a de-duplicated list of tag names. Pure. +/// +/// Each line looks like `\trefs/tags/`. We strip the `refs/tags/` +/// prefix and any `^{}` peel suffix (defensive — `--refs` already removes them). +pub fn parse_ls_remote_tags(output: &str) -> Vec { + let mut tags: Vec = Vec::new(); + for line in output.lines() { + let Some((_, refname)) = line.split_once('\t') else { + continue; + }; + let Some(name) = refname.trim().strip_prefix("refs/tags/") else { + continue; + }; + let name = name.strip_suffix("^{}").unwrap_or(name).to_string(); + if !name.is_empty() && !tags.contains(&name) { + tags.push(name); + } + } + tags +} + +/// Whether a tag names a stable release: optional leading `v`, then +/// dot-separated all-numeric components (e.g. `v2.1.8`). Rejects pre-releases +/// like `v2.2.0-rc1` and non-version tags. Pure. +fn is_stable_release_tag(tag: &str) -> bool { + let s = tag.trim().trim_start_matches(['v', 'V']); + !s.is_empty() + && s.split('.') + .all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_digit())) +} + +/// Highest stable release tag among `tags`, or `None` if there are none. Pure. +pub fn latest_stable_tag(tags: &[String]) -> Option { + tags.iter() + .filter(|t| is_stable_release_tag(t)) + .max_by(|a, b| compare_versions(a, b)) + .cloned() +} + +/// Parse a version string into a `(major, minor, patch)` tuple. +/// Strips a leading `v`/`V`. Each component keeps only its leading digits +/// (so `8-rc1` becomes `8`); anything unparseable becomes `0`. +fn parse_semver(s: &str) -> (u64, u64, u64) { + let s = s.trim().trim_start_matches(['v', 'V']); + let mut parts = s.split('.').map(|p| { + let digits: String = p.chars().take_while(|c| c.is_ascii_digit()).collect(); + digits.parse::().unwrap_or(0) + }); + ( + parts.next().unwrap_or(0), + parts.next().unwrap_or(0), + parts.next().unwrap_or(0), + ) +} + +/// Compare a current version against a release tag. Pure. +/// `Ordering::Less` means the current version is behind `latest_tag`. +pub fn compare_versions(current: &str, latest_tag: &str) -> Ordering { + parse_semver(current).cmp(&parse_semver(latest_tag)) +} + +/// Build a one-line update notice if `latest_tag` is newer than `current`, +/// otherwise `None`. Pure (no I/O, no color). +pub fn notify_line(current: &str, latest_tag: &str) -> Option { + if compare_versions(current, latest_tag) == Ordering::Less { + Some(format!( + "A new git-supervisor release is available: {} (current {}). Download: {}", + latest_tag, + current, + releases_url() + )) + } else { + None + } +} + +/// Cached result of a previous version check. +#[derive(Serialize, Deserialize)] +struct CacheData { + checked_at: u64, + latest_tag: String, +} + +/// Path to the cache file, or `None` if no cache dir is available. +fn cache_path() -> Option { + dirs::cache_dir().map(|d| d.join("git-supervisor").join("version-check.json")) +} + +/// Current unix time in seconds (0 on the impossible pre-epoch error). +fn now_unix() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +/// Whether a cache entry stamped at `checked_at` is older than `ttl` at `now`. Pure. +fn cache_is_stale(checked_at: u64, now: u64, ttl: u64) -> bool { + now.saturating_sub(checked_at) >= ttl +} + +/// Read the cache, returning `None` on any error (missing, unreadable, corrupt). +fn read_cache() -> Option { + let path = cache_path()?; + let data = std::fs::read_to_string(path).ok()?; + serde_json::from_str(&data).ok() +} + +/// Write the cache, ignoring all errors (best-effort). +fn write_cache(tag: &str) { + let Some(path) = cache_path() else { + return; + }; + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + let data = CacheData { + checked_at: now_unix(), + latest_tag: tag.to_string(), + }; + if let Ok(json) = serde_json::to_string(&data) { + let _ = std::fs::write(path, json); + } +} + +/// Fetch the latest stable release tag via `git ls-remote`. Side-effecting. +/// +/// `GIT_TERMINAL_PROMPT=0` ensures a credential prompt can never hang the call. +fn fetch_latest_tag() -> Result { + let output = std::process::Command::new("git") + .arg("ls-remote") + .arg("--tags") + .arg("--refs") + .arg(git_url()) + .env("GIT_TERMINAL_PROMPT", "0") + .output() + .context("running git ls-remote")?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + anyhow::bail!( + "git ls-remote failed: {}", + if stderr.is_empty() { + format!("exit {}", output.status) + } else { + stderr + } + ); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let tags = parse_ls_remote_tags(&stdout); + latest_stable_tag(&tags).context("no stable release tags found via git ls-remote") +} + +/// Background check for `watch`: cache-gated and silent on every error. +/// Prints at most one highlighted line when a newer release exists. +pub fn maybe_notify_update(current: &str) { + let latest_tag = match read_cache() { + Some(c) if !cache_is_stale(c.checked_at, now_unix(), CACHE_TTL_SECS) => c.latest_tag, + _ => match fetch_latest_tag() { + Ok(tag) => { + write_cache(&tag); + tag + } + Err(e) => { + console::log_debug(format!("version check skipped: {}", e)); + return; + } + }, + }; + if let Some(line) = notify_line(current, &latest_tag) { + console::log_highlight(line); + } +} + +/// Explicit `version` subcommand: always fetch fresh, refresh the cache, +/// and print the full result. Returns `Err` on lookup failure. +pub fn run_version_check(current: &str) -> Result<()> { + println!("git-supervisor {}", current); + let latest = fetch_latest_tag().context("checking GitHub for the latest release")?; + write_cache(&latest); + match compare_versions(current, &latest) { + Ordering::Less => { + println!( + "{}", + console::highlight(format!("A newer release is available: {}", latest)) + ); + println!("Download binaries: {}", releases_url()); + } + Ordering::Equal => { + println!("{}", console::highlight("You are on the latest release.")); + } + Ordering::Greater => { + println!( + "You are ahead of the latest published release ({}).", + latest + ); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_ls_remote_tags_extracts_and_dedupes() { + let output = "\ +deadbeef\trefs/tags/v2.1.8 +cafef00d\trefs/tags/v2.2.0 +cafef00d\trefs/tags/v2.2.0^{} +abc123\trefs/tags/v2.0.0 +"; + let tags = parse_ls_remote_tags(output); + assert_eq!(tags, vec!["v2.1.8", "v2.2.0", "v2.0.0"]); + } + + #[test] + fn parse_ls_remote_tags_ignores_garbage_lines() { + let output = "not-a-ref-line\n\tno-sha-but-tab\nsha\trefs/heads/master\n"; + assert!(parse_ls_remote_tags(output).is_empty()); + } + + #[test] + fn latest_stable_tag_picks_highest_stable() { + let tags = vec![ + "v2.1.8".to_string(), + "v2.2.0-rc1".to_string(), + "v2.10.0".to_string(), + "nightly".to_string(), + "v2.2.0".to_string(), + ]; + assert_eq!(latest_stable_tag(&tags), Some("v2.10.0".to_string())); + } + + #[test] + fn latest_stable_tag_none_when_no_stable() { + let tags = vec!["v2.2.0-rc1".to_string(), "latest".to_string()]; + assert_eq!(latest_stable_tag(&tags), None); + assert_eq!(latest_stable_tag(&[]), None); + } + + #[test] + fn compare_versions_orders_correctly() { + assert_eq!(compare_versions("2.1.8", "v2.1.9"), Ordering::Less); + assert_eq!(compare_versions("2.1.8", "v2.1.8"), Ordering::Equal); + assert_eq!(compare_versions("2.2.0", "v2.1.9"), Ordering::Greater); + assert_eq!(compare_versions("v2.1.8", "2.1.8"), Ordering::Equal); + assert_eq!(compare_versions("2.1.10", "v2.2.0"), Ordering::Less); + assert_eq!(compare_versions("garbage", "v0.0.0"), Ordering::Equal); + assert_eq!(compare_versions("2.1.8", "v2.1.8-rc1"), Ordering::Equal); + } + + #[test] + fn notify_line_only_when_behind() { + let line = notify_line("2.1.8", "v2.2.0").expect("should notify when behind"); + assert!(line.contains("v2.2.0")); + assert!(line.contains("2.1.8")); + assert!(line.contains("github.com/jfding/git-supervisor/releases")); + + assert!(notify_line("2.2.0", "v2.2.0").is_none()); + assert!(notify_line("2.3.0", "v2.2.0").is_none()); + } + + #[test] + fn cache_is_stale_respects_ttl() { + let ttl = 24 * 60 * 60; + assert!(!cache_is_stale(1_000_000, 1_000_000 + 3_600, ttl)); + assert!(cache_is_stale(1_000_000, 1_000_000 + ttl, ttl)); + assert!(cache_is_stale(1_000_000, 1_000_000 + ttl + 1, ttl)); + // clock skew (now < checked_at) must not panic and counts as fresh + assert!(!cache_is_stale(1_000_000, 999_000, ttl)); + } +}