From df2217e47237e94322e4936101d64d18a6d96cf1 Mon Sep 17 00:00:00 2001 From: Mark Lopez Date: Wed, 1 Jul 2026 10:56:22 -0500 Subject: [PATCH] feat: added reflink support --- ARCHITECTURE.md | 4 +- Cargo.lock | 64 +++++++++++++++++++++++++++++++ Cargo.toml | 1 + README.md | 10 ++++- src/cli/commands/lifecycle/new.rs | 12 +++++- tests/cmd_new.rs | 51 ++++++++++++++++++++++++ 6 files changed, 137 insertions(+), 5 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index fb4992e..56cd99e 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -309,10 +309,10 @@ pre_merge = ["pnpm test", "pnpm lint"] ### 配置约束与信任边界 - **路径解析**:项目配置从 `git rev-parse --git-common-dir` 上溯到主 repo 根读取——worktree/子目录任意位置行为一致 -- **`copy_files` 路径沙箱**:拒绝 `/` 开头(绝对路径)和 `..` 段;不跟随符号链接 +- **`copy_files` 路径沙箱**:拒绝 `/` 开头(绝对路径)和 `..` 段;不跟随符号链接。复制优先使用写时复制(reflink/clonefile/DUPLICATE_EXTENTS),不支持时回退为普通拷贝 - **hooks 安全**:hooks 通过 `sh -c`(Windows `cmd /C`)执行,无沙箱无超时——按"committed shell script"信任处理,禁运行不信任 repo - **hook CWD**:`pre_merge`/`post_merge` 一律 worktree 根;`post_create` 在新 worktree 内 -- **hook 环境变量**:所有 hook 注入 `WT_MAIN_REPO`(主仓库根)/`WT_WORKTREE`(worktree 路径)/`WT_BRANCH`(分支名)/`WT_BASE_BRANCH`(base 分支:new=创建来源,merge=合并目标);叠加于继承环境。让 hook 可移植引用路径,如 `post_create = ['ln -s "$WT_MAIN_REPO/node_modules" node_modules']` 软链替代 `copy_files` 复制 +- **hook 环境变量**:所有 hook 注入 `WT_MAIN_REPO`(主仓库根)/`WT_WORKTREE`(worktree 路径)/`WT_BRANCH`(分支名)/`WT_BASE_BRANCH`(base 分支:new=创建来源,merge=合并目标);叠加于继承环境。让 hook 可移植引用路径,如 `post_create = ['ln -s "$WT_MAIN_REPO/node_modules" node_modules']` 软链替代 `copy_files` 复制。`copy_files` 已在支持的文件系统上使用写时复制,故该软链技巧主要用于不支持 CoW 的文件系统 - **trunk 检测**:`origin/HEAD` > `main` > `master` > 默认 `"main"` --- diff --git a/Cargo.lock b/Cargo.lock index 71afcb1..9a573d6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -21,6 +21,7 @@ dependencies = [ "filetime", "ignore", "rand", + "reflink-copy", "serde", "serde_json", "tempfile", @@ -731,6 +732,18 @@ dependencies = [ "thiserror", ] +[[package]] +name = "reflink-copy" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9dd7ab4af0363d5ccfd2838d782a28196cf32a5cc2e4fe3c5dc83f2be588b8b" +dependencies = [ + "cfg-if", + "libc", + "rustix", + "windows", +] + [[package]] name = "regex-automata" version = "0.4.14" @@ -1192,6 +1205,27 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + [[package]] name = "windows-core" version = "0.62.2" @@ -1205,6 +1239,17 @@ dependencies = [ "windows-strings", ] +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + [[package]] name = "windows-implement" version = "0.60.2" @@ -1233,6 +1278,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + [[package]] name = "windows-result" version = "0.4.1" @@ -1311,6 +1366,15 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" diff --git a/Cargo.toml b/Cargo.toml index 7a17f86..f0f264c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,7 @@ rand = "0.10" ignore = "0.4" dirs = "6.0.0" ureq = "3" +reflink-copy = "0.1" [dev-dependencies] tempfile = "3" diff --git a/README.md b/README.md index 31a0097..41f5d64 100644 --- a/README.md +++ b/README.md @@ -177,7 +177,9 @@ post_merge = [] > **`copy_files` constraints** — patterns are gitignore-style and must stay > inside the repo: leading `/` (absolute paths) and `..` traversal are -> rejected. Symlinks are not followed. +> rejected. Symlinks are not followed. Files are copied using Reflink +> where the filesystem supports it (e.g., BTRFS, ZFS, XFS, APFS, ReFS, etc.), +> falling back to a plain copy otherwise. > > **Hook trust boundary** — hooks run via `sh -c` (or `cmd /C` on Windows) > with no sandboxing or timeout. Treat `.agent-worktree.toml` like any @@ -197,7 +199,11 @@ post_merge = [] > | `WT_BASE_BRANCH` | Base branch (creation source for `new`, merge target for `merge`) | > > This makes `post_create` a cheaper alternative to `copy_files` when you only -> want to share — not duplicate — heavy directories like dependencies: +> want to share — not duplicate — heavy directories like dependencies. +> `copy_files` already uses copy-on-write where the filesystem supports it +> (btrfs/XFS reflink, APFS clonefile, ReFS block cloning), so a plain +> `copy_files = ["node_modules"]` is near-free on those filesystems; the +> symlink trick is mainly useful on filesystems without CoW: > > ```toml > [hooks] diff --git a/src/cli/commands/lifecycle/new.rs b/src/cli/commands/lifecycle/new.rs index 5a78c51..6b198eb 100644 --- a/src/cli/commands/lifecycle/new.rs +++ b/src/cli/commands/lifecycle/new.rs @@ -202,7 +202,7 @@ fn copy_files(from: &Path, to: &Path, config: &Config) -> Result<()> { } } - if let Err(e) = std::fs::copy(path, &dest) { + if let Err(e) = copy_file_reflink(path, &dest) { eprintln!("Warning: failed to copy {}: {e}", rel.display()); } } @@ -211,6 +211,16 @@ fn copy_files(from: &Path, to: &Path, config: &Config) -> Result<()> { Ok(()) } +/// Copy `from` to `to` using copy-on-write (reflink/clonefile/DUPLICATE_EXTENTS) +/// where the filesystem supports it, falling back to a plain content copy +/// otherwise. +fn copy_file_reflink(from: &Path, to: &Path) -> std::io::Result<()> { + if to.exists() { + std::fs::remove_file(to)?; + } + reflink_copy::reflink_or_copy(from, to).map(|_| ()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/tests/cmd_new.rs b/tests/cmd_new.rs index 3e32eb7..87f065f 100644 --- a/tests/cmd_new.rs +++ b/tests/cmd_new.rs @@ -250,3 +250,54 @@ fn test_nested_snap_is_rejected() { "stderr should explain nested rejection: {stderr}" ); } + +#[test] +fn test_new_copies_files_matching_copy_files_pattern() { + let (dir, repo, home) = setup_worktree_test_env(); + + std::fs::write(repo.join(".env"), "SECRET=test\n").unwrap(); + + std::fs::write( + repo.join(".agent-worktree.toml"), + r#" +[general] +copy_files = [".env"] +"#, + ) + .unwrap(); + + let path_file = create_path_file(dir.path()); + let output = Command::new(wt_binary()) + .args([ + "new", + "copy-files-test", + "--path-file", + path_file.to_str().unwrap(), + ]) + .current_dir(&repo) + .env("HOME", &home) + .output() + .expect("wt new failed"); + + assert!( + output.status.success(), + "wt new failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let wt_path = read_path_file(&path_file); + let wt_root = std::path::Path::new(wt_path.trim()); + let copied_env = wt_root.join(".env"); + assert!( + copied_env.exists(), + ".env was not copied into the worktree at {}", + copied_env.display() + ); + let contents = std::fs::read_to_string(&copied_env).unwrap(); + assert_eq!(contents, "SECRET=test\n"); + assert_eq!( + std::fs::read_to_string(repo.join(".env")).unwrap(), + contents, + "source and copy should match" + ); +}