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.
217fn generate_candidate_channels(prefix: &str, current_version: (u32, u32)) -> Vec<String> {
218    let (current_year, current_month) = current_version;
219    let mut candidates = Vec::new();
220
221    // NixOS cuts a release in May (.05) and November (.11). Ten
222    // iterations covers about five years of future cuts; far enough
223    // ahead that we never need a refresh, close enough that the
224    // candidate set stays small.
225    let mut year = current_year;
226    let mut month = current_month;
227
228    for _ in 0..10 {
229        if month == 5 {
230            month = 11;
231        } else {
232            month = 5;
233            year += 1;
234        }
235
236        candidates.push(format!("{}{}.{:02}", prefix, year, month));
237    }
238
239    // Newest first so [`find_latest_channel_targeted`] can early-exit
240    // on the first hit and avoid probing older candidates.
241    candidates.reverse();
242    candidates
243}
244
245/// Find the latest channel by probing candidate branches one by
246/// one. Cheaper than listing all branches; falls back to that on
247/// `Ok(None)`. `Err` always propagates. See [`find_latest_channel`]
248/// for the full contract.
249fn find_latest_channel_targeted(
250    client: &ForgeClient,
251    prefix: &str,
252    current_version: (u32, u32),
253    owner: &str,
254    repo: &str,
255    domain: Option<&str>,
256) -> Result<Option<String>, ApiError> {
257    let candidates = generate_candidate_channels(prefix, current_version);
258
259    tracing::debug!(
260        "Checking candidate channels (newest first): {:?}",
261        candidates
262    );
263
264    for candidate in &candidates {
265        tracing::debug!("Checking if branch exists: {}", candidate);
266        if client.branch_exists(owner, repo, candidate, domain)? {
267            tracing::debug!("Found existing channel: {}", candidate);
268            return Ok(Some(candidate.clone()));
269        }
270    }
271
272    let current_branch = format!("{}{}.{:02}", prefix, current_version.0, current_version.1);
273    tracing::debug!("No newer channel, checking current: {}", current_branch);
274    if client.branch_exists(owner, repo, &current_branch, domain)? {
275        return Ok(Some(current_branch));
276    }
277
278    Ok(None)
279}
280
281/// Find the latest branch matching a given prefix that is newer than current_version.
282fn find_latest_matching_branch(
283    branches: &Branches,
284    prefix: &str,
285    current_version: (u32, u32),
286) -> Option<String> {
287    let mut best: Option<(u32, u32, String)> = None;
288
289    for branch_name in &branches.names {
290        if let Some(version_str) = branch_name.strip_prefix(prefix) {
291            // `nixos-unstable` / `nixpkgs-unstable` share the prefix but
292            // are rolling branches; updating to one of them would point
293            // a release pin at a moving target.
294            if version_str == "unstable" {
295                continue;
296            }
297
298            if let Some((year, month)) = parse_version(version_str)
299                && (year, month) >= current_version
300            {
301                match &best {
302                    None => {
303                        best = Some((year, month, branch_name.clone()));
304                    }
305                    Some((best_year, best_month, _)) => {
306                        if (year, month) > (*best_year, *best_month) {
307                            best = Some((year, month, branch_name.clone()));
308                        }
309                    }
310                }
311            }
312        }
313    }
314
315    best.map(|(_, _, name)| name)
316}
317
318#[cfg(test)]
319mod tests {
320    use super::*;
321
322    #[test]
323    fn test_detect_strategy() {
324        assert_eq!(
325            detect_strategy("nixos", "nixpkgs"),
326            UpdateStrategy::NixpkgsChannel
327        );
328        assert_eq!(
329            detect_strategy("NixOS", "nixpkgs"),
330            UpdateStrategy::NixpkgsChannel
331        );
332        assert_eq!(
333            detect_strategy("nix-community", "home-manager"),
334            UpdateStrategy::HomeManagerChannel
335        );
336        assert_eq!(
337            detect_strategy("LnL7", "nix-darwin"),
338            UpdateStrategy::NixDarwinChannel
339        );
340        assert_eq!(
341            detect_strategy("nix-community", "nix-darwin"),
342            UpdateStrategy::NixDarwinChannel
343        );
344        assert_eq!(
345            detect_strategy("some-user", "some-repo"),
346            UpdateStrategy::SemverTags
347        );
348    }
349
350    #[test]
351    fn test_parse_channel_ref_nixos() {
352        assert_eq!(
353            parse_channel_ref("nixos-24.11"),
354            ChannelType::NixosStable {
355                year: 24,
356                month: 11
357            }
358        );
359        assert_eq!(
360            parse_channel_ref("nixos-25.05"),
361            ChannelType::NixosStable { year: 25, month: 5 }
362        );
363        assert_eq!(parse_channel_ref("nixos-unstable"), ChannelType::Unstable);
364    }
365
366    #[test]
367    fn test_parse_channel_ref_nixpkgs() {
368        assert_eq!(
369            parse_channel_ref("nixpkgs-24.11"),
370            ChannelType::NixpkgsStable {
371                year: 24,
372                month: 11
373            }
374        );
375        assert_eq!(parse_channel_ref("nixpkgs-unstable"), ChannelType::Unstable);
376    }
377
378    #[test]
379    fn test_parse_channel_ref_home_manager() {
380        assert_eq!(
381            parse_channel_ref("release-24.11"),
382            ChannelType::HomeManagerRelease {
383                year: 24,
384                month: 11
385            }
386        );
387        assert_eq!(parse_channel_ref("master"), ChannelType::Unstable);
388    }
389
390    #[test]
391    fn test_parse_channel_ref_nix_darwin() {
392        assert_eq!(
393            parse_channel_ref("nix-darwin-24.11"),
394            ChannelType::NixDarwinStable {
395                year: 24,
396                month: 11
397            }
398        );
399        assert_eq!(parse_channel_ref("main"), ChannelType::Unstable);
400    }
401
402    #[test]
403    fn test_parse_channel_ref_bare_version() {
404        assert_eq!(
405            parse_channel_ref("24.11"),
406            ChannelType::BareVersion {
407                year: 24,
408                month: 11
409            }
410        );
411        assert_eq!(
412            parse_channel_ref("25.05"),
413            ChannelType::BareVersion { year: 25, month: 5 }
414        );
415    }
416
417    #[test]
418    fn test_parse_channel_ref_with_refs_heads_prefix() {
419        assert_eq!(
420            parse_channel_ref("refs/heads/nixos-24.11"),
421            ChannelType::NixosStable {
422                year: 24,
423                month: 11
424            }
425        );
426    }
427
428    #[test]
429    fn test_parse_channel_ref_unknown() {
430        assert_eq!(parse_channel_ref("v1.0.0"), ChannelType::Unknown);
431        assert_eq!(parse_channel_ref("nixos-invalid"), ChannelType::Unknown);
432        assert_eq!(parse_channel_ref("feature-branch"), ChannelType::Unknown);
433    }
434
435    #[test]
436    fn test_channel_type_is_unstable() {
437        assert!(ChannelType::Unstable.is_unstable());
438        assert!(parse_channel_ref("master").is_unstable());
439        assert!(parse_channel_ref("main").is_unstable());
440        assert!(parse_channel_ref("nixos-unstable").is_unstable());
441        assert!(
442            !ChannelType::NixosStable {
443                year: 24,
444                month: 11
445            }
446            .is_unstable()
447        );
448    }
449
450    #[test]
451    fn test_find_latest_matching_branch() {
452        let branches = Branches {
453            names: vec![
454                "nixos-23.11".to_string(),
455                "nixos-24.05".to_string(),
456                "nixos-24.11".to_string(),
457                "nixos-unstable".to_string(),
458                "master".to_string(),
459            ],
460        };
461
462        // Should find 24.11 as latest when on 24.05
463        let result = find_latest_matching_branch(&branches, "nixos-", (24, 5));
464        assert_eq!(result, Some("nixos-24.11".to_string()));
465
466        // Should return current when already on latest
467        let result = find_latest_matching_branch(&branches, "nixos-", (24, 11));
468        assert_eq!(result, Some("nixos-24.11".to_string()));
469
470        // Should return None when on a version newer than anything available
471        let result = find_latest_matching_branch(&branches, "nixos-", (25, 5));
472        assert_eq!(result, None);
473    }
474
475    #[test]
476    fn test_generate_candidate_channels() {
477        // Starting from 24.05: 24.11, 25.05, 25.11, ..., 29.05 (10 items)
478        // Reversed: 29.05, 28.11, ..., 24.11
479        let candidates = generate_candidate_channels("nixos-", (24, 5));
480        assert_eq!(candidates.len(), 10);
481        // Newest first
482        assert_eq!(candidates[0], "nixos-29.05");
483        // Oldest last
484        assert_eq!(candidates[9], "nixos-24.11");
485
486        // Starting from 24.11: 25.05, 25.11, ..., 29.11 (10 items)
487        // Reversed: 29.11, 29.05, ..., 25.05
488        let candidates = generate_candidate_channels("nixpkgs-", (24, 11));
489        assert_eq!(candidates[0], "nixpkgs-29.11"); // newest
490        assert_eq!(candidates[9], "nixpkgs-25.05"); // oldest
491    }
492}