From 3828a5da6da6429d18f832b0e0c6567daeeb335b Mon Sep 17 00:00:00 2001 From: RinChanNOWWW Date: Tue, 8 Sep 2026 20:29:05 +0800 Subject: [PATCH] feat: add file restoration and installable FileTrail skill --- AGENTS.md | 3 + README.md | 63 +++++ README_zh.md | 54 +++++ skills/filetrail/SKILL.md | 221 +++++++++++++++++ skills/filetrail/agents/openai.yaml | 3 + src/lib.rs | 1 + src/main.rs | 27 +++ src/restore.rs | 320 +++++++++++++++++++++++++ src/sync.rs | 18 +- tests/workflow.rs | 352 ++++++++++++++++++++++++++++ 10 files changed, 1059 insertions(+), 3 deletions(-) create mode 100644 skills/filetrail/SKILL.md create mode 100644 skills/filetrail/agents/openai.yaml create mode 100644 src/restore.rs diff --git a/AGENTS.md b/AGENTS.md index 05152d3..d3d6558 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,6 +8,9 @@ one executable for macOS and Linux. Read README.md before changing its behavior. - README.md is the primary English README; README_zh.md is the Chinese version. Every README change must update both files in the same change, keeping behavior, examples, and section coverage equivalent. Preserve their language-switch links. +- When adding, removing, or changing a CLI command, option, default, or user-visible + behavior, update `skills/filetrail/SKILL.md` in the same change so the installable + agent skill stays accurate. Keep it focused on FileTrail's purpose and usage. - Use the exact toolchain in rust-toolchain.toml. Keep Cargo.lock checked in. - Building requires a C compiler for vendored libgit2 and SQLite. The distributed executable needs no separate Rust installation, and its built-in commands need no system Git. diff --git a/README.md b/README.md index c2a432f..05d9c5c 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,28 @@ with `./install.sh zsh`. It installs with Cargo, then configures that shell's completion. Open a new shell afterward. The installation root defaults to `${CARGO_HOME:-$HOME/.cargo}`; set `CARGO_INSTALL_ROOT` to override it. +## Agent skill + +The [FileTrail skill](skills/filetrail/SKILL.md) teaches an agent what FileTrail +does and how to use its commands, including restoration. To install it, simply +send Codex this message: + +```text +Please install this Skill: https://github.com/RinChanNOWWW/FileTrail/tree/master/skills/filetrail +``` + +You can also run Codex's GitHub skill installer manually: + +```sh +python3 "${CODEX_HOME:-$HOME/.codex}/skills/.system/skill-installer/scripts/install-skill-from-github.py" \ + --repo RinChanNOWWW/FileTrail --ref master --path skills/filetrail +``` + +If the skill is on another branch or tag, replace `master` with that ref. +The skill installs as `filetrail`; invoke it with `$filetrail` in a supporting +agent environment. It provides instructions only: the agent still needs access +to the FileTrail executable and the files on the machine being managed. + ## Tab completion and shell integration If you installed FileTrail with `cargo install`, enable completion with: @@ -110,6 +132,47 @@ 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. +## Restore files to this system + +Select an existing FileTrail repository with `init` on a new machine, then restore: + +```sh +filetrail init ~/dotfiles --subdir macos # Only for a profile not yet initialized +filetrail restore --dry-run +filetrail restore +filetrail restore macos/__HOME__/.zshrc macos/__HOME__/.config/nvim +filetrail restore --track macos/__HOME__/.zshrc +filetrail restore --overwrite macos/__HOME__/.zshrc +``` + +Paths are relative to the repository root, including the configured subdirectory. +Supply multiple files or directories; directories are copied recursively and overlapping +selections are copied once. With no paths, restore all files under the current +subdirectory's `__HOME__` and `__ROOT__`. Other subdirectories and repository files +such as READMEs are not restored. `__HOME__` maps to the current user's Home; +`__ROOT__` maps to `/` and requires write access at the original absolute locations. +The repository's current working files are used, including uncommitted files. + +By default, this only copies files and leaves management unchanged. Identical files +are left alone; different local contents, file types, or permissions require +`--overwrite`. Local directories are never replaced by files or links. Files absent +from the repository are not deleted. File permissions and symlinks are preserved; +symlink targets are not copied or rewritten. Restore refuses symlink ancestors, +paths into the repository or application data directory, and `.git` paths. +Nested `.git` entries are skipped. + +`--track` also adds each restored file or symlink to management with deletion disabled, +so extra local files in the same directory do not become managed. Compatible existing +mappings retain their settings; disabled or excluded mappings must be adjusted before +using `--track`. Files encoded under `__ROOT__` that now belong inside Home can be +copied, but must use the `__HOME__` layout to be tracked. Empty directories are created +but are not added as management entries. Afterward, ordinary synchronization flows +from this system back to the repository; restore does not start the daemon or commit. + +`--dry-run` previews copying and optional management changes without applying them. +All selections and conflicts are checked before copying. An I/O error during execution +can leave some files restored; fix the error and rerun the command. + ## Change the target or start over To switch repositories while keeping your sources, exclusions, and deletion settings: diff --git a/README_zh.md b/README_zh.md index e50ef93..4c52d38 100644 --- a/README_zh.md +++ b/README_zh.md @@ -28,6 +28,26 @@ filetrail --help 它先通过 Cargo 安装,再配置所选 shell 的补全,完成后重新打开 shell 即可。 安装根目录默认为 `${CARGO_HOME:-$HOME/.cargo}`,可通过 `CARGO_INSTALL_ROOT` 覆盖。 +## Agent skill + +[FileTrail skill](skills/filetrail/SKILL.md) 向 agent 介绍 FileTrail 的用途和命令用法, +包括文件恢复。直接把下面这句话发给 Codex,即可让它安装: + +```text +请安装这个 Skill:https://github.com/RinChanNOWWW/FileTrail/tree/master/skills/filetrail +``` + +也可以手动运行 Codex 的 GitHub skill 安装脚本: + +```sh +python3 "${CODEX_HOME:-$HOME/.codex}/skills/.system/skill-installer/scripts/install-skill-from-github.py" \ + --repo RinChanNOWWW/FileTrail --ref master --path skills/filetrail +``` + +如果 skill 位于其他分支或标签,将 `master` 替换为对应 ref。 +安装后的名称为 `filetrail`,可在支持 skill 的 agent 环境中通过 `$filetrail` 调用。 +skill 只提供使用说明;agent 仍需要能访问被管理机器上的 FileTrail 可执行文件和相关文件。 + ## Tab 补全与 shell 集成 如果使用 `cargo install` 安装 FileTrail,执行以下命令启用补全: @@ -100,6 +120,40 @@ filetrail add ~/notes --exclude '**/*.tmp' 目录默认递归监听,排除规则相对于来源根目录。相对来源路径以当前目录为基准, 父目录中的符号链接会解析为实际路径。来源、目标和应用数据目录不能相互重叠。 +## 恢复文件到当前系统 + +在新机器上通过 `init` 选择已有的 FileTrail 仓库,然后恢复: + +```sh +filetrail init ~/dotfiles --subdir macos # 仅用于尚未初始化的配置 +filetrail restore --dry-run +filetrail restore +filetrail restore macos/__HOME__/.zshrc macos/__HOME__/.config/nvim +filetrail restore --track macos/__HOME__/.zshrc +filetrail restore --overwrite macos/__HOME__/.zshrc +``` + +路径相对于仓库根目录,需要包含已配置的子目录。可以一次指定多个文件或目录; +目录会递归拷贝,重叠选择只拷贝一次。不指定路径时,恢复当前子目录下 +`__HOME__` 和 `__ROOT__` 中的所有文件。其他子目录及 README 等仓库文件不会恢复。 +`__HOME__` 映射到当前用户的 Home,`__ROOT__` 映射到 `/`,需要对原始绝对位置 +具有写入权限。恢复使用仓库当前工作区中的文件,包括尚未提交的文件。 + +默认仅拷贝文件,不改变管理状态。相同文件保持不变;本地内容、文件类型或权限 +不同时,需要显式传入 `--overwrite`。不会用文件或链接替换本地目录,也不会删除 +仓库中不存在的本地文件。文件权限和符号链接会保留,但不会拷贝或改写链接指向的目标。 +恢复拒绝经过符号链接父目录、写入仓库或应用数据目录,以及显式选择 `.git` 路径; +递归扫描时跳过嵌套的 `.git` 项。 + +`--track` 会将恢复的每个文件或符号链接加入管理,默认关闭删除传播,同目录下 +额外存在的本地文件不会因此纳入管理。兼容的已有管理项保留原设置;已禁用或被排除的 +管理项需要先调整设置才能使用 `--track`。编码在 `__ROOT__` 下、但在当前系统中属于 +Home 的文件可以拷贝,加入管理则需要使用 `__HOME__` 布局。空目录会创建,但不会作为 +管理项加入。之后普通同步仍从当前系统同步到仓库;恢复不会启动后台任务或提交。 + +`--dry-run` 预览拷贝和可选的管理变更,不实际应用。所有选择和冲突都会在拷贝前检查。 +执行期间发生 I/O 错误可能留下部分已恢复的文件;修复错误后可重新运行命令。 + ## 更换目标或重新初始化 切换仓库时保留监听项、排除规则和删除设置: diff --git a/skills/filetrail/SKILL.md b/skills/filetrail/SKILL.md new file mode 100644 index 0000000..0c7fc11 --- /dev/null +++ b/skills/filetrail/SKILL.md @@ -0,0 +1,221 @@ +--- +name: filetrail +description: Use the FileTrail CLI on macOS or Linux to manage file and dotfile synchronization into a Git repository, restore files to the current system, review and commit managed changes, and operate its daemon and shell integration. Applies to using FileTrail, not developing its Rust implementation. +--- + +# FileTrail + +FileTrail watches selected files and directories and copies changes into a local +Git repository. It provides explicit review and commit commands, and can restore +repository files onto the current system. Normal synchronization goes from local +sources to the repository; it is not automatic bidirectional synchronization. +The daemon never stages, commits, or pushes. One executable supports macOS and +Linux; built-in commands need neither a Rust installation nor system Git. +Only `filetrail git` requires Git on PATH. + +## Discover the installed CLI and profile + +Run `filetrail --version`, `filetrail --help`, and relevant subcommand help before +using unfamiliar options; the installed version may differ from this guide. +FileTrail must be installed and executable in the environment where the user's +files reside. Loading this skill does not install the executable or grant access +to another machine's files. + +The default profile is `$HOME/.filetrail`. Use the same `--data-dir ` +for every command when the user selects another profile: + +```sh +filetrail --data-dir ~/filetrail-work status +filetrail --data-dir ~/filetrail-work list +``` + +Inspect `status` and `list` for an existing setup. Use an isolated temporary profile, +source, and repository for experiments; the data directory must be outside both +source and repository. Real setup examples below apply when configuration is part +of the user's request. + +## Set up sources and understand paths + +```sh +filetrail init ~/dotfiles --subdir macos +filetrail add ~/.zshrc +filetrail add ~/.config/nvim +filetrail add ~/notes --exclude '**/*.tmp' +``` + +Use `--subdir linux` for a Linux layout, or omit `--subdir` to put the reserved +roots directly in the repository. `init` creates the destination and initializes +Git if needed; an existing local repository can also be selected. It does not +clone or fetch a remote. `add` immediately synchronizes the source's existing +files. Directories are recursive; `--exclude` is a repeatable glob relative to +each source root. + +| Local source | Repository path with `--subdir macos` | +| --- | --- | +| `~/.zshrc` | `macos/__HOME__/.zshrc` | +| `~/.config/nvim/init.lua` | `macos/__HOME__/.config/nvim/init.lua` | +| `/opt/scripts/build.sh` | `macos/__ROOT__/opt/scripts/build.sh` | + +`__HOME__` represents the current user's Home; `__ROOT__` represents `/`. +Custom targets are unsupported, and neither reserved name can occur in `--subdir`. +Source arguments are local paths, relative to the current directory when not +absolute. Parent symlinks are resolved when adding sources. Paths for `restore`, +`diff`, `commit`, and `resolve` are repository-relative and include the subdirectory. +Sources, the target repository, and the application data directory must not overlap. + +For batch addition, use `filetrail add --from ./files.txt`. Write one source per +line, quoting paths containing spaces; blank lines and `#` comments are allowed. +Relative paths are resolved from the list's directory. `~` expands to Home; +variables and commands are not expanded or executed. The entire list is validated +before adding entries. Editing the list later does not update existing entries. + +```text +~/.zshrc +~/.config/nvim +"~/My Notes" +/opt/scripts +``` + +## Manage and synchronize + +| Command | Effect | +| --- | --- | +| `filetrail list` | Show source mappings and IDs. | +| `filetrail disable ` | Disable synchronization for a mapping. | +| `filetrail enable ` | Enable a mapping. | +| `filetrail remove ` | Stop managing a mapping; keep its files. | +| `filetrail sync --dry-run` | Preview forward synchronization. | +| `filetrail sync` | Synchronize immediately, even while automatic sync is paused. | + +Use IDs returned by `list`. Source deletion does not delete repository copies by +default. `add --delete` opts into propagating deletion of previously +synchronized files. An unavailable source directory never triggers mass deletion. +Symlinks are copied as links; `.git` is excluded. Empty directories can be copied, +but Git does not track them. Git ignore rules apply when committing. + +## Restore from the repository + +On a new profile, first select the existing local repository with `init` and the +appropriate `--subdir`. Then use: + +```sh +filetrail restore --dry-run +filetrail restore +filetrail restore macos/__HOME__/.zshrc macos/__HOME__/.config/nvim +filetrail restore --track macos/__HOME__/.zshrc +filetrail restore --overwrite macos/__HOME__/.zshrc +``` + +- Without paths, restore all files under the configured subdirectory's `__HOME__` + and `__ROOT__`; other platform directories and repository READMEs are not restored. +- Multiple files or directories can be selected. Directories are recursive; + overlapping selections are copied once. Current working files are used, including + uncommitted files. `__ROOT__` locations need normal system write permissions. +- Default behavior is copying only, leaving management unchanged. Identical files + are left alone. Different local contents, types, or permissions require + `--overwrite`; local directories are never replaced by files or links. +- `--track` adds restored files and symlinks individually, with deletion disabled. + Extra local files are not added. Compatible existing mappings retain their settings; + disabled or excluded mappings must be adjusted before tracking. Empty directories + are created but not tracked. A `__ROOT__` path that now belongs inside Home must + use the `__HOME__` layout to be tracked. +- `--dry-run` previews both copying and optional tracking. Restore does not delete + extra local files, start the daemon, commit, or run forward synchronization. +- Permissions and symlinks are preserved; link targets are not rewritten or followed. + Symlink ancestors and writes into the repository, application data directory, or + `.git` are refused. Nested `.git` entries are skipped. + +Selections and conflicts are checked before copying. An I/O failure can leave +some files restored; fix the error and rerun. Subsequent normal synchronization +still goes from local sources to the repository. + +## Review, commit, and resolve conflicts + +```sh +filetrail status +filetrail diff +filetrail diff -- macos/__HOME__/.config/nvim +filetrail commit +filetrail commit -m 'Update shell configuration' +filetrail commit -- macos/__HOME__/.zshrc +``` + +`diff` includes untracked file contents. `commit` includes only managed changes +and refuses unrelated staged changes; preserve the user's staging. A Git name +and email must be configured before committing. Omit `-m` for an automatically +generated message describing the selected changes. Commits do not push. + +For stable review while the daemon runs, `pause` automatic sync, inspect with +`status`/`diff`, then `resume` if this task paused it. Preserve a preexisting paused +state. `resume` catches up; explicit `sync` and `add` still synchronize while paused. + +```sh +filetrail conflicts +filetrail resolve macos/__HOME__/.zshrc --use-source +``` + +A conflict means the repository copy differs on first sync or changed outside +FileTrail. `resolve --use-source` explicitly overwrites that copy with the local +source. To choose the repository copy instead, use `restore --overwrite --track` +for that path, subject to tracking compatibility. Another option is to make the +copies identical and synchronize. Finish Git merge/rebase operations and unresolved +Git conflicts before synchronizing or restoring. + +Run commits, pushes, overwrites, deletion propagation, and service installation +only within the user's requested scope; do not use them just to clear diagnostics. +Existing authorization need not be requested again. + +## Background operation and shell integration + +| Command | Effect | +| --- | --- | +| `filetrail daemon start` | Start background synchronization. | +| `filetrail daemon start --poll` | Use periodic scans instead of filesystem events. | +| `filetrail daemon status` | Inspect the daemon. | +| `filetrail daemon restart` | Restart it; `--poll` is also supported. | +| `filetrail daemon stop` | Stop it. | +| `filetrail daemon run` | Run in the foreground; `--poll` is also supported. | +| `filetrail pause` / `filetrail resume` | Pause automatic sync / resume and catch up. | +| `filetrail service show` | Preview the platform's service definition. | +| `filetrail service install` | Install login startup using launchd on macOS or systemd on Linux. | +| `filetrail service uninstall` | Stop the installed service and prevent automatic restarts. | + +Place the executable at a stable location before installing its service. + +`filetrail completions --install` detects the shell from `$SHELL`; use +`filetrail completions bash --install`, `zsh --install`, or `fish --install` to +choose explicitly. It preserves existing shell configuration. Open a new shell +after installation. Omit `--install` to print a completion script. + +With Bash, Zsh, or Fish integration loaded, `filetrail cd` jumps to the repository +root. `command filetrail cd` prints the path; `--print0` selects NUL termination. +For an agent shell without integration, use `cd "$(command filetrail cd)"` in +Bash/Zsh or use the printed path as the next command's working directory. + +`filetrail git ` runs system Git in the repository root and prevents +concurrent FileTrail synchronization for the duration. Put FileTrail options +before `git`, e.g. `filetrail --data-dir ~/filetrail-work git status`. All following +arguments, I/O, and exit codes belong to Git. This passthrough uses normal Git +staging, including unmanaged files, and adds no automatic sync or commit. + +## Change the target, stop using a profile, or troubleshoot + +```sh +filetrail retarget ~/new-dotfiles +filetrail retarget ~/new-dotfiles --subdir linux +filetrail retarget ~/dotfiles --subdir . +``` + +`retarget` keeps sources, exclusions, deletion settings, and paused/running status. +Omitting `--subdir` preserves its value; `--subdir .` selects the repository root. +It immediately syncs enabled sources. The old repository and history remain. +If initial sync reports conflicts, the target has still changed: review `conflicts` +and resolve against the new repository. + +`filetrail deinit` stops the daemon, uninstalls its service, and forgets the profile. +It preserves source files, repositories, Git history, logs, and shell completion. +It is repeatable. To start over, run `init`, add or restore-and-track sources, and +start the daemon or install a service as requested. + +Use `filetrail doctor` to validate the setup, `filetrail logs` to read the log, +`filetrail logs --follow` to follow it, and `filetrail --help` for details. diff --git a/skills/filetrail/agents/openai.yaml b/skills/filetrail/agents/openai.yaml new file mode 100644 index 0000000..cee87b0 --- /dev/null +++ b/skills/filetrail/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "FileTrail" + short_description: "Sync, restore, and version files with FileTrail" diff --git a/src/lib.rs b/src/lib.rs index 0eb146c..e79463d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,6 +9,7 @@ pub mod daemon; pub mod git; pub mod lifecycle; pub mod manifest; +pub mod restore; pub mod service; mod state; pub mod sync; diff --git a/src/main.rs b/src/main.rs index a6574c5..9110c29 100644 --- a/src/main.rs +++ b/src/main.rs @@ -69,6 +69,20 @@ enum Commands { }, /// List source mappings and IDs. List, + /// Copy repository files back to this system; does not add management by default. + Restore { + /// Repository-relative files/directories; omitted restores both reserved roots in the current subdir. + paths: Vec, + /// Add restored files to management, preserving compatible existing mappings. + #[arg(long)] + track: bool, + /// Replace local files or symlinks with different contents (never directories). + #[arg(long)] + overwrite: bool, + /// Preview copies and management changes without writing them. + #[arg(long)] + dry_run: bool, + }, /// Stop tracking an entry, keeping its destination files. Remove { id: u64, @@ -390,6 +404,19 @@ fn execute(cli: Cli) -> Result { } print_report(filetrail::sync::run(&store, false, None)?)?; } + Commands::Restore { + paths, + track, + overwrite, + dry_run, + } => { + let report = filetrail::restore::run(&store, &paths, track, overwrite, dry_run)?; + if report.actions.is_empty() { + println!("Nothing to restore"); + } else { + println!("{}", report.text()); + } + } Commands::List => { let _lock = store.lock()?; let config = store.config()?; diff --git a/src/restore.rs b/src/restore.rs new file mode 100644 index 0000000..d38cbed --- /dev/null +++ b/src/restore.rs @@ -0,0 +1,320 @@ +//! Restore the configured repository's reserved layout without running forward sync. +use std::collections::BTreeMap; +use std::fs; +use std::io::ErrorKind; +use std::path::Path; +use std::path::PathBuf; + +use anyhow::Context; +use anyhow::Result; +use anyhow::bail; +use walkdir::WalkDir; + +use crate::config::Baseline; +use crate::config::Config; +use crate::config::Entry; +use crate::config::Store; +use crate::sync::Report; +use crate::sync::fingerprint; +use crate::sync::key; +use crate::sync::safe_destination; + +struct Copy { + repository_path: PathBuf, + local: PathBuf, + directory: bool, + expected: Option, + previous: Option, + entry: Option, +} + +pub fn run( + store: &Store, + paths: &[PathBuf], + track: bool, + overwrite: bool, + dry_run: bool, +) -> Result { + let _lock = store.lock()?; + let mut config = store.config()?; + let repo = crate::git::open(&config)?; + crate::git::ensure_idle(&repo)?; + // Even copy-only operations must not hide corrupt synchronization history. + let mut state = store.state()?; + let home = crate::config::home_dir()?; + let roots = if paths.is_empty() { + let mut roots = Vec::new(); + for name in ["__HOME__", "__ROOT__"] { + let relative = config.subdir.join(name); + let absolute = safe_destination(&config.repository, &relative)?; + match fs::symlink_metadata(absolute) { + Ok(_) => roots.push(relative), + Err(error) if error.kind() == ErrorKind::NotFound => (), + Err(error) => return Err(error.into()), + } + } + roots + } else { + paths + .iter() + .map(|path| crate::config::relative(path)) + .collect::>>()? + }; + let mut copies = BTreeMap::new(); + let mut local_paths = BTreeMap::new(); + for root in roots { + local_path(&config, &home, &root)?; + let absolute = safe_destination(&config.repository, &root)?; + for item in WalkDir::new(absolute) + .follow_links(false) + .follow_root_links(false) + .sort_by_file_name() + .into_iter() + .filter_entry(|item| { + !item + .file_name() + .to_string_lossy() + .eq_ignore_ascii_case(".git") + }) + { + let item = item?; + let relative = crate::config::relative(item.path().strip_prefix(&config.repository)?)?; + if copies.contains_key(&relative) { + continue; + } + let local = local_path(&config, &home, &relative)?; + let directory = item.file_type().is_dir(); + // Reserved roots encode bases, never links or files to replace Home or /. + if relative.strip_prefix(&config.subdir)?.components().count() == 1 && !directory { + bail!( + "reserved restore root must be a real directory: {}", + relative.display() + ); + } + validate_local(store, &config, &local, directory)?; + let folded = key(&local)?.to_lowercase(); + if let Some(other) = local_paths.insert(folded, relative.clone()) { + bail!( + "restore paths collide: {} and {}", + other.display(), + relative.display() + ); + } + let (expected, previous) = if directory { + (None, None) + } else { + let expected = fingerprint(item.path())?.context("repository file disappeared")?; + let previous = fingerprint(&local)?; + if previous.as_ref().is_some_and(|value| value != &expected) && !overwrite { + bail!( + "local file differs: {}; use --overwrite to replace it", + local.display() + ); + } + (Some(expected), previous) + }; + copies.insert( + relative.clone(), + Copy { + repository_path: relative, + local, + directory, + expected, + previous, + entry: None, + }, + ); + } + } + // Plan the entire batch, including management compatibility, before writing. + if track { + let mut next = config + .entries + .iter() + .map(|entry| entry.id) + .chain(state.owned.values().copied()) + .chain(state.files.values().map(|baseline| baseline.entry)) + .max() + .unwrap_or(0); + for copy in copies.values_mut().filter(|copy| !copy.directory) { + let target = crate::config::default_target(©.local, &home)?; + if config.subdir.join(&target) != copy.repository_path { + bail!( + "cannot track {}: its current-system mapping belongs at {}", + copy.repository_path.display(), + config.subdir.join(target).display() + ); + } + let existing = config.entries.iter().find(|entry| { + copy.local == entry.source + || (entry.directory && copy.local.starts_with(&entry.source)) + }); + let id = if let Some(entry) = existing { + let relative = copy.local.strip_prefix(&entry.source)?; + if config.destination(entry).join(relative) != copy.repository_path { + bail!("incompatible existing mapping for {}", copy.local.display()); + } + ensure_included(&config, entry, relative)?; + entry.id + } else { + next = next + .checked_add(1) + .filter(|id| *id <= i64::MAX as u64) + .context("no available mapping IDs")?; + let entry = Entry { + id: next, + source: copy.local.clone(), + target, + directory: false, + enabled: true, + delete: false, + exclude: vec![], + }; + ensure_included(&config, &entry, Path::new(""))?; + config.entries.push(entry); + next + }; + copy.entry = Some(id); + } + config.validate()?; + store.validate_layout(&config)?; + } + let mut report = Report::default(); + for copy in copies.values() { + if copy.directory { + if !copy.local.exists() { + report + .actions + .push(format!("mkdir {}", copy.local.display())); + if !dry_run { + validate_local(store, &config, ©.local, true)?; + fs::create_dir_all(©.local)?; + } + } + continue; + } + report.actions.push(format!( + "{} {} -> {}", + if copy.expected == copy.previous { + "unchanged" + } else { + "restore" + }, + copy.repository_path.display(), + copy.local.display() + )); + if let Some(id) = copy.entry { + report + .actions + .push(format!("track [{id}] {}", copy.local.display())); + } + if dry_run { + continue; + } + validate_local(store, &config, ©.local, false)?; + let source = safe_destination(&config.repository, ©.repository_path)?; + if fingerprint(&source)? != copy.expected || fingerprint(©.local)? != copy.previous { + bail!( + "file changed after restore planning: {}; retry restore", + copy.repository_path.display() + ); + } + let expected = copy + .expected + .as_deref() + .context("missing file fingerprint")?; + if copy.expected != copy.previous { + crate::sync::copy_atomic_checked(&source, ©.local, expected, overwrite)?; + } + if let Some(entry) = copy.entry { + let name = key(©.repository_path)?; + state.owned.insert(name.clone(), entry); + state.files.insert( + name.clone(), + Baseline { + entry, + fingerprint: expected.to_owned(), + }, + ); + state.conflicts.remove(&name); + } + } + if track && !dry_run && copies.values().any(|copy| copy.entry.is_some()) { + // Config first: if saving state fails, a later sync can adopt identical + // copies, without ever having a baseline referencing an unsaved mapping. + store.save_config(&config)?; + store.save_state(&state)?; + } + Ok(report) +} + +fn local_path(config: &Config, home: &Path, relative: &Path) -> Result { + let mut components = relative + .strip_prefix(&config.subdir) + .context("restore path must be inside the configured subdirectory")? + .components(); + let root = components + .next() + .context("select paths under __HOME__ or __ROOT__")?; + let base = match root.as_os_str().to_str() { + Some("__HOME__") => home, + Some("__ROOT__") => Path::new("/"), + _ => bail!( + "restore path must be under __HOME__ or __ROOT__: {}", + relative.display() + ), + }; + Ok(base.join(components.as_path())) +} + +fn validate_local(store: &Store, config: &Config, local: &Path, directory: bool) -> Result<()> { + // Be conservative on both platforms, like mapping validation: macOS often + // aliases case variants of the same path. + let folded = PathBuf::from(key(local)?.to_lowercase()); + for protected in [&store.root, &config.repository] { + let protected = PathBuf::from(key(protected)?.to_lowercase()); + if folded.starts_with(&protected) || (!directory && protected.starts_with(&folded)) { + bail!( + "restore location overlaps repository or data directory: {}", + local.display() + ); + } + } + // Reject symlink ancestors on both sides, including a directory leaf. Leaf + // symlinks remain links and can only be replaced with explicit overwrite. + let check = if directory { + local.join(".filetrail-check") + } else { + local.to_owned() + }; + safe_destination(Path::new("/"), check.strip_prefix("/")?)?; + key(local)?; + Ok(()) +} + +fn ensure_included(config: &Config, entry: &Entry, relative: &Path) -> Result<()> { + let patterns = config + .exclude + .iter() + .chain(&entry.exclude) + .cloned() + .collect::>(); + let ignored = crate::sync::exclusions(&patterns)?; + let relative = if entry.directory { + relative + } else { + Path::new( + entry + .source + .file_name() + .context("missing source filename")?, + ) + }; + if !entry.enabled || relative.ancestors().any(|path| ignored.is_match(path)) { + bail!( + "cannot track disabled or excluded source: {}", + entry.source.display() + ); + } + Ok(()) +} diff --git a/src/sync.rs b/src/sync.rs index b4396e4..eb2146f 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -394,6 +394,15 @@ pub fn fingerprint(path: &Path) -> Result> { } fn copy_atomic(source: &Path, destination: &Path, expected: &str) -> Result<()> { + copy_atomic_checked(source, destination, expected, true) +} + +pub(crate) fn copy_atomic_checked( + source: &Path, + destination: &Path, + expected: &str, + overwrite: bool, +) -> Result<()> { let parent = destination.parent().context("missing destination parent")?; fs::create_dir_all(parent)?; let temporary = tempfile::NamedTempFile::new_in(parent)?; @@ -415,8 +424,11 @@ fn copy_atomic(source: &Path, destination: &Path, expected: &str) -> Result<()> { bail!("source changed during copy; will retry on the next scan"); } - temporary - .persist(destination) - .map_err(|error| error.error)?; + if overwrite { + temporary.persist(destination) + } else { + temporary.persist_noclobber(destination) + } + .map_err(|error| error.error)?; Ok(()) } diff --git a/tests/workflow.rs b/tests/workflow.rs index 66b1d63..9199106 100644 --- a/tests/workflow.rs +++ b/tests/workflow.rs @@ -131,6 +131,9 @@ fn completion_generation_includes_nested_commands_without_initialization() { "uninstall", "retarget", "deinit", + "restore", + "track", + "overwrite", "from", "install", ] { @@ -1822,3 +1825,352 @@ fn retarget_refuses_corrupt_state_and_preserves_disabled_entries() { "content" ); } + +#[test] +fn restore_copies_all_without_changing_management_or_repository() { + let f = Fixture::new("macos", false); + f.write("settings", "saved"); + f.write("nested/tool", "#!/bin/sh\n"); + fs::set_permissions( + f.source.join("nested/tool"), + fs::Permissions::from_mode(0o751), + ) + .unwrap(); + symlink("missing", f.source.join("link")).unwrap(); + f.sync(); + let config = fs::read(f.store.root.join("config.toml")).unwrap(); + let state = f.store.state().unwrap(); + fs::remove_dir_all(&f.source).unwrap(); + fs::write(f.repository.join("README.md"), "not restored").unwrap(); + fs::create_dir_all(f.repository.join("linux/__HOME__")).unwrap(); + fs::write( + f.repository.join("linux/__HOME__/untouched"), + "other platform", + ) + .unwrap(); + output_text(f.cli(&["restore"])); + assert_eq!( + fs::read_to_string(f.source.join("settings")).unwrap(), + "saved" + ); + assert_eq!( + fs::metadata(f.source.join("nested/tool")) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o751 + ); + assert_eq!( + fs::read_link(f.source.join("link")).unwrap(), + Path::new("missing") + ); + assert_eq!(fs::read(f.store.root.join("config.toml")).unwrap(), config); + assert_eq!(f.store.state().unwrap(), state); + assert_eq!(fs::read_to_string(f.target("settings")).unwrap(), "saved"); + assert!(output_text(f.cli(&["restore"])).contains("unchanged")); +} + +#[test] +fn restore_selects_multiple_paths_deduplicates_and_previews_tracking() { + let f = Fixture::new("", false); + f.write("a", "a"); + f.write("dir/b", "b"); + f.write("unselected", "c"); + f.sync(); + let mut config = f.store.config().unwrap(); + config.entries.clear(); + f.store.save_config(&config).unwrap(); + fs::remove_file(f.store.root.join("state.db")).unwrap(); + fs::remove_dir_all(&f.source).unwrap(); + let before = fs::read(f.store.root.join("config.toml")).unwrap(); + let args = [ + "restore", + "--track", + "--dry-run", + &f.key("a"), + &f.key("dir"), + &f.key("dir/b"), + ]; + let preview = output_text(f.cli(&args)); + assert_eq!(preview.matches("track [").count(), 2); + assert!(!f.source.exists()); + assert!(!f.store.root.join("state.db").exists()); + assert_eq!(fs::read(f.store.root.join("config.toml")).unwrap(), before); + output_text(f.cli(&[ + "restore", + "--track", + &f.key("a"), + &f.key("dir"), + &f.key("dir/b"), + ])); + assert!(!f.source.join("unselected").exists()); + let config = f.store.config().unwrap(); + assert_eq!(config.entries.len(), 2); + assert!( + config + .entries + .iter() + .all(|entry| !entry.directory && entry.enabled && !entry.delete) + ); + assert_eq!(f.store.state().unwrap().files.len(), 2); + f.write("dir/local-only", "stay local"); + f.write("a", "updated"); + f.sync(); + assert_eq!(fs::read_to_string(f.target("a")).unwrap(), "updated"); + assert!(!f.target("dir/local-only").exists()); + output_text(f.cli(&["restore", "--track", &f.key("dir")])); + assert_eq!(f.store.config().unwrap().entries.len(), 2); +} + +#[test] +fn restore_preflights_conflicts_and_requires_explicit_overwrite() { + let f = Fixture::new("macos", false); + f.write("a", "saved a"); + f.write("z", "saved z"); + f.sync(); + fs::remove_file(f.source.join("a")).unwrap(); + f.write("z", "local edits"); + let output = f.cli(&["restore"]); + assert!(!output.status.success()); + assert!(String::from_utf8_lossy(&output.stderr).contains("--overwrite")); + assert!(!f.source.join("a").exists()); + assert_eq!( + fs::read_to_string(f.source.join("z")).unwrap(), + "local edits" + ); + output_text(f.cli(&["restore", "--overwrite", "--dry-run"])); + assert!(!f.source.join("a").exists()); + output_text(f.cli(&["restore", "--overwrite"])); + assert_eq!(fs::read_to_string(f.source.join("z")).unwrap(), "saved z"); + fs::remove_file(f.source.join("z")).unwrap(); + fs::create_dir(f.source.join("z")).unwrap(); + assert!(!f.cli(&["restore", "--overwrite"]).status.success()); +} + +#[test] +fn restore_reuses_existing_management_and_refuses_disabled_or_excluded_tracking() { + let f = Fixture::new("macos", true); + f.write("a", "saved"); + f.sync(); + let mut config = f.store.config().unwrap(); + config.entries[0].exclude.push("ignored/**".into()); + f.store.save_config(&config).unwrap(); + fs::remove_file(f.source.join("a")).unwrap(); + output_text(f.cli(&["restore", "--track"])); + let restored = f.store.config().unwrap(); + assert_eq!(restored.entries.len(), 1); + assert!(restored.entries[0].delete); + assert_eq!(restored.entries[0].exclude, vec!["ignored/**"]); + assert_eq!(f.store.state().unwrap().files[&f.key("a")].entry, 1); + for excluded in [false, true] { + config.entries[0].enabled = excluded; + config.entries[0].exclude = if excluded { vec!["a".into()] } else { vec![] }; + f.store.save_config(&config).unwrap(); + fs::remove_file(f.source.join("a")).unwrap(); + assert!(!f.cli(&["restore", "--track"]).status.success()); + assert!(!f.source.join("a").exists()); + output_text(f.cli(&["restore"])); + } +} + +#[test] +fn restore_rejects_unsafe_selections_and_symlink_ancestors() { + let f = Fixture::new("macos", false); + f.write("dir/a", "saved"); + f.sync(); + fs::remove_dir_all(&f.source).unwrap(); + for path in [ + "../escape", + "/absolute", + "macos/.git/config", + "linux/__HOME__/a", + "macos/README.md", + "macos", + &f.key("missing"), + ] { + assert!(!f.cli(&["restore", path]).status.success(), "{path}"); + assert!(!f.source.exists()); + } + let outside = f._temp.path().join("outside"); + fs::create_dir(&outside).unwrap(); + symlink(&outside, &f.source).unwrap(); + assert!(!f.cli(&["restore", "--overwrite"]).status.success()); + assert!(!outside.join("dir/a").exists()); + fs::remove_file(&f.source).unwrap(); + fs::remove_dir_all(f.target("dir")).unwrap(); + fs::write(outside.join("a"), "outside").unwrap(); + symlink(&outside, f.target("dir")).unwrap(); + assert!(!f.cli(&["restore", &f.key("dir/a")]).status.success()); + assert!(!f.source.exists()); + // The link itself is restored without traversing its target. + output_text(f.cli(&["restore", &f.key("dir")])); + assert_eq!(fs::read_link(f.source.join("dir")).unwrap(), outside); +} + +#[test] +fn restore_home_uses_current_home_and_tracks_only_restored_files() { + let f = CompletionFixture::new(); + let repository = f.temp.path().join("repo"); + let data = f.temp.path().join("data"); + let run = |args: &[&str]| { + f.command(&f.binary) + .env("GIT_CONFIG_NOSYSTEM", "1") + .env("GIT_CONFIG_GLOBAL", "/dev/null") + .arg("--data-dir") + .arg(&data) + .args(args) + .output() + .unwrap() + }; + output_text(run(&[ + "init", + repository.to_str().unwrap(), + "--subdir", + "macos", + ])); + let repo = Repository::open(&repository).unwrap(); + repo.config() + .unwrap() + .set_str("user.name", "Filetrail Test") + .unwrap(); + repo.config() + .unwrap() + .set_str("user.email", "filetrail@example.invalid") + .unwrap(); + let root = repository.join("macos/__HOME__"); + fs::create_dir_all(root.join(".config/example/empty")).unwrap(); + fs::write(root.join(".config/example/settings"), "saved").unwrap(); + fs::write(root.join(".zshrc"), "shell").unwrap(); + fs::create_dir_all(root.join(".git")).unwrap(); + fs::write(root.join(".git/config"), "never restore").unwrap(); + output_text(run(&["restore", "--track"])); + assert_eq!(fs::read_to_string(f.home.join(".zshrc")).unwrap(), "shell"); + assert!(f.home.join(".config/example/empty").is_dir()); + assert!(!f.home.join(".git").exists()); + assert_eq!(output_text(run(&["list"])).lines().count(), 2); + assert!(output_text(run(&["sync"])).contains("Up to date")); +} + +#[test] +fn restore_refuses_protected_locations_and_corrupt_state_before_copying() { + let f = Fixture::new("macos", false); + for protected in [&f.store.root, &fs::canonicalize(&f.repository).unwrap()] { + let target = f + .repository + .join("macos/__ROOT__") + .join(protected.strip_prefix("/").unwrap()) + .join("injected"); + fs::create_dir_all(target.parent().unwrap()).unwrap(); + fs::write(&target, "bad").unwrap(); + let relative = target + .strip_prefix(&f.repository) + .unwrap() + .to_str() + .unwrap(); + assert!( + !f.cli(&["restore", "--overwrite", relative]) + .status + .success() + ); + assert!(!protected.join("injected").exists()); + fs::remove_file(target).unwrap(); + } + f.write("a", "saved"); + f.sync(); + fs::remove_file(f.source.join("a")).unwrap(); + fs::write(f.store.root.join("state.db"), "corrupt").unwrap(); + assert!(!f.cli(&["restore", &f.key("a")]).status.success()); + assert!(!f.source.join("a").exists()); + assert_eq!( + fs::read_to_string(f.store.root.join("state.db")).unwrap(), + "corrupt" + ); +} + +#[test] +fn restore_tracking_updates_selected_baselines_without_forward_sync_or_staging() { + let f = Fixture::new("macos", false); + f.write("a", "old"); + f.write("b", "old b"); + f.sync(); + fs::write(f.target("a"), "repository edit").unwrap(); + f.write("b", "local edit"); + let report = filetrail::sync::run(&f.store, false, None).unwrap(); + assert!(!report.errors.is_empty()); + // Keep an unrelated source change pending to prove restore does not run sync. + f.write("b", "pending local edit"); + let repo = Repository::open(&f.repository).unwrap(); + fs::write(f.repository.join("unmanaged"), "staged").unwrap(); + let mut index = repo.index().unwrap(); + index.add_path(Path::new("unmanaged")).unwrap(); + index.write().unwrap(); + let before = fs::read(repo.path().join("index")).unwrap(); + output_text(f.cli(&["restore", "--track", "--overwrite", &f.key("a")])); + assert_eq!( + fs::read_to_string(f.source.join("a")).unwrap(), + "repository edit" + ); + assert_eq!(fs::read_to_string(f.target("b")).unwrap(), "local edit"); + assert!(!f.store.state().unwrap().conflicts.contains_key(&f.key("a"))); + assert_eq!(fs::read(repo.path().join("index")).unwrap(), before); + assert!(repo.head().is_err()); + f.sync(); +} + +#[test] +fn restore_overwrite_replaces_leaf_links_without_modifying_their_targets() { + let f = Fixture::new("", false); + f.write("a", "saved"); + f.sync(); + let outside = f._temp.path().join("outside"); + fs::write(&outside, "keep").unwrap(); + fs::remove_file(f.source.join("a")).unwrap(); + symlink(&outside, f.source.join("a")).unwrap(); + assert!(!f.cli(&["restore", &f.key("a")]).status.success()); + output_text(f.cli(&["restore", "--overwrite", &f.key("a")])); + assert_eq!(fs::read_to_string(&outside).unwrap(), "keep"); + assert!( + !fs::symlink_metadata(f.source.join("a")) + .unwrap() + .file_type() + .is_symlink() + ); + fs::remove_file(f.target("a")).unwrap(); + symlink("missing", f.target("a")).unwrap(); + output_text(f.cli(&["restore", "--overwrite", &f.key("a")])); + assert_eq!( + fs::read_link(f.source.join("a")).unwrap(), + Path::new("missing") + ); +} + +#[test] +fn restore_rejects_reserved_root_links_special_files_and_case_aliases() { + let f = Fixture::new("macos", false); + fs::create_dir_all(f.repository.join("macos")).unwrap(); + symlink(&f.source, f.repository.join("macos/__HOME__")).unwrap(); + assert!(!f.cli(&["restore", "--overwrite"]).status.success()); + fs::remove_file(f.repository.join("macos/__HOME__")).unwrap(); + fs::create_dir_all(f.target("")).unwrap(); + // A socket is a special file that must never be treated as file content. + // Use a FIFO instead so this test does not require socket sandbox access. + let output = Command::new("mkfifo") + .arg(f.target("pipe")) + .output() + .unwrap(); + assert!(output.status.success()); + assert!(!f.cli(&["restore", &f.key("pipe")]).status.success()); + assert!(!f.source.join("pipe").exists()); + let alias = f.store.root.parent().unwrap().join("STATE/injected"); + let relative = Path::new("macos/__ROOT__").join(alias.strip_prefix("/").unwrap()); + let target = f.repository.join(&relative); + fs::create_dir_all(target.parent().unwrap()).unwrap(); + fs::write(target, "bad").unwrap(); + assert!( + !f.cli(&["restore", relative.to_str().unwrap()]) + .status + .success() + ); + assert!(!f.store.root.join("injected").exists()); +}