feat(cli): multi-context simulation via --context flag
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>
This commit is contained in:
+190
-22
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user