From 921a31e8a15031796976515810b623463e75a012 Mon Sep 17 00:00:00 2001 From: DANILIAMMO <67186086+daniliammo@users.noreply.github.com> Date: Thu, 5 Nov 2026 20:03:57 +0500 Subject: [PATCH 1/2] launcher: include dated Nightlies in automatic version pruning The GUI runs the shared pruning path after saving its selection, but its release-only filter excludes every dated Nightly. Include those builds so the existing retention, selection, instance-pin and in-use protections also apply to GUI Nightly updates. Add regression coverage for following updates, selected and pinned Nightlies, and retention across Nightly/release/local builds. Co-Authored-By: Codex GPT-6 --- launcher/src/backend.rs | 59 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 55 insertions(+), 4 deletions(-) diff --git a/launcher/src/backend.rs b/launcher/src/backend.rs index 2970c7b6de..289f0bcdd0 100644 --- a/launcher/src/backend.rs +++ b/launcher/src/backend.rs @@ -2893,13 +2893,13 @@ pub fn remove_version(tag: &str) -> Result<(), String> { /// How many of the newest release versions stay installed when the player has not said. const KEEP_NEWEST: usize = 2; -/// The release versions to delete: all but the `keep` newest of `installed` (newest first), and -/// never one of `protected`. Nightly builds are left out: `nightly::after_update` removes the -/// older ones. +/// The release and dated Nightly versions to delete: all but the `keep` newest of `installed` +/// (newest first), and never one of `protected`. The GUI calls this shared pruning path after +/// an install, without going through the CLI's `nightly::after_update`. fn stale_versions(installed: &[String], keep: usize, protected: &[String]) -> Vec { installed .iter() - .filter(|t| is_game_tag(t)) + .filter(|t| is_game_tag(t) || crate::nightly::date_of(t).is_some()) .skip(keep) .filter(|t| !protected.contains(t)) .cloned() @@ -6737,6 +6737,57 @@ pub fn {f}(" assert!(stale_versions(&installed, 9, &[]).is_empty()); } + #[test] + fn pruning_removes_unused_nightlies_when_the_instance_follows_updates() { + let installed = [ + "nightly-2026-11-05-f319701", + "nightly-2026-11-04-6475f62", + "nightly-2026-11-03-f42ef42", + "nightly-2026-10-31-d91cab6", + ] + .map(String::from); + // The default instance follows the selected version, so it adds no pin. + let protected = [installed[0].clone()]; + assert_eq!( + stale_versions(&installed, 2, &protected), + ["nightly-2026-11-03-f42ef42", "nightly-2026-10-31-d91cab6"] + ); + } + + #[test] + fn pruning_keeps_selected_and_instance_pinned_nightlies() { + let installed = [ + "nightly-2026-11-05-f319701", + "nightly-2026-11-04-6475f62", + "nightly-2026-11-03-f42ef42", + "nightly-2026-11-01-c9d474d", + "nightly-2026-10-31-d91cab6", + ] + .map(String::from); + // A manual selection and a different instance's pin both survive the keep limit. + let protected = [installed[2].clone(), installed[4].clone()]; + assert_eq!( + stale_versions(&installed, 1, &protected), + ["nightly-2026-11-04-6475f62", "nightly-2026-11-01-c9d474d"] + ); + assert!(stale_versions(&installed, 5, &protected).is_empty()); + } + + #[test] + fn pruning_counts_nightlies_and_releases_but_leaves_local_builds_alone() { + let installed = [ + "native-local-build", + "imported-2026-11-05-abcdef0", + "nightly-2026-11-05-f319701", + "nightly-2026-11-04-6475f62", + "v0.2.91", + "v0.2.90", + ] + .map(String::from); + assert_eq!(stale_versions(&installed, 2, &[]), ["v0.2.91", "v0.2.90"]); + assert_eq!(stale_versions(&installed, 3, &[]), ["v0.2.90"]); + } + #[test] fn crash_reports_are_found_and_summarised() { let dir = From 86c0211c35d0775de516d2b4fd207df8b581e76e Mon Sep 17 00:00:00 2001 From: DANILIAMMO <67186086+daniliammo@users.noreply.github.com> Date: Thu, 5 Nov 2026 20:34:10 +0500 Subject: [PATCH 2/2] launcher: preserve manually installed Nightlies during pruning Record a marker only for Nightlies installed by automatic updates. Keep manual and legacy Nightlies out of GUI and CLI cleanup, while retaining the newest managed Nightlies separately from game releases. Add regression coverage for manual, legacy, selected and pinned builds. Co-Authored-By: Codex GPT-6 --- launcher/src/backend.rs | 103 +++++++++++++++++++++++++++++++++------- launcher/src/main.rs | 7 +++ launcher/src/nightly.rs | 32 +++++++++++-- 3 files changed, 120 insertions(+), 22 deletions(-) diff --git a/launcher/src/backend.rs b/launcher/src/backend.rs index 289f0bcdd0..3c4270c661 100644 --- a/launcher/src/backend.rs +++ b/launcher/src/backend.rs @@ -2890,17 +2890,47 @@ pub fn remove_version(tag: &str) -> Result<(), String> { Ok(()) } -/// How many of the newest release versions stay installed when the player has not said. +/// How many of the newest managed versions of each kind stay installed by default. const KEEP_NEWEST: usize = 2; -/// The release and dated Nightly versions to delete: all but the `keep` newest of `installed` -/// (newest first), and never one of `protected`. The GUI calls this shared pruning path after -/// an install, without going through the CLI's `nightly::after_update`. -fn stale_versions(installed: &[String], keep: usize, protected: &[String]) -> Vec { +/// Only Nightlies installed by an update may be removed automatically. Absence of this marker +/// means a manual install or a version from an older launcher whose origin is unknown. +const AUTO_INSTALLED: &str = ".auto-installed"; + +pub(crate) fn auto_installed(tag: &str) -> bool { + version_dir(tag).join(AUTO_INSTALLED).is_file() +} + +pub fn mark_auto_installed(tag: &str) -> Result<(), String> { + let dir = version_dir(tag); + if !dir.join(COMPLETE).is_file() { + return Err(format!("{tag} is not installed.")); + } + fs::write(dir.join(AUTO_INSTALLED), "").map_err(|e| e.to_string()) +} + +/// Keep the newest releases and automatically installed Nightlies separately. Manual and legacy +/// Nightlies are outside the quota; selected and instance-pinned versions are always protected. +fn stale_versions( + installed: &[String], + keep: usize, + protected: &[String], + auto: impl Fn(&str) -> bool, +) -> Vec { + let (mut releases, mut nightlies) = (0, 0); installed .iter() - .filter(|t| is_game_tag(t) || crate::nightly::date_of(t).is_some()) - .skip(keep) + .filter(|t| { + if is_game_tag(t) { + releases += 1; + releases > keep + } else if crate::nightly::date_of(t).is_some() && auto(t) { + nightlies += 1; + nightlies > keep + } else { + false + } + }) .filter(|t| !protected.contains(t)) .cloned() .collect() @@ -2945,7 +2975,12 @@ pub fn prune_old_versions() -> Vec { .filter_map(|i| i.version), ); let mut gone = Vec::new(); - for tag in stale_versions(&installed(), settings.keep_newest(), &protected) { + for tag in stale_versions( + &installed(), + settings.keep_newest(), + &protected, + auto_installed, + ) { if in_use(&tag) { eprintln!("Keeping old version {tag}: the game is running from it."); continue; @@ -3273,6 +3308,11 @@ pub fn install_tag(tag: &str, as_update: bool, progress: impl Fn(f32)) -> Result eprintln!("{warning}"); } install(release, progress)?; + if as_update && release.nightly.is_some() { + if let Err(e) = mark_auto_installed(&release.tag) { + eprintln!("Could not mark updated Nightly {}: {e}", release.tag); + } + } let mut settings = Settings::load(); // An older Nightly is a deliberate rollback: installed, but an update never moves the selection back. let select = !as_update @@ -6726,15 +6766,15 @@ pub fn {f}(" "v0.2.91", "v0.2.90", "v0.2.88", "v0.2.85", "v0.2.80", "nightly", ]); assert_eq!( - stale_versions(&installed, 2, &tags(&["v0.2.85"])), + stale_versions(&installed, 2, &tags(&["v0.2.85"]), |_| false), tags(&["v0.2.88", "v0.2.80"]) ); // A protected version among the newest still leaves the newest `keep` in place. assert_eq!( - stale_versions(&installed, 2, &tags(&["v0.2.91"])), + stale_versions(&installed, 2, &tags(&["v0.2.91"]), |_| false), tags(&["v0.2.88", "v0.2.85", "v0.2.80"]) ); - assert!(stale_versions(&installed, 9, &[]).is_empty()); + assert!(stale_versions(&installed, 9, &[], |_| false).is_empty()); } #[test] @@ -6749,7 +6789,7 @@ pub fn {f}(" // The default instance follows the selected version, so it adds no pin. let protected = [installed[0].clone()]; assert_eq!( - stale_versions(&installed, 2, &protected), + stale_versions(&installed, 2, &protected, |_| true), ["nightly-2026-11-03-f42ef42", "nightly-2026-10-31-d91cab6"] ); } @@ -6767,14 +6807,14 @@ pub fn {f}(" // A manual selection and a different instance's pin both survive the keep limit. let protected = [installed[2].clone(), installed[4].clone()]; assert_eq!( - stale_versions(&installed, 1, &protected), + stale_versions(&installed, 1, &protected, |_| true), ["nightly-2026-11-04-6475f62", "nightly-2026-11-01-c9d474d"] ); - assert!(stale_versions(&installed, 5, &protected).is_empty()); + assert!(stale_versions(&installed, 5, &protected, |_| true).is_empty()); } #[test] - fn pruning_counts_nightlies_and_releases_but_leaves_local_builds_alone() { + fn pruning_keeps_releases_separate_from_nightlies_and_local_builds() { let installed = [ "native-local-build", "imported-2026-11-05-abcdef0", @@ -6784,8 +6824,37 @@ pub fn {f}(" "v0.2.90", ] .map(String::from); - assert_eq!(stale_versions(&installed, 2, &[]), ["v0.2.91", "v0.2.90"]); - assert_eq!(stale_versions(&installed, 3, &[]), ["v0.2.90"]); + assert!(stale_versions(&installed, 2, &[], |_| true).is_empty()); + assert_eq!( + stale_versions(&installed, 1, &[], |_| true), + ["nightly-2026-11-04-6475f62", "v0.2.90"] + ); + } + + #[test] + fn pruning_preserves_manual_and_legacy_nightlies() { + let root = + std::env::temp_dir().join(format!("horizon-prune-origin-{}", std::process::id())); + let _ = fs::remove_dir_all(&root); + let installed = [ + "nightly-2026-11-05-aaaaaaa", // automatically updated, selected + "nightly-2026-11-04-bbbbbbb", // installed manually + "nightly-2026-11-03-ccccccc", // automatically updated + "nightly-2026-11-02-ddddddd", // from an older launcher; origin unknown + "nightly-2026-11-01-eeeeeee", // automatically updated + ] + .map(String::from); + for tag in [&installed[0], &installed[2], &installed[4]] { + let dir = root.join(tag); + fs::create_dir_all(&dir).unwrap(); + fs::write(dir.join(AUTO_INSTALLED), "").unwrap(); + } + let auto = |tag: &str| root.join(tag).join(AUTO_INSTALLED).is_file(); + assert_eq!( + stale_versions(&installed, 2, &[installed[0].clone()], auto), + [installed[4].clone()] + ); + let _ = fs::remove_dir_all(&root); } #[test] diff --git a/launcher/src/main.rs b/launcher/src/main.rs index fd5cd5d6d7..463d2730a9 100644 --- a/launcher/src/main.rs +++ b/launcher/src/main.rs @@ -1301,6 +1301,13 @@ impl Launcher { } match result { Ok(()) => { + if as_update.is_some() + && horizon_launcher::nightly::date_of(&tag).is_some() + { + if let Err(e) = backend::mark_auto_installed(&tag) { + eprintln!("Could not mark updated Nightly {tag}: {e}"); + } + } if self.updates.horizon.as_ref().is_some_and(|r| r.tag == tag) { self.updates.horizon = None; } diff --git a/launcher/src/nightly.rs b/launcher/src/nightly.rs index 6dcbe4d18d..68c0de47a6 100644 --- a/launcher/src/nightly.rs +++ b/launcher/src/nightly.rs @@ -74,22 +74,31 @@ pub fn after_update(version: &str) { .into_iter() .filter_map(|i| i.version) .collect(); - for old in stale(&backend::installed(), version, selected.as_deref(), &pinned) { + for old in stale( + &backend::installed(), + version, + selected.as_deref(), + &pinned, + backend::auto_installed, + ) { let _ = backend::remove(&old); } } -/// The installed Nightlies to remove once `current` is installed: every other one that is neither -/// selected nor pinned by an instance. +/// The automatically installed Nightlies to remove once `current` is installed: every other one +/// that is neither selected nor pinned by an instance. Manual and legacy installs stay. fn stale( installed: &[String], current: &str, selected: Option<&str>, pinned: &[String], + auto: impl Fn(&str) -> bool, ) -> Vec { installed .iter() - .filter(|v| date_of(v).is_some() && *v != current && Some(v.as_str()) != selected) + .filter(|v| { + date_of(v).is_some() && auto(v) && *v != current && Some(v.as_str()) != selected + }) .filter(|v| !pinned.contains(v)) .cloned() .collect() @@ -150,9 +159,22 @@ mod tests { &installed, "nightly-2026-10-29-aaaaaaa", Some("nightly-2026-10-28-ccccccc"), - &pinned + &pinned, + |tag| tag != "nightly-2026-10-28-ccccccc" ), ["nightly-2026-10-28-bbbbbbb"] ); } + + #[test] + fn cli_update_keeps_unselected_manual_nightly() { + let current = "nightly-2026-11-05-aaaaaaa"; + let manual = "nightly-2026-11-03-bbbbbbb"; + let previous_auto = "nightly-2026-11-02-ccccccc"; + let installed = [current, manual, previous_auto].map(String::from); + assert_eq!( + stale(&installed, current, None, &[], |tag| tag != manual), + [previous_auto] + ); + } }