From abdcf35dd29fe40823faeeb264588fd2e8b62a51 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 28 Oct 2026 10:24:09 +0000 Subject: [PATCH 1/4] Start fix for #1777 Assisted-by: Claude Code:claude-opus-5-5 From 480251e421e42b594258864756eff906eee1fc29 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 10:37:39 +0000 Subject: [PATCH 2/4] Test vendored Go undo with a committed vendor/ Adds failing e2e tests for #1777. A vendored Go patch plus the `go mod vendor` its warning asks for leaves vendor/modules.txt recording the socket replace. Undoing it with `vendor --revert`, `rollback` or `remove` breaks every default build with "inconsistent vendoring", yet none of them names `go mod vendor`. Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/e2e_vendor_golang_build.rs | 128 ++++++++++++++++++ 1 file changed, 128 insertions(+) diff --git a/crates/socket-patch-cli/tests/e2e_vendor_golang_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_golang_build.rs index 852cfc0ef..049ec404b 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_golang_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_golang_build.rs @@ -1035,3 +1035,131 @@ fn go_vendor_reports_applied_event() { chmod_writable(tmp.path()); } + +// ── #1777: vendored undo vs a committed vendor/ ─────────────────────── + +/// #1777: in a project with a committed `vendor/`, a vendored patch plus +/// the `go mod vendor` its forward warning asks for leaves +/// `vendor/modules.txt` recording the socket `replace`. Every vendored undo +/// (`vendor --revert`, `rollback`, `remove `) drops that `replace`, so +/// the next default build fails with "inconsistent vendoring" until +/// `go mod vendor` runs again. Each undo must name that regeneration (as +/// agent-mode rollback does, #343), and `apply --check` must report the +/// stale modules.txt as drift until it is run. +fn vendored_undo_needs_go_mod_vendor(label: &str, undo: &[&str]) { + if !golang_e2e_matrix::toolchain_ready(&format!("e2e_vendor_golang_build({label})")) { + return; + } + let tmp = tempfile::tempdir().unwrap(); + let (consumer, modcache, proxy) = stage(tmp.path()); + let mc = modcache.to_str().unwrap(); + let mut goenv = go_env(mc, &proxy); + let vendored = go(&consumer, &["mod", "vendor"], &goenv); + assert!( + vendored.status.success(), + "go mod vendor: {}", + String::from_utf8_lossy(&vendored.stderr) + ); + // Default flags from here on: go builds from vendor/. + goenv.retain(|(k, _)| *k != "GOFLAGS"); + goenv.push(("GOFLAGS", "")); + + write_patch(&consumer); + let (code, stdout, stderr) = run_socket( + &consumer, + &[ + "vendor", + "--json", + "--offline", + "--cwd", + consumer.to_str().unwrap(), + ], + &modcache, + ); + assert_eq!( + code, 0, + "vendor failed.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + let revendor = go(&consumer, &["mod", "vendor"], &goenv); + assert!( + revendor.status.success(), + "{}", + String::from_utf8_lossy(&revendor.stderr) + ); + let run = go(&consumer, &["run", "."], &goenv); + assert!( + String::from_utf8_lossy(&run.stdout).contains("OUT: PATCHED"), + "{}{}", + String::from_utf8_lossy(&run.stdout), + String::from_utf8_lossy(&run.stderr) + ); + + { + let cs = consumer.to_str().unwrap(); + let mut args = undo.to_vec(); + args.extend(["--cwd", cs]); + let (code, so, se) = run_socket(&consumer, &args, &modcache); + assert_eq!(code, 0, "[{label}] failed.\n{so}\n{se}"); + let renv = parse_json_envelope(&so); + assert!( + !std::fs::read_to_string(consumer.join("go.mod")) + .unwrap() + .contains("replace "), + "[{label}] the socket replace is gone" + ); + let broken = go(&consumer, &["build", "./..."], &goenv); + assert!( + !broken.status.success() + && String::from_utf8_lossy(&broken.stderr).contains("inconsistent vendoring"), + "[{label}] the fixture reproduces the broken build: {}", + String::from_utf8_lossy(&broken.stderr) + ); + assert!( + common::envelope::all_codes(&renv) + .contains(&"go_vendor_modules_txt_out_of_sync".to_string()), + "[{label}] must name the vendor/modules.txt regeneration:\n{renv}" + ); + let (code, so, se) = run_socket( + &consumer, + &["apply", "--check", "--ecosystems", "golang", "--cwd", cs], + &modcache, + ); + assert_eq!( + code, 1, + "[{label}] stale vendor/modules.txt is drift:\n{so}\n{se}" + ); + assert!(se.contains("go mod vendor"), "[{label}] {se}"); + + // The step the warning names fixes the build. + let regen = go(&consumer, &["mod", "vendor"], &goenv); + assert!( + regen.status.success(), + "[{label}] {}", + String::from_utf8_lossy(®en.stderr) + ); + let run = go(&consumer, &["run", "."], &goenv); + assert!( + String::from_utf8_lossy(&run.stdout).contains("OUT: PRISTINE"), + "[{label}] {}{}", + String::from_utf8_lossy(&run.stdout), + String::from_utf8_lossy(&run.stderr) + ); + } + + chmod_writable(tmp.path()); +} + +#[test] +fn vendor_revert_needs_go_mod_vendor_with_committed_vendor_dir() { + vendored_undo_needs_go_mod_vendor("vendor --revert", &["vendor", "--revert", "--json"]); +} + +#[test] +fn vendored_rollback_needs_go_mod_vendor_with_committed_vendor_dir() { + vendored_undo_needs_go_mod_vendor("rollback", &["rollback", "--offline", "--json"]); +} + +#[test] +fn vendored_remove_needs_go_mod_vendor_with_committed_vendor_dir() { + vendored_undo_needs_go_mod_vendor("remove", &["remove", UPURL, "--offline", "--json"]); +} From 959fe16518fc9ecdc9382f0543e9afb3a5d07f7f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 10:41:38 +0000 Subject: [PATCH 3/4] Warn after vendored Go undo leaves stale vendor/ After a vendored Go patch was undone with `vendor --revert`, `rollback` or `remove`, a committed vendor/modules.txt kept recording the dropped replace, and every default build failed with "inconsistent vendoring". Nothing told the user, and `vendor --check` passed. The shared vendored revert step now audits the Go consumer state once after it unwires a Go entry, so every undo command warns `go_vendor_modules_txt_out_of_sync` and names `go mod vendor` (or `go work vendor`), as agent-mode rollback already did. `vendor --check` now fails on a modules.txt that still records a socket replace go.mod no longer has. Rollback no longer repeats the warning when its agent and vendored legs both report it. Fixes #1777 Assisted-by: Claude Code:claude-opus-5-5 --- .../socket-patch-cli/src/commands/rollback.rs | 9 ++- .../socket-patch-cli/src/commands/vendor.rs | 44 +++++++++++++ .../src/commands/vendored_backend/mod.rs | 37 +++++++++++ .../tests/e2e_vendor_golang_build.rs | 63 ++++++++++++++++--- 4 files changed, 143 insertions(+), 10 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/rollback.rs b/crates/socket-patch-cli/src/commands/rollback.rs index 6f98e9957..d9522a456 100644 --- a/crates/socket-patch-cli/src/commands/rollback.rs +++ b/crates/socket-patch-cli/src/commands/rollback.rs @@ -2253,11 +2253,18 @@ pub async fn run(args: RollbackArgs) -> i32 { .chain(hosted_leg.warnings.iter()) .cloned() .collect(); + // Both Go legs audit the same go.mod and vendor/modules.txt + // (#1777): an issue the agent leg already reported is not + // repeated for the vendored one. vendored_leg .warnings .iter() .chain(hosted_leg.warnings.iter()) - .for_each(|(code, detail)| run_warnings.push((code.clone(), detail.clone()))); + .for_each(|w| { + if !run_warnings.contains(w) { + run_warnings.push(w.clone()); + } + }); // A restored package whose ownership could not be put back, or // whose stale bytecode could not be removed (the engine reports // it on `error` with `success: true`), is restored but worth a diff --git a/crates/socket-patch-cli/src/commands/vendor.rs b/crates/socket-patch-cli/src/commands/vendor.rs index bd85b661f..253a559b1 100644 --- a/crates/socket-patch-cli/src/commands/vendor.rs +++ b/crates/socket-patch-cli/src/commands/vendor.rs @@ -1362,6 +1362,50 @@ async fn run_check(args: &VendorArgs) -> i32 { .with_details(serde_json::json!({ "ecosystem": eco, "path": rel })), ); } + // A committed vendor/modules.txt still recording a socket `replace` + // that go.mod no longer has breaks every default build until it is + // regenerated (#1777). The ledger entry it came from is gone after a + // revert, so the per-entry check above cannot see it. + let ledger_modules: HashSet = state + .entries + .iter() + .filter(|(_, entry)| entry.ecosystem == "golang") + .filter_map(|(key, _)| socket_patch_core::utils::purl::parse_golang_purl(key)) + .map(|(module, _)| module.into_owned()) + .collect(); + let stale_records: Vec<(String, String)> = go_sync_issues + .get_or_init(|| async { + socket_patch_core::vendor::go_consumer_sync::audit(root, &HashMap::new()).await + }) + .await + .iter() + .filter(|issue| { + matches!( + issue, + socket_patch_core::vendor::go_consumer_sync::GoSyncIssue::VendorModulesTxt { + stale: true, + .. + } + ) && !ledger_modules.contains(issue.module()) + }) + .map(|issue| (issue.module().to_string(), issue.to_string())) + .collect(); + for (module, detail) in stale_records { + if !args.common.json { + eprintln!( + "{}: {detail}", + socket_patch_core::vendor::go_consumer_sync::VENDOR_MODULES_TXT_CODE + ); + } + env.record( + PatchEvent::artifact(PatchAction::Failed) + .with_error( + socket_patch_core::vendor::go_consumer_sync::VENDOR_MODULES_TXT_CODE, + detail, + ) + .with_details(serde_json::json!({ "ecosystem": "golang", "module": module })), + ); + } if args.common.json { println!("{}", env.to_pretty_json()); } diff --git a/crates/socket-patch-cli/src/commands/vendored_backend/mod.rs b/crates/socket-patch-cli/src/commands/vendored_backend/mod.rs index 860e90809..23e26bbd8 100644 --- a/crates/socket-patch-cli/src/commands/vendored_backend/mod.rs +++ b/crates/socket-patch-cli/src/commands/vendored_backend/mod.rs @@ -142,6 +142,7 @@ impl<'a> VendoredBackend<'a> { stop_on_failure: bool, ) -> Vec { let mut out = Vec::with_capacity(keys.len()); + let mut unwired_go = Vec::new(); for key in keys { // Captured before a clean revert drops the entry. let flavor = state.entries.get(key).and_then(|e| e.flavor.clone()); @@ -149,6 +150,10 @@ impl<'a> VendoredBackend<'a> { .entries .get(key) .map(|e| (e.ecosystem.clone(), e.base_purl.clone())); + let golang = state + .entries + .get(key) + .is_some_and(|e| e.ecosystem == "golang"); let mut result = revert_vendor_entry(&self.common.cwd, key, state, opts).await; let hard_failure = matches!( result.step, @@ -187,6 +192,9 @@ impl<'a> VendoredBackend<'a> { .await; } } + if golang && unwired && !matches!(result.step, VendorRevertStep::WouldRevert) { + unwired_go.push(out.len()); + } out.push(RevertedEntry { key: key.clone(), flavor, @@ -197,8 +205,37 @@ impl<'a> VendoredBackend<'a> { break; } } + self.push_go_consumer_warnings(&unwired_go, &mut out).await; out } + + /// Dropping a vendored `replace` leaves a committed vendor/modules.txt + /// (or go.mod requirements) still describing it, which breaks every + /// default build until `go mod vendor` runs (#343, #1777). Audit once + /// after the loop and attach each issue to the unwired Go entry of its + /// module (the first one when none matches), so every reverting + /// command reports it. + async fn push_go_consumer_warnings(&self, unwired_go: &[usize], out: &mut [RevertedEntry]) { + let Some(&first) = unwired_go.first() else { + return; + }; + let issues = + socket_patch_core::vendor::go_consumer_sync::audit(&self.common.cwd, &HashMap::new()) + .await; + for issue in issues { + let owner = unwired_go + .iter() + .copied() + .find(|&i| { + socket_patch_core::utils::purl::parse_golang_purl(&out[i].key) + .is_some_and(|(module, _)| module == issue.module()) + }) + .unwrap_or(first); + out[owner] + .warnings + .push(VendorWarning::new(issue.code(), issue.to_string())); + } + } } /// Name a patched `-.gem` left in Bundler's cache dir once diff --git a/crates/socket-patch-cli/tests/e2e_vendor_golang_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_golang_build.rs index 049ec404b..1517f4e53 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_golang_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_golang_build.rs @@ -1046,7 +1046,7 @@ fn go_vendor_reports_applied_event() { /// `go mod vendor` runs again. Each undo must name that regeneration (as /// agent-mode rollback does, #343), and `apply --check` must report the /// stale modules.txt as drift until it is run. -fn vendored_undo_needs_go_mod_vendor(label: &str, undo: &[&str]) { +fn vendored_undo_needs_go_mod_vendor(label: &str, undo: &[&str], workspace: bool) { if !golang_e2e_matrix::toolchain_ready(&format!("e2e_vendor_golang_build({label})")) { return; } @@ -1054,10 +1054,22 @@ fn vendored_undo_needs_go_mod_vendor(label: &str, undo: &[&str]) { let (consumer, modcache, proxy) = stage(tmp.path()); let mc = modcache.to_str().unwrap(); let mut goenv = go_env(mc, &proxy); - let vendored = go(&consumer, &["mod", "vendor"], &goenv); + // In a workspace, `go work vendor` owns vendor/modules.txt. + let regen_cmd: &[&str] = if workspace { + std::fs::write(consumer.join("go.work"), "go 1.21\n\nuse .\n").unwrap(); + &["work", "vendor"] + } else { + &["mod", "vendor"] + }; + if workspace { + // `-mod=mod` is not allowed in workspace mode. + goenv.retain(|(k, _)| *k != "GOFLAGS"); + goenv.push(("GOFLAGS", "")); + } + let vendored = go(&consumer, regen_cmd, &goenv); assert!( vendored.status.success(), - "go mod vendor: {}", + "{regen_cmd:?}: {}", String::from_utf8_lossy(&vendored.stderr) ); // Default flags from here on: go builds from vendor/. @@ -1080,7 +1092,7 @@ fn vendored_undo_needs_go_mod_vendor(label: &str, undo: &[&str]) { code, 0, "vendor failed.\nstdout:\n{stdout}\nstderr:\n{stderr}" ); - let revendor = go(&consumer, &["mod", "vendor"], &goenv); + let revendor = go(&consumer, regen_cmd, &goenv); assert!( revendor.status.success(), "{}", @@ -1128,10 +1140,17 @@ fn vendored_undo_needs_go_mod_vendor(label: &str, undo: &[&str]) { code, 1, "[{label}] stale vendor/modules.txt is drift:\n{so}\n{se}" ); - assert!(se.contains("go mod vendor"), "[{label}] {se}"); + let named = format!("go {}", regen_cmd.join(" ")); + assert!(se.contains(&named), "[{label}] must name `{named}`: {se}"); + let (code, so, se) = run_socket(&consumer, &["vendor", "--check", "--cwd", cs], &modcache); + assert_eq!( + code, 1, + "[{label}] vendor --check: stale vendor/modules.txt is drift:\n{so}\n{se}" + ); + assert!(se.contains(&named), "[{label}] must name `{named}`: {se}"); // The step the warning names fixes the build. - let regen = go(&consumer, &["mod", "vendor"], &goenv); + let regen = go(&consumer, regen_cmd, &goenv); assert!( regen.status.success(), "[{label}] {}", @@ -1144,22 +1163,48 @@ fn vendored_undo_needs_go_mod_vendor(label: &str, undo: &[&str]) { String::from_utf8_lossy(&run.stdout), String::from_utf8_lossy(&run.stderr) ); + // (`vendor --revert` keeps the manifest record, which `--check` + // still reports as unledgered; only the modules.txt drift is gone.) + let (_code, so, se) = run_socket(&consumer, &["vendor", "--check", "--cwd", cs], &modcache); + assert!( + !se.contains("go_vendor_modules_txt_out_of_sync"), + "[{label}] in sync after {named}:\n{so}\n{se}" + ); } chmod_writable(tmp.path()); } +const REVERT: &[&str] = &["vendor", "--revert", "--json"]; +const ROLLBACK: &[&str] = &["rollback", "--offline", "--json"]; +const REMOVE: &[&str] = &["remove", UPURL, "--offline", "--json"]; + #[test] fn vendor_revert_needs_go_mod_vendor_with_committed_vendor_dir() { - vendored_undo_needs_go_mod_vendor("vendor --revert", &["vendor", "--revert", "--json"]); + vendored_undo_needs_go_mod_vendor("vendor --revert", REVERT, false); } #[test] fn vendored_rollback_needs_go_mod_vendor_with_committed_vendor_dir() { - vendored_undo_needs_go_mod_vendor("rollback", &["rollback", "--offline", "--json"]); + vendored_undo_needs_go_mod_vendor("rollback", ROLLBACK, false); } #[test] fn vendored_remove_needs_go_mod_vendor_with_committed_vendor_dir() { - vendored_undo_needs_go_mod_vendor("remove", &["remove", UPURL, "--offline", "--json"]); + vendored_undo_needs_go_mod_vendor("remove", REMOVE, false); +} + +#[test] +fn vendor_revert_needs_go_work_vendor_in_workspace() { + vendored_undo_needs_go_mod_vendor("go.work vendor --revert", REVERT, true); +} + +#[test] +fn vendored_rollback_needs_go_work_vendor_in_workspace() { + vendored_undo_needs_go_mod_vendor("go.work rollback", ROLLBACK, true); +} + +#[test] +fn vendored_remove_needs_go_work_vendor_in_workspace() { + vendored_undo_needs_go_mod_vendor("go.work remove", REMOVE, true); } From 61717de7443aee3e6e59538722d4d855c4fac564 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 10:49:00 +0000 Subject: [PATCH 4/4] Audit only the Go modules an undo unwired Review follow-up. The audit no longer runs inside the shared revert step, so a forward `vendor` run that reconciles a dropped entry does not warn mid-run and again at the end. The undo commands (`vendor --revert`, `rollback`, `remove`) now call it explicitly, and it reports only issues for the Go modules they unwired, never unrelated issues under the wrong package. This also drops the rollback dedup that was only needed for that case. `vendor --check` runs the audit once. The docs now name `vendor --revert` and `remove` among the commands that warn. Assisted-by: Claude Code:claude-opus-5-5 --- .../socket-patch-cli/src/commands/remove.rs | 12 ++- .../socket-patch-cli/src/commands/rollback.rs | 20 +++-- .../socket-patch-cli/src/commands/vendor.rs | 18 ++--- .../src/commands/vendored_backend/mod.rs | 74 ++++++++++--------- .../tests/e2e_vendor_golang_build.rs | 2 +- docs/ecosystems.md | 5 +- 6 files changed, 69 insertions(+), 62 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/remove.rs b/crates/socket-patch-cli/src/commands/remove.rs index 3965a9ee2..f51222fa2 100644 --- a/crates/socket-patch-cli/src/commands/remove.rs +++ b/crates/socket-patch-cli/src/commands/remove.rs @@ -1295,9 +1295,15 @@ async fn revert_vendored_matches( let mut leg = RemoveVendorLeg::default(); // Stops at the first hard failure: remove aborts there, leaving the // remaining matches (and the manifest) untouched. - let reverted = VendoredBackend::new(&args.common, None) - .revert(keys, state, opts, true) - .await; + let backend = VendoredBackend::new(&args.common, None); + let reverted = backend.revert(keys, state, opts, true).await; + for (key, w) in backend.go_undo_warnings(&reverted).await { + if loud { + eprintln!("Warning: {}", w.detail); + } + leg.skipped + .push(PatchEvent::new(PatchAction::Skipped, key).with_reason(w.code, w.detail)); + } for RevertedEntry { key, warnings, diff --git a/crates/socket-patch-cli/src/commands/rollback.rs b/crates/socket-patch-cli/src/commands/rollback.rs index d9522a456..9a0c266d9 100644 --- a/crates/socket-patch-cli/src/commands/rollback.rs +++ b/crates/socket-patch-cli/src/commands/rollback.rs @@ -989,9 +989,14 @@ async fn run_vendored_leg( keep_artifact: preserve, }; let loud = !common.json && !common.silent; - let reverted = VendoredBackend::new(common, None) - .revert(keys, state, opts, false) - .await; + let backend = VendoredBackend::new(common, None); + let reverted = backend.revert(keys, state, opts, false).await; + for (_, w) in backend.go_undo_warnings(&reverted).await { + if loud { + eprintln!("Warning: {}", w.detail); + } + out.warnings.push((w.code.to_string(), w.detail)); + } for RevertedEntry { key, warnings, @@ -2253,18 +2258,11 @@ pub async fn run(args: RollbackArgs) -> i32 { .chain(hosted_leg.warnings.iter()) .cloned() .collect(); - // Both Go legs audit the same go.mod and vendor/modules.txt - // (#1777): an issue the agent leg already reported is not - // repeated for the vendored one. vendored_leg .warnings .iter() .chain(hosted_leg.warnings.iter()) - .for_each(|w| { - if !run_warnings.contains(w) { - run_warnings.push(w.clone()); - } - }); + .for_each(|(code, detail)| run_warnings.push((code.clone(), detail.clone()))); // A restored package whose ownership could not be put back, or // whose stale bytecode could not be removed (the engine reports // it on `error` with `success: true`), is restored but worth a diff --git a/crates/socket-patch-cli/src/commands/vendor.rs b/crates/socket-patch-cli/src/commands/vendor.rs index 253a559b1..0bcb450f5 100644 --- a/crates/socket-patch-cli/src/commands/vendor.rs +++ b/crates/socket-patch-cli/src/commands/vendor.rs @@ -1241,7 +1241,8 @@ async fn run_check(args: &VendorArgs) -> i32 { } else { None }; - let go_sync_issues = tokio::sync::OnceCell::new(); + let go_sync_issues = + socket_patch_core::vendor::go_consumer_sync::audit(root, &HashMap::new()).await; for (key, entry) in entries { let record = entry.record.as_ref().or_else(|| manifest.patches.get(key)); let mut failure = match record { @@ -1280,11 +1281,6 @@ async fn run_check(args: &VendorArgs) -> i32 { if failure.is_none() && entry.ecosystem == "golang" { if let Some((module, _)) = socket_patch_core::utils::purl::parse_golang_purl(key) { let issues: Vec = go_sync_issues - .get_or_init(|| async { - socket_patch_core::vendor::go_consumer_sync::audit(root, &HashMap::new()) - .await - }) - .await .iter() .filter(|issue| issue.module() == module) .map(ToString::to_string) @@ -1374,10 +1370,6 @@ async fn run_check(args: &VendorArgs) -> i32 { .map(|(module, _)| module.into_owned()) .collect(); let stale_records: Vec<(String, String)> = go_sync_issues - .get_or_init(|| async { - socket_patch_core::vendor::go_consumer_sync::audit(root, &HashMap::new()).await - }) - .await .iter() .filter(|issue| { matches!( @@ -4480,7 +4472,8 @@ async fn run_revert(args: &VendorArgs, env: &mut Envelope) -> i32 { // (rollback's vendored leg, both of remove's paths, the manifest // reconcile): dispatch → drift-keep → per-entry ledger save. Only the // event vocabulary and the human lines are this command's. - let reverted = VendoredBackend::new(common, None) + let backend = VendoredBackend::new(common, None); + let reverted = backend .revert( &recorded, &mut state, @@ -4488,6 +4481,9 @@ async fn run_revert(args: &VendorArgs, env: &mut Envelope) -> i32 { false, ) .await; + for (purl, w) in backend.go_undo_warnings(&reverted).await { + record_warning(env, &purl, &w, common); + } for RevertedEntry { key: purl, flavor, diff --git a/crates/socket-patch-cli/src/commands/vendored_backend/mod.rs b/crates/socket-patch-cli/src/commands/vendored_backend/mod.rs index 23e26bbd8..a00d1e93c 100644 --- a/crates/socket-patch-cli/src/commands/vendored_backend/mod.rs +++ b/crates/socket-patch-cli/src/commands/vendored_backend/mod.rs @@ -142,7 +142,6 @@ impl<'a> VendoredBackend<'a> { stop_on_failure: bool, ) -> Vec { let mut out = Vec::with_capacity(keys.len()); - let mut unwired_go = Vec::new(); for key in keys { // Captured before a clean revert drops the entry. let flavor = state.entries.get(key).and_then(|e| e.flavor.clone()); @@ -150,10 +149,6 @@ impl<'a> VendoredBackend<'a> { .entries .get(key) .map(|e| (e.ecosystem.clone(), e.base_purl.clone())); - let golang = state - .entries - .get(key) - .is_some_and(|e| e.ecosystem == "golang"); let mut result = revert_vendor_entry(&self.common.cwd, key, state, opts).await; let hard_failure = matches!( result.step, @@ -192,9 +187,6 @@ impl<'a> VendoredBackend<'a> { .await; } } - if golang && unwired && !matches!(result.step, VendorRevertStep::WouldRevert) { - unwired_go.push(out.len()); - } out.push(RevertedEntry { key: key.clone(), flavor, @@ -205,36 +197,50 @@ impl<'a> VendoredBackend<'a> { break; } } - self.push_go_consumer_warnings(&unwired_go, &mut out).await; out } - /// Dropping a vendored `replace` leaves a committed vendor/modules.txt - /// (or go.mod requirements) still describing it, which breaks every - /// default build until `go mod vendor` runs (#343, #1777). Audit once - /// after the loop and attach each issue to the unwired Go entry of its - /// module (the first one when none matches), so every reverting - /// command reports it. - async fn push_go_consumer_warnings(&self, unwired_go: &[usize], out: &mut [RevertedEntry]) { - let Some(&first) = unwired_go.first() else { - return; - }; - let issues = - socket_patch_core::vendor::go_consumer_sync::audit(&self.common.cwd, &HashMap::new()) - .await; - for issue in issues { - let owner = unwired_go - .iter() - .copied() - .find(|&i| { - socket_patch_core::utils::purl::parse_golang_purl(&out[i].key) - .is_some_and(|(module, _)| module == issue.module()) - }) - .unwrap_or(first); - out[owner] - .warnings - .push(VendorWarning::new(issue.code(), issue.to_string())); + /// Dropping a vendored Go `replace` leaves a committed + /// vendor/modules.txt still recording it, which breaks every default + /// build until `go mod vendor` runs (#343, #1777). The undo commands + /// call this after [`Self::revert`]: it audits the Go consumer state + /// once and returns the issues of the Go modules `reverted` unwired, + /// each keyed by the ledger key of its entry. Not part of `revert` + /// itself, whose forward-run caller (the manifest reconcile) audits + /// once at the end of the run instead. + pub(crate) async fn go_undo_warnings( + &self, + reverted: &[RevertedEntry], + ) -> Vec<(String, VendorWarning)> { + let unwired: Vec<(&str, String)> = reverted + .iter() + .filter(|e| { + matches!( + e.step, + VendorRevertStep::Reverted + | VendorRevertStep::Preserved + | VendorRevertStep::LedgerWriteFailed(_) + ) + }) + .filter_map(|e| { + socket_patch_core::utils::purl::parse_golang_purl(&e.key) + .map(|(module, _)| (e.key.as_str(), module.into_owned())) + }) + .collect(); + if unwired.is_empty() { + return Vec::new(); } + socket_patch_core::vendor::go_consumer_sync::audit(&self.common.cwd, &HashMap::new()) + .await + .into_iter() + .filter_map(|issue| { + let (key, _) = unwired.iter().find(|(_, m)| m == issue.module())?; + Some(( + key.to_string(), + VendorWarning::new(issue.code(), issue.to_string()), + )) + }) + .collect() } } diff --git a/crates/socket-patch-cli/tests/e2e_vendor_golang_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_golang_build.rs index 1517f4e53..bd772e205 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_golang_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_golang_build.rs @@ -1167,7 +1167,7 @@ fn vendored_undo_needs_go_mod_vendor(label: &str, undo: &[&str], workspace: bool // still reports as unledgered; only the modules.txt drift is gone.) let (_code, so, se) = run_socket(&consumer, &["vendor", "--check", "--cwd", cs], &modcache); assert!( - !se.contains("go_vendor_modules_txt_out_of_sync"), + !se.contains("go_vendor_modules_txt_out_of_sync") && !so.contains("modules.txt"), "[{label}] in sync after {named}:\n{so}\n{se}" ); } diff --git a/docs/ecosystems.md b/docs/ecosystems.md index 62b0b8556..18063b65b 100644 --- a/docs/ecosystems.md +++ b/docs/ecosystems.md @@ -1475,8 +1475,9 @@ for it until `rollback -g`. Two files the go command derives from `go.mod` can disagree with a new or removed socket `replace`, and go then refuses to build. socket-patch does not regenerate -them (that needs the go toolchain and the whole module graph); `apply`, `vendor`, -`rollback` and `scan --mode hosted` warn with the command that does, and +them (that needs the go toolchain and the whole module graph); `apply`, `vendor` +(including `vendor --revert`), `rollback`, `remove` and `scan --mode hosted` warn +with the command that does, and `apply --check` / `vendor --check` report the disagreement as drift until it is run: | Warning code | When | Fix |