Imported from dgageot/gogo (
AGENTS.md). Install upstream withnpx skills add dgageot/gogo. Copyright stays with the author.
AGENTS.md
Development commands
The project dogfoods itself: a gogo.yaml at the repo root drives
day-to-day development. Use the gogo binary for all dev workflows — see
gogo.yaml for the exact commands each task runs.
| Goal | Command |
|---|---|
| Build, lint, test | gogo (default) |
| Build the binary | gogo build |
| Run all tests | gogo test |
| Run linters | gogo lint |
| Format Go sources | gogo format |
| Watch + rebuild | gogo -w dev |
| Cross-compile all | gogo cross |
| Clean artifacts | gogo clean |
CI runs tests (go test ./...), golangci-lint, govulncheck, and a
six-target pure-Go cross-compile matrix. The vulncheck job also runs on a
weekly cron so newly disclosed advisories surface even when the repo is quiet.
Dependabot (.github/dependabot.yml) bumps Go modules and GitHub Actions SHAs
weekly — pair the Actions group with the ghapin skill when reviewing those
PRs.
Releases (release.yml) are tag-driven and publish cross-built binaries via
gh release create plus build-provenance attestations.
The Go toolchain version comes from go.mod (go 1.26.5). Tests run with
-tests=true under golangci-lint v2.
Code style and conventions
- Linters: golangci-lint v2 with a long enable list (see
.golangci.yml). Notable settings:gofumptwithextra-rules: trueandgofmtrewritesinterface{}→any.gcienforces three import groups: standard, default, thenprefix(github.com/dgageot/gogo)(custom-order). Match this exactly when adding imports.depguarddeniesgithub.com/stretchr/testifyfrom non-_test.gofiles.forbidigo(tests only) banscontext.Background/TODO(),os.MkdirTemp/Setenv/Chdir, andfmt.Print*— use the testing equivalents (t.Context(),t.TempDir(),t.Setenv(),t.Chdir()) and write to a buffer instead of stdout.reviverequires exported-symbol comments (incl. private receivers) and package comments;staticcheckruns all checks.- Disabled gocritic checks:
dupImport,hugeParam,rangeValCopy,unnamedResult,appendAssign.
- Errors: wrap with
fmt.Errorf("...: %w", err). Aggregate parallel errors witherrors.Join(seerunDeps).os.ErrNotExistis matched witherrors.Is. - Concurrency: prefer
sync.Map+sync.Oncefor memoization (seetaskRun); usewg.Go(Go 1.25+) for fan-out work; always clone slices you stash onShellCommand.Env(seecloneShellCommandin tests). - Defaults: use
cmp.Or(task.Dir, r.tf.Dir)rather than ad-hoc empty checks (seeRunner.taskDir). - Sorted iteration: when iterating maps for any user-visible or
determinism-sensitive output, sort with
slices.Sorted(maps.Keys(m))— the codebase does this consistently (runner.go,vars.go,env.go,includes.go). - YAML unmarshalling: when adding a field that should accept both a
string and a struct, follow the
Cmd/Dep/Var/Preconditionpattern (try string first, then re-unmarshal into atype plain Xto avoid recursion). For a string-or-list field (e.g.sources,aliases), use theStringListnamed slice intaskfile/types.goinstead of[]string— itsUnmarshalYAMLalready handles the single-string case. - Logging: never
fmt.Printin library code. UseRunner.logTaskor write to the injectedRunnerIO/App.Stdout|Stderr. - Comments on exported symbols: required by
revive; keep them short and descriptive (Martin-Fowler style — say why, not what).
Testing guidelines
- Run a single package:
go test ./taskfile -run TestRunWithExtraVars. - Force re-run (no cache):
go test --count=1 ./.... - Always
testify/requirefor fatal preconditions andtestify/assertfor non-fatal checks. Never use baret.Fatal/t.Errorfor value comparisons. - Use
t.Context(),t.TempDir(),t.Setenv(),t.Chdir()— the linter enforces this. - Test helpers:
taskfile/testhelper_test.go::writeFiles(t, dir, map[string]string)— writes a tree of files (creates parent dirs).taskfile/run_test.go::newTestRunner(t, tf, dir)— returns aRunnerwithBaseEnv = niland afakeShellRunneralready wired up.taskfile/run_test.go::fakeShellRunner— implementsShellRunner, records everyRun/Outputcall, and supports customrunFunc/outputFuncinjection.captureExecs(r)returns a*[]Executionpopulated forShellCommandTaskcalls only.taskfile/run_test.go::envValue(env, key)— last-match env lookup (mirrors how/bin/shresolves duplicates).app_test.go::newTestApp(t, dir, args...)— builds anAppwired to byte buffers; passdir = ""to use the realos.Getwd.
- Tests construct
taskfile.Configliterals directly when a YAML round-trip isn't part of the contract under test — this is the preferred style for runner-level tests. UseParse/LoadWithIncludesonly when the YAML/AST behavior matters. - Two-row table tests are split into two named tests; reserve table tests
for genuinely repetitive cases (see
TestShellJoinPreservesBoundariesfor an accepted multi-case test).
Configuration
gogo.yaml— repo's own task file (the project eats its own dog food). Uses the built-ingosource preset (see below) so every Go-aware task shares one source list. Edit when changing dev workflows..golangci.yml— single source of truth for lint config; keepgci.sectionsin sync if the module path ever changes..github/workflows/—ci.yml(test/lint/vulncheck plus a fast cross-compile matrix) andrelease.yml(tag-triggered pure-Go cross build + GitHub release with build-provenance attestations). Action SHAs are pinned; use theghapinskill when bumping them..github/dependabot.ymlopens grouped weekly PRs for Go modules and GitHub Actions.- Generated/ignored (
.gitignore):.gogo/(checksum cache),bin/, anddist/. - No env vars are required at runtime; gogo only consumes whatever the
user puts in their own
gogo.yaml/ dotenv files. - Source presets — built-ins
go,go-lint, andgo-vendoredlive intaskfile/sources.go::builtinSourcePresets. Users can override or extend them via a top-levelsources:map (Config.Sources); user entries win on a name collision. Presets compose recursively (go-lintandgo-vendoredreferencego); cycles and unknown preset-shaped names are caught inexpandSources. Anything containing a glob metacharacter or path separator is treated as a literal pattern, sogo.mod/*.gowork as before. flatten:(top-level or per-include) lists YAML files whose tasks merge into the parent's namespace without a prefix — the agentic-platform pattern for splitting one big task file across many. Tasks land at the ancestor's namespace (root, or the include dir),task.Diris resolved against the ancestor (not the flatten file's dir), and "first defined wins" so a parent file can override a flattened task by re-declaring it. SeeloadFlattenandTestFlattenedTaskRunsFromRootDir.- Foreign fallback (
fallback.go) — when nogogo.yamlis found, gogo looks in the current directory only (no walking up — an untrusted checkout's ancestors must never be executed; seeTestFallbackDoesNotWalkUp) for aTaskfile.yml,mise.toml, orMakefilewhose runner is onPATH, and shells out. Order is fixed byforeignRunnersand themakearm intentionally dropsdefaultand skips the--separator (sincemakedoesn't understand it).--listfollows the same path viatryForeignListFallback, delegating to the runner's native listing (task --list,mise tasks ls); runners without a native listing (e.g.make) are skipped. Tests stub the package- levelfallbackLookPath/fallbackRunhooks rather than the real exec. - Internal tasks — names whose local segment starts with
_(e.g._helper,cli:_fmt) are excluded from--listand--completebut still callable explicitly. Usetaskfile.IsInternalTaskfor the visibility check;visibleTaskNamesinmain.gois the only consumer.
Common development patterns
- Adding a new top-level CLI flag: add a tagged field to
argsinmain.go, handle it inApp.Runbeforerunner.Runis reached, and add a test inapp_test.gousingnewTestApp. The current flags are--list,--watch,--force,--dry,--completion, and the hidden--complete(used by the shell-completion scripts embedded inmain.go). - Silencing a task's per-cmd log: set
silent: trueon the task. It suppresses the[task] cmdlog line for that task's own cmds only — sub-tasks invoked viacmds: - task: Xcontinue to log unless they also opt in (seeTestSilentDoesNotPropagateToCalledTasks). The shell command itself still runs and its stdout/stderr are unaffected. - Adding a new task field: add it to
Taskintaskfile/types.go, decide whetherUnmarshalYAMLneeds string-shorthand support, thread it throughRunner.runin the right phase (deps → vars → requires → env → preconditions → up-to-date → cmds), and cover it with a literaltaskfile.Configtest inrun_test.goplus aParse-based test inparse_test.goif YAML shape matters. - Touching env/var resolution: respect the existing precedence
(
BaseEnv< parent task env < task dotenv < task env < secrets) and the rule that vars and env are separate namespaces — taskvars:are NOT exported to the shell environment (seeTestParentVarsDoNotFlowDownAsEnv) — and the rule that task dotenv never overrides global dotenv or OS env (seeTestTaskDotenvDoesNotOverrideGlobalDotenv). Sub-task calls (cmds: - task: X) propagate the parent's resolved env viaRunner.runSubTask— deps do NOT (they're prerequisites, not sequenced sub-calls; seeTestDepsDoNotInheritParentEnv). Memoization is bypassed whenever extraVars or parentEnv is non-nil so two call sites with different context don't collapse into one execution. Vars invars:resolve lazily through a recursive lookup inresolveAllVars(seetaskfile/vars.go); they may reference each other and the built-inGIT_*family transitively, declaration order is irrelevant, and cycles short-circuit to the empty string. The built-inTASK_FILE_DIRtemplate var is seeded into every task's resolved-vars map and points at the task's effective working directory (afterRunner.taskDir). Vars are namespace-scoped: a var declared in an includedgogo.yamllives inConfig.NamespaceVars[namespace](with its own working dir inConfig.NamespaceDirs[namespace]forsh:resolution) and is visible only to tasks at or below that namespace — sibling includes never see each other's vars (seeTestNamespacedVarsIsolateSiblings). Root vars inConfig.Varsstay visible everywhere. The most-specific namespace wins on collisions, soproxy:andmetrics:can each declare their ownLDFLAGSwithout clobbering. The built-inGIT_*resolver intaskfile/gitvars.gois wired throughRunner.builtinLookupand is consulted after user vars / CLI_ARGS but before the process environment byexpandVars,resolveEnvValue, andcheckRequires. Unresolved${VAR}references survive verbatim throughunknownShellVarSpan; in particular$2(awk positional) is not rewritten to${2}(seeTestShVarPreservesAwkPositional). Watch mode must callResetRanbetween iterations — it also clears the gitVars cache so{{.GIT_DIRTY}}re-evaluates after each edit. - Touching include logic: cycles must be detected by absolute file
path (
loadStack, which tracks bothgogo.yamlfiles andflatten:YAML files), nested namespaces are colon-joined (parent:child:grandchild), and dotenv files dedupe globally viaseenDotenv.Namespacesmap keys are absolute dirs and are used by namespace-aware task name resolution. - Adding a shell call: route it through
Runner.ShellRunnerso tests can intercept it. Tag it with the rightShellCommandKind(Task,Precondition, orVar). - Touching the
op://path: the trigger ishasOpSecrets(env)over the fully-built env (so dotenv-sourced secrets count). Don't move the check to before env composition. The new top-levelsecrets:block intaskfile/secrets.goruns as the last layer ofbuildEnv, so asecrets: [X]reference still feeds the existingop://detection. When the task's stdout and stderr are both terminals, gogo passes--no-maskingtoop run(opRunArgsintaskfile/shell.go) so interactive TUIs keep their TTY — op's default masking pipes the streams through itself and breaks anything more elaborate than line output. Non-interactive runs (CI, redirected output) keep masking on. New backends plug in by adding acase strings.HasPrefix(uri, ...)branch inresolveSecretURIand a scheme constant insupportedSecretSchemes. - Editing watch behavior: source collection is recursive over deps via
collectSources; remember tor.ResetRan()between iterations or the memoized first run will be returned forever.