diff --git a/src/ops.rs b/src/ops.rs index 8d4696bf..acb8b105 100644 --- a/src/ops.rs +++ b/src/ops.rs @@ -219,6 +219,51 @@ fn collect_members(root: &Path, globs: &[String]) -> WorkspaceInfo { info } +fn collect_workspace_links_for_frozen( + project: &Path, + manifest: &Manifest, + workspace: Option<&WorkspaceInfo>, +) -> Result> { + let Some(workspace) = workspace else { + return Ok(BTreeMap::new()); + }; + + let mut links = BTreeMap::new(); + let mut pending: VecDeque<(String, String)> = manifest + .dependencies + .iter() + .map(|(key, requirement)| (key.clone(), requirement.clone())) + .collect(); + + while let Some((raw_key, requirement_text)) = pending.pop_front() { + let (org, name) = split_key(&raw_key)?; + let key = format!("{org}/{name}"); + let Some(member_dir) = workspace.members.get(&key) else { + continue; + }; + let member_manifest = read_manifest(member_dir).with_context(|| { + format!( + "reading workspace member `{key}` from {}", + member_dir.display() + ) + })?; + let requirement = Requirement::parse(&requirement_text); + if !requirement.matches(&member_manifest.package.version) { + bail!( + "workspace member {key}@{} does not satisfy `{requirement_text}`", + member_manifest.package.version + ); + } + if member_dir == project || links.contains_key(&key) { + continue; + } + links.insert(key, member_dir.clone()); + pending.extend(member_manifest.dependencies); + } + + Ok(links) +} + // --------------------------------------------------------------------------- // install @@ -1023,6 +1068,8 @@ fn install_locked( workspace.as_ref(), validate_manifest_requirements, )?; + workspace_links = + collect_workspace_links_for_frozen(project, &manifest, workspace.as_ref())?; for locked in &lock.packages { if !is_slug(&locked.org) || !is_slug(&locked.name) { bail!( @@ -1058,13 +1105,24 @@ fn install_locked( if let Some(ws) = &workspace && let Some(member_dir) = ws.members.get(&key) { + let member_manifest = read_manifest(member_dir).with_context(|| { + format!( + "reading workspace member `{key}` from {}", + member_dir.display() + ) + })?; + let requirement = Requirement::parse(&req_str); + if !requirement.matches(&member_manifest.package.version) { + bail!( + "workspace member {key}@{} does not satisfy `{req_str}`", + member_manifest.package.version + ); + } if member_dir != project && !workspace_links.contains_key(&key) { workspace_links.insert(key.clone(), member_dir.clone()); - if let Ok(member_manifest) = read_manifest(member_dir) { - for (sub_key, sub_req) in member_manifest.dependencies { - let (sub_org, sub_name) = split_key(&sub_key)?; - queue.push_back((sub_org, sub_name, sub_req)); - } + for (sub_key, sub_req) in member_manifest.dependencies { + let (sub_org, sub_name) = split_key(&sub_key)?; + queue.push_back((sub_org, sub_name, sub_req)); } } continue; @@ -1316,16 +1374,95 @@ fn install_locked( for (key, member_dir) in &workspace_links { interactive::confirm(cfg.interactive, &format!("link workspace package {key}"))?; let (org, name) = split_key(key)?; + let member_manifest = read_manifest(member_dir).with_context(|| { + format!( + "reading workspace member `{key}` from {}", + member_dir.display() + ) + })?; + if !allow_ecosystem_mismatch + && let Some(problem) = ecosystem_mismatch( + key, + &name, + member_manifest.package.language, + member_manifest.package.ecosystem(), + &project_ecos, + ) + { + bail!("{problem}"); + } + let dest = modules.join(&org).join(&name); // Workspace dependencies obey the same ownership decision as registry // packages. Copy mode must remain self-contained after the member source // directory leaves the Docker/OCI build context. link_or_copy(member_dir, &dest, mode)?; - if let Ok(member_manifest) = read_manifest(member_dir) { - for (bin_name, rel_target) in &member_manifest.bin { - bins.insert(bin_name.clone(), dest.join(rel_target)); + for (bin_name, rel_target) in &member_manifest.bin { + bins.insert(bin_name.clone(), dest.join(rel_target)); + } + + let dependency_adapter = if use_dependency_adapters { + member_manifest + .install + .adapter + .as_deref() + .map(named_adapter) + .transpose()? + .unwrap_or(adapter) + } else { + adapter + }; + match dependency_adapter { + Adapter::Node => { + used_node_adapter = true; + let node_dest = project + .join("node_modules") + .join(format!("@{org}")) + .join(&name); + transaction.backup(&node_dest)?; + link_or_copy(member_dir, &node_dest, mode)?; + } + Adapter::Java => { + used_java_adapter = true; + for entry in walkdir::WalkDir::new(&dest) + .follow_links(true) + .into_iter() + .filter_map(|entry| entry.ok()) + { + if entry + .path() + .extension() + .is_some_and(|extension| extension == "jar") + { + jars.push(entry.path().to_string_lossy().to_string()); + } + } } + Adapter::Go => wired_roots + .entry(Adapter::Go) + .or_default() + .push(dest.clone()), + Adapter::Python => wired_roots + .entry(Adapter::Python) + .or_default() + .push(dest.clone()), + Adapter::Rust => wired_roots + .entry(Adapter::Rust) + .or_default() + .push(dest.clone()), + Adapter::Dart => wired_roots + .entry(Adapter::Dart) + .or_default() + .push(dest.clone()), + Adapter::Auto | Adapter::None => {} } + wired_packages.push(WiredPackage { + key: key.clone(), + version: "workspace".to_string(), + language: member_manifest.package.language, + ecosystem: member_manifest.package.ecosystem(), + path: dest, + }); installed.push((key.clone(), "workspace".to_string())); } hoist_bins(&modules, &bins, mode)?; @@ -1418,7 +1555,22 @@ pub fn uninstall(project: &Path, cfg: &Config, specs: &[String]) -> Result<()> { let text = fs::read_to_string(&lock_path) .with_context(|| format!("zed uninstall requires {LOCKFILE_FILE}"))?; let lock = Lockfile::parse(&text).with_context(|| format!("invalid {LOCKFILE_FILE}"))?; - if lock.packages.is_empty() { + + // Workspace packages deliberately do not appear in the artifact lock: the + // lock records immutable registry hashes, while workspace members are live + // source projections. Reconstruct the active workspace graph from the same + // manifest boundary used by frozen install so a workspace-only project can + // still uninstall and later restore its exact materialized graph. + let manifest = read_manifest(project).ok(); + let workspace = find_workspace(project); + let workspace_links = match manifest.as_ref() { + Some(manifest) => { + collect_workspace_links_for_frozen(project, manifest, workspace.as_ref())? + } + None => BTreeMap::new(), + }; + let total_materialized = lock.packages.len() + workspace_links.len(); + if total_materialized == 0 { println!("nothing to uninstall"); return Ok(()); } @@ -1426,6 +1578,7 @@ pub fn uninstall(project: &Path, cfg: &Config, specs: &[String]) -> Result<()> { let mut targets = BTreeSet::new(); if specs.is_empty() { targets.extend(lock.packages.iter().map(LockedPackage::full_name)); + targets.extend(workspace_links.keys().cloned()); } else { for spec in specs { if spec.contains('@') { @@ -1433,8 +1586,13 @@ pub fn uninstall(project: &Path, cfg: &Config, specs: &[String]) -> Result<()> { } let (org, name) = split_key(spec)?; let key = format!("{org}/{name}"); + if workspace_links.contains_key(&key) { + bail!( + "selective uninstall of workspace package `{key}` is not supported; run `zed uninstall` without package arguments to remove the complete materialized graph while retaining {LOCKFILE_FILE}" + ); + } if lock.find(&org, &name).is_none() { - bail!("{key} is not pinned by {LOCKFILE_FILE}"); + bail!("{key} is neither pinned by {LOCKFILE_FILE} nor an active workspace package"); } targets.insert(key); } @@ -1451,7 +1609,6 @@ pub fn uninstall(project: &Path, cfg: &Config, specs: &[String]) -> Result<()> { let store = Store::new(&cfg.home); let _install_lock = store.install_lock()?; - let manifest = read_manifest(project).ok(); let modules_dir = manifest .as_ref() .map(|manifest| manifest.modules_dir().to_string()) @@ -1459,7 +1616,7 @@ pub fn uninstall(project: &Path, cfg: &Config, specs: &[String]) -> Result<()> { let modules = project.join(&modules_dir); let had_node_adapter = project.join(".zed").join("node_path").is_file(); let had_java_adapter = project.join(".zed").join("classpath").is_file(); - let uninstall_all = targets.len() == lock.packages.len(); + let uninstall_all = targets.len() == total_materialized; let mut transaction = ProjectTransaction::begin(project)?; eprintln!( @@ -1468,14 +1625,25 @@ pub fn uninstall(project: &Path, cfg: &Config, specs: &[String]) -> Result<()> { ); if uninstall_all { transaction.backup(&modules)?; + // These files are all generated projections of the materialized graph. + // Remove them transactionally on a full uninstall so no toolchain sees + // stale package paths while the retained lock waits for frozen restore. + for generated in [ + "paths.json", + "node_path", + "classpath", + "go.work", + "pythonpath", + "cargo-paths.toml", + "pub-deps.yaml", + ] { + transaction.backup(&project.join(".zed").join(generated))?; + } } else { transaction.backup(&modules.join(BIN_DIR))?; - } - if had_java_adapter { - transaction.backup(&project.join(".zed").join("classpath"))?; - } - if uninstall_all && had_node_adapter { - transaction.backup(&project.join(".zed").join("node_path"))?; + if had_java_adapter { + transaction.backup(&project.join(".zed").join("classpath"))?; + } } for key in &targets { @@ -1499,6 +1667,10 @@ pub fn uninstall(project: &Path, cfg: &Config, specs: &[String]) -> Result<()> { .iter() .filter(|package| !targets.contains(&package.full_name())) .collect(); + let remaining_workspace = workspace_links + .keys() + .filter(|key| !targets.contains(*key)) + .count(); if !uninstall_all { let mut bins: BTreeMap = BTreeMap::new(); let mut mode = InstallMode::Copy; @@ -1531,20 +1703,52 @@ pub fn uninstall(project: &Path, cfg: &Config, specs: &[String]) -> Result<()> { } } } + // Selective uninstall currently targets registry packages only. Keep + // workspace bins and Java entries in the rebuilt aggregate projections. + for key in workspace_links.keys().filter(|key| !targets.contains(*key)) { + let (org, name) = split_key(key)?; + let installed = modules.join(&org).join(&name); + if fs::symlink_metadata(&installed) + .is_ok_and(|metadata| metadata.file_type().is_symlink()) + { + mode = InstallMode::Symlink; + } + if let Ok(package_manifest) = read_manifest(&installed) { + for (bin_name, target) in package_manifest.bin { + bins.insert(bin_name, installed.join(target)); + } + } + if had_java_adapter && installed.exists() { + for entry in walkdir::WalkDir::new(&installed) + .follow_links(true) + .into_iter() + .filter_map(|entry| entry.ok()) + { + if entry + .path() + .extension() + .is_some_and(|extension| extension == "jar") + { + jars.push(entry.path().to_string_lossy().to_string()); + } + } + } + } hoist_bins(&modules, &bins, mode)?; if had_java_adapter && !jars.is_empty() { jars.sort(); + jars.dedup(); let classpath = project.join(".zed").join("classpath"); fs::create_dir_all(classpath.parent().context("classpath parent")?)?; fs::write(classpath, jars.join(":") + "\n")?; } } + let remaining_total = remaining.len() + remaining_workspace; interactive::confirm( cfg.interactive, &format!( - "record {} remaining installed package(s) and commit transaction {}", - remaining.len(), + "record {remaining_total} remaining installed package(s) and commit transaction {}", transaction.id() ), )?; @@ -1561,8 +1765,7 @@ pub fn uninstall(project: &Path, cfg: &Config, specs: &[String]) -> Result<()> { println!("uninstalled {key}"); } println!( - "{} package(s) remain materialized; {LOCKFILE_FILE} retained for frozen reinstall", - remaining.len() + "{remaining_total} package(s) remain materialized; {LOCKFILE_FILE} retained for frozen reinstall" ); Ok(()) } @@ -2661,6 +2864,105 @@ version = "1.0.0" assert!(error.contains("two paths"), "{error}"); } + #[test] + fn frozen_workspace_resolution_expands_transitive_members_and_validates_versions() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path(); + let app = root.join("apps/cli"); + let utils = root.join("packages/utils"); + let core = root.join("packages/core"); + for directory in [&app, &utils, &core] { + fs::create_dir_all(directory).unwrap(); + } + + fs::write( + root.join(MANIFEST_FILE), + r#"[package] +org = "zedtest" +name = "workspace-root" +version = "1.0.0" + +[package.repository] +vcs = "git" +url = "https://example.invalid/workspace-root" + +[workspace] +members = ["packages/*", "apps/*"] +"#, + ) + .unwrap(); + fs::write( + core.join(MANIFEST_FILE), + r#"[package] +org = "zedtest" +name = "ws-core" +version = "1.2.0" + +[package.repository] +vcs = "git" +url = "https://example.invalid/ws-core" +"#, + ) + .unwrap(); + fs::write( + utils.join(MANIFEST_FILE), + r#"[package] +org = "zedtest" +name = "ws-utils" +version = "1.1.0" + +[package.repository] +vcs = "git" +url = "https://example.invalid/ws-utils" + +[dependencies] +"zedtest/ws-core" = "^1" +"#, + ) + .unwrap(); + fs::write( + app.join(MANIFEST_FILE), + r#"[package] +org = "zedtest" +name = "ws-cli" +version = "1.0.0" + +[package.repository] +vcs = "git" +url = "https://example.invalid/ws-cli" + +[dependencies] +"zedtest/ws-utils" = "^1" +"#, + ) + .unwrap(); + + let manifest = read_manifest(&app).unwrap(); + let workspace = find_workspace(&app).unwrap(); + let links = collect_workspace_links_for_frozen(&app, &manifest, Some(&workspace)).unwrap(); + assert_eq!( + links.keys().cloned().collect::>(), + vec![ + "zedtest/ws-core".to_string(), + "zedtest/ws-utils".to_string() + ] + ); + assert_eq!(links["zedtest/ws-core"], core); + assert_eq!(links["zedtest/ws-utils"], utils); + + let incompatible = manifest + .to_toml_string() + .unwrap() + .replace("\"^1\"", "\"^2\""); + fs::write(app.join(MANIFEST_FILE), incompatible).unwrap(); + let manifest = read_manifest(&app).unwrap(); + let error = collect_workspace_links_for_frozen(&app, &manifest, Some(&workspace)) + .unwrap_err() + .to_string(); + assert!(error.contains("ws-utils@1.1.0"), "{error}"); + assert!(error.contains("does not satisfy `^2`"), "{error}"); + } + #[test] fn lock_only_frozen_restore_skips_only_the_missing_manifest_comparison() { let manifest = Manifest::parse(