diff --git a/Cargo.lock b/Cargo.lock index 8916480..129454b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1081,6 +1081,21 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40ecd4077b5ae9fd2e9e169b102c6c330d0605168eb0e8bf79952b256dbefffd" +[[package]] +name = "git2" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b903b73e45dc0c6c596f2d37eccece7c1c8bb6e4407b001096387c63d0d93724" +dependencies = [ + "bitflags 2.6.0", + "libc", + "libgit2-sys", + "log", + "openssl-probe", + "openssl-sys", + "url", +] + [[package]] name = "gix" version = "0.63.0" @@ -2271,6 +2286,20 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "libgit2-sys" +version = "0.17.0+1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10472326a8a6477c3c20a64547b0059e4b0d086869eee31e6d7da728a8eb7224" +dependencies = [ + "cc", + "libc", + "libssh2-sys", + "libz-sys", + "openssl-sys", + "pkg-config", +] + [[package]] name = "libredox" version = "0.1.3" @@ -2281,6 +2310,32 @@ dependencies = [ "libc", ] +[[package]] +name = "libssh2-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dc8a030b787e2119a731f1951d6a773e2280c660f8ec4b0f5e1505a386e71ee" +dependencies = [ + "cc", + "libc", + "libz-sys", + "openssl-sys", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "libz-sys" +version = "1.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c15da26e5af7e25c90b37a2d75cdbf940cf4a55316de9d84c679c9b8bfabf82e" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-keyutils" version = "0.2.4" @@ -2553,6 +2608,24 @@ dependencies = [ "pathdiff", ] +[[package]] +name = "openssl-probe" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" + +[[package]] +name = "openssl-sys" +version = "0.9.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f9e8deee91df40a943c71b917e5874b951d32a802526c85721ce3b776c929d6" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "option-ext" version = "0.2.0" @@ -2638,6 +2711,7 @@ dependencies = [ "directories", "flate2", "full_moon", + "git2", "gix", "indicatif", "indicatif-log-bridge", @@ -3766,6 +3840,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.4" diff --git a/Cargo.toml b/Cargo.toml index 40ae1d1..e9c90bb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,6 +30,8 @@ toml = "0.8.15" serde_json = "1.0.120" serde_with = "3.9.0" gix = { version = "0.63.0", default-features = false, features = ["blocking-http-transport-reqwest-rust-tls", "revparse-regex", "credentials"] } +# TODO: remove this when gitoxide adds support for: committing, pushing, adding +git2 = "0.19.0" semver = { version = "1.0.23", features = ["serde"] } reqwest = { version = "0.12.5", default-features = false, features = ["rustls-tls", "blocking"] } tar = "0.4.41" diff --git a/src/cli/init.rs b/src/cli/init.rs index 662693e..6036fa6 100644 --- a/src/cli/init.rs +++ b/src/cli/init.rs @@ -68,19 +68,11 @@ impl InitCommand { .prompt() .unwrap(); - let authors = authors + authors .split(',') .map(|s| s.trim()) .filter(|s| !s.is_empty()) - .map(|s| s.into()) - .collect::>(); - - if !authors.is_empty() { - let mut authors_arr = toml_edit::Array::new(); - authors_arr.extend(authors); - - manifest["authors"] = toml_edit::value(authors_arr); - } + .for_each(|author| manifest["authors"].as_array_mut().unwrap().push(author)); let repo = inquire::Text::new( "What is the repository URL of this project? (leave empty for none)", @@ -124,8 +116,7 @@ impl InitCommand { .prompt() .unwrap(); - let mut target = toml_edit::Table::new(); - target["environment"] = toml_edit::value(target_env); + manifest["target"]["environment"] = toml_edit::value(target_env); if target_env == "roblox" || inquire::Confirm::new(&format!( @@ -147,21 +138,14 @@ impl InitCommand { ) .context("failed to write script file")?; - let scripts = manifest - .entry("scripts") - .or_insert(toml_edit::Item::Table(toml_edit::Table::new())) - .as_table_mut() - .unwrap(); - - scripts[&ScriptName::RobloxSyncConfigGenerator.to_string()] = + manifest["scripts"][&ScriptName::RobloxSyncConfigGenerator.to_string()] = toml_edit::value(format!( concat!(".", env!("CARGO_PKG_NAME"), "/{}.luau"), ScriptName::RobloxSyncConfigGenerator )); } - let mut indices = toml_edit::Table::new(); - indices[DEFAULT_INDEX_NAME] = + manifest["indices"][DEFAULT_INDEX_NAME] = toml_edit::value(read_config(project.data_dir())?.default_index.as_str()); project.write_manifest(manifest.to_string())?; diff --git a/src/cli/install.rs b/src/cli/install.rs index a8a39de..29c1977 100644 --- a/src/cli/install.rs +++ b/src/cli/install.rs @@ -97,6 +97,10 @@ impl InstallCommand { .link_dependencies(&downloaded_graph) .context("failed to link dependencies")?; + project + .apply_patches(&downloaded_graph) + .context("failed to apply patches")?; + project .write_lockfile(Lockfile { name: manifest.name, diff --git a/src/cli/mod.rs b/src/cli/mod.rs index c21bdaf..3184b71 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -3,14 +3,16 @@ use anyhow::Context; use gix::remote::Direction; use indicatif::MultiProgress; use keyring::Entry; -use pesde::Project; +use pesde::{lockfile::DownloadedGraph, names::PackageNames, source::VersionId, Project}; use serde::{Deserialize, Serialize}; -use std::{collections::HashSet, path::Path}; +use std::{collections::HashSet, path::Path, str::FromStr}; mod auth; mod config; mod init; mod install; +mod patch; +mod patch_commit; mod publish; mod run; mod self_install; @@ -280,6 +282,48 @@ impl IsUpToDate for Project { } } +#[derive(Debug, Clone)] +struct VersionedPackageName(PackageNames, Option); + +impl FromStr for VersionedPackageName { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result { + let mut parts = s.splitn(2, '@'); + let name = parts.next().unwrap(); + let version = parts.next().map(VersionId::from_str).transpose()?; + + Ok(VersionedPackageName(name.parse()?, version)) + } +} + +impl VersionedPackageName { + fn get(self, graph: &DownloadedGraph) -> anyhow::Result<(PackageNames, VersionId)> { + let version_id = match self.1 { + Some(version) => version, + None => { + let versions = graph.get(&self.0).context("package not found in graph")?; + if versions.len() == 1 { + let version = versions.keys().next().unwrap().clone(); + log::debug!("only one version found, using {version}"); + version + } else { + anyhow::bail!( + "multiple versions found, please specify one of: {}", + versions + .keys() + .map(|v| v.to_string()) + .collect::>() + .join(", ") + ); + } + } + }; + + Ok((self.0, version_id)) + } +} + #[derive(Debug, clap::Subcommand)] pub enum Subcommand { /// Authentication-related commands @@ -304,6 +348,12 @@ pub enum Subcommand { /// Installs the pesde binary and scripts SelfInstall(self_install::SelfInstallCommand), + + /// Sets up a patching environment for a package + Patch(patch::PatchCommand), + + /// Finalizes a patching environment for a package + PatchCommit(patch_commit::PatchCommitCommand), } impl Subcommand { @@ -316,6 +366,8 @@ impl Subcommand { Subcommand::Install(install) => install.run(project, multi), Subcommand::Publish(publish) => publish.run(project), Subcommand::SelfInstall(self_install) => self_install.run(project), + Subcommand::Patch(patch) => patch.run(project), + Subcommand::PatchCommit(patch_commit) => patch_commit.run(project), } } } diff --git a/src/cli/patch.rs b/src/cli/patch.rs new file mode 100644 index 0000000..9469c90 --- /dev/null +++ b/src/cli/patch.rs @@ -0,0 +1,71 @@ +use crate::cli::{reqwest_client, IsUpToDate, VersionedPackageName}; +use anyhow::Context; +use clap::Args; +use colored::Colorize; +use pesde::{ + patches::setup_patches_repo, + source::{PackageRef, PackageSource}, + Project, MANIFEST_FILE_NAME, +}; + +#[derive(Debug, Args)] +pub struct PatchCommand { + /// The package name to patch + #[arg(index = 1)] + package: VersionedPackageName, +} + +impl PatchCommand { + pub fn run(self, project: Project) -> anyhow::Result<()> { + let graph = if project.is_up_to_date(true)? { + project.deser_lockfile()?.graph + } else { + anyhow::bail!("outdated lockfile, please run the install command first") + }; + + let (name, version_id) = self.package.get(&graph)?; + + let node = graph + .get(&name) + .and_then(|versions| versions.get(&version_id)) + .context("package not found in graph")?; + let source = node.node.pkg_ref.source(); + + let directory = project + .data_dir() + .join("patches") + .join(name.escaped()) + .join(version_id.escaped()) + .join(chrono::Utc::now().timestamp().to_string()); + std::fs::create_dir_all(&directory)?; + + source.download( + &node.node.pkg_ref, + &directory, + &project, + &reqwest_client(project.data_dir())?, + )?; + + // TODO: if MANIFEST_FILE_NAME does not exist, try to convert it + + setup_patches_repo(&directory)?; + + println!( + concat!( + "done! modify the files in the directory, then run `", + env!("CARGO_BIN_NAME"), + r#" patch-commit {}` to apply. +{}: do not commit these changes +{}: the {} file will be ignored when patching"# + ), + directory.display().to_string().bold().cyan(), + "warning".yellow(), + "note".blue(), + MANIFEST_FILE_NAME + ); + + open::that(directory)?; + + Ok(()) + } +} diff --git a/src/cli/patch_commit.rs b/src/cli/patch_commit.rs new file mode 100644 index 0000000..bf562a0 --- /dev/null +++ b/src/cli/patch_commit.rs @@ -0,0 +1,77 @@ +use crate::cli::IsUpToDate; +use anyhow::Context; +use clap::Args; +use pesde::{ + manifest::Manifest, names::PackageNames, patches::create_patch, source::VersionId, Project, + MANIFEST_FILE_NAME, +}; +use std::{path::PathBuf, str::FromStr}; + +#[derive(Debug, Args)] +pub struct PatchCommitCommand { + /// The directory containing the patch to commit + #[arg(index = 1)] + directory: PathBuf, +} + +impl PatchCommitCommand { + pub fn run(self, project: Project) -> anyhow::Result<()> { + let graph = if project.is_up_to_date(true)? { + project.deser_lockfile()?.graph + } else { + anyhow::bail!("outdated lockfile, please run the install command first") + }; + + let (name, version_id) = { + let patched_manifest = std::fs::read_to_string(self.directory.join(MANIFEST_FILE_NAME)) + .context("failed to read patched manifest")?; + let patched_manifest: Manifest = + toml::from_str(&patched_manifest).context("failed to parse patched manifest")?; + + ( + PackageNames::Pesde(patched_manifest.name), + VersionId::new(patched_manifest.version, patched_manifest.target.kind()), + ) + }; + + graph + .get(&name) + .and_then(|versions| versions.get(&version_id)) + .context("package not found in graph")?; + + let mut manifest = toml_edit::DocumentMut::from_str( + &project.read_manifest().context("failed to read manifest")?, + ) + .context("failed to parse manifest")?; + + let patch = create_patch(&self.directory).context("failed to create patch")?; + std::fs::remove_dir_all(self.directory).context("failed to remove patch directory")?; + + let patches_dir = project.path().join("patches"); + std::fs::create_dir_all(&patches_dir).context("failed to create patches directory")?; + + let patch_file_name = format!("{}-{}.patch", name.escaped(), version_id.escaped(),); + + let patch_file = patches_dir.join(&patch_file_name); + if patch_file.exists() { + anyhow::bail!("patch file already exists: {}", patch_file.display()); + } + + std::fs::write(&patch_file, patch).context("failed to write patch file")?; + + manifest["patches"][&name.to_string()][&version_id.to_string()] = + toml_edit::value(format!("patches/{patch_file_name}")); + + project + .write_manifest(manifest.to_string()) + .context("failed to write manifest")?; + + println!(concat!( + "done! run `", + env!("CARGO_BIN_NAME"), + " install` to apply the patch" + )); + + Ok(()) + } +} diff --git a/src/download.rs b/src/download.rs index 79648d7..f3b20ba 100644 --- a/src/download.rs +++ b/src/download.rs @@ -6,7 +6,7 @@ use std::{ use crate::{ lockfile::{DependencyGraph, DownloadedDependencyGraphNode, DownloadedGraph}, - source::{pesde::PesdePackageSource, PackageRefs, PackageSource, PackageSources}, + source::{PackageRef, PackageSource, PackageSources}, Project, PACKAGES_CONTAINER_NAME, }; @@ -33,11 +33,7 @@ impl Project { for (name, versions) in graph { for (version_id, node) in versions { - let source = match &node.pkg_ref { - PackageRefs::Pesde(pkg_ref) => { - PackageSources::Pesde(PesdePackageSource::new(pkg_ref.index_url.clone())) - } - }; + let source = node.pkg_ref.source(); if refreshed_sources.insert(source.clone()) { source.refresh(self).map_err(Box::new)?; diff --git a/src/lib.rs b/src/lib.rs index 09fb938..bd1cde3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -11,6 +11,7 @@ pub mod linking; pub mod lockfile; pub mod manifest; pub mod names; +pub mod patches; pub mod resolver; pub mod scripts; pub mod source; @@ -87,9 +88,9 @@ impl Project { &self.auth_config } - pub fn read_manifest(&self) -> Result, errors::ManifestReadError> { - let bytes = std::fs::read(self.path.join(MANIFEST_FILE_NAME))?; - Ok(bytes) + pub fn read_manifest(&self) -> Result { + let string = std::fs::read_to_string(self.path.join(MANIFEST_FILE_NAME))?; + Ok(string) } pub fn deser_manifest(&self) -> Result { diff --git a/src/manifest.rs b/src/manifest.rs index ab129e3..aa9b315 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -1,4 +1,7 @@ -use crate::{names::PackageName, source::DependencySpecifiers}; +use crate::{ + names::{PackageName, PackageNames}, + source::{DependencySpecifiers, VersionId}, +}; use relative_path::RelativePathBuf; use semver::Version; use serde::{Deserialize, Serialize}; @@ -251,6 +254,8 @@ pub struct Manifest { pub overrides: BTreeMap, #[serde(default)] pub includes: BTreeSet, + #[serde(default, skip_serializing)] + pub patches: BTreeMap>, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub dependencies: BTreeMap, diff --git a/src/names.rs b/src/names.rs index c3d3bb5..eda4819 100644 --- a/src/names.rs +++ b/src/names.rs @@ -1,6 +1,5 @@ use std::{fmt::Display, str::FromStr}; -use serde::{Deserialize, Serialize}; use serde_with::{DeserializeFromStr, SerializeDisplay}; #[derive(Debug)] @@ -69,8 +68,9 @@ impl PackageName { } } -#[derive(Debug, Deserialize, Serialize, Clone, Hash, PartialEq, Eq, PartialOrd, Ord)] -#[serde(untagged)] +#[derive( + Debug, DeserializeFromStr, SerializeDisplay, Clone, Hash, PartialEq, Eq, PartialOrd, Ord, +)] pub enum PackageNames { Pesde(PackageName), } @@ -97,6 +97,18 @@ impl Display for PackageNames { } } +impl FromStr for PackageNames { + type Err = errors::PackageNamesError; + + fn from_str(s: &str) -> Result { + if let Ok(name) = PackageName::from_str(s) { + Ok(PackageNames::Pesde(name)) + } else { + Err(errors::PackageNamesError::InvalidPackageName(s.to_string())) + } + } +} + pub mod errors { use thiserror::Error; @@ -119,4 +131,11 @@ pub mod errors { #[error("package {0} `{1}` is not within 3-32 characters long")] InvalidLength(ErrorReason, String), } + + #[derive(Debug, Error)] + #[non_exhaustive] + pub enum PackageNamesError { + #[error("invalid package name {0}")] + InvalidPackageName(String), + } } diff --git a/src/patches.rs b/src/patches.rs new file mode 100644 index 0000000..9a74e87 --- /dev/null +++ b/src/patches.rs @@ -0,0 +1,132 @@ +use crate::{lockfile::DownloadedGraph, Project, MANIFEST_FILE_NAME, PACKAGES_CONTAINER_NAME}; +use git2::{ApplyLocation, Diff, DiffFormat, DiffLineType, Repository, Signature}; +use std::{fs::read, path::Path}; + +pub fn setup_patches_repo>(dir: P) -> Result { + let repo = Repository::init(&dir)?; + + { + let signature = Signature::now( + env!("CARGO_PKG_NAME"), + concat!(env!("CARGO_PKG_NAME"), "@localhost"), + )?; + let mut index = repo.index()?; + index.add_all(["*"], git2::IndexAddOption::DEFAULT, None)?; + index.write()?; + + let oid = index.write_tree()?; + let tree = repo.find_tree(oid)?; + + repo.commit( + Some("HEAD"), + &signature, + &signature, + "begin patch", + &tree, + &[], + )?; + } + + Ok(repo) +} + +pub fn create_patch>(dir: P) -> Result, git2::Error> { + let mut patches = vec![]; + let repo = Repository::open(dir.as_ref())?; + + let original = repo.head()?.peel_to_tree()?; + + // reset the manifest file to the original state + let mut checkout_builder = git2::build::CheckoutBuilder::new(); + checkout_builder.force(); + checkout_builder.path(MANIFEST_FILE_NAME); + repo.checkout_tree(original.as_object(), Some(&mut checkout_builder))?; + + let diff = repo.diff_tree_to_workdir(Some(&original), None)?; + + diff.print(DiffFormat::Patch, |_delta, _hunk, line| { + if matches!( + line.origin_value(), + DiffLineType::Context | DiffLineType::Addition | DiffLineType::Deletion + ) { + let origin = line.origin(); + let mut buffer = vec![0; origin.len_utf8()]; + origin.encode_utf8(&mut buffer); + patches.extend(buffer); + } + + patches.extend(line.content()); + + true + })?; + + Ok(patches) +} + +impl Project { + pub fn apply_patches(&self, graph: &DownloadedGraph) -> Result<(), errors::ApplyPatchesError> { + let manifest = self.deser_manifest()?; + + for (name, versions) in manifest.patches { + for (version_id, patch_path) in versions { + let patch_path = patch_path.to_path(self.path()); + let patch = Diff::from_buffer(&read(&patch_path).map_err(|e| { + errors::ApplyPatchesError::PatchReadError(patch_path.clone(), e) + })?)?; + + let Some(node) = graph + .get(&name) + .and_then(|versions| versions.get(&version_id)) + else { + return Err(errors::ApplyPatchesError::PackageNotFound(name, version_id)); + }; + + let container_folder = node.node.container_folder( + &self + .path() + .join(node.node.base_folder(manifest.target.kind(), true)) + .join(PACKAGES_CONTAINER_NAME), + &name, + version_id.version(), + ); + + { + let repo = setup_patches_repo(&container_folder)?; + repo.apply(&patch, ApplyLocation::Both, None)?; + } + + std::fs::remove_dir_all(container_folder.join(".git")).map_err(|e| { + errors::ApplyPatchesError::GitDirectoryRemovalError(container_folder, e) + })?; + } + } + + Ok(()) + } +} + +pub mod errors { + use std::path::PathBuf; + + use crate::{names::PackageNames, source::VersionId}; + use thiserror::Error; + + #[derive(Debug, Error)] + #[non_exhaustive] + pub enum ApplyPatchesError { + #[error("error deserializing project manifest")] + ManifestDeserializationFailed(#[from] crate::errors::ManifestReadError), + + #[error("error interacting with git")] + GitError(#[from] git2::Error), + + #[error("error reading patch file at {0}")] + PatchReadError(PathBuf, #[source] std::io::Error), + + #[error("error removing .git directory")] + GitDirectoryRemovalError(PathBuf, #[source] std::io::Error), + + #[error("package {0}@{1} not found in graph")] + PackageNotFound(PackageNames, VersionId), + } +} diff --git a/src/source/mod.rs b/src/source/mod.rs index 21ae138..81fa654 100644 --- a/src/source/mod.rs +++ b/src/source/mod.rs @@ -48,6 +48,7 @@ pub trait PackageRef: Debug { fn dependencies(&self) -> &BTreeMap; fn use_new_structure(&self) -> bool; fn target_kind(&self) -> TargetKind; + fn source(&self) -> PackageSources; } impl PackageRef for PackageRefs { fn dependencies(&self) -> &BTreeMap { @@ -67,6 +68,12 @@ impl PackageRef for PackageRefs { PackageRefs::Pesde(pkg_ref) => pkg_ref.target_kind(), } } + + fn source(&self) -> PackageSources { + match self { + PackageRefs::Pesde(pkg_ref) => pkg_ref.source(), + } + } } #[derive( @@ -75,6 +82,10 @@ impl PackageRef for PackageRefs { pub struct VersionId(Version, TargetKind); impl VersionId { + pub fn new(version: Version, target: TargetKind) -> Self { + VersionId(version, target) + } + pub fn version(&self) -> &Version { &self.0 } @@ -82,6 +93,10 @@ impl VersionId { pub fn target(&self) -> &TargetKind { &self.1 } + + pub fn escaped(&self) -> String { + format!("{}+{}", self.0, self.1) + } } impl Display for VersionId { diff --git a/src/source/pesde/pkg_ref.rs b/src/source/pesde/pkg_ref.rs index 9e1971e..e07e73b 100644 --- a/src/source/pesde/pkg_ref.rs +++ b/src/source/pesde/pkg_ref.rs @@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize}; use crate::{ manifest::{DependencyType, Target, TargetKind}, names::PackageName, - source::{DependencySpecifiers, PackageRef}, + source::{pesde::PesdePackageSource, DependencySpecifiers, PackageRef, PackageSources}, }; #[derive(Debug, Serialize, Deserialize, Clone, Eq, PartialEq)] @@ -34,6 +34,10 @@ impl PackageRef for PesdePackageRef { fn target_kind(&self) -> TargetKind { self.target.kind() } + + fn source(&self) -> PackageSources { + PackageSources::Pesde(PesdePackageSource::new(self.index_url.clone())) + } } impl Ord for PesdePackageRef {