git.delta.rocks / jrsonnet / refs/heads / master

difftreelog

source

crates/jrsonnet-pkg/src/install/mod.rs10.8 KiBsourcehistory
1#![allow(clippy::result_large_err)]23pub mod accessor;4mod git;5mod github;67use std::{8	collections::{BTreeMap, HashSet},9	fs,10	path::{Path, PathBuf},11	result,12};1314use camino::Utf8PathBuf;15use tracing::info;1617use crate::jsonnet_bundler::{Dependency, GitScheme, GitSource, JsonnetFile, Source, SubDir};1819pub const PKG_USER_AGENT: &str = "jrsonnet-pkg (https://delta.rocks/jrsonnet)";2021pub fn cache_dir(subdir: &str) -> Result<std::path::PathBuf> {22	Ok(directories::ProjectDirs::from("rocks", "delta", "jrsonnet")23		.ok_or(Error::XdgUnavailable)?24		.cache_dir()25		.join(subdir))26}2728pub(crate) struct LocalExtraction {29	/// Path inside the parent repo's tree where this local source lives.30	pub tree_path: SubDir,31	pub name: String,32}3334pub(crate) struct ResolveResult {35	pub version: String,36	pub transitive_git_deps: Vec<Dependency>,37	pub local_extractions: Vec<LocalExtraction>,38	pub source: VendorSource,39}4041const VERSION_FILE: &str = ".version";4243/// How to populate a vendor path.44pub enum VendorSource {45	GitTree {46		repo_path: PathBuf,47		commit_sha: String,48		subdir: SubDir,49	},50	GithubZip {51		zip_path: PathBuf,52		commit_sha: String,53		subdir: SubDir,54	},55	Symlink(Utf8PathBuf),56}5758impl VendorSource {59	fn with_subdir(&self, new_subdir: SubDir) -> Self {60		match self {61			VendorSource::GitTree {62				repo_path,63				commit_sha,64				..65			} => VendorSource::GitTree {66				repo_path: repo_path.clone(),67				commit_sha: commit_sha.clone(),68				subdir: new_subdir,69			},70			VendorSource::GithubZip {71				zip_path,72				commit_sha,73				..74			} => VendorSource::GithubZip {75				zip_path: zip_path.clone(),76				commit_sha: commit_sha.clone(),77				subdir: new_subdir,78			},79			VendorSource::Symlink(target) => VendorSource::Symlink(target.clone()),80		}81	}82}8384pub struct InstallPlan {85	pub lock: JsonnetFile,86	/// vendor-relative path -> how to obtain it.87	pub entries: BTreeMap<Utf8PathBuf, VendorSource>,88}8990pub fn install(91	manifest: &JsonnetFile,92	lock: Option<&JsonnetFile>,93	vendor_dir: &Path,94	dry_run: bool,95) -> Result<JsonnetFile, Error> {96	let plan = resolve(manifest, lock)?;97	execute(&plan, vendor_dir, dry_run)?;98	Ok(plan.lock)99}100101pub fn resolve(manifest: &JsonnetFile, lock: Option<&JsonnetFile>) -> Result<InstallPlan, Error> {102	let mut plan = InstallPlan {103		lock: JsonnetFile {104			version: manifest.version,105			dependencies: Vec::new(),106			legacy_imports: manifest.legacy_imports,107		},108		entries: BTreeMap::new(),109	};110	let mut installed = HashSet::new();111112	resolve_deps(113		&manifest.dependencies,114		lock,115		manifest.legacy_imports,116		&mut plan,117		&mut installed,118	)?;119120	Ok(plan)121}122123#[cfg(unix)]124fn make_symlink(target: &str, link: &Path) -> std::io::Result<()> {125	std::os::unix::fs::symlink(target, link)126}127128#[cfg(windows)]129fn make_symlink(target: &str, link: &Path) -> std::io::Result<()> {130	std::os::windows::fs::symlink_dir(target, link)131}132133#[cfg(not(any(unix, windows)))]134fn make_symlink(_target: &str, _link: &Path) -> std::io::Result<()> {135	Err(std::io::Error::new(136		std::io::ErrorKind::Unsupported,137		"symlinks are not supported on this platform",138	))139}140141fn is_up_to_date(dest: &Path, version: &str) -> bool {142	fs::read_to_string(dest.join(VERSION_FILE)).is_ok_and(|v| v.trim() == version)143}144145fn write_version(dest: &Path, version: &str) -> Result<(), Error> {146	fs::write(dest.join(VERSION_FILE), format!("{version}\n"))147		.map_err(|e| Error::Io(dest.join(VERSION_FILE), e))148}149150pub fn execute(plan: &InstallPlan, vendor_dir: &Path, dry_run: bool) -> Result<(), Error> {151	if !dry_run {152		for (path, source) in &plan.entries {153			let dest = vendor_dir.join(path);154			match source {155				VendorSource::GitTree {156					repo_path,157					commit_sha,158					subdir,159				} => {160					if is_up_to_date(&dest, commit_sha) {161						continue;162					}163					info!("extract {path}");164					if dest.exists() {165						fs::remove_dir_all(&dest).map_err(|e| Error::Io(dest.clone(), e))?;166					}167					fs::create_dir_all(&dest).map_err(|e| Error::Io(dest.clone(), e))?;168					git::extract(repo_path, commit_sha, subdir, &dest)?;169					write_version(&dest, commit_sha)?;170				}171				VendorSource::GithubZip {172					zip_path,173					commit_sha,174					subdir,175				} => {176					if is_up_to_date(&dest, commit_sha) {177						continue;178					}179					info!("extract {path}");180					if dest.exists() {181						fs::remove_dir_all(&dest).map_err(|e| Error::Io(dest.clone(), e))?;182					}183					fs::create_dir_all(&dest).map_err(|e| Error::Io(dest.clone(), e))?;184					github::extract(zip_path, subdir, &dest)?;185					write_version(&dest, commit_sha)?;186				}187				VendorSource::Symlink(_) => {}188			}189		}190		for (path, source) in &plan.entries {191			if let VendorSource::Symlink(target) = source {192				let dest = vendor_dir.join(path);193				if dest194					.symlink_metadata()195					.is_ok_and(|m| m.file_type().is_symlink())196				{197					if fs::read_link(&dest).is_ok_and(|t| t == target.as_std_path()) {198						continue;199					}200					fs::remove_file(&dest).map_err(|e| Error::Io(dest.clone(), e))?;201				}202				info!("symlink {path} -> {target}");203				make_symlink(target.as_str(), &dest).map_err(|e| Error::Io(dest.clone(), e))?;204			}205		}206	}207	prune(plan, vendor_dir, dry_run)?;208	Ok(())209}210211fn prune(plan: &InstallPlan, vendor_dir: &Path, dry_run: bool) -> Result<(), Error> {212	if !vendor_dir.is_dir() {213		return Ok(());214	}215	prune_recursive(plan, vendor_dir, vendor_dir, dry_run)216}217218fn prune_recursive(219	plan: &InstallPlan,220	vendor_dir: &Path,221	dir: &Path,222	dry_run: bool,223) -> Result<(), Error> {224	let entries = fs::read_dir(dir).map_err(|e| Error::Io(dir.to_owned(), e))?;225	for entry in entries {226		let entry = entry.map_err(|e| Error::Io(dir.to_owned(), e))?;227		let path = entry.path();228		let rel = path229			.strip_prefix(vendor_dir)230			.expect("path is under vendor_dir");231		let Ok(rel) = Utf8PathBuf::try_from(rel.to_owned()) else {232			info!("prune (non-utf8) {}", rel.display());233			continue;234		};235236		if plan.entries.contains_key(&rel) {237			continue;238		}239240		let ft = entry.file_type().map_err(|e| Error::Io(path.clone(), e))?;241		if ft.is_symlink() {242			info!("prune {rel}");243			if !dry_run {244				fs::remove_file(&path).map_err(|e| Error::Io(path, e))?;245			}246		} else if ft.is_dir() {247			let prefix: Utf8PathBuf = format!("{rel}/").into();248			let has_descendants = plan249				.entries250				.range(prefix.clone()..)251				.next()252				.is_some_and(|(k, _)| k.starts_with(&prefix));253			if has_descendants {254				prune_recursive(plan, vendor_dir, &path, dry_run)?;255			} else {256				info!("prune {rel}");257				if !dry_run {258					fs::remove_dir_all(&path).map_err(|e| Error::Io(path, e))?;259				}260			}261		} else {262			info!("prune {rel}");263			if !dry_run {264				fs::remove_file(&path).map_err(|e| Error::Io(path, e))?;265			}266		}267	}268269	if !dry_run270		&& dir != vendor_dir271		&& let Ok(mut entries) = fs::read_dir(dir)272		&& entries.next().is_none()273	{274		let _ = fs::remove_dir(dir);275	}276277	Ok(())278}279280fn resolve_one(git_source: &GitSource, version: Option<&str>) -> Result<ResolveResult, Error> {281	if git_source.host == "github.com" && git_source.scheme == GitScheme::Https {282		match github::resolve(git_source, version) {283			Ok(result) => return Ok(result),284			Err(e) => {285				info!("github archive failed ({e}), falling back to git");286			}287		}288	}289	git::resolve(git_source, version)290}291292fn locked_version<'a>(dep: &Dependency, lock: Option<&'a JsonnetFile>) -> Option<&'a str> {293	let lock = lock?;294	let key = dep.canonical_name();295	lock.dependencies296		.iter()297		.find(|d| d.canonical_name() == key)298		.and_then(|d| d.version.as_deref())299}300301fn resolve_deps(302	deps: &[Dependency],303	lock: Option<&JsonnetFile>,304	legacy_imports: bool,305	plan: &mut InstallPlan,306	installed: &mut HashSet<Utf8PathBuf>,307) -> Result<(), Error> {308	for dep in deps {309		let Source::Git(git_source) = &dep.source else {310			continue;311		};312313		let canonical = dep.canonical_name();314		if !installed.insert(canonical.clone()) {315			continue;316		}317318		let version = locked_version(dep, lock).or(dep.version.as_deref());319320		info!(321			"resolving {canonical} (version: {})",322			version.unwrap_or("<TBD>")323		);324325		let result = resolve_one(git_source, version)?;326327		plan.lock.dependencies.push(Dependency {328			source: dep.source.clone(),329			version: Some(result.version),330			sum: dep.sum.clone(),331			name: dep.name.clone(),332			single: dep.single,333		});334335		let mut repo_base = Utf8PathBuf::from(git_source.host.as_str());336		repo_base.push(git_source.plain_repo_name());337338		// Legacy symlink for the dep. Skipped if `legacyImports: false`, unless339		// the user explicitly set `dep.name` (which is always honored).340		if legacy_imports || dep.name.is_some() {341			let legacy = Utf8PathBuf::from(dep.legacy_link_name());342			if legacy != canonical {343				plan.entries344					.insert(legacy, VendorSource::Symlink(canonical.clone()));345			}346		}347348		for extraction in &result.local_extractions {349			let extraction_canonical = repo_base.join(&extraction.tree_path);350			plan.entries.insert(351				extraction_canonical.clone(),352				result.source.with_subdir(extraction.tree_path.clone()),353			);354			if legacy_imports {355				let extraction_name = Utf8PathBuf::from(&extraction.name);356				if extraction_name != extraction_canonical {357					plan.entries358						.insert(extraction_name, VendorSource::Symlink(extraction_canonical));359				}360			}361		}362363		// Main entry (after local extractions used with_subdir)364		plan.entries.insert(canonical, result.source);365366		resolve_deps(367			&result.transitive_git_deps,368			lock,369			legacy_imports,370			plan,371			installed,372		)?;373	}374375	Ok(())376}377378#[derive(Debug, thiserror::Error)]379pub enum Error {380	#[error("io error for {0}: {1}")]381	Io(PathBuf, std::io::Error),382	#[error("failed to discover xdg directories")]383	XdgUnavailable,384	#[error("git clone failed: {0}")]385	GitClone(#[from] gix::clone::Error),386	#[error(transparent)]387	GitRemote(#[from] gix::remote::init::Error),388	#[error(transparent)]389	GitConnect(#[from] gix::remote::connect::Error),390	#[error(transparent)]391	GitFetchPrepare(#[from] gix::remote::fetch::prepare::Error),392	#[error(transparent)]393	GitRemoteFetch(#[from] gix::remote::fetch::Error),394	#[error(transparent)]395	GitCloneFetch(#[from] gix::clone::fetch::Error),396	#[error(transparent)]397	GitFindObject(#[from] gix::object::find::existing::Error),398	#[error(transparent)]399	GitTraverse(#[from] gix::traverse::tree::breadthfirst::Error),400	#[error(transparent)]401	GitHead(#[from] gix::reference::head_id::Error),402	#[error(transparent)]403	GitCommit(#[from] gix::object::commit::Error),404	#[error(transparent)]405	GitRevparse(#[from] gix::revision::spec::parse::single::Error),406	#[error(transparent)]407	GitRefspec(#[from] gix::refspec::parse::Error),408	#[error(transparent)]409	GitPeel(#[from] gix::reference::peel::Error),410	#[error(transparent)]411	GitPeelToKind(#[from] gix::object::peel::to_kind::Error),412	#[error(transparent)]413	GitOpen(#[from] gix::open::Error),414	#[error("http error: {0}")]415	Http(#[from] reqwest::Error),416	#[error("zip error: {0}")]417	Zip(Box<zip::result::ZipError>),418	#[error(transparent)]419	Accessor(#[from] accessor::Error),420	#[error("unknown subdir: {0}")]421	SubdirNotFound(String),422	#[error("invalid path in tree: {0}")]423	InvalidPath(String),424}425pub(crate) type Result<T, E = Error> = result::Result<T, E>;