Compare commits

...

232 Commits
v3.42.0 ... tui

Author SHA1 Message Date
Andrey Nering
ca93c2be86 feat: getting starting with a tui 2025-10-02 14:20:33 -03:00
Pete Davison
4e84c6bb76 fix: links to static files 2025-09-23 22:34:43 +00:00
merusso
0f9baf62a1 docs: Update Remote Taskfiles default values (#2430)
* docs: Update Remote Taskfiles default values

This change updates the documentation for [Remote Taskfiles > Configuration](https://taskfile.dev/docs/experiments/remote-taskfiles#configuration) for `timeout` and `cache-expiry` to match the defaults in code.

* docs: Update `cache-expiry` default to 0s

* docs: Update executor.go comment

Fix doc comment to indicate that cache expiry default duration is 0.
2025-09-23 13:10:20 +01:00
renovate[bot]
979ad523ef chore(deps): update mvdan.cc/sh/moreinterp digest to 1714925 (#2435)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-22 09:30:52 -03:00
renovate[bot]
975c07688e chore(deps): update all non-major dependencies (#2436)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-22 09:30:29 -03:00
Andrey Nering
67a02255b5 docs(changelog): add entry for #2431 2025-09-21 16:12:06 -03:00
Andrey Nering
028ae1a660 fix: fix message shown when a taskfile was not found (#2431) 2025-09-21 16:10:06 -03:00
Andrey Nering
68b1d2783d lint: fix lint by passing context 2025-09-21 16:09:51 -03:00
Andrey Nering
12793c350d chore: delete unused file cmd/tmp/main.go 2025-09-21 16:09:51 -03:00
Andrey Nering
8716ab81be docs: remove ga 2025-09-17 18:39:09 -03:00
Andrey Nering
c2a4e4470b docs: update sprig links to our domain 2025-09-17 18:32:49 -03:00
Valentin Maerten
f5a8ec8a0c fix: changelog in website 2025-09-17 17:12:57 +02:00
Valentin Maerten
048d92709a v3.45.4 2025-09-17 17:05:20 +02:00
Valentin Maerten
8dc9637e7a chore: changelog for #2413, #2424, #2425 2025-09-16 19:36:19 +02:00
Valentin Maerten
700bf00107 fix: search for all taskrc work as expected (#2424) 2025-09-16 19:35:31 +02:00
Valentin Maerten
4836d42828 fix: cache expiry in Taskrc was not working (#2423) 2025-09-16 19:35:12 +02:00
Valentin Maerten
5762d5ef8e fix: autocomplete from subfolder works as expected in zsh shell (#2425) 2025-09-16 19:34:57 +02:00
Andrey Nering
9f2fe0da61 docs: github action is not community maintained anymore 2025-09-16 14:01:33 -03:00
renovate[bot]
d1a5771839 chore(deps): update all non-major dependencies (#2420)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-16 14:19:35 +00:00
renovate[bot]
7663abdcde chore(deps): update mvdan.cc/sh/moreinterp digest to b717ad5 (#2409)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-16 11:14:12 -03:00
Valentin Maerten
1e42e1f817 fix: changelog for website 2025-09-16 08:54:06 +02:00
Valentin Maerten
5f7ae5d32e fix: changelog for website 2025-09-16 08:39:03 +02:00
Pete Davison
17db402e4b v3.45.3 2025-09-15 12:59:29 +00:00
Valentin Maerten
f2242958a6 fix: pnpm install in the website's folder 2025-09-15 14:55:55 +02:00
Pete Davison
ea4b695b5a chore: move changelog items back to unreleased 2025-09-15 12:45:35 +00:00
Pete Davison
209c88c341 v3.45.2 2025-09-15 12:40:54 +00:00
Pete Davison
bd94f9f607 fix: set pnpm version 2025-09-15 12:40:12 +00:00
Pete Davison
9f6b78ec84 chore: move changelog items back to unreleased 2025-09-15 12:38:52 +00:00
Pete Davison
fbde227167 v3.45.1 2025-09-15 12:34:34 +00:00
Pete Davison
fc06e92a87 chore: move changelog items back to unreleased 2025-09-15 12:34:11 +00:00
Pete Davison
a0cab3f5ec fix: use go-task/setup-task instead of arduino/setup-task in CI 2025-09-15 12:30:35 +00:00
Pete Davison
bb4c254211 v3.45.0 2025-09-15 12:17:50 +00:00
Pete Davison
57bf348829 fix: release script 2025-09-15 12:17:28 +00:00
Pete Davison
092b9b6391 chore: update blog post date 2025-09-15 12:16:51 +00:00
Andrey Nering
cd8c831204 chore(website): add umami 2025-09-14 10:18:32 -03:00
Andrey Nering
0d03f4f266 docs(changelog): add entry for #2416 and #2417 2025-09-12 15:46:42 -03:00
Timothy Rule
b8bf298c84 fix: panic for empty hash var ({}) (#2417) 2025-09-12 15:29:40 -03:00
Pete Davison
9a91c4cb21 chore: changelog for the new github action 2025-09-12 14:22:55 +00:00
Pete Davison
2921450bf7 docs: add mise and github actions installation methods (#2414)
* docs: add mise and github actions installation methods

* chore: rename go-task/action to go-task/setup-task
2025-09-11 18:48:23 +01:00
Valentin Maerten
dffa355cad chore: changelog for #1808 2025-09-11 19:47:06 +02:00
Valentin Maerten
48039be12c feat: improve fingerprint, run and output with wildcard (#1808) 2025-09-11 19:33:53 +02:00
Andrey Nering
43cb64e6cc fix: address panic if a config file is not available 2025-09-11 10:02:51 -03:00
Pete Davison
25a7b5936f chore: changelog for #2415 2025-09-11 09:30:02 +00:00
Pete Davison
4ae3071845 feat: nested json (#2415)
* feat: nested json

* feat: remove up_to_date from json output when --no-status flag is set

* feat: restrict use of --nested with --json and --list/--list-all
2025-09-11 10:26:59 +01:00
Valentin Maerten
242523c797 chore: changelog for #2380, #1403 2025-09-10 17:58:33 +02:00
Valentin Maerten
0fdb5e8665 feat: add some config to taskrc.yml (#2389)
Co-authored-by: Pete Davison <pd93.uk@outlook.com>
2025-09-10 17:57:52 +02:00
renovate[bot]
534dfa089c chore(deps): update all non-major dependencies (#2410) 2025-09-08 10:11:02 -03:00
Valentin Maerten
51a3bcaacd fix: cloudsmith and add docs (#2383) 2025-09-03 19:59:06 +02:00
Andrey Nering
6289fcf34c chore: simplify blog post title 2025-08-29 17:49:40 -03:00
Andrey Nering
2959737d7d chore(changelog): fix typo 2025-08-27 11:39:45 -03:00
Andrey Nering
a3047d3cd8 chore(changelog): add entries for #197 and #2360 2025-08-27 11:37:45 -03:00
Andrey Nering
725600f220 docs: add blog post about the built-in core utilities 2025-08-27 11:29:38 -03:00
Andrey Nering
fd83414074 docs: update docs and faq to mention the new core utils 2025-08-27 11:29:38 -03:00
Andrey Nering
6c645a33f7 feat: add native core utils to improve compatibility on windows 2025-08-27 11:29:38 -03:00
Andrey Nering
9d969e5971 fix(website): remove og:* and twitter:* meta tags for now
Since we're not putting the right page title and description, it's not
really working as expected. It is currently generating the same title
and description for all pages. Removing makes socials at least use the
main `<title>` tag, which will be accurate.
2025-08-26 22:52:23 -03:00
Andrey Nering
8b382a3bae chore(website): taskfile -> task and change emoji 2025-08-26 22:24:21 -03:00
Andrey Nering
a34892ad94 chore: go mod tidy 2025-08-26 20:51:11 -03:00
renovate[bot]
e55bb29554 chore(deps): update all non-major dependencies (#2398)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-25 10:16:56 -03:00
renovate[bot]
1168ef32df chore(deps): update pnpm to v10 (#2399)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-25 10:04:45 -03:00
Valentin Maerten
245d7f747f chore: use gotestsum for test (#2381) 2025-08-24 18:47:41 +02:00
Ioannis Pinakoulakis
b216ae885c perf: pre-allocate known length arrays (#2354)
Co-authored-by: Pete Davison <pd93.uk@outlook.com>
2025-08-23 15:41:30 +01:00
Tatsuya Kyushima
61cb15ad01 chore: delete unnecessary whitespace (#2394) 2025-08-23 15:37:06 +01:00
Pete Davison
04579c0c44 chore: changelog for #2391 2025-08-20 11:22:11 +00:00
Pete Davison
39462cbfde feat: change XDG taskrc naming (#2391) 2025-08-20 12:13:26 +01:00
Valentin Maerten
72dfec68b0 chore: changelog for #2380 2025-08-18 22:50:09 +02:00
Pete Davison
f89c12ddf0 feat: XDG taskrc config (#2380)
Co-authored-by: Valentin Maerten <maerten.valentin@gmail.com>
2025-08-18 22:43:36 +02:00
renovate[bot]
c903d07332 chore(deps): update all non-major dependencies (#2386)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-18 13:45:13 -03:00
renovate[bot]
138b9a5a4f chore(deps): update actions/checkout action to v5 (#2387)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-18 18:45:04 +02:00
Valentin Maerten
1e2121a99f chore: changelog for #2235 2025-08-16 13:09:30 +02:00
Valentin Maerten
9495fb2b1c feat: add experiments to taskrc.yml schema (#2235) 2025-08-16 10:54:47 +02:00
Pete Davison
1fda55910e chore: changelog for #2359, #2369, #2371, #2375, #2378, #2358 and #2358 2025-08-15 08:46:16 +00:00
Andrey Nering
e6c808c02b chore(readme): github doesn't like svg images 2025-08-14 13:35:43 -03:00
Valentin Maerten
0fc26a43a9 chore: bump minimun version to 1.24 (#2377) 2025-08-14 18:34:38 +02:00
Andrey Nering
c0b4c19443 chore(readme): fix images 2025-08-14 13:29:40 -03:00
Pete Davison
1a8df44e9e fix: readd environment reference (#2378) 2025-08-14 17:22:04 +01:00
Valentin Maerten
82ad1de8d0 docs: remove wrong <span v-pre> (#2375) 2025-08-14 10:39:46 +02:00
Valentin Maerten
d59c795502 fix: goreleaser with cloudsmith and npm (#2372) 2025-08-13 15:14:57 +02:00
Andrey Nering
504cb94e8b chore(website): add back google analytics 2025-08-12 17:58:44 -03:00
Valentin Maerten
e7606635fe docs: remove padding in team page and fix redirect (#2371) 2025-08-12 21:59:07 +02:00
Valentin Maerten
9a05ceaa80 docs: use Algolia as search engine (#2369) 2025-08-12 18:52:47 +02:00
Valentin Maerten
083654d8c9 build: publish npm package with goreleaser (#2363) 2025-08-12 18:51:20 +02:00
Valentin Maerten
79c93fb42b docs: migrate website to vitepress (#2359)
Co-authored-by: Pete Davison <pd93.uk@outlook.com>
Co-authored-by: Andrey Nering <andreynering@users.noreply.github.com>
2025-08-12 18:09:19 +02:00
Valentin Maerten
64fc538a16 build: publish deb and rpm to cloudsmith (#2362) 2025-08-12 15:40:13 +02:00
renovate[bot]
4da081e5c3 chore(deps): update all non-major dependencies (#2364)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-11 09:35:21 -03:00
Valentin Maerten
4bdfe5ce3b fix: publish nightly draft and title (#2358) 2025-08-09 16:03:46 +02:00
Valentin Maerten
26ef693417 chore: publish nightly (#2246)
Co-authored-by: Andrey Nering <andreynering@users.noreply.github.com>
2025-08-06 20:29:36 +02:00
renovate[bot]
952f32d388 chore(deps): update all non-major dependencies (#2351)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-06 10:51:19 -03:00
Andrey Nering
e72c35f79f fix(goreleaser): fix automatic submission of winget pr 2025-07-28 10:59:45 -03:00
Pete Davison
72991d4f04 v3.44.1 2025-07-23 20:59:38 +00:00
Pete Davison
6f965e3043 chore: changelog for #2265 2025-07-23 20:59:14 +00:00
Pete Davison
1c6d686356 chore: replace PPRemoveAbsolutePaths with generic fixture template data (#2265)
* chore: replace PPRemoveAbsolutePaths with generic fixture template data

* chore: update to goldie v2.7.1
2025-07-23 21:57:25 +01:00
renovate[bot]
dac5aa1954 chore(deps): update all non-major dependencies (#2333)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-21 09:46:14 -03:00
Emil
303bd6ccb2 chore(goreleaser): add section field to deb package (#2331) 2025-07-21 09:45:08 -03:00
renovate[bot]
f736cfaaf1 chore(deps): update all non-major dependencies (#2326)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-14 09:23:53 -03:00
Pete Davison
53f97889bc chore: changelog for #2323 2025-07-09 17:38:54 +00:00
Max Mizikar
fe2da74ea3 fix: don't suggest internal tasks (#2323)
Co-authored-by: Max Mizikar <maxmzkr@gmail.com>
2025-07-09 18:36:40 +01:00
Pete Davison
64fb66895b chore: added changelogs for #2316 and #2322 2025-07-09 15:26:47 +00:00
Pete Davison
d2bd834c81 fix: spaces in path (#2322) 2025-07-09 16:21:42 +01:00
Andrey Nering
8a43ca5d8f chore: move away from deprecated func 2025-07-07 10:14:50 -03:00
Andrey Nering
a10a9faabf chore(taskfile): add go.mod as source for the lint tasks 2025-07-07 10:14:50 -03:00
renovate[bot]
3d3ed0e403 chore(deps): update all non-major dependencies 2025-07-07 10:14:50 -03:00
Pete Davison
47dc87a2c9 fix: remove extra breaking randInt function (#2316) 2025-07-03 23:08:43 +01:00
Pete Davison
3b0a746f85 feat: update go-task/template to latest version 2025-07-03 21:46:09 +00:00
renovate[bot]
281edfe5b3 chore(deps): update all non-major dependencies (#2311)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-30 09:32:44 -03:00
Alexander Kavon
7289ffce0b docs: add macports / freebsd installation instructions (#2308) 2025-06-30 09:31:48 -03:00
dependabot[bot]
61e1af50ff chore(deps): bump brace-expansion from 1.1.11 to 1.1.12 in /website (#2298)
Bumps [brace-expansion](https://github.com/juliangruber/brace-expansion) from 1.1.11 to 1.1.12.
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/1.1.11...v1.1.12)

---
updated-dependencies:
- dependency-name: brace-expansion
  dependency-version: 1.1.12
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-16 10:39:42 -03:00
renovate[bot]
715a143735 chore(deps): update all non-major dependencies (#2297)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-16 09:26:27 -03:00
Andrey Nering
a0b1605634 chore: add changelog entry for #2291 2025-06-09 14:12:03 -03:00
Timothy Rule
69fc13bd13 fix(release): fix install script for armv5/6/7 (#2291) 2025-06-09 14:07:11 -03:00
renovate[bot]
b42a52ba77 chore(deps): update all non-major dependencies (#2289)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-08 22:09:51 -03:00
Andrey Nering
cb812476b3 v3.44.0 2025-06-08 21:34:13 -03:00
Andrey Nering
b09c6870fe docs: add note about watcher reliability 2025-06-08 21:31:47 -03:00
Andrey Nering
86e4a3aac7 chore(changelog): add entried for watch fixes 2025-06-08 21:19:03 -03:00
Andrey Nering
7782bc92ae fix(watcher): fix some v3.43.x regressions (#2271) 2025-06-08 19:44:08 -03:00
renovate[bot]
9cc2d65091 chore(deps): update all non-major dependencies (#2281) 2025-06-02 13:26:32 +00:00
Andrey Nering
b932e539d9 chore: go mod tidy 2025-05-28 22:08:27 -03:00
Teddy Sommavilla
be45eb04d9 refactor: watchTasks - Chmod operations are already filtered in the Deduper 2025-05-26 16:51:37 -03:00
Teddy Sommavilla
6b878980dc refactor(fsnotifyext): use Event.Has to check for chmod operations
As recommended by the Event.Op godoc. Op is a bitmask, and some systems may send multiple operations at once
2025-05-26 16:51:37 -03:00
Teddy Sommavilla
cd910abd45 doc(fsnotifyext): add godoc for GetChan method 2025-05-26 16:51:37 -03:00
Teddy Sommavilla
6e524bb2fa refactor(fsnotifyext): GetChan should return a receive only chan 2025-05-26 16:51:37 -03:00
Teddy Sommavilla
b4c8f5a0fe refactor(fsnotifyext): handle Deduper timers in own goroutine, avoid mutex use 2025-05-26 16:51:37 -03:00
renovate[bot]
09f85844ba chore(deps): update all non-major dependencies (#2270) 2025-05-26 16:39:01 -03:00
Pete Davison
d54d2ccabc chore: add special variables task to remote for testing 2025-05-24 13:33:55 +00:00
Pete Davison
cf81ab3112 chore: go mod tidy 2025-05-24 13:11:02 +00:00
Pete Davison
aaa7b7772d chore: changelog for #2223 2025-05-24 13:03:29 +00:00
Pete Davison
71eb8cdeea feat: checksum pinning (#2223) 2025-05-24 14:00:02 +01:00
Pete Davison
68ce8b1d84 chore: changelog for #2220 2025-05-24 12:41:31 +00:00
Pete Davison
5323990c72 feat: redact credentials in remote urls (#2220)
* feat: redact credentials in remote urls

* chore: improve function naming

* fix: TaskfileNotSecureError should use redacted URI

* feat: unexport all node implementation fields

* fix: unexport HTTPNode.url
2025-05-24 13:38:18 +01:00
Pete Davison
ec4e68d601 chore: changelog for #2256 2025-05-20 20:40:28 +00:00
Aleksander Sh.
bb5b045293 feat: add task name to json output (#2256) 2025-05-20 21:37:57 +01:00
renovate[bot]
89f29cb75b chore(deps): update all non-major dependencies (#2260)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-20 13:38:05 +02:00
Andrey Nering
da4ce5b0a5 fix(expand): return nothing if there are no matches 2025-05-09 15:55:52 -03:00
renovate[bot]
fb68a5f79a chore(deps): update golangci/golangci-lint-action action to v8 (#2237)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Valentin Maerten <maerten.valentin@gmail.com>
2025-05-06 20:45:06 +02:00
renovate[bot]
f40f389cb4 chore(deps): update all non-major dependencies (#2236)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-06 20:43:16 +02:00
Valentin Maerten
a459eeaabb chore: changelog for #2233 2025-05-03 17:18:27 +02:00
Valentin Maerten
84f02a822f docs: mention that method key is allowed at root level (#2233) 2025-05-03 17:17:11 +02:00
Valentin Maerten
55d1aa260d chore: changelog for #2211 2025-05-03 17:12:31 +02:00
Valentin Maerten
e7084cdf26 chore: update schemas only when a release is done (#2211) 2025-05-03 17:11:56 +02:00
Pete Davison
ca55e9b621 chore: changelog for #2225 2025-05-01 17:58:47 +00:00
Pete Davison
6528b36caa feat: add uuid and rand number functions (#2225)
* feat: add uuid and rand number functions

* chore: remove randFloat for now
2025-05-01 17:58:01 +00:00
Pete Davison
f8736c5f77 chore: changelog for #2140 2025-05-01 17:51:47 +00:00
Pete Davison
6896accf86 feat: cli args list (#2140) 2025-05-01 18:43:43 +01:00
Pete Davison
c12ed49acb chore: remove unused any2 testdata 2025-04-28 21:04:24 +00:00
Pete Davison
d1bfd3e9f7 docs: move yaml templating functions to the correct section 2025-04-28 20:57:18 +00:00
renovate[bot]
fc17343fcc chore(deps): update all non-major dependencies (#2214)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-28 14:37:44 +02:00
Pete Davison
d3e9be1520 chore: changelog for #2219 2025-04-28 12:21:26 +00:00
Pete Davison
d850d03c96 feat: add yaml templating functions (#2219)
* feat: add yaml templating functions

* docs: add yaml functions to templating reference

* refactor: remove some unnecessary function wrappers
2025-04-28 12:19:56 +00:00
Pete Davison
0058f18676 chore: changelog for #2216 2025-04-28 12:05:10 +00:00
Pete Davison
b3c4007756 fix: double escaped paths (#2216) 2025-04-28 13:02:46 +01:00
Pete Davison
9e8fd54be9 chore: changelog for #2200 2025-04-27 23:02:32 +00:00
Valentin Maerten
a33544101a fix: fuzzy model was not instanciated (#2200)
* fix: fuzzy model was not instanciated

* add test

* add test
2025-04-28 00:00:54 +01:00
Pete Davison
1c35358fcc v3.43.3 2025-04-27 22:29:34 +00:00
Pete Davison
13daa6dc35 feat: formatting with golangci-lint and gci 2025-04-27 22:28:42 +00:00
Pete Davison
20c1ffe098 docs: update variables example so that it doesn't error 2025-04-27 22:26:59 +00:00
Pete Davison
bd8ccb8d03 chore: changelogs for reverts 2025-04-27 22:26:29 +00:00
Pete Davison
8162b05f59 Revert "feat: process variables in include vars (#2113)"
This reverts commit f0414f162d.
2025-04-27 22:15:49 +00:00
Pete Davison
68d5095761 Revert "fix: .USER_WORKING_DIR should contain the value of --dir if given (#2186)"
This reverts commit 768dca053b.
2025-04-27 22:14:50 +00:00
Andrey Nering
6cb0a5a2f2 v3.43.2 2025-04-21 16:35:01 -03:00
Andrey Nering
08056924e0 chore: add changelog entry for #2191 2025-04-21 16:33:30 -03:00
Valentin Maerten
39706105e1 fix: CLI_ARGS is a string and not an array (#2191) 2025-04-21 16:31:18 -03:00
Andrey Nering
bf4e7960cb chore: show right version on changelog 2025-04-21 14:31:25 -03:00
Andrey Nering
3d36616e9e v3.43.1 2025-04-21 13:57:43 -03:00
Andrey Nering
3976e8372a chore: move the experiments package out of the internal/ dir
Closes #2014
2025-04-21 13:55:56 -03:00
Andrey Nering
c2123dc016 v3.43.0 2025-04-21 13:50:40 -03:00
Andrey Nering
0a6cd1ee42 chore: add changelog entry for #2173 2025-04-21 13:48:25 -03:00
Valentin Maerten
7169bf6434 fix: interpolate vars in defer (#2173) 2025-04-21 13:43:20 -03:00
renovate[bot]
84cd4dfdad chore(deps): update all non-major dependencies (#2188)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-21 13:52:23 +02:00
Pete Davison
672b39413f feat: mockery v3 (#2110) 2025-04-19 12:55:22 +01:00
Pete Davison
7eebf6e704 chore: delete unused exp package 2025-04-19 11:54:48 +00:00
Pete Davison
4834ac743c chore: changelog for #2166 2025-04-19 11:54:27 +00:00
Pete Davison
c5afffb551 feat: recursive config search (#2166)
* refactor: experiments flags

* refactor: args.Parse

* feat: recursive search for taskrc files

* feat: consolidate some code into new fsext package

* feat: add tests for search and default dir

* fix: linting issues
2025-04-19 12:20:33 +01:00
Pete Davison
1ae3bf0b25 chore: changelog for #2176 2025-04-19 11:20:17 +00:00
Pete Davison
a84f09d45f feat: remote taskfile improvements (cache/expiry) (#2176)
* feat: cache as node, RemoteNode and cache-first approach

* feat: cache expiry

* feat: pass ctx into reader methods instead of timeout

* docs: updated remote taskfiles experiment doc

* feat: use cache if download fails
2025-04-19 12:12:08 +01:00
Pete Davison
f47f237093 chore: changelog for #2169 2025-04-19 11:11:51 +00:00
Pete Davison
04df108fb5 docs: package api docs (#2169)
* refactor: pass Node into Read method instead of Reader type

* docs: add "key packages" and "Reading Taskfiles" sections to package doc
2025-04-19 11:58:31 +01:00
Pete Davison
8885d9e4f7 chore: changelog for #2075 2025-04-19 10:57:36 +00:00
Pete Davison
a60c2ec3f8 fix: sources brace expansion (#2075) 2025-04-19 11:51:31 +01:00
Andrey Nering
f789c57624 chore: add changelog entry for #2134 2025-04-18 22:56:46 -03:00
atusy
7416b7d77e feat(completion): let fish complete global tasks if --global (-g) is passed (#2134) 2025-04-18 22:55:53 -03:00
Andrey Nering
c1ab661cf2 chore: add changelog entry for #2102, #2103 and #2186 2025-04-18 22:32:19 -03:00
Andrey Nering
768dca053b fix: .USER_WORKING_DIR should contain the value of --dir if given (#2186)
Closes #2102
Closes #2103

Co-authored-by: jaynis <kranz.jannis@googlemail.com>
2025-04-18 22:27:30 -03:00
Jay Berkenbilt
e65159f613 docs: clarify --dir flag (#2123)
Co-authored-by: Andrey Nering <andreynering@users.noreply.github.com>
2025-04-17 23:10:57 +00:00
Pete Davison
789a7ea950 docs: things aren't always simple 2025-04-16 15:59:35 +00:00
renovate[bot]
b11da93c78 chore(deps): update dependency @types/react to v19.1.1 (#2178)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-14 10:38:04 -03:00
renovate[bot]
8c720b03aa chore(deps): update all non-major dependencies (#2167)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-11 10:29:35 -03:00
Pete Davison
8c8b1b5f3b fix: make version semver compliant 2025-04-11 09:04:03 +00:00
Pete Davison
38b42d0fb1 fix: experiments validation should happen before command flags are evaluated 2025-04-10 08:45:55 +00:00
Pete Davison
669bf33619 chore: changelog for #2151 2025-04-05 23:15:02 +00:00
Artem Sedykh
6f0f38b8d9 feat: support for loops with generates (#2151) 2025-04-05 23:55:43 +01:00
Pete Davison
a9de239e38 chore: changelog for #2113 2025-04-05 22:16:30 +00:00
Pete Davison
f0414f162d feat: process variables in include vars (#2113)
* feat: process variables in include vars

* feat: add test for include variables
2025-04-05 23:12:54 +01:00
Pete Davison
a24f4958cd chore: changelog for #2131 2025-04-05 22:12:18 +00:00
Pete Davison
55790be6ad feat: better versioning (#2131) 2025-04-05 23:09:27 +01:00
Andrey Nering
88fdbd13cf ci: use goreleaser pro
Thanks @caarlos0 for the free key!
2025-04-05 18:20:32 -03:00
Pete Davison
566ac29932 feat: update golangci-lint version in ci 2025-04-05 17:58:50 -03:00
Pete Davison
ffef3ed1a6 feat: migrate to golangci-lint v2 2025-04-05 17:58:50 -03:00
Andrey Nering
2a60842707 chore: remove some repo files that were moved to the .github repo
https://github.com/go-task/.github
2025-04-05 17:51:56 -03:00
renovate[bot]
41bd866813 chore(deps): update all non-major dependencies (#2165)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-05 21:39:07 +02:00
Iain Majer
01bc0a0529 docs: specify that install command parameters are order specific (#2115)
Co-authored-by: Iain Majer <iain.majer@river-island.com>
2025-04-05 21:28:36 +02:00
Valentin Maerten
a6a9792b7e docs: use .taskrc instead of .task-experiments.yml in our docs (#2157)
* docs: use .taskrc instead of .task-experiments.yml in our docs

* fix formatting

* fix: whitespace in experiments.mdx

---------

Co-authored-by: Pete Davison <pd93.uk@outlook.com>
2025-04-03 13:29:00 +02:00
dependabot[bot]
ce032dc46b chore(deps): bump image-size from 1.2.0 to 1.2.1 in /website (#2152) 2025-04-02 12:55:15 -03:00
Pete Davison
f07f4c85b2 chore: changelog for #2148 2025-04-01 13:56:41 +00:00
Pete Davison
cd81d94e18 feat: better functional options for reader (#2148) 2025-04-01 14:51:25 +01:00
Pete Davison
1939f83ffe chore: changelog for #2147 2025-03-31 20:50:14 +00:00
Pete Davison
2a92b70bc2 feat: better functional options (#2147) 2025-03-31 21:49:00 +01:00
Pete Davison
4736bc2734 refactor: unify how executor tests are written (#2042)
* feat: use TaskTest for executor tests

* feat: more tests

* feat: separate tests for executing and formatting with new functional options that work for both test types

* feat: formatter tests

* refactor: more tests
2025-03-31 17:53:58 +01:00
Andrey Nering
180fcef364 docs: fix typo: source -> sources 2025-03-31 10:50:33 -03:00
renovate[bot]
f6baa5942e chore(deps): update all non-major dependencies to v19.0.12 (#2137)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-31 09:32:13 -03:00
Pete Davison
d54b0d6a2a chore: changelog for #2144 2025-03-30 19:22:37 +00:00
Pete Davison
03b242d4c3 fix: bug where undefined/null variables resolve to "" instead of nil (#2144) 2025-03-30 19:21:02 +00:00
Pete Davison
60e28ecdcc chore: changelog for #2121 2025-03-26 22:21:47 +00:00
Pete Davison
dd8daa68cd feat: allow wildcards to match multiple tasks (#2121)
* feat: allow wildcards to match multiple tasks

* docs: improved wildcard section
2025-03-26 22:17:27 +00:00
Pete Davison
55617e062f chore: changelog for #2081 2025-03-26 21:50:53 +00:00
Pete Davison
c6f1b3ae4f feat: make map variables experiment (prop 2) generally available (#2081)
* feat: make map variables experiment (prop 2) generally available

* docs: remove map variables experiment page and update usage to include map variable info
2025-03-26 21:40:09 +00:00
Andrey Nering
cb14a4f3a1 chore: add changelog for #2048 2025-03-22 20:15:26 -03:00
Andrey Nering
0d5f2b5dab feat(watcher): migrate to fsnotify (#2048) 2025-03-22 20:06:16 -03:00
Andrey Nering
89caf1e049 chore: add changelog for #2130 2025-03-19 10:36:07 -03:00
atusy
7f7e8306da fix(fish): fish completion error due to variable shadowing (#2130) 2025-03-19 13:33:32 +00:00
renovate[bot]
1f2eecda9e chore(deps): update dependency go to 1.24.x (#2126)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-17 08:57:58 -03:00
renovate[bot]
60c959c75c chore(deps): update tj-actions/changed-files action to v46 (#2127)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-17 08:57:33 -03:00
Pete Davison
a771e91ff3 chore: changelog for #2125 2025-03-16 13:20:36 +00:00
Timothy Rule
532644d7f8 feat: create NoSort sorter for CLI sort option "none" (#2125) 2025-03-16 13:17:14 +00:00
Pete Davison
b68f4067d9 chore: changelog for #2112 2025-03-12 19:59:54 +00:00
Pete Davison
c544b0058d fix: labels for experiments 2025-03-12 19:57:33 +00:00
Pete Davison
d1360ee72a refactor: embed the default Taskfile instead of defining it in code (#2112) 2025-03-11 13:53:08 +00:00
Pete Davison
076aff1f8e chore: changelog for #2085 2025-03-10 20:41:47 +00:00
Pete Davison
ffeb3bcc3f refactor: executor functional options (#2085)
* refactor: executor functional options

* refactor: minor tidy up of list code

* fix: WithVersionCheck missing from call to NewExecutor

* feat: docstrings for structs with functional options

* refactor: prefix the functional options with the name of the struct they belong to
2025-03-10 20:38:25 +00:00
Pete Davison
8181352d54 v3.42.1 2025-03-10 20:18:58 +00:00
Pete Davison
23fd7e782c chore: changelog for #2107 2025-03-10 11:48:10 +00:00
Pete Davison
6604b9a8cc fix: special variable type errors in vars with no task context (#2107)
* fix: stop dotenv trying to fetch variables when no dotenv specified

* fix: set special variables to "" when they can't be calculated
2025-03-10 11:46:07 +00:00
Andrey Nering
6ee1053c96 docs: fix link to nix package file 2025-03-08 22:53:58 -03:00
Andrey Nering
8eaf83599e fix(goreleaser): fix a deprecation warning 2025-03-08 22:44:05 -03:00
435 changed files with 21948 additions and 22517 deletions

View File

@@ -8,6 +8,6 @@ charset = utf-8
trim_trailing_whitespace = true
indent_style = tab
[*.{md,mdx,yml,yaml,json,toml,htm,html,js,ts,css,svg,sh,bash,fish}]
[*.{md,mdx,yml,yaml,json,toml,htm,html,js,ts,vue,css,svg,sh,bash,fish}]
indent_style = space
indent_size = 2

View File

@@ -1,46 +0,0 @@
# Contributor Covenant Code of Conduct
## Our Pledge
In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation.
## Our Standards
Examples of behavior that contributes to creating a positive environment include:
* Using welcoming and inclusive language
* Being respectful of differing viewpoints and experiences
* Gracefully accepting constructive criticism
* Focusing on what is best for the community
* Showing empathy towards other community members
Examples of unacceptable behavior by participants include:
* The use of sexualized language or imagery and unwelcome sexual attention or advances
* Trolling, insulting/derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or electronic address, without explicit permission
* Other conduct which could reasonably be considered inappropriate in a professional setting
## Our Responsibilities
Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior.
Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful.
## Scope
This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at task@taskfile.dev. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately.
Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version]
[homepage]: http://contributor-covenant.org
[version]: http://contributor-covenant.org/version/1/4/

View File

@@ -1,14 +0,0 @@
## You can find our [contribution guide on our website][contributing]
- Please read it carefully before opening a PR.
- If you have any questions, you can:
- [Open an issue][issues]
- [Create a discussion][discussions]
- [Chat to us on Discord][discord]
<!-- prettier-ignore-start -->
[contributing]: https://taskfile.dev/contributing
[issues]: https://github.com/go-task/task/issues
[discussions]: https://github.com/go-task/task/discussions
[discord]: https://discord.gg/6TY36E39UK
<!-- prettier-ignore-end -->

3
.github/FUNDING.yml vendored
View File

@@ -1,3 +0,0 @@
github: [andreynering, pd93, vmaerten]
open_collective: task
custom: https://taskfile.dev/donate/

View File

@@ -6,7 +6,7 @@ on:
jobs:
issue-experiment-proposed:
if: github.event.label.name == format('experiment{0} proposed', ':')
if: github.event.label.name == format('status{0} proposed', ':')
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v7
@@ -20,7 +20,7 @@ jobs:
body: 'This issue has been marked as an experiment proposal! :test_tube: It will now enter a period of consultation during which we encourage the community to provide feedback on the proposed design. Please see the [experiment workflow documentation](https://taskfile.dev/experiments#workflow) for more information on how we release experiments.'
})
issue-experiment-draft:
if: github.event.label.name == format('experiment{0} draft', ':')
if: github.event.label.name == format('status{0} draft', ':')
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v7
@@ -34,7 +34,7 @@ jobs:
body: 'This experiment has been marked as a draft! :sparkles: This means that an initial implementation has been added to the latest release of Task! You can find information about this experiment and how to enable it in our [experiments documentation](https://taskfile.dev/experiments). Please see the [experiment workflow documentation](https://taskfile.dev/experiments#workflow) for more information on how we release experiments.'
})
issue-experiment-candidate:
if: github.event.label.name == format('experiment{0} candidate', ':')
if: github.event.label.name == format('status{0} candidate', ':')
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v7
@@ -48,7 +48,7 @@ jobs:
body: 'This experiment has been marked as a candidate! :fire: This means that the implementation is nearing completion and we are entering a period for final comments and feedback! You can find information about this experiment and how to enable it in our [experiments documentation](https://taskfile.dev/experiments). Please see the [experiment workflow documentation](https://taskfile.dev/experiments#workflow) for more information on how we release experiments.'
})
issue-experiment-stable:
if: github.event.label.name == format('experiment{0} stable', ':')
if: github.event.label.name == format('status{0} stable', ':')
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v7
@@ -62,7 +62,7 @@ jobs:
body: 'This experiment has been marked as stable! :metal: This means that the implementation is now final and ready to be released. No more changes will be made and the experiment is safe to use in production! You can find information about this experiment and how to enable it in our [experiments documentation](https://taskfile.dev/experiments). Please see the [experiment workflow documentation](https://taskfile.dev/experiments#workflow) for more information on how we release experiments.'
})
issue-experiment-released:
if: github.event.label.name == format('experiment{0} released', ':')
if: github.event.label.name == format('status{0} released', ':')
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v7
@@ -82,7 +82,7 @@ jobs:
state: 'closed'
})
issue-experiment-abandoned:
if: github.event.label.name == format('experiment{0} abandoned', ':')
if: github.event.label.name == format('status{0} abandoned', ':')
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v7
@@ -102,7 +102,7 @@ jobs:
state: 'closed'
})
issue-experiment-superseded:
if: github.event.label.name == format('experiment{0} superseded', ':')
if: github.event.label.name == format('status{0} superseded', ':')
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v7

View File

@@ -13,46 +13,31 @@ jobs:
name: Lint
strategy:
matrix:
go-version: [1.23.x, 1.24.x]
go-version: [1.24.x, 1.25.x]
runs-on: ubuntu-latest
steps:
- uses: actions/setup-go@v5
with:
go-version: ${{matrix.go-version}}
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: golangci-lint
uses: golangci/golangci-lint-action@v6
uses: golangci/golangci-lint-action@v8
with:
version: v1.64.2
version: v2.1.0
lint-jsonschema:
runs-on: ubuntu-latest
steps:
- uses: actions/setup-python@v5
with:
python-version: 3.12
python-version: 3.13
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: install check-jsonschema
run: python -m pip install 'check-jsonschema==0.27.3'
- name: check-jsonschema (metaschema)
run: check-jsonschema --check-metaschema website/static/schema.json
check_doc:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Get changed files in the docs folder
id: changed-files-specific
uses: tj-actions/changed-files@v45
with:
files: website/versioned_docs/**
- uses: actions/github-script@v7
if: steps.changed-files-specific.outputs.any_changed == 'true'
with:
script: |
core.setFailed('website/versioned_docs has changed. Instead you need to update the docs in the website/docs folder.')
run: check-jsonschema --check-metaschema website/src/public/schema.json

30
.github/workflows/release-nightly.yml vendored Normal file
View File

@@ -0,0 +1,30 @@
name: Release nightly
on:
workflow_dispatch:
schedule:
- cron: 0 0 * * *
jobs:
goreleaser:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: 1.25.x
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6
with:
distribution: goreleaser-pro
version: latest
args: release --clean --nightly -f .goreleaser-nightly.yml
env:
GITHUB_TOKEN: ${{secrets.GH_PAT}}
GORELEASER_KEY: ${{secrets.GORELEASER_KEY}}
CLOUDSMITH_TOKEN: ${{secrets.CLOUDSMITH_TOKEN}}

View File

@@ -10,17 +10,39 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: 1.23.x
go-version: 1.25.x
- name: npm-login
run: |
npm config set '//registry.npmjs.org/:_authToken'=${{ secrets.NPM_TOKEN }}
- name: Install Task
uses: go-task/setup-task@v1
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
package_json_file: 'website/package.json'
run_install: 'true'
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6
with:
distribution: goreleaser-pro
version: latest
args: release --clean
args: release --clean --draft
env:
GITHUB_TOKEN: ${{secrets.GH_PAT}}
GORELEASER_KEY: ${{secrets.GORELEASER_KEY}}
CLOUDSMITH_TOKEN: ${{secrets.CLOUDSMITH_TOKEN}}
- name: Deploy Website
shell: bash
run: |
task website:deploy:prod

View File

@@ -13,7 +13,7 @@ jobs:
name: Test
strategy:
matrix:
go-version: [1.23.x, 1.24.x]
go-version: [1.24.x, 1.25.x]
platform: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{matrix.platform}}
steps:
@@ -24,7 +24,7 @@ jobs:
id: go
- name: Check out code into the Go module directory
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Download Go modules
run: go mod download

View File

@@ -1,38 +1,64 @@
# NOTE(@andreynering): The linters listed here are additions on top of
# those enabled by default:
#
# https://golangci-lint.run/usage/linters/#enabled-by-default
version: "2"
formatters:
enable:
- gofmt
- gofumpt
- goimports
- gci
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:
sections:
- standard
- default
- prefix(github.com/go-task)
- localmodule
exclusions:
generated: lax
paths:
- third_party$
- builtin$
- examples$
linters:
enable:
- depguard
- goimports
- gofmt
- gofumpt
- mirror
- misspell
- noctx
- paralleltest
- usetesting
- thelper
- tparallel
linters-settings:
depguard:
rules:
main:
files:
- "$all"
- "!$test"
- "!**/errors/*.go"
deny:
- pkg: "errors"
desc: "Use github.com/go-task/task/v3/errors instead"
goimports:
local-prefixes: github.com/go-task
gofumpt:
module-path: github.com/go-task/task/v3
gofmt:
rewrite-rules:
- pattern: 'interface{}'
replacement: 'any'
- usetesting
settings:
depguard:
rules:
main:
files:
- $all
- '!$test'
- '!**/errors/*.go'
deny:
- pkg: errors
desc: Use github.com/go-task/task/v3/errors instead
exclusions:
generated: lax
presets:
- comments
- common-false-positives
- legacy
- std-error-handling
paths:
- third_party$
- builtin$
- examples$

15
.goreleaser-nightly.yml Normal file
View File

@@ -0,0 +1,15 @@
# yaml-language-server: $schema=https://goreleaser.com/static/schema.json
version: 2
pro: true
release:
name_template: 'v{{.Version}}'
nightly:
publish_release: true
keep_single_release: true
version_template: "{{incminor .Version}}-nightly"
includes:
- from_file:
path: ./.goreleaser.yml

View File

@@ -30,42 +30,46 @@ builds:
flags:
- -trimpath
ldflags:
- -s -w # Don't set main.version.
- "-s -w"
- "{{if .IsNightly}}-X github.com/go-task/task/v3/internal/version.version={{.Version}}{{end}}"
gomod:
proxy: true
archives:
- name_template: "{{.Binary}}_{{.Os}}_{{.Arch}}"
- name_template: '{{.Binary}}_{{.Os}}_{{.Arch}}'
files:
- README.md
- LICENSE
- completion/**/*
format_overrides:
- goos: windows
format: zip
formats: [zip]
release:
draft: true
git:
ignore_tags:
- "{{if not .IsNightly}}nightly{{end}}"
snapshot:
version_template: "{{.Version}}"
version_template: '{{.Version}}'
checksum:
name_template: "task_checksums.txt"
name_template: 'task_checksums.txt'
nfpms:
- vendor: Task
homepage: https://taskfile.dev
maintainer: The Task authors <task@taskfile.dev>
description: Simple task runner written in Go
section: golang
license: MIT
conflicts:
- taskwarrior
formats:
- deb
- rpm
file_name_template: "{{.ProjectName}}_{{.Os}}_{{.Arch}}"
- apk
file_name_template: '{{.ProjectName}}_{{.Os}}_{{.Arch}}'
contents:
- src: completion/bash/task.bash
dst: /etc/bash_completion.d/task
@@ -83,8 +87,7 @@ brews:
repository:
owner: go-task
name: homebrew-tap
test:
system "#{bin}/task", "--help"
test: system "#{bin}/task", "--help"
install: |-
bin.install "task"
bash_completion.install "completion/bash/task.bash" => "task"
@@ -107,7 +110,7 @@ winget:
commit_author:
name: task-bot
email: 106601941+task-bot@users.noreply.github.com
commit_msg_template: "chore: bump {{.PackageIdentifier}} to {{.Tag}}"
commit_msg_template: 'chore: release {{.PackageIdentifier}} {{.Tag}}'
release_notes_url: https://github.com/go-task/task/releases/tag/{{.Tag}}
tags:
- build
@@ -121,13 +124,49 @@ winget:
- task-runner
- taskfile
- tool
skip_upload: true
repository:
owner: microsoft
owner: go-task
name: winget-pkgs
branch: 'chore/task-{{.Version}}'
pull_request:
enabled: true
draft: false
check_boxes: true
base:
owner: go-task
owner: microsoft
name: winget-pkgs
branch: "bump-task-to-{{.Tag}}"
branch: master
npms:
- name: "@go-task/cli"
repository: "git+https://github.com/go-task/task.git"
bugs: https://github.com/go-task/task/issues
description: A task runner / simpler Make alternative written in Go
homepage: https://taskfile.dev
license: MIT
author: "The Task authors"
access: public
keywords:
- "task"
- "taskfile"
- "build-tool"
- "task-runner"
cloudsmiths:
- organization: "task"
repository: "{{if not .IsNightly}}task{{end}}"
formats:
- deb
- rpm
- apk
distributions:
deb:
- "any-distro/any-version"
rpm:
- "any-distro/any-version"
alpine:
- "alpine/any-version"
component: main
republish: true

View File

@@ -1,4 +1,8 @@
with-expecter: true
keeptree: true
case: underscore
output: ./internal/mocks
all: False
template: testify
filename: '{{base (trimSuffix ".go" .InterfaceFile)}}_mock.go'
packages:
github.com/go-task/task/v3/internal/fingerprint:
interfaces:
SourcesCheckable:
StatusCheckable:

1
.nvmrc
View File

@@ -1 +0,0 @@
22.14.0

4
.taskrc.yml Normal file
View File

@@ -0,0 +1,4 @@
experiments:
GENTLE_FORCE: 0
REMOTE_TASKFILES: 0
ENV_PRECEDENCE: 0

View File

@@ -1,15 +1,12 @@
{
"yaml.schemas": {
"./website/static/schema.json": [
"Taskfile.yml",
"tmp/**/*.yml"
"./website/src/public/schema.json": [
"Taskfile.yml",
"Taskfile.yaml",
"taskfile.yml",
"taskfile.yaml"
]
},
"search.exclude": {
"**/versioned_docs": true,
"**/versioned_sidesbars": true,
"**/i18n": true
},
"gopls": {
"formatting.local": "github.com/go-task"
},

View File

@@ -1,5 +1,199 @@
# Changelog
## Unreleased
- Fixed bug that made a generic message, instead of an useful one, appear when
a Taskfile could not be found (#2431 by @andreynering).
## v3.45.4 - 2025-09-17
- Fixed a bug where `cache-expiry` could not be defined in `.taskrc.yml` (#2423
by @vmaerten).
- Fixed a bug where `.taskrc.yml` files in parent folders were not read
correctly (#2424 by @vmaerten).
- Fixed a bug where autocomplete in subfolders did not work with zsh (#2425 by
@vmaerten).
## v3.45.3 - 2025-09-15
- Task now includes built-in core utilities to greatly improve compatibility on
Windows. This means that your commands that uses `cp`, `mv`, `mkdir` or any
other common core utility will now work by default on Windows, without extra
setup. This is something we wanted to address for many many years, and it's
finally being shipped!
[Read our blog post this the topic](https://taskfile.dev/blog/windows-core-utils).
(#197, #2360 by @andreynering).
- :sparkles: Built and deployed a [brand new website](https://taskfile.dev)
using [VitePress](https://vitepress.dev) (#2359, #2369, #2371, #2375, #2378 by
@vmaerten, @andreynering, @pd93).
- Began releasing
[nightly builds](https://github.com/go-task/task/releases/tag/nightly). This
will allow people to test our changes before they are fully released and
without having to install Go to build them (#2358 by @vmaerten).
- Added support for global config files in `$XDG_CONFIG_HOME/task/taskrc.yml` or
`$HOME/.taskrc.yml`. Check out our new
[configuration guide](https://taskfile.dev/docs/reference/config) for more
details (#2247, #2380, #2390, #2391 by @vmaerten, @pd93).
- Added experiments to the taskrc schema to clarify the expected keys and values
(#2235 by @vmaerten).
- Added support for new properties in `.taskrc.yml`: insecure, verbose,
concurrency, remote offline, remote timeout, and remote expiry. :warning:
Note: setting offline via environment variable is no longer supported. (#2389
by @vmaerten)
- Added a `--nested` flag when outputting tasks using `--list --json`. This will
output tasks in a nested structure when tasks are namespaced (#2415 by @pd93).
- Enhanced support for tasks with wildcards: they are now logged correctly, and
wildcard parameters are fully considered during fingerprinting (#1808, #1795
by @vmaerten).
- Fixed panic when a variable was declared as an empty hash (`{}`) (#2416, #2417
by @trulede).
#### Package API
- Bumped the minimum version of Go to 1.24 (#2358 by @vmaerten).
#### Other news
We recently released our
[official GitHub Action](https://github.com/go-task/setup-task). This is based
on the fantastic work by the Arduino team who created and maintained the
community version. Now that this is officially adopted, fixes/updates should be
more timely. We have already merged a couple of longstanding PRs in our
[first release](https://github.com/go-task/setup-task/releases/tag/v1.0.0) (by
@pd93, @shrink, @trim21 and all the previous contributors to
[arduino/setup-task](https://github.com/arduino/setup-task/)).
## v3.45.0-v3.45.2 - 2025-09-15
Failed due to an issue with our release process.
## v3.44.1 - 2025-07-23
- Internal tasks will no longer be shown as suggestions since they cannot be
called (#2309, #2323 by @maxmzkrcensys)
- Fixed install script for some ARM platforms (#1516, #2291 by @trulede).
- Fixed a regression where fingerprinting was not working correctly if the path
to you Taskfile contained a space (#2321, #2322 by @pd93).
- Reverted a breaking change to `randInt` (#2312, #2316 by @pd93).
- Made new variables `TEST_NAME` and `TEST_DIR` available in fixture tests
(#2265 by @pd93).
## v3.44.0 - 2025-06-08
- Added `uuid`, `randInt` and `randIntN` template functions (#1346, #2225 by
@pd93).
- Added new `CLI_ARGS_LIST` array variable which contains the arguments passed
to Task after the `--` (the same as `CLI_ARGS`, but an array instead of a
string). (#2138, #2139, #2140 by @pd93).
- Added `toYaml` and `fromYaml` templating functions (#2217, #2219 by @pd93).
- Added `task` field the `--list --json` output (#2256 by @aleksandersh).
- Added the ability to
[pin included taskfiles](https://taskfile.dev/next/experiments/remote-taskfiles/#manual-checksum-pinning)
by specifying a checksum. This works with both local and remote Taskfiles
(#2222, #2223 by @pd93).
- When using the
[Remote Taskfiles experiment](https://github.com/go-task/task/issues/1317),
any credentials used in the URL will now be redacted in Task's output (#2100,
#2220 by @pd93).
- Fixed fuzzy suggestions not working when misspelling a task name (#2192, #2200
by @vmaerten).
- Fixed a bug where taskfiles in directories containing spaces created
directories in the wrong location (#2208, #2216 by @pd93).
- Added support for dual JSON schema files, allowing changes without affecting
the current schema. The current schemas will only be updated during releases.
(#2211 by @vmaerten).
- Improved fingerprint documentation by specifying that the method can be set at
the root level to apply to all tasks (#2233 by @vmaerten).
- Fixed some watcher regressions after #2048 (#2199, #2202, #2241, #2196 by
@wazazaby, #2271 by @andreynering).
## v3.43.3 - 2025-04-27
Reverted the changes made in #2113 and #2186 that affected the
`USER_WORKING_DIR` and built-in variables. This fixes #2206, #2195, #2207 and
#2208.
## v3.43.2 - 2025-04-21
- Fixed regresion of `CLI_ARGS` being exposed as the wrong type (#2190, #2191 by
@vmaerten).
## v3.43.1 - 2025-04-21
- Significant improvements were made to the watcher. We migrated from
[watcher](https://github.com/radovskyb/watcher) to
[fsnotify](https://github.com/fsnotify/fsnotify). The former library used
polling, which means Task had a high CPU usage when watching too many files.
`fsnotify` uses proper the APIs from each operating system to watch files,
which means a much better performance. The default interval changed from 5
seconds to 100 milliseconds, because now it configures the wait time for
duplicated events, instead of the polling time (#2048 by @andreynering, #1508,
#985, #1179).
- The [Map Variables experiment](https://github.com/go-task/task/issues/1585)
was made generally available so you can now
[define map variables in your Taskfiles!](https://taskfile.dev/usage/#variables)
(#1585, #1547, #2081 by @pd93).
- Wildcards can now
[match multiple tasks](https://taskfile.dev/usage/#wildcard-arguments) (#2072,
#2121 by @pd93).
- Added the ability to
[loop over the files specified by the `generates` keyword](https://taskfile.dev/usage/#looping-over-your-tasks-sources-or-generated-files).
This works the same way as looping over sources (#2151 by @sedyh).
- Added the ability to resolve variables when defining an include variable
(#2108, #2113 by @pd93).
- A few changes have been made to the
[Remote Taskfiles experiment](https://github.com/go-task/task/issues/1317)
(#1402, #2176 by @pd93):
- Cached files are now prioritized over remote ones.
- Added an `--expiry` flag which sets the TTL for a remote file cache. By
default the value will be 0 (caching disabled). If Task is running in
offline mode or fails to make a connection, it will fallback on the cache.
- `.taskrc` files can now be used from subdirectories and will be searched for
recursively up the file tree in the same way that Taskfiles are (#2159, #2166
by @pd93).
- The default taskfile (output when using the `--init` flag) is now an embedded
file in the binary instead of being stored in the code (#2112 by @pd93).
- Improved the way we report the Task version when using the `--version` flag or
`{{.TASK_VERSION}}` variable. This should now be more consistent and easier
for package maintainers to use (#2131 by @pd93).
- Fixed a bug where globstar (`**`) matching in `sources` only resolved the
first result (#2073, #2075 by @pd93).
- Fixed a bug where sorting tasks by "none" would use the default sorting
instead of leaving tasks in the order they were defined (#2124, #2125 by
@trulede).
- Fixed Fish completion on newer Fish versions (#2130 by @atusy).
- Fixed a bug where undefined/null variables resolved to an empty string instead
of `nil` (#1911, #2144 by @pd93).
- The `USER_WORKING_DIR` special now will now properly account for the `--dir`
(`-d`) flag, if given (#2102, #2103 by @jaynis, #2186 by @andreynering).
- Fix Fish completions when `--global` (`-g`) is given (#2134 by @atusy).
- Fixed variables not available when using `defer:` (#1909, #2173 by @vmaerten).
#### Package API
- The [`Executor`](https://pkg.go.dev/github.com/go-task/task/v3#Executor) now
uses the functional options pattern (#2085, #2147, #2148 by @pd93).
- The functional options for the
[`taskfile.Reader`](https://pkg.go.dev/github.com/go-task/task/v3/taskfile#Reader)
and
[`taskfile.Snippet`](https://pkg.go.dev/github.com/go-task/task/v3/taskfile#Snippet)
types no longer have the `Reader`/`Snippet` respective prefixes (#2148 by
@pd93).
- [`taskfile.Reader`](https://pkg.go.dev/github.com/go-task/task/v3/taskfile#Reader)
no longer accepts a
[`taskfile.Node`](https://pkg.go.dev/github.com/go-task/task/v3/taskfile#Node).
Instead nodes are passed directly into the
[`Reader.Read`](https://pkg.go.dev/github.com/go-task/task/v3/taskfile#Reader.Read)
method (#2169 by @pd93).
- [`Reader.Read`](https://pkg.go.dev/github.com/go-task/task/v3/taskfile#Reader.Read)
also now accepts a [`context.Context`](https://pkg.go.dev/context#Context)
(#2176 by @pd93).
## v3.42.1 - 2025-03-10
- Fixed a bug where some special variables caused a type error when used global
variables (#2106, #2107 by @pd93).
## v3.42.0 - 2025-03-08
- Made `--init` less verbose by default and respect `--silent` and `--verbose`

View File

@@ -1,6 +1,6 @@
<div align="center">
<a href="https://taskfile.dev">
<img src="website/static/img/logo.svg" width="200px" height="200px" />
<img src="website/src/public/img/logo.svg" width="200px" height="200px" />
</a>
<h1>Task</h1>
@@ -19,7 +19,7 @@
<tr>
<td align="center" valign="middle">
<a target="_blank" href="https://devowl.io">
<img src="/website/static/img/devowl.io.svg" height="100px" title="devowl.io" />
<img src="https://devowl.io/wp-content/uploads/meta/favicon.webp" height="100px" title="devowl.io" />
</a>
</td>
</tr>

View File

@@ -8,6 +8,7 @@ includes:
vars:
BIN: "{{.ROOT_DIR}}/bin"
GOTESTSUM_FORMAT: '{{if .CI}}github-actions{{else}}pkgname{{end}}'
env:
CGO_ENABLED: '0'
@@ -18,6 +19,11 @@ tasks:
- task: lint
- task: test
run:
desc: Runs Task
cmds:
- go run ./cmd/task {{.CLI_ARGS}}
install:
desc: Installs Task
aliases: [i]
@@ -27,27 +33,44 @@ tasks:
- go install -v ./cmd/task
generate:
desc: Runs Mockery to create mocks
aliases: [gen, g]
desc: Runs all generate tasks
cmds:
- task: generate:mocks
- task: generate:fixtures
generate:mocks:
desc: Runs Mockery to create mocks
aliases: [gen:mocks, g:mocks]
deps: [install:mockery]
sources:
- "internal/fingerprint/checker.go"
generates:
- "internal/mocks/*.go"
cmds:
- "{{.BIN}}/mockery --dir ./internal/fingerprint --name SourcesCheckable"
- "{{.BIN}}/mockery --dir ./internal/fingerprint --name StatusCheckable"
- find . -type f -name *_mock.go -delete
- "{{.BIN}}/mockery"
generate:fixtures:
desc: Runs tests and generates golden fixture files
aliases: [gen:fixtures, g:fixtures]
env:
GOLDIE_UPDATE: 'true'
GOLDIE_TEMPLATE: 'true'
cmds:
- find ./testdata -name '*.golden' -delete
- go test ./...
install:mockery:
desc: Installs mockgen; a tool to generate mock files
vars:
MOCKERY_VERSION: v2.24.0
MOCKERY_VERSION: v3.2.2
env:
GOBIN: "{{.BIN}}"
status:
- go version -m {{.BIN}}/mockery | grep github.com/vektra/mockery | grep {{.MOCKERY_VERSION}}
cmds:
- go install github.com/vektra/mockery/v2@{{.MOCKERY_VERSION}}
- GOBIN="{{.BIN}}" go install github.com/vektra/mockery/v3@{{.MOCKERY_VERSION}}
mod:
desc: Downloads and tidy Go modules
@@ -68,6 +91,7 @@ tasks:
sources:
- './**/*.go'
- .golangci.yml
- go.mod
cmds:
- golangci-lint run
@@ -76,9 +100,19 @@ tasks:
sources:
- './**/*.go'
- .golangci.yml
- go.mod
cmds:
- golangci-lint run --fix
format:
desc: Runs golangci-lint and formats any Go files
aliases: [fmt, f]
sources:
- './**/*.go'
- .golangci.yml
cmds:
- golangci-lint fmt
sleepit:build:
desc: Builds the sleepit test helper
sources:
@@ -98,23 +132,37 @@ tasks:
test:
desc: Runs test suite
aliases: [t]
deps: [gotestsum:install]
sources:
- "**/*.go"
- "testdata/**/*"
cmds:
- go test ./...
- gotestsum -f '{{.GOTESTSUM_FORMAT}}' ./...
test:watch:
desc: Runs test suite with watch tests included
deps: [sleepit:build, gotestsum:install]
cmds:
- gotestsum -f '{{.GOTESTSUM_FORMAT}}' ./... -tags 'watch'
test:all:
desc: Runs test suite with signals and watch tests included
deps: [sleepit:build]
deps: [sleepit:build, gotestsum:install]
cmds:
- go test -tags 'signals watch' ./...
- gotestsum -f '{{.GOTESTSUM_FORMAT}}' -tags 'signals watch' ./...
goreleaser:test:
desc: Tests release process without publishing
cmds:
- goreleaser --snapshot --clean
gotestsum:install:
desc: Installs gotestsum
status:
- command -v gotestsum
cmds:
- go install gotest.tools/gotestsum@latest
goreleaser:install:
desc: Installs goreleaser
cmds:
@@ -149,7 +197,7 @@ tasks:
- Push the commit/tag to the repository
- Create a GitHub release
To use the task, simply run "task release:<version>" where "<version>" is is one of:
To use the task, run "task release:<version>" where "<version>" is is one of:
- "major" - Bumps the major number
- "minor" - Bumps the minor number
@@ -164,7 +212,6 @@ tasks:
Please wait for the CI to finish and then do the following:
- Copy the changelog for v{{.VERSION}} to the GitHub release
- Publish the package to NPM with `task npm:publish`
- Update and push the snapcraft manifest in https://github.com/go-task/snap/blob/main/snap/snapcraft.yaml
preconditions:
- sh: test $(git rev-parse --abbrev-ref HEAD) = "main"
@@ -183,8 +230,3 @@ tasks:
- "git push origin tag v{{.VERSION}}"
- cmd: printf "%s" '{{.COMPLETE_MESSAGE}}'
silent: true
npm:publish:
desc: Publish release to npm
cmds:
- npm publish --access=public

View File

@@ -3,10 +3,26 @@ package args
import (
"strings"
"github.com/spf13/pflag"
"mvdan.cc/sh/v3/syntax"
"github.com/go-task/task/v3"
"github.com/go-task/task/v3/taskfile/ast"
)
// Get fetches the remaining arguments after CLI parsing and splits them into
// two groups: the arguments before the double dash (--) and the arguments after
// the double dash.
func Get() ([]string, []string, error) {
args := pflag.Args()
doubleDashPos := pflag.CommandLine.ArgsLenAtDash()
if doubleDashPos == -1 {
return args, nil, nil
}
return args[:doubleDashPos], args[doubleDashPos:], nil
}
// Parse parses command line argument: tasks and global variables
func Parse(args ...string) ([]*task.Call, *ast.Vars) {
calls := []*task.Call{}
@@ -25,6 +41,18 @@ func Parse(args ...string) ([]*task.Call, *ast.Vars) {
return calls, globals
}
func ToQuotedString(args []string) (string, error) {
var quotedCliArgs []string
for _, arg := range args {
quotedCliArg, err := syntax.Quote(arg, syntax.LangBash)
if err != nil {
return "", err
}
quotedCliArgs = append(quotedCliArgs, quotedCliArg)
}
return strings.Join(quotedCliArgs, " "), nil
}
func splitVar(s string) (string, string) {
pair := strings.SplitN(s, "=", 2)
return pair[0], pair[1]

View File

@@ -3,13 +3,11 @@ package main
import (
"fmt"
"os"
"os/exec"
"regexp"
"strings"
"time"
"github.com/Masterminds/semver/v3"
"github.com/otiai10/copy"
"github.com/spf13/pflag"
"github.com/go-task/task/v3/errors"
@@ -17,15 +15,11 @@ import (
const (
changelogSource = "CHANGELOG.md"
changelogTarget = "website/docs/changelog.mdx"
docsSource = "website/docs"
docsTarget = "website/versioned_docs/version-latest"
changelogTarget = "website/src/docs/changelog.md"
versionFile = "internal/version/version.txt"
)
var (
changelogReleaseRegex = regexp.MustCompile(`## Unreleased`)
versionRegex = regexp.MustCompile(`(?m)^ "version": "\d+\.\d+\.\d+",$`)
)
var changelogReleaseRegex = regexp.MustCompile(`## Unreleased`)
// Flags
var (
@@ -49,7 +43,7 @@ func release() error {
return errors.New("error: expected version number")
}
version, err := getVersion()
version, err := getVersion(versionFile)
if err != nil {
return err
}
@@ -67,28 +61,18 @@ func release() error {
return err
}
if err := setJSONVersion("package.json", version); err != nil {
return err
}
if err := setJSONVersion("package-lock.json", version); err != nil {
return err
}
if err := docs(); err != nil {
if err := setVersionFile(versionFile, version); err != nil {
return err
}
return nil
}
func getVersion() (*semver.Version, error) {
cmd := exec.Command("git", "describe", "--tags", "--abbrev=0")
b, err := cmd.Output()
func getVersion(filename string) (*semver.Version, error) {
b, err := os.ReadFile(filename)
if err != nil {
return nil, err
}
return semver.NewVersion(strings.TrimSpace(string(b)))
}
@@ -144,26 +128,6 @@ func changelog(version *semver.Version) error {
return os.WriteFile(changelogTarget, []byte(changelog), 0o644)
}
func setJSONVersion(fileName string, version *semver.Version) error {
// Read the JSON file
b, err := os.ReadFile(fileName)
if err != nil {
return err
}
// Replace the version
new := versionRegex.ReplaceAllString(string(b), fmt.Sprintf(` "version": "%s",`, version.String()))
// Write the JSON file
return os.WriteFile(fileName, []byte(new), 0o644)
}
func docs() error {
if err := os.RemoveAll(docsTarget); err != nil {
return err
}
if err := copy.Copy(docsSource, docsTarget); err != nil {
return err
}
return nil
func setVersionFile(fileName string, version *semver.Version) error {
return os.WriteFile(fileName, []byte(version.String()+"\n"), 0o644)
}

View File

@@ -5,21 +5,18 @@ import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/spf13/pflag"
"mvdan.cc/sh/v3/syntax"
"github.com/go-task/task/v3"
"github.com/go-task/task/v3/args"
"github.com/go-task/task/v3/errors"
"github.com/go-task/task/v3/internal/experiments"
"github.com/go-task/task/v3/experiments"
"github.com/go-task/task/v3/internal/filepathext"
"github.com/go-task/task/v3/internal/flags"
"github.com/go-task/task/v3/internal/logger"
"github.com/go-task/task/v3/internal/sort"
ver "github.com/go-task/task/v3/internal/version"
"github.com/go-task/task/v3/taskfile"
"github.com/go-task/task/v3/internal/tui"
"github.com/go-task/task/v3/internal/version"
"github.com/go-task/task/v3/taskfile/ast"
)
@@ -57,11 +54,12 @@ func run() error {
return err
}
dir := flags.Dir
entrypoint := flags.Entrypoint
if err := experiments.Validate(); err != nil {
log.Warnf("%s\n", err.Error())
}
if flags.Version {
fmt.Printf("Task version: %s\n", ver.GetVersionWithSum())
fmt.Println(version.GetVersionWithBuildInfo())
return nil
}
@@ -79,7 +77,7 @@ func run() error {
if err != nil {
return err
}
args, _, err := getArgs()
args, _, err := args.Get()
if err != nil {
return err
}
@@ -104,6 +102,10 @@ func run() error {
return nil
}
if flags.TUI {
return tui.Run()
}
if flags.Completion != "" {
script, err := task.Completion(flags.Completion)
if err != nil {
@@ -113,83 +115,30 @@ func run() error {
return nil
}
if flags.Global {
home, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("task: Failed to get user home directory: %w", err)
}
dir = home
}
if err := experiments.Validate(); err != nil {
log.Warnf("%s\n", err.Error())
}
var taskSorter sort.Sorter
switch flags.TaskSort {
case "none":
taskSorter = nil
case "alphanumeric":
taskSorter = sort.AlphaNumeric
}
e := task.Executor{
Dir: dir,
Entrypoint: entrypoint,
Force: flags.Force,
ForceAll: flags.ForceAll,
Insecure: flags.Insecure,
Download: flags.Download,
Offline: flags.Offline,
Timeout: flags.Timeout,
Watch: flags.Watch,
Verbose: flags.Verbose,
Silent: flags.Silent,
AssumeYes: flags.AssumeYes,
Dry: flags.Dry || flags.Status,
Summary: flags.Summary,
Parallel: flags.Parallel,
Color: flags.Color,
Concurrency: flags.Concurrency,
Interval: flags.Interval,
Stdin: os.Stdin,
Stdout: os.Stdout,
Stderr: os.Stderr,
OutputStyle: flags.Output,
TaskSorter: taskSorter,
EnableVersionCheck: true,
}
listOptions := task.NewListOptions(flags.List, flags.ListAll, flags.ListJson, flags.NoStatus)
if err := listOptions.Validate(); err != nil {
e := task.NewExecutor(
flags.WithFlags(),
task.WithVersionCheck(true),
)
if err := e.Setup(); err != nil {
return err
}
err := e.Setup()
if err != nil {
return err
}
// If the download flag is specified, we should stop execution as soon as
// taskfile is downloaded
if flags.Download {
return nil
}
if flags.ClearCache {
cache, err := taskfile.NewCache(e.TempDir.Remote)
if err != nil {
return err
}
return cache.Clear()
}
if (listOptions.ShouldListTasks()) && flags.Silent {
return e.ListTaskNames(flags.ListAll)
cachePath := filepath.Join(e.TempDir.Remote, "remote")
return os.RemoveAll(cachePath)
}
listOptions := task.NewListOptions(
flags.List,
flags.ListAll,
flags.ListJson,
flags.NoStatus,
flags.Nested,
)
if listOptions.ShouldListTasks() {
if flags.Silent {
return e.ListTaskNames(flags.ListAll)
}
foundTasks, err := e.ListTasks(listOptions)
if err != nil {
return err
@@ -200,24 +149,24 @@ func run() error {
return nil
}
var (
calls []*task.Call
globals *ast.Vars
)
tasksAndVars, cliArgs, err := getArgs()
// Parse the remaining arguments
cliArgsPreDash, cliArgsPostDash, err := args.Get()
if err != nil {
return err
}
calls, globals = args.Parse(tasksAndVars...)
calls, globals := args.Parse(cliArgsPreDash...)
// If there are no calls, run the default task instead
if len(calls) == 0 {
calls = append(calls, &task.Call{Task: "default"})
}
globals.Set("CLI_ARGS", ast.Var{Value: cliArgs})
cliArgsPostDashQuoted, err := args.ToQuotedString(cliArgsPostDash)
if err != nil {
return err
}
globals.Set("CLI_ARGS", ast.Var{Value: cliArgsPostDashQuoted})
globals.Set("CLI_ARGS_LIST", ast.Var{Value: cliArgsPostDash})
globals.Set("CLI_FORCE", ast.Var{Value: flags.Force || flags.ForceAll})
globals.Set("CLI_SILENT", ast.Var{Value: flags.Silent})
globals.Set("CLI_VERBOSE", ast.Var{Value: flags.Verbose})
@@ -236,24 +185,3 @@ func run() error {
return e.Run(ctx, calls...)
}
func getArgs() ([]string, string, error) {
var (
args = pflag.Args()
doubleDashPos = pflag.CommandLine.ArgsLenAtDash()
)
if doubleDashPos == -1 {
return args, "", nil
}
var quotedCliArgs []string
for _, arg := range args[doubleDashPos:] {
quotedCliArg, err := syntax.Quote(arg, syntax.LangBash)
if err != nil {
return nil, "", err
}
quotedCliArgs = append(quotedCliArgs, quotedCliArg)
}
return args[:doubleDashPos], strings.Join(quotedCliArgs, " "), nil
}

View File

@@ -75,7 +75,7 @@ func (c *Compiler) getVariables(t *ast.Task, call *Call, evaluateShVars bool) (*
return err
}
// If the variable is already set, we can set it and return
if newVar.Value != nil {
if newVar.Value != nil || newVar.Sh == nil {
result.Set(k, ast.Var{Value: newVar.Value})
return nil
}
@@ -209,9 +209,16 @@ func (c *Compiler) getSpecialVars(t *ast.Task, call *Call) (map[string]string, e
allVars["TASK_DIR"] = filepathext.SmartJoin(c.Dir, t.Dir)
allVars["TASKFILE"] = t.Location.Taskfile
allVars["TASKFILE_DIR"] = filepath.Dir(t.Location.Taskfile)
} else {
allVars["TASK"] = ""
allVars["TASK_DIR"] = ""
allVars["TASKFILE"] = ""
allVars["TASKFILE_DIR"] = ""
}
if call != nil {
allVars["ALIAS"] = call.Task
} else {
allVars["ALIAS"] = ""
}
return allVars, nil

View File

@@ -1,8 +1,25 @@
set GO_TASK_PROGNAME task
set -l GO_TASK_PROGNAME task
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
if test "_$arg" = "_--global" -o "_$arg" = "_-g"
set global_task true
break
end
end
function __task_get_tasks --description "Prints all available tasks with their description"
# Read the list of tasks (and potential errors)
$GO_TASK_PROGNAME --list-all 2>&1 | read -lz rawOutput
if $global_task
$GO_TASK_PROGNAME --global --list-all
else
$GO_TASK_PROGNAME --list-all
end 2>&1 | read -lz rawOutput
# Return on non-zero exit code (for cases when there is no Taskfile found or etc.)
if test $status -ne 0

View File

@@ -16,21 +16,18 @@ function __task_list() {
if [[ -n "$taskfile" && -f "$taskfile" ]]; then
enabled=1
cmd+=(--taskfile "$taskfile")
else
for taskfile in {T,t}askfile{,.dist}.{yaml,yml}; do
if [[ -f "$taskfile" ]]; then
enabled=1
break
fi
done
fi
if output=$("${cmd[@]}" $_GO_TASK_COMPLETION_LIST_OPTION 2>/dev/null); then
enabled=1
fi
(( enabled )) || return 0
scripts=()
for item in "${(@)${(f)$("${cmd[@]}" $_GO_TASK_COMPLETION_LIST_OPTION)}[2,-1]#\* }"; do
for item in "${(@)${(f)output}[2,-1]#\* }"; do
task="${item%%:[[:space:]]*}"
desc="${item##[^[:space:]]##[[:space:]]##}"
scripts+=( "${task//:/\\:}:$desc" )

View File

@@ -8,6 +8,11 @@ const (
CodeUnknown // Used when no other exit code is appropriate
)
// TaskRC related exit codes
const (
CodeTaskRCNotFoundError int = iota + 50
)
// Taskfile related exit codes
const (
CodeTaskfileNotFound int = iota + 100
@@ -21,6 +26,7 @@ const (
CodeTaskfileNetworkTimeout
CodeTaskfileInvalid
CodeTaskfileCycle
CodeTaskfileDoesNotMatchChecksum
)
// Task related exit codes

View File

@@ -1,6 +1,7 @@
package errors
import (
"errors"
"fmt"
"strings"
@@ -46,8 +47,9 @@ func (err *TaskRunError) Code() int {
}
func (err *TaskRunError) TaskExitCode() int {
if c, ok := interp.IsExitStatus(err.Err); ok {
return int(c)
var exit interp.ExitStatus
if errors.As(err.Err, &exit) {
return int(exit)
}
return err.Code()
}
@@ -160,7 +162,7 @@ func (v MissingVar) String() string {
}
func (err *TaskMissingRequiredVarsError) Error() string {
var vars []string
vars := make([]string, 0, len(err.MissingVars))
for _, v := range err.MissingVars {
vars = append(vars, v.String())
}

View File

@@ -11,14 +11,18 @@ import (
// TaskfileNotFoundError is returned when no appropriate Taskfile is found when
// searching the filesystem.
type TaskfileNotFoundError struct {
URI string
Walk bool
URI string
Walk bool
AskInit bool
}
func (err TaskfileNotFoundError) Error() string {
var walkText string
if err.Walk {
walkText = " (or any of the parent directories)"
walkText = " (or any of the parent directories)."
}
if err.AskInit {
walkText += " Run `task --init` to create a new Taskfile."
}
return fmt.Sprintf(`task: No Taskfile found at %q%s`, err.URI, walkText)
}
@@ -155,19 +159,14 @@ func (err *TaskfileVersionCheckError) Code() int {
// TaskfileNetworkTimeoutError is returned when the user attempts to use a remote
// Taskfile but a network connection could not be established within the timeout.
type TaskfileNetworkTimeoutError struct {
URI string
Timeout time.Duration
CheckedCache bool
URI string
Timeout time.Duration
}
func (err *TaskfileNetworkTimeoutError) Error() string {
var cacheText string
if err.CheckedCache {
cacheText = " and no offline copy was found in the cache"
}
return fmt.Sprintf(
`task: Network connection timed out after %s while attempting to download Taskfile %q%s`,
err.Timeout, err.URI, cacheText,
`task: Network connection timed out after %s while attempting to download Taskfile %q`,
err.Timeout, err.URI,
)
}
@@ -192,3 +191,24 @@ func (err TaskfileCycleError) Error() string {
func (err TaskfileCycleError) Code() int {
return CodeTaskfileCycle
}
// TaskfileDoesNotMatchChecksum is returned when a Taskfile's checksum does not
// match the one pinned in the parent Taskfile.
type TaskfileDoesNotMatchChecksum struct {
URI string
ExpectedChecksum string
ActualChecksum string
}
func (err *TaskfileDoesNotMatchChecksum) Error() string {
return fmt.Sprintf(
"task: The checksum of the Taskfile at %q does not match!\ngot: %q\nwant: %q",
err.URI,
err.ActualChecksum,
err.ExpectedChecksum,
)
}
func (err *TaskfileDoesNotMatchChecksum) Code() int {
return CodeTaskfileDoesNotMatchChecksum
}

20
errors/errors_taskrc.go Normal file
View File

@@ -0,0 +1,20 @@
package errors
import "fmt"
type TaskRCNotFoundError struct {
URI string
Walk bool
}
func (err TaskRCNotFoundError) Error() string {
var walkText string
if err.Walk {
walkText = " (or any of the parent directories)"
}
return fmt.Sprintf(`task: No Task config file found at %q%s`, err.URI, walkText)
}
func (err TaskRCNotFoundError) Code() int {
return CodeTaskRCNotFoundError
}

504
executor.go Normal file
View File

@@ -0,0 +1,504 @@
package task
import (
"context"
"io"
"os"
"sync"
"time"
"github.com/puzpuzpuz/xsync/v3"
"github.com/sajari/fuzzy"
"github.com/go-task/task/v3/internal/logger"
"github.com/go-task/task/v3/internal/output"
"github.com/go-task/task/v3/internal/sort"
"github.com/go-task/task/v3/taskfile/ast"
)
type (
// An ExecutorOption is any type that can apply a configuration to an
// [Executor].
ExecutorOption interface {
ApplyToExecutor(*Executor)
}
// An Executor is used for processing Taskfile(s) and executing the task(s)
// within them.
Executor struct {
// Flags
Dir string
Entrypoint string
TempDir TempDir
Force bool
ForceAll bool
Insecure bool
Download bool
Offline bool
Timeout time.Duration
CacheExpiryDuration time.Duration
Watch bool
Verbose bool
Silent bool
AssumeYes bool
AssumeTerm bool // Used for testing
Dry bool
Summary bool
Parallel bool
Color bool
Concurrency int
Interval time.Duration
// I/O
Stdin io.Reader
Stdout io.Writer
Stderr io.Writer
// Internal
Taskfile *ast.Taskfile
Logger *logger.Logger
Compiler *Compiler
Output output.Output
OutputStyle ast.Output
TaskSorter sort.Sorter
UserWorkingDir string
EnableVersionCheck bool
fuzzyModel *fuzzy.Model
concurrencySemaphore chan struct{}
taskCallCount map[string]*int32
mkdirMutexMap map[string]*sync.Mutex
executionHashes map[string]context.Context
executionHashesMutex sync.Mutex
watchedDirs *xsync.MapOf[string, bool]
}
TempDir struct {
Remote string
Fingerprint string
}
)
// NewExecutor creates a new [Executor] and applies the given functional options
// to it.
func NewExecutor(opts ...ExecutorOption) *Executor {
e := &Executor{
Timeout: time.Second * 10,
Stdin: os.Stdin,
Stdout: os.Stdout,
Stderr: os.Stderr,
Logger: nil,
Compiler: nil,
Output: nil,
OutputStyle: ast.Output{},
TaskSorter: sort.AlphaNumericWithRootTasksFirst,
UserWorkingDir: "",
fuzzyModel: nil,
concurrencySemaphore: nil,
taskCallCount: map[string]*int32{},
mkdirMutexMap: map[string]*sync.Mutex{},
executionHashes: map[string]context.Context{},
executionHashesMutex: sync.Mutex{},
}
e.Options(opts...)
return e
}
// Options loops through the given [ExecutorOption] functions and applies them
// to the [Executor].
func (e *Executor) Options(opts ...ExecutorOption) {
for _, opt := range opts {
opt.ApplyToExecutor(e)
}
}
// WithDir sets the working directory of the [Executor]. By default, the
// directory is set to the user's current working directory.
func WithDir(dir string) ExecutorOption {
return &dirOption{dir}
}
type dirOption struct {
dir string
}
func (o *dirOption) ApplyToExecutor(e *Executor) {
e.Dir = o.dir
}
// WithEntrypoint sets the entrypoint (main Taskfile) of the [Executor]. By
// default, Task will search for one of the default Taskfiles in the given
// directory.
func WithEntrypoint(entrypoint string) ExecutorOption {
return &entrypointOption{entrypoint}
}
type entrypointOption struct {
entrypoint string
}
func (o *entrypointOption) ApplyToExecutor(e *Executor) {
e.Entrypoint = o.entrypoint
}
// WithTempDir sets the temporary directory that will be used by [Executor] for
// storing temporary files like checksums and cached remote files. By default,
// the temporary directory is set to the user's temporary directory.
func WithTempDir(tempDir TempDir) ExecutorOption {
return &tempDirOption{tempDir}
}
type tempDirOption struct {
tempDir TempDir
}
func (o *tempDirOption) ApplyToExecutor(e *Executor) {
e.TempDir = o.tempDir
}
// WithForce ensures that the [Executor] always runs a task, even when
// fingerprinting or prompts would normally stop it.
func WithForce(force bool) ExecutorOption {
return &forceOption{force}
}
type forceOption struct {
force bool
}
func (o *forceOption) ApplyToExecutor(e *Executor) {
e.Force = o.force
}
// WithForceAll ensures that the [Executor] always runs all tasks (including
// subtasks), even when fingerprinting or prompts would normally stop them.
func WithForceAll(forceAll bool) ExecutorOption {
return &forceAllOption{forceAll}
}
type forceAllOption struct {
forceAll bool
}
func (o *forceAllOption) ApplyToExecutor(e *Executor) {
e.ForceAll = o.forceAll
}
// WithInsecure allows the [Executor] to make insecure connections when reading
// remote taskfiles. By default, insecure connections are rejected.
func WithInsecure(insecure bool) ExecutorOption {
return &insecureOption{insecure}
}
type insecureOption struct {
insecure bool
}
func (o *insecureOption) ApplyToExecutor(e *Executor) {
e.Insecure = o.insecure
}
// WithDownload forces the [Executor] to download a fresh copy of the taskfile
// from the remote source.
func WithDownload(download bool) ExecutorOption {
return &downloadOption{download}
}
type downloadOption struct {
download bool
}
func (o *downloadOption) ApplyToExecutor(e *Executor) {
e.Download = o.download
}
// WithOffline stops the [Executor] from being able to make network connections.
// It will still be able to read local files and cached copies of remote files.
func WithOffline(offline bool) ExecutorOption {
return &offlineOption{offline}
}
type offlineOption struct {
offline bool
}
func (o *offlineOption) ApplyToExecutor(e *Executor) {
e.Offline = o.offline
}
// WithTimeout sets the [Executor]'s timeout for fetching remote taskfiles. By
// default, the timeout is set to 10 seconds.
func WithTimeout(timeout time.Duration) ExecutorOption {
return &timeoutOption{timeout}
}
type timeoutOption struct {
timeout time.Duration
}
func (o *timeoutOption) ApplyToExecutor(e *Executor) {
e.Timeout = o.timeout
}
// WithCacheExpiryDuration sets the duration after which the cache is considered
// expired. By default, the cache is 0 (disabled).
func WithCacheExpiryDuration(duration time.Duration) ExecutorOption {
return &cacheExpiryDurationOption{duration: duration}
}
type cacheExpiryDurationOption struct {
duration time.Duration
}
func (o *cacheExpiryDurationOption) ApplyToExecutor(r *Executor) {
r.CacheExpiryDuration = o.duration
}
// WithWatch tells the [Executor] to keep running in the background and watch
// for changes to the fingerprint of the tasks that are run. When changes are
// detected, a new task run is triggered.
func WithWatch(watch bool) ExecutorOption {
return &watchOption{watch}
}
type watchOption struct {
watch bool
}
func (o *watchOption) ApplyToExecutor(e *Executor) {
e.Watch = o.watch
}
// WithVerbose tells the [Executor] to output more information about the tasks
// that are run.
func WithVerbose(verbose bool) ExecutorOption {
return &verboseOption{verbose}
}
type verboseOption struct {
verbose bool
}
func (o *verboseOption) ApplyToExecutor(e *Executor) {
e.Verbose = o.verbose
}
// WithSilent tells the [Executor] to suppress all output except for the output
// of the tasks that are run.
func WithSilent(silent bool) ExecutorOption {
return &silentOption{silent}
}
type silentOption struct {
silent bool
}
func (o *silentOption) ApplyToExecutor(e *Executor) {
e.Silent = o.silent
}
// WithAssumeYes tells the [Executor] to assume "yes" for all prompts.
func WithAssumeYes(assumeYes bool) ExecutorOption {
return &assumeYesOption{assumeYes}
}
type assumeYesOption struct {
assumeYes bool
}
func (o *assumeYesOption) ApplyToExecutor(e *Executor) {
e.AssumeYes = o.assumeYes
}
// WithAssumeTerm is used for testing purposes to simulate a terminal.
func WithAssumeTerm(assumeTerm bool) ExecutorOption {
return &assumeTermOption{assumeTerm}
}
type assumeTermOption struct {
assumeTerm bool
}
func (o *assumeTermOption) ApplyToExecutor(e *Executor) {
e.AssumeTerm = o.assumeTerm
}
// WithDry tells the [Executor] to output the commands that would be run without
// actually running them.
func WithDry(dry bool) ExecutorOption {
return &dryOption{dry}
}
type dryOption struct {
dry bool
}
func (o *dryOption) ApplyToExecutor(e *Executor) {
e.Dry = o.dry
}
// WithSummary tells the [Executor] to output a summary of the given tasks
// instead of running them.
func WithSummary(summary bool) ExecutorOption {
return &summaryOption{summary}
}
type summaryOption struct {
summary bool
}
func (o *summaryOption) ApplyToExecutor(e *Executor) {
e.Summary = o.summary
}
// WithParallel tells the [Executor] to run tasks given in the same call in
// parallel.
func WithParallel(parallel bool) ExecutorOption {
return &parallelOption{parallel}
}
type parallelOption struct {
parallel bool
}
func (o *parallelOption) ApplyToExecutor(e *Executor) {
e.Parallel = o.parallel
}
// WithColor tells the [Executor] whether or not to output using colorized
// strings.
func WithColor(color bool) ExecutorOption {
return &colorOption{color}
}
type colorOption struct {
color bool
}
func (o *colorOption) ApplyToExecutor(e *Executor) {
e.Color = o.color
}
// WithConcurrency sets the maximum number of tasks that the [Executor] can run
// in parallel.
func WithConcurrency(concurrency int) ExecutorOption {
return &concurrencyOption{concurrency}
}
type concurrencyOption struct {
concurrency int
}
func (o *concurrencyOption) ApplyToExecutor(e *Executor) {
e.Concurrency = o.concurrency
}
// WithInterval sets the interval at which the [Executor] will wait for
// duplicated events before running a task.
func WithInterval(interval time.Duration) ExecutorOption {
return &intervalOption{interval}
}
type intervalOption struct {
interval time.Duration
}
func (o *intervalOption) ApplyToExecutor(e *Executor) {
e.Interval = o.interval
}
// WithOutputStyle sets the output style of the [Executor]. By default, the
// output style is set to the style defined in the Taskfile.
func WithOutputStyle(outputStyle ast.Output) ExecutorOption {
return &outputStyleOption{outputStyle}
}
type outputStyleOption struct {
outputStyle ast.Output
}
func (o *outputStyleOption) ApplyToExecutor(e *Executor) {
e.OutputStyle = o.outputStyle
}
// WithTaskSorter sets the sorter that the [Executor] will use to sort tasks. By
// default, the sorter is set to sort tasks alphabetically, but with tasks with
// no namespace (in the root Taskfile) first.
func WithTaskSorter(sorter sort.Sorter) ExecutorOption {
return &taskSorterOption{sorter}
}
type taskSorterOption struct {
sorter sort.Sorter
}
func (o *taskSorterOption) ApplyToExecutor(e *Executor) {
e.TaskSorter = o.sorter
}
// WithStdin sets the [Executor]'s standard input [io.Reader].
func WithStdin(stdin io.Reader) ExecutorOption {
return &stdinOption{stdin}
}
type stdinOption struct {
stdin io.Reader
}
func (o *stdinOption) ApplyToExecutor(e *Executor) {
e.Stdin = o.stdin
}
// WithStdout sets the [Executor]'s standard output [io.Writer].
func WithStdout(stdout io.Writer) ExecutorOption {
return &stdoutOption{stdout}
}
type stdoutOption struct {
stdout io.Writer
}
func (o *stdoutOption) ApplyToExecutor(e *Executor) {
e.Stdout = o.stdout
}
// WithStderr sets the [Executor]'s standard error [io.Writer].
func WithStderr(stderr io.Writer) ExecutorOption {
return &stderrOption{stderr}
}
type stderrOption struct {
stderr io.Writer
}
func (o *stderrOption) ApplyToExecutor(e *Executor) {
e.Stderr = o.stderr
}
// WithIO sets the [Executor]'s standard input, output, and error to the same
// [io.ReadWriter].
func WithIO(rw io.ReadWriter) ExecutorOption {
return &ioOption{rw}
}
type ioOption struct {
rw io.ReadWriter
}
func (o *ioOption) ApplyToExecutor(e *Executor) {
e.Stdin = o.rw
e.Stdout = o.rw
e.Stderr = o.rw
}
// WithVersionCheck tells the [Executor] whether or not to check the version of
func WithVersionCheck(enableVersionCheck bool) ExecutorOption {
return &versionCheckOption{enableVersionCheck}
}
type versionCheckOption struct {
enableVersionCheck bool
}
func (o *versionCheckOption) ApplyToExecutor(e *Executor) {
e.EnableVersionCheck = o.enableVersionCheck
}

989
executor_test.go Normal file
View File

@@ -0,0 +1,989 @@
package task_test
import (
"bytes"
"cmp"
"fmt"
"os"
"path/filepath"
"testing"
"github.com/sebdah/goldie/v2"
"github.com/stretchr/testify/require"
"github.com/go-task/task/v3"
"github.com/go-task/task/v3/experiments"
"github.com/go-task/task/v3/internal/filepathext"
"github.com/go-task/task/v3/taskfile/ast"
)
type (
// A ExecutorTestOption is a function that configures an [ExecutorTest].
ExecutorTestOption interface {
applyToExecutorTest(*ExecutorTest)
}
// A ExecutorTest is a test wrapper around a [task.Executor] to make it easy
// to write tests for tasks. See [NewExecutorTest] for information on
// creating and running ExecutorTests. These tests use fixture files to
// assert whether the result of a task is correct. If Task's behavior has
// been changed, the fixture files can be updated by running `task
// gen:fixtures`.
ExecutorTest struct {
TaskTest
task string
vars map[string]any
input string
executorOpts []task.ExecutorOption
wantSetupError bool
wantRunError bool
wantStatusError bool
}
)
// NewExecutorTest sets up a new [task.Executor] with the given options and runs
// a task with the given [ExecutorTestOption]s. The output of the task is
// written to a set of fixture files depending on the configuration of the test.
func NewExecutorTest(t *testing.T, opts ...ExecutorTestOption) {
t.Helper()
tt := &ExecutorTest{
task: "default",
vars: map[string]any{},
TaskTest: TaskTest{
experiments: map[*experiments.Experiment]int{},
fixtureTemplateData: map[string]any{},
},
}
// Apply the functional options
for _, opt := range opts {
opt.applyToExecutorTest(tt)
}
// Enable any experiments that have been set
for x, v := range tt.experiments {
prev := *x
*x = experiments.Experiment{
Name: prev.Name,
AllowedValues: []int{v},
Value: v,
}
t.Cleanup(func() {
*x = prev
})
}
tt.run(t)
}
// Functional options
// WithInput tells the test to create a reader with the given input. This can be
// used to simulate user input when a task requires it.
func WithInput(input string) ExecutorTestOption {
return &inputTestOption{input}
}
type inputTestOption struct {
input string
}
func (opt *inputTestOption) applyToExecutorTest(t *ExecutorTest) {
t.input = opt.input
}
// WithRunError tells the test to expect an error during the run phase of the
// task execution. A fixture will be created with the output of any errors.
func WithRunError() ExecutorTestOption {
return &runErrorTestOption{}
}
type runErrorTestOption struct{}
func (opt *runErrorTestOption) applyToExecutorTest(t *ExecutorTest) {
t.wantRunError = true
}
// WithStatusError tells the test to make an additional call to
// [task.Executor.Status] after the task has been run. A fixture will be created
// with the output of any errors.
func WithStatusError() ExecutorTestOption {
return &statusErrorTestOption{}
}
type statusErrorTestOption struct{}
func (opt *statusErrorTestOption) applyToExecutorTest(t *ExecutorTest) {
t.wantStatusError = true
}
// Helpers
// writeFixtureErrRun is a wrapper for writing the output of an error during the
// run phase of the task to a fixture file.
func (tt *ExecutorTest) writeFixtureErrRun(
t *testing.T,
g *goldie.Goldie,
err error,
) {
t.Helper()
tt.writeFixture(t, g, "err-run", []byte(err.Error()))
}
// writeFixtureStatus is a wrapper for writing the output of an error when
// making an additional call to [task.Executor.Status] to a fixture file.
func (tt *ExecutorTest) writeFixtureStatus(
t *testing.T,
g *goldie.Goldie,
status string,
) {
t.Helper()
tt.writeFixture(t, g, "err-status", []byte(status))
}
// run is the main function for running the test. It sets up the task executor,
// runs the task, and writes the output to a fixture file.
func (tt *ExecutorTest) run(t *testing.T) {
t.Helper()
f := func(t *testing.T) {
t.Helper()
var buf bytes.Buffer
opts := append(
tt.executorOpts,
task.WithStdout(&buf),
task.WithStderr(&buf),
)
// If the test has input, create a reader for it and add it to the
// executor options
if tt.input != "" {
var reader bytes.Buffer
reader.WriteString(tt.input)
opts = append(opts, task.WithStdin(&reader))
}
// Set up the task executor
e := task.NewExecutor(opts...)
// Create a golden fixture file for the output
g := goldie.New(t,
goldie.WithFixtureDir(filepath.Join(e.Dir, "testdata")),
)
// Call setup and check for errors
if err := e.Setup(); tt.wantSetupError {
require.Error(t, err)
tt.writeFixtureErrSetup(t, g, err)
tt.writeFixtureBuffer(t, g, buf)
return
} else {
require.NoError(t, err)
}
// Create the task call
vars := ast.NewVars()
for key, value := range tt.vars {
vars.Set(key, ast.Var{Value: value})
}
call := &task.Call{
Task: tt.task,
Vars: vars,
}
// Run the task and check for errors
ctx := t.Context()
if err := e.Run(ctx, call); tt.wantRunError {
require.Error(t, err)
tt.writeFixtureErrRun(t, g, err)
tt.writeFixtureBuffer(t, g, buf)
return
} else {
require.NoError(t, err)
}
// If the status flag is set, run the status check
if tt.wantStatusError {
if err := e.Status(ctx, call); err != nil {
tt.writeFixtureStatus(t, g, err.Error())
}
}
tt.writeFixtureBuffer(t, g, buf)
}
// Run the test (with a name if it has one)
if tt.name != "" {
t.Run(tt.name, f)
} else {
f(t)
}
}
func TestEmptyTask(t *testing.T) {
t.Parallel()
NewExecutorTest(t,
WithExecutorOptions(
task.WithDir("testdata/empty_task"),
),
)
}
func TestEmptyTaskfile(t *testing.T) {
t.Parallel()
NewExecutorTest(t,
WithExecutorOptions(
task.WithDir("testdata/empty_taskfile"),
),
WithSetupError(),
WithFixtureTemplating(),
)
}
func TestEnv(t *testing.T) {
t.Setenv("QUX", "from_os")
NewExecutorTest(t,
WithName("env precedence disabled"),
WithExecutorOptions(
task.WithDir("testdata/env"),
task.WithSilent(true),
),
)
NewExecutorTest(t,
WithName("env precedence enabled"),
WithExecutorOptions(
task.WithDir("testdata/env"),
task.WithSilent(true),
),
WithExperiment(&experiments.EnvPrecedence, 1),
)
}
func TestVars(t *testing.T) {
t.Parallel()
NewExecutorTest(t,
WithExecutorOptions(
task.WithDir("testdata/vars"),
task.WithSilent(true),
),
)
}
func TestRequires(t *testing.T) {
t.Parallel()
NewExecutorTest(t,
WithName("required var missing"),
WithExecutorOptions(
task.WithDir("testdata/requires"),
),
WithTask("missing-var"),
WithRunError(),
)
NewExecutorTest(t,
WithName("required var ok"),
WithExecutorOptions(
task.WithDir("testdata/requires"),
),
WithTask("missing-var"),
WithVar("FOO", "bar"),
)
NewExecutorTest(t,
WithName("fails validation"),
WithExecutorOptions(
task.WithDir("testdata/requires"),
),
WithTask("validation-var"),
WithVar("ENV", "dev"),
WithVar("FOO", "bar"),
WithRunError(),
)
NewExecutorTest(t,
WithName("passes validation"),
WithExecutorOptions(
task.WithDir("testdata/requires"),
),
WithTask("validation-var"),
WithVar("FOO", "one"),
WithVar("ENV", "dev"),
)
NewExecutorTest(t,
WithName("required var missing + fails validation"),
WithExecutorOptions(
task.WithDir("testdata/requires"),
),
WithTask("validation-var"),
WithRunError(),
)
NewExecutorTest(t,
WithName("required var missing + fails validation"),
WithExecutorOptions(
task.WithDir("testdata/requires"),
),
WithTask("validation-var-dynamic"),
WithVar("FOO", "one"),
WithVar("ENV", "dev"),
)
NewExecutorTest(t,
WithName("require before compile"),
WithExecutorOptions(
task.WithDir("testdata/requires"),
),
WithTask("require-before-compile"),
WithRunError(),
)
NewExecutorTest(t,
WithName("var defined in task"),
WithExecutorOptions(
task.WithDir("testdata/requires"),
),
WithTask("var-defined-in-task"),
)
}
// TODO: mock fs
func TestSpecialVars(t *testing.T) {
t.Parallel()
const dir = "testdata/special_vars"
const subdir = "testdata/special_vars/subdir"
tests := []string{
// Root
"print-task",
"print-root-dir",
"print-taskfile",
"print-taskfile-dir",
"print-task-dir",
// Included
"included:print-task",
"included:print-root-dir",
"included:print-taskfile",
"included:print-taskfile-dir",
}
for _, dir := range []string{dir, subdir} {
for _, test := range tests {
NewExecutorTest(t,
WithName(fmt.Sprintf("%s-%s", dir, test)),
WithExecutorOptions(
task.WithDir(dir),
task.WithSilent(true),
task.WithVersionCheck(true),
),
WithTask(test),
WithFixtureTemplating(),
)
}
}
}
func TestConcurrency(t *testing.T) {
t.Parallel()
NewExecutorTest(t,
WithExecutorOptions(
task.WithDir("testdata/concurrency"),
task.WithConcurrency(1),
),
WithPostProcessFn(PPSortedLines),
)
}
func TestParams(t *testing.T) {
t.Parallel()
NewExecutorTest(t,
WithExecutorOptions(
task.WithDir("testdata/params"),
task.WithSilent(true),
),
WithPostProcessFn(PPSortedLines),
)
}
func TestDeps(t *testing.T) {
t.Parallel()
NewExecutorTest(t,
WithExecutorOptions(
task.WithDir("testdata/deps"),
task.WithSilent(true),
),
WithPostProcessFn(PPSortedLines),
)
}
// TODO: mock fs
func TestStatus(t *testing.T) {
t.Parallel()
const dir = "testdata/status"
files := []string{
"foo.txt",
"bar.txt",
"baz.txt",
}
for _, f := range files {
path := filepathext.SmartJoin(dir, f)
_ = os.Remove(path)
if _, err := os.Stat(path); err == nil {
t.Errorf("File should not exist: %v", err)
}
}
// gen-foo creates foo.txt, and will always fail it's status check.
NewExecutorTest(t,
WithName("run gen-foo 1 silent"),
WithExecutorOptions(
task.WithDir(dir),
task.WithSilent(true),
),
WithTask("gen-foo"),
)
// gen-foo creates bar.txt, and will pass its status-check the 3. time it
// is run. It creates bar.txt, but also lists it as its source. So, the checksum
// for the file won't match before after the second run as we the file
// only exists after the first run.
NewExecutorTest(t,
WithName("run gen-bar 1 silent"),
WithExecutorOptions(
task.WithDir(dir),
task.WithSilent(true),
),
WithTask("gen-bar"),
)
// gen-silent-baz is marked as being silent, and should only produce output
// if e.Verbose is set to true.
NewExecutorTest(t,
WithName("run gen-baz silent"),
WithExecutorOptions(
task.WithDir(dir),
task.WithSilent(true),
),
WithTask("gen-silent-baz"),
)
for _, f := range files {
if _, err := os.Stat(filepathext.SmartJoin(dir, f)); err != nil {
t.Errorf("File should exist: %v", err)
}
}
// Run gen-bar a second time to produce a checksum file that matches bar.txt
NewExecutorTest(t,
WithName("run gen-bar 2 silent"),
WithExecutorOptions(
task.WithDir(dir),
task.WithSilent(true),
),
WithTask("gen-bar"),
)
// Run gen-bar a third time, to make sure we've triggered the status check.
NewExecutorTest(t,
WithName("run gen-bar 3 silent"),
WithExecutorOptions(
task.WithDir(dir),
task.WithSilent(true),
),
WithTask("gen-bar"),
)
// Now, let's remove source file, and run the task again to to prepare
// for the next test.
err := os.Remove(filepathext.SmartJoin(dir, "bar.txt"))
require.NoError(t, err)
NewExecutorTest(t,
WithName("run gen-bar 4 silent"),
WithExecutorOptions(
task.WithDir(dir),
task.WithSilent(true),
),
WithTask("gen-bar"),
)
// all: not up-to-date
NewExecutorTest(t,
WithName("run gen-foo 2"),
WithExecutorOptions(
task.WithDir(dir),
),
WithTask("gen-foo"),
)
// status: not up-to-date
NewExecutorTest(t,
WithName("run gen-foo 3"),
WithExecutorOptions(
task.WithDir(dir),
),
WithTask("gen-foo"),
)
// sources: not up-to-date
NewExecutorTest(t,
WithName("run gen-bar 5"),
WithExecutorOptions(
task.WithDir(dir),
),
WithTask("gen-bar"),
)
// all: up-to-date
NewExecutorTest(t,
WithName("run gen-bar 6"),
WithExecutorOptions(
task.WithDir(dir),
),
WithTask("gen-bar"),
)
// sources: not up-to-date, no output produced.
NewExecutorTest(t,
WithName("run gen-baz 2"),
WithExecutorOptions(
task.WithDir(dir),
),
WithTask("gen-silent-baz"),
)
// up-to-date, no output produced
NewExecutorTest(t,
WithName("run gen-baz 3"),
WithExecutorOptions(
task.WithDir(dir),
),
WithTask("gen-silent-baz"),
)
// up-to-date, output produced due to Verbose mode.
NewExecutorTest(t,
WithName("run gen-baz 4 verbose"),
WithExecutorOptions(
task.WithDir(dir),
task.WithVerbose(true),
),
WithTask("gen-silent-baz"),
WithFixtureTemplating(),
)
}
func TestPrecondition(t *testing.T) {
t.Parallel()
const dir = "testdata/precondition"
NewExecutorTest(t,
WithName("a precondition has been met"),
WithExecutorOptions(
task.WithDir(dir),
),
WithTask("foo"),
)
NewExecutorTest(t,
WithName("a precondition was not met"),
WithExecutorOptions(
task.WithDir(dir),
),
WithTask("impossible"),
WithRunError(),
)
NewExecutorTest(t,
WithName("precondition in dependency fails the task"),
WithExecutorOptions(
task.WithDir(dir),
),
WithTask("depends_on_impossible"),
WithRunError(),
)
NewExecutorTest(t,
WithName("precondition in cmd fails the task"),
WithExecutorOptions(
task.WithDir(dir),
),
WithTask("executes_failing_task_as_cmd"),
WithRunError(),
)
}
func TestAlias(t *testing.T) {
t.Parallel()
NewExecutorTest(t,
WithName("alias"),
WithExecutorOptions(
task.WithDir("testdata/alias"),
),
WithTask("f"),
)
NewExecutorTest(t,
WithName("duplicate alias"),
WithExecutorOptions(
task.WithDir("testdata/alias"),
),
WithTask("x"),
WithRunError(),
)
NewExecutorTest(t,
WithName("alias summary"),
WithExecutorOptions(
task.WithDir("testdata/alias"),
task.WithSummary(true),
),
WithTask("f"),
)
}
func TestLabel(t *testing.T) {
t.Parallel()
NewExecutorTest(t,
WithName("up to date"),
WithExecutorOptions(
task.WithDir("testdata/label_uptodate"),
),
WithTask("foo"),
)
NewExecutorTest(t,
WithName("summary"),
WithExecutorOptions(
task.WithDir("testdata/label_summary"),
task.WithSummary(true),
),
WithTask("foo"),
)
NewExecutorTest(t,
WithName("status"),
WithExecutorOptions(
task.WithDir("testdata/label_status"),
),
WithTask("foo"),
WithStatusError(),
)
NewExecutorTest(t,
WithName("var"),
WithExecutorOptions(
task.WithDir("testdata/label_var"),
),
WithTask("foo"),
)
NewExecutorTest(t,
WithName("label in summary"),
WithExecutorOptions(
task.WithDir("testdata/label_summary"),
),
WithTask("foo"),
)
}
func TestPromptInSummary(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input string
wantError bool
}{
{"test short approval", "y\n", false},
{"test long approval", "yes\n", false},
{"test uppercase approval", "Y\n", false},
{"test stops task", "n\n", true},
{"test junk value stops task", "foobar\n", true},
{"test Enter stops task", "\n", true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
opts := []ExecutorTestOption{
WithName(test.name),
WithExecutorOptions(
task.WithDir("testdata/prompt"),
task.WithAssumeTerm(true),
),
WithTask("foo"),
WithInput(test.input),
}
if test.wantError {
opts = append(opts, WithRunError())
}
NewExecutorTest(t, opts...)
})
}
}
func TestPromptWithIndirectTask(t *testing.T) {
t.Parallel()
NewExecutorTest(t,
WithExecutorOptions(
task.WithDir("testdata/prompt"),
task.WithAssumeTerm(true),
),
WithTask("bar"),
WithInput("y\n"),
)
}
func TestPromptAssumeYes(t *testing.T) {
t.Parallel()
NewExecutorTest(t,
WithName("--yes flag should skip prompt"),
WithExecutorOptions(
task.WithDir("testdata/prompt"),
task.WithAssumeTerm(true),
task.WithAssumeYes(true),
),
WithTask("foo"),
WithInput("\n"),
)
NewExecutorTest(t,
WithName("task should raise errors.TaskCancelledError"),
WithExecutorOptions(
task.WithDir("testdata/prompt"),
task.WithAssumeTerm(true),
),
WithTask("foo"),
WithInput("\n"),
WithRunError(),
)
}
func TestForCmds(t *testing.T) {
t.Parallel()
tests := []struct {
name string
wantErr bool
}{
{name: "loop-explicit"},
{name: "loop-matrix"},
{name: "loop-matrix-ref"},
{
name: "loop-matrix-ref-error",
wantErr: true,
},
{name: "loop-sources"},
{name: "loop-sources-glob"},
{name: "loop-generates"},
{name: "loop-generates-glob"},
{name: "loop-vars"},
{name: "loop-vars-sh"},
{name: "loop-task"},
{name: "loop-task-as"},
{name: "loop-different-tasks"},
}
for _, test := range tests {
opts := []ExecutorTestOption{
WithName(test.name),
WithExecutorOptions(
task.WithDir("testdata/for/cmds"),
task.WithSilent(true),
task.WithForce(true),
),
WithTask(test.name),
WithFixtureTemplating(),
}
if test.wantErr {
opts = append(opts, WithRunError())
}
NewExecutorTest(t, opts...)
}
}
func TestForDeps(t *testing.T) {
t.Parallel()
tests := []struct {
name string
wantErr bool
}{
{name: "loop-explicit"},
{name: "loop-matrix"},
{name: "loop-matrix-ref"},
{
name: "loop-matrix-ref-error",
wantErr: true,
},
{name: "loop-sources"},
{name: "loop-sources-glob"},
{name: "loop-generates"},
{name: "loop-generates-glob"},
{name: "loop-vars"},
{name: "loop-vars-sh"},
{name: "loop-task"},
{name: "loop-task-as"},
{name: "loop-different-tasks"},
}
for _, test := range tests {
opts := []ExecutorTestOption{
WithName(test.name),
WithExecutorOptions(
task.WithDir("testdata/for/deps"),
task.WithSilent(true),
task.WithForce(true),
// Force output of each dep to be grouped together to prevent interleaving
task.WithOutputStyle(ast.Output{Name: "group"}),
),
WithTask(test.name),
WithFixtureTemplating(),
WithPostProcessFn(PPSortedLines),
}
if test.wantErr {
opts = append(opts, WithRunError())
}
NewExecutorTest(t, opts...)
}
}
func TestReference(t *testing.T) {
t.Parallel()
tests := []struct {
name string
call string
}{
{
name: "reference in command",
call: "ref-cmd",
},
{
name: "reference in dependency",
call: "ref-dep",
},
{
name: "reference using templating resolver",
call: "ref-resolver",
},
{
name: "reference using templating resolver and dynamic var",
call: "ref-resolver-sh",
},
}
for _, test := range tests {
NewExecutorTest(t,
WithName(test.name),
WithExecutorOptions(
task.WithDir("testdata/var_references"),
task.WithSilent(true),
task.WithForce(true),
),
WithTask(cmp.Or(test.call, "default")),
)
}
}
func TestVarInheritance(t *testing.T) {
enableExperimentForTest(t, &experiments.EnvPrecedence, 1)
tests := []struct {
name string
call string
}{
{name: "shell"},
{name: "entrypoint-global-dotenv"},
{name: "entrypoint-global-vars"},
// We can't send env vars to a called task, so the env var is not overridden
{name: "entrypoint-task-call-vars"},
// Dotenv doesn't set variables
{name: "entrypoint-task-call-dotenv"},
{name: "entrypoint-task-call-task-vars"},
// Dotenv doesn't set variables
{name: "entrypoint-task-dotenv"},
{name: "entrypoint-task-vars"},
// {
// // Dotenv not currently allowed in included taskfiles
// name: "included-global-dotenv",
// want: "included-global-dotenv\nincluded-global-dotenv\n",
// },
{
name: "included-global-vars",
call: "included",
},
{
// We can't send env vars to a called task, so the env var is not overridden
name: "included-task-call-vars",
call: "included",
},
{
// Dotenv doesn't set variables
// Dotenv not currently allowed in included taskfiles (but doesn't error in a task)
name: "included-task-call-dotenv",
call: "included",
},
{
name: "included-task-call-task-vars",
call: "included",
},
{
// Dotenv doesn't set variables
// Somehow dotenv is working here!
name: "included-task-dotenv",
call: "included",
},
{
name: "included-task-vars",
call: "included",
},
}
t.Setenv("VAR", "shell")
t.Setenv("ENV", "shell")
for _, test := range tests {
NewExecutorTest(t,
WithName(test.name),
WithExecutorOptions(
task.WithDir(fmt.Sprintf("testdata/var_inheritance/v3/%s", test.name)),
task.WithSilent(true),
task.WithForce(true),
),
WithTask(cmp.Or(test.call, "default")),
)
}
}
func TestFuzzyModel(t *testing.T) {
t.Parallel()
NewExecutorTest(t,
WithName("fuzzy"),
WithExecutorOptions(
task.WithDir("testdata/fuzzy"),
),
WithTask("instal"),
WithRunError(),
)
NewExecutorTest(t,
WithName("not-fuzzy"),
WithExecutorOptions(
task.WithDir("testdata/fuzzy"),
),
WithTask("install"),
)
NewExecutorTest(t,
WithName("intern"),
WithExecutorOptions(
task.WithDir("testdata/fuzzy"),
),
WithTask("intern"),
WithRunError(),
)
}
func TestIncludeChecksum(t *testing.T) {
t.Parallel()
NewExecutorTest(t,
WithName("correct"),
WithExecutorOptions(
task.WithDir("testdata/includes_checksum/correct"),
),
)
NewExecutorTest(t,
WithName("incorrect"),
WithExecutorOptions(
task.WithDir("testdata/includes_checksum/incorrect"),
),
WithSetupError(),
WithFixtureTemplating(),
)
}

View File

@@ -4,6 +4,8 @@ import (
"fmt"
"slices"
"strconv"
"github.com/go-task/task/v3/taskrc/ast"
)
type Experiment struct {
@@ -14,8 +16,11 @@ type Experiment struct {
// New creates a new experiment with the given name and sets the values that can
// enable it.
func New(xName string, allowedValues ...int) Experiment {
value := experimentConfig.Experiments[xName]
func New(xName string, config *ast.TaskRC, allowedValues ...int) Experiment {
var value int
if config != nil {
value = config.Experiments[xName]
}
if value == 0 {
value, _ = strconv.Atoi(getEnv(xName))

View File

@@ -0,0 +1,140 @@
package experiments_test
import (
"strconv"
"testing"
"github.com/stretchr/testify/assert"
"github.com/go-task/task/v3/experiments"
"github.com/go-task/task/v3/taskrc/ast"
)
func TestNew(t *testing.T) {
const (
exampleExperiment = "EXAMPLE"
exampleExperimentEnv = "TASK_X_EXAMPLE"
)
tests := []struct {
name string
config *ast.TaskRC
allowedValues []int
env int
wantEnabled bool
wantActive bool
wantValid error
wantValue int
}{
{
name: `[] allowed, env=""`,
wantEnabled: false,
wantActive: false,
},
{
name: `[] allowed, env="1"`,
env: 1,
wantEnabled: false,
wantActive: false,
wantValid: &experiments.InactiveError{
Name: exampleExperiment,
},
wantValue: 1,
},
{
name: `[1] allowed, env=""`,
allowedValues: []int{1},
wantEnabled: false,
wantActive: true,
},
{
name: `[1] allowed, env="1"`,
allowedValues: []int{1},
env: 1,
wantEnabled: true,
wantActive: true,
wantValue: 1,
},
{
name: `[1] allowed, env="2"`,
allowedValues: []int{1},
env: 2,
wantEnabled: false,
wantActive: true,
wantValid: &experiments.InvalidValueError{
Name: exampleExperiment,
AllowedValues: []int{1},
Value: 2,
},
wantValue: 2,
},
{
name: `[1, 2] allowed, env="1"`,
allowedValues: []int{1, 2},
env: 1,
wantEnabled: true,
wantActive: true,
wantValue: 1,
},
{
name: `[1, 2] allowed, env="1"`,
allowedValues: []int{1, 2},
env: 2,
wantEnabled: true,
wantActive: true,
wantValue: 2,
},
{
name: `[1] allowed, config="1"`,
config: &ast.TaskRC{
Experiments: map[string]int{
exampleExperiment: 1,
},
},
allowedValues: []int{1},
wantEnabled: true,
wantActive: true,
wantValue: 1,
},
{
name: `[1] allowed, config="2"`,
config: &ast.TaskRC{
Experiments: map[string]int{
exampleExperiment: 2,
},
},
allowedValues: []int{1},
wantEnabled: false,
wantActive: true,
wantValid: &experiments.InvalidValueError{
Name: exampleExperiment,
AllowedValues: []int{1},
Value: 2,
},
wantValue: 2,
},
{
name: `[1, 2] allowed, env="1", config="2"`,
config: &ast.TaskRC{
Experiments: map[string]int{
exampleExperiment: 2,
},
},
allowedValues: []int{1, 2},
env: 1,
wantEnabled: true,
wantActive: true,
wantValue: 2,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Setenv(exampleExperimentEnv, strconv.Itoa(tt.env))
x := experiments.New(exampleExperiment, tt.config, tt.allowedValues...)
assert.Equal(t, exampleExperiment, x.Name)
assert.Equal(t, tt.wantEnabled, x.Enabled())
assert.Equal(t, tt.wantActive, x.Active())
assert.Equal(t, tt.wantValid, x.Valid())
assert.Equal(t, tt.wantValue, x.Value)
})
}
}

View File

@@ -0,0 +1,91 @@
package experiments
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/joho/godotenv"
"github.com/go-task/task/v3/taskrc"
"github.com/go-task/task/v3/taskrc/ast"
)
const envPrefix = "TASK_X_"
// Active experiments.
var (
GentleForce Experiment
RemoteTaskfiles Experiment
EnvPrecedence Experiment
)
// Inactive experiments. These are experiments that cannot be enabled, but are
// preserved for error handling.
var (
AnyVariables Experiment
MapVariables Experiment
)
// An internal list of all the initialized experiments used for iterating.
var xList []Experiment
func Parse(dir string) {
config, _ := taskrc.GetConfig(dir)
ParseWithConfig(dir, config)
}
func ParseWithConfig(dir string, config *ast.TaskRC) {
// Read any .env files
readDotEnv(dir)
// Initialize the experiments
GentleForce = New("GENTLE_FORCE", config, 1)
RemoteTaskfiles = New("REMOTE_TASKFILES", config, 1)
EnvPrecedence = New("ENV_PRECEDENCE", config, 1)
AnyVariables = New("ANY_VARIABLES", config)
MapVariables = New("MAP_VARIABLES", config)
}
// Validate checks if any experiments have been enabled while being inactive.
// If one is found, the function returns an error.
func Validate() error {
for _, x := range List() {
if err := x.Valid(); err != nil {
return err
}
}
return nil
}
func List() []Experiment {
return xList
}
func getEnv(xName string) string {
envName := fmt.Sprintf("%s%s", envPrefix, xName)
return os.Getenv(envName)
}
func getFilePath(filename, dir string) string {
if dir != "" {
return filepath.Join(dir, filename)
}
return filename
}
func readDotEnv(dir string) {
env, err := godotenv.Read(getFilePath(".env", dir))
if err != nil {
return
}
// If the env var is an experiment, set it.
for key, value := range env {
if strings.HasPrefix(key, envPrefix) {
os.Setenv(key, value)
}
}
}

235
formatter_test.go Normal file
View File

@@ -0,0 +1,235 @@
package task_test
import (
"bytes"
"path/filepath"
"testing"
"github.com/sebdah/goldie/v2"
"github.com/stretchr/testify/require"
"github.com/go-task/task/v3"
"github.com/go-task/task/v3/experiments"
"github.com/go-task/task/v3/taskfile/ast"
)
type (
// A FormatterTestOption is a function that configures an [FormatterTest].
FormatterTestOption interface {
applyToFormatterTest(*FormatterTest)
}
// A FormatterTest is a test wrapper around a [task.Executor] to make it
// easy to write tests for the task formatter. See [NewFormatterTest] for
// information on creating and running FormatterTests. These tests use
// fixture files to assert whether the result of the output is correct. If
// Task's behavior has been changed, the fixture files can be updated by
// running `task gen:fixtures`.
FormatterTest struct {
TaskTest
task string
vars map[string]any
executorOpts []task.ExecutorOption
listOptions task.ListOptions
wantSetupError bool
wantListError bool
}
)
// NewFormatterTest sets up a new [task.Executor] with the given options and
// runs a task with the given [FormatterTestOption]s. The output of the task is
// written to a set of fixture files depending on the configuration of the test.
func NewFormatterTest(t *testing.T, opts ...FormatterTestOption) {
t.Helper()
tt := &FormatterTest{
task: "default",
vars: map[string]any{},
TaskTest: TaskTest{
experiments: map[*experiments.Experiment]int{},
fixtureTemplateData: map[string]any{},
},
}
// Apply the functional options
for _, opt := range opts {
opt.applyToFormatterTest(tt)
}
// Enable any experiments that have been set
for x, v := range tt.experiments {
prev := *x
*x = experiments.Experiment{
Name: prev.Name,
AllowedValues: []int{v},
Value: v,
}
t.Cleanup(func() {
*x = prev
})
}
tt.run(t)
}
// Functional options
// WithListOptions sets the list options for the formatter.
func WithListOptions(opts task.ListOptions) FormatterTestOption {
return &listOptionsTestOption{opts}
}
type listOptionsTestOption struct {
listOptions task.ListOptions
}
func (opt *listOptionsTestOption) applyToFormatterTest(t *FormatterTest) {
t.listOptions = opt.listOptions
}
// WithListError tells the test to expect an error when running the formatter.
// A fixture will be created with the output of any errors.
func WithListError() FormatterTestOption {
return &listErrorTestOption{}
}
type listErrorTestOption struct{}
func (opt *listErrorTestOption) applyToFormatterTest(t *FormatterTest) {
t.wantListError = true
}
// Helpers
// writeFixtureErrList is a wrapper for writing the output of an error when
// running the formatter to a fixture file.
func (tt *FormatterTest) writeFixtureErrList(
t *testing.T,
g *goldie.Goldie,
err error,
) {
t.Helper()
tt.writeFixture(t, g, "err-list", []byte(err.Error()))
}
// run is the main function for running the test. It sets up the task executor,
// runs the task, and writes the output to a fixture file.
func (tt *FormatterTest) run(t *testing.T) {
t.Helper()
f := func(t *testing.T) {
t.Helper()
var buf bytes.Buffer
opts := append(
tt.executorOpts,
task.WithStdout(&buf),
task.WithStderr(&buf),
)
// Set up the task executor
e := task.NewExecutor(opts...)
// Create a golden fixture file for the output
g := goldie.New(t,
goldie.WithFixtureDir(filepath.Join(e.Dir, "testdata")),
)
// Call setup and check for errors
if err := e.Setup(); tt.wantSetupError {
require.Error(t, err)
tt.writeFixtureErrSetup(t, g, err)
tt.writeFixtureBuffer(t, g, buf)
return
} else {
require.NoError(t, err)
}
// Create the task call
vars := ast.NewVars()
for key, value := range tt.vars {
vars.Set(key, ast.Var{Value: value})
}
// Run the formatter and check for errors
if _, err := e.ListTasks(tt.listOptions); tt.wantListError {
require.Error(t, err)
tt.writeFixtureErrList(t, g, err)
tt.writeFixtureBuffer(t, g, buf)
return
} else {
require.NoError(t, err)
}
tt.writeFixtureBuffer(t, g, buf)
}
// Run the test (with a name if it has one)
if tt.name != "" {
t.Run(tt.name, f)
} else {
f(t)
}
}
func TestNoLabelInList(t *testing.T) {
t.Parallel()
NewFormatterTest(t,
WithExecutorOptions(
task.WithDir("testdata/label_list"),
),
WithListOptions(task.ListOptions{
ListOnlyTasksWithDescriptions: true,
}),
)
}
// task -al case 1: listAll list all tasks
func TestListAllShowsNoDesc(t *testing.T) {
t.Parallel()
NewFormatterTest(t,
WithExecutorOptions(
task.WithDir("testdata/list_mixed_desc"),
),
WithListOptions(task.ListOptions{
ListAllTasks: true,
}),
)
}
// task -al case 2: !listAll list some tasks (only those with desc)
func TestListCanListDescOnly(t *testing.T) {
t.Parallel()
NewFormatterTest(t,
WithExecutorOptions(
task.WithDir("testdata/list_mixed_desc"),
),
WithListOptions(task.ListOptions{
ListOnlyTasksWithDescriptions: true,
}),
)
}
func TestListDescInterpolation(t *testing.T) {
t.Parallel()
NewFormatterTest(t,
WithExecutorOptions(
task.WithDir("testdata/list_desc_interpolation"),
),
WithListOptions(task.ListOptions{
ListOnlyTasksWithDescriptions: true,
}),
)
}
func TestJsonListFormat(t *testing.T) {
t.Parallel()
NewFormatterTest(t,
WithExecutorOptions(
task.WithDir("testdata/json_list_format"),
),
WithListOptions(task.ListOptions{
FormatTaskListAsJSON: true,
}),
WithFixtureTemplating(),
)
}

66
go.mod
View File

@@ -1,62 +1,84 @@
module github.com/go-task/task/v3
go 1.23.0
go 1.24.0
require (
github.com/Ladicle/tabwriter v1.0.0
github.com/Masterminds/semver/v3 v3.3.1
github.com/alecthomas/chroma/v2 v2.15.0
github.com/Masterminds/semver/v3 v3.4.0
github.com/alecthomas/chroma/v2 v2.20.0
github.com/chainguard-dev/git-urls v1.0.2
github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1
github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3
github.com/charmbracelet/x/exp/charmtone v0.0.0-20250930200525-31788bbe6486
github.com/davecgh/go-spew v1.1.1
github.com/dominikbraun/graph v0.23.0
github.com/elliotchance/orderedmap/v3 v3.1.0
github.com/fatih/color v1.18.0
github.com/fsnotify/fsnotify v1.9.0
github.com/go-git/go-billy/v5 v5.6.2
github.com/go-git/go-git/v5 v5.14.0
github.com/go-git/go-git/v5 v5.16.2
github.com/go-task/slim-sprig/v3 v3.0.0
github.com/go-task/template v0.1.0
github.com/go-task/template v0.2.0
github.com/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1
github.com/mattn/go-zglob v0.0.6
github.com/mitchellh/hashstructure/v2 v2.0.2
github.com/otiai10/copy v1.14.1
github.com/radovskyb/watcher v1.0.7
github.com/puzpuzpuz/xsync/v3 v3.5.1
github.com/sajari/fuzzy v1.0.0
github.com/spf13/pflag v1.0.6
github.com/stretchr/testify v1.10.0
github.com/sebdah/goldie/v2 v2.7.1
github.com/spf13/pflag v1.0.10
github.com/stretchr/testify v1.11.1
github.com/zeebo/xxh3 v1.0.2
golang.org/x/sync v0.12.0
golang.org/x/term v0.30.0
golang.org/x/sync v0.17.0
golang.org/x/term v0.35.0
gopkg.in/yaml.v3 v3.0.1
mvdan.cc/sh/v3 v3.11.0
mvdan.cc/sh/moreinterp v0.0.0-20250921194925-171492585d48
mvdan.cc/sh/v3 v3.12.0
)
require (
dario.cat/mergo v1.0.0 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProtonMail/go-crypto v1.1.5 // indirect
github.com/cloudflare/circl v1.6.0 // indirect
github.com/ProtonMail/go-crypto v1.1.6 // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/charmbracelet/colorprofile v0.3.1 // indirect
github.com/charmbracelet/x/ansi v0.8.0 // indirect
github.com/charmbracelet/x/cellbuf v0.0.14-0.20250501183327-ad3bc78c6a81 // indirect
github.com/charmbracelet/x/exp/color v0.0.0-20250915100343-2c2e5896ae6e // indirect
github.com/charmbracelet/x/input v0.3.5-0.20250424101541-abb4d9a9b197 // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/charmbracelet/x/windows v0.2.1 // indirect
github.com/cloudflare/circl v1.6.1 // indirect
github.com/cyphar/filepath-securejoin v0.4.1 // indirect
github.com/dlclark/regexp2 v1.11.4 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/kevinburke/ssh_config v1.2.0 // indirect
github.com/klauspost/compress v1.17.4 // indirect
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
github.com/klauspost/pgzip v1.2.6 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/otiai10/mint v1.6.3 // indirect
github.com/pierrec/lz4/v4 v4.1.22 // indirect
github.com/pjbgf/sha1cd v0.3.2 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/sahilm/fuzzy v0.1.1 // indirect
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
github.com/skeema/knownhosts v1.3.1 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/u-root/u-root v0.14.1-0.20250807200646-5e7721023dc7 // indirect
github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect
golang.org/x/crypto v0.35.0 // indirect
golang.org/x/mod v0.22.0 // indirect
golang.org/x/net v0.35.0 // indirect
golang.org/x/sys v0.31.0 // indirect
golang.org/x/tools v0.27.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/crypto v0.37.0 // indirect
golang.org/x/net v0.39.0 // indirect
golang.org/x/sys v0.36.0 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
)

192
go.sum
View File

@@ -2,52 +2,78 @@ dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
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/Masterminds/semver/v3 v3.3.1 h1:QtNSWtVZ3nBfk8mAOu/B6v7FMJ+NHTIgUPi7rj+4nv4=
github.com/Masterminds/semver/v3 v3.3.1/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow=
github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/ProtonMail/go-crypto v1.1.5 h1:eoAQfK2dwL+tFSFpr7TbOaPNUbPiJj4fLYwwGE1FQO4=
github.com/ProtonMail/go-crypto v1.1.5/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE=
github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw=
github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE=
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/chroma/v2 v2.15.0 h1:LxXTQHFoYrstG2nnV9y2X5O94sOBzf0CIUpSTbpxvMc=
github.com/alecthomas/chroma/v2 v2.15.0/go.mod h1:gUhVLrPDXPtp/f+L1jo9xepo9gL4eLwRuGAunSZMkio=
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw=
github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA=
github.com/alecthomas/repr v0.5.1 h1:E3G4t2QbHTSNpPKBgMTln5KLkZHLOcU7r37J4pXBuIg=
github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
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/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU=
github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA=
github.com/cloudflare/circl v1.6.0 h1:cr5JKic4HI+LkINy2lg3W2jF8sHCVTBncJr5gIIq7qk=
github.com/cloudflare/circl v1.6.0/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
github.com/creack/pty v1.1.23 h1:4M6+isWdcStXEf15G/RbrMPOQj1dZ7HPZCGwE4kOeP0=
github.com/creack/pty v1.1.23/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/cyphar/filepath-securejoin v0.3.6 h1:4d9N5ykBnSp5Xn2JkhocYDkOpURL/18CYMpo6xB9uWM=
github.com/cyphar/filepath-securejoin v0.3.6/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI=
github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1 h1:swACzss0FjnyPz1enfX56GKkLiuKg5FlyVmOLIlU2kE=
github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw=
github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3 h1:5A2e3myxXMpCES+kjEWgGsaf9VgZXjZbLi5iMTH7j40=
github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3/go.mod h1:ZFDg5oPjyRYrPAa3iFrtP1DO8xy+LUQxd9JFHEcuwJY=
github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40=
github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0=
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3 h1:W6DpZX6zSkZr0iFq6JVh1vItLoxfYtNlaxOJtWp8Kis=
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3/go.mod h1:65HTtKURcv/ict9ZQhr6zT84JqIjMcJbyrZYHHKNfKA=
github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
github.com/charmbracelet/x/cellbuf v0.0.14-0.20250501183327-ad3bc78c6a81 h1:iGrflaL5jQW6crML+pZx/ulWAVZQR3CQoRGvFsr2Tyg=
github.com/charmbracelet/x/cellbuf v0.0.14-0.20250501183327-ad3bc78c6a81/go.mod h1:poPFOXFTsJsnLbkV3H2KxAAXT7pdjxxLujLocWjkyzM=
github.com/charmbracelet/x/exp/charmtone v0.0.0-20250930200525-31788bbe6486 h1:bbbzFbDGxMnOlPyuBoMJwpSmwhO0nV60D1yKBRgzNxg=
github.com/charmbracelet/x/exp/charmtone v0.0.0-20250930200525-31788bbe6486/go.mod h1:URW1lCfdUqp9aey3HTV+jRvaGLuaj2d0jVk2NTRpklY=
github.com/charmbracelet/x/exp/color v0.0.0-20250915100343-2c2e5896ae6e h1:tgRdEJh8/v6MBs2ZR7g2m1fABjnGft5wXyI+X/l5WF8=
github.com/charmbracelet/x/exp/color v0.0.0-20250915100343-2c2e5896ae6e/go.mod h1:/tsSyfR1O2EokQP9iNzNK/fnf5FGdB4w0MOaJTBRp5Q=
github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a h1:FsHEJ52OC4VuTzU8t+n5frMjLvpYWEznSr/u8tnkCYw=
github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
github.com/charmbracelet/x/input v0.3.5-0.20250424101541-abb4d9a9b197 h1:fsWj8NF5njyMVzELc7++HsvRDvgz3VcgGAUgWBDWWWM=
github.com/charmbracelet/x/input v0.3.5-0.20250424101541-abb4d9a9b197/go.mod h1:xseGeVftoP9rVI+/8WKYrJFH6ior6iERGvklwwHz5+s=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/charmbracelet/x/windows v0.2.1 h1:3x7vnbpQrjpuq/4L+I4gNsG5htYoCiA5oe9hLjAij5I=
github.com/charmbracelet/x/windows v0.2.1/go.mod h1:ptZp16h40gDYqs5TSawSVW+yiLB13j4kSMA0lSCHL0M=
github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0=
github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s=
github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dominikbraun/graph v0.23.0 h1:TdZB4pPqCLFxYhdyMFb1TBdFxp8XLcJfTTBQucVPgCo=
github.com/dominikbraun/graph v0.23.0/go.mod h1:yOjYyogZLY1LSG9E33JWZJiq5k83Qy2C6POAuiViluc=
github.com/elazarl/goproxy v1.4.0 h1:4GyuSbFa+s26+3rmYNSuUVsx+HgPrV1bk1jXI0l9wjM=
github.com/elazarl/goproxy v1.4.0/go.mod h1:X/5W/t+gzDyLfHW4DrMdpjqYjpXsURlBt9lpBDxZZZQ=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
github.com/elliotchance/orderedmap/v3 v3.1.0 h1:j4DJ5ObEmMBt/lcwIecKcoRxIQUEnw0L804lXYDt/pg=
github.com/elliotchance/orderedmap/v3 v3.1.0/go.mod h1:G+Hc2RwaZvJMcS4JpGCOyViCnGeKf0bTYCGTO4uhjSo=
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
@@ -56,22 +82,20 @@ github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UN
github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
github.com/go-git/go-git/v5 v5.13.2 h1:7O7xvsK7K+rZPKW6AQR1YyNhfywkv7B8/FsP3ki6Zv0=
github.com/go-git/go-git/v5 v5.13.2/go.mod h1:hWdW5P4YZRjmpGHwRH2v3zkWcNl6HeXaXQEMGb3NJ9A=
github.com/go-git/go-git/v5 v5.14.0 h1:/MD3lCrGjCen5WfEAzKg00MJJffKhC8gzS80ycmCi60=
github.com/go-git/go-git/v5 v5.14.0/go.mod h1:Z5Xhoia5PcWA3NF8vRLURn9E5FRhSl7dGj9ItW3Wk5k=
github.com/go-git/go-git/v5 v5.16.2 h1:fT6ZIOjE5iEnkzKyxTHK1W4HGAsPhqEqiSAssSO77hM=
github.com/go-git/go-git/v5 v5.16.2/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8=
github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/go-task/template v0.1.0 h1:ym/r2G937RZA1bsgiWedNnY9e5kxDT+3YcoAnuIetTE=
github.com/go-task/template v0.1.0/go.mod h1:RgwRaZK+kni/hJJ7/AaOE2lPQFPbAdji/DyhC6pxo4k=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/go-task/template v0.2.0 h1:xW7ek0o65FUSTbKcSNeg2Vyf/I7wYXFgLUznptvviBE=
github.com/go-task/template v0.2.0/go.mod h1:dbdoUb6qKnHQi1y6o+IdIrs0J4o/SEhSTA6bbzZmdtc=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
@@ -80,8 +104,12 @@ github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4=
github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU=
github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
@@ -89,76 +117,83 @@ 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.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
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-zglob v0.0.6 h1:mP8RnmCgho4oaUYDIDn6GNxYk+qJGUs8fJLn+twYj2A=
github.com/mattn/go-zglob v0.0.6/go.mod h1:MxxjyoXXnMxfIpxTK2GAkw1w8glPsQILx3N5wrKakiY=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
github.com/otiai10/copy v1.14.1 h1:5/7E6qsUMBaH5AnQ0sSLzzTg1oTECmcCmT6lvF45Na8=
github.com/otiai10/copy v1.14.1/go.mod h1:oQwrEDDOci3IM8dJF0d8+jnbfPDllW6vUjNc3DoZm9I=
github.com/otiai10/mint v1.6.3 h1:87qsV/aw1F5as1eH1zS/yqHY85ANKVMgkDrf9rcxbQs=
github.com/otiai10/mint v1.6.3/go.mod h1:MJm72SBthJjz8qhefc4z1PYEieWmy8Bku7CjcAqyUSM=
github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU=
github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4=
github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/radovskyb/watcher v1.0.7 h1:AYePLih6dpmS32vlHfhCeli8127LzkIgwJGcwwe8tUE=
github.com/radovskyb/watcher v1.0.7/go.mod h1:78okwvY5wPdzcb1UYnip1pvrZNIVEIh/Cm+ZuvsUYIg=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/puzpuzpuz/xsync/v3 v3.5.1 h1:GJYJZwO6IdxN/IKbneznS6yPkVC+c3zyY/j19c++5Fg=
github.com/puzpuzpuz/xsync/v3 v3.5.1/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
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/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
github.com/sajari/fuzzy v1.0.0 h1:+FmwVvJErsd0d0hAPlj4CxqxUtQY/fOoY0DwX4ykpRY=
github.com/sajari/fuzzy v1.0.0/go.mod h1:OjYR6KxoWOe9+dOlXeiCJd4dIbED4Oo8wpS89o0pwOo=
github.com/sebdah/goldie/v2 v2.7.1 h1:PkBHymaYdtvEkZV7TmyqKxdmn5/Vcj+8TpATWZjnG5E=
github.com/sebdah/goldie/v2 v2.7.1/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI=
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/skeema/knownhosts v1.3.0 h1:AM+y0rI04VksttfwjkSTNQorvGqmwATnvnAHpSgc0LY=
github.com/skeema/knownhosts v1.3.0/go.mod h1:sPINvnADmT/qYH1kfv+ePMmOBTH6Tbl7b5LvTDjFK7M=
github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8=
github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/u-root/u-root v0.14.1-0.20250807200646-5e7721023dc7 h1:ax+jBy7xFhh+Ka0IGLmH5mft+YDuqvzEjSgWuAP0nsM=
github.com/u-root/u-root v0.14.1-0.20250807200646-5e7721023dc7/go.mod h1:/0Qr7qJeDwWxoKku2xKQ4Szc+SwBE3g9VE8jNiamsmc=
github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 h1:pyC9PaHYZFgEKFdlp3G8RaCKgVpHZnecvArXvPXcFkM=
github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701/go.mod h1:P3a5rG4X7tI17Nn3aOIAYr5HbIMukwXG0urG0WuL8OA=
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs=
golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
golang.org/x/mod v0.18.0 h1:5+9lSbEzPSdWkH32vYPBwEpX8KwDbM52Ud9xBUvNlb0=
golang.org/x/mod v0.18.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw=
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -168,22 +203,15 @@ golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU=
golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s=
golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y=
golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g=
golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ=
golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA=
golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c=
golang.org/x/tools v0.27.0/go.mod h1:sUi0ZgbwW9ZPAq26Ekut+weQPR5eIM6GQLQ1Yjm1H0Q=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
@@ -194,7 +222,7 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
mvdan.cc/sh/v3 v3.10.0 h1:v9z7N1DLZ7owyLM/SXZQkBSXcwr2IGMm2LY2pmhVXj4=
mvdan.cc/sh/v3 v3.10.0/go.mod h1:z/mSSVyLFGZzqb3ZIKojjyqIx/xbmz/UHdCSv9HmqXY=
mvdan.cc/sh/v3 v3.11.0 h1:q5h+XMDRfUGUedCqFFsjoFjrhwf2Mvtt1rkMvVz0blw=
mvdan.cc/sh/v3 v3.11.0/go.mod h1:LRM+1NjoYCzuq/WZ6y44x14YNAI0NK7FLPeQSaFagGg=
mvdan.cc/sh/moreinterp v0.0.0-20250921194925-171492585d48 h1:j0QO6IrQsn7rpqs9HxwwP7ae3uZDbzRZfu0gi8IDFiE=
mvdan.cc/sh/moreinterp v0.0.0-20250921194925-171492585d48/go.mod h1:Of9PCedbLDYT8b3EyiYG64rNnx5nOp27OLCVdDrjJyo=
mvdan.cc/sh/v3 v3.12.0 h1:ejKUR7ONP5bb+UGHGEG/k9V5+pRVIyD+LsZz7o8KHrI=
mvdan.cc/sh/v3 v3.12.0/go.mod h1:Se6Cj17eYSn+sNooLZiEUnNNmNxg0imoYlTu4CyaGyg=

78
help.go
View File

@@ -24,15 +24,17 @@ type ListOptions struct {
ListAllTasks bool
FormatTaskListAsJSON bool
NoStatus bool
Nested bool
}
// NewListOptions creates a new ListOptions instance
func NewListOptions(list, listAll, listAsJson, noStatus bool) ListOptions {
func NewListOptions(list, listAll, listAsJson, noStatus, nested bool) ListOptions {
return ListOptions{
ListOnlyTasksWithDescriptions: list,
ListAllTasks: listAll,
FormatTaskListAsJSON: listAsJson,
NoStatus: noStatus,
Nested: nested,
}
}
@@ -41,20 +43,6 @@ func (o ListOptions) ShouldListTasks() bool {
return o.ListOnlyTasksWithDescriptions || o.ListAllTasks
}
// Validate validates that the collection of list-related options are in a valid configuration
func (o ListOptions) Validate() error {
if o.ListOnlyTasksWithDescriptions && o.ListAllTasks {
return fmt.Errorf("task: cannot use --list and --list-all at the same time")
}
if o.FormatTaskListAsJSON && !o.ShouldListTasks() {
return fmt.Errorf("task: --json only applies to --list or --list-all")
}
if o.NoStatus && !o.FormatTaskListAsJSON {
return fmt.Errorf("task: --no-status only applies to --json with --list or --list-all")
}
return nil
}
// Filters returns the slice of FilterFunc which filters a list
// of ast.Task according to the given ListOptions
func (o ListOptions) Filters() []FilterFunc {
@@ -77,7 +65,7 @@ func (e *Executor) ListTasks(o ListOptions) (bool, error) {
return false, err
}
if o.FormatTaskListAsJSON {
output, err := e.ToEditorOutput(tasks, o.NoStatus)
output, err := e.ToEditorOutput(tasks, o.NoStatus, o.Nested)
if err != nil {
return false, err
}
@@ -149,32 +137,17 @@ func (e *Executor) ListTaskNames(allTasks bool) error {
return nil
}
func (e *Executor) ToEditorOutput(tasks []*ast.Task, noStatus bool) (*editors.Taskfile, error) {
o := &editors.Taskfile{
Tasks: make([]editors.Task, len(tasks)),
Location: e.Taskfile.Location,
}
func (e *Executor) ToEditorOutput(tasks []*ast.Task, noStatus bool, nested bool) (*editors.Namespace, error) {
var g errgroup.Group
editorTasks := make([]editors.Task, len(tasks))
// Look over each task in parallel and turn it into an editor task
for i := range tasks {
aliases := []string{}
if len(tasks[i].Aliases) > 0 {
aliases = tasks[i].Aliases
}
g.Go(func() error {
o.Tasks[i] = editors.Task{
Name: tasks[i].Name(),
Desc: tasks[i].Desc,
Summary: tasks[i].Summary,
Aliases: aliases,
UpToDate: false,
Location: &editors.Location{
Line: tasks[i].Location.Line,
Column: tasks[i].Location.Column,
Taskfile: tasks[i].Location.Taskfile,
},
}
editorTask := editors.NewTask(tasks[i])
if noStatus {
editorTasks[i] = editorTask
return nil
}
@@ -193,10 +166,35 @@ func (e *Executor) ToEditorOutput(tasks []*ast.Task, noStatus bool) (*editors.Ta
return err
}
o.Tasks[i].UpToDate = upToDate
editorTask.UpToDate = &upToDate
editorTasks[i] = editorTask
return nil
})
}
return o, g.Wait()
if err := g.Wait(); err != nil {
return nil, err
}
// Create the root namespace
var tasksLen int
if !nested {
tasksLen = len(editorTasks)
}
rootNamespace := &editors.Namespace{
Tasks: make([]editors.Task, tasksLen),
Location: e.Taskfile.Location,
}
// Recursively add namespaces to the root namespace or if nesting is
// disabled add them all to the root namespace
for i, task := range editorTasks {
taskNamespacePath := strings.Split(task.Task, ast.NamespaceSeparator)
if nested {
rootNamespace.AddNamespace(taskNamespacePath, task)
} else {
rootNamespace.Tasks[i] = task
}
}
return rootNamespace, g.Wait()
}

18
init.go
View File

@@ -1,28 +1,18 @@
package task
import (
_ "embed"
"os"
"github.com/go-task/task/v3/errors"
"github.com/go-task/task/v3/internal/filepathext"
)
const DefaultTaskfile = `# https://taskfile.dev
version: '3'
vars:
GREETING: Hello, World!
tasks:
default:
cmds:
- echo "{{.GREETING}}"
silent: true
`
const defaultTaskFilename = "Taskfile.yml"
//go:embed taskfile/templates/default.yml
var DefaultTaskfile string
// InitTaskfile creates a new Taskfile at path.
//
// path can be either a file path or a directory path.

View File

@@ -64,21 +64,15 @@ get_binaries() {
case "$PLATFORM" in
darwin/amd64) BINARIES="task" ;;
darwin/arm64) BINARIES="task" ;;
darwin/armv5) BINARIES="task" ;;
darwin/armv6) BINARIES="task" ;;
darwin/armv7) BINARIES="task" ;;
darwin/arm) BINARIES="task" ;;
linux/386) BINARIES="task" ;;
linux/amd64) BINARIES="task" ;;
linux/arm64) BINARIES="task" ;;
linux/armv5) BINARIES="task" ;;
linux/armv6) BINARIES="task" ;;
linux/armv7) BINARIES="task" ;;
linux/arm) BINARIES="task" ;;
windows/386) BINARIES="task" ;;
windows/amd64) BINARIES="task" ;;
windows/arm64) BINARIES="task" ;;
windows/armv5) BINARIES="task" ;;
windows/armv6) BINARIES="task" ;;
windows/armv7) BINARIES="task" ;;
windows/arm) BINARIES="task" ;;
*)
log_crit "platform $PLATFORM is not supported. Make sure this script is up-to-date and file request at https://github.com/${PREFIX}/issues/new"
exit 1

View File

@@ -1,18 +1,24 @@
package editors
import (
"github.com/go-task/task/v3/taskfile/ast"
)
type (
// Taskfile wraps task list output for use in editor integrations (e.g. VSCode, etc)
Taskfile struct {
Tasks []Task `json:"tasks"`
Location string `json:"location"`
// Namespace wraps task list output for use in editor integrations (e.g. VSCode, etc)
Namespace struct {
Tasks []Task `json:"tasks"`
Namespaces map[string]*Namespace `json:"namespaces,omitempty"`
Location string `json:"location,omitempty"`
}
// Task describes a single task
Task struct {
Name string `json:"name"`
Task string `json:"task"`
Desc string `json:"desc"`
Summary string `json:"summary"`
Aliases []string `json:"aliases"`
UpToDate bool `json:"up_to_date"`
UpToDate *bool `json:"up_to_date,omitempty"`
Location *Location `json:"location"`
}
// Location describes a task's location in a taskfile
@@ -22,3 +28,59 @@ type (
Taskfile string `json:"taskfile"`
}
)
func NewTask(task *ast.Task) Task {
aliases := []string{}
if len(task.Aliases) > 0 {
aliases = task.Aliases
}
return Task{
Name: task.Name(),
Task: task.Task,
Desc: task.Desc,
Summary: task.Summary,
Aliases: aliases,
Location: &Location{
Line: task.Location.Line,
Column: task.Location.Column,
Taskfile: task.Location.Taskfile,
},
}
}
func (parent *Namespace) AddNamespace(namespacePath []string, task Task) {
if len(namespacePath) == 0 {
return
}
// If there are no child namespaces, then we have found a task and we can
// simply add it to the current namespace
if len(namespacePath) == 1 {
parent.Tasks = append(parent.Tasks, task)
return
}
// Get the key of the current namespace in the path
namespaceKey := namespacePath[0]
// Add the namespace to the parent namespaces map using the namespace key
if parent.Namespaces == nil {
parent.Namespaces = make(map[string]*Namespace, 0)
}
// Search for the current namespace in the parent namespaces map
// If it doesn't exist, create it
namespace, ok := parent.Namespaces[namespaceKey]
if !ok {
namespace = &Namespace{}
parent.Namespaces[namespaceKey] = namespace
}
// Remove the current namespace key from the namespace path.
childNamespacePath := namespacePath[1:]
// If there are no child namespaces in the task name, then we have found the
// namespace of the task and we can add it to the current namespace.
// Otherwise, we need to go deeper
namespace.AddNamespace(childNamespacePath, task)
}

2
internal/env/env.go vendored
View File

@@ -5,7 +5,7 @@ import (
"os"
"strings"
"github.com/go-task/task/v3/internal/experiments"
"github.com/go-task/task/v3/experiments"
"github.com/go-task/task/v3/taskfile/ast"
)

View File

@@ -0,0 +1,20 @@
package execext
import (
"runtime"
"strconv"
"github.com/go-task/task/v3/internal/env"
)
var useGoCoreUtils bool
func init() {
// If TASK_CORE_UTILS is set to either true or false, respect that.
// By default, enable on Windows only.
if v, err := strconv.ParseBool(env.GetTaskEnv("CORE_UTILS")); err == nil {
useGoCoreUtils = v
} else {
useGoCoreUtils = runtime.GOOS == "windows"
}
}

View File

@@ -7,17 +7,19 @@ import (
"os"
"path/filepath"
"strings"
"time"
"mvdan.cc/sh/moreinterp/coreutils"
"mvdan.cc/sh/v3/expand"
"mvdan.cc/sh/v3/interp"
"mvdan.cc/sh/v3/shell"
"mvdan.cc/sh/v3/syntax"
"github.com/go-task/task/v3/errors"
)
// RunCommandOptions is the options for the RunCommand func
// ErrNilOptions is returned when a nil options is given
var ErrNilOptions = errors.New("execext: nil options given")
// RunCommandOptions is the options for the [RunCommand] func.
type RunCommandOptions struct {
Command string
Dir string
@@ -29,9 +31,6 @@ type RunCommandOptions struct {
Stderr io.Writer
}
// ErrNilOptions is returned when a nil options is given
var ErrNilOptions = errors.New("execext: nil options given")
// RunCommand runs a shell command
func RunCommand(ctx context.Context, opts *RunCommandOptions) error {
if opts == nil {
@@ -60,7 +59,7 @@ func RunCommand(ctx context.Context, opts *RunCommandOptions) error {
r, err := interp.New(
interp.Params(params...),
interp.Env(expand.ListEnviron(environ...)),
interp.ExecHandlers(execHandler),
interp.ExecHandlers(execHandlers()...),
interp.OpenHandler(openHandler),
interp.StdIO(opts.Stdin, opts.Stdout, opts.Stderr),
dirOption(opts.Dir),
@@ -91,26 +90,64 @@ func RunCommand(ctx context.Context, opts *RunCommandOptions) error {
return r.Run(ctx, p)
}
// Expand is a helper to mvdan.cc/shell.Fields that returns the first field
// if available.
func Expand(s string) (string, error) {
func escape(s string) string {
s = filepath.ToSlash(s)
s = strings.ReplaceAll(s, " ", `\ `)
s = strings.ReplaceAll(s, "&", `\&`)
s = strings.ReplaceAll(s, "(", `\(`)
s = strings.ReplaceAll(s, ")", `\)`)
fields, err := shell.Fields(s, nil)
return s
}
// ExpandLiteral is a wrapper around [expand.Literal]. It will escape the input
// string, expand any shell symbols (such as '~') and resolve any environment
// variables.
func ExpandLiteral(s string) (string, error) {
if s == "" {
return "", nil
}
p := syntax.NewParser()
word, err := p.Document(strings.NewReader(s))
if err != nil {
return "", err
}
if len(fields) > 0 {
return fields[0], nil
cfg := &expand.Config{
Env: expand.FuncEnviron(os.Getenv),
ReadDir2: os.ReadDir,
GlobStar: true,
}
return "", nil
return expand.Literal(cfg, word)
}
func execHandler(next interp.ExecHandlerFunc) interp.ExecHandlerFunc {
return interp.DefaultExecHandler(15 * time.Second)
// ExpandFields is a wrapper around [expand.Fields]. It will escape the input
// string, expand any shell symbols (such as '~') and resolve any environment
// variables. It also expands brace expressions ({a.b}) and globs (*/**) and
// returns the results as a list of strings.
func ExpandFields(s string) ([]string, error) {
s = escape(s)
p := syntax.NewParser()
var words []*syntax.Word
err := p.Words(strings.NewReader(s), func(w *syntax.Word) bool {
words = append(words, w)
return true
})
if err != nil {
return nil, err
}
cfg := &expand.Config{
Env: expand.FuncEnviron(os.Getenv),
ReadDir2: os.ReadDir,
GlobStar: true,
NullGlob: true,
}
return expand.Fields(cfg, words...)
}
func execHandlers() (handlers []func(next interp.ExecHandlerFunc) interp.ExecHandlerFunc) {
if useGoCoreUtils {
handlers = append(handlers, coreutils.ExecHandler)
}
return
}
func openHandler(ctx context.Context, path string, flag int, perm os.FileMode) (io.ReadWriteCloser, error) {

View File

@@ -1,26 +0,0 @@
// This package is intended as a place to copy functions from the
// golang.org/x/exp package. Copying these functions allows us to rely on our
// own code instead of an external package that may change unpredictably in the
// future.
//
// It also prevents problems with transitive dependencies whereby a
// package that imports Task (and therefore our version of golang.org/x/exp)
// cannot import a different version of golang.org/x/exp.
//
// Finally, it serves as a place to track functions that may be able to be
// removed in the future if they are added to the standard library. This is also
// why this package is under the internal directory since these functions are
// not intended to be used outside of Task.
package exp
import "cmp"
// Keys is a copy of https://pkg.go.dev/golang.org/x/exp@v0.0.0-20240103183307-be819d1f06fc/maps#Keys.
// This is not yet included in the standard library. See https://github.com/golang/go/issues/61538.
func Keys[K cmp.Ordered, V any](m map[K]V) []K {
var keys []K
for key := range m {
keys = append(keys, key)
}
return keys
}

View File

@@ -1,75 +0,0 @@
package experiments_test
import (
"strconv"
"testing"
"github.com/stretchr/testify/assert"
"github.com/go-task/task/v3/internal/experiments"
)
func TestNew(t *testing.T) {
const (
exampleExperiment = "EXAMPLE"
exampleExperimentEnv = "TASK_X_EXAMPLE"
)
tests := []struct {
name string
allowedValues []int
value int
wantEnabled bool
wantActive bool
wantValid error
}{
{
name: `[] allowed, value=""`,
wantEnabled: false,
wantActive: false,
},
{
name: `[] allowed, value="1"`,
value: 1,
wantEnabled: false,
wantActive: false,
wantValid: &experiments.InactiveError{
Name: exampleExperiment,
},
},
{
name: `[1] allowed, value=""`,
allowedValues: []int{1},
wantEnabled: false,
wantActive: true,
},
{
name: `[1] allowed, value="1"`,
allowedValues: []int{1},
value: 1,
wantEnabled: true,
wantActive: true,
},
{
name: `[1] allowed, value="2"`,
allowedValues: []int{1},
value: 2,
wantEnabled: false,
wantActive: true,
wantValid: &experiments.InvalidValueError{
Name: exampleExperiment,
AllowedValues: []int{1},
Value: 2,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Setenv(exampleExperimentEnv, strconv.Itoa(tt.value))
x := experiments.New(exampleExperiment, tt.allowedValues...)
assert.Equal(t, exampleExperiment, x.Name)
assert.Equal(t, tt.wantEnabled, x.Enabled())
assert.Equal(t, tt.wantActive, x.Active())
assert.Equal(t, tt.wantValid, x.Valid())
})
}
}

View File

@@ -1,124 +0,0 @@
package experiments
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/Masterminds/semver/v3"
"github.com/joho/godotenv"
"github.com/spf13/pflag"
"gopkg.in/yaml.v3"
)
const envPrefix = "TASK_X_"
var defaultConfigFilenames = []string{
".taskrc.yml",
".taskrc.yaml",
}
type experimentConfigFile struct {
Experiments map[string]int `yaml:"experiments"`
Version *semver.Version
}
var (
GentleForce Experiment
RemoteTaskfiles Experiment
AnyVariables Experiment
MapVariables Experiment
EnvPrecedence Experiment
)
// An internal list of all the initialized experiments used for iterating.
var (
xList []Experiment
experimentConfig experimentConfigFile
)
func init() {
readDotEnv()
experimentConfig = readConfig()
GentleForce = New("GENTLE_FORCE", 1)
RemoteTaskfiles = New("REMOTE_TASKFILES", 1)
AnyVariables = New("ANY_VARIABLES")
MapVariables = New("MAP_VARIABLES", 1, 2)
EnvPrecedence = New("ENV_PRECEDENCE", 1)
}
// Validate checks if any experiments have been enabled while being inactive.
// If one is found, the function returns an error.
func Validate() error {
for _, x := range List() {
if err := x.Valid(); err != nil {
return err
}
}
return nil
}
func List() []Experiment {
return xList
}
func getEnv(xName string) string {
envName := fmt.Sprintf("%s%s", envPrefix, xName)
return os.Getenv(envName)
}
func getFilePath(filename string) string {
// Parse the CLI flags again to get the directory/taskfile being run
// We use a flagset here so that we can parse a subset of flags without exiting on error.
var dir, taskfile string
fs := pflag.NewFlagSet("experiments", pflag.ContinueOnError)
fs.StringVarP(&dir, "dir", "d", "", "Sets directory of execution.")
fs.StringVarP(&taskfile, "taskfile", "t", "", `Choose which Taskfile to run. Defaults to "Taskfile.yml".`)
fs.Usage = func() {}
_ = fs.Parse(os.Args[1:])
// If the directory is set, find a .env file in that directory.
if dir != "" {
return filepath.Join(dir, filename)
}
// If the taskfile is set, find a .env file in the directory containing the Taskfile.
if taskfile != "" {
return filepath.Join(filepath.Dir(taskfile), filename)
}
// Otherwise just use the current working directory.
return filename
}
func readDotEnv() {
env, _ := godotenv.Read(getFilePath(".env"))
// If the env var is an experiment, set it.
for key, value := range env {
if strings.HasPrefix(key, envPrefix) {
os.Setenv(key, value)
}
}
}
func readConfig() experimentConfigFile {
var cfg experimentConfigFile
var content []byte
var err error
for _, filename := range defaultConfigFilenames {
path := getFilePath(filename)
content, err = os.ReadFile(path)
if err == nil {
break
}
}
if err != nil {
return experimentConfigFile{}
}
if err := yaml.Unmarshal(content, &cfg); err != nil {
return experimentConfigFile{}
}
return cfg
}

View File

@@ -0,0 +1,320 @@
// Code generated by mockery; DO NOT EDIT.
// github.com/vektra/mockery
// template: testify
package fingerprint
import (
"context"
"github.com/go-task/task/v3/taskfile/ast"
mock "github.com/stretchr/testify/mock"
)
// NewMockStatusCheckable creates a new instance of MockStatusCheckable. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// The first argument is typically a *testing.T value.
func NewMockStatusCheckable(t interface {
mock.TestingT
Cleanup(func())
}) *MockStatusCheckable {
mock := &MockStatusCheckable{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}
// MockStatusCheckable is an autogenerated mock type for the StatusCheckable type
type MockStatusCheckable struct {
mock.Mock
}
type MockStatusCheckable_Expecter struct {
mock *mock.Mock
}
func (_m *MockStatusCheckable) EXPECT() *MockStatusCheckable_Expecter {
return &MockStatusCheckable_Expecter{mock: &_m.Mock}
}
// IsUpToDate provides a mock function for the type MockStatusCheckable
func (_mock *MockStatusCheckable) IsUpToDate(ctx context.Context, t *ast.Task) (bool, error) {
ret := _mock.Called(ctx, t)
if len(ret) == 0 {
panic("no return value specified for IsUpToDate")
}
var r0 bool
var r1 error
if returnFunc, ok := ret.Get(0).(func(context.Context, *ast.Task) (bool, error)); ok {
return returnFunc(ctx, t)
}
if returnFunc, ok := ret.Get(0).(func(context.Context, *ast.Task) bool); ok {
r0 = returnFunc(ctx, t)
} else {
r0 = ret.Get(0).(bool)
}
if returnFunc, ok := ret.Get(1).(func(context.Context, *ast.Task) error); ok {
r1 = returnFunc(ctx, t)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockStatusCheckable_IsUpToDate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IsUpToDate'
type MockStatusCheckable_IsUpToDate_Call struct {
*mock.Call
}
// IsUpToDate is a helper method to define mock.On call
// - ctx
// - t
func (_e *MockStatusCheckable_Expecter) IsUpToDate(ctx interface{}, t interface{}) *MockStatusCheckable_IsUpToDate_Call {
return &MockStatusCheckable_IsUpToDate_Call{Call: _e.mock.On("IsUpToDate", ctx, t)}
}
func (_c *MockStatusCheckable_IsUpToDate_Call) Run(run func(ctx context.Context, t *ast.Task)) *MockStatusCheckable_IsUpToDate_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(*ast.Task))
})
return _c
}
func (_c *MockStatusCheckable_IsUpToDate_Call) Return(b bool, err error) *MockStatusCheckable_IsUpToDate_Call {
_c.Call.Return(b, err)
return _c
}
func (_c *MockStatusCheckable_IsUpToDate_Call) RunAndReturn(run func(ctx context.Context, t *ast.Task) (bool, error)) *MockStatusCheckable_IsUpToDate_Call {
_c.Call.Return(run)
return _c
}
// NewMockSourcesCheckable creates a new instance of MockSourcesCheckable. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// The first argument is typically a *testing.T value.
func NewMockSourcesCheckable(t interface {
mock.TestingT
Cleanup(func())
}) *MockSourcesCheckable {
mock := &MockSourcesCheckable{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}
// MockSourcesCheckable is an autogenerated mock type for the SourcesCheckable type
type MockSourcesCheckable struct {
mock.Mock
}
type MockSourcesCheckable_Expecter struct {
mock *mock.Mock
}
func (_m *MockSourcesCheckable) EXPECT() *MockSourcesCheckable_Expecter {
return &MockSourcesCheckable_Expecter{mock: &_m.Mock}
}
// IsUpToDate provides a mock function for the type MockSourcesCheckable
func (_mock *MockSourcesCheckable) IsUpToDate(t *ast.Task) (bool, error) {
ret := _mock.Called(t)
if len(ret) == 0 {
panic("no return value specified for IsUpToDate")
}
var r0 bool
var r1 error
if returnFunc, ok := ret.Get(0).(func(*ast.Task) (bool, error)); ok {
return returnFunc(t)
}
if returnFunc, ok := ret.Get(0).(func(*ast.Task) bool); ok {
r0 = returnFunc(t)
} else {
r0 = ret.Get(0).(bool)
}
if returnFunc, ok := ret.Get(1).(func(*ast.Task) error); ok {
r1 = returnFunc(t)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockSourcesCheckable_IsUpToDate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IsUpToDate'
type MockSourcesCheckable_IsUpToDate_Call struct {
*mock.Call
}
// IsUpToDate is a helper method to define mock.On call
// - t
func (_e *MockSourcesCheckable_Expecter) IsUpToDate(t interface{}) *MockSourcesCheckable_IsUpToDate_Call {
return &MockSourcesCheckable_IsUpToDate_Call{Call: _e.mock.On("IsUpToDate", t)}
}
func (_c *MockSourcesCheckable_IsUpToDate_Call) Run(run func(t *ast.Task)) *MockSourcesCheckable_IsUpToDate_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(*ast.Task))
})
return _c
}
func (_c *MockSourcesCheckable_IsUpToDate_Call) Return(b bool, err error) *MockSourcesCheckable_IsUpToDate_Call {
_c.Call.Return(b, err)
return _c
}
func (_c *MockSourcesCheckable_IsUpToDate_Call) RunAndReturn(run func(t *ast.Task) (bool, error)) *MockSourcesCheckable_IsUpToDate_Call {
_c.Call.Return(run)
return _c
}
// Kind provides a mock function for the type MockSourcesCheckable
func (_mock *MockSourcesCheckable) Kind() string {
ret := _mock.Called()
if len(ret) == 0 {
panic("no return value specified for Kind")
}
var r0 string
if returnFunc, ok := ret.Get(0).(func() string); ok {
r0 = returnFunc()
} else {
r0 = ret.Get(0).(string)
}
return r0
}
// MockSourcesCheckable_Kind_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Kind'
type MockSourcesCheckable_Kind_Call struct {
*mock.Call
}
// Kind is a helper method to define mock.On call
func (_e *MockSourcesCheckable_Expecter) Kind() *MockSourcesCheckable_Kind_Call {
return &MockSourcesCheckable_Kind_Call{Call: _e.mock.On("Kind")}
}
func (_c *MockSourcesCheckable_Kind_Call) Run(run func()) *MockSourcesCheckable_Kind_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockSourcesCheckable_Kind_Call) Return(s string) *MockSourcesCheckable_Kind_Call {
_c.Call.Return(s)
return _c
}
func (_c *MockSourcesCheckable_Kind_Call) RunAndReturn(run func() string) *MockSourcesCheckable_Kind_Call {
_c.Call.Return(run)
return _c
}
// OnError provides a mock function for the type MockSourcesCheckable
func (_mock *MockSourcesCheckable) OnError(t *ast.Task) error {
ret := _mock.Called(t)
if len(ret) == 0 {
panic("no return value specified for OnError")
}
var r0 error
if returnFunc, ok := ret.Get(0).(func(*ast.Task) error); ok {
r0 = returnFunc(t)
} else {
r0 = ret.Error(0)
}
return r0
}
// MockSourcesCheckable_OnError_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OnError'
type MockSourcesCheckable_OnError_Call struct {
*mock.Call
}
// OnError is a helper method to define mock.On call
// - t
func (_e *MockSourcesCheckable_Expecter) OnError(t interface{}) *MockSourcesCheckable_OnError_Call {
return &MockSourcesCheckable_OnError_Call{Call: _e.mock.On("OnError", t)}
}
func (_c *MockSourcesCheckable_OnError_Call) Run(run func(t *ast.Task)) *MockSourcesCheckable_OnError_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(*ast.Task))
})
return _c
}
func (_c *MockSourcesCheckable_OnError_Call) Return(err error) *MockSourcesCheckable_OnError_Call {
_c.Call.Return(err)
return _c
}
func (_c *MockSourcesCheckable_OnError_Call) RunAndReturn(run func(t *ast.Task) error) *MockSourcesCheckable_OnError_Call {
_c.Call.Return(run)
return _c
}
// Value provides a mock function for the type MockSourcesCheckable
func (_mock *MockSourcesCheckable) Value(t *ast.Task) (any, error) {
ret := _mock.Called(t)
if len(ret) == 0 {
panic("no return value specified for Value")
}
var r0 any
var r1 error
if returnFunc, ok := ret.Get(0).(func(*ast.Task) (any, error)); ok {
return returnFunc(t)
}
if returnFunc, ok := ret.Get(0).(func(*ast.Task) any); ok {
r0 = returnFunc(t)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(any)
}
}
if returnFunc, ok := ret.Get(1).(func(*ast.Task) error); ok {
r1 = returnFunc(t)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockSourcesCheckable_Value_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Value'
type MockSourcesCheckable_Value_Call struct {
*mock.Call
}
// Value is a helper method to define mock.On call
// - t
func (_e *MockSourcesCheckable_Expecter) Value(t interface{}) *MockSourcesCheckable_Value_Call {
return &MockSourcesCheckable_Value_Call{Call: _e.mock.On("Value", t)}
}
func (_c *MockSourcesCheckable_Value_Call) Run(run func(t *ast.Task)) *MockSourcesCheckable_Value_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(*ast.Task))
})
return _c
}
func (_c *MockSourcesCheckable_Value_Call) Return(v any, err error) *MockSourcesCheckable_Value_Call {
_c.Call.Return(v, err)
return _c
}
func (_c *MockSourcesCheckable_Value_Call) RunAndReturn(run func(t *ast.Task) (any, error)) *MockSourcesCheckable_Value_Call {
_c.Call.Return(run)
return _c
}

View File

@@ -4,47 +4,34 @@ import (
"os"
"sort"
"github.com/mattn/go-zglob"
"github.com/go-task/task/v3/internal/execext"
"github.com/go-task/task/v3/internal/filepathext"
"github.com/go-task/task/v3/taskfile/ast"
)
func Globs(dir string, globs []*ast.Glob) ([]string, error) {
fileMap := make(map[string]bool)
resultMap := make(map[string]bool)
for _, g := range globs {
matches, err := Glob(dir, g.Glob)
matches, err := glob(dir, g.Glob)
if err != nil {
continue
}
for _, match := range matches {
fileMap[match] = !g.Negate
resultMap[match] = !g.Negate
}
}
files := make([]string, 0)
for file, includePath := range fileMap {
if includePath {
files = append(files, file)
}
}
sort.Strings(files)
return files, nil
return collectKeys(resultMap), nil
}
func Glob(dir string, g string) ([]string, error) {
files := make([]string, 0)
func glob(dir string, g string) ([]string, error) {
g = filepathext.SmartJoin(dir, g)
g, err := execext.Expand(g)
fs, err := execext.ExpandFields(g)
if err != nil {
return nil, err
}
fs, err := zglob.GlobFollowSymlinks(g)
if err != nil {
return nil, err
}
results := make(map[string]bool, len(fs))
for _, f := range fs {
info, err := os.Stat(f)
@@ -54,7 +41,18 @@ func Glob(dir string, g string) ([]string, error) {
if info.IsDir() {
continue
}
files = append(files, f)
results[f] = true
}
return files, nil
return collectKeys(results), nil
}
func collectKeys(m map[string]bool) []string {
keys := make([]string, 0, len(m))
for k, v := range m {
if v {
keys = append(keys, k)
}
}
sort.Strings(keys)
return keys
}

View File

@@ -56,7 +56,7 @@ func (checker *ChecksumChecker) IsUpToDate(t *ast.Task) (bool, error) {
if g.Negate {
continue
}
generates, err := Glob(t.Dir, g.Glob)
generates, err := glob(t.Dir, g.Glob)
if os.IsNotExist(err) {
return false, nil
}

View File

@@ -1,14 +1,12 @@
package fingerprint
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"github.com/go-task/task/v3/internal/mocks"
"github.com/go-task/task/v3/taskfile/ast"
)
@@ -31,8 +29,8 @@ func TestIsTaskUpToDate(t *testing.T) {
tests := []struct {
name string
task *ast.Task
setupMockStatusChecker func(m *mocks.StatusCheckable)
setupMockSourcesChecker func(m *mocks.SourcesCheckable)
setupMockStatusChecker func(m *MockStatusCheckable)
setupMockSourcesChecker func(m *MockSourcesCheckable)
expected bool
}{
{
@@ -52,7 +50,7 @@ func TestIsTaskUpToDate(t *testing.T) {
Sources: []*ast.Glob{{Glob: "sources"}},
},
setupMockStatusChecker: nil,
setupMockSourcesChecker: func(m *mocks.SourcesCheckable) {
setupMockSourcesChecker: func(m *MockSourcesCheckable) {
m.EXPECT().IsUpToDate(mock.Anything).Return(true, nil)
},
expected: true,
@@ -64,7 +62,7 @@ func TestIsTaskUpToDate(t *testing.T) {
Sources: []*ast.Glob{{Glob: "sources"}},
},
setupMockStatusChecker: nil,
setupMockSourcesChecker: func(m *mocks.SourcesCheckable) {
setupMockSourcesChecker: func(m *MockSourcesCheckable) {
m.EXPECT().IsUpToDate(mock.Anything).Return(false, nil)
},
expected: false,
@@ -75,7 +73,7 @@ func TestIsTaskUpToDate(t *testing.T) {
Status: []string{"status"},
Sources: nil,
},
setupMockStatusChecker: func(m *mocks.StatusCheckable) {
setupMockStatusChecker: func(m *MockStatusCheckable) {
m.EXPECT().IsUpToDate(mock.Anything, mock.Anything).Return(true, nil)
},
setupMockSourcesChecker: nil,
@@ -87,10 +85,10 @@ func TestIsTaskUpToDate(t *testing.T) {
Status: []string{"status"},
Sources: []*ast.Glob{{Glob: "sources"}},
},
setupMockStatusChecker: func(m *mocks.StatusCheckable) {
setupMockStatusChecker: func(m *MockStatusCheckable) {
m.EXPECT().IsUpToDate(mock.Anything, mock.Anything).Return(true, nil)
},
setupMockSourcesChecker: func(m *mocks.SourcesCheckable) {
setupMockSourcesChecker: func(m *MockSourcesCheckable) {
m.EXPECT().IsUpToDate(mock.Anything).Return(true, nil)
},
expected: true,
@@ -101,10 +99,10 @@ func TestIsTaskUpToDate(t *testing.T) {
Status: []string{"status"},
Sources: []*ast.Glob{{Glob: "sources"}},
},
setupMockStatusChecker: func(m *mocks.StatusCheckable) {
setupMockStatusChecker: func(m *MockStatusCheckable) {
m.EXPECT().IsUpToDate(mock.Anything, mock.Anything).Return(true, nil)
},
setupMockSourcesChecker: func(m *mocks.SourcesCheckable) {
setupMockSourcesChecker: func(m *MockSourcesCheckable) {
m.EXPECT().IsUpToDate(mock.Anything).Return(false, nil)
},
expected: false,
@@ -115,7 +113,7 @@ func TestIsTaskUpToDate(t *testing.T) {
Status: []string{"status"},
Sources: nil,
},
setupMockStatusChecker: func(m *mocks.StatusCheckable) {
setupMockStatusChecker: func(m *MockStatusCheckable) {
m.EXPECT().IsUpToDate(mock.Anything, mock.Anything).Return(false, nil)
},
setupMockSourcesChecker: nil,
@@ -127,10 +125,10 @@ func TestIsTaskUpToDate(t *testing.T) {
Status: []string{"status"},
Sources: []*ast.Glob{{Glob: "sources"}},
},
setupMockStatusChecker: func(m *mocks.StatusCheckable) {
setupMockStatusChecker: func(m *MockStatusCheckable) {
m.EXPECT().IsUpToDate(mock.Anything, mock.Anything).Return(false, nil)
},
setupMockSourcesChecker: func(m *mocks.SourcesCheckable) {
setupMockSourcesChecker: func(m *MockSourcesCheckable) {
m.EXPECT().IsUpToDate(mock.Anything).Return(true, nil)
},
expected: false,
@@ -141,10 +139,10 @@ func TestIsTaskUpToDate(t *testing.T) {
Status: []string{"status"},
Sources: []*ast.Glob{{Glob: "sources"}},
},
setupMockStatusChecker: func(m *mocks.StatusCheckable) {
setupMockStatusChecker: func(m *MockStatusCheckable) {
m.EXPECT().IsUpToDate(mock.Anything, mock.Anything).Return(false, nil)
},
setupMockSourcesChecker: func(m *mocks.SourcesCheckable) {
setupMockSourcesChecker: func(m *MockSourcesCheckable) {
m.EXPECT().IsUpToDate(mock.Anything).Return(false, nil)
},
expected: false,
@@ -154,18 +152,18 @@ func TestIsTaskUpToDate(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
mockStatusChecker := mocks.NewStatusCheckable(t)
mockStatusChecker := NewMockStatusCheckable(t)
if tt.setupMockStatusChecker != nil {
tt.setupMockStatusChecker(mockStatusChecker)
}
mockSourcesChecker := mocks.NewSourcesCheckable(t)
mockSourcesChecker := NewMockSourcesCheckable(t)
if tt.setupMockSourcesChecker != nil {
tt.setupMockSourcesChecker(mockSourcesChecker)
}
result, err := IsTaskUpToDate(
context.Background(),
t.Context(),
tt.task,
WithStatusChecker(mockStatusChecker),
WithSourcesChecker(mockSourcesChecker),

View File

@@ -4,15 +4,18 @@ import (
"cmp"
"log"
"os"
"strconv"
"path/filepath"
"time"
"github.com/spf13/pflag"
"github.com/go-task/task/v3"
"github.com/go-task/task/v3/errors"
"github.com/go-task/task/v3/internal/env"
"github.com/go-task/task/v3/internal/experiments"
"github.com/go-task/task/v3/experiments"
"github.com/go-task/task/v3/internal/sort"
"github.com/go-task/task/v3/taskfile/ast"
"github.com/go-task/task/v3/taskrc"
taskrcast "github.com/go-task/task/v3/taskrc/ast"
)
const usage = `Usage: task [flags...] [task...]
@@ -38,55 +41,78 @@ Options:
`
var (
Version bool
Help bool
Init bool
Completion string
List bool
ListAll bool
ListJson bool
TaskSort string
Status bool
NoStatus bool
Insecure bool
Force bool
ForceAll bool
Watch bool
Verbose bool
Silent bool
AssumeYes bool
Dry bool
Summary bool
ExitCode bool
Parallel bool
Concurrency int
Dir string
Entrypoint string
Output ast.Output
Color bool
Interval time.Duration
Global bool
Experiments bool
Download bool
Offline bool
ClearCache bool
Timeout time.Duration
Version bool
Help bool
Init bool
TUI bool
Completion string
List bool
ListAll bool
ListJson bool
TaskSort string
Status bool
NoStatus bool
Nested bool
Insecure bool
Force bool
ForceAll bool
Watch bool
Verbose bool
Silent bool
AssumeYes bool
Dry bool
Summary bool
ExitCode bool
Parallel bool
Concurrency int
Dir string
Entrypoint string
Output ast.Output
Color bool
Interval time.Duration
Global bool
Experiments bool
Download bool
Offline bool
ClearCache bool
Timeout time.Duration
CacheExpiryDuration time.Duration
)
func init() {
// Config files can enable experiments which alter the availability and/or
// behavior of some flags, so we need to parse the experiments before the
// flags. However, we need the --taskfile and --dir flags before we can
// parse the experiments as they can alter the location of the config files.
// Because of this circular dependency, we parse the flags twice. First, we
// get the --taskfile and --dir flags, then we parse the experiments, then
// we parse the flags again to get the full set. We use a flagset here so
// that we can parse a subset of flags without exiting on error.
var dir, entrypoint string
fs := pflag.NewFlagSet("experiments", pflag.ContinueOnError)
fs.StringVarP(&dir, "dir", "d", "", "")
fs.StringVarP(&entrypoint, "taskfile", "t", "", "")
fs.Usage = func() {}
_ = fs.Parse(os.Args[1:])
// Parse the experiments
dir = cmp.Or(dir, filepath.Dir(entrypoint))
config, _ := taskrc.GetConfig(dir)
experiments.ParseWithConfig(dir, config)
// Parse the rest of the flags
log.SetFlags(0)
log.SetOutput(os.Stderr)
pflag.Usage = func() {
log.Print(usage)
pflag.PrintDefaults()
}
offline, err := strconv.ParseBool(cmp.Or(env.GetTaskEnv("OFFLINE"), "false"))
if err != nil {
offline = false
}
pflag.BoolVar(&Version, "version", false, "Show Task version.")
pflag.BoolVarP(&Help, "help", "h", false, "Shows Task usage.")
pflag.BoolVarP(&Init, "init", "i", false, "Creates a new Taskfile.yml in the current folder.")
pflag.BoolVarP(&TUI, "tui", "T", false, "Runs Task in TUI mode.")
pflag.StringVar(&Completion, "completion", "", "Generates shell completion script.")
pflag.BoolVarP(&List, "list", "l", false, "Lists tasks with description of current Taskfile.")
pflag.BoolVarP(&ListAll, "list-all", "a", false, "Lists tasks with or without a description.")
@@ -94,23 +120,24 @@ func init() {
pflag.StringVar(&TaskSort, "sort", "", "Changes the order of the tasks when listed. [default|alphanumeric|none].")
pflag.BoolVar(&Status, "status", false, "Exits with non-zero exit code if any of the given tasks is not up-to-date.")
pflag.BoolVar(&NoStatus, "no-status", false, "Ignore status when listing tasks as JSON")
pflag.BoolVar(&Insecure, "insecure", false, "Forces Task to download Taskfiles over insecure connections.")
pflag.BoolVar(&Nested, "nested", false, "Nest namespaces when listing tasks as JSON")
pflag.BoolVar(&Insecure, "insecure", getConfig(config, func() *bool { return config.Remote.Insecure }, false), "Forces Task to download Taskfiles over insecure connections.")
pflag.BoolVarP(&Watch, "watch", "w", false, "Enables watch of the given task.")
pflag.BoolVarP(&Verbose, "verbose", "v", false, "Enables verbose mode.")
pflag.BoolVarP(&Verbose, "verbose", "v", getConfig(config, func() *bool { return config.Verbose }, false), "Enables verbose mode.")
pflag.BoolVarP(&Silent, "silent", "s", false, "Disables echoing.")
pflag.BoolVarP(&AssumeYes, "yes", "y", false, "Assume \"yes\" as answer to all prompts.")
pflag.BoolVarP(&Parallel, "parallel", "p", false, "Executes tasks provided on command line in parallel.")
pflag.BoolVarP(&Dry, "dry", "n", false, "Compiles and prints tasks in the order that they would be run, without executing them.")
pflag.BoolVar(&Summary, "summary", false, "Show summary about a task.")
pflag.BoolVarP(&ExitCode, "exit-code", "x", false, "Pass-through the exit code of the task command.")
pflag.StringVarP(&Dir, "dir", "d", "", "Sets directory of execution.")
pflag.StringVarP(&Dir, "dir", "d", "", "Sets the directory in which Task will execute and look for a Taskfile.")
pflag.StringVarP(&Entrypoint, "taskfile", "t", "", `Choose which Taskfile to run. Defaults to "Taskfile.yml".`)
pflag.StringVarP(&Output.Name, "output", "o", "", "Sets output style: [interleaved|group|prefixed].")
pflag.StringVar(&Output.Group.Begin, "output-group-begin", "", "Message template to print before a task's grouped output.")
pflag.StringVar(&Output.Group.End, "output-group-end", "", "Message template to print after a task's grouped output.")
pflag.BoolVar(&Output.Group.ErrorOnly, "output-group-error-only", false, "Swallow output from successful tasks.")
pflag.BoolVarP(&Color, "color", "c", true, "Colored output. Enabled by default. Set flag to false or use NO_COLOR=1 to disable.")
pflag.IntVarP(&Concurrency, "concurrency", "C", 0, "Limit number of tasks to run concurrently.")
pflag.IntVarP(&Concurrency, "concurrency", "C", getConfig(config, 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.BoolVarP(&Global, "global", "g", false, "Runs global Taskfile, from $HOME/{T,t}askfile.{yml,yaml}.")
pflag.BoolVar(&Experiments, "experiments", false, "Lists all the available experiments and whether or not they are enabled.")
@@ -126,11 +153,11 @@ func init() {
// Remote Taskfiles experiment will adds the "download" and "offline" flags
if experiments.RemoteTaskfiles.Enabled() {
pflag.BoolVar(&Download, "download", false, "Downloads a cached version of a remote Taskfile.")
pflag.BoolVar(&Offline, "offline", offline, "Forces Task to only use local or cached Taskfiles.")
pflag.DurationVar(&Timeout, "timeout", time.Second*10, "Timeout for downloading remote Taskfiles.")
pflag.BoolVar(&Offline, "offline", getConfig(config, func() *bool { return config.Remote.Offline }, false), "Forces Task to only use local or cached Taskfiles.")
pflag.DurationVar(&Timeout, "timeout", getConfig(config, func() *time.Duration { return config.Remote.Timeout }, time.Second*10), "Timeout for downloading remote Taskfiles.")
pflag.BoolVar(&ClearCache, "clear-cache", false, "Clear the remote cache.")
pflag.DurationVar(&CacheExpiryDuration, "expiry", getConfig(config, func() *time.Duration { return config.Remote.CacheExpiry }, 0), "Expiry duration for cached remote Taskfiles.")
}
pflag.Parse()
}
@@ -144,8 +171,7 @@ func Validate() error {
}
if Global && Dir != "" {
log.Fatal("task: You can't set both --global and --dir")
return nil
return errors.New("task: You can't set both --global and --dir")
}
if Output.Name != "group" {
@@ -160,5 +186,87 @@ func Validate() error {
}
}
if List && ListAll {
return errors.New("task: cannot use --list and --list-all at the same time")
}
if ListJson && !List && !ListAll {
return errors.New("task: --json only applies to --list or --list-all")
}
if NoStatus && !ListJson {
return errors.New("task: --no-status only applies to --json with --list or --list-all")
}
if Nested && !ListJson {
return errors.New("task: --nested only applies to --json with --list or --list-all")
}
return nil
}
// WithFlags is a special internal functional option that is used to pass flags
// from the CLI into any constructor that accepts functional options.
func WithFlags() task.ExecutorOption {
return &flagsOption{}
}
type flagsOption struct{}
func (o *flagsOption) ApplyToExecutor(e *task.Executor) {
// Set the sorter
var sorter sort.Sorter
switch TaskSort {
case "none":
sorter = sort.NoSort
case "alphanumeric":
sorter = sort.AlphaNumeric
}
// Change the directory to the user's home directory if the global flag is set
dir := Dir
if Global {
home, err := os.UserHomeDir()
if err == nil {
dir = home
}
}
e.Options(
task.WithDir(dir),
task.WithEntrypoint(Entrypoint),
task.WithForce(Force),
task.WithForceAll(ForceAll),
task.WithInsecure(Insecure),
task.WithDownload(Download),
task.WithOffline(Offline),
task.WithTimeout(Timeout),
task.WithCacheExpiryDuration(CacheExpiryDuration),
task.WithWatch(Watch),
task.WithVerbose(Verbose),
task.WithSilent(Silent),
task.WithAssumeYes(AssumeYes),
task.WithDry(Dry || Status),
task.WithSummary(Summary),
task.WithParallel(Parallel),
task.WithColor(Color),
task.WithConcurrency(Concurrency),
task.WithInterval(Interval),
task.WithOutputStyle(Output),
task.WithTaskSorter(sorter),
task.WithVersionCheck(true),
)
}
// getConfig extracts a config value directly from a pointer field with a fallback default
func getConfig[T any](config *taskrcast.TaskRC, fieldFunc func() *T, fallback T) T {
if config == nil {
return fallback
}
field := fieldFunc()
if field != nil {
return *field
}
return fallback
}

202
internal/fsext/fs.go Normal file
View File

@@ -0,0 +1,202 @@
package fsext
import (
"os"
"path/filepath"
"github.com/go-task/task/v3/internal/filepathext"
"github.com/go-task/task/v3/internal/sysinfo"
)
// DefaultDir will return the default directory given an entrypoint or
// directory. If the directory is set, it will ensure it is an absolute path and
// return it. If the entrypoint is set, but the directory is not, it will leave
// the directory blank. If both are empty, it will default the directory to the
// current working directory.
func DefaultDir(entrypoint, dir string) string {
// If the directory is set, ensure it is an absolute path
if dir != "" {
var err error
dir, err = filepath.Abs(dir)
if err != nil {
return ""
}
return dir
}
// If the entrypoint and dir are empty, we default the directory to the current working directory
if entrypoint == "" {
wd, err := os.Getwd()
if err != nil {
return ""
}
return wd
}
// If the entrypoint is set, but the directory is not, we leave the directory blank
return ""
}
// ResolveDir returns an absolute path to the directory that the task should be
// run in. If the entrypoint and dir are BOTH set, then the Taskfile will not
// sit inside the directory specified by dir and we should ensure that the dir
// is absolute. Otherwise, the dir will always be the parent directory of the
// resolved entrypoint, so we should return that parent directory.
func ResolveDir(entrypoint, resolvedEntrypoint, dir string) (string, error) {
if entrypoint != "" && dir != "" {
return filepath.Abs(dir)
}
return filepath.Dir(resolvedEntrypoint), nil
}
// Search looks for files with the given possible filenames using the given
// entrypoint and directory. If the entrypoint is set, it checks if the
// entrypoint matches a file or if it matches a directory containing one of the
// possible filenames. Otherwise, it walks up the file tree starting at the
// given directory and performs a search in each directory for the possible
// filenames until it finds a match or reaches the root directory. If the
// entrypoint and directory are both empty, it defaults the directory to the
// current working directory and performs a recursive search starting there. If
// a match is found, the absolute path to the file is returned with its
// directory. If no match is found, an error is returned.
func Search(entrypoint, dir string, possibleFilenames []string) (string, error) {
var err error
if entrypoint != "" {
entrypoint, err = SearchPath(entrypoint, possibleFilenames)
if err != nil {
return "", err
}
return entrypoint, nil
}
if dir == "" {
dir, err = os.Getwd()
if err != nil {
return "", err
}
}
entrypoint, err = SearchPathRecursively(dir, possibleFilenames)
if err != nil {
return "", err
}
return entrypoint, nil
}
// SearchAll looks for files with the given possible filenames using the given
// entrypoint and directory. If the entrypoint is set, it checks if the
// entrypoint matches a file or if it matches a directory containing one of the
// possible filenames and add it to a list of matches. It then walks up the file
// tree starting at the given directory and performs a search in each directory
// for the possible filenames until it finds a match or reaches the root
// directory. If the entrypoint and directory are both empty, it defaults the
// directory to the current working directory and performs a recursive search
// starting there. If matches are found, the absolute path to each file is added
// to the list and returned.
func SearchAll(entrypoint, dir string, possibleFilenames []string) ([]string, error) {
var err error
var entrypoints []string
if entrypoint != "" {
entrypoint, err = SearchPath(entrypoint, possibleFilenames)
if err != nil {
return nil, err
}
entrypoints = append(entrypoints, entrypoint)
}
if dir == "" {
dir, err = os.Getwd()
if err != nil {
return nil, err
}
}
paths, err := SearchNPathRecursively(dir, possibleFilenames, -1)
if err != nil {
return nil, err
}
return append(entrypoints, paths...), nil
}
// SearchPath will check if a file at the given path exists or not. If it does,
// it will return the path to it. If it does not, it will search for any files
// at the given path with any of the given possible names. If any of these match
// a file, the first matching path will be returned. If no files are found, an
// error will be returned.
func SearchPath(path string, possibleFilenames []string) (string, error) {
// Get file info about the path
fi, err := os.Stat(path)
if err != nil {
return "", err
}
// If the path exists and is a regular file, device, symlink, or named pipe,
// return the absolute path to it
if fi.Mode().IsRegular() ||
fi.Mode()&os.ModeDevice != 0 ||
fi.Mode()&os.ModeSymlink != 0 ||
fi.Mode()&os.ModeNamedPipe != 0 {
return filepath.Abs(path)
}
// If the path is a directory, check if any of the possible names exist
// in that directory
for _, filename := range possibleFilenames {
alt := filepathext.SmartJoin(path, filename)
if _, err := os.Stat(alt); err == nil {
return filepath.Abs(alt)
}
}
return "", os.ErrNotExist
}
// SearchPathRecursively walks up the directory tree starting at the given
// path, calling the Search function in each directory until it finds a matching
// file or reaches the root directory. On supported operating systems, it will
// also check if the user ID of the directory changes and abort if it does.
func SearchPathRecursively(path string, possibleFilenames []string) (string, error) {
paths, err := SearchNPathRecursively(path, possibleFilenames, 1)
if err != nil {
return "", err
}
if len(paths) == 0 {
return "", os.ErrNotExist
}
return paths[0], nil
}
// SearchNPathRecursively walks up the directory tree starting at the given
// path, calling the Search function in each directory and adding each matching
// file that it finds to a list until it reaches the root directory or the
// length of the list exceeds n. On supported operating systems, it will also
// check if the user ID of the directory changes and abort if it does.
func SearchNPathRecursively(path string, possibleFilenames []string, n int) ([]string, error) {
var paths []string
owner, err := sysinfo.Owner(path)
if err != nil {
return nil, err
}
for n == -1 || len(paths) < n {
fpath, err := SearchPath(path, possibleFilenames)
if err == nil {
paths = append(paths, fpath)
}
// Get the parent path/user id
parentPath := filepath.Dir(path)
parentOwner, err := sysinfo.Owner(parentPath)
if err != nil {
return nil, err
}
// Error if we reached the root directory and still haven't found a file
// OR if the user id of the directory changes
if path == parentPath || (parentOwner != owner) {
return paths, nil
}
owner = parentOwner
path = parentPath
}
return paths, nil
}

224
internal/fsext/fs_test.go Normal file
View File

@@ -0,0 +1,224 @@
package fsext
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
)
func TestDefaultDir(t *testing.T) {
t.Parallel()
wd, err := os.Getwd()
require.NoError(t, err)
tests := []struct {
name string
entrypoint string
dir string
expected string
}{
{
name: "default to current working directory",
entrypoint: "",
dir: "",
expected: wd,
},
{
name: "resolves relative dir path",
entrypoint: "",
dir: "./dir",
expected: filepath.Join(wd, "dir"),
},
{
name: "return entrypoint if set",
entrypoint: filepath.Join(wd, "entrypoint"),
dir: "",
expected: "",
},
{
name: "if entrypoint and dir are set",
entrypoint: filepath.Join(wd, "entrypoint"),
dir: filepath.Join(wd, "dir"),
expected: filepath.Join(wd, "dir"),
},
{
name: "if entrypoint and dir are set and dir is relative",
entrypoint: filepath.Join(wd, "entrypoint"),
dir: "./dir",
expected: filepath.Join(wd, "dir"),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
require.Equal(t, tt.expected, DefaultDir(tt.entrypoint, tt.dir))
})
}
}
func TestSearch(t *testing.T) {
t.Parallel()
wd, err := os.Getwd()
require.NoError(t, err)
tests := []struct {
name string
entrypoint string
dir string
possibleFilenames []string
expectedEntrypoint string
}{
{
name: "find foo.txt using relative entrypoint",
entrypoint: "./testdata/foo.txt",
possibleFilenames: []string{"foo.txt"},
expectedEntrypoint: filepath.Join(wd, "testdata", "foo.txt"),
},
{
name: "find foo.txt using absolute entrypoint",
entrypoint: filepath.Join(wd, "testdata", "foo.txt"),
possibleFilenames: []string{"foo.txt"},
expectedEntrypoint: filepath.Join(wd, "testdata", "foo.txt"),
},
{
name: "find foo.txt using relative dir",
dir: "./testdata",
possibleFilenames: []string{"foo.txt"},
expectedEntrypoint: filepath.Join(wd, "testdata", "foo.txt"),
},
{
name: "find foo.txt using absolute dir",
dir: filepath.Join(wd, "testdata"),
possibleFilenames: []string{"foo.txt"},
expectedEntrypoint: filepath.Join(wd, "testdata", "foo.txt"),
},
{
name: "find foo.txt using relative dir and relative entrypoint",
entrypoint: "./testdata/foo.txt",
dir: "./testdata/some/other/dir",
possibleFilenames: []string{"foo.txt"},
expectedEntrypoint: filepath.Join(wd, "testdata", "foo.txt"),
},
{
name: "find fs.go using no entrypoint or dir",
entrypoint: "",
dir: "",
possibleFilenames: []string{"fs.go"},
expectedEntrypoint: filepath.Join(wd, "fs.go"),
},
{
name: "find ../../Taskfile.yml using no entrypoint or dir by walking",
entrypoint: "",
dir: "",
possibleFilenames: []string{"Taskfile.yml"},
expectedEntrypoint: filepath.Join(wd, "..", "..", "Taskfile.yml"),
},
{
name: "find foo.txt first if listed first in possible filenames",
entrypoint: "./testdata",
possibleFilenames: []string{"foo.txt", "bar.txt"},
expectedEntrypoint: filepath.Join(wd, "testdata", "foo.txt"),
},
{
name: "find bar.txt first if listed first in possible filenames",
entrypoint: "./testdata",
possibleFilenames: []string{"bar.txt", "foo.txt"},
expectedEntrypoint: filepath.Join(wd, "testdata", "bar.txt"),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
entrypoint, err := Search(tt.entrypoint, tt.dir, tt.possibleFilenames)
require.NoError(t, err)
require.Equal(t, tt.expectedEntrypoint, entrypoint)
require.NoError(t, err)
})
}
}
func TestResolveDir(t *testing.T) {
t.Parallel()
wd, err := os.Getwd()
require.NoError(t, err)
tests := []struct {
name string
entrypoint string
resolvedEntrypoint string
dir string
expectedDir string
}{
{
name: "find foo.txt using relative entrypoint",
entrypoint: "./testdata/foo.txt",
resolvedEntrypoint: filepath.Join(wd, "testdata", "foo.txt"),
expectedDir: filepath.Join(wd, "testdata"),
},
{
name: "find foo.txt using absolute entrypoint",
entrypoint: filepath.Join(wd, "testdata", "foo.txt"),
resolvedEntrypoint: filepath.Join(wd, "testdata", "foo.txt"),
expectedDir: filepath.Join(wd, "testdata"),
},
{
name: "find foo.txt using relative dir",
resolvedEntrypoint: filepath.Join(wd, "testdata", "foo.txt"),
dir: "./testdata",
expectedDir: filepath.Join(wd, "testdata"),
},
{
name: "find foo.txt using absolute dir",
resolvedEntrypoint: filepath.Join(wd, "testdata", "foo.txt"),
dir: filepath.Join(wd, "testdata"),
expectedDir: filepath.Join(wd, "testdata"),
},
{
name: "find foo.txt using relative dir and relative entrypoint",
entrypoint: "./testdata/foo.txt",
resolvedEntrypoint: filepath.Join(wd, "testdata", "foo.txt"),
dir: "./testdata/some/other/dir",
expectedDir: filepath.Join(wd, "testdata", "some", "other", "dir"),
},
{
name: "find fs.go using no entrypoint or dir",
entrypoint: "",
resolvedEntrypoint: filepath.Join(wd, "fs.go"),
dir: "",
expectedDir: wd,
},
{
name: "find ../../Taskfile.yml using no entrypoint or dir by walking",
entrypoint: "",
resolvedEntrypoint: filepath.Join(wd, "..", "..", "Taskfile.yml"),
dir: "",
expectedDir: filepath.Join(wd, "..", ".."),
},
{
name: "find foo.txt first if listed first in possible filenames",
entrypoint: "./testdata",
resolvedEntrypoint: filepath.Join(wd, "testdata", "foo.txt"),
expectedDir: filepath.Join(wd, "testdata"),
},
{
name: "find bar.txt first if listed first in possible filenames",
entrypoint: "./testdata",
resolvedEntrypoint: filepath.Join(wd, "testdata", "bar.txt"),
expectedDir: filepath.Join(wd, "testdata"),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
dir, err := ResolveDir(tt.entrypoint, tt.resolvedEntrypoint, tt.dir)
require.NoError(t, err)
require.Equal(t, tt.expectedDir, dir)
require.NoError(t, err)
})
}
}

View File

@@ -0,0 +1,51 @@
package fsnotifyext
import (
"math"
"time"
"github.com/fsnotify/fsnotify"
)
type Deduper struct {
w *fsnotify.Watcher
waitTime time.Duration
}
func NewDeduper(w *fsnotify.Watcher, waitTime time.Duration) *Deduper {
return &Deduper{
w: w,
waitTime: waitTime,
}
}
// GetChan returns a chan of deduplicated [fsnotify.Event].
//
// [fsnotify.Chmod] operations will be skipped.
func (d *Deduper) GetChan() <-chan fsnotify.Event {
channel := make(chan fsnotify.Event)
go func() {
timers := make(map[string]*time.Timer)
for {
event, ok := <-d.w.Events
switch {
case !ok:
return
case event.Has(fsnotify.Chmod):
continue
}
timer, ok := timers[event.String()]
if !ok {
timer = time.AfterFunc(math.MaxInt64, func() { channel <- event })
timer.Stop()
timers[event.String()] = timer
}
timer.Reset(d.waitTime)
}
}()
return channel
}

View File

@@ -12,8 +12,8 @@ import (
"github.com/fatih/color"
"github.com/go-task/task/v3/errors"
"github.com/go-task/task/v3/experiments"
"github.com/go-task/task/v3/internal/env"
"github.com/go-task/task/v3/internal/experiments"
"github.com/go-task/task/v3/internal/term"
)

View File

@@ -1,226 +0,0 @@
// Code generated by mockery v2.24.0. DO NOT EDIT.
package mocks
import (
ast "github.com/go-task/task/v3/taskfile/ast"
mock "github.com/stretchr/testify/mock"
)
// SourcesCheckable is an autogenerated mock type for the SourcesCheckable type
type SourcesCheckable struct {
mock.Mock
}
type SourcesCheckable_Expecter struct {
mock *mock.Mock
}
func (_m *SourcesCheckable) EXPECT() *SourcesCheckable_Expecter {
return &SourcesCheckable_Expecter{mock: &_m.Mock}
}
// IsUpToDate provides a mock function with given fields: t
func (_m *SourcesCheckable) IsUpToDate(t *ast.Task) (bool, error) {
ret := _m.Called(t)
var r0 bool
var r1 error
if rf, ok := ret.Get(0).(func(*ast.Task) (bool, error)); ok {
return rf(t)
}
if rf, ok := ret.Get(0).(func(*ast.Task) bool); ok {
r0 = rf(t)
} else {
r0 = ret.Get(0).(bool)
}
if rf, ok := ret.Get(1).(func(*ast.Task) error); ok {
r1 = rf(t)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// SourcesCheckable_IsUpToDate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IsUpToDate'
type SourcesCheckable_IsUpToDate_Call struct {
*mock.Call
}
// IsUpToDate is a helper method to define mock.On call
// - t *ast.Task
func (_e *SourcesCheckable_Expecter) IsUpToDate(t interface{}) *SourcesCheckable_IsUpToDate_Call {
return &SourcesCheckable_IsUpToDate_Call{Call: _e.mock.On("IsUpToDate", t)}
}
func (_c *SourcesCheckable_IsUpToDate_Call) Run(run func(t *ast.Task)) *SourcesCheckable_IsUpToDate_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(*ast.Task))
})
return _c
}
func (_c *SourcesCheckable_IsUpToDate_Call) Return(_a0 bool, _a1 error) *SourcesCheckable_IsUpToDate_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *SourcesCheckable_IsUpToDate_Call) RunAndReturn(run func(*ast.Task) (bool, error)) *SourcesCheckable_IsUpToDate_Call {
_c.Call.Return(run)
return _c
}
// Kind provides a mock function with given fields:
func (_m *SourcesCheckable) Kind() string {
ret := _m.Called()
var r0 string
if rf, ok := ret.Get(0).(func() string); ok {
r0 = rf()
} else {
r0 = ret.Get(0).(string)
}
return r0
}
// SourcesCheckable_Kind_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Kind'
type SourcesCheckable_Kind_Call struct {
*mock.Call
}
// Kind is a helper method to define mock.On call
func (_e *SourcesCheckable_Expecter) Kind() *SourcesCheckable_Kind_Call {
return &SourcesCheckable_Kind_Call{Call: _e.mock.On("Kind")}
}
func (_c *SourcesCheckable_Kind_Call) Run(run func()) *SourcesCheckable_Kind_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *SourcesCheckable_Kind_Call) Return(_a0 string) *SourcesCheckable_Kind_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *SourcesCheckable_Kind_Call) RunAndReturn(run func() string) *SourcesCheckable_Kind_Call {
_c.Call.Return(run)
return _c
}
// OnError provides a mock function with given fields: t
func (_m *SourcesCheckable) OnError(t *ast.Task) error {
ret := _m.Called(t)
var r0 error
if rf, ok := ret.Get(0).(func(*ast.Task) error); ok {
r0 = rf(t)
} else {
r0 = ret.Error(0)
}
return r0
}
// SourcesCheckable_OnError_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OnError'
type SourcesCheckable_OnError_Call struct {
*mock.Call
}
// OnError is a helper method to define mock.On call
// - t *ast.Task
func (_e *SourcesCheckable_Expecter) OnError(t interface{}) *SourcesCheckable_OnError_Call {
return &SourcesCheckable_OnError_Call{Call: _e.mock.On("OnError", t)}
}
func (_c *SourcesCheckable_OnError_Call) Run(run func(t *ast.Task)) *SourcesCheckable_OnError_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(*ast.Task))
})
return _c
}
func (_c *SourcesCheckable_OnError_Call) Return(_a0 error) *SourcesCheckable_OnError_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *SourcesCheckable_OnError_Call) RunAndReturn(run func(*ast.Task) error) *SourcesCheckable_OnError_Call {
_c.Call.Return(run)
return _c
}
// Value provides a mock function with given fields: t
func (_m *SourcesCheckable) Value(t *ast.Task) (interface{}, error) {
ret := _m.Called(t)
var r0 interface{}
var r1 error
if rf, ok := ret.Get(0).(func(*ast.Task) (interface{}, error)); ok {
return rf(t)
}
if rf, ok := ret.Get(0).(func(*ast.Task) interface{}); ok {
r0 = rf(t)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(interface{})
}
}
if rf, ok := ret.Get(1).(func(*ast.Task) error); ok {
r1 = rf(t)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// SourcesCheckable_Value_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Value'
type SourcesCheckable_Value_Call struct {
*mock.Call
}
// Value is a helper method to define mock.On call
// - t *ast.Task
func (_e *SourcesCheckable_Expecter) Value(t interface{}) *SourcesCheckable_Value_Call {
return &SourcesCheckable_Value_Call{Call: _e.mock.On("Value", t)}
}
func (_c *SourcesCheckable_Value_Call) Run(run func(t *ast.Task)) *SourcesCheckable_Value_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(*ast.Task))
})
return _c
}
func (_c *SourcesCheckable_Value_Call) Return(_a0 interface{}, _a1 error) *SourcesCheckable_Value_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *SourcesCheckable_Value_Call) RunAndReturn(run func(*ast.Task) (interface{}, error)) *SourcesCheckable_Value_Call {
_c.Call.Return(run)
return _c
}
type mockConstructorTestingTNewSourcesCheckable interface {
mock.TestingT
Cleanup(func())
}
// NewSourcesCheckable creates a new instance of SourcesCheckable. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
func NewSourcesCheckable(t mockConstructorTestingTNewSourcesCheckable) *SourcesCheckable {
mock := &SourcesCheckable{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}

View File

@@ -1,92 +0,0 @@
// Code generated by mockery v2.24.0. DO NOT EDIT.
package mocks
import (
context "context"
ast "github.com/go-task/task/v3/taskfile/ast"
mock "github.com/stretchr/testify/mock"
)
// StatusCheckable is an autogenerated mock type for the StatusCheckable type
type StatusCheckable struct {
mock.Mock
}
type StatusCheckable_Expecter struct {
mock *mock.Mock
}
func (_m *StatusCheckable) EXPECT() *StatusCheckable_Expecter {
return &StatusCheckable_Expecter{mock: &_m.Mock}
}
// IsUpToDate provides a mock function with given fields: ctx, t
func (_m *StatusCheckable) IsUpToDate(ctx context.Context, t *ast.Task) (bool, error) {
ret := _m.Called(ctx, t)
var r0 bool
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, *ast.Task) (bool, error)); ok {
return rf(ctx, t)
}
if rf, ok := ret.Get(0).(func(context.Context, *ast.Task) bool); ok {
r0 = rf(ctx, t)
} else {
r0 = ret.Get(0).(bool)
}
if rf, ok := ret.Get(1).(func(context.Context, *ast.Task) error); ok {
r1 = rf(ctx, t)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// StatusCheckable_IsUpToDate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IsUpToDate'
type StatusCheckable_IsUpToDate_Call struct {
*mock.Call
}
// IsUpToDate is a helper method to define mock.On call
// - ctx context.Context
// - t *ast.Task
func (_e *StatusCheckable_Expecter) IsUpToDate(ctx interface{}, t interface{}) *StatusCheckable_IsUpToDate_Call {
return &StatusCheckable_IsUpToDate_Call{Call: _e.mock.On("IsUpToDate", ctx, t)}
}
func (_c *StatusCheckable_IsUpToDate_Call) Run(run func(ctx context.Context, t *ast.Task)) *StatusCheckable_IsUpToDate_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(*ast.Task))
})
return _c
}
func (_c *StatusCheckable_IsUpToDate_Call) Return(_a0 bool, _a1 error) *StatusCheckable_IsUpToDate_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *StatusCheckable_IsUpToDate_Call) RunAndReturn(run func(context.Context, *ast.Task) (bool, error)) *StatusCheckable_IsUpToDate_Call {
_c.Call.Return(run)
return _c
}
type mockConstructorTestingTNewStatusCheckable interface {
mock.TestingT
Cleanup(func())
}
// NewStatusCheckable creates a new instance of StatusCheckable. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
func NewStatusCheckable(t mockConstructorTestingTNewStatusCheckable) *StatusCheckable {
mock := &StatusCheckable{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}

View File

@@ -9,6 +9,11 @@ import (
// A Sorter is any function that sorts a set of tasks.
type Sorter func(items []string, namespaces []string) []string
// NoSort leaves the tasks in the order they are defined.
func NoSort(items []string, namespaces []string) []string {
return items
}
// AlphaNumeric sorts the JSON output so that tasks are in alpha numeric order
// by task name.
func AlphaNumeric(items []string, namespaces []string) []string {

View File

@@ -79,3 +79,35 @@ func TestAlphaNumeric_Sort(t *testing.T) {
})
}
}
func TestNoSort_Sort(t *testing.T) {
t.Parallel()
item1 := "a-item1"
item2 := "m-item2"
item3 := "ns1:item3"
item4 := "ns2:item4"
item5 := "z-item5"
item6 := "ns3:item6"
tests := []struct {
name string
items []string
want []string
}{
{
name: "all items in order of definition",
items: []string{item3, item2, item5, item1, item4, item6},
want: []string{item3, item2, item5, item1, item4, item6},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
NoSort(tt.items, nil)
assert.Equal(t, tt.want, tt.items)
})
}
}

View File

@@ -2,11 +2,14 @@ package templater
import (
"maps"
"math/rand/v2"
"path/filepath"
"runtime"
"strings"
"github.com/davecgh/go-spew/spew"
"github.com/google/uuid"
"gopkg.in/yaml.v3"
"mvdan.cc/sh/v3/shell"
"mvdan.cc/sh/v3/syntax"
@@ -18,58 +21,27 @@ var templateFuncs template.FuncMap
func init() {
taskFuncs := template.FuncMap{
"OS": func() string { return runtime.GOOS },
"ARCH": func() string { return runtime.GOARCH },
"numCPU": func() int { return runtime.NumCPU() },
"catLines": func(s string) string {
s = strings.ReplaceAll(s, "\r\n", " ")
return strings.ReplaceAll(s, "\n", " ")
},
"splitLines": func(s string) []string {
s = strings.ReplaceAll(s, "\r\n", "\n")
return strings.Split(s, "\n")
},
"fromSlash": func(path string) string {
return filepath.FromSlash(path)
},
"toSlash": func(path string) string {
return filepath.ToSlash(path)
},
"exeExt": func() string {
if runtime.GOOS == "windows" {
return ".exe"
}
return ""
},
"shellQuote": func(str string) (string, error) {
return syntax.Quote(str, syntax.LangBash)
},
"splitArgs": func(s string) ([]string, error) {
return shell.Fields(s, nil)
},
// IsSH is deprecated.
"IsSH": func() bool { return true },
"joinPath": func(elem ...string) string {
return filepath.Join(elem...)
},
"relPath": func(basePath, targetPath string) (string, error) {
return filepath.Rel(basePath, targetPath)
},
"merge": func(base map[string]any, v ...map[string]any) map[string]any {
cap := len(v)
for _, m := range v {
cap += len(m)
}
result := make(map[string]any, cap)
maps.Copy(result, base)
for _, m := range v {
maps.Copy(result, m)
}
return result
},
"spew": func(v any) string {
return spew.Sdump(v)
},
"OS": os,
"ARCH": arch,
"numCPU": runtime.NumCPU,
"catLines": catLines,
"splitLines": splitLines,
"fromSlash": filepath.FromSlash,
"toSlash": filepath.ToSlash,
"exeExt": exeExt,
"shellQuote": shellQuote,
"splitArgs": splitArgs,
"IsSH": IsSH, // Deprecated
"joinPath": filepath.Join,
"relPath": filepath.Rel,
"merge": merge,
"spew": spew.Sdump,
"fromYaml": fromYaml,
"mustFromYaml": mustFromYaml,
"toYaml": toYaml,
"mustToYaml": mustToYaml,
"uuid": uuid.New,
"randIntN": rand.IntN,
}
// aliases
@@ -83,3 +55,78 @@ func init() {
templateFuncs = template.FuncMap(sprig.TxtFuncMap())
maps.Copy(templateFuncs, taskFuncs)
}
func os() string {
return runtime.GOOS
}
func arch() string {
return runtime.GOARCH
}
func catLines(s string) string {
s = strings.ReplaceAll(s, "\r\n", " ")
return strings.ReplaceAll(s, "\n", " ")
}
func splitLines(s string) []string {
s = strings.ReplaceAll(s, "\r\n", "\n")
return strings.Split(s, "\n")
}
func exeExt() string {
if runtime.GOOS == "windows" {
return ".exe"
}
return ""
}
func shellQuote(str string) (string, error) {
return syntax.Quote(str, syntax.LangBash)
}
func splitArgs(s string) ([]string, error) {
return shell.Fields(s, nil)
}
// Deprecated: now always returns true
func IsSH() bool {
return true
}
func merge(base map[string]any, v ...map[string]any) map[string]any {
cap := len(v)
for _, m := range v {
cap += len(m)
}
result := make(map[string]any, cap)
maps.Copy(result, base)
for _, m := range v {
maps.Copy(result, m)
}
return result
}
func fromYaml(v string) any {
output, _ := mustFromYaml(v)
return output
}
func mustFromYaml(v string) (any, error) {
var output any
err := yaml.Unmarshal([]byte(v), &output)
return output, err
}
func toYaml(v any) string {
output, _ := yaml.Marshal(v)
return string(output)
}
func mustToYaml(v any) (string, error) {
output, err := yaml.Marshal(v)
if err != nil {
return "", err
}
return string(output), nil
}

View File

@@ -6,9 +6,10 @@ import (
"maps"
"strings"
"github.com/go-task/template"
"github.com/go-task/task/v3/internal/deepcopy"
"github.com/go-task/task/v3/taskfile/ast"
"github.com/go-task/template"
)
// Cache is a help struct that allow us to call "replaceX" funcs multiple

43
internal/tui/list.go Normal file
View File

@@ -0,0 +1,43 @@
package tui
import (
"fmt"
"github.com/charmbracelet/bubbles/v2/list"
"github.com/charmbracelet/bubbles/v2/spinner"
tea "github.com/charmbracelet/bubbletea/v2"
)
type listModel struct {
spinner spinner.Model
list list.Model
selectedIndex int
isLoading bool
}
func newListModel() listModel {
return listModel{
spinner: newSpinner(),
list: list.New([]list.Item{}, list.NewDefaultDelegate(), 0, 0),
isLoading: true,
}
}
func (m listModel) Init() tea.Cmd {
return m.spinner.Tick
}
func (m listModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
m.spinner, cmd = m.spinner.Update(msg)
return m, cmd
}
func (m listModel) View() string {
switch {
case m.isLoading:
return fmt.Sprintf("%s %s", m.spinner.View(), "Loading tasks...")
default:
return "todo!"
}
}

59
internal/tui/main.go Normal file
View File

@@ -0,0 +1,59 @@
package tui
import (
tea "github.com/charmbracelet/bubbletea/v2"
)
type page int
const (
pageList = iota + 1
)
type mainModel struct {
page page
listPage listModel
isQuitting bool
}
func newMainModel() mainModel {
return mainModel{
page: pageList,
listPage: newListModel(),
}
}
func (m mainModel) Init() tea.Cmd {
return m.listPage.Init()
}
func (m mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c", "ctrl+q":
m.isQuitting = true
return m, tea.Quit
}
}
switch m.page {
case pageList:
model, cmd := m.listPage.Update(msg)
m.listPage = model.(listModel)
return m, cmd
}
return m, nil
}
func (m mainModel) View() string {
switch {
case m.isQuitting:
return "Quitting..."
case m.page == pageList:
return m.listPage.View()
default:
return "..."
}
}

16
internal/tui/spinner.go Normal file
View File

@@ -0,0 +1,16 @@
package tui
import (
"github.com/charmbracelet/bubbles/v2/spinner"
"github.com/charmbracelet/lipgloss/v2"
"github.com/charmbracelet/x/exp/charmtone"
)
var spinnerStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(charmtone.Violet.Hex()))
func newSpinner() spinner.Model {
return spinner.New(
spinner.WithSpinner(spinner.MiniDot),
spinner.WithStyle(spinnerStyle),
)
}

13
internal/tui/tui.go Normal file
View File

@@ -0,0 +1,13 @@
package tui
import (
tea "github.com/charmbracelet/bubbletea/v2"
)
func Run() error {
m := newMainModel()
p := tea.NewProgram(m)
_, err := p.Run()
return err
}

View File

@@ -1,33 +1,67 @@
package version
import (
"fmt"
_ "embed"
"runtime/debug"
"strings"
)
var (
version = ""
sum = ""
//go:embed version.txt
version string
commit string
dirty bool
)
func init() {
info, ok := debug.ReadBuildInfo()
if !ok || info.Main.Version == "(devel)" || info.Main.Version == "" {
version = "unknown"
} else {
if version == "" {
version = info.Main.Version
}
if sum == "" {
sum = info.Main.Sum
}
version = strings.TrimSpace(version)
// Attempt to get build info from the Go runtime. We only use this if not
// built from a tagged version.
if info, ok := debug.ReadBuildInfo(); ok && info.Main.Version == "(devel)" {
commit = getCommit(info)
dirty = getDirty(info)
}
}
func getDirty(info *debug.BuildInfo) bool {
for _, setting := range info.Settings {
if setting.Key == "vcs.modified" {
return setting.Value == "true"
}
}
return false
}
func getCommit(info *debug.BuildInfo) string {
for _, setting := range info.Settings {
if setting.Key == "vcs.revision" {
return setting.Value[:7]
}
}
return ""
}
// GetVersion returns the version of Task. By default, this is retrieved from
// the embedded version.txt file which is kept up-to-date by our release script.
// However, it can also be overridden at build time using:
// -ldflags="-X 'github.com/go-task/task/v3/internal/version.version=vX.X.X'".
func GetVersion() string {
return version
}
func GetVersionWithSum() string {
return fmt.Sprintf("%s (%s)", version, sum)
// GetVersionWithBuildInfo is the same as [GetVersion], but it also includes
// the commit hash and dirty status if available. This will only work when built
// within inside of a Git checkout.
func GetVersionWithBuildInfo() string {
var buildMetadata []string
if commit != "" {
buildMetadata = append(buildMetadata, commit)
}
if dirty {
buildMetadata = append(buildMetadata, "dirty")
}
if len(buildMetadata) > 0 {
return version + "+" + strings.Join(buildMetadata, ".")
}
return version
}

View File

@@ -0,0 +1 @@
3.45.4

32
package-lock.json generated
View File

@@ -1,32 +0,0 @@
{
"name": "@go-task/cli",
"version": "3.42.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "@go-task/cli",
"version": "3.26.0",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"@go-task/go-npm": "^0.2.0"
}
},
"node_modules/@go-task/go-npm": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/@go-task/go-npm/-/go-npm-0.2.0.tgz",
"integrity": "sha512-vQbdtBvesHm8EUFHX8QKg4rbBodmu9VsAXH1ozpbiN5jdTMOYHTCMM31EurAYmY+rNNtxJQ4JGy6t383RPlqbw==",
"bin": {
"go-npm": "bin/index.js"
}
}
},
"dependencies": {
"@go-task/go-npm": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/@go-task/go-npm/-/go-npm-0.2.0.tgz",
"integrity": "sha512-vQbdtBvesHm8EUFHX8QKg4rbBodmu9VsAXH1ozpbiN5jdTMOYHTCMM31EurAYmY+rNNtxJQ4JGy6t383RPlqbw=="
}
}
}

View File

@@ -1,34 +0,0 @@
{
"name": "@go-task/cli",
"version": "3.42.0",
"description": "A task runner / simpler Make alternative written in Go",
"scripts": {
"postinstall": "go-npm install",
"preuninstall": "go-npm uninstall"
},
"goBinary": {
"name": "task",
"path": "./bin",
"url": "https://github.com/go-task/task/releases/download/v{{version}}/task_{{platform}}_{{arch}}{{archive_ext}}"
},
"files": [],
"repository": {
"type": "git",
"url": "https://github.com/go-task/task.git"
},
"keywords": [
"task",
"taskfile",
"build-tool",
"task-runner"
],
"author": "The Task authors",
"license": "MIT",
"bugs": {
"url": "https://github.com/go-task/task/issues"
},
"homepage": "https://taskfile.dev",
"dependencies": {
"@go-task/go-npm": "^0.2.0"
}
}

View File

@@ -16,6 +16,7 @@ import (
"github.com/go-task/task/v3/internal/env"
"github.com/go-task/task/v3/internal/execext"
"github.com/go-task/task/v3/internal/filepathext"
"github.com/go-task/task/v3/internal/fsext"
"github.com/go-task/task/v3/internal/logger"
"github.com/go-task/task/v3/internal/output"
"github.com/go-task/task/v3/internal/version"
@@ -56,6 +57,13 @@ func (e *Executor) Setup() error {
func (e *Executor) getRootNode() (taskfile.Node, error) {
node, err := taskfile.NewRootNode(e.Entrypoint, e.Dir, e.Insecure, e.Timeout)
if os.IsNotExist(err) {
return nil, errors.TaskfileNotFoundError{
URI: fsext.DefaultDir(e.Entrypoint, e.Dir),
Walk: true,
AskInit: true,
}
}
if err != nil {
return nil, err
}
@@ -64,6 +72,8 @@ func (e *Executor) getRootNode() (taskfile.Node, error) {
}
func (e *Executor) readTaskfile(node taskfile.Node) error {
ctx, cf := context.WithTimeout(context.Background(), e.Timeout)
defer cf()
debugFunc := func(s string) {
e.Logger.VerboseOutf(logger.Magenta, s)
}
@@ -71,17 +81,19 @@ func (e *Executor) readTaskfile(node taskfile.Node) error {
return e.Logger.Prompt(logger.Yellow, s, "n", "y", "yes")
}
reader := taskfile.NewReader(
node,
taskfile.WithInsecure(e.Insecure),
taskfile.WithDownload(e.Download),
taskfile.WithOffline(e.Offline),
taskfile.WithTimeout(e.Timeout),
taskfile.WithTempDir(e.TempDir.Remote),
taskfile.WithCacheExpiryDuration(e.CacheExpiryDuration),
taskfile.WithDebugFunc(debugFunc),
taskfile.WithPromptFunc(promptFunc),
)
graph, err := reader.Read()
graph, err := reader.Read(ctx, node)
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
return &errors.TaskfileNetworkTimeoutError{URI: node.Location(), Timeout: e.Timeout}
}
return err
}
if e.Taskfile, err = graph.Merge(); err != nil {
@@ -91,7 +103,7 @@ func (e *Executor) readTaskfile(node taskfile.Node) error {
}
func (e *Executor) setupFuzzyModel() {
if e.Taskfile != nil {
if e.Taskfile == nil {
return
}
@@ -100,6 +112,9 @@ func (e *Executor) setupFuzzyModel() {
var words []string
for name, task := range e.Taskfile.Tasks.All(nil) {
if task.Internal {
continue
}
words = append(words, name)
words = slices.Concat(words, task.Aliases)
}
@@ -120,7 +135,7 @@ func (e *Executor) setupTempDir() error {
Fingerprint: filepathext.SmartJoin(e.Dir, ".task"),
}
} else if filepath.IsAbs(tempDir) || strings.HasPrefix(tempDir, "~") {
tempDir, err := execext.Expand(tempDir)
tempDir, err := execext.ExpandLiteral(tempDir)
if err != nil {
return err
}
@@ -141,7 +156,7 @@ func (e *Executor) setupTempDir() error {
remoteDir := env.GetTaskEnv("REMOTE_DIR")
if remoteDir != "" {
if filepath.IsAbs(remoteDir) || strings.HasPrefix(remoteDir, "~") {
remoteTempDir, err := execext.Expand(remoteDir)
remoteTempDir, err := execext.ExpandLiteral(remoteDir)
if err != nil {
return err
}
@@ -209,6 +224,10 @@ func (e *Executor) setupCompiler() error {
}
func (e *Executor) readDotEnvFiles() error {
if e.Taskfile == nil || len(e.Taskfile.Dotenv) == 0 {
return nil
}
if e.Taskfile.Version.LessThan(ast.V3) {
return nil
}

88
task.go
View File

@@ -3,13 +3,13 @@ package task
import (
"context"
"fmt"
"io"
"os"
"runtime"
"slices"
"sync"
"sync/atomic"
"time"
"golang.org/x/sync/errgroup"
"mvdan.cc/sh/v3/interp"
"github.com/go-task/task/v3/errors"
"github.com/go-task/task/v3/internal/env"
@@ -22,10 +22,6 @@ import (
"github.com/go-task/task/v3/internal/summary"
"github.com/go-task/task/v3/internal/templater"
"github.com/go-task/task/v3/taskfile/ast"
"github.com/sajari/fuzzy"
"golang.org/x/sync/errgroup"
"mvdan.cc/sh/v3/interp"
)
const (
@@ -34,57 +30,6 @@ const (
MaximumTaskCall = 1000
)
type TempDir struct {
Remote string
Fingerprint string
}
// Executor executes a Taskfile
type Executor struct {
Taskfile *ast.Taskfile
Dir string
Entrypoint string
TempDir TempDir
Force bool
ForceAll bool
Insecure bool
Download bool
Offline bool
Timeout time.Duration
Watch bool
Verbose bool
Silent bool
AssumeYes bool
AssumeTerm bool // Used for testing
Dry bool
Summary bool
Parallel bool
Color bool
Concurrency int
Interval time.Duration
Stdin io.Reader
Stdout io.Writer
Stderr io.Writer
Logger *logger.Logger
Compiler *Compiler
Output output.Output
OutputStyle ast.Output
TaskSorter sort.Sorter
UserWorkingDir string
EnableVersionCheck bool
fuzzyModel *fuzzy.Model
concurrencySemaphore chan struct{}
taskCallCount map[string]*int32
mkdirMutexMap map[string]*sync.Mutex
executionHashes map[string]context.Context
executionHashesMutex sync.Mutex
}
// MatchingTask represents a task that matches a given call. It includes the
// task itself and a list of wildcards that were matched.
type MatchingTask struct {
@@ -227,7 +172,6 @@ func (e *Executor) RunTask(ctx context.Context, call *Call) error {
if t.Method != "" {
method = t.Method
}
upToDate, err := fingerprint.IsTaskUpToDate(ctx, t,
fingerprint.WithMethod(method),
fingerprint.WithTempDir(e.TempDir.Fingerprint),
@@ -275,13 +219,13 @@ func (e *Executor) RunTask(ctx context.Context, call *Call) error {
e.Logger.VerboseErrf(logger.Yellow, "task: error cleaning status on error: %v\n", err2)
}
exitCode, isExitError := interp.IsExitStatus(err)
if isExitError {
var exitCode interp.ExitStatus
if errors.As(err, &exitCode) {
if t.IgnoreError {
e.Logger.VerboseErrf(logger.Yellow, "task: task error ignored: %v\n", err)
continue
}
deferredExitCode = exitCode
deferredExitCode = uint8(exitCode)
}
if call.Indirect {
@@ -352,6 +296,8 @@ func (e *Executor) runDeferred(t *ast.Task, call *Call, i int, deferredExitCode
}
cmd.Cmd = templater.ReplaceWithExtra(cmd.Cmd, cache, extra)
cmd.Task = templater.ReplaceWithExtra(cmd.Task, cache, extra)
cmd.Vars = templater.ReplaceVarsWithExtra(cmd.Vars, cache, extra)
if err := e.runCommand(ctx, t, call, i); err != nil {
e.Logger.VerboseErrf(logger.Yellow, "task: ignored error in deferred cmd: %s\n", err.Error())
@@ -409,7 +355,8 @@ func (e *Executor) runCommand(ctx context.Context, t *ast.Task, call *Call, i in
if closeErr := closer(err); closeErr != nil {
e.Logger.Errf(logger.Red, "task: unable to close writer: %v\n", closeErr)
}
if _, isExitError := interp.IsExitStatus(err); isExitError && cmd.IgnoreError {
var exitCode interp.ExitStatus
if errors.As(err, &exitCode) && cmd.IgnoreError {
e.Logger.VerboseErrf(logger.Yellow, "task: [%s] command error ignored: %v\n", t.Name(), err)
return nil
}
@@ -467,7 +414,6 @@ func (e *Executor) FindMatchingTasks(call *Call) []*MatchingTask {
return matchingTasks
}
// Attempt a wildcard match
// For now, we can just nil check the task before each loop
for _, value := range e.Taskfile.Tasks.All(nil) {
if match, wildcards := value.WildcardMatch(call.Task); match {
matchingTasks = append(matchingTasks, &MatchingTask{
@@ -485,23 +431,12 @@ func (e *Executor) FindMatchingTasks(call *Call) []*MatchingTask {
func (e *Executor) GetTask(call *Call) (*ast.Task, error) {
// Search for a matching task
matchingTasks := e.FindMatchingTasks(call)
switch len(matchingTasks) {
case 0: // Carry on
case 1:
if len(matchingTasks) > 0 {
if call.Vars == nil {
call.Vars = ast.NewVars()
}
call.Vars.Set("MATCH", ast.Var{Value: matchingTasks[0].Wildcards})
return matchingTasks[0].Task, nil
default:
taskNames := make([]string, len(matchingTasks))
for i, matchingTask := range matchingTasks {
taskNames[i] = matchingTask.Task.Task
}
return nil, &errors.TaskNameConflictError{
Call: call.Task,
TaskNames: taskNames,
}
}
// If didn't find one, search for a task with a matching alias
@@ -531,7 +466,6 @@ func (e *Executor) GetTask(call *Call) (*ast.Task, error) {
DidYouMean: didYouMean,
}
}
return matchingTask, nil
}

File diff suppressed because it is too large Load Diff

View File

@@ -24,6 +24,7 @@ type (
AdvancedImport bool
Vars *Vars
Flatten bool
Checksum string
}
// Includes is an ordered map of namespaces to includes.
Includes struct {
@@ -165,6 +166,7 @@ func (include *Include) UnmarshalYAML(node *yaml.Node) error {
Aliases []string
Excludes []string
Vars *Vars
Checksum string
}
if err := node.Decode(&includedTaskfile); err != nil {
return errors.NewTaskfileDecodeError(err, node)
@@ -178,6 +180,7 @@ func (include *Include) UnmarshalYAML(node *yaml.Node) error {
include.AdvancedImport = true
include.Vars = includedTaskfile.Vars
include.Flatten = includedTaskfile.Flatten
include.Checksum = includedTaskfile.Checksum
return nil
}
@@ -200,5 +203,7 @@ func (include *Include) DeepCopy() *Include {
AdvancedImport: include.AdvancedImport,
Vars: include.Vars.DeepCopy(),
Flatten: include.Flatten,
Aliases: deepcopy.Slice(include.Aliases),
Checksum: include.Checksum,
}
}

View File

@@ -46,17 +46,22 @@ type Task struct {
Namespace string
IncludeVars *Vars
IncludedTaskfileVars *Vars
FullName string
}
func (t *Task) Name() string {
if t.Label != "" {
return t.Label
}
if t.FullName != "" {
return t.FullName
}
return t.Task
}
func (t *Task) LocalName() string {
name := t.Task
name := t.FullName
name = strings.TrimPrefix(name, t.Namespace)
name = strings.TrimPrefix(name, ":")
return name
@@ -220,6 +225,7 @@ func (t *Task) DeepCopy() *Task {
Location: t.Location.DeepCopy(),
Requires: t.Requires.DeepCopy(),
Namespace: t.Namespace,
FullName: t.FullName,
}
return c
}

View File

@@ -1,12 +1,9 @@
package ast
import (
"strings"
"gopkg.in/yaml.v3"
"github.com/go-task/task/v3/errors"
"github.com/go-task/task/v3/internal/experiments"
)
// Var represents either a static or dynamic variable.
@@ -19,82 +16,29 @@ type Var struct {
}
func (v *Var) UnmarshalYAML(node *yaml.Node) error {
if experiments.MapVariables.Enabled() {
// This implementation is not backwards-compatible and replaces the 'sh' key with map variables
if experiments.MapVariables.Value == 1 {
var value any
if err := node.Decode(&value); err != nil {
return errors.NewTaskfileDecodeError(err, node)
}
// If the value is a string and it starts with $, then it's a shell command
if str, ok := value.(string); ok {
if str, ok = strings.CutPrefix(str, "$"); ok {
v.Sh = &str
return nil
}
if str, ok = strings.CutPrefix(str, "#"); ok {
v.Ref = str
return nil
}
}
v.Value = value
return nil
}
// This implementation IS backwards-compatible and keeps the 'sh' key and allows map variables to be added under the `map` key
if experiments.MapVariables.Value == 2 {
switch node.Kind {
case yaml.MappingNode:
key := node.Content[0].Value
switch key {
case "sh", "ref", "map":
var m struct {
Sh *string
Ref string
Map any
}
if err := node.Decode(&m); err != nil {
return errors.NewTaskfileDecodeError(err, node)
}
v.Sh = m.Sh
v.Ref = m.Ref
v.Value = m.Map
return nil
default:
return errors.NewTaskfileDecodeError(nil, node).WithMessage(`%q is not a valid variable type. Try "sh", "ref", "map" or using a scalar value`, key)
}
default:
var value any
if err := node.Decode(&value); err != nil {
return errors.NewTaskfileDecodeError(err, node)
}
v.Value = value
return nil
}
}
}
switch node.Kind {
case yaml.MappingNode:
key := node.Content[0].Value
key := "<none>"
if len(node.Content) > 0 {
key = node.Content[0].Value
}
switch key {
case "sh", "ref":
case "sh", "ref", "map":
var m struct {
Sh *string
Ref string
Map any
}
if err := node.Decode(&m); err != nil {
return errors.NewTaskfileDecodeError(err, node)
}
v.Sh = m.Sh
v.Ref = m.Ref
v.Value = m.Map
return nil
default:
return errors.NewTaskfileDecodeError(nil, node).WithMessage("maps cannot be assigned to variables")
return errors.NewTaskfileDecodeError(nil, node).WithMessage(`%q is not a valid variable type. Try "sh", "ref", "map" or using a scalar value`, key)
}
default:
var value any
if err := node.Decode(&value); err != nil {

View File

@@ -1,72 +0,0 @@
package taskfile
import (
"crypto/sha256"
"fmt"
"os"
"path/filepath"
"strings"
)
type Cache struct {
dir string
}
func NewCache(dir string) (*Cache, error) {
dir = filepath.Join(dir, "remote")
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, err
}
return &Cache{
dir: dir,
}, nil
}
func checksum(b []byte) string {
h := sha256.New()
h.Write(b)
return fmt.Sprintf("%x", h.Sum(nil))
}
func (c *Cache) write(node Node, b []byte) error {
return os.WriteFile(c.cacheFilePath(node), b, 0o644)
}
func (c *Cache) read(node Node) ([]byte, error) {
return os.ReadFile(c.cacheFilePath(node))
}
func (c *Cache) writeChecksum(node Node, checksum string) error {
return os.WriteFile(c.checksumFilePath(node), []byte(checksum), 0o644)
}
func (c *Cache) readChecksum(node Node) string {
b, _ := os.ReadFile(c.checksumFilePath(node))
return string(b)
}
func (c *Cache) key(node Node) string {
return strings.TrimRight(checksum([]byte(node.Location())), "=")
}
func (c *Cache) cacheFilePath(node Node) string {
return c.filePath(node, "yaml")
}
func (c *Cache) checksumFilePath(node Node) string {
return c.filePath(node, "checksum")
}
func (c *Cache) filePath(node Node, suffix string) string {
lastDir, filename := node.FilenameAndLastDir()
prefix := filename
// Means it's not "", nor "." nor "/", so it's a valid directory
if len(lastDir) > 1 {
prefix = fmt.Sprintf("%s-%s", lastDir, filename)
}
return filepath.Join(c.dir, fmt.Sprintf("%s.%s.%s", prefix, c.key(node), suffix))
}
func (c *Cache) Clear() error {
return os.RemoveAll(c.dir)
}

View File

@@ -12,10 +12,6 @@ import (
)
func Dotenv(vars *ast.Vars, tf *ast.Taskfile, dir string) (*ast.Vars, error) {
if len(tf.Dotenv) == 0 {
return nil, nil
}
env := ast.NewVars()
cache := &templater.Cache{Vars: vars}

View File

@@ -2,26 +2,31 @@ package taskfile
import (
"context"
"os"
"path/filepath"
"strings"
"time"
giturls "github.com/chainguard-dev/git-urls"
"github.com/go-task/task/v3/errors"
"github.com/go-task/task/v3/internal/experiments"
"github.com/go-task/task/v3/experiments"
"github.com/go-task/task/v3/internal/fsext"
)
type Node interface {
Read(ctx context.Context) ([]byte, error)
Read() ([]byte, error)
Parent() Node
Location() string
Dir() string
Remote() bool
Checksum() string
Verify(checksum string) bool
ResolveEntrypoint(entrypoint string) (string, error)
ResolveDir(dir string) (string, error)
FilenameAndLastDir() (string, string)
}
type RemoteNode interface {
Node
ReadContext(ctx context.Context) ([]byte, error)
CacheKey() string
}
func NewRootNode(
@@ -30,40 +35,40 @@ func NewRootNode(
insecure bool,
timeout time.Duration,
) (Node, error) {
dir = getDefaultDir(entrypoint, dir)
dir = fsext.DefaultDir(entrypoint, dir)
// If the entrypoint is "-", we read from stdin
if entrypoint == "-" {
return NewStdinNode(dir)
}
return NewNode(entrypoint, dir, insecure, timeout)
return NewNode(entrypoint, dir, insecure)
}
func NewNode(
entrypoint string,
dir string,
insecure bool,
timeout time.Duration,
opts ...NodeOption,
) (Node, error) {
var node Node
var err error
scheme, err := getScheme(entrypoint)
if err != nil {
return nil, err
}
switch scheme {
case "git":
node, err = NewGitNode(entrypoint, dir, insecure, opts...)
case "http", "https":
node, err = NewHTTPNode(entrypoint, dir, insecure, timeout, opts...)
node, err = NewHTTPNode(entrypoint, dir, insecure, opts...)
default:
node, err = NewFileNode(entrypoint, dir, opts...)
}
if node.Remote() && !experiments.RemoteTaskfiles.Enabled() {
if _, isRemote := node.(RemoteNode); isRemote && !experiments.RemoteTaskfiles.Enabled() {
return nil, errors.New("task: Remote taskfiles are not enabled. You can read more about this experiment and how to enable it at https://taskfile.dev/experiments/remote-taskfiles")
}
return node, err
}
@@ -72,6 +77,7 @@ func getScheme(uri string) (string, error) {
if u == nil {
return "", err
}
if strings.HasSuffix(strings.Split(u.Path, "//")[0], ".git") && (u.Scheme == "git" || u.Scheme == "ssh" || u.Scheme == "https" || u.Scheme == "http") {
return "git", nil
}
@@ -79,28 +85,6 @@ func getScheme(uri string) (string, error) {
if i := strings.Index(uri, "://"); i != -1 {
return uri[:i], nil
}
return "", nil
}
func getDefaultDir(entrypoint, dir string) string {
// If the entrypoint and dir are empty, we default the directory to the current working directory
if dir == "" {
if entrypoint == "" {
wd, err := os.Getwd()
if err != nil {
return ""
}
dir = wd
}
return dir
}
// If the directory is set, ensure it is an absolute path
var err error
dir, err = filepath.Abs(dir)
if err != nil {
return ""
}
return dir
}

View File

@@ -1,19 +1,20 @@
package taskfile
type (
NodeOption func(*BaseNode)
// BaseNode is a generic node that implements the Parent() methods of the
NodeOption func(*baseNode)
// baseNode is a generic node that implements the Parent() methods of the
// NodeReader interface. It does not implement the Read() method and it
// designed to be embedded in other node types so that this boilerplate code
// does not need to be repeated.
BaseNode struct {
parent Node
dir string
baseNode struct {
parent Node
dir string
checksum string
}
)
func NewBaseNode(dir string, opts ...NodeOption) *BaseNode {
node := &BaseNode{
func NewBaseNode(dir string, opts ...NodeOption) *baseNode {
node := &baseNode{
parent: nil,
dir: dir,
}
@@ -27,15 +28,29 @@ func NewBaseNode(dir string, opts ...NodeOption) *BaseNode {
}
func WithParent(parent Node) NodeOption {
return func(node *BaseNode) {
return func(node *baseNode) {
node.parent = parent
}
}
func (node *BaseNode) Parent() Node {
func WithChecksum(checksum string) NodeOption {
return func(node *baseNode) {
node.checksum = checksum
}
}
func (node *baseNode) Parent() Node {
return node.parent
}
func (node *BaseNode) Dir() string {
func (node *baseNode) Dir() string {
return node.dir
}
func (node *baseNode) Checksum() string {
return node.checksum
}
func (node *baseNode) Verify(checksum string) bool {
return node.checksum == "" || node.checksum == checksum
}

113
taskfile/node_cache.go Normal file
View File

@@ -0,0 +1,113 @@
package taskfile
import (
"crypto/sha256"
"fmt"
"os"
"path/filepath"
"time"
)
const remoteCacheDir = "remote"
type CacheNode struct {
*baseNode
source RemoteNode
}
func NewCacheNode(source RemoteNode, dir string) *CacheNode {
return &CacheNode{
baseNode: &baseNode{
dir: filepath.Join(dir, remoteCacheDir),
},
source: source,
}
}
func (node *CacheNode) Read() ([]byte, error) {
return os.ReadFile(node.Location())
}
func (node *CacheNode) Write(data []byte) error {
if err := node.CreateCacheDir(); err != nil {
return err
}
return os.WriteFile(node.Location(), data, 0o644)
}
func (node *CacheNode) ReadTimestamp() time.Time {
b, err := os.ReadFile(node.timestampPath())
if err != nil {
return time.Time{}.UTC()
}
timestamp, err := time.Parse(time.RFC3339, string(b))
if err != nil {
return time.Time{}.UTC()
}
return timestamp.UTC()
}
func (node *CacheNode) WriteTimestamp(t time.Time) error {
if err := node.CreateCacheDir(); err != nil {
return err
}
return os.WriteFile(node.timestampPath(), []byte(t.Format(time.RFC3339)), 0o644)
}
func (node *CacheNode) ReadChecksum() string {
b, _ := os.ReadFile(node.checksumPath())
return string(b)
}
func (node *CacheNode) WriteChecksum(checksum string) error {
if err := node.CreateCacheDir(); err != nil {
return err
}
return os.WriteFile(node.checksumPath(), []byte(checksum), 0o644)
}
func (node *CacheNode) CreateCacheDir() error {
if err := os.MkdirAll(node.dir, 0o755); err != nil {
return err
}
return nil
}
func (node *CacheNode) ChecksumPrompt(checksum string) string {
cachedChecksum := node.ReadChecksum()
switch {
// If the checksum doesn't exist, prompt the user to continue
case cachedChecksum == "":
return taskfileUntrustedPrompt
// If there is a cached hash, but it doesn't match the expected hash, prompt the user to continue
case cachedChecksum != checksum:
return taskfileChangedPrompt
default:
return ""
}
}
func (node *CacheNode) Location() string {
return node.filePath("yaml")
}
func (node *CacheNode) checksumPath() string {
return node.filePath("checksum")
}
func (node *CacheNode) timestampPath() string {
return node.filePath("timestamp")
}
func (node *CacheNode) filePath(suffix string) string {
return filepath.Join(node.dir, fmt.Sprintf("%s.%s", node.source.CacheKey(), suffix))
}
func checksum(b []byte) string {
h := sha256.New()
h.Write(b)
return fmt.Sprintf("%x", h.Sum(nil))
}

View File

@@ -1,7 +1,6 @@
package taskfile
import (
"context"
"io"
"os"
"path/filepath"
@@ -9,36 +8,39 @@ import (
"github.com/go-task/task/v3/internal/execext"
"github.com/go-task/task/v3/internal/filepathext"
"github.com/go-task/task/v3/internal/fsext"
)
// A FileNode is a node that reads a taskfile from the local filesystem.
type FileNode struct {
*BaseNode
Entrypoint string
*baseNode
entrypoint string
}
func NewFileNode(entrypoint, dir string, opts ...NodeOption) (*FileNode, error) {
var err error
base := NewBaseNode(dir, opts...)
entrypoint, base.dir, err = resolveFileNodeEntrypointAndDir(entrypoint, base.dir)
// Find the entrypoint file
resolvedEntrypoint, err := fsext.Search(entrypoint, dir, defaultTaskfiles)
if err != nil {
return nil, err
}
// Resolve the directory
resolvedDir, err := fsext.ResolveDir(entrypoint, resolvedEntrypoint, dir)
if err != nil {
return nil, err
}
return &FileNode{
BaseNode: base,
Entrypoint: entrypoint,
baseNode: NewBaseNode(resolvedDir, opts...),
entrypoint: resolvedEntrypoint,
}, nil
}
func (node *FileNode) Location() string {
return node.Entrypoint
return node.entrypoint
}
func (node *FileNode) Remote() bool {
return false
}
func (node *FileNode) Read(ctx context.Context) ([]byte, error) {
func (node *FileNode) Read() ([]byte, error) {
f, err := os.Open(node.Location())
if err != nil {
return nil, err
@@ -47,34 +49,6 @@ func (node *FileNode) Read(ctx context.Context) ([]byte, error) {
return io.ReadAll(f)
}
// resolveFileNodeEntrypointAndDir resolves checks the values of entrypoint and dir and
// populates them with default values if necessary.
func resolveFileNodeEntrypointAndDir(entrypoint, dir string) (string, string, error) {
var err error
if entrypoint != "" {
entrypoint, err = Exists(entrypoint)
if err != nil {
return "", "", err
}
if dir == "" {
dir = filepath.Dir(entrypoint)
}
return entrypoint, dir, nil
}
if dir == "" {
dir, err = os.Getwd()
if err != nil {
return "", "", err
}
}
entrypoint, err = ExistsWalk(dir)
if err != nil {
return "", "", err
}
dir = filepath.Dir(entrypoint)
return entrypoint, dir, nil
}
func (node *FileNode) ResolveEntrypoint(entrypoint string) (string, error) {
// If the file is remote, we don't need to resolve the path
if strings.Contains(entrypoint, "://") {
@@ -84,7 +58,7 @@ func (node *FileNode) ResolveEntrypoint(entrypoint string) (string, error) {
return entrypoint, nil
}
path, err := execext.Expand(entrypoint)
path, err := execext.ExpandLiteral(entrypoint)
if err != nil {
return "", err
}
@@ -95,12 +69,12 @@ func (node *FileNode) ResolveEntrypoint(entrypoint string) (string, error) {
// NOTE: Uses the directory of the entrypoint (Taskfile), not the current working directory
// This means that files are included relative to one another
entrypointDir := filepath.Dir(node.Entrypoint)
entrypointDir := filepath.Dir(node.entrypoint)
return filepathext.SmartJoin(entrypointDir, path), nil
}
func (node *FileNode) ResolveDir(dir string) (string, error) {
path, err := execext.Expand(dir)
path, err := execext.ExpandLiteral(dir)
if err != nil {
return "", err
}
@@ -111,10 +85,6 @@ func (node *FileNode) ResolveDir(dir string) (string, error) {
// NOTE: Uses the directory of the entrypoint (Taskfile), not the current working directory
// This means that files are included relative to one another
entrypointDir := filepath.Dir(node.Entrypoint)
entrypointDir := filepath.Dir(node.entrypoint)
return filepathext.SmartJoin(entrypointDir, path), nil
}
func (node *FileNode) FilenameAndLastDir() (string, string) {
return "", filepath.Base(node.Entrypoint)
}

View File

@@ -21,8 +21,8 @@ import (
// An GitNode is a node that reads a Taskfile from a remote location via Git.
type GitNode struct {
*BaseNode
URL *url.URL
*baseNode
url *url.URL
rawUrl string
ref string
path string
@@ -40,23 +40,20 @@ func NewGitNode(
return nil, err
}
basePath, path := func() (string, string) {
x := strings.Split(u.Path, "//")
return x[0], x[1]
}()
basePath, path := splitURLOnDoubleSlash(u)
ref := u.Query().Get("ref")
rawUrl := u.String()
rawUrl := u.Redacted()
u.RawQuery = ""
u.Path = basePath
if u.Scheme == "http" && !insecure {
return nil, &errors.TaskfileNotSecureError{URI: entrypoint}
return nil, &errors.TaskfileNotSecureError{URI: u.Redacted()}
}
return &GitNode{
BaseNode: base,
URL: u,
baseNode: base,
url: u,
rawUrl: rawUrl,
ref: ref,
path: path,
@@ -71,11 +68,15 @@ func (node *GitNode) Remote() bool {
return true
}
func (node *GitNode) Read(_ context.Context) ([]byte, error) {
func (node *GitNode) Read() ([]byte, error) {
return node.ReadContext(context.Background())
}
func (node *GitNode) ReadContext(_ context.Context) ([]byte, error) {
fs := memfs.New()
storer := memory.NewStorage()
_, err := git.Clone(storer, fs, &git.CloneOptions{
URL: node.URL.String(),
URL: node.url.String(),
ReferenceName: plumbing.ReferenceName(node.ref),
SingleBranch: true,
Depth: 1,
@@ -98,7 +99,7 @@ func (node *GitNode) Read(_ context.Context) ([]byte, error) {
func (node *GitNode) ResolveEntrypoint(entrypoint string) (string, error) {
dir, _ := filepath.Split(node.path)
resolvedEntrypoint := fmt.Sprintf("%s//%s", node.URL, filepath.Join(dir, entrypoint))
resolvedEntrypoint := fmt.Sprintf("%s//%s", node.url, filepath.Join(dir, entrypoint))
if node.ref != "" {
return fmt.Sprintf("%s?ref=%s", resolvedEntrypoint, node.ref), nil
}
@@ -106,7 +107,7 @@ func (node *GitNode) ResolveEntrypoint(entrypoint string) (string, error) {
}
func (node *GitNode) ResolveDir(dir string) (string, error) {
path, err := execext.Expand(dir)
path, err := execext.ExpandLiteral(dir)
if err != nil {
return "", err
}
@@ -121,6 +122,25 @@ func (node *GitNode) ResolveDir(dir string) (string, error) {
return filepathext.SmartJoin(entrypointDir, path), nil
}
func (node *GitNode) FilenameAndLastDir() (string, string) {
return filepath.Base(node.path), filepath.Base(filepath.Dir(node.path))
func (node *GitNode) CacheKey() string {
checksum := strings.TrimRight(checksum([]byte(node.Location())), "=")
lastDir := filepath.Base(filepath.Dir(node.path))
prefix := filepath.Base(node.path)
// Means it's not "", nor "." nor "/", so it's a valid directory
if len(lastDir) > 1 {
prefix = fmt.Sprintf("%s.%s", lastDir, prefix)
}
return fmt.Sprintf("git.%s.%s.%s", node.url.Host, prefix, checksum)
}
func splitURLOnDoubleSlash(u *url.URL) (string, string) {
x := strings.Split(u.Path, "//")
switch len(x) {
case 0:
return "", ""
case 1:
return x[0], ""
default:
return x[0], x[1]
}
}

View File

@@ -4,6 +4,7 @@ import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGitNode_ssh(t *testing.T) {
@@ -13,8 +14,8 @@ func TestGitNode_ssh(t *testing.T) {
assert.NoError(t, err)
assert.Equal(t, "main", node.ref)
assert.Equal(t, "Taskfile.yml", node.path)
assert.Equal(t, "ssh://git@github.com/foo/bar.git//Taskfile.yml?ref=main", node.rawUrl)
assert.Equal(t, "ssh://git@github.com/foo/bar.git", node.URL.String())
assert.Equal(t, "ssh://git@github.com/foo/bar.git//Taskfile.yml?ref=main", node.Location())
assert.Equal(t, "ssh://git@github.com/foo/bar.git", node.url.String())
entrypoint, err := node.ResolveEntrypoint("common.yml")
assert.NoError(t, err)
assert.Equal(t, "ssh://git@github.com/foo/bar.git//common.yml?ref=main", entrypoint)
@@ -27,8 +28,8 @@ func TestGitNode_sshWithDir(t *testing.T) {
assert.NoError(t, err)
assert.Equal(t, "main", node.ref)
assert.Equal(t, "directory/Taskfile.yml", node.path)
assert.Equal(t, "ssh://git@github.com/foo/bar.git//directory/Taskfile.yml?ref=main", node.rawUrl)
assert.Equal(t, "ssh://git@github.com/foo/bar.git", node.URL.String())
assert.Equal(t, "ssh://git@github.com/foo/bar.git//directory/Taskfile.yml?ref=main", node.Location())
assert.Equal(t, "ssh://git@github.com/foo/bar.git", node.url.String())
entrypoint, err := node.ResolveEntrypoint("common.yml")
assert.NoError(t, err)
assert.Equal(t, "ssh://git@github.com/foo/bar.git//directory/common.yml?ref=main", entrypoint)
@@ -41,8 +42,8 @@ func TestGitNode_https(t *testing.T) {
assert.NoError(t, err)
assert.Equal(t, "main", node.ref)
assert.Equal(t, "Taskfile.yml", node.path)
assert.Equal(t, "https://github.com/foo/bar.git//Taskfile.yml?ref=main", node.rawUrl)
assert.Equal(t, "https://github.com/foo/bar.git", node.URL.String())
assert.Equal(t, "https://github.com/foo/bar.git//Taskfile.yml?ref=main", node.Location())
assert.Equal(t, "https://github.com/foo/bar.git", node.url.String())
entrypoint, err := node.ResolveEntrypoint("common.yml")
assert.NoError(t, err)
assert.Equal(t, "https://github.com/foo/bar.git//common.yml?ref=main", entrypoint)
@@ -55,31 +56,38 @@ func TestGitNode_httpsWithDir(t *testing.T) {
assert.NoError(t, err)
assert.Equal(t, "main", node.ref)
assert.Equal(t, "directory/Taskfile.yml", node.path)
assert.Equal(t, "https://github.com/foo/bar.git//directory/Taskfile.yml?ref=main", node.rawUrl)
assert.Equal(t, "https://github.com/foo/bar.git", node.URL.String())
assert.Equal(t, "https://github.com/foo/bar.git//directory/Taskfile.yml?ref=main", node.Location())
assert.Equal(t, "https://github.com/foo/bar.git", node.url.String())
entrypoint, err := node.ResolveEntrypoint("common.yml")
assert.NoError(t, err)
assert.Equal(t, "https://github.com/foo/bar.git//directory/common.yml?ref=main", entrypoint)
}
func TestGitNode_FilenameAndDir(t *testing.T) {
func TestGitNode_CacheKey(t *testing.T) {
t.Parallel()
node, err := NewGitNode("https://github.com/foo/bar.git//directory/Taskfile.yml?ref=main", "", false)
assert.NoError(t, err)
filename, dir := node.FilenameAndLastDir()
assert.Equal(t, "Taskfile.yml", filename)
assert.Equal(t, "directory", dir)
tests := []struct {
entrypoint string
expectedKey string
}{
{
entrypoint: "https://github.com/foo/bar.git//directory/Taskfile.yml?ref=main",
expectedKey: "git.github.com.directory.Taskfile.yml.f1ddddac425a538870230a3e38fc0cded4ec5da250797b6cab62c82477718fbb",
},
{
entrypoint: "https://github.com/foo/bar.git//Taskfile.yml?ref=main",
expectedKey: "git.github.com.Taskfile.yml.39d28c1ff36f973705ae188b991258bbabaffd6d60bcdde9693d157d00d5e3a4",
},
{
entrypoint: "https://github.com/foo/bar.git//multiple/directory/Taskfile.yml?ref=main",
expectedKey: "git.github.com.directory.Taskfile.yml.1b6d145e01406dcc6c0aa572e5a5d1333be1ccf2cae96d18296d725d86197d31",
},
}
node, err = NewGitNode("https://github.com/foo/bar.git//Taskfile.yml?ref=main", "", false)
assert.NoError(t, err)
filename, dir = node.FilenameAndLastDir()
assert.Equal(t, "Taskfile.yml", filename)
assert.Equal(t, ".", dir)
node, err = NewGitNode("https://github.com/foo/bar.git//multiple/directory/Taskfile.yml?ref=main", "", false)
assert.NoError(t, err)
filename, dir = node.FilenameAndLastDir()
assert.Equal(t, "Taskfile.yml", filename)
assert.Equal(t, "directory", dir)
for _, tt := range tests {
node, err := NewGitNode(tt.entrypoint, "", false)
require.NoError(t, err)
key := node.CacheKey()
assert.Equal(t, tt.expectedKey, key)
}
}

View File

@@ -2,11 +2,12 @@ package taskfile
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"path/filepath"
"time"
"strings"
"github.com/go-task/task/v3/errors"
"github.com/go-task/task/v3/internal/execext"
@@ -15,17 +16,14 @@ import (
// An HTTPNode is a node that reads a Taskfile from a remote location via HTTP.
type HTTPNode struct {
*BaseNode
URL *url.URL // stores url pointing actual remote file. (e.g. with Taskfile.yml)
entrypoint string // stores entrypoint url. used for building graph vertices.
timeout time.Duration
*baseNode
url *url.URL // stores url pointing actual remote file. (e.g. with Taskfile.yml)
}
func NewHTTPNode(
entrypoint string,
dir string,
insecure bool,
timeout time.Duration,
opts ...NodeOption,
) (*HTTPNode, error) {
base := NewBaseNode(dir, opts...)
@@ -34,47 +32,43 @@ func NewHTTPNode(
return nil, err
}
if url.Scheme == "http" && !insecure {
return nil, &errors.TaskfileNotSecureError{URI: entrypoint}
return nil, &errors.TaskfileNotSecureError{URI: url.Redacted()}
}
return &HTTPNode{
BaseNode: base,
URL: url,
entrypoint: entrypoint,
timeout: timeout,
baseNode: base,
url: url,
}, nil
}
func (node *HTTPNode) Location() string {
return node.entrypoint
return node.url.Redacted()
}
func (node *HTTPNode) Remote() bool {
return true
func (node *HTTPNode) Read() ([]byte, error) {
return node.ReadContext(context.Background())
}
func (node *HTTPNode) Read(ctx context.Context) ([]byte, error) {
url, err := RemoteExists(ctx, node.URL, node.timeout)
func (node *HTTPNode) ReadContext(ctx context.Context) ([]byte, error) {
url, err := RemoteExists(ctx, *node.url)
if err != nil {
return nil, err
}
node.URL = url
req, err := http.NewRequest("GET", node.URL.String(), nil)
req, err := http.NewRequestWithContext(ctx, "GET", url.String(), nil)
if err != nil {
return nil, errors.TaskfileFetchFailedError{URI: node.URL.String()}
return nil, errors.TaskfileFetchFailedError{URI: node.Location()}
}
resp, err := http.DefaultClient.Do(req.WithContext(ctx))
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
return nil, &errors.TaskfileNetworkTimeoutError{URI: node.URL.String(), Timeout: node.timeout}
if ctx.Err() != nil {
return nil, err
}
return nil, errors.TaskfileFetchFailedError{URI: node.URL.String()}
return nil, errors.TaskfileFetchFailedError{URI: node.Location()}
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, errors.TaskfileFetchFailedError{
URI: node.URL.String(),
URI: node.Location(),
HTTPStatusCode: resp.StatusCode,
}
}
@@ -93,11 +87,11 @@ func (node *HTTPNode) ResolveEntrypoint(entrypoint string) (string, error) {
if err != nil {
return "", err
}
return node.URL.ResolveReference(ref).String(), nil
return node.url.ResolveReference(ref).String(), nil
}
func (node *HTTPNode) ResolveDir(dir string) (string, error) {
path, err := execext.Expand(dir)
path, err := execext.ExpandLiteral(dir)
if err != nil {
return "", err
}
@@ -116,7 +110,14 @@ func (node *HTTPNode) ResolveDir(dir string) (string, error) {
return filepathext.SmartJoin(parent, path), nil
}
func (node *HTTPNode) FilenameAndLastDir() (string, string) {
dir, filename := filepath.Split(node.entrypoint)
return filepath.Base(dir), filename
func (node *HTTPNode) CacheKey() string {
checksum := strings.TrimRight(checksum([]byte(node.Location())), "=")
dir, filename := filepath.Split(node.url.Path)
lastDir := filepath.Base(dir)
prefix := filename
// Means it's not "", nor "." nor "/", so it's a valid directory
if len(lastDir) > 1 {
prefix = fmt.Sprintf("%s.%s", lastDir, filename)
}
return fmt.Sprintf("http.%s.%s.%s", node.url.Host, prefix, checksum)
}

View File

@@ -0,0 +1,49 @@
package taskfile
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestHTTPNode_CacheKey(t *testing.T) {
t.Parallel()
tests := []struct {
entrypoint string
expectedKey string
}{
{
entrypoint: "https://github.com",
expectedKey: "http.github.com..996e1f714b08e971ec79e3bea686287e66441f043177999a13dbc546d8fe402a",
},
{
entrypoint: "https://github.com/Taskfile.yml",
expectedKey: "http.github.com.Taskfile.yml.85b3c3ad71b78dc74e404c7b4390fc13672925cb644a4d26c21b9f97c17b5fc0",
},
{
entrypoint: "https://github.com/foo",
expectedKey: "http.github.com.foo.df3158dafc823e6847d9bcaf79328446c4877405e79b100723fa6fd545ed3e2b",
},
{
entrypoint: "https://github.com/foo/Taskfile.yml",
expectedKey: "http.github.com.foo.Taskfile.yml.aea946ea7eb6f6bb4e159e8b840b6b50975927778b2e666df988c03bbf10c4c4",
},
{
entrypoint: "https://github.com/foo/bar",
expectedKey: "http.github.com.foo.bar.d3514ad1d4daedf9cc2825225070b49ebc8db47fa5177951b2a5b9994597570c",
},
{
entrypoint: "https://github.com/foo/bar/Taskfile.yml",
expectedKey: "http.github.com.bar.Taskfile.yml.b9cf01e01e47c0e96ea536e1a8bd7b3a6f6c1f1881bad438990d2bfd4ccd0ac0",
},
}
for _, tt := range tests {
node, err := NewHTTPNode(tt.entrypoint, "", false)
require.NoError(t, err)
key := node.CacheKey()
assert.Equal(t, tt.expectedKey, key)
}
}

View File

@@ -2,7 +2,6 @@ package taskfile
import (
"bufio"
"context"
"fmt"
"os"
"strings"
@@ -13,12 +12,12 @@ import (
// A StdinNode is a node that reads a taskfile from the standard input stream.
type StdinNode struct {
*BaseNode
*baseNode
}
func NewStdinNode(dir string) (*StdinNode, error) {
return &StdinNode{
BaseNode: NewBaseNode(dir),
baseNode: NewBaseNode(dir),
}, nil
}
@@ -30,7 +29,7 @@ func (node *StdinNode) Remote() bool {
return false
}
func (node *StdinNode) Read(ctx context.Context) ([]byte, error) {
func (node *StdinNode) Read() ([]byte, error) {
var stdin []byte
scanner := bufio.NewScanner(os.Stdin)
for scanner.Scan() {
@@ -48,7 +47,7 @@ func (node *StdinNode) ResolveEntrypoint(entrypoint string) (string, error) {
return entrypoint, nil
}
path, err := execext.Expand(entrypoint)
path, err := execext.ExpandLiteral(entrypoint)
if err != nil {
return "", err
}
@@ -61,7 +60,7 @@ func (node *StdinNode) ResolveEntrypoint(entrypoint string) (string, error) {
}
func (node *StdinNode) ResolveDir(dir string) (string, error) {
path, err := execext.Expand(dir)
path, err := execext.ExpandLiteral(dir)
if err != nil {
return "", err
}
@@ -72,7 +71,3 @@ func (node *StdinNode) ResolveDir(dir string) (string, error) {
return filepathext.SmartJoin(node.Dir(), path), nil
}
func (node *StdinNode) FilenameAndLastDir() (string, string) {
return "", "__stdin__"
}

View File

@@ -28,122 +28,168 @@ Continue?`
)
type (
// ReaderDebugFunc is a function that is called when the reader wants to
// log debug messages
ReaderDebugFunc func(string)
// ReaderPromptFunc is a function that is called when the reader wants to
// prompt the user in some way
ReaderPromptFunc func(string) error
// ReaderOption is a function that configures a Reader.
ReaderOption func(*Reader)
// A Reader will recursively read Taskfiles from a given source using a directed
// acyclic graph (DAG).
// DebugFunc is a function that can be called to log debug messages.
DebugFunc func(string)
// PromptFunc is a function that can be called to prompt the user for input.
PromptFunc func(string) error
// A ReaderOption is any type that can apply a configuration to a [Reader].
ReaderOption interface {
ApplyToReader(*Reader)
}
// A Reader will recursively read Taskfiles from a given [Node] and build a
// [ast.TaskfileGraph] from them.
Reader struct {
graph *ast.TaskfileGraph
node Node
insecure bool
download bool
offline bool
timeout time.Duration
tempDir string
debugFunc ReaderDebugFunc
promptFunc ReaderPromptFunc
promptMutex sync.Mutex
graph *ast.TaskfileGraph
insecure bool
download bool
offline bool
tempDir string
cacheExpiryDuration time.Duration
debugFunc DebugFunc
promptFunc PromptFunc
promptMutex sync.Mutex
}
)
// NewReader constructs a new Taskfile Reader using the given Node and options.
func NewReader(
node Node,
opts ...ReaderOption,
) *Reader {
reader := &Reader{
graph: ast.NewTaskfileGraph(),
node: node,
insecure: false,
download: false,
offline: false,
timeout: time.Second * 10,
tempDir: os.TempDir(),
debugFunc: nil,
promptFunc: nil,
promptMutex: sync.Mutex{},
// NewReader constructs a new Taskfile [Reader] using the given Node and
// options.
func NewReader(opts ...ReaderOption) *Reader {
r := &Reader{
graph: ast.NewTaskfileGraph(),
insecure: false,
download: false,
offline: false,
tempDir: os.TempDir(),
cacheExpiryDuration: 0,
debugFunc: nil,
promptFunc: nil,
promptMutex: sync.Mutex{},
}
r.Options(opts...)
return r
}
// Options loops through the given [ReaderOption] functions and applies them to
// the [Reader].
func (r *Reader) Options(opts ...ReaderOption) {
for _, opt := range opts {
opt(reader)
opt.ApplyToReader(r)
}
return reader
}
// WithInsecure enables insecure connections when reading remote taskfiles. By
// default, insecure connections are rejected.
// WithInsecure allows the [Reader] to make insecure connections when reading
// remote taskfiles. By default, insecure connections are rejected.
func WithInsecure(insecure bool) ReaderOption {
return func(r *Reader) {
r.insecure = insecure
}
return &insecureOption{insecure: insecure}
}
// WithDownload forces the reader to download a fresh copy of the taskfile from
// the remote source.
type insecureOption struct {
insecure bool
}
func (o *insecureOption) ApplyToReader(r *Reader) {
r.insecure = o.insecure
}
// WithDownload forces the [Reader] to download a fresh copy of the taskfile
// from the remote source.
func WithDownload(download bool) ReaderOption {
return func(r *Reader) {
r.download = download
}
return &downloadOption{download: download}
}
// WithOffline stops the reader from being able to make network connections.
type downloadOption struct {
download bool
}
func (o *downloadOption) ApplyToReader(r *Reader) {
r.download = o.download
}
// WithOffline stops the [Reader] from being able to make network connections.
// It will still be able to read local files and cached copies of remote files.
func WithOffline(offline bool) ReaderOption {
return func(r *Reader) {
r.offline = offline
}
return &offlineOption{offline: offline}
}
// WithTimeout sets the timeout for reading remote taskfiles. By default, the
// timeout is set to 10 seconds.
func WithTimeout(timeout time.Duration) ReaderOption {
return func(r *Reader) {
r.timeout = timeout
}
type offlineOption struct {
offline bool
}
// WithTempDir sets the temporary directory to be used by the reader. By
// default, the reader uses `os.TempDir()`.
func (o *offlineOption) ApplyToReader(r *Reader) {
r.offline = o.offline
}
// WithTempDir sets the temporary directory that will be used by the [Reader].
// By default, the reader uses [os.TempDir].
func WithTempDir(tempDir string) ReaderOption {
return func(r *Reader) {
r.tempDir = tempDir
}
return &tempDirOption{tempDir: tempDir}
}
// WithDebugFunc sets the debug function to be used by the reader. If set, this
// function will be called with debug messages. This can be useful if the caller
// wants to log debug messages from the reader. By default, no debug function is
// set and the logs are not written.
func WithDebugFunc(debugFunc ReaderDebugFunc) ReaderOption {
return func(r *Reader) {
r.debugFunc = debugFunc
}
type tempDirOption struct {
tempDir string
}
// WithPromptFunc sets the prompt function to be used by the reader. If set,
func (o *tempDirOption) ApplyToReader(r *Reader) {
r.tempDir = o.tempDir
}
// WithCacheExpiryDuration sets the duration after which the cache is considered
// expired. By default, the cache is considered expired after 24 hours.
func WithCacheExpiryDuration(duration time.Duration) ReaderOption {
return &cacheExpiryDurationOption{duration: duration}
}
type cacheExpiryDurationOption struct {
duration time.Duration
}
func (o *cacheExpiryDurationOption) ApplyToReader(r *Reader) {
r.cacheExpiryDuration = o.duration
}
// WithDebugFunc sets the debug function to be used by the [Reader]. If set,
// this function will be called with debug messages. This can be useful if the
// caller wants to log debug messages from the [Reader]. By default, no debug
// function is set and the logs are not written.
func WithDebugFunc(debugFunc DebugFunc) ReaderOption {
return &debugFuncOption{debugFunc: debugFunc}
}
type debugFuncOption struct {
debugFunc DebugFunc
}
func (o *debugFuncOption) ApplyToReader(r *Reader) {
r.debugFunc = o.debugFunc
}
// WithPromptFunc sets the prompt function to be used by the [Reader]. If set,
// this function will be called with prompt messages. The function should
// optionally log the message to the user and return nil if the prompt is
// accepted and the execution should continue. Otherwise, it should return an
// error which describes why the the prompt was rejected. This can then be
// caught and used later when calling the Read method. By default, no prompt
// error which describes why the prompt was rejected. This can then be caught
// and used later when calling the [Reader.Read] method. By default, no prompt
// function is set and all prompts are automatically accepted.
func WithPromptFunc(promptFunc ReaderPromptFunc) ReaderOption {
return func(r *Reader) {
r.promptFunc = promptFunc
}
func WithPromptFunc(promptFunc PromptFunc) ReaderOption {
return &promptFuncOption{promptFunc: promptFunc}
}
func (r *Reader) Read() (*ast.TaskfileGraph, error) {
// Recursively loop through each Taskfile, adding vertices/edges to the graph
if err := r.include(r.node); err != nil {
type promptFuncOption struct {
promptFunc PromptFunc
}
func (o *promptFuncOption) ApplyToReader(r *Reader) {
r.promptFunc = o.promptFunc
}
// Read will read the Taskfile defined by the [Reader]'s [Node] and recurse
// through any [ast.Includes] it finds, reading each included Taskfile and
// building an [ast.TaskfileGraph] as it goes. If any errors occur, they will be
// returned immediately.
func (r *Reader) Read(ctx context.Context, node Node) (*ast.TaskfileGraph, error) {
if err := r.include(ctx, node); err != nil {
return nil, err
}
return r.graph, nil
}
@@ -160,7 +206,7 @@ func (r *Reader) promptf(format string, a ...any) error {
return nil
}
func (r *Reader) include(node Node) error {
func (r *Reader) include(ctx context.Context, node Node) error {
// Create a new vertex for the Taskfile
vertex := &ast.TaskfileVertex{
URI: node.Location(),
@@ -178,7 +224,7 @@ func (r *Reader) include(node Node) error {
// Read and parse the Taskfile from the file and add it to the vertex
var err error
vertex.Taskfile, err = r.readNode(node)
vertex.Taskfile, err = r.readNode(ctx, node)
if err != nil {
return err
}
@@ -204,6 +250,7 @@ func (r *Reader) include(node Node) error {
AdvancedImport: include.AdvancedImport,
Excludes: include.Excludes,
Vars: include.Vars,
Checksum: include.Checksum,
}
if err := cache.Err(); err != nil {
return err
@@ -219,8 +266,9 @@ func (r *Reader) include(node Node) error {
return err
}
includeNode, err := NewNode(entrypoint, include.Dir, r.insecure, r.timeout,
includeNode, err := NewNode(entrypoint, include.Dir, r.insecure,
WithParent(node),
WithChecksum(include.Checksum),
)
if err != nil {
if include.Optional {
@@ -230,7 +278,7 @@ func (r *Reader) include(node Node) error {
}
// Recurse into the included Taskfile
if err := r.include(includeNode); err != nil {
if err := r.include(ctx, includeNode); err != nil {
return err
}
@@ -270,8 +318,8 @@ func (r *Reader) include(node Node) error {
return g.Wait()
}
func (r *Reader) readNode(node Node) (*ast.Taskfile, error) {
b, err := r.loadNodeContent(node)
func (r *Reader) readNode(ctx context.Context, node Node) (*ast.Taskfile, error) {
b, err := r.readNodeContent(ctx, node)
if err != nil {
return nil, err
}
@@ -282,9 +330,9 @@ func (r *Reader) readNode(node Node) (*ast.Taskfile, error) {
taskfileDecodeErr := &errors.TaskfileDecodeError{}
if errors.As(err, &taskfileDecodeErr) {
snippet := NewSnippet(b,
SnippetWithLine(taskfileDecodeErr.Line),
SnippetWithColumn(taskfileDecodeErr.Column),
SnippetWithPadding(2),
WithLine(taskfileDecodeErr.Line),
WithColumn(taskfileDecodeErr.Column),
WithPadding(2),
)
return nil, taskfileDecodeErr.WithFileInfo(node.Location(), snippet.String())
}
@@ -312,92 +360,133 @@ func (r *Reader) readNode(node Node) (*ast.Taskfile, error) {
return &tf, nil
}
func (r *Reader) loadNodeContent(node Node) ([]byte, error) {
if !node.Remote() {
ctx, cf := context.WithTimeout(context.Background(), r.timeout)
defer cf()
return node.Read(ctx)
func (r *Reader) readNodeContent(ctx context.Context, node Node) ([]byte, error) {
if node, isRemote := node.(RemoteNode); isRemote {
return r.readRemoteNodeContent(ctx, node)
}
cache, err := NewCache(r.tempDir)
// Read the Taskfile
b, err := node.Read()
if err != nil {
return nil, err
}
if r.offline {
// In offline mode try to use cached copy
cached, err := cache.read(node)
if errors.Is(err, os.ErrNotExist) {
return nil, &errors.TaskfileCacheNotFoundError{URI: node.Location()}
} else if err != nil {
return nil, err
}
r.debugf("task: [%s] Fetched cached copy\n", node.Location())
return cached, nil
}
ctx, cf := context.WithTimeout(context.Background(), r.timeout)
defer cf()
b, err := node.Read(ctx)
if errors.Is(err, &errors.TaskfileNetworkTimeoutError{}) {
// If we timed out then we likely have a network issue
// If a download was requested, then we can't use a cached copy
if r.download {
return nil, &errors.TaskfileNetworkTimeoutError{URI: node.Location(), Timeout: r.timeout}
}
// Search for any cached copies
cached, err := cache.read(node)
if errors.Is(err, os.ErrNotExist) {
return nil, &errors.TaskfileNetworkTimeoutError{URI: node.Location(), Timeout: r.timeout, CheckedCache: true}
} else if err != nil {
return nil, err
}
r.debugf("task: [%s] Network timeout. Fetched cached copy\n", node.Location())
return cached, nil
} else if err != nil {
return nil, err
}
r.debugf("task: [%s] Fetched remote copy\n", node.Location())
// Get the checksums
// If the given checksum doesn't match the sum pinned in the Taskfile
checksum := checksum(b)
cachedChecksum := cache.readChecksum(node)
var prompt string
if cachedChecksum == "" {
// If the checksum doesn't exist, prompt the user to continue
prompt = taskfileUntrustedPrompt
} else if checksum != cachedChecksum {
// If there is a cached hash, but it doesn't match the expected hash, prompt the user to continue
prompt = taskfileChangedPrompt
}
if prompt != "" {
if err := func() error {
r.promptMutex.Lock()
defer r.promptMutex.Unlock()
return r.promptf(prompt, node.Location())
}(); err != nil {
return nil, &errors.TaskfileNotTrustedError{URI: node.Location()}
}
// Store the checksum
if err := cache.writeChecksum(node, checksum); err != nil {
return nil, err
}
// Cache the file
r.debugf("task: [%s] Caching downloaded file\n", node.Location())
if err = cache.write(node, b); err != nil {
return nil, err
if !node.Verify(checksum) {
return nil, &errors.TaskfileDoesNotMatchChecksum{
URI: node.Location(),
ExpectedChecksum: node.Checksum(),
ActualChecksum: checksum,
}
}
return b, nil
}
func (r *Reader) readRemoteNodeContent(ctx context.Context, node RemoteNode) ([]byte, error) {
cache := NewCacheNode(node, r.tempDir)
now := time.Now().UTC()
timestamp := cache.ReadTimestamp()
expiry := timestamp.Add(r.cacheExpiryDuration)
cacheValid := now.Before(expiry)
var cacheFound bool
r.debugf("checking cache for %q in %q\n", node.Location(), cache.Location())
cachedBytes, err := cache.Read()
switch {
// If the cache doesn't exist, we need to download the file
case errors.Is(err, os.ErrNotExist):
r.debugf("no cache found\n")
// If we couldn't find a cached copy, and we are offline, we can't do anything
if r.offline {
return nil, &errors.TaskfileCacheNotFoundError{
URI: node.Location(),
}
}
// If the cache is expired
case !cacheValid:
r.debugf("cache expired at %s\n", expiry.Format(time.RFC3339))
cacheFound = true
// If we can't fetch a fresh copy, we should use the cache anyway
if r.offline {
r.debugf("in offline mode, using expired cache\n")
return cachedBytes, nil
}
// Some other error
case err != nil:
return nil, err
// Found valid cache
default:
r.debugf("cache found\n")
// Not being forced to redownload, return cache
if !r.download {
return cachedBytes, nil
}
cacheFound = true
}
// Try to read the remote file
r.debugf("downloading remote file: %s\n", node.Location())
downloadedBytes, err := node.ReadContext(ctx)
if err != nil {
// If the context timed out or was cancelled, but we found a cached version, use that
if ctx.Err() != nil && cacheFound {
if cacheValid {
r.debugf("failed to fetch remote file: %s: using cache\n", ctx.Err().Error())
} else {
r.debugf("failed to fetch remote file: %s: using expired cache\n", ctx.Err().Error())
}
return cachedBytes, nil
}
return nil, err
}
r.debugf("found remote file at %q\n", node.Location())
// If the given checksum doesn't match the sum pinned in the Taskfile
checksum := checksum(downloadedBytes)
if !node.Verify(checksum) {
return nil, &errors.TaskfileDoesNotMatchChecksum{
URI: node.Location(),
ExpectedChecksum: node.Checksum(),
ActualChecksum: checksum,
}
}
// If there is no manual checksum pin, run the automatic checks
if node.Checksum() == "" {
// Prompt the user if required
prompt := cache.ChecksumPrompt(checksum)
if prompt != "" {
if err := func() error {
r.promptMutex.Lock()
defer r.promptMutex.Unlock()
return r.promptf(prompt, node.Location())
}(); err != nil {
return nil, &errors.TaskfileNotTrustedError{URI: node.Location()}
}
}
}
// Store the checksum
if err := cache.WriteChecksum(checksum); err != nil {
return nil, err
}
// Store the timestamp
if err := cache.WriteTimestamp(now); err != nil {
return nil, err
}
// Cache the file
r.debugf("caching %q to %q\n", node.Location(), cache.Location())
if err = cache.Write(downloadedBytes); err != nil {
return nil, err
}
return downloadedBytes, nil
}

View File

@@ -33,8 +33,13 @@ func init() {
}
type (
SnippetOption func(*Snippet)
Snippet struct {
// A SnippetOption is any type that can apply a configuration to a [Snippet].
SnippetOption interface {
ApplyToSnippet(*Snippet)
}
// A Snippet is a syntax highlighted snippet of a Taskfile with optional
// padding and a line and column indicator.
Snippet struct {
linesRaw []string
linesHighlighted []string
start int
@@ -46,15 +51,13 @@ type (
}
)
// NewSnippet creates a new snippet from a byte slice and a line and column
// NewSnippet creates a new [Snippet] from a byte slice and a line and column
// number. The line and column numbers should be 1-indexed. For example, the
// first character in the file would be 1:1 (line 1, column 1). The padding
// determines the number of lines to include before and after the chosen line.
func NewSnippet(b []byte, opts ...SnippetOption) *Snippet {
snippet := &Snippet{}
for _, opt := range opts {
opt(snippet)
}
snippet.Options(opts...)
// Syntax highlight the input and split it into lines
buf := &bytes.Buffer{}
@@ -73,50 +76,87 @@ func NewSnippet(b []byte, opts ...SnippetOption) *Snippet {
return snippet
}
func SnippetWithLine(line int) SnippetOption {
return func(snippet *Snippet) {
snippet.line = line
// Options loops through the given [SnippetOption] functions and applies them
// to the [Snippet].
func (s *Snippet) Options(opts ...SnippetOption) {
for _, opt := range opts {
opt.ApplyToSnippet(s)
}
}
func SnippetWithColumn(column int) SnippetOption {
return func(snippet *Snippet) {
snippet.column = column
}
// WithLine specifies the line number that the [Snippet] should center around
// and point to.
func WithLine(line int) SnippetOption {
return &lineOption{line: line}
}
func SnippetWithPadding(padding int) SnippetOption {
return func(snippet *Snippet) {
snippet.padding = padding
}
type lineOption struct {
line int
}
func SnippetWithNoIndicators() SnippetOption {
return func(snippet *Snippet) {
snippet.noIndicators = true
}
func (o *lineOption) ApplyToSnippet(s *Snippet) {
s.line = o.line
}
func (snippet *Snippet) String() string {
// WithColumn specifies the column number that the [Snippet] should point to.
func WithColumn(column int) SnippetOption {
return &columnOption{column: column}
}
type columnOption struct {
column int
}
func (o *columnOption) ApplyToSnippet(s *Snippet) {
s.column = o.column
}
// WithPadding specifies the number of lines to include before and after the
// selected line in the [Snippet].
func WithPadding(padding int) SnippetOption {
return &paddingOption{padding: padding}
}
type paddingOption struct {
padding int
}
func (o *paddingOption) ApplyToSnippet(s *Snippet) {
s.padding = o.padding
}
// WithNoIndicators specifies that the [Snippet] should not include line or
// column indicators.
func WithNoIndicators() SnippetOption {
return &noIndicatorsOption{}
}
type noIndicatorsOption struct{}
func (o *noIndicatorsOption) ApplyToSnippet(s *Snippet) {
s.noIndicators = true
}
func (s *Snippet) String() string {
buf := &bytes.Buffer{}
maxLineNumberDigits := digits(snippet.end)
maxLineNumberDigits := digits(s.end)
lineNumberFormat := fmt.Sprintf("%%%dd", maxLineNumberDigits)
lineNumberSpacer := strings.Repeat(" ", maxLineNumberDigits)
lineIndicatorSpacer := strings.Repeat(" ", len(lineIndicator))
columnSpacer := strings.Repeat(" ", max(snippet.column-1, 0))
columnSpacer := strings.Repeat(" ", max(s.column-1, 0))
// Loop over each line in the snippet
for i, lineHighlighted := range snippet.linesHighlighted {
for i, lineHighlighted := range s.linesHighlighted {
if i > 0 {
fmt.Fprintln(buf)
}
currentLine := snippet.start + i
currentLine := s.start + i
lineNumber := fmt.Sprintf(lineNumberFormat, currentLine)
// If this is a padding line or indicators are disabled, print it as normal
if currentLine != snippet.line || snippet.noIndicators {
if currentLine != s.line || s.noIndicators {
fmt.Fprintf(buf, "%s %s | %s", lineIndicatorSpacer, lineNumber, lineHighlighted)
continue
}
@@ -125,13 +165,13 @@ func (snippet *Snippet) String() string {
fmt.Fprintf(buf, "%s %s | %s", color.RedString(lineIndicator), lineNumber, lineHighlighted)
// Only print the column indicator if the column is in bounds
if snippet.column > 0 && snippet.column <= len(snippet.linesRaw[i]) {
if s.column > 0 && s.column <= len(s.linesRaw[i]) {
fmt.Fprintf(buf, "\n%s %s | %s%s", lineIndicatorSpacer, lineNumberSpacer, columnSpacer, color.RedString(columnIndicator))
}
}
// If there are lines, but no line is selected, print the column indicator under all the lines
if len(snippet.linesHighlighted) > 0 && snippet.line == 0 && snippet.column > 0 {
if len(s.linesHighlighted) > 0 && s.line == 0 && s.column > 0 {
fmt.Fprintf(buf, "\n%s %s | %s%s", lineIndicatorSpacer, lineNumberSpacer, columnSpacer, color.RedString(columnIndicator))
}

View File

@@ -31,8 +31,8 @@ func TestNewSnippet(t *testing.T) {
name: "first line, first column",
b: []byte(sample),
opts: []SnippetOption{
SnippetWithLine(1),
SnippetWithColumn(1),
WithLine(1),
WithColumn(1),
},
want: &Snippet{
linesRaw: []string{
@@ -52,9 +52,9 @@ func TestNewSnippet(t *testing.T) {
name: "first line, first column, padding=2",
b: []byte(sample),
opts: []SnippetOption{
SnippetWithLine(1),
SnippetWithColumn(1),
SnippetWithPadding(2),
WithLine(1),
WithColumn(1),
WithPadding(2),
},
want: &Snippet{
linesRaw: []string{
@@ -96,8 +96,8 @@ func TestSnippetString(t *testing.T) {
name: "empty",
b: []byte{},
opts: []SnippetOption{
SnippetWithLine(1),
SnippetWithColumn(1),
WithLine(1),
WithColumn(1),
},
want: "",
},
@@ -110,7 +110,7 @@ func TestSnippetString(t *testing.T) {
name: "1st line, 0th column (line indicator only)",
b: []byte(sample),
opts: []SnippetOption{
SnippetWithLine(1),
WithLine(1),
},
want: "> 1 | \x1b[33mversion\x1b[0m\x1b[1m\x1b[30m:\x1b[0m\x1b[1m\x1b[30m \x1b[0m\x1b[36m3\x1b[0m\x1b[1m\x1b[30m\x1b[0m",
},
@@ -118,7 +118,7 @@ func TestSnippetString(t *testing.T) {
name: "0th line, 1st column (column indicator only)",
b: []byte(sample),
opts: []SnippetOption{
SnippetWithColumn(1),
WithColumn(1),
},
want: "",
},
@@ -126,8 +126,8 @@ func TestSnippetString(t *testing.T) {
name: "0th line, 1st column, padding=2 (column indicator only)",
b: []byte(sample),
opts: []SnippetOption{
SnippetWithColumn(1),
SnippetWithPadding(2),
WithColumn(1),
WithPadding(2),
},
want: " 1 | \x1b[33mversion\x1b[0m\x1b[1m\x1b[30m:\x1b[0m\x1b[1m\x1b[30m \x1b[0m\x1b[36m3\x1b[0m\x1b[1m\x1b[30m\x1b[0m\n 2 | \x1b[1m\x1b[30m\x1b[0m\n | ^",
},
@@ -135,8 +135,8 @@ func TestSnippetString(t *testing.T) {
name: "1st line, 1st column",
b: []byte(sample),
opts: []SnippetOption{
SnippetWithLine(1),
SnippetWithColumn(1),
WithLine(1),
WithColumn(1),
},
want: "> 1 | \x1b[33mversion\x1b[0m\x1b[1m\x1b[30m:\x1b[0m\x1b[1m\x1b[30m \x1b[0m\x1b[36m3\x1b[0m\x1b[1m\x1b[30m\x1b[0m\n | ^",
},
@@ -144,8 +144,8 @@ func TestSnippetString(t *testing.T) {
name: "1st line, 10th column",
b: []byte(sample),
opts: []SnippetOption{
SnippetWithLine(1),
SnippetWithColumn(10),
WithLine(1),
WithColumn(10),
},
want: "> 1 | \x1b[33mversion\x1b[0m\x1b[1m\x1b[30m:\x1b[0m\x1b[1m\x1b[30m \x1b[0m\x1b[36m3\x1b[0m\x1b[1m\x1b[30m\x1b[0m\n | ^",
},
@@ -153,9 +153,9 @@ func TestSnippetString(t *testing.T) {
name: "1st line, 1st column, padding=2",
b: []byte(sample),
opts: []SnippetOption{
SnippetWithLine(1),
SnippetWithColumn(1),
SnippetWithPadding(2),
WithLine(1),
WithColumn(1),
WithPadding(2),
},
want: "> 1 | \x1b[33mversion\x1b[0m\x1b[1m\x1b[30m:\x1b[0m\x1b[1m\x1b[30m \x1b[0m\x1b[36m3\x1b[0m\x1b[1m\x1b[30m\x1b[0m\n | ^\n 2 | \x1b[1m\x1b[30m\x1b[0m\n 3 | \x1b[33mtasks\x1b[0m\x1b[1m\x1b[30m:\x1b[0m\x1b[1m\x1b[30m\x1b[0m",
},
@@ -163,9 +163,9 @@ func TestSnippetString(t *testing.T) {
name: "1st line, 10th column, padding=2",
b: []byte(sample),
opts: []SnippetOption{
SnippetWithLine(1),
SnippetWithColumn(10),
SnippetWithPadding(2),
WithLine(1),
WithColumn(10),
WithPadding(2),
},
want: "> 1 | \x1b[33mversion\x1b[0m\x1b[1m\x1b[30m:\x1b[0m\x1b[1m\x1b[30m \x1b[0m\x1b[36m3\x1b[0m\x1b[1m\x1b[30m\x1b[0m\n | ^\n 2 | \x1b[1m\x1b[30m\x1b[0m\n 3 | \x1b[33mtasks\x1b[0m\x1b[1m\x1b[30m:\x1b[0m\x1b[1m\x1b[30m\x1b[0m",
},
@@ -173,8 +173,8 @@ func TestSnippetString(t *testing.T) {
name: "5th line, 1st column",
b: []byte(sample),
opts: []SnippetOption{
SnippetWithLine(5),
SnippetWithColumn(1),
WithLine(5),
WithColumn(1),
},
want: "> 5 | \x1b[1m\x1b[30m \x1b[0m\x1b[33mvars\x1b[0m\x1b[1m\x1b[30m:\x1b[0m\x1b[1m\x1b[30m\x1b[0m\n | ^",
},
@@ -182,8 +182,8 @@ func TestSnippetString(t *testing.T) {
name: "5th line, 5th column",
b: []byte(sample),
opts: []SnippetOption{
SnippetWithLine(5),
SnippetWithColumn(5),
WithLine(5),
WithColumn(5),
},
want: "> 5 | \x1b[1m\x1b[30m \x1b[0m\x1b[33mvars\x1b[0m\x1b[1m\x1b[30m:\x1b[0m\x1b[1m\x1b[30m\x1b[0m\n | ^",
},
@@ -191,9 +191,9 @@ func TestSnippetString(t *testing.T) {
name: "5th line, 5th column, padding=2",
b: []byte(sample),
opts: []SnippetOption{
SnippetWithLine(5),
SnippetWithColumn(5),
SnippetWithPadding(2),
WithLine(5),
WithColumn(5),
WithPadding(2),
},
want: " 3 | \x1b[33mtasks\x1b[0m\x1b[1m\x1b[30m:\x1b[0m\x1b[1m\x1b[30m\x1b[0m\n 4 | \x1b[1m\x1b[30m \x1b[0m\x1b[33mdefault\x1b[0m\x1b[1m\x1b[30m:\x1b[0m\x1b[1m\x1b[30m\x1b[0m\n> 5 | \x1b[1m\x1b[30m \x1b[0m\x1b[33mvars\x1b[0m\x1b[1m\x1b[30m:\x1b[0m\x1b[1m\x1b[30m\x1b[0m\n | ^\n 6 | \x1b[1m\x1b[30m \x1b[0m\x1b[33mFOO\x1b[0m\x1b[1m\x1b[30m:\x1b[0m\x1b[1m\x1b[30m \x1b[0m\x1b[36mfoo\x1b[0m\x1b[1m\x1b[30m\x1b[0m\n 7 | \x1b[1m\x1b[30m \x1b[0m\x1b[33mBAR\x1b[0m\x1b[1m\x1b[30m:\x1b[0m\x1b[1m\x1b[30m \x1b[0m\x1b[36mbar\x1b[0m\x1b[1m\x1b[30m\x1b[0m",
},
@@ -201,10 +201,10 @@ func TestSnippetString(t *testing.T) {
name: "5th line, 5th column, padding=2, no indicators",
b: []byte(sample),
opts: []SnippetOption{
SnippetWithLine(5),
SnippetWithColumn(5),
SnippetWithPadding(2),
SnippetWithNoIndicators(),
WithLine(5),
WithColumn(5),
WithPadding(2),
WithNoIndicators(),
},
want: " 3 | \x1b[33mtasks\x1b[0m\x1b[1m\x1b[30m:\x1b[0m\x1b[1m\x1b[30m\x1b[0m\n 4 | \x1b[1m\x1b[30m \x1b[0m\x1b[33mdefault\x1b[0m\x1b[1m\x1b[30m:\x1b[0m\x1b[1m\x1b[30m\x1b[0m\n 5 | \x1b[1m\x1b[30m \x1b[0m\x1b[33mvars\x1b[0m\x1b[1m\x1b[30m:\x1b[0m\x1b[1m\x1b[30m\x1b[0m\n 6 | \x1b[1m\x1b[30m \x1b[0m\x1b[33mFOO\x1b[0m\x1b[1m\x1b[30m:\x1b[0m\x1b[1m\x1b[30m \x1b[0m\x1b[36mfoo\x1b[0m\x1b[1m\x1b[30m\x1b[0m\n 7 | \x1b[1m\x1b[30m \x1b[0m\x1b[33mBAR\x1b[0m\x1b[1m\x1b[30m:\x1b[0m\x1b[1m\x1b[30m \x1b[0m\x1b[36mbar\x1b[0m\x1b[1m\x1b[30m\x1b[0m",
},
@@ -212,8 +212,8 @@ func TestSnippetString(t *testing.T) {
name: "10th line, 1st column",
b: []byte(sample),
opts: []SnippetOption{
SnippetWithLine(10),
SnippetWithColumn(1),
WithLine(10),
WithColumn(1),
},
want: "> 10 | \x1b[1m\x1b[30m \x1b[0m\x1b[1m\x1b[30m- \x1b[0m\x1b[36mecho \"{{.BAR}}\"\x1b[0m\x1b[1m\x1b[30m\x1b[0m\n | ^",
},
@@ -221,8 +221,8 @@ func TestSnippetString(t *testing.T) {
name: "10th line, 23rd column",
b: []byte(sample),
opts: []SnippetOption{
SnippetWithLine(10),
SnippetWithColumn(23),
WithLine(10),
WithColumn(23),
},
want: "> 10 | \x1b[1m\x1b[30m \x1b[0m\x1b[1m\x1b[30m- \x1b[0m\x1b[36mecho \"{{.BAR}}\"\x1b[0m\x1b[1m\x1b[30m\x1b[0m\n | ^",
},
@@ -230,8 +230,8 @@ func TestSnippetString(t *testing.T) {
name: "10th line, 24th column (out of bounds)",
b: []byte(sample),
opts: []SnippetOption{
SnippetWithLine(10),
SnippetWithColumn(24),
WithLine(10),
WithColumn(24),
},
want: "> 10 | \x1b[1m\x1b[30m \x1b[0m\x1b[1m\x1b[30m- \x1b[0m\x1b[36mecho \"{{.BAR}}\"\x1b[0m\x1b[1m\x1b[30m\x1b[0m",
},
@@ -239,9 +239,9 @@ func TestSnippetString(t *testing.T) {
name: "10th line, 23rd column, padding=2",
b: []byte(sample),
opts: []SnippetOption{
SnippetWithLine(10),
SnippetWithColumn(23),
SnippetWithPadding(2),
WithLine(10),
WithColumn(23),
WithPadding(2),
},
want: " 8 | \x1b[1m\x1b[30m \x1b[0m\x1b[33mcmds\x1b[0m\x1b[1m\x1b[30m:\x1b[0m\x1b[1m\x1b[30m\x1b[0m\n 9 | \x1b[1m\x1b[30m \x1b[0m\x1b[1m\x1b[30m- \x1b[0m\x1b[36mecho \"{{.FOO}}\"\x1b[0m\x1b[1m\x1b[30m\x1b[0m\n> 10 | \x1b[1m\x1b[30m \x1b[0m\x1b[1m\x1b[30m- \x1b[0m\x1b[36mecho \"{{.BAR}}\"\x1b[0m\x1b[1m\x1b[30m\x1b[0m\n | ^",
},
@@ -249,9 +249,9 @@ func TestSnippetString(t *testing.T) {
name: "5th line, 5th column, padding=100",
b: []byte(sample),
opts: []SnippetOption{
SnippetWithLine(5),
SnippetWithColumn(5),
SnippetWithPadding(100),
WithLine(5),
WithColumn(5),
WithPadding(100),
},
want: " 1 | \x1b[33mversion\x1b[0m\x1b[1m\x1b[30m:\x1b[0m\x1b[1m\x1b[30m \x1b[0m\x1b[36m3\x1b[0m\x1b[1m\x1b[30m\x1b[0m\n 2 | \x1b[1m\x1b[30m\x1b[0m\n 3 | \x1b[33mtasks\x1b[0m\x1b[1m\x1b[30m:\x1b[0m\x1b[1m\x1b[30m\x1b[0m\n 4 | \x1b[1m\x1b[30m \x1b[0m\x1b[33mdefault\x1b[0m\x1b[1m\x1b[30m:\x1b[0m\x1b[1m\x1b[30m\x1b[0m\n> 5 | \x1b[1m\x1b[30m \x1b[0m\x1b[33mvars\x1b[0m\x1b[1m\x1b[30m:\x1b[0m\x1b[1m\x1b[30m\x1b[0m\n | ^\n 6 | \x1b[1m\x1b[30m \x1b[0m\x1b[33mFOO\x1b[0m\x1b[1m\x1b[30m:\x1b[0m\x1b[1m\x1b[30m \x1b[0m\x1b[36mfoo\x1b[0m\x1b[1m\x1b[30m\x1b[0m\n 7 | \x1b[1m\x1b[30m \x1b[0m\x1b[33mBAR\x1b[0m\x1b[1m\x1b[30m:\x1b[0m\x1b[1m\x1b[30m \x1b[0m\x1b[36mbar\x1b[0m\x1b[1m\x1b[30m\x1b[0m\n 8 | \x1b[1m\x1b[30m \x1b[0m\x1b[33mcmds\x1b[0m\x1b[1m\x1b[30m:\x1b[0m\x1b[1m\x1b[30m\x1b[0m\n 9 | \x1b[1m\x1b[30m \x1b[0m\x1b[1m\x1b[30m- \x1b[0m\x1b[36mecho \"{{.FOO}}\"\x1b[0m\x1b[1m\x1b[30m\x1b[0m\n 10 | \x1b[1m\x1b[30m \x1b[0m\x1b[1m\x1b[30m- \x1b[0m\x1b[36mecho \"{{.BAR}}\"\x1b[0m\x1b[1m\x1b[30m\x1b[0m",
},
@@ -259,8 +259,8 @@ func TestSnippetString(t *testing.T) {
name: "11th line (out of bounds), 1st column",
b: []byte(sample),
opts: []SnippetOption{
SnippetWithLine(11),
SnippetWithColumn(1),
WithLine(11),
WithColumn(1),
},
want: "",
},
@@ -268,9 +268,9 @@ func TestSnippetString(t *testing.T) {
name: "11th line (out of bounds), 1st column, padding=2",
b: []byte(sample),
opts: []SnippetOption{
SnippetWithLine(11),
SnippetWithColumn(1),
SnippetWithPadding(2),
WithLine(11),
WithColumn(1),
WithPadding(2),
},
want: " 9 | \x1b[1m\x1b[30m \x1b[0m\x1b[1m\x1b[30m- \x1b[0m\x1b[36mecho \"{{.FOO}}\"\x1b[0m\x1b[1m\x1b[30m\x1b[0m\n 10 | \x1b[1m\x1b[30m \x1b[0m\x1b[1m\x1b[30m- \x1b[0m\x1b[36mecho \"{{.BAR}}\"\x1b[0m\x1b[1m\x1b[30m\x1b[0m",
},

View File

@@ -2,17 +2,13 @@ package taskfile
import (
"context"
"fmt"
"net/http"
"net/url"
"os"
"path/filepath"
"slices"
"strings"
"time"
"github.com/go-task/task/v3/errors"
"github.com/go-task/task/v3/internal/filepathext"
"github.com/go-task/task/v3/internal/sysinfo"
)
var (
@@ -40,20 +36,20 @@ var (
// at the given URL with any of the default Taskfile files names. If any of
// these match a file, the first matching path will be returned. If no files are
// found, an error will be returned.
func RemoteExists(ctx context.Context, u *url.URL, timeout time.Duration) (*url.URL, error) {
func RemoteExists(ctx context.Context, u url.URL) (*url.URL, error) {
// Create a new HEAD request for the given URL to check if the resource exists
req, err := http.NewRequestWithContext(ctx, "HEAD", u.String(), nil)
if err != nil {
return nil, errors.TaskfileFetchFailedError{URI: u.String()}
return nil, errors.TaskfileFetchFailedError{URI: u.Redacted()}
}
// Request the given URL
resp, err := http.DefaultClient.Do(req)
if err != nil {
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
return nil, &errors.TaskfileNetworkTimeoutError{URI: u.String(), Timeout: timeout}
if ctx.Err() != nil {
return nil, fmt.Errorf("checking remote file: %w", ctx.Err())
}
return nil, errors.TaskfileFetchFailedError{URI: u.String()}
return nil, errors.TaskfileFetchFailedError{URI: u.Redacted()}
}
defer resp.Body.Close()
@@ -65,7 +61,7 @@ func RemoteExists(ctx context.Context, u *url.URL, timeout time.Duration) (*url.
if resp.StatusCode == http.StatusOK && slices.ContainsFunc(allowedContentTypes, func(s string) bool {
return strings.Contains(contentType, s)
}) {
return u, nil
return &u, nil
}
// If the request was not successful, append the default Taskfile names to
@@ -82,7 +78,7 @@ func RemoteExists(ctx context.Context, u *url.URL, timeout time.Duration) (*url.
// Try the alternative URL
resp, err = http.DefaultClient.Do(req)
if err != nil {
return nil, errors.TaskfileFetchFailedError{URI: u.String()}
return nil, errors.TaskfileFetchFailedError{URI: u.Redacted()}
}
defer resp.Body.Close()
@@ -92,67 +88,5 @@ func RemoteExists(ctx context.Context, u *url.URL, timeout time.Duration) (*url.
}
}
return nil, errors.TaskfileNotFoundError{URI: u.String(), Walk: false}
}
// Exists will check if a file at the given path Exists. If it does, it will
// return the path to it. If it does not, it will search for any files at the
// given path with any of the default Taskfile files names. If any of these
// match a file, the first matching path will be returned. If no files are
// found, an error will be returned.
func Exists(path string) (string, error) {
fi, err := os.Stat(path)
if err != nil {
return "", err
}
if fi.Mode().IsRegular() ||
fi.Mode()&os.ModeDevice != 0 ||
fi.Mode()&os.ModeSymlink != 0 ||
fi.Mode()&os.ModeNamedPipe != 0 {
return filepath.Abs(path)
}
for _, taskfile := range defaultTaskfiles {
alt := filepathext.SmartJoin(path, taskfile)
if _, err := os.Stat(alt); err == nil {
return filepath.Abs(alt)
}
}
return "", errors.TaskfileNotFoundError{URI: path, Walk: false}
}
// ExistsWalk will check if a file at the given path exists by calling the
// exists function. If a file is not found, it will walk up the directory tree
// calling the exists function until it finds a file or reaches the root
// directory. On supported operating systems, it will also check if the user ID
// of the directory changes and abort if it does.
func ExistsWalk(path string) (string, error) {
origPath := path
owner, err := sysinfo.Owner(path)
if err != nil {
return "", err
}
for {
fpath, err := Exists(path)
if err == nil {
return fpath, nil
}
// Get the parent path/user id
parentPath := filepath.Dir(path)
parentOwner, err := sysinfo.Owner(parentPath)
if err != nil {
return "", err
}
// Error if we reached the root directory and still haven't found a file
// OR if the user id of the directory changes
if path == parentPath || (parentOwner != owner) {
return "", errors.TaskfileNotFoundError{URI: origPath, Walk: false}
}
owner = parentOwner
path = parentPath
}
return nil, errors.TaskfileNotFoundError{URI: u.Redacted(), Walk: false}
}

View File

@@ -5,12 +5,8 @@ version: '3'
vars:
GREETING: Hello, World!
interval: "500ms"
tasks:
default:
sources:
- "src/*"
cmds:
- echo "{{.GREETING}}"
silent: false
silent: true

48
taskrc/ast/taskrc.go Normal file
View File

@@ -0,0 +1,48 @@
package ast
import (
"cmp"
"maps"
"time"
"github.com/Masterminds/semver/v3"
)
type TaskRC struct {
Version *semver.Version `yaml:"version"`
Verbose *bool `yaml:"verbose"`
Concurrency *int `yaml:"concurrency"`
Remote Remote `yaml:"remote"`
Experiments map[string]int `yaml:"experiments"`
}
type Remote struct {
Insecure *bool `yaml:"insecure"`
Offline *bool `yaml:"offline"`
Timeout *time.Duration `yaml:"timeout"`
CacheExpiry *time.Duration `yaml:"cache-expiry"`
}
// Merge combines the current TaskRC with another TaskRC, prioritizing non-nil fields from the other TaskRC.
func (t *TaskRC) Merge(other *TaskRC) {
if other == nil {
return
}
t.Version = cmp.Or(other.Version, t.Version)
if t.Experiments == nil && other.Experiments != nil {
t.Experiments = other.Experiments
} else if t.Experiments != nil && other.Experiments != nil {
maps.Copy(t.Experiments, other.Experiments)
}
// Merge Remote fields
t.Remote.Insecure = cmp.Or(other.Remote.Insecure, t.Remote.Insecure)
t.Remote.Offline = cmp.Or(other.Remote.Offline, t.Remote.Offline)
t.Remote.Timeout = cmp.Or(other.Remote.Timeout, t.Remote.Timeout)
t.Remote.CacheExpiry = cmp.Or(other.Remote.CacheExpiry, t.Remote.CacheExpiry)
t.Verbose = cmp.Or(other.Verbose, t.Verbose)
t.Concurrency = cmp.Or(other.Concurrency, t.Concurrency)
}

24
taskrc/node.go Normal file
View File

@@ -0,0 +1,24 @@
package taskrc
import (
"github.com/go-task/task/v3/internal/fsext"
)
type Node struct {
entrypoint string
}
func NewNode(
entrypoint string,
dir string,
possibleFileNames []string,
) (*Node, error) {
dir = fsext.DefaultDir(entrypoint, dir)
resolvedEntrypoint, err := fsext.SearchPath(dir, possibleFileNames)
if err != nil {
return nil, err
}
return &Node{
entrypoint: resolvedEntrypoint,
}, nil
}

79
taskrc/reader.go Normal file
View File

@@ -0,0 +1,79 @@
package taskrc
import (
"os"
"gopkg.in/yaml.v3"
"github.com/go-task/task/v3/taskrc/ast"
)
type (
// DebugFunc is a function that can be called to log debug messages.
DebugFunc func(string)
// A ReaderOption is any type that can apply a configuration to a [Reader].
ReaderOption interface {
ApplyToReader(*Reader)
}
// A Reader will recursively read Taskfiles from a given [Node] and build a
// [ast.TaskRC] from them.
Reader struct {
debugFunc DebugFunc
}
)
// NewReader constructs a new Taskfile [Reader] using the given Node and
// options.
func NewReader(opts ...ReaderOption) *Reader {
r := &Reader{
debugFunc: nil,
}
r.Options(opts...)
return r
}
// Options loops through the given [ReaderOption] functions and applies them to
// the [Reader].
func (r *Reader) Options(opts ...ReaderOption) {
for _, opt := range opts {
opt.ApplyToReader(r)
}
}
// WithDebugFunc sets the debug function to be used by the [Reader]. If set,
// this function will be called with debug messages. This can be useful if the
// caller wants to log debug messages from the [Reader]. By default, no debug
// function is set and the logs are not written.
func WithDebugFunc(debugFunc DebugFunc) ReaderOption {
return &debugFuncOption{debugFunc: debugFunc}
}
type debugFuncOption struct {
debugFunc DebugFunc
}
func (o *debugFuncOption) ApplyToReader(r *Reader) {
r.debugFunc = o.debugFunc
}
// Read will read the Task config defined by the [Reader]'s [Node].
func (r *Reader) Read(node *Node) (*ast.TaskRC, error) {
var config ast.TaskRC
if node == nil {
return nil, os.ErrInvalid
}
// Read the file
b, err := os.ReadFile(node.entrypoint)
if err != nil {
return nil, err
}
// Parse the content
if err := yaml.Unmarshal(b, &config); err != nil {
return nil, err
}
return &config, nil
}

92
taskrc/taskrc.go Normal file
View File

@@ -0,0 +1,92 @@
package taskrc
import (
"os"
"path/filepath"
"slices"
"strings"
"github.com/go-task/task/v3/internal/fsext"
"github.com/go-task/task/v3/taskrc/ast"
)
var (
defaultXDGTaskRCs = []string{
"taskrc.yml",
"taskrc.yaml",
}
defaultTaskRCs = []string{
".taskrc.yml",
".taskrc.yaml",
}
)
// GetConfig loads and merges local and global Task configuration files
func GetConfig(dir string) (*ast.TaskRC, error) {
var config *ast.TaskRC
reader := NewReader()
// Read the XDG config file
if xdgConfigHome := os.Getenv("XDG_CONFIG_HOME"); xdgConfigHome != "" {
xdgConfigNode, err := NewNode("", filepath.Join(xdgConfigHome, "task"), defaultXDGTaskRCs)
if err == nil && xdgConfigNode != nil {
xdgConfig, err := reader.Read(xdgConfigNode)
if err != nil {
return nil, err
}
config = xdgConfig
}
}
// If the current path does not contain $HOME
// If it does contain $HOME, then we will find this config later anyway
home, err := os.UserHomeDir()
if err == nil && !strings.Contains(home, dir) {
homeNode, err := NewNode("", home, defaultTaskRCs)
if err == nil && homeNode != nil {
homeConfig, err := reader.Read(homeNode)
if err != nil {
return nil, err
}
if config == nil {
config = homeConfig
} else {
config.Merge(homeConfig)
}
}
}
// Find all the nodes from the given directory up to the users home directory
absDir, err := filepath.Abs(dir)
if err != nil {
return nil, err
}
entrypoints, err := fsext.SearchAll("", absDir, defaultTaskRCs)
if err != nil {
return nil, err
}
// Reverse the entrypoints since we want the child files to override parent ones
slices.Reverse(entrypoints)
// Loop over the nodes, and merge them into the main config
for _, entrypoint := range entrypoints {
node, err := NewNode("", entrypoint, defaultTaskRCs)
if err != nil {
return nil, err
}
localConfig, err := reader.Read(node)
if err != nil {
return nil, err
}
if localConfig == nil {
continue
}
if config == nil {
config = localConfig
continue
}
config.Merge(localConfig)
}
return config, nil
}

137
taskrc/taskrc_test.go Normal file
View File

@@ -0,0 +1,137 @@
package taskrc
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/go-task/task/v3/taskrc/ast"
)
const (
xdgConfigYAML = `
experiments:
FOO: 1
BAR: 1
BAZ: 1
`
homeConfigYAML = `
experiments:
FOO: 2
BAR: 2
`
localConfigYAML = `
experiments:
FOO: 3
`
)
func setupDirs(t *testing.T) (string, string, string) {
t.Helper()
xdgConfigDir := t.TempDir()
xdgTaskConfigDir := filepath.Join(xdgConfigDir, "task")
require.NoError(t, os.Mkdir(xdgTaskConfigDir, 0o755))
homeDir := t.TempDir()
localDir := filepath.Join(homeDir, "local")
require.NoError(t, os.Mkdir(localDir, 0o755))
t.Setenv("XDG_CONFIG_HOME", xdgConfigDir)
t.Setenv("HOME", homeDir)
return xdgTaskConfigDir, homeDir, localDir
}
func writeFile(t *testing.T, dir, filename, content string) {
t.Helper()
err := os.WriteFile(filepath.Join(dir, filename), []byte(content), 0o644)
assert.NoError(t, err)
}
func TestGetConfig_NoConfigFiles(t *testing.T) { //nolint:paralleltest // cannot run in parallel
_, _, localDir := setupDirs(t)
cfg, err := GetConfig(localDir)
assert.NoError(t, err)
assert.Nil(t, cfg)
}
func TestGetConfig_OnlyXDG(t *testing.T) { //nolint:paralleltest // cannot run in parallel
xdgDir, _, localDir := setupDirs(t)
writeFile(t, xdgDir, "taskrc.yml", xdgConfigYAML)
cfg, err := GetConfig(localDir)
assert.NoError(t, err)
assert.Equal(t, &ast.TaskRC{
Version: nil,
Experiments: map[string]int{
"FOO": 1,
"BAR": 1,
"BAZ": 1,
},
}, cfg)
}
func TestGetConfig_OnlyHome(t *testing.T) { //nolint:paralleltest // cannot run in parallel
_, homeDir, localDir := setupDirs(t)
writeFile(t, homeDir, ".taskrc.yml", homeConfigYAML)
cfg, err := GetConfig(localDir)
assert.NoError(t, err)
assert.Equal(t, &ast.TaskRC{
Version: nil,
Experiments: map[string]int{
"FOO": 2,
"BAR": 2,
},
}, cfg)
}
func TestGetConfig_OnlyLocal(t *testing.T) { //nolint:paralleltest // cannot run in parallel
_, _, localDir := setupDirs(t)
writeFile(t, localDir, ".taskrc.yml", localConfigYAML)
cfg, err := GetConfig(localDir)
assert.NoError(t, err)
assert.Equal(t, &ast.TaskRC{
Version: nil,
Experiments: map[string]int{
"FOO": 3,
},
}, cfg)
}
func TestGetConfig_All(t *testing.T) { //nolint:paralleltest // cannot run in parallel
xdgConfigDir, homeDir, localDir := setupDirs(t)
// Write local config
writeFile(t, localDir, ".taskrc.yml", localConfigYAML)
// Write home config
writeFile(t, homeDir, ".taskrc.yml", homeConfigYAML)
// Write XDG config
writeFile(t, xdgConfigDir, "taskrc.yml", xdgConfigYAML)
cfg, err := GetConfig(localDir)
assert.NoError(t, err)
assert.NotNil(t, cfg)
assert.Equal(t, &ast.TaskRC{
Version: nil,
Experiments: map[string]int{
"FOO": 3,
"BAR": 2,
"BAZ": 1,
},
}, cfg)
}

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