Skip to content

Lesson 14: Modifying The Source Tree

Nick Nichols edited this page Feb 2, 2026 · 2 revisions

Modifying The Source Tree

At the end of Lesson 13, we needed to perform several clean up tasks. The tasks resulted in a more consistent organizational structure, and better documentation for end users; however, they did take manual effort. In Lesson 8, we learned how to programmatically manipulate both BUILD and .bzl files. Today we'll try to build our own tools which manipulate the source tree.

A Reminder

In Lesson 9, we considered using macros/graphviz.bzl to automatically write the PNGs for graphs back to the source tree. We did not choose to follow that path. As a reminder, here's what we learned about at that time:

This is one of the core philosophical differences between Bazel and other build tools you may have used in the past- modifying the current workspace or module as a side-effect of evaluating rules is actively discouraged. One of Bazel's core principles is that builds must be reproducible. Modifying the current workspace or module as a side-effect would violate that principle, because each invocation of bazel build or bazel test would modify the source tree, causing future instances of Bazel commands to produce different results. Violations like these, or embedding current timestamps/dates into build artifacts, limit how reproducible, and by extension how correct, our builds are. Bazel's philosophy prefers solutions with the following properties in this order:

  • Correctness: Builds must be correct. For a given source tree, the output of the same build should always be the same, regardless of what the output tree looks like.
  • Throughput: The build system must strive to consistently produce consumable results, especially when utilizing shared computational resources.
  • Ease of Use: When multiple implementations are equally correct and have similar throughput profiles, prefer the solution that is easier to use.
  • Latency: All other things being equal, prefer the solution that arrives at output earlier.

Ultimately, users should always be able to run bazel build //... and bazel test //... without any previous invocations or local state present, without needing multiple invocations or manual intervention. Developers and repositories may choose to adopt other tools and scripts which do modify the source tree, but they are considered to be outside of Bazel's scope- for example, buildozer.

Bazel's official documentation denotes this as well:

A project should always be able to run bazel build //... and bazel test //... successfully on its stable branch.

So, how do we build and execute programs that modify our source tree with Bazel? The answer is with the run command; which is syntactic sugar for building and then executing a program. While the process of building an artifact can't modify the source tree, there is nothing out-of-bounds about building an executable program to modify Bazel repositories, like buildozer or buildifier, with Bazel- In fact, both tools are built in a shared Bazel repository.

Directory Structure

In Lesson 11, we wrote a runnable program that showed us where our Bazel programs actually executed by default. Let's rerun that script:

$ bazelisk run //toolchains/clojure:pwd

INFO: Analyzed target //toolchains/clojure:pwd (95 packages loaded, 4318 targets configured).
INFO: Found 1 target...
Target //toolchains/clojure:pwd up-to-date:
  bazel-bin/toolchains/clojure/pwd
INFO: Elapsed time: 8.203s, Critical Path: 0.02s
INFO: 1 process: 7 action cache hit, 1 internal.
INFO: Build completed successfully, 1 total action
INFO: Running command line: bazel-bin/toolchains/clojure/pwd


{:directory /private/var/tmp/_bazel_nnichols/eed0c1f29a947988aa70f35897692932/execroot/_main/bazel-out/darwin_arm64-fastbuild/bin/toolchains/clojure/pwd.runfiles/_main}

Let's refer to Bazel's documentation and break down what this path means.

  • /private/var/tmp/
    • This is the outputRoot, and is the parent-most folder for the state of Bazel output. The exact path is dependent upon user configuration, the host operating system, and the exact version(s) of Bazel in use.
  • _bazel_nnichols/
    • This is the outputUserRoot: a subdirectory of the outputRoot for a specific user. Bazel can share the outputRoot across multiple users of the same directory space.
  • eed0c1f29a947988aa70f35897692932/
    • This is the outputBase: the value is a hash of the workspace root. It will naturally change as the repository's state changes.
  • execroot/
    • This is the working directory for all actions. All action inputs and outputs are referenced relative to this directory.
  • _main/
    • This is the root of the working tree for bazel build
  • bazel-out/
    • This is the outputPath and is where the actual build output is written to. The top-level bazel-out symlink in our repository points to this location.
  • darwin_arm64-fastbuild/
    • This subdirectory is unique per BuildConfiguration and can contain information about the host machine's operating system and CPU architecture. This path is used to separate build artifacts with different platform dependencies.
  • bin/
    • This is a subfolder for binary output artifacts. For example, those produced by building clojure_binary or java_binary rule targets.
  • toolchains/clojure/pwd.runfiles/
    • This is the runfiles symlink. It contains the content we specified in the runfiles attribute of the DefaultInfo provider in the implementation of our clojure_binary rule.
  • _main
    • This is the directory in which the shell script that executes our binary evaluates.

