Skip to main content

flake_edit/forge/
update.rs

1use nix_uri::{FlakeRef, RefKind};
2use ropey::Rope;
3use std::cmp::Ordering;
4use std::collections::HashSet;
5use std::sync::Mutex;
6
7use super::api::{BatchLookup, ForgeClient};
8use super::archive::ArchiveUrl;
9use super::channel::{
10    ChannelType, UpdateStrategy, channel_probe_candidates, detect_strategy, find_latest_channel,
11    parse_channel_ref,
12};
13use super::channel_tarball::{ChannelTarballUrl, find_latest_published_channel};
14use super::version::{is_downgrade, parse_ref};
15use crate::edit::InputMap;
16use crate::input::Input;
17use crate::uri::is_git_url;
18
19/// Cap on concurrent forge requests during the fetch phase. Overlaps
20/// per-request latency without tripping anonymous rate limits.
21const FETCH_CONCURRENCY: usize = 4;
22
23/// Rewrites flake.nix URIs for `update`, `pin`, and `unpin`.
24///
25/// Owns one [`ForgeClient`] for the lifetime of the updater so the
26/// inner forge fetches share a single HTTP agent and memoize repeats
27/// across all inputs in one pass.
28#[derive(Debug)]
29pub struct Updater {
30    text: Rope,
31    inputs: Vec<UpdateInput>,
32    /// Cumulative char delta from edits applied earlier in the current pass.
33    /// Measured in *characters*, since ropey indexes by char.
34    offset: i32,
35    client: ForgeClient,
36}
37
38/// Per-input outcome from the fetch phase.
39///
40/// Kept separate from the edit phase so multiple inputs can race on
41/// the forge while edits to the source text stay strictly sequential
42/// and in source order.
43struct UpdatePlan {
44    /// `ref_or_rev` as it stood before this update.
45    ///
46    /// Empty when `init` is on and the input was unpinned: the edit
47    /// phase prints `Initialized ...` in that case.
48    previous_ref: String,
49    /// Final ref string after normalisation (`refs/tags/` /
50    /// `refs/heads/` re-applied where needed). Compared against
51    /// `previous_ref` to decide whether to emit `already on the
52    /// latest version`.
53    final_change: String,
54    updated_uri: String,
55}
56
57impl Updater {
58    fn print_update_status(id: &str, previous_version: &str, final_change: &str) -> bool {
59        let is_up_to_date = previous_version == final_change;
60        let initialized = previous_version.is_empty();
61
62        if is_up_to_date {
63            println!(
64                "{} is already on the latest version: {previous_version}.",
65                id
66            );
67            return false;
68        }
69
70        if initialized {
71            println!("Initialized {} version pin at {final_change}.", id);
72        } else {
73            println!("Updated {} from {previous_version} to {final_change}.", id);
74        }
75
76        true
77    }
78
79    /// Build an [`Updater`] from `text` and the inputs in `map`. Inputs
80    /// without an editable URL are skipped.
81    pub fn new(text: Rope, map: InputMap) -> Self {
82        let client = ForgeClient::new();
83        let mut inputs = vec![];
84        for (_id, input) in map {
85            if !input.has_editable_url() {
86                continue;
87            }
88            // `Input::range` carries rnix `TextRange` *byte* offsets, but ropey
89            // indexes by *character*. Convert once against the pristine text so
90            // later in-place edits can use simple char-offset arithmetic.
91            let url_start = text.byte_to_char(input.range.start) + 1;
92            let url_end = text.byte_to_char(input.range.end) - 1;
93            inputs.push(UpdateInput {
94                input,
95                url_start,
96                url_end,
97            });
98        }
99        Self {
100            inputs,
101            text,
102            offset: 0,
103            client,
104        }
105    }
106
107    /// Char-index range of the URL string *contents* (without the surrounding `"`),
108    /// adjusted for earlier in-place edits.
109    fn url_char_range(&self, input: &UpdateInput) -> (usize, usize) {
110        let start = (input.url_start as i32 + self.offset) as usize;
111        let end = (input.url_end as i32 + self.offset) as usize;
112        (start, end)
113    }
114    fn get_index(&self, id: &str) -> Option<usize> {
115        let bare = id
116            .strip_prefix('"')
117            .and_then(|s| s.strip_suffix('"'))
118            .unwrap_or(id);
119        self.inputs
120            .iter()
121            .position(|n| n.input.id().as_str() == bare)
122    }
123    /// Pin the input named `id` to `rev`.
124    ///
125    /// # Errors
126    ///
127    /// Returns the requested `id` if no such input exists.
128    pub fn pin_input_to_ref(&mut self, id: &str, rev: &str) -> Result<(), String> {
129        self.sort();
130        let idx = self.get_index(id).ok_or_else(|| id.to_string())?;
131        let input = self.inputs[idx].clone();
132        tracing::debug!("Input: {:?}", input);
133        self.change_input_to_rev(&input, rev);
134        Ok(())
135    }
136
137    /// Remove any `?ref=` or `?rev=` pin from `id`.
138    ///
139    /// # Errors
140    ///
141    /// Returns the requested `id` if no such input exists.
142    pub fn unpin_input(&mut self, id: &str) -> Result<(), String> {
143        self.sort();
144        let idx = self.get_index(id).ok_or_else(|| id.to_string())?;
145        let input = self.inputs[idx].clone();
146        tracing::debug!("Input: {:?}", input);
147        self.remove_ref_and_rev(&input);
148        Ok(())
149    }
150
151    pub fn update_all_to_latest_semver(&mut self, init: bool) {
152        self.update_matching(|_| true, init);
153    }
154
155    /// Update only the inputs whose id appears in `ids`.
156    ///
157    /// IDs that do not name an editable input are silently skipped, the
158    /// same way the all-inputs path skips inputs without an editable URL.
159    /// Duplicates collapse: each matching input is processed at most once.
160    pub fn update_inputs_to_latest_semver(&mut self, ids: &[&str], init: bool) {
161        if ids.is_empty() {
162            return;
163        }
164        let set: HashSet<&str> = ids.iter().copied().collect();
165        self.update_matching(|id| set.contains(id), init);
166    }
167
168    /// Two-phase update over the inputs whose id satisfies `keep`.
169    ///
170    /// Phase 1 fans the fetch out across a bounded worker pool so the
171    /// per-input forge round-trips overlap. Phase 2 walks the results
172    /// in source order and applies each rewrite serially, because
173    /// [`Updater::update_input`]'s cumulative `offset` arithmetic is
174    /// only valid when edits land left-to-right in the source text.
175    ///
176    /// Status prints (`Updated X ...`, `Initialized X ...`, `X is
177    /// already on the latest version`) are emitted by the edit phase
178    /// so they too appear in source order, regardless of the order
179    /// in which workers actually finished their fetches.
180    fn update_matching<F: Fn(&str) -> bool>(&mut self, keep: F, init: bool) {
181        self.sort();
182
183        // Snapshot URIs against the pristine source text. `self.offset`
184        // is zero on entry, so [`Self::get_input_text`] returns exactly
185        // what's in the original source; later edit-phase rewrites
186        // shift the offset and would otherwise corrupt these slices.
187        let pending: Vec<(UpdateInput, String)> = self
188            .inputs
189            .iter()
190            .filter(|i| keep(i.input.id.as_str()))
191            .map(|i| {
192                let uri = self.get_input_text(i);
193                (i.clone(), uri)
194            })
195            .collect();
196
197        if pending.is_empty() {
198            return;
199        }
200
201        // One GraphQL POST resolves every github.com lookup in
202        // `pending`. The REST path stays intact for non-github
203        // forges and as a fallback if the warm step fails (anonymous
204        // run, partial errors, transport hiccup), so worst case is a
205        // wasted POST plus the existing per-input REST round trips.
206        let github_lookups = build_github_batch_lookups(&pending);
207        // Threshold of 2: a single-input batch trades a REST GET for
208        // a GraphQL POST of the same wall-clock cost without any
209        // overlap dividend, so the parallel-fetch path keeps it.
210        // Two or more inputs are where the round-trip count actually
211        // collapses.
212        if github_lookups.len() >= 2
213            && let Err(e) = self.client.batch_warm_github(&github_lookups)
214        {
215            tracing::debug!(
216                "GraphQL batch warm failed; falling back to REST per input: {}",
217                e
218            );
219        }
220
221        let results = parallel_fetch(&self.client, pending, init);
222
223        for (input, plan) in results {
224            let Some(plan) = plan else { continue };
225            if Self::print_update_status(
226                input.input.id.as_str(),
227                &plan.previous_ref,
228                &plan.final_change,
229            ) {
230                self.update_input(input, &plan.updated_uri);
231            }
232        }
233    }
234
235    /// Current source after all queued edits.
236    pub fn get_changes(&self) -> String {
237        self.text.to_string()
238    }
239
240    fn get_input_text(&self, input: &UpdateInput) -> String {
241        let (start, end) = self.url_char_range(input);
242        self.text.slice(start..end).to_string()
243    }
244
245    /// Rewrite `input`'s URL to pin it to `rev`.
246    pub(crate) fn change_input_to_rev(&mut self, input: &UpdateInput, rev: &str) {
247        let uri = self.get_input_text(input);
248        match uri.parse::<FlakeRef>() {
249            Ok(parsed) => {
250                let updated = parsed.pin_to_rev(rev.into()).into_uri();
251                self.update_input(input.clone(), &updated);
252            }
253            Err(e) => {
254                tracing::error!("Error while changing input: {}", e);
255            }
256        }
257    }
258    fn remove_ref_and_rev(&mut self, input: &UpdateInput) {
259        let uri = self.get_input_text(input);
260        match uri.parse::<FlakeRef>() {
261            Ok(mut parsed) => {
262                if parsed.ref_kind() == RefKind::None {
263                    return;
264                }
265                parsed.set_ref(None);
266                parsed.set_rev(None);
267                self.update_input(input.clone(), &parsed.into_uri());
268            }
269            Err(e) => {
270                tracing::error!("Error while changing input: {}", e);
271            }
272        }
273    }
274    // Sort by source range so multi-edit passes stay aligned with `offset`.
275    fn sort(&mut self) {
276        self.inputs.sort();
277    }
278    fn update_input(&mut self, input: UpdateInput, change: &str) {
279        let (start, end) = self.url_char_range(&input);
280        let previous_len = (end - start) as i32;
281        self.text.remove(start..end);
282        self.text.insert(start, change);
283        self.offset += change.chars().count() as i32 - previous_len;
284    }
285}
286
287/// Wrapper that lets [`Updater`] sort inputs by source position.
288#[derive(Debug, Clone)]
289pub(crate) struct UpdateInput {
290    input: Input,
291    /// Char index of the first URL character (inside the quotes) in the
292    /// original, unmodified text.
293    url_start: usize,
294    /// Char index one past the last URL character in the original text.
295    url_end: usize,
296}
297
298impl Ord for UpdateInput {
299    fn cmp(&self, other: &Self) -> Ordering {
300        self.url_start.cmp(&other.url_start)
301    }
302}
303
304impl PartialEq for UpdateInput {
305    fn eq(&self, other: &Self) -> bool {
306        self.url_start == other.url_start
307    }
308}
309
310impl PartialOrd for UpdateInput {
311    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
312        Some(self.cmp(other))
313    }
314}
315
316impl Eq for UpdateInput {}
317
318/// Results come back in the same order as `pending`, which the
319/// caller is expected to keep sorted by source position so the
320/// subsequent edit phase can walk results left-to-right without a
321/// second sort.
322///
323/// Workers re-use one [`ForgeClient`], so fan-out also amortises
324/// TCP / TLS over its connection pool rather than running N
325/// independent handshakes.
326fn parallel_fetch(
327    client: &ForgeClient,
328    pending: Vec<(UpdateInput, String)>,
329    init: bool,
330) -> Vec<(UpdateInput, Option<UpdatePlan>)> {
331    let n = pending.len();
332    if n == 0 {
333        return Vec::new();
334    }
335    let cap = std::cmp::min(n, FETCH_CONCURRENCY);
336
337    // With nothing to overlap, skip the pool entirely; keeps the
338    // single-input `update <id>` path on the calling thread.
339    if cap <= 1 {
340        let mut results = Vec::with_capacity(n);
341        for (input, uri) in pending {
342            let plan = compute_change(client, &uri, init);
343            results.push((input, plan));
344        }
345        return results;
346    }
347
348    // Pop order is irrelevant: each work item carries its index so
349    // results land in source-order `slots` regardless of which
350    // worker happens to claim it.
351    type WorkItem = (usize, UpdateInput, String);
352    type ResultSlot = Mutex<Option<(UpdateInput, Option<UpdatePlan>)>>;
353    let work: Mutex<Vec<WorkItem>> = Mutex::new(
354        pending
355            .into_iter()
356            .enumerate()
357            .map(|(i, (u, s))| (i, u, s))
358            .collect(),
359    );
360    let slots: Vec<ResultSlot> = (0..n).map(|_| Mutex::new(None)).collect();
361
362    std::thread::scope(|s| {
363        for _ in 0..cap {
364            let work = &work;
365            let slots = &slots;
366            s.spawn(move || {
367                loop {
368                    let next = work.lock().expect("fetch work queue poisoned").pop();
369                    let Some((idx, input, uri)) = next else { break };
370                    let plan = compute_change(client, &uri, init);
371                    *slots[idx].lock().expect("fetch result slot poisoned") = Some((input, plan));
372                }
373            });
374        }
375    });
376
377    slots
378        .into_iter()
379        .map(|m| {
380            m.into_inner()
381                .expect("fetch result slot poisoned")
382                .expect("scope returned with an unfilled fetch slot")
383        })
384        .collect()
385}
386
387/// Plan one [`BatchLookup`] per github.com input in `pending`.
388///
389/// Inputs whose canonical domain is not `github.com`, or whose ref
390/// is unpinned, unstable, or unrecognized, are skipped: those inputs
391/// fall through to the REST path uncached. The returned list keeps
392/// `pending`'s order so aliases in the GraphQL document line up
393/// 1:1 with source-ordered inputs, which keeps debug logs and
394/// breakpoints readable.
395fn build_github_batch_lookups(pending: &[(UpdateInput, String)]) -> Vec<BatchLookup> {
396    let mut lookups = Vec::new();
397    for (_, uri) in pending {
398        let Ok(parsed) = uri.parse::<FlakeRef>() else {
399            continue;
400        };
401        // canonical_domain(None) == "github.com": shorthand inputs
402        // route through the github cache slot, so they batch the same
403        // way explicit `https://github.com/...` inputs do.
404        let canonical = match parsed.domain() {
405            None => "github.com",
406            Some(d) => d,
407        };
408        if canonical != "github.com" {
409            continue;
410        }
411        let (Some(owner), Some(repo)) = (parsed.owner(), parsed.repo()) else {
412            continue;
413        };
414        match detect_strategy(owner, repo) {
415            UpdateStrategy::SemverTags => {
416                lookups.push(BatchLookup::Tags {
417                    owner: owner.to_string(),
418                    repo: repo.to_string(),
419                });
420            }
421            UpdateStrategy::NixpkgsChannel
422            | UpdateStrategy::HomeManagerChannel
423            | UpdateStrategy::NixDarwinChannel => {
424                let current_ref = parsed.ref_or_rev().unwrap_or_default();
425                if current_ref.is_empty() {
426                    continue;
427                }
428                let channel = parse_channel_ref(current_ref);
429                let (Some(prefix), Some(current_version)) = (channel.prefix(), channel.version())
430                else {
431                    continue;
432                };
433                let candidates = channel_probe_candidates(prefix, current_version);
434                lookups.push(BatchLookup::ChannelCandidates {
435                    owner: owner.to_string(),
436                    repo: repo.to_string(),
437                    prefix: prefix.to_string(),
438                    candidates,
439                });
440            }
441        }
442    }
443    lookups
444}
445
446/// Resolve the new URI for a single input.
447///
448/// Pure with respect to the [`Updater`] state: takes only
449/// `&ForgeClient` plus the snapshotted URI text, so it is safe to
450/// run on a worker thread without aliasing `Updater::text` /
451/// `Updater::offset`. Per-input forge errors are logged via
452/// `tracing` and returned as `None`, so one flaky input never
453/// aborts the rest of the update run.
454fn compute_change(client: &ForgeClient, uri: &str, init: bool) -> Option<UpdatePlan> {
455    // `FlakeRef` exposes no owner/repo for tarball-archive URLs, so
456    // recover the parts from the URL string and resolve them here.
457    if let Some(archive) = ArchiveUrl::parse(uri) {
458        return compute_archive_change(client, &archive, init);
459    }
460
461    if let Some(channel) = ChannelTarballUrl::parse(uri) {
462        return compute_channel_tarball_change(client, &channel);
463    }
464
465    let parsed = match uri.parse::<FlakeRef>() {
466        Ok(p) => p,
467        Err(e) => {
468            tracing::error!("Failed to parse URI: {}", e);
469            return None;
470        }
471    };
472
473    let owner = match parsed.owner() {
474        Some(o) => o.to_owned(),
475        None => {
476            tracing::debug!("Skipping input without owner");
477            return None;
478        }
479    };
480    let repo = match parsed.repo() {
481        Some(r) => r.to_owned(),
482        None => {
483            tracing::debug!("Skipping input without repo");
484            return None;
485        }
486    };
487
488    let strategy = detect_strategy(&owner, &repo);
489    tracing::debug!("Update strategy for {}/{}: {:?}", owner, repo, strategy);
490
491    match strategy {
492        UpdateStrategy::NixpkgsChannel
493        | UpdateStrategy::HomeManagerChannel
494        | UpdateStrategy::NixDarwinChannel => {
495            compute_channel_change(client, &parsed, &owner, &repo)
496        }
497        UpdateStrategy::SemverTags => {
498            compute_semver_change(client, uri, &parsed, &owner, &repo, init)
499        }
500    }
501}
502
503/// Resolve the new URI for a tarball-archive input
504/// (`<scheme>://<host>/<owner>/<repo>/archive/<ref>.<ext>`).
505///
506/// Routes on the ref token, not [`detect_strategy`]: a channel ref
507/// resolves via [`find_latest_channel`], a semver ref via
508/// [`ForgeClient::list_tags`], anything else returns `None`. The host
509/// is passed through as the forge domain.
510fn compute_archive_change(
511    client: &ForgeClient,
512    archive: &ArchiveUrl,
513    init: bool,
514) -> Option<UpdatePlan> {
515    let owner = archive.owner();
516    let repo = archive.repo();
517    let host = archive.host();
518    let current_ref = archive.ref_token();
519    let prefix = archive.ref_prefix_str();
520
521    if matches!(parse_channel_ref(current_ref), ChannelType::Unknown) {
522        let parsed_ref = parse_ref(current_ref, false);
523        if !init && semver::Version::parse(&parsed_ref.normalized_for_semver).is_err() {
524            tracing::debug!(
525                "Skipping archive input {}/{}: ref {} is neither a channel nor semver",
526                owner,
527                repo,
528                current_ref
529            );
530            return None;
531        }
532
533        let tags = match client.list_tags(owner, repo, Some(host)) {
534            Ok(t) => t,
535            Err(e) => {
536                tracing::error!(
537                    "Failed to fetch tags for archive input {}/{} on {}: {}",
538                    owner,
539                    repo,
540                    host,
541                    e
542                );
543                return None;
544            }
545        };
546        let latest = match tags.get_latest_tag() {
547            Some(c) => c,
548            None => {
549                tracing::error!(
550                    "Could not find latest version for archive input {}/{}",
551                    owner,
552                    repo
553                );
554                return None;
555            }
556        };
557
558        if !init && is_downgrade(current_ref, &latest) {
559            tracing::warn!(
560                "Refusing to downgrade archive input {}/{} from {} to {}",
561                owner,
562                repo,
563                current_ref,
564                latest
565            );
566            eprintln!(
567                "Warning: skipping {}/{}: latest tag {} is older than the current pin {}.",
568                owner, repo, latest, current_ref
569            );
570            return None;
571        }
572
573        Some(UpdatePlan {
574            previous_ref: format!("{prefix}{current_ref}"),
575            final_change: format!("{prefix}{latest}"),
576            updated_uri: archive.with_ref(&latest),
577        })
578    } else {
579        let latest = match find_latest_channel(client, current_ref, owner, repo, Some(host)) {
580            Ok(Some(latest)) => latest,
581            Ok(None) => return None,
582            Err(e) => {
583                tracing::error!(
584                    "Failed to resolve latest channel for archive input {}/{}: {}",
585                    owner,
586                    repo,
587                    e
588                );
589                return None;
590            }
591        };
592
593        Some(UpdatePlan {
594            previous_ref: format!("{prefix}{current_ref}"),
595            final_change: format!("{prefix}{latest}"),
596            updated_uri: archive.with_ref(&latest),
597        })
598    }
599}
600
601/// Resolve the new URI for a nixos.org channel tarball input. No
602/// `init` case: the URL always carries its channel token.
603fn compute_channel_tarball_change(
604    client: &ForgeClient,
605    channel: &ChannelTarballUrl,
606) -> Option<UpdatePlan> {
607    let current = channel.channel();
608
609    let latest = match find_latest_published_channel(channel, |url| client.url_exists(url)) {
610        Ok(Some(latest)) => latest,
611        Ok(None) => return None,
612        Err(e) => {
613            tracing::error!(
614                "Failed to resolve latest published channel for {}: {}",
615                current,
616                e
617            );
618            return None;
619        }
620    };
621
622    Some(UpdatePlan {
623        previous_ref: current.to_string(),
624        final_change: latest.clone(),
625        updated_uri: channel.with_channel(&latest),
626    })
627}
628
629/// Resolve the new URI for a channel-strategy input (nixpkgs,
630/// home-manager, nix-darwin). Returns `None` when the input is
631/// unpinned, the ref is unstable, or the forge probe fails. The
632/// failure case logs through `tracing` so a flaky channel does not
633/// abort the rest of the run. Preserves a `refs/heads/` prefix on
634/// the final ref iff the input already carried one.
635fn compute_channel_change(
636    client: &ForgeClient,
637    parsed: &FlakeRef,
638    owner: &str,
639    repo: &str,
640) -> Option<UpdatePlan> {
641    let domain = parsed.domain();
642    let current_ref = parsed.ref_or_rev().unwrap_or_default().to_owned();
643
644    if current_ref.is_empty() {
645        tracing::debug!("Skipping unpinned channel input: {}/{}", owner, repo);
646        return None;
647    }
648
649    let has_refs_heads_prefix = current_ref.starts_with("refs/heads/");
650
651    let latest = match find_latest_channel(client, &current_ref, owner, repo, domain) {
652        Ok(Some(latest)) => latest,
653        Ok(None) => return None,
654        Err(e) => {
655            tracing::error!(
656                "Failed to resolve latest channel for {}/{}: {}",
657                owner,
658                repo,
659                e
660            );
661            return None;
662        }
663    };
664
665    let final_ref = if has_refs_heads_prefix {
666        format!("refs/heads/{}", latest)
667    } else {
668        latest.clone()
669    };
670    let updated_uri = parsed.clone().with_ref(Some(final_ref.clone())).into_uri();
671
672    Some(UpdatePlan {
673        previous_ref: current_ref,
674        final_change: final_ref,
675        updated_uri,
676    })
677}
678
679/// Resolve the new URI for a semver-strategy input. Returns `None`
680/// when the current ref does not parse as semver and `init` is off
681/// (so non-semver pins aren't silently overwritten), or when the
682/// tag listing fails. Preserves a `refs/tags/` prefix on the final
683/// ref iff the input already carried one.
684fn compute_semver_change(
685    client: &ForgeClient,
686    uri: &str,
687    parsed: &FlakeRef,
688    owner: &str,
689    repo: &str,
690    init: bool,
691) -> Option<UpdatePlan> {
692    let is_git = is_git_url(uri);
693    let maybe_version = parsed.ref_or_rev().unwrap_or_default();
694    let parsed_ref = parse_ref(maybe_version, init);
695
696    if !init && let Err(e) = semver::Version::parse(&parsed_ref.normalized_for_semver) {
697        tracing::debug!("Skip non semver version: {}: {}", maybe_version, e);
698        return None;
699    }
700
701    let tags = if is_git {
702        let domain = parsed.domain()?.to_owned();
703        match client.list_tags(owner, repo, Some(&domain)) {
704            Ok(t) => t,
705            Err(_) => {
706                tracing::error!("Failed to fetch tags for {}/{} on {}", owner, repo, domain);
707                return None;
708            }
709        }
710    } else {
711        match client.list_tags(owner, repo, None) {
712            Ok(t) => t,
713            Err(_) => {
714                tracing::error!("Failed to fetch tags for {}/{}", owner, repo);
715                return None;
716            }
717        }
718    };
719
720    let change = match tags.get_latest_tag() {
721        Some(c) => c,
722        None => {
723            tracing::error!("Could not find latest version for {}/{}", owner, repo);
724            return None;
725        }
726    };
727
728    if !init && is_downgrade(maybe_version, &change) {
729        tracing::warn!(
730            "Refusing to downgrade {}/{} from {} to {}",
731            owner,
732            repo,
733            maybe_version,
734            change
735        );
736        eprintln!(
737            "Warning: skipping {}/{}: latest tag {} is older than the current pin {}.",
738            owner, repo, change, maybe_version
739        );
740        return None;
741    }
742
743    let final_change = if parsed_ref.has_refs_tags_prefix {
744        format!("refs/tags/{}", change)
745    } else {
746        change.clone()
747    };
748    let updated_uri = parsed
749        .clone()
750        .with_ref(Some(final_change.clone()))
751        .into_uri();
752
753    Some(UpdatePlan {
754        previous_ref: parsed_ref.previous_ref,
755        final_change,
756        updated_uri,
757    })
758}