neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
Actions: branches:, tags: and paths: patterns follow GitHub's cheat sheet
91138c0 · Gabriel Morell · 2026-10-17 17:14
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