From b7a1a320438f7c1ddf51801102780d7366b2a7e1 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Sun, 4 Oct 2026 05:27:22 +0000 Subject: [PATCH 1/4] Start fix for #687 Assisted-by: Claude Code:claude-opus-5-5 From 9bf4b8e358234d2f2fa65632336a535ec0a2fc30 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 06:04:02 +0000 Subject: [PATCH 2/4] Report what a vendored group commit replaced GroupCommit::commit_changes returns every file the commit wrote together with the bytes it held before (None when the commit created it). A caller that has to undo the commit can then put back exactly those files and nothing else. commit() keeps its signature. Assisted-by: Claude Code:claude-opus-5-5 --- .../src/utils/group_commit.rs | 74 +++++++++++++++++-- 1 file changed, 68 insertions(+), 6 deletions(-) diff --git a/crates/socket-patch-core/src/utils/group_commit.rs b/crates/socket-patch-core/src/utils/group_commit.rs index e8f2284fe..bb82b0ee2 100644 --- a/crates/socket-patch-core/src/utils/group_commit.rs +++ b/crates/socket-patch-core/src/utils/group_commit.rs @@ -500,6 +500,17 @@ pub struct GroupCommit { open: bool, } +/// One project file a [`GroupCommit::commit_changes`] wrote: its +/// project-relative path and the bytes it held before the commit (`None` +/// when the commit created it). A caller that must undo the commit (the +/// hosted→vendored eject's rollback) puts exactly these back, and nothing +/// else. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CommittedFile { + pub rel: String, + pub before: Option>, +} + /// One file the commit changes. struct Change { rel: PathBuf, @@ -546,7 +557,14 @@ impl GroupCommit { /// [`remove_after_commit`] (and the empty directories queued by /// [`remove_dir_after_commit`]) are deleted only after a commit /// succeeded. - pub async fn commit(mut self) -> std::io::Result> { + pub async fn commit(self) -> std::io::Result> { + let changed = self.commit_changes().await?; + Ok(changed.into_iter().map(|c| c.rel).collect()) + } + + /// [`GroupCommit::commit`], returning each changed file with the bytes + /// it held before the commit. + pub async fn commit_changes(mut self) -> std::io::Result> { self.close(); let changed = self.write().await?; let removals = std::mem::take( @@ -572,7 +590,7 @@ impl GroupCommit { Ok(changed) } - async fn write(&mut self) -> std::io::Result> { + async fn write(&mut self) -> std::io::Result> { let root = self.overlay.root.clone(); let captured = std::mem::take( &mut *self @@ -607,17 +625,16 @@ impl GroupCommit { // sees the wiring before the ledger that records it, never a ledger // naming wiring that is not there yet. changes.sort_by_key(|c| is_ledger(&c.rel)); - let changed: Vec = changes.iter().map(|c| rel_string(&c.rel)).collect(); // Even with nothing to write: an artifact rebuilt in place (a // drifted committed copy healed at its own path) is already named // by the committed state, so it is synced before the run returns. super::durability::barrier().await?; if changes.is_empty() { - return Ok(changed); + return Ok(committed(changes)); } if let [only] = changes.as_slice() { apply_durably(&root, only).await?; - return Ok(changed); + return Ok(committed(changes)); } let journal = root.join(COMMIT_JOURNAL_REL); if let Some(parent) = journal.parent() { @@ -651,7 +668,7 @@ impl GroupCommit { { sync_dir(journal.parent()); } - Ok(changed) + Ok(committed(changes)) } } @@ -665,6 +682,17 @@ fn is_ledger(rel: &Path) -> bool { LEDGERS.contains(&rel_string(rel).as_str()) } +/// What [`GroupCommit::commit_changes`] reports for the written `changes`. +fn committed(changes: Vec) -> Vec { + changes + .into_iter() + .map(|c| CommittedFile { + rel: rel_string(&c.rel), + before: c.before, + }) + .collect() +} + fn rel_string(rel: &Path) -> String { rel.to_string_lossy().replace('\\', "/") } @@ -1127,6 +1155,40 @@ mod tests { assert!(!root.join(COMMIT_JOURNAL_REL).exists()); } + /// `commit_changes` reports each written file with the bytes it held + /// before the commit (`None` for a file the commit created), so a + /// caller can undo exactly the commit (#687). + #[tokio::test] + async fn commit_changes_reports_each_files_before_image() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + std::fs::write(root.join("package.json"), b"old").unwrap(); + std::fs::write(root.join("README.md"), b"untouched").unwrap(); + let group = GroupCommit::begin(root); + super::super::fs::atomic_write_bytes(&root.join("package.json"), b"new") + .await + .unwrap(); + super::super::fs::atomic_write_bytes(&root.join("pnpm-workspace.yaml"), b"ws") + .await + .unwrap(); + let mut changed = group.commit_changes().await.unwrap(); + changed.sort_by(|a, b| a.rel.cmp(&b.rel)); + assert_eq!( + changed, + vec![ + CommittedFile { + rel: "package.json".into(), + before: Some(b"old".to_vec()), + }, + CommittedFile { + rel: "pnpm-workspace.yaml".into(), + before: None, + }, + ] + ); + assert_eq!(std::fs::read(root.join("package.json")).unwrap(), b"new"); + } + fn render_string(value: &(dyn Any + Send + Sync)) -> std::io::Result> { Ok(value.downcast_ref::().unwrap().as_bytes().to_vec()) } From 135b0eaff849c35a065ffa08e398617a84258f69 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 06:04:02 +0000 Subject: [PATCH 3/4] Fix failed eject rewriting every root file When a hosted-to-vendored eject failed, its rollback rewrote every regular file in the project root from a pre-run snapshot. Output redirected into the project (vendor --json > report.json) was lost, lines another process appended to a root log were dropped, and untouched files such as README.md were swapped for new inodes, breaking hard links. The rollback now puts back only what the eject wrote: the pins' files, the upstream restore's files, and every file the vendored apply's group commit wrote (from its before-image when no snapshot holds it). Each one is rewritten only when its bytes changed, and the `git checkout` remedy printed when the rollback itself fails names exactly those files. Fixes #687 Assisted-by: Claude Code:claude-opus-5-5 --- CHANGELOG.md | 5 + crates/socket-patch-cli/CLI_CONTRACT.md | 5 +- .../src/commands/scan/vendor_flow.rs | 1 + .../socket-patch-cli/src/commands/vendor.rs | 285 ++++++++++++++---- .../src/commands/vendored_backend/mod.rs | 5 + .../tests/scan/hosted_management_refusals.rs | 102 +++++++ 6 files changed, 350 insertions(+), 53 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3572a298c..977c0c8f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -102,6 +102,11 @@ limits, and required install commands. ### Fixed +- A failed hosted→vendored eject (`vendor` in a hosted project) puts back + only the files it wrote. `vendor --json > report.json` or + `vendor > vendor.log 2>&1` in the project root keeps the run's output, a + log another process appends to keeps its lines, and untouched root files + keep their inode and hard links (#687). - Global mode (`-g`) finds npm, yarn, pnpm, bun, RubyGems and Composer on Windows, where they install as `.cmd` / `.bat` shims, instead of reporting an empty scan. The yarn and npm-family global lookups no longer run from the diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 69fb09df9..bf18846a5 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -565,7 +565,10 @@ non-derivable field; a binary `bun.lockb` record is rebuilt like the takeover's) remedy, nothing touched; (3) `--dry-run` stops here and reports each pin as an `applied` event with reason `eject_planned` — no file is written and no `.socket/` is created; (4) the wet run snapshots every file the eject may touch under one `apply.lock`, restores upstream, then vendors. If any -package then fails, the snapshot is put back — the project stays hosted exactly as before, with the +package then fails, exactly the files the eject wrote are put back (the pins' files, the upstream +restore's files, and every file the vendored apply committed, each only when its bytes changed) — +the project stays hosted exactly as before, and every other file (a `--json > report.json` redirect +target, a log another process appends to) is left alone, with the `eject_rolled_back` warning and `partial_failure`, exit 1; if putting the snapshot back itself fails, the error is `eject_rollback_failed` naming the files to `git checkout`. The eject does not emit the per-purl `vendor_takeover_reverted_redirect` warning (the restore is its own planned step). diff --git a/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs b/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs index 5507bb977..e00d69e66 100644 --- a/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs +++ b/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs @@ -298,6 +298,7 @@ async fn vendor_under_lock( detached: true, force: false, prior, + committed: None, }, &mut env, ) diff --git a/crates/socket-patch-cli/src/commands/vendor.rs b/crates/socket-patch-cli/src/commands/vendor.rs index 59be95b85..a108ed6bd 100644 --- a/crates/socket-patch-cli/src/commands/vendor.rs +++ b/crates/socket-patch-cli/src/commands/vendor.rs @@ -30,7 +30,7 @@ use socket_patch_core::patch::redirect::upstream::HostedPin; use socket_patch_core::telemetry::{track_patch_vendor_failed, track_patch_vendored}; use socket_patch_core::utils::composer_version::composer_purls_equivalent; use socket_patch_core::utils::concurrent::ordered_concurrent; -use socket_patch_core::utils::group_commit::GroupCommit; +use socket_patch_core::utils::group_commit::{CommittedFile, GroupCommit}; use socket_patch_core::utils::purl::{canonical_purl, normalize_purl, strip_purl_qualifiers}; use socket_patch_core::utils::socket_dir::remove_tree_and_prune; use socket_patch_core::vendor::{ @@ -1017,11 +1017,22 @@ fn hosted_pins_in_scope(common: &GlobalArgs, pins: Vec) -> Vec +/// report.json`), a log another process appends to, or an untouched +/// README keeps its inode and its bytes (#687). struct EjectSnapshot { root: std::path::PathBuf, - files: Vec<(String, Option>)>, + /// Pre-eject bytes (`None`: absent) by project-relative path. + files: std::collections::BTreeMap>>, + /// The pins' files, the planned restore files and [`Self::EXTRA`]: + /// always in the rollback's scope. + planned: std::collections::BTreeSet, root_files: std::collections::BTreeSet, vendor_dirs: std::collections::BTreeSet, } @@ -1065,55 +1076,92 @@ impl EjectSnapshot { async fn take(root: &Path, touched: &[String]) -> std::io::Result { let root_files = Self::root_file_names(root).await?; - let mut rels: std::collections::BTreeSet = root_files.clone(); - rels.extend(touched.iter().cloned()); - rels.extend(Self::EXTRA.iter().map(|s| s.to_string())); - let mut files = Vec::with_capacity(rels.len()); - for rel in rels { - let bytes = match tokio::fs::read(root.join(&rel)).await { - Ok(bytes) => Some(bytes), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => None, - Err(e) => return Err(e), - }; - files.push((rel, bytes)); + let mut planned: std::collections::BTreeSet = touched.iter().cloned().collect(); + planned.extend(Self::EXTRA.iter().map(|s| s.to_string())); + let mut files = std::collections::BTreeMap::new(); + for rel in root_files.iter().chain(planned.iter()) { + if files.contains_key(rel) { + continue; + } + files.insert(rel.clone(), Self::read(&root.join(rel)).await?); } Ok(EjectSnapshot { root: root.to_path_buf(), files, + planned, root_files, vendor_dirs: Self::vendor_dir_set(root), }) } - async fn restore(&self) -> Result<(), String> { + /// A file's bytes, `None` when it does not exist. + async fn read(path: &Path) -> std::io::Result>> { + match tokio::fs::read(path).await { + Ok(bytes) => Ok(Some(bytes)), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(e), + } + } + + /// The files the rollback restores: the planned ones, the ones the + /// upstream restore `written`, and the ones the vendored commit wrote. + fn scope( + &self, + written: &[String], + committed: &[CommittedFile], + ) -> std::collections::BTreeSet { + let mut scope = self.planned.clone(); + scope.extend(written.iter().cloned()); + scope.extend(committed.iter().map(|c| c.rel.clone())); + scope + } + + /// What the rollback puts `rel` back to: `Some(Some(bytes))` to rewrite, + /// `Some(None)` to remove, `None` to leave it as it is. The pre-eject + /// snapshot wins; without one, the bytes the vendored commit replaced. + fn wanted<'s>(&'s self, rel: &str, committed: &'s [CommittedFile]) -> Option> { + if let Some(bytes) = self.files.get(rel) { + return Some(bytes.as_deref()); + } + match committed.iter().find(|c| c.rel == rel) { + Some(c) => Some(c.before.as_deref()), + // A root file the restore created; anything deeper that no + // snapshot holds is left as it is. + None if !rel.contains('/') && !self.root_files.contains(rel) => Some(None), + None => None, + } + } + + /// Put `rel` back to `want` (`None`: absent) unless it already is, so a + /// file the eject left unchanged is never replaced. + async fn put_back(&self, rel: &str, want: Option<&[u8]>) -> std::io::Result<()> { + let path = self.root.join(rel); + if Self::read(&path).await.ok().as_ref().map(|b| b.as_deref()) == Some(want) { + return Ok(()); + } + match want { + Some(bytes) => { + socket_patch_core::utils::fs::atomic_write_bytes_preserving_mode(&path, bytes).await + } + None => match tokio::fs::remove_file(&path).await { + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + other => other, + }, + } + } + + /// Undo the eject: `written` is the upstream restore's file list and + /// `committed` what the vendored group commit wrote (see the type doc). + async fn restore(&self, written: &[String], committed: &[CommittedFile]) -> Result<(), String> { let mut errors: Vec = Vec::new(); - for (rel, bytes) in &self.files { - let path = self.root.join(rel); - let result = match bytes { - Some(bytes) => { - socket_patch_core::utils::fs::atomic_write_bytes_preserving_mode(&path, bytes) - .await - } - None => match tokio::fs::remove_file(&path).await { - Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), - other => other, - }, + for rel in self.scope(written, committed) { + let Some(want) = self.wanted(&rel, committed) else { + continue; }; - if let Err(e) = result { + if let Err(e) = self.put_back(&rel, want).await { errors.push(format!("{rel}: {e}")); } } - // Root files the eject created. - if let Ok(now) = Self::root_file_names(&self.root).await { - for name in now.difference(&self.root_files) { - if self.files.iter().any(|(rel, _)| rel == name) { - continue; - } - if let Err(e) = tokio::fs::remove_file(self.root.join(name)).await { - errors.push(format!("{name}: {e}")); - } - } - } // Vendored uuid dirs the eject created. for dir in Self::vendor_dir_set(&self.root).difference(&self.vendor_dirs) { if let Err(e) = remove_tree_and_prune(dir, &self.root.join(SOCKET_DIR)).await { @@ -1127,12 +1175,13 @@ impl EjectSnapshot { } } - /// The project files for the manual remedy. - fn files_hint(&self) -> String { - self.files - .iter() - .filter(|(_, bytes)| bytes.is_some()) - .map(|(rel, _)| rel.as_str()) + /// The project files for the manual remedy: every file in the + /// rollback's scope that it would put back to earlier bytes (a nested + /// file only the vendored commit's before-image holds included). + fn files_hint(&self, written: &[String], committed: &[CommittedFile]) -> String { + self.scope(written, committed) + .into_iter() + .filter(|rel| matches!(self.wanted(rel, committed), Some(Some(_)))) .collect::>() .join(" ") } @@ -1315,8 +1364,9 @@ async fn run_eject(args: &VendorArgs, pins: Vec) -> i32 { // One transaction under one apply lock: snapshot what the eject can // touch, restore every pin upstream (so the vendor engine resolves the // pristine registry package even in a fresh checkout with nothing - // installed), vendor, and on ANY failure put the snapshot back — a - // failed eject leaves the project hosted, exactly as it was. + // installed), vendor, and on ANY failure put back what the eject wrote + // — a failed eject leaves the project hosted, exactly as it was, and + // every file it never wrote untouched. let socket_dir = common.socket_dir(); let timeout = Duration::from_secs(common.lock_timeout.unwrap_or(0)); let guard = match crate::commands::lock_cli::acquire_with_status(&socket_dir, timeout) { @@ -1359,6 +1409,9 @@ async fn run_eject(args: &VendorArgs, pins: Vec) -> i32 { .map(|(_, why)| why.to_string()) .next() .or_else(|| restore.flush_error.clone()); + // Every file the vendored apply's group commit wrote, with its bytes + // from before: the rollback's scope beyond the restore's own files. + let mut committed: Vec = Vec::new(); let mut exit: i32; if let Some(why) = restore_failure { env.mark_error(EnvelopeError::new("redirect_revert_failed", why.clone())); @@ -1390,6 +1443,7 @@ async fn run_eject(args: &VendorArgs, pins: Vec) -> i32 { detached: true, force: false, prior: None, + committed: Some(&mut committed), }, &mut env, ) @@ -1397,7 +1451,7 @@ async fn run_eject(args: &VendorArgs, pins: Vec) -> i32 { exit = i32::from(applied); } if exit != 0 { - match snapshot.restore().await { + match snapshot.restore(&restore.reverted_files, &committed).await { Ok(()) => env.warnings.push(RunWarning { code: "eject_rolled_back".to_string(), detail: "the eject did not complete, so every file it touched was restored: the \ @@ -1408,7 +1462,7 @@ async fn run_eject(args: &VendorArgs, pins: Vec) -> i32 { let detail = format!( "the eject did not complete and restoring the pre-eject files failed ({e}); \ restore them from version control (`git checkout -- {}`)", - snapshot.files_hint() + snapshot.files_hint(&restore.reverted_files, &committed) ); if !common.json { eprintln!("Error: {detail}"); @@ -1561,6 +1615,7 @@ async fn run_vendor( detached: false, force: args.force, prior: None, + committed: None, }, env, ) @@ -1992,7 +2047,7 @@ pub(crate) async fn vendor_records( ledger: std::io::Result, ) -> bool { vendor_records_reusing( - common, records, sources, detached, force, env, service, ledger, None, + common, records, sources, detached, force, env, service, ledger, None, None, ) .await } @@ -2014,6 +2069,7 @@ pub(crate) async fn vendor_records_reusing( service: Option<&VendorServiceConfig>, ledger: std::io::Result, prior: Option<&NpmCrawlSnapshot>, + committed: Option<&mut Vec>, ) -> bool { let mut has_errors = false; // Lockfile flavors the backends wired THIS run (from the returned ledger @@ -2802,8 +2858,11 @@ pub(crate) async fn vendor_records_reusing( // where committing after every package would have left it. if let Some(group) = group { socket_patch_core::utils::failpoint::hit("vendor_group_commit"); - match group.commit().await { - Ok(_) => { + match group.commit_changes().await { + Ok(changes) => { + if let Some(committed) = committed { + committed.extend(changes); + } for stale in stale_artifacts { sweep_stale_artifact(common, env, &state, stale).await; } @@ -5844,3 +5903,125 @@ mod ui_format_tests { ); } } + +#[cfg(test)] +mod eject_snapshot_tests { + use super::*; + + fn write(root: &Path, rel: &str, bytes: &str) { + let path = root.join(rel); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, bytes).unwrap(); + } + + fn read(root: &Path, rel: &str) -> Option { + std::fs::read_to_string(root.join(rel)).ok() + } + + /// #687: the rollback puts back exactly what the eject wrote — the + /// planned restore files, the files the restore reported, and the + /// vendored commit's files (from the commit's before-image when no + /// snapshot holds them; deleted when the commit created them) — and + /// leaves every other root file as it is now, even one whose bytes + /// changed during the run (another process's log). + #[tokio::test] + async fn restore_undoes_only_what_the_eject_wrote() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write(root, "package-lock.json", "hosted lock"); + write(root, "package.json", "manifest v1"); + write(root, "build.log", "line 1\n"); + write(root, "packages/a/package.json", "member v1"); + let snapshot = EjectSnapshot::take(root, &["package-lock.json".to_string()]) + .await + .unwrap(); + + // The upstream restore: rewrites the planned lock and creates a root + // file it reports. + write(root, "package-lock.json", "upstream lock"); + write(root, ".npmrc-created", "x"); + let written = vec![ + "package-lock.json".to_string(), + ".npmrc-created".to_string(), + ]; + // The vendored commit: a root manifest edit, a nested member edit + // no snapshot holds, and a root file it creates. + write(root, "package.json", "manifest vendored"); + write(root, "packages/a/package.json", "member vendored"); + write(root, "vendored-new.yaml", "new"); + let committed = vec![ + CommittedFile { + rel: "package.json".into(), + before: Some(b"manifest v1".to_vec()), + }, + CommittedFile { + rel: "packages/a/package.json".into(), + before: Some(b"member v1".to_vec()), + }, + CommittedFile { + rel: "vendored-new.yaml".into(), + before: None, + }, + ]; + // Another process appends to its log meanwhile. + write(root, "build.log", "line 1\nline 2\n"); + + snapshot.restore(&written, &committed).await.unwrap(); + + assert_eq!( + read(root, "package-lock.json").as_deref(), + Some("hosted lock") + ); + assert_eq!(read(root, "package.json").as_deref(), Some("manifest v1")); + assert_eq!( + read(root, "packages/a/package.json").as_deref(), + Some("member v1") + ); + assert_eq!(read(root, "vendored-new.yaml"), None); + assert_eq!(read(root, ".npmrc-created"), None); + assert_eq!( + read(root, "build.log").as_deref(), + Some("line 1\nline 2\n"), + "a root file the eject never wrote keeps its new bytes" + ); + let hint = snapshot.files_hint(&written, &committed); + let hinted: Vec<&str> = hint.split(' ').collect(); + assert_eq!( + hinted, + vec![ + "package-lock.json", + "package.json", + "packages/a/package.json" + ], + "the remedy names every file the rollback restores, nested commit \ + files included, and nothing it leaves alone or removes" + ); + } + + /// #687: a file in scope whose bytes are already the snapshot's is not + /// rewritten, so it keeps its inode. + #[cfg(unix)] + #[tokio::test] + async fn restore_skips_files_already_at_their_snapshot_bytes() { + use std::os::unix::fs::MetadataExt; + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write(root, "package-lock.json", "hosted lock"); + let snapshot = EjectSnapshot::take(root, &["package-lock.json".to_string()]) + .await + .unwrap(); + let ino = std::fs::metadata(root.join("package-lock.json")) + .unwrap() + .ino(); + snapshot + .restore(&["package-lock.json".to_string()], &[]) + .await + .unwrap(); + assert_eq!( + std::fs::metadata(root.join("package-lock.json")) + .unwrap() + .ino(), + ino + ); + } +} 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 014b011c3..816b89026 100644 --- a/crates/socket-patch-cli/src/commands/vendored_backend/mod.rs +++ b/crates/socket-patch-cli/src/commands/vendored_backend/mod.rs @@ -16,6 +16,7 @@ use std::collections::HashMap; use std::path::Path; use socket_patch_core::manifest::schema::{PatchManifest, PatchRecord}; +use socket_patch_core::utils::group_commit::CommittedFile; use socket_patch_core::vendor::{ save_state, RevertOpts, VendorServiceConfig, VendorState, VendorWarning, }; @@ -47,6 +48,9 @@ pub(crate) struct ApplyRequest<'a> { /// The npm half of a crawl this process already made over an untouched /// tree (see [`vendor_records_reusing`]). pub(crate) prior: Option<&'a NpmCrawlSnapshot>, + /// Receives every project file the run's group commit wrote, with the + /// bytes it held before (the eject's rollback undoes exactly these). + pub(crate) committed: Option<&'a mut Vec>, } impl<'a> VendoredBackend<'a> { @@ -81,6 +85,7 @@ impl<'a> VendoredBackend<'a> { self.service, req.ledger, req.prior, + req.committed, )) .await } diff --git a/crates/socket-patch-cli/tests/scan/hosted_management_refusals.rs b/crates/socket-patch-cli/tests/scan/hosted_management_refusals.rs index ebdb1ba53..19b0f4b39 100644 --- a/crates/socket-patch-cli/tests/scan/hosted_management_refusals.rs +++ b/crates/socket-patch-cli/tests/scan/hosted_management_refusals.rs @@ -334,3 +334,105 @@ async fn dry_run_eject_verifies_the_plan_and_writes_nothing() { "a dry run creates no .socket/" ); } + +/// #687: a failed eject puts back only the files it wrote. `vendor --json > +/// report.json` in the project root keeps the run's envelope (the rollback +/// used to replace the redirect target with its empty pre-run bytes, so the +/// envelope went to an unlinked inode), and a root file the eject never +/// wrote keeps its inode, its hard link and its bytes. +#[tokio::test] +async fn failed_eject_leaves_root_files_it_never_wrote_alone() { + let server = MockServer::start().await; + mount_view_and_registry(&server, 404).await; + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let hosted = write_fresh_hosted_checkout(root, &server.uri()); + std::fs::write(root.join("README.md"), "# app\n").unwrap(); + #[cfg(unix)] + std::fs::hard_link(root.join("README.md"), root.join("README.link")).unwrap(); + #[cfg(unix)] + let inode = |rel: &str| { + use std::os::unix::fs::MetadataExt; + std::fs::metadata(root.join(rel)).unwrap().ino() + }; + #[cfg(unix)] + let readme_inode = inode("README.md"); + + let report = std::fs::File::create(root.join("report.json")).unwrap(); + let out = eject_cmd(&server, root, false) + .stdout(report) + .output() + .unwrap(); + assert_eq!( + out.status.code(), + Some(1), + "{}", + String::from_utf8_lossy(&out.stderr) + ); + let text = std::fs::read_to_string(root.join("report.json")).unwrap(); + let v: Value = serde_json::from_str(&text) + .unwrap_or_else(|e| panic!("report.json must hold the envelope ({e}): {text:?}")); + assert!( + v["warnings"] + .as_array() + .is_some_and(|w| w.iter().any(|w| w["code"] == "eject_rolled_back")), + "{v}" + ); + assert_eq!( + std::fs::read_to_string(root.join("package-lock.json")).unwrap(), + hosted, + "the files the eject did write are still rolled back" + ); + assert_eq!( + std::fs::read_to_string(root.join("README.md")).unwrap(), + "# app\n" + ); + #[cfg(unix)] + { + assert_eq!(inode("README.md"), readme_inode, "README.md was replaced"); + assert_eq!( + inode("README.link"), + readme_inode, + "the hard link was broken" + ); + } +} + +/// #687: `vendor > vendor.log 2>&1` in the project root keeps the whole +/// log of a failed eject, including the error and the rollback notice +/// written after the rollback ran. +#[tokio::test] +async fn failed_eject_human_log_in_the_root_keeps_every_line() { + let server = MockServer::start().await; + mount_view_and_registry(&server, 404).await; + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write_fresh_hosted_checkout(root, &server.uri()); + let log = std::fs::File::create(root.join("vendor.log")).unwrap(); + let cmd = eject_cmd(&server, root, false); + // Human output: the same command without --json. + let args: Vec = cmd + .get_args() + .map(|a| a.to_string_lossy().into_owned()) + .filter(|a| a != "--json") + .collect(); + let mut human = cli(); + human.args(&args); + for (k, v) in cmd.get_envs() { + if let Some(v) = v { + human.env(k, v); + } + } + let status = human + .stdout(log.try_clone().unwrap()) + .stderr(log) + .status() + .unwrap(); + assert_eq!(status.code(), Some(1)); + let text = std::fs::read_to_string(root.join("vendor.log")).unwrap(); + assert!(text.contains("Ejecting"), "{text}"); + assert!( + text.lines().count() > 1 && text.to_lowercase().contains("fail"), + "the failure lines after the rollback must survive: {text}" + ); +} From 65d4dcb6db2a71f207b17e09a26362b575610fa7 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Mon, 5 Oct 2026 07:28:25 -0400 Subject: [PATCH 4/4] Drop CHANGELOG entry from this PR Release notes are written when a release is cut, from the merged PR log and the code, so PRs no longer edit CHANGELOG.md. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 5 ----- 1 file changed, 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 977c0c8f1..3572a298c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -102,11 +102,6 @@ limits, and required install commands. ### Fixed -- A failed hosted→vendored eject (`vendor` in a hosted project) puts back - only the files it wrote. `vendor --json > report.json` or - `vendor > vendor.log 2>&1` in the project root keeps the run's output, a - log another process appends to keeps its lines, and untouched root files - keep their inode and hard links (#687). - Global mode (`-g`) finds npm, yarn, pnpm, bun, RubyGems and Composer on Windows, where they install as `.cmd` / `.bat` shims, instead of reporting an empty scan. The yarn and npm-family global lookups no longer run from the