| 1 | // SPDX-License-Identifier: AGPL-3.0-or-later |
| 2 | |
| 3 | package trigger |
| 4 | |
| 5 | import ( |
| 6 | "regexp" |
| 7 | "strings" |
| 8 | "sync" |
| 9 | ) |
| 10 | |
| 11 | // globCache memoizes the regex compilation per pattern. A workflow's |
| 12 | // `branches:` list compiles once and gets re-used across every |
| 13 | // candidate string (every changed path, every push event). |
| 14 | var globCache sync.Map |
| 15 | |
| 16 | // matchAny evaluates a list of GHA-style filter patterns against a |
| 17 | // candidate string and returns whether the candidate is included. |
| 18 | // |
| 19 | // Pattern semantics (subset of minimatch — what GHA's filter spec |
| 20 | // guarantees and what `on.push.branches`/`on.pull_request.paths`/etc. |
| 21 | // fixtures actually use): |
| 22 | // |
| 23 | // - Plain literal: `main` matches exactly "main". |
| 24 | // - `*` matches any sequence of non-`/` characters. |
| 25 | // - `**` matches any sequence including `/`. |
| 26 | // - `/**` at end matches zero or more trailing segments |
| 27 | // (so `feature/**` matches both `feature` and |
| 28 | // `feature/foo/bar`). |
| 29 | // - `!pattern` excludes. Evaluated in declaration order; |
| 30 | // last-match wins. (Mirrors minimatch.) |
| 31 | // |
| 32 | // Empty pattern list returns true — "no filter" means "match all" per |
| 33 | // GHA convention. |
| 34 | // |
| 35 | // A list of *only* exclusions is treated as "include everything that |
| 36 | // doesn't match the exclusions" — without that branch, a |
| 37 | // `branches: [!main]` filter would reject every push. |
| 38 | func matchAny(patterns []string, s string) bool { |
| 39 | if len(patterns) == 0 { |
| 40 | return true |
| 41 | } |
| 42 | matched := false |
| 43 | hasInclude := false |
| 44 | for _, p := range patterns { |
| 45 | if strings.HasPrefix(p, "!") { |
| 46 | if globMatch(p[1:], s) { |
| 47 | matched = false |
| 48 | } |
| 49 | continue |
| 50 | } |
| 51 | hasInclude = true |
| 52 | if globMatch(p, s) { |
| 53 | matched = true |
| 54 | } |
| 55 | } |
| 56 | if !hasInclude { |
| 57 | matched = true |
| 58 | for _, p := range patterns { |
| 59 | if strings.HasPrefix(p, "!") && globMatch(p[1:], s) { |
| 60 | matched = false |
| 61 | } |
| 62 | } |
| 63 | } |
| 64 | return matched |
| 65 | } |
| 66 | |
| 67 | // globMatch reports whether s matches the GHA-style pattern. The |
| 68 | // implementation translates the pattern to an anchored regex and |
| 69 | // memoizes the compile — patterns are repeatedly applied across many |
| 70 | // candidate strings (every changed path against a paths: filter). |
| 71 | func globMatch(pattern, s string) bool { |
| 72 | re := compilePattern(pattern) |
| 73 | return re.MatchString(s) |
| 74 | } |
| 75 | |
| 76 | // compilePattern converts a GHA-style filter pattern into an anchored |
| 77 | // regex. Memoized via globCache so a workflow's repeated `branches:` |
| 78 | // list compiles once per process lifetime. |
| 79 | func compilePattern(pattern string) *regexp.Regexp { |
| 80 | if v, ok := globCache.Load(pattern); ok { |
| 81 | return v.(*regexp.Regexp) |
| 82 | } |
| 83 | expr := patternToRegex(pattern) |
| 84 | re := regexp.MustCompile(expr) |
| 85 | globCache.Store(pattern, re) |
| 86 | return re |
| 87 | } |
| 88 | |
| 89 | // patternToRegex translates a single pattern into the regex source. |
| 90 | // Order of cases matters: `/**` (path-optional suffix) → `**` → |
| 91 | // single `*` → escaped literal. |
| 92 | func patternToRegex(p string) string { |
| 93 | var b strings.Builder |
| 94 | b.Grow(len(p) + 16) |
| 95 | b.WriteByte('^') |
| 96 | for i := 0; i < len(p); { |
| 97 | // `/**` — optional trailing path. Greedy match including zero |
| 98 | // segments. Mirrors `feature/**` matching `feature` itself. |
| 99 | if i+3 <= len(p) && p[i:i+3] == "/**" { |
| 100 | b.WriteString(`(?:/.*)?`) |
| 101 | i += 3 |
| 102 | continue |
| 103 | } |
| 104 | // `**/` — optional leading path. Mirrors `**/*.go` matching |
| 105 | // `main.go` (zero leading segments). Also handles middle |
| 106 | // occurrences like `docs/**/*.md` matching `docs/x.md`. |
| 107 | if i+3 <= len(p) && p[i:i+3] == "**/" { |
| 108 | b.WriteString(`(?:.*/)?`) |
| 109 | i += 3 |
| 110 | continue |
| 111 | } |
| 112 | // `**` — match any sequence of any characters (greedy). |
| 113 | if i+2 <= len(p) && p[i:i+2] == "**" { |
| 114 | b.WriteString(`.*`) |
| 115 | i += 2 |
| 116 | continue |
| 117 | } |
| 118 | // `*` — match any sequence of non-/ characters. |
| 119 | if p[i] == '*' { |
| 120 | b.WriteString(`[^/]*`) |
| 121 | i++ |
| 122 | continue |
| 123 | } |
| 124 | // Literal byte. Escape if it's a regex metachar. |
| 125 | c := p[i] |
| 126 | if isRegexMeta(c) { |
| 127 | b.WriteByte('\\') |
| 128 | } |
| 129 | b.WriteByte(c) |
| 130 | i++ |
| 131 | } |
| 132 | b.WriteByte('$') |
| 133 | return b.String() |
| 134 | } |
| 135 | |
| 136 | func isRegexMeta(c byte) bool { |
| 137 | switch c { |
| 138 | case '.', '+', '?', '(', ')', '[', ']', '{', '}', '|', '^', '$', '\\': |
| 139 | return true |
| 140 | } |
| 141 | return false |
| 142 | } |
| 143 |