@@ -35,17 +35,17 @@ use crate::ui::{self, plural, print_json, StatusLine};
3535
3636use super :: get:: { download_and_apply_patches_with, DownloadParams , DownloadRun } ;
3737
38- pub use self :: socket_yml_args:: { SocketYmlArgs , MIN_SEVERITY_ENV } ;
3938use self :: policy:: { load_invocation_policy, InvocationPolicy , PolicyLoadError , ScanPolicy } ;
39+ pub use self :: socket_yml_args:: { SocketYmlArgs , MIN_SEVERITY_ENV } ;
4040
4141mod discovery;
4242mod gc;
4343pub ( crate ) mod hosted;
4444pub ( crate ) mod policy;
45- mod socket_yml_args;
4645pub ( crate ) mod render;
4746pub ( crate ) mod rollout;
4847pub mod rollout_args;
48+ mod socket_yml_args;
4949pub ( crate ) mod vendor_flow;
5050
5151use self :: discovery:: {
@@ -65,13 +65,13 @@ use self::gc::gc_json;
6565pub ( crate ) use self :: hosted:: boxed_run_redirect_selected;
6666use self :: hosted:: run_redirect;
6767pub ( crate ) use self :: hosted:: { vlt_rollback_heal, vlt_takeover_heal} ;
68- pub ( crate ) use self :: vendor_flow:: {
69- boxed_vendor_step, preview_vendor_json, print_dry_run_refusals, VendorStep ,
70- } ;
7168use self :: vendor_flow:: {
7269 boxed_vendor_interactive_path, boxed_vendor_json_path, fold_vendored_skips_into_apply,
7370 partition_skipped_selected,
7471} ;
72+ pub ( crate ) use self :: vendor_flow:: {
73+ boxed_vendor_step, preview_vendor_json, print_dry_run_refusals, VendorStep ,
74+ } ;
7575
7676/// Packages per batch request on the authenticated API when `--batch-size`
7777/// is not given: the server's own per-request maximum
@@ -318,11 +318,7 @@ pub struct ScanArgs {
318318 /// `requests`), or a purl with or without its version
319319 /// (`pkg:npm/lodash`, `pkg:pypi/requests@2.31.0`). Repeat the flag or
320320 /// separate with commas
321- #[ arg(
322- long = "package" ,
323- env = "SOCKET_SCAN_PACKAGES" ,
324- value_delimiter = ','
325- ) ]
321+ #[ arg( long = "package" , env = "SOCKET_SCAN_PACKAGES" , value_delimiter = ',' ) ]
326322 pub packages : Vec < String > ,
327323
328324 /// On a successful scan, also generate an OpenVEX 0.2.0 document.
@@ -500,9 +496,10 @@ async fn discover_selected(
500496 telemetry. flush ( ) . await ;
501497 let error_count = failures. len ( ) ;
502498 if error_count > 0 && error_count == packages. len ( ) {
503- let err = failures
504- . last ( )
505- . map_or_else ( || "all patch-detail queries failed" . to_string ( ) , |( _, e) | e. clone ( ) ) ;
499+ let err = failures. last ( ) . map_or_else (
500+ || "all patch-detail queries failed" . to_string ( ) ,
501+ |( _, e) | e. clone ( ) ,
502+ ) ;
506503 let message = format ! ( "all {error_count} patch-detail queries failed: {err}" ) ;
507504 if detail_error_line {
508505 eprintln ! ( "{}" , render:: fetch_details_failed( & failures) ) ;
@@ -568,7 +565,11 @@ fn classified_rows(
568565 packages : & [ BatchPackagePatches ] ,
569566 result : Option < & mut serde_json:: Value > ,
570567) -> Vec < rollout:: Row > {
571- let failed: Vec < String > = discovered. failed . iter ( ) . map ( |( purl, _) | purl. clone ( ) ) . collect ( ) ;
568+ let failed: Vec < String > = discovered
569+ . failed
570+ . iter ( )
571+ . map ( |( purl, _) | purl. clone ( ) )
572+ . collect ( ) ;
572573 stage. incomplete = rollout:: lookup_incomplete ( & recorded. index , & failed, batch_failed) ;
573574 let rows = rollout:: classify ( & discovered. offers , & recorded. index , & stage. project ) ;
574575 if let Some ( result) = result {
@@ -1317,7 +1318,8 @@ fn project_dirs(cwd: &Path, paths: &[String]) -> Result<Vec<(PathBuf, bool)>, St
13171318 let joined = cwd. join ( raw) ;
13181319 if raw. contains ( [ '*' , '?' , '[' ] ) {
13191320 let pattern = joined. to_string_lossy ( ) . into_owned ( ) ;
1320- let matches = glob:: glob ( & pattern) . map_err ( |e| format ! ( "invalid path pattern `{raw}`: {e}" ) ) ?;
1321+ let matches =
1322+ glob:: glob ( & pattern) . map_err ( |e| format ! ( "invalid path pattern `{raw}`: {e}" ) ) ?;
13211323 let before = dirs. len ( ) ;
13221324 dirs. extend (
13231325 matches
@@ -1390,7 +1392,10 @@ async fn run_project_dirs(
13901392 }
13911393 // One budget per invocation (§5.2): the directories spend it in sorted
13921394 // order, and a package admitted in one is admitted free in the next.
1393- let configured = match args. rollout . resolve_from_env ( invocation. policy . max_new_patches ( ) ) {
1395+ let configured = match args
1396+ . rollout
1397+ . resolve_from_env ( invocation. policy . max_new_patches ( ) )
1398+ {
13941399 Ok ( max) => max,
13951400 Err ( message) => {
13961401 eprintln ! ( "Error: {message}" ) ;
@@ -1491,19 +1496,19 @@ async fn run_scan(
14911496 // error.
14921497 let configured_cap = match args. rollout . carry . as_ref ( ) {
14931498 Some ( carry) => carry. lock ( ) . configured ,
1494- None => match args. rollout . resolve_from_env ( invocation. policy . max_new_patches ( ) ) {
1499+ None => match args
1500+ . rollout
1501+ . resolve_from_env ( invocation. policy . max_new_patches ( ) )
1502+ {
14951503 Ok ( max) => max,
14961504 Err ( message) => {
14971505 eprintln ! ( "Error: {message}" ) ;
14981506 return 2 ;
14991507 }
15001508 } ,
15011509 } ;
1502- let mut stage = rollout:: Stage :: new (
1503- configured_cap,
1504- args. rollout . carry . clone ( ) ,
1505- & args. common . cwd ,
1506- ) ;
1510+ let mut stage =
1511+ rollout:: Stage :: new ( configured_cap, args. rollout . carry . clone ( ) , & args. common . cwd ) ;
15071512
15081513 // Strict airgap (CLI_CONTRACT.md `--offline`): scan's patch discovery
15091514 // is remote data, so refuse before the crawl and before the API client
@@ -1704,8 +1709,11 @@ async fn run_scan(
17041709 . filter ( |pkg| args. common . purl_ecosystem_selected ( & pkg. purl ) )
17051710 . collect ( ) ;
17061711
1707- let package_specs: Vec < & String > =
1708- args. packages . iter ( ) . filter ( |s| !s. trim ( ) . is_empty ( ) ) . collect ( ) ;
1712+ let package_specs: Vec < & String > = args
1713+ . packages
1714+ . iter ( )
1715+ . filter ( |s| !s. trim ( ) . is_empty ( ) )
1716+ . collect ( ) ;
17091717 let filtered_crawled: Vec < _ > = if package_specs. is_empty ( ) {
17101718 filtered_crawled
17111719 } else {
@@ -1860,13 +1868,12 @@ async fn run_scan(
18601868 // `redirectState` rides the empty-discovery envelope too
18611869 // (same rule as the ≥1-package path). `wiringLive` is empty
18621870 // by construction: this run covered zero packages.
1863- let redirect_state = ( !args . common . is_global ( ) ) . then_some (
1864- crate :: commands:: hosted_state_from_pins (
1871+ let redirect_state =
1872+ ( !args . common . is_global ( ) ) . then_some ( crate :: commands:: hosted_state_from_pins (
18651873 & socket_patch_core:: patch:: redirect:: upstream:: HostedPin :: all (
18661874 ctx. discovery ( ) . await ,
18671875 ) ,
1868- ) ,
1869- ) ;
1876+ ) ) ;
18701877 if let Some ( state) = redirect_state_json ( redirect_state. as_ref ( ) , & [ ] ) {
18711878 result[ "redirectState" ] = state;
18721879 }
@@ -2222,7 +2229,8 @@ async fn run_scan(
22222229 // A report-only run selects nothing, but a severity floor or
22232230 // `enabled: false` still hides candidates; report them like the
22242231 // human arm does (the detail fetch runs only then).
2225- if !apply && !vendor && policy. reports_selection ( ) && !all_packages_with_patches. is_empty ( ) {
2232+ if !apply && !vendor && policy. reports_selection ( ) && !all_packages_with_patches. is_empty ( )
2233+ {
22262234 if let Err ( ( code, message) ) = discover_selected (
22272235 & api_client,
22282236 & all_packages_with_patches,
@@ -2515,12 +2523,7 @@ async fn run_scan(
25152523 & all_packages_with_patches,
25162524 None ,
25172525 ) ;
2518- updates = offer_updates (
2519- & rows,
2520- & discovered,
2521- & recorded,
2522- & all_packages_with_patches,
2523- ) ;
2526+ updates = offer_updates ( & rows, & discovered, & recorded, & all_packages_with_patches) ;
25242527 rows
25252528 }
25262529 // `discover_selected` already printed the failure to stderr.
@@ -2730,8 +2733,11 @@ async fn run_scan(
27302733 plan_kept_rows ( & mut stage, rows, selected)
27312734 } ;
27322735
2733- // Drop selections the manifest already records at the same uuid.
2734- // Agent mode only: vendored mode never reads the manifest.
2736+ // Set aside selections the manifest already records at the same uuid.
2737+ // A wet agent run still hands them to the download step below, whose
2738+ // nested apply re-applies them after a reinstall (#454, #732); a
2739+ // preview only names them. Agent mode only: vendored mode never reads
2740+ // the manifest.
27352741 let recorded = |p : & PatchSearchResult | {
27362742 existing_manifest
27372743 . as_ref ( )
@@ -2743,17 +2749,18 @@ async fn run_scan(
27432749 } else {
27442750 selected. into_iter ( ) . partition ( |p| recorded ( p) )
27452751 } ;
2752+ let reapply = !report_only && !args. common . dry_run ;
27462753 if !silent {
27472754 for p in & already_recorded {
27482755 open_paragraph ( & mut skip_paragraph) ;
27492756 println ! (
27502757 "{}" ,
2751- render:: already_recorded_line( & normalize_purl( & p. purl) , & p. uuid)
2758+ render:: already_recorded_line( & normalize_purl( & p. purl) , & p. uuid, reapply )
27522759 ) ;
27532760 }
27542761 }
27552762
2756- if selected. is_empty ( ) {
2763+ if selected. is_empty ( ) && ( !reapply || already_recorded . is_empty ( ) ) {
27572764 if !silent {
27582765 open_paragraph ( & mut skip_paragraph) ;
27592766 if !stage. deferred_keys ( ) . is_empty ( ) {
@@ -2764,6 +2771,8 @@ async fn run_scan(
27642771 }
27652772 } else if already_recorded. is_empty ( ) {
27662773 println ! ( "No patches selected." ) ;
2774+ } else if args. common . dry_run && !report_only {
2775+ println ! ( "{}" , render:: ALL_ALREADY_RECORDED_DRY_RUN ) ;
27672776 } else {
27682777 println ! ( "{}" , render:: ALL_ALREADY_RECORDED ) ;
27692778 }
@@ -2773,7 +2782,7 @@ async fn run_scan(
27732782 }
27742783
27752784 // Display detailed summary of selected patches (skipped under --silent).
2776- if !silent {
2785+ if !silent && !selected . is_empty ( ) {
27772786 if vendor {
27782787 println ! ( "\n Patches to vendor:\n " ) ;
27792788 } else {
@@ -2927,9 +2936,16 @@ async fn run_scan(
29272936 )
29282937 . await
29292938 } else {
2930- let ( code, _) =
2931- download_and_apply_patches_with ( & selected, & params, & download_run ( & args, & api_client) )
2932- . await ;
2939+ // The recorded selections ride along: the fetch loop skips their
2940+ // record and the nested apply re-applies them (a no-op on disk
2941+ // when the installed copy is still patched).
2942+ let to_download: Vec < _ > = selected. iter ( ) . chain ( & already_recorded) . cloned ( ) . collect ( ) ;
2943+ let ( code, _) = download_and_apply_patches_with (
2944+ & to_download,
2945+ & params,
2946+ & download_run ( & args, & api_client) ,
2947+ )
2948+ . await ;
29332949 code
29342950 } ;
29352951
@@ -2982,14 +2998,20 @@ mod tests {
29822998 dirs. iter ( )
29832999 . map ( |( d, explicit) | {
29843000 (
2985- d. strip_prefix ( tmp. path ( ) ) . unwrap ( ) . to_string_lossy ( ) . replace ( '\\' , "/" ) ,
3001+ d. strip_prefix ( tmp. path ( ) )
3002+ . unwrap ( )
3003+ . to_string_lossy ( )
3004+ . replace ( '\\' , "/" ) ,
29863005 * explicit,
29873006 )
29883007 } )
29893008 . collect ( )
29903009 } ;
2991- let got = project_dirs ( tmp. path ( ) , & [ "apps/*" . into ( ) , "libs/core" . into ( ) , "apps/web" . into ( ) ] )
2992- . unwrap ( ) ;
3010+ let got = project_dirs (
3011+ tmp. path ( ) ,
3012+ & [ "apps/*" . into ( ) , "libs/core" . into ( ) , "apps/web" . into ( ) ] ,
3013+ )
3014+ . unwrap ( ) ;
29933015 // Named literally = explicit (also when a glob matches it too).
29943016 assert_eq ! (
29953017 rel( got) ,
0 commit comments