defmodule Pipette.Git do
@moduledoc """
Git change detection and glob matching for pipeline activation.
Provides utilities to determine which files changed between commits,
match file paths against glob patterns, and resolve which scopes
should be activated based on changed files.
## Glob Matching Rules
Patterns use `**` and `*` wildcards:
* `**` — matches any number of path segments (including zero), e.g.
`"apps/api/**"` matches `"apps/api/lib/user.ex"`
* `*` — matches anything except `/`, e.g. `"*.md"` matches `"README.md"`
* Patterns without `/` also match against the file's basename, so
`"*.md"` matches both `"README.md"` and `"docs/guide.md"`
## Base Commit Resolution
The base commit for `git diff` is determined by priority:
1. PR base branch (`BUILDKITE_PULL_REQUEST_BASE_BRANCH`) — `origin/`
2. Non-default branch — `origin/`
3. Default branch — `HEAD~1`
## Examples
base = Pipette.Git.base_commit(ctx)
{:ok, files} = Pipette.Git.changed_files(base)
fired = Pipette.Git.fired_scopes(scopes, files)
Pipette.Git.matches_glob?("apps/api/lib/user.ex", "apps/api/**")
#=> true
Pipette.Git.matches_glob?("README.md", "*.md")
#=> true
"""
alias Pipette.Context
@doc """
Check if a file path matches a glob pattern.
Patterns without `/` also match against the file's basename.
## Examples
iex> Pipette.Git.matches_glob?("apps/api/lib/user.ex", "apps/api/**")
true
iex> Pipette.Git.matches_glob?("README.md", "*.md")
true
iex> Pipette.Git.matches_glob?("apps/api/lib/user.ex", "apps/web/**")
false
"""
@spec matches_glob?(String.t(), String.t()) :: boolean()
def matches_glob?(file_path, pattern) do
regex = glob_to_regex(pattern)
if String.contains?(pattern, "/") do
Regex.match?(regex, file_path)
else
Regex.match?(regex, file_path) or Regex.match?(regex, Path.basename(file_path))
end
end
@doc """
Determine the base commit for `git diff` comparison.
Resolution order:
1. PR base branch — `origin/`
2. Non-default branch — `origin/`
3. Default branch — `HEAD~1`
## Examples
iex> ctx = %Pipette.Context{pull_request_base_branch: "main", branch: "feature/x", default_branch: "main"}
iex> Pipette.Git.base_commit(ctx)
"origin/main"
iex> ctx = %Pipette.Context{pull_request_base_branch: nil, branch: "main", default_branch: "main"}
iex> Pipette.Git.base_commit(ctx)
"HEAD~1"
"""
@spec base_commit(Context.t()) :: String.t()
def base_commit(%Context{} = ctx) do
cond do
ctx.pull_request_base_branch != nil ->
"origin/#{ctx.pull_request_base_branch}"
ctx.branch != ctx.default_branch ->
"origin/#{ctx.default_branch}"
true ->
"HEAD~1"
end
end
@doc """
Get the list of files changed since the base commit.
Runs `git diff --name-only ` and returns the list of file paths.
Accepts a `:runner` option for testing (a function that takes the base
commit and returns `{:ok, output}` or `{:error, reason}`).
## Examples
{:ok, files} = Pipette.Git.changed_files("origin/main")
files #=> ["apps/api/lib/user.ex", "apps/web/src/App.tsx"]
"""
@spec changed_files(String.t(), keyword()) :: {:ok, [String.t()]} | {:error, String.t()}
def changed_files(base, opts \\ []) do
runner = Keyword.get(opts, :runner, &default_runner/1)
case runner.(base) do
{:ok, output} ->
files =
output
|> String.split("\n", trim: true)
|> Enum.map(&String.trim/1)
|> Enum.reject(&(&1 == ""))
{:ok, files}
{:error, _} = error ->
error
end
end
defp default_runner(base) do
case System.cmd("git", ["diff", "--name-only", base], stderr_to_stdout: true) do
{output, 0} -> {:ok, output}
{output, _code} -> {:error, "git diff failed: #{output}"}
end
end
@doc """
Determine which scopes are fired by the given changed files.
A scope fires when any changed file matches at least one of its `files`
patterns and none of its `exclude` patterns.
## Examples
scopes = [
%Pipette.Scope{name: :api_code, files: ["apps/api/**"]},
%Pipette.Scope{name: :web_code, files: ["apps/web/**"]}
]
fired = Pipette.Git.fired_scopes(scopes, ["apps/api/lib/user.ex"])
fired #=> MapSet.new([:api_code])
"""
@spec fired_scopes([Pipette.Scope.t()], [String.t()]) :: MapSet.t(atom())
def fired_scopes(scopes, changed_files) do
Enum.reduce(scopes, MapSet.new(), fn scope, fired ->
if scope_matches?(scope, changed_files) do
MapSet.put(fired, scope.name)
else
fired
end
end)
end
defp scope_matches?(scope, changed_files) do
Enum.any?(changed_files, fn file ->
matches_any?(file, scope.files || []) and not matches_any?(file, scope.exclude || [])
end)
end
defp matches_any?(file, patterns) do
Enum.any?(patterns, &matches_glob?(file, &1))
end
@doc """
Check if all changed files match the ignore patterns.
Returns `true` when every file in the list matches at least one ignore
pattern. Returns `false` for an empty file list (no changes means not ignored).
## Examples
Pipette.Git.all_ignored?(["README.md", "docs/guide.md"], ["*.md", "docs/**"])
#=> true
Pipette.Git.all_ignored?(["README.md", "apps/api/lib/user.ex"], ["*.md"])
#=> false
Pipette.Git.all_ignored?([], ["*.md"])
#=> false
"""
@spec all_ignored?([String.t()], [String.t()]) :: boolean()
def all_ignored?([], _ignore_patterns), do: false
def all_ignored?(changed_files, ignore_patterns) do
Enum.all?(changed_files, fn file ->
matches_any?(file, ignore_patterns)
end)
end
defp glob_to_regex(pattern) do
regex_str =
pattern
|> String.replace("**", "\0DOUBLESTAR\0")
|> String.replace("*", "\0STAR\0")
|> Regex.escape()
|> String.replace("\0DOUBLESTAR\0", ".*")
|> String.replace("\0STAR\0", "[^/]*")
Regex.compile!("^#{regex_str}$")
end
end