4.9.7 watch() / watch_tree() File Dependencies
recommendedFile dependencies in repository rules need the same discipline as environment variables in 4.9.5 Reproducibility Concerns. If a repo rule reads a host file or directory and that value affects the generated repository, Bazel needs an explicit watch edge so the repository is fetched again when that input changes.1 The common bug is not an immediate failure. It is a stale external repository whose generated BUILD.bazel, wrapper script, or SDK path still reflects yesterday's host filesystem.2
Refetching Depends On Recorded Inputs
A repository rule implementation runs when Bazel needs an external repository, then writes that repository into the filesystem.3 Bazel does not rerun the implementation for every possible host change. The official repository-rule docs list the tracked refetch triggers: rule attributes, the Starlark implementation, environment variables read through repository_ctx.getenv(), legacy environment declarations, watched paths, and explicit bazel fetch --force requests.1 Current API docs mark the repository_rule(environ = [...]) form deprecated, so new code should prefer getenv().
That makes watch() the filesystem counterpart to 4.9.6 getenv() vs os.environ Pitfall. getenv() records an environment input. watch() records a file or path input. Repository rules are a reproducibility blind spot because they can observe shell environment, filesystem state, and network state before ordinary action sandboxing exists.4 The fix for filesystem state is to declare what the rule reads.
Watch The Path You Read
Use repository_ctx.watch(path) when the repository result depends on a path's existence, type, or file contents. The API says changes include file content changes, file-versus-directory changes, and the path starting or stopping existing.5
def _local_sdk_repo_impl(rctx):
config = rctx.path(rctx.attr.config_path)
rctx.watch(config)
settings = rctx.read(config, watch = "no")
rctx.file("BUILD.bazel", _build_file_from(settings))
local_sdk_repo = repository_rule(
implementation = _local_sdk_repo_impl,
attrs = {
"config_path": attr.string(mandatory = True),
},
)
The separate watch() call is useful when the implementation branches on metadata before reading the file, or when the code reads through another API. If the only operation is repository_ctx.read(), you can also use its watch parameter. Passing watch = "yes" is equivalent to calling watch() for that file. watch = "no" deliberately skips the watch. watch = "auto" asks Bazel to watch only when it is legal to do so.6
def _config_repo_impl(rctx):
settings = rctx.read(rctx.attr.config, watch = "yes")
rctx.file("BUILD.bazel", _build_file_from(settings))
config_repo = repository_rule(
implementation = _config_repo_impl,
attrs = {
"config": attr.label(
mandatory = True,
allow_single_file = True,
),
},
)
Use the explicit form when reviewing a ruleset. It makes the dependency visible next to the host read, which is the part future maintainers are most likely to break.
Directories Have Three Different Meanings
Directory watching is where mistakes usually hide. watch(dir) does not mean "watch every file below this directory." It watches the path itself: whether the directory exists, whether it becomes a file, or whether that path changes. The repository_ctx docs say it does not include changes to files under the directory.5
Pick the API that matches the dependency:
| Need | API | What changes trigger refetch |
|---|---|---|
| One known file or path | watch(path) or read(path, watch = "yes") | file contents, existence, or file/directory type |
| Directory membership | path.readdir(watch = "yes") | entry creation, deletion, or rename in that directory |
| Whole directory tree | watch_tree(path) | file contents, file and directory existence, and names anywhere under the tree |
path.readdir(watch = "yes") is precise when the generated repository only depends on the list of immediate entries. It does not watch the contents of those entries.7 Use watch_tree() when nested file contents matter, such as a repository rule that scans an SDK include tree and generates filegroup() targets from the result.8
def _headers_under(path):
headers = []
for child in path.readdir(watch = "no"):
if child.is_dir:
headers.extend(_headers_under(child))
elif child.basename.endswith(".h"):
headers.append(str(child))
return headers
def _sdk_headers_repo_impl(rctx):
include_dir = rctx.path(rctx.attr.include_dir)
rctx.watch_tree(include_dir)
headers = _headers_under(include_dir)
rctx.file("BUILD.bazel", _headers_build_file(headers))
This example uses watch_tree() because the generated repository depends on the directory's transitive structure. If the rule only needed the top-level list of names, include_dir.readdir(watch = "yes") would be the smaller dependency.
Metadata Reads Are Not Watches
The path object has convenient properties, but they are not dependency declarations by themselves. The API docs call this out for both path.exists and path.is_dir: checking them does not cause the path to be watched. If the repository should change when a path appears, disappears, or switches between file and directory, call watch().9
def _optional_config_repo_impl(rctx):
config = rctx.path(rctx.attr.config_path)
rctx.watch(config)
if config.exists:
content = rctx.read(config, watch = "no")
else:
content = "default = True\n"
rctx.file("config.bzl", content)
Without the watch(config) line, creating the file later may not invalidate the already-fetched repository. The generated config.bzl can stay on the default branch until some unrelated refetch trigger fires.
Host Traversal Needs A Boundary
Real repository rules often discover host filesystem state. A CTC++ integration from Tweag copies the generated @local_config_cc toolchain tree by taking the directory of @local_config_cc//:BUILD, calling readdir(), and copying entries into a generated repository.10 That is a practical repository-rule shape: inspect a local toolchain, generate a modified repository, then let normal targets use it.
The rule-authoring question is: which filesystem facts affect the output? If the output depends only on which files are present at the top level, readdir(watch = "yes") matches that boundary. If the output depends on nested files or file contents, use watch_tree() or a narrower set of watch() calls. If the implementation shells out with repository_ctx.execute(), remember that Bazel cannot automatically see which files the process opened. A repository-cache design note classifies executable inputs as potentially undiscoverable.11
Bazel 8 Migration Check
In Bazel 8, repository rules are expected to watch files explicitly. Older implicit behavior can be temporarily re-enabled while adapting rulesets.2 Treat that as a migration bridge, not a design pattern. New repository rules should make the watch edge local to the read, branch, or traversal that needs it.
When a repository rule reads host filesystem state, record that state as an input. Use watch() for one path, read(..., watch = "yes") for direct file reads, path.readdir(watch = "yes") for directory membership, and watch_tree() for transitive directory contents.
Do not rely on path.exists, path.is_dir, or a host command to make the dependency visible. If the generated external repository would change when a local file changes, the implementation should say so.
Check your understanding · 3 questions
1.Why should a repository rule declare a watch for a host file that affects generated repository contents?
Select one answer
2.Match each file-dependency API to what it tracks.
Drag each answer onto the matching prompt, or click an answer and then click a prompt
watch(path)path.readdir(watch = "yes")watch_tree(path)3.True or false: repository rule file dependencies.
Choose True or False for each sentence
path.exists and path.is_dir establish watch dependencies automatically.read(path, watch = "yes") can be used instead of a separate watch(path) call for a direct file read.repository_ctx.execute() makes every file it opens visible to Bazel as a watched input.watch(dir) watches nested file contents under dir.Footnotes
-
Repository Rules — documented repository refetch triggers, including watched paths and
bazel fetch --force. ↩1 ↩2 -
Determinism in a Non-Hermetic World - Albert Lloveras, Canva — file-system dependency problems, explicit watch APIs,
watch_tree(), and Bazel 8 migration note. ↩1 ↩2 -
Repository Rules — repository rule implementation functions fetch and materialize external repositories on demand. ↩
-
Determinism in a Non-Hermetic World - Albert Lloveras, Canva — repository rules can depend on shell environment, filesystem, and arbitrary network calls. ↩
-
repository_ctx —
watch()invalidation semantics and the note that directory watches do not include nested file contents. ↩1 ↩2 -
repository_ctx —
read(path, watch = ...)parameter andyes/no/autobehavior. ↩ -
path —
readdir(watch = ...)tracks directory entry creation, deletion, and renaming, but not entry contents. ↩ -
repository_ctx —
watch_tree()watches transitive file and directory changes under a path. ↩ -
path —
existsandis_dirdo not establish watch dependencies. ↩ -
Integrating Testwell CTC++ with Bazel — repository rule example that locates
@local_config_cc//:BUILD, callsreaddir(), and copies the local toolchain tree. ↩ -
A True Repository Cache for Bazel — input taxonomy for repository fetches and why reads/traversals or executable behavior can become undiscoverable without recorded inputs. ↩