| File: ChangedFileFilter.cs | Web Access |
| Project: src\tools\SelectTests\SelectTests.csproj (SelectTests) |
// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using System.Text; using System.Text.RegularExpressions; namespace Aspire.SelectTests; // The pre-filter stage of selective CI: drops changed files that require no CI at all (documentation, // agent skills/instructions, loose scripts, ...), reading the SAME pattern list the top-level ci.yml // skip gate uses -- eng/github-ci/ci-skip-entirely-patterns.txt -- so the two can never drift. // // Applied to the changed-file input of BOTH layers, BEFORE Layer 1 and Layer 2 run, so an excluded // file influences no selection. That is the crucial difference from `ignore:` (which only suppresses // the Layer 2 run-all fallback while Layer 1 still git-diffs and attributes the file -- e.g. a README.md // is a packed <None> item, so the graph would otherwise fan it out to the owning project's test closure). // // keep_routed (from the map's `prefilter` block) are carve-outs: files the patterns file lists but the // selector deliberately routes to a target (.github/workflows/** and eng/pipelines/** -> Infrastructure // .Tests, and the patterns file itself, which is a selector input). Those are never dropped. // // Pattern semantics MUST match the check-changed-files action (.github/actions/check-changed-files), so // the selector's "excluded" set equals the gate's "skip" set. The action interprets each pattern with // its own glob_to_regex (NOT Microsoft.Extensions.FileSystemGlobbing): `**` -> any chars incl. '/', // `*` -> any chars except '/', `.` literal, anchored. GlobToRegex below ports that verbatim. The // keep_routed carve-outs use the ordinary map glob matcher (TriggerMap.GlobMatches). internal sealed class ChangedFileFilter { private static readonly ChangedFileFilter s_empty = new(Array.Empty<Regex>(), Array.Empty<string>()); private readonly IReadOnlyList<Regex> _patternRegexes; private readonly IReadOnlyList<string> _keepRoutedGlobs; private ChangedFileFilter(IReadOnlyList<Regex> patternRegexes, IReadOnlyList<string> keepRoutedGlobs) { _patternRegexes = patternRegexes; _keepRoutedGlobs = keepRoutedGlobs; } public static ChangedFileFilter Create(string repoRoot, PrefilterConfig? prefilter) { if (prefilter?.PatternsFile is null) { return s_empty; } var patternsPath = Path.Combine(repoRoot, prefilter.PatternsFile); if (!File.Exists(patternsPath)) { throw new InvalidOperationException($"prefilter patterns_file not found: {patternsPath}"); } // Skip blank lines and '#' comments, exactly like the action. var regexes = File.ReadAllLines(patternsPath) .Select(line => line.Trim()) .Where(line => line.Length > 0 && !line.StartsWith('#')) .Select(GlobToRegex) .ToList(); return new ChangedFileFilter(regexes, prefilter.KeepRouted.ToList()); } // True when the repo-relative ('/'-separated) path should be dropped before selection: it matches a // patterns_file glob AND is not carved out by keep_routed. keep_routed wins, so a routed file is // never dropped even if the patterns file would otherwise skip it. public bool IsExcluded(string repoRelativePath) { foreach (var glob in _keepRoutedGlobs) { if (TriggerMap.GlobMatches(glob, repoRelativePath)) { return false; } } foreach (var regex in _patternRegexes) { if (regex.IsMatch(repoRelativePath)) { return true; } } return false; } // Verbatim port of .github/actions/check-changed-files/action.yml `glob_to_regex`: // ** -> .* (any chars including '/') // * -> [^/]* (any chars except '/') // . -> \. (literal dot) // the same regex metacharacters the action escapes (\ . + ? [ ] ( ) |) are escaped; { } ^ $ are // intentionally left unescaped (the action does the same), then the result is anchored ^...$. private static Regex GlobToRegex(string glob) { // Reserve placeholders for the two glob stars so escaping below cannot touch them. Use control // chars that cannot appear in a repo path. const char doubleStar = '\u0001'; const char star = '\u0002'; var withPlaceholders = glob .Replace("**", doubleStar.ToString(), StringComparison.Ordinal) .Replace("*", star.ToString(), StringComparison.Ordinal); var sb = new StringBuilder(withPlaceholders.Length + 8); foreach (var c in withPlaceholders) { switch (c) { case doubleStar: sb.Append(".*"); break; case star: sb.Append("[^/]*"); break; // Metacharacters the action escapes. case '\\': case '.': case '+': case '?': case '[': case ']': case '(': case ')': case '|': sb.Append('\\').Append(c); break; default: sb.Append(c); break; } } return new Regex("^" + sb + "$", RegexOptions.CultureInvariant); } }