From f27c491a13b0c17cda6fe254251b6de9c4f42a65 Mon Sep 17 00:00:00 2001 From: "loongtao.zhang" Date: Mon, 7 Sep 2026 10:11:52 +0800 Subject: [PATCH 1/4] feat(remote): support .dockerignore when copying files to remote host Add DockerIgnore support using the glob crate to filter files and directories when copying project and mounted volumes to remote container data volumes. Also integrate with Fingerprint to ensure persistent volume incremental updates respect .dockerignore. --- Cargo.lock | 7 + Cargo.toml | 1 + src/docker/docker_ignore.rs | 368 ++++++++++++++++++++++++++++++++++++ src/docker/mod.rs | 2 + src/docker/remote.rs | 223 +++++++++++++++++++--- 5 files changed, 573 insertions(+), 28 deletions(-) create mode 100644 src/docker/docker_ignore.rs diff --git a/Cargo.lock b/Cargo.lock index 6efbd162e..828f5175c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -258,6 +258,7 @@ dependencies = [ "directories", "dunce", "eyre", + "glob", "home", "ignore", "is-terminal", @@ -426,6 +427,12 @@ version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + [[package]] name = "globset" version = "0.4.18" diff --git a/Cargo.toml b/Cargo.toml index 7878bdd0e..eebea44fe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -49,6 +49,7 @@ tempfile = "=3.24.0" owo-colors = { version = "4.2.2", features = ["supports-colors"] } semver = "1.0.26" is_ci = "1.2.0" +glob = "0.3" [target.'cfg(not(windows))'.dependencies] nix = { version = "0.30.1", default-features = false, features = ["user"] } diff --git a/src/docker/docker_ignore.rs b/src/docker/docker_ignore.rs new file mode 100644 index 000000000..8bf6551b0 --- /dev/null +++ b/src/docker/docker_ignore.rs @@ -0,0 +1,368 @@ +use std::fs; +use std::path::Path; +use std::str::FromStr; + +use eyre::Context; + +use crate::errors::Result; +use crate::file::PathExt; + +#[derive(Debug, Clone)] +pub struct DockerIgnoreRule { + pub pattern: glob::Pattern, + pub is_exception: bool, + pub only_dir: bool, +} + +#[derive(Debug, Clone, Default)] +pub struct DockerIgnore { + pub rules: Vec, +} + +impl DockerIgnore { + pub fn empty() -> Self { + Self { rules: Vec::new() } + } + + pub fn is_empty(&self) -> bool { + self.rules.is_empty() + } + + pub fn from_dir(dir: &Path) -> Result { + let ignore_file = dir.join(".dockerignore"); + if ignore_file.is_file() { + Self::from_path(&ignore_file) + } else { + Ok(Self::empty()) + } + } + + pub fn from_path(path: &Path) -> Result { + let content = fs::read_to_string(path) + .wrap_err_with(|| format!("when reading dockerignore file {path:?}"))?; + Self::parse(&content) + } + + pub fn parse(content: &str) -> Result { + let content = content.strip_prefix('\u{feff}').unwrap_or(content); + let mut rules = Vec::new(); + + for (line_idx, line) in content.lines().enumerate() { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + + let (is_exception, pattern_str) = if let Some(rest) = trimmed.strip_prefix('!') { + (true, rest.trim()) + } else { + (false, trimmed) + }; + + if pattern_str.is_empty() { + continue; + } + + let mut pat = pattern_str.replace('\\', "/"); + + while let Some(rest) = pat.strip_prefix("./") { + pat = rest.to_owned(); + } + + while let Some(rest) = pat.strip_prefix('/') { + pat = rest.to_owned(); + } + + while pat.contains("//") { + pat = pat.replace("//", "/"); + } + + if pat.is_empty() { + continue; + } + + let only_dir = pat.ends_with('/'); + while pat.ends_with('/') && pat.len() > 1 { + pat.pop(); + } + + if pat == "/" || pat.is_empty() { + continue; + } + + let compiled = glob::Pattern::new(&pat).wrap_err_with(|| { + format!( + "invalid pattern on line {} in .dockerignore: {:?}", + line_idx + 1, + pattern_str + ) + })?; + + rules.push(DockerIgnoreRule { + pattern: compiled, + is_exception, + only_dir, + }); + } + + Ok(Self { rules }) + } + + pub fn is_ignored(&self, path: &str, is_dir: bool) -> bool { + if self.rules.is_empty() { + return false; + } + + let path = path.replace('\\', "/"); + let path = path.trim_matches('/'); + if path.is_empty() || path == "." { + return false; + } + + let mut parent_dirs = Vec::new(); + let mut current = path; + while let Some((parent, _)) = current.rsplit_once('/') { + if !parent.is_empty() { + parent_dirs.push(parent); + current = parent; + } else { + break; + } + } + + let match_opts = glob::MatchOptions { + case_sensitive: true, + require_literal_separator: true, + require_literal_leading_dot: false, + }; + + let mut matched = false; + + for rule in &self.rules { + if rule.is_exception != matched { + continue; + } + + let mut is_match = false; + + if (!rule.only_dir || is_dir) && rule.pattern.matches_with(path, match_opts) { + is_match = true; + } + + if !is_match { + for parent in &parent_dirs { + if rule.pattern.matches_with(parent, match_opts) { + is_match = true; + break; + } + } + } + + if is_match { + matched = !rule.is_exception; + } + } + + matched + } + + pub fn is_dir_ignored(&self, dir_path: &str) -> bool { + if !self.is_ignored(dir_path, true) { + return false; + } + + let dir_path = dir_path.replace('\\', "/"); + let dir_path = dir_path.trim_matches('/'); + let dir_parts: Vec<&str> = dir_path.split('/').filter(|s| !s.is_empty()).collect(); + if dir_parts.is_empty() { + return false; + } + + let has_child_exception = self.rules.iter().any(|rule| { + if !rule.is_exception { + return false; + } + + let pat_str = rule.pattern.as_str(); + let pat_parts: Vec<&str> = pat_str.split('/').filter(|s| !s.is_empty()).collect(); + + let mut could_match_child = true; + for (i, dir_part) in dir_parts.iter().enumerate() { + if i >= pat_parts.len() { + could_match_child = false; + break; + } + if pat_parts[i] == "**" { + could_match_child = true; + break; + } + let matches = glob::Pattern::new(pat_parts[i]) + .map(|p| p.matches(dir_part)) + .unwrap_or(false); + if !matches { + could_match_child = false; + break; + } + } + + if could_match_child { + pat_parts.len() > dir_parts.len() || pat_parts.contains(&"**") + } else { + false + } + }); + + !has_child_exception + } + + pub fn is_path_ignored(&self, path: &Path, is_dir: bool) -> Result { + let posix = path.as_posix_relative()?; + Ok(self.is_ignored(&posix, is_dir)) + } + + pub fn is_path_dir_ignored(&self, path: &Path) -> Result { + let posix = path.as_posix_relative()?; + Ok(self.is_dir_ignored(&posix)) + } +} + +impl FromStr for DockerIgnore { + type Err = eyre::Report; + + fn from_str(s: &str) -> Result { + Self::parse(s) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_dockerignore_parsing() { + let content = "\u{feff}# Comment line\n\n # Indented comment\n target/ \n/src//*.rs\n!/src/main.rs\n./build/\n"; + let di = DockerIgnore::parse(content).unwrap(); + assert_eq!(di.rules.len(), 4); + + assert_eq!(di.rules[0].pattern.as_str(), "target"); + assert!(di.rules[0].only_dir); + assert!(!di.rules[0].is_exception); + + assert_eq!(di.rules[1].pattern.as_str(), "src/*.rs"); + assert!(!di.rules[1].only_dir); + assert!(!di.rules[1].is_exception); + + assert_eq!(di.rules[2].pattern.as_str(), "src/main.rs"); + assert!(!di.rules[2].only_dir); + assert!(di.rules[2].is_exception); + + assert_eq!(di.rules[3].pattern.as_str(), "build"); + assert!(di.rules[3].only_dir); + assert!(!di.rules[3].is_exception); + } + + #[test] + fn test_dockerignore_matching_basic() { + let content = "target\n*.md\n**/*.log\n*/temp*\ntemp?\n"; + let di = DockerIgnore::parse(content).unwrap(); + + // target matches at root + assert!(di.is_ignored("target", true)); + assert!(di.is_ignored("target", false)); + assert!(di.is_ignored("target/debug/app", false)); + assert!(di.is_ignored("target/debug", true)); + // target does not match in subdirectories (literal separator) + assert!(!di.is_ignored("src/target", true)); + assert!(!di.is_ignored("src/target/foo", false)); + + // *.md only matches at root + assert!(di.is_ignored("README.md", false)); + assert!(!di.is_ignored("docs/README.md", false)); + + // **/*.log matches at any depth + assert!(di.is_ignored("app.log", false)); + assert!(di.is_ignored("logs/app.log", false)); + assert!(di.is_ignored("a/b/c/app.log", false)); + + // */temp* matches in immediate subdirectories + assert!(!di.is_ignored("temp", false)); + assert!(di.is_ignored("sub/temp", false)); + assert!(di.is_ignored("sub/temporary.txt", false)); + assert!(!di.is_ignored("a/b/temp", false)); + + // temp? matches 1 char extension at root + assert!(di.is_ignored("tempa", false)); + assert!(di.is_ignored("temp1", false)); + assert!(!di.is_ignored("temp", false)); + assert!(!di.is_ignored("temporary", false)); + } + + #[test] + fn test_dockerignore_dir_only() { + let content = "logs/\n"; + let di = DockerIgnore::parse(content).unwrap(); + + // logs/ should match directory, but not file named logs + assert!(!di.is_ignored("logs", false)); + assert!(di.is_ignored("logs", true)); + // children of logs directory are inside an excluded directory + assert!(di.is_ignored("logs/app.log", false)); + assert!(di.is_ignored("logs/sub/app.log", false)); + } + + #[test] + fn test_dockerignore_exceptions() { + let content = "*.md\n!README*.md\nREADME-secret.md\n"; + let di = DockerIgnore::parse(content).unwrap(); + + assert!(!di.is_ignored("README.md", false)); + assert!(!di.is_ignored("README-test.md", false)); + assert!(di.is_ignored("README-secret.md", false)); + assert!(di.is_ignored("other.md", false)); + assert!(!di.is_ignored("other.txt", false)); + } + + #[test] + fn test_dockerignore_nested_exceptions() { + // Moby test case + let content = "**\n!util/docker/web\n"; + let di = DockerIgnore::parse(content).unwrap(); + assert!(!di.is_ignored("util/docker/web/foo", false)); + + let content2 = "**\n!util/docker/web\nutil/docker/web/foo\n"; + let di2 = DockerIgnore::parse(content2).unwrap(); + assert!(di2.is_ignored("util/docker/web/foo", false)); + + // Directory recursion exception + let content3 = "target/**\n!target/keep.txt\n"; + let di3 = DockerIgnore::parse(content3).unwrap(); + assert!(!di3.is_ignored("target", true)); + assert!(!di3.is_ignored("target/keep.txt", false)); + assert!(di3.is_ignored("target/delete.txt", false)); + } + + #[test] + fn test_dockerignore_dir_exception() { + let content = "target/\n!target/keep.txt\nlogs/\n!logs/**/*.log\nbuild/\n"; + let di = DockerIgnore::parse(content).unwrap(); + + // target/ is ignored by default rule + assert!(di.is_ignored("target", true)); + // but is_dir_ignored returns false because of child exception !target/keep.txt + assert!(!di.is_dir_ignored("target")); + assert!(!di.is_ignored("target/keep.txt", false)); + assert!(di.is_ignored("target/secret.txt", false)); + + // logs/ has child exception !logs/**/*.log + assert!(di.is_ignored("logs", true)); + assert!(!di.is_dir_ignored("logs")); + assert!(!di.is_dir_ignored("logs/sub")); + assert!(!di.is_ignored("logs/sub/app.log", false)); + assert!(di.is_ignored("logs/sub/app.txt", false)); + + // build/ has NO child exception + assert!(di.is_ignored("build", true)); + assert!(di.is_dir_ignored("build")); + assert!(di.is_ignored("build/app", false)); + } +} diff --git a/src/docker/mod.rs b/src/docker/mod.rs index a9dbbc30c..753621855 100644 --- a/src/docker/mod.rs +++ b/src/docker/mod.rs @@ -1,5 +1,6 @@ mod build; pub(crate) mod custom; +pub mod docker_ignore; mod engine; mod image; mod local; @@ -8,6 +9,7 @@ pub mod remote; mod shared; pub use self::build::{BuildCommandExt, BuildResultExt, Progress}; +pub use self::docker_ignore::{DockerIgnore, DockerIgnoreRule}; pub use self::engine::*; pub use self::provided_images::PROVIDED_IMAGES; pub use self::shared::*; diff --git a/src/docker/remote.rs b/src/docker/remote.rs index 82fb82c6b..c8de949a4 100644 --- a/src/docker/remote.rs +++ b/src/docker/remote.rs @@ -8,6 +8,7 @@ use eyre::Context; use is_terminal::IsTerminal; use serde::Deserialize; +use super::docker_ignore::DockerIgnore; use super::engine::Engine; use super::shared::*; use crate::TargetTriple; @@ -105,21 +106,41 @@ impl ContainerDataVolume<'_, '_, '_> { /// /// if copying from a src directory to dst directory with docker, to /// copy the contents from `src` into `dst`, `src` must end with `/.` + /// copy files for a docker volume, filtering out cache directories and dockerignore + #[allow(clippy::too_many_arguments)] #[track_caller] - fn copy_files_nocache( + fn copy_files_filtered( &self, src: &Path, reldst: &str, mount_prefix: &str, copy_symlinks: bool, + copy_cache: bool, + dockerignore: &DockerIgnore, msg_info: &mut MessageInfo, ) -> Result { - // avoid any cached directories when copying - // see https://bford.info/cachedir/ + if dockerignore.is_empty() && copy_cache { + return self.copy_files(&src.join("."), reldst, mount_prefix, msg_info); + } + // SAFETY: safe, single-threaded execution. let tempdir = unsafe { temp::TempDir::new()? }; let temppath = tempdir.path(); - let had_symlinks = copy_dir(src, temppath, copy_symlinks, 0, |e, _| is_cachedir(e))?; + let had_symlinks = copy_dir_with_rel( + src, + src, + temppath, + copy_symlinks, + 0, + |e, _, rel_path, is_dir| { + (!copy_cache && is_cachedir(e)) + || if is_dir { + dockerignore.is_dir_ignored(rel_path) + } else { + dockerignore.is_ignored(rel_path, false) + } + }, + )?; warn_symlinks(had_symlinks, msg_info)?; self.copy_files(&temppath.join("."), reldst, mount_prefix, msg_info) } @@ -384,12 +405,17 @@ impl ContainerDataVolume<'_, '_, '_> { copy_cache: bool, msg_info: &mut MessageInfo, ) -> Result<()> { + let dockerignore = DockerIgnore::from_dir(src)?; let copy_all = |info: &mut MessageInfo| { - if copy_cache { - self.copy_files(&src.join("."), reldst, mount_prefix, info) - } else { - self.copy_files_nocache(&src.join("."), reldst, mount_prefix, true, info) - } + self.copy_files_filtered( + src, + reldst, + mount_prefix, + true, + copy_cache, + &dockerignore, + info, + ) }; match volume { VolumeId::Keep(_) => { @@ -399,7 +425,7 @@ impl ContainerDataVolume<'_, '_, '_> { let toolchain = &self.toolchain_dirs.toolchain(); let filename = toolchain.unique_mount_identifier(src)?; let fingerprint = parent.join(filename); - let current = Fingerprint::read_dir(src, copy_cache)?; + let current = Fingerprint::read_dir(src, copy_cache, &dockerignore)?; // need to check if the container path exists, otherwise we might // have stale data: the persistent volume was deleted & recreated. if fingerprint.exists() @@ -450,8 +476,9 @@ fn is_cachedir(entry: &fs::DirEntry) -> bool { } } -// recursively copy a directory into another -fn copy_dir( +// recursively copy a directory into another with relative path information for skip callback +fn copy_dir_with_rel( + root: &Path, src: &Path, dst: &Path, copy_symlinks: bool, @@ -459,32 +486,39 @@ fn copy_dir( skip: Skip, ) -> Result where - Skip: Copy + Fn(&fs::DirEntry, u32) -> bool, + Skip: Copy + Fn(&fs::DirEntry, u32, &str, bool) -> bool, { let mut had_symlinks = false; for entry in fs::read_dir(src).wrap_err_with(|| format!("when reading directory {src:?}"))? { let file = entry?; - if skip(&file, depth) { + let src_path = file.path(); + let file_type = file.file_type()?; + let is_dir = file_type.is_dir(); + let rel_path = src_path + .strip_prefix(root) + .wrap_err_with(|| format!("when stripping prefix {root:?} from {src_path:?}"))? + .as_posix_relative()?; + + if skip(&file, depth, &rel_path, is_dir) { continue; } - let src_path = file.path(); let dst_path = dst.join(file.file_name()); - let file_type = file.file_type()?; if file_type.is_file() { fs::copy(&src_path, &dst_path) .wrap_err_with(|| format!("when copying file {src_path:?} -> {dst_path:?}"))?; - } else if file_type.is_dir() { + } else if is_dir { fs::create_dir(&dst_path).ok(); - had_symlinks = copy_dir(&src_path, &dst_path, copy_symlinks, depth + 1, skip)?; + had_symlinks |= + copy_dir_with_rel(root, &src_path, &dst_path, copy_symlinks, depth + 1, skip)?; } else if file_type.is_symlink() && copy_symlinks { had_symlinks = true; - let link_dst = fs::read_link(src_path)?; + let link_dst = fs::read_link(&src_path)?; #[cfg(target_family = "unix")] { - std::os::unix::fs::symlink(link_dst, dst_path)?; + std::os::unix::fs::symlink(link_dst, &dst_path)?; } #[cfg(target_family = "windows")] @@ -496,10 +530,10 @@ where src.join(&link_dst) }; if link_dst_absolute.is_dir() { - std::os::windows::fs::symlink_dir(link_dst, dst_path)?; + std::os::windows::fs::symlink_dir(link_dst, &dst_path)?; } else { // symlink_file handles everything that isn't a directory - std::os::windows::fs::symlink_file(link_dst, dst_path)?; + std::os::windows::fs::symlink_file(link_dst, &dst_path)?; } } } else { @@ -510,6 +544,20 @@ where Ok(had_symlinks) } +// recursively copy a directory into another +fn copy_dir( + src: &Path, + dst: &Path, + copy_symlinks: bool, + depth: u32, + skip: Skip, +) -> Result +where + Skip: Copy + Fn(&fs::DirEntry, u32) -> bool, +{ + copy_dir_with_rel(src, src, dst, copy_symlinks, depth, |e, d, _, _| skip(e, d)) +} + fn warn_symlinks(had_symlinks: bool, msg_info: &mut MessageInfo) -> Result<()> { if had_symlinks { msg_info.warn("copied directory contained symlinks. if the volume the link points to was not mounted, the remote build may fail") @@ -656,21 +704,37 @@ impl Fingerprint { Ok(()) } - fn _read_dir(&mut self, home: &Path, path: &Path, copy_cache: bool) -> Result<()> { + fn _read_dir( + &mut self, + home: &Path, + path: &Path, + copy_cache: bool, + dockerignore: &DockerIgnore, + ) -> Result<()> { for entry in fs::read_dir(path)? { let file = entry?; let file_type = file.file_type()?; + let is_dir = file_type.is_dir(); + let relpath = file.path().strip_prefix(home)?.as_posix_relative()?; + let ignored = if is_dir { + dockerignore.is_dir_ignored(&relpath) + } else { + dockerignore.is_ignored(&relpath, false) + }; + if ignored { + continue; + } + // only parse known files types: 0 or 1 of these tests can pass. - if file_type.is_dir() { + if is_dir { if copy_cache || !is_cachedir(&file) { - self._read_dir(home, &path.join(file.file_name()), copy_cache)?; + self._read_dir(home, &path.join(file.file_name()), copy_cache, dockerignore)?; } } else if file_type.is_file() || file_type.is_symlink() { // we're mounting to the same location, so this should fine // we need to round the modified date to millis. let modified = file.metadata()?.modified()?; let rounded = time_from_millis(time_to_millis(&modified)?); - let relpath = file.path().strip_prefix(home)?.as_posix_relative()?; self.map.insert(relpath, rounded); } } @@ -678,9 +742,9 @@ impl Fingerprint { Ok(()) } - fn read_dir(home: &Path, copy_cache: bool) -> Result { + fn read_dir(home: &Path, copy_cache: bool, dockerignore: &DockerIgnore) -> Result { let mut result = Fingerprint::new(); - result._read_dir(home, home, copy_cache)?; + result._read_dir(home, home, copy_cache, dockerignore)?; Ok(result) } @@ -1206,4 +1270,107 @@ mod tests { assert!(parse_artifact_filenames("").is_empty()); assert!(parse_artifact_filenames("\n \n").is_empty()); } + + #[test] + fn test_dockerignore_copy_dir_with_rel() { + let src_temp = tempfile::tempdir().unwrap(); + let dst_temp = tempfile::tempdir().unwrap(); + let src = src_temp.path(); + let dst = dst_temp.path(); + + std::fs::create_dir_all(src.join("src")).unwrap(); + std::fs::create_dir_all(src.join("target/debug")).unwrap(); + std::fs::create_dir_all(src.join("logs")).unwrap(); + std::fs::create_dir_all(src.join("temp")).unwrap(); + + std::fs::write(src.join("Cargo.toml"), "cargo").unwrap(); + std::fs::write(src.join("src/main.rs"), "fn main() {}").unwrap(); + std::fs::write(src.join("target/debug/app"), "binary").unwrap(); + std::fs::write(src.join("logs/app.log"), "log").unwrap(); + std::fs::write(src.join("temp/delete.txt"), "delete").unwrap(); + std::fs::write(src.join("temp/keep.txt"), "keep").unwrap(); + + let ignore_content = "target/\nlogs\ntemp/*\n!temp/keep.txt\n"; + let di = super::DockerIgnore::parse(ignore_content).unwrap(); + + super::copy_dir_with_rel(src, src, dst, false, 0, |_, _, rel_path, is_dir| { + di.is_ignored(rel_path, is_dir) + }) + .unwrap(); + + assert!(dst.join("Cargo.toml").exists()); + assert!(dst.join("src/main.rs").exists()); + assert!(dst.join("temp/keep.txt").exists()); + + assert!(!dst.join("target").exists()); + assert!(!dst.join("logs").exists()); + assert!(!dst.join("temp/delete.txt").exists()); + } + + #[test] + fn test_dockerignore_fingerprint() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path(); + + std::fs::create_dir_all(root.join("src")).unwrap(); + std::fs::create_dir_all(root.join("target")).unwrap(); + std::fs::write(root.join("src/lib.rs"), "// code").unwrap(); + std::fs::write(root.join("target/artifact"), "build").unwrap(); + std::fs::write(root.join("debug.log"), "log").unwrap(); + std::fs::write(root.join(".dockerignore"), "target/\n*.log\n").unwrap(); + + let di = super::DockerIgnore::from_dir(root).unwrap(); + let fp = super::Fingerprint::read_dir(root, true, &di).unwrap(); + + assert!(fp.map.contains_key("src/lib.rs")); + assert!(fp.map.contains_key(".dockerignore")); + assert!(!fp.map.contains_key("target/artifact")); + assert!(!fp.map.contains_key("debug.log")); + } + + #[test] + fn test_dockerignore_copy_dir_with_dir_exception() { + let src_temp = tempfile::tempdir().unwrap(); + let dst_temp = tempfile::tempdir().unwrap(); + let src = src_temp.path(); + let dst = dst_temp.path(); + + std::fs::create_dir_all(src.join("target/debug")).unwrap(); + std::fs::write(src.join("target/debug/app"), "binary").unwrap(); + std::fs::write(src.join("target/keep.txt"), "keep").unwrap(); + std::fs::write(src.join("target/delete.txt"), "delete").unwrap(); + + let ignore_content = "target/\n!target/keep.txt\n"; + let di = super::DockerIgnore::parse(ignore_content).unwrap(); + + super::copy_dir_with_rel(src, src, dst, false, 0, |_, _, rel_path, is_dir| { + if is_dir { + di.is_dir_ignored(rel_path) + } else { + di.is_ignored(rel_path, false) + } + }) + .unwrap(); + + assert!(dst.join("target/keep.txt").exists()); + assert!(!dst.join("target/delete.txt").exists()); + assert!(!dst.join("target/debug").exists()); + } + + #[test] + fn test_dockerignore_fingerprint_with_dir_exception() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path(); + + std::fs::create_dir_all(root.join("target/debug")).unwrap(); + std::fs::write(root.join("target/debug/app"), "binary").unwrap(); + std::fs::write(root.join("target/keep.txt"), "keep").unwrap(); + std::fs::write(root.join(".dockerignore"), "target/\n!target/keep.txt\n").unwrap(); + + let di = super::DockerIgnore::from_dir(root).unwrap(); + let fp = super::Fingerprint::read_dir(root, true, &di).unwrap(); + + assert!(fp.map.contains_key("target/keep.txt")); + assert!(!fp.map.contains_key("target/debug/app")); + } } From b636f52f3db5f6259ab8733251206811f256d1e3 Mon Sep 17 00:00:00 2001 From: "loongtao.zhang" Date: Thu, 10 Sep 2026 11:37:12 +0800 Subject: [PATCH 2/4] refactor(tests): use raw strings for dockerignore fixtures Replace escaped \n string literals with multi-line raw strings so .dockerignore test content is readable at a glance. --- src/docker/docker_ignore.rs | 47 ++++++++++++++++++++++++++++++------- src/docker/remote.rs | 26 ++++++++++++++++---- 2 files changed, 61 insertions(+), 12 deletions(-) diff --git a/src/docker/docker_ignore.rs b/src/docker/docker_ignore.rs index 8bf6551b0..1d3f21ae7 100644 --- a/src/docker/docker_ignore.rs +++ b/src/docker/docker_ignore.rs @@ -240,7 +240,17 @@ mod tests { #[test] fn test_dockerignore_parsing() { - let content = "\u{feff}# Comment line\n\n # Indented comment\n target/ \n/src//*.rs\n!/src/main.rs\n./build/\n"; + let content = concat!( + "\u{feff}", + r#"# Comment line + + # Indented comment + target/ +/src//*.rs +!/src/main.rs +./build/ +"# + ); let di = DockerIgnore::parse(content).unwrap(); assert_eq!(di.rules.len(), 4); @@ -263,7 +273,12 @@ mod tests { #[test] fn test_dockerignore_matching_basic() { - let content = "target\n*.md\n**/*.log\n*/temp*\ntemp?\n"; + let content = r#"target +*.md +**/*.log +*/temp* +temp? +"#; let di = DockerIgnore::parse(content).unwrap(); // target matches at root @@ -299,7 +314,8 @@ mod tests { #[test] fn test_dockerignore_dir_only() { - let content = "logs/\n"; + let content = r#"logs/ +"#; let di = DockerIgnore::parse(content).unwrap(); // logs/ should match directory, but not file named logs @@ -312,7 +328,10 @@ mod tests { #[test] fn test_dockerignore_exceptions() { - let content = "*.md\n!README*.md\nREADME-secret.md\n"; + let content = r#"*.md +!README*.md +README-secret.md +"#; let di = DockerIgnore::parse(content).unwrap(); assert!(!di.is_ignored("README.md", false)); @@ -325,16 +344,23 @@ mod tests { #[test] fn test_dockerignore_nested_exceptions() { // Moby test case - let content = "**\n!util/docker/web\n"; + let content = r#"** +!util/docker/web +"#; let di = DockerIgnore::parse(content).unwrap(); assert!(!di.is_ignored("util/docker/web/foo", false)); - let content2 = "**\n!util/docker/web\nutil/docker/web/foo\n"; + let content2 = r#"** +!util/docker/web +util/docker/web/foo +"#; let di2 = DockerIgnore::parse(content2).unwrap(); assert!(di2.is_ignored("util/docker/web/foo", false)); // Directory recursion exception - let content3 = "target/**\n!target/keep.txt\n"; + let content3 = r#"target/** +!target/keep.txt +"#; let di3 = DockerIgnore::parse(content3).unwrap(); assert!(!di3.is_ignored("target", true)); assert!(!di3.is_ignored("target/keep.txt", false)); @@ -343,7 +369,12 @@ mod tests { #[test] fn test_dockerignore_dir_exception() { - let content = "target/\n!target/keep.txt\nlogs/\n!logs/**/*.log\nbuild/\n"; + let content = r#"target/ +!target/keep.txt +logs/ +!logs/**/*.log +build/ +"#; let di = DockerIgnore::parse(content).unwrap(); // target/ is ignored by default rule diff --git a/src/docker/remote.rs b/src/docker/remote.rs index c8de949a4..2ca62248d 100644 --- a/src/docker/remote.rs +++ b/src/docker/remote.rs @@ -1290,7 +1290,11 @@ mod tests { std::fs::write(src.join("temp/delete.txt"), "delete").unwrap(); std::fs::write(src.join("temp/keep.txt"), "keep").unwrap(); - let ignore_content = "target/\nlogs\ntemp/*\n!temp/keep.txt\n"; + let ignore_content = r#"target/ +logs +temp/* +!temp/keep.txt +"#; let di = super::DockerIgnore::parse(ignore_content).unwrap(); super::copy_dir_with_rel(src, src, dst, false, 0, |_, _, rel_path, is_dir| { @@ -1317,7 +1321,13 @@ mod tests { std::fs::write(root.join("src/lib.rs"), "// code").unwrap(); std::fs::write(root.join("target/artifact"), "build").unwrap(); std::fs::write(root.join("debug.log"), "log").unwrap(); - std::fs::write(root.join(".dockerignore"), "target/\n*.log\n").unwrap(); + std::fs::write( + root.join(".dockerignore"), + r#"target/ +*.log +"#, + ) + .unwrap(); let di = super::DockerIgnore::from_dir(root).unwrap(); let fp = super::Fingerprint::read_dir(root, true, &di).unwrap(); @@ -1340,7 +1350,9 @@ mod tests { std::fs::write(src.join("target/keep.txt"), "keep").unwrap(); std::fs::write(src.join("target/delete.txt"), "delete").unwrap(); - let ignore_content = "target/\n!target/keep.txt\n"; + let ignore_content = r#"target/ +!target/keep.txt +"#; let di = super::DockerIgnore::parse(ignore_content).unwrap(); super::copy_dir_with_rel(src, src, dst, false, 0, |_, _, rel_path, is_dir| { @@ -1365,7 +1377,13 @@ mod tests { std::fs::create_dir_all(root.join("target/debug")).unwrap(); std::fs::write(root.join("target/debug/app"), "binary").unwrap(); std::fs::write(root.join("target/keep.txt"), "keep").unwrap(); - std::fs::write(root.join(".dockerignore"), "target/\n!target/keep.txt\n").unwrap(); + std::fs::write( + root.join(".dockerignore"), + r#"target/ +!target/keep.txt +"#, + ) + .unwrap(); let di = super::DockerIgnore::from_dir(root).unwrap(); let fp = super::Fingerprint::read_dir(root, true, &di).unwrap(); From 4eaee6b82708128130452bf9fd74e173af1d0b55 Mon Sep 17 00:00:00 2001 From: "loongtao.zhang" Date: Thu, 10 Sep 2026 11:47:25 +0800 Subject: [PATCH 3/4] feat(dockerignore): select ignore file by container engine Podman prefers `.containerignore` with a fallback to `.dockerignore`, while Docker only reads `.dockerignore`. Warn when the chosen file does not match the running engine. --- src/docker/docker_ignore.rs | 29 +++++++++++++++++++++++++++-- src/docker/remote.rs | 9 ++++++--- 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/src/docker/docker_ignore.rs b/src/docker/docker_ignore.rs index 1d3f21ae7..b334fb3d9 100644 --- a/src/docker/docker_ignore.rs +++ b/src/docker/docker_ignore.rs @@ -6,6 +6,9 @@ use eyre::Context; use crate::errors::Result; use crate::file::PathExt; +use crate::shell::MessageInfo; + +use super::engine::EngineType; #[derive(Debug, Clone)] pub struct DockerIgnoreRule { @@ -28,9 +31,31 @@ impl DockerIgnore { self.rules.is_empty() } - pub fn from_dir(dir: &Path) -> Result { - let ignore_file = dir.join(".dockerignore"); + pub fn from_dir( + dir: &Path, + engine_kind: EngineType, + msg_info: &mut MessageInfo, + ) -> Result { + let containerignore = dir.join(".containerignore"); + let dockerignore = dir.join(".dockerignore"); + let (ignore_file, is_containerignore) = + if engine_kind.is_podman() && containerignore.is_file() { + (containerignore, true) + } else if dockerignore.is_file() { + (dockerignore, false) + } else { + (containerignore, true) + }; if ignore_file.is_file() { + if engine_kind.is_podman() && !is_containerignore { + msg_info.warn(format_args!( + "using `.dockerignore` with podman; consider renaming it to `.containerignore`" + ))?; + } else if !engine_kind.is_podman() && is_containerignore { + msg_info.warn(format_args!( + "using `.containerignore` with docker; consider renaming it to `.dockerignore`" + ))?; + } Self::from_path(&ignore_file) } else { Ok(Self::empty()) diff --git a/src/docker/remote.rs b/src/docker/remote.rs index 2ca62248d..5aad917c0 100644 --- a/src/docker/remote.rs +++ b/src/docker/remote.rs @@ -405,7 +405,7 @@ impl ContainerDataVolume<'_, '_, '_> { copy_cache: bool, msg_info: &mut MessageInfo, ) -> Result<()> { - let dockerignore = DockerIgnore::from_dir(src)?; + let dockerignore = DockerIgnore::from_dir(src, self.engine.kind, msg_info)?; let copy_all = |info: &mut MessageInfo| { self.copy_files_filtered( src, @@ -1201,6 +1201,7 @@ symlink_recurse \"${{prefix}}\" #[cfg(test)] mod tests { use super::{is_cargo_json_message, parse_artifact_filenames}; + use crate::docker::EngineType; #[test] fn cargo_json_message_detection() { @@ -1329,7 +1330,8 @@ temp/* ) .unwrap(); - let di = super::DockerIgnore::from_dir(root).unwrap(); + let di = super::DockerIgnore::from_dir(root, EngineType::Docker, &mut Default::default()) + .unwrap(); let fp = super::Fingerprint::read_dir(root, true, &di).unwrap(); assert!(fp.map.contains_key("src/lib.rs")); @@ -1385,7 +1387,8 @@ temp/* ) .unwrap(); - let di = super::DockerIgnore::from_dir(root).unwrap(); + let di = super::DockerIgnore::from_dir(root, EngineType::Docker, &mut Default::default()) + .unwrap(); let fp = super::Fingerprint::read_dir(root, true, &di).unwrap(); assert!(fp.map.contains_key("target/keep.txt")); From 8dce6d7898657ca3c5799cb7b9b4b84e92ad82f2 Mon Sep 17 00:00:00 2001 From: "loongtao.zhang" Date: Thu, 10 Sep 2026 12:57:59 +0800 Subject: [PATCH 4/4] refactor(remote): convert directory walks to iterative BFS Replace recursive copy_dir_with_rel and Fingerprint::_read_dir with a VecDeque queue so deeply nested contexts cannot blow the stack, while preserving skip/dockerignore pruning and depth-aware callbacks. --- src/docker/remote.rs | 146 +++++++++++++++++++++++-------------------- 1 file changed, 77 insertions(+), 69 deletions(-) diff --git a/src/docker/remote.rs b/src/docker/remote.rs index 5aad917c0..315548aa6 100644 --- a/src/docker/remote.rs +++ b/src/docker/remote.rs @@ -1,4 +1,4 @@ -use std::collections::BTreeMap; +use std::collections::{BTreeMap, VecDeque}; use std::io::{self, BufRead, Read, Write}; use std::path::Path; use std::process::{Command, ExitStatus}; @@ -476,7 +476,7 @@ fn is_cachedir(entry: &fs::DirEntry) -> bool { } } -// recursively copy a directory into another with relative path information for skip callback +// iteratively copy a directory into another with relative path information for skip callback fn copy_dir_with_rel( root: &Path, src: &Path, @@ -489,62 +489,66 @@ where Skip: Copy + Fn(&fs::DirEntry, u32, &str, bool) -> bool, { let mut had_symlinks = false; + let mut queue = VecDeque::from([(src.to_path_buf(), dst.to_path_buf(), depth)]); - for entry in fs::read_dir(src).wrap_err_with(|| format!("when reading directory {src:?}"))? { - let file = entry?; - let src_path = file.path(); - let file_type = file.file_type()?; - let is_dir = file_type.is_dir(); - let rel_path = src_path - .strip_prefix(root) - .wrap_err_with(|| format!("when stripping prefix {root:?} from {src_path:?}"))? - .as_posix_relative()?; - - if skip(&file, depth, &rel_path, is_dir) { - continue; - } + while let Some((src_dir, dst_dir, depth)) = queue.pop_front() { + for entry in fs::read_dir(&src_dir) + .wrap_err_with(|| format!("when reading directory {src_dir:?}"))? + { + let file = entry?; + let src_path = file.path(); + let file_type = file.file_type()?; + let is_dir = file_type.is_dir(); + let rel_path = src_path + .strip_prefix(root) + .wrap_err_with(|| format!("when stripping prefix {root:?} from {src_path:?}"))? + .as_posix_relative()?; - let dst_path = dst.join(file.file_name()); - if file_type.is_file() { - fs::copy(&src_path, &dst_path) - .wrap_err_with(|| format!("when copying file {src_path:?} -> {dst_path:?}"))?; - } else if is_dir { - fs::create_dir(&dst_path).ok(); - had_symlinks |= - copy_dir_with_rel(root, &src_path, &dst_path, copy_symlinks, depth + 1, skip)?; - } else if file_type.is_symlink() && copy_symlinks { - had_symlinks = true; - let link_dst = fs::read_link(&src_path)?; - - #[cfg(target_family = "unix")] - { - std::os::unix::fs::symlink(link_dst, &dst_path)?; + if skip(&file, depth, &rel_path, is_dir) { + continue; } - #[cfg(target_family = "windows")] - { - let link_dst_absolute = if link_dst.is_absolute() { - link_dst.clone() - } else { - // we cannot fail even if the linked to path does not exist. - src.join(&link_dst) - }; - if link_dst_absolute.is_dir() { - std::os::windows::fs::symlink_dir(link_dst, &dst_path)?; - } else { - // symlink_file handles everything that isn't a directory - std::os::windows::fs::symlink_file(link_dst, &dst_path)?; + let dst_path = dst_dir.join(file.file_name()); + if file_type.is_file() { + fs::copy(&src_path, &dst_path) + .wrap_err_with(|| format!("when copying file {src_path:?} -> {dst_path:?}"))?; + } else if is_dir { + fs::create_dir(&dst_path).ok(); + queue.push_back((src_path, dst_path, depth + 1)); + } else if file_type.is_symlink() && copy_symlinks { + had_symlinks = true; + let link_dst = fs::read_link(&src_path)?; + + #[cfg(target_family = "unix")] + { + std::os::unix::fs::symlink(link_dst, &dst_path)?; + } + + #[cfg(target_family = "windows")] + { + let link_dst_absolute = if link_dst.is_absolute() { + link_dst.clone() + } else { + // we cannot fail even if the linked to path does not exist. + src_dir.join(&link_dst) + }; + if link_dst_absolute.is_dir() { + std::os::windows::fs::symlink_dir(link_dst, &dst_path)?; + } else { + // symlink_file handles everything that isn't a directory + std::os::windows::fs::symlink_file(link_dst, &dst_path)?; + } } + } else { + had_symlinks = true; } - } else { - had_symlinks = true; } } Ok(had_symlinks) } -// recursively copy a directory into another +// iteratively copy a directory into another fn copy_dir( src: &Path, dst: &Path, @@ -711,31 +715,35 @@ impl Fingerprint { copy_cache: bool, dockerignore: &DockerIgnore, ) -> Result<()> { - for entry in fs::read_dir(path)? { - let file = entry?; - let file_type = file.file_type()?; - let is_dir = file_type.is_dir(); - let relpath = file.path().strip_prefix(home)?.as_posix_relative()?; - let ignored = if is_dir { - dockerignore.is_dir_ignored(&relpath) - } else { - dockerignore.is_ignored(&relpath, false) - }; - if ignored { - continue; - } + let mut queue = VecDeque::from([path.to_path_buf()]); - // only parse known files types: 0 or 1 of these tests can pass. - if is_dir { - if copy_cache || !is_cachedir(&file) { - self._read_dir(home, &path.join(file.file_name()), copy_cache, dockerignore)?; + while let Some(dir) = queue.pop_front() { + for entry in fs::read_dir(&dir)? { + let file = entry?; + let file_type = file.file_type()?; + let is_dir = file_type.is_dir(); + let relpath = file.path().strip_prefix(home)?.as_posix_relative()?; + let ignored = if is_dir { + dockerignore.is_dir_ignored(&relpath) + } else { + dockerignore.is_ignored(&relpath, false) + }; + if ignored { + continue; + } + + // only parse known files types: 0 or 1 of these tests can pass. + if is_dir { + if copy_cache || !is_cachedir(&file) { + queue.push_back(dir.join(file.file_name())); + } + } else if file_type.is_file() || file_type.is_symlink() { + // we're mounting to the same location, so this should fine + // we need to round the modified date to millis. + let modified = file.metadata()?.modified()?; + let rounded = time_from_millis(time_to_millis(&modified)?); + self.map.insert(relpath, rounded); } - } else if file_type.is_file() || file_type.is_symlink() { - // we're mounting to the same location, so this should fine - // we need to round the modified date to millis. - let modified = file.metadata()?.modified()?; - let rounded = time_from_millis(time_to_millis(&modified)?); - self.map.insert(relpath, rounded); } }