diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6057245..bfd0517 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
This project uses [Semantic Versioning](https://semver.org).
+## [0.2.22] - 2026-06-25
+
+### Added
+
+- **Multi-context simulation** — `glint check` now accepts a repeatable `--context KEY=VALUE[,...]` flag. Each `--context` invocation defines one simulation context; when two or more are given, glint evaluates every pipeline job across all contexts and prints a side-by-side comparison table with `active`, `manual`, `skipped`, or `blocked` (when `workflow:rules:` would prevent the pipeline from starting) per column. Known context keys are `branch`, `tag`, and `source` (case-insensitive); any other `KEY=VALUE` pair is treated as a CI variable override. The `--changes` / `--changes-from` flags work alongside `--context` and the changed-file list is shared across all contexts. Implicit `--branch main --source push` defaults are skipped when `--context` is given.
+
## [0.2.21] - 2026-06-21
### Added
diff --git a/FEATURES.md b/FEATURES.md
index 9760ad0..1d23102 100644
--- a/FEATURES.md
+++ b/FEATURES.md
@@ -151,6 +151,24 @@ Use `--list-vars` to print the resolved variable table to stderr.
| `--changes PATH` | Mark PATH as changed; repeatable |
| `--changes-from REF` | Run `git diff --name-only REF` to auto-detect changed files |
+**Multi-context comparison** (`--context`):
+
+Pass `--context KEY=VALUE[,...]` (repeatable) instead of `--branch`/`--tag`/`--source` to evaluate every job across multiple contexts simultaneously. Each `--context` flag defines one column; glint prints a table showing `active`, `manual`, `skipped`, or `blocked` per job per context.
+
+Known context keys: `branch`, `tag`, `source` (case-insensitive). Any other `KEY=VALUE` pair is injected as a CI variable override. The `--changes`/`--changes-from` file list is shared across all contexts.
+
+```
+glint check --context branch=main --context branch=develop .gitlab-ci.yml
+
+Context comparison:
+
+JOB branch=main branch=develop
+---------- ----------- -------------
+build-job active active
+deploy-job active skipped
+test-job active active
+```
+
---
## Output formats
diff --git a/README.md b/README.md
index 5f78d51..81bfbe3 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,7 @@
-
+
> **Disclaimer:** This tool was built through iterative AI-assisted development with [Claude](https://claude.ai). It is experimental, incomplete, and not intended for production use. Coverage of GitLab CI keywords is best-effort and may lag behind GitLab's evolving spec. Use it at your own discretion — no correctness guarantees are made. Contributions and bug reports are welcome.
@@ -17,7 +17,7 @@ A local tool to validate and lint `.gitlab-ci.yml` pipelines without needing a G
- **Lints** — 43 rules covering pipeline structure, keyword constraints, `needs:`/`dependencies:` graphs, expression reachability, and deprecations (GL001–GL043); run `glint explain ` for any rule
- **Resolves includes** — local files, HTTPS URLs, GitLab project templates, and CI/CD Catalog components, with offline cache support
-- **Simulates context** — `--branch`, `--tag`, `--source` flags evaluate `rules:if:` and `only`/`except` to show which jobs would be active, manual, or skipped
+- **Simulates context** — `--branch`, `--tag`, `--source` flags evaluate `rules:if:` and `only`/`except` to show which jobs would be active, manual, or skipped; `--context branch=main --context branch=develop` prints a multi-column comparison table across multiple contexts in one run
- **Multiple output formats** — `--format text` (default, ruff-style), `json`, `sarif` (GitHub Code Scanning / GitLab SAST), `junit`, `github` (PR annotations)
- **Project config** — `.glint.yml` for rule suppression, severity overrides, token/URL defaults; `# glint: ignore RULE` for per-job inline suppression
- **Graph visualization** — `glint graph` prints a terminal job tree; `glint graph pipeline` renders a GitLab CI-style SVG/PNG
diff --git a/ROADMAP.md b/ROADMAP.md
index 90ef17c..b4edb88 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -23,7 +23,7 @@ Pass `--branch`, `--tag`, `--source`, or `--var` to `glint check` or `glint grap
- ~~**Workflow rule strict evaluation**~~ — ✓ shipped v0.2.14; unparseable `if:` skips the rule instead of matching everything; prevents wrong variables being injected
- ~~**Single `=` operator**~~ — ✓ shipped v0.2.14; bare `=` accepted as alias for `==` in `rules:if:` expressions
- ~~**`rules:changes:` evaluation**~~ — ✓ shipped v0.2.21; `--changes PATH` and `--changes-from REF` flags; doublestar glob matching (`*` within segment, `**` across segments); permissive when no file list provided
-- **Multi-context simulation** — run multiple contexts in one invocation and print a comparison table (`--context branch=main --context branch=develop --context tag=v1.0.0`)
+- ~~**Multi-context simulation**~~ — ✓ shipped v0.2.22; `--context KEY=VALUE[,...]`; repeatable; prints a comparison table of `active`/`manual`/`skipped`/`blocked` per job across all contexts
- **Context-scoped linting** — skip `needs:`/`dependencies:` cross-checks for jobs that are statically unreachable in the given context
---
diff --git a/cmd/glint/main.go b/cmd/glint/main.go
index acd1476..40d7c19 100644
--- a/cmd/glint/main.go
+++ b/cmd/glint/main.go
@@ -126,6 +126,8 @@ func cmdCheck(args []string) {
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)")
+ var contexts multiFlag
+ fs.Var(&contexts, "context", "simulation context as KEY=VALUE[,...]; repeatable for multi-context comparison table")
fs.Usage = func() {
fmt.Fprintf(os.Stderr, "glint %s\n\n", version)
fmt.Fprint(os.Stderr, `Lint a GitLab CI pipeline file.
@@ -190,6 +192,15 @@ Options:
Run "git diff --name-only [" to determine changed files for
rules:changes: evaluation. Combined with --changes if both are given.
+ --context
+ Define a simulation context. Repeatable: each --context flag adds one
+ column to a comparison table showing every job's state across contexts.
+ Known keys: branch, tag, source. Any other KEY=VALUE is treated as a
+ CI variable override. Examples:
+ --context branch=main --context branch=develop
+ --context tag=v1.0.0
+ --context branch=main,DEPLOY_ENV=prod
+
--list-vars
Print all pipeline-level variables collected from the root file and
every included file (sorted KEY=VALUE) to stderr, then continue
@@ -219,12 +230,14 @@ Examples:
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
+ glint check --context branch=main --context branch=develop .gitlab-ci.yml
+ glint check --context branch=main --context tag=v1.0.0 --context source=schedule .gitlab-ci.yml
`)
}
_ = fs.Parse(args)
- // Apply implicit defaults when no context flag is given at all.
- if *branch == "" && *tag == "" && *source == "" && len(vars) == 0 {
+ // Apply implicit defaults only in single-context mode when no flags are given.
+ if len(contexts) == 0 && *branch == "" && *tag == "" && *source == "" && len(vars) == 0 {
*branch = "main"
*source = "push"
}
@@ -308,40 +321,62 @@ Examples:
fmt.Fprintf(os.Stderr, "%s: [warning] job %q extends unknown job %q; extends chain skipped\n", path, w.Job, w.Base)
}
- ctx := cicontext.New(*branch, *tag, *source, vars)
- // Wire up rules:changes: evaluation when file-change data is provided.
+ // Compute changed files once; shared across single and multi-context modes.
+ var allChanged []string
+ var changesReliable bool
if *changesFrom != "" || len(changesFiles) > 0 {
- var allChanged []string
- reliable := len(changesFiles) > 0
+ changesReliable = 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
+ changesReliable = true
}
}
allChanged = append(allChanged, changesFiles...)
- if reliable {
- if allChanged == nil {
- allChanged = []string{}
+ if changesReliable && allChanged == nil {
+ allChanged = []string{}
+ }
+ }
+
+ if len(contexts) > 0 {
+ // Multi-context mode: build one context per --context flag, then print a comparison table.
+ ctxList := make([]*cicontext.Context, 0, len(contexts))
+ runs := make([]bool, 0, len(contexts))
+ for _, spec := range contexts {
+ cb, ct, cs, cv := parseContextSpec(spec)
+ c := cicontext.New(cb, ct, cs, cv)
+ if changesReliable {
+ c.SetChangedFiles(allChanged)
}
+ ran := enrichContext(c, p)
+ ctxList = append(ctxList, c)
+ runs = append(runs, ran)
+ }
+ if *format == "text" {
+ printContextTable(p, ctxList, contexts, runs)
+ }
+ } else {
+ // Single-context mode: existing flow.
+ ctx := cicontext.New(*branch, *tag, *source, vars)
+ if changesReliable {
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)
+ if !ctx.IsEmpty() {
+ if !enrichContext(ctx, p) {
+ fmt.Fprintf(os.Stderr, "%s: [warning] workflow:rules: pipeline would not start for this context\n", path)
+ }
+ }
+ if *listVars {
+ printVars(p, ctx)
+ }
+ // Context summary only makes sense in plain-text output; suppress it in
+ // structured formats so stdout contains only the machine-readable payload.
+ if !ctx.IsEmpty() && *format == "text" {
+ printContext(p, ctx)
}
- }
- if *listVars {
- printVars(p, ctx)
- }
- // Context summary only makes sense in plain-text output; suppress it in
- // structured formats so stdout contains only the machine-readable payload.
- if !ctx.IsEmpty() && *format == "text" {
- printContext(p, ctx)
}
findings := linter.Lint(p)
@@ -679,3 +714,136 @@ func printJobGroup(label string, jobs []string) {
}
fmt.Printf("%s (%d): %s\n", label, len(jobs), strings.Join(jobs, ", "))
}
+
+// parseContextSpec parses "branch=main,DEPLOY_ENV=prod" into its parts.
+// Known keys (branch, tag, source) are extracted; everything else goes into extraVars.
+func parseContextSpec(spec string) (branch, tag, source string, extraVars []string) {
+ for _, kv := range strings.Split(spec, ",") {
+ kv = strings.TrimSpace(kv)
+ if kv == "" {
+ continue
+ }
+ parts := strings.SplitN(kv, "=", 2)
+ key := parts[0]
+ val := ""
+ if len(parts) == 2 {
+ val = parts[1]
+ }
+ switch strings.ToLower(key) {
+ case "branch":
+ branch = val
+ case "tag":
+ tag = val
+ case "source":
+ source = val
+ default:
+ extraVars = append(extraVars, kv)
+ }
+ }
+ return
+}
+
+// sortedJobNames returns non-hidden job names ordered by stage position, then alphabetically.
+func sortedJobNames(p *model.Pipeline) []string {
+ stageIdx := make(map[string]int, len(p.Stages))
+ for i, s := range p.Stages {
+ stageIdx[s] = i
+ }
+ type entry struct {
+ name string
+ stage int
+ }
+ entries := make([]entry, 0, len(p.Jobs))
+ for name, job := range p.Jobs {
+ if strings.HasPrefix(name, ".") {
+ continue
+ }
+ idx, ok := stageIdx[job.Stage]
+ if !ok {
+ idx = len(p.Stages)
+ }
+ entries = append(entries, entry{name: name, stage: idx})
+ }
+ sort.Slice(entries, func(i, j int) bool {
+ if entries[i].stage != entries[j].stage {
+ return entries[i].stage < entries[j].stage
+ }
+ return entries[i].name < entries[j].name
+ })
+ names := make([]string, len(entries))
+ for i, e := range entries {
+ names[i] = e.name
+ }
+ return names
+}
+
+// printContextTable prints a side-by-side comparison of job states across contexts.
+func printContextTable(p *model.Pipeline, ctxs []*cicontext.Context, labels []string, runs []bool) {
+ jobs := sortedJobNames(p)
+ if len(jobs) == 0 {
+ return
+ }
+
+ // Evaluate all jobs for all contexts up front so we can compute column widths.
+ states := make([][]string, len(jobs))
+ for r, name := range jobs {
+ states[r] = make([]string, len(ctxs))
+ for c, ctx := range ctxs {
+ if !runs[c] {
+ states[r][c] = "blocked"
+ } else {
+ switch cicontext.EvalJob(p.Jobs[name], ctx) {
+ case cicontext.JobActive:
+ states[r][c] = "active"
+ case cicontext.JobManual:
+ states[r][c] = "manual"
+ default:
+ states[r][c] = "skipped"
+ }
+ }
+ }
+ }
+
+ // Compute column widths.
+ jobCol := len("JOB")
+ for _, name := range jobs {
+ if len(name) > jobCol {
+ jobCol = len(name)
+ }
+ }
+ ctxCols := make([]int, len(labels))
+ for i, lbl := range labels {
+ ctxCols[i] = len(lbl)
+ }
+ for r := range jobs {
+ for c, s := range states[r] {
+ if len(s) > ctxCols[c] {
+ ctxCols[c] = len(s)
+ }
+ }
+ }
+
+ // Print header.
+ fmt.Println("Context comparison:")
+ fmt.Println()
+ fmt.Printf("%-*s", jobCol, "JOB")
+ for i, lbl := range labels {
+ fmt.Printf(" %-*s", ctxCols[i], lbl)
+ }
+ fmt.Println()
+ fmt.Print(strings.Repeat("-", jobCol))
+ for _, w := range ctxCols {
+ fmt.Print(" " + strings.Repeat("-", w))
+ }
+ fmt.Println()
+
+ // Print rows.
+ for r, name := range jobs {
+ fmt.Printf("%-*s", jobCol, name)
+ for c, s := range states[r] {
+ fmt.Printf(" %-*s", ctxCols[c], s)
+ }
+ fmt.Println()
+ }
+ fmt.Println()
+}
diff --git a/cmd/glint/main_test.go b/cmd/glint/main_test.go
index b6d696b..d279cd4 100644
--- a/cmd/glint/main_test.go
+++ b/cmd/glint/main_test.go
@@ -823,6 +823,355 @@ build-job:
}
}
+// ── parseContextSpec ─────────────────────────────────────────────────────────
+
+func TestParseContextSpec(t *testing.T) {
+ cases := []struct {
+ spec string
+ branch string
+ tag string
+ source string
+ extraVars []string
+ }{
+ {"branch=main", "main", "", "", nil},
+ {"tag=v1.0.0", "", "v1.0.0", "", nil},
+ {"source=schedule", "", "", "schedule", nil},
+ {"branch=main,source=push", "main", "", "push", nil},
+ {"branch=main,DEPLOY=prod", "main", "", "", []string{"DEPLOY=prod"}},
+ {"DEPLOY=prod,ENV=staging", "", "", "", []string{"DEPLOY=prod", "ENV=staging"}},
+ {"branch=main,tag=v1,source=push,X=y", "main", "v1", "push", []string{"X=y"}},
+ {"branch=main, ,source=push", "main", "", "push", nil}, // spaces and empty segments
+ {"", "", "", "", nil},
+ {"noequals", "", "", "", []string{"noequals"}}, // no = → whole token as extraVar
+ {"BRANCH=develop", "develop", "", "", nil}, // case-insensitive key matching
+ }
+ for _, tc := range cases {
+ t.Run(tc.spec, func(t *testing.T) {
+ b, tg, s, ev := parseContextSpec(tc.spec)
+ if b != tc.branch {
+ t.Errorf("branch: want %q got %q", tc.branch, b)
+ }
+ if tg != tc.tag {
+ t.Errorf("tag: want %q got %q", tc.tag, tg)
+ }
+ if s != tc.source {
+ t.Errorf("source: want %q got %q", tc.source, s)
+ }
+ if len(ev) != len(tc.extraVars) {
+ t.Errorf("extraVars len: want %d got %d (%v)", len(tc.extraVars), len(ev), ev)
+ return
+ }
+ for i := range ev {
+ if ev[i] != tc.extraVars[i] {
+ t.Errorf("extraVars[%d]: want %q got %q", i, tc.extraVars[i], ev[i])
+ }
+ }
+ })
+ }
+}
+
+// ── sortedJobNames ────────────────────────────────────────────────────────────
+
+func TestSortedJobNames_Order(t *testing.T) {
+ p := &model.Pipeline{
+ Stages: []string{"build", "test", "deploy"},
+ Jobs: map[string]model.Job{
+ "deploy-job": {Stage: "deploy"},
+ "test-b": {Stage: "test"},
+ "test-a": {Stage: "test"},
+ "build-job": {Stage: "build"},
+ ".hidden": {Stage: "build"}, // excluded
+ },
+ }
+ got := sortedJobNames(p)
+ want := []string{"build-job", "test-a", "test-b", "deploy-job"}
+ if len(got) != len(want) {
+ t.Fatalf("want %v got %v", want, got)
+ }
+ for i := range want {
+ if got[i] != want[i] {
+ t.Errorf("[%d]: want %q got %q", i, want[i], got[i])
+ }
+ }
+}
+
+func TestSortedJobNames_UnknownStage(t *testing.T) {
+ p := &model.Pipeline{
+ Stages: []string{"build"},
+ Jobs: map[string]model.Job{
+ "build-job": {Stage: "build"},
+ "orphan-job": {Stage: "nonexistent"},
+ },
+ }
+ got := sortedJobNames(p)
+ if len(got) != 2 {
+ t.Fatalf("want 2 jobs, got %v", got)
+ }
+ if got[0] != "build-job" {
+ t.Errorf("expected build-job first, got %q", got[0])
+ }
+ if got[1] != "orphan-job" {
+ t.Errorf("expected orphan-job last, got %q", got[1])
+ }
+}
+
+func TestSortedJobNames_Empty(t *testing.T) {
+ p := &model.Pipeline{
+ Stages: []string{"build"},
+ Jobs: map[string]model.Job{".hidden": {Stage: "build"}},
+ }
+ got := sortedJobNames(p)
+ if len(got) != 0 {
+ t.Errorf("want empty, got %v", got)
+ }
+}
+
+// ── printContextTable ─────────────────────────────────────────────────────────
+
+func TestPrintContextTable_Empty(t *testing.T) {
+ // Pipeline with no visible jobs: should return without printing.
+ p := &model.Pipeline{
+ Stages: []string{"build"},
+ Jobs: map[string]model.Job{".hidden": {Stage: "build"}},
+ }
+ // No panic expected, no output to verify.
+ printContextTable(p, nil, nil, nil)
+}
+
+func TestPrintContextTable_ActiveSkipped(t *testing.T) {
+ // Job always active.
+ content := `
+stages: [build, deploy]
+build-job:
+ stage: build
+ script: make
+deploy-job:
+ stage: deploy
+ script: make deploy
+ rules:
+ - if: '$CI_COMMIT_BRANCH == "main"'
+`
+ path := writePipeline(t, content)
+ p, err := model.Parse(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ ctx1 := cicontext.New("main", "", "push", nil)
+ ctx2 := cicontext.New("develop", "", "push", nil)
+ enrichContext(ctx1, p)
+ enrichContext(ctx2, p)
+
+ // Just ensure it doesn't panic; output goes to real stdout in tests.
+ printContextTable(p, []*cicontext.Context{ctx1, ctx2},
+ []string{"branch=main", "branch=develop"}, []bool{true, true})
+}
+
+func TestPrintContextTable_Blocked(t *testing.T) {
+ // A context where workflow:rules: blocks the pipeline.
+ content := `
+stages: [build]
+workflow:
+ rules:
+ - if: '$CI_COMMIT_BRANCH == "main"'
+build-job:
+ stage: build
+ script: make
+`
+ path := writePipeline(t, content)
+ p, err := model.Parse(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ ctx1 := cicontext.New("main", "", "push", nil)
+ ctx2 := cicontext.New("develop", "", "push", nil)
+ r1 := enrichContext(ctx1, p)
+ r2 := enrichContext(ctx2, p)
+
+ printContextTable(p, []*cicontext.Context{ctx1, ctx2},
+ []string{"branch=main", "branch=develop"}, []bool{r1, r2})
+}
+
+func TestPrintContextTable_ManualState(t *testing.T) {
+ content := `
+stages: [build]
+build-job:
+ stage: build
+ script: make
+ rules:
+ - when: manual
+`
+ path := writePipeline(t, content)
+ p, err := model.Parse(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ ctx := cicontext.New("main", "", "push", nil)
+ enrichContext(ctx, p)
+ printContextTable(p, []*cicontext.Context{ctx}, []string{"branch=main"}, []bool{true})
+}
+
+func TestPrintContextTable_ShortLabel(t *testing.T) {
+ // Short label "x" (1 char) ensures a state string ("skipped", 7 chars) triggers
+ // the ctxCols[c] = len(s) branch in printContextTable.
+ content := `
+stages: [build]
+build-job:
+ stage: build
+ script: make
+ rules:
+ - if: '$CI_COMMIT_TAG != ""'
+ when: on_success
+ - when: never
+`
+ path := writePipeline(t, content)
+ p, err := model.Parse(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ ctx := cicontext.New("main", "", "push", nil)
+ enrichContext(ctx, p)
+ // Label "x" is shorter than "skipped" (7 chars), covering ctxCols width-expansion.
+ printContextTable(p, []*cicontext.Context{ctx}, []string{"x"}, []bool{true})
+}
+
+// ── cmdCheck multi-context ───────────────────────────────────────────────────
+
+func TestCmdCheck_MultiContext_Single(t *testing.T) {
+ code := captureExit(t)
+ path := writePipeline(t, minimalPipeline)
+ cmdCheck([]string{"--context", "branch=main", path})
+ if *code != -1 {
+ t.Errorf("multi-context single: want no exit, got %d", *code)
+ }
+}
+
+func TestCmdCheck_MultiContext_Multiple(t *testing.T) {
+ code := captureExit(t)
+ path := writePipeline(t, minimalPipeline)
+ cmdCheck([]string{"--context", "branch=main", "--context", "branch=develop", path})
+ if *code != -1 {
+ t.Errorf("multi-context multiple: want no exit, got %d", *code)
+ }
+}
+
+func TestCmdCheck_MultiContext_WithExtraVar(t *testing.T) {
+ code := captureExit(t)
+ path := writePipeline(t, minimalPipeline)
+ cmdCheck([]string{"--context", "branch=main,DEPLOY=prod", path})
+ if *code != -1 {
+ t.Errorf("multi-context with extra var: want no exit, got %d", *code)
+ }
+}
+
+func TestCmdCheck_MultiContext_ErrorPipeline(t *testing.T) {
+ code := captureExit(t)
+ content := `
+stages: [build]
+test-job:
+ stage: nonexistent
+ script: echo
+`
+ path := writePipeline(t, content)
+ cmdCheck([]string{"--context", "branch=main", path})
+ if *code != 1 {
+ t.Errorf("multi-context error pipeline: want exit(1), got %d", *code)
+ }
+}
+
+func TestCmdCheck_MultiContext_SkipsImplicitDefaults(t *testing.T) {
+ // When --context is given, implicit defaults (branch=main, source=push) must not be set.
+ // This is a smoke test: the command must complete without panicking.
+ code := captureExit(t)
+ path := writePipeline(t, minimalPipeline)
+ cmdCheck([]string{"--context", "tag=v1.0.0", path})
+ if *code == 2 {
+ t.Errorf("multi-context tag: unexpected exit(2)")
+ }
+}
+
+func TestCmdCheck_MultiContext_WithChanges(t *testing.T) {
+ code := captureExit(t)
+ content := `
+stages: [build]
+build-job:
+ stage: build
+ script: make
+ rules:
+ - changes: [src/**]
+ when: on_success
+`
+ path := writePipeline(t, content)
+ cmdCheck([]string{
+ "--context", "branch=main",
+ "--changes", "src/app.go",
+ path,
+ })
+ if *code != -1 {
+ t.Errorf("multi-context+changes: want no exit, got %d", *code)
+ }
+}
+
+func TestCmdCheck_MultiContext_WithChangesFrom(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)
+ cmdCheck([]string{"--context", "branch=main", "--changes-from", "origin/main", path})
+ if *code != -1 {
+ t.Errorf("multi-context+changes-from: want no exit, got %d", *code)
+ }
+}
+
+func TestCmdCheck_MultiContext_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{} guard hit in multi-context path
+ cmdCheck([]string{"--context", "branch=main", "--changes-from", "origin/main", path})
+ if *code != -1 {
+ t.Errorf("multi-context+changes-from empty diff: want no exit, got %d", *code)
+ }
+}
+
+func TestCmdCheck_MultiContext_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)
+ // git fails → changesReliable stays false → SetChangedFiles not called
+ cmdCheck([]string{"--context", "branch=main", "--changes-from", "origin/main", path})
+ if *code != -1 {
+ t.Errorf("multi-context+changes-from fail: want no exit, got %d", *code)
+ }
+}
+
+func TestCmdCheck_MultiContext_FormatJSON(t *testing.T) {
+ // --context + non-text format: printContextTable should NOT be called.
+ code := captureExit(t)
+ path := writePipeline(t, minimalPipeline)
+ cmdCheck([]string{"--context", "branch=main", "--format", "json", path})
+ if *code != -1 {
+ t.Errorf("multi-context json: want no exit, got %d", *code)
+ }
+}
+
// ── isSuppressed ─────────────────────────────────────────────────────────────
func TestIsSuppressed(t *testing.T) {
]