Skip to content
Open
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -766,7 +766,7 @@ socket-patch setup --remove # revert what setup added
| Flag | Env var | Description |
|------|---------|-------------|
| `--check` | — | Read-only verification that every manifest is configured **and** every installed patch is still applied on disk (each file matches its recorded `afterHash`); exits non-zero if any manifest still needs setup or a patch has drifted. Never writes (safe in CI). Conflicts with `--remove`. |
| `--remove` | — | Revert every install hook `setup` added (npm `package.json` scripts, the Python `socket-patch[hook]` dependency, the gem Bundler plugin wiring, and the Composer `post-install-cmd`/`post-update-cmd` script entries). |
| `--remove` | — | Revert every install hook `setup` added (npm `package.json` scripts, the Python `socket-patch[hook]` dependency, the gem Bundler plugin wiring — including bundler's machine-local `.bundle/plugin` registration, so later `bundle install`s don't warn about the unwired plugin — and the Composer `post-install-cmd`/`post-update-cmd` script entries). If the registration can't be cleared automatically (unexpected index format), the error names the fallback: `bundler plugin uninstall socket-patch`. |
| `--exclude <paths>` | `SOCKET_SETUP_EXCLUDE` | Workspace-member path(s) to exclude from setup (comma-separated, relative to the repo root). The exclusion is persisted in `.socket/manifest.json`, so `setup --check` and a fresh clone honor it without re-passing the flag. |

#### Disabling / opting out (Python hook)
Expand Down
5 changes: 4 additions & 1 deletion crates/socket-patch-cli/CLI_CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -294,7 +294,10 @@ wins, pinned by `find_by_purls_prefers_root_copy_over_nested_duplicate`).

`setup` predates the v3.0 unified envelope and emits its own three shapes. They are stable as of v3.0;
consumers may rely on these keys. All three share a `files[*]` entry shape; `kind` is one of
`package_json`, `pth`, `gemfile`, `gem_plugin`, `composer`.
`package_json`, `pth`, `gemfile`, `gem_plugin`, `composer`, `gem_plugin_registration` (the last is
`setup --remove`-only: clearing bundler's machine-local `.bundle/plugin` registration of the wired
plugin — emitted only when a registration existed; `status: error` carries the
`bundler plugin uninstall socket-patch` remedy when it could not be cleared safely).

**`setup`:**

