Compare commits

...

1 Commits

Author SHA1 Message Date
k3nny 3b0bcb72d3 feat(cli): multi-context simulation via --context flag
ci / vet, staticcheck, test, build (push) Failing after 2m39s
release / Build and publish release (push) Successful in 1m22s
Add --context KEY=VALUE[,...] (repeatable) to glint check. When two or
more --context flags are given, glint evaluates every pipeline job across
all contexts and prints a side-by-side comparison table:

  glint check --context branch=main --context branch=develop .yml

Each column shows active / manual / skipped / blocked per job. Known
context keys are branch, tag, source (case-insensitive); any other
KEY=VALUE pair is treated as a CI variable override. The --changes /
--changes-from changed-file list is shared across all contexts. Implicit
branch=main / source=push defaults are skipped when --context is used.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 00:12:38 +02:00
6 changed files with 566 additions and 25 deletions
+6
View File
@@ -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
+18
View File
@@ -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
+2 -2
View File
@@ -6,7 +6,7 @@
<p align="center">
<a href="LICENSE"><img src="https://img.shields.io/badge/license-Apache%202.0-blue.svg" alt="License"></a>
<a href="CHANGELOG.md"><img src="https://img.shields.io/badge/release-v0.2.21-blue.svg" alt="Release"></a>
<a href="CHANGELOG.md"><img src="https://img.shields.io/badge/release-v0.2.22-blue.svg" alt="Release"></a>
</p>
> **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 (GL001GL043); run `glint explain <ID>` 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
+1 -1
View File
@@ -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
---
+190 -22
View File
@@ -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 <REF>" to determine changed files for
rules:changes: evaluation. Combined with --changes if both are given.
--context <KEY=VALUE[,...]>
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()
}
+349
View File
@@ -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) {