diff --git a/crates/templates/src/manager.rs b/crates/templates/src/manager.rs index 1759d8d25d..6f22f1e2b7 100644 --- a/crates/templates/src/manager.rs +++ b/crates/templates/src/manager.rs @@ -25,20 +25,32 @@ pub trait ProgressReporter { #[derive(Debug)] pub struct InstallOptions { exists_behaviour: ExistsBehaviour, + skip_if_all_match: bool, } impl InstallOptions { /// Sets the option to update existing templates. If `update` is true, /// existing templates are updated. If false, existing templates are /// skipped. - pub fn update(self, update: bool) -> Self { + pub fn update(mut self, update: bool) -> Self { let exists_behaviour = if update { ExistsBehaviour::Update } else { ExistsBehaviour::Skip }; - Self { exists_behaviour } + self.exists_behaviour = exists_behaviour; + self + } + + /// If set, the installer will skip if the manager has templates, + /// and all templates were installed from the install source. + /// This allows a consumer to avoid downloading from a + /// remote source, at the expense of missing an update if the + /// source is mutable (such as a Git tag or branch). + pub fn skip_if_all_match(mut self, skip_if_all_match: bool) -> Self { + self.skip_if_all_match = skip_if_all_match; + self } } @@ -46,6 +58,7 @@ impl Default for InstallOptions { fn default() -> Self { Self { exists_behaviour: ExistsBehaviour::Skip, + skip_if_all_match: false, } } } @@ -133,6 +146,18 @@ impl TemplateManager { options: &InstallOptions, reporter: &impl ProgressReporter, ) -> anyhow::Result { + if options.skip_if_all_match && self.all_match(source).await { + let existing = self.list().await.map(|lr| lr.templates).unwrap_or_default(); // don't fail if we can't list + return Ok(InstallationResults { + installed: Default::default(), + skipped: existing + .into_iter() + .map(|t| (t.id().to_string(), SkippedReason::AlreadyExists)) + .collect(), + removed: Default::default(), + }); + } + if source.requires_copy() { reporter.report("Copying remote template source"); } @@ -317,6 +342,23 @@ impl TemplateManager { .map(|l| Template::load_from(&l)) .transpose() } + + async fn all_match(&self, source: &TemplateSource) -> bool { + let existing = self.list().await.map(|lr| lr.templates).unwrap_or_default(); + if existing.is_empty() { + // We don't expect a template source to be empty. If the manager is empty, count it as no match. + return false; + } + + let (expected_repo, expected_tag) = match source { + TemplateSource::Git(g) => (g.repo(), g.branch()), + _ => return false, // other sources are too mutable + }; + + existing + .iter() + .all(|template| template.is_installed_from(expected_repo, expected_tag)) + } } async fn copy_template_over_existing( diff --git a/crates/templates/src/reader.rs b/crates/templates/src/reader.rs index cdfe4b9012..98cb41440e 100644 --- a/crates/templates/src/reader.rs +++ b/crates/templates/src/reader.rs @@ -120,7 +120,7 @@ pub(crate) fn parse_manifest_toml(text: impl AsRef) -> anyhow::Result }, File { dir: String }, RemoteTar { url: String }, } diff --git a/crates/templates/src/source.rs b/crates/templates/src/source.rs index 65c8cfcd3b..fe600cf463 100644 --- a/crates/templates/src/source.rs +++ b/crates/templates/src/source.rs @@ -46,6 +46,16 @@ pub struct GitTemplateSource { spin_version: String, } +impl GitTemplateSource { + pub(crate) fn repo(&self) -> &str { + self.url.as_str() + } + + pub(crate) fn branch(&self) -> Option<&str> { + self.branch.as_deref() + } +} + impl TemplateSource { /// Creates a `TemplateSource` referring to the specified Git repository /// and branch. @@ -68,6 +78,7 @@ impl TemplateSource { match self { Self::Git(g) => Some(crate::reader::RawInstalledFrom::Git { git: g.url.to_string(), + branch: g.branch.clone(), }), Self::File(p) => { // Saving a relative path would be meaningless (but should never happen) diff --git a/crates/templates/src/template.rs b/crates/templates/src/template.rs index f91d17c1b2..93cde5f202 100644 --- a/crates/templates/src/template.rs +++ b/crates/templates/src/template.rs @@ -35,7 +35,7 @@ pub struct Template { #[derive(Debug)] enum InstalledFrom { - Git(String), + Git { url: String, branch: Option }, Directory(String), RemoteTar(String), Unknown, @@ -244,7 +244,7 @@ impl Template { // TODO: this is kind of specialised - should we do the discarding of // non-Git sources at the application layer? match &self.installed_from { - InstalledFrom::Git(url) => Some(url), + InstalledFrom::Git { url, .. } => Some(url), _ => None, } } @@ -254,11 +254,23 @@ impl Template { .is_some_and(|r| r == source_repo.as_str()) } + /// Determines if the template was installed from the expected repo and + /// branch. This does not prove that the template is up to date, as the + /// tag or branch may have moved in the interim. + pub fn is_installed_from(&self, expected_repo: &str, expected_tag: Option<&str>) -> bool { + match &self.installed_from { + InstalledFrom::Git { url, branch } => { + url == expected_repo && branch.as_deref() == expected_tag + } + _ => false, + } + } + /// A human-readable description of where the template was installed /// from. pub fn installed_from_or_empty(&self) -> &str { match &self.installed_from { - InstalledFrom::Git(repo) => repo, + InstalledFrom::Git { url, .. } => url, InstalledFrom::Directory(path) => path, InstalledFrom::RemoteTar(url) => url, InstalledFrom::Unknown => "", @@ -599,7 +611,7 @@ fn read_install_record(layout: &TemplateLayout) -> InstalledFrom { let installed_from_text = std::fs::read_to_string(layout.installation_record_file()).ok(); match installed_from_text.and_then(parse_installed_from) { - Some(RawInstalledFrom::Git { git }) => InstalledFrom::Git(git), + Some(RawInstalledFrom::Git { git, branch }) => InstalledFrom::Git { url: git, branch }, Some(RawInstalledFrom::File { dir }) => InstalledFrom::Directory(dir), Some(RawInstalledFrom::RemoteTar { url }) => InstalledFrom::RemoteTar(url), None => InstalledFrom::Unknown, diff --git a/src/commands/new.rs b/src/commands/new.rs index a302d49c39..e280faa07c 100644 --- a/src/commands/new.rs +++ b/src/commands/new.rs @@ -336,7 +336,9 @@ async fn env_templates_and_plugins( if let Err(e) = template_manager .install( &source, - &spin_templates::InstallOptions::default().update(true), + &spin_templates::InstallOptions::default() + .update(true) + .skip_if_all_match(true), &DiscardingReporter, ) .await