Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions crates/socket-patch-cli/CLI_CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -1139,7 +1139,7 @@ A bare `rollback` (or a scoped one, for its scope) restores the SYSTEM to unpatc

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").
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/<eco>/` 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.
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/<eco>/` 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/<eco>/` 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 <target>` 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).
Expand Down Expand Up @@ -1185,11 +1185,12 @@ v5.0 replaces v4's per-purl reverts and whole-ledger reverse replay (`revert_rem
| Vendored leg: reverted / preserved (`--preserve-state`) | `rolledBack` (`verified` on `--dry-run`), `details.mode: "vendored"`; preserved adds `details.preserved: true` |
| Vendored leg: drift-keep | `failed` `vendor_revert_kept`, `details.mode: "vendored"` (exit 1: the system is still patched) |
| Vendored leg: revert failed | `failed` `vendor_revert_failed` (`vendor_state_write_failed` when the ledger save failed), `details.mode: "vendored"` |
| Vendored leg: orphan uuid dir swept | `removed` `vendor_orphan_removed` (also on `--dry-run`), `details.mode: "vendored"`; `purl` is the package its leaf names, else `<eco>/<uuid>` |
| Hosted leg: pin restored | `rolledBack` (`verified` on `--dry-run`), `details.mode: "hosted"` |
| Hosted leg: pin refused / write failed / contested wiring | `failed` `hosted_restore_refused` / artifact-level `hosted_write_failed` / artifact-level `hosted_wiring_contested`, `details.mode: "hosted"` |
| Manifest entry dropped | `removed` (`verified` on `--dry-run`) with `details.manifest: true` |

Events carry the patch `uuid` when one is recorded. A package wired through two legs gets one event per leg. Cargo's `.cargo-checksum.json` resync rides the envelope's `sidecars[]`. Rollback's own top-level keys: `hosted: {editedFiles: N}` (distinct files the hosted leg rewrote) and `paths` (the path-glob targets verbatim, `[]` when none); `path` on `manifest_not_found` and `legacyRedirectLedgerRemoved` on the pre-v5-ledger exit. `gc` is the shared GC object (via `summary.bytesFreed` too); it is absent under `--preserve-state`, and absent with a `gc_skipped` warning after a before-blob gate abort or under a corrupt vendor ledger. `warnings[]` (omitted when empty): `reinstall_required`, `hosted_state_not_preservable`, `out_of_scope_copies_restored`, `vendor_state_unreadable`, `cleanup_failed`, `manifest_write_failed`, `gc_skipped`, `legacy_redirect_ledger_kept`, `ownership_not_restored`, `rollback_record_superseded`, `gradle_m2_copy_not_restored`, `cargo_build_cache_stale` (see the apply warnings), the upstream-restore advisories (including `hosted_resolution_orphaned`) and the vendored/hosted leg advisories.
Events carry the patch `uuid` when one is recorded. A package wired through two legs gets one event per leg. Cargo's `.cargo-checksum.json` resync rides the envelope's `sidecars[]`. Rollback's own top-level keys: `hosted: {editedFiles: N}` (distinct files the hosted leg rewrote) and `paths` (the path-glob targets verbatim, `[]` when none); `path` on `manifest_not_found` and `legacyRedirectLedgerRemoved` on the pre-v5-ledger exit. `gc` is the shared GC object (via `summary.bytesFreed` too); it is absent under `--preserve-state`, and absent with a `gc_skipped` warning after a before-blob gate abort or under a corrupt vendor ledger. `warnings[]` (omitted when empty): `reinstall_required`, `hosted_state_not_preservable`, `out_of_scope_copies_restored`, `vendor_state_unreadable`, `cleanup_failed`, `manifest_write_failed`, `gc_skipped`, `legacy_redirect_ledger_kept`, `ownership_not_restored`, `rollback_record_superseded`, `gradle_m2_copy_not_restored`, `cargo_build_cache_stale` (see the apply warnings), `vendor_orphan_still_wired`, the upstream-restore advisories (including `hosted_resolution_orphaned`) and the vendored/hosted leg advisories.

**Status and exit**: `success` (exit 0) unless something leaves the system still patched — an agent failure, a vendored drift-keep or failure, a hosted refusal, contested wiring under an unscoped run, a corrupt vendor ledger, a failed manifest write — which is `partialFailure` (exit 1). When something failed and nothing was rolled back, previewed, already original or not installed, the run failed as a whole: `status: "error"` with `error: {code: "rollback_failed"}` (exit 1; the `failed` events stay). Not-installed entries never flip the exit (the apply/rollback asymmetry). GC failures never affect the exit. A dry run's would-be refusals stay `failed` events (not `skipped`): a dry run that cannot roll something back exits 1, as before. Every early error (`manifest_not_found`, `manifest_invalid`, `manifest_unreadable`, `patch_not_found`, `ambiguous_target`, `path_glob_no_match`, `hosted_wiring_contested`, `vendor_ledger_missing`, `lock_held` / `lock_io`, the catch-all `rollback_failed`, and the exit-2 usage error `path_glob_invalid`) is a full envelope with empty `events`.

