Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# Copilot Instructions for Mage

Mage is a make-like build tool that uses Go functions as build targets. Users write plain Go functions in "magefiles" and mage makes them runnable from the command line.

## Build, Test, and Lint

```bash
# Build
go build ./...

# Test (full suite, including race detector as required by CI)
go test -race ./...

# Test a single package
go test -race ./parse/

# Test a single test function
go test -race ./mage/ -run TestGoCmd

# CI runs tests with: go test -v -vet=all -tags CI -race ./...

# Lint (requires golangci-lint) — run after any code changes
golangci-lint run ./...
```

After making changes, always run `golangci-lint run ./...` before committing to catch lint issues early.

Mage builds itself with mage. The bootstrap path (`go run bootstrap.go`) is for building mage when mage isn't installed yet. The project's own build targets live in `magefiles/`.

## Architecture

Mage works by **parsing user Go source files and generating a temporary CLI binary** that dispatches to the user's target functions:

1. **Entry** — `main.go` calls `mage.Main()` which parses CLI flags and dispatches commands.
2. **File scanning** — `mage/main.go` finds magefiles (files with `//go:build mage` or `// +build mage` tags) in the current directory.
3. **AST parsing** — `parse.PrimaryPackage()` uses `go/parser` and `go/doc` to extract exported functions, namespaces (types embedding `mg.Namespace`), `//mage:import` directives, aliases, and the default target.
4. **Code generation** — `GenerateMainfile()` renders `mage/magefile_tmpl.go` (a Go `text/template`) into a wrapper `main` package that handles flag parsing, help output, and dispatching to user targets.
5. **Compilation & caching** — `Compile()` runs `go build` on the magefiles plus the generated wrapper. The output binary is cached by content hash in the user's cache directory.

### Package Map

- **`mage/`** — Core library: CLI entry point, file scanning, code generation, compilation, and execution. Can be used as a library (`mage.Invoke()`).
- **`mg/`** — User-facing API for magefiles: `Deps`/`CtxDeps` for dependency declaration, `mg.F()` for parameterized targets, `mg.Namespace` for grouping targets, `Fatal`/`Fatalf` for error handling.
- **`parse/`** — Go AST parser that extracts target metadata (functions, namespaces, imports, aliases, defaults) from magefiles into a `parse.PkgInfo` model consumed by code generation.
- **`sh/`** — Shell helper functions (`sh.Run`, `sh.Output`, `sh.Exec`) for use in magefiles.
- **`internal/`** — Shared low-level utilities for command execution and debug output.
- **`target/`** — Timestamp-based rebuild helpers (`target.Path`, `target.Dir`, `target.Glob`) for use in magefiles.

## Key Conventions

- **Zero external dependencies.** Mage uses only the Go standard library. This is intentional — since mage is often vendored into projects, adding dependencies to mage adds them to every project that uses it. Do not add external dependencies.
- **Go 1.18 minimum.** The `go.mod` specifies Go 1.18. CI tests against both Go 1.18 and stable. Avoid language features or stdlib APIs from newer Go versions.
- **Target function signatures** follow strict rules enforced by `parse/parse.go` (`funcType`): optional leading `context.Context` parameter, supported arg types (`string`, `int`, `bool`, `time.Duration`), and must return either nothing or `error`. Pointer args become optional CLI arguments.
- **`//mage:import`** comments on blank imports cause mage to recursively parse imported packages and surface their exported functions as targets.
- **Namespace targets** are methods on types that embed `mg.Namespace`. The type name becomes a CLI prefix (e.g., `mage ns:target`).
- **Documentation** — All functions, methods, types, package variables, and package constants must have Go doc comments describing their purpose, including unexported ones. Every package must have a detailed package-level doc comment explaining what the package is for and how to use it.
- **Formatting** uses `goimports` (configured in `.golangci.toml`).
- **Tests** are primarily integration-style: `mage/main_test.go` calls `Invoke()` against fixture directories under `testdata/`. Table-driven unit tests are used in `parse/`, `sh/`, `internal/`, and `target/`. Always run tests with `-race`.
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,8 @@ Session.vim

