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 and any nested repository.
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() => {
111                if is_nested_repo(&base.join(&child_rel)) {
112                    continue;
113                }
114                walk_into(base, &child_rel, out)
115            }
116            Ok(ft) if ft.is_file() => out.push(child_rel),
117            _ => {}
118        }
119    }
120}
121
122/// Whether `dir` is the working tree of a nested repository — a submodule, or
123/// any repo that happens to sit inside this one.
124///
125/// A populated submodule holds a `.git` *file* (a gitlink), not a directory, so
126/// the top-level `.git` skip does not stop the walk from descending into it. Its
127/// contents belong to the submodule and are tracked there, meaning `ls-files` on
128/// the superproject does not list them and every one of them would look like an
129/// untracked candidate to copy. On a repo with many populated submodules that is
130/// both a large pointless walk and a source of wrong copies.
131fn is_nested_repo(dir: &Path) -> bool {
132    dir.join(".git").exists()
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138    use crate::git::cli::RealGit;
139    use crate::testutil::TestRepo;
140
141    #[test]
142    fn copies_ignored_files_skipping_tracked_and_existing() {
143        let repo = TestRepo::init();
144        // Tracked file matching a pattern is skipped.
145        repo.write("config.local", "tracked\n");
146        repo.commit_all("add tracked local");
147        // Untracked ignored files to copy.
148        repo.write(".env", "SECRET=1\n");
149        repo.write(".config/settings", "x\n");
150        repo.write("keep.local", "local\n");
151
152        let target = repo.root().parent().unwrap().join("target");
153        std::fs::create_dir_all(&target).unwrap();
154        // Pre-existing target file must not be overwritten.
155        std::fs::write(target.join(".env"), "EXISTING\n").unwrap();
156
157        let patterns = vec![
158            ".env".to_string(),
159            "*.local".to_string(),
160            ".config/**".to_string(),
161        ];
162        let outcome = copy_ignored_files(&RealGit, repo.root(), &target, &patterns).unwrap();
163
164        // .env exists in target -> skipped; keep.local + .config/settings copied;
165        // config.local is tracked -> not copied.
166        assert!(outcome.skipped_existing.contains(&PathBuf::from(".env")));
167        assert!(outcome.copied.contains(&PathBuf::from("keep.local")));
168        assert!(outcome.copied.contains(&PathBuf::from(".config/settings")));
169        assert!(!outcome.copied.contains(&PathBuf::from("config.local")));
170
171        // The pre-existing target was preserved.
172        assert_eq!(
173            std::fs::read_to_string(target.join(".env")).unwrap(),
174            "EXISTING\n"
175        );
176        assert_eq!(
177            std::fs::read_to_string(target.join(".config/settings")).unwrap(),
178            "x\n"
179        );
180    }
181
182    #[test]
183    fn empty_patterns_copy_nothing() {
184        let repo = TestRepo::init();
185        repo.write(".env", "x\n");
186        let target = repo.root().parent().unwrap().join("t2");
187        std::fs::create_dir_all(&target).unwrap();
188        let outcome = copy_ignored_files(&RealGit, repo.root(), &target, &[]).unwrap();
189        assert!(outcome.copied.is_empty());
190        assert!(!target.join(".env").exists());
191    }
192
193    #[test]
194    fn invalid_glob_is_config_error() {
195        let repo = TestRepo::init();
196        let target = repo.root().parent().unwrap().join("t3");
197        let err =
198            copy_ignored_files(&RealGit, repo.root(), &target, &["[".to_string()]).unwrap_err();
199        assert!(matches!(err, Error::Config { .. }));
200    }
201
202    #[test]
203    fn walk_skips_git_directory() {
204        let repo = TestRepo::init();
205        let files = walk_files(repo.root());
206        assert!(files.iter().all(|p| !p.starts_with(".git")));
207        assert!(files.contains(&PathBuf::from("README.md")));
208    }
209
210    #[test]
211    fn ls_files_failure_is_propagated_not_silent() {
212        use crate::git::cli::{GitCli, GitOutput};
213        // A git that fails `ls-files` must abort the copy (so tracked files are
214        // never copied), not silently treat the tracked set as empty (spec §8).
215        struct FailLs;
216        impl GitCli for FailLs {
217            fn run_raw(&self, _repo: &Path, args: &[&str]) -> Result<GitOutput> {
218                if args.first() == Some(&"ls-files") {
219                    return Ok(GitOutput {
220                        success: false,
221                        stdout: String::new(),
222                        stderr: "boom".into(),
223                    });
224                }
225                Ok(GitOutput {
226                    success: true,
227                    stdout: String::new(),
228                    stderr: String::new(),
229                })
230            }
231        }
232        let repo = TestRepo::init();
233        repo.write(".env", "x\n");
234        let target = repo.root().parent().unwrap().join("tfail");
235        std::fs::create_dir_all(&target).unwrap();
236        let err =
237            copy_ignored_files(&FailLs, repo.root(), &target, &[".env".to_string()]).unwrap_err();
238        assert!(matches!(err, Error::Subprocess { .. }));
239    }
240
241    #[test]
242    fn does_not_copy_out_of_a_populated_submodule() {
243        let repo = TestRepo::init();
244        repo.add_submodule("libs/sub");
245        // A file inside the submodule that matches the copy pattern. It is
246        // untracked *in the superproject* (the submodule owns that subtree), so
247        // without the nested-repo skip it looks like a copy candidate.
248        repo.write("libs/sub/.env", "SUBMODULE=1\n");
249        repo.write(".env", "TOP=1\n");
250
251        let target = repo.root().parent().unwrap().join("nested-target");
252        std::fs::create_dir_all(&target).unwrap();
253        let outcome =
254            copy_ignored_files(&RealGit, repo.root(), &target, &["**/.env".to_string()]).unwrap();
255
256        assert!(outcome.copied.contains(&PathBuf::from(".env")));
257        assert!(
258            !outcome
259                .copied
260                .contains(&PathBuf::from("libs/sub/.env".to_string())),
261            "walked into a submodule: {:?}",
262            outcome.copied
263        );
264        assert!(!target.join("libs/sub/.env").exists());
265    }
266}