Glob copy files (#1420)

* Simplify glob file search

Switch to globwalk

* Cleanup

* Fix single file continue, add timeout to copy files

* Remove error in copy file dropdown when no match found

* Remove error message for file search text are, remove dead code

* Move copy logic to copy.rs
This commit is contained in:
Alex Netsch
2025-12-04 15:36:14 +00:00
committed by GitHub
parent 9bfaa6dde5
commit ef1ba1b4bb
13 changed files with 380 additions and 241 deletions

View File

@@ -28,3 +28,7 @@ sentry = { version = "0.41.0", features = ["anyhow", "backtrace", "panic", "debu
futures = "0.3"
json-patch = "2.0"
tokio = { workspace = true }
globwalk = "0.9"
[dev-dependencies]
tempfile = "3.8"

View File

@@ -64,7 +64,7 @@ use utils::{
};
use uuid::Uuid;
use crate::command;
use crate::{command, copy};
#[derive(Clone)]
pub struct LocalContainerService {
@@ -1196,40 +1196,19 @@ impl ContainerService for LocalContainerService {
target_dir: &Path,
copy_files: &str,
) -> Result<(), ContainerError> {
let files: Vec<&str> = copy_files
.split(',')
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.collect();
let source_dir = source_dir.to_path_buf();
let target_dir = target_dir.to_path_buf();
let copy_files = copy_files.to_string();
for file_path in files {
let source_file = source_dir.join(file_path);
let target_file = target_dir.join(file_path);
// Create parent directories if needed
if let Some(parent) = target_file.parent()
&& !parent.exists()
{
std::fs::create_dir_all(parent).map_err(|e| {
ContainerError::Other(anyhow!("Failed to create directory {parent:?}: {e}"))
})?;
}
// Copy the file
if source_file.exists() {
std::fs::copy(&source_file, &target_file).map_err(|e| {
ContainerError::Other(anyhow!(
"Failed to copy file {source_file:?} to {target_file:?}: {e}"
))
})?;
tracing::info!("Copied file {:?} to worktree", file_path);
} else {
return Err(ContainerError::Other(anyhow!(
"File {source_file:?} does not exist in the project directory"
)));
}
}
Ok(())
tokio::time::timeout(
std::time::Duration::from_secs(30),
tokio::task::spawn_blocking(move || {
copy::copy_project_files_impl(&source_dir, &target_dir, &copy_files)
}),
)
.await
.map_err(|_| ContainerError::Other(anyhow!("Copy project files timed out after 30s")))?
.map_err(|e| ContainerError::Other(anyhow!("Copy files task failed: {e}")))?
}
async fn kill_all_running_processes(&self) -> Result<(), ContainerError> {
@@ -1252,7 +1231,6 @@ impl ContainerService for LocalContainerService {
Ok(())
}
}
fn success_exit_status() -> std::process::ExitStatus {
#[cfg(unix)]
{

View File

@@ -0,0 +1,326 @@
use std::{
collections::HashSet,
fs,
path::{Path, PathBuf},
};
use anyhow::anyhow;
use globwalk::GlobWalkerBuilder;
use services::services::container::ContainerError;
/// Normalize pattern for cross-platform glob matching (convert backslashes to forward slashes)
fn normalize_pattern(pattern: &str) -> String {
pattern.replace('\\', "/")
}
pub(crate) fn copy_project_files_impl(
source_dir: &Path,
target_dir: &Path,
copy_files: &str,
) -> Result<(), ContainerError> {
let patterns: Vec<&str> = copy_files
.split(',')
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.collect();
// Track files to avoid duplicates
let mut seen = HashSet::new();
for pattern in patterns {
let pattern = normalize_pattern(pattern);
let pattern_path = source_dir.join(&pattern);
if pattern_path.is_file() {
if let Err(e) = copy_single_file(&pattern_path, source_dir, target_dir, &mut seen) {
tracing::warn!(
"Failed to copy file {} (from {}): {}",
pattern,
pattern_path.display(),
e
);
}
continue;
}
let glob_pattern = if pattern_path.is_dir() {
// For directories, append /** to match all contents recursively
format!("{pattern}/**")
} else {
pattern.clone()
};
let walker = match GlobWalkerBuilder::from_patterns(source_dir, &[&glob_pattern])
.file_type(globwalk::FileType::FILE)
.build()
{
Ok(w) => w,
Err(e) => {
tracing::warn!("Invalid glob pattern '{glob_pattern}': {e}");
continue;
}
};
let mut had_matches = false;
for entry in walker.flatten() {
had_matches = true;
if let Err(e) = copy_single_file(entry.path(), source_dir, target_dir, &mut seen) {
tracing::warn!("Failed to copy file {:?}: {e}", entry.path());
}
}
if !had_matches {
tracing::info!("No files matched pattern: {pattern}");
}
}
Ok(())
}
fn copy_single_file(
source_file: &Path,
source_root: &Path,
target_root: &Path,
seen: &mut HashSet<PathBuf>,
) -> Result<bool, ContainerError> {
let canonical_source = source_root.canonicalize()?;
let canonical_file = source_file.canonicalize()?;
// Validate path is within source_dir
if !canonical_file.starts_with(canonical_source) {
return Err(ContainerError::Other(anyhow!(
"File {source_file:?} is outside project directory"
)));
}
if !seen.insert(canonical_file.clone()) {
return Ok(false);
}
let relative_path = source_file.strip_prefix(source_root).map_err(|e| {
ContainerError::Other(anyhow!(
"Failed to get relative path for {source_file:?}: {e}"
))
})?;
let target_file = target_root.join(relative_path);
if let Some(parent) = target_file.parent()
&& !parent.exists()
{
fs::create_dir_all(parent)?;
}
fs::copy(source_file, &target_file)?;
Ok(true)
}
#[cfg(test)]
mod tests {
use std::fs;
use tempfile::TempDir;
use super::*;
#[test]
fn test_copy_project_files_mixed_patterns() {
let source_dir = TempDir::new().unwrap();
let target_dir = TempDir::new().unwrap();
fs::write(source_dir.path().join(".env"), "secret").unwrap();
fs::write(source_dir.path().join("config.json"), "{}").unwrap();
let src_dir = source_dir.path().join("src");
fs::create_dir(&src_dir).unwrap();
fs::write(src_dir.join("main.rs"), "code").unwrap();
fs::write(src_dir.join("lib.rs"), "lib").unwrap();
let config_dir = source_dir.path().join("config");
fs::create_dir(&config_dir).unwrap();
fs::write(config_dir.join("app.toml"), "config").unwrap();
copy_project_files_impl(
source_dir.path(),
target_dir.path(),
".env, *.json, src, config",
)
.unwrap();
assert!(target_dir.path().join(".env").exists());
assert!(target_dir.path().join("config.json").exists());
assert!(target_dir.path().join("src/main.rs").exists());
assert!(target_dir.path().join("src/lib.rs").exists());
assert!(target_dir.path().join("config/app.toml").exists());
}
#[test]
fn test_copy_project_files_nonexistent_pattern_ok() {
let source_dir = TempDir::new().unwrap();
let target_dir = TempDir::new().unwrap();
let result =
copy_project_files_impl(source_dir.path(), target_dir.path(), "nonexistent.txt");
assert!(result.is_ok());
assert!(!target_dir.path().join("nonexistent.txt").exists());
}
#[test]
fn test_copy_project_files_empty_pattern_ok() {
let source_dir = TempDir::new().unwrap();
let target_dir = TempDir::new().unwrap();
let result = copy_project_files_impl(source_dir.path(), target_dir.path(), "");
assert!(result.is_ok());
assert_eq!(fs::read_dir(target_dir.path()).unwrap().count(), 0);
}
#[test]
fn test_copy_project_files_whitespace_handling() {
let source_dir = TempDir::new().unwrap();
let target_dir = TempDir::new().unwrap();
fs::write(source_dir.path().join("test.txt"), "content").unwrap();
copy_project_files_impl(source_dir.path(), target_dir.path(), " test.txt , ").unwrap();
assert!(target_dir.path().join("test.txt").exists());
}
#[test]
fn test_copy_project_files_nested_directory() {
let source_dir = TempDir::new().unwrap();
let target_dir = TempDir::new().unwrap();
let config_dir = source_dir.path().join("config");
fs::create_dir(&config_dir).unwrap();
fs::write(config_dir.join("app.json"), "{}").unwrap();
let nested_dir = config_dir.join("nested");
fs::create_dir(&nested_dir).unwrap();
fs::write(nested_dir.join("deep.txt"), "deep").unwrap();
copy_project_files_impl(source_dir.path(), target_dir.path(), "config").unwrap();
assert!(target_dir.path().join("config/app.json").exists());
assert!(target_dir.path().join("config/nested/deep.txt").exists());
}
#[test]
fn test_copy_project_files_outside_source_skips_without_copying() {
let source_dir = TempDir::new().unwrap();
let target_dir = TempDir::new().unwrap();
// Create file outside of source directory (one level up)
let parent_dir = source_dir.path().parent().unwrap().to_path_buf();
let outside_file = parent_dir.join("secret.txt");
fs::write(&outside_file, "secret").unwrap();
// Pattern referencing parent directory should resolve to outside_file and be rejected
let result = copy_project_files_impl(source_dir.path(), target_dir.path(), "../secret.txt");
assert!(result.is_ok());
assert_eq!(fs::read_dir(target_dir.path()).unwrap().count(), 0);
}
#[test]
fn test_copy_project_files_recursive_glob_extension_filter() {
let source_dir = TempDir::new().unwrap();
let target_dir = TempDir::new().unwrap();
// Create nested directory structure with YAML files
let config_dir = source_dir.path().join("config");
fs::create_dir(&config_dir).unwrap();
fs::write(config_dir.join("app.yml"), "app: config").unwrap();
fs::write(config_dir.join("db.json"), "{}").unwrap();
let nested_dir = config_dir.join("nested");
fs::create_dir(&nested_dir).unwrap();
fs::write(nested_dir.join("settings.yml"), "settings: value").unwrap();
fs::write(nested_dir.join("other.txt"), "text").unwrap();
let deep_dir = nested_dir.join("deep");
fs::create_dir(&deep_dir).unwrap();
fs::write(deep_dir.join("deep.yml"), "deep: config").unwrap();
// Copy all YAML files recursively
copy_project_files_impl(source_dir.path(), target_dir.path(), "config/**/*.yml").unwrap();
// Verify only YAML files are copied
assert!(target_dir.path().join("config/app.yml").exists());
assert!(
target_dir
.path()
.join("config/nested/settings.yml")
.exists()
);
assert!(
target_dir
.path()
.join("config/nested/deep/deep.yml")
.exists()
);
// Verify non-YAML files are not copied
assert!(!target_dir.path().join("config/db.json").exists());
assert!(!target_dir.path().join("config/nested/other.txt").exists());
}
#[test]
fn test_copy_project_files_duplicate_patterns_ok() {
let source_dir = TempDir::new().unwrap();
let target_dir = TempDir::new().unwrap();
// Create source files
let src_dir = source_dir.path().join("src");
fs::create_dir(&src_dir).unwrap();
fs::write(src_dir.join("lib.rs"), "lib code").unwrap();
fs::write(src_dir.join("main.rs"), "main code").unwrap();
// Copy with overlapping patterns: glob and specific file
copy_project_files_impl(source_dir.path(), target_dir.path(), "src/*.rs, src/lib.rs")
.unwrap();
// Verify file exists once (deduplication works)
let target_file = target_dir.path().join("src/lib.rs");
assert!(target_file.exists());
assert_eq!(fs::read_to_string(target_file).unwrap(), "lib code");
// Verify other file from glob is also copied
assert!(target_dir.path().join("src/main.rs").exists());
}
#[test]
fn test_copy_project_files_single_file_path() {
let source_dir = TempDir::new().unwrap();
let target_dir = TempDir::new().unwrap();
// Create source file
let src_dir = source_dir.path().join("src");
fs::create_dir(&src_dir).unwrap();
fs::write(src_dir.join("lib.rs"), "library code").unwrap();
// Copy single file by exact path (exercises fast path)
copy_project_files_impl(source_dir.path(), target_dir.path(), "src/lib.rs").unwrap();
// Verify file is copied
let target_file = target_dir.path().join("src/lib.rs");
assert!(target_file.exists());
assert_eq!(fs::read_to_string(target_file).unwrap(), "library code");
}
#[cfg(unix)]
#[test]
fn test_symlink_loop_is_skipped() {
use std::os::unix::fs::symlink;
let src = TempDir::new().unwrap();
let dst = TempDir::new().unwrap();
let loop_dir = src.path().join("loop");
std::fs::create_dir(&loop_dir).unwrap();
symlink(".", loop_dir.join("self")).unwrap(); // loop/self -> loop
copy_project_files_impl(src.path(), dst.path(), "loop").unwrap();
assert_eq!(std::fs::read_dir(dst.path()).unwrap().count(), 0);
}
}

View File

@@ -31,6 +31,7 @@ use uuid::Uuid;
use crate::container::LocalContainerService;
mod command;
pub mod container;
mod copy;
#[derive(Clone)]
pub struct LocalDeployment {