In this path, we see that many of the subdirectories contain information that can and will change over time. If we updated our program to traverse this path upwards, we would need to handle multiple, fragile routes. It would need to consider:

  • The current version of Bazel running
  • The current user executing the command
  • The SHA of the repository
  • The host machine's operating system and CPU architecture
  • The package containing the rule target we're currently evaluating
  • Any configuration supplied to the Bazel CLI through .bazelrc files or command line flags

Navigating these permutations correctly would be difficult and brittle. Thankfully, Bazel provides information about the workspace to binaries executed with bazel run.

The Workspace Directory

Let's update our clojure_binary rule to consider the environment variable BUILD_WORKSPACE_DIRECTORY. We'll want to surface a new attribute to control where the binary executes:

"""
Defines the clojure_binary rule, which makes clojure scripts executable via `bazelisk run`
"""

def _clojure_binary_impl(ctx):
    toolchain = ctx.toolchains["//:clojure_toolchain"]
    classpath = ctx.actions.declare_file("%s.classpath" % ctx.label.name)

    # The compiler has direct dependencies satisfied by the toolchain
    # The generated classpath has transitive dependencies of what the underlying code requires
    deps = depset(
        direct = toolchain.files.runtime,
        transitive = [dep[JavaInfo].transitive_runtime_jars for dep in ctx.attr.deps],
    )

    # Write a classpath file containing all of our code dependencies and our static resources
    ctx.actions.write(
        output = classpath,
        content = ":".join(
            [f.short_path for f in deps.to_list()] +
            [f.dirname for f in ctx.files.data],
        ),
    )

    ctx.actions.write(
        output = ctx.outputs.executable,
        content = """
        set -ex
        BAZEL_ROOT=$(pwd)

        # The Python-casing of booleans is shared by Starlark
        if [[ {execute_in_workspace} == "True" ]]; then
          cd "$BUILD_WORKSPACE_DIRECTORY"
        fi

        FULL_CLASSPATH_FILE="$BAZEL_ROOT/full_classpath"
        sed -E "s=(^|:)=\\1$BAZEL_ROOT/=g" $BAZEL_ROOT/{classpath} > $FULL_CLASSPATH_FILE

        {java} \
          -XX:-OmitStackTraceInFastThrow \
          -classpath @$FULL_CLASSPATH_FILE \
          clojure.main \
          -m {main} \
          {data} \
          {arguments} $@
        """.format(
            java = toolchain.java_runfiles,
            classpath = classpath.short_path,
            main = ctx.attr.main,
            data = "--data {}".format(":".join([f.short_path for f in ctx.files.data])) if ctx.files.data else "",
            arguments = " ".join(ctx.attr.arguments),
            execute_in_workspace = ctx.attr.execute_in_workspace,
        ),
    )

    return DefaultInfo(
        runfiles = ctx.runfiles(
            files = ctx.files.data +
                    toolchain.files.jdk +
                    [classpath],
            transitive_files = deps,
        ),
    )

clojure_binary = rule(
    doc = "Executes a clojure program as a runnable script",
    implementation = _clojure_binary_impl,
    executable = True,
    attrs = {
        "main": attr.string(
            mandatory = True,
            doc = "The namespace whose -main function is the target for execution.",
        ),
        "deps": attr.label_list(
            mandatory = True,
            allow_empty = False,
            providers = [JavaInfo],
            doc = "Libraries to link as dependencies of this binary.",
        ),
        "arguments": attr.string_list(
            default = [],
            doc = "A list of string arguments to pass to our script.",
        ),
        "data": attr.label_list(
            allow_files = True,
            doc = "Static data dependencies required at runtime",
        ),
        "execute_in_workspace": attr.bool(
            default = False,
            doc = "A boolean that determines if the script should execute in the workspace or the execRoot.",
        ),
    },
    toolchains = ["//:clojure_toolchain"],
)

The new execute_in_workspace attribute will tell us to cd into the directory referenced by BUILD_WORKSPACE_DIRECTORY. To test it out, we'll need a new rule target in toolchains/clojure/BUILD:

clojure_binary(
    name = "pwd_workspace",
    execute_in_workspace = True,
    main = "toolchains.clojure.pwd",
    tags = [clojure_tag],
    deps = [":pwd_source"],
)

