xtask: Add more helpers for querying information on GPUI crates (#64333)

Primarily, add a 
- helper to easily get information on what pulls in a crate (hey Mr
Piotr we can consider moving this up in the namespace to also use this
elsewhere)
- helper to extract a list of GPUI crates in order which need publishing

Release Notes:

- N/A
This commit is contained in:
Finn Evers authored and GitHub committed 2026-09-21 22:40:38 +00:00
1 parent c96197a34d
commit 1df382558f
5 files changed
+518 -52

No files matched your search

+4 -3
View File
@@ -19,8 +19,9 @@ enum CliCommand {
Licenses(tasks::licenses::LicensesArgs), Licenses(tasks::licenses::LicensesArgs),
/// Checks that packages conform to a set of standards. /// Checks that packages conform to a set of standards.
PackageConformity(tasks::package_conformity::PackageConformityArgs), PackageConformity(tasks::package_conformity::PackageConformityArgs),
/// Prints the GPUI crate graph in dependency-first order. /// Inspect the GPUI publish plan and its dependencies.
Gpui, #[command(subcommand)]
Gpui(tasks::gpui::GpuiCommand),
/// Runs the Linux Bubblewrap sandboxing NixOS VM tests. /// Runs the Linux Bubblewrap sandboxing NixOS VM tests.
SandboxTests(tasks::sandbox_tests::SandboxTestsArgs), SandboxTests(tasks::sandbox_tests::SandboxTestsArgs),
/// Runs the Windows WSL Bubblewrap sandbox behavior tests. /// Runs the Windows WSL Bubblewrap sandbox behavior tests.
@@ -43,7 +44,7 @@ fn main() -> Result<()> {
CliCommand::PackageConformity(args) => { CliCommand::PackageConformity(args) => {
tasks::package_conformity::run_package_conformity(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::SandboxTests(args) => tasks::sandbox_tests::run_sandbox_tests(args),
CliCommand::WslSandboxTests(args) => tasks::wsl_sandbox_tests::run_wsl_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), CliCommand::SetupWebrtc(args) => tasks::setup_webrtc::run_setup_webrtc(args),
+80 -13
View File
@@ -1,16 +1,38 @@
#![allow(clippy::disallowed_methods, reason = "tooling is exempt")] #![allow(clippy::disallowed_methods, reason = "tooling is exempt")]
pub mod crate_graph; pub mod crate_graph;
pub mod publish_plan;
use std::io::{self, Write}; use std::io::{self, Write};
use std::process::{Command, Output, Stdio}; use std::process::{Command, Output, Stdio};
use anyhow::{Context as _, Result, bail}; use anyhow::{Context as _, Result, bail};
use clap::Parser; use clap::{Parser, Subcommand};
use guppy::graph::DependencyDirection; 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)] #[derive(Parser)]
pub struct GpuiArgs { 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 /// Perform a dry-run and wait for user confirmation before each publish
#[arg(long)] #[arg(long)]
dry_run: bool, dry_run: bool,
@@ -20,18 +42,40 @@ pub struct GpuiArgs {
skip_to: Option<String>, skip_to: Option<String>,
} }
pub fn run_gpui() -> Result<()> { pub fn run_gpui(command: GpuiCommand) -> Result<()> {
let graph = crate_graph::load_workspace_graph()?; let graph = crate_graph::load_workspace_graph()?;
let crates = crate_graph::gpui_crates(&graph)?; match command {
GpuiCommand::List(args) => {
println!("GPUI crate graph ({} crates):", crates.len()); let plan = publish_plan::build_publish_plan(&graph)?;
for package in crates.packages(DependencyDirection::Reverse) { println!(
println!("{} ({})", package.name(), package.source()); "GPUI publish plan ({} crates, dependencies first):",
for link in package.direct_links().filter(|link| !link.dev_only()) { plan.len()
match crates.contains(link.to().id()) { );
Ok(true) => println!(" -> {}", link.to().name()), for entry in &plan {
Ok(false) => {} let package = entry.repository.publish_info(entry.package.name());
Err(e) => eprintln!(" error checking dependency {}: {}", link.to().name(), e), 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")] #[allow(unused, reason = "retained while GPUI releases are reworked")]
fn run_publish_gpui(args: GpuiArgs) -> Result<()> { fn run_publish_gpui(args: PublishGpuiArgs) -> Result<()> {
println!( println!(
"Starting GPUI publish process{}...", "Starting GPUI publish process{}...",
if args.dry_run { " (with dry-run)" } else { "" } if args.dry_run { " (with dry-run)" } else { "" }
@@ -363,6 +407,29 @@ mod tests {
use super::*; 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] #[test]
fn test_update_dependency_version_in_toml() { fn test_update_dependency_version_in_toml() {
let input = indoc! {r#" let input = indoc! {r#"
+112 -3
View File
@@ -1,7 +1,10 @@
use anyhow::{Context as _, Result, ensure}; use anyhow::{Context as _, Result, ensure};
use guppy::{ use guppy::{
MetadataCommand, MetadataCommand,
graph::{DependencyDirection, ExternalSource, PackageGraph, PackageSet, PackageSource}, graph::{
DependencyDirection, ExternalSource, PackageGraph, PackageMetadata, PackageSet,
PackageSource,
},
}; };
use url::Url; use url::Url;
@@ -11,12 +14,16 @@ pub fn load_workspace_graph() -> Result<PackageGraph> {
.context("failed to load the workspace crate graph") .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 /// Returns all GPUI crates in the current workspace
pub fn gpui_crates(graph: &PackageGraph) -> Result<PackageSet<'_>> { pub fn gpui_crates(graph: &PackageGraph) -> Result<PackageSet<'_>> {
let roots = graph let roots = graph
.workspace() .workspace()
.iter_by_name() .iter_by_name()
.filter(|(name, _)| name.starts_with("gpui")) .filter(|(_, package)| is_gpui_root(*package))
.map(|(_, package)| package.id()) .map(|(_, package)| package.id())
.collect::<Vec<_>>(); .collect::<Vec<_>>();
ensure!(!roots.is_empty(), "no GPUI crates found in the workspace"); ensure!(!roots.is_empty(), "no GPUI crates found in the workspace");
@@ -34,6 +41,29 @@ pub fn gpui_crates(graph: &PackageGraph) -> Result<PackageSet<'_>> {
) )
} }
pub fn explain_crate<'graph>(
graph: &'graph PackageGraph,
name: &str,
) -> Result<PackageSet<'graph>> {
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 { fn is_zed_fork(source: PackageSource<'_>) -> bool {
let Some(ExternalSource::Git { repository, .. }) = source.parse_external() else { let Some(ExternalSource::Git { repository, .. }) = source.parse_external() else {
return false; return false;
@@ -219,7 +249,9 @@ mod tests {
}, },
FixturePackage { FixturePackage {
name: "fork", 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 { dependencies: &[FixtureDependency {
to: "collections", to: "collections",
kind: Normal(Required), kind: Normal(Required),
@@ -283,6 +315,83 @@ mod tests {
assert!(crates.links(DependencyDirection::Reverse).all(|link| { assert!(crates.links(DependencyDirection::Reverse).all(|link| {
names.contains(&link.from().name()) && names.contains(&link.to().name()) 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::<Vec<_>>(),
["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::<std::collections::BTreeSet<_>>(),
[
("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::<Vec<_>>(),
names,
);
assert_eq!(
plan.iter()
.map(|entry| entry.package.id())
.collect::<Vec<_>>(),
crates
.package_ids(DependencyDirection::Reverse)
.collect::<Vec<_>>(),
);
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(()) Ok(())
} }
@@ -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<Self> {
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::<GitSha>()
.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<Self> {
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<Vec<PublishPlanEntry<'_>>> {
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}"
);
}
}
}
+36 -33
View File
@@ -41,44 +41,47 @@ impl AsRef<str> for GitSha {
} }
} }
impl std::str::FromStr for GitSha {
type Err = String;
fn from_str(value: &str) -> Result<Self, Self::Err> {
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( #[allow(
clippy::disallowed_methods, clippy::disallowed_methods,
reason = "This runs only in a CLI environment" reason = "This runs only in a CLI environment"
)] )]
fn parse_ref(value: &str) -> Result<GitSha, String> { fn parse_ref(value: &str) -> Result<GitSha, String> {
const GIT_SHA_LENGTH: usize = 40; let sha = value.parse::<GitSha>()?;
(value.len() == GIT_SHA_LENGTH) let output = std::process::Command::new("git")
.then_some(value) .args([
.ok_or_else(|| { "rev-parse",
format!( "--quiet",
"Git SHA has wrong length! \ "--verify",
Only SHAs with a full length of {GIT_SHA_LENGTH} are supported, found {len} characters.", &format!("{}^{{commit}}", sha.as_ref()),
len = value.len() ])
) .output()
}) .map_err(|_| "Failed to spawn Git command to verify SHA".to_owned())?;
.and_then(|value| { if !output.status.success() {
let mut tmp = [0; 4]; return Err(format!(
value "SHA {} is not a valid Git SHA within this repository!",
.chars() sha.as_ref()
.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()) }
}) Ok(sha)
.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()))
} }
#[derive(Parser)] #[derive(Parser)]