From 03226f0ca30d65ae425c6cba3b9f20a1ee3d3a23 Mon Sep 17 00:00:00 2001 From: Rito Rhymes Date: Tue, 14 Apr 2026 23:05:30 -0400 Subject: [PATCH] Add local workspace and site build support Add the local workspace model for the multi-repo build system. This adds workspace config and profile support, workspace init, refresh, and doctor commands, generated helper tasks, and the local site build path against sibling repos and theme inputs. The site commands support normal local usage, parity checks, standard clone and git worktree checkouts, and explicit dirty builds from the active content repo. --- src/config.rs | 385 +++++++++++++++++++- src/find_root.rs | 2 +- src/git.rs | 467 +++++++++++++++++++++--- src/lint.rs | 10 +- src/main.rs | 900 +++++++++++++++++++++++++++++++++++++++++++---- src/zola.rs | 29 +- 6 files changed, 1651 insertions(+), 142 deletions(-) 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..2394712 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,25 +139,350 @@ 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)) +} + +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> { @@ -170,19 +517,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 +547,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 +589,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 +619,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 +747,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 +850,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 +862,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 +887,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..228de2c 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; @@ -257,8 +257,7 @@ 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, @@ -271,7 +270,10 @@ pub async fn eipw( 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"); diff --git a/src/main.rs b/src/main.rs index 6244467..3f14b19 100644 --- a/src/main.rs +++ b/src/main.rs @@ -23,14 +23,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 +54,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, } @@ -85,6 +119,31 @@ enum Operation { #[clap(long, value_enum, default_value_t)] format: ChangedFormat, }, + + /// 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, clap::ValueEnum, Clone, Default)] @@ -95,6 +154,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 +223,38 @@ 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()); + } +} + fn lock(build_path: &Path) -> Result { let lock_path = build_path.join(".lock"); let mut lock_file = @@ -145,33 +271,585 @@ 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 +"# +} + +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, + }) } #[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,24 +891,32 @@ impl Prepared { p == OsStr::new("") } - fn prepare( - eipw: lint::CmdArgs, - config: Config, - root_path: PathBuf, - build_path: PathBuf, - ) -> Result { + fn prepare(eipw: lint::CmdArgs, 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 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")?; let changed_files: Vec<_> = both .changed_files() @@ -245,22 +931,14 @@ impl Prepared { 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")?; + lint::eipw(&theme, &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 +946,163 @@ 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 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")?; + } + + Ok(()) +} + +fn run() -> Result<(), Whatever> { + let args = Args::parse(); - let root_path = root(&args)?; - let build_path = make_build_dir(&root_path)?; + 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()?; + Prepared::prepare(eipw, resolved)?.check()?; } Operation::Build { eipw } => { - Prepared::prepare(eipw, config, root_path, build_path)?.build()?; + Prepared::prepare(eipw, resolved)?.build()?; } Operation::Serve { eipw } => { - Prepared::prepare(eipw, config, root_path, build_path)?.serve()?; + Prepared::prepare(eipw, 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() 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) {