4.9.7 watch() / watch_tree() File Dependencies

recommended

File 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:

NeedAPIWhat changes trigger refetch
One known file or pathwatch(path) or read(path, watch = "yes")file contents, existence, or file/directory type
Directory membershippath.readdir(watch = "yes")entry creation, deletion, or rename in that directory
Whole directory treewatch_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 Before choosing watch_tree(), ask whether the tree is an input to repository fetching or simply a set of files that build targets will consume.

Expose SDK Files Without a Fetch-Time Scanner

If you only need to expose an installed SDK's headers, use new_local_repository and let the generated package enumerate them with glob().8,9 This example assumes the SDK has an include/ tree without nested Bazel packages:

# MODULE.bazel
new_local_repository = use_repo_rule(
    "@bazel_tools//tools/build_defs/repo:local.bzl",
    "new_local_repository",
)

new_local_repository(
    name = "vendor_sdk",
    path = "/opt/vendor/sdk",
    build_file_content = """
filegroup(
    name = "headers",
    srcs = glob(["include/**/*.h"]),
    visibility = ["//visibility:public"],
)
""",
)

Downstream targets consume @vendor_sdk//:headers. The glob() runs when Bazel loads the generated package, not inside the repository-rule implementation. Package loading tracks which files match, and actions consuming those files track their contents. You do not need a custom tree walker or an additional watch_tree() just to refresh the header list.9 glob() does not cross package boundaries. This adapter also remains machine-dependent: a path to an installed SDK is not a pinned, downloadable dependency. 4.9.8 Hermetic Host Tooling explains the local-tooling trade-off.

Watch Inputs That Produce Fetch-Time Snapshots

The situation changes when repository fetching processes SDK metadata to generate configuration. Suppose an SDK-specific inspector reads a tree of component metadata and writes a complete sdk_config.bzl to stdout. The generated file is a snapshot of those reads. Changing a nested metadata file must rerun the inspector, not merely reload a package's glob().

Here inspector is an explicitly supplied, host-runnable executable file. Its interface is one directory argument and generated Starlark on stdout. It must be available before normal build actions run, with its runtime dependencies supplied separately if needed. The example shows the repository adapter, not an implementation of the SDK-specific inspector.

def _sdk_config_repo_impl(rctx):
    metadata_dir = rctx.path(rctx.attr.metadata_dir)
    inspector = rctx.path(rctx.attr.inspector)
    rctx.watch(inspector)
    rctx.watch_tree(metadata_dir)

    result = rctx.execute(
        [inspector, str(metadata_dir)],
        environment = {"PATH": ""},
        timeout = 60,
    )
    if result.return_code:
        fail(result.stderr)
    rctx.file("sdk_config.bzl", result.stdout, executable = False)
    rctx.file(
        "BUILD.bazel",
        'exports_files(["sdk_config.bzl"])\n',
        executable = False,
    )

sdk_config_repo = repository_rule(
    implementation = _sdk_config_repo_impl,
    attrs = {
        "metadata_dir": attr.string(mandatory = True),
        "inspector": attr.label(mandatory = True, allow_single_file = True),
    },
)

The callback receives a repository_ctx and implicitly returns None. It writes repository files as an effect. The schema declares metadata_dir as a string and inspector as a source-file label, not an executable built by this workspace. execute() returns an exec_result with the exit status and captured output.5,10 Clearing PATH avoids an implicit executable lookup, but does not by itself make the helper or its runtime hermetic.

watch_tree() records the metadata tree as an input even though another process reads it. Without that edge, its changed contents can leave sdk_config.bzl stale.11 Watch the smallest subtree the inspector actually consumes. If it reads only a few known files, individual watches are more precise. 4.9.8 Hermetic Host Tooling covers supplying bootstrap tools and their dependencies.

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().12

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.13 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.14

extra

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.

key takeaway

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.

Distinguish files exposed to build targets from inputs processed during fetching. Use package and action dependencies for the former. Declare watches for the latter so generated configuration does not become a stale snapshot. path.exists, path.is_dir, and a host command do not record those watches by themselves.

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

Answers
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.
A host command run with repository_ctx.execute() makes every file it opens visible to Bazel as a watched input.
watch(dir) watches nested file contents under dir.
0 of 3 answered

Footnotes

  1. Repository Rules — documented repository refetch triggers, including watched paths and bazel fetch --force. ↩1 ↩2

  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

  3. Repository Rules — repository rule implementation functions fetch and materialize external repositories on demand. ↩

  4. Determinism in a Non-Hermetic World - Albert Lloveras, Canva — repository rules can depend on shell environment, filesystem, and arbitrary network calls. ↩

  5. repository_ctx — watch() invalidation semantics and the note that directory watches do not include nested file contents. ↩1 ↩2 ↩3

  6. repository_ctx — read(path, watch = ...) parameter and yes / no / auto behavior. ↩

  7. path — readdir(watch = ...) tracks directory entry creation, deletion, and renaming, but not entry contents. ↩

  8. local repository rules — new_local_repository exposes a local directory with a supplied BUILD file. ↩

  9. BUILD files — glob() enumerates source files in the current package during loading. Build Encyclopedia functions describes incremental glob evaluation and package boundaries. ↩1 ↩2

  10. exec_result — return_code, stdout, and stderr returned by repository_ctx.execute(). ↩

  11. repository_ctx — watch_tree() watches transitive file and directory changes under a path. ↩

  12. path — exists and is_dir do not establish watch dependencies. ↩

  13. Integrating Testwell CTC++ with Bazel — repository rule example that locates @local_config_cc//:BUILD, calls readdir(), and copies the local toolchain tree. ↩

  14. A True Repository Cache for Bazel — input taxonomy for repository fetches and why reads/traversals or executable behavior can become undiscoverable without recorded inputs. ↩