Skip to content

Commit e99337f

Browse files
Fix human scan skipping re-apply of recorded patches (#732) (#733)
* Start fix for #732 Assisted-by: Claude Code:claude-opus-5-5 * Re-apply recorded patches in human scan output After a reinstall put pristine bytes back, `scan --mode agent` or `scan --sync` without `--json` printed "[skip] ... (already recorded)" and exited 0 while leaving the package unpatched. Only the `--json` path re-applied (#454). The human path now hands recorded selections to the same download step, whose nested apply re-applies them; they are listed as `[re-apply]`. `--dry-run` and report-only scans still change nothing and say what a wet run would do. Fixes #732 Assisted-by: Claude Code:claude-opus-5-5 * Simplify the all-recorded early-return check Clippy's nonminimal_bool lint rejected the negated condition. Assisted-by: Claude Code:claude-opus-5-5 * Keep apply advice for report-only dry runs `scan --prune --dry-run` with every selection already recorded said a run without --dry-run would re-apply them, but a report-only scan never applies, so following that advice changed nothing. It now points at `socket-patch apply` again; only an agent-mode dry run promises the re-apply. Refs #732 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EPsXDSe1x9i96swon5nxdE * Drop workspace-wide rustfmt churn from the #732 fix The fix commit ran cargo fmt over the whole workspace, reformatting 127 files the change does not touch. That noise hides the real diff from reviewers and conflicts with every other open PR editing those files. Restore them to main; the fix itself (scan/mod.rs, render.rs and its tests) is unchanged. Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c3399d1 commit e99337f

3 files changed

Lines changed: 286 additions & 60 deletions

File tree

‎crates/socket-patch-cli/src/commands/scan/mod.rs‎

Lines changed: 68 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -35,17 +35,17 @@ use crate::ui::{self, plural, print_json, StatusLine};
3535

3636
use super::get::{download_and_apply_patches_with, DownloadParams, DownloadRun};
3737

38-
pub use self::socket_yml_args::{SocketYmlArgs, MIN_SEVERITY_ENV};
3938
use self::policy::{load_invocation_policy, InvocationPolicy, PolicyLoadError, ScanPolicy};
39+
pub use self::socket_yml_args::{SocketYmlArgs, MIN_SEVERITY_ENV};
4040

4141
mod discovery;
4242
mod gc;
4343
pub(crate) mod hosted;
4444
pub(crate) mod policy;
45-
mod socket_yml_args;
4645
pub(crate) mod render;
4746
pub(crate) mod rollout;
4847
pub mod rollout_args;
48+
mod socket_yml_args;
4949
pub(crate) mod vendor_flow;
5050

5151
use self::discovery::{
@@ -65,13 +65,13 @@ use self::gc::gc_json;
6565
pub(crate) use self::hosted::boxed_run_redirect_selected;
6666
use self::hosted::run_redirect;
6767
pub(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-
};
7168
use 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!("\nPatches 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),

‎crates/socket-patch-cli/src/commands/scan/render.rs‎

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -300,18 +300,27 @@ pub(super) fn not_installed_skip_line(purl: &str) -> String {
300300
)
301301
}
302302

303-
/// `[skip]` line for a selection the manifest already records.
304-
pub(super) fn already_recorded_line(purl: &str, uuid: &str) -> String {
303+
/// Line for a selection the manifest already records: `[re-apply]` on a
304+
/// wet agent run (the nested apply re-applies it), `[skip]` in a preview.
305+
pub(super) fn already_recorded_line(purl: &str, uuid: &str, reapply: bool) -> String {
306+
let tag = if reapply { "re-apply" } else { "skip" };
305307
format!(
306-
" [skip] {purl} (already recorded: {})",
308+
" [{tag}] {purl} (already recorded: {})",
307309
super::super::get::short_uuid(uuid)
308310
)
309311
}
310312

311-
/// Printed when every selection is already recorded (agent mode).
313+
/// Printed when every selection is already recorded and nothing is applied
314+
/// (report-only scan).
312315
pub(super) const ALL_ALREADY_RECORDED: &str =
313316
"All selected patches are already recorded in the manifest; run `socket-patch apply` to re-apply them.";
314317

318+
/// [`ALL_ALREADY_RECORDED`] for an agent-mode `--dry-run`. A report-only
319+
/// dry run keeps [`ALL_ALREADY_RECORDED`]: dropping `--dry-run` there still
320+
/// only reports.
321+
pub(super) const ALL_ALREADY_RECORDED_DRY_RUN: &str =
322+
"All selected patches are already recorded in the manifest; a run without --dry-run re-applies them.";
323+
315324
/// The terminal error when no package's patch details could be fetched.
316325
pub(super) fn fetch_details_failed(failed: &[(String, String)]) -> String {
317326
match failed {
@@ -746,7 +755,10 @@ mod tests {
746755

747756
#[test]
748757
fn report_only_hint_names_agent_mode() {
749-
assert_eq!(report_only_hint()[0], "To apply these patches in place, run:");
758+
assert_eq!(
759+
report_only_hint()[0],
760+
"To apply these patches in place, run:"
761+
);
750762
assert!(report_only_hint()[1].contains("--mode agent"));
751763
}
752764

@@ -760,9 +772,13 @@ mod tests {
760772
assert!(l.contains("`socket-patch scan --mode vendored`"), "{l}");
761773
assert!(!l.contains("--vendor`"), "{l}");
762774
assert_eq!(
763-
already_recorded_line("pkg:npm/x@1", "884e9f6d-aaaa"),
775+
already_recorded_line("pkg:npm/x@1", "884e9f6d-aaaa", false),
764776
" [skip] pkg:npm/x@1 (already recorded: 884e9f6d)"
765777
);
778+
assert_eq!(
779+
already_recorded_line("pkg:npm/x@1", "884e9f6d-aaaa", true),
780+
" [re-apply] pkg:npm/x@1 (already recorded: 884e9f6d)"
781+
);
766782
}
767783

768784
#[test]

0 commit comments

Comments
 (0)