From 1dfe3de16eed83dcd5cc2ca1441ff6f285afb56f Mon Sep 17 00:00:00 2001 From: CheckPickerUpper <194722943+CheckPickerUpper@users.noreply.github.com> Date: Fri, 28 Aug 2026 14:04:46 -0400 Subject: [PATCH] fix: prune stale workspace member links --- src/reportal_commands/workspace_layout.rs | 177 +++++++++++++++++++++- 1 file changed, 176 insertions(+), 1 deletion(-) diff --git a/src/reportal_commands/workspace_layout.rs b/src/reportal_commands/workspace_layout.rs index d26ab8d..9f09f9c 100644 --- a/src/reportal_commands/workspace_layout.rs +++ b/src/reportal_commands/workspace_layout.rs @@ -15,6 +15,7 @@ use crate::code_workspace::CodeWorkspaceFile; use crate::error::ReportalError; +use std::ffi::OsStr; use std::io; use std::path::{Path, PathBuf}; @@ -116,7 +117,9 @@ pub struct WorkspaceLayoutParameters<'caller> { /// updates links whose targets have changed, leaves already-correct /// links untouched, and refuses to clobber non-link entries at the /// link paths so a user who drops a real file into the workspace -/// directory cannot lose it to a silent overwrite. +/// directory cannot lose it to a silent overwrite. It also removes +/// stale generated symlinks or junctions that no longer correspond +/// to a current member. /// /// The `.code-workspace` file is written with relative `./` /// folder entries so opening it in Cursor / `VSCode` surfaces the @@ -138,6 +141,7 @@ pub fn materialize_workspace_layout( for link_spec in layout_params.member_links { ensure_member_link(layout_params.workspace_directory, link_spec)?; } + prune_stale_generated_links(layout_params)?; let workspace_file_path = workspace_file_path_inside_dir( layout_params.workspace_directory, layout_params.workspace_name, @@ -159,6 +163,106 @@ pub fn materialize_workspace_layout( Ok(workspace_file_path) } +/// Removes stale symlinks or junctions left behind when a workspace +/// member is removed from configuration. Real files and directories +/// are intentionally ignored, so user-authored data is never part +/// of the reconciliation delete set. +fn prune_stale_generated_links( + layout_params: &WorkspaceLayoutParameters<'_>, +) -> Result<(), ReportalError> { + let workspace_file_path = workspace_file_path_inside_dir( + layout_params.workspace_directory, + layout_params.workspace_name, + ); + let directory_entries = + std::fs::read_dir(layout_params.workspace_directory).map_err(|read_error| { + ReportalError::CodeWorkspaceIoFailure { + file_path: layout_params.workspace_directory.display().to_string(), + reason: read_error.to_string(), + } + })?; + for directory_entry in directory_entries { + let directory_entry = + directory_entry.map_err(|read_error| ReportalError::CodeWorkspaceIoFailure { + file_path: layout_params.workspace_directory.display().to_string(), + reason: read_error.to_string(), + })?; + let entry_path = directory_entry.path(); + let entry_name = directory_entry.file_name(); + let is_current_member = layout_params + .member_links + .iter() + .any(|link_spec| entry_name == OsStr::new(link_spec.link_name.as_str())); + if entry_path == workspace_file_path || is_current_member { + continue; + } + let is_generated_link = is_workspace_generated_link(&entry_path).map_err(|link_error| { + ReportalError::CodeWorkspaceIoFailure { + file_path: entry_path.display().to_string(), + reason: link_error.to_string(), + } + })?; + if is_generated_link { + remove_workspace_generated_link(&entry_path).map_err(|remove_error| { + ReportalError::CodeWorkspaceIoFailure { + file_path: entry_path.display().to_string(), + reason: remove_error.to_string(), + } + })?; + } + } + Ok(()) +} + +/// Reports whether a filesystem entry is a reportal-generated link +/// on the current platform. +fn is_workspace_generated_link(entry_path: &Path) -> io::Result { + #[cfg(unix)] + { + std::fs::symlink_metadata(entry_path).map(|metadata| metadata.file_type().is_symlink()) + } + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0400; + let metadata = std::fs::symlink_metadata(entry_path)?; + if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT == 0 { + return Ok(false); + } + junction::exists(entry_path) + } + #[cfg(not(any(unix, windows)))] + { + let _ = entry_path; + Err(io::Error::new( + io::ErrorKind::Unsupported, + "workspace link pruning is not supported on this platform", + )) + } +} + +/// Removes a reportal-generated link without following it to the +/// member repository. +fn remove_workspace_generated_link(entry_path: &Path) -> io::Result<()> { + #[cfg(unix)] + { + std::fs::remove_file(entry_path) + } + #[cfg(windows)] + { + junction::delete(entry_path) + } + #[cfg(not(any(unix, windows)))] + { + let _ = entry_path; + Err(io::Error::new( + io::ErrorKind::Unsupported, + "workspace link pruning is not supported on this platform", + )) + } +} + /// Computes the canonical `.code-workspace` file path inside a /// workspace directory, for callers that only need the file path /// without running the full materialization. @@ -507,6 +611,77 @@ mod tests { let _ = std::fs::remove_dir_all(&scratch_dir); } + #[test] + #[cfg(any(unix, windows))] + fn materialize_layout_prunes_stale_generated_links_and_preserves_user_entries() { + let scratch_dir = unique_temp_dir("prune-stale"); + let current_target = scratch_dir.join("repos").join("current"); + let stale_target = scratch_dir.join("repos").join("stale"); + std::fs::create_dir_all(¤t_target).expect("current target must be creatable"); + std::fs::create_dir_all(&stale_target).expect("stale target must be creatable"); + + let workspace_dir = scratch_dir.join("workspaces").join("nro"); + std::fs::create_dir_all(&workspace_dir).expect("workspace dir must be creatable"); + let stale_link_path = workspace_dir.join("removed"); + create_workspace_link(&stale_target, &stale_link_path) + .expect("stale generated link must be creatable"); + + let user_file_path = workspace_dir.join("notes.txt"); + std::fs::write(&user_file_path, b"keep this file\n").expect("user file must be writable"); + let user_directory_path = workspace_dir.join("keep-directory"); + std::fs::create_dir_all(&user_directory_path).expect("user directory must be creatable"); + let user_nested_file_path = user_directory_path.join("readme.txt"); + std::fs::write(&user_nested_file_path, b"keep this directory\n") + .expect("nested user file must be writable"); + + let member_links = vec![WorkspaceLinkSpec { + link_name: "current".to_owned(), + target_absolute_path: current_target, + }]; + let workspace_file_path = materialize_workspace_layout(&WorkspaceLayoutParameters { + workspace_directory: &workspace_dir, + workspace_name: "nro", + member_links: &member_links, + window_title_override: None, + title_bar_color_hex: None, + }) + .expect("materialize must succeed"); + + assert!( + !stale_link_path.exists(), + "stale generated link must be removed when membership is gone", + ); + assert!( + workspace_dir.join("current").exists(), + "current generated link must remain", + ); + assert!(user_file_path.exists(), "real user file must be preserved"); + assert_eq!( + std::fs::read(&user_file_path).expect("user file must remain readable"), + b"keep this file\n", + ); + assert!( + user_directory_path.is_dir(), + "real user directory must be preserved", + ); + assert_eq!( + std::fs::read(&user_nested_file_path).expect("nested user file must remain readable"), + b"keep this directory\n", + ); + let workspace_file_contents = + std::fs::read_to_string(workspace_file_path).expect("workspace file must be readable"); + assert!( + workspace_file_contents.contains("./current"), + "folders[] must contain current membership", + ); + assert!( + !workspace_file_contents.contains("./removed"), + "folders[] must omit removed membership", + ); + + let _ = std::fs::remove_dir_all(&scratch_dir); + } + #[test] #[cfg(unix)] fn materialize_layout_refuses_to_clobber_real_file_at_link_path() {