Skip to content

Commit d804623

Browse files
committed
Add top-level flatten: []string to merge YAML files without a namespace
A new `flatten:` field on Config takes a list of YAML file paths whose tasks (and global vars) are merged into the parent file unchanged — colons in task names are preserved as literal characters, not treated as namespace separators. This is the agentic-platform pattern of splitting one logical Taskfile into tasks/*.yml without forcing a namespace prefix on every task name. Mechanics: - Paths in `flatten:` resolve relative to the file declaring them; absolute paths and ~ are honored (via resolvePath, same as dotenv). - A flattened task's `dir:` is resolved against the namespaced ancestor (the root for root flatten, the include's dir for flatten inside a namespaced include), so `dir: backend` written inside tasks/foo.yml means `<root>/backend` — what users naturally expect when splitting one file across many. - First defined wins on collisions: a parent task or earlier flatten file beats a later one, mirroring how mergeVars already behaves for includes. - Cycle detection is unified under a single loadStack keyed by absolute file path, so cycles spanning includes and flatten are caught uniformly. The existing "cyclic include" error message and behavior are preserved. - Flatten works recursively: a flatten file may itself flatten or include further files; included files may themselves declare flatten (their tasks land at that include's namespace). - Task descriptions extracted from YAML comments still work in flatten files; dotenv files declared inside a flatten file are loaded with the same dedup semantics as includes. 14 new tests cover: basic merge, dir resolution against root, parent-wins and first-flatten-wins collisions, var merging, no-rewrite of local task references at root, comment-derived descs, nested flatten chains, cycle rejection, missing file errors, flatten-inside-include namespacing, absolute paths, raw YAML field parsing, and end-to-end runner execution from the resolved dir. Assisted-By: docker-agent
1 parent 73a0f70 commit d804623

5 files changed

Lines changed: 508 additions & 18 deletions

File tree

taskfile/includes.go

Lines changed: 148 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,14 @@ func LoadWithIncludes(dir string) (*Config, error) {
2121
return loader.load()
2222
}
2323

24-
// includeLoader holds shared state while recursively loading included task files.
24+
// includeLoader holds shared state while recursively loading task files
25+
// pulled in via `includes:` (namespaced) and `flatten:` (no namespace).
2526
type includeLoader struct {
26-
root *Config
27-
rootDir string
28-
seenDotenv map[string]struct{} // deduplicates dotenv files across includes
29-
dotenvVars map[string]string // accumulated dotenv variables
30-
includeStack map[string]struct{} // dirs on the current recursion path (cycle detection)
27+
root *Config
28+
rootDir string
29+
seenDotenv map[string]struct{} // deduplicates dotenv files across loaded files
30+
dotenvVars map[string]string // accumulated dotenv variables
31+
loadStack map[string]struct{} // absolute file paths currently being loaded (cycle detection)
3132
}
3233

3334
func newIncludeLoader(root *Config) (*includeLoader, error) {
@@ -38,17 +39,32 @@ func newIncludeLoader(root *Config) (*includeLoader, error) {
3839
}
3940

4041
return &includeLoader{
41-
root: root,
42-
rootDir: root.Dir,
43-
seenDotenv: seenDotenv,
44-
dotenvVars: dotenvVars,
45-
includeStack: map[string]struct{}{root.Dir: {}},
42+
root: root,
43+
rootDir: root.Dir,
44+
seenDotenv: seenDotenv,
45+
dotenvVars: dotenvVars,
46+
loadStack: map[string]struct{}{filepath.Join(root.Dir, fileName): {}},
4647
}, nil
4748
}
4849

4950
func (l *includeLoader) load() (*Config, error) {
5051
l.root.Namespaces = make(map[string]string)
5152

53+
// Process flatten files first (their tasks merge into the root namespace).
54+
// Order vs. includes doesn't matter for correctness because both honor
55+
// "first defined wins"; the root's own tasks were already loaded at Parse.
56+
for _, p := range l.root.Flatten {
57+
if err := l.loadFlatten(flattenRequest{
58+
parentDir: l.rootDir,
59+
parentFile: filepath.Join(l.rootDir, fileName),
60+
path: p,
61+
namespace: "",
62+
ancestorDir: l.rootDir,
63+
}); err != nil {
64+
return nil, err
65+
}
66+
}
67+
5268
for _, includeName := range l.root.Includes {
5369
if err := l.loadInclude(includeRequest{
5470
parentDir: l.rootDir,
@@ -77,18 +93,33 @@ func (r includeRequest) childDir() (string, error) {
7793
return filepath.Abs(filepath.Join(r.parentDir, r.name))
7894
}
7995

80-
// loadInclude parses an included task file, loads nested includes, then merges it into the root.
96+
// loadInclude parses an included task file, loads nested includes and flatten
97+
// files, then merges it into the root.
8198
func (l *includeLoader) loadInclude(req includeRequest) error {
8299
included, err := l.parseInclude(req)
83100
if err != nil {
84101
return err
85102
}
86-
defer l.leaveInclude(included.Dir)
103+
defer l.leaveLoad(filepath.Join(included.Dir, fileName))
87104

88105
if err := l.loadIncludeDotenv(included); err != nil {
89106
return err
90107
}
91108

109+
// Process flatten files declared inside this include — their tasks merge
110+
// at this include's namespace.
111+
for _, p := range included.Flatten {
112+
if err := l.loadFlatten(flattenRequest{
113+
parentDir: included.Dir,
114+
parentFile: filepath.Join(included.Dir, fileName),
115+
path: p,
116+
namespace: included.Namespace,
117+
ancestorDir: included.Dir,
118+
}); err != nil {
119+
return err
120+
}
121+
}
122+
92123
for _, nested := range nestedIncludes(included) {
93124
if err := l.loadInclude(nested); err != nil {
94125
return err
@@ -105,14 +136,15 @@ func (l *includeLoader) parseInclude(req includeRequest) (*includedConfig, error
105136
if err != nil {
106137
return nil, fmt.Errorf("resolving include %q from %s: %w", req.namespace, req.parentFile(), err)
107138
}
108-
if _, onPath := l.includeStack[childDir]; onPath {
139+
expectedFile := filepath.Join(childDir, fileName)
140+
if _, onPath := l.loadStack[expectedFile]; onPath {
109141
return nil, fmt.Errorf("cyclic include %q detected in %s", req.namespace, req.parentFile())
110142
}
111-
l.includeStack[childDir] = struct{}{}
143+
l.loadStack[expectedFile] = struct{}{}
112144

113145
child, err := Parse(childDir)
114146
if err != nil {
115-
delete(l.includeStack, childDir)
147+
delete(l.loadStack, expectedFile)
116148
return nil, fmt.Errorf("loading include %q from %s: %w", req.namespace, req.parentFile(), err)
117149
}
118150

@@ -125,8 +157,8 @@ func (l *includeLoader) parseInclude(req includeRequest) (*includedConfig, error
125157
return included, nil
126158
}
127159

128-
func (l *includeLoader) leaveInclude(dir string) {
129-
delete(l.includeStack, dir)
160+
func (l *includeLoader) leaveLoad(filePath string) {
161+
delete(l.loadStack, filePath)
130162
}
131163

132164
func (l *includeLoader) loadIncludeDotenv(included *includedConfig) error {
@@ -161,6 +193,83 @@ func nestedIncludes(parent *includedConfig) []includeRequest {
161193
return requests
162194
}
163195

196+
// flattenRequest describes a single `flatten:` entry pulled from a parent
197+
// task file. Tasks loaded via flatten merge into the parent's namespace
198+
// (or the root namespace when invoked from the root) without a prefix.
199+
type flattenRequest struct {
200+
parentDir string // dir of the file that declared this flatten (path resolution base)
201+
parentFile string // absolute path of that file (for error messages and cycle detection)
202+
path string // the path string written in YAML
203+
namespace string // namespace at which to merge tasks ("" means the root)
204+
ancestorDir string // dir of the namespaced ancestor (root, or include dir) for resolving task.Dir
205+
}
206+
207+
// loadFlatten parses a flatten YAML file, resolves its nested flatten/includes,
208+
// then merges its tasks into l.root at the given namespace.
209+
func (l *includeLoader) loadFlatten(req flattenRequest) error {
210+
absPath, err := filepath.Abs(resolvePath(req.parentDir, req.path))
211+
if err != nil {
212+
return fmt.Errorf("resolving flatten %q from %s: %w", req.path, req.parentFile, err)
213+
}
214+
if _, onPath := l.loadStack[absPath]; onPath {
215+
return fmt.Errorf("cyclic flatten %q detected in %s", req.path, req.parentFile)
216+
}
217+
l.loadStack[absPath] = struct{}{}
218+
defer l.leaveLoad(absPath)
219+
220+
flattenedDir := filepath.Dir(absPath)
221+
flattened, err := parseFile(absPath, flattenedDir)
222+
if err != nil {
223+
return fmt.Errorf("loading flatten %q from %s: %w", req.path, req.parentFile, err)
224+
}
225+
226+
// Load this file's dotenv (paths resolve relative to the file's own dir).
227+
childDotenv, err := loadDotenvFiles(flattened.Dir, flattened.Dotenv, l.seenDotenv)
228+
if err != nil {
229+
return fmt.Errorf("loading dotenv for flatten %q from %s: %w", req.path, req.parentFile, err)
230+
}
231+
for k, v := range childDotenv {
232+
if _, exists := l.dotenvVars[k]; !exists {
233+
l.dotenvVars[k] = v
234+
}
235+
}
236+
237+
// Recurse: nested flatten files keep the same namespace and ancestor.
238+
for _, p := range flattened.Flatten {
239+
if err := l.loadFlatten(flattenRequest{
240+
parentDir: flattened.Dir,
241+
parentFile: absPath,
242+
path: p,
243+
namespace: req.namespace,
244+
ancestorDir: req.ancestorDir,
245+
}); err != nil {
246+
return err
247+
}
248+
}
249+
250+
// Recurse: nested includes get sub-namespaces under our current one.
251+
for _, name := range flattened.Includes {
252+
if err := l.loadInclude(includeRequest{
253+
parentDir: flattened.Dir,
254+
name: name,
255+
namespace: namespaceJoin(req.namespace, name),
256+
}); err != nil {
257+
return err
258+
}
259+
}
260+
261+
l.mergeVars(flattened.Vars)
262+
l.mergeFlattenedTasks(flattened, req.namespace, req.ancestorDir)
263+
return nil
264+
}
265+
266+
func namespaceJoin(prefix, name string) string {
267+
if prefix == "" {
268+
return name
269+
}
270+
return prefix + ":" + name
271+
}
272+
164273
// mergeVars merges child global vars into the root. Root vars win conflicts.
165274
func (l *includeLoader) mergeVars(vars map[string]Var) {
166275
if len(vars) == 0 {
@@ -183,6 +292,27 @@ func (l *includeLoader) mergeTasks(included *includedConfig) {
183292
}
184293
}
185294

295+
// mergeFlattenedTasks merges tasks from a flattened file into l.root at the
296+
// given namespace. With an empty namespace, tasks land in the root unchanged
297+
// (this is the agentic-platform pattern for splitting one file across many).
298+
// First defined wins, so a parent file can override a flattened task by
299+
// declaring it locally with the same name.
300+
func (l *includeLoader) mergeFlattenedTasks(flattened *Config, namespace, ancestorDir string) {
301+
for _, name := range slices.Sorted(maps.Keys(flattened.Tasks)) {
302+
finalName := namespaceJoin(namespace, name)
303+
if _, exists := l.root.Tasks[finalName]; exists {
304+
continue // first wins (root or earlier flatten file beats this one)
305+
}
306+
task := flattened.Tasks[name]
307+
makeTaskDirAbsolute(&task, ancestorDir)
308+
if namespace != "" {
309+
ic := &includedConfig{Config: flattened, Namespace: namespace}
310+
namespaceLocalReferences(&task, ic)
311+
}
312+
l.root.Tasks[finalName] = task
313+
}
314+
}
315+
186316
func normalizedIncludedTask(included *includedConfig, name string) Task {
187317
task := included.Tasks[name]
188318
makeTaskDirAbsolute(&task, included.Dir)

taskfile/parse.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,13 @@ func Parse(dir string) (*Config, error) {
1717
if path == "" {
1818
return nil, fmt.Errorf("no gogo.yaml found in %s", dir)
1919
}
20+
return parseFile(path, dir)
21+
}
2022

23+
// parseFile reads and parses a YAML file at the given path. The dir argument
24+
// becomes the Config's Dir, and is used to resolve relative paths for dotenv,
25+
// nested includes and (for an included gogo.yaml) the task file's own dir.
26+
func parseFile(path, dir string) (*Config, error) {
2127
data, err := os.ReadFile(path)
2228
if err != nil {
2329
return nil, fmt.Errorf("reading %s: %w", path, err)

0 commit comments

Comments
 (0)