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() => {
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
122fn 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 repo.write("config.local", "tracked\n");
146 repo.commit_all("add tracked local");
147 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 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 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 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 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 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}