diff --git a/tooling/xtask/src/main.rs b/tooling/xtask/src/main.rs index e763c7b1cb..9594fc6b65 100644 --- a/tooling/xtask/src/main.rs +++ b/tooling/xtask/src/main.rs @@ -19,8 +19,9 @@ enum CliCommand { Licenses(tasks::licenses::LicensesArgs), /// Checks that packages conform to a set of standards. PackageConformity(tasks::package_conformity::PackageConformityArgs), - /// Prints the GPUI crate graph in dependency-first order. - Gpui, + /// Inspect the GPUI publish plan and its dependencies. + #[command(subcommand)] + Gpui(tasks::gpui::GpuiCommand), /// Runs the Linux Bubblewrap sandboxing NixOS VM tests. SandboxTests(tasks::sandbox_tests::SandboxTestsArgs), /// Runs the Windows WSL Bubblewrap sandbox behavior tests. @@ -43,7 +44,7 @@ fn main() -> Result<()> { CliCommand::PackageConformity(args) => { tasks::package_conformity::run_package_conformity(args) } - CliCommand::Gpui => tasks::gpui::run_gpui(), + CliCommand::Gpui(args) => tasks::gpui::run_gpui(args), CliCommand::SandboxTests(args) => tasks::sandbox_tests::run_sandbox_tests(args), CliCommand::WslSandboxTests(args) => tasks::wsl_sandbox_tests::run_wsl_sandbox_tests(args), CliCommand::SetupWebrtc(args) => tasks::setup_webrtc::run_setup_webrtc(args), diff --git a/tooling/xtask/src/tasks/gpui.rs b/tooling/xtask/src/tasks/gpui.rs index 483c915369..76bd52803b 100644 --- a/tooling/xtask/src/tasks/gpui.rs +++ b/tooling/xtask/src/tasks/gpui.rs @@ -1,16 +1,38 @@ #![allow(clippy::disallowed_methods, reason = "tooling is exempt")] pub mod crate_graph; +pub mod publish_plan; use std::io::{self, Write}; use std::process::{Command, Output, Stdio}; use anyhow::{Context as _, Result, bail}; -use clap::Parser; +use clap::{Parser, Subcommand}; use guppy::graph::DependencyDirection; +use crate::tasks::workflows::GitSha; + +#[derive(Subcommand)] +pub enum GpuiCommand { + /// Print the publish plan in dependency-first order + List(GpuiArgs), + /// Show the dependency paths that put a crate in the publish list + Why { + /// Original Cargo package name + crate_name: String, + }, +} + #[derive(Parser)] pub struct GpuiArgs { + /// Full commit SHA to use when checking out workspace crates + #[arg(long)] + sha: GitSha, +} + +#[derive(Parser)] +#[allow(unused)] +struct PublishGpuiArgs { /// Perform a dry-run and wait for user confirmation before each publish #[arg(long)] dry_run: bool, @@ -20,18 +42,40 @@ pub struct GpuiArgs { skip_to: Option, } -pub fn run_gpui() -> Result<()> { +pub fn run_gpui(command: GpuiCommand) -> Result<()> { let graph = crate_graph::load_workspace_graph()?; - let crates = crate_graph::gpui_crates(&graph)?; - - println!("GPUI crate graph ({} crates):", crates.len()); - for package in crates.packages(DependencyDirection::Reverse) { - println!("{} ({})", package.name(), package.source()); - for link in package.direct_links().filter(|link| !link.dev_only()) { - match crates.contains(link.to().id()) { - Ok(true) => println!(" -> {}", link.to().name()), - Ok(false) => {} - Err(e) => eprintln!(" error checking dependency {}: {}", link.to().name(), e), + match command { + GpuiCommand::List(args) => { + let plan = publish_plan::build_publish_plan(&graph)?; + println!( + "GPUI publish plan ({} crates, dependencies first):", + plan.len() + ); + for entry in &plan { + let package = entry.repository.publish_info(entry.package.name()); + println!( + "{} -> {} ({})", + package.original_name, + package.repository.target_name(package.original_name), + package.repository.display(&args) + ); + } + } + GpuiCommand::Why { crate_name } => { + let explanation = crate_graph::explain_crate(&graph, &crate_name)?; + println!( + "Why `{crate_name}` is in the GPUI publish list (A -> B means A depends on B):" + ); + for package in explanation.packages(DependencyDirection::Forward) { + if crate_graph::is_gpui_root(package) { + println!(" {} is a GPUI workspace root", package.name()); + } + } + for link in explanation + .links(DependencyDirection::Forward) + .filter(|link| !link.dev_only()) + { + println!(" {} -> {}", link.from().name(), link.to().name()); } } } @@ -39,7 +83,7 @@ pub fn run_gpui() -> Result<()> { } #[allow(unused, reason = "retained while GPUI releases are reworked")] -fn run_publish_gpui(args: GpuiArgs) -> Result<()> { +fn run_publish_gpui(args: PublishGpuiArgs) -> Result<()> { println!( "Starting GPUI publish process{}...", if args.dry_run { " (with dry-run)" } else { "" } @@ -363,6 +407,29 @@ mod tests { use super::*; + #[test] + fn gpui_list_accepts_an_explicit_workspace_sha() -> Result<()> { + let sha = "0123456789abcdef0123456789abcdef01234567"; + let args = crate::Args::try_parse_from(["xtask", "gpui", "list", "--sha", sha])?; + let crate::CliCommand::Gpui(GpuiCommand::List(args)) = args.command else { + bail!("expected the GPUI list command"); + }; + assert_eq!(args.sha.as_ref(), sha); + Ok(()) + } + + #[test] + fn gpui_list_requires_a_full_workspace_sha() { + assert!(crate::Args::try_parse_from(["xtask", "gpui"]).is_err()); + assert!(crate::Args::try_parse_from(["xtask", "gpui", "list"]).is_err()); + for sha in ["HEAD", "abc", "g123456789abcdef0123456789abcdef01234567"] { + assert!( + crate::Args::try_parse_from(["xtask", "gpui", "list", "--sha", sha]).is_err(), + "{sha}" + ); + } + } + #[test] fn test_update_dependency_version_in_toml() { let input = indoc! {r#" diff --git a/tooling/xtask/src/tasks/gpui/crate_graph.rs b/tooling/xtask/src/tasks/gpui/crate_graph.rs index a2137677f7..289ac91532 100644 --- a/tooling/xtask/src/tasks/gpui/crate_graph.rs +++ b/tooling/xtask/src/tasks/gpui/crate_graph.rs @@ -1,7 +1,10 @@ use anyhow::{Context as _, Result, ensure}; use guppy::{ MetadataCommand, - graph::{DependencyDirection, ExternalSource, PackageGraph, PackageSet, PackageSource}, + graph::{ + DependencyDirection, ExternalSource, PackageGraph, PackageMetadata, PackageSet, + PackageSource, + }, }; use url::Url; @@ -11,12 +14,16 @@ pub fn load_workspace_graph() -> Result { .context("failed to load the workspace crate graph") } +pub fn is_gpui_root(package: PackageMetadata<'_>) -> bool { + package.in_workspace() && package.name().starts_with("gpui") +} + /// Returns all GPUI crates in the current workspace pub fn gpui_crates(graph: &PackageGraph) -> Result> { let roots = graph .workspace() .iter_by_name() - .filter(|(name, _)| name.starts_with("gpui")) + .filter(|(_, package)| is_gpui_root(*package)) .map(|(_, package)| package.id()) .collect::>(); ensure!(!roots.is_empty(), "no GPUI crates found in the workspace"); @@ -34,6 +41,29 @@ pub fn gpui_crates(graph: &PackageGraph) -> Result> { ) } +pub fn explain_crate<'graph>( + graph: &'graph PackageGraph, + name: &str, +) -> Result> { + let crates = gpui_crates(graph)?; + let selected = crates.filter(DependencyDirection::Forward, |package| { + package.name() == name + }); + ensure!( + !selected.is_empty(), + "crate `{name}` is not in the GPUI publish list" + ); + + let dependents = selected + .to_package_query(DependencyDirection::Reverse) + .resolve_with_fn(|_, link| !link.dev_only()); + + let dependencies = crates + .to_package_query(DependencyDirection::Forward) + .resolve_with_fn(|_, link| !link.dev_only()); + Ok(dependents.intersection(&dependencies)) +} + fn is_zed_fork(source: PackageSource<'_>) -> bool { let Some(ExternalSource::Git { repository, .. }) = source.parse_external() else { return false; @@ -219,7 +249,9 @@ mod tests { }, FixturePackage { name: "fork", - origin: Git("git+https://github.com/zed-industries/fork?rev=abc#abc"), + origin: Git( + "git+https://github.com/zed-industries/fork?rev=abc#0123456789abcdef0123456789abcdef01234567", + ), dependencies: &[FixtureDependency { to: "collections", kind: Normal(Required), @@ -283,6 +315,83 @@ mod tests { assert!(crates.links(DependencyDirection::Reverse).all(|link| { names.contains(&link.from().name()) && names.contains(&link.to().name()) })); + + let explanation = explain_crate(&graph, "fork")?; + assert_eq!( + explanation + .packages(DependencyDirection::Forward) + .map(|package| package.name()) + .collect::>(), + ["gpui_platform", "gpui", "registry", "fork"], + ); + assert_eq!( + explanation + .links(DependencyDirection::Forward) + .filter(|link| !link.dev_only()) + .map(|link| (link.from().name(), link.to().name())) + .collect::>(), + [ + ("gpui_platform", "gpui"), + ("gpui", "registry"), + ("registry", "fork") + ] + .into_iter() + .collect(), + ); + let root = explain_crate(&graph, "gpui_platform")?; + assert_eq!(root.len(), 1); + assert!( + root.packages(DependencyDirection::Forward) + .all(is_gpui_root) + ); + for excluded in ["zed", "test_helper", "registry", "missing"] { + assert!(explain_crate(&graph, excluded).is_err(), "{excluded}"); + } + + use super::super::publish_plan::{Repository, build_publish_plan}; + + let plan = build_publish_plan(&graph)?; + assert_eq!( + plan.iter() + .map(|entry| entry.package.name()) + .collect::>(), + names, + ); + assert_eq!( + plan.iter() + .map(|entry| entry.package.id()) + .collect::>(), + crates + .package_ids(DependencyDirection::Reverse) + .collect::>(), + ); + for entry in &plan { + let package = entry.repository.publish_info(entry.package.name()); + match package.repository { + Repository::Workspace => { + assert_ne!(package.original_name, "fork"); + if package.original_name == "gpui_macros" { + assert_eq!( + package.repository.target_name(package.original_name), + "gpui-macros" + ); + } + } + Repository::Git(repository) => { + assert_eq!(package.original_name, "fork"); + assert_eq!( + package.repository.target_name(package.original_name), + "zed-fork" + ); + assert_eq!(repository.owner, "zed-industries"); + assert_eq!(repository.repo, "fork"); + assert_eq!( + repository.revision.as_ref(), + "0123456789abcdef0123456789abcdef01234567" + ); + } + } + } Ok(()) } diff --git a/tooling/xtask/src/tasks/gpui/publish_plan.rs b/tooling/xtask/src/tasks/gpui/publish_plan.rs new file mode 100644 index 0000000000..67b0595f74 --- /dev/null +++ b/tooling/xtask/src/tasks/gpui/publish_plan.rs @@ -0,0 +1,286 @@ +use std::fmt; + +use anyhow::{Context as _, Result, bail, ensure}; +use guppy::graph::{DependencyDirection, ExternalSource, PackageGraph, PackageMetadata}; +use url::Url; + +use crate::tasks::{gpui::GpuiArgs, workflows::GitSha}; + +pub enum Repository { + Workspace, + Git(GitRepository), +} + +pub struct GitRepository { + pub owner: String, + pub repo: String, + pub revision: GitSha, +} + +impl GitRepository { + fn new(repository: &str, revision: &str) -> Result { + let url = Url::parse(repository).context("invalid Git repository URL")?; + ensure!( + url.host_str() + .is_some_and(|host| host.eq_ignore_ascii_case("github.com")), + "expected a GitHub repository, got {repository}" + ); + let (owner, repo) = url + .path() + .trim_matches('/') + .split_once('/') + .context("repository URL must contain an owner and repository name")?; + let repo = repo.strip_suffix(".git").unwrap_or(repo); + ensure!( + !owner.is_empty() && !repo.is_empty() && !repo.contains('/'), + "expected an owner/repo path, got {repository}" + ); + let revision = revision + .parse::() + .map_err(anyhow::Error::msg) + .with_context(|| format!("invalid resolved revision for {repository}"))?; + + Ok(Self { + owner: owner.to_owned(), + repo: repo.to_owned(), + revision, + }) + } +} + +impl Repository { + pub fn fork(repository: &str, revision: &str) -> Result { + Ok(Self::Git(GitRepository::new(repository, revision)?)) + } + + pub fn publish_info<'a>(&'a self, name: &'a str) -> CratePublishInfo<'a> { + CratePublishInfo { + repository: self, + original_name: name, + } + } + + pub fn name(&self) -> String { + match self { + Self::Workspace => "zed-industries/zed".to_owned(), + Self::Git(repository) => format!( + "{owner}/{repo}", + owner = repository.owner, + repo = repository.repo + ), + } + } + + pub fn target_name(&self, name: &str) -> String { + match self { + Self::Workspace => { + if name == "gpui_macros" { + "gpui-macros".to_owned() + } else if name == "gpui" || name.starts_with("gpui_") { + name.to_owned() + } else { + format!("gpui_{name}") + } + } + Self::Git(_) => { + if name.starts_with("zed-") { + name.to_owned() + } else { + format!("zed-{name}") + } + } + } + } + + pub fn display<'a>(&'a self, args: &'a GpuiArgs) -> impl fmt::Display + 'a { + let revision = match self { + Self::Workspace => &args.sha, + Self::Git(repository) => &repository.revision, + }; + fmt::from_fn(move |formatter| { + write!( + formatter, + "{name}@{revision}", + name = self.name(), + revision = revision.as_ref() + ) + }) + } +} + +pub struct CratePublishInfo<'a> { + pub repository: &'a Repository, + pub original_name: &'a str, +} + +pub struct PublishPlanEntry<'graph> { + pub package: PackageMetadata<'graph>, + pub repository: Repository, +} + +pub fn build_publish_plan(graph: &PackageGraph) -> Result>> { + let packages = super::crate_graph::gpui_crates(graph)?; + packages + .packages(DependencyDirection::Reverse) + .map(|package| { + let repository = if package.in_workspace() { + Repository::Workspace + } else if let Some(ExternalSource::Git { + repository, + resolved, + .. + }) = package.source().parse_external() + { + Repository::fork(repository, resolved)? + } else { + bail!( + "unexpected source for GPUI crate {}: {}", + package.name(), + package.source() + ); + }; + Ok(PublishPlanEntry { + package, + repository, + }) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + const REVISION: &str = "0123456789abcdef0123456789abcdef01234567"; + const WORKSPACE_REVISION: &str = "fedcba9876543210fedcba9876543210fedcba98"; + + #[test] + fn publishing_info_borrows_its_repository_and_crate_name() { + let repository = Repository::Workspace; + let name = String::from("collections"); + let package = repository.publish_info(&name); + + assert!(std::ptr::eq(package.repository, &repository)); + assert!(std::ptr::eq(package.original_name, name.as_str())); + } + + #[test] + fn workspace_names_follow_existing_publish_names() { + let repository = Repository::Workspace; + for (original, target) in [ + ("collections", "gpui_collections"), + ("perf", "gpui_perf"), + ("util_macros", "gpui_util_macros"), + ("util", "gpui_util"), + ("gpui_macros", "gpui-macros"), + ("http_client", "gpui_http_client"), + ("derive_refineable", "gpui_derive_refineable"), + ("refineable", "gpui_refineable"), + ("semantic_version", "gpui_semantic_version"), + ("sum_tree", "gpui_sum_tree"), + ("media", "gpui_media"), + ("gpui", "gpui"), + ("gpui_platform", "gpui_platform"), + ("gpui-macros", "gpui_gpui-macros"), + ("scheduler", "gpui_scheduler"), + ] { + let package = repository.publish_info(original); + assert_eq!(package.original_name, original); + assert_eq!( + package.repository.target_name(package.original_name), + target + ); + assert!(matches!(package.repository, Repository::Workspace)); + } + } + + #[test] + fn fork_names_have_a_single_zed_prefix() -> Result<()> { + let repository = Repository::fork("https://github.com/zed-industries/fork", REVISION)?; + for (original, target) in [ + ("calloop", "zed-calloop"), + ("wasm_thread", "zed-wasm_thread"), + ("zed-font-kit", "zed-font-kit"), + ("zed-scap", "zed-scap"), + ] { + let package = repository.publish_info(original); + assert_eq!(package.original_name, original); + assert_eq!( + package.repository.target_name(package.original_name), + target + ); + } + Ok(()) + } + + #[test] + fn workspace_repository_uses_the_argument_sha() -> Result<()> { + let repository = Repository::Workspace; + let package = repository.publish_info("gpui"); + assert_eq!(package.repository.name(), "zed-industries/zed"); + for revision in [REVISION, WORKSPACE_REVISION] { + let args = GpuiArgs { + sha: revision.parse().map_err(anyhow::Error::msg)?, + }; + assert_eq!( + package.repository.display(&args).to_string(), + format!("zed-industries/zed@{revision}") + ); + } + Ok(()) + } + + #[test] + fn git_repositories_have_checkout_coordinates() -> Result<()> { + let args = GpuiArgs { + sha: WORKSPACE_REVISION.parse().map_err(anyhow::Error::msg)?, + }; + for url in [ + "https://github.com/zed-industries/fork", + "https://github.com/zed-industries/fork.git", + "https://github.com/zed-industries/fork.git/", + "ssh://git@github.com/zed-industries/fork.git", + ] { + let repository = Repository::fork(url, REVISION)?; + let package = repository.publish_info("fork"); + assert_eq!(package.repository.name(), "zed-industries/fork"); + assert_eq!( + package.repository.display(&args).to_string(), + format!("zed-industries/fork@{REVISION}") + ); + let Repository::Git(repository) = package.repository else { + bail!("expected a Git repository"); + }; + assert_eq!(repository.owner, "zed-industries"); + assert_eq!(repository.repo, "fork"); + assert_eq!(repository.revision.as_ref(), REVISION); + } + Ok(()) + } + + #[test] + fn git_repositories_require_an_owner_repo_and_full_sha() { + for url in [ + "not a URL", + "https://example.com/zed-industries/fork", + "https://github.com/zed-industries", + "https://github.com/zed-industries/", + "https://github.com/zed-industries/.git", + "https://github.com/zed-industries/fork/tree/main", + ] { + assert!(Repository::fork(url, REVISION).is_err(), "{url}"); + } + for revision in [ + "", + "main", + "v1.0", + "abc", + "g123456789abcdef0123456789abcdef01234567", + ] { + assert!( + Repository::fork("https://github.com/zed-industries/fork", revision).is_err(), + "{revision}" + ); + } + } +} diff --git a/tooling/xtask/src/tasks/workflows.rs b/tooling/xtask/src/tasks/workflows.rs index 86a2a95220..8107cb4571 100644 --- a/tooling/xtask/src/tasks/workflows.rs +++ b/tooling/xtask/src/tasks/workflows.rs @@ -41,44 +41,47 @@ impl AsRef for GitSha { } } +impl std::str::FromStr for GitSha { + type Err = String; + + fn from_str(value: &str) -> Result { + const GIT_SHA_LENGTH: usize = 40; + if value.len() != GIT_SHA_LENGTH { + return Err(format!( + "Git SHA has wrong length! \ + Only SHAs with a full length of {GIT_SHA_LENGTH} are supported, found {len} characters.", + len = value.len() + )); + } + if !value.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err("Not a valid Git SHA".to_owned()); + } + Ok(Self(value.to_owned())) + } +} + #[allow( clippy::disallowed_methods, reason = "This runs only in a CLI environment" )] fn parse_ref(value: &str) -> Result { - const GIT_SHA_LENGTH: usize = 40; - (value.len() == GIT_SHA_LENGTH) - .then_some(value) - .ok_or_else(|| { - format!( - "Git SHA has wrong length! \ - Only SHAs with a full length of {GIT_SHA_LENGTH} are supported, found {len} characters.", - len = value.len() - ) - }) - .and_then(|value| { - let mut tmp = [0; 4]; - value - .chars() - .all(|char| u16::from_str_radix(char.encode_utf8(&mut tmp), 16).is_ok()).then_some(value) - .ok_or_else(|| "Not a valid Git SHA".to_owned()) - }) - .and_then(|sha| { - std::process::Command::new("git") - .args([ - "rev-parse", - "--quiet", - "--verify", - &format!("{sha}^{{commit}}") - ]) - .output() - .map_err(|_| "Failed to spawn Git command to verify SHA".to_owned()) - .and_then(|output| - output - .status.success() - .then_some(sha) - .ok_or_else(|| format!("SHA {sha} is not a valid Git SHA within this repository!"))) - }).map(|sha| GitSha(sha.to_owned())) + let sha = value.parse::()?; + let output = std::process::Command::new("git") + .args([ + "rev-parse", + "--quiet", + "--verify", + &format!("{}^{{commit}}", sha.as_ref()), + ]) + .output() + .map_err(|_| "Failed to spawn Git command to verify SHA".to_owned())?; + if !output.status.success() { + return Err(format!( + "SHA {} is not a valid Git SHA within this repository!", + sha.as_ref() + )); + } + Ok(sha) } #[derive(Parser)]