From 2ec140fd952df0ae9628fe97b3cad5b8d783ff6e Mon Sep 17 00:00:00 2001 From: Tor Arvid Lund Date: Fri, 14 Nov 2025 14:29:38 +0100 Subject: [PATCH 1/5] Add test to demonstrate that .dockerignore files are not read --- .../DockerignoreTests.swift | 209 ++++++++++++++++++ 1 file changed, 209 insertions(+) create mode 100644 Tests/ContainerBuildTests/DockerignoreTests.swift diff --git a/Tests/ContainerBuildTests/DockerignoreTests.swift b/Tests/ContainerBuildTests/DockerignoreTests.swift new file mode 100644 index 00000000..6188b2f9 --- /dev/null +++ b/Tests/ContainerBuildTests/DockerignoreTests.swift @@ -0,0 +1,209 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import Foundation +import Testing + +@testable import ContainerBuild + +enum TestError: Error { + case missingBuildTransfer +} + +@Suite class DockerignoreTests { + private var baseTempURL: URL + private let fileManager = FileManager.default + + init() throws { + self.baseTempURL = URL.temporaryDirectory + .appendingPathComponent("DockerignoreTests-\(UUID().uuidString)") + try fileManager.createDirectory(at: baseTempURL, withIntermediateDirectories: true, attributes: nil) + } + + deinit { + try? fileManager.removeItem(at: baseTempURL) + } + + private func createFile(at url: URL, content: String = "") throws { + try fileManager.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true, + attributes: nil + ) + let created = fileManager.createFile( + atPath: url.path, + contents: content.data(using: .utf8), + attributes: nil + ) + try #require(created) + } + + @Test func testDockerignoreExcludesMatchingFiles() async throws { + // Setup: Create a build context with files and .dockerignore + let contextDir = baseTempURL.appendingPathComponent("build-context") + try fileManager.createDirectory(at: contextDir, withIntermediateDirectories: true, attributes: nil) + + // Create Dockerfile + let dockerfilePath = contextDir.appendingPathComponent("Dockerfile") + try createFile(at: dockerfilePath, content: "FROM scratch\n") + + // Create hello.txt (should be included) + let helloPath = contextDir.appendingPathComponent("hello.txt") + try createFile(at: helloPath, content: "Hello, World!\n") + + // Create to-be-ignored.txt (should be excluded) + let ignoredPath = contextDir.appendingPathComponent("to-be-ignored.txt") + try createFile(at: ignoredPath, content: "This should be ignored\n") + + // Create .dockerignore + let dockerignorePath = contextDir.appendingPathComponent(".dockerignore") + try createFile(at: dockerignorePath, content: "to-be-ignored.txt\n") + + // Execute: Create BuildFSSync and call walk with all files pattern + let fsSync = try BuildFSSync(contextDir) + + // Create a BuildTransfer packet to simulate the walk request + let buildTransfer: BuildTransfer = { + var transfer = BuildTransfer() + transfer.id = "test-walk" + transfer.source = "." + transfer.metadata = [ + "stage": "fssync", + "method": "Walk", + "followpaths": "**", // Include all files + "mode": "json", + ] + return transfer + }() + + // Capture the response + var responses: [ClientStream] = [] + let stream = AsyncStream { continuation in + Task { + do { + try await fsSync.walk(continuation, buildTransfer, "test-build") + } catch { + // Propagate error + throw error + } + continuation.finish() + } + } + + for await response in stream { + responses.append(response) + } + + // Parse the JSON response + try #require(responses.count == 1, "Expected exactly one response") + let response = responses[0] + + // Check if the response has a buildTransfer packet type + guard case .buildTransfer(let responseTransfer) = response.packetType else { + throw TestError.missingBuildTransfer + } + + try #require(responseTransfer.complete, "Response should be complete") + + let fileInfos = try JSONDecoder().decode([BuildFSSync.FileInfo].self, from: responseTransfer.data) + + // Extract file names + let fileNames = Set(fileInfos.map { $0.name }) + + // Assert: Verify hello.txt is present and to-be-ignored.txt is NOT present + #expect(fileNames.contains("hello.txt"), "hello.txt should be included in the build context") + #expect(!fileNames.contains("to-be-ignored.txt"), "to-be-ignored.txt should be excluded by .dockerignore") + #expect(fileNames.contains("Dockerfile"), "Dockerfile should be included") + + // The .dockerignore file itself should also be included (Docker behavior) + #expect(fileNames.contains(".dockerignore"), ".dockerignore should be included") + } + + @Test func testDockerignoreExcludesDirectories() async throws { + // Setup: Create a build context with directories and .dockerignore + let contextDir = baseTempURL.appendingPathComponent("build-context-dirs") + try fileManager.createDirectory(at: contextDir, withIntermediateDirectories: true, attributes: nil) + + // Create Dockerfile + let dockerfilePath = contextDir.appendingPathComponent("Dockerfile") + try createFile(at: dockerfilePath, content: "FROM scratch\n") + + // Create src/app.txt (should be included) + let srcPath = contextDir.appendingPathComponent("src") + try fileManager.createDirectory(at: srcPath, withIntermediateDirectories: true, attributes: nil) + let appPath = srcPath.appendingPathComponent("app.txt") + try createFile(at: appPath, content: "app code\n") + + // Create node_modules/package.txt (should be excluded) + let nodeModulesPath = contextDir.appendingPathComponent("node_modules") + try fileManager.createDirectory(at: nodeModulesPath, withIntermediateDirectories: true, attributes: nil) + let packagePath = nodeModulesPath.appendingPathComponent("package.txt") + try createFile(at: packagePath, content: "dependency\n") + + // Create .dockerignore + let dockerignorePath = contextDir.appendingPathComponent(".dockerignore") + try createFile(at: dockerignorePath, content: "node_modules\n") + + // Execute: Create BuildFSSync and call walk + let fsSync = try BuildFSSync(contextDir) + + let buildTransfer: BuildTransfer = { + var transfer = BuildTransfer() + transfer.id = "test-walk-dirs" + transfer.source = "." + transfer.metadata = [ + "stage": "fssync", + "method": "Walk", + "followpaths": "**", + "mode": "json", + ] + return transfer + }() + + var responses: [ClientStream] = [] + let stream = AsyncStream { continuation in + Task { + do { + try await fsSync.walk(continuation, buildTransfer, "test-build") + } catch { + throw error + } + continuation.finish() + } + } + + for await response in stream { + responses.append(response) + } + + // Parse the JSON response + try #require(responses.count == 1) + let response = responses[0] + + guard case .buildTransfer(let responseTransfer) = response.packetType else { + throw TestError.missingBuildTransfer + } + + let fileInfos = try JSONDecoder().decode([BuildFSSync.FileInfo].self, from: responseTransfer.data) + let fileNames = Set(fileInfos.map { $0.name }) + + // Assert: Verify src is included, node_modules is excluded + #expect(fileNames.contains("src"), "src directory should be included") + #expect(fileNames.contains("src/app.txt"), "src/app.txt should be included") + #expect(!fileNames.contains("node_modules"), "node_modules directory should be excluded") + #expect(!fileNames.contains("node_modules/package.txt"), "node_modules/package.txt should be excluded") + } +} From 2289c34e80ce4fa8b614f9638da66c359a7444bf Mon Sep 17 00:00:00 2001 From: Tor Arvid Lund Date: Fri, 14 Nov 2025 15:14:24 +0100 Subject: [PATCH 2/5] Read .dockerignore files when building --- Sources/ContainerBuild/BuildFSSync.swift | 65 ++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/Sources/ContainerBuild/BuildFSSync.swift b/Sources/ContainerBuild/BuildFSSync.swift index c4cfb352..38351e75 100644 --- a/Sources/ContainerBuild/BuildFSSync.swift +++ b/Sources/ContainerBuild/BuildFSSync.swift @@ -136,6 +136,9 @@ actor BuildFSSync: BuildPipelineHandler { var entries: [String: Set] = [:] let followPaths: [String] = packet.followPaths() ?? [] + // Parse .dockerignore if present + let ignorePatterns = try parseDockerignore() + let followPathsWalked = try walk(root: self.contextDir, includePatterns: followPaths) for url in followPathsWalked { guard self.contextDir.absoluteURL.cleanPath != url.absoluteURL.cleanPath else { @@ -146,6 +149,12 @@ actor BuildFSSync: BuildPipelineHandler { } let relPath = try url.relativeChildPath(to: contextDir) + + // Check if the file should be ignored + if try shouldIgnore(relPath, patterns: ignorePatterns, isDirectory: url.hasDirectoryPath) { + continue + } + let parentPath = try url.deletingLastPathComponent().relativeChildPath(to: contextDir) let entry = DirEntry(url: url, isDirectory: url.hasDirectoryPath, relativePath: relPath) entries[parentPath, default: []].insert(entry) @@ -278,6 +287,62 @@ actor BuildFSSync: BuildPipelineHandler { return Array(globber.results) } + /// Parse .dockerignore file and return list of ignore patterns + private func parseDockerignore() throws -> [String] { + let dockerignorePath = contextDir.appendingPathComponent(".dockerignore") + + guard FileManager.default.fileExists(atPath: dockerignorePath.path) else { + return [] + } + + let contents = try String(contentsOf: dockerignorePath, encoding: .utf8) + + return + contents + .split(separator: "\n") + .map { line in line.trimmingCharacters(in: .whitespaces) } + .filter { line in !line.isEmpty && !line.hasPrefix("#") } + .map { String($0) } + } + + /// Check if a file path should be ignored based on .dockerignore patterns + private func shouldIgnore(_ path: String, patterns: [String], isDirectory: Bool) throws -> Bool { + guard !patterns.isEmpty else { + return false + } + + let globber = Globber(URL(fileURLWithPath: "/")) + + for pattern in patterns { + // Try to match the pattern against the path + let pathToMatch = isDirectory ? path + "/" : path + + let matchesWithSlash = try globber.glob(pathToMatch, pattern) + if matchesWithSlash { + return true + } + + // Also try without the trailing slash for directories + if isDirectory { + let matchesWithoutSlash = try globber.glob(path, pattern) + if matchesWithoutSlash { + return true + } + } + + // Check if pattern matches with ** prefix for nested paths + let shouldAddPrefix = !pattern.hasPrefix("**/") && !pattern.hasPrefix("/") + if shouldAddPrefix { + let matchesWithPrefix = try globber.glob(pathToMatch, "**/" + pattern) + if matchesWithPrefix { + return true + } + } + } + + return false + } + private func processDirectory( _ currentDir: String, inputEntries: [String: Set], From fba7a782eaa582e9b6409593f9ab8a6002595cca Mon Sep 17 00:00:00 2001 From: Tor Arvid Lund Date: Tue, 18 Nov 2025 07:26:47 +0100 Subject: [PATCH 3/5] Refactor dockerignore logic into IgnoreSpec type This addresses feedback from jglogan in PR #879. --- Sources/ContainerBuild/BuildFSSync.swift | 71 +++----------- Sources/ContainerBuild/IgnoreSpec.swift | 93 +++++++++++++++++++ ...gnoreTests.swift => IgnoreSpecTests.swift} | 56 ++++++++++- 3 files changed, 159 insertions(+), 61 deletions(-) create mode 100644 Sources/ContainerBuild/IgnoreSpec.swift rename Tests/ContainerBuildTests/{DockerignoreTests.swift => IgnoreSpecTests.swift} (80%) diff --git a/Sources/ContainerBuild/BuildFSSync.swift b/Sources/ContainerBuild/BuildFSSync.swift index 38351e75..c7cfb745 100644 --- a/Sources/ContainerBuild/BuildFSSync.swift +++ b/Sources/ContainerBuild/BuildFSSync.swift @@ -136,8 +136,17 @@ actor BuildFSSync: BuildPipelineHandler { var entries: [String: Set] = [:] let followPaths: [String] = packet.followPaths() ?? [] - // Parse .dockerignore if present - let ignorePatterns = try parseDockerignore() + // Load .dockerignore if present + let dockerignorePath = contextDir.appendingPathComponent(".dockerignore") + let ignoreSpec: IgnoreSpec? = { + guard FileManager.default.fileExists(atPath: dockerignorePath.path) else { + return nil + } + guard let data = try? Data(contentsOf: dockerignorePath) else { + return nil + } + return IgnoreSpec(data) + }() let followPathsWalked = try walk(root: self.contextDir, includePatterns: followPaths) for url in followPathsWalked { @@ -151,7 +160,7 @@ actor BuildFSSync: BuildPipelineHandler { let relPath = try url.relativeChildPath(to: contextDir) // Check if the file should be ignored - if try shouldIgnore(relPath, patterns: ignorePatterns, isDirectory: url.hasDirectoryPath) { + if let ignoreSpec = ignoreSpec, try ignoreSpec.shouldIgnore(relPath: relPath, isDirectory: url.hasDirectoryPath) { continue } @@ -287,62 +296,6 @@ actor BuildFSSync: BuildPipelineHandler { return Array(globber.results) } - /// Parse .dockerignore file and return list of ignore patterns - private func parseDockerignore() throws -> [String] { - let dockerignorePath = contextDir.appendingPathComponent(".dockerignore") - - guard FileManager.default.fileExists(atPath: dockerignorePath.path) else { - return [] - } - - let contents = try String(contentsOf: dockerignorePath, encoding: .utf8) - - return - contents - .split(separator: "\n") - .map { line in line.trimmingCharacters(in: .whitespaces) } - .filter { line in !line.isEmpty && !line.hasPrefix("#") } - .map { String($0) } - } - - /// Check if a file path should be ignored based on .dockerignore patterns - private func shouldIgnore(_ path: String, patterns: [String], isDirectory: Bool) throws -> Bool { - guard !patterns.isEmpty else { - return false - } - - let globber = Globber(URL(fileURLWithPath: "/")) - - for pattern in patterns { - // Try to match the pattern against the path - let pathToMatch = isDirectory ? path + "/" : path - - let matchesWithSlash = try globber.glob(pathToMatch, pattern) - if matchesWithSlash { - return true - } - - // Also try without the trailing slash for directories - if isDirectory { - let matchesWithoutSlash = try globber.glob(path, pattern) - if matchesWithoutSlash { - return true - } - } - - // Check if pattern matches with ** prefix for nested paths - let shouldAddPrefix = !pattern.hasPrefix("**/") && !pattern.hasPrefix("/") - if shouldAddPrefix { - let matchesWithPrefix = try globber.glob(pathToMatch, "**/" + pattern) - if matchesWithPrefix { - return true - } - } - } - - return false - } - private func processDirectory( _ currentDir: String, inputEntries: [String: Set], diff --git a/Sources/ContainerBuild/IgnoreSpec.swift b/Sources/ContainerBuild/IgnoreSpec.swift new file mode 100644 index 00000000..12b32881 --- /dev/null +++ b/Sources/ContainerBuild/IgnoreSpec.swift @@ -0,0 +1,93 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import Foundation + +/// A type that handles .dockerignore pattern matching +struct IgnoreSpec { + private let patterns: [String] + + /// Initialize an IgnoreSpec from .dockerignore file data + /// - Parameter data: The contents of a .dockerignore file + init(_ data: Data) { + guard let contents = String(data: data, encoding: .utf8) else { + self.patterns = [] + return + } + + self.patterns = + contents + .split(separator: "\n") + .map { line in line.trimmingCharacters(in: .whitespaces) } + .filter { line in !line.isEmpty && !line.hasPrefix("#") } + .map { String($0) } + } + + /// Check if a file path should be ignored based on .dockerignore patterns + /// - Parameters: + /// - relPath: The relative path to check + /// - isDirectory: Whether the path is a directory + /// - Returns: true if the path should be ignored, false otherwise + func shouldIgnore(relPath: String, isDirectory: Bool) throws -> Bool { + guard !patterns.isEmpty else { + return false + } + + let globber = Globber(URL(fileURLWithPath: "/")) + + for pattern in patterns { + // According to Docker's .dockerignore spec, patterns match at any depth + // unless they start with / (which means root only) + + let pathToMatch = isDirectory ? relPath + "/" : relPath + + // Get the base name (last component) of the path for simple pattern matching + let pathComponents = relPath.split(separator: "/") + let baseName = String(pathComponents.last ?? "") + let baseNameToMatch = isDirectory ? baseName + "/" : baseName + + // Try exact match first (handles absolute patterns like /node_modules) + if try globber.glob(pathToMatch, pattern) { + return true + } + + // Also try without trailing slash for directories + if isDirectory { + let matchesNoSlash = try globber.glob(relPath, pattern) + if matchesNoSlash { + return true + } + } + + // For patterns without leading /, they should match at any depth + // Try matching against just the basename + if !pattern.hasPrefix("/") { + let baseMatches = try globber.glob(baseNameToMatch, pattern) + if baseMatches { + return true + } + if isDirectory { + let baseMatchesNoSlash = try globber.glob(baseName, pattern) + if baseMatchesNoSlash { + return true + } + } + } + } + + return false + } +} diff --git a/Tests/ContainerBuildTests/DockerignoreTests.swift b/Tests/ContainerBuildTests/IgnoreSpecTests.swift similarity index 80% rename from Tests/ContainerBuildTests/DockerignoreTests.swift rename to Tests/ContainerBuildTests/IgnoreSpecTests.swift index 6188b2f9..054dfb75 100644 --- a/Tests/ContainerBuildTests/DockerignoreTests.swift +++ b/Tests/ContainerBuildTests/IgnoreSpecTests.swift @@ -23,13 +23,13 @@ enum TestError: Error { case missingBuildTransfer } -@Suite class DockerignoreTests { +@Suite class IgnoreSpecTests { private var baseTempURL: URL private let fileManager = FileManager.default init() throws { self.baseTempURL = URL.temporaryDirectory - .appendingPathComponent("DockerignoreTests-\(UUID().uuidString)") + .appendingPathComponent("IgnoreSpecTests-\(UUID().uuidString)") try fileManager.createDirectory(at: baseTempURL, withIntermediateDirectories: true, attributes: nil) } @@ -51,6 +51,58 @@ enum TestError: Error { try #require(created) } + // MARK: - Unit tests for IgnoreSpec + + @Test func testIgnoreSpecParsesPatterns() throws { + let content = """ + # This is a comment + *.log + + node_modules + # Another comment + temp/ + """ + let data = content.data(using: .utf8)! + let spec = IgnoreSpec(data) + + // Test that it correctly ignores files matching patterns + #expect(try spec.shouldIgnore(relPath: "debug.log", isDirectory: false)) + #expect(try spec.shouldIgnore(relPath: "node_modules", isDirectory: true)) + #expect(try spec.shouldIgnore(relPath: "temp/", isDirectory: true)) + #expect(try !spec.shouldIgnore(relPath: "src/app.swift", isDirectory: false)) + } + + @Test func testIgnoreSpecHandlesEmptyData() throws { + let data = Data() + let spec = IgnoreSpec(data) + + // Empty spec should not ignore anything + #expect(try !spec.shouldIgnore(relPath: "anyfile.txt", isDirectory: false)) + } + + @Test func testIgnoreSpecHandlesNestedPaths() throws { + let content = "*.log" + let data = content.data(using: .utf8)! + let spec = IgnoreSpec(data) + + // Should match .log files in nested directories + #expect(try spec.shouldIgnore(relPath: "logs/debug.log", isDirectory: false)) + #expect(try spec.shouldIgnore(relPath: "app/logs/error.log", isDirectory: false)) + #expect(try !spec.shouldIgnore(relPath: "logs/debug.txt", isDirectory: false)) + } + + @Test func testIgnoreSpecHandlesDirectories() throws { + let content = "node_modules" + let data = content.data(using: .utf8)! + let spec = IgnoreSpec(data) + + // Should match directories + #expect(try spec.shouldIgnore(relPath: "node_modules", isDirectory: true)) + #expect(try spec.shouldIgnore(relPath: "src/node_modules", isDirectory: true)) + } + + // MARK: - Integration tests with BuildFSSync + @Test func testDockerignoreExcludesMatchingFiles() async throws { // Setup: Create a build context with files and .dockerignore let contextDir = baseTempURL.appendingPathComponent("build-context") From 884eb24bd66ccb02f264ec339dbd3a08f54d8413 Mon Sep 17 00:00:00 2001 From: Tor Arvid Lund Date: Mon, 17 Nov 2025 20:41:29 +0100 Subject: [PATCH 4/5] Support ** patterns. Move away from Globber --- Sources/ContainerBuild/IgnoreSpec.swift | 141 ++++++++++++++---- .../ContainerBuildTests/IgnoreSpecTests.swift | 46 +++++- 2 files changed, 158 insertions(+), 29 deletions(-) diff --git a/Sources/ContainerBuild/IgnoreSpec.swift b/Sources/ContainerBuild/IgnoreSpec.swift index 12b32881..3adc3a90 100644 --- a/Sources/ContainerBuild/IgnoreSpec.swift +++ b/Sources/ContainerBuild/IgnoreSpec.swift @@ -46,48 +46,133 @@ struct IgnoreSpec { return false } - let globber = Globber(URL(fileURLWithPath: "/")) - for pattern in patterns { - // According to Docker's .dockerignore spec, patterns match at any depth - // unless they start with / (which means root only) + if try matchesPattern(path: relPath, pattern: pattern, isDirectory: isDirectory) { + return true + } + } + + return false + } + + /// Match a path against a dockerignore pattern + /// - Parameters: + /// - path: The path to match + /// - pattern: The dockerignore pattern + /// - isDirectory: Whether the path is a directory + /// - Returns: true if the path matches the pattern + private func matchesPattern(path: String, pattern: String, isDirectory: Bool) throws -> Bool { + var pattern = pattern + + // Handle trailing slash on pattern (means directories only) + let dirOnly = pattern.hasSuffix("/") + if dirOnly { + pattern = String(pattern.dropLast()) + if !isDirectory { + return false + } + } + + // Handle root-only patterns (starting with /) + let rootOnly = pattern.hasPrefix("/") + if rootOnly { + pattern = String(pattern.dropFirst()) + } - let pathToMatch = isDirectory ? relPath + "/" : relPath + // Convert pattern to regex, handling ** specially + let regex = try patternToRegex(pattern: pattern, rootOnly: rootOnly) - // Get the base name (last component) of the path for simple pattern matching - let pathComponents = relPath.split(separator: "/") - let baseName = String(pathComponents.last ?? "") - let baseNameToMatch = isDirectory ? baseName + "/" : baseName + // Try matching the path (and with trailing slash for directories) + if path.range(of: regex, options: .regularExpression) != nil { + return true + } - // Try exact match first (handles absolute patterns like /node_modules) - if try globber.glob(pathToMatch, pattern) { + if isDirectory { + let pathWithSlash = path + "/" + if pathWithSlash.range(of: regex, options: .regularExpression) != nil { return true } + } + + return false + } + + /// Convert a dockerignore pattern to a regex pattern + /// - Parameters: + /// - pattern: The dockerignore pattern + /// - rootOnly: Whether the pattern should only match at root + /// - Returns: A regex pattern string + private func patternToRegex(pattern: String, rootOnly: Bool) throws -> String { + var result = "" + + // If not root-only, pattern can match at any depth + if !rootOnly && !pattern.hasPrefix("**/") { + // Pattern matches at any depth (like **/pattern) + result = "(?:^|.*/)" + } else { + result = "^" + } - // Also try without trailing slash for directories - if isDirectory { - let matchesNoSlash = try globber.glob(relPath, pattern) - if matchesNoSlash { - return true + // Process the pattern + var i = pattern.startIndex + while i < pattern.endIndex { + let char = pattern[i] + + // Handle ** + if char == "*" && pattern.index(after: i) < pattern.endIndex && pattern[pattern.index(after: i)] == "*" { + // Check if it's **/ or just ** + let afterStar = pattern.index(i, offsetBy: 2) + if afterStar < pattern.endIndex && pattern[afterStar] == "/" { + // **/ matches zero or more path segments + result += "(?:.*/)?" + i = pattern.index(after: afterStar) + continue + } else if afterStar == pattern.endIndex { + // ** at end matches anything + result += ".*" + i = afterStar + continue } } - // For patterns without leading /, they should match at any depth - // Try matching against just the basename - if !pattern.hasPrefix("/") { - let baseMatches = try globber.glob(baseNameToMatch, pattern) - if baseMatches { - return true + // Handle single * + if char == "*" { + result += "[^/]*" + i = pattern.index(after: i) + continue + } + + // Handle ? + if char == "?" { + result += "[^/]" + i = pattern.index(after: i) + continue + } + + // Handle character classes [...] + if char == "[" { + var j = pattern.index(after: i) + while j < pattern.endIndex && pattern[j] != "]" { + j = pattern.index(after: j) } - if isDirectory { - let baseMatchesNoSlash = try globber.glob(baseName, pattern) - if baseMatchesNoSlash { - return true - } + if j < pattern.endIndex { + let charClass = String(pattern[i...j]) + result += charClass + i = pattern.index(after: j) + continue } } + + // Escape special regex characters + let specialChars = "\\^$.+(){}|" + if specialChars.contains(char) { + result += "\\" + } + result += String(char) + i = pattern.index(after: i) } - return false + result += "$" + return result } } diff --git a/Tests/ContainerBuildTests/IgnoreSpecTests.swift b/Tests/ContainerBuildTests/IgnoreSpecTests.swift index 054dfb75..3cc524f2 100644 --- a/Tests/ContainerBuildTests/IgnoreSpecTests.swift +++ b/Tests/ContainerBuildTests/IgnoreSpecTests.swift @@ -68,7 +68,7 @@ enum TestError: Error { // Test that it correctly ignores files matching patterns #expect(try spec.shouldIgnore(relPath: "debug.log", isDirectory: false)) #expect(try spec.shouldIgnore(relPath: "node_modules", isDirectory: true)) - #expect(try spec.shouldIgnore(relPath: "temp/", isDirectory: true)) + #expect(try spec.shouldIgnore(relPath: "temp", isDirectory: true)) #expect(try !spec.shouldIgnore(relPath: "src/app.swift", isDirectory: false)) } @@ -101,6 +101,50 @@ enum TestError: Error { #expect(try spec.shouldIgnore(relPath: "src/node_modules", isDirectory: true)) } + @Test func testIgnoreSpecHandlesDoubleStarPatterns() throws { + let content = """ + **/*.log + **/temp + src/**/*.tmp + """ + let data = content.data(using: .utf8)! + let spec = IgnoreSpec(data) + + // **/*.log should match .log files at any depth + #expect(try spec.shouldIgnore(relPath: "debug.log", isDirectory: false)) + #expect(try spec.shouldIgnore(relPath: "logs/debug.log", isDirectory: false)) + #expect(try spec.shouldIgnore(relPath: "app/logs/error.log", isDirectory: false)) + #expect(try !spec.shouldIgnore(relPath: "debug.txt", isDirectory: false)) + + // **/temp should match temp directory at any depth + #expect(try spec.shouldIgnore(relPath: "temp", isDirectory: true)) + #expect(try spec.shouldIgnore(relPath: "build/temp", isDirectory: true)) + #expect(try spec.shouldIgnore(relPath: "app/cache/temp", isDirectory: true)) + + // src/**/*.tmp should match .tmp files only under src/ + #expect(try spec.shouldIgnore(relPath: "src/file.tmp", isDirectory: false)) + #expect(try spec.shouldIgnore(relPath: "src/nested/file.tmp", isDirectory: false)) + #expect(try !spec.shouldIgnore(relPath: "file.tmp", isDirectory: false)) + #expect(try !spec.shouldIgnore(relPath: "other/file.tmp", isDirectory: false)) + } + + @Test func testIgnoreSpecHandlesRootPatterns() throws { + let content = """ + /root-only.txt + /build + """ + let data = content.data(using: .utf8)! + let spec = IgnoreSpec(data) + + // /root-only.txt should only match at root + #expect(try spec.shouldIgnore(relPath: "root-only.txt", isDirectory: false)) + #expect(try !spec.shouldIgnore(relPath: "src/root-only.txt", isDirectory: false)) + + // /build should only match at root + #expect(try spec.shouldIgnore(relPath: "build", isDirectory: true)) + #expect(try !spec.shouldIgnore(relPath: "src/build", isDirectory: true)) + } + // MARK: - Integration tests with BuildFSSync @Test func testDockerignoreExcludesMatchingFiles() async throws { From 289ba5e874eed22a33d71ed05629b8effd492595 Mon Sep 17 00:00:00 2001 From: Tor Arvid Lund Date: Tue, 18 Nov 2025 07:14:32 +0100 Subject: [PATCH 5/5] Support negation operator '!' --- Sources/ContainerBuild/IgnoreSpec.swift | 33 +++++++++-- .../ContainerBuildTests/IgnoreSpecTests.swift | 58 +++++++++++++++++++ 2 files changed, 85 insertions(+), 6 deletions(-) diff --git a/Sources/ContainerBuild/IgnoreSpec.swift b/Sources/ContainerBuild/IgnoreSpec.swift index 3adc3a90..b1d925d8 100644 --- a/Sources/ContainerBuild/IgnoreSpec.swift +++ b/Sources/ContainerBuild/IgnoreSpec.swift @@ -46,13 +46,22 @@ struct IgnoreSpec { return false } + var shouldIgnore = false + + // Process patterns in order - later patterns override earlier ones for pattern in patterns { - if try matchesPattern(path: relPath, pattern: pattern, isDirectory: isDirectory) { - return true + // Check if this is a negation pattern + let isNegation = pattern.hasPrefix("!") + let actualPattern = isNegation ? String(pattern.dropFirst()) : pattern + + if try matchesPattern(path: relPath, pattern: actualPattern, isDirectory: isDirectory) { + // If it's a negation pattern, DON'T ignore (include the file) + // Otherwise, DO ignore + shouldIgnore = !isNegation } } - return false + return shouldIgnore } /// Match a path against a dockerignore pattern @@ -68,9 +77,6 @@ struct IgnoreSpec { let dirOnly = pattern.hasSuffix("/") if dirOnly { pattern = String(pattern.dropLast()) - if !isDirectory { - return false - } } // Handle root-only patterns (starting with /) @@ -84,6 +90,10 @@ struct IgnoreSpec { // Try matching the path (and with trailing slash for directories) if path.range(of: regex, options: .regularExpression) != nil { + // If dirOnly is set, ensure the matched path is actually a directory + if dirOnly && !isDirectory { + return false + } return true } @@ -94,6 +104,17 @@ struct IgnoreSpec { } } + // Also check if the path is inside a matched directory + // e.g., pattern "foo" should match "foo/bar.txt" + // This is done by checking if any parent directory matches + let pathComponents = path.split(separator: "/") + for i in 0..