diff --git a/crates/socket-patch-cli/src/commands/scan/mod.rs b/crates/socket-patch-cli/src/commands/scan/mod.rs index b02e9cfdd..ab4d6e219 100644 --- a/crates/socket-patch-cli/src/commands/scan/mod.rs +++ b/crates/socket-patch-cli/src/commands/scan/mod.rs @@ -2790,15 +2790,18 @@ async fn run_scan( let silent = args.common.silent; // Every human-path exit that did not fail: the `--prune` GC first - // (not vendored, which runs its own, nor hosted, which runs none), then - // the embedded VEX. An early "nothing to apply" exit still runs the GC. + // (not hosted, which runs none), then the embedded VEX. An early + // "nothing to apply" exit still runs the GC, vendored mode included: + // its wet vendor step runs its own GC and never reaches this closure, + // but the early exits (nothing patched, paid-only, nothing selected, + // `--dry-run`) must reconcile the ledger like the JSON arm (#1127). let (args_ref, manifest_ref, socket_ref) = (&args, &manifest_path, &socket_dir); let client_ref: &ApiClient = &api_client; let (scanned_ref, vendored_ref) = (&scanned_purls, &vendored_purls); let policy_ref: &ScanPolicy = &policy; let finish_human = move |code: i32| async move { policy_ref.print_human(silent, verbose); - if prune && !vendor && !hosted && code == 0 { + if prune && !hosted && code == 0 { gc::run_human_gc( &args_ref.common, manifest_ref, diff --git a/crates/socket-patch-cli/tests/scan_vendor_requirements_unwired.rs b/crates/socket-patch-cli/tests/scan_vendor_requirements_unwired.rs index 6f4645676..ad9fe9148 100644 --- a/crates/socket-patch-cli/tests/scan_vendor_requirements_unwired.rs +++ b/crates/socket-patch-cli/tests/scan_vendor_requirements_unwired.rs @@ -221,3 +221,75 @@ async fn wired_vendored_pin_stays_discoverable() { wired ); } + +/// #1127: the human `--prune` run reverts an unwired entry too, when the +/// crawl found packages but none of them has a patch. Its early "No patches +/// available" exit used to skip the GC in vendored mode, while `--json` +/// ran it. The installed npm package makes sure the crawl is non-empty +/// (an empty crawl takes the vendored-only GC, which already worked). +#[tokio::test] +async fn human_prune_reverts_unwired_entry_when_no_package_is_patched() { + let mock = empty_patch_api().await; + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + seed_vendored(root); + std::fs::write(root.join("requirements.txt"), "idna==3.7\n").unwrap(); + std::fs::write( + root.join("package.json"), + r#"{ "name": "p", "version": "1.0.0", "dependencies": { "ms": "2.1.3" } }"#, + ) + .unwrap(); + let ms = root.join("node_modules/ms"); + std::fs::create_dir_all(&ms).unwrap(); + std::fs::write( + ms.join("package.json"), + r#"{ "name": "ms", "version": "2.1.3" }"#, + ) + .unwrap(); + + let out = Command::new(env!("CARGO_BIN_EXE_socket-patch")) + .args([ + "scan", + "--mode", + "vendored", + "--prune", + "--yes", + "--api-url", + &mock.uri(), + "--api-token", + "fake-token", + "--org", + ORG_SLUG, + "--vendor-url", + &mock.uri(), + "--patch-server-url", + &mock.uri(), + ]) + .current_dir(root) + .env("SOCKET_TELEMETRY_DISABLED", "1") + .env_remove("VIRTUAL_ENV") + .env_remove("CONDA_PREFIX") + .output() + .expect("run socket-patch"); + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + assert_eq!( + out.status.code(), + Some(0), + "stdout={stdout}; stderr={stderr}" + ); + assert!( + stdout.contains("No patches available for installed packages."), + "the run must take the found-but-unpatched exit: stdout={stdout}" + ); + assert!( + stdout.contains("GC: reverted 1 vendored entry"), + "the human run must report the vendored GC: stdout={stdout}; stderr={stderr}" + ); + assert!( + !root.join(format!(".socket/vendor/pypi/{UUID}")).exists(), + "the dead uuid dir is reclaimed: stdout={stdout}" + ); + let state = std::fs::read_to_string(root.join(".socket/vendor/state.json")).unwrap_or_default(); + assert!(!state.contains(PURL), "{state}"); +}