From 027f00a4ef5a73308ff412c540aeda094697f832 Mon Sep 17 00:00:00 2001
From: "Eric J. Holmes"
Date: Fri, 6 Mar 2026 16:43:36 -0800
Subject: [PATCH 1/8] Add Walkfile inheritance
walk(1) now searches up the directory tree for a Walkfile, allowing a
single Walkfile at the project root to handle targets in any subdirectory.
The target name passed to the Walkfile ($2) is now the path relative to
the Walkfile's directory (e.g. "subdir/foo.o" instead of just "foo.o").
Local Walkfiles still take precedence over parent Walkfiles.
Fixes #24
Co-Authored-By: Claude
---
CHANGELOG.md | 6 +++
README.md | 2 +-
man/walk.1 | 6 +--
man/walk.1.html | 18 +++++----
man/walk.1.md | 20 +++++----
plan.go | 46 ++++++++++++---------
plan_test.go | 105 ++++++++++++++++++++++++++++++++++++++++++++++--
7 files changed, 162 insertions(+), 41 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0376991..322d0eb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,11 @@
# Changelog
+## Unreleased
+
+**Features**
+
+* Walkfile inheritance: walk(1) now searches up the directory tree for a Walkfile, allowing a single Walkfile at the project root to handle targets in any subdirectory. The target name passed to the Walkfile (`$2`) is now the path relative to the Walkfile's directory.
+
## 0.3.3 (2017-09-20)
**Improvements**
diff --git a/README.md b/README.md
index c4a87e8..b302feb 100644
--- a/README.md
+++ b/README.md
@@ -36,7 +36,7 @@ Create an executable file called `Walkfile`:
#!/bin/bash
phase=$1 # "deps" or "exec"
-target=$2 # the target name
+target=$2 # the target path (relative to the Walkfile)
case $target in
hello)
diff --git a/man/walk.1 b/man/walk.1
index 5b27f8f..dd493f5 100644
--- a/man/walk.1
+++ b/man/walk.1
@@ -33,17 +33,17 @@ By default, the stdout/stderr output from the \fBWalkfile\fR is prefixed with th
.SH "TARGETS"
Targets can be used to represent a task, or a file that needs to be built\. They are synonymous with targets in make(1)\. In general, targets are relative paths to files that need to be built, like \fBsrc/hello\.o\fR\. When a target does not relate to an actual file on disk, it's synonymous with \fB\.PHONY\fR targets in make(1)\.
.P
-walk(1) delegates to an executable file called \fIWalkfile\fR within the same directory as the target, to determine what dependencies the target has, and how to execute it\.
+walk(1) delegates to an executable file called \fIWalkfile\fR to determine what dependencies the target has, and how to execute it\. walk(1) searches for a Walkfile starting from the target's directory and walking up the directory tree until one is found\. This allows a single Walkfile at the project root to handle targets in any subdirectory\.
.SH "WALKFILE"
The \fBWalkfile\fR determines \fIhow\fR a target is executed, and what other targets it depends on\.
.P
-When walk(1) begins execution of a target, it attempts to find an executable file called \fBWalkfile\fR in the same directory as the target, and then executes it with the following positional arguments:
+When walk(1) begins execution of a target, it searches for an executable file called \fBWalkfile\fR starting from the target's directory and walking up the directory tree\. Once found, it executes the Walkfile with the following positional arguments:
.TP
\fB$1\fR
The \fIphase\fR (\fBdeps\fR or \fBexec\fR)\.
.TP
\fB$2\fR
-The name of the target to build (e\.g\. \fBhello\.o\fR)\.
+The target path relative to the Walkfile's directory (e\.g\. \fBhello\.o\fR or \fBsubdir/hello\.o\fR if the Walkfile is in a parent directory)\.
.P
It's up to the \fBWalkfile\fR to determine what dependencies the target has, and how to execute it\.
.SH "PHASES"
diff --git a/man/walk.1.html b/man/walk.1.html
index 05f0aac..1832c9b 100644
--- a/man/walk.1.html
+++ b/man/walk.1.html
@@ -143,24 +143,28 @@ TARGETS
relate to an actual file on disk, it's synonymous with .PHONY targets in
make(1).
-walk(1) delegates to an executable file called Walkfile within the same
-directory as the target, to determine what dependencies the target has, and how
-to execute it.
+walk(1) delegates to an executable file called Walkfile to determine
+what dependencies the target has, and how to execute it. walk(1) searches for a
+Walkfile starting from the target's directory and walking up the directory tree
+until one is found. This allows a single Walkfile at the project root to handle
+targets in any subdirectory.
WALKFILE
The Walkfile determines how a target is executed, and what other targets it
depends on.
-When walk(1) begins execution of a target, it attempts to find an executable
-file called Walkfile in the same directory as the target, and then executes
-it with the following positional arguments:
+When walk(1) begins execution of a target, it searches for an executable file
+called Walkfile starting from the target's directory and walking up the
+directory tree. Once found, it executes the Walkfile with the following
+positional arguments:
$1
- The phase (
deps or exec).
$2
-- The name of the target to build (e.g.
hello.o).
+- The target path relative to the Walkfile's directory (e.g.
hello.o or
+subdir/hello.o if the Walkfile is in a parent directory).
It's up to the Walkfile to determine what dependencies the target has, and
diff --git a/man/walk.1.md b/man/walk.1.md
index b6d9f98..bff3b01 100644
--- a/man/walk.1.md
+++ b/man/walk.1.md
@@ -60,24 +60,28 @@ to files that need to be built, like `src/hello.o`. When a target does not
relate to an actual file on disk, it's synonymous with `.PHONY` targets in
make(1).
-walk(1) delegates to an executable file called [Walkfile][WALKFILE] within the same
-directory as the target, to determine what dependencies the target has, and how
-to execute it.
+walk(1) delegates to an executable file called [Walkfile][WALKFILE] to determine
+what dependencies the target has, and how to execute it. walk(1) searches for a
+Walkfile starting from the target's directory and walking up the directory tree
+until one is found. This allows a single Walkfile at the project root to handle
+targets in any subdirectory.
## WALKFILE
The `Walkfile` determines _how_ a target is executed, and what other targets it
depends on.
-When walk(1) begins execution of a target, it attempts to find an executable
-file called `Walkfile` in the same directory as the target, and then executes
-it with the following positional arguments:
+When walk(1) begins execution of a target, it searches for an executable file
+called `Walkfile` starting from the target's directory and walking up the
+directory tree. Once found, it executes the Walkfile with the following
+positional arguments:
* `$1`:
The [phase][PHASES] (`deps` or `exec`).
-
+
* `$2`:
- The name of the target to build (e.g. `hello.o`).
+ The target path relative to the Walkfile's directory (e.g. `hello.o` or
+ `subdir/hello.o` if the Walkfile is in a parent directory).
It's up to the `Walkfile` to determine what dependencies the target has, and
how to execute it.
diff --git a/plan.go b/plan.go
index 1214fba..00047c0 100644
--- a/plan.go
+++ b/plan.go
@@ -238,6 +238,10 @@ type target struct {
// build file.
dir string
+ // targetName is the target name relative to the Walkfile's directory.
+ // This is passed as $2 to the Walkfile.
+ targetName string
+
// The working directory.
wd string
@@ -251,16 +255,21 @@ func newTarget(wd, name string) *target {
rulefile := RuleFile(path)
var dir string
+ var targetName string
if rulefile != "" {
- dir = filepath.Dir(path)
+ // Use the Walkfile's directory as the working directory
+ dir = filepath.Dir(rulefile)
+ // Compute target name relative to the Walkfile's directory
+ targetName, _ = filepath.Rel(dir, path)
}
return &target{
- name: name,
- path: path,
- rulefile: rulefile,
- dir: dir,
- wd: wd,
+ name: name,
+ path: path,
+ rulefile: rulefile,
+ dir: dir,
+ targetName: targetName,
+ wd: wd,
}
}
@@ -327,8 +336,7 @@ func (t *target) Dependencies(ctx context.Context) ([]string, error) {
}
func (t *target) ruleCommand(ctx context.Context, phase string) (*exec.Cmd, error) {
- name := filepath.Base(t.path)
- cmd := exec.CommandContext(ctx, t.rulefile, phase, name)
+ cmd := exec.CommandContext(ctx, t.rulefile, phase, t.targetName)
cmd.Stdout = t.stdout
cmd.Stderr = t.stderr
cmd.Dir = t.dir
@@ -363,20 +371,22 @@ func (t *verboseTarget) Exec(ctx context.Context) error {
}
// RuleFile is used to determine the path to an executable which will be used as
-// the Rule to execute the given target. At the moment, this simply looks for an
-// executable file called `Walkfile` in the same directory as the target.
+// the Rule to execute the given target. It walks up the directory tree from the
+// target's directory until it finds a Walkfile.
func RuleFile(path string) string {
dir := filepath.Dir(path)
- try := []string{
- Walkfile,
- }
- for _, n := range try {
- path := filepath.Join(dir, n)
- _, err := os.Stat(path)
- if err == nil {
- return path
+ for {
+ walkfile := filepath.Join(dir, Walkfile)
+ if _, err := os.Stat(walkfile); err == nil {
+ return walkfile
+ }
+
+ parent := filepath.Dir(dir)
+ if parent == dir {
+ break
}
+ dir = parent
}
return ""
diff --git a/plan_test.go b/plan_test.go
index fc292d6..6018bc1 100644
--- a/plan_test.go
+++ b/plan_test.go
@@ -94,22 +94,27 @@ func TestPlan_Error(t *testing.T) {
}
func TestPlan_NoWalkfile(t *testing.T) {
- clean(t)
+ // Use a temp directory that truly has no Walkfile anywhere in its ancestry
+ tmpdir := t.TempDir()
+ targetPath := filepath.Join(tmpdir, "all")
b := new(bytes.Buffer)
plan := newPlan()
plan.NewTarget = NewTarget(TargetOptions{
- Stdout: b,
+ WorkingDir: tmpdir,
+ Stdout: b,
})
- err := plan.Plan(ctx, "test/000-no-walkfile/all")
+ err := plan.Plan(ctx, "all")
assert.NoError(t, err)
err = plan.Exec(ctx, NewSemaphore(0))
assert.NoError(t, err)
- // If there's no Walkfile in the directory, it might just be a static
+ // If there's no Walkfile in the directory (or any parent), it's a static
// file. We don't really need to show these in output.
assert.Equal(t, "", b.String())
+
+ _ = targetPath // silence unused warning
}
func TestPrefixWriter(t *testing.T) {
@@ -161,6 +166,98 @@ prefix: to do the job in dark, airless conditions?
`, b.String())
}
+func TestRuleFile_Inheritance(t *testing.T) {
+ // Create a temp directory structure:
+ // tmpdir/
+ // Walkfile <- should be found
+ // subdir/
+ // target.txt <- target here, no local Walkfile
+ tmpdir := t.TempDir()
+
+ walkfile := filepath.Join(tmpdir, "Walkfile")
+ err := os.WriteFile(walkfile, []byte("#!/bin/bash\necho test"), 0755)
+ assert.NoError(t, err)
+
+ subdir := filepath.Join(tmpdir, "subdir")
+ err = os.Mkdir(subdir, 0755)
+ assert.NoError(t, err)
+
+ targetPath := filepath.Join(subdir, "target.txt")
+
+ // RuleFile should find the Walkfile in the parent directory
+ found := RuleFile(targetPath)
+ assert.Equal(t, walkfile, found)
+}
+
+func TestRuleFile_Inheritance_EndToEnd(t *testing.T) {
+ // Create a temp directory structure with a Walkfile that handles subdirectory targets
+ tmpdir := t.TempDir()
+
+ // Walkfile that echoes the target name for deps and creates a marker file on exec
+ walkfile := filepath.Join(tmpdir, "Walkfile")
+ err := os.WriteFile(walkfile, []byte(`#!/bin/bash
+phase=$1
+target=$2
+
+case $phase in
+ deps) ;; # no deps
+ exec) touch "$target.built" ;;
+esac
+`), 0755)
+ assert.NoError(t, err)
+
+ // Create a subdirectory (no Walkfile here - should inherit)
+ subdir := filepath.Join(tmpdir, "subdir")
+ err = os.Mkdir(subdir, 0755)
+ assert.NoError(t, err)
+
+ b := new(bytes.Buffer)
+ plan := newPlan()
+ plan.NewTarget = NewTarget(TargetOptions{
+ WorkingDir: tmpdir,
+ Stdout: b,
+ })
+
+ // Build a target in the subdirectory
+ err = plan.Plan(ctx, "subdir/foo")
+ assert.NoError(t, err)
+
+ err = plan.Exec(ctx, NewSemaphore(0))
+ assert.NoError(t, err)
+
+ // Verify the Walkfile was invoked and created the marker file
+ _, err = os.Stat(filepath.Join(tmpdir, "subdir/foo.built"))
+ assert.NoError(t, err, "expected subdir/foo.built to exist")
+}
+
+func TestRuleFile_LocalOverridesParent(t *testing.T) {
+ // Create a temp directory structure:
+ // tmpdir/
+ // Walkfile <- parent Walkfile
+ // subdir/
+ // Walkfile <- local Walkfile (should be used)
+ // target.txt
+ tmpdir := t.TempDir()
+
+ parentWalkfile := filepath.Join(tmpdir, "Walkfile")
+ err := os.WriteFile(parentWalkfile, []byte("#!/bin/bash\necho parent"), 0755)
+ assert.NoError(t, err)
+
+ subdir := filepath.Join(tmpdir, "subdir")
+ err = os.Mkdir(subdir, 0755)
+ assert.NoError(t, err)
+
+ localWalkfile := filepath.Join(subdir, "Walkfile")
+ err = os.WriteFile(localWalkfile, []byte("#!/bin/bash\necho local"), 0755)
+ assert.NoError(t, err)
+
+ targetPath := filepath.Join(subdir, "target.txt")
+
+ // RuleFile should find the local Walkfile first
+ found := RuleFile(targetPath)
+ assert.Equal(t, localWalkfile, found)
+}
+
func clean(t testing.TB) {
err := Exec(ctx, NewSemaphore(0), "test/clean")
assert.NoError(t, err)
From 139389f005048250fd45b948bf60722c75dc44da Mon Sep 17 00:00:00 2001
From: "Eric J. Holmes"
Date: Fri, 6 Mar 2026 16:50:24 -0800
Subject: [PATCH 2/8] Remove docs/Walkfile using inheritance
Demonstrate the new Walkfile inheritance feature by removing
docs/Walkfile and handling docs/* targets in the root Walkfile.
Co-Authored-By: Claude
---
Walkfile | 19 ++++++++++++++++++-
docs/Walkfile | 15 ---------------
docs/index.html | 18 +++++++++++-------
3 files changed, 29 insertions(+), 23 deletions(-)
delete mode 100755 docs/Walkfile
diff --git a/Walkfile b/Walkfile
index ddd3891..ad1a5c9 100755
--- a/Walkfile
+++ b/Walkfile
@@ -101,7 +101,24 @@ EOF
exec) exec bundle install ;
esac ;;
- bin|test|docs|man|dist)
+ # docs/* targets handled here via Walkfile inheritance
+ docs)
+ case $phase in
+ deps) echo docs/all ;;
+ esac ;;
+
+ docs/all)
+ case $phase in
+ deps) echo docs/index.html ;;
+ esac ;;
+
+ docs/index.html)
+ case $phase in
+ deps) echo man/walk.1.html ;;
+ exec) cp man/walk.1.html $target ;;
+ esac ;;
+
+ bin|test|man|dist)
case $phase in
deps) echo $target/all ;;
esac ;;
diff --git a/docs/Walkfile b/docs/Walkfile
deleted file mode 100755
index 23eed9a..0000000
--- a/docs/Walkfile
+++ /dev/null
@@ -1,15 +0,0 @@
-#!/bin/bash
-
-target=$2
-
-case $target in
- all)
- case $1 in
- deps) echo index.html ;;
- esac ;;
- index.html)
- case $1 in
- deps) echo ../man/walk.1.html ;;
- exec) cp ../man/walk.1.html index.html ;;
- esac ;;
-esac
diff --git a/docs/index.html b/docs/index.html
index 05f0aac..1832c9b 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -143,24 +143,28 @@ TARGETS
relate to an actual file on disk, it's synonymous with .PHONY targets in
make(1).
-walk(1) delegates to an executable file called Walkfile within the same
-directory as the target, to determine what dependencies the target has, and how
-to execute it.
+walk(1) delegates to an executable file called Walkfile to determine
+what dependencies the target has, and how to execute it. walk(1) searches for a
+Walkfile starting from the target's directory and walking up the directory tree
+until one is found. This allows a single Walkfile at the project root to handle
+targets in any subdirectory.
WALKFILE
The Walkfile determines how a target is executed, and what other targets it
depends on.
-When walk(1) begins execution of a target, it attempts to find an executable
-file called Walkfile in the same directory as the target, and then executes
-it with the following positional arguments:
+When walk(1) begins execution of a target, it searches for an executable file
+called Walkfile starting from the target's directory and walking up the
+directory tree. Once found, it executes the Walkfile with the following
+positional arguments:
$1
- The phase (
deps or exec).
$2
-- The name of the target to build (e.g.
hello.o).
+- The target path relative to the Walkfile's directory (e.g.
hello.o or
+subdir/hello.o if the Walkfile is in a parent directory).
It's up to the Walkfile to determine what dependencies the target has, and
From 0877dd90dc178461c70aee56c5dd9a7a5863f9ac Mon Sep 17 00:00:00 2001
From: "Eric J. Holmes"
Date: Fri, 6 Mar 2026 16:54:31 -0800
Subject: [PATCH 3/8] Document breaking changes in CHANGELOG
Add detailed breaking changes section and migration guide for
Walkfile inheritance feature.
Co-Authored-By: Claude
---
CHANGELOG.md | 28 +++++++++++++++++++++++++++-
1 file changed, 27 insertions(+), 1 deletion(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 322d0eb..53b6f04 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,7 +4,33 @@
**Features**
-* Walkfile inheritance: walk(1) now searches up the directory tree for a Walkfile, allowing a single Walkfile at the project root to handle targets in any subdirectory. The target name passed to the Walkfile (`$2`) is now the path relative to the Walkfile's directory.
+* Walkfile inheritance: walk(1) now searches up the directory tree for a Walkfile, allowing a single Walkfile at the project root to handle targets in any subdirectory. This eliminates the need to create a Walkfile in every directory.
+
+**Breaking Changes**
+
+* **Target name (`$2`) format changed**: When a Walkfile is found in a parent directory, `$2` now contains the path relative to the Walkfile's directory (e.g., `subdir/foo.o` instead of `foo.o`). Walkfiles that assume `$2` is a simple filename may need adjustment:
+ ```bash
+ # Before: worked when $2 was "foo.o"
+ echo ${target}.c
+
+ # After: use basename or adjust patterns
+ echo ${target%.o}.c # works for both "foo.o" and "subdir/foo.o"
+ ```
+
+* **Working directory changed**: The Walkfile now runs from its own directory, not the target's directory. Commands using relative paths like `ls *.c` should be updated to use paths relative to the Walkfile.
+
+* **Unintended Walkfile discovery**: Directories that previously had no Walkfile (treating targets as static files) may now inherit a Walkfile from a parent directory. If the parent Walkfile doesn't handle the target, this will produce an error instead of a no-op.
+
+**Migration Guide**
+
+1. If your Walkfile uses `$2` directly as a filename, consider using `basename "$target"` or updating patterns to handle paths.
+
+2. If you rely on "no Walkfile means static file" behavior in a subdirectory, add an explicit case to the parent Walkfile:
+ ```bash
+ subdir/*) ;; # treat as static files
+ ```
+
+3. Review any Walkfiles higher in your directory tree that might now be discovered unexpectedly.
## 0.3.3 (2017-09-20)
From bd2e17d1f4435cb29b3d5a6521bfa5f6509154d7 Mon Sep 17 00:00:00 2001
From: "Eric J. Holmes"
Date: Fri, 6 Mar 2026 17:13:43 -0800
Subject: [PATCH 4/8] Add Walkfile fallback with exit code 127
A local Walkfile can now delegate to a parent Walkfile by exiting
with code 127. This enables composition where a local Walkfile
handles specific targets while inheriting generic rules from parent.
Example:
case $target in
special) ;; # handle locally
*) exit 127 ;; # delegate to parent
esac
Co-Authored-By: Claude
---
CHANGELOG.md | 8 +++
man/walk.1 | 10 ++++
man/walk.1.html | 10 ++++
man/walk.1.md | 9 +++
plan.go | 155 ++++++++++++++++++++++++++++++------------------
plan_test.go | 80 +++++++++++++++++++++++++
6 files changed, 215 insertions(+), 57 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 53b6f04..4cf9db3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,14 @@
* Walkfile inheritance: walk(1) now searches up the directory tree for a Walkfile, allowing a single Walkfile at the project root to handle targets in any subdirectory. This eliminates the need to create a Walkfile in every directory.
+* Walkfile fallback (exit code 127): A local Walkfile can delegate to a parent Walkfile by exiting with code 127. This enables composition where a local Walkfile handles specific targets while inheriting generic rules from a parent:
+ ```bash
+ case $target in
+ special) ;; # handle locally
+ *) exit 127 ;; # delegate to parent
+ esac
+ ```
+
**Breaking Changes**
* **Target name (`$2`) format changed**: When a Walkfile is found in a parent directory, `$2` now contains the path relative to the Walkfile's directory (e.g., `subdir/foo.o` instead of `foo.o`). Walkfiles that assume `$2` is a simple filename may need adjustment:
diff --git a/man/walk.1 b/man/walk.1
index dd493f5..8a4db11 100644
--- a/man/walk.1
+++ b/man/walk.1
@@ -46,6 +46,16 @@ The \fIphase\fR (\fBdeps\fR or \fBexec\fR)\.
The target path relative to the Walkfile's directory (e\.g\. \fBhello\.o\fR or \fBsubdir/hello\.o\fR if the Walkfile is in a parent directory)\.
.P
It's up to the \fBWalkfile\fR to determine what dependencies the target has, and how to execute it\.
+.P
+If a Walkfile exits with code \fB127\fR, walk(1) will try the next Walkfile up the directory tree\. This allows a local Walkfile to handle specific targets while delegating unknown targets to a parent Walkfile:
+.IP "" 4
+.nf
+case $target in
+ special) ;; # handle locally
+ *) exit 127 ;; # delegate to parent
+esac
+.fi
+.IP "" 0
.SH "PHASES"
walk(1) has two phases:
.TP
diff --git a/man/walk.1.html b/man/walk.1.html
index 1832c9b..f8e6db2 100644
--- a/man/walk.1.html
+++ b/man/walk.1.html
@@ -170,6 +170,16 @@ WALKFILE
It's up to the Walkfile to determine what dependencies the target has, and
how to execute it.
+If a Walkfile exits with code 127, walk(1) will try the next Walkfile up the
+directory tree. This allows a local Walkfile to handle specific targets while
+delegating unknown targets to a parent Walkfile:
+
+case $target in
+ special) ;; # handle locally
+ *) exit 127 ;; # delegate to parent
+esac
+
+
PHASES
walk(1) has two phases:
diff --git a/man/walk.1.md b/man/walk.1.md
index bff3b01..67da57c 100644
--- a/man/walk.1.md
+++ b/man/walk.1.md
@@ -86,6 +86,15 @@ positional arguments:
It's up to the `Walkfile` to determine what dependencies the target has, and
how to execute it.
+If a Walkfile exits with code `127`, walk(1) will try the next Walkfile up the
+directory tree. This allows a local Walkfile to handle specific targets while
+delegating unknown targets to a parent Walkfile:
+
+ case $target in
+ special) ;; # handle locally
+ *) exit 127 ;; # delegate to parent
+ esac
+
## PHASES
walk(1) has two phases:
diff --git a/plan.go b/plan.go
index 00047c0..ebe3eae 100644
--- a/plan.go
+++ b/plan.go
@@ -221,6 +221,10 @@ func (e *targetError) Error() string {
return fmt.Sprintf("%s: %v", e.target.Name(), e.err)
}
+// ExitCodeFallback is the exit code that signals walk to try the next
+// Walkfile in the inheritance chain.
+const ExitCodeFallback = 127
+
// target is a Target implementation, that represents a file on disk, which may
// be built by a rule.
type target struct {
@@ -230,17 +234,14 @@ type target struct {
// The absolute path to the file.
path string
- // path to the rulefile to use. This is determined by the RuleFile
- // function.
- rulefile string
-
- // the directory to use as the working directory when executing the
- // build file.
- dir string
+ // rulefiles is the list of candidate Walkfiles, ordered from most
+ // specific (closest to target) to least specific (furthest up the tree).
+ rulefiles []string
- // targetName is the target name relative to the Walkfile's directory.
- // This is passed as $2 to the Walkfile.
- targetName string
+ // rulefileIdx is the index into rulefiles of the active Walkfile.
+ // This is set during Dependencies() when a Walkfile successfully handles
+ // the target (doesn't return ExitCodeFallback).
+ rulefileIdx int
// The working directory.
wd string
@@ -251,26 +252,39 @@ type target struct {
// newTarget initializes and returns a new target instance.
func newTarget(wd, name string) *target {
path := abs(wd, name)
+ rulefiles := RuleFiles(path)
- rulefile := RuleFile(path)
+ return &target{
+ name: name,
+ path: path,
+ rulefiles: rulefiles,
+ wd: wd,
+ }
+}
- var dir string
- var targetName string
- if rulefile != "" {
- // Use the Walkfile's directory as the working directory
- dir = filepath.Dir(rulefile)
- // Compute target name relative to the Walkfile's directory
- targetName, _ = filepath.Rel(dir, path)
+// rulefile returns the currently active Walkfile path.
+func (t *target) rulefile() string {
+ if len(t.rulefiles) == 0 {
+ return ""
}
+ return t.rulefiles[t.rulefileIdx]
+}
- return &target{
- name: name,
- path: path,
- rulefile: rulefile,
- dir: dir,
- targetName: targetName,
- wd: wd,
+// dir returns the working directory for the active Walkfile.
+func (t *target) dir() string {
+ if rf := t.rulefile(); rf != "" {
+ return filepath.Dir(rf)
}
+ return ""
+}
+
+// targetName returns the target name relative to the active Walkfile's directory.
+func (t *target) targetName() string {
+ if dir := t.dir(); dir != "" {
+ rel, _ := filepath.Rel(dir, t.path)
+ return rel
+ }
+ return ""
}
// Name implements the Target interface.
@@ -281,7 +295,7 @@ func (t *target) Name() string {
// Exec executes the rule with "exec" as the first argument.
func (t *target) Exec(ctx context.Context) error {
// No .walk file, meaning it's a static dependency.
- if t.rulefile == "" {
+ if t.rulefile() == "" {
return nil
}
@@ -296,50 +310,66 @@ func (t *target) Exec(ctx context.Context) error {
// out the newline delimited list of dependencies.
func (t *target) Dependencies(ctx context.Context) ([]string, error) {
// No .walk file, meaning it's a static dependency.
- if t.rulefile == "" {
+ if len(t.rulefiles) == 0 {
return nil, nil
}
- b := new(bytes.Buffer)
- cmd, err := t.ruleCommand(ctx, PhaseDeps)
- if err != nil {
- return nil, err
- }
- cmd.Stdout = b
-
- if err := cmd.Run(); err != nil {
- return nil, err
- }
+ // Try each Walkfile in order until one handles the target
+ // (doesn't return ExitCodeFallback).
+ var lastErr error
+ for i := range t.rulefiles {
+ t.rulefileIdx = i
- var deps []string
- scanner := bufio.NewScanner(b)
- for scanner.Scan() {
- path := scanner.Text()
- if path == "" {
- continue
+ b := new(bytes.Buffer)
+ cmd, err := t.ruleCommand(ctx, PhaseDeps)
+ if err != nil {
+ return nil, err
}
+ cmd.Stdout = b
- // If the path is not already and absolute path, make it one.
- if !filepath.IsAbs(path) {
- path = filepath.Join(t.dir, path)
+ if err := cmd.Run(); err != nil {
+ // Check if this is a fallback signal
+ if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == ExitCodeFallback {
+ lastErr = err
+ continue // Try next Walkfile
+ }
+ return nil, err
}
- // Make all paths relative to the working directory.
- path, err := filepath.Rel(t.wd, path)
- if err != nil {
- return deps, err
+ // This Walkfile handled it - parse dependencies
+ var deps []string
+ scanner := bufio.NewScanner(b)
+ for scanner.Scan() {
+ path := scanner.Text()
+ if path == "" {
+ continue
+ }
+
+ // If the path is not already and absolute path, make it one.
+ if !filepath.IsAbs(path) {
+ path = filepath.Join(t.dir(), path)
+ }
+
+ // Make all paths relative to the working directory.
+ path, err := filepath.Rel(t.wd, path)
+ if err != nil {
+ return deps, err
+ }
+ deps = append(deps, path)
}
- deps = append(deps, path)
+
+ return deps, scanner.Err()
}
- return deps, scanner.Err()
+ // All Walkfiles returned fallback - return the last error
+ return nil, lastErr
}
func (t *target) ruleCommand(ctx context.Context, phase string) (*exec.Cmd, error) {
- cmd := exec.CommandContext(ctx, t.rulefile, phase, t.targetName)
+ cmd := exec.CommandContext(ctx, t.rulefile(), phase, t.targetName())
cmd.Stdout = t.stdout
cmd.Stderr = t.stderr
- cmd.Dir = t.dir
+ cmd.Dir = t.dir()
return cmd, nil
}
@@ -361,7 +391,7 @@ func (t *verboseTarget) Exec(ctx context.Context) error {
if err != nil {
line = fmt.Sprintf("%s\t%s", line, err)
}
- if t.rulefile != "" {
+ if t.rulefile() != "" {
fmt.Fprintf(t.stdout, "%s\n", line)
}
if err != nil {
@@ -374,12 +404,23 @@ func (t *verboseTarget) Exec(ctx context.Context) error {
// the Rule to execute the given target. It walks up the directory tree from the
// target's directory until it finds a Walkfile.
func RuleFile(path string) string {
+ rulefiles := RuleFiles(path)
+ if len(rulefiles) == 0 {
+ return ""
+ }
+ return rulefiles[0]
+}
+
+// RuleFiles returns all candidate Walkfiles for the given target path, ordered
+// from most specific (closest to target) to least specific (furthest ancestor).
+func RuleFiles(path string) []string {
+ var rulefiles []string
dir := filepath.Dir(path)
for {
walkfile := filepath.Join(dir, Walkfile)
if _, err := os.Stat(walkfile); err == nil {
- return walkfile
+ rulefiles = append(rulefiles, walkfile)
}
parent := filepath.Dir(dir)
@@ -389,7 +430,7 @@ func RuleFile(path string) string {
dir = parent
}
- return ""
+ return rulefiles
}
// prefixWriter wraps an io.Writer to append a prefix to each line written.
diff --git a/plan_test.go b/plan_test.go
index 6018bc1..b486830 100644
--- a/plan_test.go
+++ b/plan_test.go
@@ -230,6 +230,86 @@ esac
assert.NoError(t, err, "expected subdir/foo.built to exist")
}
+func TestRuleFile_Fallback(t *testing.T) {
+ // Test that a local Walkfile can delegate to parent by exiting with code 127
+ // Structure:
+ // tmpdir/
+ // Walkfile <- handles *.o generically
+ // subdir/
+ // Walkfile <- handles "special" only, exits 127 for others
+ // foo.o <- should fall back to parent's *.o rule
+ // special <- handled by local Walkfile
+ tmpdir := t.TempDir()
+
+ // Parent Walkfile: handles *.o
+ parentWalkfile := filepath.Join(tmpdir, "Walkfile")
+ err := os.WriteFile(parentWalkfile, []byte(`#!/bin/bash
+phase=$1
+target=$2
+
+case $target in
+ *.o)
+ case $phase in
+ exec) touch "$target.from-parent" ;;
+ esac ;;
+ *) exit 127 ;;
+esac
+`), 0755)
+ assert.NoError(t, err)
+
+ // Create subdirectory
+ subdir := filepath.Join(tmpdir, "subdir")
+ err = os.Mkdir(subdir, 0755)
+ assert.NoError(t, err)
+
+ // Local Walkfile: handles "special" only, exits 127 for unknown targets
+ localWalkfile := filepath.Join(subdir, "Walkfile")
+ err = os.WriteFile(localWalkfile, []byte(`#!/bin/bash
+phase=$1
+target=$2
+
+case $target in
+ special)
+ case $phase in
+ exec) touch "$target.from-local" ;;
+ esac ;;
+ *) exit 127 ;; # Signal: try parent Walkfile
+esac
+`), 0755)
+ assert.NoError(t, err)
+
+ // Test 1: "special" should be handled by local Walkfile
+ b := new(bytes.Buffer)
+ plan := newPlan()
+ plan.NewTarget = NewTarget(TargetOptions{
+ WorkingDir: tmpdir,
+ Stdout: b,
+ })
+
+ err = plan.Plan(ctx, "subdir/special")
+ assert.NoError(t, err)
+ err = plan.Exec(ctx, NewSemaphore(0))
+ assert.NoError(t, err)
+
+ _, err = os.Stat(filepath.Join(subdir, "special.from-local"))
+ assert.NoError(t, err, "expected special.from-local to exist")
+
+ // Test 2: "foo.o" should fall back to parent Walkfile
+ plan = newPlan()
+ plan.NewTarget = NewTarget(TargetOptions{
+ WorkingDir: tmpdir,
+ Stdout: b,
+ })
+
+ err = plan.Plan(ctx, "subdir/foo.o")
+ assert.NoError(t, err)
+ err = plan.Exec(ctx, NewSemaphore(0))
+ assert.NoError(t, err)
+
+ _, err = os.Stat(filepath.Join(tmpdir, "subdir/foo.o.from-parent"))
+ assert.NoError(t, err, "expected subdir/foo.o.from-parent to exist")
+}
+
func TestRuleFile_LocalOverridesParent(t *testing.T) {
// Create a temp directory structure:
// tmpdir/
From fa56db006155d40379b9205107db078df913d9d4 Mon Sep 17 00:00:00 2001
From: "Eric J. Holmes"
Date: Fri, 6 Mar 2026 17:18:02 -0800
Subject: [PATCH 5/8] Change delegate exit code from 127 to 200
Exit code 127 is "command not found" in shells, which could be
triggered accidentally if a command inside the Walkfile fails.
Using 200 is safer - it won't conflict with common error codes.
Co-Authored-By: Claude
---
CHANGELOG.md | 4 ++--
man/walk.1 | 4 ++--
man/walk.1.html | 4 ++--
man/walk.1.md | 4 ++--
plan.go | 10 +++++-----
plan_test.go | 4 ++--
6 files changed, 15 insertions(+), 15 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4cf9db3..2e06fcd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,11 +6,11 @@
* Walkfile inheritance: walk(1) now searches up the directory tree for a Walkfile, allowing a single Walkfile at the project root to handle targets in any subdirectory. This eliminates the need to create a Walkfile in every directory.
-* Walkfile fallback (exit code 127): A local Walkfile can delegate to a parent Walkfile by exiting with code 127. This enables composition where a local Walkfile handles specific targets while inheriting generic rules from a parent:
+* Walkfile fallback (exit code 200): A local Walkfile can delegate to a parent Walkfile by exiting with code 127. This enables composition where a local Walkfile handles specific targets while inheriting generic rules from a parent:
```bash
case $target in
special) ;; # handle locally
- *) exit 127 ;; # delegate to parent
+ *) exit 200 ;; # delegate to parent
esac
```
diff --git a/man/walk.1 b/man/walk.1
index 8a4db11..0ae9052 100644
--- a/man/walk.1
+++ b/man/walk.1
@@ -47,12 +47,12 @@ The target path relative to the Walkfile's directory (e\.g\. \fBhello\.o\fR or \
.P
It's up to the \fBWalkfile\fR to determine what dependencies the target has, and how to execute it\.
.P
-If a Walkfile exits with code \fB127\fR, walk(1) will try the next Walkfile up the directory tree\. This allows a local Walkfile to handle specific targets while delegating unknown targets to a parent Walkfile:
+If a Walkfile exits with code \fB200\fR, walk(1) will try the next Walkfile up the directory tree\. This allows a local Walkfile to handle specific targets while delegating unknown targets to a parent Walkfile:
.IP "" 4
.nf
case $target in
special) ;; # handle locally
- *) exit 127 ;; # delegate to parent
+ *) exit 200 ;; # delegate to parent
esac
.fi
.IP "" 0
diff --git a/man/walk.1.html b/man/walk.1.html
index f8e6db2..d190725 100644
--- a/man/walk.1.html
+++ b/man/walk.1.html
@@ -170,13 +170,13 @@ WALKFILE
It's up to the Walkfile to determine what dependencies the target has, and
how to execute it.
-If a Walkfile exits with code 127, walk(1) will try the next Walkfile up the
+
If a Walkfile exits with code 200, walk(1) will try the next Walkfile up the
directory tree. This allows a local Walkfile to handle specific targets while
delegating unknown targets to a parent Walkfile:
case $target in
special) ;; # handle locally
- *) exit 127 ;; # delegate to parent
+ *) exit 200 ;; # delegate to parent
esac
diff --git a/man/walk.1.md b/man/walk.1.md
index 67da57c..1e887bf 100644
--- a/man/walk.1.md
+++ b/man/walk.1.md
@@ -86,13 +86,13 @@ positional arguments:
It's up to the `Walkfile` to determine what dependencies the target has, and
how to execute it.
-If a Walkfile exits with code `127`, walk(1) will try the next Walkfile up the
+If a Walkfile exits with code `200`, walk(1) will try the next Walkfile up the
directory tree. This allows a local Walkfile to handle specific targets while
delegating unknown targets to a parent Walkfile:
case $target in
special) ;; # handle locally
- *) exit 127 ;; # delegate to parent
+ *) exit 200 ;; # delegate to parent
esac
## PHASES
diff --git a/plan.go b/plan.go
index ebe3eae..99add0b 100644
--- a/plan.go
+++ b/plan.go
@@ -221,9 +221,9 @@ func (e *targetError) Error() string {
return fmt.Sprintf("%s: %v", e.target.Name(), e.err)
}
-// ExitCodeFallback is the exit code that signals walk to try the next
+// ExitCodeDelegate is the exit code that signals walk to try the next
// Walkfile in the inheritance chain.
-const ExitCodeFallback = 127
+const ExitCodeDelegate = 200
// target is a Target implementation, that represents a file on disk, which may
// be built by a rule.
@@ -240,7 +240,7 @@ type target struct {
// rulefileIdx is the index into rulefiles of the active Walkfile.
// This is set during Dependencies() when a Walkfile successfully handles
- // the target (doesn't return ExitCodeFallback).
+ // the target (doesn't return ExitCodeDelegate).
rulefileIdx int
// The working directory.
@@ -315,7 +315,7 @@ func (t *target) Dependencies(ctx context.Context) ([]string, error) {
}
// Try each Walkfile in order until one handles the target
- // (doesn't return ExitCodeFallback).
+ // (doesn't return ExitCodeDelegate).
var lastErr error
for i := range t.rulefiles {
t.rulefileIdx = i
@@ -329,7 +329,7 @@ func (t *target) Dependencies(ctx context.Context) ([]string, error) {
if err := cmd.Run(); err != nil {
// Check if this is a fallback signal
- if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == ExitCodeFallback {
+ if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == ExitCodeDelegate {
lastErr = err
continue // Try next Walkfile
}
diff --git a/plan_test.go b/plan_test.go
index b486830..455e0ed 100644
--- a/plan_test.go
+++ b/plan_test.go
@@ -252,7 +252,7 @@ case $target in
case $phase in
exec) touch "$target.from-parent" ;;
esac ;;
- *) exit 127 ;;
+ *) exit 200 ;;
esac
`), 0755)
assert.NoError(t, err)
@@ -273,7 +273,7 @@ case $target in
case $phase in
exec) touch "$target.from-local" ;;
esac ;;
- *) exit 127 ;; # Signal: try parent Walkfile
+ *) exit 200 ;; # Signal: try parent Walkfile
esac
`), 0755)
assert.NoError(t, err)
From d5d5dbd123fb36625a1b1621394eb28515f33252 Mon Sep 17 00:00:00 2001
From: "Eric J. Holmes"
Date: Fri, 6 Mar 2026 17:21:10 -0800
Subject: [PATCH 6/8] Demonstrate inheritance by generalizing *.o rule
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Move the generic *.o compilation rule from test/110-compile/Walkfile
to test/Walkfile. The subdirectory Walkfile now delegates unknown
targets to the parent using exit 200.
This shows how inheritance + delegation enables:
- Generic rules in parent (*.o → compile .c)
- Specific rules in child (hello → link)
- Automatic fallback for unhandled targets
Co-Authored-By: Claude
---
test/110-compile/Walkfile | 11 +++++------
test/Walkfile | 12 ++++++++++++
2 files changed, 17 insertions(+), 6 deletions(-)
diff --git a/test/110-compile/Walkfile b/test/110-compile/Walkfile
index a5f3c5d..31434ec 100755
--- a/test/110-compile/Walkfile
+++ b/test/110-compile/Walkfile
@@ -22,15 +22,14 @@ case $target in
exec) clean ;;
esac ;;
- hello.o)
- case $phase in
- deps) echo hello.c ;;
- exec) exec gcc -Wall -o hello.o -c $($0 deps $target) ;;
- esac ;;
+ test) ;; # phony target
hello)
case $phase in
deps) echo hello.o ;;
exec) exec gcc -Wall -o hello $($0 deps $target) ;;
- esac
+ esac ;;
+
+ # Delegate unknown targets (like *.o) to parent Walkfile
+ *) exit 200 ;;
esac
diff --git a/test/Walkfile b/test/Walkfile
index d0b351e..60a710b 100755
--- a/test/Walkfile
+++ b/test/Walkfile
@@ -40,5 +40,17 @@ case $target in
exec) exec go test -race ../...
esac ;;
+ # Generic rule: compile any .c file to .o
+ # This is inherited by subdirectories via exit 200
+ */*.o)
+ src=${target%.o}.c
+ case $phase in
+ deps) echo $src ;;
+ exec) exec gcc -Wall -o $target -c $src ;;
+ esac ;;
+
+ # Static files - no build needed
+ */*.c|*/*.h) ;;
+
*) >&2 echo "No rule for target \"$target\"" && exit 1 ;;
esac
From 12ee1e5165b08684224f7de18bb0082ff6cd6231 Mon Sep 17 00:00:00 2001
From: "Eric J. Holmes"
Date: Mon, 9 Mar 2026 08:46:31 -0700
Subject: [PATCH 7/8] Regenerate docs/index.html with delegation docs
The exit code 200 delegation documentation was missing from the
generated HTML docs.
Co-Authored-By: Claude
---
docs/index.html | 10 ++++++++++
1 file changed, 10 insertions(+)
diff --git a/docs/index.html b/docs/index.html
index 1832c9b..d190725 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -170,6 +170,16 @@ WALKFILE
It's up to the Walkfile to determine what dependencies the target has, and
how to execute it.
+If a Walkfile exits with code 200, walk(1) will try the next Walkfile up the
+directory tree. This allows a local Walkfile to handle specific targets while
+delegating unknown targets to a parent Walkfile:
+
+case $target in
+ special) ;; # handle locally
+ *) exit 200 ;; # delegate to parent
+esac
+
+
PHASES
walk(1) has two phases:
From 8c60b9a806a223c6e29729541de4d7f63de22659 Mon Sep 17 00:00:00 2001
From: "Eric J. Holmes"
Date: Mon, 9 Mar 2026 08:48:15 -0700
Subject: [PATCH 8/8] Fix stale references to exit code 127
Updated comments and docs that still mentioned 127 to say 200.
Co-Authored-By: Claude
---
CHANGELOG.md | 2 +-
plan_test.go | 6 +++---
2 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2e06fcd..8f7d1ba 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,7 +6,7 @@
* Walkfile inheritance: walk(1) now searches up the directory tree for a Walkfile, allowing a single Walkfile at the project root to handle targets in any subdirectory. This eliminates the need to create a Walkfile in every directory.
-* Walkfile fallback (exit code 200): A local Walkfile can delegate to a parent Walkfile by exiting with code 127. This enables composition where a local Walkfile handles specific targets while inheriting generic rules from a parent:
+* Walkfile fallback (exit code 200): A local Walkfile can delegate to a parent Walkfile by exiting with code 200. This enables composition where a local Walkfile handles specific targets while inheriting generic rules from a parent:
```bash
case $target in
special) ;; # handle locally
diff --git a/plan_test.go b/plan_test.go
index 455e0ed..76bc6dc 100644
--- a/plan_test.go
+++ b/plan_test.go
@@ -231,12 +231,12 @@ esac
}
func TestRuleFile_Fallback(t *testing.T) {
- // Test that a local Walkfile can delegate to parent by exiting with code 127
+ // Test that a local Walkfile can delegate to parent by exiting with code 200
// Structure:
// tmpdir/
// Walkfile <- handles *.o generically
// subdir/
- // Walkfile <- handles "special" only, exits 127 for others
+ // Walkfile <- handles "special" only, exits 200 for others
// foo.o <- should fall back to parent's *.o rule
// special <- handled by local Walkfile
tmpdir := t.TempDir()
@@ -262,7 +262,7 @@ esac
err = os.Mkdir(subdir, 0755)
assert.NoError(t, err)
- // Local Walkfile: handles "special" only, exits 127 for unknown targets
+ // Local Walkfile: handles "special" only, exits 200 for unknown targets
localWalkfile := filepath.Join(subdir, "Walkfile")
err = os.WriteFile(localWalkfile, []byte(`#!/bin/bash
phase=$1