Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"`

---
Expand Down
64 changes: 64 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ rand = "0.10"
ignore = "0.4"
dirs = "6.0.0"
ureq = "3"
reflink-copy = "0.1"

[dev-dependencies]
tempfile = "3"
Expand Down
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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]
Expand Down
12 changes: 11 additions & 1 deletion src/cli/commands/lifecycle/new.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
}
Expand All @@ -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::*;
Expand Down
51 changes: 51 additions & 0 deletions tests/cmd_new.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
);
}