feat(cli)!: subcommand CLI, graph tree mode, local include resolution

BREAKING CHANGES:
- `glint <file>` removed; use `glint check <file>`
- `--graph <mode>` removed; use `glint graph [mode]`
- `--graph-out` renamed to `--out` on `glint graph`

feat(cli): ruff-style subcommands — `glint check` and `glint graph [mode]`
feat(graph): `glint graph tree` — terminal job tree with context annotations
feat(graph): context flags (--branch/--tag/--source/--var) on `glint graph`
feat(resolver): recursive local include resolution from disk
fix(resolver): extends unknown base emits warning instead of fatal error
fix(model): script/before_script/after_script accept block scalar string form
test(linter): Samba project CI fixtures as integration tests
chore(build): fix .gitignore to not exclude cmd/glint/ directory
docs: update CHANGELOG, README, ROADMAP for v0.2.0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 00:27:28 +02:00
parent aca37de2b1
commit 88f20165db
23 changed files with 1772 additions and 258 deletions
+208 -38
View File
@@ -4,6 +4,7 @@ import (
"flag"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
@@ -15,6 +16,38 @@ import (
"git.k3nny.fr/glint/internal/resolver"
)
const globalUsage = `glint: Lint and visualise GitLab CI pipelines locally.
Usage: glint [OPTIONS] <COMMAND>
Commands:
check Lint a pipeline file — exits 0 (clean) or 1 (errors found)
graph Visualise the pipeline as a job tree or Mermaid graph
Options:
-h, --help Print help
For help with a specific command, see: ` + "`glint <command> --help`" + `.
`
func main() {
if len(os.Args) < 2 {
fmt.Fprint(os.Stderr, globalUsage)
os.Exit(2)
}
switch os.Args[1] {
case "check":
cmdCheck(os.Args[2:])
case "graph":
cmdGraph(os.Args[2:])
case "-h", "--help", "help":
fmt.Fprint(os.Stderr, globalUsage)
default:
fmt.Fprintf(os.Stderr, "glint: unknown command %q\n\n%s", os.Args[1], globalUsage)
os.Exit(2)
}
}
// multiFlag allows a flag to be specified multiple times.
type multiFlag []string
@@ -24,29 +57,70 @@ func (f *multiFlag) Set(v string) error {
return nil
}
func main() {
var (
token = flag.String("token", "", "GitLab personal access token (overrides GITLAB_TOKEN)")
gitlabURL = flag.String("gitlab-url", "", "GitLab instance URL (overrides CI_SERVER_URL / GITLAB_URL)")
graphMode = flag.String("graph", "", "graph mode: includes | pipeline | all")
graphOut = flag.String("graph-out", "glint-out", "output directory for pipeline graph files")
branch = flag.String("branch", "", "simulate a branch push (sets CI_COMMIT_BRANCH, …)")
tag = flag.String("tag", "", "simulate a tag push (sets CI_COMMIT_TAG, …)")
source = flag.String("source", "", "set CI_PIPELINE_SOURCE")
vars multiFlag
)
flag.Var(&vars, "var", "set a CI variable as KEY=VALUE; repeatable")
flag.Usage = func() {
fmt.Fprintf(os.Stderr, "usage: glint [options] <pipeline.yml>\n\n")
flag.PrintDefaults()
}
flag.Parse()
func cmdCheck(args []string) {
fs := flag.NewFlagSet("glint check", flag.ExitOnError)
token := fs.String("token", "", "GitLab personal access token (overrides GITLAB_TOKEN)")
gitlabURL := fs.String("gitlab-url", "", "GitLab instance URL (overrides CI_SERVER_URL / GITLAB_URL)")
branch := fs.String("branch", "", "simulate a branch push (sets CI_COMMIT_BRANCH, …)")
tag := fs.String("tag", "", "simulate a tag push (sets CI_COMMIT_TAG, …)")
source := fs.String("source", "", "set CI_PIPELINE_SOURCE")
var vars multiFlag
fs.Var(&vars, "var", "set a CI variable as KEY=VALUE; repeatable")
fs.Usage = func() {
fmt.Fprint(os.Stderr, `Lint a GitLab CI pipeline file.
if flag.NArg() != 1 {
flag.Usage()
Resolves local includes and extends chains, then runs all lint rules.
Exits 0 when no errors are found, 1 when at least one error is reported.
Usage: glint check [OPTIONS] <PIPELINE>
Arguments:
<PIPELINE> Path to the .gitlab-ci.yml file to lint
Options:
--token <TOKEN>
GitLab personal access token. Required to fetch project: includes;
component: includes are attempted unauthenticated.
[env: GITLAB_TOKEN | CI_JOB_TOKEN | GITLAB_PRIVATE_TOKEN]
--gitlab-url <URL>
GitLab instance URL.
[env: CI_SERVER_URL | GITLAB_URL] [default: https://gitlab.com]
--branch <NAME>
Simulate a branch push. Populates: CI_COMMIT_BRANCH,
CI_COMMIT_REF_NAME, CI_COMMIT_REF_SLUG, CI_PIPELINE_SOURCE=push.
--tag <NAME>
Simulate a tag push. Populates: CI_COMMIT_TAG, CI_COMMIT_REF_NAME,
CI_COMMIT_REF_SLUG, CI_PIPELINE_SOURCE=push. Clears CI_COMMIT_BRANCH.
--source <EVENT>
Override CI_PIPELINE_SOURCE.
[possible values: push, merge_request_event, schedule, web, api]
--var <KEY=VALUE>
Set or override a CI variable. Takes precedence over --branch, --tag,
and --source. Repeatable.
-h, --help
Print help
Examples:
glint check .gitlab-ci.yml
glint check --branch main .gitlab-ci.yml
GITLAB_TOKEN=glpat-xxxx glint check .gitlab-ci.yml
glint check --token glpat-xxxx --gitlab-url https://gitlab.example.com .gitlab-ci.yml
glint check --branch main --var DEPLOY_ENV=production .gitlab-ci.yml
`)
}
_ = fs.Parse(args)
if fs.NArg() != 1 {
fs.Usage()
os.Exit(2)
}
path := flag.Arg(0)
path := fs.Arg(0)
cfg := fetcher.AutoConfig().WithOverrides(*gitlabURL, *token)
@@ -56,19 +130,19 @@ func main() {
os.Exit(2)
}
warnings := resolver.ResolveIncludes(p, cfg)
rootDir := filepath.Dir(filepath.Clean(path))
warnings, _ := resolver.ResolveIncludes(p, cfg, rootDir)
for _, w := range warnings {
fmt.Fprintf(os.Stderr, "[WARNING] include %s\n", w)
}
if err := resolver.Resolve(p); err != nil {
extWarnings, err := resolver.Resolve(p)
if err != nil {
fmt.Fprintf(os.Stderr, "error: resolving extends: %v\n", err)
os.Exit(2)
}
if *graphMode != "" {
runGraph(p, path, *graphMode, *graphOut)
return
for _, w := range extWarnings {
fmt.Fprintf(os.Stderr, "[WARNING] job %q extends unknown job %q; extends chain skipped\n", w.Job, w.Base)
}
ctx := cicontext.New(*branch, *tag, *source, vars)
@@ -85,11 +159,8 @@ func main() {
}
}
jobCount := len(p.Jobs)
stageCount := len(p.Stages)
if len(findings) == 0 {
fmt.Printf("OK: %s — no issues found (%d job(s), %d stage(s))\n", path, jobCount, stageCount)
fmt.Printf("OK: %s — no issues found (%d job(s), %d stage(s))\n", path, len(p.Jobs), len(p.Stages))
} else {
errCount := 0
for _, f := range findings {
@@ -105,28 +176,127 @@ func main() {
}
}
func runGraph(p *model.Pipeline, path, mode, outDir string) {
var knownGraphModes = map[string]bool{
"tree": true, "includes": true, "pipeline": true, "all": true,
}
func cmdGraph(args []string) {
// Optional mode word must come before any flags or the file path.
mode := "default"
if len(args) > 0 && !strings.HasPrefix(args[0], "-") && knownGraphModes[args[0]] {
mode = args[0]
args = args[1:]
}
fs := flag.NewFlagSet("glint graph", flag.ExitOnError)
token := fs.String("token", "", "GitLab personal access token (overrides GITLAB_TOKEN)")
gitlabURL := fs.String("gitlab-url", "", "GitLab instance URL (overrides CI_SERVER_URL / GITLAB_URL)")
out := fs.String("out", "glint-out", "output directory for Mermaid graph files (pipeline mode)")
fs.Usage = func() {
fmt.Fprint(os.Stderr, `Visualise the pipeline as a job tree and/or Mermaid graph.
Usage: glint graph [MODE] [OPTIONS] <PIPELINE>
Arguments:
[MODE] Graph mode; must appear before options [default: tree+includes]
[possible values: tree, includes, pipeline, all]
<PIPELINE> Path to the .gitlab-ci.yml file
Options:
--out <DIR>
Output directory for rendered graph files.
Used by the pipeline and all modes only. [default: glint-out]
--token <TOKEN>
GitLab personal access token. Used to fetch remote project: includes
when building the include dependency graph.
[env: GITLAB_TOKEN | CI_JOB_TOKEN | GITLAB_PRIVATE_TOKEN]
--gitlab-url <URL>
GitLab instance URL.
[env: CI_SERVER_URL | GITLAB_URL] [default: https://gitlab.com]
--branch <NAME>
Simulate a branch push. Jobs in tree output are annotated with their
evaluated state ([skipped] or [manual]; no tag means active).
Populates: CI_COMMIT_BRANCH, CI_COMMIT_REF_NAME, CI_COMMIT_REF_SLUG,
CI_PIPELINE_SOURCE=push.
--tag <NAME>
Simulate a tag push. Populates: CI_COMMIT_TAG, CI_COMMIT_REF_NAME,
CI_COMMIT_REF_SLUG, CI_PIPELINE_SOURCE=push. Clears CI_COMMIT_BRANCH.
--source <EVENT>
Override CI_PIPELINE_SOURCE.
[possible values: push, merge_request_event, schedule, web, api]
--var <KEY=VALUE>
Set or override a CI variable. Repeatable.
-h, --help
Print help
Examples:
glint graph .gitlab-ci.yml
glint graph tree .gitlab-ci.yml
glint graph tree --branch main .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
glint graph all .gitlab-ci.yml > includes.mmd
`)
}
branch := fs.String("branch", "", "simulate a branch push (sets CI_COMMIT_BRANCH, …)")
tag := fs.String("tag", "", "simulate a tag push (sets CI_COMMIT_TAG, …)")
source := fs.String("source", "", "set CI_PIPELINE_SOURCE")
var vars multiFlag
fs.Var(&vars, "var", "set a CI variable as KEY=VALUE; repeatable")
_ = fs.Parse(args)
if fs.NArg() != 1 {
fs.Usage()
os.Exit(2)
}
path := fs.Arg(0)
cfg := fetcher.AutoConfig().WithOverrides(*gitlabURL, *token)
p, err := model.Parse(path)
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(2)
}
rootDir := filepath.Dir(filepath.Clean(path))
resolver.ResolveIncludes(p, cfg, rootDir) //nolint:errcheck
resolver.Resolve(p) //nolint:errcheck
ctx := cicontext.New(*branch, *tag, *source, vars)
switch mode {
case "default":
fmt.Print(graph.Tree(p, ctx))
fmt.Println("---")
fmt.Print(graph.Includes(path, p.Include, cfg))
case "tree":
fmt.Print(graph.Tree(p, ctx))
case "includes":
fmt.Print(graph.Includes(path, p.Include))
fmt.Print(graph.Includes(path, p.Include, cfg))
case "pipeline":
outPath, err := graph.RenderPipeline(p, outDir)
outPath, err := graph.RenderPipeline(p, *out)
if err != nil {
fmt.Fprintf(os.Stderr, "error: rendering pipeline graph: %v\n", err)
os.Exit(2)
}
fmt.Println(outPath)
case "all":
fmt.Print(graph.Includes(path, p.Include))
outPath, err := graph.RenderPipeline(p, outDir)
fmt.Print(graph.Includes(path, p.Include, cfg))
outPath, err := graph.RenderPipeline(p, *out)
if err != nil {
fmt.Fprintf(os.Stderr, "error: rendering pipeline graph: %v\n", err)
os.Exit(2)
}
fmt.Fprintln(os.Stderr, outPath)
default:
fmt.Fprintf(os.Stderr, "error: unknown --graph value %q; use: includes, pipeline, all\n", mode)
os.Exit(2)
}
}