diff --git a/src/config.rs b/src/config.rs index 26c4ad4..e806b0a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -4,11 +4,64 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ -use std::collections::HashMap; +use std::{ + collections::HashMap, + path::{Path, PathBuf}, +}; use serde::{Deserialize, Serialize}; +use snafu::{Backtrace, OptionExt, ResultExt, Snafu}; use url::Url; +pub const LOCAL_CONFIG_FILE: &str = ".build-eips.toml"; +pub const DEFAULT_BUILD_ROOT_BASE: &str = ".local-build"; +pub const DEFAULT_THEME_DIR: &str = "theme"; +pub const DEFAULT_PROFILE: &str = "workspace"; + +#[derive(Debug, Snafu)] +pub enum WorkspaceError { + #[snafu(display("i/o error while accessing `{}`", path.to_string_lossy()))] + Fs { + path: PathBuf, + source: std::io::Error, + backtrace: Backtrace, + }, + + #[snafu(display("unable to parse workspace config `{}`", path.to_string_lossy()))] + Parse { + path: PathBuf, + source: toml::de::Error, + backtrace: Backtrace, + }, + + #[snafu(display("cannot use `--profile {profile}` without a workspace config"))] + ProfileWithoutConfig { + profile: String, + backtrace: Backtrace, + }, + + #[snafu(display( + "workspace config `{}` does not define profile `{profile}`", + path.to_string_lossy() + ))] + MissingProfile { + path: PathBuf, + profile: String, + backtrace: Backtrace, + }, + + #[snafu(display( + "workspace config profile `{profile}` sets incompatible values for `{local}` and `{remote}`" + ))] + ConflictingProfileSwitch { + path: PathBuf, + profile: String, + local: &'static str, + remote: &'static str, + backtrace: Backtrace, + }, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Theme { /// Where to fetch the theme from. @@ -43,6 +96,81 @@ pub struct Config { pub locations: Locations, } +#[derive(Debug, Clone, Default)] +pub struct LocalOverrides { + pub theme_path: Option, + pub other_repo_path: Option, + pub build_root: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct WorkspaceConfig { + pub default_profile: Option, + pub build_root_base: PathBuf, + pub profiles: HashMap, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct LocalProfile { + pub staging: bool, + pub use_local_theme: bool, + pub use_local_sibling: bool, + pub allow_dirty: bool, +} + +#[derive(Debug, Clone)] +pub struct LoadedWorkspaceConfig { + path: PathBuf, + workspace_root: PathBuf, + config: WorkspaceConfig, +} + +#[derive(Debug, Clone)] +pub struct SelectedProfile { + pub name: String, + pub profile: LocalProfile, +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(default)] +struct RawLocalProfile { + staging: bool, + use_local_theme: Option, + use_local_sibling: Option, + use_remote_theme: Option, + use_remote_sibling: Option, + allow_dirty: bool, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(default)] +struct RawWorkspaceConfig { + default_profile: Option, + build_root_base: PathBuf, + profiles: HashMap, +} + +impl Default for WorkspaceConfig { + fn default() -> Self { + Self { + default_profile: Some(DEFAULT_PROFILE.into()), + build_root_base: DEFAULT_BUILD_ROOT_BASE.into(), + profiles: HashMap::new(), + } + } +} + +impl Default for RawWorkspaceConfig { + fn default() -> Self { + Self { + default_profile: Some(DEFAULT_PROFILE.into()), + build_root_base: DEFAULT_BUILD_ROOT_BASE.into(), + profiles: HashMap::new(), + } + } +} + impl Config { pub fn production() -> Self { let mut locations = HashMap::new(); @@ -106,3 +234,258 @@ impl Config { } } } + +impl LoadedWorkspaceConfig { + pub fn load( + explicit: Option<&Path>, + search_from: &Path, + ) -> Result, WorkspaceError> { + match explicit { + Some(path) => Self::from_path(path).map(Some), + None => Self::discover(search_from), + } + } + + pub fn from_path(path: &Path) -> Result { + let path = path.canonicalize().context(FsSnafu { + path: path.to_path_buf(), + })?; + let contents = std::fs::read_to_string(&path).context(FsSnafu { path: &path })?; + let raw = + toml::from_str::(&contents).context(ParseSnafu { path: &path })?; + let workspace_root = path + .parent() + .expect("workspace config should always have a parent") + .to_path_buf(); + + let mut profiles = HashMap::with_capacity(raw.profiles.len()); + for (name, raw_profile) in raw.profiles { + let profile = LocalProfile::from_raw(&path, &name, raw_profile)?; + profiles.insert(name, profile); + } + + Ok(Self { + path, + workspace_root, + config: WorkspaceConfig { + default_profile: raw.default_profile, + build_root_base: raw.build_root_base, + profiles, + }, + }) + } + + pub fn discover(start: &Path) -> Result, WorkspaceError> { + match discover_path(start) { + Some(path) => Self::from_path(&path).map(Some), + None => Ok(None), + } + } + + pub fn selected_profile( + &self, + requested: Option<&str>, + ) -> Result, WorkspaceError> { + let name = requested + .map(str::to_owned) + .or_else(|| self.config.default_profile.clone()); + + let Some(name) = name else { + return Ok(None); + }; + + let profile = self + .config + .profiles + .get(&name) + .cloned() + .context(MissingProfileSnafu { + path: self.path.clone(), + profile: name.clone(), + })?; + + Ok(Some(SelectedProfile { name, profile })) + } + + pub fn path(&self) -> &Path { + &self.path + } + + pub fn workspace_root(&self) -> &Path { + &self.workspace_root + } + + pub fn build_root_for(&self, repo_name: &str) -> PathBuf { + self.resolve_path(&self.config.build_root_base) + .join(repo_name) + } + + pub fn local_theme_path(&self) -> PathBuf { + self.workspace_root.join(DEFAULT_THEME_DIR) + } + + pub fn local_repo_path(&self, repo_name: &str) -> PathBuf { + self.workspace_root.join(repo_name) + } + + fn resolve_path(&self, path: &Path) -> PathBuf { + if path.is_absolute() { + path.to_path_buf() + } else { + self.workspace_root.join(path) + } + } +} + +pub fn discover_path(start: &Path) -> Option { + let mut current = Some(start); + + while let Some(candidate) = current { + let path = candidate.join(LOCAL_CONFIG_FILE); + if path.is_file() { + return Some(path); + } + + current = candidate.parent(); + } + + None +} + +impl LocalProfile { + fn from_raw(path: &Path, profile: &str, raw: RawLocalProfile) -> Result { + Ok(Self { + staging: raw.staging, + use_local_theme: resolve_profile_switch( + path, + profile, + raw.use_local_theme, + raw.use_remote_theme, + "use_local_theme", + "use_remote_theme", + )?, + use_local_sibling: resolve_profile_switch( + path, + profile, + raw.use_local_sibling, + raw.use_remote_sibling, + "use_local_sibling", + "use_remote_sibling", + )?, + allow_dirty: raw.allow_dirty, + }) + } +} + +fn resolve_profile_switch( + path: &Path, + profile: &str, + local: Option, + remote: Option, + local_name: &'static str, + remote_name: &'static str, +) -> Result { + match (local, remote) { + (Some(local), Some(remote)) if local == !remote => Ok(local), + (Some(_), Some(_)) => ConflictingProfileSwitchSnafu { + path: path.to_path_buf(), + profile: profile.to_owned(), + local: local_name, + remote: remote_name, + } + .fail(), + (Some(local), None) => Ok(local), + (None, Some(remote)) => Ok(!remote), + (None, None) => Ok(false), + } +} + +pub fn selected_profile( + config: Option<&LoadedWorkspaceConfig>, + requested: Option<&str>, +) -> Result, WorkspaceError> { + match config { + Some(config) => config.selected_profile(requested), + None => match requested { + Some(profile) => ProfileWithoutConfigSnafu { + profile: profile.to_owned(), + } + .fail(), + None => Ok(None), + }, + } +} + +pub fn default_workspace_config_text() -> &'static str { + r#"default_profile = "workspace" +build_root_base = ".local-build" + +[profiles.workspace] +staging = true +use_local_theme = true +use_local_sibling = true + +[profiles.parity] +staging = true +use_remote_theme = true +use_remote_sibling = true + +[profiles.dirty] +staging = true +use_local_theme = true +use_local_sibling = true +allow_dirty = true +"# +} + +#[cfg(test)] +mod tests { + use super::{default_workspace_config_text, LoadedWorkspaceConfig, LOCAL_CONFIG_FILE}; + + #[test] + fn parses_default_workspace_config() { + let dir = std::env::temp_dir().join(format!( + "build-eips-config-test-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join(LOCAL_CONFIG_FILE); + std::fs::write(&path, default_workspace_config_text()).unwrap(); + + let config = LoadedWorkspaceConfig::from_path(&path).unwrap(); + let workspace = config + .selected_profile(Some("workspace")) + .unwrap() + .unwrap() + .profile; + let parity = config + .selected_profile(Some("parity")) + .unwrap() + .unwrap() + .profile; + let dirty = config + .selected_profile(Some("dirty")) + .unwrap() + .unwrap() + .profile; + + assert!(workspace.staging); + assert!(workspace.use_local_theme); + assert!(workspace.use_local_sibling); + + assert!(parity.staging); + assert!(!parity.use_local_theme); + assert!(!parity.use_local_sibling); + + assert!(dirty.staging); + assert!(dirty.use_local_theme); + assert!(dirty.use_local_sibling); + assert!(dirty.allow_dirty); + + std::fs::remove_dir_all(dir).unwrap(); + } +} diff --git a/src/find_root.rs b/src/find_root.rs index c2db1e0..46ed10e 100644 --- a/src/find_root.rs +++ b/src/find_root.rs @@ -34,7 +34,7 @@ pub enum Error { pub fn is_root(path: &Path) -> Result<(), Error> { let git = path.join(".git"); let contents = path.join(CONTENT_DIR); - if git.is_dir() && contents.is_dir() { + if (git.is_dir() || git.is_file()) && contents.is_dir() { Ok(()) } else { NoRootSnafu.fail() diff --git a/src/git.rs b/src/git.rs index 357743f..b77ead7 100644 --- a/src/git.rs +++ b/src/git.rs @@ -5,6 +5,7 @@ */ use std::{ + collections::BTreeSet, collections::HashMap, ffi::OsStr, path::{absolute, Path, PathBuf}, @@ -17,13 +18,15 @@ use crate::{ }; use git2::{ build::{CheckoutBuilder, TreeUpdateBuilder}, - BranchType, Commit, FetchOptions, FileMode, ObjectType, Oid, RepositoryOpenFlags, Signature, + Commit, FetchOptions, FileMode, ObjectType, Oid, RepositoryOpenFlags, Signature, Status, StatusOptions, Tree, TreeEntry, TreeWalkResult, }; use log::{debug, info}; use snafu::{ensure, Backtrace, IntoError, OptionExt, ResultExt, Snafu}; use url::Url; +const DIRTY_PATH_DISPLAY_LIMIT: usize = 10; + #[derive(Debug, Snafu)] pub enum Error { #[snafu(display("cannot convert path into URL (`{}`)", path.to_string_lossy()))] @@ -47,8 +50,21 @@ pub enum Error { }, #[snafu(display("unable to determine which repository is being built (none match)"))] NoIdentify { backtrace: Backtrace }, - #[snafu(display("working tree or index has uncommitted modifications"))] - Dirty { backtrace: Backtrace }, + #[snafu(display("{message}"))] + Dirty { + message: String, + backtrace: Backtrace, + }, + #[snafu(display( + "dirty mode cannot materialize conflicted path `{}`; resolve the conflict and try again", + path.to_string_lossy() + ))] + DirtyConflict { path: PathBuf, backtrace: Backtrace }, + #[snafu(display( + "dirty mode cannot materialize `{}` because it is not a tracked file or symlink in the working tree", + path.to_string_lossy() + ))] + DirtyUnsupportedPath { path: PathBuf, backtrace: Backtrace }, #[snafu(display("unable to update tree ({msg})"))] UpdateTree { msg: String, backtrace: Backtrace }, #[snafu(context(false))] @@ -58,6 +74,12 @@ pub enum Error { }, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SourceMaterialization { + Clean, + Dirty, +} + #[derive(Debug, Clone)] pub struct RepositoryUse { pub title: String, @@ -117,27 +139,357 @@ impl Locations { } } +impl RepositoryUse { + pub fn only_other_repo(&self) -> Option<(&str, &Url)> { + let mut repos = self.other_repos.iter(); + let next = repos.next()?; + if repos.next().is_some() { + None + } else { + Some((next.0.as_str(), next.1)) + } + } +} + +fn is_generated_path(path: &Path) -> bool { + path.components() + .next() + .map(|component| component.as_os_str() == OsStr::new(super::BUILD_DIR)) + .unwrap_or(false) +} + +fn dirty_statuses(repo: &git2::Repository) -> Result, Error> { + let mut options = StatusOptions::default(); + options + .include_untracked(true) + .recurse_untracked_dirs(true) + .renames_head_to_index(true) + .renames_index_to_workdir(true); + repo.statuses(Some(&mut options)).context(GitSnafu { + what: "get root repository status", + }) +} + +fn format_dirty_rejection(tracked_paths: &BTreeSet, untracked_count: usize) -> String { + let mut lines = vec![String::from( + "working tree or index has uncommitted modifications; the clean/default path requires a clean working tree:", + )]; + + for path in tracked_paths.iter().take(DIRTY_PATH_DISPLAY_LIMIT) { + lines.push(format!("- {}", path.to_string_lossy())); + } + + if tracked_paths.len() > DIRTY_PATH_DISPLAY_LIMIT { + lines.push(format!( + "- ... and {} more tracked path(s)", + tracked_paths.len() - DIRTY_PATH_DISPLAY_LIMIT + )); + } + + if untracked_count > 0 { + lines.push(format!( + "- ... plus {} untracked file(s) not listed", + untracked_count + )); + } + + lines.push(String::new()); + + if untracked_count > 0 { + lines.push(String::from( + "Use `--profile dirty` or `--allow-dirty` to include tracked local changes, and commit/stash/remove any untracked files first.", + )); + } else { + lines.push(String::from( + "Use `--profile dirty` or `--allow-dirty` to include tracked local changes, or commit/stash them first.", + )); + } + + lines.join("\n") +} + pub fn check_dirty(root_path: &Path) -> Result<(), Error> { + let (tracked_paths, untracked_count) = collect_dirty_paths(root_path)?; + + if tracked_paths.is_empty() && untracked_count == 0 { + Ok(()) + } else { + DirtySnafu { + message: format_dirty_rejection(&tracked_paths, untracked_count), + } + .fail() + } +} + +fn entry_path(entry: &git2::StatusEntry<'_>) -> Option { + entry + .head_to_index() + .and_then(|delta| delta.new_file().path().or_else(|| delta.old_file().path())) + .or_else(|| { + entry + .index_to_workdir() + .and_then(|delta| delta.new_file().path().or_else(|| delta.old_file().path())) + }) + .or_else(|| entry.path().map(Path::new)) + .map(Path::to_path_buf) +} + +fn collect_dirty_paths(root_path: &Path) -> Result<(BTreeSet, usize), Error> { let repo = git2::Repository::open(root_path).context(GitSnafu { what: "open root repository", })?; - let mut options = StatusOptions::default(); - options.include_untracked(true); - let statuses = repo.statuses(Some(&mut options)).context(GitSnafu { - what: "get root repository status", - })?; - let mut statuses = statuses.iter().filter(|x| { - x.path() - .map(|x| !x.trim_end_matches('/').ends_with(super::BUILD_DIR)) - .unwrap_or(false) - }); - if statuses.next().is_some() { - DirtySnafu.fail() + let statuses = dirty_statuses(&repo)?; + let mut paths = BTreeSet::new(); + let mut untracked_count = 0; + + for entry in statuses.iter() { + let status = entry.status(); + let path = entry_path(&entry).unwrap_or_else(|| PathBuf::from("")); + + if status.contains(Status::CONFLICTED) { + return DirtyConflictSnafu { path }.fail(); + } + + if status == Status::CURRENT || status == Status::IGNORED { + continue; + } + + if status == Status::WT_NEW { + if !is_generated_path(&path) { + untracked_count += 1; + } + continue; + } + + if let Some(delta) = entry.head_to_index() { + if let Some(old_path) = delta + .old_file() + .path() + .filter(|path| !is_generated_path(path)) + { + paths.insert(old_path.to_path_buf()); + } + if let Some(new_path) = delta + .new_file() + .path() + .filter(|path| !is_generated_path(path)) + { + paths.insert(new_path.to_path_buf()); + } + } + + if let Some(delta) = entry.index_to_workdir() { + if let Some(old_path) = delta + .old_file() + .path() + .filter(|path| !is_generated_path(path)) + { + paths.insert(old_path.to_path_buf()); + } + if let Some(new_path) = delta + .new_file() + .path() + .filter(|path| !is_generated_path(path)) + { + paths.insert(new_path.to_path_buf()); + } + } + + if !is_generated_path(&path) { + paths.insert(path); + } + } + + Ok((paths, untracked_count)) +} + +pub fn working_tree_paths(root_path: &Path) -> Result, Error> { + let (paths, _) = collect_dirty_paths(root_path)?; + Ok(paths.into_iter().collect()) +} + +fn remove_existing_path(path: &Path) -> Result<(), std::io::Error> { + match std::fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_dir() && !metadata.file_type().is_symlink() => { + std::fs::remove_dir_all(path) + } + Ok(_) => std::fs::remove_file(path), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error), + } +} + +fn remove_index_path(index: &mut git2::Index, path: &Path) -> Result<(), Error> { + match index.remove_path(path) { + Ok(()) => Ok(()), + Err(error) if error.code() == git2::ErrorCode::NotFound => match index.remove_dir(path, -1) + { + Ok(()) => Ok(()), + Err(error) if error.code() == git2::ErrorCode::NotFound => Ok(()), + Err(error) => Err(GitSnafu { + what: "remove dirty path from index", + } + .into_error(error)), + }, + Err(error) => Err(GitSnafu { + what: "remove dirty path from index", + } + .into_error(error)), + } +} + +#[cfg(target_family = "unix")] +fn copy_symlink(source: &Path, destination: &Path) -> Result<(), std::io::Error> { + let target = std::fs::read_link(source)?; + std::os::unix::fs::symlink(target, destination) +} + +#[cfg(target_family = "windows")] +fn copy_symlink(source: &Path, destination: &Path) -> Result<(), std::io::Error> { + let target = std::fs::read_link(source)?; + let resolved_target = source + .parent() + .map(|parent| parent.join(&target)) + .unwrap_or_else(|| target.clone()); + + if std::fs::metadata(&resolved_target) + .map(|metadata| metadata.is_dir()) + .unwrap_or(false) + { + std::os::windows::fs::symlink_dir(target, destination) } else { - Ok(()) + std::os::windows::fs::symlink_file(target, destination) + } +} + +#[cfg(not(any(target_family = "unix", target_family = "windows")))] +fn copy_symlink(_source: &Path, _destination: &Path) -> Result<(), std::io::Error> { + Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "no symlink implementation available", + )) +} + +fn sync_dirty_path( + source_root: &Path, + working_root: &Path, + index: &mut git2::Index, + relative_path: &Path, +) -> Result<(), Error> { + let source_path = source_root.join(relative_path); + let working_path = working_root.join(relative_path); + + match std::fs::symlink_metadata(&source_path) { + Ok(metadata) if metadata.file_type().is_dir() && !metadata.file_type().is_symlink() => { + remove_existing_path(&working_path).context(IoSnafu { + path: working_path.clone(), + })?; + remove_index_path(index, relative_path)?; + Ok(()) + } + Ok(metadata) if metadata.file_type().is_file() || metadata.file_type().is_symlink() => { + if let Some(parent) = working_path.parent() { + std::fs::create_dir_all(parent).context(IoSnafu { + path: parent.to_path_buf(), + })?; + } + + remove_existing_path(&working_path).context(IoSnafu { + path: working_path.clone(), + })?; + + if metadata.file_type().is_symlink() { + copy_symlink(&source_path, &working_path).context(IoSnafu { + path: working_path.clone(), + })?; + } else { + std::fs::copy(&source_path, &working_path).context(IoSnafu { + path: source_path.clone(), + })?; + } + + index.add_path(relative_path).context(GitSnafu { + what: "add dirty path to index", + })?; + Ok(()) + } + Ok(_) => DirtyUnsupportedPathSnafu { + path: relative_path.to_path_buf(), + } + .fail(), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + remove_existing_path(&working_path).context(IoSnafu { + path: working_path.clone(), + })?; + remove_index_path(index, relative_path)?; + Ok(()) + } + Err(error) => Err(IoSnafu { path: source_path }.into_error(error)), } } +fn materialize_dirty_tree( + source_root: &Path, + working_repo: &git2::Repository, + local_head: Oid, +) -> Result { + let (dirty_paths, untracked_count) = collect_dirty_paths(source_root)?; + if untracked_count > 0 { + info!("dirty mode ignores untracked files in the active content repo"); + } + + if dirty_paths.is_empty() { + return Ok(local_head); + } + + let working_root = working_repo.workdir().context(UpdateTreeSnafu:: { + msg: "build repository workdir is unavailable".into(), + })?; + let mut index = working_repo.index().context(GitSnafu { + what: "open build repository index", + })?; + + for path in dirty_paths { + sync_dirty_path(source_root, working_root, &mut index, &path)?; + } + + index.write().context(GitSnafu { + what: "write build repository index", + })?; + let tree_id = index.write_tree().context(GitSnafu { + what: "write dirty materialization tree", + })?; + let tree = working_repo.find_tree(tree_id).context(GitSnafu { + what: "find dirty materialization tree", + })?; + + let sig = Signature::now("eips-build", "eips-build@eips-build.invalid").context(GitSnafu { + what: "dirty commit signature", + })?; + let parent = working_repo.find_commit(local_head).context(GitSnafu { + what: "find clean local head commit", + })?; + let dirty_head = working_repo + .commit( + Some("HEAD"), + &sig, + &sig, + "Dirty working tree materialization", + &tree, + &[&parent], + ) + .context(GitSnafu { + what: "commit dirty working tree materialization", + })?; + + info!( + "materialized tracked dirty changes from the active content repo into `{}`", + working_root.to_string_lossy() + ); + + Ok(dirty_head) +} + fn check_conflict(master_tree: &Tree, path: &Path, entry: &TreeEntry) -> Result<(), Error> { let original = match master_tree.get_path(path) { Err(_) => return Ok(()), @@ -170,19 +522,29 @@ fn check_conflict(master_tree: &Tree, path: &Path, entry: &TreeEntry) -> Result< pub struct Fresh { src_repo_use: RepositoryUse, + src_repo_path: PathBuf, src_repo_url: Url, + source_materialization: SourceMaterialization, working_repo: git2::Repository, } impl Fresh { - pub fn new(root_path: &Path, build_path: &Path, locations: &Locations) -> Result { + pub fn new( + root_path: &Path, + build_path: &Path, + src_repo_use: RepositoryUse, + source_materialization: SourceMaterialization, + ) -> Result { let root_path = absolute(root_path).context(IoSnafu { path: root_path })?; - check_dirty(&root_path)?; - let src_repo_use = locations.identify_repository(&root_path)?; + if source_materialization == SourceMaterialization::Clean { + check_dirty(&root_path)?; + } let src_repo_url = Url::from_directory_path(&root_path) .ok() - .context(PathUrlSnafu { path: root_path })?; + .context(PathUrlSnafu { + path: root_path.clone(), + })?; debug!("source repository at `{src_repo_url}`"); @@ -190,14 +552,20 @@ impl Fresh { Ok(Self { working_repo, + src_repo_path: root_path, src_repo_url, src_repo_use, + source_materialization, }) } pub fn clone_src(self) -> Result { info!("cloning local repository"); - let master = fetch(&self.working_repo, self.src_repo_url.as_str(), "HEAD")?; + let master = fetch( + &self.working_repo, + self.src_repo_url.as_str(), + "HEAD:refs/build-eips/source-head", + )?; self.working_repo .set_head_detached(master.id()) .context(GitSnafu { what: "detach" })?; @@ -226,9 +594,15 @@ impl Fresh { panic!("submodules not supported yet"); } - let local_head = master.id(); + let mut local_head = master.id(); drop(master); drop(branch); + + if self.source_materialization == SourceMaterialization::Dirty { + local_head = + materialize_dirty_tree(&self.src_repo_path, &self.working_repo, local_head)?; + } + Ok(SourceOnly { local_head, src_repo_use: self.src_repo_use, @@ -250,7 +624,7 @@ impl SourceOnly { let latest_master = fetch( &self.working_repo, self.src_repo_use.location.repository.as_str(), - "master", + "master:refs/build-eips/upstream-head", )?; let upstream_head = latest_master.id(); drop(latest_master); @@ -378,11 +752,13 @@ impl SourceWithUpstream { let mut local_head = self.local_head; for (other_kind, other_repo) in repo_use.other_repos.iter().progress_ext("Merge Repos") { info!("fetching {other_kind} repository"); - let master_other = fetch( - &self.working_repo, - other_repo.as_str(), - "master:master-other", - )?; + // Local sibling overrides should follow the checked-out repo HEAD instead of assuming `master`. + let other_refspec = if other_repo.scheme() == "file" { + "HEAD:refs/build-eips/other-head" + } else { + "master:refs/build-eips/other-head" + }; + let master_other = fetch(&self.working_repo, other_repo.as_str(), other_refspec)?; let other_tree = master_other.tree().context(GitSnafu { what: "getting other tree", })?; @@ -479,16 +855,6 @@ impl SourceWithUpstream { .context(GitSnafu { what: "checkout merged", })?; - - self.working_repo - .find_branch("master-other", BranchType::Local) - .context(GitSnafu { - what: "find master-other", - })? - .delete() - .context(GitSnafu { - what: "delete master-other", - })?; } Ok(()) @@ -501,7 +867,19 @@ fn fetch<'a>( refspec: &'_ str, ) -> Result, Error> { debug!("fetching repository at `{url}`"); - let mut remote = repo.remote_anonymous(url).context(GitSnafu { + let remote_name = "__build_eips_fetch"; + match repo.remote_delete(remote_name) { + Ok(()) => (), + Err(error) if error.code() == git2::ErrorCode::NotFound => (), + Err(error) => { + return Err(GitSnafu { + what: "deleting temporary remote", + } + .into_error(error)) + } + } + + let mut remote = repo.remote(remote_name, url).context(GitSnafu { what: "creating remote", })?; { @@ -514,14 +892,24 @@ fn fetch<'a>( what: "fetching repo", })?; } + drop(remote); + repo.remote_delete(remote_name).context(GitSnafu { + what: "deleting temporary remote", + })?; + + let fetched_ref = refspec + .split_once(':') + .map(|(_, destination)| destination) + .filter(|destination| !destination.is_empty()) + .unwrap_or("FETCH_HEAD"); let commit = repo - .revparse_single("FETCH_HEAD") + .revparse_single(fetched_ref) .context(GitSnafu { - what: "revparse FETCH_HEAD", + what: "revparse fetched ref", })? .peel_to_commit() .context(GitSnafu { - what: "peel FETCH_HEAD", + what: "peel fetched ref", })?; Ok(commit) } diff --git a/src/lint.rs b/src/lint.rs index a433b37..cf05ee6 100644 --- a/src/lint.rs +++ b/src/lint.rs @@ -11,8 +11,8 @@ use clap::ValueEnum; use log::debug; use semver::{Comparator, Op, VersionReq}; -use crate::cache::Cache; use crate::progress::ProgressIteratorExt; +use crate::{cache::Cache, ThemeSource}; use eipw_lint::reporters::{AdditionalHelp, Count, Json, Reporter, Text}; use eipw_lint::Linter; @@ -92,17 +92,9 @@ struct Config { eipw: eipw_lint::config::DefaultOptions, } -#[derive(Debug, clap::Args, Serialize, Deserialize)] +#[derive(Debug, Clone, clap::Args, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub struct CmdArgs { - /// Disable linting entirely - #[arg(long, exclusive(true))] - no_lint: bool, - - /// Restrict linting to specific files and/or directories (relative to project root) - #[clap(required(false))] - sources: Vec, - /// Lint output format #[clap(long, value_enum, default_value_t)] format: Format, @@ -257,21 +249,18 @@ fn version_cmp( #[tokio::main(flavor = "current_thread")] pub async fn eipw( - theme_repo: &str, - theme_rev: &str, + theme: &ThemeSource, cache: &Cache, - root_dir: &Path, repo_dir: &Path, - changed_paths: Vec, + sources: Vec, opts: CmdArgs, ) -> Result<(), Error> { - if opts.no_lint { - return Ok(()); - } - let mut stdout = std::io::stdout(); - let mut config_path = cache.repo(theme_repo, theme_rev)?; + let mut config_path = match theme { + ThemeSource::Remote { repository, commit } => cache.repo(repository, commit)?, + ThemeSource::Local { path } => path.to_path_buf(), + }; config_path.push("config"); config_path.push("eipw.toml"); @@ -301,36 +290,8 @@ pub async fn eipw( .await .context(FsSnafu { path: repo_dir })?; - let paths = if opts.sources.is_empty() { - changed_paths - } else { - let root_dir = tokio::fs::canonicalize(root_dir) - .await - .context(FsSnafu { path: root_dir })?; - let mut repo_relative_sources = Vec::with_capacity(opts.sources.len()); - for source in &opts.sources { - let root_relative_source = root_dir.join(source); - let full_source = tokio::fs::canonicalize(&root_relative_source) - .await - .context(FsSnafu { - path: root_relative_source, - })?; - - let relative_source = match full_source.strip_prefix(&root_dir) { - Ok(r) => r, - Err(e) => { - let err = std::io::Error::new(std::io::ErrorKind::NotFound, e); - return Err(FsSnafu { path: full_source }.into_error(err)); - } - }; - - repo_relative_sources.push(repo_dir.join(relative_source)); - } - - repo_relative_sources - }; - - let sources = collect_sources(paths).await?; + let sources: Vec<_> = sources.iter().map(|source| repo_dir.join(source)).collect(); + let sources = collect_sources(sources).await?; let reporter = match opts.format { Format::Json => EitherReporter::Json(Json::default()), diff --git a/src/main.rs b/src/main.rs index 6244467..f8c53a1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,6 +16,7 @@ mod progress; mod zola; use std::{ + collections::BTreeSet, ffi::OsStr, path::{Path, PathBuf}, }; @@ -23,14 +24,24 @@ use std::{ use clap::{Parser, Subcommand}; use fslock::LockFile; use log::{debug, info}; -use snafu::{Report, ResultExt, Whatever}; +use snafu::{OptionExt, Report, ResultExt, Whatever}; +use url::Url; -use crate::config::Config; +use crate::config::{Config, LoadedWorkspaceConfig, LocalOverrides}; const CONTENT_DIR: &str = "content"; const BUILD_DIR: &str = "build"; const REPO_DIR: &str = "repo"; const OUTPUT_DIR: &str = "output"; +const JUSTFILE_NAME: &str = "justfile"; +const PLATFORM_PREPROCESSOR_URL: &str = "https://github.com/eips-wg/preprocessor.git"; +const PLATFORM_EIPW_URL: &str = "https://github.com/ethereum/eipw.git"; + +#[derive(Debug, Clone)] +pub(crate) enum ThemeSource { + Remote { repository: String, commit: String }, + Local { path: PathBuf }, +} /// Build script for Ethereum EIPs and ERCs. #[derive(Parser, Debug)] @@ -44,6 +55,30 @@ struct Args { #[clap(long = "staging")] staging: bool, + /// Load workspace defaults from CONFIG instead of auto-discovering `.build-eips.toml` + #[clap(long)] + config: Option, + + /// Use the named local profile from `.build-eips.toml` + #[clap(long)] + profile: Option, + + /// Use a local theme checkout instead of the configured remote theme + #[clap(long)] + theme_path: Option, + + /// Use a local checkout for the sibling content repository + #[clap(long)] + other_repo_path: Option, + + /// Write build artifacts under BUILD_ROOT instead of the default location + #[clap(long)] + build_root: Option, + + /// Use tracked working-tree changes from the active content repo without requiring a commit + #[clap(long)] + allow_dirty: bool, + #[clap(subcommand)] operation: Operation, } @@ -57,25 +92,16 @@ enum Operation { }, /// Build the project and output HTML - Build { - #[command(flatten)] - eipw: lint::CmdArgs, - }, + Build, /// Build the project and launch a web server to preview it - Serve { - #[command(flatten)] - eipw: lint::CmdArgs, - }, + Serve, /// Remove temporary and output files Clean, /// Analyze the repository and report errors, but don't build HTML files - Check { - #[command(flatten)] - eipw: lint::CmdArgs, - }, + Check, /// List files changed since the last commit common to both the local and upstream repositories Changed { @@ -85,6 +111,77 @@ enum Operation { #[clap(long, value_enum, default_value_t)] format: ChangedFormat, }, + + /// Run targeted editorial validation with eipw + Editorial { + #[command(subcommand)] + command: EditorialCommand, + }, + + /// Manage local multi-repo workspace state + Workspace { + #[command(subcommand)] + command: WorkspaceCommand, + }, +} + +#[derive(Debug, Subcommand, Clone)] +enum WorkspaceCommand { + /// Create the local workspace config and clone any missing sibling repositories + Init { + /// Workspace root directory + path: PathBuf, + + /// Also clone preprocessor and eipw for platform development + #[arg(long)] + platform_dev: bool, + }, + + /// Regenerate generated workspace helper files + Refresh, + + /// Check whether the local workspace is ready for the local daily workflow + Doctor, +} + +#[derive(Debug, Subcommand, Clone)] +enum EditorialCommand { + /// Run eipw on explicitly selected proposal targets + Lint { + #[command(flatten)] + selectors: EditorialSelectorArgs, + + #[command(flatten)] + eipw: lint::CmdArgs, + }, + + /// Run targeted editorial validation, then the runtime check path + Build { + #[command(flatten)] + selectors: EditorialSelectorArgs, + + #[command(flatten)] + eipw: lint::CmdArgs, + }, +} + +#[derive(Debug, clap::Args, Clone)] +struct EditorialSelectorArgs { + /// Repo-relative proposal path(s), such as `content/07949.md` + #[arg(value_name = "PATH")] + paths: Vec, + + /// Read repo-relative proposal paths from BATCH, one per line + #[arg(long)] + batch: Option, + + /// Select tracked dirty proposal files from the active content repo + #[arg(long)] + working_tree: bool, + + /// Select proposal files changed versus the upstream merge-base + #[arg(long)] + against_upstream: bool, } #[derive(Debug, clap::ValueEnum, Clone, Default)] @@ -95,6 +192,41 @@ enum ChangedFormat { Json, } +#[derive(Debug, Clone)] +struct ResolvedExecution { + root_path: PathBuf, + build_path: PathBuf, + repository_use: git::RepositoryUse, + theme: ThemeSource, + source_materialization: git::SourceMaterialization, +} + +#[derive(Debug, Clone, Copy)] +enum GeneratedFileState { + Created, + Updated, + Current, +} + +#[derive(Debug, Clone, Copy)] +enum DoctorStatus { + Ok, + Warn, + Fail, +} + +#[derive(Debug, Default)] +struct DoctorReport { + warnings: usize, + failures: usize, +} + +#[derive(Debug, Clone)] +struct WorkspaceCommandContext { + search_from: PathBuf, + config_path: Option, +} + impl ChangedFormat { fn print_sep(files: &[&Path], sep: &str) { let files: Vec<_> = files @@ -129,6 +261,47 @@ impl ChangedFormat { } } +impl GeneratedFileState { + fn verb(self) -> &'static str { + match self { + Self::Created => "generated", + Self::Updated => "refreshed", + Self::Current => "already current", + } + } +} + +impl DoctorStatus { + fn label(self) -> &'static str { + match self { + Self::Ok => "ok", + Self::Warn => "warn", + Self::Fail => "fail", + } + } +} + +impl DoctorReport { + fn record(&mut self, status: DoctorStatus, message: impl AsRef) { + match status { + DoctorStatus::Ok => (), + DoctorStatus::Warn => self.warnings += 1, + DoctorStatus::Fail => self.failures += 1, + } + + println!("[{}] {}", status.label(), message.as_ref()); + } +} + +impl EditorialSelectorArgs { + fn selector_count(&self) -> usize { + usize::from(!self.paths.is_empty()) + + usize::from(self.batch.is_some()) + + usize::from(self.working_tree) + + usize::from(self.against_upstream) + } +} + fn lock(build_path: &Path) -> Result { let lock_path = build_path.join(".lock"); let mut lock_file = @@ -145,33 +318,715 @@ fn lock(build_path: &Path) -> Result { Ok(lock_file) } +fn resolve_input_path(path: &Path) -> Result { + if path.is_absolute() { + Ok(path.to_path_buf()) + } else { + let cwd = std::env::current_dir().whatever_context("unable to get current directory")?; + Ok(cwd.join(path)) + } +} + fn root(args: &Args) -> Result { let dir = match &args.root { None => find_root::find_root().whatever_context("cannot find repository root")?, - Some(p) => p.to_path_buf(), + Some(path) => { + find_root::is_root(path).whatever_context("invalid root directory")?; + path.canonicalize() + .whatever_context("unable to canonicalize root directory")? + } }; find_root::is_root(&dir).whatever_context("invalid root directory")?; Ok(dir) } -fn make_build_dir(root: &Path) -> Result { - let build_path = root.join(BUILD_DIR); - if let Err(e) = std::fs::create_dir_all(&build_path) { +fn workspace_search_start(args: &Args) -> Result { + match &args.root { + Some(path) => { + let path = resolve_input_path(path)?; + path.canonicalize() + .whatever_context("unable to canonicalize workspace search path") + } + None => std::env::current_dir().whatever_context("unable to get current directory"), + } +} + +fn load_workspace_command_context(args: &Args) -> Result { + let search_from = workspace_search_start(args)?; + let config_path = match args.config.as_deref() { + Some(path) => Some(resolve_input_path(path)?), + None => config::discover_path(&search_from), + }; + + Ok(WorkspaceCommandContext { + search_from, + config_path, + }) +} + +fn generated_justfile_text() -> &'static str { + r#"# Generated by `build-eips workspace refresh`. +default: + @just --list + +check: + build-eips -C "{{ invocation_directory() }}" check + +build: + build-eips -C "{{ invocation_directory() }}" build + +serve: + build-eips -C "{{ invocation_directory() }}" serve + +parity-check: + build-eips -C "{{ invocation_directory() }}" --profile parity check + +parity-build: + build-eips -C "{{ invocation_directory() }}" --profile parity build + +parity-serve: + build-eips -C "{{ invocation_directory() }}" --profile parity serve + +dirty-check: + build-eips -C "{{ invocation_directory() }}" --profile dirty check + +dirty-build: + build-eips -C "{{ invocation_directory() }}" --profile dirty build + +dirty-serve: + build-eips -C "{{ invocation_directory() }}" --profile dirty serve + +editorial-lint: + build-eips -C "{{ invocation_directory() }}" editorial lint --working-tree + +editorial-build: + build-eips -C "{{ invocation_directory() }}" editorial build --working-tree +"# +} + +fn sync_generated_file(path: &Path, contents: &str) -> Result { + match std::fs::read_to_string(path) { + Ok(existing) if existing == contents => Ok(GeneratedFileState::Current), + Ok(_) => { + std::fs::write(path, contents) + .whatever_context("unable to update generated workspace helper")?; + Ok(GeneratedFileState::Updated) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + std::fs::write(path, contents) + .whatever_context("unable to write generated workspace helper")?; + Ok(GeneratedFileState::Created) + } + Err(error) => snafu::whatever!( + "unable to read generated workspace helper `{}`: {}", + path.to_string_lossy(), + Report::from_error(error) + ), + } +} + +fn refresh_workspace(args: &Args) -> Result<(), Whatever> { + let context = load_workspace_command_context(args)?; + let loaded_config = context + .config_path + .as_deref() + .map(LoadedWorkspaceConfig::from_path) + .transpose() + .whatever_context("unable to load workspace config")?; + let config = loaded_config + .as_ref() + .whatever_context("unable to find workspace config `.build-eips.toml`")?; + let justfile_path = config.workspace_root().join(JUSTFILE_NAME); + let state = sync_generated_file(&justfile_path, generated_justfile_text())?; + info!("{} `{}`", state.verb(), justfile_path.to_string_lossy()); + Ok(()) +} + +fn command_path(command: &str) -> Option { + let path = std::env::var_os("PATH")?; + + #[cfg(not(windows))] + let candidates = vec![command.to_owned()]; + + #[cfg(windows)] + { + use std::ffi::OsString; + + let mut candidates = vec![command.to_owned()]; + let command = OsString::from(command); + let path_exts = std::env::var_os("PATHEXT") + .unwrap_or_default() + .to_string_lossy() + .split(';') + .filter(|ext| !ext.is_empty()) + .map(|ext| format!("{}{}", command.to_string_lossy(), ext)) + .collect::>(); + candidates.extend(path_exts); + std::env::split_paths(&path).find_map(|entry| { + candidates + .iter() + .map(|candidate| entry.join(candidate)) + .find(|candidate| candidate.is_file()) + }) + } + + #[cfg(not(windows))] + { + std::env::split_paths(&path).find_map(|entry| { + candidates + .iter() + .map(|candidate| entry.join(candidate)) + .find(|candidate| candidate.is_file()) + }) + } +} + +fn check_workspace_repo(report: &mut DoctorReport, workspace_root: &Path, name: &str) { + let path = workspace_root.join(name); + if !path.exists() { + report.record( + DoctorStatus::Fail, + format!( + "expected workspace repo `{}` at `{}`", + name, + path.to_string_lossy() + ), + ); + return; + } + + match git2::Repository::open(&path) { + Ok(_) => report.record( + DoctorStatus::Ok, + format!( + "found workspace repo `{}` at `{}`", + name, + path.to_string_lossy() + ), + ), + Err(_) => report.record( + DoctorStatus::Fail, + format!( + "expected `{}` to be a git repository at `{}`", + name, + path.to_string_lossy() + ), + ), + } +} + +fn check_tool(report: &mut DoctorReport, command: &str, why: &str) { + match command_path(command) { + Some(path) => report.record( + DoctorStatus::Ok, + format!( + "found required tool `{}` at `{}`", + command, + path.to_string_lossy() + ), + ), + None => report.record( + DoctorStatus::Fail, + format!("missing required tool `{}`: {}", command, why), + ), + } +} + +fn check_optional_download_tool(report: &mut DoctorReport) { + let curl = command_path("curl"); + let wget = command_path("wget"); + + match (curl, wget) { + (Some(path), _) => report.record( + DoctorStatus::Ok, + format!( + "found front-door download helper `curl` at `{}`", + path.to_string_lossy() + ), + ), + (None, Some(path)) => report.record( + DoctorStatus::Ok, + format!( + "found front-door download helper `wget` at `{}`", + path.to_string_lossy() + ), + ), + (None, None) => report.record( + DoctorStatus::Warn, + "missing both `curl` and `wget`; `scripts/dev-setup` will not be able to download a release binary", + ), + } +} + +fn doctor_workspace(args: &Args) -> Result<(), Whatever> { + let context = load_workspace_command_context(args)?; + let mut report = DoctorReport::default(); + + match context.config_path.as_ref() { + Some(path) if path.is_file() => report.record( + DoctorStatus::Ok, + format!( + "found workspace config candidate `{}`", + path.to_string_lossy() + ), + ), + Some(path) => report.record( + DoctorStatus::Fail, + format!("expected workspace config at `{}`", path.to_string_lossy()), + ), + None => report.record( + DoctorStatus::Fail, + format!( + "could not find `{}` while searching upward from `{}`", + config::LOCAL_CONFIG_FILE, + context.search_from.to_string_lossy() + ), + ), + } + + let parsed_config = match context.config_path.as_deref() { + Some(path) if path.is_file() => Some(LoadedWorkspaceConfig::from_path(path)).transpose(), + Some(_) | None => Ok(None), + }; + + if let Ok(Some(config)) = parsed_config.as_ref() { + report.record( + DoctorStatus::Ok, + format!( + "workspace config parses at `{}`", + config.path().to_string_lossy() + ), + ); + + let workspace_root = config.workspace_root(); + if workspace_root.is_dir() { + report.record( + DoctorStatus::Ok, + format!( + "workspace root exists at `{}`", + workspace_root.to_string_lossy() + ), + ); + } else { + report.record( + DoctorStatus::Fail, + format!( + "workspace root is missing at `{}`", + workspace_root.to_string_lossy() + ), + ); + } + + for repo_name in ["EIPs", "ERCs", config::DEFAULT_THEME_DIR] { + check_workspace_repo(&mut report, workspace_root, repo_name); + } + + let justfile_path = workspace_root.join(JUSTFILE_NAME); + match std::fs::read_to_string(&justfile_path) { + Ok(existing) if existing == generated_justfile_text() => report.record( + DoctorStatus::Ok, + format!( + "generated helper `{}` is current", + justfile_path.to_string_lossy() + ), + ), + Ok(_) => report.record( + DoctorStatus::Fail, + format!( + "generated helper `{}` is stale; run `build-eips workspace refresh`", + justfile_path.to_string_lossy() + ), + ), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => report.record( + DoctorStatus::Fail, + format!( + "generated helper `{}` is missing; run `build-eips workspace refresh`", + justfile_path.to_string_lossy() + ), + ), + Err(error) => report.record( + DoctorStatus::Fail, + format!( + "unable to read generated helper `{}`: {}", + justfile_path.to_string_lossy(), + Report::from_error(error) + ), + ), + } + } else if let Err(error) = parsed_config { + report.record( + DoctorStatus::Fail, + format!( + "workspace config could not be parsed: {}", + Report::from_error(error) + ), + ); + report.record( + DoctorStatus::Warn, + "workspace layout checks were skipped because the workspace config could not be parsed", + ); + } else if context.config_path.is_some() { + report.record( + DoctorStatus::Fail, + "workspace config could not be parsed, so workspace layout checks were skipped", + ); + } else { + report.record( + DoctorStatus::Warn, + "workspace layout checks were skipped because no workspace config was found", + ); + } + + check_tool( + &mut report, + "build-eips", + "`just` recipes call `build-eips` directly, so install the release binary or put your dev build on PATH", + ); + check_tool( + &mut report, + "git", + "workspace init, refresh, and daily builds expect git to be available", + ); + check_tool( + &mut report, + "zola", + "daily build, check, and serve commands need a working zola binary", + ); + check_tool( + &mut report, + "just", + "local daily commands use the generated workspace justfile", + ); + check_optional_download_tool(&mut report); + + match command_path("tar") { + Some(path) => report.record( + DoctorStatus::Ok, + format!( + "found front-door archive tool `tar` at `{}`", + path.to_string_lossy() + ), + ), + None => report.record( + DoctorStatus::Warn, + "missing `tar`; `scripts/dev-setup` will not be able to unpack the release binary", + ), + } + + if report.failures > 0 { + snafu::whatever!( + "workspace doctor found {} failing check(s)", + report.failures + ); + } + + Ok(()) +} + +fn make_build_dir(build_path: &Path) -> Result { + if let Err(e) = std::fs::create_dir_all(build_path) { debug!( "got while creating build directory: {}", Report::from_error(e) ); } - Ok(build_path) + Ok(build_path.to_path_buf()) +} + +fn apply_local_other_repo( + repository_use: &mut git::RepositoryUse, + path: &Path, +) -> Result<(), Whatever> { + repository_use + .only_other_repo() + .whatever_context("local sibling overrides require exactly one sibling repository")?; + + let url = Url::from_directory_path(path) + .ok() + .whatever_context("unable to convert local sibling repository path into a file URL")?; + + for repository in repository_use.other_repos.values_mut() { + *repository = url.clone(); + } + + Ok(()) +} + +fn build_path( + root_path: &Path, + repository_use: &git::RepositoryUse, + workspace_config: Option<&LoadedWorkspaceConfig>, + overrides: &LocalOverrides, +) -> PathBuf { + overrides + .build_root + .clone() + .or_else(|| { + workspace_config + .map(|workspace_config| workspace_config.build_root_for(&repository_use.title)) + }) + .unwrap_or_else(|| root_path.join(BUILD_DIR)) +} + +fn theme_source( + baseline: &Config, + workspace_config: Option<&LoadedWorkspaceConfig>, + selected_profile: Option<&config::SelectedProfile>, + overrides: &LocalOverrides, +) -> ThemeSource { + let theme_path = + overrides + .theme_path + .clone() + .or_else(|| match (workspace_config, selected_profile) { + (Some(workspace_config), Some(profile)) if profile.profile.use_local_theme => { + Some(workspace_config.local_theme_path()) + } + _ => None, + }); + + match theme_path { + Some(path) => ThemeSource::Local { path }, + None => ThemeSource::Remote { + repository: baseline.theme.repository.to_string(), + commit: baseline.theme.commit.clone(), + }, + } +} + +fn resolve_execution(args: &Args) -> Result { + let root_path = root(args)?; + let workspace_config = LoadedWorkspaceConfig::load(args.config.as_deref(), &root_path) + .whatever_context("unable to load workspace config")?; + let selected_profile = + config::selected_profile(workspace_config.as_ref(), args.profile.as_deref()) + .whatever_context("unable to select workspace profile")?; + + if let Some(workspace_config) = workspace_config.as_ref() { + debug!( + "using workspace config `{}`", + workspace_config.path().to_string_lossy() + ); + } + + if let Some(profile) = selected_profile.as_ref() { + info!("using workspace profile `{}`", profile.name); + } + + let overrides = LocalOverrides { + theme_path: args + .theme_path + .as_deref() + .map(resolve_input_path) + .transpose()?, + other_repo_path: args + .other_repo_path + .as_deref() + .map(resolve_input_path) + .transpose()?, + build_root: args + .build_root + .as_deref() + .map(resolve_input_path) + .transpose()?, + }; + + let use_staging = args.staging + || selected_profile + .as_ref() + .map(|profile| profile.profile.staging) + .unwrap_or(false); + let allow_dirty = args.allow_dirty + || selected_profile + .as_ref() + .map(|profile| profile.profile.allow_dirty) + .unwrap_or(false); + let baseline = if use_staging { + Config::staging() + } else { + Config::production() + }; + + let mut repository_use = baseline + .locations + .identify_repository(&root_path) + .whatever_context("cannot identify repository use")?; + + let other_repo_path = overrides.other_repo_path.clone().or_else(|| { + match (workspace_config.as_ref(), selected_profile.as_ref()) { + (Some(workspace_config), Some(profile)) if profile.profile.use_local_sibling => { + let (other_name, _) = repository_use.only_other_repo()?; + Some(workspace_config.local_repo_path(other_name)) + } + _ => None, + } + }); + + if let Some(path) = other_repo_path { + apply_local_other_repo(&mut repository_use, &path)?; + } + + let build_path = build_path( + &root_path, + &repository_use, + workspace_config.as_ref(), + &overrides, + ); + let theme = theme_source( + &baseline, + workspace_config.as_ref(), + selected_profile.as_ref(), + &overrides, + ); + let source_materialization = if allow_dirty { + info!( + "dirty mode is enabled; tracked working-tree changes from the active content repo will be materialized into the build input" + ); + git::SourceMaterialization::Dirty + } else { + git::SourceMaterialization::Clean + }; + + Ok(ResolvedExecution { + root_path, + build_path, + repository_use, + theme, + source_materialization, + }) +} + +fn repo_relative_path(root_path: &Path, path: &Path) -> Result { + if path.is_absolute() { + snafu::whatever!( + "editorial selectors require repo-relative proposal paths, got `{}`", + path.to_string_lossy() + ); + } + + let full_path = root_path.join(path); + let canonical = full_path.canonicalize().whatever_context(format!( + "unable to resolve editorial target `{}`", + full_path.to_string_lossy() + ))?; + + let relative = canonical + .strip_prefix(root_path) + .whatever_context(format!( + "editorial target `{}` escapes the active repository root", + path.to_string_lossy() + ))? + .to_path_buf(); + + Ok(relative) +} + +fn validate_editorial_targets( + root_path: &Path, + paths: Vec, + strict: bool, +) -> Result, Whatever> { + let mut unique = BTreeSet::new(); + let mut targets = Vec::new(); + + for path in paths { + if path.is_absolute() { + snafu::whatever!( + "editorial selectors require repo-relative proposal paths, got `{}`", + path.to_string_lossy() + ); + } + + if !strict && !root_path.join(&path).exists() { + continue; + } + + let relative = repo_relative_path(root_path, &path)?; + + if !Prepared::is_proposal_path(relative.clone()) { + if strict { + snafu::whatever!( + "editorial target `{}` is not a supported proposal path", + relative.to_string_lossy() + ); + } + continue; + } + + if unique.insert(relative.clone()) { + targets.push(relative); + } + } + + if strict && targets.is_empty() { + snafu::whatever!("editorial selector resolved no proposal files"); + } + + Ok(targets) +} + +fn read_editorial_batch(path: &Path) -> Result, Whatever> { + let contents = + std::fs::read_to_string(path).whatever_context("unable to read editorial batch file")?; + let mut paths = Vec::new(); + + for line in contents.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + paths.push(PathBuf::from(line)); + } + + Ok(paths) +} + +fn editorial_targets( + selectors: &EditorialSelectorArgs, + resolved: &ResolvedExecution, +) -> Result, Whatever> { + if selectors.selector_count() != 1 { + snafu::whatever!( + "choose exactly one editorial selector: explicit proposal paths, `--batch`, `--working-tree`, or `--against-upstream`" + ); + } + + let raw_targets = if !selectors.paths.is_empty() { + selectors.paths.clone() + } else if let Some(batch) = selectors.batch.as_deref() { + let batch = resolve_input_path(batch)?; + read_editorial_batch(&batch)? + } else if selectors.working_tree { + git::working_tree_paths(&resolved.root_path) + .whatever_context("unable to resolve working-tree editorial targets")? + } else { + let repo_path = resolved.build_path.join(REPO_DIR); + git::Fresh::new( + &resolved.root_path, + &repo_path, + resolved.repository_use.clone(), + resolved.source_materialization, + ) + .whatever_context("initializing build repo for editorial target selection")? + .clone_src() + .whatever_context("cloning source repo for editorial target selection")? + .fetch_upstream() + .whatever_context("fetching upstream repo for editorial target selection")? + .changed_files() + .whatever_context("unable to list editorial targets against upstream")? + }; + + let strict = !selectors.paths.is_empty() || selectors.batch.is_some(); + validate_editorial_targets(&resolved.root_path, raw_targets, strict) } #[derive(Debug)] struct Prepared { cache: cache::Cache, - root_path: PathBuf, repo_path: PathBuf, output_path: PathBuf, - config: Config, + repository_use: git::RepositoryUse, + theme: ThemeSource, } impl Prepared { @@ -213,54 +1068,43 @@ impl Prepared { p == OsStr::new("") } - fn prepare( - eipw: lint::CmdArgs, - config: Config, - root_path: PathBuf, - build_path: PathBuf, - ) -> Result { + fn prepare(resolved: ResolvedExecution) -> Result { zola::find_zola().whatever_context("unable to find suitable zola binary")?; + let ResolvedExecution { + root_path, + build_path, + repository_use, + theme, + source_materialization, + } = resolved; + let repo_path = build_path.join(REPO_DIR); let content_path = repo_path.join(CONTENT_DIR); let output_path = build_path.join(OUTPUT_DIR); - let both = git::Fresh::new(&root_path, &repo_path, &config.locations) - .whatever_context("initializing build repo")? - .clone_src() - .whatever_context("cloning source repo")? - .fetch_upstream() - .whatever_context("fetching upstream repo")?; - - let changed_files: Vec<_> = both - .changed_files() - .whatever_context("unable to list changed files")? - .into_iter() - .filter(|p| Self::is_proposal_path(p.into())) - .map(|p| repo_path.join(p)) - .collect(); + let both = git::Fresh::new( + &root_path, + &repo_path, + repository_use.clone(), + source_materialization, + ) + .whatever_context("initializing build repo")? + .clone_src() + .whatever_context("cloning source repo")? + .fetch_upstream() + .whatever_context("fetching upstream repo")?; both.merge() .whatever_context("unable to merge ERC/EIP repositories")?; let cache = cache::Cache::open().whatever_context("unable to open cache")?; - lint::eipw( - config.theme.repository.as_str(), - &config.theme.commit, - &cache, - &root_path, - &repo_path, - changed_files, - eipw, - ) - .whatever_context("linting failed")?; - markdown::preprocess(&content_path).whatever_context("unable to preprocess markdown")?; Ok(Prepared { - config, - root_path, + repository_use, + theme, cache, repo_path, output_path, @@ -268,95 +1112,193 @@ impl Prepared { } fn build(self) -> Result<(), Whatever> { - let repository_use = self - .config - .locations - .identify_repository(&self.root_path) - .whatever_context("cannot identify repository use")?; zola::build( - self.config.theme.repository.as_str(), - &self.config.theme.commit, + &self.theme, &self.cache, &self.repo_path, &self.output_path, - repository_use.location.base_url.as_str(), + self.repository_use.location.base_url.as_str(), ) .whatever_context("zola build failed")?; Ok(()) } fn serve(self) -> Result<(), Whatever> { - zola::serve( - self.config.theme.repository.as_str(), - &self.config.theme.commit, - &self.cache, - &self.repo_path, - &self.output_path, - ) - .whatever_context("zola serve failed")?; + zola::serve(&self.theme, &self.cache, &self.repo_path, &self.output_path) + .whatever_context("zola serve failed")?; Ok(()) } fn check(self) -> Result<(), Whatever> { - zola::check( - self.config.theme.repository.as_str(), - &self.config.theme.commit, - &self.cache, - &self.repo_path, - ) - .whatever_context("zola check failed")?; + zola::check(&self.theme, &self.cache, &self.repo_path) + .whatever_context("zola check failed")?; Ok(()) } } -fn run() -> Result<(), Whatever> { - let args = Args::parse(); - if let Operation::Print { print } = args.operation { - print::print(print); +fn run_editorial_lint( + resolved: &ResolvedExecution, + selectors: &EditorialSelectorArgs, + eipw: lint::CmdArgs, +) -> Result { + let targets = editorial_targets(selectors, resolved)?; + if targets.is_empty() { + info!("editorial selector resolved no proposal files; skipping editorial lint"); + return Ok(false); + } + + let cache = cache::Cache::open().whatever_context("unable to open cache")?; + + lint::eipw(&resolved.theme, &cache, &resolved.root_path, targets, eipw) + .whatever_context("editorial lint failed")?; + + Ok(true) +} + +fn editorial_runtime_execution( + resolved: &ResolvedExecution, + selectors: &EditorialSelectorArgs, +) -> ResolvedExecution { + let mut runtime = resolved.clone(); + if selectors.working_tree { + runtime.source_materialization = git::SourceMaterialization::Dirty; + } + runtime +} + +fn clone_missing_repo(url: &str, destination: &Path) -> Result<(), Whatever> { + if destination.exists() { + git2::Repository::open(destination) + .whatever_context("expected existing workspace repo path to be a git repository")?; + info!( + "using existing workspace repo `{}`", + destination.to_string_lossy() + ); return Ok(()); } - let config = if args.staging { - Config::staging() + info!("cloning `{url}` into `{}`", destination.to_string_lossy()); + git2::Repository::clone(url, destination).whatever_context("unable to clone workspace repo")?; + Ok(()) +} + +fn init_workspace(args: &Args, path: PathBuf, platform_dev: bool) -> Result<(), Whatever> { + let root_path = root(args)?; + let workspace_root = resolve_input_path(&path)?; + std::fs::create_dir_all(&workspace_root) + .whatever_context("unable to create workspace root directory")?; + let workspace_root = workspace_root + .canonicalize() + .whatever_context("unable to canonicalize workspace root directory")?; + + // Workspace init is a local-dev bootstrap path, so it intentionally uses staging URLs. + let workspace_config = Config::staging(); + let repository_use = workspace_config + .locations + .identify_repository(&root_path) + .whatever_context("cannot identify repository use")?; + + let expected_root = workspace_root.join(&repository_use.title); + if root_path != expected_root { + snafu::whatever!( + "workspace init expects the active repository at `{}`, found `{}`", + expected_root.to_string_lossy(), + root_path.to_string_lossy(), + ); + } + + let (other_name, other_url) = repository_use + .only_other_repo() + .whatever_context("workspace init requires exactly one sibling repository")?; + clone_missing_repo(other_url.as_str(), &workspace_root.join(other_name))?; + clone_missing_repo( + workspace_config.theme.repository.as_str(), + &workspace_root.join(config::DEFAULT_THEME_DIR), + )?; + + if platform_dev { + clone_missing_repo( + PLATFORM_PREPROCESSOR_URL, + &workspace_root.join("preprocessor"), + )?; + clone_missing_repo(PLATFORM_EIPW_URL, &workspace_root.join("eipw"))?; + } + + std::fs::create_dir_all(workspace_root.join(config::DEFAULT_BUILD_ROOT_BASE)) + .whatever_context("unable to create local build root")?; + + let config_path = workspace_root.join(config::LOCAL_CONFIG_FILE); + if config_path.exists() { + info!( + "leaving existing workspace config `{}` in place", + config_path.to_string_lossy() + ); } else { - Config::production() - }; + std::fs::write(&config_path, config::default_workspace_config_text()) + .whatever_context("unable to write workspace config")?; + } - let root_path = root(&args)?; - let build_path = make_build_dir(&root_path)?; + Ok(()) +} + +fn run() -> Result<(), Whatever> { + let args = Args::parse(); + + if let Operation::Print { print } = &args.operation { + print::print(print.clone()); + return Ok(()); + } + if let Operation::Workspace { command } = &args.operation { + match command.clone() { + WorkspaceCommand::Init { path, platform_dev } => { + init_workspace(&args, path, platform_dev)? + } + WorkspaceCommand::Refresh => refresh_workspace(&args)?, + WorkspaceCommand::Doctor => doctor_workspace(&args)?, + } + return Ok(()); + } + + let resolved = resolve_execution(&args)?; + let build_path = make_build_dir(&resolved.build_path)?; let mut lock_file = lock(&build_path)?; match args.operation { - Operation::Print { .. } => unreachable!(), + Operation::Print { .. } | Operation::Workspace { .. } => unreachable!(), Operation::Clean => { // TODO: There's a race condition here. Maybe we move the lockfile to the repository // root? lock_file .unlock() .whatever_context("unable to unlock build directory")?; - std::fs::remove_dir_all(build_path) + std::fs::remove_dir_all(&build_path) .whatever_context("unable to remove build directory")?; return Ok(()); } - Operation::Check { eipw } => { - Prepared::prepare(eipw, config, root_path, build_path)?.check()?; + Operation::Check => { + Prepared::prepare(resolved)?.check()?; } - Operation::Build { eipw } => { - Prepared::prepare(eipw, config, root_path, build_path)?.build()?; + Operation::Build => { + Prepared::prepare(resolved)?.build()?; } - Operation::Serve { eipw } => { - Prepared::prepare(eipw, config, root_path, build_path)?.serve()?; + Operation::Serve => { + Prepared::prepare(resolved)?.serve()?; } Operation::Changed { all, format } => { let repo_path = build_path.join(REPO_DIR); - let both = git::Fresh::new(&root_path, &repo_path, &config.locations) - .whatever_context("initializing build repo")? - .clone_src() - .whatever_context("cloning source repo")? - .fetch_upstream() - .whatever_context("fetching upstream repo")?; + let both = git::Fresh::new( + &resolved.root_path, + &repo_path, + resolved.repository_use.clone(), + resolved.source_materialization, + ) + .whatever_context("initializing build repo")? + .clone_src() + .whatever_context("cloning source repo")? + .fetch_upstream() + .whatever_context("fetching upstream repo")?; let changed_files: Vec<_> = both .changed_files() @@ -368,6 +1310,15 @@ fn run() -> Result<(), Whatever> { format.print(&changed_files, &repo_path); } + Operation::Editorial { command } => match command { + EditorialCommand::Lint { selectors, eipw } => { + run_editorial_lint(&resolved, &selectors, eipw)?; + } + EditorialCommand::Build { selectors, eipw } => { + run_editorial_lint(&resolved, &selectors, eipw)?; + Prepared::prepare(editorial_runtime_execution(&resolved, &selectors))?.check()?; + } + }, } lock_file diff --git a/src/zola.rs b/src/zola.rs index fc65638..ac164be 100644 --- a/src/zola.rs +++ b/src/zola.rs @@ -15,7 +15,7 @@ use semver::Version; use snafu::{ensure, Backtrace, IntoError, Report, ResultExt, Snafu}; use url::Url; -use crate::{cache::Cache, git}; +use crate::{cache::Cache, git, ThemeSource}; const MINIMUM_VERSION: Version = Version::new(0, 22, 1); @@ -96,20 +96,14 @@ pub fn find_zola() -> Result<(), Error> { Ok(()) } -pub fn check( - theme_repo: &str, - theme_rev: &str, - cache: &Cache, - project_path: &Path, -) -> Result<(), Error> { +pub fn check(theme: &ThemeSource, cache: &Cache, project_path: &Path) -> Result<(), Error> { let args = ["check", "--drafts", "--skip-external-links"]; - spawn_log(theme_repo, theme_rev, cache, project_path, args)?; + spawn_log(theme, cache, project_path, args)?; Ok(()) } pub fn build( - theme_repo: &str, - theme_rev: &str, + theme: &ThemeSource, cache: &Cache, project_path: &Path, output_path: &Path, @@ -120,7 +114,7 @@ pub fn build( .map(OsString::from) .into_iter() .chain(std::iter::once(output_path.into())); - spawn_log(theme_repo, theme_rev, cache, project_path, args)?; + spawn_log(theme, cache, project_path, args)?; if let Ok(url) = Url::from_file_path(output_path) { info!("HTML output to: {}", url); } @@ -128,8 +122,7 @@ pub fn build( } pub fn serve( - theme_repo: &str, - theme_rev: &str, + theme: &ThemeSource, cache: &Cache, project_path: &Path, output_path: &Path, @@ -141,7 +134,7 @@ pub fn serve( .map(OsString::from) .into_iter() .chain(std::iter::once(output_path.into())); - spawn_log(theme_repo, theme_rev, cache, project_path, args)?; + spawn_log(theme, cache, project_path, args)?; Ok(()) } @@ -155,8 +148,7 @@ fn remove_output(output_path: &Path) { } fn spawn_log( - theme_repo: &str, - theme_rev: &str, + theme: &ThemeSource, cache: &Cache, project_path: &Path, args: U, @@ -173,7 +165,10 @@ where find_zola()?; - let theme_dir = cache.repo(theme_repo, theme_rev)?; + let theme_dir = match theme { + ThemeSource::Remote { repository, commit } => cache.repo(repository, commit)?, + ThemeSource::Local { path } => path.to_path_buf(), + }; let mut themes_dir = project_path.join("themes"); if let Err(e) = std::fs::create_dir(&themes_dir) {