From 688a16b9e4abb8b70cbce1bc7141427ab073e21e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Estev=C3=A3o?= Date: Sat, 31 Oct 2026 19:31:11 -0300 Subject: [PATCH 1/3] feat: add dock sync and preserve unmanaged content Reconcile managed links from workspace configuration without disturbing extra files or manual symlinks. Recover missing links, reject conflicts before mutation, and atomically replace ownership locks. Make delete --force remove the entire validated owned dock without following symlink targets. Cover recovery, conflicts, ownership checks, and complete deletion with regression tests. Fixes #35 --- src/cli.rs | 4 + src/dock.rs | 301 +++++++++++++++++++++++++++++++-------------------- src/error.rs | 18 ++- src/lib.rs | 20 ++++ tests/cli.rs | 242 ++++++++++++++++++++++++++++++++++++++++- 5 files changed, 461 insertions(+), 124 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index d23b670..ad9ea52 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -50,6 +50,10 @@ pub enum Commands { #[arg(long)] force: bool, }, + /// Synchronize a dock without opening Zed. Infer the workspace from the dock root. + Sync { + workspace: Option, + }, Status { workspace: Option, }, diff --git a/src/dock.rs b/src/dock.rs index 950e345..8987b20 100644 --- a/src/dock.rs +++ b/src/dock.rs @@ -1,6 +1,7 @@ use std::{ collections::HashSet, fs, + io::Write, path::{Path, PathBuf}, }; @@ -48,13 +49,11 @@ pub(crate) fn build_dock_in( let workspace_abs = absolute_workspace_path(workspace_path)?; let dock_root = dock_root_in(cache_dir, workspace_path, &workspace_abs); - prepare_dock_dir(&dock_root, &workspace_abs)?; - - for folder in folders { - create_symlink(&folder.target, &dock_root.join(folder.name.as_str()))?; + if metadata(&dock_root)?.is_none() { + fs::create_dir_all(&dock_root)?; + write_lock(&dock_root, &workspace_abs, &[])?; } - - write_lock(&dock_root, &workspace_abs, folders)?; + reconcile_dock(&dock_root, &workspace_abs, folders)?; Ok(dock_root) } @@ -79,7 +78,7 @@ pub(crate) fn infer_workspace_from_current_dock() -> Result { pub(crate) fn validate_existing_dock(workspace_path: &Path) -> Result> { let dock_root = dock_root_for(workspace_path)?; - if !dock_root.exists() { + if metadata(&dock_root)?.is_none() { return Ok(None); } @@ -97,13 +96,7 @@ pub(crate) fn sync_existing_dock( return Ok(false); }; let workspace_abs = absolute_workspace_path(workspace_path)?; - let lock = validate_owned_dock(&dock_root, &workspace_abs)?; - let original_folders = resolved_folders_from_lock(&lock)?; - - if let Err(error) = reconcile_dock(&dock_root, &workspace_abs, folders) { - let _ = reconcile_dock(&dock_root, &workspace_abs, &original_folders); - return Err(error); - } + reconcile_dock(&dock_root, &workspace_abs, folders)?; Ok(true) } @@ -112,28 +105,27 @@ pub(crate) fn remove_owned_dock(workspace_path: &Path) -> Result { let Some(dock_root) = validate_existing_dock(workspace_path)? else { return Ok(false); }; - let workspace_abs = absolute_workspace_path(workspace_path)?; - let lock = validate_owned_dock(&dock_root, &workspace_abs)?; - - remove_locked_links(&dock_root, &lock)?; - fs::remove_file(dock_root.join(LOCK_FILE))?; - fs::remove_dir(&dock_root)?; + fs::remove_dir_all(&dock_root)?; Ok(true) } pub(crate) fn current_directory_is_dock(workspace_path: &Path) -> Result { - Ok(std::env::current_dir()? == dock_root_for(workspace_path)?) + let dock = dock_root_for(workspace_path)?; + let Some(_) = metadata(&dock)? else { + return Ok(false); + }; + Ok(std::env::current_dir()?.starts_with(dock.canonicalize()?)) } pub(crate) fn dock_status(workspace_path: &Path) -> Result { let dock_root = dock_root_for(workspace_path)?; - if !dock_root.exists() { + if metadata(&dock_root)?.is_none() { return Ok(DockStatus::Absent { path: dock_root }); } let workspace_abs = absolute_workspace_path(workspace_path)?; - match validate_owned_dock(&dock_root, &workspace_abs) { + match inspect_dock_health(&dock_root, &workspace_abs) { Ok(_) => Ok(DockStatus::Healthy { path: dock_root }), Err(error) => Ok(DockStatus::Unhealthy { path: dock_root, @@ -142,31 +134,20 @@ pub(crate) fn dock_status(workspace_path: &Path) -> Result { } } -fn prepare_dock_dir(dock_root: &Path, workspace_path: &Path) -> Result<()> { - if !dock_root.exists() { - fs::create_dir_all(dock_root)?; - return Ok(()); - } - - if !dock_root.is_dir() { - return Err(AppError::DockPathNotDirectory { - path: dock_root.to_path_buf(), - }); +fn metadata(path: &Path) -> Result> { + match fs::symlink_metadata(path) { + Ok(metadata) => Ok(Some(metadata)), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(error.into()), } - - let lock = validate_owned_dock(dock_root, workspace_path)?; - remove_locked_links(dock_root, &lock)?; - - Ok(()) } fn validate_owned_dock(dock_root: &Path, workspace_path: &Path) -> Result { - if !dock_root.is_dir() { + if !fs::symlink_metadata(dock_root)?.is_dir() { return Err(AppError::DockPathNotDirectory { path: dock_root.to_path_buf(), }); } - let lock = read_lock(dock_root)?; if lock.workspace_path != workspace_path { return Err(AppError::DockWorkspacePathMismatch { @@ -174,108 +155,159 @@ fn validate_owned_dock(dock_root: &Path, workspace_path: &Path) -> Result>(); - - for entry in fs::read_dir(dock_root)? { - let entry = entry?; - let file_name = entry.file_name(); - let file_name = file_name.to_string_lossy(); - - if file_name == LOCK_FILE { +fn validate_managed_links(dock_root: &Path, lock: &DockLock) -> Result<()> { + for link in &lock.links { + let path = dock_root.join(&link.name); + let Some(entry) = metadata(&path)? else { continue; + }; + if !entry.is_symlink() { + return Err(AppError::ManagedDockEntryNotSymlink { path }); } - if !expected_links.contains(file_name.as_ref()) { - return Err(AppError::UnmanagedDockContent { path: entry.path() }); - } - if !entry.file_type()?.is_symlink() { - return Err(AppError::ManagedDockEntryNotSymlink { path: entry.path() }); + let actual = fs::read_link(&path)?; + if actual != link.target { + return Err(AppError::DockLinkTargetMismatch { + path, + expected: link.target.clone(), + actual, + }); } } - - Ok(lock) + Ok(()) } -fn remove_locked_links(dock_root: &Path, lock: &DockLock) -> Result<()> { - for entry in fs::read_dir(dock_root)? { - let entry = entry?; - let file_name = entry.file_name(); - if file_name == LOCK_FILE { - continue; - } - if lock - .links - .iter() - .any(|link| link.name == file_name.to_string_lossy()) - { - remove_managed_link(&entry.path())?; +fn inspect_dock_health(dock_root: &Path, workspace_path: &Path) -> Result<()> { + let lock = validate_owned_dock(dock_root, workspace_path)?; + validate_managed_links(dock_root, &lock)?; + let folders = crate::workspace::read_workspace_file(workspace_path)? + .resolved_dock_folders(workspace_path)?; + if lock.links.len() != folders.len() + || folders.iter().any(|folder| { + !lock + .links + .iter() + .any(|link| link.name == folder.name.as_str() && link.target == folder.target) + }) + { + return Err(AppError::DockOutOfSync { + path: dock_root.to_path_buf(), + }); + } + for link in &lock.links { + if metadata(&dock_root.join(&link.name))?.is_none() { + return Err(AppError::DockOutOfSync { + path: dock_root.join(&link.name), + }); } } - Ok(()) } +struct LinkChange { + path: PathBuf, + previous: Option, + desired: Option, +} + fn reconcile_dock( dock_root: &Path, workspace_path: &Path, folders: &[ResolvedFolder], ) -> Result<()> { - for entry in fs::read_dir(dock_root)? { - let entry = entry?; - let file_name = entry.file_name(); - if file_name == LOCK_FILE { - continue; - } - let Some(folder) = folders + let lock = validate_owned_dock(dock_root, workspace_path)?; + validate_managed_links(dock_root, &lock)?; + let mut changes = Vec::new(); + for link in &lock.links { + let path = dock_root.join(&link.name); + let present = metadata(&path)?.is_some(); + let desired = folders .iter() - .find(|folder| folder.name.as_str() == file_name.to_string_lossy()) - else { - remove_managed_link(&entry.path())?; - continue; - }; - if fs::read_link(entry.path())? != folder.target { - remove_managed_link(&entry.path())?; + .find(|folder| folder.name.as_str() == link.name); + if present && desired.is_none_or(|folder| folder.target != link.target) { + changes.push(LinkChange { + path, + previous: Some(link.target.clone()), + desired: desired.map(|folder| folder.target.clone()), + }); } } - for folder in folders { - let link = dock_root.join(folder.name.as_str()); - if link.symlink_metadata().is_err() { - create_symlink(&folder.target, &link)?; + let path = dock_root.join(folder.name.as_str()); + let present = metadata(&path)?.is_some(); + let managed = lock + .links + .iter() + .any(|link| link.name == folder.name.as_str()); + if present && !managed { + return Err(AppError::DockLinkPathExists { path }); + } + if !present { + changes.push(LinkChange { + path, + previous: None, + desired: Some(folder.target.clone()), + }); } } + for (index, change) in changes.iter().enumerate() { + if let Err(error) = apply_link_change(change) { + restore_links(&changes[..=index]); + return Err(error); + } + } + if let Err(error) = write_lock(dock_root, workspace_path, folders) { + restore_links(&changes); + return Err(error); + } + Ok(()) +} - write_lock(dock_root, workspace_path, folders)?; - +fn apply_link_change(change: &LinkChange) -> Result<()> { + if change.previous.is_some() { + remove_managed_link(&change.path)?; + } + if let Some(target) = &change.desired { + create_symlink(target, &change.path)?; + } Ok(()) } -fn resolved_folders_from_lock(lock: &DockLock) -> Result> { - lock.links - .iter() - .map(|link| { - Ok(ResolvedFolder { - name: LinkName::new(&link.name)?, - target: link.target.clone(), - }) - }) - .collect() +fn restore_links(changes: &[LinkChange]) { + for change in changes.iter().rev() { + // Only undo links created by this reconciliation; never remove a conflicting entry. + if change + .desired + .as_ref() + .is_some_and(|target| fs::read_link(&change.path).ok().as_ref() == Some(target)) + { + let _ = remove_managed_link(&change.path); + } + if let Some(target) = &change.previous + && matches!(metadata(&change.path), Ok(None)) + { + let _ = create_symlink(target, &change.path); + } + } } fn read_lock(dock_root: &Path) -> Result { let lock_path = dock_root.join(LOCK_FILE); - if !lock_path.exists() { + let Some(lock_metadata) = metadata(&lock_path)? else { return Err(AppError::DockMissingLock { path: dock_root.to_path_buf(), }); + }; + if !lock_metadata.is_file() { + return Err(AppError::InvalidDockLock { + path: lock_path, + reason: "must be a regular file, not a symlink", + }); } - - let content = fs::read_to_string(lock_path)?; + let content = fs::read_to_string(&lock_path)?; let lock: DockLock = serde_json::from_str(&content)?; if lock.version != LOCK_VERSION { @@ -284,6 +316,23 @@ fn read_lock(dock_root: &Path) -> Result { }); } + if !lock.workspace_path.is_absolute() + || lock.links.iter().any(|link| !link.target.is_absolute()) + { + return Err(AppError::InvalidDockLock { + path: lock_path, + reason: "workspace and target paths must be absolute", + }); + } + let mut names = HashSet::new(); + for link in &lock.links { + LinkName::new(&link.name)?; + if !names.insert(link.name.to_lowercase()) { + return Err(AppError::DuplicateFolderName { + name: link.name.clone(), + }); + } + } Ok(lock) } @@ -301,9 +350,24 @@ fn write_lock(dock_root: &Path, workspace_path: &Path, folders: &[ResolvedFolder }; let content = serde_json::to_string_pretty(&lock)?; - fs::write(dock_root.join(LOCK_FILE), format!("{content}\n"))?; - - Ok(()) + let mut nonce = [0_u8; 16]; + getrandom::fill(&mut nonce)?; + let temporary_path = dock_root.join(format!(".zwd-lock-{}.tmp", hex::encode(nonce))); + let mut file = fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&temporary_path)?; + let result = (|| -> Result<()> { + writeln!(file, "{content}")?; + file.sync_all()?; + drop(file); + fs::rename(&temporary_path, dock_root.join(LOCK_FILE))?; + Ok(()) + })(); + if result.is_err() { + let _ = fs::remove_file(temporary_path); + } + result } fn absolute_workspace_path(path: &Path) -> Result { @@ -591,7 +655,7 @@ mod tests { } #[test] - fn aborts_when_lock_owned_dock_has_unmanaged_content() { + fn preserves_unmanaged_content_when_reconciling() { let temp = tempdir().unwrap(); let project = temp.path().join("api"); fs::create_dir(&project).unwrap(); @@ -604,14 +668,21 @@ mod tests { let dock_root = build_dock_in(temp.path(), &workspace, &folders).unwrap(); fs::write(dock_root.join("notes.txt"), "do not delete").unwrap(); - let error = build_dock_in(temp.path(), &workspace, &folders) - .unwrap_err() - .to_string(); - - assert!(error.contains("unmanaged content")); + build_dock_in(temp.path(), &workspace, &folders).unwrap(); assert!(dock_root.join("notes.txt").exists()); } + #[test] + fn failed_lock_replacement_preserves_destination_and_cleans_temporary_file() { + let temp = tempdir().unwrap(); + let lock = temp.path().join(LOCK_FILE); + fs::create_dir(&lock).unwrap(); + fs::write(lock.join("sentinel"), "keep").unwrap(); + assert!(write_lock(temp.path(), Path::new("/workspace.code-workspace"), &[]).is_err()); + assert_eq!(fs::read_to_string(lock.join("sentinel")).unwrap(), "keep"); + assert_eq!(fs::read_dir(temp.path()).unwrap().count(), 1); + } + fn read_link_target(path: &Path) -> PathBuf { fs::read_link(path).unwrap() } diff --git a/src/error.rs b/src/error.rs index 2a5d34a..d82b00c 100644 --- a/src/error.rs +++ b/src/error.rs @@ -48,7 +48,9 @@ pub enum AppError { WorkspaceAlreadyExists { path: PathBuf }, #[error("registered workspace not found: {name}")] RegisteredWorkspaceNotFound { name: String }, - #[error("workspace is required outside the root of a managed dock; pass --workspace")] + #[error( + "workspace is required outside the root of a managed dock; provide a workspace reference (sync/status: positional; add/remove: --workspace)" + )] WorkspaceReferenceRequired, #[error("current directory is the dock being deleted; leave {path} before retrying")] CurrentDirectoryIsDock { path: PathBuf }, @@ -64,8 +66,18 @@ pub enum AppError { DockMissingLock { path: PathBuf }, #[error("unsupported dock lock version: {version}")] UnsupportedDockLockVersion { version: u8 }, - #[error("dock contains unmanaged content: {path}")] - UnmanagedDockContent { path: PathBuf }, + #[error("invalid dock lock at {path}: {reason}")] + InvalidDockLock { path: PathBuf, reason: &'static str }, + #[error("dock is out of sync at {path}; run zwd sync ")] + DockOutOfSync { path: PathBuf }, + #[error( + "managed dock link {path} points to {actual}, expected {expected}; restore the link manually" + )] + DockLinkTargetMismatch { + path: PathBuf, + expected: PathBuf, + actual: PathBuf, + }, #[error("managed dock entry is not a symlink: {path}")] ManagedDockEntryNotSymlink { path: PathBuf }, #[error("dock lock belongs to {lock_workspace_path}, not {workspace_path}")] diff --git a/src/lib.rs b/src/lib.rs index 516aa62..7bab1de 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -65,6 +65,7 @@ pub fn run(cli: Cli) -> Result<()> { workspace::WorkspaceEditOperation::Remove, ), Commands::Delete { workspace, force } => delete_workspace(&workspace, force), + Commands::Sync { workspace } => sync_workspace(workspace.as_deref()), Commands::Status { workspace } => show_workspace_status(workspace.as_deref()), Commands::Install { command, @@ -80,6 +81,20 @@ pub fn run(cli: Cli) -> Result<()> { } } +fn sync_workspace(reference: Option<&std::path::Path>) -> Result<()> { + let workspace_path = resolve_edit_workspace(reference)?; + let workspace = workspace::read_workspace_file(&workspace_path)?; + let mode = workspace.open_mode(None)?; + if mode == cli::Mode::Folders && dock::validate_existing_dock(&workspace_path)?.is_none() { + println!("no dock to synchronize (folders mode)"); + return Ok(()); + } + let folders = workspace.resolved_dock_folders(&workspace_path)?; + let dock = dock::build_dock(&workspace_path, &folders)?; + println!("dock synchronized\t{}", dock.display()); + Ok(()) +} + fn edit_workspace( reference: Option<&std::path::Path>, paths: &[std::path::PathBuf], @@ -166,6 +181,11 @@ fn delete_workspace(reference: &std::path::Path, force: bool) -> Result<()> { } if !force { + if dock.is_some() { + println!( + "contents\tall dock files and directories will be removed; symlink targets are preserved" + ); + } println!("dry-run\tpass --force to delete"); return Ok(()); } diff --git a/tests/cli.rs b/tests/cli.rs index d4ccc6e..673a96d 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -21,6 +21,7 @@ fn run_with_home_and_cwd(args: &[&str], home: &Path, cwd: &Path) -> Result Result<(), Box< ), )?; let dock = open_dock(&workspace, &home, &project_root)?; + fs::write(dock.join("notes.txt"), "keep")?; let workspace_arg = workspace.to_string_lossy().into_owned(); let docs_arg = docs.to_string_lossy().into_owned(); let missing_arg = missing.to_string_lossy().into_owned(); @@ -835,13 +837,13 @@ fn remove_updates_dock_without_deleting_project_directories() -> Result<(), Box< assert_eq!(folder_paths(&workspace)?.len(), 1); assert!(!dock.join("docs").exists()); assert!(docs.is_dir()); + assert_eq!(fs::read_to_string(dock.join("notes.txt"))?, "keep"); Ok(()) } #[test] -fn add_aborts_without_changing_workspace_when_dock_has_unmanaged_content() --> Result<(), Box> { +fn add_preserves_unmanaged_content() -> Result<(), Box> { let temp = tempdir()?; let home = temp.path().join("home"); let project_root = temp.path().join("projects"); @@ -859,7 +861,6 @@ fn add_aborts_without_changing_workspace_when_dock_has_unmanaged_content() )?; let dock = open_dock(&workspace, &home, &project_root)?; fs::write(dock.join("notes.txt"), "do not touch")?; - let before = fs::read_to_string(&workspace)?; let workspace_arg = workspace.to_string_lossy().into_owned(); let docs_arg = docs.to_string_lossy().into_owned(); @@ -869,9 +870,13 @@ fn add_aborts_without_changing_workspace_when_dock_has_unmanaged_content() &project_root, )?; - assert!(!output.status.success()); - assert!(String::from_utf8_lossy(&output.stderr).contains("unmanaged content")); - assert_eq!(fs::read_to_string(&workspace)?, before); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!(fs::read_to_string(dock.join("notes.txt"))?, "do not touch"); + assert!(dock.join("docs").is_dir()); Ok(()) } @@ -981,6 +986,9 @@ fn status_reports_healthy_and_unhealthy_dock_states() -> Result<(), Box Result< Ok(()) } + +#[test] +fn sync_creates_recovers_and_reconciles_without_opening_zed() -> Result<(), Box> { + let temp = tempdir()?; + let api = temp.path().join("api"); + let web = temp.path().join("web"); + fs::create_dir(&api)?; + fs::create_dir(&web)?; + let workspace = temp.path().join("work.code-workspace"); + let document = serde_json::json!({"folders":[{"path":api}], "zwd":{"mode":"symlink"}}); + fs::write(&workspace, document.to_string())?; + let arg = workspace.to_string_lossy(); + let sync = run_with_home_and_cwd(&["sync", &arg], temp.path(), temp.path())?; + assert!( + sync.status.success(), + "{}", + String::from_utf8_lossy(&sync.stderr) + ); + let dock = open_dock(&workspace, temp.path(), temp.path())?; + fs::create_dir(dock.join(".dirac"))?; + fs::write(dock.join(".dirac/config.json"), "{}")?; + fs::write(dock.join("notes.txt"), "keep")?; + #[cfg(unix)] + std::os::unix::fs::symlink(&api, dock.join("manual-link"))?; + fs::remove_file(dock.join("api"))?; + let repaired = run_with_home_and_cwd(&["sync"], temp.path(), &dock)?; + assert!( + repaired.status.success(), + "{}", + String::from_utf8_lossy(&repaired.stderr) + ); + assert!(dock.join("api").is_dir()); + let before = fs::read(&workspace)?; + let lock = fs::read(dock.join(".zwd-lock.json"))?; + let again = run_with_home_and_cwd(&["sync", &arg], temp.path(), temp.path())?; + assert!(again.status.success()); + assert_eq!(fs::read(&workspace)?, before); + assert_eq!(fs::read(dock.join(".zwd-lock.json"))?, lock); + fs::write( + &workspace, + serde_json::json!({"folders":[{"path":web}], "zwd":{"mode":"symlink"}}).to_string(), + )?; + open_dock(&workspace, temp.path(), temp.path())?; + assert!(!dock.join("api").exists()); + assert!(dock.join("web").is_dir()); + assert_eq!(fs::read_to_string(dock.join("notes.txt"))?, "keep"); + assert!(dock.join(".dirac/config.json").exists()); + assert!(api.is_dir()); + #[cfg(unix)] + assert_eq!(fs::read_link(dock.join("manual-link"))?, api); + let mut folders_document: Value = serde_json::from_slice(&fs::read(&workspace)?)?; + folders_document["zwd"]["mode"] = serde_json::json!("folders"); + fs::write(&workspace, folders_document.to_string())?; + fs::remove_file(dock.join("web"))?; + let folders_sync = run_with_home_and_cwd(&["sync", &arg], temp.path(), temp.path())?; + assert!(folders_sync.status.success()); + assert!(dock.join("web").is_dir()); + fs::remove_dir(&web)?; + let deleted = run_with_home_and_cwd(&["delete", &arg, "--force"], temp.path(), temp.path())?; + assert!(deleted.status.success()); + assert!(api.is_dir()); + Ok(()) +} + +#[test] +fn sync_folders_without_dock_is_a_noop_and_requires_reference_outside_dock() +-> Result<(), Box> { + let temp = tempdir()?; + let workspace = temp.path().join("work.code-workspace"); + fs::write(&workspace, r#"{"folders":[],"zwd":{"mode":"folders"}}"#)?; + let arg = workspace.to_string_lossy(); + let output = run_with_home_and_cwd(&["sync", &arg], temp.path(), temp.path())?; + assert!(output.status.success()); + assert!(stdout_line(&output).contains("no dock")); + let missing = run_with_home_and_cwd(&["sync"], temp.path(), temp.path())?; + assert!(!missing.status.success()); + assert!(String::from_utf8_lossy(&missing.stderr).contains("workspace")); + Ok(()) +} + +#[cfg(unix)] +#[test] +fn delete_removes_extras_without_following_any_symlinks() -> Result<(), Box> { + let temp = tempdir()?; + let project = temp.path().join("api"); + fs::create_dir(&project)?; + fs::write(project.join("sentinel"), "keep")?; + let workspace = temp.path().join("work.code-workspace"); + fs::write( + &workspace, + serde_json::json!({"folders":[{"path":project}],"zwd":{"mode":"symlink"}}).to_string(), + )?; + let dock = open_dock(&workspace, temp.path(), temp.path())?; + fs::create_dir(dock.join(".dirac"))?; + fs::write(dock.join(".dirac/config"), "remove")?; + std::os::unix::fs::symlink(&project, dock.join("extra"))?; + std::os::unix::fs::symlink(&project, dock.join(".dirac/nested-link"))?; + let arg = workspace.to_string_lossy(); + let preview = run_with_home_and_cwd(&["delete", &arg], temp.path(), temp.path())?; + assert!(preview.status.success()); + assert!(dock.join(".dirac/config").exists()); + let inside = run_with_home_and_cwd( + &["delete", &arg, "--force"], + temp.path(), + &dock.join(".dirac"), + )?; + assert!(!inside.status.success()); + assert!(workspace.exists()); + let deleted = run_with_home_and_cwd(&["delete", &arg, "--force"], temp.path(), temp.path())?; + assert!( + deleted.status.success(), + "{}", + String::from_utf8_lossy(&deleted.stderr) + ); + assert!(!dock.exists()); + assert!(!workspace.exists()); + assert_eq!(fs::read_to_string(project.join("sentinel"))?, "keep"); + Ok(()) +} + +#[cfg(unix)] +#[test] +fn dock_conflicts_and_invalid_ownership_fail_without_mutation() -> Result<(), Box> { + for case in [ + "target", + "directory", + "collision", + "lock-symlink", + "root-symlink", + "owner", + "traversal", + "duplicate", + "relative", + "version", + ] { + let temp = tempdir()?; + let api = temp.path().join("api"); + let web = temp.path().join("web"); + fs::create_dir(&api)?; + fs::create_dir(&web)?; + fs::write(api.join("sentinel"), "keep")?; + let workspace = temp.path().join("work.code-workspace"); + let document = serde_json::json!({"folders":[{"path":api}],"zwd":{"mode":"symlink"}}); + fs::write(&workspace, document.to_string())?; + let dock = open_dock(&workspace, temp.path(), temp.path())?; + let lock_path = dock.join(".zwd-lock.json"); + let mut lock: Value = serde_json::from_slice(&fs::read(&lock_path)?)?; + match case { + "target" => { + fs::remove_file(dock.join("api"))?; + std::os::unix::fs::symlink(&web, dock.join("api"))?; + } + "directory" => { + fs::remove_file(dock.join("api"))?; + fs::create_dir(dock.join("api"))?; + } + "collision" => { + fs::write( + &workspace, + serde_json::json!({"folders":[{"path":web}],"zwd":{"mode":"symlink"}}) + .to_string(), + )?; + std::os::unix::fs::symlink(&web, dock.join("web"))?; + } + "lock-symlink" => { + let outside = temp.path().join("external-lock"); + fs::rename(&lock_path, &outside)?; + std::os::unix::fs::symlink(outside, &lock_path)?; + } + "root-symlink" => { + let outside = temp.path().join("external-dock"); + fs::rename(&dock, &outside)?; + std::os::unix::fs::symlink(outside, &dock)?; + } + "owner" => { + lock["workspace_path"] = + serde_json::json!(temp.path().join("other.code-workspace")); + } + "traversal" => { + lock["links"][0]["name"] = serde_json::json!("../api"); + } + "duplicate" => { + let link = lock["links"][0].clone(); + lock["links"].as_array_mut().unwrap().push(link); + } + "relative" => { + lock["links"][0]["target"] = serde_json::json!("../api"); + } + "version" => { + lock["version"] = serde_json::json!(2); + } + _ => unreachable!(), + } + if matches!( + case, + "owner" | "traversal" | "duplicate" | "relative" | "version" + ) { + fs::write(&lock_path, lock.to_string())?; + } + let before = fs::read(&lock_path)?; + let workspace_before = fs::read(&workspace)?; + let arg = workspace.to_string_lossy(); + for command in ["sync", "open"] { + let output = run_with_home_and_cwd(&[command, &arg], temp.path(), temp.path())?; + assert!( + !output.status.success(), + "{case}: {command} unexpectedly succeeded" + ); + assert_eq!(fs::read(&lock_path)?, before, "{case}"); + assert_eq!(fs::read(&workspace)?, workspace_before); + assert_eq!(fs::read_to_string(api.join("sentinel"))?, "keep"); + } + if !matches!(case, "target" | "directory" | "collision") { + let output = + run_with_home_and_cwd(&["delete", &arg, "--force"], temp.path(), temp.path())?; + assert!(!output.status.success(), "{case}: unsafe delete"); + assert_eq!(fs::read(&lock_path)?, before); + } + assert!(dock.join("api").symlink_metadata().is_ok()); + } + Ok(()) +} From 54eda633ad30b757019ff4b8b82c54562d7844d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Estev=C3=A3o?= Date: Sat, 31 Oct 2026 19:31:31 -0300 Subject: [PATCH 2/3] docs: define dock synchronization and complete deletion --- AGENTS.md | 14 ++--- CONTEXT.md | 12 ++++- README.md | 25 +++++++-- ...rkspaces-with-owned-dock-reconciliation.md | 4 +- ...low-unmanaged-dock-content-and-add-sync.md | 52 +++++++++++++++++++ 5 files changed, 91 insertions(+), 16 deletions(-) create mode 100644 docs/adr/adr-0010-allow-unmanaged-dock-content-and-add-sync.md diff --git a/AGENTS.md b/AGENTS.md index c33e3e7..9f292f3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,15 +4,12 @@ Zed Workspace Dock is a Rust CLI for creating and opening Zed `.code-workspace` Use this file as the local operating guide for AI agents working in this repository. Keep changes aligned with the current CLI contract in `README.md`, the project language in `CONTEXT.md`, and the release decisions in `docs/adr/`. -> [!IMPORTANT] -> The supported way to add folders to an existing registered workspace today is to recreate it with `create ... --name --force` and pass the full folder list again. Do not document or implement an incremental `add` workflow unless the CLI actually gains that command. - ## Current Product Shape - Canonical product name: Zed Workspace Dock. - Canonical short name, repository slug, Cargo package, and installed binary: `zwd`. - Public command and only installed binary: `zwd`, backed by `src/bin/zwd.rs`. -- Commands: `create`, `open`, `install`, and `list`. +- Commands: `create`, `open`, `sync`, `add`, `remove`, `delete`, `status`, `install`, and `list`. - Default create mode: `symlink`. - Alternate open/create mode: `folders`. - Workspace format: strict JSON `.code-workspace` files with optional `folders` and optional `zwd`. @@ -22,7 +19,7 @@ Use this file as the local operating guide for AI agents working in this reposit - Zed task templates come from `resources/zed-tasks.json`. - JSON Schemas live under `resources/schemas/` and are documentation/editor/test resources, not runtime validators. -Windows support is partial in the MVP: folder mode is supported, while symlink dock mode is currently macOS/Linux-oriented. +Windows dock mode uses directory symbolic links and may require Developer Mode or symlink privileges. CI runs tests on Linux and checks Windows compilation. ## Repository Map @@ -96,7 +93,10 @@ If a change affects the Zed UX directly, keep the manual Zed smoke-test gap visi - Never modify a dock directory unless it has a valid `.zwd-lock.json` lock owned by this tool. - Abort on existing dock directories without a valid lock. -- Abort when unmanaged files are present inside a lock-owned dock. +- Preserve unmanaged files, directories, and manual symlinks during open, sync, add, and remove. +- `delete` previews only; `delete --force` removes all contents of a validated owned dock, including extras, without following symlinks. +- Reject symlink dock roots and symlink lock files. Block managed link conflicts before reconciliation. +- Treat the workspace file as configuration and the lock as derived ownership state. - Symlink project folders; do not copy source folders into dock roots. - Do not delete or mutate symlink targets. - Keep workspace names and dock link names to single filesystem entry names. @@ -135,7 +135,7 @@ Use conventional commit messages with lowercase imperative subjects, for example ```text feat: add registered workspace listing -fix: reject unmanaged dock contents +fix: preserve unmanaged dock contents docs: document release automation ``` diff --git a/CONTEXT.md b/CONTEXT.md index 524ba6b..44bf251 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -37,13 +37,21 @@ A standalone workspace file written inside a user-provided output directory via _Avoid_: Local registry, exported registry entry **Symlink dock**: -A lock-protected directory whose entries are symbolic links to the projects from a workspace file. One symlink dock belongs to one workspace file path. +A lock-protected directory containing managed project symlinks and optionally user-created files, directories, or symlinks. One symlink dock belongs to one workspace file path. _Avoid_: Temporary copy, workspace copy, virtual workspace **Dock lock**: The JSON ownership record stored as `.zwd-lock.json` inside a symlink dock. A dock lock identifies the owning workspace file and the symlink entries managed by Zed Workspace Dock. _Avoid_: Marker, ownership marker, cache manifest +**Managed dock link**: +A project symlink whose name and target are recorded in the owning dock lock. +_Avoid_: Any symlink in the dock + +**Unmanaged dock content**: +A dock entry not recorded as a managed link in the dock lock. It belongs to the dock's local contents but not to workspace membership. +_Avoid_: Invalid content, inconsistent workspace + **Dock reconciliation**: The safe in-place synchronization of an existing owned symlink dock with its workspace membership. _Avoid_: Dock refresh, cache rebuild @@ -104,7 +112,7 @@ Older planning text may use pluralized product names, describe `zwd` as an alias - Created workspaces store folder paths as canonical absolute paths resolved from the current working directory at creation time. - `add ... --workspace ` and `remove ... --workspace ` change workspace membership. The workspace flag is optional only from the root of a valid owned dock. - `add` and `remove` are idempotent and synchronize an existing dock incrementally. They do not create a dock that does not already exist. -- `delete ` prints a deletion preview; `delete --force` removes the workspace file and its valid owned dock, never its project folders. +- `delete ` prints a deletion preview; `delete --force` removes the workspace file and its entire valid owned dock, including unmanaged content, never symlink targets. - `status [workspace]` reports workspace and dock health. An absent dock is normal, while unresolvable folders or unsafe docks are unhealthy states. - `open ` prefers a registered workspace over a same-name file or directory in the current working directory. Use an explicit path such as `./work.code-workspace` when the local file is intended. - `list` prints registered workspaces as `namepath`. diff --git a/README.md b/README.md index 8e6c25e..5e0f843 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ Zed can open multiple folders directly, but its terminal still benefits from a s - Choose between direct `folders` mode and managed `symlink` dock mode. - Add or remove one or more project folders while synchronizing an existing dock safely. - Install global Zed tasks backed by the packaged task templates. -- Rebuild dock roots safely using `.zwd-lock.json` ownership locks. +- Reconcile dock links safely using `.zwd-lock.json` ownership locks, preserving extra files and manual symlinks. - Use the short `zwd` command for repeated terminal workflows. ## Install @@ -158,13 +158,26 @@ zwd remove ../legacy Both commands are idempotent. Adding an existing folder or removing an absent one reports that state without changing the workspace or dock. A dock that already exists is reconciled immediately and incrementally; a dock that does not exist is still materialized by the next `zwd open` in symlink mode. +Synchronize a dock without opening Zed: + +```bash +zwd sync work +zwd sync ./work.code-workspace +# From the root of a managed dock: +zwd sync +``` + +`open` and `sync` recover missing managed links and reconcile changes from the `.code-workspace` configuration. Correct links remain in place. Files, directories such as `.dirac`, and manually added symlinks are preserved without being adopted into the lock. A changed managed symlink target, a managed link replaced by a real file/directory, or an unmanaged entry occupying a required link name causes an error before link changes. Restore or move that conflicting entry manually before retrying. + +`sync` creates an absent dock for a symlink-mode workspace. For a folder-mode workspace it synchronizes an existing dock, or succeeds with a message when there is no dock. Workspace inference uses only the lock in the current dock root; it does not search parent directories. The workspace file remains the source of truth, so synchronization does not restore projects removed from that file. + Inspect a workspace and its dock health: ```bash zwd status work ``` -`status` reports the workspace path, registration, mode, resolved folders, and dock state. It exits unsuccessfully after reporting an unresolvable folder or an unsafe dock. A missing dock is normal until symlink mode has opened the workspace. +`status` reports the workspace path, registration, mode, resolved folders, and dock state without changing files. Extra dock content is healthy; missing managed links, changed destinations, stale lock membership, unresolvable folders, or invalid ownership are unhealthy. Use `sync` to recover missing links or reconcile configuration changes. A missing dock is normal before the first `open` or `sync` in symlink mode. Preview workspace deletion, then confirm it: @@ -173,7 +186,7 @@ zwd delete work zwd delete work --force ``` -`delete` accepts a registered name or an explicit `.code-workspace` path. Without `--force`, it only lists the workspace file and owned dock that would be removed. With `--force`, it removes the workspace file and only its validated, lock-owned dock. It never deletes project folders, does not close applications using the dock, and refuses to delete while its own current directory is that dock root. +`delete` accepts a registered name or an explicit `.code-workspace` path. Without `--force`, it previews the workspace file and owned dock and states that all local dock contents would be removed. With `--force`, it removes the entire validated, lock-owned dock, including extra files, nested directories, and manual symlinks, then removes the workspace file. Symlinks are unlinked without following their targets, so linked project folders are preserved. Files stored only inside the dock are deleted. The command does not close applications using the dock and refuses to delete from the dock root or a real subdirectory beneath it. Missing project targets do not prevent deletion. Filesystem errors can interrupt deletion; the workspace file is kept if dock removal fails. ## Zed Tasks @@ -247,14 +260,16 @@ The schemas are documentation, editor, and test resources. Runtime parsing still - Rebuilds modify only lock-owned docks. - Incremental edits reconcile only changed symlinks in an existing owned dock. - Existing dock directories without a valid lock abort. -- Unmanaged files inside a lock-owned dock abort. +- Extra files and manual symlinks are preserved during opening and reconciliation; `delete --force` removes all local dock contents. +- Existing dock roots must be real directories and locks must be regular files, not symlinks. +- Managed link conflicts abort synchronization before any link changes. - Project folders are symlinked, not copied. - Symlink targets are not deleted or mutated. - `folders[].name` must be one filesystem entry name, not a path. - `folders[].name` cannot use reserved dock metadata names such as `.zwd-lock.json`. - Registered workspace names must be one filesystem entry name without `.code-workspace`; `open` accepts the name with or without that extension. - Registered workspace creation does not overwrite an existing workspace unless `--force` is passed. -- `delete --force` never bypasses dock ownership or unmanaged-content checks. +- `delete --force` never bypasses dock ownership checks; it confirms deletion of the entire owned dock. ## Development diff --git a/docs/adr/adr-0009-mutate-workspaces-with-owned-dock-reconciliation.md b/docs/adr/adr-0009-mutate-workspaces-with-owned-dock-reconciliation.md index 87df449..cdbce81 100644 --- a/docs/adr/adr-0009-mutate-workspaces-with-owned-dock-reconciliation.md +++ b/docs/adr/adr-0009-mutate-workspaces-with-owned-dock-reconciliation.md @@ -5,14 +5,14 @@ date: "2026-08-08" authors: "Project maintainer" tags: ["architecture", "cli", "workspace", "dock", "safety"] supersedes: "" -superseded_by: "" +superseded_by: "ADR-0010 (unmanaged content and deletion policy only)" --- # ADR-0009: Mutate workspaces with owned dock reconciliation ## Status -Accepted +Accepted; unmanaged-content restrictions and deletion policy are superseded by ADR-0010. Other decisions remain in effect. ## Context diff --git a/docs/adr/adr-0010-allow-unmanaged-dock-content-and-add-sync.md b/docs/adr/adr-0010-allow-unmanaged-dock-content-and-add-sync.md new file mode 100644 index 0000000..e703015 --- /dev/null +++ b/docs/adr/adr-0010-allow-unmanaged-dock-content-and-add-sync.md @@ -0,0 +1,52 @@ +--- +title: "ADR-0010: Allow unmanaged dock content and add sync" +status: "Accepted" +date: "2026-10-31" +authors: "Project maintainer" +tags: ["cli", "dock", "safety"] +supersedes: "ADR-0009 (unmanaged content and deletion policy only)" +superseded_by: "" +--- + +# ADR-0010: Allow unmanaged dock content and add sync + +## Status + +Accepted. + +## Context + +Issue #35 demonstrates that tools commonly create configuration directories in the dock root. Rejecting every extra entry prevents reopening otherwise valid workspaces. The old reconciliation algorithm relied on that rejection before removing entries outside desired workspace membership. + +## Decision + +Preserve unmanaged files, directories, and symlinks during `open`, `sync`, `add`, and `remove`. Reconcile only previously managed links, using the workspace file as configuration and the lock as derived ownership state. Recover missing links automatically; reject changed managed destinations, real entries replacing managed links, and collisions with unmanaged entries before modifying links. + +Add `sync [workspace]` to perform reconciliation without opening Zed. Accept registered names and explicit paths, or infer ownership only from the current dock root. Create absent symlink-mode docks. For folder-mode workspaces, reconcile an existing dock or report that none requires synchronization. Do not adopt manual symlinks or edit workspace membership during sync. + +Keep `delete` as a non-mutating preview. Explicit `delete --force` authorizes removal of the entire validated owned dock, including unmanaged contents and extra symlinks, followed by the workspace file. Never follow symlinks or delete their targets. Ownership validation remains mandatory and independent of link health or project availability. Reject execution inside the dock or its real descendants. + +## Consequences + +- Configuration generated by editors and agents no longer blocks opening workspaces. +- Correct symlinks stay in place, reducing disruption to active editor sessions. +- Files kept exclusively inside the dock are deleted by `delete --force`; the preview and usage documentation state this explicitly. +- Ownership checks reject symlink dock roots, non-regular locks, invalid link names, duplicate names, relative ownership/target paths, and unsupported versions. +- Lock version 1 and the persistence layout remain unchanged. Filesystem failures may interrupt removal; deletion is not transactional. + +## Alternatives Considered + +- Reject all extras: conflicts with routine tool behavior and issue #35. +- Prompt during open or adopt manual symlinks: disrupts automated tasks or implicitly changes ownership. +- Preserve extras after deletion: leaves unregistered content in the cache and conflicts with the selected whole-dock deletion contract. +- Restore exclusively from the lock: can resurrect projects removed from the authoritative workspace file. + +## Implementation Notes + +Separate ownership validation from health inspection and reconciliation. Validate conflicts before applying changes and preserve unmanaged entries during rollback. Use standard-library recursive removal without following symlinks. Keep schema resources descriptive rather than runtime validators. + +## References + +- [Issue #35](https://github.com/runrly/zwd/issues/35) +- [ADR-0009](adr-0009-mutate-workspaces-with-owned-dock-reconciliation.md) +- [Rust remove_dir_all](https://doc.rust-lang.org/std/fs/fn.remove_dir_all.html) From 74d2e277b3095f2e89f61fd5fac6a00c7ae947bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Estev=C3=A3o?= Date: Sat, 31 Oct 2026 19:41:41 -0300 Subject: [PATCH 3/3] fix: reconcile case-only managed link renames Distinguish case-insensitive lookup aliases from separate directory entries before rejecting unmanaged collisions. Recreate renamed links after removing their old managed spelling, including configuration target changes. Add CLI regression tests and run case-only rename coverage on macOS CI. Local Linux suite, formatting, Clippy, and local/Windows compilation pass; native macOS execution remains pending. --- .github/workflows/ci.yml | 8 ++++ AGENTS.md | 2 +- src/dock.rs | 18 ++++++++- tests/cli.rs | 85 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 110 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e5970b2..12a3871 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,14 @@ permissions: contents: read jobs: + case-only-renames: + name: Case-only link renames (macOS) + runs-on: macos-latest + steps: + - uses: actions/checkout@v6 + - name: Test case-only link renames on a case-insensitive filesystem + run: cargo test --test cli case_only --locked + rust: name: Rust runs-on: ubuntu-22.04 diff --git a/AGENTS.md b/AGENTS.md index 9f292f3..83f02c8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,7 +19,7 @@ Use this file as the local operating guide for AI agents working in this reposit - Zed task templates come from `resources/zed-tasks.json`. - JSON Schemas live under `resources/schemas/` and are documentation/editor/test resources, not runtime validators. -Windows dock mode uses directory symbolic links and may require Developer Mode or symlink privileges. CI runs tests on Linux and checks Windows compilation. +Windows dock mode uses directory symbolic links and may require Developer Mode or symlink privileges. CI runs the full tests on Linux, case-only link rename regressions on macOS, and checks Windows compilation. ## Repository Map diff --git a/src/dock.rs b/src/dock.rs index 8987b20..bf93f0b 100644 --- a/src/dock.rs +++ b/src/dock.rs @@ -219,6 +219,9 @@ fn reconcile_dock( ) -> Result<()> { let lock = validate_owned_dock(dock_root, workspace_path)?; validate_managed_links(dock_root, &lock)?; + let entry_names = fs::read_dir(dock_root)? + .map(|entry| entry.map(|entry| entry.file_name())) + .collect::>>()?; let mut changes = Vec::new(); for link in &lock.links { let path = dock_root.join(&link.name); @@ -241,10 +244,21 @@ fn reconcile_dock( .links .iter() .any(|link| link.name == folder.name.as_str()); - if present && !managed { + // A successful lookup without this exact directory entry spelling can be + // a case-insensitive alias of an old managed name scheduled for removal. + // An actual extra entry with the desired spelling must remain a conflict, + // even when its symlink points to the same project. + let aliases_managed = present + && !managed + && !entry_names.contains(std::ffi::OsStr::new(folder.name.as_str())) + && lock + .links + .iter() + .any(|link| link.name.to_lowercase() == folder.name.as_str().to_lowercase()); + if present && !managed && !aliases_managed { return Err(AppError::DockLinkPathExists { path }); } - if !present { + if !present || aliases_managed { changes.push(LinkChange { path, previous: None, diff --git a/tests/cli.rs b/tests/cli.rs index 673a96d..b72e0d8 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -1345,3 +1345,88 @@ fn dock_conflicts_and_invalid_ownership_fail_without_mutation() -> Result<(), Bo } Ok(()) } + +#[test] +fn open_and_sync_reconcile_case_only_link_renames() -> Result<(), Box> { + for command in ["open", "sync"] { + let temp = tempdir()?; + let project = temp.path().join("project"); + fs::create_dir(&project)?; + fs::write(project.join("sentinel"), "keep")?; + let workspace = temp.path().join("work.code-workspace"); + let mut document = serde_json::json!({ + "folders": [{"name":"api", "path":project}], + "zwd": {"mode":"symlink"} + }); + fs::write(&workspace, document.to_string())?; + let dock = open_dock(&workspace, temp.path(), temp.path())?; + fs::write(dock.join("notes.txt"), "keep")?; + // Exercise both directions, including a simultaneous target change. + let other = temp.path().join("other"); + fs::create_dir(&other)?; + for (name, target) in [("API", &project), ("api", &other)] { + document["folders"][0]["name"] = serde_json::json!(name); + document["folders"][0]["path"] = serde_json::json!(target); + fs::write(&workspace, document.to_string())?; + let arg = workspace.to_string_lossy(); + let args = if command == "open" { + vec![command, &arg, "--reuse", "--zed-bin", "/bin/echo"] + } else { + vec![command, &arg] + }; + let output = run_with_home_and_cwd(&args, temp.path(), temp.path())?; + assert!( + output.status.success(), + "{command}: {}", + String::from_utf8_lossy(&output.stderr) + ); + let names = fs::read_dir(&dock)? + .map(|entry| entry.map(|entry| entry.file_name())) + .collect::, _>>()?; + assert!(names.iter().any(|entry| entry == name)); + assert!( + !names + .iter() + .any(|entry| entry == if name == "API" { "api" } else { "API" }) + ); + assert_eq!(fs::read_link(dock.join(name))?, target.canonicalize()?); + let lock: Value = serde_json::from_slice(&fs::read(dock.join(".zwd-lock.json"))?)?; + assert_eq!(lock["links"][0]["name"], name); + assert_eq!(fs::read_to_string(dock.join("notes.txt"))?, "keep"); + } + assert_eq!(fs::read_to_string(project.join("sentinel"))?, "keep"); + } + Ok(()) +} + +#[cfg(unix)] +#[test] +fn case_only_rename_rejects_a_separate_manual_link_to_the_same_target() -> Result<(), Box> +{ + let temp = tempdir()?; + let project = temp.path().join("project"); + fs::create_dir(&project)?; + let workspace = temp.path().join("work.code-workspace"); + let mut document = + serde_json::json!({"folders":[{"name":"api","path":project}],"zwd":{"mode":"symlink"}}); + fs::write(&workspace, document.to_string())?; + let dock = open_dock(&workspace, temp.path(), temp.path())?; + if dock.join("API").symlink_metadata().is_ok() { + // This filesystem aliases the managed entry; the rename regression test covers it. + return Ok(()); + } + std::os::unix::fs::symlink(&project, dock.join("API"))?; + let original_lock = fs::read(dock.join(".zwd-lock.json"))?; + document["folders"][0]["name"] = serde_json::json!("API"); + fs::write(&workspace, document.to_string())?; + let arg = workspace.to_string_lossy(); + for command in ["sync", "open"] { + let output = run_with_home_and_cwd(&[command, &arg], temp.path(), temp.path())?; + assert!(!output.status.success()); + assert!(String::from_utf8_lossy(&output.stderr).contains("already exists")); + assert!(dock.join("api").symlink_metadata()?.is_symlink()); + assert!(dock.join("API").symlink_metadata()?.is_symlink()); + assert_eq!(fs::read(dock.join(".zwd-lock.json"))?, original_lock); + } + Ok(()) +}