Go · 4040 bytes Raw Blame History
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