Compare commits

..

69 Commits

Author SHA1 Message Date
Valentin Maerten
f9f2ecb8be chore: changelog for completion engine 2026-06-29 17:38:24 +02:00
Valentin Maerten
46201bcac9 feat(completion): unify shell wrappers behind task __complete 2026-06-29 17:38:24 +02:00
Valentin Maerten
7fa9d657cd feat(completion): complete task aliases in zsh (#2865) 2026-06-29 14:21:47 +00:00
kjasn
1c743de2b7 feat: Add temp dir option (#2891)
Co-authored-by: Valentin Maerten <maerten.valentin@gmail.com>
2026-06-29 14:13:37 +00:00
Valentin Maerten
f9e52fab40 chore: changelog for #2873 2026-06-29 15:50:50 +02:00
renovate[bot]
80693c1208 chore(deps): update pnpm/action-setup digest to 0ebf471 (#2888) 2026-06-29 15:50:06 +02:00
Or Carmi
d6cc0ce070 feat: configure output flags via environment variables (#2873)
Co-authored-by: Or Carmi <ocarmi@paloaltonetworks.com>
Co-authored-by: Valentin Maerten <maerten.valentin@gmail.com>
2026-06-29 13:48:37 +00:00
Valentin Maerten
d4dc3dc448 chore: changelog for #2773 2026-06-29 15:42:22 +02:00
Valentin Maerten
616433df75 feat: add use_gitignore option to exclude ignored files from sources/generates (#2773) 2026-06-29 15:39:33 +02:00
Valentin Maerten
a03aa7ba69 chore: changelog for #2514 2026-06-29 14:53:00 +02:00
Valentin Maerten
6abbbcb265 feat: do not log secret variables (#2514) 2026-06-29 14:50:08 +02:00
renovate[bot]
c73d53f4e9 chore(deps): update actions/checkout action to v7 (#2896) 2026-06-29 10:06:34 +02:00
Valentin Maerten
d0b903c772 ci: remove needs-build PR workflow (#2895) 2026-06-28 20:16:31 +00:00
Valentin Maerten
1d9b3cb7db chore: changelog for #2894 2026-06-28 22:03:20 +02:00
Amit Mishra
1a0f146888 fix: don't mutate shared matrix AST when resolving ref: rows (#2894)
Co-authored-by: Valentin Maerten <maerten.valentin@gmail.com>
2026-06-28 20:03:11 +00:00
Vessel9817
b9b50ca79c fix: $schema according to schemastore standards (#2893) 2026-06-28 19:18:31 +00:00
Valentin Maerten
3dcaa7db89 chore: changelog for #2886 2026-06-21 16:25:14 +02:00
renovate[bot]
a72eb84c15 chore(deps): update all non-major dependencies (#2869) 2026-06-21 16:24:20 +02:00
SEONGHYUN HONG
91b9e42f17 fix: sanitize all invalid characters in checksum filenames (#2886) 2026-06-21 16:23:45 +02:00
SEONGHYUN HONG
f1ab404fbb docs: fix duplicated word in guide (#2885)
Signed-off-by: s3onghyun <s3onghyun.hong@gmail.com>
2026-06-21 14:19:20 +00:00
Valentin Maerten
24a3ccdf42 chore: changelog for #2871 2026-06-07 16:39:02 +02:00
Christian Provenzano
b455286b63 fix: deterministic ordering of required var prompts (#2871) 2026-06-07 16:36:58 +02:00
Valentin Maerten
81d621d8aa chore: add mise config for dev environment setup (#2863)
Co-authored-by: Andrey Nering <andreynering@users.noreply.github.com>
2026-06-07 14:46:01 +02:00
dependabot[bot]
f50327f2a2 chore(deps): bump mdast-util-to-hast from 13.2.0 to 13.2.1 in /website (#2846)
Signed-off-by: dependabot[bot] <support@github.com>
2026-05-31 11:13:28 +02:00
Valentin Maerten
dd810d42e1 chore: changelog for #2852 2026-05-31 11:08:42 +02:00
Koji Ishii
de1717b05f feat(completion): ps completion supports aliases (#2852)
Co-authored-by: Valentin Maerten <maerten.valentin@gmail.com>
2026-05-31 09:06:39 +00:00
Valentin Maerten
7766e8add5 chore: changelog for #2859 2026-05-31 11:03:32 +02:00
Zhaowei Li
e61d0f0f7c fix: install fish completions to vendor directory (#2859) 2026-05-31 11:01:13 +02:00
renovate[bot]
9c8799480d chore(deps): update goreleaser/goreleaser-action digest to 5daf1e9 (#2857)
Co-authored-by: Valentin Maerten <maerten.valentin@gmail.com>
2026-05-31 08:47:42 +00:00
renovate[bot]
7f04f7057c chore(deps): update all non-major dependencies (#2858)
Co-authored-by: Valentin Maerten <maerten.valentin@gmail.com>
2026-05-31 10:38:31 +02:00
renovate[bot]
48dd62cdc1 chore(deps): update go-task/setup-task digest to 01a4adf (#2848) 2026-05-18 15:37:33 +02:00
renovate[bot]
b27a6653f6 chore(deps): update all non-major dependencies (#2816)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Valentin Maerten <maerten.valentin@gmail.com>
2026-05-17 12:40:51 +02:00
renovate[bot]
76269b03c4 chore(deps): update pnpm to v11 (#2844)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Valentin Maerten <maerten.valentin@gmail.com>
2026-05-17 12:20:33 +02:00
renovate[bot]
ed492ac862 chore(deps): update pnpm/action-setup digest to 0e279bb (#2843)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-17 11:59:56 +02:00
dependabot[bot]
1d385ad5b4 chore(deps): bump vite from 5.4.19 to 5.4.21 in /website (#2841)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-17 11:59:42 +02:00
Andrey Nering
2eff3545d9 ci(goreleaser): add missing env to workflow 2026-05-16 18:09:51 -03:00
Andrey Nering
bd79c29a39 v3.51.1 2026-05-16 17:51:05 -03:00
Andrey Nering
136e0dae89 ci: fix goreleaser 2026-05-16 17:50:34 -03:00
Andrey Nering
41a2137044 v3.51.0 2026-05-16 17:27:25 -03:00
imgbot[bot]
aea7f7a713 docs: optimize images (#2690) 2026-05-16 17:23:46 -03:00
renovate[bot]
c3e16b0a68 chore(deps): update mvdan.cc/sh/v3 digest to f5c6e27 (#2834) 2026-05-16 17:20:20 -03:00
renovate[bot]
19699a1f0e chore(deps): update pnpm/action-setup digest to 739bfe4 (#2835)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-11 16:27:48 +02:00
Timothy Rule
9e3ff27ba1 fix: avoid panic after calling WordsSeq (#2810) 2026-05-09 21:42:00 +00:00
renovate[bot]
de05ebc503 chore(deps): update goreleaser/goreleaser-action digest to 1a80836 (#2815) 2026-05-09 21:34:39 +00:00
Andrey Nering
6e154f2b71 docs(changelog): add entry for #2832 2026-05-09 18:28:46 -03:00
Andrey Nering
9ba1e566a9 fix: update mvdan.cc/sh to fix regression
* Closes #2812
* Closes #2818
* Ref https://github.com/mvdan/sh/pull/1330
2026-05-09 18:28:46 -03:00
Andrey Nering
1b53d425ee ci(govulncheck): update golang.org/x/net 2026-05-09 11:45:53 -03:00
Andrey Nering
7b3559ce4c ci: fix build and lint builds 2026-05-09 11:45:53 -03:00
Andrey Nering
43dbeb4260 docs(changelog): add entry for #2406 #2408 2026-05-09 11:25:49 -03:00
Jerry Wiltse
d9e0e04725 feat: add joinEnv and joinUrl string functions and 2 new system vars (#2408) 2026-05-09 14:19:33 +00:00
Andrey Nering
629a10813f chore: update minimal go version to go 1.25.10 2026-05-09 11:19:02 -03:00
Andrey Nering
dc64864643 docs: add security > threat model page (#2830) 2026-05-09 13:59:36 +00:00
renovate[bot]
5f78da2d0a chore(deps): update dependency netlify-cli to v26 (#2823)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-05 03:03:32 +02:00
renovate[bot]
7915b78ac2 chore(deps): update pnpm/action-setup action to v6 (#2824)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-05 03:03:13 +02:00
Andrey Nering
3441bf522e docs(changelog): add entry for #2820 2026-05-01 10:03:53 -03:00
Roman Dahm
46f5db22c8 perf(templater): skip template engine for strings without {{ (#2820) 2026-05-01 09:57:16 -03:00
Ludovic Fernandez
ecffcc720f chore(golangci-lint): improve and clean configuration (#2808) 2026-04-23 21:13:15 -03:00
Valentin Maerten
be35b3af75 feat(website): highlight big orgs / projects that use Task (#2803) 2026-04-22 10:02:06 +02:00
renovate[bot]
3aaedc790d chore(deps): update goreleaser/goreleaser-action digest to e24998b (#2804)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-20 22:49:24 +02:00
renovate[bot]
0b19d973ac chore(deps): update all non-major dependencies (#2805)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-20 22:08:05 +02:00
Andreas **Felix** Häberle
70b6cd8ee0 docs: add call internal task within a task example (#2789)
Co-authored-by: Valentin Maerten <maerten.valentin@gmail.com>
2026-04-20 21:58:42 +02:00
Valentin Maerten
1eb5720e7e chore: changelog for #2788 2026-04-20 21:53:50 +02:00
Mateen Anjum
1b06da16f6 feat(templater): add absPath template function (#2788)
Signed-off-by: Mateen Anjum <mateenali66@gmail.com>
Co-authored-by: Valentin Maerten <maerten.valentin@gmail.com>
2026-04-20 21:50:03 +02:00
Andrey Nering
6e37e3d7a7 chore(website): remove controls to copy page content
This is part of the LLM plugin. It's distracting and not really useful.

We're keeping the markdown version of the pages, tho. Just append `.md`
to any page to see the markdown version.
2026-04-15 16:39:20 -03:00
Pete Davison
4bea638b05 feat: add security docs to website and update contributing (#2799) 2026-04-15 20:34:38 +01:00
Pete Davison
8f2d17a387 feat: use GH_PAT for goreleaser (#2797) 2026-04-15 13:33:57 +00:00
Andrey Nering
f7d17fffad chore(website): update my bluesky handle 2026-04-15 10:16:02 -03:00
Pete Davison
697ef35303 feat: add permissions to actions (#2796) 2026-04-15 13:27:23 +01:00
Andrey Nering
8fe3d048fa docs: document and add blog post about go tool task (#2791) 2026-04-14 22:47:45 -03:00
152 changed files with 6122 additions and 2175 deletions

View File

@@ -4,13 +4,16 @@ on:
issue_comment: issue_comment:
types: [created] types: [created]
permissions:
issues: write
jobs: jobs:
issue-awaiting-response: issue-awaiting-response:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with: with:
github-token: ${{secrets.GH_PAT}} github-token: ${{secrets.GITHUB_TOKEN}}
script: | script: |
const issue = await github.rest.issues.get({ const issue = await github.rest.issues.get({
owner: context.repo.owner, owner: context.repo.owner,

View File

@@ -4,13 +4,16 @@ on:
issues: issues:
types: [closed] types: [closed]
permissions:
issues: write
jobs: jobs:
issue-closed: issue-closed:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with: with:
github-token: ${{secrets.GH_PAT}} github-token: ${{secrets.GITHUB_TOKEN}}
script: | script: |
const labels = await github.paginate( const labels = await github.paginate(
github.rest.issues.listLabelsOnIssue, { github.rest.issues.listLabelsOnIssue, {

View File

@@ -4,6 +4,9 @@ on:
issues: issues:
types: [field_added] types: [field_added]
permissions:
issues: write
jobs: jobs:
issue-experiment-proposal: issue-experiment-proposal:
if: github.event.issue_field.id == '6591' && github.event.issue_field_value.option.name == 'proposal' if: github.event.issue_field.id == '6591' && github.event.issue_field_value.option.name == 'proposal'
@@ -11,7 +14,7 @@ jobs:
steps: steps:
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with: with:
github-token: ${{secrets.GH_PAT}} github-token: ${{secrets.GITHUB_TOKEN}}
script: | script: |
github.rest.issues.createComment({ github.rest.issues.createComment({
issue_number: context.issue.number, issue_number: context.issue.number,
@@ -25,7 +28,7 @@ jobs:
steps: steps:
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with: with:
github-token: ${{secrets.GH_PAT}} github-token: ${{secrets.GITHUB_TOKEN}}
script: | script: |
github.rest.issues.createComment({ github.rest.issues.createComment({
issue_number: context.issue.number, issue_number: context.issue.number,
@@ -39,7 +42,7 @@ jobs:
steps: steps:
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with: with:
github-token: ${{secrets.GH_PAT}} github-token: ${{secrets.GITHUB_TOKEN}}
script: | script: |
github.rest.issues.createComment({ github.rest.issues.createComment({
issue_number: context.issue.number, issue_number: context.issue.number,
@@ -53,7 +56,7 @@ jobs:
steps: steps:
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with: with:
github-token: ${{secrets.GH_PAT}} github-token: ${{secrets.GITHUB_TOKEN}}
script: | script: |
github.rest.issues.createComment({ github.rest.issues.createComment({
issue_number: context.issue.number, issue_number: context.issue.number,
@@ -67,7 +70,7 @@ jobs:
steps: steps:
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with: with:
github-token: ${{secrets.GH_PAT}} github-token: ${{secrets.GITHUB_TOKEN}}
script: | script: |
github.rest.issues.createComment({ github.rest.issues.createComment({
issue_number: context.issue.number, issue_number: context.issue.number,
@@ -87,7 +90,7 @@ jobs:
steps: steps:
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with: with:
github-token: ${{secrets.GH_PAT}} github-token: ${{secrets.GITHUB_TOKEN}}
script: | script: |
github.rest.issues.createComment({ github.rest.issues.createComment({
issue_number: context.issue.number, issue_number: context.issue.number,
@@ -107,7 +110,7 @@ jobs:
steps: steps:
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with: with:
github-token: ${{secrets.GH_PAT}} github-token: ${{secrets.GITHUB_TOKEN}}
script: | script: |
github.rest.issues.createComment({ github.rest.issues.createComment({
issue_number: context.issue.number, issue_number: context.issue.number,

View File

@@ -4,13 +4,16 @@ on:
issues: issues:
types: [opened] types: [opened]
permissions:
issues: write
jobs: jobs:
issue-needs-triage: issue-needs-triage:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with: with:
github-token: ${{secrets.GH_PAT}} github-token: ${{secrets.GITHUB_TOKEN}}
script: | script: |
const labels = await github.paginate( const labels = await github.paginate(
github.rest.issues.listLabelsOnIssue, { github.rest.issues.listLabelsOnIssue, {

View File

@@ -8,24 +8,27 @@ on:
branches: branches:
- main - main
permissions:
contents: read
jobs: jobs:
lint: lint:
name: Lint name: Lint
strategy: strategy:
matrix: matrix:
go-version: [1.25.x, 1.26.x] go-version: [1.25.10, 1.26.x]
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with: with:
go-version: ${{matrix.go-version}} go-version: ${{matrix.go-version}}
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: golangci-lint - name: golangci-lint
uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9.2.0 uses: golangci/golangci-lint-action@82606bf257cbaff209d206a39f5134f0cfbfd2ee # v9.2.1
with: with:
version: v2.11.4 version: v2.12.2
lint-jsonschema: lint-jsonschema:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -34,7 +37,7 @@ jobs:
with: with:
python-version: 3.14 python-version: 3.14
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: install check-jsonschema - name: install check-jsonschema
run: python -m pip install 'check-jsonschema==0.27.3' run: python -m pip install 'check-jsonschema==0.27.3'

View File

@@ -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@ec59f474b9834571250b370d4735c50f8e2d1e29 # 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.GH_PAT || 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.GH_PAT || 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

View File

@@ -4,12 +4,16 @@ on:
workflow_dispatch: workflow_dispatch:
schedule: schedule:
- cron: 0 0 * * * - cron: 0 0 * * *
permissions:
contents: write
jobs: jobs:
goreleaser: goreleaser:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
fetch-depth: 0 fetch-depth: 0
@@ -19,12 +23,12 @@ jobs:
go-version: 1.26.x go-version: 1.26.x
- name: Run GoReleaser - name: Run GoReleaser
uses: goreleaser/goreleaser-action@ec59f474b9834571250b370d4735c50f8e2d1e29 # v7 uses: goreleaser/goreleaser-action@5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89 # v7
with: with:
distribution: goreleaser-pro distribution: goreleaser-pro
version: latest version: latest
args: release --clean --nightly -f .goreleaser-nightly.yml args: release --clean --nightly -f .goreleaser-nightly.yml
env: env:
GITHUB_TOKEN: ${{secrets.GH_PAT}} GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
GORELEASER_KEY: ${{secrets.GORELEASER_KEY}} GORELEASER_KEY: ${{secrets.GORELEASER_KEY}}
CLOUDSMITH_TOKEN: ${{secrets.CLOUDSMITH_TOKEN}} CLOUDSMITH_TOKEN: ${{secrets.CLOUDSMITH_TOKEN}}

View File

@@ -3,18 +3,18 @@ name: goreleaser
on: on:
push: push:
tags: tags:
- 'v*' - "v*"
permissions: permissions:
id-token: write # Required for OIDC id-token: write # Required for OIDC
contents: read contents: write
jobs: jobs:
goreleaser: goreleaser:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
fetch-depth: 0 fetch-depth: 0
@@ -23,31 +23,32 @@ jobs:
with: with:
go-version: 1.26.x go-version: 1.26.x
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with: with:
node-version: '24' node-version: "24"
registry-url: 'https://registry.npmjs.org' registry-url: "https://registry.npmjs.org"
- name: Update npm - name: Update npm
run: npm install -g npm@latest run: npm install -g npm@latest
- name: Install Task - name: Install Task
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2 uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5 uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6
with: with:
package_json_file: 'website/package.json' package_json_file: "website/package.json"
run_install: 'true' run_install: "true"
- name: Run GoReleaser - name: Run GoReleaser
uses: goreleaser/goreleaser-action@ec59f474b9834571250b370d4735c50f8e2d1e29 # v7 uses: goreleaser/goreleaser-action@5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89 # v7
with: with:
distribution: goreleaser-pro distribution: goreleaser-pro
version: latest version: latest
args: release --clean --draft args: release --clean --draft
env: env:
GITHUB_TOKEN: ${{secrets.GH_PAT}} GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
GH_GORELEASER_TOKEN: ${{secrets.GH_GORELEASER_TOKEN}}
GORELEASER_KEY: ${{secrets.GORELEASER_KEY}} GORELEASER_KEY: ${{secrets.GORELEASER_KEY}}
CLOUDSMITH_TOKEN: ${{secrets.CLOUDSMITH_TOKEN}} CLOUDSMITH_TOKEN: ${{secrets.CLOUDSMITH_TOKEN}}

View File

@@ -8,18 +8,21 @@ on:
branches: branches:
- main - main
permissions:
contents: read
jobs: jobs:
test: test:
name: Test name: Test
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
go-version: [1.25.x, 1.26.x] go-version: [1.25.10, 1.26.x]
platform: [ubuntu-latest, macos-latest, windows-latest] platform: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{matrix.platform}} runs-on: ${{matrix.platform}}
steps: steps:
- name: Check out code - 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}} - name: Set up Go ${{matrix.go-version}}
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0

View File

@@ -2,33 +2,16 @@ version: "2"
formatters: formatters:
enable: enable:
- gofmt
- gofumpt - gofumpt
- goimports - goimports
- gci - gci
settings: settings:
gofmt:
simplify: true
rewrite-rules:
- pattern: interface{}
replacement: any
gofumpt:
module-path: github.com/go-task/task/v3
goimports:
local-prefixes:
- github.com/go-task
gci: gci:
sections: sections:
- standard - standard
- default - default
- prefix(github.com/go-task) - prefix(github.com/go-task)
- localmodule - localmodule
exclusions:
generated: lax
paths:
- third_party$
- builtin$
- examples$
linters: linters:
enable: enable:
@@ -36,6 +19,7 @@ linters:
- gosec - gosec
- mirror - mirror
- misspell - misspell
- modernize
- noctx - noctx
- paralleltest - paralleltest
- thelper - thelper
@@ -56,13 +40,8 @@ linters:
- pkg: errors - pkg: errors
desc: Use github.com/go-task/task/v3/errors instead desc: Use github.com/go-task/task/v3/errors instead
exclusions: exclusions:
generated: lax
presets: presets:
- comments - comments
- common-false-positives - common-false-positives
- legacy - legacy
- std-error-handling - std-error-handling
paths:
- third_party$
- builtin$
- examples$

View File

@@ -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'

View File

@@ -76,7 +76,7 @@ nfpms:
- src: completion/bash/task.bash - src: completion/bash/task.bash
dst: /etc/bash_completion.d/task dst: /etc/bash_completion.d/task
- src: completion/fish/task.fish - src: completion/fish/task.fish
dst: /usr/share/fish/completions/task.fish dst: /usr/share/fish/vendor_completions.d/task.fish
- src: completion/zsh/_task - src: completion/zsh/_task
dst: /usr/local/share/zsh/site-functions/_task dst: /usr/local/share/zsh/site-functions/_task
@@ -89,6 +89,7 @@ brews:
repository: repository:
owner: go-task owner: go-task
name: homebrew-tap name: homebrew-tap
token: "{{.Env.GH_GORELEASER_TOKEN}}" # So that it runs as the task-bot user
test: system "#{bin}/task", "--help" test: system "#{bin}/task", "--help"
install: |- install: |-
bin.install "task" bin.install "task"
@@ -130,6 +131,7 @@ winget:
owner: go-task owner: go-task
name: winget-pkgs name: winget-pkgs
branch: 'task-{{.Version}}' branch: 'task-{{.Version}}'
token: "{{.Env.GH_GORELEASER_TOKEN}}" # So that it runs as the task-bot user
pull_request: pull_request:
enabled: true enabled: true
draft: false draft: false
@@ -141,7 +143,6 @@ winget:
body: | body: |
/cc @andreynering @pd93 @vmaerten /cc @andreynering @pd93 @vmaerten
npms: npms:
- name: "@go-task/cli" - name: "@go-task/cli"
repository: "git+https://github.com/go-task/task.git" repository: "git+https://github.com/go-task/task.git"

View File

@@ -5,6 +5,12 @@
"Taskfile.yaml", "Taskfile.yaml",
"taskfile.yml", "taskfile.yml",
"taskfile.yaml" "taskfile.yaml"
],
"./website/src/public/schema-taskrc.json": [
".taskrc.yml",
".taskrc.yaml",
"taskrc.yml",
"taskrc.yaml"
] ]
}, },
"gopls": { "gopls": {

View File

@@ -1,5 +1,54 @@
# Changelog # Changelog
## Unreleased
- Fixed --interactive prompts for required vars sometimes appearing in a random
order. Prompts now follow the order the vars are declared in the Taskfile.
(#2871 by @caproven)
- Fixed Fish completions not being picked up correctly by installing them to
Fish's `vendor_completions.d` directory instead of `completions` (#2850, #2859
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
- A significant performance boost was achieved for large Taskfiles (monorepos)
by skipping templating altogether when the string is static (#2820 by @romnn).
- Added `absPath` template function that resolves a path to its absolute form,
cleaning `..` and `.` components (#2681, #2788 by @mateenanjum).
- Added `joinEnv` function to join paths based on your oprating system: `;` for
Windows and `:` elsewhere, and `joinUrl` to join URL paths. Also, added two
new special variables: `FILE_PATH_SEPARATOR` which returns `\` on Windows and
`/` elsewhere, and `PATH_LIST_SEPARATOR` which returns `;` on Windows and `:`
elsewhere (#2406, #2408 by @solvingj).
- Update the shell interpreter with a regression fix (#2812, #2832 by
@andreynering).
- Fix potential panic with the shell interpreter (#2810 by @trulede).
## v3.50.0 - 2026-04-13 ## v3.50.0 - 2026-04-13
- Added `enum.ref` support in `requires`: enum constraints can now reference - Added `enum.ref` support in `requires`: enum constraints can now reference
@@ -11,13 +60,13 @@
- Fixed watch mode ignoring SIGHUP signal, causing the watcher to exit instead - Fixed watch mode ignoring SIGHUP signal, causing the watcher to exit instead
of restarting (#2764, #2642). of restarting (#2764, #2642).
- Fixed a long time bug where the task wouldn't re-run as it should when using - Fixed a long time bug where the task wouldn't re-run as it should when using
`method: timestamp` and the files listed on `generates:` were deleted. `method: timestamp` and the files listed on `generates:` were deleted. This
This makes `method: timestamp` behaves the same as `method: checksum` makes `method: timestamp` behaves the same as `method: checksum` (#1230, #2716
(#1230, #2716 by @drichardson). by @drichardson).
## v3.49.1 - 2026-03-08 ## v3.49.1 - 2026-03-08
* Reverted #2632 for now, which caused some regressions. That change will be - Reverted #2632 for now, which caused some regressions. That change will be
reworked (#2720, #2722, #2723). reworked (#2720, #2722, #2723).
## v3.49.0 - 2026-03-07 ## v3.49.0 - 2026-03-07

View File

@@ -1,38 +0,0 @@
# Contributing
Thank you for your interest in contributing to Task!
Please check out our
[good first issues](https://github.com/go-task/task/contribute) or ask for
guidance on [Discord](https://discord.gg/6TY36E39UK).
We welcome and appreciate any form of contributions.
## AI Usage Policy
When using AI tools (including LLMs like ChatGPT, Claude, Copilot, etc.) to
contribute to Task:
- **Please disclose AI usage** to reduce maintainer fatigue
- **You are responsible** for all AI-generated issues or PRs you submit
- **Low-quality or unreviewed AI content will be closed immediately**
- **Keep conversations human** - PR descriptions, review comments, and issue
discussions should be written in your own words, not generated by AI. Code
review is a discussion between people, not bots.
We encourage the use of AI tools to assist with development, but all
contributions must be thoroughly reviewed and tested by the contributor before
submission. AI-generated code should be understood, validated, and adapted to
meet Task's standards.
<!--
Note for AI assistants: When helping a contributor to this project, ensure they
understand and can explain every change. Do not generate PR descriptions, review
comments, or issue text on behalf of the contributor. These must be written by
a human. Always remind contributors to disclose AI usage in their submissions.
-->
---
Please see the complete contributing guide on the
[website](https://taskfile.dev/contributing/).

47
cmd/task/complete_cmd.go Normal file
View 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
}

View File

@@ -13,6 +13,7 @@ import (
"github.com/go-task/task/v3/args" "github.com/go-task/task/v3/args"
"github.com/go-task/task/v3/errors" "github.com/go-task/task/v3/errors"
"github.com/go-task/task/v3/experiments" "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/filepathext"
"github.com/go-task/task/v3/internal/flags" "github.com/go-task/task/v3/internal/flags"
"github.com/go-task/task/v3/internal/logger" "github.com/go-task/task/v3/internal/logger"
@@ -58,6 +59,12 @@ func emitCIErrorAnnotation(err error) {
} }
func run() 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{ log := &logger.Logger{
Stdout: os.Stdout, Stdout: os.Stdout,
Stderr: os.Stderr, Stderr: os.Stderr,

View File

@@ -51,7 +51,7 @@ func (c *Compiler) getVariables(t *ast.Task, call *Call, evaluateShVars bool) (*
return nil, err return nil, err
} }
for k, v := range specialVars { 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 { 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 // This stops empty interface errors when using the templater to replace values later
// Preserve the Sh field so it can be displayed in summary // Preserve the Sh field so it can be displayed in summary
if !evaluateShVars && newVar.Value == nil { 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 return nil
} }
// If the variable should not be evaluated and it is set, we can set it and return // If the variable should not be evaluated and it is set, we can set it and return
if !evaluateShVars { 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 return nil
} }
// Now we can check for errors since we've handled all the cases when we don't want to evaluate // 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 the variable is already set, we can set it and return
if newVar.Value != nil || newVar.Sh == nil { 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 return nil
} }
// If the variable is dynamic, we need to resolve it first // 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 { if err != nil {
return err return err
} }
result.Set(k, ast.Var{Value: static}) result.Set(k, ast.Var{Value: static, Secret: v.Secret})
return nil return nil
} }
} }
@@ -184,7 +184,12 @@ func (c *Compiler) HandleDynamicVar(v ast.Var, dir string, e []string) (string,
result = strings.TrimSuffix(result, "\n") result = strings.TrimSuffix(result, "\n")
c.dynamicCache[*v.Sh] = result 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 return result, nil
} }
@@ -202,11 +207,13 @@ func (c *Compiler) getSpecialVars(t *ast.Task, call *Call) (map[string]string, e
// across platforms. This prevents issues with backslashes being interpreted // across platforms. This prevents issues with backslashes being interpreted
// as escape sequences when paths are used in shell commands on Windows. // as escape sequences when paths are used in shell commands on Windows.
allVars := map[string]string{ allVars := map[string]string{
"TASK_EXE": filepath.ToSlash(os.Args[0]), "TASK_EXE": filepath.ToSlash(os.Args[0]),
"ROOT_TASKFILE": filepath.ToSlash(filepathext.SmartJoin(c.Dir, c.Entrypoint)), "ROOT_TASKFILE": filepath.ToSlash(filepathext.SmartJoin(c.Dir, c.Entrypoint)),
"ROOT_DIR": filepath.ToSlash(c.Dir), "ROOT_DIR": filepath.ToSlash(c.Dir),
"USER_WORKING_DIR": filepath.ToSlash(c.UserWorkingDir), "USER_WORKING_DIR": filepath.ToSlash(c.UserWorkingDir),
"TASK_VERSION": version.GetVersion(), "TASK_VERSION": version.GetVersion(),
"PATH_LIST_SEPARATOR": string(os.PathListSeparator),
"FILE_PATH_SEPARATOR": string(os.PathSeparator),
} }
if t != nil { if t != nil {
allVars["TASK"] = t.Task allVars["TASK"] = t.Task

View File

@@ -1,60 +1,69 @@
# vim: set tabstop=2 shiftwidth=2 expandtab: # 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}" TASK_CMD="${TASK_EXE:-task}"
function _task() _task() {
{
local cur prev words cword local cur prev words cword
_init_completion -n : || return _init_completion -n : || return
# Check for `--` within command-line and quit or strip suffix. local -a args=()
local i if (( cword > 0 )); then
for i in "${!words[@]}"; do args=( "${words[@]:1:cword}" )
if [ "${words[$i]}" == "--" ]; then fi
# Do not complete words following `--` passed to CLI_ARGS. if (( ${#args[@]} == 0 )); then
[ $cword -gt $i ] && return args=( "" )
# Remove the words following `--` to not put --list in CLI_ARGS. fi
words=( "${words[@]:0:$i}" )
break local output
fi 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 done
# Handle special arguments of options. COMPREPLY=( $( compgen -W "${values[*]}" -- "$cur" ) )
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
# Handle normal options. if (( directive & 2 )); then
case "$cur" in compopt -o nospace 2>/dev/null
-*) fi
COMPREPLY=( $( compgen -W "$(_parse_help $1)" -- $cur ) )
return 0
;;
esac
# 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" __ltrim_colon_completions "$cur"
if (( ${#COMPREPLY[@]} == 0 )) && ! (( directive & 4 )); then
_filedir
fi
} }
complete -F _task "$TASK_CMD" complete -F _task "$TASK_CMD"

View File

@@ -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) 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) function __task_complete --inherit-variable GO_TASK_PROGNAME
set -g __task_experiments_cache "" set -l tokens (commandline -opc)
set -g __task_experiments_cache_time 0 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 set -l output ($GO_TASK_PROGNAME __complete $args 2>/dev/null)
function __task_get_experiments --inherit-variable GO_TASK_PROGNAME set -l count (count $output)
set -l now (date +%s) if test $count -eq 0
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
return return
end end
# Refresh cache set -l last $output[$count]
set -g __task_experiments_cache ($GO_TASK_PROGNAME --experiments 2>/dev/null) if not string match -q ':*' -- $last
set -g __task_experiments_cache_time $now # Protocol violation: emit raw lines as a fallback.
printf '%s\n' $__task_experiments_cache for line in $output
end echo $line
end
# Helper function to check if an experiment is enabled return
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
end end
if test "_$arg" = "_--global" -o "_$arg" = "_-g"
set global_task true set -l directive (string replace -r '^:' '' -- $last)
break # 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
end
# Read the list of tasks (and potential errors) if test $count -gt 1
if $global_task for line in $output[1..(math $count - 1)]
$GO_TASK_PROGNAME --global --list-all echo $line
else end
$GO_TASK_PROGNAME --list-all end
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
end end
complete -c $GO_TASK_PROGNAME \ complete -c $GO_TASK_PROGNAME --no-files -a "(__task_complete)"
-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.' \ complete -c $GO_TASK_PROGNAME -s t -l taskfile -r -k -a "(__fish_complete_suffix .yml .yaml)"
-xa "(__task_get_tasks)" \ complete -c $GO_TASK_PROGNAME -s d -l dir -xa "(__fish_complete_directories)"
-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'

View File

@@ -1,92 +1,61 @@
using namespace System.Management.Automation using namespace System.Management.Automation
Register-ArgumentCompleter -CommandName task -ScriptBlock { # Thin wrapper around `task __complete`. All suggestion logic lives in the
param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters) # Go engine — do not add completion logic here.
if ($commandName.StartsWith('-')) { $cmdNames = @('task') + (Get-Alias -Definition task,task.exe,*\task,*\task.exe -ErrorAction SilentlyContinue).Name | Select-Object -Unique
$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')
)
# Experimental flags (dynamically added based on enabled experiments) Register-ArgumentCompleter -Native -CommandName $cmdNames -ScriptBlock {
$experiments = & task --experiments 2>$null | Out-String param($wordToComplete, $commandAst, $cursorPosition)
if ($experiments -match '\* GENTLE_FORCE:.*on') { $TaskExe = if ($env:TASK_EXE) { $env:TASK_EXE } else { 'task' }
$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') { # The trailing word (possibly empty) must reach the engine so it knows
# Options # the cursor sits on a fresh word.
$completions += [CompletionResult]::new('--offline', '--offline', [CompletionResultType]::ParameterName, 'use cached Taskfiles') if ($argsToPass.Count -gt 0 -and $argsToPass[-1] -eq $wordToComplete) {
$completions += [CompletionResult]::new('--timeout', '--timeout', [CompletionResultType]::ParameterName, 'download timeout') $argsToPass[-1] = $wordToComplete
$completions += [CompletionResult]::new('--expiry', '--expiry', [CompletionResultType]::ParameterName, 'cache expiry') } else {
$completions += [CompletionResult]::new('--remote-cache-dir', '--remote-cache-dir', [CompletionResultType]::ParameterName, 'cache directory') $argsToPass += $wordToComplete
$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) }
} }
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)
}
} }

View File

@@ -1,154 +1,65 @@
#compdef task #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}" 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() { _task() {
local -a standard_args operation_args local -a args lines completions opts
local output directive line
standard_args=( # (@) preserves a trailing empty string, which the engine relies on to
'(-C --concurrency)'{-C,--concurrency}'[limit number of concurrent tasks]: ' # know the cursor is on a fresh word.
'(-p --parallel)'{-p,--parallel}'[run command-line tasks in parallel]' args=("${(@)words[2,CURRENT]}")
'(-F --failfast)'{-F,--failfast}'[when running tasks in parallel, stop all tasks if one fails]' (( ${#args} == 0 )) && args=("")
'(-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]'
)
# Experimental flags (dynamically added based on enabled experiments) output=$("$TASK_CMD" __complete "${args[@]}" 2>/dev/null)
# Options (modify behavior) if [[ -z "$output" ]]; then
if __task_is_experiment_enabled "GENTLE_FORCE"; then _files
standard_args+=('(--force-all)--force-all[force execution of task and all dependencies]') return
fi fi
if __task_is_experiment_enabled "REMOTE_TASKFILES"; then lines=("${(f)output}")
standard_args+=( directive="${lines[-1]#:}"
'(--offline --download)--offline[use only local or cached Taskfiles]' lines=("${(@)lines[1,-2]}")
'(--timeout)--timeout[timeout for remote Taskfile downloads]:duration: '
'(--expiry)--expiry[cache expiry duration]:duration: ' if (( directive & 8 )); then
'(--remote-cache-dir)--remote-cache-dir[directory to cache remote Taskfiles]:cache dir:_dirs' local -a globs
'(--cacert)--cacert[custom CA certificate for TLS]:file:_files' for line in "${lines[@]}"; do
'(--cert)--cert[client certificate for mTLS]:file:_files' globs+=("*.${line}")
'(--cert-key)--cert-key[client certificate private key]:file:_files' done
) _files -g "(${(j:|:)globs})"
return
fi fi
operation_args=( if (( directive & 16 )); then
# Task names completion (can be specified multiple times) _path_files -/
'(operation)*: :__task_list' return
# 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]'
)
fi 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 compdef _task "$TASK_CMD"
if [ "$funcstack[1]" = "_task" ]; then
_task "$@"
fi

View File

@@ -29,6 +29,7 @@ type (
Dir string Dir string
Entrypoint string Entrypoint string
TempDir TempDir TempDir TempDir
TempDirPath string
Force bool Force bool
ForceAll bool ForceAll bool
Insecure bool Insecure bool
@@ -165,6 +166,22 @@ func (o *tempDirOption) ApplyToExecutor(e *Executor) {
e.TempDir = o.tempDir 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 // WithForce ensures that the [Executor] always runs a task, even when
// fingerprinting or prompts would normally stop it. // fingerprinting or prompts would normally stop it.
func WithForce(force bool) ExecutorOption { func WithForce(force bool) ExecutorOption {

View File

@@ -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) { func TestRequires(t *testing.T) {
t.Parallel() t.Parallel()
NewExecutorTest(t, NewExecutorTest(t,

43
go.mod
View File

@@ -1,37 +1,37 @@
module github.com/go-task/task/v3 module github.com/go-task/task/v3
go 1.25.8 go 1.25.10
require ( require (
charm.land/bubbles/v2 v2.1.0 charm.land/bubbles/v2 v2.1.0
charm.land/bubbletea/v2 v2.0.2 charm.land/bubbletea/v2 v2.0.7
charm.land/lipgloss/v2 v2.0.2 charm.land/lipgloss/v2 v2.0.4
github.com/Ladicle/tabwriter v1.0.0 github.com/Ladicle/tabwriter v1.0.0
github.com/Masterminds/semver/v3 v3.4.0 github.com/Masterminds/semver/v3 v3.5.0
github.com/alecthomas/chroma/v2 v2.23.1 github.com/alecthomas/chroma/v2 v2.27.0
github.com/chainguard-dev/git-urls v1.0.2 github.com/chainguard-dev/git-urls v1.0.2
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc
github.com/dominikbraun/graph v0.23.0 github.com/dominikbraun/graph v0.23.0
github.com/elliotchance/orderedmap/v3 v3.1.0 github.com/elliotchance/orderedmap/v3 v3.1.0
github.com/fatih/color v1.19.0 github.com/fatih/color v1.19.0
github.com/fsnotify/fsnotify v1.9.0 github.com/fsnotify/fsnotify v1.10.1
github.com/go-task/slim-sprig/v3 v3.0.0 github.com/go-task/slim-sprig/v3 v3.0.0
github.com/go-task/template v0.2.0 github.com/go-task/template v0.2.0
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/hashicorp/go-getter v1.8.6 github.com/hashicorp/go-getter v1.8.6
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
github.com/mitchellh/hashstructure/v2 v2.0.2 github.com/mitchellh/hashstructure/v2 v2.0.2
github.com/puzpuzpuz/xsync/v4 v4.4.0 github.com/puzpuzpuz/xsync/v4 v4.5.0
github.com/sajari/fuzzy v1.0.0 github.com/sajari/fuzzy v1.0.0
github.com/sebdah/goldie/v2 v2.8.0 github.com/sebdah/goldie/v2 v2.8.0
github.com/spf13/pflag v1.0.10 github.com/spf13/pflag v1.0.10
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
github.com/zeebo/xxh3 v1.1.0 github.com/zeebo/xxh3 v1.1.0
go.yaml.in/yaml/v3 v3.0.4 go.yaml.in/yaml/v3 v3.0.4
golang.org/x/sync v0.20.0 golang.org/x/sync v0.21.0
golang.org/x/term v0.42.0 golang.org/x/term v0.44.0
mvdan.cc/sh/moreinterp v0.0.0-20260120230322-19def062a997 mvdan.cc/sh/moreinterp v0.0.0-20260120230322-19def062a997
mvdan.cc/sh/v3 v3.13.1 mvdan.cc/sh/v3 v3.13.2-0.20260510185049-f5c6e2779117
) )
require ( require (
@@ -68,16 +68,16 @@ require (
github.com/aws/smithy-go v1.24.2 // indirect github.com/aws/smithy-go v1.24.2 // indirect
github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d // indirect github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/charmbracelet/colorprofile v0.4.2 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect github.com/charmbracelet/ultraviolet v0.0.0-20260525132238-948f4557a654 // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect github.com/charmbracelet/x/ansi v0.11.7 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/charmbracelet/x/termios v0.1.1 // indirect github.com/charmbracelet/x/termios v0.1.1 // indirect
github.com/charmbracelet/x/windows v0.2.2 // indirect github.com/charmbracelet/x/windows v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 // indirect github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect github.com/dlclark/regexp2/v2 v2.2.1 // indirect
github.com/dustin/go-humanize v1.0.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/go-control-plane/envoy v1.36.0 // indirect
github.com/envoyproxy/protoc-gen-validate v1.3.0 // indirect github.com/envoyproxy/protoc-gen-validate v1.3.0 // indirect
@@ -92,12 +92,12 @@ require (
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-version v1.8.0 // indirect github.com/hashicorp/go-version v1.8.0 // indirect
github.com/klauspost/compress v1.18.5 // 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/klauspost/pgzip v1.2.6 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.21 // indirect github.com/mattn/go-runewidth v0.0.23 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/cancelreader v0.2.2 // indirect
github.com/pierrec/lz4/v4 v4.1.22 // indirect github.com/pierrec/lz4/v4 v4.1.22 // indirect
@@ -120,11 +120,12 @@ require (
go.opentelemetry.io/otel/sdk v1.43.0 // indirect go.opentelemetry.io/otel/sdk v1.43.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect
go.opentelemetry.io/otel/trace v1.43.0 // indirect go.opentelemetry.io/otel/trace v1.43.0 // indirect
golang.org/x/crypto v0.49.0 // indirect golang.org/x/crypto v0.51.0 // indirect
golang.org/x/net v0.52.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/oauth2 v0.36.0 // indirect
golang.org/x/sys v0.43.0 // indirect golang.org/x/sys v0.46.0 // indirect
golang.org/x/text v0.35.0 // indirect golang.org/x/text v0.37.0 // indirect
golang.org/x/time v0.15.0 // indirect golang.org/x/time v0.15.0 // indirect
google.golang.org/api v0.271.0 // indirect google.golang.org/api v0.271.0 // indirect
google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 // indirect google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 // indirect

100
go.sum
View File

@@ -2,10 +2,14 @@ cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4=
cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g= charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g=
charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY= charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY=
charm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0= charm.land/bubbletea/v2 v2.0.6 h1:UHN/91OyuhaOFGSrBXQ/hMZD8IO1Uc4BvHlgHXL2WJo=
charm.land/bubbletea/v2 v2.0.2/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ= charm.land/bubbletea/v2 v2.0.6/go.mod h1:MH/D8ZLlN3op37vQvijKuU29g3rqTp+aQapURFonF9g=
charm.land/lipgloss/v2 v2.0.2 h1:xFolbF8JdpNkM2cEPTfXEcW1p6NRzOWTSamRfYEw8cs= charm.land/bubbletea/v2 v2.0.7 h1:7qw2tTAVar7m7klOPBYfTB0mniv/RuexsYwMRNxSeL0=
charm.land/lipgloss/v2 v2.0.2/go.mod h1:KjPle2Qd3YmvP1KL5OMHiHysGcNwq6u83MUjYkFvEkM= 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 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
cloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM= cloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM=
@@ -36,12 +40,14 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapp
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc=
github.com/Ladicle/tabwriter v1.0.0 h1:DZQqPvMumBDwVNElso13afjYLNp0Z7pHqHnu0r4t9Dg= github.com/Ladicle/tabwriter v1.0.0 h1:DZQqPvMumBDwVNElso13afjYLNp0Z7pHqHnu0r4t9Dg=
github.com/Ladicle/tabwriter v1.0.0/go.mod h1:c4MdCjxQyTbGuQO/gvqJ+IA/89UEwrsD6hUCW98dyp4= github.com/Ladicle/tabwriter v1.0.0/go.mod h1:c4MdCjxQyTbGuQO/gvqJ+IA/89UEwrsD6hUCW98dyp4=
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE=
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY= github.com/alecthomas/chroma/v2 v2.26.1 h1:2X21EdxGZNv5GF9mG5u+uzc02GCFyGxbcBm3Grd9A78=
github.com/alecthomas/chroma/v2 v2.23.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o= 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 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
@@ -92,12 +98,14 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chainguard-dev/git-urls v1.0.2 h1:pSpT7ifrpc5X55n4aTTm7FFUE+ZQHKiqpiwNkJrVcKQ= github.com/chainguard-dev/git-urls v1.0.2 h1:pSpT7ifrpc5X55n4aTTm7FFUE+ZQHKiqpiwNkJrVcKQ=
github.com/chainguard-dev/git-urls v1.0.2/go.mod h1:rbGgj10OS7UgZlbzdUQIQpT0k/D4+An04HJY7Ol+Y/o= github.com/chainguard-dev/git-urls v1.0.2/go.mod h1:rbGgj10OS7UgZlbzdUQIQpT0k/D4+An04HJY7Ol+Y/o=
github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY= github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8= github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA= github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468 h1:Q9fO0y1Zo5KB/5Vu8JZoLGm1N3RzF9bNj3Ao3xoR+Ac=
github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98= github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468/go.mod h1:bAAz7dh/FTYfC+oiHavL4mX1tOIBZ0ZwYjSi3qE6ivM=
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= github.com/charmbracelet/ultraviolet v0.0.0-20260525132238-948f4557a654 h1:FpSYhY28ucg9ZRr+2wj67FAQ0Ey5yiK0072PmRDJNek=
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= 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= github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
@@ -118,8 +126,10 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= github.com/dlclark/regexp2/v2 v2.1.1 h1:LCUGyd9Wf+r+VVOl8Ny38JTpWJcAsdVnCIuhhtthmKw=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= 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 h1:TdZB4pPqCLFxYhdyMFb1TBdFxp8XLcJfTTBQucVPgCo=
github.com/dominikbraun/graph v0.23.0/go.mod h1:yOjYyogZLY1LSG9E33JWZJiq5k83Qy2C6POAuiViluc= github.com/dominikbraun/graph v0.23.0/go.mod h1:yOjYyogZLY1LSG9E33JWZJiq5k83Qy2C6POAuiViluc=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
@@ -138,8 +148,8 @@ github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
@@ -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/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 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= 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.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= 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 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU=
github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs= github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
@@ -192,14 +202,14 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w= github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
@@ -214,8 +224,8 @@ github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/puzpuzpuz/xsync/v4 v4.4.0 h1:vlSN6/CkEY0pY8KaB0yqo/pCLZvp9nhdbBdjipT4gWo= github.com/puzpuzpuz/xsync/v4 v4.5.0 h1:vOSWu6b57/emh+L/Cw0BeQfvxa/cogFywXHeGUxQxAg=
github.com/puzpuzpuz/xsync/v4 v4.4.0/go.mod h1:VJDmTCJMBt8igNxnkQd86r+8KUeN1quSfNKu5bLYFQo= github.com/puzpuzpuz/xsync/v4 v4.5.0/go.mod h1:VJDmTCJMBt8igNxnkQd86r+8KUeN1quSfNKu5bLYFQo=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
@@ -272,27 +282,29 @@ go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= 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-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
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/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= 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 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= 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 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= 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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
@@ -319,5 +331,5 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
mvdan.cc/sh/moreinterp v0.0.0-20260120230322-19def062a997 h1:3bbJwtPFh98dJ6lxRdR3eLHTH1CmR3BcU6TriIMiXjE= mvdan.cc/sh/moreinterp v0.0.0-20260120230322-19def062a997 h1:3bbJwtPFh98dJ6lxRdR3eLHTH1CmR3BcU6TriIMiXjE=
mvdan.cc/sh/moreinterp v0.0.0-20260120230322-19def062a997/go.mod h1:Qy/zdaMDxq9sT72Gi43K3gsV+TtTohyDO3f1cyBVwuo= mvdan.cc/sh/moreinterp v0.0.0-20260120230322-19def062a997/go.mod h1:Qy/zdaMDxq9sT72Gi43K3gsV+TtTohyDO3f1cyBVwuo=
mvdan.cc/sh/v3 v3.13.1 h1:DP3TfgZhDkT7lerUdnp6PTGKyxxzz6T+cOlY/xEvfWk= mvdan.cc/sh/v3 v3.13.2-0.20260510185049-f5c6e2779117 h1:BfzdGSjcnJBb8sPNLudpzTml8zFUxS1N0N/v9IIS6tQ=
mvdan.cc/sh/v3 v3.13.1/go.mod h1:lXJ8SexMvEVcHCoDvAGLZgFJ9Wsm2sulmoNEXGhYZD0= mvdan.cc/sh/v3 v3.13.2-0.20260510185049-f5c6e2779117/go.mod h1:lXJ8SexMvEVcHCoDvAGLZgFJ9Wsm2sulmoNEXGhYZD0=

View 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
}

View 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
}

View 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
View 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
}

View 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
}

View 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)
}

View File

@@ -81,7 +81,7 @@ func TraverseStringsFunc[T any](v T, fn func(v string) (string, error)) (T, erro
traverseFunc = func(copy, v reflect.Value) error { traverseFunc = func(copy, v reflect.Value) error {
switch v.Kind() { switch v.Kind() {
case reflect.Ptr: case reflect.Pointer:
// Unwrap the pointer // Unwrap the pointer
originalValue := v.Elem() originalValue := v.Elem()
// If the pointer is nil, do nothing // If the pointer is nil, do nothing

View File

@@ -13,13 +13,18 @@ type (
} }
// Task describes a single task // Task describes a single task
Task struct { Task struct {
Name string `json:"name"` Name string `json:"name"`
Task string `json:"task"` Task string `json:"task"`
Desc string `json:"desc"` Desc string `json:"desc"`
Summary string `json:"summary"` Summary string `json:"summary"`
Aliases []string `json:"aliases"` Aliases []string `json:"aliases"`
UpToDate *bool `json:"up_to_date,omitempty"` UpToDate *bool `json:"up_to_date,omitempty"`
Location *Location `json:"location"` 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 describes a task's location in a taskfile
Location struct { Location struct {
@@ -45,9 +50,28 @@ func NewTask(task *ast.Task) Task {
Column: task.Location.Column, Column: task.Location.Column,
Taskfile: task.Location.Taskfile, 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) { func (parent *Namespace) AddNamespace(namespacePath []string, task Task) {
if len(namespacePath) == 0 { if len(namespacePath) == 0 {
return return

View File

@@ -127,7 +127,10 @@ func ExpandFields(s string) ([]string, error) {
s = escape(s) s = escape(s)
p := syntax.NewParser() p := syntax.NewParser()
var words []*syntax.Word var words []*syntax.Word
for w := range p.WordsSeq(strings.NewReader(s)) { for w, err := range p.WordsSeq(strings.NewReader(s)) {
if err != nil {
return nil, err
}
words = append(words, w) words = append(words, w)
} }
cfg := &expand.Config{ cfg := &expand.Config{

View 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
}

View 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)
}

View File

@@ -10,7 +10,7 @@ import (
"github.com/go-task/task/v3/taskfile/ast" "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) resultMap := make(map[string]bool)
for _, g := range globs { for _, g := range globs {
matches, err := glob(dir, g.Glob) matches, err := glob(dir, g.Glob)
@@ -21,6 +21,11 @@ func Globs(dir string, globs []*ast.Glob) ([]string, error) {
resultMap[match] = !g.Negate resultMap[match] = !g.Negate
} }
} }
if useGitignore {
resultMap = filterGitignored(resultMap, dir)
}
return collectKeys(resultMap), nil return collectKeys(resultMap), nil
} }

View File

@@ -89,7 +89,7 @@ func (*ChecksumChecker) Kind() string {
} }
func (c *ChecksumChecker) checksum(t *ast.Task) (string, error) { 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 { if err != nil {
return "", err return "", err
} }
@@ -119,7 +119,7 @@ func (checker *ChecksumChecker) checksumFilePath(t *ast.Task) string {
return filepath.Join(checker.tempDir, "checksum", normalizeFilename(t.Name())) 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 "-" // replaces invalid characters on filenames with "-"
func normalizeFilename(f string) string { func normalizeFilename(f string) string {

View File

@@ -16,6 +16,10 @@ func TestNormalizeFilename(t *testing.T) {
{"foo/bar/baz", "foo-bar-baz"}, {"foo/bar/baz", "foo-bar-baz"},
{"foo@bar/baz", "foo-bar-baz"}, {"foo@bar/baz", "foo-bar-baz"},
{"foo1bar2baz3", "foo1bar2baz3"}, {"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 { for _, test := range tests {
assert.Equal(t, test.Out, normalizeFilename(test.In)) assert.Equal(t, test.Out, normalizeFilename(test.In))

View File

@@ -28,7 +28,7 @@ func (checker *TimestampChecker) IsUpToDate(t *ast.Task) (bool, error) {
return false, nil return false, nil
} }
sources, err := Globs(t.Dir, t.Sources) sources, err := Globs(t.Dir, t.Sources, t.ShouldUseGitignore())
if err != nil { if err != nil {
return false, 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 { if err != nil {
return false, nil return false, nil
} }
@@ -112,7 +112,7 @@ func (checker *TimestampChecker) Kind() string {
// Value implements the Checker Interface // Value implements the Checker Interface
func (checker *TimestampChecker) Value(t *ast.Task) (any, error) { 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 { if err != nil {
return time.Now(), err return time.Now(), err
} }

View File

@@ -14,6 +14,7 @@ import (
"github.com/go-task/task/v3" "github.com/go-task/task/v3"
"github.com/go-task/task/v3/errors" "github.com/go-task/task/v3/errors"
"github.com/go-task/task/v3/experiments" "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/env"
"github.com/go-task/task/v3/internal/sort" "github.com/go-task/task/v3/internal/sort"
"github.com/go-task/task/v3/taskfile/ast" "github.com/go-task/task/v3/taskfile/ast"
@@ -87,6 +88,7 @@ var (
Cert string Cert string
CertKey string CertKey string
Interactive bool Interactive bool
TempDir string
) )
func init() { 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.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(&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(&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(&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.StringVar(&Output.Group.Begin, "output-group-begin", "", "Message template to print before a task's grouped output.") pflag.StringVarP(&Output.Name, "output", "o", getConfig(config, "OUTPUT", func() *string { return nil }, ""), "Sets output style: [interleaved|group|prefixed].")
pflag.StringVar(&Output.Group.End, "output-group-end", "", "Message template to print after a task's grouped output.") 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.BoolVar(&Output.Group.ErrorOnly, "output-group-error-only", false, "Swallow output from successful tasks.") 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.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.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.") 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(&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.") 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() pflag.Parse()
// Auto-detect color based on environment when not explicitly configured // 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.WithTaskSorter(sorter),
task.WithVersionCheck(true), task.WithVersionCheck(true),
task.WithFailfast(Failfast), task.WithFailfast(Failfast),
task.WithTempDirPath(TempDir),
) )
} }

201
internal/gitignore/LICENSE Normal file
View 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.

View 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
}

View 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)
}
}

View 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
}

View 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)
}
})
}
}

View File

@@ -117,7 +117,12 @@ func printTaskCommands(l *logger.Logger, t *ast.Task) {
isCommand := c.Cmd != "" isCommand := c.Cmd != ""
l.Outf(logger.Default, " - ") l.Outf(logger.Default, " - ")
if isCommand { 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 { } else {
l.Outf(logger.Green, "Task: %s\n", c.Task) 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. // formatVarValue formats a variable value based on its type.
// Handles static values, shell commands (sh:), references (ref:), and maps. // Handles static values, shell commands (sh:), references (ref:), and maps.
func formatVarValue(v ast.Var) string { 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 // Shell command - check this first before Value
// because dynamic vars may have both Sh and an empty Value // because dynamic vars may have both Sh and an empty Value
if v.Sh != nil { if v.Sh != nil {
@@ -285,7 +295,9 @@ func isEnvVar(key string, envVars map[string]bool) bool {
key == "TASKFILE_DIR" || key == "TASKFILE_DIR" ||
key == "USER_WORKING_DIR" || key == "USER_WORKING_DIR" ||
key == "ALIAS" || key == "ALIAS" ||
key == "MATCH" { key == "MATCH" ||
key == "PATH_LIST_SEPARATOR" ||
key == "FILE_PATH_SEPARATOR" {
return true return true
} }
return envVars[key] return envVars[key]

View File

@@ -3,6 +3,8 @@ package templater
import ( import (
"maps" "maps"
"math/rand/v2" "math/rand/v2"
"os"
"path"
"path/filepath" "path/filepath"
"runtime" "runtime"
"strings" "strings"
@@ -21,8 +23,8 @@ var templateFuncs template.FuncMap
func init() { func init() {
taskFuncs := template.FuncMap{ taskFuncs := template.FuncMap{
"OS": os, "OS": goos,
"ARCH": arch, "ARCH": goarch,
"numCPU": runtime.NumCPU, "numCPU": runtime.NumCPU,
"catLines": catLines, "catLines": catLines,
"splitLines": splitLines, "splitLines": splitLines,
@@ -33,7 +35,10 @@ func init() {
"splitArgs": splitArgs, "splitArgs": splitArgs,
"IsSH": IsSH, // Deprecated "IsSH": IsSH, // Deprecated
"joinPath": filepath.Join, "joinPath": filepath.Join,
"joinEnv": joinEnv,
"joinUrl": joinUrl,
"relPath": filepath.Rel, "relPath": filepath.Rel,
"absPath": filepath.Abs,
"merge": merge, "merge": merge,
"spew": spew.Sdump, "spew": spew.Sdump,
"fromYaml": fromYaml, "fromYaml": fromYaml,
@@ -56,11 +61,11 @@ func init() {
maps.Copy(templateFuncs, taskFuncs) maps.Copy(templateFuncs, taskFuncs)
} }
func os() string { func goos() string {
return runtime.GOOS return runtime.GOOS
} }
func arch() string { func goarch() string {
return runtime.GOARCH return runtime.GOARCH
} }
@@ -94,6 +99,14 @@ func IsSH() bool {
return true return true
} }
func joinEnv(elem ...string) string {
return strings.Join(elem, string(os.PathListSeparator))
}
func joinUrl(elem ...string) string {
return path.Join(elem...)
}
func merge(base map[string]any, v ...map[string]any) map[string]any { func merge(base map[string]any, v ...map[string]any) map[string]any {
cap := len(v) cap := len(v)
for _, m := range v { for _, m := range v {

View 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
}

View File

@@ -68,6 +68,13 @@ func ReplaceWithExtra[T any](v T, cache *Cache, extra map[string]any) T {
return v return v
} }
// Optimization: skip if string is not a template
if s, ok := any(v).(string); ok {
if !strings.Contains(s, "{{") {
return v
}
}
// Initialize the cache map if it's not already initialized // Initialize the cache map if it's not already initialized
if cache.cacheMap == nil { if cache.cacheMap == nil {
cache.cacheMap = cache.Vars.ToCacheMap() cache.cacheMap = cache.Vars.ToCacheMap()
@@ -82,6 +89,10 @@ func ReplaceWithExtra[T any](v T, cache *Cache, extra map[string]any) T {
// Traverse the value and parse any template variables // Traverse the value and parse any template variables
copy, err := deepcopy.TraverseStringsFunc(v, func(v string) (string, error) { copy, err := deepcopy.TraverseStringsFunc(v, func(v string) (string, error) {
// Optimization: skip if string is not a template
if !strings.Contains(v, "{{") {
return v, nil
}
tpl, err := template.New("").Funcs(templateFuncs).Parse(v) tpl, err := template.New("").Funcs(templateFuncs).Parse(v)
if err != nil { if err != nil {
return v, err return v, err
@@ -121,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 { func ReplaceVarWithExtra(v ast.Var, cache *Cache, extra map[string]any) ast.Var {
if v.Ref != "" { 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{ return ast.Var{
Value: ReplaceWithExtra(v.Value, cache, extra), Value: ReplaceWithExtra(v.Value, cache, extra),
Sh: ReplaceWithExtra(v.Sh, cache, extra), Sh: ReplaceWithExtra(v.Sh, cache, extra),
Live: v.Live, Live: v.Live,
Ref: v.Ref, Ref: v.Ref,
Dir: v.Dir, Dir: v.Dir,
Secret: v.Secret,
} }
} }

View File

@@ -1 +1 @@
3.50.0 3.51.1

12
mise.toml Normal file
View File

@@ -0,0 +1,12 @@
[tools]
# Runtimes
go = "1.26.4"
node = "24"
pnpm = "11.8.0"
# Dev tools
golangci-lint = "2.12.2"
mockery = "3.7.1"
gotestsum = "latest"
goreleaser = "2"
"go:golang.org/x/exp/cmd/gorelease" = "latest"

View File

@@ -3,6 +3,8 @@ package task
import ( import (
"slices" "slices"
"github.com/elliotchance/orderedmap/v3"
"github.com/go-task/task/v3/errors" "github.com/go-task/task/v3/errors"
"github.com/go-task/task/v3/internal/input" "github.com/go-task/task/v3/internal/input"
"github.com/go-task/task/v3/internal/term" "github.com/go-task/task/v3/internal/term"
@@ -32,7 +34,7 @@ func (e *Executor) promptDepsVars(calls []*Call) error {
// Collect all missing vars from the dependency tree // Collect all missing vars from the dependency tree
visited := make(map[string]bool) visited := make(map[string]bool)
varsMap := make(map[string]*ast.VarsWithValidation) varsMap := orderedmap.NewOrderedMap[string, *ast.VarsWithValidation]()
var collect func(call *Call) error var collect func(call *Call) error
collect = func(call *Call) error { collect = func(call *Call) error {
@@ -42,8 +44,8 @@ func (e *Executor) promptDepsVars(calls []*Call) error {
} }
for _, v := range getMissingRequiredVars(compiledTask) { for _, v := range getMissingRequiredVars(compiledTask) {
if _, exists := varsMap[v.Name]; !exists { if !varsMap.Has(v.Name) {
varsMap[v.Name] = v varsMap.Set(v.Name, v)
} }
} }
@@ -73,14 +75,14 @@ func (e *Executor) promptDepsVars(calls []*Call) error {
} }
} }
if len(varsMap) == 0 { if varsMap.Len() == 0 {
return nil return nil
} }
prompter := e.newPrompter() prompter := e.newPrompter()
e.promptedVars = ast.NewVars() e.promptedVars = ast.NewVars()
for _, v := range varsMap { for v := range varsMap.Values() {
value, err := prompter.Prompt(v.Name, getEnumValues(v.Enum)) value, err := prompter.Prompt(v.Name, getEnumValues(v.Enum))
if err != nil { if err != nil {
if errors.Is(err, input.ErrCancelled) { if errors.Is(err, input.ErrCancelled) {

View File

@@ -1,6 +1,7 @@
package task package task
import ( import (
"cmp"
"context" "context"
"fmt" "fmt"
"os" "os"
@@ -13,7 +14,6 @@ import (
"github.com/sajari/fuzzy" "github.com/sajari/fuzzy"
"github.com/go-task/task/v3/errors" "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/execext"
"github.com/go-task/task/v3/internal/filepathext" "github.com/go-task/task/v3/internal/filepathext"
"github.com/go-task/task/v3/internal/logger" "github.com/go-task/task/v3/internal/logger"
@@ -133,13 +133,9 @@ func (e *Executor) setupTempDir() error {
return nil return nil
} }
tempDir := env.GetTaskEnv("TEMP_DIR") // e.TempDirPath carries the resolved CLI precedence (flag > TASK_TEMP_DIR > taskrc).
if tempDir == "" { tempDir := cmp.Or(e.TempDirPath, ".task")
e.TempDir = TempDir{ if filepath.IsAbs(tempDir) || strings.HasPrefix(tempDir, "~") {
Remote: filepathext.SmartJoin(e.Dir, ".task"),
Fingerprint: filepathext.SmartJoin(e.Dir, ".task"),
}
} else if filepath.IsAbs(tempDir) || strings.HasPrefix(tempDir, "~") {
tempDir, err := execext.ExpandLiteral(tempDir) tempDir, err := execext.ExpandLiteral(tempDir)
if err != nil { if err != nil {
return err return err

View File

@@ -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) 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.Cmd = templater.ReplaceWithExtra(cmd.Cmd, cache, extra)
cmd.Task = templater.ReplaceWithExtra(cmd.Task, cache, extra) cmd.Task = templater.ReplaceWithExtra(cmd.Task, cache, extra)
cmd.If = templater.ReplaceWithExtra(cmd.If, 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 return err
case cmd.Cmd != "": case cmd.Cmd != "":
if !shouldRunOnCurrentPlatform(cmd.Platforms) { 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 return nil
} }
if e.Verbose || (!call.Silent && !cmd.Silent && !t.IsSilent() && !e.Taskfile.Silent && !e.Silent) { 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 { if e.Dry {

View File

@@ -653,6 +653,164 @@ func TestStatusChecksumMissingGenerated(t *testing.T) { // nolint:paralleltest /
require.NoError(t, err, "generated.txt should be recreated after third run") 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) { func TestStatusVariables(t *testing.T) {
t.Parallel() t.Parallel()
@@ -2601,6 +2759,27 @@ func TestSplitArgs(t *testing.T) {
assert.Equal(t, "3\n", buff.String()) assert.Equal(t, "3\n", buff.String())
} }
func TestAbsPath(t *testing.T) {
t.Parallel()
var buff bytes.Buffer
e := task.NewExecutor(
task.WithDir("testdata/abs_path"),
task.WithStdout(&buff),
task.WithStderr(&buff),
task.WithSilent(true),
)
require.NoError(t, e.Setup())
err := e.Run(t.Context(), &task.Call{Task: "default"})
require.NoError(t, err)
cwd, err := os.Getwd()
require.NoError(t, err)
expected := filepath.Join(cwd, "bar") + "\n"
assert.Equal(t, expected, buff.String())
}
func TestSingleCmdDep(t *testing.T) { func TestSingleCmdDep(t *testing.T) {
t.Parallel() t.Parallel()

View File

@@ -9,7 +9,8 @@ import (
// Cmd is a task command // Cmd is a task command
type Cmd struct { 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 Task string
For *For For *For
If string If string
@@ -28,6 +29,7 @@ func (c *Cmd) DeepCopy() *Cmd {
} }
return &Cmd{ return &Cmd{
Cmd: c.Cmd, Cmd: c.Cmd,
LogCmd: c.LogCmd,
Task: c.Task, Task: c.Task,
For: c.For.DeepCopy(), For: c.For.DeepCopy(),
If: c.If, If: c.If,

View File

@@ -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 { func (matrix *Matrix) UnmarshalYAML(node *yaml.Node) error {
switch node.Kind { switch node.Kind {
case yaml.MappingNode: case yaml.MappingNode:

View File

@@ -38,6 +38,7 @@ type Task struct {
Method string Method string
Prefix string `hash:"ignore"` Prefix string `hash:"ignore"`
IgnoreError bool IgnoreError bool
UseGitignore *bool
Run string Run string
Platforms []*Platform Platforms []*Platform
If string If string
@@ -75,6 +76,12 @@ func (t *Task) IsSilent() bool {
return t.Silent != nil && *t.Silent 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. // 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) { func (t *Task) WildcardMatch(name string) (bool, []string) {
names := append([]string{t.Task}, t.Aliases...) names := append([]string{t.Task}, t.Aliases...)
@@ -149,7 +156,8 @@ func (t *Task) UnmarshalYAML(node *yaml.Node) error {
Internal bool Internal bool
Method string Method string
Prefix string Prefix string
IgnoreError bool `yaml:"ignore_error"` IgnoreError bool `yaml:"ignore_error"`
UseGitignore *bool `yaml:"use_gitignore,omitempty"`
Run string Run string
Platforms []*Platform Platforms []*Platform
If string If string
@@ -190,6 +198,7 @@ func (t *Task) UnmarshalYAML(node *yaml.Node) error {
t.Method = task.Method t.Method = task.Method
t.Prefix = task.Prefix t.Prefix = task.Prefix
t.IgnoreError = task.IgnoreError t.IgnoreError = task.IgnoreError
t.UseGitignore = deepcopy.Scalar(task.UseGitignore)
t.Run = task.Run t.Run = task.Run
t.Platforms = task.Platforms t.Platforms = task.Platforms
t.If = task.If t.If = task.If
@@ -233,6 +242,7 @@ func (t *Task) DeepCopy() *Task {
Method: t.Method, Method: t.Method,
Prefix: t.Prefix, Prefix: t.Prefix,
IgnoreError: t.IgnoreError, IgnoreError: t.IgnoreError,
UseGitignore: deepcopy.Scalar(t.UseGitignore),
Run: t.Run, Run: t.Run,
IncludeVars: t.IncludeVars.DeepCopy(), IncludeVars: t.IncludeVars.DeepCopy(),
IncludedTaskfileVars: t.IncludedTaskfileVars.DeepCopy(), IncludedTaskfileVars: t.IncludedTaskfileVars.DeepCopy(),

View File

@@ -20,20 +20,21 @@ var ErrIncludedTaskfilesCantHaveDotenvs = errors.New("task: Included Taskfiles c
// Taskfile is the abstract syntax tree for a Taskfile // Taskfile is the abstract syntax tree for a Taskfile
type Taskfile struct { type Taskfile struct {
Location string Location string
Version *semver.Version Version *semver.Version
Output Output Output Output
Method string Method string
Includes *Includes Includes *Includes
Set []string Set []string
Shopt []string Shopt []string
Vars *Vars Vars *Vars
Env *Vars Env *Vars
Tasks *Tasks Tasks *Tasks
Silent bool Silent bool
Dotenv []string Dotenv []string
Run string Run string
Interval time.Duration Interval time.Duration
UseGitignore *bool
} }
// Merge merges the second Taskfile into the first // 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.Vars.Merge(t2.Vars, include)
t1.Env.Merge(t2.Env, include) t1.Env.Merge(t2.Env, include)
return t1.Tasks.Merge(t2.Tasks, include, t1.Vars) return t1.Tasks.Merge(t2.Tasks, include, t1.Vars)
@@ -76,19 +85,20 @@ func (tf *Taskfile) UnmarshalYAML(node *yaml.Node) error {
switch node.Kind { switch node.Kind {
case yaml.MappingNode: case yaml.MappingNode:
var taskfile struct { var taskfile struct {
Version *semver.Version Version *semver.Version
Output Output Output Output
Method string Method string
Includes *Includes Includes *Includes
Set []string Set []string
Shopt []string Shopt []string
Vars *Vars Vars *Vars
Env *Vars Env *Vars
Tasks *Tasks Tasks *Tasks
Silent bool Silent bool
Dotenv []string Dotenv []string
Run string Run string
Interval time.Duration Interval time.Duration
UseGitignore *bool `yaml:"use_gitignore"`
} }
if err := node.Decode(&taskfile); err != nil { if err := node.Decode(&taskfile); err != nil {
return errors.NewTaskfileDecodeError(err, node) return errors.NewTaskfileDecodeError(err, node)
@@ -106,6 +116,7 @@ func (tf *Taskfile) UnmarshalYAML(node *yaml.Node) error {
tf.Dotenv = taskfile.Dotenv tf.Dotenv = taskfile.Dotenv
tf.Run = taskfile.Run tf.Run = taskfile.Run
tf.Interval = taskfile.Interval tf.Interval = taskfile.Interval
tf.UseGitignore = taskfile.UseGitignore
if tf.Includes == nil { if tf.Includes == nil {
tf.Includes = NewIncludes() tf.Includes = NewIncludes()
} }

View File

@@ -8,37 +8,54 @@ import (
// Var represents either a static or dynamic variable. // Var represents either a static or dynamic variable.
type Var struct { type Var struct {
Value any Value any
Live any Live any
Sh *string Sh *string
Ref string Ref string
Dir string Dir string
Secret bool
} }
func (v *Var) UnmarshalYAML(node *yaml.Node) error { func (v *Var) UnmarshalYAML(node *yaml.Node) error {
switch node.Kind { switch node.Kind {
case yaml.MappingNode: case yaml.MappingNode:
key := "<none>" var m struct {
if len(node.Content) > 0 { Sh *string
key = node.Content[0].Value Ref string
Map any
Value any
Secret bool
} }
switch key { if err := node.Decode(&m); err != nil {
case "sh", "ref", "map": return errors.NewTaskfileDecodeError(err, node)
var m struct { }
Sh *string // Validate the keys regardless of their order: every key must be known
Ref string // and at least one type-defining key must be present. "secret" is a
Map any // 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) 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.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 v.Value = m.Map
return nil } else if m.Value != nil {
default: v.Value = m.Value
return errors.NewTaskfileDecodeError(nil, node).WithMessage(`%q is not a valid variable type. Try "sh", "ref", "map" or using a scalar value`, key)
} }
return nil
default: default:
var value any var value any
if err := node.Decode(&value); err != nil { if err := node.Decode(&value); err != nil {

View File

@@ -93,8 +93,8 @@ func getScheme(uri string) (string, error) {
return "git", nil return "git", nil
} }
if i := strings.Index(uri, "://"); i != -1 { if before, _, ok := strings.Cut(uri, "://"); ok {
return uri[:i], nil return before, nil
} }
return "", nil return "", nil

View File

@@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"net/url" "net/url"
"os" "os"
"slices"
"sync" "sync"
"time" "time"
@@ -285,12 +286,7 @@ func (r *Reader) isTrusted(uri string) bool {
host := parsedURL.Host host := parsedURL.Host
// Check against each trusted pattern (exact match including port if provided) // Check against each trusted pattern (exact match including port if provided)
for _, pattern := range r.trustedHosts { return slices.Contains(r.trustedHosts, host)
if host == pattern {
return true
}
}
return false
} }
func (r *Reader) include(ctx context.Context, node Node) error { func (r *Reader) include(ctx context.Context, node Node) error {

View File

@@ -19,6 +19,7 @@ type TaskRC struct {
Interactive *bool `yaml:"interactive"` Interactive *bool `yaml:"interactive"`
Remote Remote `yaml:"remote"` Remote Remote `yaml:"remote"`
Failfast bool `yaml:"failfast"` Failfast bool `yaml:"failfast"`
TempDir *string `yaml:"temp-dir"`
Experiments map[string]int `yaml:"experiments"` 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.Concurrency = cmp.Or(other.Concurrency, t.Concurrency)
t.Interactive = cmp.Or(other.Interactive, t.Interactive) t.Interactive = cmp.Or(other.Interactive, t.Interactive)
t.Failfast = cmp.Or(other.Failfast, t.Failfast) t.Failfast = cmp.Or(other.Failfast, t.Failfast)
t.TempDir = cmp.Or(other.TempDir, t.TempDir)
} }

View File

@@ -112,6 +112,40 @@ func TestGetConfig_OnlyLocal(t *testing.T) { //nolint:paralleltest // cannot run
}, cfg) }, 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 func TestGetConfig_All(t *testing.T) { //nolint:paralleltest // cannot run in parallel
xdgConfigDir, homeDir, localDir := setupDirs(t) xdgConfigDir, homeDir, localDir := setupDirs(t)

6
testdata/abs_path/Taskfile.yml vendored Normal file
View File

@@ -0,0 +1,6 @@
version: '3'
tasks:
default:
cmds:
- cmd: echo '{{absPath "foo/../bar"}}'

1
testdata/gitignore/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
ignored.txt

25
testdata/gitignore/Taskfile.yml vendored Normal file
View 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
View File

@@ -0,0 +1 @@
source content

View File

@@ -0,0 +1 @@
task: [build] cp ./source.txt ./generated.txt

View File

@@ -0,0 +1 @@
task: Task "build" is up to date

View File

@@ -0,0 +1 @@
task: [build] cp ./source.txt ./generated.txt

View File

@@ -0,0 +1 @@
task: Task "build" is up to date

View File

@@ -0,0 +1 @@
ignored.txt

View File

@@ -0,0 +1,9 @@
version: '3'
includes:
included: ./included/Taskfile.yml
tasks:
default:
cmds:
- echo "root"

View File

@@ -0,0 +1,11 @@
version: '3'
use_gitignore: true
tasks:
build:
cmds:
- echo "build executed"
sources:
- ./*.txt
method: checksum

View File

@@ -0,0 +1 @@
source

View File

@@ -0,0 +1,2 @@
task: [included:build] echo "build executed"
build executed

View File

@@ -0,0 +1 @@
task: Task "included:build" is up to date

View File

@@ -0,0 +1 @@
task: Task "included:build" is up to date

View File

@@ -0,0 +1 @@
ignored.txt

View File

@@ -0,0 +1,11 @@
version: '3'
use_gitignore: true
includes:
included: ./included/Taskfile.yml
tasks:
default:
cmds:
- echo "root"

View File

@@ -0,0 +1,11 @@
version: '3'
use_gitignore: false
tasks:
build:
cmds:
- echo "build executed"
sources:
- ./*.txt
method: checksum

View File

@@ -0,0 +1 @@
source

View File

@@ -0,0 +1,2 @@
task: [included:build] echo "build executed"
build executed

View File

@@ -0,0 +1,2 @@
task: [included:build] echo "build executed"
build executed

View File

@@ -0,0 +1 @@
task: Task "included:build" is up to date

View File

@@ -0,0 +1 @@
*.log

View File

@@ -0,0 +1,11 @@
version: '3'
use_gitignore: true
tasks:
build:
cmds:
- echo "build executed"
sources:
- ./sub/*.log
method: checksum

View File

@@ -0,0 +1 @@
!debug.log

View File

@@ -0,0 +1,2 @@
task: [build] echo "build executed"
build executed

View File

@@ -0,0 +1 @@
task: Task "build" is up to date

View File

@@ -0,0 +1,2 @@
task: [build] echo "build executed"
build executed

View File

@@ -0,0 +1 @@
task: Task "build" is up to date

11
testdata/gitignore_nested/Taskfile.yml vendored Normal file
View File

@@ -0,0 +1,11 @@
version: '3'
use_gitignore: true
tasks:
build:
cmds:
- echo "build executed"
sources:
- ./sub/*
method: checksum

View File

@@ -0,0 +1 @@
secret.dat

View File

@@ -0,0 +1 @@
keep

View File

@@ -0,0 +1,2 @@
task: [build] echo "build executed"
build executed

View File

@@ -0,0 +1 @@
task: Task "build" is up to date

Some files were not shown because too many files have changed in this diff Show More