Skip to content

Commit 5830e66

Browse files
feat(list): add --tree flag for namespace-grouped task display
Introduces --tree (-T), which renders tasks grouped by their top-level namespace with indentation instead of a flat list. Namespaced groups appear first, followed by a separator and root-level tasks. Refs: #2091 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 9ee70a7 commit 5830e66

12 files changed

Lines changed: 399 additions & 1 deletion

File tree

cmd/task/task.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,7 @@ func run() error {
146146
flags.NoStatus,
147147
flags.Nested,
148148
flags.Long,
149+
flags.Tree,
149150
)
150151
if listOptions.ShouldListTasks() {
151152
if flags.Silent {

formatter_test.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,35 @@ func TestListLong(t *testing.T) {
262262
)
263263
}
264264

265+
func TestListTree(t *testing.T) {
266+
t.Parallel()
267+
268+
NewFormatterTest(t,
269+
WithExecutorOptions(
270+
task.WithDir("testdata/list_tree"),
271+
),
272+
WithListOptions(task.ListOptions{
273+
ListOnlyTasksWithDescriptions: true,
274+
Tree: true,
275+
}),
276+
)
277+
}
278+
279+
func TestListTreeLong(t *testing.T) {
280+
t.Parallel()
281+
282+
NewFormatterTest(t,
283+
WithExecutorOptions(
284+
task.WithDir("testdata/list_tree_long"),
285+
),
286+
WithListOptions(task.ListOptions{
287+
ListOnlyTasksWithDescriptions: true,
288+
Tree: true,
289+
Long: true,
290+
}),
291+
)
292+
}
293+
265294
func TestJsonListLong(t *testing.T) {
266295
t.Parallel()
267296

help.go

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,17 +27,19 @@ type ListOptions struct {
2727
NoStatus bool
2828
Nested bool
2929
Long bool
30+
Tree bool
3031
}
3132

3233
// NewListOptions creates a new ListOptions instance
33-
func NewListOptions(list, listAll, listAsJson, noStatus, nested, long bool) ListOptions {
34+
func NewListOptions(list, listAll, listAsJson, noStatus, nested, long, tree bool) ListOptions {
3435
return ListOptions{
3536
ListOnlyTasksWithDescriptions: list,
3637
ListAllTasks: listAll,
3738
FormatTaskListAsJSON: listAsJson,
3839
NoStatus: noStatus,
3940
Nested: nested,
4041
Long: long,
42+
Tree: tree,
4143
}
4244
}
4345

@@ -89,6 +91,9 @@ func (e *Executor) ListTasks(o ListOptions) (bool, error) {
8991
}
9092
return false, nil
9193
}
94+
if o.Tree {
95+
return e.listTasksTree(tasks, o)
96+
}
9297
e.Logger.Outf(logger.Default, "task: Available tasks for this project:\n")
9398

9499
// Format in tab-separated columns with a tab stop of 8.
@@ -162,6 +167,63 @@ func (e *Executor) ListTaskNames(allTasks bool) error {
162167
return nil
163168
}
164169

170+
func (e *Executor) listTasksTree(tasks []*ast.Task, o ListOptions) (bool, error) {
171+
e.Logger.Outf(logger.Default, "task: Available tasks for this project:\n")
172+
173+
groups := listing.GroupByNamespace(tasks)
174+
hasNamespaced := listing.HasNamespacedGroups(groups)
175+
hasRoot := listing.HasRootGroup(groups)
176+
showSeparator := hasNamespaced && hasRoot
177+
178+
// Move root group to end so namespaced groups appear first
179+
if showSeparator {
180+
for i, g := range groups {
181+
if g.Namespace == "" && i < len(groups)-1 {
182+
rootGroup := groups[i]
183+
groups = append(groups[:i], groups[i+1:]...)
184+
groups = append(groups, rootGroup)
185+
break
186+
}
187+
}
188+
}
189+
190+
w := tabwriter.NewWriter(e.Stdout, 0, 8, 6, ' ', 0)
191+
firstGroup := true
192+
for _, group := range groups {
193+
isRoot := group.Namespace == ""
194+
if !firstGroup {
195+
_, _ = fmt.Fprint(w, "\n")
196+
}
197+
firstGroup = false
198+
if isRoot && showSeparator {
199+
e.Logger.FOutf(w, logger.Dim, " ─────\n\n")
200+
}
201+
if !isRoot {
202+
e.Logger.FOutf(w, logger.Dim, " %s\n", group.Namespace)
203+
}
204+
for _, task := range group.Tasks {
205+
name := group.LocalName(task)
206+
desc := strings.ReplaceAll(task.Desc, "\n", " ")
207+
indent := " "
208+
if !isRoot {
209+
indent = " "
210+
}
211+
nameColor := logger.Green
212+
if task.Internal {
213+
nameColor = logger.Dim
214+
}
215+
e.Logger.FOutf(w, nameColor, "%s%s", indent, name)
216+
e.Logger.FOutf(w, logger.Default, ":\t%s", desc)
217+
if len(task.Aliases) > 0 {
218+
e.Logger.FOutf(w, logger.Cyan, "\t(aliases: %s)", strings.Join(task.Aliases, ", "))
219+
}
220+
_, _ = fmt.Fprint(w, "\n")
221+
e.writeTaskDetails(w, task, indent+" \t", o.Long)
222+
}
223+
}
224+
return true, w.Flush()
225+
}
226+
165227
func (e *Executor) ToEditorOutput(tasks []*ast.Task, noStatus bool, nested bool, long bool) (*editors.Namespace, error) {
166228
var g errgroup.Group
167229
editorTasks := make([]editors.Task, len(tasks))

internal/flags/flags.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ var (
8888
Cert string
8989
CertKey string
9090
Interactive bool
91+
Tree bool
9192
)
9293

9394
func init() {
@@ -129,6 +130,7 @@ func init() {
129130
pflag.BoolVarP(&ListJson, "json", "j", false, "Formats task list as JSON.")
130131
pflag.StringVar(&TaskSort, "sort", "", "Changes the order of the tasks when listed. [default|alphanumeric|none].")
131132
pflag.BoolVarP(&Long, "long", "L", false, "Show detailed task information when listing.")
133+
pflag.BoolVarP(&Tree, "tree", "T", false, "Display tasks grouped by namespace.")
132134
pflag.BoolVar(&Status, "status", false, "Exits with non-zero exit code if any of the given tasks is not up-to-date.")
133135
pflag.BoolVar(&NoStatus, "no-status", false, "Ignore status when listing tasks as JSON")
134136
pflag.BoolVar(&Nested, "nested", false, "Nest namespaces when listing tasks as JSON")
@@ -248,6 +250,14 @@ func Validate() error {
248250
return errors.New("task: --nested only applies to --json with --list or --list-all")
249251
}
250252

253+
if Tree && !List && !ListAll {
254+
return errors.New("task: --tree only applies to --list or --list-all")
255+
}
256+
257+
if Tree && ListJson {
258+
return errors.New("task: --tree and --json are mutually exclusive")
259+
}
260+
251261
// Validate certificate flags
252262
if (Cert != "" && CertKey == "") || (Cert == "" && CertKey != "") {
253263
return errors.New("task: --cert and --cert-key must be provided together")

internal/listing/helpers_test.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package listing_test
2+
3+
import "github.com/go-task/task/v3/taskfile/ast"
4+
5+
func newTask(name, desc string) *ast.Task {
6+
return &ast.Task{Task: name, Desc: desc}
7+
}

internal/listing/tree.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
package listing
2+
3+
import (
4+
"strings"
5+
6+
"github.com/go-task/task/v3/taskfile/ast"
7+
)
8+
9+
// TaskGroup represents a set of tasks under a common top-level namespace.
10+
type TaskGroup struct {
11+
Namespace string
12+
Tasks []*ast.Task
13+
}
14+
15+
func (g TaskGroup) LocalName(t *ast.Task) string {
16+
if g.Namespace == "" {
17+
return t.Task
18+
}
19+
return strings.TrimPrefix(t.Task, g.Namespace+":")
20+
}
21+
22+
// GroupByNamespace partitions tasks into groups by their top-level namespace.
23+
func GroupByNamespace(tasks []*ast.Task) []TaskGroup {
24+
groupMap := make(map[string]int)
25+
var groups []TaskGroup
26+
for _, t := range tasks {
27+
ns := topLevelNamespace(t.Task)
28+
idx, exists := groupMap[ns]
29+
if !exists {
30+
idx = len(groups)
31+
groupMap[ns] = idx
32+
groups = append(groups, TaskGroup{Namespace: ns})
33+
}
34+
groups[idx].Tasks = append(groups[idx].Tasks, t)
35+
}
36+
return groups
37+
}
38+
39+
func HasNamespacedGroups(groups []TaskGroup) bool {
40+
for _, g := range groups {
41+
if g.Namespace != "" {
42+
return true
43+
}
44+
}
45+
return false
46+
}
47+
48+
func HasRootGroup(groups []TaskGroup) bool {
49+
for _, g := range groups {
50+
if g.Namespace == "" {
51+
return true
52+
}
53+
}
54+
return false
55+
}
56+
57+
func topLevelNamespace(taskName string) string {
58+
if ns, _, found := strings.Cut(taskName, ":"); found {
59+
return ns
60+
}
61+
return ""
62+
}

internal/listing/tree_test.go

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
package listing_test
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
8+
"github.com/go-task/task/v3/internal/listing"
9+
"github.com/go-task/task/v3/taskfile/ast"
10+
)
11+
12+
func TestGroupByNamespace_Empty(t *testing.T) {
13+
t.Parallel()
14+
groups := listing.GroupByNamespace(nil)
15+
assert.Empty(t, groups)
16+
}
17+
18+
func TestGroupByNamespace_OnlyRoot(t *testing.T) {
19+
t.Parallel()
20+
tasks := []*ast.Task{
21+
newTask("build", "Build"),
22+
newTask("test", "Test"),
23+
}
24+
groups := listing.GroupByNamespace(tasks)
25+
assert.Len(t, groups, 1)
26+
assert.Equal(t, "", groups[0].Namespace)
27+
assert.Len(t, groups[0].Tasks, 2)
28+
}
29+
30+
func TestGroupByNamespace_OnlyNamespaced(t *testing.T) {
31+
t.Parallel()
32+
tasks := []*ast.Task{
33+
newTask("ns1:build", "Build"),
34+
newTask("ns1:test", "Test"),
35+
newTask("ns2:deploy", "Deploy"),
36+
}
37+
groups := listing.GroupByNamespace(tasks)
38+
assert.Len(t, groups, 2)
39+
assert.Equal(t, "ns1", groups[0].Namespace)
40+
assert.Len(t, groups[0].Tasks, 2)
41+
assert.Equal(t, "ns2", groups[1].Namespace)
42+
assert.Len(t, groups[1].Tasks, 1)
43+
}
44+
45+
func TestGroupByNamespace_Mixed(t *testing.T) {
46+
t.Parallel()
47+
tasks := []*ast.Task{
48+
newTask("build", "Build"),
49+
newTask("ns1:lint", "Lint"),
50+
newTask("ns1:test", "Test"),
51+
newTask("deploy", "Deploy"),
52+
}
53+
groups := listing.GroupByNamespace(tasks)
54+
assert.Len(t, groups, 2)
55+
assert.Equal(t, "", groups[0].Namespace)
56+
assert.Len(t, groups[0].Tasks, 2)
57+
assert.Equal(t, "ns1", groups[1].Namespace)
58+
assert.Len(t, groups[1].Tasks, 2)
59+
}
60+
61+
func TestGroupByNamespace_PreservesOrder(t *testing.T) {
62+
t.Parallel()
63+
tasks := []*ast.Task{
64+
newTask("z:first", ""),
65+
newTask("a:second", ""),
66+
newTask("m:third", ""),
67+
}
68+
groups := listing.GroupByNamespace(tasks)
69+
assert.Len(t, groups, 3)
70+
assert.Equal(t, "z", groups[0].Namespace)
71+
assert.Equal(t, "a", groups[1].Namespace)
72+
assert.Equal(t, "m", groups[2].Namespace)
73+
}
74+
75+
func TestLocalName_Namespaced(t *testing.T) {
76+
t.Parallel()
77+
g := listing.TaskGroup{Namespace: "ns1"}
78+
task := newTask("ns1:build", "Build")
79+
assert.Equal(t, "build", g.LocalName(task))
80+
}
81+
82+
func TestLocalName_Root(t *testing.T) {
83+
t.Parallel()
84+
g := listing.TaskGroup{Namespace: ""}
85+
task := newTask("build", "Build")
86+
assert.Equal(t, "build", g.LocalName(task))
87+
}
88+
89+
func TestHasNamespacedGroups(t *testing.T) {
90+
t.Parallel()
91+
assert.False(t, listing.HasNamespacedGroups([]listing.TaskGroup{{Namespace: ""}}))
92+
assert.True(t, listing.HasNamespacedGroups([]listing.TaskGroup{{Namespace: "ns"}}))
93+
assert.True(t, listing.HasNamespacedGroups([]listing.TaskGroup{{Namespace: ""}, {Namespace: "ns"}}))
94+
}
95+
96+
func TestHasRootGroup(t *testing.T) {
97+
t.Parallel()
98+
assert.True(t, listing.HasRootGroup([]listing.TaskGroup{{Namespace: ""}}))
99+
assert.False(t, listing.HasRootGroup([]listing.TaskGroup{{Namespace: "ns"}}))
100+
assert.True(t, listing.HasRootGroup([]listing.TaskGroup{{Namespace: ""}, {Namespace: "ns"}}))
101+
}

internal/logger/logger.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ var (
3535
attrsFgHiYellow = envColor("COLOR_BRIGHT_YELLOW", color.FgHiYellow)
3636
attrsFgHiMagenta = envColor("COLOR_BRIGHT_MAGENTA", color.FgHiMagenta)
3737
attrsFgHiRed = envColor("COLOR_BRIGHT_RED", color.FgHiRed)
38+
attrsBold = envColor("COLOR_BOLD", color.Bold)
39+
attrsDim = envColor("COLOR_DIM", color.Faint)
3840
)
3941

4042
type (
@@ -100,6 +102,14 @@ func BrightRed() PrintFunc {
100102
return color.New(attrsFgHiRed...).FprintfFunc()
101103
}
102104

105+
func Bold() PrintFunc {
106+
return color.New(attrsBold...).FprintfFunc()
107+
}
108+
109+
func Dim() PrintFunc {
110+
return color.New(attrsDim...).FprintfFunc()
111+
}
112+
103113
func envColor(name string, defaultColor color.Attribute) []color.Attribute {
104114
// Fetch the environment variable
105115
override := env.GetTaskEnv(name)

0 commit comments

Comments
 (0)