diff --git a/AGENTS.md b/AGENTS.md index 05f57e8..075d586 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -56,11 +56,16 @@ filetrail add ~/.config/nvim filetrail daemon start ``` -On Linux, use `--subdir linux`, or omit it to write at the repository root. An -`add --to` path is relative to that configured subdirectory; paths passed to -`diff`, `commit`, and `resolve` are relative to the repository root. -For example, `filetrail add /opt/scripts/build.sh` with `--subdir macos` configured -stores `macos/opt/scripts/build.sh`; no explicit target is required. +On Linux, use `--subdir linux`, or omit it to put the reserved directories at the +repository root. Home sources go under `__HOME__`; external sources go under +`__ROOT__`, preserving their original relative paths. Custom targets are not +supported. Paths passed to `diff`, `commit`, and `resolve` are repository-relative. +For example, `/opt/scripts/build.sh` with `--subdir macos` is stored at +`macos/__ROOT__/opt/scripts/build.sh`. + +Use `retarget [--subdir ]` to change the destination while keeping +sources; omitting `--subdir` preserves its current value. `deinit` stops the daemon, +uninstalls its service, and forgets the profile while keeping sources and repositories. Keep both READMEs focused on how to use the product. Database schemas, state-file layouts, internal locking/hashing details, toolchain versions, and formatter diff --git a/README.md b/README.md index 22a3d9a..1632df1 100644 --- a/README.md +++ b/README.md @@ -81,20 +81,26 @@ immediately copies its existing files; the daemon keeps subsequent changes in sy ## Choose where files go -Sources inside HOME keep their Home-relative paths. Sources outside HOME keep their -absolute hierarchy without the leading `/`. Use `--to` to choose a different target, -relative to the subdirectory selected during `init`. - -| Source | Subdirectory | Target | File in the repository | -| --- | --- | --- | --- | -| `~/.zshrc` | `macos` | Default | `macos/.zshrc` | -| `~/.config/nvim` | `linux` | Default | `linux/.config/nvim/init.lua` | -| `/opt/scripts/build.sh` | `macos` | Default | `macos/opt/scripts/build.sh` | -| `/opt/scripts` | `macos` | `scripts` | `macos/scripts/build.sh` | +Sources inside Home are stored under `__HOME__`, preserving their Home-relative +paths. All other sources are stored under `__ROOT__`, preserving their absolute +hierarchy without the leading `/`. These directories sit inside the subdirectory +selected during `init`, or directly at the repository root when it is omitted. + +| Source | Subdirectory | File in the repository | +| --- | --- | --- | +| `~/.zshrc` | `macos` | `macos/__HOME__/.zshrc` | +| `~/.config/nvim` | `linux` | `linux/__HOME__/.config/nvim/init.lua` | +| `/opt/scripts/build.sh` | `macos` | `macos/__ROOT__/opt/scripts/build.sh` | +| `/opt/scripts` | Omitted | `__ROOT__/opt/scripts/build.sh` | + +For restoration, `__HOME__` refers to the current user's Home and `__ROOT__` to `/`. +Custom target paths are not supported: preserving source paths makes the original +location unambiguous. `__HOME__` and `__ROOT__` are reserved and cannot be used as +components of `--subdir`. ```sh -filetrail add /opt/scripts --to scripts -filetrail add ~/notes --to notes --exclude '**/*.tmp' +filetrail add /opt/scripts +filetrail add ~/notes --exclude '**/*.tmp' ``` Directories are watched recursively. Exclusions are relative to the source root. @@ -102,23 +108,55 @@ Relative source paths are resolved from your current directory; parent-directory symlinks are resolved to their actual locations. Sources, destinations, and the application data directory must not overlap. +## Change the target or start over + +To switch repositories while keeping your sources, exclusions, and deletion settings: + +```sh +filetrail retarget ~/new-dotfiles +filetrail retarget ~/new-dotfiles --subdir linux +filetrail retarget ~/new-dotfiles --subdir . # Save at the repository root +``` + +Omitting `--subdir` keeps the current subdirectory. You can also change just the +subdirectory by specifying the current repository. FileTrail creates the repository +if needed and immediately syncs enabled sources to the new location. The daemon +continues using the new target and retains its paused/running status. + +The old files and Git history remain where they are; they are not moved or deleted. +Different content already present at the new destination is reported as a conflict +and kept for you to resolve. The target has changed even if this initial sync reports +conflicts; use `conflicts` and `resolve` against the new repository. + +To stop using a profile or initialize it again from scratch: + +```sh +filetrail deinit +filetrail init ~/another-repository --subdir macos +``` + +`deinit` stops the daemon, uninstalls its registered startup service, and clears +this profile's configuration and synchronization records. It preserves source files, +repositories, Git history, logs, and shell completion. You can run it again safely. +After reinitializing, add your sources and start the daemon or install the service again. +Use the same `--data-dir` if you selected a custom profile. + ## Add sources from a list ```sh filetrail add --from ./files.txt ``` -Write one entry per line as `source` or `source target`, separated by spaces. -Use single or double quotes around paths containing spaces. Targets are optional; -omitting one uses the defaults above. +Write one source per line. Use single or double quotes around paths containing +spaces. A second column for a custom target is not supported. ```text -# source [target] +# source ~/.zshrc ~/.config/nvim -/opt/scripts scripts -"~/My Notes" "notes backup" -'./local scripts' 'scripts backup' +/opt/scripts +"~/My Notes" +'./local scripts' ``` Relative source paths are resolved from the list's directory. Blank lines and @@ -144,7 +182,7 @@ Source deletions are retained at the destination by default. Enable deletion propagation when adding a source: ```sh -filetrail add ~/scripts --to scripts --delete +filetrail add ~/scripts --delete ``` Only previously synchronized files can be deleted. If an entire source directory @@ -157,10 +195,10 @@ and destination Git ignore rules apply when committing. ```sh filetrail status filetrail diff -filetrail diff -- macos/.config/nvim +filetrail diff -- macos/__HOME__/.config/nvim filetrail commit filetrail commit -m 'Update shell configuration' -filetrail commit -- macos/.zshrc +filetrail commit -- macos/__HOME__/.zshrc ``` The daemon never commits or pushes automatically. `diff` includes new file contents. @@ -172,9 +210,9 @@ Without `-m`, FileTrail generates a message listing the selected changes: ```text FileTrail: sync 3 files (+1 ~1 -1) -add "macos/.config/nvim/init.lua" -delete "macos/.oldrc" -modify "macos/.zshrc" +add "macos/__HOME__/.config/nvim/init.lua" +delete "macos/__HOME__/.oldrc" +modify "macos/__HOME__/.zshrc" ``` To keep the destination stable while reviewing: @@ -198,7 +236,7 @@ the source version: ```sh filetrail conflicts -filetrail resolve macos/.zshrc --use-source +filetrail resolve macos/__HOME__/.zshrc --use-source ``` You can also make both copies identical yourself and run `filetrail sync` again. diff --git a/README_zh.md b/README_zh.md index c6e323d..8ad72e6 100644 --- a/README_zh.md +++ b/README_zh.md @@ -75,40 +75,75 @@ Linux 上使用 `--subdir linux`;省略 `--subdir` 则保存到仓库根目录 ## 选择保存位置 -HOME 内的来源保留相对 HOME 的路径;HOME 外的来源保留绝对路径层级,去掉开头的 -`/`。使用 `--to` 可以自定义目标位置,它相对于 `init` 时选择的子目录。 +Home 内的来源保存在 `__HOME__` 下,保留相对 Home 的路径;其他来源保存在 +`__ROOT__` 下,保留去掉开头 `/` 的绝对路径层级。这两个目录位于 `init` 指定的 +子目录中;未指定子目录时,直接位于仓库根目录。 -| 来源 | 子目录 | 目标 | 仓库中的文件 | -| --- | --- | --- | --- | -| `~/.zshrc` | `macos` | 默认 | `macos/.zshrc` | -| `~/.config/nvim` | `linux` | 默认 | `linux/.config/nvim/init.lua` | -| `/opt/scripts/build.sh` | `macos` | 默认 | `macos/opt/scripts/build.sh` | -| `/opt/scripts` | `macos` | `scripts` | `macos/scripts/build.sh` | +| 来源 | 子目录 | 仓库中的文件 | +| --- | --- | --- | +| `~/.zshrc` | `macos` | `macos/__HOME__/.zshrc` | +| `~/.config/nvim` | `linux` | `linux/__HOME__/.config/nvim/init.lua` | +| `/opt/scripts/build.sh` | `macos` | `macos/__ROOT__/opt/scripts/build.sh` | +| `/opt/scripts` | 不指定 | `__ROOT__/opt/scripts/build.sh` | + +恢复时,`__HOME__` 对应当前用户的 Home,`__ROOT__` 对应 `/`。 +不支持自定义目标路径,保留来源路径才能明确原始位置。`__HOME__` 和 `__ROOT__` +为保留名称,不能作为 `--subdir` 的路径组成部分。 ```sh -filetrail add /opt/scripts --to scripts -filetrail add ~/notes --to notes --exclude '**/*.tmp' +filetrail add /opt/scripts +filetrail add ~/notes --exclude '**/*.tmp' ``` 目录默认递归监听,排除规则相对于来源根目录。相对来源路径以当前目录为基准, 父目录中的符号链接会解析为实际路径。来源、目标和应用数据目录不能相互重叠。 +## 更换目标或重新初始化 + +切换仓库时保留监听项、排除规则和删除设置: + +```sh +filetrail retarget ~/new-dotfiles +filetrail retarget ~/new-dotfiles --subdir linux +filetrail retarget ~/new-dotfiles --subdir . # 保存到仓库根目录 +``` + +省略 `--subdir` 时保留当前子目录。指定当前仓库也可以仅更换子目录。 +FileTrail 会在需要时创建仓库,并立即将已启用的监听项同步到新位置。 +后台任务会继续使用新目标,并保持原来的暂停或运行状态。 + +旧文件和 Git 历史保留在原处,不会迁移或删除。新目标已有的不同内容会保留并报告为冲突, +由你决定如何处理。即使首次同步报告冲突,目标也已经切换;此时 `conflicts` 和 `resolve` +针对的是新仓库。 + +不再使用某个配置,或需要从头初始化时: + +```sh +filetrail deinit +filetrail init ~/another-repository --subdir macos +``` + +`deinit` 会停止后台任务、卸载该配置已注册的开机启动服务,并清除配置与同步记录。 +源文件、仓库、Git 历史、日志和 shell 补全都会保留,可以安全地重复执行。 +重新初始化后,需要再次添加监听项并启动后台任务或安装服务。 +如果使用了自定义数据目录,请传入相同的 `--data-dir`。 + ## 从列表批量添加 ```sh filetrail add --from ./files.txt ``` -每行写成 `source` 或 `source target`,用空格分隔。路径中包含空格时,使用单引号 -或双引号包住。目标位置可选,省略时使用前面介绍的默认规则。 +每行填写一个来源路径,包含空格时使用单引号或双引号包住。 +不支持用于自定义目标的第二列。 ```text -# source [target] +# source ~/.zshrc ~/.config/nvim -/opt/scripts scripts -"~/My Notes" "notes backup" -'./local scripts' 'scripts backup' +/opt/scripts +"~/My Notes" +'./local scripts' ``` 相对来源路径以列表文件所在目录为基准,支持空行和 `#` 注释。HOME 路径使用 `~`; @@ -132,7 +167,7 @@ filetrail sync --dry-run 默认情况下,源文件删除后仍保留目标文件。添加来源时可开启同步删除: ```sh -filetrail add ~/scripts --to scripts --delete +filetrail add ~/scripts --delete ``` 只会删除以前成功同步过的文件。如果整个来源目录不可用,FileTrail 会保留目标 @@ -144,10 +179,10 @@ filetrail add ~/scripts --to scripts --delete ```sh filetrail status filetrail diff -filetrail diff -- macos/.config/nvim +filetrail diff -- macos/__HOME__/.config/nvim filetrail commit filetrail commit -m 'Update shell configuration' -filetrail commit -- macos/.zshrc +filetrail commit -- macos/__HOME__/.zshrc ``` 后台不会自动提交或推送。`diff` 包含新增文件的内容;`commit` 只提交受管理的文件, @@ -158,9 +193,9 @@ filetrail commit -- macos/.zshrc ```text FileTrail: sync 3 files (+1 ~1 -1) -add "macos/.config/nvim/init.lua" -delete "macos/.oldrc" -modify "macos/.zshrc" +add "macos/__HOME__/.config/nvim/init.lua" +delete "macos/__HOME__/.oldrc" +modify "macos/__HOME__/.zshrc" ``` 检查期间需要保持目标内容稳定,可以暂停自动同步: @@ -182,7 +217,7 @@ FileTrail 会报告冲突。明确希望使用来源版本时执行: ```sh filetrail conflicts -filetrail resolve macos/.zshrc --use-source +filetrail resolve macos/__HOME__/.zshrc --use-source ``` 也可以自行将两份文件改为相同内容,再运行 `filetrail sync`。仓库正在进行 diff --git a/src/config.rs b/src/config.rs index e068e44..8e334e4 100644 --- a/src/config.rs +++ b/src/config.rs @@ -76,6 +76,7 @@ impl Store { pub fn lock(&self) -> Result { let file = self.lock_file("operation.lock")?; fs2::FileExt::lock_exclusive(&file)?; + crate::lifecycle::recover_retarget(self)?; Ok(file) } @@ -110,7 +111,7 @@ impl Store { crate::state::load(&self.root) } - fn validate_layout(&self, config: &Config) -> Result<()> { + pub(crate) fn validate_layout(&self, config: &Config) -> Result<()> { if self.root.starts_with(&config.repository) || config.repository.starts_with(&self.root) { bail!("repository and data directory must not overlap"); } @@ -149,7 +150,14 @@ impl Config { bail!("repository must be absolute"); } relative(&self.subdir)?; + if self.subdir.components().any(|part| { + let name = part.as_os_str().to_string_lossy(); + name.eq_ignore_ascii_case("__HOME__") || name.eq_ignore_ascii_case("__ROOT__") + }) { + bail!("subdirectory must not contain the reserved __HOME__ or __ROOT__ names"); + } crate::sync::exclusions(&self.exclude)?; + let home = home_dir()?; for (i, entry) in self.entries.iter().enumerate() { let normalized_target = relative(&entry.target)?; if normalized_target.as_os_str().is_empty() @@ -161,6 +169,12 @@ impl Config { { bail!("invalid source or target for entry {}", entry.id); } + if normalized_target != default_target(&entry.source, &home)? { + bail!( + "entry {} does not use the __HOME__/__ROOT__ layout; initialize a new data directory and re-add the source", + entry.id + ); + } crate::sync::key(&entry.source)?; crate::sync::key(&entry.target)?; crate::sync::exclusions(&entry.exclude)?; @@ -241,15 +255,21 @@ pub fn expand(path: &Path, base: &Path) -> Result { )) } -/// Map Home files relative to Home, and other files relative to the filesystem root. +pub fn home_dir() -> Result { + Ok(fs::canonicalize( + dirs::home_dir().context("cannot determine home directory")?, + )?) +} + +/// Encode the restoration base in the destination without renaming the source. pub fn default_target(source: &Path, home: &Path) -> Result { if !source.is_absolute() || !home.is_absolute() { bail!("source and Home paths must be absolute"); } - let target = source - .strip_prefix(home) - .or_else(|_| source.strip_prefix("/"))?; - relative(target) + match source.strip_prefix(home) { + Ok(path) => Ok(Path::new("__HOME__").join(relative(path)?)), + Err(_) => Ok(Path::new("__ROOT__").join(relative(source.strip_prefix("/")?)?)), + } } pub fn atomic_write(path: &Path, bytes: &[u8]) -> Result<()> { @@ -259,6 +279,7 @@ pub fn atomic_write(path: &Path, bytes: &[u8]) -> Result<()> { file.write_all(bytes)?; file.as_file().sync_all()?; file.persist(path).map_err(|error| error.error)?; + File::open(parent)?.sync_all()?; Ok(()) } @@ -274,11 +295,15 @@ mod tests { fn default_targets_preserve_home_relative_and_external_absolute_paths() { let home = Path::new("/home/alice"); for (source, expected) in [ - ("/home/alice/.zshrc", ".zshrc"), - ("/home/alice/.config/nvim", ".config/nvim"), - ("/opt/scripts/build.sh", "opt/scripts/build.sh"), - ("/opt/scripts", "opt/scripts"), - ("/home/alice-other/settings", "home/alice-other/settings"), + ("/home/alice/.zshrc", "__HOME__/.zshrc"), + ("/home/alice/.config/nvim", "__HOME__/.config/nvim"), + ("/home/alice", "__HOME__"), + ("/opt/scripts/build.sh", "__ROOT__/opt/scripts/build.sh"), + ("/opt/scripts", "__ROOT__/opt/scripts"), + ( + "/home/alice-other/settings", + "__ROOT__/home/alice-other/settings", + ), ] { assert_eq!( default_target(Path::new(source), home).unwrap(), diff --git a/src/lib.rs b/src/lib.rs index 72fdd3f..0eb146c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7,6 +7,7 @@ pub mod completion; pub mod config; pub mod daemon; pub mod git; +pub mod lifecycle; pub mod manifest; pub mod service; mod state; diff --git a/src/lifecycle.rs b/src/lifecycle.rs new file mode 100644 index 0000000..e2d909e --- /dev/null +++ b/src/lifecycle.rs @@ -0,0 +1,154 @@ +use std::fs; +use std::fs::File; +use std::io::ErrorKind; +use std::path::Component; +use std::path::Path; +use std::path::PathBuf; +use std::thread; +use std::time::Duration; +use std::time::Instant; + +use anyhow::Context; +use anyhow::Result; +use anyhow::bail; + +use crate::config::Config; +use crate::config::State; +use crate::config::Store; +use crate::config::atomic_write; +use crate::sync::Report; + +const PENDING_TARGET: &str = "pending-retarget.toml"; + +// Resolve existing ancestors before creating anything, including symlinks and .. . +fn repository_path(path: &Path) -> Result { + let path = if path.starts_with("~") { + crate::config::home_dir()?.join(path.strip_prefix("~")?) + } else { + std::path::absolute(path)? + }; + let mut resolved = PathBuf::new(); + for component in path.components() { + match component { + Component::CurDir => {} + Component::ParentDir => { + resolved.pop(); + } + _ => { + resolved.push(component); + match fs::symlink_metadata(&resolved) { + Ok(_) => resolved = fs::canonicalize(&resolved)?, + Err(error) if error.kind() == ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + } + } + } + Ok(resolved) +} + +pub fn retarget(store: &Store, repository: &Path, subdir: Option<&Path>) -> Result { + let _lock = store.lock()?; + let old = store.config()?; + let mut config = old.clone(); + config.repository = repository_path(repository)?; + if let Some(subdir) = subdir { + config.subdir = crate::config::relative(subdir)?; + } + config.validate()?; + store.validate_layout(&config)?; + if config.repository != old.repository + && (config.repository.starts_with(&old.repository) + || old.repository.starts_with(&config.repository)) + { + bail!("the old and new repositories must not be nested inside each other"); + } + if config.repository == old.repository && config.subdir == old.subdir { + // Keep baselines and ownership when the target has not changed. + return crate::sync::run_locked(store, false, None); + } + // Refuse corrupt state before preparing a switch; never silently discard it. + store.state()?; + fs::create_dir_all(&config.repository)?; + if !config.repository.join(".git").exists() { + git2::Repository::init(&config.repository)?; + } + let repo = crate::git::open(&config)?; + crate::git::ensure_idle(&repo)?; + crate::sync::safe_destination(&config.repository, &config.subdir.join(".filetrail-check"))?; + for entry in &config.entries { + crate::sync::safe_destination(&config.repository, &config.destination(entry))?; + } + // A durable intent makes the two-file update recoverable. No synchronization + // may use either configuration until recovery has reset the old baselines. + atomic_write( + &store.root.join(PENDING_TARGET), + toml::to_string_pretty(&config)?.as_bytes(), + )?; + recover_retarget(store).context("target switch pending; retry to complete it")?; + crate::sync::run_locked(store, false, None) + .context("target changed, but initial synchronization failed; run filetrail sync to retry") +} + +// Called only with operation.lock held, before any operation can use sync state. +pub(crate) fn recover_retarget(store: &Store) -> Result<()> { + let path = store.root.join(PENDING_TARGET); + let text = match fs::read_to_string(&path) { + Ok(text) => text, + Err(error) if error.kind() == ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(error.into()), + }; + let config: Config = toml::from_str(&text).context("invalid pending target switch")?; + config.validate()?; + store.validate_layout(&config)?; + store.save_state(&State::default())?; + store.save_config(&config)?; + fs::remove_file(path)?; + File::open(&store.root)?.sync_all()?; + Ok(()) +} + +pub fn deinit(store: &Store) -> Result<()> { + // Service removal comes first: launchd's KeepAlive could otherwise restart + // a stopped daemon. If unloading fails, retain the profile for a retry. + if crate::service::is_installed(store)? { + crate::service::uninstall(store)?; + } + let singleton = store.lock_file("daemon.lock")?; + let deadline = Instant::now() + Duration::from_secs(10); + loop { + match fs2::FileExt::try_lock_exclusive(&singleton) { + Ok(()) => break, + Err(error) if error.kind() == ErrorKind::WouldBlock => {} + Err(error) => return Err(error.into()), + } + // Also handles a daemon still starting up, before its socket is ready. + if crate::daemon::request(store, "status").is_ok() { + crate::daemon::stop(store)?; + } + if Instant::now() >= deadline { + bail!("cannot stop daemon; profile has been kept"); + } + thread::sleep(Duration::from_millis(50)); + } + // Explicit deinit can remove invalid configuration or an interrupted switch. + // Keep lock files: unlinking them would allow concurrent locks on new inodes. + let operation = store.lock_file("operation.lock")?; + fs2::FileExt::lock_exclusive(&operation)?; + for name in [ + PENDING_TARGET, + "config.toml", + "state.db", + "state.db-journal", + "state.db-wal", + "state.db-shm", + "daemon.sock", + ] { + match fs::remove_file(store.root.join(name)) { + Ok(()) => File::open(&store.root)?.sync_all()?, + Err(error) if error.kind() == ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + } + Ok(()) +} diff --git a/src/main.rs b/src/main.rs index 15353ec..90928e7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -39,14 +39,21 @@ enum Commands { #[arg(long, default_value = ".")] subdir: PathBuf, }, + /// Change the target while keeping sources and synchronization options. + Retarget { + #[arg(value_hint = clap::ValueHint::DirPath)] + repository: PathBuf, + /// New repository-relative destination root; omitted keeps the current value. + #[arg(long, value_hint = clap::ValueHint::DirPath)] + subdir: Option, + }, + /// Stop the daemon, uninstall its service, and forget this profile. Keeps files and repositories. + Deinit, /// Add a source and immediately synchronize existing files. Add { #[arg(required_unless_present = "from", conflicts_with = "from")] source: Option, - /// Override the default Home-relative or root-relative destination. - #[arg(long, conflicts_with = "from")] - to: Option, - /// Import source [target] lines separated by spaces; quote paths containing spaces. + /// Import one source per line; quote paths containing spaces. #[arg(long, value_hint = clap::ValueHint::FilePath)] from: Option, /// Propagate source deletions for files previously synchronized. @@ -173,11 +180,57 @@ fn execute(cli: Cli) -> Result<()> { return Ok(()); } let store = Store::new(data_root(cli.data_dir)?)?; + // Lifecycle commands share a separate lock: never wait for a daemon while + // holding operation.lock, since the daemon may itself be waiting to sync. + let _lifecycle = if matches!( + &cli.command, + Commands::Init { .. } + | Commands::Retarget { .. } + | Commands::Deinit + | Commands::Daemon( + DaemonCommands::Start { .. } + | DaemonCommands::Stop + | DaemonCommands::Restart { .. } + ) + | Commands::Service(ServiceCommands::Install | ServiceCommands::Uninstall) + ) { + let lock = store.lock_file("lifecycle.lock")?; + fs2::FileExt::lock_exclusive(&lock)?; + Some(lock) + } else { + None + }; match cli.command { + Commands::Retarget { repository, subdir } => { + let report = filetrail::lifecycle::retarget(&store, &repository, subdir.as_deref())?; + println!("Target selected. The previous repository and files have been kept."); + print_report(report)?; + } + Commands::Deinit => { + filetrail::lifecycle::deinit(&store)?; + println!( + "Deinitialized. Source files, repositories, logs, and shell completion have been kept." + ); + } Commands::Init { repository, subdir } => { let _lock = store.lock()?; if store.root.join("config.toml").exists() { - bail!("already initialized; edit config.toml or use a different --data-dir"); + bail!( + "already initialized; use filetrail retarget to change the target, or filetrail deinit to start over" + ); + } + if [ + "state.db", + "state.db-journal", + "state.db-wal", + "state.db-shm", + ] + .iter() + .any(|name| store.root.join(name).exists()) + { + bail!( + "leftover synchronization state; run filetrail deinit before initializing again" + ); } let subdir = filetrail::config::relative(&subdir)?; let repository = if repository.starts_with("~") { @@ -219,7 +272,6 @@ fn execute(cli: Cli) -> Result<()> { } Commands::Add { source, - to, from, delete, exclude, @@ -242,28 +294,28 @@ fn execute(cli: Cli) -> Result<()> { let list = filetrail::config::expand(&list, &cwd)?; let base = list.parent().context("list has no parent")?; for (line_number, line) in fs::read_to_string(&list)?.lines().enumerate() { - let Some((source, target)) = filetrail::manifest::parse_line(line) + let Some(source) = filetrail::manifest::parse_line(line) .with_context(|| format!("{}:{}", list.display(), line_number + 1))? else { continue; }; - sources.push(( + sources.push( filetrail::config::expand(&source, base).with_context(|| { format!("{}:{}", list.display(), line_number + 1) })?, - target, - )); + ); } } else { - sources.push(( - filetrail::config::expand(&source.context("missing source")?, &cwd)?, - to, - )); + sources.push(filetrail::config::expand( + &source.context("missing source")?, + &cwd, + )?); } if sources.is_empty() { bail!("source list contains no entries"); } - for (source, target) in sources { + let home = filetrail::config::home_dir()?; + for source in sources { if source.starts_with(&store.root) || store.root.starts_with(&source) { bail!("source and data directory must not overlap"); } @@ -275,15 +327,7 @@ fn execute(cli: Cli) -> Result<()> { { bail!("source must be a regular file, directory, or symlink"); } - let target = match target { - Some(target) => filetrail::config::relative(&target)?, - None => { - let home = fs::canonicalize( - dirs::home_dir().context("cannot determine home directory")?, - )?; - filetrail::config::default_target(&source, &home)? - } - }; + let target = filetrail::config::default_target(&source, &home)?; config.entries.push(Entry { id: next, source, @@ -478,6 +522,13 @@ mod tests { use super::Cli; use super::data_root; + #[test] + fn add_rejects_custom_targets() { + assert!( + Cli::try_parse_from(["filetrail", "add", "/tmp/source", "--to", "renamed"]).is_err() + ); + } + #[test] fn data_directory_defaults_to_dotfile_in_home() { let expected = dirs::home_dir().unwrap().join(".filetrail"); diff --git a/src/manifest.rs b/src/manifest.rs index fcc8147..7346c13 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -4,26 +4,25 @@ use anyhow::Context; use anyhow::Result; use anyhow::bail; -/// Parse one list entry as source [target], using quotes for whitespace in paths. +/// Parse one source per line, using quotes for whitespace in paths. /// This only tokenizes text: it never invokes a shell or expands variables. -pub fn parse_line(line: &str) -> Result)>> { +pub fn parse_line(line: &str) -> Result> { let words = shlex::split(line).context("invalid quoting or trailing escape in file list")?; if words.is_empty() { return Ok(None); } - if words.len() > 2 { - bail!("expected source [target]; quote paths containing spaces"); + if words.len() != 1 { + bail!( + "expected one source per line; quote paths containing spaces; custom targets are not supported" + ); } if words .iter() .any(|word| word.is_empty() || word.contains('\0')) { - bail!("source and target must be nonempty paths without NUL characters"); + bail!("source must be a nonempty path without NUL characters"); } - Ok(Some(( - PathBuf::from(&words[0]), - words.get(1).map(PathBuf::from), - ))) + Ok(Some(PathBuf::from(&words[0]))) } #[cfg(test)] @@ -33,32 +32,19 @@ mod tests { use super::parse_line; #[test] - fn parses_optional_targets_quotes_escapes_and_comments() { - for (line, source, target) in [ - ("~/.zshrc", "~/.zshrc", None), - (" /opt/scripts scripts ", "/opt/scripts", Some("scripts")), - ( - "\"My Notes\" 'backup notes'", - "My Notes", - Some("backup notes"), - ), - ( - "My\\ Notes backup\\ notes", - "My Notes", - Some("backup notes"), - ), - ("\"file\\\"name\" target", "file\"name", Some("target")), - ("'hash#file' target # comment", "hash#file", Some("target")), - ( - "\"$HOME/$(command)\" target", - "$HOME/$(command)", - Some("target"), - ), - ("source\ttarget", "source", Some("target")), + fn parses_sources_quotes_escapes_and_comments() { + for (line, source) in [ + ("~/.zshrc", "~/.zshrc"), + (" /opt/scripts ", "/opt/scripts"), + ("\"My Notes\"", "My Notes"), + ("My\\ Notes", "My Notes"), + ("\"file\\\"name\"", "file\"name"), + ("'hash#file' # comment", "hash#file"), + ("\"$HOME/$(command)\"", "$HOME/$(command)"), ] { assert_eq!( parse_line(line).unwrap(), - Some((PathBuf::from(source), target.map(PathBuf::from))), + Some(PathBuf::from(source)), "{line}" ); } @@ -70,6 +56,9 @@ mod tests { fn rejects_ambiguous_and_malformed_entries() { for line in [ "a b c", + "source target", + "source\ttarget", + "\"\"", "\"unterminated", "'unterminated", "source \\", diff --git a/src/service.rs b/src/service.rs index d684d1c..3745f21 100644 --- a/src/service.rs +++ b/src/service.rs @@ -129,6 +129,10 @@ pub fn install(store: &Store) -> Result { Ok(format!("installed {}", location.display())) } +pub fn is_installed(store: &Store) -> Result { + Ok(location(store)?.try_exists()?) +} + pub fn uninstall(store: &Store) -> Result { let location = location(store)?; if !location.exists() { diff --git a/src/sync.rs b/src/sync.rs index 5d10196..b4396e4 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -50,6 +50,11 @@ pub fn exclusions(patterns: &[String]) -> Result { pub fn run(store: &Store, dry_run: bool, overwrite: Option<&Path>) -> Result { let _lock = store.lock()?; + run_locked(store, dry_run, overwrite) +} + +// The caller must hold operation.lock across configuration/state changes and sync. +pub(crate) fn run_locked(store: &Store, dry_run: bool, overwrite: Option<&Path>) -> Result { let config = store.config()?; let repo = crate::git::open(&config)?; crate::git::ensure_idle(&repo)?; diff --git a/tests/workflow.rs b/tests/workflow.rs index 0c86f91..065b5cf 100644 --- a/tests/workflow.rs +++ b/tests/workflow.rs @@ -127,6 +127,8 @@ fn completion_generation_includes_nested_commands_without_initialization() { "restart", "service", "uninstall", + "retarget", + "deinit", "from", "install", ] { @@ -459,7 +461,12 @@ impl Fixture { config.entries.push(Entry { id: 1, source: fs::canonicalize(&source).unwrap(), - target: "config".into(), + target: Path::new("__ROOT__").join( + fs::canonicalize(&source) + .unwrap() + .strip_prefix("/") + .unwrap(), + ), directory: true, enabled: true, delete, @@ -480,12 +487,25 @@ impl Fixture { fs::write(path, content).unwrap(); } - fn target(&self, name: &str) -> PathBuf { + fn key(&self, name: &str) -> String { let config = self.store.config().unwrap(); - self.repository - .join(&config.subdir) - .join("config") + Path::new(&config.subdir) + .join("__ROOT__") + .join( + fs::canonicalize(self.source.parent().unwrap()) + .unwrap() + .join("source") + .strip_prefix("/") + .unwrap(), + ) .join(name) + .to_string_lossy() + .trim_end_matches('/') + .to_owned() + } + + fn target(&self, name: &str) -> PathBuf { + self.repository.join(self.key(name)) } fn sync(&self) { @@ -533,7 +553,12 @@ fn dry_run_does_not_write_files_or_state() { let f = Fixture::new("", false); f.write("a", "one"); let report = filetrail::sync::run(&f.store, true, None).unwrap(); - assert!(report.actions.iter().any(|line| line == "add config/a")); + assert!( + report + .actions + .iter() + .any(|line| line == &format!("add {}", f.key("a"))) + ); assert!(!f.target("a").exists()); assert!(!f.store.root.join("state.db").exists()); } @@ -548,7 +573,7 @@ fn first_sync_and_external_edits_are_protected_and_resolvable() { assert_eq!(report.errors.len(), 1); assert_eq!(fs::read_to_string(f.target("a")).unwrap(), "existing"); assert!( - filetrail::sync::run(&f.store, false, Some(Path::new("linux/config/a"))) + filetrail::sync::run(&f.store, false, Some(Path::new(&f.key("a")))) .unwrap() .errors .is_empty() @@ -650,7 +675,8 @@ fn destination_symlink_ancestors_cannot_escape_repository() { f.write("a", "one"); let outside = f._temp.path().join("outside"); fs::create_dir(&outside).unwrap(); - symlink(&outside, f.repository.join("config")).unwrap(); + fs::create_dir_all(f.target("").parent().unwrap()).unwrap(); + symlink(&outside, f.target("")).unwrap(); assert!( !filetrail::sync::run(&f.store, false, None) .unwrap() @@ -672,7 +698,7 @@ fn default_commit_message_and_untracked_diff_include_files() { let repo = Repository::open(&f.repository).unwrap(); let commit = repo.head().unwrap().peel_to_commit().unwrap(); assert!(commit.message().unwrap().starts_with("FileTrail: ")); - assert!(commit.message().unwrap().contains("macos/config/a")); + assert!(commit.message().unwrap().contains(&f.key("a"))); assert!( commit .tree() @@ -683,14 +709,14 @@ fn default_commit_message_and_untracked_diff_include_files() { f.write("a", "updated\n"); f.write("b", "new\n"); f.sync(); - filetrail::git::commit(&f.store, Some("custom message"), &["macos/config/a".into()]).unwrap(); + filetrail::git::commit(&f.store, Some("custom message"), &[f.key("a")]).unwrap(); let commit = repo.head().unwrap().peel_to_commit().unwrap(); assert_eq!(commit.message().unwrap(), "custom message"); assert!( commit .tree() .unwrap() - .get_path(Path::new("macos/config/b")) + .get_path(Path::new(&f.key("b"))) .is_err() ); fs::remove_file(f.source.join("a")).unwrap(); @@ -703,7 +729,7 @@ fn default_commit_message_and_untracked_diff_include_files() { .unwrap() .message() .unwrap() - .contains("delete \"macos/config/a\"") + .contains(&format!("delete {:?}", f.key("a"))) ); } @@ -839,15 +865,27 @@ fn cli_init_subdir_and_list_import() { fs::write(temp.path().join("one"), "1").unwrap(); fs::write(temp.path().join("two"), "2").unwrap(); let list = temp.path().join("files.txt"); - fs::write(&list, "# relative to this file\n\none first\ntwo second\n").unwrap(); + fs::write(&list, "# relative to this file\n\none\ntwo\n").unwrap(); let add = run(&["add", "--from", list.to_str().unwrap()]); assert!( add.status.success(), "{}", String::from_utf8_lossy(&add.stderr) ); - assert_eq!(fs::read_to_string(repo.join("linux/first")).unwrap(), "1"); - assert_eq!(fs::read_to_string(repo.join("linux/second")).unwrap(), "2"); + let prefix = Path::new("linux/__ROOT__").join( + fs::canonicalize(temp.path()) + .unwrap() + .strip_prefix("/") + .unwrap(), + ); + assert_eq!( + fs::read_to_string(repo.join(&prefix).join("one")).unwrap(), + "1" + ); + assert_eq!( + fs::read_to_string(repo.join(&prefix).join("two")).unwrap(), + "2" + ); let repository = Repository::open(&repo).unwrap(); repository .config() @@ -873,11 +911,17 @@ fn cli_init_subdir_and_list_import() { .message() .unwrap() .to_owned(); - assert!(message.contains("add \"linux/first\""), "{message}"); - assert!(message.contains("add \"linux/second\""), "{message}"); + assert!( + message.contains(&format!("add {:?}", prefix.join("one"))), + "{message}" + ); + assert!( + message.contains(&format!("add {:?}", prefix.join("two"))), + "{message}" + ); assert!(!run(&["init", repo.to_str().unwrap()]).status.success()); assert!(run(&["remove", "1"]).status.success()); - assert!(repo.join("linux/first").exists()); + assert!(repo.join(&prefix).join("one").exists()); } #[test] @@ -890,7 +934,7 @@ fn list_import_supports_spaces_quoted_paths_and_literal_variables() { f.write("scripts two/build.sh", "build\n"); f.write("$literal", "literal\n"); let list = f._temp.path().join("files.txt"); - fs::write(&list, "# quoted sources and targets\n\"source/notes one\" \"notes copy\"\n'source/scripts two' 'scripts copy' # directory\n'source/$literal' 'literal/$name'\n").unwrap(); + fs::write(&list, "# quoted sources\n\"source/notes one\"\n'source/scripts two' # directory\n'source/$literal'\n").unwrap(); let output = f.cli(&["add", "--from", list.to_str().unwrap()]); assert!( output.status.success(), @@ -898,14 +942,11 @@ fn list_import_supports_spaces_quoted_paths_and_literal_variables() { String::from_utf8_lossy(&output.stderr) ); for (path, expected) in [ - ("notes copy", "notes\n"), - ("scripts copy/build.sh", "build\n"), - ("literal/$name", "literal\n"), + ("notes one", "notes\n"), + ("scripts two/build.sh", "build\n"), + ("$literal", "literal\n"), ] { - assert_eq!( - fs::read_to_string(f.repository.join("macos").join(path)).unwrap(), - expected - ); + assert_eq!(fs::read_to_string(f.target(path)).unwrap(), expected); } } @@ -918,14 +959,14 @@ fn malformed_list_reports_line_number_without_partial_import() { f.write("a", "first entry"); let before = fs::read(f.store.root.join("config.toml")).unwrap(); let list = f._temp.path().join("files.txt"); - for invalid in ["unquoted path target", "\"unclosed"] { - fs::write(&list, format!("source/a first\n{invalid}\n")).unwrap(); + for invalid in ["source/a target", "unquoted path target", "\"unclosed"] { + fs::write(&list, format!("source/a\n{invalid}\n")).unwrap(); let output = f.cli(&["add", "--from", list.to_str().unwrap()]); assert!(!output.status.success()); let error = String::from_utf8_lossy(&output.stderr); assert!(error.contains("files.txt:2"), "{error}"); assert_eq!(fs::read(f.store.root.join("config.toml")).unwrap(), before); - assert!(!f.repository.join("first").exists()); + assert!(!f.target("a").exists()); assert!(!f.store.root.join("state.db").exists()); } } @@ -942,7 +983,7 @@ fn dry_run_preserves_existing_sqlite_state() { report .actions .iter() - .any(|action| action == "update config/a") + .any(|action| action == &format!("update {}", f.key("a"))) ); assert_eq!(fs::read(f.store.root.join("state.db")).unwrap(), before); assert_eq!(fs::read_to_string(f.target("a")).unwrap(), "before"); @@ -955,10 +996,16 @@ fn single_file_ownership_modification_and_deletion() { let mut config = f.store.config().unwrap(); config.entries[0].source.push("shellrc"); config.entries[0].directory = false; - config.entries[0].target = ".zshrc".into(); + config.entries[0].target.push("shellrc"); f.store.save_config(&config).unwrap(); f.sync(); - assert!(f.store.state().unwrap().owned.contains_key("macos/.zshrc")); + assert!( + f.store + .state() + .unwrap() + .owned + .contains_key(&f.key("shellrc")) + ); filetrail::git::commit(&f.store, None, &[]).unwrap(); f.write("shellrc", "changed\n"); f.sync(); @@ -969,9 +1016,96 @@ fn single_file_ownership_modification_and_deletion() { ); fs::remove_file(f.source.join("shellrc")).unwrap(); f.sync(); - assert!(!f.repository.join("macos/.zshrc").exists()); + assert!(!f.target("shellrc").exists()); let message = filetrail::git::commit(&f.store, None, &[]).unwrap(); - assert!(message.contains("delete \"macos/.zshrc\""), "{message}"); + assert!( + message.contains(&format!("delete {:?}", f.key("shellrc"))), + "{message}" + ); +} + +#[test] +fn cli_layout_distinguishes_home_and_root_for_sources_and_lists() { + for subdir in [".", "macos"] { + for list_import in [false, true] { + let f = CompletionFixture::new(); + let repository = f.temp.path().join("repo"); + let data = f.temp.path().join("data"); + let external = f.temp.path().join("external scripts"); + fs::create_dir(&external).unwrap(); + fs::write(external.join("build.sh"), "build\n").unwrap(); + fs::write(f.home.join(".zshrc"), "shell\n").unwrap(); + let run = |args: &[&str]| { + output_text( + f.command(&f.binary) + .arg("--data-dir") + .arg(&data) + .args(args) + .output() + .unwrap(), + ) + }; + run(&["init", repository.to_str().unwrap(), "--subdir", subdir]); + if list_import { + let list = f.home.join("files.txt"); + fs::write(&list, "~/.zshrc\n'../external scripts'\n").unwrap(); + run(&["add", "--from", list.to_str().unwrap()]); + } else { + run(&["add", ".zshrc"]); + run(&["add", external.to_str().unwrap()]); + } + let base = repository.join(subdir); + assert_eq!( + fs::read_to_string(base.join("__HOME__/.zshrc")).unwrap(), + "shell\n" + ); + let external_relative = fs::canonicalize(&external) + .unwrap() + .strip_prefix("/") + .unwrap() + .to_owned(); + assert_eq!( + fs::read_to_string( + base.join("__ROOT__") + .join(&external_relative) + .join("build.sh") + ) + .unwrap(), + "build\n" + ); + assert!(!base.join(".zshrc").exists()); + assert!(!base.join(&external_relative).exists()); + run(&["sync"]); + assert!(run(&["list"]).contains("__HOME__/.zshrc")); + for reserved in ["__HOME__", "macos/__root__"] { + let rejected = f + .command(&f.binary) + .arg("--data-dir") + .arg(f.temp.path().join("reserved-data")) + .args([ + "init", + f.temp.path().join("reserved-repo").to_str().unwrap(), + "--subdir", + reserved, + ]) + .output() + .unwrap(); + assert!(!rejected.status.success()); + } + } + } +} + +#[test] +fn custom_and_legacy_targets_are_rejected_without_rewriting_configuration() { + let f = Fixture::new("", false); + let before = fs::read(f.store.root.join("config.toml")).unwrap(); + for target in ["config", "__HOME__/renamed", "__ROOT__/renamed"] { + let mut config = f.store.config().unwrap(); + config.entries[0].target = target.into(); + assert!(f.store.save_config(&config).is_err()); + assert_eq!(fs::read(f.store.root.join("config.toml")).unwrap(), before); + } } #[test] @@ -1007,6 +1141,7 @@ fn external_sources_default_to_absolute_hierarchy_for_cli_and_lists() { let target = f .repository .join(subdir) + .join("__ROOT__") .join(source.strip_prefix("/").unwrap()); assert_eq!( fs::read_to_string(target.join("one")).unwrap(), @@ -1019,7 +1154,10 @@ fn external_sources_default_to_absolute_hierarchy_for_cli_and_lists() { let config = f.store.config().unwrap(); assert_eq!(config.entries.len(), 2); for entry in &config.entries { - assert_eq!(entry.target, entry.source.strip_prefix("/").unwrap()); + assert_eq!( + entry.target, + Path::new("__ROOT__").join(entry.source.strip_prefix("/").unwrap()) + ); } let commit = filetrail::git::commit(&f.store, None, &[]).unwrap(); assert!(commit.contains("sync 2 files"), "{commit}"); @@ -1053,8 +1191,8 @@ fn target_deletion_and_conflicted_deletion_are_protected() { ); assert!(!f.target("a").exists()); assert_eq!(fs::read_to_string(f.target("b")).unwrap(), "external edit"); - filetrail::sync::run(&f.store, false, Some(Path::new("config/a"))).unwrap(); - assert!(f.store.state().unwrap().conflicts.contains_key("config/b")); + filetrail::sync::run(&f.store, false, Some(Path::new(&f.key("a")))).unwrap(); + assert!(f.store.state().unwrap().conflicts.contains_key(&f.key("b"))); assert_eq!(fs::read_to_string(f.target("b")).unwrap(), "external edit"); } @@ -1065,25 +1203,29 @@ fn source_exclusions_do_not_turn_a_single_file_into_a_deletion() { let mut config = f.store.config().unwrap(); config.entries[0].source.push("a.tmp"); config.entries[0].directory = false; - config.entries[0].target = "renamed".into(); + config.entries[0].target.push("a.tmp"); f.store.save_config(&config).unwrap(); f.sync(); config.entries[0].exclude = vec!["*.tmp".into()]; f.store.save_config(&config).unwrap(); f.sync(); - assert!(f.repository.join("renamed").exists()); + assert!(f.target("a.tmp").exists()); } #[test] fn target_gitignore_does_not_force_add_ignored_files() { let f = Fixture::new("", false); - fs::write(f.repository.join(".gitignore"), "config/ignored\n").unwrap(); + fs::write( + f.repository.join(".gitignore"), + format!("/{}\n", f.key("ignored")), + ) + .unwrap(); f.write("ignored", "value"); f.sync(); assert!( filetrail::git::status(&f.store) .unwrap() - .contains("[ignored] config/ignored") + .contains(&format!("[ignored] {}", f.key("ignored"))) ); assert!(filetrail::git::commit(&f.store, None, &[]).is_err()); } @@ -1094,8 +1236,8 @@ fn case_insensitive_target_collisions_are_rejected() { let mut config = f.store.config().unwrap(); let mut other = config.entries[0].clone(); other.id = 2; - other.source = f.source.with_extension("other"); - other.target = "CONFIG/child".into(); + other.source = config.entries[0].source.with_file_name("SOURCE"); + other.target = Path::new("__ROOT__").join(other.source.strip_prefix("/").unwrap()); config.entries.push(other); assert!(f.store.save_config(&config).is_err()); } @@ -1145,3 +1287,322 @@ fn background_start_polling_and_singleton() { assert!(f.cli(&["daemon", "stop"]).status.success()); assert!(!f.cli(&["daemon", "status"]).status.success()); } + +#[test] +fn retarget_preserves_options_and_old_history_and_resets_ownership() { + let f = Fixture::new("macos", true); + f.write("a", "original"); + f.write("deleted", "keep in old repository"); + f.sync(); + output_text(f.cli(&["commit", "-m", "original"])); + let old_head = Repository::open(&f.repository) + .unwrap() + .head() + .unwrap() + .target(); + let mut config = f.store.config().unwrap(); + config.exclude = vec!["*.tmp".into()]; + config.entries[0].exclude = vec!["*.bak".into()]; + f.store.save_config(&config).unwrap(); + fs::remove_file(f.source.join("deleted")).unwrap(); + f.write("a", "new"); + f.write("skip.tmp", "excluded"); + f.write("skip.bak", "excluded"); + let new_repo = f._temp.path().join("new/nested-repo"); + output_text(f.cli(&["retarget", new_repo.to_str().unwrap()])); + let new_config = f.store.config().unwrap(); + assert_eq!(new_config.repository, fs::canonicalize(&new_repo).unwrap()); + config.repository = new_config.repository.clone(); + assert_eq!( + toml::to_string(&config).unwrap(), + toml::to_string(&new_config).unwrap() + ); + assert_eq!( + fs::read_to_string(new_repo.join(f.key("a"))).unwrap(), + "new" + ); + assert!(!new_repo.join(f.key("skip.tmp")).exists()); + assert!(!new_repo.join(f.key("skip.bak")).exists()); + assert_eq!(fs::read_to_string(f.target("a")).unwrap(), "original"); + assert!(f.target("deleted").exists()); + assert!( + !f.store + .state() + .unwrap() + .owned + .contains_key(&f.key("deleted")) + ); + assert_eq!( + Repository::open(&f.repository) + .unwrap() + .head() + .unwrap() + .target(), + old_head + ); + assert!(Repository::open(&new_repo).unwrap().head().is_err()); +} + +#[test] +fn retarget_conflicts_preserve_existing_content_and_staging() { + let f = Fixture::new("macos", false); + f.write("a", "old baseline"); + f.sync(); + f.write("a", "changed source"); + let new_repo = f._temp.path().join("new-repo"); + let repo = Repository::init(&new_repo).unwrap(); + let target = new_repo.join(f.key("a")); + fs::create_dir_all(target.parent().unwrap()).unwrap(); + // Matching the old baseline must still conflict in a different repository. + fs::write(&target, "old baseline").unwrap(); + fs::write(new_repo.join("unrelated"), "staged").unwrap(); + let mut index = repo.index().unwrap(); + index.add_path(Path::new("unrelated")).unwrap(); + index.write().unwrap(); + let index_before = fs::read(repo.path().join("index")).unwrap(); + let output = f.cli(&["retarget", new_repo.to_str().unwrap()]); + assert!(!output.status.success()); + assert_eq!( + f.store.config().unwrap().repository, + fs::canonicalize(new_repo).unwrap() + ); + assert_eq!(fs::read_to_string(&target).unwrap(), "old baseline"); + assert!(f.store.state().unwrap().conflicts.contains_key(&f.key("a"))); + assert_eq!(fs::read(repo.path().join("index")).unwrap(), index_before); + output_text(f.cli(&["resolve", &f.key("a"), "--use-source"])); + assert_eq!(fs::read_to_string(target).unwrap(), "changed source"); +} + +#[test] +fn retarget_subdir_and_unchanged_target_keep_expected_baselines() { + let f = Fixture::new("macos", false); + f.write("a", "first"); + f.sync(); + let old_target = f.target("a"); + f.write("a", "second"); + // Resetting baselines here would incorrectly report a first-sync conflict. + output_text(f.cli(&["retarget", f.repository.to_str().unwrap()])); + assert_eq!(fs::read_to_string(&old_target).unwrap(), "second"); + output_text(f.cli(&["retarget", f.repository.to_str().unwrap(), "--subdir", "."])); + assert_eq!(fs::read_to_string(f.target("a")).unwrap(), "second"); + assert!(f.key("a").starts_with("__ROOT__/")); + f.write("a", "third"); + f.sync(); + assert_eq!(fs::read_to_string(old_target).unwrap(), "second"); + assert_eq!(fs::read_to_string(f.target("a")).unwrap(), "third"); +} + +#[test] +fn invalid_retarget_leaves_configuration_and_state_untouched() { + let f = Fixture::new("macos", false); + f.write("a", "content"); + f.sync(); + let before_config = fs::read(f.store.root.join("config.toml")).unwrap(); + let before_state = f.store.state().unwrap(); + for target in [ + f.source.join("nested"), + f.store.root.join("nested"), + f.repository.join("nested"), + f._temp.path().to_path_buf(), + ] { + assert!( + !f.cli(&["retarget", target.to_str().unwrap()]) + .status + .success() + ); + } + assert!( + !f.cli(&[ + "retarget", + f.repository.to_str().unwrap(), + "--subdir", + "__ROOT__" + ]) + .status + .success() + ); + let new_repo = f._temp.path().join("new-repo"); + fs::create_dir(&new_repo).unwrap(); + symlink(&f.source, new_repo.join("macos")).unwrap(); + assert!( + !f.cli(&["retarget", new_repo.to_str().unwrap()]) + .status + .success() + ); + assert_eq!( + fs::read(f.store.root.join("config.toml")).unwrap(), + before_config + ); + assert_eq!(f.store.state().unwrap(), before_state); + assert!(!f.source.join("nested").exists()); +} + +#[test] +fn interrupted_retarget_recovers_before_using_old_baselines() { + // Simulate interruption after each durable step of the cross-file update. + for phase in 0..3 { + let f = Fixture::new("macos", false); + f.write("a", "old baseline"); + f.sync(); + f.write("a", "source changed"); + let new_repo = f._temp.path().join("new-repo"); + Repository::init(&new_repo).unwrap(); + let target = new_repo.join(f.key("a")); + fs::create_dir_all(target.parent().unwrap()).unwrap(); + fs::write(&target, "old baseline").unwrap(); + let mut config = f.store.config().unwrap(); + config.repository = fs::canonicalize(new_repo).unwrap(); + fs::write( + f.store.root.join("pending-retarget.toml"), + toml::to_string(&config).unwrap(), + ) + .unwrap(); + if phase >= 1 { + f.store.save_state(&Default::default()).unwrap(); + } + if phase >= 2 { + f.store.save_config(&config).unwrap(); + } + let report = filetrail::sync::run(&f.store, false, None).unwrap(); + assert!(!report.errors.is_empty()); + assert_eq!(fs::read_to_string(target).unwrap(), "old baseline"); + assert_eq!(f.store.config().unwrap().repository, config.repository); + assert!(!f.store.root.join("pending-retarget.toml").exists()); + assert!(f.store.state().unwrap().conflicts.contains_key(&f.key("a"))); + } +} + +#[test] +fn deinit_is_repeatable_preserves_files_and_allows_fresh_init() { + let f = Fixture::new("macos", true); + f.write("a", "content"); + f.sync(); + output_text(f.cli(&["commit", "-m", "saved"])); + let target = f.target("a"); + let head = Repository::open(&f.repository) + .unwrap() + .head() + .unwrap() + .target(); + fs::write(f.store.root.join("daemon.log"), "keep log").unwrap(); + fs::write(f.store.root.join("custom.txt"), "keep custom file").unwrap(); + // deinit must work even with invalid configuration/state or recovery data. + for name in ["config.toml", "state.db", "pending-retarget.toml"] { + fs::write(f.store.root.join(name), "invalid").unwrap(); + } + for _ in 0..2 { + output_text(f.cli(&["deinit"])); + assert!(!f.store.root.join("config.toml").exists()); + assert!(!f.store.root.join("state.db").exists()); + assert!(!f.store.root.join("pending-retarget.toml").exists()); + } + assert_eq!(fs::read_to_string(target).unwrap(), "content"); + assert_eq!(fs::read_to_string(f.source.join("a")).unwrap(), "content"); + assert_eq!( + fs::read_to_string(f.store.root.join("daemon.log")).unwrap(), + "keep log" + ); + assert!(f.store.root.join("custom.txt").exists()); + assert_eq!( + Repository::open(&f.repository) + .unwrap() + .head() + .unwrap() + .target(), + head + ); + let new_repo = f._temp.path().join("new-repo"); + output_text(f.cli(&["init", new_repo.to_str().unwrap()])); + assert!(f.store.config().unwrap().entries.is_empty()); + assert_eq!(f.store.state().unwrap(), Default::default()); +} + +#[test] +fn retarget_keeps_daemon_paused_then_deinit_stops_it() { + let f = Fixture::new("macos", false); + f.write("a", "initial"); + let child = Command::new(env!("CARGO_BIN_EXE_filetrail")) + .arg("--data-dir") + .arg(&f.store.root) + .args(["daemon", "run", "--poll"]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::inherit()) + .spawn() + .unwrap(); + let mut child = ChildGuard(child); + wait_until(|| f.target("a").exists()); + let old_target = f.target("a"); + output_text(f.cli(&["pause"])); + f.write("a", "retargeted"); + let new_repo = f._temp.path().join("new-repo"); + output_text(f.cli(&["retarget", new_repo.to_str().unwrap(), "--subdir", "linux"])); + assert!(output_text(f.cli(&["daemon", "status"])).contains("paused=true")); + let target = new_repo.join(f.key("a")); + assert_eq!(fs::read_to_string(&target).unwrap(), "retargeted"); + f.write("a", "paused change"); + std::thread::sleep(Duration::from_millis(1200)); + assert_eq!(fs::read_to_string(&target).unwrap(), "retargeted"); + output_text(f.cli(&["resume"])); + wait_until(|| fs::read_to_string(&target).is_ok_and(|value| value == "paused change")); + assert_eq!(fs::read_to_string(old_target).unwrap(), "initial"); + output_text(f.cli(&["deinit"])); + assert!(child.0.wait().unwrap().success()); + assert!(!f.store.root.join("daemon.sock").exists()); + assert!(!f.store.root.join("state.db").exists()); + assert_eq!(fs::read_to_string(target).unwrap(), "paused change"); +} + +#[test] +fn init_refuses_leftover_state_until_explicit_deinit() { + for name in [ + "state.db", + "state.db-journal", + "state.db-wal", + "state.db-shm", + ] { + let f = Fixture::new("macos", false); + fs::remove_file(f.store.root.join("config.toml")).unwrap(); + fs::write(f.store.root.join(name), "leftover").unwrap(); + assert!( + !f.cli(&["init", f.repository.to_str().unwrap()]) + .status + .success() + ); + assert_eq!( + fs::read_to_string(f.store.root.join(name)).unwrap(), + "leftover" + ); + output_text(f.cli(&["deinit"])); + output_text(f.cli(&["init", f.repository.to_str().unwrap()])); + assert_eq!(f.store.state().unwrap(), Default::default()); + } +} + +#[test] +fn retarget_refuses_corrupt_state_and_preserves_disabled_entries() { + let f = Fixture::new("macos", true); + f.write("a", "content"); + let new_repo = f._temp.path().join("new-repo"); + fs::write(f.store.root.join("state.db"), "corrupt").unwrap(); + assert!( + !f.cli(&["retarget", new_repo.to_str().unwrap()]) + .status + .success() + ); + assert!(!new_repo.exists()); + assert_eq!( + fs::read_to_string(f.store.root.join("state.db")).unwrap(), + "corrupt" + ); + fs::remove_file(f.store.root.join("state.db")).unwrap(); + output_text(f.cli(&["disable", "1"])); + output_text(f.cli(&["retarget", new_repo.to_str().unwrap()])); + assert!(!f.store.config().unwrap().entries[0].enabled); + assert!(!new_repo.join(f.key("a")).exists()); + output_text(f.cli(&["enable", "1"])); + f.sync(); + assert_eq!( + fs::read_to_string(new_repo.join(f.key("a"))).unwrap(), + "content" + ); +}