-
Notifications
You must be signed in to change notification settings - Fork 3
feat(ignore matcher): support for .dockerignore for build context #673
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,290 @@ | ||
| import * as fs from 'node:fs/promises'; | ||
| import * as path from 'node:path'; | ||
|
|
||
| export type IgnoreKind = 'docker'; | ||
|
|
||
| /** | ||
| * Interface for ignore pattern matching. | ||
| * `matches(path)` returns true if the path should be ignored. | ||
| */ | ||
| export interface IgnoreMatcher { | ||
| matches(filePath: string): boolean; | ||
| } | ||
|
|
||
| /** | ||
| * Create an IgnoreMatcher from raw patterns for a given kind. | ||
| * Currently only supports 'docker' (.dockerignore semantics). | ||
| */ | ||
| export function createIgnoreMatcher(patterns: string[], kind: IgnoreKind = 'docker'): IgnoreMatcher | null { | ||
| const cleaned = normalizePatterns(patterns); | ||
| if (cleaned.length === 0) return null; | ||
|
|
||
| switch (kind) { | ||
| case 'docker': | ||
| default: | ||
| return new DockerIgnoreMatcher(cleaned); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Load an ignore matcher from a directory. | ||
| * For 'docker', this looks for `.dockerignore` in `dir`. | ||
| */ | ||
| export async function loadIgnoreMatcher( | ||
| dir: string, | ||
| kind: IgnoreKind = 'docker', | ||
| ): Promise<IgnoreMatcher | null> { | ||
| const filename = kind === 'docker' ? '.dockerignore' : null; | ||
| if (!filename) return null; | ||
|
|
||
| const ignorePath = path.join(dir, filename); | ||
|
|
||
| try { | ||
| const stats = await fs.stat(ignorePath); | ||
| if (typeof stats.isFile === 'function' && !stats.isFile()) { | ||
| return null; | ||
| } | ||
| } catch { | ||
| // No .dockerignore file – nothing to ignore | ||
| return null; | ||
| } | ||
|
|
||
| try { | ||
| const content = await fs.readFile(ignorePath, 'utf-8'); | ||
| const rawPatterns = content | ||
| .split(/\r?\n/) | ||
| .map((line) => line.trim()) | ||
| .filter((line) => line && !line.startsWith('#')); | ||
|
|
||
| return createIgnoreMatcher(rawPatterns, kind); | ||
| } catch { | ||
| // If we can't read/parse, fail open (no ignores) | ||
| return null; | ||
| } | ||
| } | ||
|
|
||
| function normalizePatterns(patterns: string[]): string[] { | ||
| return patterns | ||
| .map((p) => p.trim()) | ||
| .filter(Boolean) | ||
| .map((p) => { | ||
| // .dockerignore uses forward slashes | ||
| let pat = p.replace(/\\/g, '/'); | ||
| // Leading "/" anchors to context root; since we only ever match | ||
| // paths relative to the context root (no leading slash), we can | ||
| // drop this while preserving Docker semantics. | ||
| if (pat.startsWith('/')) pat = pat.slice(1); | ||
| // Remove redundant leading "./" | ||
| if (pat.startsWith('./')) pat = pat.slice(2); | ||
| // Remove trailing slash except root | ||
| if (pat.endsWith('/') && pat !== '/') pat = pat.slice(0, -1); | ||
| return pat; | ||
| }); | ||
| } | ||
|
|
||
| /** | ||
| * Load an ignore matcher from an explicit ignore file path. | ||
| */ | ||
| export async function loadIgnoreMatcherFromFile( | ||
| ignoreFilePath: string, | ||
| kind: IgnoreKind = 'docker', | ||
| ): Promise<IgnoreMatcher | null> { | ||
| try { | ||
| const stats = await fs.stat(ignoreFilePath); | ||
| if (typeof stats.isFile === 'function' && !stats.isFile()) { | ||
| return null; | ||
| } | ||
| } catch { | ||
| return null; | ||
| } | ||
|
|
||
| try { | ||
| const content = await fs.readFile(ignoreFilePath, 'utf-8'); | ||
| const rawPatterns = content | ||
| .split(/\r?\n/) | ||
| .map((line) => line.trim()) | ||
| .filter((line) => line && !line.startsWith('#')); | ||
|
|
||
| return createIgnoreMatcher(rawPatterns, kind); | ||
| } catch { | ||
| return null; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Docker-style matcher implementing the core behavior of moby/patternmatcher: | ||
| * - glob patterns with *, **, ?, and character classes | ||
| * - '!' exclusion patterns | ||
| * - parent-directory matches (MatchesOrParentMatches semantics) | ||
| */ | ||
| export class DockerIgnoreMatcher implements IgnoreMatcher { | ||
| private patterns: CompiledPattern[]; | ||
|
|
||
| constructor(patterns: string[]) { | ||
| this.patterns = patterns | ||
| .map((raw) => { | ||
| const trimmed = raw.trim(); | ||
| if (!trimmed || trimmed.startsWith('#')) return null; | ||
|
|
||
| // Handle exclusion prefix | ||
| let isExclusion = false; | ||
| let cleaned = trimmed; | ||
| if (cleaned.startsWith('!')) { | ||
| isExclusion = true; | ||
| cleaned = cleaned.slice(1); | ||
| } | ||
|
|
||
| // Normalize path separators | ||
| cleaned = cleaned.replace(/\\/g, '/'); | ||
|
|
||
| // Remove leading/trailing formatting | ||
| if (cleaned.startsWith('/')) cleaned = cleaned.slice(1); | ||
| if (cleaned.startsWith('./')) cleaned = cleaned.slice(2); | ||
| if (cleaned.endsWith('/') && cleaned !== '/') cleaned = cleaned.slice(0, -1); | ||
|
|
||
| const pattern = isExclusion ? '!' + cleaned : cleaned; | ||
| return new CompiledPattern(pattern, raw); | ||
| }) | ||
| .filter((p): p is CompiledPattern => p !== null); | ||
| } | ||
|
|
||
| matches(filePath: string): boolean { | ||
| // Always ignore the .dockerignore file itself | ||
| if (filePath === '.dockerignore') return true; | ||
|
|
||
| // Expect paths relative to context root, forward-slash separated | ||
| let normalized = filePath.replace(/\\/g, '/'); | ||
|
|
||
| // Strip leading ./ if present | ||
| if (normalized.startsWith('./')) normalized = normalized.slice(2); | ||
|
|
||
| // Strip trailing slash if present (unless it's just root) | ||
| if (normalized.endsWith('/') && normalized !== '/') normalized = normalized.slice(0, -1); | ||
|
|
||
| if (!normalized) normalized = '.'; | ||
|
|
||
| const parts = normalized.split('/'); | ||
| const parentPaths: string[] = []; | ||
| for (let i = 0; i < parts.length - 1; i++) { | ||
| parentPaths.push(parts.slice(0, i + 1).join('/')); | ||
| } | ||
|
|
||
| let matched = false; | ||
|
|
||
| for (const pattern of this.patterns) { | ||
| // If we are already ignored (matched=true), we only care about exceptions (exclusion=true). | ||
| // If we are NOT ignored (matched=false), we only care about ignores (exclusion=false). | ||
| if (pattern.exclusion !== matched) { | ||
| continue; | ||
| } | ||
|
|
||
| let isMatch = pattern.match(normalized); | ||
|
|
||
| if (!isMatch && parentPaths.length > 0) { | ||
| for (const parent of parentPaths) { | ||
| if (pattern.match(parent)) { | ||
| isMatch = true; | ||
| break; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| if (isMatch) { | ||
| matched = !pattern.exclusion; | ||
| } | ||
| } | ||
|
|
||
| return matched; | ||
| } | ||
| } | ||
|
|
||
| class CompiledPattern { | ||
| readonly raw: string; | ||
| readonly exclusion: boolean; | ||
| private readonly cleaned: string; | ||
| private readonly regexp: RegExp; | ||
|
|
||
| constructor(pattern: string, raw?: string) { | ||
| this.raw = raw || pattern; | ||
| if (pattern.startsWith('!')) { | ||
| this.exclusion = true; | ||
| this.cleaned = pattern.slice(1); | ||
| } else { | ||
| this.exclusion = false; | ||
| this.cleaned = pattern; | ||
| } | ||
|
|
||
| this.regexp = globToRegExp(this.cleaned); | ||
| } | ||
|
|
||
| match(target: string): boolean { | ||
| return this.regexp.test(target); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Convert a Docker-style glob pattern to a RegExp. | ||
| * Roughly mirrors moby/patternmatcher.compile: | ||
| * - '*' => any sequence except '/' | ||
| * - '**' => any sequence including '/' | ||
| * - '?' => any single char except '/' | ||
| * - character classes [] are passed through | ||
| */ | ||
| function globToRegExp(pattern: string): RegExp { | ||
| let re = '^'; | ||
| const len = pattern.length; | ||
|
|
||
| for (let i = 0; i < len; i++) { | ||
| const ch = pattern[i]!; | ||
| const next = i + 1 < len ? pattern[i + 1]! : ''; | ||
|
|
||
| if (ch === '*') { | ||
| if (next === '*') { | ||
| // "**" | ||
| i++; | ||
| const afterNext = i + 1 < len ? pattern[i + 1]! : ''; | ||
|
|
||
| if (afterNext === '/') { | ||
| // "**/" => any number of directories (including none) | ||
| i++; | ||
| re += '(?:.*/)?'; | ||
| } else if (i + 1 === len) { | ||
| // trailing "**" | ||
| re += '.*'; | ||
| } else { | ||
| // general "**" in middle | ||
| re += '.*'; | ||
| } | ||
| } else { | ||
| // "*" => anything but '/' | ||
| re += '[^/]*'; | ||
| } | ||
| } else if (ch === '?') { | ||
| re += '[^/]'; | ||
| } else if (ch === '[') { | ||
| // Character class: copy through until closing ']' | ||
| let j = i + 1; | ||
| let cls = '['; | ||
| while (j < len && pattern[j] !== ']') { | ||
| const c = pattern[j]!; | ||
| cls += c === '\\' ? '\\\\' : c; | ||
| j++; | ||
| } | ||
| if (j < len && pattern[j] === ']') { | ||
| cls += ']'; | ||
| re += cls; | ||
| i = j; | ||
| } else { | ||
| // Unterminated '[', treat literally | ||
| re += '\\['; | ||
| } | ||
| } else if ('().+|{}^$\\'.includes(ch)) { | ||
| re += '\\' + ch; | ||
| } else { | ||
| re += ch; | ||
| } | ||
| } | ||
|
|
||
| re += '$'; | ||
| return new RegExp(re); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Suggestion: The matcher currently treats
.dockerignorelike any other file, so unless explicitly excluded by a pattern it will be included in the uploaded context, which diverges from Docker CLI behavior where the.dockerignorefile itself is always excluded from the build context. [logic error]Severity Level: Minor⚠️
Why it matters? ⭐
Docker's CLI always excludes the .dockerignore file from the build context. The current implementation applies only the provided patterns and therefore will include '.dockerignore' unless a user pattern excludes it. Adding a small explicit check to always ignore '.dockerignore' reproduces Docker behavior and prevents accidental inclusion of the ignore file in contexts or archives produced by the tooling.
Prompt for AI Agent 🤖