1use 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#[derive(Debug, Clone, Default, PartialEq, Eq)]
18pub struct CopyOutcome {
19 pub copied: Vec<PathBuf>,
21 pub skipped_existing: Vec<PathBuf>,
23}
24
25pub 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
58fn 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
77fn 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
89fn walk_files(root: &Path) -> Vec<PathBuf> {
92 let mut files = Vec::new();
93 walk_into(root, Path::new(""), &mut files);
94 files
95}
96
97fn 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 repo.write("config.local", "tracked\n");
128 repo.commit_all("add tracked local");
129 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 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 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 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 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}