Let's try it out:

$ bazelisk run //toolchains/clojure:pwd_workspace

INFO: Analyzed target //toolchains/clojure:pwd_workspace (95 packages loaded, 4318 targets configured).
INFO: Found 1 target...
Target //toolchains/clojure:pwd_workspace up-to-date:
  bazel-bin/toolchains/clojure/pwd_workspace
INFO: Elapsed time: 7.936s, Critical Path: 0.02s
INFO: 2 processes: 5 action cache hit, 2 internal.
INFO: Build completed successfully, 2 total actions
INFO: Running command line: bazel-bin/toolchains/clojure/pwd_workspace

java: No such file or directory

The command fails. To understand what happened, let's try running our original rule target:

$ bazelisk run //toolchains/clojure:pwd_workspace

INFO: Analyzed target //toolchains/clojure:pwd (0 packages loaded, 1 target configured).
INFO: Found 1 target...
Target //toolchains/clojure:pwd up-to-date:
  bazel-bin/toolchains/clojure/pwd
INFO: Elapsed time: 0.107s, Critical Path: 0.00s
INFO: 1 process: 6 action cache hit, 1 internal.
INFO: Build completed successfully, 1 total action
INFO: Running command line: bazel-bin/toolchains/clojure/pwd

{:directory /private/var/tmp/_bazel_nnichols/eed0c1f29a947988aa70f35897692932/execroot/_main/bazel-out/darwin_arm64-fastbuild/bin/toolchains/clojure/pwd.runfiles/_main}

So, what's the difference between the two? The Java executable our clojure toolchain is built on top of is loaded into the runfiles of our binary rule target. By leaving that directory, the relative path to our dependency is no longer the same. To resolve that, we'll need to use another environment variable which refers to the full path we see when we execute //toolchains/clojure:pwd_workspace. That path is supplied through the environment variable BAZEL_ROOT.

Our rule now looks like:

"""
Defines the clojure_binary rule, which makes clojure scripts executable via `bazelisk run`
"""

def _clojure_binary_impl(ctx):
    toolchain = ctx.toolchains["//:clojure_toolchain"]
    classpath = ctx.actions.declare_file("%s.classpath" % ctx.label.name)

    # The compiler has direct dependencies satisfied by the toolchain
    # The generated classpath has transitive dependencies of what the underlying code requires
    deps = depset(
        direct = toolchain.files.runtime,
        transitive = [dep[JavaInfo].transitive_runtime_jars for dep in ctx.attr.deps],
    )

    # Write a classpath file containing all of our code dependencies and our static resources
    ctx.actions.write(
        output = classpath,
        content = ":".join(
            [f.short_path for f in deps.to_list()] +
            [f.dirname for f in ctx.files.data],
        ),
    )

    ctx.actions.write(
        output = ctx.outputs.executable,
        content = """
        set -ex
        BAZEL_ROOT=$(pwd)

        # The Python-casing of booleans is shared by Starlark
        if [[ {execute_in_workspace} == "True" ]]; then
          cd "$BUILD_WORKSPACE_DIRECTORY"
        fi

        FULL_CLASSPATH_FILE="$BAZEL_ROOT/full_classpath"
        sed -E "s=(^|:)=\\1$BAZEL_ROOT/=g" $BAZEL_ROOT/{classpath} > $FULL_CLASSPATH_FILE

        # We might have navigated outside of the execRoot, so we need to supply an absolute path for the Java executable
        $BAZEL_ROOT/{java} \
          -XX:-OmitStackTraceInFastThrow \
          -classpath @$FULL_CLASSPATH_FILE \
          clojure.main \
          -m {main} \
          {data} \
          {arguments} $@
        """.format(
            java = toolchain.java_runfiles,
            classpath = classpath.short_path,
            main = ctx.attr.main,
            data = "--data {}".format(":".join([f.short_path for f in ctx.files.data])) if ctx.files.data else "",
            arguments = " ".join(ctx.attr.arguments),
            execute_in_workspace = ctx.attr.execute_in_workspace,
        ),
    )

    return DefaultInfo(
        runfiles = ctx.runfiles(
            files = ctx.files.data +
                    toolchain.files.jdk +
                    [classpath],
            transitive_files = deps,
        ),
    )

