Skip to main content

wt/
copy.rs

1//! Copying Git-ignored local files into newly created worktrees (spec §8).
2//!
3//! On `new`/`pr`, files in the source worktree matching the configured `copy`
4//! glob patterns are copied into the new worktree, except: tracked files (they
5//! come from the checkout) and files that already exist in the target (never
6//! overwritten). The `.git` directory is never traversed.
7
8use std::collections::HashSet;
9use std::path::{Path, PathBuf};
10
11use globset::{Glob, GlobSet, GlobSetBuilder};
12
13use crate::error::{Error, Result};
14use crate::git::cli::GitCli;
15
16/// The outcome of a copy step.
17#[derive(Debug, Clone, Default, PartialEq, Eq)]
18pub struct CopyOutcome {
19    /// Relative paths that were copied.
20    pub copied: Vec<PathBuf>,
21    /// Relative paths skipped because the target already existed.
22    pub skipped_existing: Vec<PathBuf>,
23}
24
25/// Copies ignored files matching `patterns` from `source` into `target`
26/// (spec §8). Tracked files and existing targets are skipped.
27pub fn copy_ignored_files(
28    git: &dyn GitCli,
29    source: &Path,
30    target: &Path,
31    patterns: &[String],
32) -> Result<CopyOutcome> {
33    let mut outcome = CopyOutcome::default();
34    if patterns.is_empty() {
35        return Ok(outcome);
36    }
37    let globset = build_globset(patterns)?;
38    let tracked = tracked_files(git, source)?;
39
40    for rel in walk_files(source) {
41        if !globset.is_match(&rel) || tracked.contains(&rel) {
42            continue;
43        }
44        let destination = target.join(&rel);
45        if destination.exists() {
46            outcome.skipped_existing.push(rel);
47            continue;
48        }
49        if let Some(parent) = destination.parent() {
50            std::fs::create_dir_all(parent)?;
51        }
52        std::fs::copy(source.join(&rel), &destination)?;
53        outcome.copied.push(rel);
54    }
55    Ok(outcome)
56}
57
58/// Compiles the copy patterns into a [`GlobSet`]; an invalid glob is a config
59/// error.
60fn build_globset(patterns: &[String]) -> Result<GlobSet> {
61    let mut builder = GlobSetBuilder::new();
62    for pattern in patterns {
63        let glob = Glob::new(pattern).map_err(|e| Error::Config {
64            file: "copy".into(),
65            key: pattern.clone(),
66            reason: format!("invalid glob: {e}"),
67        })?;
68        builder.add(glob);
69    }
70    builder.build().map_err(|e| Error::Config {
71        file: "copy".into(),
72        key: "copy".into(),
73        reason: format!("invalid glob set: {e}"),
74    })
75}
76
77/// The set of tracked files (relative paths) in `source`. A failure to list
78/// them is propagated rather than swallowed: copying would otherwise risk
79/// overwriting/duplicating tracked files, which spec §8 forbids.
80fn tracked_files(git: &dyn GitCli, source: &Path) -> Result<HashSet<PathBuf>> {
81    let output = git.run(source, &["ls-files", "-z"])?;
82    Ok(output
83        .split('\0')
84        .filter(|s| !s.is_empty())
85        .map(PathBuf::from)
86        .collect())
87}
88
89/// Recursively lists files under `root` (relative paths), skipping the `.git`
90/// directory.
91fn walk_files(root: &Path) -> Vec<PathBuf> {
92    let mut files = Vec::new();
93    walk_into(root, Path::new(""), &mut files);
94    files
95}
96
97/// Recursive helper for [`walk_files`].
98fn walk_into(base: &Path, rel: &Path, out: &mut Vec<PathBuf>) {
99    let dir = base.join(rel);
100    let Ok(entries) = std::fs::read_dir(&dir) else {
101        return;
102    };
103    for entry in entries.flatten() {
104        let name = entry.file_name();
105        if name == ".git" {
106            continue;
107        }
108        let child_rel = rel.join(&name);
109        match entry.file_type() {
110            Ok(ft) if ft.is_dir() => walk_into(base, &child_rel, out),
111            Ok(ft) if ft.is_file() => out.push(child_rel),
112            _ => {}
113        }
114    }
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120    use crate::git::cli::RealGit;
121    use crate::testutil::TestRepo;
122
123    #[test]
124    fn copies_ignored_files_skipping_tracked_and_existing() {
125        let repo = TestRepo::init();
126        // Tracked file matching a pattern is skipped.
127        repo.write("config.local", "tracked\n");
128        repo.commit_all("add tracked local");
129        // Untracked ignored files to copy.
130        repo.write(".env", "SECRET=1\n");
131        repo.write(".config/settings", "x\n");
132        repo.write("keep.local", "local\n");
133
134        let target = repo.root().parent().unwrap().join("target");
135        std::fs::create_dir_all(&target).unwrap();
136        // Pre-existing target file must not be overwritten.
137        std::fs::write(target.join(".env"), "EXISTING\n").unwrap();
138
139        let patterns = vec![
140            ".env".to_string(),
141            "*.local".to_string(),
142            ".config/**".to_string(),
143        ];
144        let outcome = copy_ignored_files(&RealGit, repo.root(), &target, &patterns).unwrap();
145
146        // .env exists in target -> skipped; keep.local + .config/settings copied;
147        // config.local is tracked -> not copied.
148        assert!(outcome.skipped_existing.contains(&PathBuf::from(".env")));
149        assert!(outcome.copied.contains(&PathBuf::from("keep.local")));
150        assert!(outcome.copied.contains(&PathBuf::from(".config/settings")));
151        assert!(!outcome.copied.contains(&PathBuf::from("config.local")));
152
153        // The pre-existing target was preserved.
154        assert_eq!(
155            std::fs::read_to_string(target.join(".env")).unwrap(),
156            "EXISTING\n"
157        );
158        assert_eq!(
159            std::fs::read_to_string(target.join(".config/settings")).unwrap(),
160            "x\n"
161        );
162    }
163
164    #[test]
165    fn empty_patterns_copy_nothing() {
166        let repo = TestRepo::init();
167        repo.write(".env", "x\n");
168        let target = repo.root().parent().unwrap().join("t2");
169        std::fs::create_dir_all(&target).unwrap();
170        let outcome = copy_ignored_files(&RealGit, repo.root(), &target, &[]).unwrap();
171        assert!(outcome.copied.is_empty());
172        assert!(!target.join(".env").exists());
173    }
174
175    #[test]
176    fn invalid_glob_is_config_error() {
177        let repo = TestRepo::init();
178        let target = repo.root().parent().unwrap().join("t3");
179        let err =
180            copy_ignored_files(&RealGit, repo.root(), &target, &["[".to_string()]).unwrap_err();
181        assert!(matches!(err, Error::Config { .. }));
182    }
183
184    #[test]
185    fn walk_skips_git_directory() {
186        let repo = TestRepo::init();
187        let files = walk_files(repo.root());
188        assert!(files.iter().all(|p| !p.starts_with(".git")));
189        assert!(files.contains(&PathBuf::from("README.md")));
190    }
191
192    #[test]
193    fn ls_files_failure_is_propagated_not_silent() {
194        use crate::git::cli::{GitCli, GitOutput};
195        // A git that fails `ls-files` must abort the copy (so tracked files are
196        // never copied), not silently treat the tracked set as empty (spec §8).
197        struct FailLs;
198        impl GitCli for FailLs {
199            fn run_raw(&self, _repo: &Path, args: &[&str]) -> Result<GitOutput> {
200                if args.first() == Some(&"ls-files") {
201                    return Ok(GitOutput {
202                        success: false,
203                        stdout: String::new(),
204                        stderr: "boom".into(),
205                    });
206                }
207                Ok(GitOutput {
208                    success: true,
209                    stdout: String::new(),
210                    stderr: String::new(),
211                })
212            }
213        }
214        let repo = TestRepo::init();
215        repo.write(".env", "x\n");
216        let target = repo.root().parent().unwrap().join("tfail");
217        std::fs::create_dir_all(&target).unwrap();
218        let err =
219            copy_ignored_files(&FailLs, repo.root(), &target, &[".env".to_string()]).unwrap_err();
220        assert!(matches!(err, Error::Subprocess { .. }));
221    }
222}