Skip to main content

flake_edit/forge/
channel.rs

1//! Channel-based update logic for repos like nixpkgs, home-manager, and nix-darwin.
2//!
3//! These repos use branches (e.g., `nixos-24.11`, `nixpkgs-unstable`) instead of
4//! semver tags for versioning.
5
6use super::api::{ApiError, Branches, ForgeClient};
7
8/// Update strategy for a given input.
9#[derive(Debug, Clone, PartialEq)]
10pub(crate) enum UpdateStrategy {
11    /// Standard semver tag-based updates (most repos)
12    SemverTags,
13    /// nixpkgs channel-based updates (nixos-YY.MM, nixpkgs-YY.MM)
14    NixpkgsChannel,
15    /// home-manager channel-based updates (release-YY.MM)
16    HomeManagerChannel,
17    /// nix-darwin channel-based updates (nix-darwin-YY.MM)
18    NixDarwinChannel,
19}
20
21/// Detected channel type from a ref string.
22#[derive(Debug, Clone, PartialEq)]
23pub(crate) enum ChannelType {
24    /// nixos-YY.MM (e.g., nixos-24.11)
25    NixosStable { year: u32, month: u32 },
26    /// nixpkgs-YY.MM (e.g., nixpkgs-24.11)
27    NixpkgsStable { year: u32, month: u32 },
28    /// Unstable/rolling branches (nixos-unstable, nixpkgs-unstable, master, main)
29    Unstable,
30    /// home-manager release-YY.MM
31    HomeManagerRelease { year: u32, month: u32 },
32    /// nix-darwin-YY.MM
33    NixDarwinStable { year: u32, month: u32 },
34    /// Bare version YY.MM (no prefix)
35    BareVersion { year: u32, month: u32 },
36    /// Not a recognized channel pattern
37    Unknown,
38}
39
40impl ChannelType {
41    /// Returns true if this is an unstable/rolling channel that shouldn't be updated.
42    pub(crate) fn is_unstable(&self) -> bool {
43        matches!(self, ChannelType::Unstable)
44    }
45
46    /// Returns the version tuple for comparison, if applicable.
47    pub(crate) fn version(&self) -> Option<(u32, u32)> {
48        match self {
49            ChannelType::NixosStable { year, month }
50            | ChannelType::NixpkgsStable { year, month }
51            | ChannelType::HomeManagerRelease { year, month }
52            | ChannelType::NixDarwinStable { year, month }
53            | ChannelType::BareVersion { year, month } => Some((*year, *month)),
54            _ => None,
55        }
56    }
57
58    /// Returns the branch prefix for finding similar channels.
59    pub(crate) fn prefix(&self) -> Option<&'static str> {
60        match self {
61            ChannelType::NixosStable { .. } => Some("nixos-"),
62            ChannelType::NixpkgsStable { .. } => Some("nixpkgs-"),
63            ChannelType::HomeManagerRelease { .. } => Some("release-"),
64            ChannelType::NixDarwinStable { .. } => Some("nix-darwin-"),
65            ChannelType::BareVersion { .. } => Some(""),
66            _ => None,
67        }
68    }
69}
70
71/// Detect the update strategy based on owner/repo.
72pub(crate) fn detect_strategy(owner: &str, repo: &str) -> UpdateStrategy {
73    match (owner.to_lowercase().as_str(), repo.to_lowercase().as_str()) {
74        ("nixos", "nixpkgs") => UpdateStrategy::NixpkgsChannel,
75        ("nix-community", "home-manager") => UpdateStrategy::HomeManagerChannel,
76        ("lnl7", "nix-darwin") | ("nix-community", "nix-darwin") => {
77            UpdateStrategy::NixDarwinChannel
78        }
79        _ => UpdateStrategy::SemverTags,
80    }
81}
82
83/// Parse a ref string to determine its channel type.
84pub(crate) fn parse_channel_ref(ref_str: &str) -> ChannelType {
85    let ref_str = ref_str.strip_prefix("refs/heads/").unwrap_or(ref_str);
86
87    if ref_str == "nixos-unstable" || ref_str == "nixpkgs-unstable" {
88        return ChannelType::Unstable;
89    }
90
91    // master/main are unstable branches for home-manager and nix-darwin
92    if ref_str == "master" || ref_str == "main" {
93        return ChannelType::Unstable;
94    }
95
96    if let Some(version) = ref_str.strip_prefix("nixos-")
97        && let Some((year, month)) = parse_version(version)
98    {
99        return ChannelType::NixosStable { year, month };
100    }
101
102    if let Some(version) = ref_str.strip_prefix("nixpkgs-")
103        && let Some((year, month)) = parse_version(version)
104    {
105        return ChannelType::NixpkgsStable { year, month };
106    }
107
108    if let Some(version) = ref_str.strip_prefix("release-")
109        && let Some((year, month)) = parse_version(version)
110    {
111        return ChannelType::HomeManagerRelease { year, month };
112    }
113
114    if let Some(version) = ref_str.strip_prefix("nix-darwin-")
115        && let Some((year, month)) = parse_version(version)
116    {
117        return ChannelType::NixDarwinStable { year, month };
118    }
119
120    if let Some((year, month)) = parse_version(ref_str) {
121        return ChannelType::BareVersion { year, month };
122    }
123
124    ChannelType::Unknown
125}
126
127/// Parse a version string like "24.11" into (year, month).
128fn parse_version(version: &str) -> Option<(u32, u32)> {
129    let parts: Vec<&str> = version.split('.').collect();
130    if parts.len() == 2 {
131        let year = parts[0].parse::<u32>().ok()?;
132        let month = parts[1].parse::<u32>().ok()?;
133        // Sanity check: year should be reasonable (20-99), month should be valid
134        if (20..=99).contains(&year) && (month == 5 || month == 11) {
135            return Some((year, month));
136        }
137    }
138    None
139}
140
141/// Find the latest channel branch that matches the current channel type.
142///
143/// Returns `Ok(None)` if:
144/// - The current ref is unstable (should not be updated)
145/// - The current ref is not a recognized channel
146/// - No newer channel exists and we are already on the latest
147///
148/// Returns `Err` when a transient forge failure (timeout, DNS,
149/// 5xx, ...) prevents us from proving anything about the candidate
150/// set. Without this, a flaky network looked exactly like "no
151/// newer channel exists" and silently kept the user pinned to a
152/// stale release.
153pub(crate) fn find_latest_channel(
154    client: &ForgeClient,
155    current_ref: &str,
156    owner: &str,
157    repo: &str,
158    domain: Option<&str>,
159) -> Result<Option<String>, ApiError> {
160    let current_channel = parse_channel_ref(current_ref);
161
162    if current_channel.is_unstable() {
163        tracing::debug!("Skipping update for unstable channel: {}", current_ref);
164        return Ok(None);
165    }
166
167    let (prefix, current_version) = match (current_channel.prefix(), current_channel.version()) {
168        (Some(p), Some(v)) => (p, v),
169        _ => return Ok(None),
170    };
171
172    // Targeted candidate probing is cheap on repos with many
173    // branches (nixpkgs has thousands); the all-branches list is the
174    // fallback for forges or transient conditions where targeted
175    // returned `Ok(None)`.
176    if let Some(latest) =
177        find_latest_channel_targeted(client, prefix, current_version, owner, repo, domain)?
178    {
179        if latest != current_ref {
180            return Ok(Some(latest));
181        } else {
182            tracing::debug!("{} is already on the latest channel", current_ref);
183            return Ok(None);
184        }
185    }
186
187    tracing::debug!("Targeted lookup failed, falling back to listing all branches");
188    let branches = client.list_branches(owner, repo, domain)?;
189    let latest = find_latest_matching_branch(&branches, prefix, current_version);
190
191    if let Some(ref latest_branch) = latest
192        && latest_branch == current_ref
193    {
194        tracing::debug!("{} is already on the latest channel", current_ref);
195        return Ok(None);
196    }
197
198    Ok(latest)
199}
200
201/// Every branch name [`find_latest_channel_targeted`] may probe for
202/// a given `(prefix, current_version)` start: the future candidates
203/// plus the current branch itself. Used by the GraphQL batch warmer
204/// in `api.rs` to pre-populate `branch_exists_cache` so the targeted
205/// loop later short-circuits to cache hits.
206pub(crate) fn channel_probe_candidates(prefix: &str, current_version: (u32, u32)) -> Vec<String> {
207    let mut all = generate_candidate_channels(prefix, current_version);
208    all.push(format!(
209        "{}{}.{:02}",
210        prefix, current_version.0, current_version.1
211    ));
212    all
213}
214
215/// Generate candidate channel versions from current to ~5 years in the future.
216/// Returns candidates from NEWEST to OLDEST for early exit optimization.
217pub(crate) fn generate_candidate_channels(
218    prefix: &str,
219    current_version: (u32, u32),
220) -> Vec<String> {
221    let (current_year, current_month) = current_version;
222    let mut candidates = Vec::new();
223
224    // NixOS cuts a release in May (.05) and November (.11). Ten
225    // iterations covers about five years of future cuts; far enough
226    // ahead that we never need a refresh, close enough that the
227    // candidate set stays small.
228    let mut year = current_year;
229    let mut month = current_month;
230
231    for _ in 0..10 {
232        if month == 5 {
233            month = 11;
234        } else {
235            month = 5;
236            year += 1;
237        }
238
239        candidates.push(format!("{}{}.{:02}", prefix, year, month));
240    }
241
242    // Newest first so [`find_latest_channel_targeted`] can early-exit
243    // on the first hit and avoid probing older candidates.
244    candidates.reverse();
245    candidates
246}
247
248/// Find the latest channel by probing candidate branches one by
249/// one. Cheaper than listing all branches; falls back to that on
250/// `Ok(None)`. `Err` always propagates. See [`find_latest_channel`]
251/// for the full contract.
252fn find_latest_channel_targeted(
253    client: &ForgeClient,
254    prefix: &str,
255    current_version: (u32, u32),
256    owner: &str,
257    repo: &str,
258    domain: Option<&str>,
259) -> Result<Option<String>, ApiError> {
260    let candidates = generate_candidate_channels(prefix, current_version);
261
262    tracing::debug!(
263        "Checking candidate channels (newest first): {:?}",
264        candidates
265    );
266
267    for candidate in &candidates {
268        tracing::debug!("Checking if branch exists: {}", candidate);
269        if client.branch_exists(owner, repo, candidate, domain)? {
270            tracing::debug!("Found existing channel: {}", candidate);
271            return Ok(Some(candidate.clone()));
272        }
273    }
274
275    let current_branch = format!("{}{}.{:02}", prefix, current_version.0, current_version.1);
276    tracing::debug!("No newer channel, checking current: {}", current_branch);
277    if client.branch_exists(owner, repo, &current_branch, domain)? {
278        return Ok(Some(current_branch));
279    }
280
281    Ok(None)
282}
283
284/// Find the latest branch matching a given prefix that is newer than current_version.
285fn find_latest_matching_branch(
286    branches: &Branches,
287    prefix: &str,
288    current_version: (u32, u32),
289) -> Option<String> {
290    let mut best: Option<(u32, u32, String)> = None;
291
292    for branch_name in &branches.names {
293        if let Some(version_str) = branch_name.strip_prefix(prefix) {
294            // `nixos-unstable` / `nixpkgs-unstable` share the prefix but
295            // are rolling branches; updating to one of them would point
296            // a release pin at a moving target.
297            if version_str == "unstable" {
298                continue;
299            }
300
301            if let Some((year, month)) = parse_version(version_str)
302                && (year, month) >= current_version
303            {
304                match &best {
305                    None => {
306                        best = Some((year, month, branch_name.clone()));
307                    }
308                    Some((best_year, best_month, _)) => {
309                        if (year, month) > (*best_year, *best_month) {
310                            best = Some((year, month, branch_name.clone()));
311                        }
312                    }
313                }
314            }
315        }
316    }
317
318    best.map(|(_, _, name)| name)
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324
325    #[test]
326    fn test_detect_strategy() {
327        assert_eq!(
328            detect_strategy("nixos", "nixpkgs"),
329            UpdateStrategy::NixpkgsChannel
330        );
331        assert_eq!(
332            detect_strategy("NixOS", "nixpkgs"),
333            UpdateStrategy::NixpkgsChannel
334        );
335        assert_eq!(
336            detect_strategy("nix-community", "home-manager"),
337            UpdateStrategy::HomeManagerChannel
338        );
339        assert_eq!(
340            detect_strategy("LnL7", "nix-darwin"),
341            UpdateStrategy::NixDarwinChannel
342        );
343        assert_eq!(
344            detect_strategy("nix-community", "nix-darwin"),
345            UpdateStrategy::NixDarwinChannel
346        );
347        assert_eq!(
348            detect_strategy("some-user", "some-repo"),
349            UpdateStrategy::SemverTags
350        );
351    }
352
353    #[test]
354    fn test_parse_channel_ref_nixos() {
355        assert_eq!(
356            parse_channel_ref("nixos-24.11"),
357            ChannelType::NixosStable {
358                year: 24,
359                month: 11
360            }
361        );
362        assert_eq!(
363            parse_channel_ref("nixos-25.05"),
364            ChannelType::NixosStable { year: 25, month: 5 }
365        );
366        assert_eq!(parse_channel_ref("nixos-unstable"), ChannelType::Unstable);
367    }
368
369    #[test]
370    fn test_parse_channel_ref_nixpkgs() {
371        assert_eq!(
372            parse_channel_ref("nixpkgs-24.11"),
373            ChannelType::NixpkgsStable {
374                year: 24,
375                month: 11
376            }
377        );
378        assert_eq!(parse_channel_ref("nixpkgs-unstable"), ChannelType::Unstable);
379    }
380
381    #[test]
382    fn test_parse_channel_ref_home_manager() {
383        assert_eq!(
384            parse_channel_ref("release-24.11"),
385            ChannelType::HomeManagerRelease {
386                year: 24,
387                month: 11
388            }
389        );
390        assert_eq!(parse_channel_ref("master"), ChannelType::Unstable);
391    }
392
393    #[test]
394    fn test_parse_channel_ref_nix_darwin() {
395        assert_eq!(
396            parse_channel_ref("nix-darwin-24.11"),
397            ChannelType::NixDarwinStable {
398                year: 24,
399                month: 11
400            }
401        );
402        assert_eq!(parse_channel_ref("main"), ChannelType::Unstable);
403    }
404
405    #[test]
406    fn test_parse_channel_ref_bare_version() {
407        assert_eq!(
408            parse_channel_ref("24.11"),
409            ChannelType::BareVersion {
410                year: 24,
411                month: 11
412            }
413        );
414        assert_eq!(
415            parse_channel_ref("25.05"),
416            ChannelType::BareVersion { year: 25, month: 5 }
417        );
418    }
419
420    #[test]
421    fn test_parse_channel_ref_with_refs_heads_prefix() {
422        assert_eq!(
423            parse_channel_ref("refs/heads/nixos-24.11"),
424            ChannelType::NixosStable {
425                year: 24,
426                month: 11
427            }
428        );
429    }
430
431    #[test]
432    fn test_parse_channel_ref_unknown() {
433        assert_eq!(parse_channel_ref("v1.0.0"), ChannelType::Unknown);
434        assert_eq!(parse_channel_ref("nixos-invalid"), ChannelType::Unknown);
435        assert_eq!(parse_channel_ref("feature-branch"), ChannelType::Unknown);
436    }
437
438    #[test]
439    fn test_channel_type_is_unstable() {
440        assert!(ChannelType::Unstable.is_unstable());
441        assert!(parse_channel_ref("master").is_unstable());
442        assert!(parse_channel_ref("main").is_unstable());
443        assert!(parse_channel_ref("nixos-unstable").is_unstable());
444        assert!(
445            !ChannelType::NixosStable {
446                year: 24,
447                month: 11
448            }
449            .is_unstable()
450        );
451    }
452
453    #[test]
454    fn test_find_latest_matching_branch() {
455        let branches = Branches {
456            names: vec![
457                "nixos-23.11".to_string(),
458                "nixos-24.05".to_string(),
459                "nixos-24.11".to_string(),
460                "nixos-unstable".to_string(),
461                "master".to_string(),
462            ],
463        };
464
465        // Should find 24.11 as latest when on 24.05
466        let result = find_latest_matching_branch(&branches, "nixos-", (24, 5));
467        assert_eq!(result, Some("nixos-24.11".to_string()));
468
469        // Should return current when already on latest
470        let result = find_latest_matching_branch(&branches, "nixos-", (24, 11));
471        assert_eq!(result, Some("nixos-24.11".to_string()));
472
473        // Should return None when on a version newer than anything available
474        let result = find_latest_matching_branch(&branches, "nixos-", (25, 5));
475        assert_eq!(result, None);
476    }
477
478    #[test]
479    fn test_generate_candidate_channels() {
480        // Starting from 24.05: 24.11, 25.05, 25.11, ..., 29.05 (10 items)
481        // Reversed: 29.05, 28.11, ..., 24.11
482        let candidates = generate_candidate_channels("nixos-", (24, 5));
483        assert_eq!(candidates.len(), 10);
484        // Newest first
485        assert_eq!(candidates[0], "nixos-29.05");
486        // Oldest last
487        assert_eq!(candidates[9], "nixos-24.11");
488
489        // Starting from 24.11: 25.05, 25.11, ..., 29.11 (10 items)
490        // Reversed: 29.11, 29.05, ..., 25.05
491        let candidates = generate_candidate_channels("nixpkgs-", (24, 11));
492        assert_eq!(candidates[0], "nixpkgs-29.11"); // newest
493        assert_eq!(candidates[9], "nixpkgs-25.05"); // oldest
494    }
495}