clojure_binary = rule(
    doc = "Executes a clojure program as a runnable script",
    implementation = _clojure_binary_impl,
    executable = True,
    attrs = {
        "main": attr.string(
            mandatory = True,
            doc = "The namespace whose -main function is the target for execution.",
        ),
        "deps": attr.label_list(
            mandatory = True,
            allow_empty = False,
            providers = [JavaInfo],
            doc = "Libraries to link as dependencies of this binary.",
        ),
        "arguments": attr.string_list(
            default = [],
            doc = "A list of string arguments to pass to our script.",
        ),
        "data": attr.label_list(
            allow_files = True,
            doc = "Static data dependencies required at runtime",
        ),
        "execute_in_workspace": attr.bool(
            default = False,
            doc = "A boolean that determines if the script should execute in the workspace or the execRoot.",
        ),
    },
    toolchains = ["//:clojure_toolchain"],
)

Now we should be able to see the directory our source code is located in:

$ bazelisk run //toolchains/clojure:pwd_workspace
INFO: Analyzed target //toolchains/clojure:pwd_workspace (1 packages loaded, 11 targets configured).
INFO: Found 1 target...
Target //toolchains/clojure:pwd_workspace up-to-date:
  bazel-bin/toolchains/clojure/pwd_workspace
INFO: Elapsed time: 0.116s, Critical Path: 0.00s
INFO: 2 processes: 5 action cache hit, 2 internal.
INFO: Build completed successfully, 2 total actions
INFO: Running command line: bazel-bin/toolchains/clojure/pwd_workspace
+

{:directory ~/bite-sized-bazel}

Now that we have a reference to the current directory, let's do something useful with that.

Copying Artifacts

For the last two Lessons, we've been using Stardoc to generate API documentation for rules, macros, providers, and aspects. The content of this documentation is helpful, but as it stands, we have to build the documentation and find the appropriate artifacts to view them. Let's see if we can make the documentation part of the repository's source code. We'll write a new clojure_binary rule target to do that.

touch doc_hub/copy.clj

And we'll introduce the following code:

(ns doc-hub.copy
  "Copies files from the execRoot into the workspace"
  (:require
   [clojure.java.io :as io]
   [clojure.string :as str]
   [clojure.tools.cli :as cli]))

(def create-file
  "Create a file, and, if necessary, all of its parent directories.
   Similar to chaining `mkdir -p` and `touch`"
  (comp io/make-parents io/file))

(def bazel-artifact-directory
  "bazel-bin/")

(defn copy-file
  "Copies the artifact from `source-file` to `output`"
  [source-file output]
  (let [source-filename      (str bazel-artifact-directory source-file)
        content              (slurp source-filename)
        destination-filename (str output)]
    (println (format "Copying %s to %s" source-filename destination-filename))
    (create-file destination-filename)
    (spit destination-filename content)))


(defn -main
  [& args]
  (let [cli-arguments (cli/parse-opts args
                                      ;; the `--data` flag comes from `clojure_binary` rule
                                    [["-data FILE"
                                      "--data FILE"
                                      "A file, relative to where Bazel artifacts are generated, which needs to be copied."
                                      :default ""]
                                     ["-output FILE"
                                      "--output FILES"
                                      "The filepath where the artifact should be copied to. Relative to the workspace directory."
                                      :default ""]])
        source-filepath (get-in cli-arguments [:options :data])
        output-filepath (get-in cli-arguments [:options :output])]
    (assert (not (str/blank? source-filepath)))
    (assert (not (str/blank? output-filepath)))
    (copy-file source-filepath output-filepath)))

In the above, we'll pass references to a built artifact as well as a path to copy the content to. To try the rule out, we'll need to update duc_hub's BUILD file. First, we'll load the clojure_library and clojure_binary rules:

load("//toolchains/clojure:clojure_binary.bzl", "clojure_binary")
load("//toolchains/clojure:clojure_library.bzl", "clojure_library")

Then, we'll define a rule target to package our copy.clj file into a JAR:

clojure_library(
    name = "copy",
    srcs = [
        "copy.clj",
    ],
)

And we'll execute the main method of that JAR through a clojure_binary. That program will need a handle on the API documentation we've concatenated together, so we'll add :concated_docs as a data attribute:

clojure_binary(
    name = "copy_api_documentation",
    arguments = [
        "--output",
        "./generated/api-documentation.md",
    ],
    data = [
        ":concated_docs",
    ],
    execute_in_workspace = True,
    main = "doc-hub.copy",
    deps = [
        ":copy",
    ],
)

We now have a target we can exercise with run to copy our documentation back to our source code. Let's try it out:

$ bazelisk run //doc_hub:copy_api_documentation

