Actions: branches:, tags: and paths: patterns follow GitHub's cheat sheet

91138c0 · Gabriel Morell · 2026-10-17 17:14

12 files +293 -70
Message
{commit_body(@commit)}

Files changed

modified PROGRESS.md
+1 −1
@@ -37,7 +37,7 @@ each gap below was a wall that pipeline hit.
37 37 - **`paths:` / `paths-ignore:`** are honoured for `push`, with GitHub's semantics and pattern syntax (`GitGud.Workflows.Glob`). Every push used to run every workflow: bigtrippr's Android workflow, filtered to `android/**`, ran its tests and re-published an identical APK under a new versionCode on each server-only push. The push worker now passes the ref's old SHA; dispatch lists the changed files once (`Git.changed_paths/3`, `git diff --name-only --no-renames -z`), only when some workflow filters on paths. A new branch is diffed from its merge-base with the default branch. Tags and manual runs ignore path filters; anything that can't be determined (or > 3000 files) fails open.
38 38 - **`github.event_name`** is set in every job's context. It was empty, so a bigtrippr job gated on `github.event_name == 'push'` never ran.
39 39 - **`on: pull_request`** for same-repository pull requests: `opened` (PR creation), `synchronize` (a push that moves an open PR's head, attributed to the pusher) and `reopened` (which first catches the head up with the branch, since closed PRs aren't refreshed on push), each through the `Workflows.Workers.PullRequestDispatch` Oban job so the LiveView and the push worker never wait on it. `Workflows.dispatch_pull_request/3` reads workflows from the PR head, pins `refs/pull/<N>/head`, and records the PR number and branches on the run (migration `add_pull_request_to_workflow_runs`) for the job's `head_ref` / `base_ref`. `types:`, `branches:` / `branches-ignore:` (the target branch) and `paths:` are honoured; `branches-ignore:` (and `tags-ignore:`) now work for `push` too. Fork and federated PRs are not dispatched — they'd run with the target's secrets — and the head is tested, not a merge result.
40 −- Follow-up: `branches:` / `tags:` still use the old matcher, whose `*` crosses `/` (GitHub's doesn't). Left alone because changing it changes which existing branch filters fire.
40 +- ~~Follow-up: `branches:` / `tags:` still use the old matcher, whose `*` crosses `/`.~~ Done: `branches:` / `tags:` / `paths:` and their `-ignore` lists share `GitGud.Workflows.Glob`, with GitHub's cheat sheet. **Behaviour change**: a workflow with `branches: ['*']` stops running for `feature/x` (use `'**'`); `!pattern` excludes in `branches:` / `tags:`; `?` / `+` are quantifiers on the preceding character (`?` used to be any one character in `paths:`); a backslash escapes (`\*`). Branch-protection patterns (`BranchProtections.matches_pattern?/2`), whose `*` also crosses `/`, are deliberately unchanged — narrowing them would unprotect nested branches; the maintainer's call.
41 41
42 42 ### Runner protocol
43 43
modified README.md
+2 −1
@@ -90,7 +90,8 @@ reinvent them.
90 90 - **Five deployment styles** offered in the registration UI: Native binary, Docker, Podman, Podman Quadlet, Kubernetes (Deployment + dind sidecar) — copy-pasteable `register` + `daemon` snippets per style
91 91 - **Triggers**: `push` (with `branches:` / `branches-ignore:` / `tags:` / `tags-ignore:`, GitHub's rule that naming one kind excludes the other kind of ref, and a list beating its `-ignore` if both are set), `pull_request` and `workflow_dispatch`
92 92 - **`on: pull_request`** for pull requests within one repository: `types:` `opened`, `synchronize` (the head moved) and `reopened` (the default; other activity types never fire). `branches:` / `branches-ignore:` filter the **target** branch, and `paths:` the PR's diff from its merge-base. Workflows are read from the PR's head commit, so a PR can change its own CI, and the job tests the head itself at `refs/pull/<N>/head` — not a merge with the target, as GitHub's `refs/pull/<N>/merge` does. The job gets `github.event_name`, `head_ref` and `base_ref` (the source and target branches), `ref_name` `<N>/head` and an empty `ref_type`. **Fork and federated pull requests don't run yet**: their head is code the target's maintainers haven't reviewed, and a job is handed the target repository's secrets and a token that can publish its packages; GitHub runs them without secrets and with a read-only token, and until that exists here they don't run at all. `pull_request_target` isn't supported
93 − - **`paths:` / `paths-ignore:`** with GitHub's pattern rules (`*` stays within a directory, `**` crosses them, `?`, `[a-z]`; in `paths:`, `!pattern` un-matches and a later positive pattern re-matches). A push runs if any changed file is selected by `paths:`, or if not every changed file is matched by `paths-ignore:`; both set → `paths:` wins, with a warning. Changed files are the ref's old → new diff (renames count as both paths); a new branch is diffed from where it left the default branch. Tag pushes and manual runs ignore path filters, and when the changed files can't be worked out (no default branch to compare with, an unknown base, a git error, more than 3000 files) the filter passes — a run is never skipped because the forge couldn't look
93 + - **Filter patterns** in `branches:`, `tags:`, `paths:` and their `-ignore` lists follow GitHub's cheat sheet: `*` is any run of characters but `/`, `**` any run at all (`**/` also no directory), `?` zero or one and `+` one or more of the **preceding** character, `[a-z0-9]` one listed character (alphanumerics and `a-z`/`A-Z`/`0-9` ranges only), `\x` a literal `x`. In `branches:`, `tags:` and `paths:` the last matching pattern decides, so `!pattern` excludes and a later positive pattern includes again; a list with only `!` patterns matches nothing. `*` no longer crosses `/` in `branches:` / `tags:` (`branches: ['*']` doesn't run for `feature/x`), and `!` works there now. Branch protection and required-check patterns are separate: their `*` still crosses `/`
94 + - **`paths:` / `paths-ignore:`**: a push runs if any changed file is selected by `paths:`, or if not every changed file is matched by `paths-ignore:`; both set → `paths:` wins, with a warning. Changed files are the ref's old → new diff (renames count as both paths); a new branch is diffed from where it left the default branch. Tag pushes and manual runs ignore path filters, and when the changed files can't be worked out (no default branch to compare with, an unknown base, a git error, more than 3000 files) the filter passes — a run is never skipped because the forge couldn't look
94 95 - **The job's context** follows Forgejo's: `github.event_name`, `ref`, `ref_name`, `ref_type`, `sha`, `repository` (`owner/name`), `repository_id`, `repository_owner(_id)`, `actor(_id)`, `run_id`, `run_number`, `run_attempt` (1, bumped by re-running the job; re-running the whole run makes a new run and a new `run_id`), `workflow`, `workflow_ref` (`owner/name/<path>@<ref>`), `job`, `server_url`, `api_url`, `token`. **`github.event`** is GitHub's webhook payload, trimmed: every event has `repository` and `sender`; `push` has `ref`, `before` (40 zeros for a new ref), `after`, `created`, `deleted`, `forced` (always `false`: not tracked), `compare`, `commits` (at most 20) and `head_commit`, `pusher`; `pull_request` has `action`, `number` and `pull_request` (`title`, `body`, `html_url`, `user`, `head`/`base` with `ref`, `sha`, `label`, `repo`); `workflow_dispatch` has `inputs`, `ref`, `workflow`. **`secrets.GITHUB_TOKEN`** (and `GITEA_TOKEN`, `FORGEJO_TOKEN`) is the job token. The workflow's top-level `env:`, `defaults:`, `permissions:`, `run-name:` and `on:` reach the runner with the job
95 96 - **Jobs that depend on jobs**, as on GitHub: `${{ needs.<id>.outputs.* }}` and `needs.<id>.result` (`success`, `failure`, `cancelled`, `skipped`; for a matrix, the worst leg's, with the legs' outputs merged) reach the dependent job. A job waits until all its needs finish; if any didn't succeed it is **skipped** — and its own dependents with it — unless its `if:` calls `always()`, `failure()` or `cancelled()`, so cleanup and "report the failure" jobs run. A job its own `if:` skipped shows as skipped, not green. Job-level `continue-on-error: true` (or `${{ matrix.* }}` that comes out `true`) keeps a failure from skipping its dependents, failing the run or fail-fasting its matrix siblings, though the job still shows as failed; any other expression there counts as false. Skipped jobs don't fail a run. Unlike GitHub, **cancelling a run cancels its waiting jobs too**, `always()` or not
96 97 - Re-run / cancel buttons on the workflow show page. **Cancelling stops running jobs**: the runner hears it in the reply to its next progress report and stops the job, still uploading its log; a cancelled job stays cancelled whatever its runner reports afterwards
modified lib/git_gud/workflows/glob.ex
+117 −33
@@ -1,26 +1,37 @@
1 1 defmodule GitGud.Workflows.Glob do
2 2 @moduledoc """
3 − File path patterns as workflows write them under `paths:` and
4 − `paths-ignore:`, with GitHub's rules (its "filter pattern cheat sheet"),
5 − because that is the documentation the patterns are copied from:
3 + Filter patterns as workflows write them under `branches:`, `tags:`,
4 + `paths:` and their `-ignore` lists, with GitHub's rules (its "filter
5 + pattern cheat sheet"), because that is the documentation the patterns
6 + are copied from:
6 7
7 8 * `*` matches any run of characters except `/`
8 9 * `**` matches any run of characters including `/`; `**/` also matches
9 10 no directory at all, so `**/*.md` matches `README.md`
10 − * `?` matches one character except `/`
11 − * `[abc]`, `[a-z]` are character classes
11 + * `?` makes the preceding character (or class) optional, `+` repeats
12 + it one or more times — regex quantifiers, not wildcards. At the start
13 + of a pattern, or after `*`, `**` or another quantifier, they are
14 + literal
15 + * `[abc]`, `[a-z]` match one character listed or in a range; only
16 + alphanumerics and ranges within `a-z`, `A-Z`, `0-9`. Anything else
17 + in the brackets (`[!a]`, `[^a]`, `[.]`) makes the `[` literal
18 + * `\\x` is a literal `x` (`\\*`, `\\[`, `\\!`)
12 19 * everything else is literal — `.` is a dot
13 20
14 − Patterns are relative to the repository root and anchored at both ends.
15 − A leading `!` (negation) is the caller's business: it means something
16 − only in a list of patterns, see `GitGud.Workflows.Yaml`.
21 + Patterns are anchored at both ends. A leading `!` negates, and means
22 + something only in a list: see `select?/2`.
17 23
18 − Not used for `branches:` / `tags:`, whose `*` still crosses `/`; making
19 − those follow the same rule would change which existing filters fire.
24 + Not used for branch protection or required checks, whose `*` crosses `/`
25 + (`GitGud.BranchProtections`).
20 26 """
21 27
22 28 import Kernel, except: [match?: 2]
23 29
30 + require Logger
31 +
32 + @typedoc "A list compiled by `compile_list/1`: `{positive?, regex}` in order."
33 + @type rules :: [{boolean(), Regex.t()}]
34 +
24 35 @doc """
25 36 Compile `pattern` into an anchored regex.
26 37
@@ -29,48 +40,121 @@ defmodule GitGud.Workflows.Glob do
29 40 """
30 41 @spec compile(String.t()) :: Regex.t()
31 42 def compile(pattern) when is_binary(pattern) do
32 − Regex.compile!("^" <> translate(pattern, "") <> "$")
43 + Regex.compile!("^" <> render(translate(pattern, [])) <> "$")
33 44 end
34 45
35 46 @doc """
36 − Whether `path` matches `pattern` (a string, or a regex from `compile/1`).
47 + Whether `string` matches `pattern` (a string, or a regex from `compile/1`).
37 48
38 49 iex> GitGud.Workflows.Glob.match?("**/*.md", "README.md")
39 50 true
40 51 """
41 52 @spec match?(String.t() | Regex.t(), String.t()) :: boolean()
42 − def match?(%Regex{} = regex, path) when is_binary(path), do: Regex.match?(regex, path)
43 − def match?(pattern, path) when is_binary(pattern), do: Regex.match?(compile(pattern), path)
53 + def match?(%Regex{} = regex, string) when is_binary(string), do: Regex.match?(regex, string)
54 + def match?(pattern, string) when is_binary(pattern), do: Regex.match?(compile(pattern), string)
55 +
56 + @doc """
57 + Compile a list of patterns for `select?/2`, so a list checked against
58 + many strings (a push's changed files) is compiled once. A list with no
59 + positive pattern selects nothing — GitHub rejects it — and is logged.
60 + """
61 + @spec compile_list([String.t()]) :: rules()
62 + def compile_list(patterns) when is_list(patterns) do
63 + rules =
64 + Enum.map(patterns, fn
65 + "!" <> pattern -> {false, compile(pattern)}
66 + pattern -> {true, compile(pattern)}
67 + end)
68 +
69 + if rules != [] and not Enum.any?(rules, &elem(&1, 0)) do
70 + Logger.warning(
71 + "filter #{inspect(patterns)} has only negative patterns, so it matches nothing"
72 + )
73 + end
74 +
75 + rules
76 + end
77 +
78 + @doc """
79 + Whether a list of patterns selects `string`: the **last** pattern that
80 + matches decides, `!pattern` deselecting and a later positive pattern
81 + selecting again. Nothing matching, or no positive pattern at all, is
82 + `false`. Takes patterns or a list from `compile_list/1`.
83 +
84 + iex> GitGud.Workflows.Glob.select?(["*", "!b*", "beta"], "beta")
85 + true
86 + iex> GitGud.Workflows.Glob.select?(["*", "!b*", "beta"], "bravo")
87 + false
88 + """
89 + @spec select?([String.t()] | rules(), String.t()) :: boolean()
90 + def select?([{positive?, %Regex{}} | _] = rules, string) when is_boolean(positive?) do
91 + Enum.reduce(rules, false, fn {positive?, regex}, selected? ->
92 + if Regex.match?(regex, string), do: positive?, else: selected?
93 + end)
94 + end
95 +
96 + def select?([], _string), do: false
97 +
98 + def select?(patterns, string) when is_list(patterns),
99 + do: select?(compile_list(patterns), string)
100 +
101 + # The pattern becomes a list of atoms, most recent first, so a `?` or `+`
102 + # can see what it follows:
103 + #
104 + # {:lit, char} | {:class, body} | {:quant, atom, "?" | "+"}
105 + # | :star | :globstar | :globstar_slash
106 + defp translate("\\" <> <<c::utf8, rest::binary>>, acc),
107 + do: translate(rest, [{:lit, <<c::utf8>>} | acc])
44 108
45 109 # `**/` — zero or more whole directories.
46 − defp translate("**/" <> rest, acc), do: translate(rest, acc <> "(?:.*/)?")
110 + defp translate("**/" <> rest, acc), do: translate(rest, [:globstar_slash | acc])
47 111 # `**` anywhere else (`docs/**`, `**.js`, bare `**`) — anything at all.
48 − defp translate("**" <> rest, acc), do: translate(rest, acc <> ".*")
49 − defp translate("*" <> rest, acc), do: translate(rest, acc <> "[^/]*")
50 − defp translate("?" <> rest, acc), do: translate(rest, acc <> "[^/]")
112 + defp translate("**" <> rest, acc), do: translate(rest, [:globstar | acc])
113 + defp translate("*" <> rest, acc), do: translate(rest, [:star | acc])
114 +
115 + defp translate(<<q, rest::binary>>, [{kind, _} = atom | acc])
116 + when q in [??, ?+] and kind in [:lit, :class],
117 + do: translate(rest, [{:quant, atom, <<q>>} | acc])
51 118
52 119 defp translate("[" <> rest, acc) do
53 120 case class(rest, "") do
54 − {:ok, body, rest} -> translate(rest, acc <> "[" <> body <> "]")
55 − # No closing bracket: the `[` was just a character.
56 − :error -> translate(rest, acc <> "\\[")
121 + {:ok, body, rest} -> translate(rest, [{:class, body} | acc])
122 + # Not a class the cheat sheet allows: the `[` was just a character.
123 + :error -> translate(rest, [{:lit, "["} | acc])
57 124 end
58 125 end
59 126
60 − defp translate(<<c::utf8, rest::binary>>, acc),
61 − do: translate(rest, acc <> Regex.escape(<<c::utf8>>))
127 + defp translate(<<c::utf8, rest::binary>>, acc), do: translate(rest, [{:lit, <<c::utf8>>} | acc])
128 + defp translate("", acc), do: Enum.reverse(acc)
62 129
63 − defp translate("", acc), do: acc
130 + defp render(atoms), do: Enum.map_join(atoms, &atom/1)
64 131
65 − # The inside of `[...]`: ranges (`-`) and a leading `^` keep their regex
66 − # meaning; anything else that a regex class would read specially is escaped.
67 − defp class("]" <> rest, body) when body not in ["", "^"], do: {:ok, body, rest}
68 − defp class("-" <> rest, body), do: class(rest, body <> "-")
69 − defp class("^" <> rest, ""), do: class(rest, "^")
132 + defp atom({:lit, char}), do: Regex.escape(char)
133 + defp atom({:class, body}), do: "[" <> body <> "]"
134 + # The regex is byte-wise, so a quantified multibyte character is grouped.
135 + defp atom({:quant, {:lit, char}, q}) when byte_size(char) > 1,
136 + do: "(?:" <> Regex.escape(char) <> ")" <> q
70 137
71 − defp class(<<c::utf8, rest::binary>>, body) when c in [?\\, ?[, ?], ?^],
72 − do: class(rest, body <> "\\" <> <<c::utf8>>)
138 + defp atom({:quant, atom, q}), do: atom(atom) <> q
139 + defp atom(:star), do: "[^/]*"
140 + defp atom(:globstar), do: ".*"
141 + defp atom(:globstar_slash), do: "(?:.*/)?"
73 142
74 − defp class(<<c::utf8, rest::binary>>, body), do: class(rest, body <> <<c::utf8>>)
75 − defp class("", _body), do: :error
143 + # The inside of `[...]`: alphanumerics, and ranges whose ends are both
144 + # lowercase letters, both uppercase, or both digits. Anything else — and
145 + # an empty `[]` — isn't a class.
146 + defp class("]" <> rest, body) when body != "", do: {:ok, body, rest}
147 +
148 + defp class(<<lo, ?-, hi, rest::binary>>, body) when hi != ?] do
149 + if range?(lo, hi), do: class(rest, body <> <<lo, ?-, hi>>), else: :error
150 + end
151 +
152 + defp class(<<c, rest::binary>>, body) when c in ?a..?z or c in ?A..?Z or c in ?0..?9,
153 + do: class(rest, body <> <<c>>)
154 +
155 + defp class(_other, _body), do: :error
156 +
157 + defp range?(lo, hi) do
158 + lo <= hi and Enum.any?([?a..?z, ?A..?Z, ?0..?9], &(lo in &1 and hi in &1))
159 + end
76 160 end
modified lib/git_gud/workflows/yaml.ex
+8 −26
@@ -113,6 +113,10 @@ defmodule GitGud.Workflows.Yaml do
113 113 `reopened`) must contain `ctx[:action]`.
114 114 * `paths:` / `paths-ignore:` are checked against `ctx[:changed_paths]`
115 115 once the ref filter has passed; see `paths_match?/3`'s comment.
116 + * All of them take `GitGud.Workflows.Glob` patterns (`*` stops at `/`).
117 + In `branches:`, `tags:` and `paths:` the last matching pattern
118 + decides and `!pattern` excludes (`Glob.select?/2`); an `-ignore`
119 + list excludes whatever any of its patterns matches.
116 120 """
117 121 def matches?(%{triggers: triggers} = doc, event, ctx \\ %{}) when is_map(triggers) do
118 122 # `Map.fetch`, not `Map.get`: a bare `on: push:` with nothing under it
@@ -171,7 +175,7 @@ defmodule GitGud.Workflows.Yaml do
171 175 not Enum.any?(other_keys, &Map.has_key?(config, &1))
172 176
173 177 {nil, ignored} ->
174 − not Enum.any?(List.wrap(ignored), &glob_match?(to_string(&1), name))
178 + not Enum.any?(patterns(ignored), &Glob.match?(&1, name))
175 179
176 180 {patterns, ignored} ->
177 181 if ignored != nil do
@@ -180,7 +184,7 @@ defmodule GitGud.Workflows.Yaml do
180 184 )
181 185 end
182 186
183 − Enum.any?(List.wrap(patterns), &glob_match?(to_string(&1), name))
187 + Glob.select?(patterns(patterns), name)
184 188 end
185 189 end
186 190
@@ -237,32 +241,10 @@ defmodule GitGud.Workflows.Yaml do
237 241 )
238 242 end
239 243
240 − rules =
241 − Enum.map(patterns(paths), fn
242 − "!" <> pattern -> {false, Glob.compile(pattern)}
243 − pattern -> {true, Glob.compile(pattern)}
244 − end)
245 −
246 − Enum.any?(changed, &path_selected?(rules, &1))
244 + rules = Glob.compile_list(patterns(paths))
245 + Enum.any?(changed, &Glob.select?(rules, &1))
247 246 end
248 247 end
249 248
250 − defp path_selected?(rules, file) do
251 − Enum.reduce(rules, false, fn {positive?, regex}, selected? ->
252 − if Glob.match?(regex, file), do: positive?, else: selected?
253 − end)
254 − end
255 −
256 249 defp patterns(list), do: list |> List.wrap() |> Enum.map(&to_string/1)
257 −
258 − defp glob_match?(pattern, str) do
259 − # Cheap fnmatch: treat * as .*, ? as ., everything else literal.
260 − regex =
261 − pattern
262 − |> Regex.escape()
263 − |> String.replace("\\*", ".*")
264 − |> String.replace("\\?", ".")
265 −
266 − Regex.match?(~r/^#{regex}$/, str)
267 − end
268 250 end
modified test/git_gud/workflows/glob_test.exs
+95 −9
@@ -1,8 +1,9 @@
1 1 defmodule GitGud.Workflows.GlobTest do
2 2 @moduledoc """
3 − `paths:` patterns follow GitHub's cheat sheet, not shell globbing and not
4 − the looser matcher `branches:` uses: a workflow copied from GitHub's docs
5 − has to claim the same files here.
3 + Filter patterns (`branches:`, `tags:`, `paths:`) follow GitHub's cheat
4 + sheet, not shell globbing: a workflow copied from GitHub's docs has to
5 + claim the same refs and files here. `?` and `+` are quantifiers there, not
6 + wildcards.
6 7 """
7 8 use ExUnit.Case, async: true
8 9
@@ -42,10 +43,44 @@ defmodule GitGud.Workflows.GlobTest do
42 43 assert m?("**", "anything/at/all")
43 44 end
44 45
45 − test "? is one character, never a slash" do
46 − assert m?("a?c", "abc")
47 − refute m?("a?c", "ac")
48 − refute m?("a?c", "a/c")
46 + # `?` used to be "any one character" (`a?c` matched `abc`); on GitHub it
47 + # makes the preceding character optional.
48 + test "? is zero or one of the preceding character" do
49 + assert m?("colou?r", "color")
50 + assert m?("colou?r", "colour")
51 + refute m?("colou?r", "colouur")
52 + assert m?("a?c", "ac")
53 + assert m?("a?c", "c")
54 + refute m?("a?c", "abc")
55 + end
56 +
57 + test "+ is one or more of the preceding character" do
58 + assert m?("v1+", "v1")
59 + assert m?("v1+", "v11")
60 + refute m?("v1+", "v")
61 + assert m?("a+b", "aaab")
62 + refute m?("a+b", "b")
63 + end
64 +
65 + test "quantifiers apply to a class" do
66 + assert m?("v[0-9]+.[0-9]+", "v10.233")
67 + refute m?("v[0-9]+.[0-9]+", "v.1")
68 + assert m?("[ab]?c", "c")
69 + end
70 +
71 + test "a quantifier applies to a whole multibyte character" do
72 + assert m?("café+", "cafééé")
73 + refute m?("café+", "café\xA9")
74 + end
75 +
76 + test "a ? or + with nothing to quantify is literal" do
77 + assert m?("?a", "?a")
78 + refute m?("?a", "a")
79 + assert m?("+a", "+a")
80 + assert m?("*?", "x?")
81 + refute m?("*?", "x")
82 + assert m?("a++", "aa+")
83 + refute m?("a++", "aaa")
49 84 end
50 85
51 86 test "character classes and ranges" do
@@ -54,16 +89,37 @@ defmodule GitGud.Workflows.GlobTest do
54 89 refute m?("[ab].txt", "c.txt")
55 90 assert m?("v[0-9]", "v7")
56 91 refute m?("v[0-9]", "vx")
92 + assert m?("[CB]at", "Cat")
93 + assert m?("[CB]at", "Bat")
94 + refute m?("[CB]at", "cat")
95 + assert m?("[a-zA-Z0-9]", "Q")
57 96 end
58 97
59 − test "an unclosed bracket is literal" do
98 + test "a bracket the cheat sheet doesn't allow is literal" do
60 99 assert m?("a[b", "a[b")
100 + assert m?("[!a]", "[!a]")
101 + refute m?("[!a]", "b")
102 + assert m?("[^a]", "[^a]")
103 + assert m?("[.]", "[.]")
104 + assert m?("[]", "[]")
105 + assert m?("[a-Z]", "[a-Z]")
106 + end
107 +
108 + test "a backslash escapes a special character" do
109 + assert m?("a\\*b", "a*b")
110 + refute m?("a\\*b", "axb")
111 + assert m?("\\[x]", "[x]")
112 + refute m?("\\[x]", "x")
113 + assert m?("\\!a", "!a")
114 + assert m?("a\\?", "a?")
115 + refute m?("a\\?", "a")
61 116 end
62 117
63 118 test "dots and other regex characters are literal" do
64 119 assert m?("mix.exs", "mix.exs")
65 120 refute m?("mix.exs", "mixxexs")
66 − assert m?("a+b(c).txt", "a+b(c).txt")
121 + assert m?("a(b|c).txt", "a(b|c).txt")
122 + refute m?("a(b|c).txt", "ab.txt")
67 123 end
68 124
69 125 test "patterns are anchored at both ends" do
@@ -76,4 +132,34 @@ defmodule GitGud.Workflows.GlobTest do
76 132 assert Glob.match?(regex, "lib/a.ex")
77 133 refute Glob.match?(regex, "test/a.exs")
78 134 end
135 +
136 + describe "select?/2" do
137 + test "the last matching pattern decides" do
138 + assert Glob.select?(["releases/**", "!releases/**-alpha"], "releases/1.0")
139 + refute Glob.select?(["releases/**", "!releases/**-alpha"], "releases/1.0-alpha")
140 + assert Glob.select?(["*", "!b*", "beta"], "beta")
141 + refute Glob.select?(["*", "!b*", "beta"], "bravo")
142 + end
143 +
144 + test "nothing matching selects nothing" do
145 + refute Glob.select?(["main"], "topic")
146 + refute Glob.select?([], "topic")
147 + end
148 +
149 + @tag :capture_log
150 + test "a list of only negative patterns selects nothing" do
151 + refute Glob.select?(["!main"], "topic")
152 + refute Glob.select?(["!main"], "main")
153 + end
154 +
155 + test "an escaped ! is a positive, literal pattern" do
156 + assert Glob.select?(["\\!x"], "!x")
157 + end
158 +
159 + test "takes a compiled list" do
160 + rules = Glob.compile_list(["src/**", "!src/gen/**"])
161 + assert Glob.select?(rules, "src/a")
162 + refute Glob.select?(rules, "src/gen/a")
163 + end
164 + end
79 165 end
modified test/git_gud/workflows/trigger_filters_test.exs
+70 −0
@@ -301,4 +301,74 @@ defmodule GitGud.Workflows.TriggerFiltersTest do
301 301 refute pr(doc(@branches_only), %{action: "opened"})
302 302 end
303 303 end
304 +
305 + describe "branch and tag patterns" do
306 + defp branch?(on, branch), do: Yaml.matches?(doc(on), "push", %{branch: branch})
307 + defp tag?(on, tag), do: Yaml.matches?(doc(on), "push", %{tag: tag})
308 +
309 + # `*` used to cross `/` here, so a workflow for top-level branches ran
310 + # for every nested one too.
311 + test "* stays within one path segment, ** doesn't" do
312 + star = " push:\n branches: ['*']\n"
313 + globstar = " push:\n branches: ['**']\n"
314 +
315 + assert branch?(star, "main")
316 + refute branch?(star, "feature/x")
317 + assert branch?(globstar, "main")
318 + assert branch?(globstar, "feature/x")
319 + end
320 +
321 + test "a negative pattern excludes what an earlier one matched" do
322 + on = " push:\n branches: ['releases/**', '!releases/**-alpha']\n"
323 +
324 + assert branch?(on, "releases/1.0")
325 + refute branch?(on, "releases/1.0-alpha")
326 + end
327 +
328 + test "a later positive pattern includes again" do
329 + on = " push:\n branches: ['*', '!b*', 'beta']\n"
330 +
331 + assert branch?(on, "beta")
332 + refute branch?(on, "bravo")
333 + assert branch?(on, "main")
334 + end
335 +
336 + @tag :capture_log
337 + test "a list of only negative patterns matches nothing" do
338 + on = " push:\n branches: ['!main']\n"
339 +
340 + refute branch?(on, "main")
341 + refute branch?(on, "topic")
342 + end
343 +
344 + test "tags: + is one or more of the preceding character" do
345 + on = " push:\n tags: ['v[0-9]+.[0-9]+.[0-9]+']\n"
346 +
347 + assert tag?(on, "v10.2.33")
348 + refute tag?(on, "v1.x.3")
349 + end
350 +
351 + test "tags: negation" do
352 + on = " push:\n tags: ['v*', '!v*-rc*']\n"
353 +
354 + assert tag?(on, "v1.0")
355 + refute tag?(on, "v1.0-rc1")
356 + end
357 +
358 + test "branches-ignore: * doesn't cross /" do
359 + on = " push:\n branches-ignore: ['feature/*']\n"
360 +
361 + refute branch?(on, "feature/a")
362 + assert branch?(on, "feature/a/b")
363 + end
364 +
365 + test "pull_request branches: use the same patterns" do
366 + on = " pull_request:\n branches: ['*', '!wip']\n"
367 + pr? = &Yaml.matches?(doc(on), "pull_request", %{action: "opened", branch: &1})
368 +
369 + assert pr?.("main")
370 + refute pr?.("wip")
371 + refute pr?.("release/1")
372 + end
373 + end
304 374 end

Parents: b812854