feat(cicontext): rules:changes: path-glob evaluation; 100% test coverage
- Add --changes PATH and --changes-from REF flags to glint check and glint graph
for rules:changes: evaluation. --changes marks files explicitly; --changes-from
runs git diff --name-only <REF> automatically. Both flags can be combined.
- Implement doublestar glob matching (*, ** across path segments) in EvalJob and
EvalWorkflow; extended {paths, compare_to} map form supported.
- Without --changes/--changes-from the condition stays permissive (existing behaviour).
- Context summary line now shows changed-file count when file data is provided.
- Achieve 100% statement coverage: comprehensive tests added across all packages;
removed provably dead code; added testability seams (exit, userHomeDirFn,
execCommandOutput variables) to cover previously unreachable paths.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,291 @@
|
||||
package cicontext
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.k3nny.fr/glint/internal/model"
|
||||
)
|
||||
|
||||
// ── doublestarMatch ───────────────────────────────────────────────────────────
|
||||
|
||||
func TestDoublestarMatch(t *testing.T) {
|
||||
cases := []struct {
|
||||
pattern string
|
||||
path string
|
||||
want bool
|
||||
}{
|
||||
// exact match
|
||||
{"Dockerfile", "Dockerfile", true},
|
||||
{"Dockerfile", "dockerfile", false},
|
||||
// single-segment wildcard
|
||||
{"*.go", "main.go", true},
|
||||
{"*.go", "main.py", false},
|
||||
// * does not cross /
|
||||
{"*.go", "src/main.go", false},
|
||||
// ** matches multiple segments
|
||||
{"**/*.go", "src/main.go", true},
|
||||
{"**/*.go", "src/pkg/util.go", true},
|
||||
{"**/*.go", "src/main.py", false},
|
||||
// ** at end matches everything
|
||||
{"src/**", "src/main.go", true},
|
||||
{"src/**", "src/a/b/c.go", true},
|
||||
{"src/**", "other/main.go", false},
|
||||
// ** in the middle
|
||||
{"src/**/*.go", "src/pkg/main.go", true},
|
||||
{"src/**/*.go", "src/a/b/main.go", true},
|
||||
{"src/**/*.go", "test/pkg/main.go", false},
|
||||
// bare ** matches everything
|
||||
{"**", "anything/and/everything.txt", true},
|
||||
{"**", "file.go", true},
|
||||
// no wildcard, multi-segment
|
||||
{"src/main.go", "src/main.go", true},
|
||||
{"src/main.go", "src/other.go", false},
|
||||
// ** matches zero segments too
|
||||
{"src/**/main.go", "src/main.go", true},
|
||||
{"src/**/main.go", "src/pkg/main.go", true},
|
||||
// malformed pattern (gracefully handled)
|
||||
{"[invalid", "file.go", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.pattern+"~"+tc.path, func(t *testing.T) {
|
||||
got := doublestarMatch(tc.pattern, tc.path)
|
||||
if got != tc.want {
|
||||
t.Errorf("doublestarMatch(%q, %q) = %v; want %v", tc.pattern, tc.path, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── extractChangesPaths ───────────────────────────────────────────────────────
|
||||
|
||||
func TestExtractChangesPaths(t *testing.T) {
|
||||
t.Run("nil → nil", func(t *testing.T) {
|
||||
if extractChangesPaths(nil) != nil {
|
||||
t.Error("expected nil")
|
||||
}
|
||||
})
|
||||
t.Run("string", func(t *testing.T) {
|
||||
got := extractChangesPaths("Dockerfile")
|
||||
if len(got) != 1 || got[0] != "Dockerfile" {
|
||||
t.Errorf("got %v", got)
|
||||
}
|
||||
})
|
||||
t.Run("[]string", func(t *testing.T) {
|
||||
got := extractChangesPaths([]string{"a.go", "b.go"})
|
||||
if len(got) != 2 || got[0] != "a.go" || got[1] != "b.go" {
|
||||
t.Errorf("got %v", got)
|
||||
}
|
||||
})
|
||||
t.Run("[]any strings", func(t *testing.T) {
|
||||
got := extractChangesPaths([]any{"src/**", "*.yml"})
|
||||
if len(got) != 2 || got[0] != "src/**" || got[1] != "*.yml" {
|
||||
t.Errorf("got %v", got)
|
||||
}
|
||||
})
|
||||
t.Run("[]any ignores non-strings", func(t *testing.T) {
|
||||
got := extractChangesPaths([]any{"ok", 42})
|
||||
if len(got) != 1 || got[0] != "ok" {
|
||||
t.Errorf("got %v", got)
|
||||
}
|
||||
})
|
||||
t.Run("map with paths key", func(t *testing.T) {
|
||||
got := extractChangesPaths(map[string]any{
|
||||
"paths": []any{"src/**"},
|
||||
"compare_to": "origin/main",
|
||||
})
|
||||
if len(got) != 1 || got[0] != "src/**" {
|
||||
t.Errorf("got %v", got)
|
||||
}
|
||||
})
|
||||
t.Run("map without paths key → nil", func(t *testing.T) {
|
||||
got := extractChangesPaths(map[string]any{"compare_to": "origin/main"})
|
||||
if got != nil {
|
||||
t.Errorf("expected nil, got %v", got)
|
||||
}
|
||||
})
|
||||
t.Run("unknown type → nil", func(t *testing.T) {
|
||||
got := extractChangesPaths(12345)
|
||||
if got != nil {
|
||||
t.Errorf("expected nil, got %v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ── changesMatch ──────────────────────────────────────────────────────────────
|
||||
|
||||
func TestChangesMatch(t *testing.T) {
|
||||
t.Run("nil changes → always true", func(t *testing.T) {
|
||||
ctx := New("main", "", "", nil)
|
||||
ctx.SetChangedFiles([]string{"anything.go"})
|
||||
if !changesMatch(nil, ctx) {
|
||||
t.Error("nil changes should always match")
|
||||
}
|
||||
})
|
||||
t.Run("nil changedFiles → permissive true", func(t *testing.T) {
|
||||
ctx := New("main", "", "", nil)
|
||||
// changedFiles is nil by default → permissive
|
||||
if !changesMatch([]any{"src/**"}, ctx) {
|
||||
t.Error("nil changedFiles should be permissive (true)")
|
||||
}
|
||||
})
|
||||
t.Run("no match → false", func(t *testing.T) {
|
||||
ctx := New("main", "", "", nil)
|
||||
ctx.SetChangedFiles([]string{"test/main_test.go"})
|
||||
if changesMatch([]any{"src/**/*.go"}, ctx) {
|
||||
t.Error("should not match: changed file not under src/")
|
||||
}
|
||||
})
|
||||
t.Run("match → true", func(t *testing.T) {
|
||||
ctx := New("main", "", "", nil)
|
||||
ctx.SetChangedFiles([]string{"src/pkg/main.go"})
|
||||
if !changesMatch([]any{"src/**/*.go"}, ctx) {
|
||||
t.Error("should match: src/pkg/main.go satisfies src/**/*.go")
|
||||
}
|
||||
})
|
||||
t.Run("empty changedFiles + non-nil changes → false", func(t *testing.T) {
|
||||
ctx := New("main", "", "", nil)
|
||||
ctx.SetChangedFiles([]string{}) // explicit empty list
|
||||
if changesMatch([]any{"Dockerfile"}, ctx) {
|
||||
t.Error("empty file list with active filter should be false")
|
||||
}
|
||||
})
|
||||
t.Run("one of many changed files matches", func(t *testing.T) {
|
||||
ctx := New("main", "", "", nil)
|
||||
ctx.SetChangedFiles([]string{"README.md", "src/main.go", "go.sum"})
|
||||
if !changesMatch([]any{"src/**"}, ctx) {
|
||||
t.Error("src/main.go should satisfy src/**")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ── EvalJob with rules:changes: ───────────────────────────────────────────────
|
||||
|
||||
func TestEvalJob_RulesChanges(t *testing.T) {
|
||||
makeJob := func(rules []model.Rule) model.Job {
|
||||
return model.Job{Name: "job", Script: []any{"echo"}, Rules: rules}
|
||||
}
|
||||
|
||||
t.Run("no changed files (permissive) — rule fires", func(t *testing.T) {
|
||||
ctx := New("main", "", "", nil)
|
||||
// changedFiles nil → permissive
|
||||
job := makeJob([]model.Rule{{Changes: []any{"src/**/*.go"}, When: "on_success"}})
|
||||
if EvalJob(job, ctx) != JobActive {
|
||||
t.Error("expected active: permissive when no file list")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("matching changed file — rule fires", func(t *testing.T) {
|
||||
ctx := New("main", "", "", nil)
|
||||
ctx.SetChangedFiles([]string{"src/main.go"})
|
||||
job := makeJob([]model.Rule{{Changes: []any{"src/**"}, When: "on_success"}})
|
||||
if EvalJob(job, ctx) != JobActive {
|
||||
t.Error("expected active: src/main.go matches src/**")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no matching file — rule skipped, no other rule → skipped", func(t *testing.T) {
|
||||
ctx := New("main", "", "", nil)
|
||||
ctx.SetChangedFiles([]string{"docs/README.md"})
|
||||
job := makeJob([]model.Rule{{Changes: []any{"src/**"}, When: "on_success"}})
|
||||
if EvalJob(job, ctx) != JobSkipped {
|
||||
t.Error("expected skipped: docs/README.md does not match src/**")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("changes filter with if: both must pass", func(t *testing.T) {
|
||||
ctx := New("main", "", "", nil)
|
||||
ctx.SetChangedFiles([]string{"src/main.go"})
|
||||
job := makeJob([]model.Rule{
|
||||
{If: `$CI_COMMIT_BRANCH == "develop"`, Changes: []any{"src/**"}, When: "on_success"},
|
||||
})
|
||||
// if: fails → rule skipped even though changes match
|
||||
if EvalJob(job, ctx) != JobSkipped {
|
||||
t.Error("expected skipped: if: condition fails")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("map form changes: {paths: [...]}", func(t *testing.T) {
|
||||
ctx := New("main", "", "", nil)
|
||||
ctx.SetChangedFiles([]string{"Dockerfile"})
|
||||
job := makeJob([]model.Rule{{
|
||||
Changes: map[string]any{"paths": []any{"Dockerfile"}, "compare_to": "origin/main"},
|
||||
When: "on_success",
|
||||
}})
|
||||
if EvalJob(job, ctx) != JobActive {
|
||||
t.Error("expected active: map form changes should work")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ── EvalWorkflow with rules:changes: ─────────────────────────────────────────
|
||||
|
||||
func TestEvalWorkflow_Changes(t *testing.T) {
|
||||
makePipeline := func(rules []model.Rule) *model.Pipeline {
|
||||
return &model.Pipeline{Workflow: &model.Workflow{Rules: rules}}
|
||||
}
|
||||
|
||||
t.Run("no changedFiles (permissive) → pipeline runs", func(t *testing.T) {
|
||||
ctx := New("main", "", "", nil)
|
||||
p := makePipeline([]model.Rule{{Changes: []any{"src/**"}, When: "always"}})
|
||||
runs, _ := EvalWorkflow(p, ctx)
|
||||
if !runs {
|
||||
t.Error("expected pipeline to run: permissive when no file list")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("matching file → pipeline runs", func(t *testing.T) {
|
||||
ctx := New("main", "", "", nil)
|
||||
ctx.SetChangedFiles([]string{"src/app.go"})
|
||||
p := makePipeline([]model.Rule{{Changes: []any{"src/**"}, When: "always"}})
|
||||
runs, _ := EvalWorkflow(p, ctx)
|
||||
if !runs {
|
||||
t.Error("expected pipeline to run: src/app.go matches src/**")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no matching file → no rule matches → pipeline blocked", func(t *testing.T) {
|
||||
ctx := New("main", "", "", nil)
|
||||
ctx.SetChangedFiles([]string{"docs/README.md"})
|
||||
p := makePipeline([]model.Rule{{Changes: []any{"src/**"}, When: "always"}})
|
||||
runs, _ := EvalWorkflow(p, ctx)
|
||||
if runs {
|
||||
t.Error("expected pipeline blocked: docs/README.md does not match src/**")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ── SetChangedFiles / Summary ─────────────────────────────────────────────────
|
||||
|
||||
func TestSetChangedFiles(t *testing.T) {
|
||||
ctx := New("main", "", "", nil)
|
||||
if ctx.changedFiles != nil {
|
||||
t.Error("changedFiles should be nil initially")
|
||||
}
|
||||
ctx.SetChangedFiles([]string{"a.go", "b.go"})
|
||||
if len(ctx.changedFiles) != 2 {
|
||||
t.Errorf("expected 2 changed files, got %d", len(ctx.changedFiles))
|
||||
}
|
||||
// nil receiver should not panic
|
||||
var nilCtx *Context
|
||||
nilCtx.SetChangedFiles([]string{"x"})
|
||||
}
|
||||
|
||||
func TestSummary_WithChangedFiles(t *testing.T) {
|
||||
ctx := New("main", "", "", nil)
|
||||
// Without changed files
|
||||
if s := ctx.Summary(); s != "branch=main, source=push" {
|
||||
t.Errorf("unexpected summary without changes: %q", s)
|
||||
}
|
||||
// With changed files
|
||||
ctx.SetChangedFiles([]string{"a.go", "b.go"})
|
||||
s := ctx.Summary()
|
||||
if s != "branch=main, source=push, 2 changed file(s)" {
|
||||
t.Errorf("unexpected summary with changes: %q", s)
|
||||
}
|
||||
// With empty changed files (strict, 0 files)
|
||||
ctx.SetChangedFiles([]string{})
|
||||
s = ctx.Summary()
|
||||
if s != "branch=main, source=push, 0 changed file(s)" {
|
||||
t.Errorf("unexpected summary with 0 changes: %q", s)
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,9 @@ import (
|
||||
// pipeline evaluation (rules:if:, only:, except:, workflow:rules:).
|
||||
// Variables are keyed by their name without the leading $.
|
||||
type Context struct {
|
||||
Vars map[string]string
|
||||
pinned map[string]bool // vars set via --var or shortcuts; never overwritten by Inject
|
||||
Vars map[string]string
|
||||
pinned map[string]bool // vars set via --var or shortcuts; never overwritten by Inject
|
||||
changedFiles []string // nil = not provided (permissive); non-nil = known set of changed files
|
||||
}
|
||||
|
||||
// New builds a Context from high-level shortcut values and optional KEY=VALUE
|
||||
@@ -101,6 +102,18 @@ func (c *Context) Inject(key, value string) {
|
||||
c.Vars[key] = value
|
||||
}
|
||||
|
||||
// SetChangedFiles records the list of files that changed for rules:changes:
|
||||
// evaluation. A non-nil slice (even empty) enables strict matching: only jobs
|
||||
// whose rules:changes: patterns match at least one file in the list will have
|
||||
// that rule fire. A nil slice (the default) means "not provided" — rules:changes:
|
||||
// conditions are treated as always satisfied (permissive), preserving the
|
||||
// behaviour when no changed-file data is available.
|
||||
func (c *Context) SetChangedFiles(files []string) {
|
||||
if c != nil {
|
||||
c.changedFiles = files
|
||||
}
|
||||
}
|
||||
|
||||
// Summary returns a short human-readable description of the context for CLI output.
|
||||
func (c *Context) Summary() string {
|
||||
if c.IsEmpty() {
|
||||
@@ -115,6 +128,9 @@ func (c *Context) Summary() string {
|
||||
if v := c.Get("CI_PIPELINE_SOURCE"); v != "" {
|
||||
parts = append(parts, "source="+v)
|
||||
}
|
||||
if c.changedFiles != nil {
|
||||
parts = append(parts, fmt.Sprintf("%d changed file(s)", len(c.changedFiles)))
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package cicontext
|
||||
|
||||
import (
|
||||
"path"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
@@ -49,6 +50,9 @@ func EvalWorkflow(p *model.Pipeline, ctx *Context) (bool, map[string]string) {
|
||||
if !ruleIfMatchesStrict(rule.If, vars) {
|
||||
continue
|
||||
}
|
||||
if !changesMatch(rule.Changes, ctx) {
|
||||
continue
|
||||
}
|
||||
when := rule.When
|
||||
if when == "" {
|
||||
when = "always"
|
||||
@@ -74,6 +78,9 @@ func EvalJob(job model.Job, ctx *Context) JobState {
|
||||
if !ruleIfMatches(rule.If, vars) {
|
||||
continue
|
||||
}
|
||||
if !changesMatch(rule.Changes, ctx) {
|
||||
continue
|
||||
}
|
||||
return whenToState(rule.When)
|
||||
}
|
||||
return JobSkipped // no rule matched → job is excluded
|
||||
@@ -262,3 +269,91 @@ func matchGlob(pattern, s string) bool {
|
||||
}
|
||||
return len(s) == 0
|
||||
}
|
||||
|
||||
// ── rules:changes: evaluation ─────────────────────────────────────────────────
|
||||
|
||||
// changesMatch reports whether the rule's changes: filter is satisfied.
|
||||
//
|
||||
// - rule.Changes == nil → no filter; always true
|
||||
// - ctx.changedFiles == nil → file list not provided; always true (permissive)
|
||||
// - otherwise → true iff at least one changed file matches at least one pattern
|
||||
func changesMatch(changes any, ctx *Context) bool {
|
||||
patterns := extractChangesPaths(changes)
|
||||
if patterns == nil {
|
||||
return true // no changes: filter
|
||||
}
|
||||
if ctx.changedFiles == nil {
|
||||
return true // no file list provided → permissive
|
||||
}
|
||||
for _, changed := range ctx.changedFiles {
|
||||
for _, pat := range patterns {
|
||||
if doublestarMatch(pat, changed) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// extractChangesPaths normalises a rule.Changes value into a []string of glob
|
||||
// patterns. Returns nil when no changes: filter is present.
|
||||
func extractChangesPaths(changes any) []string {
|
||||
switch v := changes.(type) {
|
||||
case nil:
|
||||
return nil
|
||||
case string:
|
||||
return []string{v}
|
||||
case []string:
|
||||
return v
|
||||
case []any:
|
||||
var out []string
|
||||
for _, item := range v {
|
||||
if s, ok := item.(string); ok {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
case map[string]any:
|
||||
// Extended form: { paths: [...], compare_to: "..." }
|
||||
if raw, ok := v["paths"]; ok {
|
||||
return extractChangesPaths(raw)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// doublestarMatch reports whether pattern matches the file path using GitLab-style
|
||||
// glob rules: '*' matches any character sequence within a single path segment;
|
||||
// '**' matches zero or more path segments (crossing '/' boundaries).
|
||||
func doublestarMatch(pattern, filePath string) bool {
|
||||
return matchParts(strings.Split(pattern, "/"), strings.Split(filePath, "/"))
|
||||
}
|
||||
|
||||
// matchParts is the recursive engine for doublestarMatch.
|
||||
func matchParts(pat, name []string) bool {
|
||||
for len(pat) > 0 {
|
||||
if pat[0] == "**" {
|
||||
if len(pat) == 1 {
|
||||
return true // trailing ** matches everything remaining
|
||||
}
|
||||
// ** can match 0, 1, 2, … leading segments of name.
|
||||
for i := 0; i <= len(name); i++ {
|
||||
if matchParts(pat[1:], name[i:]) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
if len(name) == 0 {
|
||||
return false
|
||||
}
|
||||
ok, err := path.Match(pat[0], name[0])
|
||||
if err != nil || !ok {
|
||||
return false
|
||||
}
|
||||
pat = pat[1:]
|
||||
name = name[1:]
|
||||
}
|
||||
return len(name) == 0
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user