feat(cicontext): rules:changes: path-glob evaluation; 100% test coverage
release / Build and publish release (push) Successful in 1m12s
ci / vet, staticcheck, test, build (push) Failing after 1m54s

- 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:
2026-06-21 22:47:32 +02:00
parent 04f17f8616
commit b21ef5c0bb
14 changed files with 1163 additions and 13 deletions
+95 -1
View File
@@ -4,6 +4,7 @@ import (
"flag"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
@@ -23,18 +24,43 @@ var version = "dev"
// exit is a variable so tests can capture exit calls without terminating.
var exit = os.Exit
// userHomeDirFn is a variable so tests can simulate UserHomeDir failure.
var userHomeDirFn = os.UserHomeDir
// defaultCacheDir returns the platform-default glint cache directory:
// $XDG_CACHE_HOME/glint or ~/.cache/glint.
func defaultCacheDir() string {
if xdg := os.Getenv("XDG_CACHE_HOME"); xdg != "" {
return filepath.Join(xdg, "glint")
}
if home, err := os.UserHomeDir(); err == nil {
if home, err := userHomeDirFn(); err == nil {
return filepath.Join(home, ".cache", "glint")
}
return ""
}
// execCommandOutput is a variable so tests can mock external command execution.
var execCommandOutput = func(name string, args ...string) ([]byte, error) {
return exec.Command(name, args...).Output()
}
// gitDiffFiles runs "git diff --name-only <ref>" and returns the list of changed
// file paths. Returns nil + error when the command fails (e.g. not in a git repo
// or the ref doesn't exist).
func gitDiffFiles(ref string) ([]string, error) {
out, err := execCommandOutput("git", "diff", "--name-only", ref)
if err != nil {
return nil, fmt.Errorf("git diff --name-only %s: %w", ref, err)
}
var files []string
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
if line != "" {
files = append(files, line)
}
}
return files, nil
}
const globalUsage = `glint: Lint and visualise GitLab CI pipelines locally.
Usage: glint [OPTIONS] <COMMAND>
@@ -97,6 +123,9 @@ func cmdCheck(args []string) {
listVars := fs.Bool("list-vars", false, "print all collected pipeline variables (from root and included files) to stderr, then continue")
var vars multiFlag
fs.Var(&vars, "var", "set a CI variable as KEY=VALUE; repeatable")
var changesFiles multiFlag
fs.Var(&changesFiles, "changes", "mark a file path as changed for rules:changes: evaluation; repeatable")
changesFrom := fs.String("changes-from", "", "git ref to diff against for rules:changes: evaluation (e.g. HEAD~1, origin/main)")
fs.Usage = func() {
fmt.Fprintf(os.Stderr, "glint %s\n\n", version)
fmt.Fprint(os.Stderr, `Lint a GitLab CI pipeline file.
@@ -151,6 +180,16 @@ Options:
Set or override a CI variable. Takes precedence over --branch, --tag,
and --source. Repeatable.
--changes <PATH>
Mark a file as changed for rules:changes: evaluation. Repeatable.
When given, only jobs whose rules:changes: patterns match at least one
--changes path will have that rule fire; without --changes or
--changes-from the condition is treated as always matching (permissive).
--changes-from <REF>
Run "git diff --name-only <REF>" to determine changed files for
rules:changes: evaluation. Combined with --changes if both are given.
--list-vars
Print all pipeline-level variables collected from the root file and
every included file (sorted KEY=VALUE) to stderr, then continue
@@ -178,6 +217,8 @@ Examples:
glint check --branch main --var DEPLOY_ENV=production .gitlab-ci.yml
glint check --cache-dir ~/.cache/glint .gitlab-ci.yml
glint check --offline --cache-dir ~/.cache/glint .gitlab-ci.yml
glint check --changes src/main.go --changes Dockerfile .gitlab-ci.yml
glint check --changes-from origin/main .gitlab-ci.yml
`)
}
_ = fs.Parse(args)
@@ -268,6 +309,27 @@ Examples:
}
ctx := cicontext.New(*branch, *tag, *source, vars)
// Wire up rules:changes: evaluation when file-change data is provided.
if *changesFrom != "" || len(changesFiles) > 0 {
var allChanged []string
reliable := len(changesFiles) > 0
if *changesFrom != "" {
files, err := gitDiffFiles(*changesFrom)
if err != nil {
fmt.Fprintf(os.Stderr, "%s: [warning] --changes-from: %v\n", path, err)
} else {
allChanged = append(allChanged, files...)
reliable = true
}
}
allChanged = append(allChanged, changesFiles...)
if reliable {
if allChanged == nil {
allChanged = []string{}
}
ctx.SetChangedFiles(allChanged)
}
}
if !ctx.IsEmpty() {
if !enrichContext(ctx, p) {
fmt.Fprintf(os.Stderr, "%s: [warning] workflow:rules: pipeline would not start for this context\n", path)
@@ -379,6 +441,13 @@ Options:
--var <KEY=VALUE>
Set or override a CI variable. Repeatable.
--changes <PATH>
Mark a file path as changed for rules:changes: evaluation. Repeatable.
--changes-from <REF>
Run "git diff --name-only <REF>" to determine changed files for
rules:changes: evaluation.
--list-vars
Print all pipeline-level variables collected from the root file and
every included file (sorted KEY=VALUE) to stderr, then continue
@@ -397,6 +466,7 @@ Examples:
glint graph tree --branch develop .gitlab-ci.yml
glint graph tree --tag v1.0.0 .gitlab-ci.yml
glint graph tree --list-vars .gitlab-ci.yml
glint graph tree --changes src/main.go .gitlab-ci.yml
glint graph includes .gitlab-ci.yml > includes.mmd
glint graph pipeline .gitlab-ci.yml
glint graph pipeline --out /tmp/graphs .gitlab-ci.yml
@@ -409,6 +479,9 @@ Examples:
listVars := fs.Bool("list-vars", false, "print all collected pipeline variables to stderr, then continue")
var vars multiFlag
fs.Var(&vars, "var", "set a CI variable as KEY=VALUE; repeatable")
var changesFiles multiFlag
fs.Var(&changesFiles, "changes", "mark a file path as changed for rules:changes: evaluation; repeatable")
changesFrom := fs.String("changes-from", "", "git ref to diff against for rules:changes: evaluation (e.g. HEAD~1, origin/main)")
_ = fs.Parse(args)
// Apply implicit defaults when no context flag is given at all.
@@ -443,6 +516,27 @@ Examples:
resolver.Resolve(p) //nolint:errcheck
ctx := cicontext.New(*branch, *tag, *source, vars)
// Wire up rules:changes: evaluation when file-change data is provided.
if *changesFrom != "" || len(changesFiles) > 0 {
var allChanged []string
reliable := len(changesFiles) > 0
if *changesFrom != "" {
files, err := gitDiffFiles(*changesFrom)
if err != nil {
fmt.Fprintf(os.Stderr, "%s: [warning] --changes-from: %v\n", path, err)
} else {
allChanged = append(allChanged, files...)
reliable = true
}
}
allChanged = append(allChanged, changesFiles...)
if reliable {
if allChanged == nil {
allChanged = []string{}
}
ctx.SetChangedFiles(allChanged)
}
}
if !ctx.IsEmpty() {
enrichContext(ctx, p)
}
+432
View File
@@ -1,11 +1,16 @@
package main
import (
"bytes"
"errors"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"git.k3nny.fr/glint/internal/cicontext"
"git.k3nny.fr/glint/internal/linter"
"git.k3nny.fr/glint/internal/model"
)
@@ -391,6 +396,433 @@ func TestPrintJobGroup(t *testing.T) {
printJobGroup("Empty ", []string{}) // empty: no output
}
// ── main() switch cases ───────────────────────────────────────────────────────
func TestMain_Check(t *testing.T) {
captureExit(t)
path := writePipeline(t, minimalPipeline)
os.Args = []string{"glint", "check", path}
main()
}
func TestMain_Graph(t *testing.T) {
captureExit(t)
path := writePipeline(t, minimalPipeline)
os.Args = []string{"glint", "graph", path}
main()
}
func TestMain_Explain(t *testing.T) {
captureExit(t)
os.Args = []string{"glint", "explain", "GL001"}
main()
}
// ── defaultCacheDir — UserHomeDir failure ─────────────────────────────────────
func TestDefaultCacheDir_HomeDirFails(t *testing.T) {
orig := userHomeDirFn
userHomeDirFn = func() (string, error) { return "", errors.New("no home") }
t.Cleanup(func() { userHomeDirFn = orig })
origXDG := os.Getenv("XDG_CACHE_HOME")
os.Unsetenv("XDG_CACHE_HOME")
t.Cleanup(func() { os.Setenv("XDG_CACHE_HOME", origXDG) })
got := defaultCacheDir()
if got != "" {
t.Errorf("expected empty string when UserHomeDir fails, got %q", got)
}
}
// ── cmdCheck — config load error ──────────────────────────────────────────────
func TestCmdCheck_ConfigError(t *testing.T) {
captureExit(t)
dir := t.TempDir()
path := filepath.Join(dir, ".gitlab-ci.yml")
if err := os.WriteFile(path, []byte(minimalPipeline), 0o644); err != nil {
t.Fatal(err)
}
// Create an unreadable .glint.yml — config.Load returns a non-NotExist error.
cfgPath := filepath.Join(dir, ".glint.yml")
if err := os.WriteFile(cfgPath, []byte("ignore: []"), 0o000); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { os.Chmod(cfgPath, 0o644) }) // allow cleanup
cmdCheck([]string{path})
// Warning is emitted to stderr; linting still proceeds, so no exit(2).
}
// ── cmdCheck — glintCfg.Stages ────────────────────────────────────────────────
func TestCmdCheck_ConfigStages(t *testing.T) {
captureExit(t)
dir := t.TempDir()
path := filepath.Join(dir, ".gitlab-ci.yml")
if err := os.WriteFile(path, []byte(minimalPipeline), 0o644); err != nil {
t.Fatal(err)
}
// Write a .glint.yml that declares an extra stage.
glintCfg := "stages:\n - extra-stage\n"
if err := os.WriteFile(filepath.Join(dir, ".glint.yml"), []byte(glintCfg), 0o644); err != nil {
t.Fatal(err)
}
cmdCheck([]string{path})
}
// ── cmdCheck — ResolveIncludes warnings ──────────────────────────────────────
func TestCmdCheck_IncludeWarning(t *testing.T) {
captureExit(t)
// Clear all token env vars so the project include is skipped (no token).
for _, k := range []string{"GITLAB_TOKEN", "CI_JOB_TOKEN", "GITLAB_PRIVATE_TOKEN"} {
t.Setenv(k, "")
}
content := `
stages: [build]
include:
- project: some/group/project
ref: main
file: template.yml
build-job:
stage: build
script: echo
`
path := writePipeline(t, content)
cmdCheck([]string{path})
// warning printed to stderr; pipeline still lints cleanly → no exit
}
// ── cmdCheck — resolver.Resolve error (cycle) ─────────────────────────────────
func TestCmdCheck_ResolveCycle(t *testing.T) {
code := captureExit(t)
content := `
stages: [build]
.base:
script: echo base
extends: .child
.child:
script: echo child
extends: .base
real-job:
stage: build
script: echo
`
path := writePipeline(t, content)
cmdCheck([]string{path})
if *code != 2 {
t.Errorf("cycle in extends: want exit(2), got %d", *code)
}
}
// ── cmdCheck — extends unknown base (extWarnings) ────────────────────────────
func TestCmdCheck_ExtendsWarning(t *testing.T) {
captureExit(t)
content := `
stages: [build]
build-job:
stage: build
extends: .nonexistent-base
script: echo
`
path := writePipeline(t, content)
cmdCheck([]string{path})
// Warning is printed; exit code is not 2 (extends warning is non-fatal).
}
// ── cmdGraph — RenderPipeline errors ─────────────────────────────────────────
func TestCmdGraph_PipelineError(t *testing.T) {
code := captureExit(t)
path := writePipeline(t, minimalPipeline)
// Create a regular file at the outDir path so os.MkdirAll fails.
outFile := filepath.Join(t.TempDir(), "not-a-dir")
if err := os.WriteFile(outFile, []byte{}, 0o644); err != nil {
t.Fatal(err)
}
cmdGraph([]string{"pipeline", "--out", outFile, path})
if *code != 2 {
t.Errorf("pipeline outDir-is-file: want exit(2), got %d", *code)
}
}
func TestCmdGraph_AllError(t *testing.T) {
code := captureExit(t)
path := writePipeline(t, minimalPipeline)
outFile := filepath.Join(t.TempDir(), "not-a-dir")
if err := os.WriteFile(outFile, []byte{}, 0o644); err != nil {
t.Fatal(err)
}
cmdGraph([]string{"all", "--out", outFile, path})
if *code != 2 {
t.Errorf("all outDir-is-file: want exit(2), got %d", *code)
}
}
// ── enrichContext — workflow rule variables ───────────────────────────────────
func TestEnrichContext_WorkflowVars(t *testing.T) {
p := &model.Pipeline{
Workflow: &model.Workflow{
Rules: []model.Rule{{
// No If: → always matches; Variables are injected into ctx.
When: "always",
Variables: map[string]any{"DEPLOY_ENV": "production"},
}},
},
}
ctx := cicontext.New("main", "", "", nil)
runs := enrichContext(ctx, p)
if !runs {
t.Error("expected pipeline to run with unconditional workflow rule")
}
if ctx.Get("DEPLOY_ENV") != "production" {
t.Errorf("expected DEPLOY_ENV=production, got %q", ctx.Get("DEPLOY_ENV"))
}
}
// ── writeJUnit — empty Rule and File ─────────────────────────────────────────
func TestWriteJUnit_EmptyRuleAndFile(t *testing.T) {
var buf bytes.Buffer
// Rule == "" → name falls back to "lint" (format.go:231-233).
// File == "" → classname falls back to pipeline arg (format.go:235-237).
writeJUnit(&buf, []linter.Finding{
{Severity: linter.Error, Rule: "", File: "", Message: "something went wrong"},
}, "my-pipeline.yml")
out := buf.String()
if !strings.Contains(out, "lint") {
t.Errorf("expected 'lint' as testcase name when Rule is empty; got:\n%s", out)
}
if !strings.Contains(out, "my-pipeline.yml") {
t.Errorf("expected pipeline path as classname when File is empty; got:\n%s", out)
}
}
// ── execCommandOutput (default implementation) ────────────────────────────────
func TestExecCommandOutput_Default(t *testing.T) {
// Call the default implementation directly (without mocking) to cover the
// closure body in main.go.
orig := execCommandOutput
execCommandOutput = func(name string, args ...string) ([]byte, error) {
return exec.Command(name, args...).Output()
}
t.Cleanup(func() { execCommandOutput = orig })
out, err := execCommandOutput("echo", "hello")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(string(out), "hello") {
t.Errorf("unexpected output: %q", string(out))
}
}
// ── gitDiffFiles ─────────────────────────────────────────────────────────────
func TestGitDiffFiles_Success(t *testing.T) {
orig := execCommandOutput
execCommandOutput = func(name string, args ...string) ([]byte, error) {
return []byte("src/main.go\nDockerfile\n"), nil
}
t.Cleanup(func() { execCommandOutput = orig })
files, err := gitDiffFiles("origin/main")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(files) != 2 || files[0] != "src/main.go" || files[1] != "Dockerfile" {
t.Errorf("unexpected files: %v", files)
}
}
func TestGitDiffFiles_Error(t *testing.T) {
orig := execCommandOutput
execCommandOutput = func(name string, args ...string) ([]byte, error) {
return nil, errors.New("not a git repository")
}
t.Cleanup(func() { execCommandOutput = orig })
files, err := gitDiffFiles("HEAD~1")
if err == nil {
t.Fatal("expected error")
}
if files != nil {
t.Errorf("expected nil files on error, got %v", files)
}
}
func TestGitDiffFiles_EmptyOutput(t *testing.T) {
orig := execCommandOutput
execCommandOutput = func(name string, args ...string) ([]byte, error) {
return []byte(""), nil
}
t.Cleanup(func() { execCommandOutput = orig })
files, err := gitDiffFiles("HEAD~1")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Empty output → nil slice (no lines passed the filter)
if files != nil {
t.Errorf("expected nil (no files), got %v", files)
}
}
// ── cmdCheck --changes / --changes-from ──────────────────────────────────────
func TestCmdCheck_ChangesFlag(t *testing.T) {
code := captureExit(t)
content := `stages: [build]
build-job:
stage: build
script: echo
rules:
- changes: [src/**/*.go]
when: on_success
`
path := writePipeline(t, content)
// With --changes src/main.go: the rule fires → active job, no error
cmdCheck([]string{"--changes", "src/main.go", path})
if *code != -1 {
t.Errorf("expected clean exit with matching --changes, got %d", *code)
}
}
func TestCmdCheck_ChangesFrom_Fails(t *testing.T) {
// When --changes-from fails (not a git repo / bad ref), a warning is emitted
// and the check continues normally (permissive behaviour).
orig := execCommandOutput
execCommandOutput = func(name string, args ...string) ([]byte, error) {
return nil, errors.New("fatal: not a git repository")
}
t.Cleanup(func() { execCommandOutput = orig })
code := captureExit(t)
path := writePipeline(t, minimalPipeline)
// Should warn but not crash; pipeline is clean → no exit(1).
cmdCheck([]string{"--changes-from", "origin/main", path})
if *code == 1 {
t.Errorf("expected no exit(1) when --changes-from fails gracefully, got %d", *code)
}
}
func TestCmdCheck_ChangesFrom_Success(t *testing.T) {
orig := execCommandOutput
execCommandOutput = func(name string, args ...string) ([]byte, error) {
return []byte("src/main.go\n"), nil
}
t.Cleanup(func() { execCommandOutput = orig })
code := captureExit(t)
content := `stages: [build]
build-job:
stage: build
script: echo
rules:
- changes: [src/**]
when: on_success
`
path := writePipeline(t, content)
cmdCheck([]string{"--changes-from", "origin/main", path})
if *code != -1 {
t.Errorf("expected clean exit, got %d", *code)
}
}
func TestCmdCheck_ChangesFrom_EmptyDiff(t *testing.T) {
// --changes-from succeeds but returns no files (nothing changed).
// reliable=true, allChanged stays nil → allChanged = []string{} branch is hit.
orig := execCommandOutput
execCommandOutput = func(name string, args ...string) ([]byte, error) {
return []byte(""), nil // empty output → no changed files
}
t.Cleanup(func() { execCommandOutput = orig })
code := captureExit(t)
content := `stages: [build]
build-job:
stage: build
script: echo
rules:
- changes: [src/**]
when: on_success
`
path := writePipeline(t, content)
// build-job's rule fires only if src/** matches; with 0 changed files it is skipped.
// Pipeline is clean (no lint errors) → no exit(1).
cmdCheck([]string{"--changes-from", "origin/main", path})
if *code == 1 {
t.Errorf("unexpected exit(1): %d", *code)
}
}
func TestCmdGraph_ChangesFrom_Fails(t *testing.T) {
orig := execCommandOutput
execCommandOutput = func(name string, args ...string) ([]byte, error) {
return nil, errors.New("not a git repository")
}
t.Cleanup(func() { execCommandOutput = orig })
code := captureExit(t)
path := writePipeline(t, minimalPipeline)
cmdGraph([]string{"tree", "--changes-from", "origin/main", path})
if *code == 1 {
t.Errorf("unexpected exit(1) when --changes-from fails in graph mode")
}
}
func TestCmdGraph_ChangesFrom_Success(t *testing.T) {
orig := execCommandOutput
execCommandOutput = func(name string, args ...string) ([]byte, error) {
return []byte("src/app.go\n"), nil
}
t.Cleanup(func() { execCommandOutput = orig })
code := captureExit(t)
path := writePipeline(t, minimalPipeline)
cmdGraph([]string{"tree", "--changes-from", "origin/main", path})
if *code == 1 {
t.Errorf("unexpected exit(1) in graph --changes-from success path")
}
}
func TestCmdGraph_ChangesFrom_EmptyDiff(t *testing.T) {
orig := execCommandOutput
execCommandOutput = func(name string, args ...string) ([]byte, error) {
return []byte(""), nil
}
t.Cleanup(func() { execCommandOutput = orig })
code := captureExit(t)
path := writePipeline(t, minimalPipeline)
// reliable=true, allChanged nil → allChanged = []string{} branch hit
cmdGraph([]string{"tree", "--changes-from", "origin/main", path})
if *code == 1 {
t.Errorf("unexpected exit(1) in graph --changes-from empty diff")
}
}
func TestCmdGraph_ChangesFlag(t *testing.T) {
code := captureExit(t)
content := `stages: [build]
build-job:
stage: build
script: echo
rules:
- changes: [src/**]
when: on_success
`
path := writePipeline(t, content)
cmdGraph([]string{"tree", "--changes", "src/app.go", path})
if *code == 1 {
t.Errorf("unexpected exit(1) with valid pipeline and --changes flag")
}
}
// ── isSuppressed ─────────────────────────────────────────────────────────────
func TestIsSuppressed(t *testing.T) {