Expand Down
1 change: 1 addition & 0 deletions crates/socket-patch-cli/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ pub mod rollback;
pub mod scan;
pub mod update;
pub mod vendor;
pub(crate) mod vendor_orphans;
pub(crate) mod vendored_backend;
pub mod vex;
pub(crate) mod vex_consumed;
Expand Down
75 changes: 73 additions & 2 deletions crates/socket-patch-cli/src/commands/remove.rs
Original file line number Diff line number Diff line change
Expand Up @@ -515,7 +515,7 @@ pub async fn run(args: RemoveArgs) -> i32 {
if let Ok(state) = vendor_state_result {
let ledger_matches = vendor_entries_matching(&state, None, &target);
if !ledger_matches.is_empty() {
return remove_ledger_only(&args, ledger_matches, state, &telemetry).await;
return remove_ledger_only(&args, &target, ledger_matches, state, &telemetry).await;
}
}

Expand Down Expand Up @@ -916,6 +916,8 @@ pub async fn run(args: RemoveArgs) -> i32 {
return 1;
}
}
sweep_target_orphans(&args, &vendor_state, &target, &mut vendor_leg).await;
printed_progress |= loud && vendor_leg.printed;

if loud {
if printed_progress {
Expand Down Expand Up @@ -1117,6 +1119,7 @@ pub async fn run(args: RemoveArgs) -> i32 {
use crate::commands::vendored_backend::tag_event_mode;
use crate::commands::{HOSTED_MODE_LABEL, VENDORED_MODE_LABEL};
let mut vendor_reverted = vendor_leg.reverted;
vendor_reverted.extend(vendor_leg.orphans);
tag_event_mode(&mut vendor_reverted, VENDORED_MODE_LABEL);
env.events.extend(vendor_reverted);
// Hosted unwinds likewise bypass `record` — summary.removed stays
Expand Down Expand Up @@ -1257,6 +1260,10 @@ struct RemoveVendorLeg {
kept: Vec<String>,
/// Why each [`Self::kept`] entry was kept (same order).
kept_causes: Vec<KeepCause>,
/// `Removed`/`vendor_orphan_removed` events (dry runs too, as in
/// `vendor --revert`): uuid dirs of the reverted packages that no
/// ledger entry owns, swept after the reverts (#1610).
orphans: Vec<PatchEvent>,
/// Entries actually reverted and dropped from the ledger (wet runs).
reverted_count: usize,
/// Entries unwired with their artifact and ledger entry kept
Expand Down Expand Up @@ -1412,6 +1419,60 @@ async fn revert_vendored_matches(
Ok(leg)
}

/// Sweep the uuid dirs under `.socket/vendor/` that no ledger entry owns
/// and `target` names (a git merge of two vendor runs keeps both sides'
/// dirs, #1610), as `vendor --revert` does: into `leg.orphans`, with a
/// `skipped` advisory for each one a lockfile still points into (kept) or
/// that could not be deleted. Runs only after a vendored revert, past the
/// remove's last abort point. `--preserve-state` keeps local state,
/// orphans included.
async fn sweep_target_orphans(
args: &RemoveArgs,
state: &VendorState,
target: &Target,
leg: &mut RemoveVendorLeg,
) {
use crate::commands::vendor_orphans::{
orphan_in_target, orphan_label, orphan_removal_failed, orphan_removed_event,
orphan_still_wired, print_orphan_sweep, sweep_orphan_vendor_dirs_where,
};
if args.preserve_state || leg.reverted.is_empty() {
return;
}
let sweep = sweep_orphan_vendor_dirs_where(
&args.common.project_root(),
state,
args.common.dry_run,
|unit| orphan_in_target(target, unit),
)
.await;
let loud = !args.common.json && !args.common.silent;
let warnings = sweep
.still_wired
.iter()
.map(|unit| (unit, orphan_still_wired(unit)))
.chain(
sweep
.failed
.iter()
.map(|(unit, e)| (unit, orphan_removal_failed(unit, e))),
);
for (unit, warning) in warnings {
if loud {
eprintln!("Warning: {}", warning.detail);
}
leg.skipped.push(
PatchEvent::new(PatchAction::Skipped, orphan_label(unit))
.with_reason(warning.code, warning.detail),
);
}
if loud && !sweep.removed.is_empty() {
print_orphan_sweep(args.common.dry_run, &sweep);
leg.printed = true;
}
leg.orphans = sweep.removed.iter().map(orphan_removed_event).collect();
}

/// Why a hosted unwind stopped: a pin the upstream restore refused (or a
/// write failure). Each caller renders its own message (the
/// manifest-backed path adds that the manifest was not touched).
Expand Down Expand Up @@ -1591,6 +1652,7 @@ async fn remove_hosted_only(
/// patch can only mean reverting its vendoring.
async fn remove_ledger_only(
args: &RemoveArgs,
target: &Target,
matches: Vec<(String, VendorEntry)>,
mut state: VendorState,
telemetry: &TelemetryAuth,
Expand Down Expand Up @@ -1657,10 +1719,11 @@ async fn remove_ledger_only(
}

let keys: Vec<String> = matches.iter().map(|(k, _)| k.clone()).collect();
let leg = match revert_vendored_matches(args, &keys, &mut state, telemetry, false).await {
let mut leg = match revert_vendored_matches(args, &keys, &mut state, telemetry, false).await {
Ok(leg) => leg,
Err(code) => return code,
};
sweep_target_orphans(args, &state, target, &mut leg).await;

let mut env = Envelope::new(Command::Remove);
env.dry_run = args.common.dry_run;
Expand All @@ -1675,6 +1738,14 @@ async fn remove_ledger_only(
for ev in events {
env.record(ev);
}
// Swept orphan dirs bypass `record`: `summary.removed` counts the
// reverted entries, not the dirs no entry owned.
let mut orphans = leg.orphans;
crate::commands::vendored_backend::tag_event_mode(
&mut orphans,
crate::commands::VENDORED_MODE_LABEL,
);
env.events.extend(orphans);
if !leg.kept.is_empty() {
// Any drift-kept entry means part of the requested removal did
// NOT happen: partialFailure (exit 1). When EVERY match kept, the
Expand Down
Loading
Loading