From 09f2f6c3813721d145537334071ec33e96b82919 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 3 Aug 2026 15:00:58 -0500 Subject: [PATCH 01/11] test: stage workspace adapter and frozen-install implementation --- .github/workspace_install_wiring.py | 355 ++++++++++++++++++++++++++++ 1 file changed, 355 insertions(+) create mode 100644 .github/workspace_install_wiring.py diff --git a/.github/workspace_install_wiring.py b/.github/workspace_install_wiring.py new file mode 100644 index 00000000..31238bf8 --- /dev/null +++ b/.github/workspace_install_wiring.py @@ -0,0 +1,355 @@ +from pathlib import Path + + +path = Path("src/ops.rs") +text = path.read_text(encoding="utf-8") + + +def replace_once(old: str, new: str, label: str) -> None: + global text + if new in text: + return + count = text.count(old) + if count != 1: + raise RuntimeError(f"{label}: expected one match, found {count}") + text = text.replace(old, new, 1) + + +workspace_helper = r'''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) +} + +''' +replace_once( + "// ---------------------------------------------------------------------------\n// install\n", + workspace_helper + "// ---------------------------------------------------------------------------\n// install\n", + "insert frozen workspace resolver", +) + +replace_once( + r''' validate_frozen_manifest_requirements( + &manifest, + &lock, + workspace.as_ref(), + validate_manifest_requirements, + )?; + for locked in &lock.packages { +''', + r''' validate_frozen_manifest_requirements( + &manifest, + &lock, + workspace.as_ref(), + validate_manifest_requirements, + )?; + workspace_links = + collect_workspace_links_for_frozen(project, &manifest, workspace.as_ref())?; + for locked in &lock.packages { +''', + "restore workspace members during frozen installs", +) + +replace_once( + r''' if let Some(ws) = &workspace + && let Some(member_dir) = ws.members.get(&key) + { + 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)); + } + } + } + continue; + } +''', + r''' 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()); + 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; + } +''', + "validate mutable workspace members", +) + +replace_once( + r''' for (key, member_dir) in &workspace_links { + interactive::confirm(cfg.interactive, &format!("link workspace package {key}"))?; + let (org, name) = split_key(key)?; + 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)); + } + } + installed.push((key.clone(), "workspace".to_string())); + } +''', + r''' 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)?; + 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())); + } +''', + "wire workspace packages through native adapters", +) + +replace_once( + r''' #[test] + fn lock_only_frozen_restore_skips_only_the_missing_manifest_comparison() { +''', + r''' #[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() { +''', + "add frozen workspace graph tests", +) + +path.write_text(text, encoding="utf-8") From 9de1d32fde7f3dfa24b8c21fd70908e5535d6030 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 3 Aug 2026 15:01:19 -0500 Subject: [PATCH 02/11] ci: validate and publish workspace install wiring --- .../apply-workspace-install-wiring.yml | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 .github/workflows/apply-workspace-install-wiring.yml diff --git a/.github/workflows/apply-workspace-install-wiring.yml b/.github/workflows/apply-workspace-install-wiring.yml new file mode 100644 index 00000000..9d3b05ee --- /dev/null +++ b/.github/workflows/apply-workspace-install-wiring.yml @@ -0,0 +1,95 @@ +name: apply workspace install wiring + +on: + push: + branches: + - agent/workspace-install-wiring + paths: + - .github/workspace_install_wiring.py + - .github/workflows/apply-workspace-install-wiring.yml + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: apply-workspace-install-wiring + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: "1" + +jobs: + apply-and-validate: + if: github.actor != 'github-actions[bot]' + runs-on: ubuntu-24.04 + timeout-minutes: 45 + permissions: + contents: write + steps: + - name: Check out workspace-wiring branch + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: agent/workspace-install-wiring + path: zed-cli + fetch-depth: 0 + persist-credentials: false + show-progress: false + + - name: Check out pinned zed-interfaces sibling + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: zed-pkg/zed-interfaces + ref: dc0e0a0620b9462817950b552d3d334a184b1cb1 + path: zed-interfaces + fetch-depth: 1 + persist-credentials: false + show-progress: false + + - name: Install stable Rust with formatting and lint support + run: | + set -euo pipefail + rustup toolchain install stable --profile minimal --component rustfmt,clippy + rustup default stable + rustc --version --verbose + cargo --version --verbose + + - name: Apply exact workspace install patch + working-directory: zed-cli + run: | + set -euo pipefail + python3 .github/workspace_install_wiring.py + cargo fmt --all + git diff --check + + - name: Validate workspace and install contracts + run: | + set -euo pipefail + cargo fmt --all --check --manifest-path zed-cli/Cargo.toml + cargo test --locked --manifest-path zed-cli/Cargo.toml --lib \ + ops::tests::frozen_workspace_resolution_expands_transitive_members_and_validates_versions \ + -- --exact --nocapture + cargo test --locked --manifest-path zed-cli/Cargo.toml --lib ops::tests + cargo test --locked --manifest-path zed-cli/Cargo.toml --lib install_graph::tests + cargo test --locked --manifest-path zed-cli/Cargo.toml --lib install_graph::hardening_tests + cargo clippy --locked --manifest-path zed-cli/Cargo.toml --all-targets -- -D warnings + + - name: Publish validated workspace install implementation + working-directory: zed-cli + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + git add src/ops.rs + git diff --cached --check + if git diff --cached --quiet; then + echo 'workspace install implementation is already published' + exit 0 + fi + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git commit -m 'fix: wire and restore workspace packages' + git push \ + "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" \ + HEAD:agent/workspace-install-wiring From 62954bce2db32f939e229b06c03ed6a80c513e16 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 3 Aug 2026 15:02:28 -0500 Subject: [PATCH 03/11] ci: trigger workspace patch validation from the stacked PR --- .github/workflows/apply-workspace-install-wiring.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/apply-workspace-install-wiring.yml b/.github/workflows/apply-workspace-install-wiring.yml index 9d3b05ee..4a42066b 100644 --- a/.github/workflows/apply-workspace-install-wiring.yml +++ b/.github/workflows/apply-workspace-install-wiring.yml @@ -7,13 +7,20 @@ on: paths: - .github/workspace_install_wiring.py - .github/workflows/apply-workspace-install-wiring.yml + pull_request: + branches: + - agent/recursive-resolver-input-hardening + paths: + - .github/workspace_install_wiring.py + - .github/workflows/apply-workspace-install-wiring.yml + - src/ops.rs workflow_dispatch: permissions: contents: read concurrency: - group: apply-workspace-install-wiring + group: apply-workspace-install-wiring-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true env: From 235ef28c24d9b2ae4760b1446b8de9cb26ab27c3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 3 Aug 2026 20:05:16 +0000 Subject: [PATCH 04/11] fix: wire and restore workspace packages --- src/ops.rs | 252 +++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 244 insertions(+), 8 deletions(-) diff --git a/src/ops.rs b/src/ops.rs index 8d4696bf..bab67e96 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)?; @@ -2661,6 +2798,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( From 93e26a64f9b8060b9d104a33f401ebad4e2d5428 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 3 Aug 2026 15:06:20 -0500 Subject: [PATCH 05/11] ci: remove completed workspace install patch bootstrap --- .github/workspace_install_wiring.py | 355 ---------------------------- 1 file changed, 355 deletions(-) delete mode 100644 .github/workspace_install_wiring.py diff --git a/.github/workspace_install_wiring.py b/.github/workspace_install_wiring.py deleted file mode 100644 index 31238bf8..00000000 --- a/.github/workspace_install_wiring.py +++ /dev/null @@ -1,355 +0,0 @@ -from pathlib import Path - - -path = Path("src/ops.rs") -text = path.read_text(encoding="utf-8") - - -def replace_once(old: str, new: str, label: str) -> None: - global text - if new in text: - return - count = text.count(old) - if count != 1: - raise RuntimeError(f"{label}: expected one match, found {count}") - text = text.replace(old, new, 1) - - -workspace_helper = r'''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) -} - -''' -replace_once( - "// ---------------------------------------------------------------------------\n// install\n", - workspace_helper + "// ---------------------------------------------------------------------------\n// install\n", - "insert frozen workspace resolver", -) - -replace_once( - r''' validate_frozen_manifest_requirements( - &manifest, - &lock, - workspace.as_ref(), - validate_manifest_requirements, - )?; - for locked in &lock.packages { -''', - r''' validate_frozen_manifest_requirements( - &manifest, - &lock, - workspace.as_ref(), - validate_manifest_requirements, - )?; - workspace_links = - collect_workspace_links_for_frozen(project, &manifest, workspace.as_ref())?; - for locked in &lock.packages { -''', - "restore workspace members during frozen installs", -) - -replace_once( - r''' if let Some(ws) = &workspace - && let Some(member_dir) = ws.members.get(&key) - { - 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)); - } - } - } - continue; - } -''', - r''' 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()); - 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; - } -''', - "validate mutable workspace members", -) - -replace_once( - r''' for (key, member_dir) in &workspace_links { - interactive::confirm(cfg.interactive, &format!("link workspace package {key}"))?; - let (org, name) = split_key(key)?; - 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)); - } - } - installed.push((key.clone(), "workspace".to_string())); - } -''', - r''' 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)?; - 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())); - } -''', - "wire workspace packages through native adapters", -) - -replace_once( - r''' #[test] - fn lock_only_frozen_restore_skips_only_the_missing_manifest_comparison() { -''', - r''' #[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() { -''', - "add frozen workspace graph tests", -) - -path.write_text(text, encoding="utf-8") From 56e597b35deccc82bff7deee807f459a1def537d Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 3 Aug 2026 15:06:26 -0500 Subject: [PATCH 06/11] ci: remove completed workspace install publishing workflow --- .../apply-workspace-install-wiring.yml | 102 ------------------ 1 file changed, 102 deletions(-) delete mode 100644 .github/workflows/apply-workspace-install-wiring.yml diff --git a/.github/workflows/apply-workspace-install-wiring.yml b/.github/workflows/apply-workspace-install-wiring.yml deleted file mode 100644 index 4a42066b..00000000 --- a/.github/workflows/apply-workspace-install-wiring.yml +++ /dev/null @@ -1,102 +0,0 @@ -name: apply workspace install wiring - -on: - push: - branches: - - agent/workspace-install-wiring - paths: - - .github/workspace_install_wiring.py - - .github/workflows/apply-workspace-install-wiring.yml - pull_request: - branches: - - agent/recursive-resolver-input-hardening - paths: - - .github/workspace_install_wiring.py - - .github/workflows/apply-workspace-install-wiring.yml - - src/ops.rs - workflow_dispatch: - -permissions: - contents: read - -concurrency: - group: apply-workspace-install-wiring-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - -env: - CARGO_TERM_COLOR: always - RUST_BACKTRACE: "1" - -jobs: - apply-and-validate: - if: github.actor != 'github-actions[bot]' - runs-on: ubuntu-24.04 - timeout-minutes: 45 - permissions: - contents: write - steps: - - name: Check out workspace-wiring branch - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: agent/workspace-install-wiring - path: zed-cli - fetch-depth: 0 - persist-credentials: false - show-progress: false - - - name: Check out pinned zed-interfaces sibling - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: zed-pkg/zed-interfaces - ref: dc0e0a0620b9462817950b552d3d334a184b1cb1 - path: zed-interfaces - fetch-depth: 1 - persist-credentials: false - show-progress: false - - - name: Install stable Rust with formatting and lint support - run: | - set -euo pipefail - rustup toolchain install stable --profile minimal --component rustfmt,clippy - rustup default stable - rustc --version --verbose - cargo --version --verbose - - - name: Apply exact workspace install patch - working-directory: zed-cli - run: | - set -euo pipefail - python3 .github/workspace_install_wiring.py - cargo fmt --all - git diff --check - - - name: Validate workspace and install contracts - run: | - set -euo pipefail - cargo fmt --all --check --manifest-path zed-cli/Cargo.toml - cargo test --locked --manifest-path zed-cli/Cargo.toml --lib \ - ops::tests::frozen_workspace_resolution_expands_transitive_members_and_validates_versions \ - -- --exact --nocapture - cargo test --locked --manifest-path zed-cli/Cargo.toml --lib ops::tests - cargo test --locked --manifest-path zed-cli/Cargo.toml --lib install_graph::tests - cargo test --locked --manifest-path zed-cli/Cargo.toml --lib install_graph::hardening_tests - cargo clippy --locked --manifest-path zed-cli/Cargo.toml --all-targets -- -D warnings - - - name: Publish validated workspace install implementation - working-directory: zed-cli - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - git add src/ops.rs - git diff --cached --check - if git diff --cached --quiet; then - echo 'workspace install implementation is already published' - exit 0 - fi - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git commit -m 'fix: wire and restore workspace packages' - git push \ - "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" \ - HEAD:agent/workspace-install-wiring From 3bbf82d79d25f0fcffc2b82a7ac45169605d8969 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 3 Aug 2026 15:26:54 -0500 Subject: [PATCH 07/11] test: stage workspace-aware uninstall and wiring cleanup --- .github/workspace_uninstall_fix.py | 231 +++++++++++++++++++++++++++++ 1 file changed, 231 insertions(+) create mode 100644 .github/workspace_uninstall_fix.py diff --git a/.github/workspace_uninstall_fix.py b/.github/workspace_uninstall_fix.py new file mode 100644 index 00000000..9d77e47b --- /dev/null +++ b/.github/workspace_uninstall_fix.py @@ -0,0 +1,231 @@ +from pathlib import Path + + +path = Path("src/ops.rs") +text = path.read_text(encoding="utf-8") +start = text.index("pub fn uninstall(project: &Path, cfg: &Config, specs: &[String]) -> Result<()> {") +end = text.index("\n// ---------------------------------------------------------------------------\n// build hooks", start) + +replacement = r'''pub fn uninstall(project: &Path, cfg: &Config, specs: &[String]) -> Result<()> { + let lock_path = project.join(LOCKFILE_FILE); + 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}"))?; + + // 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(()); + } + + 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('@') { + bail!("uninstall accepts package identities without versions (expected org/name)"); + } + 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 neither pinned by {LOCKFILE_FILE} nor an active workspace package"); + } + targets.insert(key); + } + } + + interactive::confirm( + cfg.interactive, + &format!( + "uninstall {} package(s) from {} while retaining {LOCKFILE_FILE}", + targets.len(), + project.display() + ), + )?; + + let store = Store::new(&cfg.home); + let _install_lock = store.install_lock()?; + let modules_dir = manifest + .as_ref() + .map(|manifest| manifest.modules_dir().to_string()) + .unwrap_or_else(|| MODULES_DIR.to_string()); + 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() == total_materialized; + + let mut transaction = ProjectTransaction::begin(project)?; + eprintln!( + "transaction {}: staging uninstall rollback data", + transaction.id() + ); + 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"))?; + } + } + + for key in &targets { + interactive::confirm(cfg.interactive, &format!("unmaterialize {key}"))?; + let (org, name) = split_key(key)?; + if !uninstall_all { + transaction.backup(&modules.join(&org).join(&name))?; + } + if had_node_adapter { + transaction.backup( + &project + .join("node_modules") + .join(format!("@{org}")) + .join(&name), + )?; + } + } + + let remaining: Vec<&LockedPackage> = lock + .packages + .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; + let mut jars = Vec::new(); + for package in &remaining { + let installed = modules.join(&package.org).join(&package.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 (name, target) in package_manifest.bin { + bins.insert(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()); + } + } + } + } + // 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_total} remaining installed package(s) and commit transaction {}", + transaction.id() + ), + )?; + store.record_project( + project, + remaining + .iter() + .map(|package| package.sha256.clone()) + .collect(), + )?; + transaction.commit()?; + + for key in &targets { + println!("uninstalled {key}"); + } + println!( + "{remaining_total} package(s) remain materialized; {LOCKFILE_FILE} retained for frozen reinstall" + ); + Ok(()) +} +''' + +text = text[:start] + replacement + text[end:] +path.write_text(text, encoding="utf-8") From c96abd018413f4a338346fccf506545fab6b1f44 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 3 Aug 2026 15:27:11 -0500 Subject: [PATCH 08/11] ci: validate workspace uninstall and adapter cleanup --- .../workflows/apply-workspace-uninstall.yml | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 .github/workflows/apply-workspace-uninstall.yml diff --git a/.github/workflows/apply-workspace-uninstall.yml b/.github/workflows/apply-workspace-uninstall.yml new file mode 100644 index 00000000..d5caf088 --- /dev/null +++ b/.github/workflows/apply-workspace-uninstall.yml @@ -0,0 +1,99 @@ +name: apply workspace uninstall lifecycle + +on: + pull_request: + branches: + - agent/recursive-resolver-input-hardening + paths: + - .github/workspace_uninstall_fix.py + - .github/workflows/apply-workspace-uninstall.yml + - src/ops.rs + push: + branches: + - agent/workspace-install-wiring + paths: + - .github/workspace_uninstall_fix.py + - .github/workflows/apply-workspace-uninstall.yml + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: apply-workspace-uninstall-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: "1" + +jobs: + apply-and-validate: + if: github.actor != 'github-actions[bot]' + runs-on: ubuntu-24.04 + timeout-minutes: 45 + permissions: + contents: write + steps: + - name: Check out workspace install branch + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: agent/workspace-install-wiring + path: zed-cli + fetch-depth: 0 + persist-credentials: false + show-progress: false + + - name: Check out pinned zed-interfaces sibling + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: zed-pkg/zed-interfaces + ref: dc0e0a0620b9462817950b552d3d334a184b1cb1 + path: zed-interfaces + fetch-depth: 1 + persist-credentials: false + show-progress: false + + - name: Install stable Rust with formatting and lint support + run: | + set -euo pipefail + rustup toolchain install stable --profile minimal --component rustfmt,clippy + rustup default stable + rustc --version --verbose + cargo --version --verbose + + - name: Apply exact workspace uninstall patch + working-directory: zed-cli + run: | + set -euo pipefail + python3 .github/workspace_uninstall_fix.py + cargo fmt --all + git diff --check + + - name: Validate uninstall and install contracts + run: | + set -euo pipefail + cargo fmt --all --check --manifest-path zed-cli/Cargo.toml + cargo test --locked --manifest-path zed-cli/Cargo.toml --lib ops::tests + cargo test --locked --manifest-path zed-cli/Cargo.toml --lib install_graph::tests + cargo test --locked --manifest-path zed-cli/Cargo.toml --lib install_graph::hardening_tests + cargo clippy --locked --manifest-path zed-cli/Cargo.toml --all-targets -- -D warnings + + - name: Publish validated workspace uninstall implementation + working-directory: zed-cli + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + git add src/ops.rs + git diff --cached --check + if git diff --cached --quiet; then + echo 'workspace uninstall implementation is already published' + exit 0 + fi + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git commit -m 'fix: uninstall workspace projections transactionally' + git push \ + "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" \ + HEAD:agent/workspace-install-wiring From 29df5905a151d797872b9a3731b5a8a48de65e67 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 3 Aug 2026 20:28:51 +0000 Subject: [PATCH 09/11] fix: uninstall workspace projections transactionally --- src/ops.rs | 94 ++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 80 insertions(+), 14 deletions(-) diff --git a/src/ops.rs b/src/ops.rs index bab67e96..acb8b105 100644 --- a/src/ops.rs +++ b/src/ops.rs @@ -1555,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(()); } @@ -1563,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('@') { @@ -1570,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); } @@ -1588,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()) @@ -1596,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!( @@ -1605,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 { @@ -1636,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; @@ -1668,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() ), )?; @@ -1698,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(()) } From 678dbb521c489027f86d1747c8094b09c76d7ce6 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 3 Aug 2026 15:31:18 -0500 Subject: [PATCH 10/11] ci: remove completed workspace uninstall patch helper --- .github/workspace_uninstall_fix.py | 231 ----------------------------- 1 file changed, 231 deletions(-) delete mode 100644 .github/workspace_uninstall_fix.py diff --git a/.github/workspace_uninstall_fix.py b/.github/workspace_uninstall_fix.py deleted file mode 100644 index 9d77e47b..00000000 --- a/.github/workspace_uninstall_fix.py +++ /dev/null @@ -1,231 +0,0 @@ -from pathlib import Path - - -path = Path("src/ops.rs") -text = path.read_text(encoding="utf-8") -start = text.index("pub fn uninstall(project: &Path, cfg: &Config, specs: &[String]) -> Result<()> {") -end = text.index("\n// ---------------------------------------------------------------------------\n// build hooks", start) - -replacement = r'''pub fn uninstall(project: &Path, cfg: &Config, specs: &[String]) -> Result<()> { - let lock_path = project.join(LOCKFILE_FILE); - 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}"))?; - - // 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(()); - } - - 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('@') { - bail!("uninstall accepts package identities without versions (expected org/name)"); - } - 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 neither pinned by {LOCKFILE_FILE} nor an active workspace package"); - } - targets.insert(key); - } - } - - interactive::confirm( - cfg.interactive, - &format!( - "uninstall {} package(s) from {} while retaining {LOCKFILE_FILE}", - targets.len(), - project.display() - ), - )?; - - let store = Store::new(&cfg.home); - let _install_lock = store.install_lock()?; - let modules_dir = manifest - .as_ref() - .map(|manifest| manifest.modules_dir().to_string()) - .unwrap_or_else(|| MODULES_DIR.to_string()); - 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() == total_materialized; - - let mut transaction = ProjectTransaction::begin(project)?; - eprintln!( - "transaction {}: staging uninstall rollback data", - transaction.id() - ); - 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"))?; - } - } - - for key in &targets { - interactive::confirm(cfg.interactive, &format!("unmaterialize {key}"))?; - let (org, name) = split_key(key)?; - if !uninstall_all { - transaction.backup(&modules.join(&org).join(&name))?; - } - if had_node_adapter { - transaction.backup( - &project - .join("node_modules") - .join(format!("@{org}")) - .join(&name), - )?; - } - } - - let remaining: Vec<&LockedPackage> = lock - .packages - .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; - let mut jars = Vec::new(); - for package in &remaining { - let installed = modules.join(&package.org).join(&package.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 (name, target) in package_manifest.bin { - bins.insert(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()); - } - } - } - } - // 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_total} remaining installed package(s) and commit transaction {}", - transaction.id() - ), - )?; - store.record_project( - project, - remaining - .iter() - .map(|package| package.sha256.clone()) - .collect(), - )?; - transaction.commit()?; - - for key in &targets { - println!("uninstalled {key}"); - } - println!( - "{remaining_total} package(s) remain materialized; {LOCKFILE_FILE} retained for frozen reinstall" - ); - Ok(()) -} -''' - -text = text[:start] + replacement + text[end:] -path.write_text(text, encoding="utf-8") From 8cc6745c75fd46dabf2e383cbcfc14db5ff933da Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 3 Aug 2026 15:31:24 -0500 Subject: [PATCH 11/11] ci: remove completed workspace uninstall publishing workflow --- .../workflows/apply-workspace-uninstall.yml | 99 ------------------- 1 file changed, 99 deletions(-) delete mode 100644 .github/workflows/apply-workspace-uninstall.yml diff --git a/.github/workflows/apply-workspace-uninstall.yml b/.github/workflows/apply-workspace-uninstall.yml deleted file mode 100644 index d5caf088..00000000 --- a/.github/workflows/apply-workspace-uninstall.yml +++ /dev/null @@ -1,99 +0,0 @@ -name: apply workspace uninstall lifecycle - -on: - pull_request: - branches: - - agent/recursive-resolver-input-hardening - paths: - - .github/workspace_uninstall_fix.py - - .github/workflows/apply-workspace-uninstall.yml - - src/ops.rs - push: - branches: - - agent/workspace-install-wiring - paths: - - .github/workspace_uninstall_fix.py - - .github/workflows/apply-workspace-uninstall.yml - workflow_dispatch: - -permissions: - contents: read - -concurrency: - group: apply-workspace-uninstall-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - -env: - CARGO_TERM_COLOR: always - RUST_BACKTRACE: "1" - -jobs: - apply-and-validate: - if: github.actor != 'github-actions[bot]' - runs-on: ubuntu-24.04 - timeout-minutes: 45 - permissions: - contents: write - steps: - - name: Check out workspace install branch - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: agent/workspace-install-wiring - path: zed-cli - fetch-depth: 0 - persist-credentials: false - show-progress: false - - - name: Check out pinned zed-interfaces sibling - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: zed-pkg/zed-interfaces - ref: dc0e0a0620b9462817950b552d3d334a184b1cb1 - path: zed-interfaces - fetch-depth: 1 - persist-credentials: false - show-progress: false - - - name: Install stable Rust with formatting and lint support - run: | - set -euo pipefail - rustup toolchain install stable --profile minimal --component rustfmt,clippy - rustup default stable - rustc --version --verbose - cargo --version --verbose - - - name: Apply exact workspace uninstall patch - working-directory: zed-cli - run: | - set -euo pipefail - python3 .github/workspace_uninstall_fix.py - cargo fmt --all - git diff --check - - - name: Validate uninstall and install contracts - run: | - set -euo pipefail - cargo fmt --all --check --manifest-path zed-cli/Cargo.toml - cargo test --locked --manifest-path zed-cli/Cargo.toml --lib ops::tests - cargo test --locked --manifest-path zed-cli/Cargo.toml --lib install_graph::tests - cargo test --locked --manifest-path zed-cli/Cargo.toml --lib install_graph::hardening_tests - cargo clippy --locked --manifest-path zed-cli/Cargo.toml --all-targets -- -D warnings - - - name: Publish validated workspace uninstall implementation - working-directory: zed-cli - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - git add src/ops.rs - git diff --cached --check - if git diff --cached --quiet; then - echo 'workspace uninstall implementation is already published' - exit 0 - fi - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git commit -m 'fix: uninstall workspace projections transactionally' - git push \ - "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" \ - HEAD:agent/workspace-install-wiring