Expand Down
207 changes: 207 additions & 0 deletions crates/socket-patch-cli/tests/setup_matrix_gem.rs
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,13 @@ mod host_guard {
/// explicit flags alone — nothing reaches authed endpoints and no ambient
/// var can stand in for a flag.
fn run(cwd: &Path, args: &[&str]) -> (i32, String, String) {
run_env(cwd, args, &[])
}

/// [`run`] with extra environment variables for the child (e.g. bundler's
/// `BUNDLE_APP_CONFIG`, which relocates the machine-local registration
/// `--remove` must clean).
fn run_env(cwd: &Path, args: &[&str], envs: &[(&str, &str)]) -> (i32, String, String) {
let mut cmd = Command::new(binary());
cmd.args(args).current_dir(cwd);
// Prefix-scrub the whole ambient `SOCKET_*` surface (mirrors
Expand All @@ -119,6 +126,13 @@ mod host_guard {
// would strip a developer's own opt-out. Force it off for the child —
// no assertion here concerns telemetry.
cmd.env("SOCKET_TELEMETRY_DISABLED", "1");
// An ambient BUNDLE_APP_CONFIG would relocate where `--remove` looks
// for bundler's plugin registration — strip it so only the explicit
// per-test env below can steer that resolution.
cmd.env_remove("BUNDLE_APP_CONFIG");
for (k, v) in envs {
cmd.env(k, v);
}
let out = cmd.output().expect("failed to execute socket-patch binary");
(
out.status.code().unwrap_or(-1),
Expand Down Expand Up @@ -313,6 +327,27 @@ mod host_guard {
// A stamp left behind by a previous apply: `--remove`'s no-residue
// contract covers it (it sits in the committed .socket/ dir).
std::fs::write(root.join(".socket/gem-plugin-stamp"), "e".repeat(64)).unwrap();
// Bundler's machine-local plugin registration, exactly as the first
// `bundle install` after `setup` writes it (bundler's YAMLSerializer
// dialect, verified against bundler 2.7.2 / 4.0.18): the hook
// subscriptions + load/plugin paths in `.bundle/plugin/index`.
// `--remove` must clear it too — a dangling registration makes every
// later `bundle install` print bundler's "The following plugin paths
// don't exist ... Continuing without installing plugin socket-patch"
// block (with a misleading reinstall suggestion) forever.
let plugin_reg_dir = root.join(".bundle").join("plugin");
std::fs::create_dir_all(&plugin_reg_dir).unwrap();
let index_path = plugin_reg_dir.join("index");
std::fs::write(
&index_path,
format!(
"---\ncommands:\nhooks:\n after-install:\n - \"socket-patch\"\n \
after-install-all:\n - \"socket-patch\"\nload_paths:\n socket-patch:\n \
- \"{root_s}/.socket/bundler-plugin/.\"\nplugin_paths:\n \
socket-patch: \"{root_s}/.socket/bundler-plugin\"\nsources:\n"
),
)
.unwrap();
let (code, out, err) = run(
root,
&["setup", "--remove", "--cwd", root_s, "--yes", "--json"],
Expand Down Expand Up @@ -342,6 +377,23 @@ mod host_guard {
!root.join(".socket/.gitignore").exists(),
"remove must delete the .gitignore setup created (it held only our line)"
);
// The machine-local registration must be gone too. socket-patch was
// the ONLY registered plugin, so nothing in the index is left worth
// keeping — the index (and thereby every socket-patch entry) must not
// survive.
let residue = std::fs::read_to_string(&index_path).unwrap_or_default();
assert!(
!residue.contains("socket-patch"),
"remove must clear bundler's machine-local plugin registration \
(.bundle/plugin/index) — a dangling entry makes every later \
`bundle install` warn \"plugin paths don't exist ... Continuing \
without installing plugin socket-patch\":\n{residue}"
);
assert!(
!index_path.exists(),
"socket-patch was the only registered plugin: the emptied index \
must be deleted, not left as an all-empty husk"
);

// ── check (after remove): needs_configuration again, exit 1 ─────────
let (code, out, _) = run(root, &["setup", "--check", "--cwd", root_s, "--json"]);
Expand All @@ -356,6 +408,88 @@ mod host_guard {
);
}

/// `setup --remove` must clear bundler's machine-local plugin
/// registration SURGICALLY: only the socket-patch entries leave the
/// index; another plugin's registration (its hook subscriptions and
/// paths) survives byte-intact. And the index location must follow
/// bundler's own `BUNDLE_APP_CONFIG` resolution — a relative value
/// resolves against the project root (`Bundler.app_config_path`), not
/// the process cwd or a hardcoded `.bundle`.
#[test]
fn gem_setup_remove_strips_registration_surgically_under_bundle_app_config() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(root.join("Gemfile"), GEMFILE).unwrap();
let root_s = root.to_str().unwrap();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New tests use bare unwrap

Low Severity

New test setup in this PR uses bare .unwrap() on tempfile::tempdir(), fixture writes, and path conversions. On failure CI only reports a generic unwrap panic with no step context. Prefer .expect("…") with a short description of the setup step being performed.

Additional Locations (2)
Fix in Cursor Fix in Web

Triggered by learned rule: Prefer .expect("context") over bare .unwrap() in test code

Reviewed by Cursor Bugbot for commit 499e498. Configure here.


// Wire the project (the registration below is what bundler would
// write on the first `bundle install` after this).
let (code, out, err) = run_env(
root,
&["setup", "--cwd", root_s, "--yes", "--json"],
&[("BUNDLE_APP_CONFIG", "bundle-config")],
);
assert_eq!(code, 0, "setup must exit 0.\n{out}\n{err}");

// The registration lives under the RELATIVE app-config dir, resolved
// against the project root — bundler's own resolution rule.
let plugin_reg_dir = root.join("bundle-config").join("plugin");
std::fs::create_dir_all(&plugin_reg_dir).unwrap();
let index_path = plugin_reg_dir.join("index");
std::fs::write(
&index_path,
format!(
"---\ncommands:\nhooks:\n after-install:\n - \"other-plugin\"\n \
- \"socket-patch\"\n after-install-all:\n - \"socket-patch\"\n \
before-install-all:\n - \"other-plugin\"\nload_paths:\n other-plugin:\n \
- \"{root_s}/plugins/other-plugin/.\"\n socket-patch:\n \
- \"{root_s}/.socket/bundler-plugin/.\"\nplugin_paths:\n \
other-plugin: \"{root_s}/plugins/other-plugin\"\n \
socket-patch: \"{root_s}/.socket/bundler-plugin\"\nsources:\n"
),
)
.unwrap();

let (code, out, err) = run_env(
root,
&["setup", "--remove", "--cwd", root_s, "--yes", "--json"],
&[("BUNDLE_APP_CONFIG", "bundle-config")],
);
assert_eq!(code, 0, "remove must exit 0.\n{out}\n{err}");
assert_eq!(
json_str(&parse_json(&out, "remove"), "status", "remove"),
"success"
);

let index = std::fs::read_to_string(&index_path).unwrap_or_else(|e| {
panic!(
"the index must SURVIVE (another plugin is still registered), \
not be deleted wholesale: {e}"
)
});
assert!(
!index.contains("socket-patch"),
"every socket-patch registration entry must be stripped:\n{index}"
);
for kept in [
" after-install:\n - \"other-plugin\"",
" before-install-all:\n - \"other-plugin\"",
&format!(" other-plugin:\n - \"{root_s}/plugins/other-plugin/.\"") as &str,
&format!(" other-plugin: \"{root_s}/plugins/other-plugin\"") as &str,
] {
assert!(
index.contains(kept),
"the OTHER plugin's registration must survive verbatim — \
missing {kept:?} in:\n{index}"
);
}
assert!(
!index.contains("after-install-all:"),
"a hook event left with NO subscribers must be dropped, not left \
as an empty key bundler chokes on:\n{index}"
);
}

/// `bundle` resolves the Gemfile by walking UP from the invocation dir,
/// and `discover_bundler_project` documents the same contract. Run from a
/// subdirectory with NO `--cwd` flag the CLI defaults to the RELATIVE
Expand Down Expand Up @@ -846,6 +980,79 @@ mod plugin_runtime {
);
}

/// [P2 remove leaves registration dangling] Bundler records the plugin
/// machine-locally at first install (`.bundle/plugin/index`: hook
/// subscriptions + plugin/load paths). `setup --remove` unwires the
/// Gemfile and deletes the generated plugin dir — if it leaves that
/// registration behind, EVERY later `bundle install` prints bundler's
/// 5-line "The following plugin paths don't exist ... Continuing without
/// installing plugin socket-patch" block with a misleading reinstall
/// suggestion (install still exits 0, so nothing ever heals it).
/// Reproduced against real bundler 2.7.2 and 4.0.18 in the 2026-08 e2e
/// campaign; this drives the same flow with the host bundler.
#[test]
fn setup_remove_clears_bundler_plugin_registration() {
if !have("bundle") {
eprintln!("skip plugin_runtime: bundler not on PATH");
return;
}
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
scaffold(root);
let (fake, _log) = write_fake_apply(root, 0);

// First install: bundler registers the plugin machine-locally.
let (code, out, err) = bundle_install(root, &fake, &[]);
assert_eq!(code, 0, "wired install must succeed.\n{out}\n{err}");
let index_path = root.join(".bundle/plugin/index");
assert!(
std::fs::read_to_string(&index_path)
.unwrap_or_default()
.contains("socket-patch"),
"precondition: bundler must have registered the plugin at {}",
index_path.display()
);

// Unwire.
let mut cmd = Command::new(binary());
cmd.args(["setup", "--remove", "--yes", "--json"])
.current_dir(root);
scrub(&mut cmd);
let (code, out, err) = run(cmd);
assert_eq!(code, 0, "setup --remove must exit 0.\n{out}\n{err}");
assert!(
out.contains("\"status\": \"success\""),
"setup --remove must report success:\n{out}"
);

// No socket-patch registration may survive under .bundle/plugin.
let residue = std::fs::read_to_string(&index_path).unwrap_or_default();
assert!(
!residue.contains("socket-patch"),
".bundle/plugin must hold no socket-patch entry after remove:\n{residue}"
);

// And the REAL oracle: the next bundle install is silent about the
// unwired plugin — no "plugin paths don't exist", no "Continuing
// without installing plugin", on either stream.
let (code, out, err) = bundle_install(root, &fake, &[]);
assert_eq!(
code, 0,
"post-remove install must still succeed.\n{out}\n{err}"
);
let combined = format!("{out}\n{err}");
assert!(
!combined.contains("plugin paths don't exist"),
"post-remove `bundle install` must not warn about the unwired \
plugin's missing paths:\n{combined}"
);
assert!(
!combined.contains("Continuing without installing plugin"),
"post-remove `bundle install` must not print bundler's \
skipped-plugin block:\n{combined}"
);
}

/// [P1 digest honesty + migration] Drive the applier directly with plain
/// ruby (no bundler process, no network): the digest stamp must reflect
/// the ACTUAL on-disk gem-file state, so an out-of-band reversion
Expand Down
Loading
Loading