INFO: Analyzed target //doc_hub:copy_api_documentation (172 packages loaded, 5717 targets configured).
INFO: Found 1 target...
Target //doc_hub:copy_api_documentation up-to-date:
  bazel-bin/doc_hub/copy_api_documentation
INFO: Elapsed time: 8.397s, Critical Path: 0.10s
INFO: 2 processes: 960 action cache hit, 2 internal.
INFO: Build completed successfully, 2 total actions
INFO: Running command line: bazel-bin/doc_hub/copy_api_documentation

Copying bazel-bin/doc_hub/build_api.md to ./generated/api-documentation.md

If we want to verify the contents, we can:

less ./generated/api-documentation.md

Path Dependencies

In the previous example, our rule target explicitly executed from the workspace directory. A natural question to ask is "What if we didn't set that attribute?" Let's try it out by adding another clojure_binary rule target:

clojure_binary(
    name = "copy_api_documentation_local",
    arguments = [
        "--output",
        "./generated/api-documentation.md",
    ],
    data = [
        ":concated_docs",
    ],
    execute_in_workspace = False,
    main = "doc-hub.copy",
    deps = [
        ":copy",
    ],
)

And we'll now run that target:

$ bazelisk run //doc_hub:copy_api_documentation_local

Execution error (FileNotFoundException) at java.io.FileInputStream/open0 (FileInputStream.java:-2).
bazel-bin/doc_hub/build_api.md (No such file or directory)

Full report at:
/var/folders/bn/4w6xpwps6f149dfv3z87z4tw0000gp/T/clojure-18312290384586490433.edn

The most important part of the output bazel-bin/doc_hub/build_api.md (No such file or directory) is an important reminder that the execution root and the workspace have distinct directory layouts. In the workspace, we have multiple convenience symlinks available to us- and our script depends upon them. While writing tools that integrate with output produced by Bazel, it's important to know and declare where we expect the code to run. It's also important to note that these paths and conventions could change in future versions of Bazel.

The Golden Rule

Earlier in this lesson, we highlighted a line from Bazel's official documentation:

A project should always be able to run bazel build //... and bazel test //... successfully on its stable branch.

We're now in a state where we can use the Bazel CLI to potentially modify the workspace prior to invoking bazel build //... or bazel test //.... However, that would undermine the both the spirit of the recommendation. For our scripts to adhere to this guidance, we should:

  • Only depend upon artifacts which have been built by Bazel.
    • Thankfully, bazelisk run will build all depended upon targets prior to execution.
  • Produce content which is generally exists outside of the dependency graph

For the second bullet point, there is an additional configuration file we can add to the repository: .bazelignore Let's try it out:

touch .bazelignore
echo "generated/" >> .bazelignore

To see it in practice, we'll try to query for all of the content in the generated/ directory:

$ bazelisk query "//generated:*"

ERROR: no such package 'generated': Package is considered deleted due to --deleted_packages

The .bazelignore file configures Bazel to be aware that generated/ is a directory, but is not a valid package. Even if we add a BUILD file to this directory, Bazel will ignore it.

touch generated/BUILD

Any rule target in this package will be ignored:

"""
This BUILD file is unreachable and is ignored by Bazel.
The configuration controlling that behavior lives in `.bazelignore`
"""

filegroup(
    name = "bazel_ignore_test",
    srcs = [
        "api-documentation.md",
    ],
)

Which we can confirm with build:

$ bazelisk build //generated:bazel_ignore_test

WARNING: Target pattern parsing failed.
ERROR: Skipping '//generated:bazel_ignore_test': no such package 'generated': Package is considered deleted due to --deleted_packages
ERROR: no such package 'generated': Package is considered deleted due to --deleted_packages
INFO: Elapsed time: 6.511s
INFO: 0 processes.
ERROR: Build did NOT complete successfully

Going forward, we'll need to be intentional about where our programs execute, and what is part of the source tree.

State of the Repo

Most of our changes exist outside of the dependency graph, so we won't see many differences since the last Lesson. That said, it is an important visual aid to help us understand our progress:

bazelisk query "//..." --output=graph > visualizations/src/source_tree_graph.gv
dot -Tpng < visualizations/src/source_tree_graph.gv > visualizations/out/source_tree_graph.png

Which renders as:

A graph containing the new workspace-centric pwd target, and no generated package

As always, you can view the changes from this Lesson on GitHub.

Previous - Lesson 13: Aspects

Further Reading

Clone this wiki locally