From 8abae8aaeee01f10f3ff5b10bece11c8cb42841c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 08:24:52 +0000 Subject: [PATCH 1/7] Start fix for #991 Assisted-by: Claude Code:claude-opus-5-5 From 536f776187356d2779d37d223fad342127c2ac90 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 08:32:21 +0000 Subject: [PATCH 2/7] Test rollback over unledgered vendor wiring Regression tests for #991. After a git merge keeps one side of .socket/vendor/state.json, the lockfile still wires the other side's package into .socket/vendor/ with no ledger entry. An unscoped rollback should refuse before writing anything instead of reporting success, deleting the ledger and leaving that package patched. - in_process_rollback_vendored: the still-wired orphan case now expects a vendor_ledger_missing refusal (dry run, wet run and --preserve-state), with the lock, ledger and manifest untouched. - e2e_vendor_yarn_classic_build: the merge flow from the issue with real yarn classic, ending with the merged-entries remedy unwinding both packages byte-exact. - vendor_orphans: the vendor_orphan_still_wired advisory must not send users to `repair`, which no longer rebuilds ledger entries. These fail on main. Assisted-by: Claude Code:claude-opus-5-5 --- .../src/commands/vendor_orphans.rs | 23 +++ .../tests/e2e_vendor_yarn_classic_build.rs | 157 ++++++++++++++++++ .../tests/in_process_rollback_vendored.rs | 59 +++++-- 3 files changed, 224 insertions(+), 15 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/vendor_orphans.rs b/crates/socket-patch-cli/src/commands/vendor_orphans.rs index e794d9e91..88c5bfeea 100644 --- a/crates/socket-patch-cli/src/commands/vendor_orphans.rs +++ b/crates/socket-patch-cli/src/commands/vendor_orphans.rs @@ -202,4 +202,27 @@ mod tests { assert!(orphan_in_target(&Target::parse(uuid), &unparsed)); assert!(!orphan_in_target(&Target::parse("left-pad"), &unparsed)); } + /// A kept, still-wired orphan points the user at a remedy that works + /// in v5: `repair` no longer rebuilds ledger entries from lockfiles, + /// so the advisory names the ledger restore (and the merge case, + /// #991) or the lockfile restore instead. + #[test] + fn orphan_still_wired_names_a_working_remedy() { + let unit = vendor::path::SweptVendorDir { + eco: "npm".into(), + uuid: "aaaaaaaa-1111-4111-8111-aaaaaaaaaaaa".into(), + dir: PathBuf::from("/x"), + purls: vec![], + }; + let warning = orphan_still_wired(&unit); + assert_eq!(warning.code, "vendor_orphan_still_wired"); + let detail = &warning.detail; + assert!( + detail.contains(".socket/vendor/npm/aaaaaaaa-1111-4111-8111-aaaaaaaaaaaa"), + "{detail}" + ); + assert!(!detail.contains("socket-patch repair"), "{detail}"); + assert!(detail.contains("entries"), "{detail}"); + assert!(detail.contains("git checkout -- "), "{detail}"); + } } diff --git a/crates/socket-patch-cli/tests/e2e_vendor_yarn_classic_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_yarn_classic_build.rs index b388f5b9f..abc0884a5 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_yarn_classic_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_yarn_classic_build.rs @@ -1281,3 +1281,160 @@ fn yarn_classic_rollback_keeps_a_shared_vendor_store_intact() { ); } } + +/// #991: two branches vendor different packages, and the merge keeps one +/// side of the conflicting `.socket/vendor/state.json`. `yarn.lock` merges +/// cleanly, so it still wires the other side's package to its committed +/// tarball with no ledger entry. `rollback` used to revert the ledgered +/// package, exit 0, delete the ledger and leave the other package wired, +/// so every later run asked for a ledger no commit had. It now refuses +/// before writing anything, and once the two sides' entries are merged it +/// unwinds both byte-exact. +#[test] +fn yarn_classic_rollback_refuses_a_lock_reference_the_ledger_lost() { + if !require_yarn_classic("e2e_vendor_yarn_classic_build", |c| { + cache_env::isolate(c); + }) { + return; + } + const OTHER: &str = "is-number"; + const OTHER_VERSION: &str = "7.0.0"; + const OTHER_UUID: &str = "2b3c4d5e-6f7a-4b2c-9d3e-123456789abc"; + let tmp = tempfile::tempdir().unwrap(); + let proj = tmp.path().join("app"); + std::fs::create_dir_all(&proj).unwrap(); + let cache = tmp.path().join("yarn-cache"); + std::fs::write( + proj.join("package.json"), + format!( + r#"{{"name":"app","version":"1.0.0","private":true,"dependencies":{{"{DEP}":"{DEP_VERSION}","{OTHER}":"{OTHER_VERSION}"}}}}"# + ), + ) + .unwrap(); + let install = corepack( + &proj, + &yarn_classic(), + &["install", "--no-progress"], + &[("YARN_CACHE_FOLDER", cache.to_str().unwrap())], + ); + if !install.status.success() { + skip!( + "fixture `yarn install` failed (registry unreachable?):\n{}", + String::from_utf8_lossy(&install.stderr) + ); + return; + } + let base_lock = std::fs::read(proj.join("yarn.lock")).unwrap(); + + // One patch per package, each with its own uuid (one per branch). + let socket = proj.join(".socket"); + std::fs::create_dir_all(socket.join("blobs")).unwrap(); + let mut patches = serde_json::Map::new(); + for (name, version, uuid) in [(DEP, DEP_VERSION, UUID), (OTHER, OTHER_VERSION, OTHER_UUID)] { + let orig = std::fs::read(proj.join("node_modules").join(name).join("index.js")).unwrap(); + let patched: Vec = [MARKER.as_bytes(), orig.as_slice()].concat(); + std::fs::write(socket.join("blobs").join(git_sha256(&patched)), &patched).unwrap(); + patches.insert( + format!("pkg:npm/{name}@{version}"), + serde_json::json!({ + "uuid": uuid, + "exportedAt": "2026-01-01T00:00:00Z", + "files": { "package/index.js": { + "beforeHash": git_sha256(&orig), + "afterHash": git_sha256(&patched), + }}, + "vulnerabilities": {}, + "description": "merge-resolution marker patch", + "license": "MIT", + "tier": "free", + }), + ); + } + std::fs::write( + socket.join("manifest.json"), + serde_json::to_string_pretty(&serde_json::json!({ "patches": patches })).unwrap(), + ) + .unwrap(); + let cwd = proj.to_str().unwrap().to_string(); + let (code, stdout, stderr) = + run_socket(&proj, &["vendor", "--json", "--offline", "--cwd", &cwd]); + assert_eq!(code, 0, "vendor:\n{stdout}\n{stderr}"); + + // The merge kept "our" side: the ledger and manifest lose the other + // branch's package, while the lock still wires it. + let other_purl = format!("pkg:npm/{OTHER}@{OTHER_VERSION}"); + let state_path = proj.join(".socket/vendor/state.json"); + let full_state: serde_json::Value = + serde_json::from_slice(&std::fs::read(&state_path).unwrap()).unwrap(); + assert!( + full_state["entries"][&other_purl].is_object(), + "sanity: both packages vendored: {full_state:#}" + ); + let mut ours = full_state.clone(); + ours["entries"].as_object_mut().unwrap().remove(&other_purl); + std::fs::write(&state_path, serde_json::to_vec_pretty(&ours).unwrap()).unwrap(); + let manifest_path = socket.join("manifest.json"); + let mut manifest: serde_json::Value = + serde_json::from_slice(&std::fs::read(&manifest_path).unwrap()).unwrap(); + manifest["patches"] + .as_object_mut() + .unwrap() + .remove(&other_purl); + std::fs::write( + &manifest_path, + serde_json::to_vec_pretty(&manifest).unwrap(), + ) + .unwrap(); + let merged_lock = std::fs::read(proj.join("yarn.lock")).unwrap(); + let merged_state = std::fs::read(&state_path).unwrap(); + let other_tgz = proj.join(format!( + ".socket/vendor/npm/{OTHER_UUID}/{OTHER}-{OTHER_VERSION}.tgz" + )); + assert!(other_tgz.is_file(), "sanity: the other side's tarball"); + + // Both the dry run and the real run refuse, naming the lost entry, + // and write nothing. + for dry in [true, false] { + let mut args = vec!["rollback", "--json", "--yes", "--offline", "--cwd", &cwd]; + if dry { + args.push("--dry-run"); + } + let (code, stdout, stderr) = run_socket(&proj, &args); + assert_eq!( + code, 1, + "rollback (dry={dry}) must refuse:\n{stdout}\n{stderr}" + ); + let env = parse_json_envelope(&stdout); + assert_eq!(env["error"]["code"], "vendor_ledger_missing", "{env:#}"); + let msg = env["error"]["message"].as_str().unwrap_or_default(); + assert!( + msg.contains(&format!(".socket/vendor/npm/{OTHER_UUID}")), + "names the unledgered dir: {msg}" + ); + assert!(msg.contains("entries"), "names the merge remedy: {msg}"); + assert_eq!(std::fs::read(proj.join("yarn.lock")).unwrap(), merged_lock); + assert_eq!(std::fs::read(&state_path).unwrap(), merged_state); + assert!(other_tgz.is_file()); + assert!(proj + .join(format!(".socket/vendor/npm/{UUID}/{DEP}-{DEP_VERSION}.tgz")) + .is_file()); + } + + // The remedy: merge both sides' entries. The rollback then unwinds + // both packages back to the pre-vendor lock. + std::fs::write(&state_path, serde_json::to_vec_pretty(&full_state).unwrap()).unwrap(); + let (code, stdout, stderr) = run_socket( + &proj, + &["rollback", "--json", "--yes", "--offline", "--cwd", &cwd], + ); + assert_eq!(code, 0, "rollback after the merge fix:\n{stdout}\n{stderr}"); + assert_eq!( + std::fs::read(proj.join("yarn.lock")).unwrap(), + base_lock, + "both packages unwound byte-exact" + ); + assert!( + !proj.join(".socket/vendor").exists(), + "no vendor state left" + ); +} diff --git a/crates/socket-patch-cli/tests/in_process_rollback_vendored.rs b/crates/socket-patch-cli/tests/in_process_rollback_vendored.rs index 12c8ccb5a..fb22144eb 100644 --- a/crates/socket-patch-cli/tests/in_process_rollback_vendored.rs +++ b/crates/socket-patch-cli/tests/in_process_rollback_vendored.rs @@ -977,10 +977,13 @@ async fn unscoped_rollback_sweeps_orphan_vendor_dirs() { ); } -/// An orphan a lockfile still points at is kept and named with -/// `vendor_orphan_still_wired`: deleting it would break the next install. +/// An orphan a lockfile still points at is a package the ledger lost +/// (the losing side of a merge of `.socket/vendor/state.json`, #991): an +/// unscoped rollback cannot unwind it, so it refuses with +/// `vendor_ledger_missing` before writing anything. It used to revert the +/// rest, exit 0 and delete the ledger, leaving the package wired. #[tokio::test] -async fn unscoped_rollback_keeps_a_still_wired_orphan() { +async fn unscoped_rollback_refuses_a_still_wired_orphan() { let fx = vendored_fixture_with_orphans().await; // Wire `right-pad` to its orphan dir behind the ledger's back. let mut lock: Value = serde_json::from_slice(&fx.lock_bytes()).unwrap(); @@ -989,19 +992,45 @@ async fn unscoped_rollback_keeps_a_still_wired_orphan() { "resolved": format!("file:.socket/vendor/npm/{OTHER_ORPHAN_UUID}/right-pad-1.0.0.tgz"), }); std::fs::write(fx.lock_path(), serde_json::to_vec_pretty(&lock).unwrap()).unwrap(); + let lock_before = fx.lock_bytes(); + let state_before = std::fs::read(fx.state_path()).unwrap(); + let manifest_before = std::fs::read(fx.manifest_path()).unwrap(); - let (code, env) = rollback_cli(fx.root(), &[]); - assert_eq!(code, 0, "rollback exits 0: {env:#}"); - rollback_json::assert_rollback_envelope(&env); - assert_eq!(orphans_removed(&env), vec![PURL.to_string()], "{env:#}"); - assert!( - rollback_json::warning_codes(&env).contains(&"vendor_orphan_still_wired".to_string()), - "the kept orphan is named: {env:#}" - ); - assert!( - !orphan_dir(&fx, ORPHAN_UUID).exists(), - "unwired orphan swept" - ); + for extra in [&["--dry-run"][..], &[][..], &["--preserve-state"][..]] { + let (code, env) = rollback_cli(fx.root(), extra); + assert_eq!(code, 1, "rollback {extra:?} refuses: {env:#}"); + assert_eq!(env["status"], "error", "{env:#}"); + assert_eq!(env["error"]["code"], "vendor_ledger_missing", "{env:#}"); + let msg = env["error"]["message"].as_str().unwrap_or_default(); + assert!( + msg.contains(&format!(".socket/vendor/npm/{OTHER_ORPHAN_UUID}")), + "names the unledgered dir: {msg}" + ); + assert!( + !msg.contains("socket-patch repair"), + "repair does not rebuild ledger entries (v5): {msg}" + ); + assert!( + env["events"].as_array().is_some_and(Vec::is_empty), + "{env:#}" + ); + assert_eq!(fx.lock_bytes(), lock_before, "lock untouched"); + assert_eq!( + std::fs::read(fx.state_path()).unwrap(), + state_before, + "ledger kept" + ); + assert_eq!(std::fs::read(fx.manifest_path()).unwrap(), manifest_before); + assert!(fx.tgz_path().is_file(), "the ledgered artifact stays"); + assert!(orphan_dir(&fx, ORPHAN_UUID).is_dir()); + assert!(orphan_dir(&fx, OTHER_ORPHAN_UUID).is_dir()); + } + + // A scoped rollback of the ledgered package does not own the other + // wiring: it reverts its target as before. + let (code, env) = rollback_cli(fx.root(), &[PURL]); + assert_eq!(code, 0, "rollback exits 0: {env:#}"); + assert!(!fx.tgz_path().exists(), "the target is reverted"); assert!( orphan_dir(&fx, OTHER_ORPHAN_UUID).is_dir(), "a wired orphan is never deleted" From b4fb63614df172ff29db29645ac60161705151fd Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 08:33:30 +0000 Subject: [PATCH 3/7] Refuse rollback over unledgered vendor wiring When a git merge keeps one side of .socket/vendor/state.json, the lockfile can still wire the other side's package into .socket/vendor/ with no ledger entry. A bare rollback reverted the ledgered packages, exited 0 and deleted the ledger, leaving that package patched and every later run asking for a ledger no commit had (#991). An unscoped rollback now refuses up front with vendor_ledger_missing, naming each unowned dir and how to recover (merge both sides' ledger entries, or restore the lockfile), and writes nothing. This matches the existing refusal when the ledger is missing entirely. A scoped rollback does not own that wiring and proceeds as before. The vendor_orphan_still_wired advisory no longer tells users to run `repair`, which stopped rebuilding ledger entries in v5. Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-cli/CLI_CONTRACT.md | 6 +- .../socket-patch-cli/src/commands/rollback.rs | 56 +++++++++++++++++++ .../src/commands/vendor_orphans.rs | 15 ++++- 3 files changed, 72 insertions(+), 5 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index f210b750e..70995cc9f 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -1148,9 +1148,9 @@ worse, lets a warm cache silently serve unpatched bytes): A bare `rollback` (or a scoped one, for its scope) restores the SYSTEM to unpatched and cleans up the local state, in phases under one `apply.lock` acquisition: -1. **State discovery.** A missing manifest is no longer fatal when the vendor ledger or the lockfiles' hosted pins hold work (`rollback` runs manifest-less on hosted-only / vendored projects — every `scan`/`get --mode vendored` and v5 `scan --mode hosted` project is manifest-less). The **truly-empty** project — no manifest, no vendor ledger, no hosted pin — keeps the "Manifest not found" exit 1 (JSON: the error envelope with `error.code: "manifest_not_found"` plus a top-level `path`), with one v5.0 exception: when a pre-v5 `.socket/vendor/redirect-state.json` is the only thing left, nothing pins it any more, so a wet run deletes it and exits 0 (human `Removed the pre-v5 hosted ledger .socket/vendor/redirect-state.json: no lockfile pins a hosted patch.`, `Would remove …` on `--dry-run`, which deletes nothing; JSON: a `success` envelope with no events plus `legacyRedirectLedgerRemoved` (bool); a failed delete is the `legacy_redirect_ledger_kept` warning, still exit 0). A project whose lockfiles still reference `.socket/vendor/` artifacts but whose vendor ledger is missing errors asking for `.socket/vendor/state.json` to be restored from version control first (v5.0: `repair` no longer reconstructs the ledger). **Corrupt-ledger containment**: an unreadable vendor ledger fails ONLY the legs that need it — the vendored leg, manifest cleanup, and GC are skipped fail-closed (`vendor_state_unreadable` warning) while the agent and hosted legs still run; it drives `partialFailure` exit 1, and an emergency restore is never blocked by it. When the ONLY state on disk is an unreadable vendor ledger, the run fails closed naming the store. A pre-v5 redirect ledger is never read by rollback (v4's `redirect_state_unreadable` is no longer emitted). Under `--global`/`--global-prefix` the project's hosted pins and vendor ledger are not discovered at all, so the vendored and hosted legs below do not run (see "Global scope never touches the project's state"). +1. **State discovery.** A missing manifest is no longer fatal when the vendor ledger or the lockfiles' hosted pins hold work (`rollback` runs manifest-less on hosted-only / vendored projects — every `scan`/`get --mode vendored` and v5 `scan --mode hosted` project is manifest-less). The **truly-empty** project — no manifest, no vendor ledger, no hosted pin — keeps the "Manifest not found" exit 1 (JSON: the error envelope with `error.code: "manifest_not_found"` plus a top-level `path`), with one v5.0 exception: when a pre-v5 `.socket/vendor/redirect-state.json` is the only thing left, nothing pins it any more, so a wet run deletes it and exits 0 (human `Removed the pre-v5 hosted ledger .socket/vendor/redirect-state.json: no lockfile pins a hosted patch.`, `Would remove …` on `--dry-run`, which deletes nothing; JSON: a `success` envelope with no events plus `legacyRedirectLedgerRemoved` (bool); a failed delete is the `legacy_redirect_ledger_kept` warning, still exit 0). A project whose lockfiles still reference `.socket/vendor/` artifacts but whose vendor ledger is missing errors asking for `.socket/vendor/state.json` to be restored from version control first (v5.0: `repair` no longer reconstructs the ledger). An unscoped run whose ledger is present but does not own every `.socket/vendor///` dir a lockfile references (in the `--ecosystems` scope) refuses the same way, before any write and on `--dry-run` and `--preserve-state` too: the usual cause is a git merge that kept one side of `state.json`, and reverting the rest would exit 0, leave that package wired and delete the ledger (#991). The `vendor_ledger_missing` error names each unowned dir; recovery is to restore its entry (after such a merge, merge both sides' `entries`) or restore the lockfile. A scoped run does not own that wiring and proceeds. **Corrupt-ledger containment**: an unreadable vendor ledger fails ONLY the legs that need it — the vendored leg, manifest cleanup, and GC are skipped fail-closed (`vendor_state_unreadable` warning) while the agent and hosted legs still run; it drives `partialFailure` exit 1, and an emergency restore is never blocked by it. When the ONLY state on disk is an unreadable vendor ledger, the run fails closed naming the store. A pre-v5 redirect ledger is never read by rollback (v4's `redirect_state_unreadable` is no longer emitted). Under `--global`/`--global-prefix` the project's hosted pins and vendor ledger are not discovered at all, so the vendored and hosted legs below do not run (see "Global scope never touches the project's state"). 2. **Agent leg** — the existing in-place restore machinery, unchanged (v5.0 presentation: the human `No patches found in manifest` line prints only for an unscoped run with no work in ANY leg — a run whose work is all vendored/hosted stays quiet about the manifest): multi-copy restore, release-variant narrowing, the before-blob gate (+ on-demand download; a gate abort still exits 1 with per-package `missing_blob` failure results **and** skips manifest cleanup + GC entirely — nothing was restored, and the retry's revert data must survive), local-go redirect drop, and the `not_installed` exit-0 asymmetry verbatim. Vendor-owned purls are still excluded here (see the vendored-mode section) — they are handled by the next leg instead of being punted to other commands — with one exception: a vendored Cargo crate whose copy in the shared `$CARGO_HOME/registry/src` cache (or a `cargo vendor` dir) still carries an earlier agent-mode patch is restored here too (vendoring never touches that copy, and the manifest record holds the only blobs that can restore it); its record leaves the manifest only when both this leg and the vendored leg succeeded. `remove` restores it the same way. -3. **Vendored leg** — each in-scope ledger entry (embedded-record entries included) is reverted through the vendor backends: lockfile wiring restored, artifact dir deleted (and its emptied `.socket/vendor//` husk pruned, v5.0), ledger entry dropped + persisted per purl (crash-consistent, like `vendor --revert`). A **drift-keep** (the backend refused a drifted lock) keeps the entry, the artifact, AND the manifest record (a `failed` `vendor_revert_kept` event, exit 1 — the system is still patched); a failure is recorded and other entries proceed. Then, on a run with work in any leg, uuid dirs under `.socket/vendor//` that no ledger entry owns (the losing side of a git merge of two vendor runs, an interrupted run) are swept as `vendor --revert` sweeps them: every one in the `--ecosystems` scope on an unscoped run, only those whose leaf names a targeted package (or whose uuid is a targeted uuid) on a scoped one; path-glob targets sweep none. The confirmation prompt counts them (`delete N orphaned vendored dirs with no ledger record`). A dir a lockfile still points into is kept with a `vendor_orphan_still_wired` warning, and one that cannot be deleted is a `cleanup_failed` warning. Skipped under `--preserve-state`, global scope, an unreadable ledger and a before-blob gate abort. `remove ` sweeps the orphans its target names the same way, once its vendored revert and manifest write have succeeded; they are `removed` `vendor_orphan_removed` events that `summary.removed` does not count, and the kept or undeletable ones `skipped` advisories (#1610). +3. **Vendored leg** — each in-scope ledger entry (embedded-record entries included) is reverted through the vendor backends: lockfile wiring restored, artifact dir deleted (and its emptied `.socket/vendor//` husk pruned, v5.0), ledger entry dropped + persisted per purl (crash-consistent, like `vendor --revert`). A **drift-keep** (the backend refused a drifted lock) keeps the entry, the artifact, AND the manifest record (a `failed` `vendor_revert_kept` event, exit 1 — the system is still patched); a failure is recorded and other entries proceed. Then, on a run with work in any leg, uuid dirs under `.socket/vendor//` that no ledger entry owns (the losing side of a git merge of two vendor runs, an interrupted run) are swept as `vendor --revert` sweeps them: every one in the `--ecosystems` scope on an unscoped run, only those whose leaf names a targeted package (or whose uuid is a targeted uuid) on a scoped one; path-glob targets sweep none. The confirmation prompt counts them (`delete N orphaned vendored dirs with no ledger record`). A dir a lockfile still points into is kept with a `vendor_orphan_still_wired` warning (only a scoped run can reach one: an unscoped run refuses such wiring up front, see State discovery), and one that cannot be deleted is a `cleanup_failed` warning. Skipped under `--preserve-state`, global scope, an unreadable ledger and a before-blob gate abort. `remove ` sweeps the orphans its target names the same way, once its vendored revert and manifest write have succeeded; they are `removed` `vendor_orphan_removed` events that `summary.removed` does not count, and the kept or undeletable ones `skipped` advisories (#1610). 4. **Hosted leg** — each in-scope hosted pin is restored to its default upstream registry entry; see "Hosted unwind coverage" below. After a hosted leg with no failure, a wet run deletes a pre-v5 `redirect-state.json` once no lockfile pins a hosted patch any more (a failed delete is the `legacy_redirect_ledger_kept` warning). 5. **Manifest cleanup** — entries are removed ONLY for in-scope purls whose legs fully succeeded, were not-installed, or were release-variant siblings narrowed away by an attempted variant that succeeded (half a variant group never lingers — `remove` parity); drift-kept and failed purls keep their records, and a failed variant holds its whole group. A manifest record that a live hosted pin has superseded (the lockfile wires the same package release to a different patch uuid, e.g. an agent → hosted migration after the patch was replaced) and whose installed copy holds neither side of the record is not restored in place and does not fail the run: the hosted leg's lock restore and the next install unwind it, and the record leaves the manifest with the `rollback_record_superseded` warning (`remove` does the same instead of aborting before its hosted leg). A copy still holding the record's patched bytes is restored as usual. No-op removals never rewrite the file. A failed write surfaces as `manifest_write_failed` (warning + `partialFailure` exit 1; GC still runs against the unchanged manifest). 6. **GC** — blob, diff and legacy package-archive sweeps against the post-removal manifest, using the same artifact-reference policy as `remove`, retaining beforeHash blobs for (a) removed-but-not-installed entries (a crawler miss must not destroy the only local revert data — `remove` parity) and (b) EVERY entry remaining in the post-removal manifest — still-active patches (failed, drift-kept, eco-/path-excluded) keep their revert data, so a scoped or failed run never destroys the blobs a later rollback needs; only blobs referenced solely by genuinely-removed entries are swept. GC errors warn (`cleanup_failed`) and continue — they never affect the exit (repair's posture). @@ -1732,7 +1732,7 @@ outcome for a selected record, rather than inferring a write from exit 0. | `socket_yml_invalid` / `socket_yml_ambiguous` | scan | The socket.yml patch policy cannot be honored (see socket.yml patch policy). | | `patch_not_found` | rollback | No manifest, ledger or hosted patch matches the identifier. | | `path_glob_no_match` | rollback, scan | rollback: a path target matched no patched package (exit 1). scan: a PATH glob matched no directory (usage error, exit 2). | -| `vendor_ledger_missing` | rollback | Lockfiles reference `.socket/vendor/` artifacts but the vendor ledger is missing. | +| `vendor_ledger_missing` | rollback | Lockfiles reference `.socket/vendor/` artifacts but the vendor ledger is missing, or (unscoped run) has no entry for a referenced uuid dir (#991). | | `rollback_failed` | rollback | The rollback pipeline failed before any per-package result, or (#1066) something failed and nothing was rolled back, previewed, already original or not installed (the `failed` events stay in `events`). | | `lock_held` / `lock_io` | every lock-taking command | Another live run holds `apply.lock`, or the lock file could not be opened. | | `invalid_args` | scan, get, remove, repair | Usage error (exit 2): flags that cannot be combined. | diff --git a/crates/socket-patch-cli/src/commands/rollback.rs b/crates/socket-patch-cli/src/commands/rollback.rs index 2c0950c7e..518a67ab5 100644 --- a/crates/socket-patch-cli/src/commands/rollback.rs +++ b/crates/socket-patch-cli/src/commands/rollback.rs @@ -905,6 +905,41 @@ fn error_envelope(dry_run: bool, err: EnvelopeError) -> Envelope { /// Report a top-level rollback error: the error envelope on `--json` /// (message verbatim), an `Error:` stderr line otherwise. Errors print even /// under --silent ("errors only", never "nothing"). +/// The `vendor_ledger_missing` refusal for lockfile references into +/// `.socket/vendor///` (in the `--ecosystems` scope) that no +/// entry of `state` owns, or `None` when every reference is ledgered. +async fn unledgered_vendor_wiring( + common: &crate::args::GlobalArgs, + ledger_root: &Path, + state: &VendorState, +) -> Option { + let mut dirs: Vec = + crate::commands::vendored_backend::repair::scan_vendor_references(ledger_root) + .await + .into_iter() + .filter(|(eco, uuid, _)| { + crate::commands::vendor::ecosystem_in_scope(common, eco) + && !state + .entries + .values() + .any(|e| e.ecosystem == *eco && e.uuid == *uuid) + }) + .map(|(eco, uuid, _)| format!(".socket/vendor/{eco}/{uuid}/")) + .collect(); + if dirs.is_empty() { + return None; + } + dirs.sort(); + dirs.dedup(); + Some(format!( + "lockfiles reference {}, which the vendor ledger (.socket/vendor/state.json) has no \ + entry for, so rollback cannot unwind {}; {}, then roll back", + dirs.join(", "), + if dirs.len() == 1 { "it" } else { "them" }, + crate::commands::vendor_orphans::UNLEDGERED_WIRING_REMEDY, + )) +} + fn emit_rollback_error(json: bool, dry_run: bool, code: &str, msg: &str) { if json { println!( @@ -1589,6 +1624,27 @@ pub async fn run(args: RollbackArgs) -> i32 { let scoped = !identifiers.is_empty() || !path_scope.is_empty(); + // A lockfile still wired into a `.socket/vendor/` dir that the present + // ledger does not own (a merge kept one side of state.json, #991) is a + // patch this run cannot unwind: the pre-vendor originals lived in the + // lost entry. An unscoped run promises to unwind everything, so it + // refuses before writing, like the ledger-missing case above, instead + // of reverting the rest and deleting the ledger around the wiring. + if !scoped && project_state { + if let Ok(state) = &vendor_state_result { + if let Some(detail) = unledgered_vendor_wiring(&args.common, &ledger_root, state).await + { + emit_rollback_error( + args.common.json, + args.common.dry_run, + "vendor_ledger_missing", + &detail, + ); + return 1; + } + } + } + // Identifier matching runs across ALL THREE stores; an identifier // matching nothing anywhere is the familiar exit-1 error. let mut manifest_scope: HashSet = HashSet::new(); diff --git a/crates/socket-patch-cli/src/commands/vendor_orphans.rs b/crates/socket-patch-cli/src/commands/vendor_orphans.rs index 88c5bfeea..c917b9ab4 100644 --- a/crates/socket-patch-cli/src/commands/vendor_orphans.rs +++ b/crates/socket-patch-cli/src/commands/vendor_orphans.rs @@ -106,6 +106,16 @@ pub(crate) fn orphan_label(unit: &vendor::path::SweptVendorDir) -> String { .unwrap_or_else(|| format!("{}/{}", unit.eco, unit.uuid)) } +/// How to recover a lockfile reference into `.socket/vendor/` that the +/// ledger lost. `repair` does not rebuild ledger entries from lockfiles +/// (v5.0), so the entry must come back from version control (after a git +/// merge kept one side of state.json, both sides' `entries` merged), or +/// the lockfile goes back to its pre-vendor version. +pub(crate) const UNLEDGERED_WIRING_REMEDY: &str = + "restore its entry in .socket/vendor/state.json from version control (if a git merge \ + kept one side of state.json, merge both sides' `entries`), or restore the lockfile \ + with `git checkout -- `"; + /// The advisory for an orphan uuid dir the sweep kept because a project /// lockfile still points into it. pub(crate) fn orphan_still_wired(unit: &vendor::path::SweptVendorDir) -> VendorWarning { @@ -113,8 +123,8 @@ pub(crate) fn orphan_still_wired(unit: &vendor::path::SweptVendorDir) -> VendorW "vendor_orphan_still_wired", format!( "a project lockfile still points at .socket/vendor/{}/{}, which no ledger \ - entry owns; the artifacts were kept (run `socket-patch repair` to re-adopt \ - them into the ledger, then revert again)", + entry owns; the artifacts were kept ({UNLEDGERED_WIRING_REMEDY}, then revert \ + again)", unit.eco, unit.uuid ), ) @@ -202,6 +212,7 @@ mod tests { assert!(orphan_in_target(&Target::parse(uuid), &unparsed)); assert!(!orphan_in_target(&Target::parse("left-pad"), &unparsed)); } + /// A kept, still-wired orphan points the user at a remedy that works /// in v5: `repair` no longer rebuilds ledger entries from lockfiles, /// so the advisory names the ledger restore (and the merge case, From bc80351b7c2e648f86e1f1750fdf47dfccf09f90 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 08:45:07 +0000 Subject: [PATCH 4/7] Share the unledgered-reference check Review follow-ups for the #991 refusal: - vendor --check and rollback now read lockfile references the ledger does not own through one helper, so a fix to that comparison reaches both commands. - The rollback refusal is recorded as a failed rollback in telemetry, like the other errors raised after the lock is taken. - The remedy reads correctly when several entries are missing, and emit_rollback_error keeps its own doc comment. - A test covers a scoped rollback that keeps its target's still-wired orphan, checking the advisory's remedy text. Assisted-by: Claude Code:claude-opus-5-5 --- .../socket-patch-cli/src/commands/rollback.rs | 21 ++++------ .../socket-patch-cli/src/commands/vendor.rs | 9 ++--- .../src/commands/vendor_orphans.rs | 6 +-- .../src/commands/vendored_backend/repair.rs | 20 ++++++++++ .../tests/in_process_rollback_vendored.rs | 39 ++++++++++++++++++- 5 files changed, 70 insertions(+), 25 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/rollback.rs b/crates/socket-patch-cli/src/commands/rollback.rs index 518a67ab5..659d171e2 100644 --- a/crates/socket-patch-cli/src/commands/rollback.rs +++ b/crates/socket-patch-cli/src/commands/rollback.rs @@ -902,9 +902,6 @@ fn error_envelope(dry_run: bool, err: EnvelopeError) -> Envelope { env } -/// Report a top-level rollback error: the error envelope on `--json` -/// (message verbatim), an `Error:` stderr line otherwise. Errors print even -/// under --silent ("errors only", never "nothing"). /// The `vendor_ledger_missing` refusal for lockfile references into /// `.socket/vendor///` (in the `--ecosystems` scope) that no /// entry of `state` owns, or `None` when every reference is ledgered. @@ -914,32 +911,27 @@ async fn unledgered_vendor_wiring( state: &VendorState, ) -> Option { let mut dirs: Vec = - crate::commands::vendored_backend::repair::scan_vendor_references(ledger_root) + crate::commands::vendored_backend::repair::unledgered_vendor_references(ledger_root, state) .await .into_iter() - .filter(|(eco, uuid, _)| { - crate::commands::vendor::ecosystem_in_scope(common, eco) - && !state - .entries - .values() - .any(|e| e.ecosystem == *eco && e.uuid == *uuid) - }) + .filter(|(eco, _, _)| crate::commands::vendor::ecosystem_in_scope(common, eco)) .map(|(eco, uuid, _)| format!(".socket/vendor/{eco}/{uuid}/")) .collect(); if dirs.is_empty() { return None; } dirs.sort(); - dirs.dedup(); Some(format!( "lockfiles reference {}, which the vendor ledger (.socket/vendor/state.json) has no \ - entry for, so rollback cannot unwind {}; {}, then roll back", + entry for, so rollback cannot unwind them; {}, then roll back", dirs.join(", "), - if dirs.len() == 1 { "it" } else { "them" }, crate::commands::vendor_orphans::UNLEDGERED_WIRING_REMEDY, )) } +/// Report a top-level rollback error: the error envelope on `--json` +/// (message verbatim), an `Error:` stderr line otherwise. Errors print even +/// under --silent ("errors only", never "nothing"). fn emit_rollback_error(json: bool, dry_run: bool, code: &str, msg: &str) { if json { println!( @@ -1634,6 +1626,7 @@ pub async fn run(args: RollbackArgs) -> i32 { if let Ok(state) = &vendor_state_result { if let Some(detail) = unledgered_vendor_wiring(&args.common, &ledger_root, state).await { + track_patch_rollback_failed(&detail, &telemetry).await; emit_rollback_error( args.common.json, args.common.dry_run, diff --git a/crates/socket-patch-cli/src/commands/vendor.rs b/crates/socket-patch-cli/src/commands/vendor.rs index 8933fe66a..0270d041b 100644 --- a/crates/socket-patch-cli/src/commands/vendor.rs +++ b/crates/socket-patch-cli/src/commands/vendor.rs @@ -1330,13 +1330,10 @@ async fn run_check(args: &VendorArgs) -> i32 { // know (the ledger was ignored or dropped from the commit along with the // manifest) leaves every fresh install failing; the manifest keys above // cannot see it, so the references are read from the wiring itself. - let references = crate::commands::vendored_backend::repair::scan_vendor_references(root).await; + let references = + crate::commands::vendored_backend::repair::unledgered_vendor_references(root, &state).await; for (eco, uuid, rel) in references { - let ledgered = state - .entries - .values() - .any(|entry| entry.uuid == uuid && entry.ecosystem == eco); - if ledgered || unledgered_uuids.contains(uuid.as_str()) { + if unledgered_uuids.contains(uuid.as_str()) { continue; } // No ledger entry means no purl to name: like repair, the event diff --git a/crates/socket-patch-cli/src/commands/vendor_orphans.rs b/crates/socket-patch-cli/src/commands/vendor_orphans.rs index c917b9ab4..b3a5cbf38 100644 --- a/crates/socket-patch-cli/src/commands/vendor_orphans.rs +++ b/crates/socket-patch-cli/src/commands/vendor_orphans.rs @@ -112,9 +112,9 @@ pub(crate) fn orphan_label(unit: &vendor::path::SweptVendorDir) -> String { /// merge kept one side of state.json, both sides' `entries` merged), or /// the lockfile goes back to its pre-vendor version. pub(crate) const UNLEDGERED_WIRING_REMEDY: &str = - "restore its entry in .socket/vendor/state.json from version control (if a git merge \ - kept one side of state.json, merge both sides' `entries`), or restore the lockfile \ - with `git checkout -- `"; + "restore the missing entries in .socket/vendor/state.json from version control (if a \ + git merge kept one side of state.json, merge both sides' `entries`), or restore the \ + lockfile with `git checkout -- `"; /// The advisory for an orphan uuid dir the sweep kept because a project /// lockfile still points into it. diff --git a/crates/socket-patch-cli/src/commands/vendored_backend/repair.rs b/crates/socket-patch-cli/src/commands/vendored_backend/repair.rs index 15163d100..34d8dbeb7 100644 --- a/crates/socket-patch-cli/src/commands/vendored_backend/repair.rs +++ b/crates/socket-patch-cli/src/commands/vendored_backend/repair.rs @@ -185,6 +185,26 @@ fn rebuild_reason_label(code: &str) -> &str { } } +/// The [`scan_vendor_references`] triples whose `(ecosystem, uuid)` no +/// entry of `state` owns: lockfile wiring the ledger lost (state.json +/// deleted, or a git merge kept one side of it, #991). `vendor --check` +/// fails on them and an unscoped `rollback` refuses them. +pub(crate) async fn unledgered_vendor_references( + project_root: &Path, + state: &socket_patch_core::vendor::VendorState, +) -> Vec<(String, String, String)> { + scan_vendor_references(project_root) + .await + .into_iter() + .filter(|(eco, uuid, _)| { + !state + .entries + .values() + .any(|entry| entry.ecosystem == *eco && entry.uuid == *uuid) + }) + .collect() +} + /// The `vendor_ledger_missing` detail for a lockfile reference no ledger /// entry owns. fn format_ledger_missing(eco: &str, uuid: &str) -> String { diff --git a/crates/socket-patch-cli/tests/in_process_rollback_vendored.rs b/crates/socket-patch-cli/tests/in_process_rollback_vendored.rs index fb22144eb..39a8cab4a 100644 --- a/crates/socket-patch-cli/tests/in_process_rollback_vendored.rs +++ b/crates/socket-patch-cli/tests/in_process_rollback_vendored.rs @@ -1026,8 +1026,8 @@ async fn unscoped_rollback_refuses_a_still_wired_orphan() { assert!(orphan_dir(&fx, OTHER_ORPHAN_UUID).is_dir()); } - // A scoped rollback of the ledgered package does not own the other - // wiring: it reverts its target as before. + // A scoped rollback does not own the other package's wiring: it + // reverts its target as before. let (code, env) = rollback_cli(fx.root(), &[PURL]); assert_eq!(code, 0, "rollback exits 0: {env:#}"); assert!(!fx.tgz_path().exists(), "the target is reverted"); @@ -1037,6 +1037,41 @@ async fn unscoped_rollback_refuses_a_still_wired_orphan() { ); } +/// A scoped rollback whose target's own orphan dir a lockfile still points +/// at keeps that dir and names it with `vendor_orphan_still_wired`, with a +/// remedy that works in v5 (not `repair`). +#[tokio::test] +async fn scoped_rollback_keeps_a_still_wired_orphan_of_its_target() { + let fx = vendored_fixture_with_orphans().await; + // A nested copy of the target wired to the target's orphan dir. + let mut lock: Value = serde_json::from_slice(&fx.lock_bytes()).unwrap(); + lock["packages"]["node_modules/other/node_modules/left-pad"] = json!({ + "version": "1.3.0", + "resolved": format!("file:.socket/vendor/npm/{ORPHAN_UUID}/left-pad-1.3.0.tgz"), + }); + std::fs::write(fx.lock_path(), serde_json::to_vec_pretty(&lock).unwrap()).unwrap(); + + let (code, env) = rollback_cli(fx.root(), &[PURL]); + assert_eq!(code, 0, "rollback exits 0: {env:#}"); + rollback_json::assert_rollback_envelope(&env); + let warnings = env["warnings"].as_array().cloned().unwrap_or_default(); + let still_wired = warnings + .iter() + .find(|w| w["code"] == "vendor_orphan_still_wired") + .unwrap_or_else(|| panic!("the kept orphan is named: {env:#}")); + let detail = still_wired["message"] + .as_str() + .or_else(|| still_wired["detail"].as_str()) + .unwrap_or_default(); + assert!(detail.contains(ORPHAN_UUID), "{env:#}"); + assert!(!detail.contains("socket-patch repair"), "{env:#}"); + assert!( + orphan_dir(&fx, ORPHAN_UUID).is_dir(), + "a wired orphan is never deleted" + ); + assert!(!fx.tgz_path().exists(), "the target is reverted"); +} + /// A scoped rollback sweeps only the orphans of the package it targets. #[tokio::test] async fn scoped_rollback_sweeps_only_the_targets_orphans() { From 2120d222eebfd17e79d046e05c5b6c062e37e71e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 09:12:22 +0000 Subject: [PATCH 5/7] Keep entries with a stray ecosystem owning wiring A ledger entry whose ecosystem has no backend in this build (or was hand-edited) still owns its uuid dir. The new rollback refusal matched on ecosystem and uuid together, so it refused such a project as "unledgered wiring" instead of letting that entry's own revert fail with vendor_revert_failed. Rollback now treats any entry carrying the uuid as the owner. Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-cli/src/commands/rollback.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/socket-patch-cli/src/commands/rollback.rs b/crates/socket-patch-cli/src/commands/rollback.rs index 659d171e2..249bbd786 100644 --- a/crates/socket-patch-cli/src/commands/rollback.rs +++ b/crates/socket-patch-cli/src/commands/rollback.rs @@ -914,7 +914,13 @@ async fn unledgered_vendor_wiring( crate::commands::vendored_backend::repair::unledgered_vendor_references(ledger_root, state) .await .into_iter() - .filter(|(eco, _, _)| crate::commands::vendor::ecosystem_in_scope(common, eco)) + // An entry that carries the uuid under another ecosystem (an + // unknown backend, a hand edit) still owns it: its own revert + // reports what is wrong with it. + .filter(|(eco, uuid, _)| { + crate::commands::vendor::ecosystem_in_scope(common, eco) + && !state.entries.values().any(|e| e.uuid == *uuid) + }) .map(|(eco, uuid, _)| format!(".socket/vendor/{eco}/{uuid}/")) .collect(); if dirs.is_empty() { From 274386a53b0a3c21ef91ad0179f6f88a0685588f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 09:19:22 +0000 Subject: [PATCH 6/7] Pin unearth<0.19 in the PDM e2e bootstrap Ported from #1738 so this PR's PDM e2e leg can go green. unearth 0.19 moved to httpx2, and PDM 2.x's unbounded unearth now resolves it, so `pdm lock` crashes with "PDMPyPIClient is not an instance or subtype of type (Client)". This is a no-op once #1738 lands on main. Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-cli/tests/e2e_vex_build/pdm.rs | 6 ++++++ scripts/backtest-pdm.py | 5 +++++ 2 files changed, 11 insertions(+) diff --git a/crates/socket-patch-cli/tests/e2e_vex_build/pdm.rs b/crates/socket-patch-cli/tests/e2e_vex_build/pdm.rs index 7331301bd..16efed366 100644 --- a/crates/socket-patch-cli/tests/e2e_vex_build/pdm.rs +++ b/crates/socket-patch-cli/tests/e2e_vex_build/pdm.rs @@ -137,6 +137,12 @@ fn pins_for(v: &str) -> Vec { if ((2, 21, 0)..(2, 26, 1)).contains(&t) { pins.push("hishel<1"); } + // 2.x declares an unbounded `unearth`, and unearth 0.19 (2026-10-27) + // moved to `httpx2`: PDM's PyPI client is no longer the `httpx` + // client unearth expects, so `pdm lock` dies with "PDMPyPIClient is + // not an instance or subtype of type (Client)" (seen on 2.29.2). + // `<0.19` resolves the 0.18 line every 2.x ran with before. + pins.push("unearth<0.19"); } let mut out = vec![format!("pdm=={v}")]; out.extend(pins.into_iter().map(String::from)); diff --git a/scripts/backtest-pdm.py b/scripts/backtest-pdm.py index e7f4db789..d403cb545 100644 --- a/scripts/backtest-pdm.py +++ b/scripts/backtest-pdm.py @@ -204,6 +204,11 @@ def pins_for(version): # ModuleNotFoundError before locking. if (2, 21) <= v < (2, 26, 1): pins.append("hishel<1") + # 2.x declares an unbounded `unearth`; 0.19 (2026-10-27) moved to + # httpx2 and breaks PDM's PyPI client ("PDMPyPIClient is not an + # instance or subtype of type (Client)" on `pdm lock`, seen on + # 2.29.2). `<0.19` resolves the 0.18 line 2.x ran with before. + pins.append("unearth<0.19") return pins From dcd512732c3c2276904f15a9123bc4705641c8d6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 09:21:50 +0000 Subject: [PATCH 7/7] Drop the lockfile checkout from the orphan advice The vendor_orphan_still_wired advisory follows a revert that has already rewritten the lockfile, so telling users to run `git checkout -- ` would bring back the wiring that revert just removed. The advisory now names only the ledger restore. The up-front rollback refusal, which writes nothing, still offers returning the lockfiles to their pre-vendor versions. Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-cli/CLI_CONTRACT.md | 2 +- crates/socket-patch-cli/src/commands/rollback.rs | 3 ++- .../socket-patch-cli/src/commands/vendor_orphans.rs | 13 ++++++++----- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 70995cc9f..c2dd5f913 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -1148,7 +1148,7 @@ worse, lets a warm cache silently serve unpatched bytes): A bare `rollback` (or a scoped one, for its scope) restores the SYSTEM to unpatched and cleans up the local state, in phases under one `apply.lock` acquisition: -1. **State discovery.** A missing manifest is no longer fatal when the vendor ledger or the lockfiles' hosted pins hold work (`rollback` runs manifest-less on hosted-only / vendored projects — every `scan`/`get --mode vendored` and v5 `scan --mode hosted` project is manifest-less). The **truly-empty** project — no manifest, no vendor ledger, no hosted pin — keeps the "Manifest not found" exit 1 (JSON: the error envelope with `error.code: "manifest_not_found"` plus a top-level `path`), with one v5.0 exception: when a pre-v5 `.socket/vendor/redirect-state.json` is the only thing left, nothing pins it any more, so a wet run deletes it and exits 0 (human `Removed the pre-v5 hosted ledger .socket/vendor/redirect-state.json: no lockfile pins a hosted patch.`, `Would remove …` on `--dry-run`, which deletes nothing; JSON: a `success` envelope with no events plus `legacyRedirectLedgerRemoved` (bool); a failed delete is the `legacy_redirect_ledger_kept` warning, still exit 0). A project whose lockfiles still reference `.socket/vendor/` artifacts but whose vendor ledger is missing errors asking for `.socket/vendor/state.json` to be restored from version control first (v5.0: `repair` no longer reconstructs the ledger). An unscoped run whose ledger is present but does not own every `.socket/vendor///` dir a lockfile references (in the `--ecosystems` scope) refuses the same way, before any write and on `--dry-run` and `--preserve-state` too: the usual cause is a git merge that kept one side of `state.json`, and reverting the rest would exit 0, leave that package wired and delete the ledger (#991). The `vendor_ledger_missing` error names each unowned dir; recovery is to restore its entry (after such a merge, merge both sides' `entries`) or restore the lockfile. A scoped run does not own that wiring and proceeds. **Corrupt-ledger containment**: an unreadable vendor ledger fails ONLY the legs that need it — the vendored leg, manifest cleanup, and GC are skipped fail-closed (`vendor_state_unreadable` warning) while the agent and hosted legs still run; it drives `partialFailure` exit 1, and an emergency restore is never blocked by it. When the ONLY state on disk is an unreadable vendor ledger, the run fails closed naming the store. A pre-v5 redirect ledger is never read by rollback (v4's `redirect_state_unreadable` is no longer emitted). Under `--global`/`--global-prefix` the project's hosted pins and vendor ledger are not discovered at all, so the vendored and hosted legs below do not run (see "Global scope never touches the project's state"). +1. **State discovery.** A missing manifest is no longer fatal when the vendor ledger or the lockfiles' hosted pins hold work (`rollback` runs manifest-less on hosted-only / vendored projects — every `scan`/`get --mode vendored` and v5 `scan --mode hosted` project is manifest-less). The **truly-empty** project — no manifest, no vendor ledger, no hosted pin — keeps the "Manifest not found" exit 1 (JSON: the error envelope with `error.code: "manifest_not_found"` plus a top-level `path`), with one v5.0 exception: when a pre-v5 `.socket/vendor/redirect-state.json` is the only thing left, nothing pins it any more, so a wet run deletes it and exits 0 (human `Removed the pre-v5 hosted ledger .socket/vendor/redirect-state.json: no lockfile pins a hosted patch.`, `Would remove …` on `--dry-run`, which deletes nothing; JSON: a `success` envelope with no events plus `legacyRedirectLedgerRemoved` (bool); a failed delete is the `legacy_redirect_ledger_kept` warning, still exit 0). A project whose lockfiles still reference `.socket/vendor/` artifacts but whose vendor ledger is missing errors asking for `.socket/vendor/state.json` to be restored from version control first (v5.0: `repair` no longer reconstructs the ledger). An unscoped run whose ledger is present but does not own every `.socket/vendor///` dir a lockfile references (in the `--ecosystems` scope) refuses the same way, before any write and on `--dry-run` and `--preserve-state` too: the usual cause is a git merge that kept one side of `state.json`, and reverting the rest would exit 0, leave that package wired and delete the ledger (#991). The `vendor_ledger_missing` error names each unowned dir; recovery is to restore its entry (after such a merge, merge both sides' `entries`) or return the lockfiles to their pre-vendor versions. The `vendor_orphan_still_wired` advisory names only the ledger restore: it follows a revert that already rewrote the lockfile. A scoped run does not own that wiring and proceeds. **Corrupt-ledger containment**: an unreadable vendor ledger fails ONLY the legs that need it — the vendored leg, manifest cleanup, and GC are skipped fail-closed (`vendor_state_unreadable` warning) while the agent and hosted legs still run; it drives `partialFailure` exit 1, and an emergency restore is never blocked by it. When the ONLY state on disk is an unreadable vendor ledger, the run fails closed naming the store. A pre-v5 redirect ledger is never read by rollback (v4's `redirect_state_unreadable` is no longer emitted). Under `--global`/`--global-prefix` the project's hosted pins and vendor ledger are not discovered at all, so the vendored and hosted legs below do not run (see "Global scope never touches the project's state"). 2. **Agent leg** — the existing in-place restore machinery, unchanged (v5.0 presentation: the human `No patches found in manifest` line prints only for an unscoped run with no work in ANY leg — a run whose work is all vendored/hosted stays quiet about the manifest): multi-copy restore, release-variant narrowing, the before-blob gate (+ on-demand download; a gate abort still exits 1 with per-package `missing_blob` failure results **and** skips manifest cleanup + GC entirely — nothing was restored, and the retry's revert data must survive), local-go redirect drop, and the `not_installed` exit-0 asymmetry verbatim. Vendor-owned purls are still excluded here (see the vendored-mode section) — they are handled by the next leg instead of being punted to other commands — with one exception: a vendored Cargo crate whose copy in the shared `$CARGO_HOME/registry/src` cache (or a `cargo vendor` dir) still carries an earlier agent-mode patch is restored here too (vendoring never touches that copy, and the manifest record holds the only blobs that can restore it); its record leaves the manifest only when both this leg and the vendored leg succeeded. `remove` restores it the same way. 3. **Vendored leg** — each in-scope ledger entry (embedded-record entries included) is reverted through the vendor backends: lockfile wiring restored, artifact dir deleted (and its emptied `.socket/vendor//` husk pruned, v5.0), ledger entry dropped + persisted per purl (crash-consistent, like `vendor --revert`). A **drift-keep** (the backend refused a drifted lock) keeps the entry, the artifact, AND the manifest record (a `failed` `vendor_revert_kept` event, exit 1 — the system is still patched); a failure is recorded and other entries proceed. Then, on a run with work in any leg, uuid dirs under `.socket/vendor//` that no ledger entry owns (the losing side of a git merge of two vendor runs, an interrupted run) are swept as `vendor --revert` sweeps them: every one in the `--ecosystems` scope on an unscoped run, only those whose leaf names a targeted package (or whose uuid is a targeted uuid) on a scoped one; path-glob targets sweep none. The confirmation prompt counts them (`delete N orphaned vendored dirs with no ledger record`). A dir a lockfile still points into is kept with a `vendor_orphan_still_wired` warning (only a scoped run can reach one: an unscoped run refuses such wiring up front, see State discovery), and one that cannot be deleted is a `cleanup_failed` warning. Skipped under `--preserve-state`, global scope, an unreadable ledger and a before-blob gate abort. `remove ` sweeps the orphans its target names the same way, once its vendored revert and manifest write have succeeded; they are `removed` `vendor_orphan_removed` events that `summary.removed` does not count, and the kept or undeletable ones `skipped` advisories (#1610). 4. **Hosted leg** — each in-scope hosted pin is restored to its default upstream registry entry; see "Hosted unwind coverage" below. After a hosted leg with no failure, a wet run deletes a pre-v5 `redirect-state.json` once no lockfile pins a hosted patch any more (a failed delete is the `legacy_redirect_ledger_kept` warning). diff --git a/crates/socket-patch-cli/src/commands/rollback.rs b/crates/socket-patch-cli/src/commands/rollback.rs index 249bbd786..6f98e9957 100644 --- a/crates/socket-patch-cli/src/commands/rollback.rs +++ b/crates/socket-patch-cli/src/commands/rollback.rs @@ -929,7 +929,8 @@ async fn unledgered_vendor_wiring( dirs.sort(); Some(format!( "lockfiles reference {}, which the vendor ledger (.socket/vendor/state.json) has no \ - entry for, so rollback cannot unwind them; {}, then roll back", + entry for, so rollback cannot unwind them; {} and roll back, or return the \ + lockfiles to their pre-vendor versions", dirs.join(", "), crate::commands::vendor_orphans::UNLEDGERED_WIRING_REMEDY, )) diff --git a/crates/socket-patch-cli/src/commands/vendor_orphans.rs b/crates/socket-patch-cli/src/commands/vendor_orphans.rs index b3a5cbf38..abe61651f 100644 --- a/crates/socket-patch-cli/src/commands/vendor_orphans.rs +++ b/crates/socket-patch-cli/src/commands/vendor_orphans.rs @@ -109,12 +109,13 @@ pub(crate) fn orphan_label(unit: &vendor::path::SweptVendorDir) -> String { /// How to recover a lockfile reference into `.socket/vendor/` that the /// ledger lost. `repair` does not rebuild ledger entries from lockfiles /// (v5.0), so the entry must come back from version control (after a git -/// merge kept one side of state.json, both sides' `entries` merged), or -/// the lockfile goes back to its pre-vendor version. +/// merge kept one side of state.json, both sides' `entries` merged). It +/// names no lockfile restore: the advisory below follows a revert that +/// already rewrote the lockfile, and checking it out again would re-wire +/// what that revert removed. pub(crate) const UNLEDGERED_WIRING_REMEDY: &str = "restore the missing entries in .socket/vendor/state.json from version control (if a \ - git merge kept one side of state.json, merge both sides' `entries`), or restore the \ - lockfile with `git checkout -- `"; + git merge kept one side of state.json, merge both sides' `entries`)"; /// The advisory for an orphan uuid dir the sweep kept because a project /// lockfile still points into it. @@ -234,6 +235,8 @@ mod tests { ); assert!(!detail.contains("socket-patch repair"), "{detail}"); assert!(detail.contains("entries"), "{detail}"); - assert!(detail.contains("git checkout -- "), "{detail}"); + // The advisory follows a revert that rewrote the lockfile: + // checking the lockfile out again would re-wire what it removed. + assert!(!detail.contains("git checkout"), "{detail}"); } }