# Hugo build lock
.hugo_build.lock

/site/public/

# Release output
/dist
49 changes: 9 additions & 40 deletions .golangci.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,13 @@ disable = [
'asciicheck',
'canonicalheader',
'containedctx',
'copyloopvar', # Not applicable in go versions under 1.22
'copyloopvar',
'cyclop',
'depguard',
'dogsled',
'dupl',
'dupword',
'err113',
'errcheck',
'exhaustive',
'exhaustruct',
'forbidigo',
Expand Down Expand Up @@ -61,8 +60,6 @@ disable = [
'thelper',
'unparam',
'varnamelen',
'wastedassign',
'whitespace',
'wrapcheck',
'wsl',
'wsl_v5'
Expand All @@ -81,7 +78,6 @@ pattern = 'time.After\.*(# use of time After can create memory allocation issues
[linters.settings.gocritic]
disabled-checks = [
'importShadow',
# 'unnamedResult'
]
enabled-tags = [
'diagnostic',
Expand All @@ -98,38 +94,32 @@ sizeThreshold = 256

[linters.settings.gosec]
excludes = [
'G204',
'G304',
'G307',
'G702',
'G706'
'G204', # Audit use of exec.Command with variable arguments (command injection risk)
'G304', # Audit file path provided as taint input (path traversal via user-supplied file paths)
'G307', # Deferring a method which returns an error (e.g., defer f.Close() without checking the error)
'G702', # net/http SetDeadline not called (HTTP server timeout not configured)
'G703', # Path traversal via taint analysis
'G706', # Audit use of io.ReadAll (potential denial of service from unbounded reads)
]

[linters.settings.gosec.config]
[linters.settings.gosec.config.G104]
# os = ['Setenv']

[linters.settings.govet]
disable = [
'fieldalignment',
'shadow',
'fieldalignment'
]
enable-all = true

[linters.settings.modernize]
disable = ["any"]

[linters.settings.nestif]
min-complexity = 9
min-complexity = 6

[linters.settings.nolintlint]
require-explanation = true
require-specific = true
allow-unused = false

# [linters.settings.recvcheck]
# exclusions = ['*.UnmarshalJSON']

[linters.settings.revive]
confidence = 0.8
severity = 'error'
Expand All @@ -140,7 +130,6 @@ name = 'comment-spacings'
arguments = [
'nolint'
]
disabled = false

[[linters.settings.revive.rules]]
name = 'argument-limit'
Expand Down Expand Up @@ -168,34 +157,18 @@ disabled = true
name = 'flag-parameter'
disabled = true

[[linters.settings.revive.rules]]
name = 'blank-imports'
disabled = false

[[linters.settings.revive.rules]]
name = 'cognitive-complexity'
disabled = true

[[linters.settings.revive.rules]]
name = 'constant-logical-expr'
disabled = false

[[linters.settings.revive.rules]]
name = 'cyclomatic'
disabled = true

[[linters.settings.revive.rules]]
name = 'file-header'
disabled = false

[[linters.settings.revive.rules]]
name = 'function-length'
disabled = true

[[linters.settings.revive.rules]]
name = 'get-return'
disabled = false

[[linters.settings.revive.rules]]
name = 'line-length-limit'
disabled = true
Expand All @@ -204,10 +177,6 @@ disabled = true
name = 'max-public-structs'
disabled = true

[[linters.settings.revive.rules]]
name = 'optimize-operands-order'
disabled = false

[[linters.settings.revive.rules]]
name = 'redundant-test-main-exit'
disabled = true
Expand Down
1 change: 0 additions & 1 deletion bootstrap.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
//go:build ignore
// +build ignore

package main

Expand Down
1 change: 0 additions & 1 deletion install_test.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
//go:build CI
// +build CI

package main

Expand Down
140 changes: 140 additions & 0 deletions mage/colors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
package mage

//nolint:revive // these are named this way because we also use this code in the generated output and we don't want the imports to potentially conflict with globals in user code.
import (
_fmt "fmt"
_os "os"
_strconv "strconv"
_strings "strings"
)

var printName = func(str string) string {
// color is ANSI color type
type color int

// If you add/change/remove any items in this constant,
// you will need to run "stringer -type=color" in this directory again.
// NOTE: Please keep the list in an alphabetical order.
const (
black color = iota
red
green
yellow
blue
magenta
cyan
white
brightblack
brightred
brightgreen
brightyellow
brightblue
brightmagenta
brightcyan
brightwhite
)

// AnsiColor are ANSI color codes for supported terminal colors.
var ansiColor = map[color]string{
black: "\u001b[30m",
red: "\u001b[31m",
green: "\u001b[32m",
yellow: "\u001b[33m",
blue: "\u001b[34m",
magenta: "\u001b[35m",
cyan: "\u001b[36m",
white: "\u001b[37m",
brightblack: "\u001b[30;1m",
brightred: "\u001b[31;1m",
brightgreen: "\u001b[32;1m",
brightyellow: "\u001b[33;1m",
brightblue: "\u001b[34;1m",
brightmagenta: "\u001b[35;1m",
brightcyan: "\u001b[36;1m",
brightwhite: "\u001b[37;1m",
}

const colorName = "blackredgreenyellowbluemagentacyanwhitebrightblackbrightredbrightgreenbrightyellowbrightbluebrightmagentabrightcyanbrightwhite"

var colorIndex = [...]uint8{0, 5, 8, 13, 19, 23, 30, 34, 39, 50, 59, 70, 82, 92, 105, 115, 126}

colorToLowerString := func(i color) string {
if i < 0 || i >= color(len(colorIndex)-1) {
return "color(" + _strconv.FormatInt(int64(i), 10) + ")"
}
return colorName[colorIndex[i]:colorIndex[i+1]]
}

// ansiColorReset is an ANSI color code to reset the terminal color.
const ansiColorReset = "\033[0m"

// defaultTargetAnsiColor is a default ANSI color for colorizing targets.
// It is set to Cyan as an arbitrary color, because it has a neutral meaning
var defaultTargetAnsiColor = ansiColor[cyan]

getAnsiColor := func(color string) (string, bool) {
colorLower := _strings.ToLower(color)
for k, v := range ansiColor {
colorConstLower := colorToLowerString(k)
if colorConstLower == colorLower {
return v, true
}
}
return "", false
}

// Terminals which don't support color:
//
// TERM=vt100
// TERM=cygwin
// TERM=xterm-mono
var noColorTerms = map[string]bool{
"vt100": false,
"cygwin": false,
"xterm-mono": false,
}

// terminalSupportsColor checks if the current console supports color output
//
// Supported:
//
// linux, mac, or windows's ConEmu, Cmder, putty, git-bash.exe, pwsh.exe
//
// Not supported:
//
// windows cmd.exe, powerShell.exe
terminalSupportsColor := func() bool {
envTerm := _os.Getenv("TERM")
if _, ok := noColorTerms[envTerm]; ok {
return false
}
return true
}

// enableColor reports whether the user has requested to enable a color output.
enableColor := func() bool {
b, _ := _strconv.ParseBool(_os.Getenv("MAGEFILE_ENABLE_COLOR"))
return b
}

// targetColor returns the ANSI color which should be used to colorize targets.
targetColor := func() string {
s, exists := _os.LookupEnv("MAGEFILE_TARGET_COLOR")
if exists {
if c, ok := getAnsiColor(s); ok {
return c
}
}
return defaultTargetAnsiColor
}

// store the color terminal variables, so that the detection isn't repeated for each target
var enableColorValue = enableColor() && terminalSupportsColor()
var targetColorValue = targetColor()

if enableColorValue {
return _fmt.Sprintf("%s%s%s", targetColorValue, str, ansiColorReset)
}

return str
}
4 changes: 2 additions & 2 deletions mage/command_string.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading