From 5ca0272f15a80b7cc3cbd2bbf455fcb4675333f4 Mon Sep 17 00:00:00 2001 From: danielebrox Date: Mon, 2 Nov 2026 19:23:49 +0100 Subject: [PATCH] feat(sync): attach local folders to existing My files folders --- crates/pdfs-cli/src/main.rs | 25 +++ crates/pdfs-core/src/control.rs | 7 + crates/pdfs-fuse/src/attach.rs | 136 ++++++++++++++++ crates/pdfs-fuse/src/control.rs | 26 +++ crates/pdfs-fuse/src/devices.rs | 6 + crates/pdfs-fuse/src/drive.rs | 8 +- crates/pdfs-fuse/src/lib.rs | 1 + crates/pdfs-fuse/src/profile.rs | 1 + crates/pdfs-fuse/src/sim/daemon.rs | 214 +++++++++++++++++++++++++ crates/pdfs-fuse/src/sim/fake_drive.rs | 8 + docs/CHANGELOG.md | 5 + docs/CLI.md | 1 + docs/SYNCED_FOLDERS.md | 16 ++ 13 files changed, 453 insertions(+), 1 deletion(-) create mode 100644 crates/pdfs-fuse/src/attach.rs diff --git a/crates/pdfs-cli/src/main.rs b/crates/pdfs-cli/src/main.rs index 90cd4ce..c8cdf35 100644 --- a/crates/pdfs-cli/src/main.rs +++ b/crates/pdfs-cli/src/main.rs @@ -696,6 +696,15 @@ enum SyncCmd { /// Local directory to sync. path: PathBuf, }, + /// Sync a real local folder with an existing folder in My files. + /// The first pass preserves local-only files and keeps differing versions. + Attach { + /// Existing local directory; it remains a real local directory. + path: PathBuf, + /// Existing folder in My files, relative to the cloud mount or inside it. + #[arg(long)] + to: PathBuf, + }, /// List this machine's synced folders. List, /// Remove a synced folder by id. @@ -1116,6 +1125,22 @@ fn cmd_sync(action: SyncCmd) -> Result<()> { .to_string(); ok_or_bail(control_request(CtlRequest::AddSyncFolder { local_path })?)? } + SyncCmd::Attach { path, to } => { + let abs = std::fs::canonicalize(&path) + .with_context(|| format!("resolve {}", path.display()))?; + let local_path = abs + .to_str() + .ok_or_else(|| anyhow!("path is not valid UTF-8"))? + .to_owned(); + let remote_path = to + .to_str() + .ok_or_else(|| anyhow!("remote path is not valid UTF-8"))? + .to_owned(); + ok_or_bail(control_request(CtlRequest::AttachSyncFolder { + local_path, + remote_path, + })?)? + } SyncCmd::List => { let response = control_request(CtlRequest::ListSyncFolders)?; if emit_json(&response)? { diff --git a/crates/pdfs-core/src/control.rs b/crates/pdfs-core/src/control.rs index 3b7c590..0407f94 100644 --- a/crates/pdfs-core/src/control.rs +++ b/crates/pdfs-core/src/control.rs @@ -469,6 +469,13 @@ pub enum Request { /// Add a local folder to this machine's device, uploading its tree and /// registering the device on first use. Replies with [`Response::Ok`]. AddSyncFolder { local_path: String }, + /// Keep a real local directory in step with an existing folder in My files. + /// First sync unions one-sided files and preserves differing versions. + /// Replies with [`Response::Ok`]; no device backup is created. + AttachSyncFolder { + local_path: String, + remote_path: String, + }, /// List this device's synced folders. Replies with [`Response::SyncFolders`]. ListSyncFolders, /// Remove a synced folder by id; `delete_remote` also trashes its cloud copy. diff --git a/crates/pdfs-fuse/src/attach.rs b/crates/pdfs-fuse/src/attach.rs new file mode 100644 index 0000000..810b2c5 --- /dev/null +++ b/crates/pdfs-fuse/src/attach.rs @@ -0,0 +1,136 @@ +//! Explicitly bind an existing My files folder to a real local mirror. +//! +//! Unlike a FUSE mount, this never covers or evicts the local directory. +//! The existing three-way engine performs the initial, non-destructive union. + +use super::{Core, parse_uid, sync}; +use pdfs_core::db::StoredSyncFolder; +use pdfs_core::{CoreError, CoreResult}; +use proton_drive_rs::proton_sdk::ids::NodeUid; +use std::collections::HashSet; +use std::path::{Component, Path, PathBuf}; + +impl Core { + /// A bounded, server-verified ancestry walk. Do not decide ownership or + /// remote-root overlap from a possibly incomplete cached folder listing. + fn attach_ancestors(&self, start: &NodeUid) -> CoreResult> { + let mut ancestors = Vec::new(); + let mut seen = HashSet::new(); + let mut next = Some(start.clone()); + while let Some(uid) = next { + if !seen.insert(uid.clone()) || ancestors.len() >= 256 { + return Err(CoreError::invalid( + "invalid or excessively deep remote folder ancestry", + )); + } + ancestors.push(uid.clone()); + if uid == self.primary_root_uid { + break; + } + let nodes = self + .rt + .block_on(self.drive.enumerate_nodes(std::slice::from_ref(&uid))) + .map_err(|e| CoreError::from_api(&e, "verify sync folder ancestry"))?; + let node = nodes + .into_iter() + .next() + .ok_or_else(|| CoreError::not_found("remote sync folder is missing"))?; + if node.trashed { + return Err(CoreError::invalid("remote sync folder is in the trash")); + } + next = node.parent_uid; + } + Ok(ancestors) + } + + pub(crate) fn attach_sync_folder( + &self, + local: &Path, + remote: &Path, + mountpoint: &Path, + ) -> CoreResult { + let _registration = super::devices::SYNC_ROOT_REGISTRATION.lock(); + if !self.primary { + return Err(CoreError::invalid("attach must use My files")); + } + if remote.is_absolute() + || remote + .components() + .any(|c| !matches!(c, Component::Normal(_) | Component::CurDir)) + { + return Err(CoreError::invalid( + "remote folder must stay inside My files", + )); + } + let local = local + .canonicalize() + .map_err(|e| CoreError::invalid(format!("resolve {}: {e}", local.display())))?; + if !local.is_dir() { + return Err(CoreError::invalid("local sync path is not a directory")); + } + let existing = self + .db + .sync_folder_list() + .map_err(|e| CoreError::internal(format!("db: {e:?}")))?; + let others = existing + .iter() + .map(|f| PathBuf::from(&f.local_path)) + .collect::>(); + let mut reserved = crate::devices::sync_root_reserved(); + reserved.mountpoint = Some( + mountpoint + .canonicalize() + .map_err(|e| CoreError::internal(format!("resolve mountpoint: {e}")))?, + ); + if let Some(problem) = pdfs_core::syncroot::problem(&local, &others, &reserved) { + return Err(CoreError::invalid(problem)); + } + let (ino, remote_uid) = self.resolve(remote)?; + let is_folder = self + .state() + .entries + .get(&ino) + .is_some_and(|e| e.node.is_folder() && !e.node.trashed); + if !is_folder { + return Err(CoreError::invalid("remote sync path is not a folder")); + } + let ancestors = self.attach_ancestors(&remote_uid)?; + if remote_uid == self.primary_root_uid || !ancestors.contains(&self.primary_root_uid) { + return Err(CoreError::invalid( + "choose an owned folder below My files, not its root or a shared folder", + )); + } + for other in &existing { + let uid = parse_uid(&other.remote_uid) + .ok_or_else(|| CoreError::internal("invalid existing remote sync root"))?; + if uid == remote_uid + || ancestors.contains(&uid) + || self.attach_ancestors(&uid)?.contains(&remote_uid) + { + return Err(CoreError::invalid( + "remote folder overlaps an existing synced folder", + )); + } + } + let share_id = self + .rt + .block_on(self.drive.context_share_id(&self.primary_root_uid)) + .map_err(|e| CoreError::from_api(&e, "resolve My files share"))? + .to_string(); + if share_id.is_empty() { + return Err(CoreError::invalid("My files share is unavailable")); + } + let id = self + .db + .sync_folder_add(&local.to_string_lossy(), &remote_uid.to_string(), &share_id) + .map_err(|e| CoreError::internal(format!("db: {e:?}")))?; + // An empty baseline makes one-sided files new, never deletions. The + // engine hashes matching-size files and preserves divergent versions. + let _ = self.sync_tx.send(sync::SyncMsg::Rewatch); + let _ = self.sync_tx.send(sync::SyncMsg::Reconcile(id)); + self.db + .sync_folder_get(id) + .map_err(|e| CoreError::internal(format!("db: {e:?}")))? + .ok_or_else(|| CoreError::not_found("new sync folder disappeared")) + } +} diff --git a/crates/pdfs-fuse/src/control.rs b/crates/pdfs-fuse/src/control.rs index d45fa95..e2209a1 100644 --- a/crates/pdfs-fuse/src/control.rs +++ b/crates/pdfs-fuse/src/control.rs @@ -385,6 +385,7 @@ fn touched_topics(request: &CtlRequest) -> &'static [Topic] { R::RenameDevice { .. } | R::DeleteDevice { .. } => &[Topic::Devices], R::AdoptDevice { .. } | R::MigrateDevice { .. } => &[Topic::Devices, Topic::Locations], R::AddSyncFolder { .. } + | R::AttachSyncFolder { .. } | R::RemoveSyncFolder { .. } | R::SetSyncFolderMode { .. } | R::SetSyncFolderPaused { .. } @@ -1397,6 +1398,31 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U message: format!("adding {local_path}"), } } + Ok(CtlRequest::AttachSyncFolder { + local_path, + remote_path, + }) => { + let result = rel_to_mount(mountpoint, &remote_path).and_then(|remote| { + core.attach_sync_folder(Path::new(&local_path), &remote, mountpoint) + }); + match result { + Ok(folder) => { + core.log_activity( + ActivityKind::Upload, + &local_path, + "attached existing My files folder", + true, + ); + CtlResponse::Ok { + message: format!( + "sync folder {} attached: {}", + folder.id, folder.local_path + ), + } + } + Err(error) => CtlResponse::error(error), + } + } Ok(CtlRequest::ListSyncFolders) => match core.list_sync_folders() { Ok(items) => CtlResponse::SyncFolders { items }, Err(e) => CtlResponse::error(e), diff --git a/crates/pdfs-fuse/src/devices.rs b/crates/pdfs-fuse/src/devices.rs index f7e4ca4..1129ebe 100644 --- a/crates/pdfs-fuse/src/devices.rs +++ b/crates/pdfs-fuse/src/devices.rs @@ -33,6 +33,11 @@ use super::{ now_secs, parse_uid, spawn_session, sync_folder_info, this_hostname, }; +/// Registration validates local and remote root overlap before inserting a +/// mapping. Serialize all registration paths so concurrent control requests +/// cannot both validate against the same old set of roots. +pub(super) static SYNC_ROOT_REGISTRATION: Mutex<()> = Mutex::new(()); + /// A mode that this daemon is allowed to apply. /// /// Persisted and control-protocol values first pass through [`MountMode`] so @@ -513,6 +518,7 @@ impl Core { /// tree into it once, and record the mapping. Phase 1 is a one-shot upload — /// the two-way engine (Phase 2) reconciles later changes. pub(crate) fn add_sync_folder(&self, local: &Path) -> CoreResult { + let _registration = SYNC_ROOT_REGISTRATION.lock(); let meta = std::fs::metadata(local) .map_err(|e| CoreError::internal(format!("stat {}: {e}", local.display())))?; if !meta.is_dir() { diff --git a/crates/pdfs-fuse/src/drive.rs b/crates/pdfs-fuse/src/drive.rs index 5d9a261..b2d6342 100644 --- a/crates/pdfs-fuse/src/drive.rs +++ b/crates/pdfs-fuse/src/drive.rs @@ -26,7 +26,7 @@ use futures::StreamExt as _; use futures::stream::BoxStream; use proton_drive_rs::proton_sdk::account::Quota; use proton_drive_rs::proton_sdk::error::Result; -use proton_drive_rs::proton_sdk::ids::{DriveEventId, NodeUid}; +use proton_drive_rs::proton_sdk::ids::{DriveEventId, NodeUid, ShareId}; use proton_drive_rs::{ AvailableNameTarget, Device, DeviceType, DriveEvent, DriveEventScopeId, Node, NodeMoveItem, ProtonDriveClient, RevisionReader, Thumbnail, @@ -43,6 +43,8 @@ pub(crate) type OutcomeStream<'a> = BoxStream<'a, Result<(NodeUid, Result<()>)>> pub(crate) trait DriveApi: Send + Sync { async fn get_my_files_folder(&self) -> Result; + async fn context_share_id(&self, uid: &NodeUid) -> Result; + async fn get_node(&self, uid: &NodeUid) -> Result>; async fn enumerate_nodes(&self, uids: &[NodeUid]) -> Result>; @@ -305,6 +307,10 @@ impl RevisionRead for RevisionReader { #[async_trait] impl DriveApi for ProtonDriveClient { + async fn context_share_id(&self, uid: &NodeUid) -> Result { + ProtonDriveClient::context_share_id(self, uid).await + } + async fn get_my_files_folder(&self) -> Result { ProtonDriveClient::get_my_files_folder(self).await } diff --git a/crates/pdfs-fuse/src/lib.rs b/crates/pdfs-fuse/src/lib.rs index 29a256e..e2432e9 100644 --- a/crates/pdfs-fuse/src/lib.rs +++ b/crates/pdfs-fuse/src/lib.rs @@ -79,6 +79,7 @@ use proton_drive_rs::{ }; mod albums; +mod attach; mod background; mod conflicts; mod control; diff --git a/crates/pdfs-fuse/src/profile.rs b/crates/pdfs-fuse/src/profile.rs index cd24a8d..23aa8ac 100644 --- a/crates/pdfs-fuse/src/profile.rs +++ b/crates/pdfs-fuse/src/profile.rs @@ -389,6 +389,7 @@ impl Core { items: &[pdfs_core::control::RestoreItem], device: Option<&str>, ) -> CoreResult { + let _registration = super::devices::SYNC_ROOT_REGISTRATION.lock(); if items.is_empty() { return Err(CoreError::invalid("nothing to restore")); } diff --git a/crates/pdfs-fuse/src/sim/daemon.rs b/crates/pdfs-fuse/src/sim/daemon.rs index 561aa83..6c2ee39 100644 --- a/crates/pdfs-fuse/src/sim/daemon.rs +++ b/crates/pdfs-fuse/src/sim/daemon.rs @@ -2912,6 +2912,220 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + #[ignore = "mounts FUSE: run explicitly on an isolated fake Drive"] + fn attaching_my_files_preserves_local_files_and_syncs_both_ways() { + use std::os::unix::fs::MetadataExt; + let drive = FakeDrive::new(); + let dir = scratch("attach-my-files"); + let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap(); + std::fs::create_dir(daemon.mountpoint.join("Work")).unwrap(); + assert!(wait_until(Duration::from_secs(30), || { + daemon.pending().is_ok_and(|items| items.is_empty()) && drive.lookup("Work").is_some() + })); + drive.device().write("Work/cloud.txt", b"from cloud"); + drive.device().write("Work/common.txt", b"remote version"); + drive.device().write("Work/identical.txt", b"same bytes"); + let local = dir.join("local"); + std::fs::create_dir(&local).unwrap(); + std::fs::write(local.join("local.txt"), b"only on this computer").unwrap(); + std::fs::write(local.join("common.txt"), b"local version").unwrap(); + std::fs::write(local.join("identical.txt"), b"same bytes").unwrap(); + daemon + .request(&Request::AttachSyncFolder { + local_path: local.to_str().unwrap().into(), + remote_path: "Work".into(), + }) + .unwrap(); + let folder = match daemon.request(&Request::ListSyncFolders).unwrap() { + Response::SyncFolders { items } => { + items + .into_iter() + .find(|f| f.local_path == local.to_str().unwrap()) + .unwrap() + .id + } + other => panic!("unexpected reply: {other:?}"), + }; + assert!(wait_until(Duration::from_secs(30), || { + drive.lookup("Work/local.txt").is_some() + && std::fs::read(local.join("cloud.txt")).is_ok_and(|b| b == b"from cloud") + && std::fs::read(local.join("common.txt")).is_ok_and(|b| b == b"remote version") + && !conflict_copies(&local, "common").is_empty() + })); + assert_ne!( + std::fs::metadata(&local).unwrap().dev(), + std::fs::metadata(&daemon.mountpoint).unwrap().dev() + ); + assert!( + conflict_copies(&local, "common") + .iter() + .any(|p| std::fs::read(p).is_ok_and(|b| b == b"local version")) + ); + assert!( + daemon + .request(&Request::AttachSyncFolder { + local_path: local.to_str().unwrap().into(), + remote_path: "Work".into() + }) + .is_err() + ); + let duplicate = dir.join("duplicate"); + std::fs::create_dir(&duplicate).unwrap(); + assert!( + daemon + .request(&Request::AttachSyncFolder { + local_path: duplicate.to_str().unwrap().into(), + remote_path: "Work".into() + }) + .is_err() + ); + assert!( + daemon + .request(&Request::AttachSyncFolder { + local_path: duplicate.to_str().unwrap().into(), + remote_path: "../Work".into() + }) + .is_err() + ); + // Parent/child mappings, the account root and files cannot be attached. + std::fs::create_dir(daemon.mountpoint.join("Work/Sub")).unwrap(); + assert!(wait_until(Duration::from_secs(30), || { + daemon.pending().is_ok_and(|items| items.is_empty()) + && drive.lookup("Work/Sub").is_some() + })); + for remote in ["Work/Sub", ".", "Work/cloud.txt"] { + assert!( + daemon + .request(&Request::AttachSyncFolder { + local_path: duplicate.to_str().unwrap().into(), + remote_path: remote.into(), + }) + .is_err() + ); + } + // Local overlap and symlink aliases into the actual cloud mount are refused. + let alias = dir.join("cloud-alias"); + std::os::unix::fs::symlink(daemon.mountpoint.join("Work"), &alias).unwrap(); + std::fs::create_dir_all(local.join("Sub")).unwrap(); + for path in [ + local.join("Sub"), + dir.clone(), + daemon.mountpoint.join("Work"), + alias, + ] { + let error = daemon + .request(&Request::AttachSyncFolder { + local_path: path.to_string_lossy().into_owned(), + remote_path: "Work/Sub".into(), + }) + .unwrap_err() + .to_string(); + assert!(error.contains("overlaps"), "{error}"); + } + assert!(conflict_copies(&local, "identical").is_empty()); + assert_eq!( + std::fs::read(local.join("identical.txt")).unwrap(), + b"same bytes" + ); + // Simultaneous requests cannot register two local trees for one cloud root. + std::fs::create_dir(daemon.mountpoint.join("Other")).unwrap(); + assert!(wait_until(Duration::from_secs(30), || { + daemon.pending().is_ok_and(|items| items.is_empty()) && drive.lookup("Other").is_some() + })); + let candidate_a = dir.join("candidate-a"); + let candidate_b = dir.join("candidate-b"); + std::fs::create_dir(&candidate_a).unwrap(); + std::fs::create_dir(&candidate_b).unwrap(); + let outcomes = std::thread::scope(|scope| { + let a = scope.spawn(|| { + daemon + .request(&Request::AttachSyncFolder { + local_path: candidate_a.to_str().unwrap().into(), + remote_path: "Other".into(), + }) + .map_err(|e| e.to_string()) + }); + let b = scope.spawn(|| { + daemon + .request(&Request::AttachSyncFolder { + local_path: candidate_b.to_str().unwrap().into(), + remote_path: "Other".into(), + }) + .map_err(|e| e.to_string()) + }); + [a.join().unwrap(), b.join().unwrap()] + }); + assert_eq!( + outcomes.iter().filter(|result| result.is_ok()).count(), + 1, + "exactly one mapping may own a remote root: {outcomes:?}" + ); + drive + .device() + .write("Work/cloud.txt", b"new cloud revision"); + daemon + .request(&Request::SyncNow { id: Some(folder) }) + .unwrap(); + assert!(wait_until(Duration::from_secs(30), || std::fs::read( + local.join("cloud.txt") + ) + .is_ok_and(|b| b == b"new cloud revision"))); + std::fs::write(local.join("local.txt"), b"new local revision").unwrap(); + daemon + .request(&Request::SyncNow { id: Some(folder) }) + .unwrap(); + assert!(wait_until(Duration::from_secs(30), || drive + .tree() + .get("Work/local.txt") + == Some(&Entry::File(Arc::new(b"new local revision".to_vec()))))); + assert!(wait_until(Duration::from_secs(30), || listed_folder( + &daemon, folder + ) + .is_some_and(|f| f.state == "idle"))); + daemon + .request(&Request::SetSyncFolderPaused { + id: folder, + paused: true, + }) + .unwrap(); + let before = drive.tree(); + for entry in std::fs::read_dir(&local).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { + std::fs::remove_dir_all(path).unwrap(); + } else { + std::fs::remove_file(path).unwrap(); + } + } + daemon + .request(&Request::SetSyncFolderPaused { + id: folder, + paused: false, + }) + .unwrap(); + daemon + .request(&Request::SyncNow { id: Some(folder) }) + .unwrap(); + assert!(wait_until(Duration::from_secs(30), || listed_folder( + &daemon, folder + ) + .is_some_and(|f| f.state == "error"))); + assert_eq!( + drive.tree(), + before, + "the wipe guard must preserve the remote tree" + ); + assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown))); + let db = Db::open(&dir.join("pdfs.db")).unwrap(); + assert_eq!( + db.sync_folder_get(folder).unwrap().unwrap().remote_share_id, + "my-files-share" + ); + drop(db); + let _ = std::fs::remove_dir_all(&dir); + } + /// Register `local` as a synced folder in `mode` and wait until the daemon /// lists it that way, returning its id. fn sync_folder(daemon: &Daemon, local: &Path, mode: &str) -> i64 { diff --git a/crates/pdfs-fuse/src/sim/fake_drive.rs b/crates/pdfs-fuse/src/sim/fake_drive.rs index eaf7e52..cbfc18e 100644 --- a/crates/pdfs-fuse/src/sim/fake_drive.rs +++ b/crates/pdfs-fuse/src/sim/fake_drive.rs @@ -1123,6 +1123,14 @@ fn delete_one(server: &mut Server, uid: &NodeUid) -> Result<()> { #[async_trait] impl DriveApi for FakeClient { + async fn context_share_id(&self, uid: &NodeUid) -> Result { + self.request(false, |server| { + assert_eq!(uid, &server.root); + Ok(ShareId::new("my-files-share")) + }) + .await + } + async fn get_my_files_folder(&self) -> Result { self.request(false, |server| { let root = server.root.clone(); diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 968efc8..17ecb14 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -11,6 +11,11 @@ scratch (user data in `staging/` and `recovery/` is never touched by this). ## [Unreleased] +### Added + +- `pdfs sync attach PATH --to REMOTE` connects an existing local directory to an existing + My files folder. The first sync preserves one-sided files and differing versions (#67). + ## [3.7.0] — 2026-11-02 Schema: **44**, unchanged. diff --git a/docs/CLI.md b/docs/CLI.md index 96b6134..14a3b2d 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -79,6 +79,7 @@ commands are for scripts and for cases where the mount is not available. | Command | Description | |---|---| +| `pdfs sync attach PATH --to REMOTE` | Merge a real local folder with an existing My files folder, keeping both versions on initial conflicts. | | `pdfs sync add PATH` | Back up a local folder under this computer and keep it in sync. | | `pdfs sync list` | List synced folders with their IDs and state. | | `pdfs sync rm ID [--delete-remote]` | Stop syncing. Local files stay; `--delete-remote` also removes the copy on Proton Drive. | diff --git a/docs/SYNCED_FOLDERS.md b/docs/SYNCED_FOLDERS.md index 9d5a34b..504f205 100644 --- a/docs/SYNCED_FOLDERS.md +++ b/docs/SYNCED_FOLDERS.md @@ -169,3 +169,19 @@ The version you do not keep goes to the Trash, so a wrong choice can be undone. `pdfs sync resume` continues at once. - **Limit bandwidth** under **Preferences → General → Network**, or with `pdfs sync limit --up 2M --down 10M`. `0` removes a limit. + +## Attach an existing My files folder + +Run `pdfs sync attach /path/to/local --to Work` to merge a real local directory +with the existing `Work` folder in My files. `--to` also accepts an absolute path +inside the My files mount. Both folders must already exist. + +The local directory stays a normal mirrored directory. The first pass copies files +found on only one side, adopts identical files, and keeps a conflict copy when +versions differ; it does not propagate deletions. Later passes use ordinary +bidirectional sync, including deletions and the existing wipe guards. + +The My files root, shared folders, overlapping sync roots, and local paths inside +or containing the cloud mount are refused. Use `pdfs sync list` to find the new +folder's ID and monitor its state. `pdfs sync rm ID` stops syncing; adding +`--delete-remote` also trashes the linked cloud folder.