From b56ebe297ef341802983b95b45275934fa84191f Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Fri, 9 Oct 2026 12:45:18 -0400 Subject: [PATCH 1/2] Run vendored --prune GC on human early exits Co-Authored-By: Claude Opus 5.5 (1M context) From e4187802827e24b75f99fef4d9a104c1eef35f16 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Fri, 9 Oct 2026 12:55:04 -0400 Subject: [PATCH 2/2] Run vendored --prune GC on human early exits Human `scan --mode vendored --prune` skipped its GC whenever the crawl found packages but none had a patch (or nothing was selected, or the run was paid-only / --dry-run): those exits go through finish_human, whose GC excluded vendored mode. An npm-uninstalled (or Pipenv-removed) vendored entry was never reverted, the run exited 0, and `vendor --check` kept pointing at the same command. The JSON arm already ran the GC. Drop the exclusion; the wet vendor step runs its own GC and never reaches finish_human, so nothing runs twice. Fixes #1127 Co-Authored-By: Claude Opus 5.5 (1M context) --- .../socket-patch-cli/src/commands/scan/mod.rs | 9 ++- .../tests/scan_vendor_requirements_unwired.rs | 72 +++++++++++++++++++ 2 files changed, 78 insertions(+), 3 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/scan/mod.rs b/crates/socket-patch-cli/src/commands/scan/mod.rs index a3d5b08e6..f47b1378f 100644 --- a/crates/socket-patch-cli/src/commands/scan/mod.rs +++ b/crates/socket-patch-cli/src/commands/scan/mod.rs @@ -2791,15 +2791,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}"); +}