mirror of
https://github.com/go-task/task.git
synced 2026-06-29 23:55:18 +00:00
Compare commits
20 Commits
renovate/m
...
feat/compl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f9f2ecb8be | ||
|
|
46201bcac9 | ||
|
|
7fa9d657cd | ||
|
|
1c743de2b7 | ||
|
|
f9e52fab40 | ||
|
|
80693c1208 | ||
|
|
d6cc0ce070 | ||
|
|
d4dc3dc448 | ||
|
|
616433df75 | ||
|
|
a03aa7ba69 | ||
|
|
6abbbcb265 | ||
|
|
c73d53f4e9 | ||
|
|
d0b903c772 | ||
|
|
1d9b3cb7db | ||
|
|
1a0f146888 | ||
|
|
b9b50ca79c | ||
|
|
3dcaa7db89 | ||
|
|
a72eb84c15 | ||
|
|
91b9e42f17 | ||
|
|
f1ab404fbb |
4
.github/workflows/lint.yml
vendored
4
.github/workflows/lint.yml
vendored
@@ -23,7 +23,7 @@ jobs:
|
||||
with:
|
||||
go-version: ${{matrix.go-version}}
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@82606bf257cbaff209d206a39f5134f0cfbfd2ee # v9.2.1
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
with:
|
||||
python-version: 3.14
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: install check-jsonschema
|
||||
run: python -m pip install 'check-jsonschema==0.27.3'
|
||||
|
||||
69
.github/workflows/pr-build.yml
vendored
69
.github/workflows/pr-build.yml
vendored
@@ -1,69 +0,0 @@
|
||||
name: PR Build
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [labeled, synchronize]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
if: contains(github.event.pull_request.labels.*.name, 'needs-build')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
with:
|
||||
go-version: "1.26.x"
|
||||
cache: true
|
||||
- uses: goreleaser/goreleaser-action@5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89 # v7
|
||||
with:
|
||||
version: "~> v2"
|
||||
args: release --snapshot --clean --config .goreleaser-pr.yml
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: task_linux_amd64
|
||||
path: dist/task_linux_amd64.tar.gz
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: task_linux_arm64
|
||||
path: dist/task_linux_arm64.tar.gz
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: task_darwin_amd64
|
||||
path: dist/task_darwin_amd64.tar.gz
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: task_darwin_arm64
|
||||
path: dist/task_darwin_arm64.tar.gz
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: task_windows_amd64
|
||||
path: dist/task_windows_amd64.zip
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: checksums
|
||||
path: dist/task_checksums.txt
|
||||
- uses: peter-evans/find-comment@b30e6a3c0ed37e7c023ccd3f1db5c6c0b0c23aad # v4.0.0
|
||||
id: find-comment
|
||||
with:
|
||||
token: ${{secrets.GITHUB_TOKEN}}
|
||||
issue-number: ${{ github.event.pull_request.number }}
|
||||
body-includes: "📦 Build artifacts ready!"
|
||||
- uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0
|
||||
with:
|
||||
token: ${{secrets.GITHUB_TOKEN}}
|
||||
comment-id: ${{ steps.find-comment.outputs.comment-id }}
|
||||
issue-number: ${{ github.event.pull_request.number }}
|
||||
body: |
|
||||
## 📦 Build artifacts ready!
|
||||
|
||||
Download binaries from [this workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}).
|
||||
|
||||
Available platforms: Linux, macOS, Windows (amd64, arm64)
|
||||
edit-mode: replace
|
||||
2
.github/workflows/release-nightly.yml
vendored
2
.github/workflows/release-nightly.yml
vendored
@@ -13,7 +13,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
4
.github/workflows/release.yml
vendored
4
.github/workflows/release.yml
vendored
@@ -14,7 +14,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6
|
||||
with:
|
||||
package_json_file: "website/package.json"
|
||||
run_install: "true"
|
||||
|
||||
2
.github/workflows/test.yml
vendored
2
.github/workflows/test.yml
vendored
@@ -22,7 +22,7 @@ jobs:
|
||||
runs-on: ${{matrix.platform}}
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Set up Go ${{matrix.go-version}}
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
# yaml-language-server: $schema=https://goreleaser.com/static/schema.json
|
||||
version: 2
|
||||
|
||||
builds:
|
||||
- binary: task
|
||||
main: ./cmd/task
|
||||
goos: [windows, darwin, linux]
|
||||
goarch: [amd64, arm64]
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
mod_timestamp: '{{ .CommitTimestamp }}'
|
||||
flags:
|
||||
- -trimpath
|
||||
ldflags:
|
||||
- "-s -w"
|
||||
|
||||
archives:
|
||||
- name_template: '{{.Binary}}_{{.Os}}_{{.Arch}}'
|
||||
files:
|
||||
- README.md
|
||||
- LICENSE
|
||||
- completion/**/*
|
||||
format_overrides:
|
||||
- goos: windows
|
||||
formats: [zip]
|
||||
|
||||
snapshot:
|
||||
version_template: 'pr-{{ .ShortCommit }}'
|
||||
|
||||
checksum:
|
||||
name_template: 'task_checksums.txt'
|
||||
6
.vscode/settings-sample.json
vendored
6
.vscode/settings-sample.json
vendored
@@ -5,6 +5,12 @@
|
||||
"Taskfile.yaml",
|
||||
"taskfile.yml",
|
||||
"taskfile.yaml"
|
||||
],
|
||||
"./website/src/public/schema-taskrc.json": [
|
||||
".taskrc.yml",
|
||||
".taskrc.yaml",
|
||||
"taskrc.yml",
|
||||
"taskrc.yaml"
|
||||
]
|
||||
},
|
||||
"gopls": {
|
||||
|
||||
23
CHANGELOG.md
23
CHANGELOG.md
@@ -10,6 +10,29 @@
|
||||
by @Legimity).
|
||||
- PowerShell completions now work with aliases of the `task` command, not just
|
||||
the `task` binary itself (#2852 by @kojiishi).
|
||||
- Fixed task and namespace aliases not being completed by the Zsh completion.
|
||||
A `show-aliases` zstyle can turn this off (#2865, #2864 by @vmaerten).
|
||||
- Fixed task names containing certain characters (e.g. `\`, `_`, `^`) leaking
|
||||
into checksum/timestamp filenames, breaking `sources:`/`generates:`
|
||||
up-to-date detection (#2886 by @s3onghyun).
|
||||
- Fixed `for: matrix:` loops using `ref:` rows producing wrong values when the
|
||||
same task was run concurrently (e.g. by parallel `deps`) with different vars
|
||||
(#2890, #2894 by @amitmishra11).
|
||||
- Added a `secret: true` flag for variables that masks their value in logs,
|
||||
`task --summary`, and command output (#2514 by @vmaerten).
|
||||
- Added the `use_gitignore` setting (global or per-task) to skip files matched
|
||||
by your `.gitignore` when fingerprinting `sources`/`generates` and when
|
||||
watching (#2773 by @vmaerten).
|
||||
- Added support for configuring output flags (`--output`, `--output-group-begin`,
|
||||
`--output-group-end`, `--output-group-error-only`) via the `TASK_OUTPUT*`
|
||||
environment variables (#2873 by @liiight).
|
||||
- Added a `--temp-dir` flag (with `TASK_TEMP_DIR` env var and `temp-dir` taskrc
|
||||
config) to customise the directory where Task stores temporary files such as
|
||||
checksums. Relative paths are resolved against the root Taskfile (#2891 by
|
||||
@kjasn).
|
||||
- Unified Bash, Fish, Zsh and PowerShell completions behind a single `task
|
||||
__complete` engine, so every shell offers the same suggestions: task names,
|
||||
aliases, flags, flag values and per-task CLI variables (#2897 by @vmaerten).
|
||||
|
||||
## v3.51.1 - 2026-05-16
|
||||
|
||||
|
||||
47
cmd/task/complete_cmd.go
Normal file
47
cmd/task/complete_cmd.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/pflag"
|
||||
|
||||
"github.com/go-task/task/v3"
|
||||
"github.com/go-task/task/v3/internal/complete"
|
||||
)
|
||||
|
||||
func runComplete(args []string) error {
|
||||
dir, entrypoint, global := extractTaskfileFlags(args)
|
||||
|
||||
e := task.NewExecutor(
|
||||
task.WithDir(dir),
|
||||
task.WithEntrypoint(entrypoint),
|
||||
task.WithStdout(io.Discard),
|
||||
task.WithStderr(io.Discard),
|
||||
task.WithVersionCheck(false),
|
||||
)
|
||||
if global {
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
e.Options(task.WithDir(home))
|
||||
}
|
||||
}
|
||||
|
||||
// Best-effort: a missing or broken Taskfile must not break completion.
|
||||
_ = e.Setup()
|
||||
|
||||
suggs, dirv := complete.Complete(e, pflag.CommandLine, args)
|
||||
complete.Write(os.Stdout, suggs, dirv)
|
||||
return nil
|
||||
}
|
||||
|
||||
func extractTaskfileFlags(args []string) (dir, entrypoint string, global bool) {
|
||||
fs := pflag.NewFlagSet("complete", pflag.ContinueOnError)
|
||||
fs.SetOutput(io.Discard)
|
||||
fs.ParseErrorsAllowlist.UnknownFlags = true
|
||||
fs.Usage = func() {}
|
||||
fs.StringVarP(&dir, "dir", "d", "", "")
|
||||
fs.StringVarP(&entrypoint, "taskfile", "t", "", "")
|
||||
fs.BoolVarP(&global, "global", "g", false, "")
|
||||
_ = fs.Parse(args)
|
||||
return
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/go-task/task/v3/args"
|
||||
"github.com/go-task/task/v3/errors"
|
||||
"github.com/go-task/task/v3/experiments"
|
||||
"github.com/go-task/task/v3/internal/complete"
|
||||
"github.com/go-task/task/v3/internal/filepathext"
|
||||
"github.com/go-task/task/v3/internal/flags"
|
||||
"github.com/go-task/task/v3/internal/logger"
|
||||
@@ -58,6 +59,12 @@ func emitCIErrorAnnotation(err error) {
|
||||
}
|
||||
|
||||
func run() error {
|
||||
// Dispatched before flag validation: the args after __complete are the
|
||||
// user's command line, not Task's own flags.
|
||||
if complete.IsActive() {
|
||||
return runComplete(os.Args[2:])
|
||||
}
|
||||
|
||||
log := &logger.Logger{
|
||||
Stdout: os.Stdout,
|
||||
Stderr: os.Stderr,
|
||||
|
||||
17
compiler.go
17
compiler.go
@@ -51,7 +51,7 @@ func (c *Compiler) getVariables(t *ast.Task, call *Call, evaluateShVars bool) (*
|
||||
return nil, err
|
||||
}
|
||||
for k, v := range specialVars {
|
||||
result.Set(k, ast.Var{Value: v})
|
||||
result.Set(k, ast.Var{Value: v, Secret: false})
|
||||
}
|
||||
|
||||
getRangeFunc := func(dir string) func(k string, v ast.Var) error {
|
||||
@@ -63,12 +63,12 @@ func (c *Compiler) getVariables(t *ast.Task, call *Call, evaluateShVars bool) (*
|
||||
// This stops empty interface errors when using the templater to replace values later
|
||||
// Preserve the Sh field so it can be displayed in summary
|
||||
if !evaluateShVars && newVar.Value == nil {
|
||||
result.Set(k, ast.Var{Value: "", Sh: newVar.Sh})
|
||||
result.Set(k, ast.Var{Value: "", Sh: newVar.Sh, Secret: v.Secret})
|
||||
return nil
|
||||
}
|
||||
// If the variable should not be evaluated and it is set, we can set it and return
|
||||
if !evaluateShVars {
|
||||
result.Set(k, ast.Var{Value: newVar.Value, Sh: newVar.Sh})
|
||||
result.Set(k, ast.Var{Value: newVar.Value, Sh: newVar.Sh, Secret: v.Secret})
|
||||
return nil
|
||||
}
|
||||
// Now we can check for errors since we've handled all the cases when we don't want to evaluate
|
||||
@@ -77,7 +77,7 @@ func (c *Compiler) getVariables(t *ast.Task, call *Call, evaluateShVars bool) (*
|
||||
}
|
||||
// If the variable is already set, we can set it and return
|
||||
if newVar.Value != nil || newVar.Sh == nil {
|
||||
result.Set(k, ast.Var{Value: newVar.Value})
|
||||
result.Set(k, ast.Var{Value: newVar.Value, Secret: v.Secret})
|
||||
return nil
|
||||
}
|
||||
// If the variable is dynamic, we need to resolve it first
|
||||
@@ -85,7 +85,7 @@ func (c *Compiler) getVariables(t *ast.Task, call *Call, evaluateShVars bool) (*
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result.Set(k, ast.Var{Value: static})
|
||||
result.Set(k, ast.Var{Value: static, Secret: v.Secret})
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -184,7 +184,12 @@ func (c *Compiler) HandleDynamicVar(v ast.Var, dir string, e []string) (string,
|
||||
result = strings.TrimSuffix(result, "\n")
|
||||
|
||||
c.dynamicCache[*v.Sh] = result
|
||||
c.Logger.VerboseErrf(logger.Magenta, "task: dynamic variable: %q result: %q\n", *v.Sh, result)
|
||||
// Never print the resolved value of a secret variable, even in verbose mode
|
||||
logResult := result
|
||||
if v.Secret {
|
||||
logResult = "*****"
|
||||
}
|
||||
c.Logger.VerboseErrf(logger.Magenta, "task: dynamic variable: %q result: %q\n", *v.Sh, logResult)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -1,60 +1,69 @@
|
||||
# vim: set tabstop=2 shiftwidth=2 expandtab:
|
||||
#
|
||||
# Thin wrapper around `task __complete`. All suggestion logic lives in the
|
||||
# Go engine — do not add completion logic here.
|
||||
|
||||
_GO_TASK_COMPLETION_LIST_OPTION='--list-all'
|
||||
TASK_CMD="${TASK_EXE:-task}"
|
||||
|
||||
function _task()
|
||||
{
|
||||
_task() {
|
||||
local cur prev words cword
|
||||
_init_completion -n : || return
|
||||
|
||||
# Check for `--` within command-line and quit or strip suffix.
|
||||
local i
|
||||
for i in "${!words[@]}"; do
|
||||
if [ "${words[$i]}" == "--" ]; then
|
||||
# Do not complete words following `--` passed to CLI_ARGS.
|
||||
[ $cword -gt $i ] && return
|
||||
# Remove the words following `--` to not put --list in CLI_ARGS.
|
||||
words=( "${words[@]:0:$i}" )
|
||||
break
|
||||
fi
|
||||
local -a args=()
|
||||
if (( cword > 0 )); then
|
||||
args=( "${words[@]:1:cword}" )
|
||||
fi
|
||||
if (( ${#args[@]} == 0 )); then
|
||||
args=( "" )
|
||||
fi
|
||||
|
||||
local output
|
||||
output=$("$TASK_CMD" __complete "${args[@]}" 2>/dev/null)
|
||||
if [[ -z "$output" ]]; then
|
||||
_filedir
|
||||
return
|
||||
fi
|
||||
|
||||
local -a lines=()
|
||||
local line
|
||||
while IFS= read -r line; do
|
||||
lines+=( "$line" )
|
||||
done <<< "$output"
|
||||
|
||||
local last_idx=$(( ${#lines[@]} - 1 ))
|
||||
local directive="${lines[$last_idx]#:}"
|
||||
unset 'lines[$last_idx]'
|
||||
|
||||
if (( directive & 8 )); then
|
||||
local exts=""
|
||||
for line in "${lines[@]}"; do
|
||||
exts+="${exts:+|}$line"
|
||||
done
|
||||
_filedir "@($exts)"
|
||||
return
|
||||
fi
|
||||
|
||||
if (( directive & 16 )); then
|
||||
_filedir -d
|
||||
return
|
||||
fi
|
||||
|
||||
local -a values=()
|
||||
for line in "${lines[@]}"; do
|
||||
values+=( "${line%%$'\t'*}" )
|
||||
done
|
||||
|
||||
# Handle special arguments of options.
|
||||
case "$prev" in
|
||||
-d|--dir|--remote-cache-dir)
|
||||
_filedir -d
|
||||
return $?
|
||||
;;
|
||||
--cacert|--cert|--cert-key)
|
||||
_filedir
|
||||
return $?
|
||||
;;
|
||||
-t|--taskfile)
|
||||
_filedir yaml || return $?
|
||||
_filedir yml
|
||||
return $?
|
||||
;;
|
||||
-o|--output)
|
||||
COMPREPLY=( $( compgen -W "interleaved group prefixed" -- $cur ) )
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
COMPREPLY=( $( compgen -W "${values[*]}" -- "$cur" ) )
|
||||
|
||||
# Handle normal options.
|
||||
case "$cur" in
|
||||
-*)
|
||||
COMPREPLY=( $( compgen -W "$(_parse_help $1)" -- $cur ) )
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
if (( directive & 2 )); then
|
||||
compopt -o nospace 2>/dev/null
|
||||
fi
|
||||
|
||||
# Prepare task name completions.
|
||||
local tasks=( $( "${words[@]}" --silent $_GO_TASK_COMPLETION_LIST_OPTION 2> /dev/null ) )
|
||||
COMPREPLY=( $( compgen -W "${tasks[*]}" -- "$cur" ) )
|
||||
|
||||
# Post-process because task names might contain colons.
|
||||
__ltrim_colon_completions "$cur"
|
||||
|
||||
if (( ${#COMPREPLY[@]} == 0 )) && ! (( directive & 4 )); then
|
||||
_filedir
|
||||
fi
|
||||
}
|
||||
|
||||
complete -F _task "$TASK_CMD"
|
||||
|
||||
@@ -1,120 +1,46 @@
|
||||
# Thin wrapper around `task __complete`. All suggestion logic lives in the
|
||||
# Go engine — do not add completion logic here.
|
||||
|
||||
set -l GO_TASK_PROGNAME (if set -q GO_TASK_PROGNAME; echo $GO_TASK_PROGNAME; else if set -q TASK_EXE; echo $TASK_EXE; else; echo task; end)
|
||||
|
||||
# Cache variables for experiments (global)
|
||||
set -g __task_experiments_cache ""
|
||||
set -g __task_experiments_cache_time 0
|
||||
function __task_complete --inherit-variable GO_TASK_PROGNAME
|
||||
set -l tokens (commandline -opc)
|
||||
set -l current (commandline -ct)
|
||||
set -l args
|
||||
if test (count $tokens) -gt 1
|
||||
set args $tokens[2..-1]
|
||||
end
|
||||
set args $args $current
|
||||
|
||||
# Helper function to get experiments with 1-second cache
|
||||
function __task_get_experiments --inherit-variable GO_TASK_PROGNAME
|
||||
set -l now (date +%s)
|
||||
set -l ttl 1 # Cache for 1 second only
|
||||
|
||||
# Return cached value if still valid
|
||||
if test (math "$now - $__task_experiments_cache_time") -lt $ttl
|
||||
printf '%s\n' $__task_experiments_cache
|
||||
set -l output ($GO_TASK_PROGNAME __complete $args 2>/dev/null)
|
||||
set -l count (count $output)
|
||||
if test $count -eq 0
|
||||
return
|
||||
end
|
||||
|
||||
# Refresh cache
|
||||
set -g __task_experiments_cache ($GO_TASK_PROGNAME --experiments 2>/dev/null)
|
||||
set -g __task_experiments_cache_time $now
|
||||
printf '%s\n' $__task_experiments_cache
|
||||
end
|
||||
|
||||
# Helper function to check if an experiment is enabled
|
||||
function __task_is_experiment_enabled
|
||||
set -l experiment $argv[1]
|
||||
__task_get_experiments | string match -qr "^\* $experiment:.*on"
|
||||
end
|
||||
|
||||
function __task_get_tasks --description "Prints all available tasks with their description" --inherit-variable GO_TASK_PROGNAME
|
||||
# Check if the global task is requested
|
||||
set -l global_task false
|
||||
commandline --current-process | read --tokenize --list --local cmd_args
|
||||
for arg in $cmd_args
|
||||
if test "_$arg" = "_--"
|
||||
break # ignore arguments to be passed to the task
|
||||
set -l last $output[$count]
|
||||
if not string match -q ':*' -- $last
|
||||
# Protocol violation: emit raw lines as a fallback.
|
||||
for line in $output
|
||||
echo $line
|
||||
end
|
||||
return
|
||||
end
|
||||
if test "_$arg" = "_--global" -o "_$arg" = "_-g"
|
||||
set global_task true
|
||||
break
|
||||
|
||||
set -l directive (string replace -r '^:' '' -- $last)
|
||||
# FilterFileExt / FilterDirs are handled by fish's native file completion
|
||||
# via the separate `complete` registrations below.
|
||||
if test (math "$directive & 8") -ne 0; or test (math "$directive & 16") -ne 0
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
# Read the list of tasks (and potential errors)
|
||||
if $global_task
|
||||
$GO_TASK_PROGNAME --global --list-all
|
||||
else
|
||||
$GO_TASK_PROGNAME --list-all
|
||||
end 2>&1 | read -lz rawOutput
|
||||
|
||||
# Return on non-zero exit code (for cases when there is no Taskfile found or etc.)
|
||||
if test $status -ne 0
|
||||
return
|
||||
end
|
||||
|
||||
# Grab names and descriptions (if any) of the tasks
|
||||
set -l output (echo $rawOutput | sed -e '1d; s/\* \(.*\):[[:space:]]\{2,\}\(.*\)[[:space:]]\{2,\}(\(aliases.*\))/\1\t\2\t\3/' -e 's/\* \(.*\):[[:space:]]\{2,\}\(.*\)/\1\t\2/'| string split0)
|
||||
if test $output
|
||||
echo $output
|
||||
end
|
||||
if test $count -gt 1
|
||||
for line in $output[1..(math $count - 1)]
|
||||
echo $line
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
complete -c $GO_TASK_PROGNAME \
|
||||
-d 'Runs the specified task(s). Falls back to the "default" task if no task name was specified, or lists all tasks if an unknown task name was specified.' \
|
||||
-xa "(__task_get_tasks)" \
|
||||
-n "not __fish_seen_subcommand_from --"
|
||||
|
||||
# Standard flags
|
||||
complete -c $GO_TASK_PROGNAME -s a -l list-all -d 'list all tasks'
|
||||
complete -c $GO_TASK_PROGNAME -s c -l color -d 'colored output (default true)'
|
||||
complete -c $GO_TASK_PROGNAME -s C -l concurrency -d 'limit number of concurrent tasks'
|
||||
complete -c $GO_TASK_PROGNAME -l completion -d 'generate shell completion script' -xa "bash zsh fish powershell"
|
||||
complete -c $GO_TASK_PROGNAME -s d -l dir -d 'set directory of execution'
|
||||
complete -c $GO_TASK_PROGNAME -l disable-fuzzy -d 'disable fuzzy matching for task names'
|
||||
complete -c $GO_TASK_PROGNAME -s n -l dry -d 'compile and print tasks without executing'
|
||||
complete -c $GO_TASK_PROGNAME -s x -l exit-code -d 'pass-through exit code of task command'
|
||||
complete -c $GO_TASK_PROGNAME -l experiments -d 'list available experiments'
|
||||
complete -c $GO_TASK_PROGNAME -s F -l failfast -d 'when running tasks in parallel, stop all tasks if one fails'
|
||||
complete -c $GO_TASK_PROGNAME -s f -l force -d 'force execution even when up-to-date'
|
||||
complete -c $GO_TASK_PROGNAME -s g -l global -d 'run global Taskfile from home directory'
|
||||
complete -c $GO_TASK_PROGNAME -s h -l help -d 'show help'
|
||||
complete -c $GO_TASK_PROGNAME -s i -l init -d 'create new Taskfile'
|
||||
complete -c $GO_TASK_PROGNAME -l insecure -d 'allow insecure Taskfile downloads'
|
||||
complete -c $GO_TASK_PROGNAME -s I -l interval -d 'interval to watch for changes'
|
||||
complete -c $GO_TASK_PROGNAME -s j -l json -d 'format task list as JSON'
|
||||
complete -c $GO_TASK_PROGNAME -s l -l list -d 'list tasks with descriptions'
|
||||
complete -c $GO_TASK_PROGNAME -l nested -d 'nest namespaces when listing as JSON'
|
||||
complete -c $GO_TASK_PROGNAME -l no-status -d 'ignore status when listing as JSON'
|
||||
complete -c $GO_TASK_PROGNAME -l interactive -d 'prompt for missing required variables'
|
||||
complete -c $GO_TASK_PROGNAME -s o -l output -d 'set output style' -xa "interleaved group prefixed"
|
||||
complete -c $GO_TASK_PROGNAME -l output-group-begin -d 'message template before grouped output'
|
||||
complete -c $GO_TASK_PROGNAME -l output-group-end -d 'message template after grouped output'
|
||||
complete -c $GO_TASK_PROGNAME -l output-group-error-only -d 'hide output from successful tasks'
|
||||
complete -c $GO_TASK_PROGNAME -s p -l parallel -d 'execute tasks in parallel'
|
||||
complete -c $GO_TASK_PROGNAME -s s -l silent -d 'disable echoing'
|
||||
complete -c $GO_TASK_PROGNAME -l sort -d 'set task sorting order' -xa "default alphanumeric none"
|
||||
complete -c $GO_TASK_PROGNAME -l status -d 'exit non-zero if tasks not up-to-date'
|
||||
complete -c $GO_TASK_PROGNAME -l summary -d 'show task summary'
|
||||
complete -c $GO_TASK_PROGNAME -s t -l taskfile -d 'choose Taskfile to run'
|
||||
complete -c $GO_TASK_PROGNAME -s v -l verbose -d 'verbose output'
|
||||
complete -c $GO_TASK_PROGNAME -l version -d 'show version'
|
||||
complete -c $GO_TASK_PROGNAME -s w -l watch -d 'watch mode, re-run on changes'
|
||||
complete -c $GO_TASK_PROGNAME -s y -l yes -d 'assume yes to all prompts'
|
||||
|
||||
# Experimental flags (dynamically checked at completion time via -n condition)
|
||||
# GentleForce experiment
|
||||
complete -c $GO_TASK_PROGNAME -n "__task_is_experiment_enabled GENTLE_FORCE" -l force-all -d 'force execution of task and all dependencies'
|
||||
|
||||
# RemoteTaskfiles experiment - Options
|
||||
complete -c $GO_TASK_PROGNAME -n "__task_is_experiment_enabled REMOTE_TASKFILES" -l offline -d 'use only local or cached Taskfiles'
|
||||
complete -c $GO_TASK_PROGNAME -n "__task_is_experiment_enabled REMOTE_TASKFILES" -l timeout -d 'timeout for remote Taskfile downloads'
|
||||
complete -c $GO_TASK_PROGNAME -n "__task_is_experiment_enabled REMOTE_TASKFILES" -l expiry -d 'cache expiry duration'
|
||||
complete -c $GO_TASK_PROGNAME -n "__task_is_experiment_enabled REMOTE_TASKFILES" -l remote-cache-dir -d 'directory to cache remote Taskfiles' -xa "(__fish_complete_directories)"
|
||||
complete -c $GO_TASK_PROGNAME -n "__task_is_experiment_enabled REMOTE_TASKFILES" -l cacert -d 'custom CA certificate for TLS' -r
|
||||
complete -c $GO_TASK_PROGNAME -n "__task_is_experiment_enabled REMOTE_TASKFILES" -l cert -d 'client certificate for mTLS' -r
|
||||
complete -c $GO_TASK_PROGNAME -n "__task_is_experiment_enabled REMOTE_TASKFILES" -l cert-key -d 'client certificate private key' -r
|
||||
|
||||
# RemoteTaskfiles experiment - Operations
|
||||
complete -c $GO_TASK_PROGNAME -n "__task_is_experiment_enabled REMOTE_TASKFILES" -l download -d 'download remote Taskfile'
|
||||
complete -c $GO_TASK_PROGNAME -n "__task_is_experiment_enabled REMOTE_TASKFILES" -l clear-cache -d 'clear remote Taskfile cache'
|
||||
complete -c $GO_TASK_PROGNAME --no-files -a "(__task_complete)"
|
||||
complete -c $GO_TASK_PROGNAME -s t -l taskfile -r -k -a "(__fish_complete_suffix .yml .yaml)"
|
||||
complete -c $GO_TASK_PROGNAME -s d -l dir -xa "(__fish_complete_directories)"
|
||||
|
||||
@@ -1,94 +1,61 @@
|
||||
using namespace System.Management.Automation
|
||||
|
||||
# Thin wrapper around `task __complete`. All suggestion logic lives in the
|
||||
# Go engine — do not add completion logic here.
|
||||
|
||||
$cmdNames = @('task') + (Get-Alias -Definition task,task.exe,*\task,*\task.exe -ErrorAction SilentlyContinue).Name | Select-Object -Unique
|
||||
|
||||
Register-ArgumentCompleter -CommandName $cmdNames -ScriptBlock {
|
||||
param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters)
|
||||
Register-ArgumentCompleter -Native -CommandName $cmdNames -ScriptBlock {
|
||||
param($wordToComplete, $commandAst, $cursorPosition)
|
||||
|
||||
if ($commandName.StartsWith('-')) {
|
||||
$completions = @(
|
||||
# Standard flags (alphabetical order)
|
||||
[CompletionResult]::new('-a', '-a', [CompletionResultType]::ParameterName, 'list all tasks'),
|
||||
[CompletionResult]::new('--list-all', '--list-all', [CompletionResultType]::ParameterName, 'list all tasks'),
|
||||
[CompletionResult]::new('-c', '-c', [CompletionResultType]::ParameterName, 'colored output'),
|
||||
[CompletionResult]::new('--color', '--color', [CompletionResultType]::ParameterName, 'colored output'),
|
||||
[CompletionResult]::new('-C', '-C', [CompletionResultType]::ParameterName, 'limit concurrent tasks'),
|
||||
[CompletionResult]::new('--concurrency', '--concurrency', [CompletionResultType]::ParameterName, 'limit concurrent tasks'),
|
||||
[CompletionResult]::new('--completion', '--completion', [CompletionResultType]::ParameterName, 'generate shell completion'),
|
||||
[CompletionResult]::new('-d', '-d', [CompletionResultType]::ParameterName, 'set directory'),
|
||||
[CompletionResult]::new('--dir', '--dir', [CompletionResultType]::ParameterName, 'set directory'),
|
||||
[CompletionResult]::new('--disable-fuzzy', '--disable-fuzzy', [CompletionResultType]::ParameterName, 'disable fuzzy matching'),
|
||||
[CompletionResult]::new('-n', '-n', [CompletionResultType]::ParameterName, 'dry run'),
|
||||
[CompletionResult]::new('--dry', '--dry', [CompletionResultType]::ParameterName, 'dry run'),
|
||||
[CompletionResult]::new('-x', '-x', [CompletionResultType]::ParameterName, 'pass-through exit code'),
|
||||
[CompletionResult]::new('--exit-code', '--exit-code', [CompletionResultType]::ParameterName, 'pass-through exit code'),
|
||||
[CompletionResult]::new('--experiments', '--experiments', [CompletionResultType]::ParameterName, 'list experiments'),
|
||||
[CompletionResult]::new('-F', '-F', [CompletionResultType]::ParameterName, 'fail fast on pallalel tasks'),
|
||||
[CompletionResult]::new('--failfast', '--failfast', [CompletionResultType]::ParameterName, 'force execution'),
|
||||
[CompletionResult]::new('-f', '-f', [CompletionResultType]::ParameterName, 'force execution'),
|
||||
[CompletionResult]::new('--force', '--force', [CompletionResultType]::ParameterName, 'force execution'),
|
||||
[CompletionResult]::new('-g', '-g', [CompletionResultType]::ParameterName, 'run global Taskfile'),
|
||||
[CompletionResult]::new('--global', '--global', [CompletionResultType]::ParameterName, 'run global Taskfile'),
|
||||
[CompletionResult]::new('-h', '-h', [CompletionResultType]::ParameterName, 'show help'),
|
||||
[CompletionResult]::new('--help', '--help', [CompletionResultType]::ParameterName, 'show help'),
|
||||
[CompletionResult]::new('-i', '-i', [CompletionResultType]::ParameterName, 'create new Taskfile'),
|
||||
[CompletionResult]::new('--init', '--init', [CompletionResultType]::ParameterName, 'create new Taskfile'),
|
||||
[CompletionResult]::new('--insecure', '--insecure', [CompletionResultType]::ParameterName, 'allow insecure downloads'),
|
||||
[CompletionResult]::new('-I', '-I', [CompletionResultType]::ParameterName, 'watch interval'),
|
||||
[CompletionResult]::new('--interval', '--interval', [CompletionResultType]::ParameterName, 'watch interval'),
|
||||
[CompletionResult]::new('-j', '-j', [CompletionResultType]::ParameterName, 'format as JSON'),
|
||||
[CompletionResult]::new('--json', '--json', [CompletionResultType]::ParameterName, 'format as JSON'),
|
||||
[CompletionResult]::new('-l', '-l', [CompletionResultType]::ParameterName, 'list tasks'),
|
||||
[CompletionResult]::new('--list', '--list', [CompletionResultType]::ParameterName, 'list tasks'),
|
||||
[CompletionResult]::new('--nested', '--nested', [CompletionResultType]::ParameterName, 'nest namespaces in JSON'),
|
||||
[CompletionResult]::new('--no-status', '--no-status', [CompletionResultType]::ParameterName, 'ignore status in JSON'),
|
||||
[CompletionResult]::new('--interactive', '--interactive', [CompletionResultType]::ParameterName, 'prompt for missing required variables'),
|
||||
[CompletionResult]::new('-o', '-o', [CompletionResultType]::ParameterName, 'set output style'),
|
||||
[CompletionResult]::new('--output', '--output', [CompletionResultType]::ParameterName, 'set output style'),
|
||||
[CompletionResult]::new('--output-group-begin', '--output-group-begin', [CompletionResultType]::ParameterName, 'template before group'),
|
||||
[CompletionResult]::new('--output-group-end', '--output-group-end', [CompletionResultType]::ParameterName, 'template after group'),
|
||||
[CompletionResult]::new('--output-group-error-only', '--output-group-error-only', [CompletionResultType]::ParameterName, 'hide successful output'),
|
||||
[CompletionResult]::new('-p', '-p', [CompletionResultType]::ParameterName, 'execute in parallel'),
|
||||
[CompletionResult]::new('--parallel', '--parallel', [CompletionResultType]::ParameterName, 'execute in parallel'),
|
||||
[CompletionResult]::new('-s', '-s', [CompletionResultType]::ParameterName, 'silent mode'),
|
||||
[CompletionResult]::new('--silent', '--silent', [CompletionResultType]::ParameterName, 'silent mode'),
|
||||
[CompletionResult]::new('--sort', '--sort', [CompletionResultType]::ParameterName, 'task sorting order'),
|
||||
[CompletionResult]::new('--status', '--status', [CompletionResultType]::ParameterName, 'check task status'),
|
||||
[CompletionResult]::new('--summary', '--summary', [CompletionResultType]::ParameterName, 'show task summary'),
|
||||
[CompletionResult]::new('-t', '-t', [CompletionResultType]::ParameterName, 'choose Taskfile'),
|
||||
[CompletionResult]::new('--taskfile', '--taskfile', [CompletionResultType]::ParameterName, 'choose Taskfile'),
|
||||
[CompletionResult]::new('-v', '-v', [CompletionResultType]::ParameterName, 'verbose output'),
|
||||
[CompletionResult]::new('--verbose', '--verbose', [CompletionResultType]::ParameterName, 'verbose output'),
|
||||
[CompletionResult]::new('--version', '--version', [CompletionResultType]::ParameterName, 'show version'),
|
||||
[CompletionResult]::new('-w', '-w', [CompletionResultType]::ParameterName, 'watch mode'),
|
||||
[CompletionResult]::new('--watch', '--watch', [CompletionResultType]::ParameterName, 'watch mode'),
|
||||
[CompletionResult]::new('-y', '-y', [CompletionResultType]::ParameterName, 'assume yes'),
|
||||
[CompletionResult]::new('--yes', '--yes', [CompletionResultType]::ParameterName, 'assume yes')
|
||||
)
|
||||
$TaskExe = if ($env:TASK_EXE) { $env:TASK_EXE } else { 'task' }
|
||||
|
||||
# Experimental flags (dynamically added based on enabled experiments)
|
||||
$experiments = & task --experiments 2>$null | Out-String
|
||||
|
||||
if ($experiments -match '\* GENTLE_FORCE:.*on') {
|
||||
$completions += [CompletionResult]::new('--force-all', '--force-all', [CompletionResultType]::ParameterName, 'force all dependencies')
|
||||
# Words after the program name, truncated to the cursor.
|
||||
$argsToPass = @()
|
||||
$elements = $commandAst.CommandElements
|
||||
if ($elements.Count -gt 1) {
|
||||
for ($i = 1; $i -lt $elements.Count; $i++) {
|
||||
$el = $elements[$i]
|
||||
if ($el.Extent.StartOffset -ge $cursorPosition) { break }
|
||||
$argsToPass += $el.ToString()
|
||||
}
|
||||
|
||||
if ($experiments -match '\* REMOTE_TASKFILES:.*on') {
|
||||
# Options
|
||||
$completions += [CompletionResult]::new('--offline', '--offline', [CompletionResultType]::ParameterName, 'use cached Taskfiles')
|
||||
$completions += [CompletionResult]::new('--timeout', '--timeout', [CompletionResultType]::ParameterName, 'download timeout')
|
||||
$completions += [CompletionResult]::new('--expiry', '--expiry', [CompletionResultType]::ParameterName, 'cache expiry')
|
||||
$completions += [CompletionResult]::new('--remote-cache-dir', '--remote-cache-dir', [CompletionResultType]::ParameterName, 'cache directory')
|
||||
$completions += [CompletionResult]::new('--cacert', '--cacert', [CompletionResultType]::ParameterName, 'custom CA certificate')
|
||||
$completions += [CompletionResult]::new('--cert', '--cert', [CompletionResultType]::ParameterName, 'client certificate')
|
||||
$completions += [CompletionResult]::new('--cert-key', '--cert-key', [CompletionResultType]::ParameterName, 'client private key')
|
||||
# Operations
|
||||
$completions += [CompletionResult]::new('--download', '--download', [CompletionResultType]::ParameterName, 'download remote Taskfile')
|
||||
$completions += [CompletionResult]::new('--clear-cache', '--clear-cache', [CompletionResultType]::ParameterName, 'clear cache')
|
||||
}
|
||||
|
||||
return $completions.Where{ $_.CompletionText.StartsWith($commandName) }
|
||||
}
|
||||
# The trailing word (possibly empty) must reach the engine so it knows
|
||||
# the cursor sits on a fresh word.
|
||||
if ($argsToPass.Count -gt 0 -and $argsToPass[-1] -eq $wordToComplete) {
|
||||
$argsToPass[-1] = $wordToComplete
|
||||
} else {
|
||||
$argsToPass += $wordToComplete
|
||||
}
|
||||
|
||||
return $(task --list-all --silent) | Where-Object { $_.StartsWith($commandName) } | ForEach-Object { return $_ + " " }
|
||||
$output = & $TaskExe __complete @argsToPass 2>$null
|
||||
if (-not $output) { return }
|
||||
|
||||
$lines = @($output)
|
||||
if ($lines.Count -eq 0) { return }
|
||||
$last = $lines[-1]
|
||||
if (-not $last.StartsWith(':')) { return }
|
||||
|
||||
$directive = [int]($last.Substring(1))
|
||||
$data = if ($lines.Count -gt 1) { $lines[0..($lines.Count - 2)] } else { @() }
|
||||
|
||||
# FilterFileExt
|
||||
if ($directive -band 8) {
|
||||
$patterns = $data | ForEach-Object { "*.$_" }
|
||||
return Get-ChildItem -Path . -Include $patterns -File -ErrorAction SilentlyContinue |
|
||||
ForEach-Object { [CompletionResult]::new($_.Name, $_.Name, [CompletionResultType]::ProviderItem, $_.Name) }
|
||||
}
|
||||
|
||||
# FilterDirs
|
||||
if ($directive -band 16) {
|
||||
return Get-ChildItem -Path . -Directory -ErrorAction SilentlyContinue |
|
||||
ForEach-Object { [CompletionResult]::new($_.Name, $_.Name, [CompletionResultType]::ProviderContainer, $_.Name) }
|
||||
}
|
||||
|
||||
return $data | ForEach-Object {
|
||||
$parts = $_ -split "`t", 2
|
||||
$value = $parts[0]
|
||||
$desc = if ($parts.Count -gt 1 -and $parts[1]) { $parts[1] } else { $value }
|
||||
[CompletionResult]::new($value, $value, [CompletionResultType]::ParameterValue, $desc)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,154 +1,65 @@
|
||||
#compdef task
|
||||
typeset -A opt_args
|
||||
#
|
||||
# Thin wrapper around `task __complete`. All suggestion logic lives in the
|
||||
# Go engine — do not add completion logic here.
|
||||
|
||||
TASK_CMD="${TASK_EXE:-task}"
|
||||
compdef _task "$TASK_CMD"
|
||||
|
||||
_GO_TASK_COMPLETION_LIST_OPTION="${GO_TASK_COMPLETION_LIST_OPTION:---list-all}"
|
||||
|
||||
# Check if an experiment is enabled
|
||||
function __task_is_experiment_enabled() {
|
||||
local experiment=$1
|
||||
task --experiments 2>/dev/null | grep -q "^\* ${experiment}:.*on"
|
||||
}
|
||||
|
||||
# Listing commands from Taskfile.yml
|
||||
function __task_list() {
|
||||
local -a scripts cmd
|
||||
local -i enabled=0
|
||||
local taskfile item task desc
|
||||
|
||||
cmd=($TASK_CMD)
|
||||
taskfile=${(Qv)opt_args[(i)-t|--taskfile]}
|
||||
taskfile=${taskfile//\~/$HOME}
|
||||
|
||||
for arg in "${words[@]:0:$CURRENT}"; do
|
||||
if [[ "$arg" = "--" ]]; then
|
||||
# Use default completion for words after `--` as they are CLI_ARGS.
|
||||
_default
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ -n "$taskfile" && -f "$taskfile" ]]; then
|
||||
cmd+=(--taskfile "$taskfile")
|
||||
fi
|
||||
|
||||
# Check if global flag is set
|
||||
if (( ${+opt_args[-g]} || ${+opt_args[--global]} )); then
|
||||
cmd+=(--global)
|
||||
fi
|
||||
|
||||
if output=$("${cmd[@]}" $_GO_TASK_COMPLETION_LIST_OPTION 2>/dev/null); then
|
||||
enabled=1
|
||||
fi
|
||||
|
||||
(( enabled )) || return 0
|
||||
|
||||
scripts=()
|
||||
|
||||
# Read zstyle verbose option (default = true via -T)
|
||||
local show_desc
|
||||
zstyle -T ":completion:${curcontext}:" verbose && show_desc=true || show_desc=false
|
||||
|
||||
for item in "${(@)${(f)output}[2,-1]#\* }"; do
|
||||
task="${item%%:[[:space:]]*}"
|
||||
|
||||
if [[ "$show_desc" == "true" ]]; then
|
||||
local desc="${item##[^[:space:]]##[[:space:]]##}"
|
||||
scripts+=( "${task//:/\\:}:$desc" )
|
||||
else
|
||||
scripts+=( "$task" )
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ "$show_desc" == "true" ]]; then
|
||||
_describe 'Task to run' scripts
|
||||
else
|
||||
compadd -Q -a scripts
|
||||
fi
|
||||
}
|
||||
|
||||
_task() {
|
||||
local -a standard_args operation_args
|
||||
local -a args lines completions opts
|
||||
local output directive line
|
||||
|
||||
standard_args=(
|
||||
'(-C --concurrency)'{-C,--concurrency}'[limit number of concurrent tasks]: '
|
||||
'(-p --parallel)'{-p,--parallel}'[run command-line tasks in parallel]'
|
||||
'(-F --failfast)'{-F,--failfast}'[when running tasks in parallel, stop all tasks if one fails]'
|
||||
'(-f --force)'{-f,--force}'[run even if task is up-to-date]'
|
||||
'(-c --color)'{-c,--color}'[colored output]'
|
||||
'(--completion)--completion[generate shell completion script]:shell:(bash zsh fish powershell)'
|
||||
'(-d --dir)'{-d,--dir}'[dir to run in]:execution dir:_dirs'
|
||||
'(--disable-fuzzy)--disable-fuzzy[disable fuzzy matching for task names]'
|
||||
'(-n --dry)'{-n,--dry}'[compiles and prints tasks without executing]'
|
||||
'(--dry)--dry[dry-run mode, compile and print tasks only]'
|
||||
'(-x --exit-code)'{-x,--exit-code}'[pass-through exit code of task command]'
|
||||
'(--experiments)--experiments[list available experiments]'
|
||||
'(-g --global)'{-g,--global}'[run global Taskfile from home directory]'
|
||||
'(--insecure)--insecure[allow insecure Taskfile downloads]'
|
||||
'(-I --interval)'{-I,--interval}'[interval to watch for changes]:duration: '
|
||||
'(-j --json)'{-j,--json}'[format task list as JSON]'
|
||||
'(--nested)--nested[nest namespaces when listing as JSON]'
|
||||
'(--no-status)--no-status[ignore status when listing as JSON]'
|
||||
'(--interactive)--interactive[prompt for missing required variables]'
|
||||
'(-o --output)'{-o,--output}'[set output style]:style:(interleaved group prefixed)'
|
||||
'(--output-group-begin)--output-group-begin[message template before grouped output]:template text: '
|
||||
'(--output-group-end)--output-group-end[message template after grouped output]:template text: '
|
||||
'(--output-group-error-only)--output-group-error-only[hide output from successful tasks]'
|
||||
'(-s --silent)'{-s,--silent}'[disable echoing]'
|
||||
'(--sort)--sort[set task sorting order]:order:(default alphanumeric none)'
|
||||
'(--status)--status[exit non-zero if supplied tasks not up-to-date]'
|
||||
'(--summary)--summary[show summary\: field from tasks instead of running them]'
|
||||
'(-t --taskfile)'{-t,--taskfile}'[specify a different taskfile]:taskfile:_files'
|
||||
'(-v --verbose)'{-v,--verbose}'[verbose mode]'
|
||||
'(-w --watch)'{-w,--watch}'[watch-mode for given tasks, re-run when inputs change]'
|
||||
'(-y --yes)'{-y,--yes}'[assume yes to all prompts]'
|
||||
)
|
||||
# (@) preserves a trailing empty string, which the engine relies on to
|
||||
# know the cursor is on a fresh word.
|
||||
args=("${(@)words[2,CURRENT]}")
|
||||
(( ${#args} == 0 )) && args=("")
|
||||
|
||||
# Experimental flags (dynamically added based on enabled experiments)
|
||||
# Options (modify behavior)
|
||||
if __task_is_experiment_enabled "GENTLE_FORCE"; then
|
||||
standard_args+=('(--force-all)--force-all[force execution of task and all dependencies]')
|
||||
output=$("$TASK_CMD" __complete "${args[@]}" 2>/dev/null)
|
||||
if [[ -z "$output" ]]; then
|
||||
_files
|
||||
return
|
||||
fi
|
||||
|
||||
if __task_is_experiment_enabled "REMOTE_TASKFILES"; then
|
||||
standard_args+=(
|
||||
'(--offline --download)--offline[use only local or cached Taskfiles]'
|
||||
'(--timeout)--timeout[timeout for remote Taskfile downloads]:duration: '
|
||||
'(--expiry)--expiry[cache expiry duration]:duration: '
|
||||
'(--remote-cache-dir)--remote-cache-dir[directory to cache remote Taskfiles]:cache dir:_dirs'
|
||||
'(--cacert)--cacert[custom CA certificate for TLS]:file:_files'
|
||||
'(--cert)--cert[client certificate for mTLS]:file:_files'
|
||||
'(--cert-key)--cert-key[client certificate private key]:file:_files'
|
||||
)
|
||||
lines=("${(f)output}")
|
||||
directive="${lines[-1]#:}"
|
||||
lines=("${(@)lines[1,-2]}")
|
||||
|
||||
if (( directive & 8 )); then
|
||||
local -a globs
|
||||
for line in "${lines[@]}"; do
|
||||
globs+=("*.${line}")
|
||||
done
|
||||
_files -g "(${(j:|:)globs})"
|
||||
return
|
||||
fi
|
||||
|
||||
operation_args=(
|
||||
# Task names completion (can be specified multiple times)
|
||||
'(operation)*: :__task_list'
|
||||
# Operational args completion (mutually exclusive)
|
||||
+ '(operation)'
|
||||
'(*)'{-l,--list}'[list describable tasks]'
|
||||
'(*)'{-a,--list-all}'[list all tasks]'
|
||||
'(*)'{-i,--init}'[create new Taskfile.yml]'
|
||||
'(- *)'{-h,--help}'[show help]'
|
||||
'(- *)--version[show version and exit]'
|
||||
)
|
||||
|
||||
# Experimental operations (dynamically added based on enabled experiments)
|
||||
if __task_is_experiment_enabled "REMOTE_TASKFILES"; then
|
||||
standard_args+=(
|
||||
'(--offline --clear-cache)--download[download remote Taskfile]'
|
||||
)
|
||||
operation_args+=(
|
||||
'(* --download)--clear-cache[clear remote Taskfile cache]'
|
||||
)
|
||||
if (( directive & 16 )); then
|
||||
_path_files -/
|
||||
return
|
||||
fi
|
||||
|
||||
_arguments -S $standard_args $operation_args
|
||||
# `:` inside the value must be escaped: _describe splits on the first
|
||||
# unescaped colon (e.g. "docs:serve" would otherwise become value "docs").
|
||||
local value desc
|
||||
for line in "${lines[@]}"; do
|
||||
if [[ "$line" == *$'\t'* ]]; then
|
||||
value="${line%%$'\t'*}"
|
||||
desc="${line#*$'\t'}"
|
||||
completions+=("${value//:/\\:}:$desc")
|
||||
else
|
||||
completions+=("${line//:/\\:}")
|
||||
fi
|
||||
done
|
||||
|
||||
(( directive & 2 )) && opts+=(-S '')
|
||||
(( directive & 32 )) && opts+=(-V)
|
||||
|
||||
if (( ${#completions} > 0 )); then
|
||||
_describe -t tasks 'task' completions "${opts[@]}"
|
||||
fi
|
||||
|
||||
(( directive & 4 )) && return
|
||||
_files
|
||||
}
|
||||
|
||||
# don't run the completion function when being source-ed or eval-ed
|
||||
if [ "$funcstack[1]" = "_task" ]; then
|
||||
_task "$@"
|
||||
fi
|
||||
compdef _task "$TASK_CMD"
|
||||
|
||||
17
executor.go
17
executor.go
@@ -29,6 +29,7 @@ type (
|
||||
Dir string
|
||||
Entrypoint string
|
||||
TempDir TempDir
|
||||
TempDirPath string
|
||||
Force bool
|
||||
ForceAll bool
|
||||
Insecure bool
|
||||
@@ -165,6 +166,22 @@ func (o *tempDirOption) ApplyToExecutor(e *Executor) {
|
||||
e.TempDir = o.tempDir
|
||||
}
|
||||
|
||||
// WithTempDirPath sets an unresolved path used to build [Executor.TempDir]
|
||||
// during [Executor.Setup]. Relative paths are resolved from the root Taskfile
|
||||
// directory. Use [WithTempDir] when the remote and fingerprint directories have
|
||||
// already been resolved.
|
||||
func WithTempDirPath(path string) ExecutorOption {
|
||||
return &tempDirPathOption{path: path}
|
||||
}
|
||||
|
||||
type tempDirPathOption struct {
|
||||
path string
|
||||
}
|
||||
|
||||
func (o *tempDirPathOption) ApplyToExecutor(e *Executor) {
|
||||
e.TempDirPath = o.path
|
||||
}
|
||||
|
||||
// WithForce ensures that the [Executor] always runs a task, even when
|
||||
// fingerprinting or prompts would normally stop it.
|
||||
func WithForce(force bool) ExecutorOption {
|
||||
|
||||
@@ -283,6 +283,68 @@ func TestVars(t *testing.T) {
|
||||
)
|
||||
}
|
||||
|
||||
func TestSecretVars(t *testing.T) {
|
||||
t.Parallel()
|
||||
NewExecutorTest(t,
|
||||
WithName("secret vars are masked in logs"),
|
||||
WithExecutorOptions(
|
||||
task.WithDir("testdata/secrets"),
|
||||
),
|
||||
WithTask("test-secret-masking"),
|
||||
)
|
||||
NewExecutorTest(t,
|
||||
WithName("multiple secrets masked"),
|
||||
WithExecutorOptions(
|
||||
task.WithDir("testdata/secrets"),
|
||||
),
|
||||
WithTask("test-multiple-secrets"),
|
||||
)
|
||||
NewExecutorTest(t,
|
||||
WithName("mixed secret and public vars"),
|
||||
WithExecutorOptions(
|
||||
task.WithDir("testdata/secrets"),
|
||||
),
|
||||
WithTask("test-mixed"),
|
||||
)
|
||||
NewExecutorTest(t,
|
||||
WithName("deferred command with secrets"),
|
||||
WithExecutorOptions(
|
||||
task.WithDir("testdata/secrets"),
|
||||
),
|
||||
WithTask("test-deferred-secret"),
|
||||
)
|
||||
NewExecutorTest(t,
|
||||
WithName("env secret limitation"),
|
||||
WithExecutorOptions(
|
||||
task.WithDir("testdata/secrets"),
|
||||
),
|
||||
WithTask("test-env-secret-limitation"),
|
||||
)
|
||||
NewExecutorTest(t,
|
||||
WithName("secret vars are masked in summary"),
|
||||
WithExecutorOptions(
|
||||
task.WithDir("testdata/secrets"),
|
||||
task.WithSummary(true),
|
||||
),
|
||||
WithTask("test-secret-masking"),
|
||||
)
|
||||
NewExecutorTest(t,
|
||||
WithName("dynamic secret masked in verbose"),
|
||||
WithExecutorOptions(
|
||||
task.WithDir("testdata/secrets"),
|
||||
task.WithVerbose(true),
|
||||
),
|
||||
WithTask("test-dynamic-secret-verbose"),
|
||||
)
|
||||
NewExecutorTest(t,
|
||||
WithName("secret key order independent"),
|
||||
WithExecutorOptions(
|
||||
task.WithDir("testdata/secrets"),
|
||||
),
|
||||
WithTask("test-secret-key-order"),
|
||||
)
|
||||
}
|
||||
|
||||
func TestRequires(t *testing.T) {
|
||||
t.Parallel()
|
||||
NewExecutorTest(t,
|
||||
|
||||
19
go.mod
19
go.mod
@@ -4,11 +4,11 @@ go 1.25.10
|
||||
|
||||
require (
|
||||
charm.land/bubbles/v2 v2.1.0
|
||||
charm.land/bubbletea/v2 v2.0.6
|
||||
charm.land/lipgloss/v2 v2.0.3
|
||||
charm.land/bubbletea/v2 v2.0.7
|
||||
charm.land/lipgloss/v2 v2.0.4
|
||||
github.com/Ladicle/tabwriter v1.0.0
|
||||
github.com/Masterminds/semver/v3 v3.5.0
|
||||
github.com/alecthomas/chroma/v2 v2.26.1
|
||||
github.com/alecthomas/chroma/v2 v2.27.0
|
||||
github.com/chainguard-dev/git-urls v1.0.2
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc
|
||||
github.com/dominikbraun/graph v0.23.0
|
||||
@@ -28,8 +28,8 @@ require (
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/zeebo/xxh3 v1.1.0
|
||||
go.yaml.in/yaml/v3 v3.0.4
|
||||
golang.org/x/sync v0.20.0
|
||||
golang.org/x/term v0.43.0
|
||||
golang.org/x/sync v0.21.0
|
||||
golang.org/x/term v0.44.0
|
||||
mvdan.cc/sh/moreinterp v0.0.0-20260120230322-19def062a997
|
||||
mvdan.cc/sh/v3 v3.13.2-0.20260510185049-f5c6e2779117
|
||||
)
|
||||
@@ -69,7 +69,7 @@ require (
|
||||
github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.4.3 // indirect
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468 // indirect
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260525132238-948f4557a654 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.11.7 // indirect
|
||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||
github.com/charmbracelet/x/termios v0.1.1 // indirect
|
||||
@@ -77,7 +77,7 @@ require (
|
||||
github.com/clipperhouse/displaywidth v0.11.0 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 // indirect
|
||||
github.com/dlclark/regexp2/v2 v2.1.1 // indirect
|
||||
github.com/dlclark/regexp2/v2 v2.2.1 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.36.0 // indirect
|
||||
github.com/envoyproxy/protoc-gen-validate v1.3.0 // indirect
|
||||
@@ -92,7 +92,7 @@ require (
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
|
||||
github.com/hashicorp/go-version v1.8.0 // indirect
|
||||
github.com/klauspost/compress v1.18.5 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/klauspost/pgzip v1.2.6 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
@@ -121,9 +121,10 @@ require (
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.43.0 // indirect
|
||||
golang.org/x/crypto v0.51.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
|
||||
golang.org/x/net v0.55.0 // indirect
|
||||
golang.org/x/oauth2 v0.36.0 // indirect
|
||||
golang.org/x/sys v0.45.0 // indirect
|
||||
golang.org/x/sys v0.46.0 // indirect
|
||||
golang.org/x/text v0.37.0 // indirect
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
google.golang.org/api v0.271.0 // indirect
|
||||
|
||||
24
go.sum
24
go.sum
@@ -4,8 +4,12 @@ charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g=
|
||||
charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY=
|
||||
charm.land/bubbletea/v2 v2.0.6 h1:UHN/91OyuhaOFGSrBXQ/hMZD8IO1Uc4BvHlgHXL2WJo=
|
||||
charm.land/bubbletea/v2 v2.0.6/go.mod h1:MH/D8ZLlN3op37vQvijKuU29g3rqTp+aQapURFonF9g=
|
||||
charm.land/bubbletea/v2 v2.0.7 h1:7qw2tTAVar7m7klOPBYfTB0mniv/RuexsYwMRNxSeL0=
|
||||
charm.land/bubbletea/v2 v2.0.7/go.mod h1:DGW2q8gvzHnOpMpZTORs0aySVHCox5C+2Svk0fci1qs=
|
||||
charm.land/lipgloss/v2 v2.0.3 h1:yM2zJ4Cf5Y51b7RHIwioil4ApI/aypFXXVHSwlM6RzU=
|
||||
charm.land/lipgloss/v2 v2.0.3/go.mod h1:7myLU9iG/3xluAWzpY/fSxYYHCgoKTie7laxk6ATwXA=
|
||||
charm.land/lipgloss/v2 v2.0.4 h1:lcPeVtcp23SNra7lHy8iYE4UC2aIipVQ47sbGyyxR5Q=
|
||||
charm.land/lipgloss/v2 v2.0.4/go.mod h1:0653x8epbZSzdDfO/XPS1a/uYPOBeSsCssOpJOqDzik=
|
||||
cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
|
||||
cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
|
||||
cloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM=
|
||||
@@ -42,6 +46,8 @@ github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8v
|
||||
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||
github.com/alecthomas/chroma/v2 v2.26.1 h1:2X21EdxGZNv5GF9mG5u+uzc02GCFyGxbcBm3Grd9A78=
|
||||
github.com/alecthomas/chroma/v2 v2.26.1/go.mod h1:lxhRRa9H4hPmRLOOdYga4zkQIQjq3dtrrdwQeCfu78Y=
|
||||
github.com/alecthomas/chroma/v2 v2.27.0 h1:FodwmyOBgJULFYmDqibcp9pvfDLWdtPRh9v/r5BXYZs=
|
||||
github.com/alecthomas/chroma/v2 v2.27.0/go.mod h1:NjJ3ciIgrqBNeIkWZ4e46nseoLDslxU1LmfCoL+wcY8=
|
||||
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
|
||||
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||
@@ -96,6 +102,8 @@ github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex
|
||||
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468 h1:Q9fO0y1Zo5KB/5Vu8JZoLGm1N3RzF9bNj3Ao3xoR+Ac=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468/go.mod h1:bAAz7dh/FTYfC+oiHavL4mX1tOIBZ0ZwYjSi3qE6ivM=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260525132238-948f4557a654 h1:FpSYhY28ucg9ZRr+2wj67FAQ0Ey5yiK0072PmRDJNek=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260525132238-948f4557a654/go.mod h1:hFpumms29Smx3LStRfku8vcCTBe1Kq8aCXtHUJa3mjY=
|
||||
github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
|
||||
github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
|
||||
@@ -120,6 +128,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dlclark/regexp2/v2 v2.1.1 h1:LCUGyd9Wf+r+VVOl8Ny38JTpWJcAsdVnCIuhhtthmKw=
|
||||
github.com/dlclark/regexp2/v2 v2.1.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
|
||||
github.com/dlclark/regexp2/v2 v2.2.1 h1:mf4KkFUj0gJuarK8P+LgiS+Lit7m9N1yAwEfPbee7R0=
|
||||
github.com/dlclark/regexp2/v2 v2.2.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
|
||||
github.com/dominikbraun/graph v0.23.0 h1:TdZB4pPqCLFxYhdyMFb1TBdFxp8XLcJfTTBQucVPgCo=
|
||||
github.com/dominikbraun/graph v0.23.0/go.mod h1:yOjYyogZLY1LSG9E33JWZJiq5k83Qy2C6POAuiViluc=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
@@ -181,8 +191,8 @@ github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
|
||||
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU=
|
||||
github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
@@ -274,19 +284,25 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
|
||||
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
|
||||
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw=
|
||||
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM=
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
|
||||
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
|
||||
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
|
||||
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
|
||||
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
|
||||
30
internal/complete/complete.go
Normal file
30
internal/complete/complete.go
Normal file
@@ -0,0 +1,30 @@
|
||||
// Package complete implements the `task __complete` protocol consumed by the
|
||||
// shell completion wrappers. The protocol mirrors cobra v2 so a future
|
||||
// migration stays cheap.
|
||||
package complete
|
||||
|
||||
import "os"
|
||||
|
||||
const CommandName = "__complete"
|
||||
|
||||
func IsActive() bool {
|
||||
return len(os.Args) >= 2 && os.Args[1] == CommandName
|
||||
}
|
||||
|
||||
// Directive mirrors cobra's ShellCompDirective bitfield.
|
||||
type Directive int
|
||||
|
||||
const (
|
||||
DirectiveDefault Directive = 0
|
||||
DirectiveError Directive = 1 << 0
|
||||
DirectiveNoSpace Directive = 1 << 1
|
||||
DirectiveNoFileComp Directive = 1 << 2
|
||||
DirectiveFilterFileExt Directive = 1 << 3
|
||||
DirectiveFilterDirs Directive = 1 << 4
|
||||
DirectiveKeepOrder Directive = 1 << 5
|
||||
)
|
||||
|
||||
type Suggestion struct {
|
||||
Value string
|
||||
Description string
|
||||
}
|
||||
279
internal/complete/complete_test.go
Normal file
279
internal/complete/complete_test.go
Normal file
@@ -0,0 +1,279 @@
|
||||
package complete_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/pflag"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/go-task/task/v3"
|
||||
"github.com/go-task/task/v3/internal/complete"
|
||||
)
|
||||
|
||||
func newTestFlagSet() *pflag.FlagSet {
|
||||
fs := pflag.NewFlagSet("test", pflag.ContinueOnError)
|
||||
var b bool
|
||||
var s string
|
||||
fs.BoolVarP(&b, "list-all", "a", false, "Lists all tasks")
|
||||
fs.BoolVarP(&b, "list", "l", false, "Lists tasks with descriptions")
|
||||
fs.BoolVarP(&b, "verbose", "v", false, "Verbose mode")
|
||||
fs.StringVarP(&s, "taskfile", "t", "", "Taskfile path")
|
||||
fs.StringVarP(&s, "dir", "d", "", "Run dir")
|
||||
fs.StringVarP(&s, "output", "o", "", "Output style")
|
||||
fs.StringVar(&s, "sort", "", "Sort order")
|
||||
fs.StringVar(&s, "cacert", "", "CA cert path")
|
||||
return fs
|
||||
}
|
||||
|
||||
const testTaskfile = `version: '3'
|
||||
|
||||
vars:
|
||||
ALLOWED_ENVS:
|
||||
- dev
|
||||
- staging
|
||||
- prod
|
||||
|
||||
tasks:
|
||||
deploy:
|
||||
desc: Deploy the application
|
||||
aliases: [dep, ship]
|
||||
requires:
|
||||
vars:
|
||||
- name: ENV
|
||||
enum:
|
||||
- dev
|
||||
- staging
|
||||
- prod
|
||||
- REGION
|
||||
cmds:
|
||||
- 'echo {{.ENV}} {{.REGION}}'
|
||||
|
||||
build:
|
||||
desc: Build it
|
||||
cmds:
|
||||
- 'echo build'
|
||||
|
||||
dynenum:
|
||||
desc: Dynamic enum
|
||||
requires:
|
||||
vars:
|
||||
- name: ENV
|
||||
enum:
|
||||
ref: .ALLOWED_ENVS
|
||||
cmds:
|
||||
- 'echo {{.ENV}}'
|
||||
|
||||
docs:serve:
|
||||
desc: Serve docs locally
|
||||
cmds:
|
||||
- 'echo serving'
|
||||
`
|
||||
|
||||
func setupExecutor(t *testing.T) *task.Executor {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "Taskfile.yml"), []byte(testTaskfile), 0o644))
|
||||
|
||||
e := task.NewExecutor(
|
||||
task.WithDir(dir),
|
||||
task.WithStdout(io.Discard),
|
||||
task.WithStderr(io.Discard),
|
||||
task.WithVersionCheck(false),
|
||||
)
|
||||
require.NoError(t, e.Setup())
|
||||
return e
|
||||
}
|
||||
|
||||
func TestComplete_TaskNames(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
e := setupExecutor(t)
|
||||
suggs, dir := complete.Complete(e, newTestFlagSet(), []string{""})
|
||||
|
||||
require.ElementsMatch(t,
|
||||
[]string{"build", "deploy", "dep", "ship", "dynenum", "docs:serve"},
|
||||
values(suggs),
|
||||
)
|
||||
require.Equal(t, complete.DirectiveNoFileComp, dir)
|
||||
require.Contains(t, descriptions(suggs), "Deploy the application")
|
||||
}
|
||||
|
||||
func TestComplete_AliasResolvesToTaskVars(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
e := setupExecutor(t)
|
||||
suggs, dir := complete.Complete(e, newTestFlagSet(), []string{"dep", ""})
|
||||
require.Equal(t, []string{"ENV=dev", "ENV=staging", "ENV=prod", "REGION="}, values(suggs))
|
||||
require.Equal(t, complete.DirectiveNoSpace|complete.DirectiveNoFileComp, dir)
|
||||
}
|
||||
|
||||
func TestComplete_StaticEnum(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
e := setupExecutor(t)
|
||||
suggs, dir := complete.Complete(e, newTestFlagSet(), []string{"deploy", ""})
|
||||
|
||||
require.Equal(t, []string{"ENV=dev", "ENV=staging", "ENV=prod", "REGION="}, values(suggs))
|
||||
require.Equal(t, complete.DirectiveNoSpace|complete.DirectiveNoFileComp, dir)
|
||||
}
|
||||
|
||||
func TestComplete_EnumRef(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
e := setupExecutor(t)
|
||||
suggs, _ := complete.Complete(e, newTestFlagSet(), []string{"dynenum", ""})
|
||||
require.Equal(t, []string{"ENV=dev", "ENV=staging", "ENV=prod"}, values(suggs))
|
||||
}
|
||||
|
||||
func TestComplete_NoRequires(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
e := setupExecutor(t)
|
||||
suggs, dir := complete.Complete(e, newTestFlagSet(), []string{"build", ""})
|
||||
require.Empty(t, suggs)
|
||||
require.Equal(t, complete.DirectiveNoFileComp, dir)
|
||||
}
|
||||
|
||||
func TestComplete_FlagValueNotConfusedWithTaskName(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
e := setupExecutor(t)
|
||||
suggs, dir := complete.Complete(e, newTestFlagSet(), []string{"--dir", "deploy", ""})
|
||||
require.ElementsMatch(t,
|
||||
[]string{"build", "deploy", "dep", "ship", "dynenum", "docs:serve"},
|
||||
values(suggs),
|
||||
)
|
||||
require.Equal(t, complete.DirectiveNoFileComp, dir)
|
||||
}
|
||||
|
||||
func TestComplete_NamespacedTaskName(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
e := setupExecutor(t)
|
||||
suggs, dir := complete.Complete(e, newTestFlagSet(), []string{"docs:serve", ""})
|
||||
require.Empty(t, suggs)
|
||||
require.Equal(t, complete.DirectiveNoFileComp, dir)
|
||||
}
|
||||
|
||||
func TestComplete_FlagValueInlineEquals(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
e := setupExecutor(t)
|
||||
suggs, dir := complete.Complete(e, newTestFlagSet(), []string{"--output="})
|
||||
require.Equal(t, []string{"interleaved", "group", "prefixed"}, values(suggs))
|
||||
require.Equal(t, complete.DirectiveNoFileComp, dir)
|
||||
}
|
||||
|
||||
func TestComplete_AfterDash(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
e := setupExecutor(t)
|
||||
suggs, dir := complete.Complete(e, newTestFlagSet(), []string{"deploy", "--", ""})
|
||||
require.Empty(t, suggs)
|
||||
require.Equal(t, complete.DirectiveDefault, dir)
|
||||
}
|
||||
|
||||
func TestComplete_FlagNames(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
e := setupExecutor(t)
|
||||
suggs, dir := complete.Complete(e, newTestFlagSet(), []string{"-"})
|
||||
require.NotEmpty(t, suggs)
|
||||
require.Equal(t, complete.DirectiveNoFileComp, dir)
|
||||
|
||||
vals := values(suggs)
|
||||
require.Contains(t, vals, "--list-all")
|
||||
require.Contains(t, vals, "--taskfile")
|
||||
require.Contains(t, vals, "-a")
|
||||
}
|
||||
|
||||
func TestComplete_EnumFlagValue_Output(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
e := setupExecutor(t)
|
||||
suggs, dir := complete.Complete(e, newTestFlagSet(), []string{"--output", ""})
|
||||
require.Equal(t, []string{"interleaved", "group", "prefixed"}, values(suggs))
|
||||
require.Equal(t, complete.DirectiveNoFileComp, dir)
|
||||
}
|
||||
|
||||
func TestComplete_EnumFlagValue_Sort(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
e := setupExecutor(t)
|
||||
suggs, _ := complete.Complete(e, newTestFlagSet(), []string{"--sort", ""})
|
||||
require.Equal(t, []string{"default", "alphanumeric", "none"}, values(suggs))
|
||||
}
|
||||
|
||||
func TestComplete_PathFlag_Taskfile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
e := setupExecutor(t)
|
||||
suggs, dir := complete.Complete(e, newTestFlagSet(), []string{"--taskfile", ""})
|
||||
require.Equal(t, []string{"yml", "yaml"}, values(suggs))
|
||||
require.Equal(t, complete.DirectiveFilterFileExt, dir)
|
||||
}
|
||||
|
||||
func TestComplete_PathFlag_Dir(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
e := setupExecutor(t)
|
||||
suggs, dir := complete.Complete(e, newTestFlagSet(), []string{"--dir", ""})
|
||||
require.Empty(t, suggs)
|
||||
require.Equal(t, complete.DirectiveFilterDirs, dir)
|
||||
}
|
||||
|
||||
func TestComplete_PathFlag_Cacert(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
e := setupExecutor(t)
|
||||
suggs, dir := complete.Complete(e, newTestFlagSet(), []string{"--cacert", ""})
|
||||
require.Empty(t, suggs)
|
||||
require.Equal(t, complete.DirectiveDefault, dir)
|
||||
}
|
||||
|
||||
func TestComplete_NilExecutor(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
suggs, dir := complete.Complete(nil, newTestFlagSet(), []string{"-"})
|
||||
require.NotEmpty(t, suggs)
|
||||
require.Equal(t, complete.DirectiveNoFileComp, dir)
|
||||
}
|
||||
|
||||
func TestWrite_Format(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var buf bytes.Buffer
|
||||
complete.Write(&buf, []complete.Suggestion{
|
||||
{Value: "deploy", Description: "Deploy the app"},
|
||||
{Value: "build"},
|
||||
}, complete.DirectiveNoSpace|complete.DirectiveNoFileComp)
|
||||
require.Equal(t, "deploy\tDeploy the app\nbuild\n:6\n", buf.String())
|
||||
}
|
||||
|
||||
func TestWrite_EmptyWithDirective(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var buf bytes.Buffer
|
||||
complete.Write(&buf, nil, complete.DirectiveFilterDirs)
|
||||
require.Equal(t, ":16\n", buf.String())
|
||||
}
|
||||
|
||||
func values(suggs []complete.Suggestion) []string {
|
||||
out := make([]string, 0, len(suggs))
|
||||
for _, s := range suggs {
|
||||
out = append(out, s.Value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func descriptions(suggs []complete.Suggestion) []string {
|
||||
out := make([]string, 0, len(suggs))
|
||||
for _, s := range suggs {
|
||||
out = append(out, s.Description)
|
||||
}
|
||||
return out
|
||||
}
|
||||
65
internal/complete/context.go
Normal file
65
internal/complete/context.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package complete
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
type completionContext struct {
|
||||
toComplete string
|
||||
prev string
|
||||
taskName string
|
||||
afterDash bool
|
||||
}
|
||||
|
||||
// parseContext infers the cursor position from args. fs is needed to skip the
|
||||
// word following a value-taking flag, otherwise `task --dir deploy` would
|
||||
// mistake "deploy" (the directory) for a task name.
|
||||
func parseContext(args []string, knownTasks []string, fs *pflag.FlagSet) completionContext {
|
||||
ctx := completionContext{}
|
||||
if len(args) == 0 {
|
||||
return ctx
|
||||
}
|
||||
|
||||
ctx.toComplete = args[len(args)-1]
|
||||
if len(args) >= 2 {
|
||||
ctx.prev = args[len(args)-2]
|
||||
}
|
||||
|
||||
known := make(map[string]struct{}, len(knownTasks))
|
||||
for _, t := range knownTasks {
|
||||
known[t] = struct{}{}
|
||||
}
|
||||
|
||||
skipNext := false
|
||||
for _, w := range args[:len(args)-1] {
|
||||
if skipNext {
|
||||
skipNext = false
|
||||
continue
|
||||
}
|
||||
if w == "--" {
|
||||
ctx.afterDash = true
|
||||
continue
|
||||
}
|
||||
if ctx.afterDash {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(w, "-") {
|
||||
if !strings.Contains(w, "=") {
|
||||
if f := matchFlagName(fs, w); f != nil && flagTakesValue(f) {
|
||||
skipNext = true
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
if strings.Contains(w, "=") {
|
||||
continue
|
||||
}
|
||||
if _, ok := known[w]; ok {
|
||||
ctx.taskName = w
|
||||
}
|
||||
}
|
||||
|
||||
return ctx
|
||||
}
|
||||
171
internal/complete/engine.go
Normal file
171
internal/complete/engine.go
Normal file
@@ -0,0 +1,171 @@
|
||||
package complete
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/pflag"
|
||||
|
||||
"github.com/go-task/task/v3"
|
||||
"github.com/go-task/task/v3/internal/templater"
|
||||
"github.com/go-task/task/v3/taskfile/ast"
|
||||
)
|
||||
|
||||
// Complete is the single entry point used by `task __complete`. e may be nil
|
||||
// when the Taskfile failed to load; flag completion still works in that case.
|
||||
func Complete(e *task.Executor, fs *pflag.FlagSet, args []string) ([]Suggestion, Directive) {
|
||||
knownTasks := taskNames(e)
|
||||
ctx := parseContext(args, knownTasks, fs)
|
||||
|
||||
if ctx.afterDash {
|
||||
return nil, DirectiveDefault
|
||||
}
|
||||
|
||||
if ctx.prev != "" {
|
||||
if flag := matchFlagName(fs, ctx.prev); flag != nil && flagTakesValue(flag) {
|
||||
return completeFlagValue(flag.Name, ctx.toComplete)
|
||||
}
|
||||
}
|
||||
|
||||
if strings.HasPrefix(ctx.toComplete, "-") {
|
||||
if eqIdx := strings.Index(ctx.toComplete, "="); eqIdx != -1 {
|
||||
flagWord := ctx.toComplete[:eqIdx]
|
||||
partial := ctx.toComplete[eqIdx+1:]
|
||||
if f := matchFlagName(fs, flagWord); f != nil && flagTakesValue(f) {
|
||||
return completeFlagValue(f.Name, partial)
|
||||
}
|
||||
}
|
||||
return listFlags(fs), DirectiveNoFileComp
|
||||
}
|
||||
|
||||
if ctx.taskName != "" && e != nil && e.Taskfile != nil {
|
||||
return completeTaskVars(e, ctx.taskName, ctx.toComplete)
|
||||
}
|
||||
|
||||
return completeTaskNames(e), DirectiveNoFileComp
|
||||
}
|
||||
|
||||
func taskNames(e *task.Executor) []string {
|
||||
if e == nil || e.Taskfile == nil {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
for t := range e.Taskfile.Tasks.Values(nil) {
|
||||
if t.Internal {
|
||||
continue
|
||||
}
|
||||
out = append(out, strings.TrimSuffix(t.Task, ":"))
|
||||
for _, alias := range t.Aliases {
|
||||
out = append(out, strings.TrimSuffix(alias, ":"))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func completeTaskNames(e *task.Executor) []Suggestion {
|
||||
if e == nil || e.Taskfile == nil {
|
||||
return nil
|
||||
}
|
||||
tasks, err := e.GetTaskList(task.FilterOutInternal)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]Suggestion, 0, len(tasks))
|
||||
for _, t := range tasks {
|
||||
out = append(out, Suggestion{
|
||||
Value: strings.TrimSuffix(t.Task, ":"),
|
||||
Description: t.Desc,
|
||||
})
|
||||
for _, alias := range t.Aliases {
|
||||
out = append(out, Suggestion{
|
||||
Value: strings.TrimSuffix(alias, ":"),
|
||||
Description: t.Desc,
|
||||
})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func completeFlagValue(flagName, toComplete string) ([]Suggestion, Directive) {
|
||||
if dir, ok := flagDirective[flagName]; ok {
|
||||
switch dir {
|
||||
case DirectiveFilterFileExt:
|
||||
suggs := make([]Suggestion, 0, len(taskfileExtensions))
|
||||
for _, ext := range taskfileExtensions {
|
||||
suggs = append(suggs, Suggestion{Value: ext})
|
||||
}
|
||||
return suggs, DirectiveFilterFileExt
|
||||
case DirectiveFilterDirs:
|
||||
return nil, DirectiveFilterDirs
|
||||
default:
|
||||
return nil, DirectiveDefault
|
||||
}
|
||||
}
|
||||
|
||||
if values, ok := flagEnums[flagName]; ok {
|
||||
out := make([]Suggestion, 0, len(values))
|
||||
for _, v := range values {
|
||||
out = append(out, Suggestion{Value: v})
|
||||
}
|
||||
_ = toComplete
|
||||
return out, DirectiveNoFileComp
|
||||
}
|
||||
|
||||
return nil, DirectiveDefault
|
||||
}
|
||||
|
||||
func completeTaskVars(e *task.Executor, taskName, toComplete string) ([]Suggestion, Directive) {
|
||||
compiled, err := e.FastCompiledTask(&task.Call{Task: taskName})
|
||||
if err != nil || compiled == nil || compiled.Requires == nil {
|
||||
return nil, DirectiveNoFileComp
|
||||
}
|
||||
|
||||
cache := &templater.Cache{Vars: compiled.Vars}
|
||||
out := make([]Suggestion, 0, 8)
|
||||
for _, v := range compiled.Requires.Vars {
|
||||
if v == nil || v.Name == "" {
|
||||
continue
|
||||
}
|
||||
values := enumValues(v.Enum, cache)
|
||||
if len(values) == 0 {
|
||||
out = append(out, Suggestion{Value: v.Name + "="})
|
||||
continue
|
||||
}
|
||||
for _, val := range values {
|
||||
out = append(out, Suggestion{Value: v.Name + "=" + val})
|
||||
}
|
||||
}
|
||||
_ = toComplete
|
||||
if len(out) == 0 {
|
||||
return nil, DirectiveNoFileComp
|
||||
}
|
||||
return out, DirectiveNoSpace | DirectiveNoFileComp
|
||||
}
|
||||
|
||||
func enumValues(enum *ast.Enum, cache *templater.Cache) []string {
|
||||
if enum == nil {
|
||||
return nil
|
||||
}
|
||||
if len(enum.Value) > 0 {
|
||||
return enum.Value
|
||||
}
|
||||
if enum.Ref == "" {
|
||||
return nil
|
||||
}
|
||||
resolved := templater.ResolveRef(enum.Ref, cache)
|
||||
if cache.Err() != nil {
|
||||
return nil
|
||||
}
|
||||
arr, ok := resolved.([]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(arr))
|
||||
for _, item := range arr {
|
||||
s, ok := item.(string)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
71
internal/complete/flags.go
Normal file
71
internal/complete/flags.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package complete
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
// flagEnums lists allowed values for enum-style flags. Keep in sync with the
|
||||
// help strings in internal/flags/flags.go.
|
||||
var flagEnums = map[string][]string{
|
||||
"output": {"interleaved", "group", "prefixed"},
|
||||
"sort": {"default", "alphanumeric", "none"},
|
||||
"completion": {"bash", "zsh", "fish", "powershell"},
|
||||
}
|
||||
|
||||
var flagDirective = map[string]Directive{
|
||||
"taskfile": DirectiveFilterFileExt,
|
||||
"dir": DirectiveFilterDirs,
|
||||
"remote-cache-dir": DirectiveFilterDirs,
|
||||
"cacert": DirectiveDefault,
|
||||
"cert": DirectiveDefault,
|
||||
"cert-key": DirectiveDefault,
|
||||
}
|
||||
|
||||
var taskfileExtensions = []string{"yml", "yaml"}
|
||||
|
||||
// flagTakesValue is false for boolean switches (NoOptDefVal == "true").
|
||||
func flagTakesValue(f *pflag.Flag) bool {
|
||||
return f.NoOptDefVal == ""
|
||||
}
|
||||
|
||||
// listFlags walks fs at call time so experiment-gated flags appear or
|
||||
// disappear based on the active experiments.
|
||||
func listFlags(fs *pflag.FlagSet) []Suggestion {
|
||||
if fs == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]Suggestion, 0, 64)
|
||||
fs.VisitAll(func(f *pflag.Flag) {
|
||||
if f.Hidden || f.Deprecated != "" {
|
||||
return
|
||||
}
|
||||
out = append(out, Suggestion{
|
||||
Value: "--" + f.Name,
|
||||
Description: f.Usage,
|
||||
})
|
||||
if f.Shorthand != "" {
|
||||
out = append(out, Suggestion{
|
||||
Value: "-" + f.Shorthand,
|
||||
Description: f.Usage,
|
||||
})
|
||||
}
|
||||
})
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Value < out[j].Value })
|
||||
return out
|
||||
}
|
||||
|
||||
func matchFlagName(fs *pflag.FlagSet, word string) *pflag.Flag {
|
||||
if fs == nil {
|
||||
return nil
|
||||
}
|
||||
switch {
|
||||
case strings.HasPrefix(word, "--"):
|
||||
return fs.Lookup(strings.TrimPrefix(word, "--"))
|
||||
case strings.HasPrefix(word, "-") && len(word) == 2:
|
||||
return fs.ShorthandLookup(word[1:])
|
||||
}
|
||||
return nil
|
||||
}
|
||||
28
internal/complete/output.go
Normal file
28
internal/complete/output.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package complete
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Write emits the cobra-v2 completion protocol: one `value\tdescription` (or
|
||||
// bare `value`) per suggestion, followed by a trailing `:<directive>` line
|
||||
// that shell wrappers split off even when there are zero suggestions.
|
||||
func Write(w io.Writer, suggs []Suggestion, dir Directive) {
|
||||
for _, s := range suggs {
|
||||
value := sanitize(s.Value)
|
||||
desc := sanitize(s.Description)
|
||||
if desc == "" {
|
||||
fmt.Fprintln(w, value)
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(w, "%s\t%s\n", value, desc)
|
||||
}
|
||||
fmt.Fprintf(w, ":%d\n", dir)
|
||||
}
|
||||
|
||||
func sanitize(s string) string {
|
||||
r := strings.NewReplacer("\n", " ", "\r", " ", "\t", " ")
|
||||
return r.Replace(s)
|
||||
}
|
||||
@@ -13,13 +13,18 @@ type (
|
||||
}
|
||||
// Task describes a single task
|
||||
Task struct {
|
||||
Name string `json:"name"`
|
||||
Task string `json:"task"`
|
||||
Desc string `json:"desc"`
|
||||
Summary string `json:"summary"`
|
||||
Aliases []string `json:"aliases"`
|
||||
UpToDate *bool `json:"up_to_date,omitempty"`
|
||||
Location *Location `json:"location"`
|
||||
Name string `json:"name"`
|
||||
Task string `json:"task"`
|
||||
Desc string `json:"desc"`
|
||||
Summary string `json:"summary"`
|
||||
Aliases []string `json:"aliases"`
|
||||
UpToDate *bool `json:"up_to_date,omitempty"`
|
||||
Location *Location `json:"location"`
|
||||
Requires []RequiredVar `json:"requires,omitempty"`
|
||||
}
|
||||
RequiredVar struct {
|
||||
Name string `json:"name"`
|
||||
Enum []string `json:"enum,omitempty"`
|
||||
}
|
||||
// Location describes a task's location in a taskfile
|
||||
Location struct {
|
||||
@@ -45,9 +50,28 @@ func NewTask(task *ast.Task) Task {
|
||||
Column: task.Location.Column,
|
||||
Taskfile: task.Location.Taskfile,
|
||||
},
|
||||
Requires: newRequiredVars(task.Requires),
|
||||
}
|
||||
}
|
||||
|
||||
func newRequiredVars(requires *ast.Requires) []RequiredVar {
|
||||
if requires == nil || len(requires.Vars) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]RequiredVar, 0, len(requires.Vars))
|
||||
for _, v := range requires.Vars {
|
||||
if v == nil {
|
||||
continue
|
||||
}
|
||||
rv := RequiredVar{Name: v.Name}
|
||||
if v.Enum != nil && len(v.Enum.Value) > 0 {
|
||||
rv.Enum = append([]string{}, v.Enum.Value...)
|
||||
}
|
||||
out = append(out, rv)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (parent *Namespace) AddNamespace(namespacePath []string, task Task) {
|
||||
if len(namespacePath) == 0 {
|
||||
return
|
||||
|
||||
211
internal/fingerprint/gitignore.go
Normal file
211
internal/fingerprint/gitignore.go
Normal file
@@ -0,0 +1,211 @@
|
||||
package fingerprint
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/go-task/task/v3/internal/gitignore"
|
||||
)
|
||||
|
||||
type linesCacheEntry struct {
|
||||
mtime time.Time
|
||||
lines []string
|
||||
}
|
||||
|
||||
var (
|
||||
gitignoreLinesCache sync.Map // dir -> linesCacheEntry, invalidated by mtime
|
||||
repoRootCache sync.Map // dir -> repo root (or "" when not in a repo)
|
||||
)
|
||||
|
||||
// findRepoRoot returns the first ancestor of dir containing a .git entry, or
|
||||
// ("", false) when dir is not inside a git repository.
|
||||
func findRepoRoot(dir string) (string, bool) {
|
||||
if v, ok := repoRootCache.Load(dir); ok {
|
||||
root := v.(string)
|
||||
return root, root != ""
|
||||
}
|
||||
|
||||
current := dir
|
||||
for {
|
||||
if _, err := os.Stat(filepath.Join(current, ".git")); err == nil {
|
||||
repoRootCache.Store(dir, current)
|
||||
return current, true
|
||||
}
|
||||
parent := filepath.Dir(current)
|
||||
if parent == current {
|
||||
break
|
||||
}
|
||||
current = parent
|
||||
}
|
||||
|
||||
repoRootCache.Store(dir, "")
|
||||
return "", false
|
||||
}
|
||||
|
||||
// filterGitignored marks entries matching gitignore rules as excluded (false).
|
||||
// All .gitignore files from the repo root down to each candidate file's
|
||||
// directory feed a single matcher so that precedence and cross-file negations
|
||||
// (`!pattern`) resolve correctly.
|
||||
func filterGitignored(files map[string]bool, dir string) map[string]bool {
|
||||
if len(files) == 0 {
|
||||
return files
|
||||
}
|
||||
|
||||
absDir, err := filepath.Abs(dir)
|
||||
if err != nil {
|
||||
return files
|
||||
}
|
||||
repoRoot, ok := findRepoRoot(absDir)
|
||||
if !ok {
|
||||
return files
|
||||
}
|
||||
|
||||
// Every directory from the repo root down to each candidate file's dir, so
|
||||
// nested .gitignore files reached by deep globs are included too.
|
||||
dirSet := make(map[string]struct{})
|
||||
for path, included := range files {
|
||||
if !included {
|
||||
continue
|
||||
}
|
||||
absPath, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
d := filepath.Dir(absPath)
|
||||
if !withinRepo(repoRoot, d) {
|
||||
continue
|
||||
}
|
||||
for {
|
||||
dirSet[d] = struct{}{}
|
||||
if d == repoRoot {
|
||||
break
|
||||
}
|
||||
parent := filepath.Dir(d)
|
||||
if parent == d {
|
||||
break
|
||||
}
|
||||
d = parent
|
||||
}
|
||||
}
|
||||
|
||||
// Shallow dirs first (lower priority): the matcher scans patterns last to
|
||||
// first, so deeper rules win and can negate shallower ones.
|
||||
dirs := make([]string, 0, len(dirSet))
|
||||
for d := range dirSet {
|
||||
dirs = append(dirs, d)
|
||||
}
|
||||
sort.Slice(dirs, func(i, j int) bool {
|
||||
di := strings.Count(dirs[i], string(filepath.Separator))
|
||||
dj := strings.Count(dirs[j], string(filepath.Separator))
|
||||
if di != dj {
|
||||
return di < dj
|
||||
}
|
||||
return dirs[i] < dirs[j]
|
||||
})
|
||||
|
||||
var patterns []gitignore.Pattern
|
||||
for _, d := range dirs {
|
||||
lines := readGitignoreLines(d)
|
||||
if len(lines) == 0 {
|
||||
continue
|
||||
}
|
||||
// domain scopes each pattern to its .gitignore subtree (go-git semantics).
|
||||
var domain []string
|
||||
if rel, err := filepath.Rel(repoRoot, d); err == nil && rel != "." {
|
||||
domain = strings.Split(filepath.ToSlash(rel), "/")
|
||||
}
|
||||
for _, line := range lines {
|
||||
patterns = append(patterns, gitignore.ParsePattern(line, domain))
|
||||
}
|
||||
}
|
||||
if len(patterns) == 0 {
|
||||
return files
|
||||
}
|
||||
|
||||
matcher := gitignore.NewMatcher(patterns)
|
||||
for path, included := range files {
|
||||
if !included {
|
||||
continue
|
||||
}
|
||||
absPath, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
relPath, err := filepath.Rel(repoRoot, absPath)
|
||||
if err != nil || relPath == ".." || strings.HasPrefix(relPath, ".."+string(filepath.Separator)) {
|
||||
continue
|
||||
}
|
||||
if ignored(matcher, strings.Split(filepath.ToSlash(relPath), "/")) {
|
||||
files[path] = false
|
||||
}
|
||||
}
|
||||
|
||||
return files
|
||||
}
|
||||
|
||||
// ignored honors Git's rule that a file under an ignored directory cannot be
|
||||
// re-included by a deeper negation: if any ancestor directory is ignored, so is
|
||||
// the file. Otherwise the file's own verdict applies (isDir=false).
|
||||
func ignored(matcher gitignore.Matcher, segments []string) bool {
|
||||
for i := 1; i < len(segments); i++ {
|
||||
if matcher.Match(segments[:i], true) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return matcher.Match(segments, false)
|
||||
}
|
||||
|
||||
func withinRepo(repoRoot, p string) bool {
|
||||
rel, err := filepath.Rel(repoRoot, p)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)))
|
||||
}
|
||||
|
||||
// readGitignoreLines returns the .gitignore lines in dir (nil if none), cached
|
||||
// per directory and invalidated by mtime so watch mode picks up edits.
|
||||
func readGitignoreLines(dir string) []string {
|
||||
path := filepath.Join(dir, ".gitignore")
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
mtime := info.ModTime()
|
||||
|
||||
if v, ok := gitignoreLinesCache.Load(dir); ok {
|
||||
entry := v.(linesCacheEntry)
|
||||
if entry.mtime.Equal(mtime) {
|
||||
return entry.lines
|
||||
}
|
||||
}
|
||||
|
||||
lines := parseGitignoreLines(path)
|
||||
gitignoreLinesCache.Store(dir, linesCacheEntry{mtime: mtime, lines: lines})
|
||||
return lines
|
||||
}
|
||||
|
||||
func parseGitignoreLines(path string) []string {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
var lines []string
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimRight(scanner.Text(), "\r")
|
||||
if line != "" && !strings.HasPrefix(line, "#") {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
}
|
||||
// On a scan error (e.g. an over-long line) keep what was parsed rather than
|
||||
// dropping the whole file.
|
||||
return lines
|
||||
}
|
||||
254
internal/fingerprint/gitignore_test.go
Normal file
254
internal/fingerprint/gitignore_test.go
Normal file
@@ -0,0 +1,254 @@
|
||||
package fingerprint
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/go-task/task/v3/taskfile/ast"
|
||||
)
|
||||
|
||||
func initGitRepo(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(dir, ".git"), 0o755))
|
||||
}
|
||||
|
||||
func TestGlobsWithGitignore(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
initGitRepo(t, dir)
|
||||
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "included.txt"), []byte("included"), 0o644))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "ignored.log"), []byte("ignored"), 0o644))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "also-included.txt"), []byte("also included"), 0o644))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, ".gitignore"), []byte("*.log\n"), 0o644))
|
||||
|
||||
globs := []*ast.Glob{
|
||||
{Glob: "./*"},
|
||||
}
|
||||
|
||||
filesWithout, err := Globs(dir, globs, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
filesWith, err := Globs(dir, globs, true)
|
||||
require.NoError(t, err)
|
||||
|
||||
hasLog := false
|
||||
for _, f := range filesWithout {
|
||||
if filepath.Base(f) == "ignored.log" {
|
||||
hasLog = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, hasLog, "ignored.log should be present without gitignore filter")
|
||||
|
||||
hasLog = false
|
||||
for _, f := range filesWith {
|
||||
if filepath.Base(f) == "ignored.log" {
|
||||
hasLog = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.False(t, hasLog, "ignored.log should be excluded with gitignore filter")
|
||||
|
||||
txtCount := 0
|
||||
for _, f := range filesWith {
|
||||
if filepath.Ext(f) == ".txt" {
|
||||
txtCount++
|
||||
}
|
||||
}
|
||||
assert.Equal(t, 2, txtCount, "both .txt files should remain")
|
||||
}
|
||||
|
||||
func TestGlobsWithGitignoreParentDirIgnored(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
initGitRepo(t, dir)
|
||||
|
||||
buildDir := filepath.Join(dir, "build")
|
||||
require.NoError(t, os.MkdirAll(buildDir, 0o755))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(buildDir, "keep.txt"), []byte("keep"), 0o644))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(buildDir, "other.txt"), []byte("other"), 0o644))
|
||||
|
||||
// Git cannot re-include a file under an ignored directory.
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, ".gitignore"), []byte("build/\n"), 0o644))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(buildDir, ".gitignore"), []byte("!keep.txt\n"), 0o644))
|
||||
|
||||
globs := []*ast.Glob{
|
||||
{Glob: "./**/*"},
|
||||
}
|
||||
|
||||
files, err := Globs(dir, globs, true)
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, f := range files {
|
||||
base := filepath.Base(f)
|
||||
assert.NotEqual(t, "keep.txt", base, "keep.txt must stay excluded under ignored build/")
|
||||
assert.NotEqual(t, "other.txt", base, "other.txt must stay excluded under ignored build/")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGlobsWithGitignoreNested(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
initGitRepo(t, dir)
|
||||
|
||||
subDir := filepath.Join(dir, "sub")
|
||||
require.NoError(t, os.MkdirAll(subDir, 0o755))
|
||||
|
||||
require.NoError(t, os.WriteFile(filepath.Join(subDir, "keep.txt"), []byte("keep"), 0o644))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(subDir, "build.out"), []byte("build"), 0o644))
|
||||
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, ".gitignore"), []byte("*.log\n"), 0o644))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(subDir, ".gitignore"), []byte("*.out\n"), 0o644))
|
||||
|
||||
globs := []*ast.Glob{
|
||||
{Glob: "./*"},
|
||||
}
|
||||
|
||||
files, err := Globs(subDir, globs, true)
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, f := range files {
|
||||
assert.NotEqual(t, "build.out", filepath.Base(f), "build.out should be excluded by nested .gitignore")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGlobsWithGitignoreCrossFileNegation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
initGitRepo(t, dir)
|
||||
|
||||
subDir := filepath.Join(dir, "sub")
|
||||
require.NoError(t, os.MkdirAll(subDir, 0o755))
|
||||
|
||||
require.NoError(t, os.WriteFile(filepath.Join(subDir, "debug.log"), []byte("debug"), 0o644))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(subDir, "other.log"), []byte("other"), 0o644))
|
||||
|
||||
// Root ignores all *.log; a nested .gitignore re-includes debug.log.
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, ".gitignore"), []byte("*.log\n"), 0o644))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(subDir, ".gitignore"), []byte("!debug.log\n"), 0o644))
|
||||
|
||||
globs := []*ast.Glob{
|
||||
{Glob: "./*"},
|
||||
}
|
||||
|
||||
files, err := Globs(subDir, globs, true)
|
||||
require.NoError(t, err)
|
||||
|
||||
hasDebug, hasOther := false, false
|
||||
for _, f := range files {
|
||||
switch filepath.Base(f) {
|
||||
case "debug.log":
|
||||
hasDebug = true
|
||||
case "other.log":
|
||||
hasOther = true
|
||||
}
|
||||
}
|
||||
assert.True(t, hasDebug, "debug.log should be re-included by the nested negation")
|
||||
assert.False(t, hasOther, "other.log should remain excluded by the root *.log rule")
|
||||
}
|
||||
|
||||
func TestGlobsWithGitignoreDeepGlob(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
initGitRepo(t, dir)
|
||||
|
||||
subDir := filepath.Join(dir, "sub")
|
||||
require.NoError(t, os.MkdirAll(subDir, 0o755))
|
||||
|
||||
require.NoError(t, os.WriteFile(filepath.Join(subDir, "keep.txt"), []byte("keep"), 0o644))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(subDir, "gen.out"), []byte("gen"), 0o644))
|
||||
|
||||
require.NoError(t, os.WriteFile(filepath.Join(subDir, ".gitignore"), []byte("*.out\n"), 0o644))
|
||||
|
||||
globs := []*ast.Glob{
|
||||
{Glob: "./**/*"},
|
||||
}
|
||||
|
||||
files, err := Globs(dir, globs, true)
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, f := range files {
|
||||
assert.NotEqual(t, "gen.out", filepath.Base(f), "gen.out should be excluded by the nested .gitignore reached via deep glob")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGlobsWithGitignoreDoubleDotFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
initGitRepo(t, dir)
|
||||
|
||||
// A ".."-prefixed name must not be skipped by the out-of-tree guard.
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "..keep.log"), []byte("x"), 0o644))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "keep.txt"), []byte("y"), 0o644))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, ".gitignore"), []byte("*.log\n"), 0o644))
|
||||
|
||||
globs := []*ast.Glob{
|
||||
{Glob: "./*"},
|
||||
}
|
||||
|
||||
files, err := Globs(dir, globs, true)
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, f := range files {
|
||||
assert.NotEqual(t, "..keep.log", filepath.Base(f), "..keep.log should be excluded by *.log")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGlobsWithGitignoreLongLine(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
initGitRepo(t, dir)
|
||||
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "ignored.log"), []byte("x"), 0o644))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "keep.txt"), []byte("y"), 0o644))
|
||||
|
||||
// A line over bufio.Scanner's 64KB limit triggers a scan error; patterns
|
||||
// parsed before it must survive.
|
||||
longLine := strings.Repeat("a", 70*1024)
|
||||
content := "*.log\n" + longLine + "\n"
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, ".gitignore"), []byte(content), 0o644))
|
||||
|
||||
globs := []*ast.Glob{
|
||||
{Glob: "./*"},
|
||||
}
|
||||
|
||||
files, err := Globs(dir, globs, true)
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, f := range files {
|
||||
assert.NotEqual(t, "ignored.log", filepath.Base(f), "*.log parsed before the long line should still apply")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGlobsWithGitignoreNoRepo(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Cannot use t.TempDir() here because it creates a dir inside the
|
||||
// go-task repo which has a .git parent, defeating the "no repo" test.
|
||||
dir, err := os.MkdirTemp("", "task-gitignore-norepo-*") //nolint:usetesting
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { os.RemoveAll(dir) })
|
||||
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "file.txt"), []byte("content"), 0o644))
|
||||
|
||||
globs := []*ast.Glob{
|
||||
{Glob: "./*"},
|
||||
}
|
||||
|
||||
files, err := Globs(dir, globs, true)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, files, 1)
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"github.com/go-task/task/v3/taskfile/ast"
|
||||
)
|
||||
|
||||
func Globs(dir string, globs []*ast.Glob) ([]string, error) {
|
||||
func Globs(dir string, globs []*ast.Glob, useGitignore bool) ([]string, error) {
|
||||
resultMap := make(map[string]bool)
|
||||
for _, g := range globs {
|
||||
matches, err := glob(dir, g.Glob)
|
||||
@@ -21,6 +21,11 @@ func Globs(dir string, globs []*ast.Glob) ([]string, error) {
|
||||
resultMap[match] = !g.Negate
|
||||
}
|
||||
}
|
||||
|
||||
if useGitignore {
|
||||
resultMap = filterGitignored(resultMap, dir)
|
||||
}
|
||||
|
||||
return collectKeys(resultMap), nil
|
||||
}
|
||||
|
||||
|
||||
@@ -89,7 +89,7 @@ func (*ChecksumChecker) Kind() string {
|
||||
}
|
||||
|
||||
func (c *ChecksumChecker) checksum(t *ast.Task) (string, error) {
|
||||
sources, err := Globs(t.Dir, t.Sources)
|
||||
sources, err := Globs(t.Dir, t.Sources, t.ShouldUseGitignore())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -119,7 +119,7 @@ func (checker *ChecksumChecker) checksumFilePath(t *ast.Task) string {
|
||||
return filepath.Join(checker.tempDir, "checksum", normalizeFilename(t.Name()))
|
||||
}
|
||||
|
||||
var checksumFilenameRegexp = regexp.MustCompile("[^A-z0-9]")
|
||||
var checksumFilenameRegexp = regexp.MustCompile("[^[:alnum:]]")
|
||||
|
||||
// replaces invalid characters on filenames with "-"
|
||||
func normalizeFilename(f string) string {
|
||||
|
||||
@@ -16,6 +16,10 @@ func TestNormalizeFilename(t *testing.T) {
|
||||
{"foo/bar/baz", "foo-bar-baz"},
|
||||
{"foo@bar/baz", "foo-bar-baz"},
|
||||
{"foo1bar2baz3", "foo1bar2baz3"},
|
||||
{"foo\\bar", "foo-bar"},
|
||||
{"foo_bar", "foo-bar"},
|
||||
{"foo[bar]baz", "foo-bar-baz"},
|
||||
{"foo^bar`baz", "foo-bar-baz"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
assert.Equal(t, test.Out, normalizeFilename(test.In))
|
||||
|
||||
@@ -28,7 +28,7 @@ func (checker *TimestampChecker) IsUpToDate(t *ast.Task) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
sources, err := Globs(t.Dir, t.Sources)
|
||||
sources, err := Globs(t.Dir, t.Sources, t.ShouldUseGitignore())
|
||||
if err != nil {
|
||||
return false, nil
|
||||
}
|
||||
@@ -54,7 +54,7 @@ func (checker *TimestampChecker) IsUpToDate(t *ast.Task) (bool, error) {
|
||||
}
|
||||
}
|
||||
|
||||
generates, err := Globs(t.Dir, t.Generates)
|
||||
generates, err := Globs(t.Dir, t.Generates, t.ShouldUseGitignore())
|
||||
if err != nil {
|
||||
return false, nil
|
||||
}
|
||||
@@ -112,7 +112,7 @@ func (checker *TimestampChecker) Kind() string {
|
||||
|
||||
// Value implements the Checker Interface
|
||||
func (checker *TimestampChecker) Value(t *ast.Task) (any, error) {
|
||||
sources, err := Globs(t.Dir, t.Sources)
|
||||
sources, err := Globs(t.Dir, t.Sources, t.ShouldUseGitignore())
|
||||
if err != nil {
|
||||
return time.Now(), err
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/go-task/task/v3"
|
||||
"github.com/go-task/task/v3/errors"
|
||||
"github.com/go-task/task/v3/experiments"
|
||||
"github.com/go-task/task/v3/internal/complete"
|
||||
"github.com/go-task/task/v3/internal/env"
|
||||
"github.com/go-task/task/v3/internal/sort"
|
||||
"github.com/go-task/task/v3/taskfile/ast"
|
||||
@@ -87,6 +88,7 @@ var (
|
||||
Cert string
|
||||
CertKey string
|
||||
Interactive bool
|
||||
TempDir string
|
||||
)
|
||||
|
||||
func init() {
|
||||
@@ -143,10 +145,11 @@ func init() {
|
||||
pflag.BoolVarP(&ExitCode, "exit-code", "x", false, "Pass-through the exit code of the task command.")
|
||||
pflag.StringVarP(&Dir, "dir", "d", "", "Sets the directory in which Task will execute and look for a Taskfile.")
|
||||
pflag.StringVarP(&Entrypoint, "taskfile", "t", "", `Choose which Taskfile to run. Defaults to "Taskfile.yml".`)
|
||||
pflag.StringVarP(&Output.Name, "output", "o", "", "Sets output style: [interleaved|group|prefixed].")
|
||||
pflag.StringVar(&Output.Group.Begin, "output-group-begin", "", "Message template to print before a task's grouped output.")
|
||||
pflag.StringVar(&Output.Group.End, "output-group-end", "", "Message template to print after a task's grouped output.")
|
||||
pflag.BoolVar(&Output.Group.ErrorOnly, "output-group-error-only", false, "Swallow output from successful tasks.")
|
||||
pflag.StringVar(&TempDir, "temp-dir", getConfig(config, "TEMP_DIR", func() *string { return config.TempDir }, ""), "Sets the directory used to store Task temporary files, such as checksums. Relative paths are relative to the root Taskfile.")
|
||||
pflag.StringVarP(&Output.Name, "output", "o", getConfig(config, "OUTPUT", func() *string { return nil }, ""), "Sets output style: [interleaved|group|prefixed].")
|
||||
pflag.StringVar(&Output.Group.Begin, "output-group-begin", getConfig(config, "OUTPUT_GROUP_BEGIN", func() *string { return nil }, ""), "Message template to print before a task's grouped output.")
|
||||
pflag.StringVar(&Output.Group.End, "output-group-end", getConfig(config, "OUTPUT_GROUP_END", func() *string { return nil }, ""), "Message template to print after a task's grouped output.")
|
||||
pflag.BoolVar(&Output.Group.ErrorOnly, "output-group-error-only", getConfig(config, "OUTPUT_GROUP_ERROR_ONLY", func() *bool { return nil }, false), "Swallow output from successful tasks.")
|
||||
pflag.BoolVarP(&Color, "color", "c", getConfig(config, "COLOR", func() *bool { return config.Color }, true), "Colored output. Enabled by default. Set flag to false or use NO_COLOR=1 to disable.")
|
||||
pflag.IntVarP(&Concurrency, "concurrency", "C", getConfig(config, "CONCURRENCY", func() *int { return config.Concurrency }, 0), "Limit number of tasks to run concurrently.")
|
||||
pflag.DurationVarP(&Interval, "interval", "I", 0, "Interval to watch for changes.")
|
||||
@@ -175,6 +178,13 @@ func init() {
|
||||
pflag.StringVar(&Cert, "cert", getConfig(config, "REMOTE_CERT", func() *string { return config.Remote.Cert }, ""), "Path to a client certificate for HTTPS connections.")
|
||||
pflag.StringVar(&CertKey, "cert-key", getConfig(config, "REMOTE_CERT_KEY", func() *string { return config.Remote.CertKey }, ""), "Path to a client certificate key for HTTPS connections.")
|
||||
}
|
||||
// In completion mode the user's `--flag` words must reach the engine
|
||||
// untouched. The BoolVar/StringVar calls above already populated
|
||||
// pflag.CommandLine, which is all the engine needs.
|
||||
if complete.IsActive() {
|
||||
return
|
||||
}
|
||||
|
||||
pflag.Parse()
|
||||
|
||||
// Auto-detect color based on environment when not explicitly configured
|
||||
@@ -308,6 +318,7 @@ func (o *flagsOption) ApplyToExecutor(e *task.Executor) {
|
||||
task.WithTaskSorter(sorter),
|
||||
task.WithVersionCheck(true),
|
||||
task.WithFailfast(Failfast),
|
||||
task.WithTempDirPath(TempDir),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
201
internal/gitignore/LICENSE
Normal file
201
internal/gitignore/LICENSE
Normal file
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "{}"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2018 Sourced Technologies, S.L.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
34
internal/gitignore/matcher.go
Normal file
34
internal/gitignore/matcher.go
Normal file
@@ -0,0 +1,34 @@
|
||||
// Vendored from go-git: github.com/go-git/go-git/v5 v5.19.1,
|
||||
// plumbing/format/gitignore/matcher.go. Licensed under the Apache License 2.0;
|
||||
// see the LICENSE file in this directory.
|
||||
|
||||
package gitignore
|
||||
|
||||
// Matcher defines a global multi-pattern matcher for gitignore patterns
|
||||
type Matcher interface {
|
||||
// Match matches patterns in the order of priorities. As soon as an inclusion or
|
||||
// exclusion is found, not further matching is performed.
|
||||
Match(path []string, isDir bool) bool
|
||||
}
|
||||
|
||||
// NewMatcher constructs a new global matcher. Patterns must be given in the order of
|
||||
// increasing priority. That is most generic settings files first, then the content of
|
||||
// the repo .gitignore, then content of .gitignore down the path or the repo and then
|
||||
// the content command line arguments.
|
||||
func NewMatcher(ps []Pattern) Matcher {
|
||||
return &matcher{ps}
|
||||
}
|
||||
|
||||
type matcher struct {
|
||||
patterns []Pattern
|
||||
}
|
||||
|
||||
func (m *matcher) Match(path []string, isDir bool) bool {
|
||||
n := len(m.patterns)
|
||||
for i := n - 1; i >= 0; i-- {
|
||||
if match := m.patterns[i].Match(path, isDir); match > NoMatch {
|
||||
return match == Exclude
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
23
internal/gitignore/matcher_test.go
Normal file
23
internal/gitignore/matcher_test.go
Normal file
@@ -0,0 +1,23 @@
|
||||
// Test cases ported from go-git: github.com/go-git/go-git/v5 v5.19.1,
|
||||
// plumbing/format/gitignore/matcher_test.go. Licensed under the Apache
|
||||
// License 2.0; see LICENSE.
|
||||
|
||||
package gitignore
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestMatcher_Match(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
m := NewMatcher([]Pattern{
|
||||
ParsePattern("**/middle/v[uo]l?ano", nil),
|
||||
ParsePattern("!volcano", nil),
|
||||
})
|
||||
|
||||
if got := m.Match([]string{"head", "middle", "vulkano"}, false); got != true {
|
||||
t.Errorf("Match(vulkano) = %t, want true", got)
|
||||
}
|
||||
if got := m.Match([]string{"head", "middle", "volcano"}, false); got != false {
|
||||
t.Errorf("Match(volcano) = %t, want false (negated)", got)
|
||||
}
|
||||
}
|
||||
165
internal/gitignore/pattern.go
Normal file
165
internal/gitignore/pattern.go
Normal file
@@ -0,0 +1,165 @@
|
||||
// Package gitignore implements gitignore pattern matching.
|
||||
//
|
||||
// This package is vendored from go-git:
|
||||
//
|
||||
// github.com/go-git/go-git/v5 v5.19.1, plumbing/format/gitignore
|
||||
//
|
||||
// Only the pattern parsing and matching logic (pattern.go and matcher.go) is
|
||||
// copied; the file-walking helpers (dir.go) are omitted as they pull in
|
||||
// go-billy and other go-git internals. The original code is licensed under the
|
||||
// Apache License 2.0; see the LICENSE file in this directory.
|
||||
package gitignore
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// MatchResult defines outcomes of a match, no match, exclusion or inclusion.
|
||||
type MatchResult int
|
||||
|
||||
const (
|
||||
// NoMatch defines the no match outcome of a match check
|
||||
NoMatch MatchResult = iota
|
||||
// Exclude defines an exclusion of a file as a result of a match check
|
||||
Exclude
|
||||
// Include defines an explicit inclusion of a file as a result of a match check
|
||||
Include
|
||||
)
|
||||
|
||||
const (
|
||||
inclusionPrefix = "!"
|
||||
zeroToManyDirs = "**"
|
||||
patternDirSep = "/"
|
||||
)
|
||||
|
||||
// Pattern defines a single gitignore pattern.
|
||||
type Pattern interface {
|
||||
// Match matches the given path to the pattern.
|
||||
Match(path []string, isDir bool) MatchResult
|
||||
}
|
||||
|
||||
type pattern struct {
|
||||
domain []string
|
||||
pattern []string
|
||||
inclusion bool
|
||||
dirOnly bool
|
||||
isGlob bool
|
||||
}
|
||||
|
||||
// ParsePattern parses a gitignore pattern string into the Pattern structure.
|
||||
func ParsePattern(p string, domain []string) Pattern {
|
||||
// storing domain, copy it to ensure it isn't changed externally
|
||||
domain = append([]string(nil), domain...)
|
||||
res := pattern{domain: domain}
|
||||
|
||||
if strings.HasPrefix(p, inclusionPrefix) {
|
||||
res.inclusion = true
|
||||
p = p[1:]
|
||||
}
|
||||
|
||||
if !strings.HasSuffix(p, "\\ ") {
|
||||
p = strings.TrimRight(p, " ")
|
||||
}
|
||||
|
||||
if strings.HasSuffix(p, patternDirSep) {
|
||||
res.dirOnly = true
|
||||
p = p[:len(p)-1]
|
||||
}
|
||||
|
||||
if strings.Contains(p, patternDirSep) {
|
||||
res.isGlob = true
|
||||
}
|
||||
|
||||
res.pattern = strings.Split(p, patternDirSep)
|
||||
return &res
|
||||
}
|
||||
|
||||
func (p *pattern) Match(path []string, isDir bool) MatchResult {
|
||||
if len(path) <= len(p.domain) {
|
||||
return NoMatch
|
||||
}
|
||||
for i, e := range p.domain {
|
||||
if path[i] != e {
|
||||
return NoMatch
|
||||
}
|
||||
}
|
||||
|
||||
path = path[len(p.domain):]
|
||||
if p.isGlob && !p.globMatch(path, isDir) {
|
||||
return NoMatch
|
||||
} else if !p.isGlob && !p.simpleNameMatch(path, isDir) {
|
||||
return NoMatch
|
||||
}
|
||||
|
||||
if p.inclusion {
|
||||
return Include
|
||||
} else {
|
||||
return Exclude
|
||||
}
|
||||
}
|
||||
|
||||
func (p *pattern) simpleNameMatch(path []string, isDir bool) bool {
|
||||
for i, name := range path {
|
||||
if match, err := filepath.Match(p.pattern[0], name); err != nil {
|
||||
return false
|
||||
} else if !match {
|
||||
continue
|
||||
}
|
||||
if p.dirOnly && !isDir && i == len(path)-1 {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (p *pattern) globMatch(path []string, isDir bool) bool {
|
||||
matched := false
|
||||
canTraverse := false
|
||||
for i, pattern := range p.pattern {
|
||||
if pattern == "" {
|
||||
canTraverse = false
|
||||
continue
|
||||
}
|
||||
if pattern == zeroToManyDirs {
|
||||
if i == len(p.pattern)-1 {
|
||||
break
|
||||
}
|
||||
canTraverse = true
|
||||
continue
|
||||
}
|
||||
if strings.Contains(pattern, zeroToManyDirs) {
|
||||
return false
|
||||
}
|
||||
if len(path) == 0 {
|
||||
return false
|
||||
}
|
||||
if canTraverse {
|
||||
canTraverse = false
|
||||
for len(path) > 0 {
|
||||
e := path[0]
|
||||
path = path[1:]
|
||||
if match, err := filepath.Match(pattern, e); err != nil {
|
||||
return false
|
||||
} else if match {
|
||||
matched = true
|
||||
break
|
||||
} else if len(path) == 0 {
|
||||
// if nothing left then fail
|
||||
matched = false
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if match, err := filepath.Match(pattern, path[0]); err != nil || !match {
|
||||
return false
|
||||
}
|
||||
matched = true
|
||||
path = path[1:]
|
||||
}
|
||||
}
|
||||
if matched && p.dirOnly && !isDir && len(path) == 0 {
|
||||
matched = false
|
||||
}
|
||||
return matched
|
||||
}
|
||||
80
internal/gitignore/pattern_test.go
Normal file
80
internal/gitignore/pattern_test.go
Normal file
@@ -0,0 +1,80 @@
|
||||
// Test cases ported from go-git: github.com/go-git/go-git/v5 v5.19.1,
|
||||
// plumbing/format/gitignore/pattern_test.go (originally written against
|
||||
// gopkg.in/check.v1; rewritten here as table-driven stdlib tests to avoid an
|
||||
// extra test dependency). Licensed under the Apache License 2.0; see LICENSE.
|
||||
|
||||
package gitignore
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParsePattern_Match(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
pattern string
|
||||
domain []string
|
||||
path []string
|
||||
isDir bool
|
||||
want MatchResult
|
||||
}{
|
||||
{"inclusion", "!vul?ano", nil, []string{"value", "vulkano", "tail"}, false, Include},
|
||||
{"domainLonger_mismatch", "value", []string{"head", "middle", "tail"}, []string{"head", "middle"}, false, NoMatch},
|
||||
{"domainSameLength_mismatch", "value", []string{"head", "middle", "tail"}, []string{"head", "middle", "tail"}, false, NoMatch},
|
||||
{"domainMismatch_mismatch", "value", []string{"head", "middle", "tail"}, []string{"head", "middle", "_tail_", "value"}, false, NoMatch},
|
||||
{"withDomain", "middle/", []string{"value", "volcano"}, []string{"value", "volcano", "middle", "tail"}, false, Exclude},
|
||||
{"onlyMatchInDomain_mismatch", "volcano/", []string{"value", "volcano"}, []string{"value", "volcano", "tail"}, true, NoMatch},
|
||||
{"atStart", "value", nil, []string{"value", "tail"}, false, Exclude},
|
||||
{"inTheMiddle", "value", nil, []string{"head", "value", "tail"}, false, Exclude},
|
||||
{"atEnd", "value", nil, []string{"head", "value"}, false, Exclude},
|
||||
{"atStart_dirWanted", "value/", nil, []string{"value", "tail"}, false, Exclude},
|
||||
{"inTheMiddle_dirWanted", "value/", nil, []string{"head", "value", "tail"}, false, Exclude},
|
||||
{"atEnd_dirWanted", "value/", nil, []string{"head", "value"}, true, Exclude},
|
||||
{"atEnd_dirWanted_notADir_mismatch", "value/", nil, []string{"head", "value"}, false, NoMatch},
|
||||
{"mismatch", "value", nil, []string{"head", "val", "tail"}, false, NoMatch},
|
||||
{"valueLonger_mismatch", "val", nil, []string{"head", "value", "tail"}, false, NoMatch},
|
||||
{"withAsterisk", "v*o", nil, []string{"value", "vulkano", "tail"}, false, Exclude},
|
||||
{"withQuestionMark", "vul?ano", nil, []string{"value", "vulkano", "tail"}, false, Exclude},
|
||||
{"magicChars", "v[ou]l[kc]ano", nil, []string{"value", "volcano"}, false, Exclude},
|
||||
{"wrongPattern_mismatch", "v[ou]l[", nil, []string{"value", "vol["}, false, NoMatch},
|
||||
{"glob_fromRootWithSlash", "/value/vul?ano", nil, []string{"value", "vulkano", "tail"}, false, Exclude},
|
||||
{"glob_withDomain", "middle/tail/", []string{"value", "volcano"}, []string{"value", "volcano", "middle", "tail"}, true, Exclude},
|
||||
{"glob_onlyMatchInDomain_mismatch", "volcano/tail", []string{"value", "volcano"}, []string{"value", "volcano", "tail"}, false, NoMatch},
|
||||
{"glob_fromRootWithoutSlash", "value/vul?ano", nil, []string{"value", "vulkano", "tail"}, false, Exclude},
|
||||
{"glob_fromRoot_mismatch", "value/vulkano", nil, []string{"value", "volcano"}, false, NoMatch},
|
||||
{"glob_fromRoot_tooShort_mismatch", "value/vul?ano", nil, []string{"value"}, false, NoMatch},
|
||||
{"glob_fromRoot_notAtRoot_mismatch", "/value/volcano", nil, []string{"value", "value", "volcano"}, false, NoMatch},
|
||||
{"glob_leadingAsterisks_atStart", "**/*lue/vol?ano", nil, []string{"value", "volcano", "tail"}, false, Exclude},
|
||||
{"glob_leadingAsterisks_notAtStart", "**/*lue/vol?ano", nil, []string{"head", "value", "volcano", "tail"}, false, Exclude},
|
||||
{"glob_leadingAsterisks_mismatch", "**/*lue/vol?ano", nil, []string{"head", "value", "Volcano", "tail"}, false, NoMatch},
|
||||
{"glob_leadingAsterisks_isDir", "**/*lue/vol?ano/", nil, []string{"head", "value", "volcano", "tail"}, false, Exclude},
|
||||
{"glob_leadingAsterisks_isDirAtEnd", "**/*lue/vol?ano/", nil, []string{"head", "value", "volcano"}, true, Exclude},
|
||||
{"glob_leadingAsterisks_isDir_mismatch", "**/*lue/vol?ano/", nil, []string{"head", "value", "Colcano"}, true, NoMatch},
|
||||
{"glob_leadingAsterisks_isDirNoDirAtEnd_mismatch", "**/*lue/vol?ano/", nil, []string{"head", "value", "volcano"}, false, NoMatch},
|
||||
{"glob_tailingAsterisks", "/*lue/vol?ano/**", nil, []string{"value", "volcano", "tail", "moretail"}, false, Exclude},
|
||||
{"glob_tailingAsterisks_exactMatch", "/*lue/vol?ano/**", nil, []string{"value", "volcano"}, false, Exclude},
|
||||
{"glob_middleAsterisks_emptyMatch", "/*lue/**/vol?ano", nil, []string{"value", "volcano"}, false, Exclude},
|
||||
{"glob_middleAsterisks_oneMatch", "/*lue/**/vol?ano", nil, []string{"value", "middle", "volcano"}, false, Exclude},
|
||||
{"glob_middleAsterisks_multiMatch", "/*lue/**/vol?ano", nil, []string{"value", "middle1", "middle2", "volcano"}, false, Exclude},
|
||||
{"glob_middleAsterisks_isDir_trailing", "/*lue/**/vol?ano/", nil, []string{"value", "middle1", "middle2", "volcano"}, true, Exclude},
|
||||
{"glob_middleAsterisks_isDir_trailing_mismatch", "/*lue/**/vol?ano/", nil, []string{"value", "middle1", "middle2", "volcano"}, false, NoMatch},
|
||||
{"glob_middleAsterisks_isDir", "/*lue/**/vol?ano/", nil, []string{"value", "middle1", "middle2", "volcano", "tail"}, false, Exclude},
|
||||
{"glob_wrongDoubleAsterisk_mismatch", "/*lue/**foo/vol?ano", nil, []string{"value", "foo", "volcano", "tail"}, false, NoMatch},
|
||||
{"glob_magicChars", "**/head/v[ou]l[kc]ano", nil, []string{"value", "head", "volcano"}, false, Exclude},
|
||||
{"glob_wrongPattern_noTraversal_mismatch", "**/head/v[ou]l[", nil, []string{"value", "head", "vol["}, false, NoMatch},
|
||||
{"glob_wrongPattern_onTraversal_mismatch", "/value/**/v[ou]l[", nil, []string{"value", "head", "vol["}, false, NoMatch},
|
||||
{"glob_issue_923", "**/android/**/GeneratedPluginRegistrant.java", nil, []string{"packages", "flutter_tools", "lib", "src", "android", "gradle.dart"}, false, NoMatch},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
p := ParsePattern(tt.pattern, tt.domain)
|
||||
if got := p.Match(tt.path, tt.isDir); got != tt.want {
|
||||
t.Errorf("ParsePattern(%q, %v).Match(%v, %t) = %v, want %v",
|
||||
tt.pattern, tt.domain, tt.path, tt.isDir, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -117,7 +117,12 @@ func printTaskCommands(l *logger.Logger, t *ast.Task) {
|
||||
isCommand := c.Cmd != ""
|
||||
l.Outf(logger.Default, " - ")
|
||||
if isCommand {
|
||||
l.Outf(logger.Yellow, "%s\n", c.Cmd)
|
||||
// Use the masked command so secret values are not leaked in summaries
|
||||
logCmd := c.LogCmd
|
||||
if logCmd == "" {
|
||||
logCmd = c.Cmd
|
||||
}
|
||||
l.Outf(logger.Yellow, "%s\n", logCmd)
|
||||
} else {
|
||||
l.Outf(logger.Green, "Task: %s\n", c.Task)
|
||||
}
|
||||
@@ -196,6 +201,11 @@ func printTaskEnv(l *logger.Logger, t *ast.Task) {
|
||||
// formatVarValue formats a variable value based on its type.
|
||||
// Handles static values, shell commands (sh:), references (ref:), and maps.
|
||||
func formatVarValue(v ast.Var) string {
|
||||
// Never expose secret variables in the summary, whatever their type
|
||||
if v.Secret {
|
||||
return "*****"
|
||||
}
|
||||
|
||||
// Shell command - check this first before Value
|
||||
// because dynamic vars may have both Sh and an empty Value
|
||||
if v.Sh != nil {
|
||||
|
||||
61
internal/templater/secrets.go
Normal file
61
internal/templater/secrets.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package templater
|
||||
|
||||
import (
|
||||
"github.com/go-task/task/v3/taskfile/ast"
|
||||
)
|
||||
|
||||
// MaskSecrets replaces template placeholders with their values, masking secrets.
|
||||
// This function uses the Go templater to resolve all variables ({{.VAR}}) while
|
||||
// masking secret ones as "*****".
|
||||
func MaskSecrets(cmdTemplate string, vars *ast.Vars) string {
|
||||
return MaskSecretsWithExtra(cmdTemplate, vars, nil)
|
||||
}
|
||||
|
||||
// MaskSecretsWithExtra is like MaskSecrets but also resolves extra variables (e.g., loop vars).
|
||||
func MaskSecretsWithExtra(cmdTemplate string, vars *ast.Vars, extra map[string]any) string {
|
||||
if vars == nil {
|
||||
vars = ast.NewVars()
|
||||
}
|
||||
|
||||
// Fast path: if there are no secrets to mask, resolve the template directly
|
||||
// without the extra DeepCopy + masking pass.
|
||||
if !hasSecrets(vars) {
|
||||
cache := &Cache{Vars: vars}
|
||||
result := ReplaceWithExtra(cmdTemplate, cache, extra)
|
||||
if cache.Err() != nil {
|
||||
return cmdTemplate
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Create a copy with secret values masked, leaving the originals untouched.
|
||||
maskedVars := vars.DeepCopy()
|
||||
for name, v := range maskedVars.All() {
|
||||
if v.Secret {
|
||||
maskedVars.Set(name, ast.Var{
|
||||
Value: "*****",
|
||||
Secret: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
cache := &Cache{Vars: maskedVars}
|
||||
result := ReplaceWithExtra(cmdTemplate, cache, extra)
|
||||
|
||||
// If there was an error, return the original template
|
||||
if cache.Err() != nil {
|
||||
return cmdTemplate
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// hasSecrets reports whether any variable is marked as secret.
|
||||
func hasSecrets(vars *ast.Vars) bool {
|
||||
for _, v := range vars.All() {
|
||||
if v.Secret {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -132,14 +132,15 @@ func ReplaceVar(v ast.Var, cache *Cache) ast.Var {
|
||||
|
||||
func ReplaceVarWithExtra(v ast.Var, cache *Cache, extra map[string]any) ast.Var {
|
||||
if v.Ref != "" {
|
||||
return ast.Var{Value: ResolveRef(v.Ref, cache)}
|
||||
return ast.Var{Value: ResolveRef(v.Ref, cache), Secret: v.Secret}
|
||||
}
|
||||
return ast.Var{
|
||||
Value: ReplaceWithExtra(v.Value, cache, extra),
|
||||
Sh: ReplaceWithExtra(v.Sh, cache, extra),
|
||||
Live: v.Live,
|
||||
Ref: v.Ref,
|
||||
Dir: v.Dir,
|
||||
Value: ReplaceWithExtra(v.Value, cache, extra),
|
||||
Sh: ReplaceWithExtra(v.Sh, cache, extra),
|
||||
Live: v.Live,
|
||||
Ref: v.Ref,
|
||||
Dir: v.Dir,
|
||||
Secret: v.Secret,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
[tools]
|
||||
# Runtimes
|
||||
go = "1.25.10"
|
||||
go = "1.26.4"
|
||||
node = "24"
|
||||
pnpm = "11.5.0"
|
||||
pnpm = "11.8.0"
|
||||
|
||||
# Dev tools
|
||||
golangci-lint = "2.12.2"
|
||||
mockery = "3.2.2"
|
||||
mockery = "3.7.1"
|
||||
gotestsum = "latest"
|
||||
goreleaser = "2"
|
||||
"go:golang.org/x/exp/cmd/gorelease" = "latest"
|
||||
|
||||
12
setup.go
12
setup.go
@@ -1,6 +1,7 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
@@ -13,7 +14,6 @@ import (
|
||||
"github.com/sajari/fuzzy"
|
||||
|
||||
"github.com/go-task/task/v3/errors"
|
||||
"github.com/go-task/task/v3/internal/env"
|
||||
"github.com/go-task/task/v3/internal/execext"
|
||||
"github.com/go-task/task/v3/internal/filepathext"
|
||||
"github.com/go-task/task/v3/internal/logger"
|
||||
@@ -133,13 +133,9 @@ func (e *Executor) setupTempDir() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
tempDir := env.GetTaskEnv("TEMP_DIR")
|
||||
if tempDir == "" {
|
||||
e.TempDir = TempDir{
|
||||
Remote: filepathext.SmartJoin(e.Dir, ".task"),
|
||||
Fingerprint: filepathext.SmartJoin(e.Dir, ".task"),
|
||||
}
|
||||
} else if filepath.IsAbs(tempDir) || strings.HasPrefix(tempDir, "~") {
|
||||
// e.TempDirPath carries the resolved CLI precedence (flag > TASK_TEMP_DIR > taskrc).
|
||||
tempDir := cmp.Or(e.TempDirPath, ".task")
|
||||
if filepath.IsAbs(tempDir) || strings.HasPrefix(tempDir, "~") {
|
||||
tempDir, err := execext.ExpandLiteral(tempDir)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
6
task.go
6
task.go
@@ -349,6 +349,8 @@ func (e *Executor) runDeferred(t *ast.Task, call *Call, i int, vars *ast.Vars, d
|
||||
extra["EXIT_CODE"] = fmt.Sprintf("%d", *deferredExitCode)
|
||||
}
|
||||
|
||||
// Resolve template with secrets masked for logging
|
||||
cmd.LogCmd = templater.MaskSecretsWithExtra(cmd.Cmd, vars, extra)
|
||||
cmd.Cmd = templater.ReplaceWithExtra(cmd.Cmd, cache, extra)
|
||||
cmd.Task = templater.ReplaceWithExtra(cmd.Task, cache, extra)
|
||||
cmd.If = templater.ReplaceWithExtra(cmd.If, cache, extra)
|
||||
@@ -388,12 +390,12 @@ func (e *Executor) runCommand(ctx context.Context, t *ast.Task, call *Call, i in
|
||||
return err
|
||||
case cmd.Cmd != "":
|
||||
if !shouldRunOnCurrentPlatform(cmd.Platforms) {
|
||||
e.Logger.VerboseOutf(logger.Yellow, "task: [%s] %s not for current platform - ignored\n", t.Name(), cmd.Cmd)
|
||||
e.Logger.VerboseOutf(logger.Yellow, "task: [%s] %s not for current platform - ignored\n", t.Name(), cmd.LogCmd)
|
||||
return nil
|
||||
}
|
||||
|
||||
if e.Verbose || (!call.Silent && !cmd.Silent && !t.IsSilent() && !e.Taskfile.Silent && !e.Silent) {
|
||||
e.Logger.Errf(logger.Green, "task: [%s] %s\n", t.Name(), cmd.Cmd)
|
||||
e.Logger.Errf(logger.Green, "task: [%s] %s\n", t.Name(), cmd.LogCmd)
|
||||
}
|
||||
|
||||
if e.Dry {
|
||||
|
||||
158
task_test.go
158
task_test.go
@@ -653,6 +653,164 @@ func TestStatusChecksumMissingGenerated(t *testing.T) { // nolint:paralleltest /
|
||||
require.NoError(t, err, "generated.txt should be recreated after third run")
|
||||
}
|
||||
|
||||
func writeFile(t *testing.T, dir, name, content string) {
|
||||
t.Helper()
|
||||
require.NoError(t, os.WriteFile(filepathext.SmartJoin(dir, name), []byte(content), 0o644))
|
||||
}
|
||||
|
||||
// gitignoreStep writes a set of files then runs the task once, capturing its
|
||||
// output as a golden fixture named run.
|
||||
type gitignoreStep struct {
|
||||
write map[string]string
|
||||
run string
|
||||
}
|
||||
|
||||
// gitignoreSeq drives a checksum task through a sequence of runs against a
|
||||
// fixture dir. create seeds runtime files (removed on cleanup); restore resets
|
||||
// tracked files to their committed content on cleanup; artifacts are
|
||||
// task-produced files to delete on cleanup.
|
||||
type gitignoreSeq struct {
|
||||
dir string
|
||||
task string
|
||||
create map[string]string
|
||||
restore map[string]string
|
||||
artifacts []string
|
||||
steps []gitignoreStep
|
||||
}
|
||||
|
||||
func (s gitignoreSeq) run(t *testing.T) {
|
||||
t.Helper()
|
||||
cleanup := func() {
|
||||
_ = os.RemoveAll(filepathext.SmartJoin(s.dir, ".task"))
|
||||
for name := range s.create {
|
||||
_ = os.Remove(filepathext.SmartJoin(s.dir, name))
|
||||
}
|
||||
for _, name := range s.artifacts {
|
||||
_ = os.Remove(filepathext.SmartJoin(s.dir, name))
|
||||
}
|
||||
for name, content := range s.restore {
|
||||
writeFile(t, s.dir, name, content)
|
||||
}
|
||||
}
|
||||
cleanup()
|
||||
t.Cleanup(cleanup)
|
||||
for name, content := range s.create {
|
||||
writeFile(t, s.dir, name, content)
|
||||
}
|
||||
for _, step := range s.steps {
|
||||
for name, content := range step.write {
|
||||
writeFile(t, s.dir, name, content)
|
||||
}
|
||||
NewExecutorTest(t,
|
||||
WithName(step.run),
|
||||
WithExecutorOptions(task.WithDir(s.dir)),
|
||||
WithTask(s.task),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitignoreChecksum(t *testing.T) { //nolint:paralleltest // shares testdata/gitignore and mutates fixture files
|
||||
gitignoreSeq{
|
||||
dir: "testdata/gitignore",
|
||||
task: "build",
|
||||
create: map[string]string{"ignored.txt": "ignored\n"},
|
||||
restore: map[string]string{"source.txt": "source content\n"},
|
||||
artifacts: []string{"generated.txt"},
|
||||
steps: []gitignoreStep{
|
||||
{run: "first run"},
|
||||
{run: "up to date"},
|
||||
{run: "ignored file modified", write: map[string]string{"ignored.txt": "ignored modified\n"}},
|
||||
{run: "source file modified", write: map[string]string{"source.txt": "source modified\n"}},
|
||||
},
|
||||
}.run(t)
|
||||
}
|
||||
|
||||
// TestGitignoreNegation checks that a `!pattern` in a nested .gitignore
|
||||
// re-includes a file excluded by a parent .gitignore.
|
||||
func TestGitignoreNegation(t *testing.T) { //nolint:paralleltest // mutates fixture files
|
||||
gitignoreSeq{
|
||||
dir: "testdata/gitignore_negation",
|
||||
task: "build",
|
||||
create: map[string]string{"sub/debug.log": "debug\n", "sub/other.log": "other\n"},
|
||||
steps: []gitignoreStep{
|
||||
{run: "first run"},
|
||||
{run: "up to date"},
|
||||
{run: "ignored file modified", write: map[string]string{"sub/other.log": "other modified\n"}},
|
||||
{run: "reincluded file modified", write: map[string]string{"sub/debug.log": "debug modified\n"}},
|
||||
},
|
||||
}.run(t)
|
||||
}
|
||||
|
||||
// TestGitignoreNested checks that a .gitignore in a subdirectory below the task
|
||||
// dir is honored when its files are reached by a deep glob.
|
||||
func TestGitignoreNested(t *testing.T) { //nolint:paralleltest // mutates fixture files
|
||||
gitignoreSeq{
|
||||
dir: "testdata/gitignore_nested",
|
||||
task: "build",
|
||||
create: map[string]string{"sub/secret.dat": "secret\n"},
|
||||
restore: map[string]string{"sub/keep.txt": "keep\n"},
|
||||
steps: []gitignoreStep{
|
||||
{run: "first run"},
|
||||
{run: "up to date"},
|
||||
{run: "ignored file modified", write: map[string]string{"sub/secret.dat": "secret modified\n"}},
|
||||
{run: "source file modified", write: map[string]string{"sub/keep.txt": "keep modified\n"}},
|
||||
},
|
||||
}.run(t)
|
||||
}
|
||||
|
||||
// TestGitignoreIncluded checks that a top-level use_gitignore in an included
|
||||
// Taskfile is propagated onto its tasks during merge.
|
||||
func TestGitignoreIncluded(t *testing.T) { //nolint:paralleltest // mutates fixture files
|
||||
gitignoreSeq{
|
||||
dir: "testdata/gitignore_included",
|
||||
task: "included:build",
|
||||
create: map[string]string{"ignored.txt": "ignored\n"},
|
||||
steps: []gitignoreStep{
|
||||
{run: "first run"},
|
||||
{run: "up to date"},
|
||||
{run: "ignored file modified", write: map[string]string{"ignored.txt": "ignored modified\n"}},
|
||||
},
|
||||
}.run(t)
|
||||
}
|
||||
|
||||
// TestGitignoreIncludedOverride checks that an explicit use_gitignore: false in
|
||||
// an included Taskfile is preserved even when the root Taskfile sets it to true.
|
||||
func TestGitignoreIncludedOverride(t *testing.T) { //nolint:paralleltest // mutates fixture files
|
||||
gitignoreSeq{
|
||||
dir: "testdata/gitignore_included_override",
|
||||
task: "included:build",
|
||||
create: map[string]string{"ignored.txt": "ignored\n"},
|
||||
steps: []gitignoreStep{
|
||||
{run: "first run"},
|
||||
{run: "up to date"},
|
||||
{run: "ignored file modified", write: map[string]string{"ignored.txt": "ignored modified\n"}},
|
||||
},
|
||||
}.run(t)
|
||||
}
|
||||
|
||||
func TestGitignoreTaskListFallback(t *testing.T) { //nolint:paralleltest // shares testdata/gitignore with TestGitignoreChecksum
|
||||
const dir = "testdata/gitignore"
|
||||
|
||||
var buff bytes.Buffer
|
||||
e := task.NewExecutor(
|
||||
task.WithDir(dir),
|
||||
task.WithStdout(&buff),
|
||||
task.WithStderr(&buff),
|
||||
)
|
||||
require.NoError(t, e.Setup())
|
||||
|
||||
listed, err := e.CompiledTaskForTaskList(&task.Call{Task: "build"})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, listed.ShouldUseGitignore(),
|
||||
"task list should reflect the global use_gitignore fallback")
|
||||
|
||||
// "build-no-use_gitignore" explicitly disables it.
|
||||
listedOff, err := e.CompiledTaskForTaskList(&task.Call{Task: "build-no-use_gitignore"})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, listedOff.ShouldUseGitignore(),
|
||||
"explicit use_gitignore: false must be preserved in the list path")
|
||||
}
|
||||
|
||||
func TestStatusVariables(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -9,7 +9,8 @@ import (
|
||||
|
||||
// Cmd is a task command
|
||||
type Cmd struct {
|
||||
Cmd string
|
||||
Cmd string // Resolved command (used for execution and fingerprinting)
|
||||
LogCmd string // Command with secrets masked (used for logging)
|
||||
Task string
|
||||
For *For
|
||||
If string
|
||||
@@ -28,6 +29,7 @@ func (c *Cmd) DeepCopy() *Cmd {
|
||||
}
|
||||
return &Cmd{
|
||||
Cmd: c.Cmd,
|
||||
LogCmd: c.LogCmd,
|
||||
Task: c.Task,
|
||||
For: c.For.DeepCopy(),
|
||||
If: c.If,
|
||||
|
||||
@@ -93,6 +93,21 @@ func (matrix *Matrix) DeepCopy() *Matrix {
|
||||
}
|
||||
}
|
||||
|
||||
// DeepCopy returns a copy of the MatrixRow. Without this, deepcopy.OrderedMap
|
||||
// falls back to copying the *MatrixRow pointer as-is, so every "copy" of a
|
||||
// Matrix would still share the same underlying rows - see #2890, where
|
||||
// concurrent invocations of a task with a `ref:` matrix row raced on
|
||||
// resolveMatrixRefs mutating that shared row.
|
||||
func (row *MatrixRow) DeepCopy() *MatrixRow {
|
||||
if row == nil {
|
||||
return nil
|
||||
}
|
||||
return &MatrixRow{
|
||||
Ref: row.Ref,
|
||||
Value: deepcopy.Slice(row.Value),
|
||||
}
|
||||
}
|
||||
|
||||
func (matrix *Matrix) UnmarshalYAML(node *yaml.Node) error {
|
||||
switch node.Kind {
|
||||
case yaml.MappingNode:
|
||||
|
||||
@@ -38,6 +38,7 @@ type Task struct {
|
||||
Method string
|
||||
Prefix string `hash:"ignore"`
|
||||
IgnoreError bool
|
||||
UseGitignore *bool
|
||||
Run string
|
||||
Platforms []*Platform
|
||||
If string
|
||||
@@ -75,6 +76,12 @@ func (t *Task) IsSilent() bool {
|
||||
return t.Silent != nil && *t.Silent
|
||||
}
|
||||
|
||||
// ShouldUseGitignore returns true if gitignore filtering is enabled for the task.
|
||||
// Returns false if UseGitignore is nil or set to false.
|
||||
func (t *Task) ShouldUseGitignore() bool {
|
||||
return t.UseGitignore != nil && *t.UseGitignore
|
||||
}
|
||||
|
||||
// WildcardMatch will check if the given string matches the name of the Task and returns any wildcard values.
|
||||
func (t *Task) WildcardMatch(name string) (bool, []string) {
|
||||
names := append([]string{t.Task}, t.Aliases...)
|
||||
@@ -149,7 +156,8 @@ func (t *Task) UnmarshalYAML(node *yaml.Node) error {
|
||||
Internal bool
|
||||
Method string
|
||||
Prefix string
|
||||
IgnoreError bool `yaml:"ignore_error"`
|
||||
IgnoreError bool `yaml:"ignore_error"`
|
||||
UseGitignore *bool `yaml:"use_gitignore,omitempty"`
|
||||
Run string
|
||||
Platforms []*Platform
|
||||
If string
|
||||
@@ -190,6 +198,7 @@ func (t *Task) UnmarshalYAML(node *yaml.Node) error {
|
||||
t.Method = task.Method
|
||||
t.Prefix = task.Prefix
|
||||
t.IgnoreError = task.IgnoreError
|
||||
t.UseGitignore = deepcopy.Scalar(task.UseGitignore)
|
||||
t.Run = task.Run
|
||||
t.Platforms = task.Platforms
|
||||
t.If = task.If
|
||||
@@ -233,6 +242,7 @@ func (t *Task) DeepCopy() *Task {
|
||||
Method: t.Method,
|
||||
Prefix: t.Prefix,
|
||||
IgnoreError: t.IgnoreError,
|
||||
UseGitignore: deepcopy.Scalar(t.UseGitignore),
|
||||
Run: t.Run,
|
||||
IncludeVars: t.IncludeVars.DeepCopy(),
|
||||
IncludedTaskfileVars: t.IncludedTaskfileVars.DeepCopy(),
|
||||
|
||||
@@ -20,20 +20,21 @@ var ErrIncludedTaskfilesCantHaveDotenvs = errors.New("task: Included Taskfiles c
|
||||
|
||||
// Taskfile is the abstract syntax tree for a Taskfile
|
||||
type Taskfile struct {
|
||||
Location string
|
||||
Version *semver.Version
|
||||
Output Output
|
||||
Method string
|
||||
Includes *Includes
|
||||
Set []string
|
||||
Shopt []string
|
||||
Vars *Vars
|
||||
Env *Vars
|
||||
Tasks *Tasks
|
||||
Silent bool
|
||||
Dotenv []string
|
||||
Run string
|
||||
Interval time.Duration
|
||||
Location string
|
||||
Version *semver.Version
|
||||
Output Output
|
||||
Method string
|
||||
Includes *Includes
|
||||
Set []string
|
||||
Shopt []string
|
||||
Vars *Vars
|
||||
Env *Vars
|
||||
Tasks *Tasks
|
||||
Silent bool
|
||||
Dotenv []string
|
||||
Run string
|
||||
Interval time.Duration
|
||||
UseGitignore *bool
|
||||
}
|
||||
|
||||
// Merge merges the second Taskfile into the first
|
||||
@@ -67,6 +68,14 @@ func (t1 *Taskfile) Merge(t2 *Taskfile, include *Include) error {
|
||||
}
|
||||
}
|
||||
}
|
||||
if t2.UseGitignore != nil {
|
||||
for _, t := range t2.Tasks.All(nil) {
|
||||
if t.UseGitignore == nil {
|
||||
v := *t2.UseGitignore
|
||||
t.UseGitignore = &v
|
||||
}
|
||||
}
|
||||
}
|
||||
t1.Vars.Merge(t2.Vars, include)
|
||||
t1.Env.Merge(t2.Env, include)
|
||||
return t1.Tasks.Merge(t2.Tasks, include, t1.Vars)
|
||||
@@ -76,19 +85,20 @@ func (tf *Taskfile) UnmarshalYAML(node *yaml.Node) error {
|
||||
switch node.Kind {
|
||||
case yaml.MappingNode:
|
||||
var taskfile struct {
|
||||
Version *semver.Version
|
||||
Output Output
|
||||
Method string
|
||||
Includes *Includes
|
||||
Set []string
|
||||
Shopt []string
|
||||
Vars *Vars
|
||||
Env *Vars
|
||||
Tasks *Tasks
|
||||
Silent bool
|
||||
Dotenv []string
|
||||
Run string
|
||||
Interval time.Duration
|
||||
Version *semver.Version
|
||||
Output Output
|
||||
Method string
|
||||
Includes *Includes
|
||||
Set []string
|
||||
Shopt []string
|
||||
Vars *Vars
|
||||
Env *Vars
|
||||
Tasks *Tasks
|
||||
Silent bool
|
||||
Dotenv []string
|
||||
Run string
|
||||
Interval time.Duration
|
||||
UseGitignore *bool `yaml:"use_gitignore"`
|
||||
}
|
||||
if err := node.Decode(&taskfile); err != nil {
|
||||
return errors.NewTaskfileDecodeError(err, node)
|
||||
@@ -106,6 +116,7 @@ func (tf *Taskfile) UnmarshalYAML(node *yaml.Node) error {
|
||||
tf.Dotenv = taskfile.Dotenv
|
||||
tf.Run = taskfile.Run
|
||||
tf.Interval = taskfile.Interval
|
||||
tf.UseGitignore = taskfile.UseGitignore
|
||||
if tf.Includes == nil {
|
||||
tf.Includes = NewIncludes()
|
||||
}
|
||||
|
||||
@@ -8,37 +8,54 @@ import (
|
||||
|
||||
// Var represents either a static or dynamic variable.
|
||||
type Var struct {
|
||||
Value any
|
||||
Live any
|
||||
Sh *string
|
||||
Ref string
|
||||
Dir string
|
||||
Value any
|
||||
Live any
|
||||
Sh *string
|
||||
Ref string
|
||||
Dir string
|
||||
Secret bool
|
||||
}
|
||||
|
||||
func (v *Var) UnmarshalYAML(node *yaml.Node) error {
|
||||
switch node.Kind {
|
||||
case yaml.MappingNode:
|
||||
key := "<none>"
|
||||
if len(node.Content) > 0 {
|
||||
key = node.Content[0].Value
|
||||
var m struct {
|
||||
Sh *string
|
||||
Ref string
|
||||
Map any
|
||||
Value any
|
||||
Secret bool
|
||||
}
|
||||
switch key {
|
||||
case "sh", "ref", "map":
|
||||
var m struct {
|
||||
Sh *string
|
||||
Ref string
|
||||
Map any
|
||||
if err := node.Decode(&m); err != nil {
|
||||
return errors.NewTaskfileDecodeError(err, node)
|
||||
}
|
||||
// Validate the keys regardless of their order: every key must be known
|
||||
// and at least one type-defining key must be present. "secret" is a
|
||||
// modifier, not a type, so it can appear in any position.
|
||||
hasType := false
|
||||
for i := 0; i+1 < len(node.Content); i += 2 {
|
||||
switch node.Content[i].Value {
|
||||
case "sh", "ref", "map", "value":
|
||||
hasType = true
|
||||
case "secret":
|
||||
// modifier, not a type
|
||||
default:
|
||||
return errors.NewTaskfileDecodeError(nil, node).WithMessage(`%q is not a valid variable type. Try "sh", "ref", "map", "value" or using a scalar value`, node.Content[i].Value)
|
||||
}
|
||||
if err := node.Decode(&m); err != nil {
|
||||
return errors.NewTaskfileDecodeError(err, node)
|
||||
}
|
||||
v.Sh = m.Sh
|
||||
v.Ref = m.Ref
|
||||
}
|
||||
if !hasType {
|
||||
return errors.NewTaskfileDecodeError(nil, node).WithMessage(`a variable must define one of "sh", "ref", "map", "value" or be a scalar value`)
|
||||
}
|
||||
v.Sh = m.Sh
|
||||
v.Ref = m.Ref
|
||||
v.Secret = m.Secret
|
||||
// Handle both "map" and "value" keys
|
||||
if m.Map != nil {
|
||||
v.Value = m.Map
|
||||
return nil
|
||||
default:
|
||||
return errors.NewTaskfileDecodeError(nil, node).WithMessage(`%q is not a valid variable type. Try "sh", "ref", "map" or using a scalar value`, key)
|
||||
} else if m.Value != nil {
|
||||
v.Value = m.Value
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
var value any
|
||||
if err := node.Decode(&value); err != nil {
|
||||
|
||||
@@ -19,6 +19,7 @@ type TaskRC struct {
|
||||
Interactive *bool `yaml:"interactive"`
|
||||
Remote Remote `yaml:"remote"`
|
||||
Failfast bool `yaml:"failfast"`
|
||||
TempDir *string `yaml:"temp-dir"`
|
||||
Experiments map[string]int `yaml:"experiments"`
|
||||
}
|
||||
|
||||
@@ -70,4 +71,5 @@ func (t *TaskRC) Merge(other *TaskRC) {
|
||||
t.Concurrency = cmp.Or(other.Concurrency, t.Concurrency)
|
||||
t.Interactive = cmp.Or(other.Interactive, t.Interactive)
|
||||
t.Failfast = cmp.Or(other.Failfast, t.Failfast)
|
||||
t.TempDir = cmp.Or(other.TempDir, t.TempDir)
|
||||
}
|
||||
|
||||
@@ -112,6 +112,40 @@ func TestGetConfig_OnlyLocal(t *testing.T) { //nolint:paralleltest // cannot run
|
||||
}, cfg)
|
||||
}
|
||||
|
||||
func TestGetConfig_TempDir(t *testing.T) { //nolint:paralleltest // cannot run in parallel
|
||||
_, _, localDir := setupDirs(t)
|
||||
|
||||
writeFile(t, localDir, ".taskrc.yml", `
|
||||
temp-dir: .task-cache
|
||||
`)
|
||||
|
||||
cfg, err := GetConfig(localDir)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, cfg)
|
||||
require.NotNil(t, cfg.TempDir)
|
||||
assert.Equal(t, ".task-cache", *cfg.TempDir)
|
||||
}
|
||||
|
||||
func TestGetConfig_TempDirMergePrecedence(t *testing.T) { //nolint:paralleltest // cannot run in parallel
|
||||
xdgConfigDir, homeDir, localDir := setupDirs(t)
|
||||
|
||||
writeFile(t, xdgConfigDir, "taskrc.yml", `
|
||||
temp-dir: xdg-cache
|
||||
`)
|
||||
writeFile(t, homeDir, ".taskrc.yml", `
|
||||
temp-dir: home-cache
|
||||
`)
|
||||
writeFile(t, localDir, ".taskrc.yml", `
|
||||
temp-dir: local-cache
|
||||
`)
|
||||
|
||||
cfg, err := GetConfig(localDir)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, cfg)
|
||||
require.NotNil(t, cfg.TempDir)
|
||||
assert.Equal(t, "local-cache", *cfg.TempDir)
|
||||
}
|
||||
|
||||
func TestGetConfig_All(t *testing.T) { //nolint:paralleltest // cannot run in parallel
|
||||
xdgConfigDir, homeDir, localDir := setupDirs(t)
|
||||
|
||||
|
||||
1
testdata/gitignore/.gitignore
vendored
Normal file
1
testdata/gitignore/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
ignored.txt
|
||||
25
testdata/gitignore/Taskfile.yml
vendored
Normal file
25
testdata/gitignore/Taskfile.yml
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
version: '3'
|
||||
|
||||
use_gitignore: true
|
||||
|
||||
tasks:
|
||||
build:
|
||||
cmds:
|
||||
- cp ./source.txt ./generated.txt
|
||||
sources:
|
||||
- ./*.txt
|
||||
- exclude: ./generated.txt
|
||||
generates:
|
||||
- ./generated.txt
|
||||
method: checksum
|
||||
|
||||
build-no-use_gitignore:
|
||||
use_gitignore: false
|
||||
cmds:
|
||||
- cp ./source.txt ./generated.txt
|
||||
sources:
|
||||
- ./*.txt
|
||||
- exclude: ./generated.txt
|
||||
generates:
|
||||
- ./generated.txt
|
||||
method: checksum
|
||||
1
testdata/gitignore/source.txt
vendored
Normal file
1
testdata/gitignore/source.txt
vendored
Normal file
@@ -0,0 +1 @@
|
||||
source content
|
||||
1
testdata/gitignore/testdata/TestGitignoreChecksum-first_run.golden
vendored
Normal file
1
testdata/gitignore/testdata/TestGitignoreChecksum-first_run.golden
vendored
Normal file
@@ -0,0 +1 @@
|
||||
task: [build] cp ./source.txt ./generated.txt
|
||||
1
testdata/gitignore/testdata/TestGitignoreChecksum-ignored_file_modified.golden
vendored
Normal file
1
testdata/gitignore/testdata/TestGitignoreChecksum-ignored_file_modified.golden
vendored
Normal file
@@ -0,0 +1 @@
|
||||
task: Task "build" is up to date
|
||||
1
testdata/gitignore/testdata/TestGitignoreChecksum-source_file_modified.golden
vendored
Normal file
1
testdata/gitignore/testdata/TestGitignoreChecksum-source_file_modified.golden
vendored
Normal file
@@ -0,0 +1 @@
|
||||
task: [build] cp ./source.txt ./generated.txt
|
||||
1
testdata/gitignore/testdata/TestGitignoreChecksum-up_to_date.golden
vendored
Normal file
1
testdata/gitignore/testdata/TestGitignoreChecksum-up_to_date.golden
vendored
Normal file
@@ -0,0 +1 @@
|
||||
task: Task "build" is up to date
|
||||
1
testdata/gitignore_included/.gitignore
vendored
Normal file
1
testdata/gitignore_included/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
ignored.txt
|
||||
9
testdata/gitignore_included/Taskfile.yml
vendored
Normal file
9
testdata/gitignore_included/Taskfile.yml
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
version: '3'
|
||||
|
||||
includes:
|
||||
included: ./included/Taskfile.yml
|
||||
|
||||
tasks:
|
||||
default:
|
||||
cmds:
|
||||
- echo "root"
|
||||
11
testdata/gitignore_included/included/Taskfile.yml
vendored
Normal file
11
testdata/gitignore_included/included/Taskfile.yml
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
version: '3'
|
||||
|
||||
use_gitignore: true
|
||||
|
||||
tasks:
|
||||
build:
|
||||
cmds:
|
||||
- echo "build executed"
|
||||
sources:
|
||||
- ./*.txt
|
||||
method: checksum
|
||||
1
testdata/gitignore_included/source.txt
vendored
Normal file
1
testdata/gitignore_included/source.txt
vendored
Normal file
@@ -0,0 +1 @@
|
||||
source
|
||||
2
testdata/gitignore_included/testdata/TestGitignoreIncluded-first_run.golden
vendored
Normal file
2
testdata/gitignore_included/testdata/TestGitignoreIncluded-first_run.golden
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
task: [included:build] echo "build executed"
|
||||
build executed
|
||||
1
testdata/gitignore_included/testdata/TestGitignoreIncluded-ignored_file_modified.golden
vendored
Normal file
1
testdata/gitignore_included/testdata/TestGitignoreIncluded-ignored_file_modified.golden
vendored
Normal file
@@ -0,0 +1 @@
|
||||
task: Task "included:build" is up to date
|
||||
1
testdata/gitignore_included/testdata/TestGitignoreIncluded-up_to_date.golden
vendored
Normal file
1
testdata/gitignore_included/testdata/TestGitignoreIncluded-up_to_date.golden
vendored
Normal file
@@ -0,0 +1 @@
|
||||
task: Task "included:build" is up to date
|
||||
1
testdata/gitignore_included_override/.gitignore
vendored
Normal file
1
testdata/gitignore_included_override/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
ignored.txt
|
||||
11
testdata/gitignore_included_override/Taskfile.yml
vendored
Normal file
11
testdata/gitignore_included_override/Taskfile.yml
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
version: '3'
|
||||
|
||||
use_gitignore: true
|
||||
|
||||
includes:
|
||||
included: ./included/Taskfile.yml
|
||||
|
||||
tasks:
|
||||
default:
|
||||
cmds:
|
||||
- echo "root"
|
||||
11
testdata/gitignore_included_override/included/Taskfile.yml
vendored
Normal file
11
testdata/gitignore_included_override/included/Taskfile.yml
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
version: '3'
|
||||
|
||||
use_gitignore: false
|
||||
|
||||
tasks:
|
||||
build:
|
||||
cmds:
|
||||
- echo "build executed"
|
||||
sources:
|
||||
- ./*.txt
|
||||
method: checksum
|
||||
1
testdata/gitignore_included_override/source.txt
vendored
Normal file
1
testdata/gitignore_included_override/source.txt
vendored
Normal file
@@ -0,0 +1 @@
|
||||
source
|
||||
2
testdata/gitignore_included_override/testdata/TestGitignoreIncludedOverride-first_run.golden
vendored
Normal file
2
testdata/gitignore_included_override/testdata/TestGitignoreIncludedOverride-first_run.golden
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
task: [included:build] echo "build executed"
|
||||
build executed
|
||||
@@ -0,0 +1,2 @@
|
||||
task: [included:build] echo "build executed"
|
||||
build executed
|
||||
@@ -0,0 +1 @@
|
||||
task: Task "included:build" is up to date
|
||||
1
testdata/gitignore_negation/.gitignore
vendored
Normal file
1
testdata/gitignore_negation/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
*.log
|
||||
11
testdata/gitignore_negation/Taskfile.yml
vendored
Normal file
11
testdata/gitignore_negation/Taskfile.yml
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
version: '3'
|
||||
|
||||
use_gitignore: true
|
||||
|
||||
tasks:
|
||||
build:
|
||||
cmds:
|
||||
- echo "build executed"
|
||||
sources:
|
||||
- ./sub/*.log
|
||||
method: checksum
|
||||
1
testdata/gitignore_negation/sub/.gitignore
vendored
Normal file
1
testdata/gitignore_negation/sub/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
!debug.log
|
||||
2
testdata/gitignore_negation/testdata/TestGitignoreNegation-first_run.golden
vendored
Normal file
2
testdata/gitignore_negation/testdata/TestGitignoreNegation-first_run.golden
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
task: [build] echo "build executed"
|
||||
build executed
|
||||
1
testdata/gitignore_negation/testdata/TestGitignoreNegation-ignored_file_modified.golden
vendored
Normal file
1
testdata/gitignore_negation/testdata/TestGitignoreNegation-ignored_file_modified.golden
vendored
Normal file
@@ -0,0 +1 @@
|
||||
task: Task "build" is up to date
|
||||
2
testdata/gitignore_negation/testdata/TestGitignoreNegation-reincluded_file_modified.golden
vendored
Normal file
2
testdata/gitignore_negation/testdata/TestGitignoreNegation-reincluded_file_modified.golden
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
task: [build] echo "build executed"
|
||||
build executed
|
||||
1
testdata/gitignore_negation/testdata/TestGitignoreNegation-up_to_date.golden
vendored
Normal file
1
testdata/gitignore_negation/testdata/TestGitignoreNegation-up_to_date.golden
vendored
Normal file
@@ -0,0 +1 @@
|
||||
task: Task "build" is up to date
|
||||
11
testdata/gitignore_nested/Taskfile.yml
vendored
Normal file
11
testdata/gitignore_nested/Taskfile.yml
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
version: '3'
|
||||
|
||||
use_gitignore: true
|
||||
|
||||
tasks:
|
||||
build:
|
||||
cmds:
|
||||
- echo "build executed"
|
||||
sources:
|
||||
- ./sub/*
|
||||
method: checksum
|
||||
1
testdata/gitignore_nested/sub/.gitignore
vendored
Normal file
1
testdata/gitignore_nested/sub/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
secret.dat
|
||||
1
testdata/gitignore_nested/sub/keep.txt
vendored
Normal file
1
testdata/gitignore_nested/sub/keep.txt
vendored
Normal file
@@ -0,0 +1 @@
|
||||
keep
|
||||
2
testdata/gitignore_nested/testdata/TestGitignoreNested-first_run.golden
vendored
Normal file
2
testdata/gitignore_nested/testdata/TestGitignoreNested-first_run.golden
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
task: [build] echo "build executed"
|
||||
build executed
|
||||
1
testdata/gitignore_nested/testdata/TestGitignoreNested-ignored_file_modified.golden
vendored
Normal file
1
testdata/gitignore_nested/testdata/TestGitignoreNested-ignored_file_modified.golden
vendored
Normal file
@@ -0,0 +1 @@
|
||||
task: Task "build" is up to date
|
||||
2
testdata/gitignore_nested/testdata/TestGitignoreNested-source_file_modified.golden
vendored
Normal file
2
testdata/gitignore_nested/testdata/TestGitignoreNested-source_file_modified.golden
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
task: [build] echo "build executed"
|
||||
build executed
|
||||
1
testdata/gitignore_nested/testdata/TestGitignoreNested-up_to_date.golden
vendored
Normal file
1
testdata/gitignore_nested/testdata/TestGitignoreNested-up_to_date.golden
vendored
Normal file
@@ -0,0 +1 @@
|
||||
task: Task "build" is up to date
|
||||
83
testdata/secrets/Taskfile.yml
vendored
Normal file
83
testdata/secrets/Taskfile.yml
vendored
Normal file
@@ -0,0 +1,83 @@
|
||||
version: '3'
|
||||
|
||||
vars:
|
||||
# Public variable
|
||||
APP_NAME: myapp
|
||||
|
||||
# Secret variable with value
|
||||
API_KEY:
|
||||
value: "secret-api-key-123"
|
||||
secret: true
|
||||
|
||||
# Secret variable from shell command
|
||||
PASSWORD:
|
||||
sh: "echo 'my-super-secret-password'"
|
||||
secret: true
|
||||
|
||||
# Non-secret variable
|
||||
PUBLIC_URL: https://example.com
|
||||
|
||||
tasks:
|
||||
test-secret-masking:
|
||||
desc: Test that secret variables are masked in logs
|
||||
cmds:
|
||||
- echo "Deploying {{.APP_NAME}} to {{.PUBLIC_URL}}"
|
||||
- echo "Using API key {{.API_KEY}}"
|
||||
- echo "Password is {{.PASSWORD}}"
|
||||
- echo "Public app name is {{.APP_NAME}}"
|
||||
|
||||
test-multiple-secrets:
|
||||
desc: Test multiple secrets in one command
|
||||
cmds:
|
||||
- echo "API={{.API_KEY}} PWD={{.PASSWORD}}"
|
||||
|
||||
test-mixed:
|
||||
desc: Test mix of secret and public vars
|
||||
vars:
|
||||
LOCAL_SECRET:
|
||||
value: "task-level-secret"
|
||||
secret: true
|
||||
cmds:
|
||||
- echo "App={{.APP_NAME}} Secret={{.LOCAL_SECRET}} URL={{.PUBLIC_URL}}"
|
||||
|
||||
test-deferred-secret:
|
||||
desc: Test that deferred commands mask secrets
|
||||
vars:
|
||||
DEFERRED_SECRET:
|
||||
value: "deferred-secret-value"
|
||||
secret: true
|
||||
cmds:
|
||||
- echo "Starting task"
|
||||
- defer: echo "Cleanup with secret={{.DEFERRED_SECRET}} and app={{.APP_NAME}}"
|
||||
- echo "Main command executed"
|
||||
|
||||
test-dynamic-secret-verbose:
|
||||
desc: Test that dynamic (sh) secrets are masked even in verbose logs
|
||||
cmds:
|
||||
- echo "Password is {{.PASSWORD}}"
|
||||
|
||||
test-secret-key-order:
|
||||
desc: Test that "secret" may be declared before the value/sh key
|
||||
vars:
|
||||
SECRET_FIRST:
|
||||
secret: true
|
||||
value: "order-independent-secret"
|
||||
SH_SECRET_FIRST:
|
||||
secret: true
|
||||
sh: "echo 'sh-order-independent-secret'"
|
||||
cmds:
|
||||
- echo "Value={{.SECRET_FIRST}} Sh={{.SH_SECRET_FIRST}}"
|
||||
|
||||
test-env-secret-limitation:
|
||||
desc: Test showing that env vars with secret flag are NOT masked (limitation)
|
||||
env:
|
||||
SECRET_TOKEN:
|
||||
value: "env-secret-token-123"
|
||||
secret: true
|
||||
PUBLIC_ENV: "public-value"
|
||||
cmds:
|
||||
# Templates {{.VAR}} don't work with env - they're empty
|
||||
- echo "Token via template is {{.SECRET_TOKEN}}"
|
||||
# Shell $VAR works but is NOT masked (env vars not in template system)
|
||||
- echo "Token via shell is $SECRET_TOKEN"
|
||||
- echo "Public env is {{.PUBLIC_ENV}}"
|
||||
6
testdata/secrets/testdata/TestSecretVars-deferred_command_with_secrets.golden
vendored
Normal file
6
testdata/secrets/testdata/TestSecretVars-deferred_command_with_secrets.golden
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
task: [test-deferred-secret] echo "Starting task"
|
||||
Starting task
|
||||
task: [test-deferred-secret] echo "Main command executed"
|
||||
Main command executed
|
||||
task: [test-deferred-secret] echo "Cleanup with secret=***** and app=myapp"
|
||||
Cleanup with secret=deferred-secret-value and app=myapp
|
||||
5
testdata/secrets/testdata/TestSecretVars-dynamic_secret_masked_in_verbose.golden
vendored
Normal file
5
testdata/secrets/testdata/TestSecretVars-dynamic_secret_masked_in_verbose.golden
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
task: dynamic variable: "echo 'my-super-secret-password'" result: "*****"
|
||||
task: "test-dynamic-secret-verbose" started
|
||||
task: [test-dynamic-secret-verbose] echo "Password is *****"
|
||||
Password is my-super-secret-password
|
||||
task: "test-dynamic-secret-verbose" finished
|
||||
6
testdata/secrets/testdata/TestSecretVars-env_secret_limitation.golden
vendored
Normal file
6
testdata/secrets/testdata/TestSecretVars-env_secret_limitation.golden
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
task: [test-env-secret-limitation] echo "Token via template is "
|
||||
Token via template is
|
||||
task: [test-env-secret-limitation] echo "Token via shell is $SECRET_TOKEN"
|
||||
Token via shell is env-secret-token-123
|
||||
task: [test-env-secret-limitation] echo "Public env is "
|
||||
Public env is
|
||||
2
testdata/secrets/testdata/TestSecretVars-mixed_secret_and_public_vars.golden
vendored
Normal file
2
testdata/secrets/testdata/TestSecretVars-mixed_secret_and_public_vars.golden
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
task: [test-mixed] echo "App=myapp Secret=***** URL=https://example.com"
|
||||
App=myapp Secret=task-level-secret URL=https://example.com
|
||||
2
testdata/secrets/testdata/TestSecretVars-multiple_secrets_masked.golden
vendored
Normal file
2
testdata/secrets/testdata/TestSecretVars-multiple_secrets_masked.golden
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
task: [test-multiple-secrets] echo "API=***** PWD=*****"
|
||||
API=secret-api-key-123 PWD=my-super-secret-password
|
||||
2
testdata/secrets/testdata/TestSecretVars-secret_key_order_independent.golden
vendored
Normal file
2
testdata/secrets/testdata/TestSecretVars-secret_key_order_independent.golden
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
task: [test-secret-key-order] echo "Value=***** Sh=*****"
|
||||
Value=order-independent-secret Sh=sh-order-independent-secret
|
||||
8
testdata/secrets/testdata/TestSecretVars-secret_vars_are_masked_in_logs.golden
vendored
Normal file
8
testdata/secrets/testdata/TestSecretVars-secret_vars_are_masked_in_logs.golden
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
task: [test-secret-masking] echo "Deploying myapp to https://example.com"
|
||||
Deploying myapp to https://example.com
|
||||
task: [test-secret-masking] echo "Using API key *****"
|
||||
Using API key secret-api-key-123
|
||||
task: [test-secret-masking] echo "Password is *****"
|
||||
Password is my-super-secret-password
|
||||
task: [test-secret-masking] echo "Public app name is myapp"
|
||||
Public app name is myapp
|
||||
15
testdata/secrets/testdata/TestSecretVars-secret_vars_are_masked_in_summary.golden
vendored
Normal file
15
testdata/secrets/testdata/TestSecretVars-secret_vars_are_masked_in_summary.golden
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
task: test-secret-masking
|
||||
|
||||
Test that secret variables are masked in logs
|
||||
|
||||
vars:
|
||||
APP_NAME: "myapp"
|
||||
API_KEY: *****
|
||||
PASSWORD: *****
|
||||
PUBLIC_URL: "https://example.com"
|
||||
|
||||
commands:
|
||||
- echo "Deploying myapp to https://example.com"
|
||||
- echo "Using API key *****"
|
||||
- echo "Password is *****"
|
||||
- echo "Public app name is myapp"
|
||||
63
variables.go
63
variables.go
@@ -19,6 +19,16 @@ import (
|
||||
"github.com/go-task/task/v3/taskfile/ast"
|
||||
)
|
||||
|
||||
// shouldTaskUseGitignore resolves whether gitignore filtering applies to a
|
||||
// task: the task-level value takes precedence, falling back to the Taskfile's
|
||||
// global use_gitignore when the task does not set it.
|
||||
func (e *Executor) shouldTaskUseGitignore(t *ast.Task) bool {
|
||||
if t.UseGitignore != nil {
|
||||
return *t.UseGitignore
|
||||
}
|
||||
return e.Taskfile.UseGitignore != nil && *e.Taskfile.UseGitignore
|
||||
}
|
||||
|
||||
// CompiledTask returns a copy of a task, but replacing variables in almost all
|
||||
// properties using the Go template package.
|
||||
func (e *Executor) CompiledTask(call *Call) (*ast.Task, error) {
|
||||
@@ -43,6 +53,8 @@ func (e *Executor) CompiledTaskForTaskList(call *Call) (*ast.Task, error) {
|
||||
|
||||
cache := &templater.Cache{Vars: vars}
|
||||
|
||||
gitignore := e.shouldTaskUseGitignore(origTask)
|
||||
|
||||
return &ast.Task{
|
||||
Task: origTask.Task,
|
||||
Label: templater.Replace(origTask.Label, cache),
|
||||
@@ -59,6 +71,7 @@ func (e *Executor) CompiledTaskForTaskList(call *Call) (*ast.Task, error) {
|
||||
Env: nil,
|
||||
Dotenv: origTask.Dotenv,
|
||||
Silent: deepcopy.Scalar(origTask.Silent),
|
||||
UseGitignore: &gitignore,
|
||||
Interactive: origTask.Interactive,
|
||||
Internal: origTask.Internal,
|
||||
Method: origTask.Method,
|
||||
@@ -110,6 +123,8 @@ func (e *Executor) compiledTask(call *Call, evaluateShVars bool) (*ast.Task, err
|
||||
}
|
||||
}
|
||||
|
||||
gitignore := e.shouldTaskUseGitignore(origTask)
|
||||
|
||||
new := ast.Task{
|
||||
Task: origTask.Task,
|
||||
Label: templater.Replace(origTask.Label, cache),
|
||||
@@ -126,6 +141,7 @@ func (e *Executor) compiledTask(call *Call, evaluateShVars bool) (*ast.Task, err
|
||||
Env: nil,
|
||||
Dotenv: templater.Replace(origTask.Dotenv, cache),
|
||||
Silent: deepcopy.Scalar(origTask.Silent),
|
||||
UseGitignore: &gitignore,
|
||||
Interactive: origTask.Interactive,
|
||||
Internal: origTask.Internal,
|
||||
Method: templater.Replace(origTask.Method, cache),
|
||||
@@ -219,7 +235,7 @@ func (e *Executor) compiledTask(call *Call, evaluateShVars bool) (*ast.Task, err
|
||||
continue
|
||||
}
|
||||
if cmd.For != nil {
|
||||
list, keys, err := itemsFromFor(cmd.For, new.Dir, new.Sources, new.Generates, vars, origTask.Location, cache)
|
||||
list, keys, err := itemsFromFor(cmd.For, new.Dir, new.Sources, new.Generates, gitignore, vars, origTask.Location, cache)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -239,6 +255,8 @@ func (e *Executor) compiledTask(call *Call, evaluateShVars bool) (*ast.Task, err
|
||||
extra["KEY"] = keys[i]
|
||||
}
|
||||
newCmd := cmd.DeepCopy()
|
||||
// Resolve template with secrets masked + loop vars for logging
|
||||
newCmd.LogCmd = templater.MaskSecretsWithExtra(cmd.Cmd, cache.Vars, extra)
|
||||
newCmd.Cmd = templater.ReplaceWithExtra(cmd.Cmd, cache, extra)
|
||||
newCmd.Task = templater.ReplaceWithExtra(cmd.Task, cache, extra)
|
||||
newCmd.If = templater.ReplaceWithExtra(cmd.If, cache, extra)
|
||||
@@ -254,6 +272,8 @@ func (e *Executor) compiledTask(call *Call, evaluateShVars bool) (*ast.Task, err
|
||||
continue
|
||||
}
|
||||
newCmd := cmd.DeepCopy()
|
||||
// Resolve template with secrets masked for logging
|
||||
newCmd.LogCmd = templater.MaskSecrets(cmd.Cmd, cache.Vars)
|
||||
newCmd.Cmd = templater.Replace(cmd.Cmd, cache)
|
||||
newCmd.Task = templater.Replace(cmd.Task, cache)
|
||||
newCmd.If = templater.Replace(cmd.If, cache)
|
||||
@@ -268,7 +288,7 @@ func (e *Executor) compiledTask(call *Call, evaluateShVars bool) (*ast.Task, err
|
||||
continue
|
||||
}
|
||||
if dep.For != nil {
|
||||
list, keys, err := itemsFromFor(dep.For, new.Dir, new.Sources, new.Generates, vars, origTask.Location, cache)
|
||||
list, keys, err := itemsFromFor(dep.For, new.Dir, new.Sources, new.Generates, gitignore, vars, origTask.Location, cache)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -339,6 +359,7 @@ func itemsFromFor(
|
||||
dir string,
|
||||
sources []*ast.Glob,
|
||||
generates []*ast.Glob,
|
||||
gitignore bool,
|
||||
vars *ast.Vars,
|
||||
location *ast.Location,
|
||||
cache *templater.Cache,
|
||||
@@ -347,13 +368,14 @@ func itemsFromFor(
|
||||
var values []any // The list of values to loop over
|
||||
// Get the list from a matrix
|
||||
if f.Matrix.Len() != 0 {
|
||||
if err := resolveMatrixRefs(f.Matrix, cache); err != nil {
|
||||
resolvedMatrix, err := resolveMatrixRefs(f.Matrix, cache)
|
||||
if err != nil {
|
||||
return nil, nil, errors.TaskfileInvalidError{
|
||||
URI: location.Taskfile,
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
return asAnySlice(product(f.Matrix)), nil, nil
|
||||
return asAnySlice(product(resolvedMatrix)), nil, nil
|
||||
}
|
||||
// Get the list from the explicit for list
|
||||
if len(f.List) > 0 {
|
||||
@@ -361,7 +383,7 @@ func itemsFromFor(
|
||||
}
|
||||
// Get the list from the task sources
|
||||
if f.From == "sources" {
|
||||
glist, err := fingerprint.Globs(dir, sources)
|
||||
glist, err := fingerprint.Globs(dir, sources, gitignore)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
@@ -375,7 +397,7 @@ func itemsFromFor(
|
||||
}
|
||||
// Get the list from the task generates
|
||||
if f.From == "generates" {
|
||||
glist, err := fingerprint.Globs(dir, generates)
|
||||
glist, err := fingerprint.Globs(dir, generates, gitignore)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
@@ -425,22 +447,43 @@ func itemsFromFor(
|
||||
return values, keys, nil
|
||||
}
|
||||
|
||||
func resolveMatrixRefs(matrix *ast.Matrix, cache *templater.Cache) error {
|
||||
// resolveMatrixRefs resolves any `ref:` rows in matrix and returns a new
|
||||
// Matrix with those rows populated. It must not mutate the matrix passed in:
|
||||
// that matrix is part of the shared, cached Task AST, and concurrent
|
||||
// invocations of the same task (e.g. via parallel deps) call this with the
|
||||
// same *ast.Matrix and would otherwise race on the row.Value assignment
|
||||
// below, intermittently leaking a value resolved for one caller into another
|
||||
// caller's expansion. See #2890.
|
||||
func resolveMatrixRefs(matrix *ast.Matrix, cache *templater.Cache) (*ast.Matrix, error) {
|
||||
if matrix.Len() == 0 {
|
||||
return nil
|
||||
return matrix, nil
|
||||
}
|
||||
hasRef := false
|
||||
for _, row := range matrix.All() {
|
||||
if row.Ref != "" {
|
||||
hasRef = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasRef {
|
||||
return matrix, nil
|
||||
}
|
||||
resolved := matrix.DeepCopy()
|
||||
for _, row := range resolved.All() {
|
||||
if row.Ref != "" {
|
||||
v := templater.ResolveRef(row.Ref, cache)
|
||||
if cache.Err() != nil {
|
||||
return nil, cache.Err()
|
||||
}
|
||||
switch value := v.(type) {
|
||||
case []any:
|
||||
row.Value = value
|
||||
default:
|
||||
return fmt.Errorf("matrix reference %q must resolve to a list", row.Ref)
|
||||
return nil, fmt.Errorf("matrix reference %q must resolve to a list", row.Ref)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
func resolveEnumRefs(requires *ast.Requires, cache *templater.Cache) error {
|
||||
|
||||
45
variables_test.go
Normal file
45
variables_test.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/go-task/task/v3/internal/templater"
|
||||
"github.com/go-task/task/v3/taskfile/ast"
|
||||
)
|
||||
|
||||
// TestResolveMatrixRefsDoesNotMutateInput is a regression test for #2890. The
|
||||
// *ast.Matrix passed to resolveMatrixRefs is part of the shared, cached Task
|
||||
// AST: the same *ast.Matrix is reused on every invocation of a task. If
|
||||
// resolveMatrixRefs resolved `ref:` rows in place, concurrent invocations of
|
||||
// the same task (e.g. via parallel deps) would race on that mutation and leak
|
||||
// a value resolved for one caller into another caller's expansion.
|
||||
//
|
||||
// The invariant that prevents this is that resolveMatrixRefs must resolve into
|
||||
// a copy and leave its input untouched, which this test asserts deterministically.
|
||||
func TestResolveMatrixRefsDoesNotMutateInput(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
matrix := ast.NewMatrix(
|
||||
&ast.MatrixElement{Key: "ARCH", Value: &ast.MatrixRow{Ref: ".ARCH_VAR"}},
|
||||
)
|
||||
|
||||
vars := ast.NewVars()
|
||||
vars.Set("ARCH_VAR", ast.Var{Value: []any{"amd64"}})
|
||||
cache := &templater.Cache{Vars: vars}
|
||||
|
||||
resolved, err := resolveMatrixRefs(matrix, cache)
|
||||
require.NoError(t, err)
|
||||
|
||||
// The returned matrix has the ref resolved...
|
||||
row, ok := resolved.Get("ARCH")
|
||||
require.True(t, ok, "ARCH row missing from resolved matrix")
|
||||
require.Equal(t, []any{"amd64"}, row.Value)
|
||||
|
||||
// ...but the shared input matrix must be left untouched.
|
||||
orig, ok := matrix.Get("ARCH")
|
||||
require.True(t, ok, "ARCH row missing from input matrix")
|
||||
require.Nil(t, orig.Value, "input matrix was mutated: Ref rows must be resolved into a copy")
|
||||
require.Equal(t, ".ARCH_VAR", orig.Ref, "input matrix Ref was altered")
|
||||
}
|
||||
2
watch.go
2
watch.go
@@ -205,7 +205,7 @@ func (e *Executor) collectSources(calls []*Call) ([]string, error) {
|
||||
var sources []string
|
||||
|
||||
err := e.traverse(calls, func(task *ast.Task) error {
|
||||
files, err := fingerprint.Globs(task.Dir, task.Sources)
|
||||
files, err := fingerprint.Globs(task.Dir, task.Sources, task.ShouldUseGitignore())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -23,5 +23,5 @@
|
||||
"vitepress-plugin-llms": "^1.9.1",
|
||||
"vue": "^3.5.18"
|
||||
},
|
||||
"packageManager": "pnpm@11.5.0+sha512.dbfcc4f81cf48597afd4bc391ffdf12c11f1a9fb83a395bfa6b0a2d9cc2fd8ffebafdb1ccbd529632153f793904c2615b7f09fe1a345473fd1c35845172a8eb1"
|
||||
"packageManager": "pnpm@11.8.0+sha512.c1f5e7c4cb241c8f174b743851d82f42b802324afc8b0f116b96adb15aa06664948dde36960a3ba1079ba5b4b29dd0140135b94b5b5f5263592249d68e555f26"
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user