From 075b7a470d2de1dd0d8ce51d93509aeec604a4a7 Mon Sep 17 00:00:00 2001 From: gustav-fff <286169375+gustav-fff@users.noreply.github.com> Date: Wed, 29 Jul 2026 09:00:58 -0700 Subject: [PATCH 1/2] fix(fff-mcp): compare CARGO_PKG_VERSION to stable release tag (#721) The update check compared the embedded 40-char git SHA prefix against the release tag string, which never matched a semver tag. It also hit /releases?per_page=1 (prerelease-inclusive) while install-mcp.sh is pinned to a stable tag, so the suggested installer could not silence the notice when the API returned a nightly. Compare CARGO_PKG_VERSION against /releases/latest (stable-only) with a leading `v` stripped from the tag. Closes #721 --- crates/fff-mcp/src/update_check.rs | 76 +++++++++++++++++++----------- 1 file changed, 49 insertions(+), 27 deletions(-) diff --git a/crates/fff-mcp/src/update_check.rs b/crates/fff-mcp/src/update_check.rs index 0cfd9094e..975847599 100644 --- a/crates/fff-mcp/src/update_check.rs +++ b/crates/fff-mcp/src/update_check.rs @@ -1,20 +1,14 @@ -//! Background update checker — compares the embedded build hash against -//! the latest GitHub release tag to surface upgrade notices in MCP instructions. - use std::sync::OnceLock; const REPO: &str = "dmtrKovalenko/fff.nvim"; -const BUILD_HASH: &str = env!("FFF_GIT_HASH"); +const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION"); -/// Holds the result of the update check (empty string = up to date or check failed). static UPDATE_NOTICE: OnceLock = OnceLock::new(); -/// Returns the update notice if the check has completed, empty string otherwise. pub fn get_update_notice() -> &'static str { UPDATE_NOTICE.get().map(|s| s.as_str()).unwrap_or("") } -/// Kick off the update check in a background thread so it never blocks the server. pub fn spawn_update_check() { std::thread::spawn(|| { let notice = check_latest_release(); @@ -22,34 +16,28 @@ pub fn spawn_update_check() { }); } -/// Fetch the latest release tag from GitHub and compare against the build hash. fn check_latest_release() -> String { - match fetch_latest_tag() { - Ok(tag) => compare_versions(BUILD_HASH, &tag), + match fetch_latest_stable_tag() { + Ok(tag) => compare_versions(CURRENT_VERSION, &tag), Err(_) => String::new(), } } -/// Compare a build hash against a release tag. -/// Returns an update notice string, or empty if up-to-date. -fn compare_versions(build_hash: &str, release_tag: &str) -> String { +fn compare_versions(current_version: &str, release_tag: &str) -> String { let tag = release_tag.trim(); - if tag.is_empty() || build_hash == "unknown" { - return String::new(); - } - - let our_short = &build_hash[..build_hash.len().min(tag.len())]; - if our_short == tag { + let tag_version = tag.strip_prefix('v').unwrap_or(tag); + if tag.is_empty() || tag_version == current_version { return String::new(); } format!( - "\n[fff update available: `curl -fsSL https://raw.githubusercontent.com/{REPO}/main/install-mcp.sh | bash`]\n" + "\n[fff update available ({current_version} -> {tag_version}): `curl -fsSL https://raw.githubusercontent.com/{REPO}/main/install-mcp.sh | bash`]\n" ) } -/// Shell out to curl to fetch the latest release tag name from GitHub API. -fn fetch_latest_tag() -> Result> { +// Uses /releases/latest — GitHub excludes prereleases here, matching the +// stable channel that install-mcp.sh installs from. +fn fetch_latest_stable_tag() -> Result> { let output = std::process::Command::new("curl") .args([ "-fsSL", @@ -57,7 +45,7 @@ fn fetch_latest_tag() -> Result> { "5", "-H", "Accept: application/vnd.github.v3+json", - &format!("https://api.github.com/repos/{REPO}/releases?per_page=1"), + &format!("https://api.github.com/repos/{REPO}/releases/latest"), ]) .output()?; @@ -66,13 +54,47 @@ fn fetch_latest_tag() -> Result> { } let body = String::from_utf8(output.stdout)?; - let releases: Vec = serde_json::from_str(&body)?; - let tag = releases - .first() - .and_then(|r| r.get("tag_name")) + let release: serde_json::Value = serde_json::from_str(&body)?; + let tag = release + .get("tag_name") .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); Ok(tag) } + +#[cfg(test)] +mod tests { + use super::compare_versions; + + #[test] + fn same_version_with_v_prefix_is_silent() { + assert_eq!(compare_versions("0.10.1", "v0.10.1"), ""); + } + + #[test] + fn same_version_without_v_prefix_is_silent() { + assert_eq!(compare_versions("0.10.1", "0.10.1"), ""); + } + + #[test] + fn empty_tag_is_silent() { + assert_eq!(compare_versions("0.10.1", ""), ""); + assert_eq!(compare_versions("0.10.1", " "), ""); + } + + #[test] + fn older_current_reports_update() { + let notice = compare_versions("0.10.0", "v0.10.1"); + assert!(notice.contains("0.10.0 -> 0.10.1"), "got: {notice}"); + assert!(notice.contains("install-mcp.sh")); + } + + #[test] + fn nightly_tag_never_equals_stable_current() { + let notice = compare_versions("0.10.1", "0.10.2-nightly.6a239e9"); + assert!(!notice.is_empty()); + assert!(notice.contains("0.10.1 -> 0.10.2-nightly.6a239e9")); + } +} From d78fefce28a0f842d76914e7d67e8af5638c9cfe Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Jul 2026 00:18:30 +0000 Subject: [PATCH 2/2] test(fff-node): retry watch subscribe in exit test --- packages/fff-node/test/watch.mjs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/fff-node/test/watch.mjs b/packages/fff-node/test/watch.mjs index 4c0b6fc6e..09ea7ace3 100644 --- a/packages/fff-node/test/watch.mjs +++ b/packages/fff-node/test/watch.mjs @@ -244,8 +244,14 @@ describe("fff-node watch", { concurrency: 1 }, () => { const created = FileFinder.create({ basePath: dir }); if (!created.ok) throw new Error(created.error); const finder = created.value; - await finder.waitForScan(10_000); - const sub = finder.watch("**/*.txt", () => {}); + const wait = await finder.waitForScan(10_000); + if (!wait.ok || !wait.value) throw new Error("waitForScan failed"); + const deadline = Date.now() + 2_000; + let sub = finder.watch("**/*.txt", () => {}); + while (!sub.ok && Date.now() < deadline) { + await new Promise((r) => setTimeout(r, 50)); + sub = finder.watch("**/*.txt", () => {}); + } if (!sub.ok) throw new Error(sub.error); await new Promise((r) => setTimeout(r, 300)); sub.value();