Skip to content

Commit 00b5320

Browse files
mikolalysenkoclaude
andcommitted
feat: add apply --force flag and rename --no-apply to --save-only
Add --force/-f flag to `apply` command that skips pre-application hash verification, allowing patches to be applied even when the installed package version differs from what the patch targets. When force is enabled, hash mismatches are treated as ready-to-patch and missing files (for non-new entries) are skipped rather than aborting. Rename `get --no-apply` to `get --save-only` for clarity, with `--no-apply` kept as a backward-compatible alias. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent a2b946c commit 00b5320

File tree

5 files changed

+343
-36
lines changed

5 files changed

+343
-36
lines changed

crates/socket-patch-cli/src/commands/apply.rs

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,10 @@ pub struct ApplyArgs {
4848
/// Restrict patching to specific ecosystems
4949
#[arg(long, value_delimiter = ',')]
5050
pub ecosystems: Option<Vec<String>>,
51+
52+
/// Skip pre-application hash verification (apply even if package version differs)
53+
#[arg(short = 'f', long, default_value_t = false)]
54+
pub force: bool,
5155
}
5256

5357
pub async fn run(args: ApplyArgs) -> i32 {
@@ -246,11 +250,13 @@ async fn apply_patches_inner(
246250
None => continue,
247251
};
248252

249-
// Check first file hash match
250-
if let Some((file_name, file_info)) = patch.files.iter().next() {
251-
let verify = verify_file_patch(pkg_path, file_name, file_info).await;
252-
if verify.status == socket_patch_core::patch::apply::VerifyStatus::HashMismatch {
253-
continue;
253+
// Check first file hash match (skip when --force)
254+
if !args.force {
255+
if let Some((file_name, file_info)) = patch.files.iter().next() {
256+
let verify = verify_file_patch(pkg_path, file_name, file_info).await;
257+
if verify.status == socket_patch_core::patch::apply::VerifyStatus::HashMismatch {
258+
continue;
259+
}
254260
}
255261
}
256262

@@ -260,6 +266,7 @@ async fn apply_patches_inner(
260266
&patch.files,
261267
&blobs_path,
262268
args.dry_run,
269+
args.force,
263270
)
264271
.await;
265272

@@ -292,6 +299,7 @@ async fn apply_patches_inner(
292299
&patch.files,
293300
&blobs_path,
294301
args.dry_run,
302+
args.force,
295303
)
296304
.await;
297305

crates/socket-patch-cli/src/commands/get.rs

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,8 @@ pub struct GetArgs {
5757
pub api_token: Option<String>,
5858

5959
/// Download patch without applying it
60-
#[arg(long = "no-apply", default_value_t = false)]
61-
pub no_apply: bool,
60+
#[arg(long = "save-only", alias = "no-apply", default_value_t = false)]
61+
pub save_only: bool,
6262

6363
/// Apply patch to globally installed npm packages
6464
#[arg(short = 'g', long, default_value_t = false)]
@@ -110,8 +110,8 @@ pub async fn run(args: GetArgs) -> i32 {
110110
eprintln!("Error: Only one of --id, --cve, --ghsa, or --package can be specified");
111111
return 1;
112112
}
113-
if args.one_off && args.no_apply {
114-
eprintln!("Error: --one-off and --no-apply cannot be used together");
113+
if args.one_off && args.save_only {
114+
eprintln!("Error: --one-off and --save-only cannot be used together");
115115
return 1;
116116
}
117117

@@ -125,11 +125,8 @@ pub async fn run(args: GetArgs) -> i32 {
125125

126126
let (api_client, use_public_proxy) = get_api_client_from_env(args.org.as_deref()).await;
127127

128-
let effective_org_slug: Option<&str> = if use_public_proxy {
129-
None
130-
} else {
131-
None // org slug is already stored in the client
132-
};
128+
// org slug is already stored in the client
129+
let effective_org_slug: Option<&str> = None;
133130

134131
// Determine identifier type
135132
let id_type = if args.id {
@@ -438,8 +435,8 @@ pub async fn run(args: GetArgs) -> i32 {
438435
println!(" Failed: {patches_failed}");
439436
}
440437

441-
// Auto-apply unless --no-apply
442-
if !args.no_apply && patches_added > 0 {
438+
// Auto-apply unless --save-only
439+
if !args.save_only && patches_added > 0 {
443440
println!("\nApplying patches...");
444441
let apply_args = super::apply::ApplyArgs {
445442
cwd: args.cwd.clone(),
@@ -450,6 +447,7 @@ pub async fn run(args: GetArgs) -> i32 {
450447
global: args.global,
451448
global_prefix: args.global_prefix.clone(),
452449
ecosystems: None,
450+
force: false,
453451
};
454452
let code = super::apply::run(apply_args).await;
455453
if code != 0 {
@@ -621,7 +619,7 @@ async fn save_and_apply_patch(
621619
println!(" Skipped: 1 (already exists)");
622620
}
623621

624-
if !args.no_apply {
622+
if !args.save_only {
625623
println!("\nApplying patches...");
626624
let apply_args = super::apply::ApplyArgs {
627625
cwd: args.cwd.clone(),
@@ -632,6 +630,7 @@ async fn save_and_apply_patch(
632630
global: args.global,
633631
global_prefix: args.global_prefix.clone(),
634632
ecosystems: None,
633+
force: false,
635634
};
636635
let code = super::apply::run(apply_args).await;
637636
if code != 0 {

crates/socket-patch-cli/tests/e2e_npm.rs

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -393,6 +393,98 @@ fn test_npm_global_lifecycle() {
393393
);
394394
}
395395

396+
/// `get --save-only` should save the patch to the manifest without applying.
397+
#[test]
398+
#[ignore]
399+
fn test_npm_save_only() {
400+
if !has_command("npm") {
401+
eprintln!("SKIP: npm not found on PATH");
402+
return;
403+
}
404+
405+
let dir = tempfile::tempdir().unwrap();
406+
let cwd = dir.path();
407+
408+
write_package_json(cwd);
409+
npm_run(cwd, &["install", "minimist@1.2.2"]);
410+
411+
let index_js = cwd.join("node_modules/minimist/index.js");
412+
assert_eq!(git_sha256_file(&index_js), BEFORE_HASH);
413+
414+
// Download with --save-only (new name for --no-apply).
415+
assert_run_ok(cwd, &["get", NPM_UUID, "--save-only"], "get --save-only");
416+
417+
// File should still be original.
418+
assert_eq!(
419+
git_sha256_file(&index_js),
420+
BEFORE_HASH,
421+
"file should not change after get --save-only"
422+
);
423+
424+
// Manifest should exist with the patch.
425+
let manifest_path = cwd.join(".socket/manifest.json");
426+
assert!(manifest_path.exists(), "manifest should exist after get --save-only");
427+
428+
let manifest: serde_json::Value =
429+
serde_json::from_str(&std::fs::read_to_string(&manifest_path).unwrap()).unwrap();
430+
let patch = &manifest["patches"][NPM_PURL];
431+
assert!(patch.is_object(), "manifest should contain {NPM_PURL}");
432+
assert_eq!(patch["uuid"].as_str().unwrap(), NPM_UUID);
433+
434+
// Real apply should work.
435+
assert_run_ok(cwd, &["apply"], "apply");
436+
assert_eq!(
437+
git_sha256_file(&index_js),
438+
AFTER_HASH,
439+
"file should match afterHash after apply"
440+
);
441+
}
442+
443+
/// `apply --force` should apply patches even when the installed version differs.
444+
#[test]
445+
#[ignore]
446+
fn test_npm_apply_force() {
447+
if !has_command("npm") {
448+
eprintln!("SKIP: npm not found on PATH");
449+
return;
450+
}
451+
452+
let dir = tempfile::tempdir().unwrap();
453+
let cwd = dir.path();
454+
455+
// Install minimist@1.2.2 first, get the patch, then swap to a different version.
456+
write_package_json(cwd);
457+
npm_run(cwd, &["install", "minimist@1.2.2"]);
458+
459+
let index_js = cwd.join("node_modules/minimist/index.js");
460+
assert_eq!(git_sha256_file(&index_js), BEFORE_HASH);
461+
462+
// Save the patch without applying.
463+
assert_run_ok(cwd, &["get", NPM_UUID, "--save-only"], "get --save-only");
464+
465+
// Now reinstall a different version to create a hash mismatch.
466+
npm_run(cwd, &["install", "minimist@1.2.5"]);
467+
468+
let mismatched_hash = git_sha256_file(&index_js);
469+
assert_ne!(
470+
mismatched_hash, BEFORE_HASH,
471+
"minimist@1.2.5 should have a different index.js hash"
472+
);
473+
474+
// Normal apply should fail due to hash mismatch.
475+
let (code, _stdout, _stderr) = run(cwd, &["apply"]);
476+
assert_ne!(code, 0, "apply without --force should fail on hash mismatch");
477+
478+
// Apply with --force should succeed.
479+
assert_run_ok(cwd, &["apply", "--force"], "apply --force");
480+
481+
assert_eq!(
482+
git_sha256_file(&index_js),
483+
AFTER_HASH,
484+
"index.js should match afterHash after apply --force"
485+
);
486+
}
487+
396488
/// UUID shortcut: `socket-patch <UUID>` should behave like `socket-patch get <UUID>`.
397489
#[test]
398490
#[ignore]

crates/socket-patch-cli/tests/e2e_pypi.rs

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -517,6 +517,120 @@ fn test_pypi_global_lifecycle() {
517517
);
518518
}
519519

520+
/// `get --save-only` should save the patch to the manifest without applying.
521+
#[test]
522+
#[ignore]
523+
fn test_pypi_save_only() {
524+
if !has_python3() {
525+
eprintln!("SKIP: python3 not found on PATH");
526+
return;
527+
}
528+
529+
let dir = tempfile::tempdir().unwrap();
530+
let cwd = dir.path();
531+
532+
setup_venv(cwd);
533+
534+
let site_packages = find_site_packages(cwd);
535+
let messages_py = site_packages.join("pydantic_ai/messages.py");
536+
assert!(messages_py.exists());
537+
let original_hash = git_sha256_file(&messages_py);
538+
539+
// Download with --save-only.
540+
assert_run_ok(cwd, &["get", PYPI_UUID, "--save-only"], "get --save-only");
541+
542+
// File should be unchanged.
543+
assert_eq!(
544+
git_sha256_file(&messages_py),
545+
original_hash,
546+
"file should not change after get --save-only"
547+
);
548+
549+
// Manifest should exist with the patch.
550+
let manifest_path = cwd.join(".socket/manifest.json");
551+
assert!(manifest_path.exists(), "manifest should exist after get --save-only");
552+
553+
let (purl, _) = read_patch_files(&manifest_path);
554+
assert!(
555+
purl.starts_with(PYPI_PURL_PREFIX),
556+
"manifest should contain a pydantic-ai patch"
557+
);
558+
559+
// Real apply should work.
560+
assert_run_ok(cwd, &["apply"], "apply");
561+
562+
let (_, files_value) = read_patch_files(&manifest_path);
563+
let files = files_value.as_object().unwrap();
564+
let after_hash = files["pydantic_ai/messages.py"]["afterHash"]
565+
.as_str()
566+
.unwrap();
567+
assert_eq!(
568+
git_sha256_file(&messages_py),
569+
after_hash,
570+
"file should match afterHash after apply"
571+
);
572+
}
573+
574+
/// `apply --force` should apply patches even when file hashes don't match.
575+
#[test]
576+
#[ignore]
577+
fn test_pypi_apply_force() {
578+
if !has_python3() {
579+
eprintln!("SKIP: python3 not found on PATH");
580+
return;
581+
}
582+
583+
let dir = tempfile::tempdir().unwrap();
584+
let cwd = dir.path();
585+
586+
setup_venv(cwd);
587+
588+
let site_packages = find_site_packages(cwd);
589+
590+
// Save the patch without applying.
591+
assert_run_ok(cwd, &["get", PYPI_UUID, "--save-only"], "get --save-only");
592+
593+
let manifest_path = cwd.join(".socket/manifest.json");
594+
let (_, files_value) = read_patch_files(&manifest_path);
595+
let files = files_value.as_object().unwrap();
596+
597+
// Corrupt one of the files to create a hash mismatch.
598+
let messages_py = site_packages.join("pydantic_ai/messages.py");
599+
let before_hash = files["pydantic_ai/messages.py"]["beforeHash"]
600+
.as_str()
601+
.unwrap();
602+
assert_eq!(
603+
git_sha256_file(&messages_py),
604+
before_hash,
605+
"file should match beforeHash before corruption"
606+
);
607+
608+
std::fs::write(&messages_py, b"# corrupted content\n").unwrap();
609+
assert_ne!(
610+
git_sha256_file(&messages_py),
611+
before_hash,
612+
"file should have a different hash after corruption"
613+
);
614+
615+
// Normal apply should fail due to hash mismatch.
616+
let (code, _stdout, _stderr) = run(cwd, &["apply"]);
617+
assert_ne!(code, 0, "apply without --force should fail on hash mismatch");
618+
619+
// Apply with --force should succeed.
620+
assert_run_ok(cwd, &["apply", "--force"], "apply --force");
621+
622+
// Verify all files match afterHash.
623+
for (rel_path, info) in files {
624+
let after_hash = info["afterHash"].as_str().expect("afterHash");
625+
let full_path = site_packages.join(rel_path);
626+
assert_eq!(
627+
git_sha256_file(&full_path),
628+
after_hash,
629+
"{rel_path} should match afterHash after apply --force"
630+
);
631+
}
632+
}
633+
520634
/// UUID shortcut: `socket-patch <UUID>` should behave like `socket-patch get <UUID>`.
521635
#[test]
522636
#[ignore]

0 commit comments

Comments
 (0)