use std::collections::HashSet;
use std::fs;
use std::io::Write as _;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use glob::Pattern;
use walkdir::WalkDir;
use crate::config::CopyIgnoredFilesConfig;
use crate::error::Result;
fn copy_dir_recursive(source: &Path, target: &Path) -> std::io::Result<u64> {
fs::create_dir_all(target)?;
let mut count = 0;
for entry in fs::read_dir(source)? {
let entry = entry?;
let source_path = entry.path();
let target_path = target.join(entry.file_name());
if source_path.is_symlink() {
continue;
}
if source_path.is_dir() {
count += copy_dir_recursive(&source_path, &target_path)?;
} else if source_path.is_file() {
fs::copy(&source_path, &target_path)?;
count += 1;
}
}
Ok(count)
}
fn get_gitignored_files(worktree: &Path) -> Result<HashSet<PathBuf>> {
let mut ignored_files = HashSet::new();
let all_paths: Vec<PathBuf> = WalkDir::new(worktree)
.into_iter()
.filter_map(|entry_result| match entry_result {
Ok(entry) => Some(entry),
Err(err) => {
eprintln!("Warning: Error walking directory: {}", err);
None
}
})
.filter(|e| e.path() != worktree) .filter(|e| !e.path().to_string_lossy().contains("/.git/")) .filter(|e| e.file_type().is_file() || e.file_type().is_dir())
.map(|e| e.path().to_path_buf())
.collect();
if all_paths.is_empty() {
return Ok(ignored_files);
}
let mut child = Command::new("git")
.args([
"-C",
&worktree.to_string_lossy(),
"check-ignore",
"--stdin",
"-z",
])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?;
if let Some(mut stdin) = child.stdin.take() {
for path in &all_paths {
if let Ok(relative) = path.strip_prefix(worktree) {
if let Err(e) = stdin.write_all(relative.to_string_lossy().as_bytes()) {
eprintln!("Warning: Failed to write path to git check-ignore: {}", e);
continue;
}
if let Err(e) = stdin.write_all(b"\0") {
eprintln!(
"Warning: Failed to write null byte to git check-ignore: {}",
e
);
continue; }
}
}
}
let output = child.wait_with_output()?;
if !output.status.success() {
let exit_code = output.status.code();
if exit_code != Some(1) {
if exit_code == Some(128) {
return Ok(ignored_files);
}
let stderr = String::from_utf8_lossy(&output.stderr);
if !stderr.is_empty() {
eprintln!("Warning: git check-ignore stderr: {}", stderr.trim());
}
if exit_code.is_some() && exit_code != Some(0) && exit_code != Some(1) {
return Err(crate::error::GwmError::git_command(format!(
"git check-ignore failed with exit code {:?}: {}",
exit_code,
stderr.trim()
)));
}
}
}
let stdout = String::from_utf8_lossy(&output.stdout);
for ignored_path in stdout.split('\0') {
if !ignored_path.is_empty() {
ignored_files.insert(worktree.join(ignored_path));
}
}
Ok(ignored_files)
}
#[derive(Debug, Default)]
pub struct CopyResult {
pub copied: Vec<String>,
pub skipped: Vec<String>,
pub existing: Vec<String>,
pub failed: Vec<(String, String)>,
}
impl CopyResult {
pub fn has_copied(&self) -> bool {
!self.copied.is_empty()
}
pub fn has_failed(&self) -> bool {
!self.failed.is_empty()
}
pub fn summary(&self) -> String {
let mut parts = Vec::new();
if !self.copied.is_empty() {
parts.push(format!(
"Copied {} file(s): {}",
self.copied.len(),
self.copied.join(", ")
));
}
if !self.failed.is_empty() {
let failed_names: Vec<_> = self.failed.iter().map(|(name, _)| name.as_str()).collect();
parts.push(format!(
"Failed {} file(s): {}",
self.failed.len(),
failed_names.join(", ")
));
}
if parts.is_empty() {
"No files copied".to_string()
} else {
parts.join("; ")
}
}
}
pub fn copy_ignored_files(
source_worktree: &Path,
target_worktree: &Path,
config: &CopyIgnoredFilesConfig,
) -> Result<CopyResult> {
if !config.enabled {
return Ok(CopyResult::default());
}
let mut result = CopyResult::default();
let exclude_patterns: Vec<Pattern> = config
.exclude_patterns
.iter()
.filter_map(|p| match Pattern::new(p) {
Ok(pattern) => Some(pattern),
Err(e) => {
eprintln!("Warning: Invalid exclude pattern '{}': {}", p, e);
None
}
})
.collect();
let mut matched_paths: HashSet<PathBuf> = HashSet::new();
if config.patterns.is_empty() {
matched_paths = get_gitignored_files(source_worktree)?;
} else {
for pattern in &config.patterns {
let full_pattern = source_worktree.join(pattern);
let pattern_str = full_pattern.to_string_lossy();
match glob::glob(&pattern_str) {
Ok(paths) => {
for entry in paths.flatten() {
matched_paths.insert(entry);
}
}
Err(e) => {
eprintln!("Warning: Invalid glob pattern '{}': {}", pattern, e);
}
}
}
}
for source_path in matched_paths {
let relative_path = match source_path.strip_prefix(source_worktree) {
Ok(p) => p,
Err(e) => {
eprintln!(
"Warning: Failed to get relative path for '{}': {}",
source_path.display(),
e
);
continue;
}
};
let relative_str = relative_path.to_string_lossy().to_string();
let matches_exclude = exclude_patterns.iter().any(|p| p.matches(&relative_str));
if matches_exclude {
result.skipped.push(relative_str);
continue;
}
let target_path = target_worktree.join(relative_path);
if target_path.exists() {
result.existing.push(relative_str);
continue;
}
if let Some(parent) = target_path.parent() {
if !parent.exists() {
if let Err(e) = fs::create_dir_all(parent) {
result.failed.push((relative_str, e.to_string()));
continue;
}
}
}
if source_path.is_dir() {
match copy_dir_recursive(&source_path, &target_path) {
Ok(count) => {
result
.copied
.push(format!("{}/ ({} files)", relative_str, count));
}
Err(e) => {
result.failed.push((relative_str, e.to_string()));
}
}
} else if source_path.is_file() {
match fs::copy(&source_path, &target_path) {
Ok(_) => result.copied.push(relative_str),
Err(e) => result.failed.push((relative_str, e.to_string())),
}
}
}
Ok(result)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs::File;
use std::io::Write;
use std::process::Command;
use tempfile::TempDir;
fn create_test_config() -> CopyIgnoredFilesConfig {
CopyIgnoredFilesConfig {
enabled: true,
patterns: vec![".env".to_string(), ".env.*".to_string()],
exclude_patterns: vec![".env.example".to_string(), ".env.sample".to_string()],
}
}
fn init_git_repo(path: &Path) {
Command::new("git")
.args(["init"])
.current_dir(path)
.output()
.expect("Failed to init git repo");
Command::new("git")
.args(["config", "user.email", "test@example.com"])
.current_dir(path)
.output()
.ok();
Command::new("git")
.args(["config", "user.name", "Test User"])
.current_dir(path)
.output()
.ok();
}
fn create_gitignore(path: &Path, patterns: &[&str]) {
let gitignore_path = path.join(".gitignore");
let mut file = File::create(gitignore_path).expect("Failed to create .gitignore");
for pattern in patterns {
writeln!(file, "{}", pattern).expect("Failed to write .gitignore");
}
}
#[test]
fn test_copy_env_file() {
let source = TempDir::new().unwrap();
let target = TempDir::new().unwrap();
let env_path = source.path().join(".env");
let mut file = File::create(env_path).unwrap();
writeln!(file, "API_KEY=secret").unwrap();
let config = create_test_config();
let result = copy_ignored_files(source.path(), target.path(), &config).unwrap();
assert_eq!(result.copied, vec![".env"]);
assert!(target.path().join(".env").exists());
let content = fs::read_to_string(target.path().join(".env")).unwrap();
assert!(content.contains("API_KEY=secret"));
}
#[test]
fn test_skip_excluded_files() {
let source = TempDir::new().unwrap();
let target = TempDir::new().unwrap();
File::create(source.path().join(".env.example")).unwrap();
let config = create_test_config();
let result = copy_ignored_files(source.path(), target.path(), &config).unwrap();
assert!(result.copied.is_empty());
assert_eq!(result.skipped, vec![".env.example"]);
assert!(!target.path().join(".env.example").exists());
}
#[test]
fn test_skip_existing_files() {
let source = TempDir::new().unwrap();
let target = TempDir::new().unwrap();
File::create(source.path().join(".env")).unwrap();
File::create(target.path().join(".env")).unwrap();
let config = create_test_config();
let result = copy_ignored_files(source.path(), target.path(), &config).unwrap();
assert!(result.copied.is_empty());
assert_eq!(result.existing, vec![".env"]);
}
#[test]
fn test_disabled_config() {
let source = TempDir::new().unwrap();
let target = TempDir::new().unwrap();
File::create(source.path().join(".env")).unwrap();
let config = CopyIgnoredFilesConfig {
enabled: false,
patterns: vec![".env".to_string()],
exclude_patterns: vec![],
};
let result = copy_ignored_files(source.path(), target.path(), &config).unwrap();
assert!(result.copied.is_empty());
assert!(!target.path().join(".env").exists());
}
#[test]
fn test_multiple_env_files() {
let source = TempDir::new().unwrap();
let target = TempDir::new().unwrap();
File::create(source.path().join(".env")).unwrap();
File::create(source.path().join(".env.local")).unwrap();
File::create(source.path().join(".env.development")).unwrap();
File::create(source.path().join(".env.example")).unwrap();
let config = create_test_config();
let result = copy_ignored_files(source.path(), target.path(), &config).unwrap();
assert_eq!(result.copied.len(), 3);
assert!(result.copied.contains(&".env".to_string()));
assert!(result.copied.contains(&".env.local".to_string()));
assert!(result.copied.contains(&".env.development".to_string()));
assert_eq!(result.skipped, vec![".env.example"]);
}
#[test]
fn test_copy_result_summary() {
let mut result = CopyResult::default();
assert_eq!(result.summary(), "No files copied");
result.copied = vec![".env".to_string(), ".env.local".to_string()];
assert_eq!(result.summary(), "Copied 2 file(s): .env, .env.local");
}
#[test]
fn test_exclude_patterns_only() {
let source = TempDir::new().unwrap();
let target = TempDir::new().unwrap();
init_git_repo(source.path());
create_gitignore(source.path(), &[".env*", "config.json"]);
File::create(source.path().join(".env")).unwrap();
File::create(source.path().join(".env.local")).unwrap();
File::create(source.path().join(".env.example")).unwrap();
File::create(source.path().join("config.json")).unwrap();
File::create(source.path().join("tracked.txt")).unwrap();
let config = CopyIgnoredFilesConfig {
enabled: true,
patterns: vec![], exclude_patterns: vec![".env.example".to_string()],
};
let result = copy_ignored_files(source.path(), target.path(), &config).unwrap();
assert!(result.copied.contains(&".env".to_string()));
assert!(result.copied.contains(&".env.local".to_string()));
assert!(result.copied.contains(&"config.json".to_string()));
assert!(result.skipped.contains(&".env.example".to_string()));
assert!(!result.copied.contains(&"tracked.txt".to_string()));
}
#[test]
fn test_empty_patterns_and_exclude_patterns() {
let source = TempDir::new().unwrap();
let target = TempDir::new().unwrap();
init_git_repo(source.path());
create_gitignore(source.path(), &[".env"]);
File::create(source.path().join(".env")).unwrap();
File::create(source.path().join("config.json")).unwrap();
let config = CopyIgnoredFilesConfig {
enabled: true,
patterns: vec![],
exclude_patterns: vec![],
};
let result = copy_ignored_files(source.path(), target.path(), &config).unwrap();
assert!(result.copied.contains(&".env".to_string()));
assert!(!result.copied.contains(&"config.json".to_string()));
}
#[test]
fn test_copy_directory() {
let source = TempDir::new().unwrap();
let target = TempDir::new().unwrap();
let docs_dir = source.path().join(".local_docs");
fs::create_dir(&docs_dir).unwrap();
let mut file = File::create(docs_dir.join("note.md")).unwrap();
writeln!(file, "# Test Note").unwrap();
let sub_dir = docs_dir.join("sub");
fs::create_dir(&sub_dir).unwrap();
File::create(sub_dir.join("nested.txt")).unwrap();
let config = CopyIgnoredFilesConfig {
enabled: true,
patterns: vec![".local_docs".to_string()],
exclude_patterns: vec![],
};
let result = copy_ignored_files(source.path(), target.path(), &config).unwrap();
assert_eq!(result.copied.len(), 1);
assert!(result.copied[0].starts_with(".local_docs/"));
assert!(result.copied[0].contains("2 files"));
assert!(target.path().join(".local_docs").exists());
assert!(target.path().join(".local_docs").is_dir());
assert!(target.path().join(".local_docs/note.md").exists());
assert!(target.path().join(".local_docs/sub/nested.txt").exists());
let content = fs::read_to_string(target.path().join(".local_docs/note.md")).unwrap();
assert!(content.contains("# Test Note"));
}
#[test]
fn test_skip_existing_directory() {
let source = TempDir::new().unwrap();
let target = TempDir::new().unwrap();
let docs_dir = source.path().join(".local_docs");
fs::create_dir(&docs_dir).unwrap();
File::create(docs_dir.join("note.md")).unwrap();
fs::create_dir(target.path().join(".local_docs")).unwrap();
let config = CopyIgnoredFilesConfig {
enabled: true,
patterns: vec![".local_docs".to_string()],
exclude_patterns: vec![],
};
let result = copy_ignored_files(source.path(), target.path(), &config).unwrap();
assert!(result.copied.is_empty());
assert_eq!(result.existing, vec![".local_docs"]);
}
#[test]
fn test_copy_files_and_directories() {
let source = TempDir::new().unwrap();
let target = TempDir::new().unwrap();
File::create(source.path().join(".env")).unwrap();
let docs_dir = source.path().join(".local_docs");
fs::create_dir(&docs_dir).unwrap();
File::create(docs_dir.join("note.md")).unwrap();
let config = CopyIgnoredFilesConfig {
enabled: true,
patterns: vec![".env".to_string(), ".local_docs".to_string()],
exclude_patterns: vec![],
};
let result = copy_ignored_files(source.path(), target.path(), &config).unwrap();
assert_eq!(result.copied.len(), 2);
assert!(result.copied.contains(&".env".to_string()));
assert!(result.copied.iter().any(|s| s.starts_with(".local_docs/")));
assert!(target.path().join(".env").exists());
assert!(target.path().join(".local_docs/note.md").exists());
}
#[test]
fn test_copy_subdirectory_path_pattern() {
let source = TempDir::new().unwrap();
let target = TempDir::new().unwrap();
let subdir = source.path().join("subproject");
fs::create_dir(&subdir).unwrap();
let docs_dir = subdir.join(".local_docs");
fs::create_dir(&docs_dir).unwrap();
let mut file = File::create(docs_dir.join("note.md")).unwrap();
writeln!(file, "# Nested Note").unwrap();
let config = CopyIgnoredFilesConfig {
enabled: true,
patterns: vec!["subproject/.local_docs".to_string()],
exclude_patterns: vec![],
};
let result = copy_ignored_files(source.path(), target.path(), &config).unwrap();
assert_eq!(result.copied.len(), 1);
assert!(result.copied[0].contains(".local_docs"));
assert!(target.path().join("subproject/.local_docs").exists());
assert!(target
.path()
.join("subproject/.local_docs/note.md")
.exists());
let content =
fs::read_to_string(target.path().join("subproject/.local_docs/note.md")).unwrap();
assert!(content.contains("# Nested Note"));
}
#[test]
fn test_copy_with_glob_pattern() {
let source = TempDir::new().unwrap();
let target = TempDir::new().unwrap();
let dir1 = source.path().join("dir1");
fs::create_dir(&dir1).unwrap();
File::create(dir1.join(".env")).unwrap();
let dir2 = source.path().join("dir2");
fs::create_dir(&dir2).unwrap();
File::create(dir2.join(".env")).unwrap();
File::create(source.path().join(".env")).unwrap();
let config = CopyIgnoredFilesConfig {
enabled: true,
patterns: vec!["**/.env".to_string()],
exclude_patterns: vec![],
};
let result = copy_ignored_files(source.path(), target.path(), &config).unwrap();
assert!(result.copied.len() >= 2);
assert!(target.path().join("dir1/.env").exists());
assert!(target.path().join("dir2/.env").exists());
}
#[test]
fn test_patterns_empty_copies_nested_gitignored_files() {
let source = TempDir::new().unwrap();
let target = TempDir::new().unwrap();
init_git_repo(source.path());
let subdir = source.path().join("config");
fs::create_dir(&subdir).unwrap();
create_gitignore(source.path(), &[".env*"]);
File::create(source.path().join(".env")).unwrap();
File::create(subdir.join(".env.local")).unwrap();
let config = CopyIgnoredFilesConfig {
enabled: true,
patterns: vec![], exclude_patterns: vec![],
};
let result = copy_ignored_files(source.path(), target.path(), &config).unwrap();
assert!(result.copied.contains(&".env".to_string()));
assert!(result.copied.contains(&"config/.env.local".to_string()));
}
#[test]
fn test_patterns_empty_in_non_git_repo() {
let source = TempDir::new().unwrap();
let target = TempDir::new().unwrap();
File::create(source.path().join(".env")).unwrap();
File::create(source.path().join("config.json")).unwrap();
let config = CopyIgnoredFilesConfig {
enabled: true,
patterns: vec![], exclude_patterns: vec![],
};
let result = copy_ignored_files(source.path(), target.path(), &config).unwrap();
assert!(result.copied.is_empty());
}
}