├── .bazelrc ├── .bazelversion ├── .gitattributes ├── .github └── workflows │ ├── ci.bazelrc │ ├── presubmit.yml │ └── release.yml ├── .gitignore ├── BUILD.bazel ├── LICENSE ├── MODULE.bazel ├── MODULE.bazel.lock ├── README.md ├── cli ├── BUILD.bazel └── flags.go ├── common ├── BUILD.bazel ├── relpath.go ├── relpath_test.go ├── sorted_set │ ├── BUILD.bazel │ ├── sorted_set.go │ └── sorted_set_test.go └── versions │ ├── BUILD.bazel │ ├── versions.go │ └── versions_test.go ├── driver ├── BUILD.bazel └── driver.go ├── go.mod ├── go.sum ├── maven_install.json ├── pkg ├── BUILD.bazel ├── bazel.go ├── configurations.go ├── hash_cache.go ├── hash_cache_test.go ├── normalizer.go ├── normalizer_test.go ├── target_determinator.go ├── target_determinator_test.go ├── targets_list.go └── walker.go ├── rules ├── BUILD.bazel ├── copy_proto_output.bzl └── multi_platform_go_binary.bzl ├── scripts ├── format ├── update-dependencies └── workspace-status.sh ├── target-determinator ├── BUILD.bazel └── target-determinator.go ├── testdata └── HelloWorld │ ├── BUILD.bazel │ ├── Greeting.java │ ├── HelloWorld.java │ └── InhabitedPlanets │ └── Earth ├── tests └── integration │ ├── README.md │ └── java │ └── com │ └── github │ └── bazel_contrib │ └── target_determinator │ ├── integration │ ├── BUILD.bazel │ ├── TargetComputationErrorException.java │ ├── TargetDeterminator.java │ ├── TargetDeterminatorIntegrationTest.java │ ├── TargetDeterminatorSpecificFlagsTest.java │ ├── TestdataRepo.java │ ├── Tests.java │ └── Util.java │ └── label │ ├── BUILD.bazel │ ├── Label.java │ └── LabelTest.java ├── third_party └── protobuf │ └── bazel │ ├── analysis │ ├── BUILD.bazel │ └── dummy.go │ └── build │ ├── BUILD.bazel │ └── dummy.go ├── tools ├── BUILD.bazel └── tools.go └── version ├── BUILD.bazel └── version.go /.bazelrc: -------------------------------------------------------------------------------- 1 | build --java_language_version=21 2 | build --java_runtime_version=remotejdk_21 3 | build --tool_java_language_version=21 4 | build --tool_java_runtime_version=remotejdk_21 5 | 6 | build --verbose_failures 7 | build --test_output=errors 8 | 9 | common --per_file_copt=external/.*protobuf.*@--PROTOBUF_WAS_NOT_SUPPOSED_TO_BE_BUILT 10 | common --host_per_file_copt=external/.*protobuf.*@--PROTOBUF_WAS_NOT_SUPPOSED_TO_BE_BUILT 11 | common --per_file_copt=external/.*grpc.*@--GRPC_WAS_NOT_SUPPOSED_TO_BE_BUILT 12 | common --host_per_file_copt=external/.*grpc.*@--GRPC_WAS_NOT_SUPPOSED_TO_BE_BUILT 13 | 14 | common --incompatible_enable_proto_toolchain_resolution -------------------------------------------------------------------------------- /.bazelversion: -------------------------------------------------------------------------------- 1 | 7.5.0 2 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *maven_install.json linguist-generated=true 2 | -------------------------------------------------------------------------------- /.github/workflows/ci.bazelrc: -------------------------------------------------------------------------------- 1 | # This file contains Bazel settings to apply on CI only. 2 | # It is referenced with a --bazelrc option in the call to bazel in ci.yaml 3 | 4 | # Debug where options came from 5 | build --announce_rc 6 | # Don't rely on test logs being easily accessible from the test runner, 7 | # though it makes the log noisier. 8 | test --test_output=errors 9 | # Allows tests to run bazelisk-in-bazel, since this is the cache folder used 10 | test --test_env=XDG_CACHE_HOME 11 | -------------------------------------------------------------------------------- /.github/workflows/presubmit.yml: -------------------------------------------------------------------------------- 1 | name: presubmit 2 | on: 3 | push: 4 | pull_request: 5 | # Allows you to run this workflow manually from the Actions tab 6 | workflow_dispatch: 7 | jobs: 8 | presubmit: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v3 12 | - name: Check dependencies and format 13 | run: scripts/update-dependencies && scripts/format && { [[ -z "$(git status --porcelain)" ]] || exit 1; } 14 | - name: bazel test //... 15 | env: 16 | # Bazelisk will download bazel to here, ensure it is cached within tests. 17 | XDG_CACHE_HOME: /home/runner/.cache/bazel-repo 18 | run: bazel --bazelrc=.github/workflows/ci.bazelrc --bazelrc=.bazelrc test //... 19 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | on: 3 | push: 4 | tags: 5 | - '*' 6 | # Allows you to run this workflow manually from the Actions tab 7 | workflow_dispatch: 8 | permissions: 9 | contents: write 10 | jobs: 11 | release: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v3 15 | - name: build 16 | run: bazel build --stamp --workspace_status_command=./scripts/workspace-status.sh //target-determinator:all //driver:all && mkdir .release-artifacts && for f in $(bazel cquery --output=files 'let bins = kind(go_binary, //target-determinator:all + //driver:all) in $bins - attr(tags, "\bmanual\b", $bins)'); do cp "$(bazel info execution_root)/${f}" .release-artifacts/; done 17 | - name: release 18 | uses: softprops/action-gh-release@v1 19 | with: 20 | files: .release-artifacts/* 21 | prerelease: false 22 | generate_release_notes: true 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /bazel-* 2 | /.ijwb/ 3 | /.idea/ 4 | 5 | /third_party/protobuf/bazel/*/*.pb.go 6 | -------------------------------------------------------------------------------- /BUILD.bazel: -------------------------------------------------------------------------------- 1 | load("@bazel_gazelle//:def.bzl", "gazelle") 2 | 3 | # gazelle:prefix github.com/bazel-contrib/target-determinator 4 | gazelle( 5 | name = "gazelle", 6 | mode = "fix", 7 | ) 8 | 9 | config_setting( 10 | name = "linux_arm64", 11 | constraint_values = [ 12 | "@platforms//cpu:arm64", 13 | "@platforms//os:linux", 14 | ], 15 | ) 16 | 17 | config_setting( 18 | name = "linux_x86_64", 19 | constraint_values = [ 20 | "@platforms//cpu:x86_64", 21 | "@platforms//os:linux", 22 | ], 23 | ) 24 | 25 | config_setting( 26 | name = "macos_arm64", 27 | constraint_values = [ 28 | "@platforms//cpu:arm64", 29 | "@platforms//os:macos", 30 | ], 31 | ) 32 | 33 | config_setting( 34 | name = "macos_x86_64", 35 | constraint_values = [ 36 | "@platforms//cpu:x86_64", 37 | "@platforms//os:macos", 38 | ], 39 | ) 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /MODULE.bazel: -------------------------------------------------------------------------------- 1 | module( 2 | name = "target-determinator", 3 | ) 4 | 5 | bazel_dep(name = "bazel_skylib", version = "1.7.1") 6 | bazel_dep(name = "gazelle", version = "0.43.0", repo_name = "bazel_gazelle") 7 | bazel_dep(name = "platforms", version = "0.0.11") 8 | bazel_dep(name = "protobuf", version = "29.3") 9 | bazel_dep(name = "rules_go", version = "0.51.0", repo_name = "io_bazel_rules_go") 10 | bazel_dep(name = "rules_java", version = "8.7.1") 11 | bazel_dep(name = "rules_jvm_external", version = "6.6") 12 | bazel_dep(name = "rules_proto", version = "7.1.0") 13 | bazel_dep(name = "toolchains_protoc", version = "0.4.1") 14 | 15 | protoc = use_extension("@toolchains_protoc//protoc:extensions.bzl", "protoc") 16 | protoc.toolchain( 17 | # Creates a repository to satisfy well-known-types dependencies such as 18 | # deps=["@com_google_protobuf//:any_proto"] 19 | google_protobuf = "com_google_protobuf", 20 | # Pin to any version of protoc 21 | version = "v30.2", 22 | ) 23 | use_repo(protoc, "com_google_protobuf", "toolchains_protoc_hub") 24 | 25 | register_toolchains("@toolchains_protoc_hub//:all") 26 | 27 | go_sdk = use_extension("@io_bazel_rules_go//go:extensions.bzl", "go_sdk") 28 | go_sdk.download( 29 | name = "go_sdk", 30 | version = "1.24.2", 31 | ) 32 | use_repo(go_sdk, "go_sdk") 33 | 34 | go_deps = use_extension("@bazel_gazelle//:extensions.bzl", "go_deps") 35 | go_deps.from_file(go_mod = "//:go.mod") 36 | use_repo( 37 | go_deps, 38 | "com_github_aristanetworks_goarista", 39 | "com_github_google_btree", 40 | "com_github_google_uuid", 41 | "com_github_hashicorp_go_version", 42 | "com_github_otiai10_copy", 43 | "com_github_stretchr_testify", 44 | "com_github_wi2l_jsondiff", 45 | "org_golang_google_protobuf", 46 | "org_golang_x_tools", 47 | ) 48 | 49 | maven = use_extension("@rules_jvm_external//:extensions.bzl", "maven") 50 | maven.install( 51 | artifacts = [ 52 | "com.google.guava:guava:31.0.1-jre", 53 | "junit:junit:4.12", 54 | "org.eclipse.jgit:org.eclipse.jgit:5.11.0.202103091610-r", 55 | "org.hamcrest:hamcrest-all:1.3", 56 | ], 57 | fail_if_repin_required = True, 58 | fetch_sources = True, 59 | lock_file = "//:maven_install.json", 60 | ) 61 | use_repo(maven, "maven") 62 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Target Determinator 2 | 3 | Target determinator is a binary (and Go API) used to determine which Bazel targets changed between two git commits. 4 | 5 | ## target-determinator binary 6 | 7 | For simple listing, the `target-determinator` binary is supplied: 8 | 9 | ``` 10 | Usage of target-determinator: 11 | target-determinator 12 | Where may be any commit revision - full commit hashes, short commit hashes, tags, branches, etc. 13 | -bazel string 14 | Bazel binary (basename on $PATH, or absolute or relative path) to run (default "bazel") 15 | -ignore-file value 16 | Files to ignore for git operations, relative to the working-directory. These files shan't affect the Bazel graph. 17 | -targets bazel query 18 | Targets to consider. Accepts any valid bazel query expression (see https://bazel.build/reference/query). (default "//...") 19 | -verbose 20 | Whether to explain (messily) why each target is getting run 21 | -working-directory string 22 | Working directory to query (default ".") 23 | ``` 24 | 25 | This binary lists targets to stdout, one-per-line, which were affected between and the currently checked-out revision. 26 | 27 | ## driver binary 28 | 29 | `driver` is a binary which implements a simple CI pipeline; it runs the same logic as `target-determinator`, then tests all identified targets. 30 | 31 | ``` 32 | Usage of driver: 33 | driver 34 | Where may be any commit-like strings - full commit hashes, short commit hashes, tags, branches, etc. 35 | Optional flags: 36 | -bazel string 37 | Bazel binary (basename on $PATH, or absolute or relative path) to run (default "bazel") 38 | -ignore-file value 39 | Files to ignore for git operations, relative to the working-directory. These files shan't affect the Bazel graph. 40 | -manual-test-mode string 41 | How to handle affected tests tagged manual. Possible values: run|skip (default "skip") 42 | -targets bazel query 43 | Targets to consider. Accepts any valid bazel query expression (see https://bazel.build/reference/query). (default "//...") 44 | -working-directory string 45 | Working directory to query (default ".") 46 | ``` 47 | 48 | ## WalkAffectedTargets API 49 | 50 | Both of the above binaries are thin wrappers around a Go function called `WalkAffectedTargets` which calls a user-supplied callback for each affected target between two commits: 51 | 52 | ```go 53 | // WalkAffectedTargets computes which targets have changed between two commits, and calls 54 | // callback once for each target which has changed. 55 | // Explanation of the differences may be expensive in both time and memory to compute, so if 56 | // includeDifferences is set to false, the []Difference parameter to the callback will always be nil. 57 | func WalkAffectedTargets(context *Context, commitishBefore, commitishAfter LabelledGitRev, pattern label.Pattern, includeDifferences bool, callback WalkCallback) error { ... } 58 | 59 | type WalkCallback func(label.Label, []Difference, *analysis.ConfiguredTarget) 60 | ``` 61 | 62 | This can be used to flexibly build your own logic handling the affected targets to drive whatever analysis you want. 63 | 64 | ## How to get Target Determinator 65 | 66 | Pre-built binary releases are published as [GitHub Releases](https://github.com/bazel-contrib/target-determinator/releases) for most changes. 67 | 68 | We recommend you download the latest release for your platform, and run it where needed (e.g. in your CI pipeline). 69 | 70 | We avoid breaking changes where possible, but offer no formal compatibility guarantees release-to-release. 71 | 72 | We do not recommend integrating Target Determinator into your Bazel build graph unless you have a compelling reason to do so. 73 | 74 | ## Contributing 75 | 76 | Contributions are very welcome! 77 | 78 | We have an extensive integration testing suite in the `tests/integration` directory which has its own README. The test suite also runs against several other target determinator implementations which are pulled in as `http_archive`s, to test compatibility. Please make sure any contributions are covered by a test. 79 | 80 | When adding new dependencies to the Go code, please run `scripts/update-dependencies`. 81 | 82 | In general, BUILD files in this repo are maintained by `gazelle`; to regenerate tem, please run `bazel run //:gazelle`. 83 | 84 | Alongside each `go_proto_library`, there is a runnable `copy_proto_output` rule which can be used to generate the Go source for a protobuf, in case it's useful to inspect. 85 | 86 | ## Supported Bazel versions 87 | 88 | Target Determinator currently supports Bazel 4.0.0 up to and including the latest LTS release. 89 | 90 | We are happy to support newer Bazel versions (as long as this doesn't break support for the current LTS), but only exhaustively test against the latest LTS release. 91 | 92 | We have a small number of smoke tests which verify basic functionality on the oldest supported release, but do not regularly test against it. 93 | -------------------------------------------------------------------------------- /cli/BUILD.bazel: -------------------------------------------------------------------------------- 1 | load("@io_bazel_rules_go//go:def.bzl", "go_library") 2 | 3 | go_library( 4 | name = "cli", 5 | srcs = ["flags.go"], 6 | importpath = "github.com/bazel-contrib/target-determinator/cli", 7 | visibility = ["//visibility:public"], 8 | deps = [ 9 | "//common", 10 | "//pkg", 11 | "//version", 12 | ], 13 | ) 14 | -------------------------------------------------------------------------------- /cli/flags.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | "strings" 9 | 10 | "github.com/bazel-contrib/target-determinator/common" 11 | "github.com/bazel-contrib/target-determinator/pkg" 12 | "github.com/bazel-contrib/target-determinator/version" 13 | ) 14 | 15 | type IgnoreFileFlag []common.RelPath 16 | 17 | func (i *IgnoreFileFlag) stringSlice() []string { 18 | var stringSlice []string 19 | for _, p := range *i { 20 | stringSlice = append(stringSlice, p.String(), "/") 21 | } 22 | return stringSlice 23 | } 24 | 25 | func (i *IgnoreFileFlag) String() string { 26 | return "[" + strings.Join(i.stringSlice(), ", ") + "]" 27 | } 28 | 29 | func (i *IgnoreFileFlag) Set(value string) error { 30 | *i = append(*i, common.NewRelPath(value)) 31 | return nil 32 | } 33 | 34 | type EnforceCleanFlag int 35 | 36 | const ( 37 | EnforceClean EnforceCleanFlag = iota 38 | AllowIgnored 39 | AllowDirty 40 | ) 41 | 42 | func (e EnforceCleanFlag) String() string { 43 | switch e { 44 | case EnforceClean: 45 | return "enforce-clean" 46 | case AllowIgnored: 47 | return "allow-ignored" 48 | case AllowDirty: 49 | return "allow-dirty" 50 | } 51 | return "" 52 | } 53 | 54 | func (e *EnforceCleanFlag) Set(value string) error { 55 | switch value { 56 | case "enforce-clean": 57 | *e = EnforceClean 58 | case "allow-ignored": 59 | *e = AllowIgnored 60 | case "allow-dirty": 61 | *e = AllowDirty 62 | default: 63 | return fmt.Errorf("invalid value for --enforce-clean: %v", value) 64 | } 65 | return nil 66 | } 67 | 68 | type CommonFlags struct { 69 | Version bool 70 | WorkingDirectory *string 71 | BazelPath *string 72 | BazelStartupOpts *MultipleStrings 73 | BazelOpts *MultipleStrings 74 | EnforceCleanRepo EnforceCleanFlag 75 | DeleteCachedWorktree bool 76 | IgnoredFiles *IgnoreFileFlag 77 | BeforeQueryErrorBehavior *string 78 | TargetsFlag *string 79 | AnalysisCacheClearStrategy *string 80 | CompareQueriesAroundAnalysisCacheClear bool 81 | FilterIncompatibleTargets bool 82 | } 83 | 84 | func StrPtr() *string { 85 | var s string 86 | return &s 87 | } 88 | 89 | func RegisterCommonFlags() *CommonFlags { 90 | commonFlags := CommonFlags{ 91 | Version: false, 92 | WorkingDirectory: StrPtr(), 93 | BazelPath: StrPtr(), 94 | BazelStartupOpts: &MultipleStrings{}, 95 | BazelOpts: &MultipleStrings{}, 96 | EnforceCleanRepo: AllowIgnored, 97 | DeleteCachedWorktree: false, 98 | IgnoredFiles: &IgnoreFileFlag{}, 99 | BeforeQueryErrorBehavior: StrPtr(), 100 | TargetsFlag: StrPtr(), 101 | AnalysisCacheClearStrategy: StrPtr(), 102 | CompareQueriesAroundAnalysisCacheClear: false, 103 | FilterIncompatibleTargets: true, 104 | } 105 | flag.BoolVar(&commonFlags.Version, "version", false, "Print the version of the tool and exit.") 106 | flag.StringVar(commonFlags.WorkingDirectory, "working-directory", ".", "Working directory to query.") 107 | flag.StringVar(commonFlags.BazelPath, "bazel", "bazel", 108 | "Bazel binary (basename on $PATH, or absolute or relative path) to run.") 109 | flag.Var(commonFlags.BazelStartupOpts, "bazel-startup-opts", "Startup options to pass to Bazel. Options such as '--bazelrc' should use relative paths for files under the repository to avoid issues (TD may check out the repository in a temporary directory).") 110 | flag.Var(commonFlags.BazelOpts, "bazel-opts", "Options to pass to Bazel. Assumed to apply to build and cquery. Options should use relative paths for repository files (see --bazel-startup-opts).") 111 | flag.Var(&commonFlags.EnforceCleanRepo, "enforce-clean", 112 | fmt.Sprintf("Pass --enforce-clean=%v to fail if the repository is unclean, or --enforce-clean=%v to allow ignored untracked files (the default).", 113 | EnforceClean.String(), AllowIgnored.String())) 114 | flag.BoolVar(&commonFlags.DeleteCachedWorktree, "delete-cached-worktree", false, 115 | "Delete created worktrees after use when created. Keeping them can make subsequent invocations faster.") 116 | flag.Var(commonFlags.IgnoredFiles, "ignore-file", 117 | "Files to ignore for git operations, relative to the working-directory. These files shan't affect the Bazel graph.") 118 | flag.StringVar(commonFlags.BeforeQueryErrorBehavior, "before-query-error-behavior", "ignore-and-build-all", "How to behave if the 'before' revision query fails. Accepted values: fatal,ignore-and-build-all") 119 | flag.StringVar(commonFlags.TargetsFlag, "targets", "//...", 120 | "Targets to consider. Accepts any valid `bazel query` expression (see https://bazel.build/reference/query).") 121 | flag.StringVar(commonFlags.AnalysisCacheClearStrategy, "analysis-cache-clear-strategy", "skip", "Strategy for clearing the analysis cache. Accepted values: skip,shutdown,discard.") 122 | flag.BoolVar(&commonFlags.CompareQueriesAroundAnalysisCacheClear, "compare-queries-around-analysis-cache-clear", false, "Whether to check for query result differences before and after analysis cache clears. This is a temporary flag for performing real-world analysis.") 123 | flag.BoolVar(&commonFlags.FilterIncompatibleTargets, "filter-incompatible-targets", true, "Whether to filter out incompatible targets from the candidate set of affected targets.") 124 | return &commonFlags 125 | } 126 | 127 | type CommonConfig struct { 128 | Context *pkg.Context 129 | RevisionBefore pkg.LabelledGitRev 130 | Targets pkg.TargetsList 131 | } 132 | 133 | // ValidateCommonFlags ensures that the argument follow the right format 134 | func ValidateCommonFlags(commandName string, flags *CommonFlags) (targetPattern string, err error) { 135 | if flags.Version { 136 | fmt.Printf("%s %s\n", commandName, version.Version) 137 | os.Exit(0) 138 | } 139 | 140 | positional := flag.Args() 141 | if len(positional) != 1 { 142 | return "", fmt.Errorf("expected one positional argument, , but got %d", len(positional)) 143 | } 144 | return positional[0], nil 145 | 146 | } 147 | 148 | func ResolveCommonConfig(commonFlags *CommonFlags, beforeRevStr string) (*CommonConfig, error) { 149 | 150 | // Context attributes 151 | 152 | workingDirectory, err := filepath.Abs(*commonFlags.WorkingDirectory) 153 | if err != nil { 154 | return nil, fmt.Errorf("failed to get working directory from %v: %w", *commonFlags.WorkingDirectory, err) 155 | } 156 | 157 | currentBranch, err := pkg.GitRevParse(workingDirectory, "HEAD", true) 158 | if err != nil { 159 | return nil, fmt.Errorf("failed to get current git revision: %w", err) 160 | } 161 | 162 | afterRev, err := pkg.NewLabelledGitRev(workingDirectory, currentBranch, "after") 163 | if err != nil { 164 | return nil, fmt.Errorf("failed to resolve the \"after\" (i.e. original) git revision: %w", err) 165 | } 166 | 167 | bazelCmd := pkg.DefaultBazelCmd{ 168 | BazelPath: *commonFlags.BazelPath, 169 | BazelStartupOpts: *commonFlags.BazelStartupOpts, 170 | BazelOpts: *commonFlags.BazelOpts, 171 | } 172 | 173 | outputBase, err := pkg.BazelOutputBase(workingDirectory, bazelCmd) 174 | if err != nil { 175 | return nil, fmt.Errorf("failed to resolve the bazel output base: %w", err) 176 | } 177 | 178 | context := &pkg.Context{ 179 | WorkspacePath: workingDirectory, 180 | OriginalRevision: afterRev, 181 | BazelCmd: bazelCmd, 182 | BazelOutputBase: outputBase, 183 | DeleteCachedWorktree: commonFlags.DeleteCachedWorktree, 184 | IgnoredFiles: *commonFlags.IgnoredFiles, 185 | BeforeQueryErrorBehavior: *commonFlags.BeforeQueryErrorBehavior, 186 | AnalysisCacheClearStrategy: *commonFlags.AnalysisCacheClearStrategy, 187 | CompareQueriesAroundAnalysisCacheClear: commonFlags.CompareQueriesAroundAnalysisCacheClear, 188 | FilterIncompatibleTargets: commonFlags.FilterIncompatibleTargets, 189 | EnforceCleanRepo: commonFlags.EnforceCleanRepo == EnforceClean, 190 | } 191 | 192 | // Non-context attributes 193 | 194 | beforeRev, err := pkg.NewLabelledGitRev(workingDirectory, beforeRevStr, "before") 195 | if err != nil { 196 | return nil, fmt.Errorf("failed to resolve the \"before\" git revision: %w", err) 197 | } 198 | 199 | targetsList, err := pkg.ParseTargetsList(*commonFlags.TargetsFlag) 200 | if err != nil { 201 | return nil, fmt.Errorf("failed to parse targets: %w", err) 202 | } 203 | 204 | return &CommonConfig{ 205 | Context: context, 206 | RevisionBefore: beforeRev, 207 | Targets: targetsList, 208 | }, nil 209 | } 210 | 211 | type MultipleStrings []string 212 | 213 | func (s *MultipleStrings) String() string { 214 | return fmt.Sprintf("%v", MultipleStrings{}) 215 | } 216 | 217 | func (s *MultipleStrings) Set(value string) error { 218 | *s = append(*s, value) 219 | return nil 220 | } 221 | -------------------------------------------------------------------------------- /common/BUILD.bazel: -------------------------------------------------------------------------------- 1 | load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test") 2 | 3 | go_library( 4 | name = "common", 5 | srcs = ["relpath.go"], 6 | importpath = "github.com/bazel-contrib/target-determinator/common", 7 | visibility = ["//visibility:public"], 8 | deps = [ 9 | "@com_github_aristanetworks_goarista//key", 10 | "@com_github_aristanetworks_goarista//path", 11 | ], 12 | ) 13 | 14 | go_test( 15 | name = "common_test", 16 | srcs = ["relpath_test.go"], 17 | embed = [":common"], 18 | ) 19 | -------------------------------------------------------------------------------- /common/relpath.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/aristanetworks/goarista/key" 7 | "github.com/aristanetworks/goarista/path" 8 | ) 9 | 10 | // RelPath represents a relative path. 11 | // It interprets the inner path as if it did not have a trailing slash. 12 | type RelPath struct { 13 | path key.Path 14 | } 15 | 16 | // NewRelPath creates a new RelPath from a string. 17 | // Leading slashes on the string will simply be ignored. 18 | func NewRelPath(str string) RelPath { 19 | return RelPath{path.FromString(str)} 20 | } 21 | 22 | // String returns the string representation of the RelPath. 23 | // An empty path returns an empty string. 24 | func (r RelPath) String() string { 25 | return strings.TrimLeft(r.path.String(), "/") 26 | } 27 | 28 | func (r RelPath) Path() key.Path { 29 | return r.path 30 | } 31 | -------------------------------------------------------------------------------- /common/relpath_test.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestRelPath_String(t *testing.T) { 8 | tests := []struct { 9 | name string 10 | relPath RelPath 11 | want string 12 | }{ 13 | { 14 | name: "Simple relative path", 15 | relPath: NewRelPath("foo/bar"), 16 | want: "foo/bar", 17 | }, 18 | { 19 | name: "Simple relative path with dot-slash", 20 | relPath: NewRelPath("./foo/bar"), 21 | want: "./foo/bar", 22 | }, 23 | { 24 | name: "Absolute path is treated as if they did not contain leading slashes", 25 | relPath: NewRelPath("/foo/bar"), 26 | want: "foo/bar", 27 | }, 28 | { 29 | name: "Multiple leading slashes are handled correctly", 30 | relPath: NewRelPath("////foo/bar"), 31 | want: "foo/bar", 32 | }, 33 | { 34 | name: "Empty paths return an empty string", 35 | relPath: NewRelPath(""), 36 | want: "", 37 | }, 38 | } 39 | for _, tt := range tests { 40 | t.Run(tt.name, func(t *testing.T) { 41 | r := tt.relPath 42 | if got := r.String(); got != tt.want { 43 | t.Errorf("String() = %v, want %v", got, tt.want) 44 | } 45 | }) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /common/sorted_set/BUILD.bazel: -------------------------------------------------------------------------------- 1 | load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test") 2 | 3 | go_library( 4 | name = "sorted_set", 5 | srcs = ["sorted_set.go"], 6 | importpath = "github.com/bazel-contrib/target-determinator/common/sorted_set", 7 | visibility = ["//visibility:public"], 8 | deps = ["@com_github_google_btree//:btree"], 9 | ) 10 | 11 | go_test( 12 | name = "sorted_set_test", 13 | srcs = ["sorted_set_test.go"], 14 | deps = [ 15 | ":sorted_set", 16 | "@com_github_google_uuid//:uuid", 17 | ], 18 | ) 19 | -------------------------------------------------------------------------------- /common/sorted_set/sorted_set.go: -------------------------------------------------------------------------------- 1 | package sorted_set 2 | 3 | import ( 4 | "github.com/google/btree" 5 | ) 6 | 7 | // SortedSet is a Set whose elements are traversable in sorted ordered. 8 | type SortedSet[T any] struct { 9 | tree *btree.BTreeG[T] 10 | } 11 | 12 | const degree int = 2 13 | 14 | // NewSortedSet makes a SortedSet of the elements in the passed slice. 15 | // The original slice is not modified. 16 | func NewSortedSet[T btree.Ordered](in []T) *SortedSet[T] { 17 | ss := &SortedSet[T]{ 18 | tree: btree.NewOrderedG[T](degree), 19 | } 20 | for _, v := range in { 21 | ss.Add(v) 22 | } 23 | return ss 24 | } 25 | 26 | // NewSortedSetFn makes a SortedSet of the elements in the passed slice. 27 | // The original slice is not modified. 28 | func NewSortedSetFn[T any](in []T, less btree.LessFunc[T]) *SortedSet[T] { 29 | ss := &SortedSet[T]{ 30 | tree: btree.NewG[T](degree, less), 31 | } 32 | for _, v := range in { 33 | ss.Add(v) 34 | } 35 | return ss 36 | } 37 | 38 | // Contains returns whether the SortedSet contains the given value. 39 | func (s *SortedSet[T]) Contains(v T) bool { 40 | if s == nil { 41 | return false 42 | } 43 | return s.tree.Has(v) 44 | } 45 | 46 | // SortedSlice returns a sorted slice of the values in the SortedSet. 47 | // The returned slice is owned by the caller, and may be modified freely. 48 | func (s *SortedSet[T]) SortedSlice() []T { 49 | if s == nil { 50 | return nil 51 | } 52 | slice := make([]T, 0, s.tree.Len()) 53 | s.tree.Ascend(func(v T) bool { 54 | slice = append(slice, v) 55 | return true 56 | }) 57 | return slice 58 | } 59 | 60 | // Add adds an element to the SortedSet. 61 | // If adding another SortedSet, AddAll is more efficient than repeated calls to 62 | // Add as it avoids needing to materialize an intermediate slice. 63 | func (s *SortedSet[T]) Add(v T) { 64 | s.tree.ReplaceOrInsert(v) 65 | } 66 | 67 | // AddAll adds all of the elements from the other SortedSet to this one. 68 | func (s *SortedSet[T]) AddAll(other *SortedSet[T]) { 69 | other.tree.Ascend(func(v T) bool { 70 | s.Add(v) 71 | return true 72 | }) 73 | } 74 | 75 | // Filter creates a new SortedSet containing only the elements from this one for 76 | // which the passed predicate evaluates true. 77 | func (s *SortedSet[T]) Filter(predicate func(T) bool) *SortedSet[T] { 78 | new := &SortedSet[T]{ 79 | tree: s.tree.Clone(), 80 | } 81 | s.tree.Ascend(func(v T) bool { 82 | if !predicate(v) { 83 | new.tree.Delete(v) 84 | } 85 | return true 86 | }) 87 | return new 88 | } 89 | 90 | // Clone makes a copy of this SortedSet. 91 | // Behaviorally, the copy is completely independent of the original, but 92 | // as the copying is done lazily with copy-on-write structures, performance 93 | // of mutations on both sets may be worse while initial copies are (lazily) 94 | // made. 95 | func (s *SortedSet[T]) Clone() *SortedSet[T] { 96 | return &SortedSet[T]{ 97 | tree: s.tree.Clone(), 98 | } 99 | } 100 | 101 | func (s *SortedSet[T]) Len() int { 102 | if s == nil { 103 | return 0 104 | } 105 | return s.tree.Len() 106 | } 107 | -------------------------------------------------------------------------------- /common/sorted_set/sorted_set_test.go: -------------------------------------------------------------------------------- 1 | package sorted_set_test 2 | 3 | import ( 4 | "reflect" 5 | "sort" 6 | "strings" 7 | "testing" 8 | 9 | ss "github.com/bazel-contrib/target-determinator/common/sorted_set" 10 | "github.com/google/uuid" 11 | ) 12 | 13 | func TestContains(t *testing.T) { 14 | inSet := "cheese" 15 | notInSet := "onions" 16 | 17 | s := ss.NewSortedSet([]string{inSet}) 18 | if !s.Contains(inSet) { 19 | t.Errorf("want contains %v got not", inSet) 20 | } 21 | if s.Contains(notInSet) { 22 | t.Errorf("want not contains %v got contains", notInSet) 23 | } 24 | } 25 | 26 | func TestSortedSlice(t *testing.T) { 27 | want := []string{"cheese", "onions"} 28 | { 29 | alreadySorted := ss.NewSortedSet([]string{"cheese", "onions"}) 30 | got := alreadySorted.SortedSlice() 31 | if !reflect.DeepEqual(got, want) { 32 | t.Errorf("already sorted: wanted %v got %v", want, got) 33 | } 34 | } 35 | 36 | { 37 | notAlreadySorted := ss.NewSortedSet([]string{"onions", "cheese"}) 38 | got := notAlreadySorted.SortedSlice() 39 | if !reflect.DeepEqual(got, want) { 40 | t.Errorf("not already sorted: wanted %v got %v", want, got) 41 | } 42 | } 43 | } 44 | 45 | func TestInsert(t *testing.T) { 46 | s := ss.NewSortedSet([]string{}) 47 | 48 | { 49 | if len := s.Len(); len != 0 { 50 | t.Errorf("Want len 0 got %v", len) 51 | } 52 | 53 | got := s.SortedSlice() 54 | want := []string{} 55 | if !reflect.DeepEqual(got, want) { 56 | t.Errorf("Before insert: wanted %v got %v") 57 | } 58 | 59 | if s.Contains("cheese") { 60 | t.Errorf("Before insert: wanted not contains cheese") 61 | } 62 | if s.Contains("onions") { 63 | t.Errorf("Before insert: wanted not contains onions") 64 | } 65 | } 66 | 67 | s.Add("cheese") 68 | { 69 | if len := s.Len(); len != 1 { 70 | t.Errorf("Want len 1 got %v", len) 71 | } 72 | 73 | got := s.SortedSlice() 74 | want := []string{"cheese"} 75 | if !reflect.DeepEqual(got, want) { 76 | t.Errorf("After one insert: wanted %v got %v") 77 | } 78 | 79 | if !s.Contains("cheese") { 80 | t.Errorf("After one insert: wanted contains cheese") 81 | } 82 | if s.Contains("onions") { 83 | t.Errorf("After one insert: wanted not contains onions") 84 | } 85 | } 86 | 87 | s.Add("cheese") 88 | { 89 | if len := s.Len(); len != 1 { 90 | t.Errorf("Want len 1 got %v", len) 91 | } 92 | 93 | got := s.SortedSlice() 94 | want := []string{"cheese"} 95 | if !reflect.DeepEqual(got, want) { 96 | t.Errorf("After two inserts: wanted %v got %v") 97 | } 98 | 99 | if !s.Contains("cheese") { 100 | t.Errorf("After two inserts: wanted contains cheese") 101 | } 102 | if s.Contains("onions") { 103 | t.Errorf("After two inserts: wanted not contains onions") 104 | } 105 | } 106 | 107 | s.Add("onions") 108 | { 109 | if len := s.Len(); len != 2 { 110 | t.Errorf("Want len 2 got %v", len) 111 | } 112 | 113 | got := s.SortedSlice() 114 | want := []string{"cheese", "onions"} 115 | if !reflect.DeepEqual(got, want) { 116 | t.Errorf("After three inserts: wanted %v got %v") 117 | } 118 | 119 | if !s.Contains("cheese") { 120 | t.Errorf("After three inserts: wanted contains cheese") 121 | } 122 | if !s.Contains("onions") { 123 | t.Errorf("After three inserts: wanted contains onions") 124 | } 125 | } 126 | 127 | s.Add("brie") 128 | { 129 | if len := s.Len(); len != 3 { 130 | t.Errorf("Want len 3 got %v", len) 131 | } 132 | 133 | got := s.SortedSlice() 134 | want := []string{"brie", "cheese", "onions"} 135 | if !reflect.DeepEqual(got, want) { 136 | t.Errorf("After four inserts: wanted %v got %v") 137 | } 138 | 139 | if !s.Contains("cheese") { 140 | t.Errorf("After four inserts: wanted contains cheese") 141 | } 142 | if !s.Contains("onions") { 143 | t.Errorf("After four inserts: wanted contains onions") 144 | } 145 | if !s.Contains("brie") { 146 | t.Errorf("After four inserts: wanted contains brie") 147 | } 148 | } 149 | } 150 | 151 | func TestRandomInsertsAlwaysSorted(t *testing.T) { 152 | s := ss.NewSortedSet([]string{}) 153 | 154 | added := make(map[string]struct{}) 155 | 156 | for len(added) < 1000000 { 157 | new := uuid.New().String() 158 | if _, ok := added[new]; !ok { 159 | added[new] = struct{}{} 160 | s.Add(new) 161 | } 162 | } 163 | sortedAdded := make([]string, 0, len(added)) 164 | for a := range added { 165 | sortedAdded = append(sortedAdded, a) 166 | } 167 | sort.Strings(sortedAdded) 168 | 169 | if !reflect.DeepEqual(s.SortedSlice(), sortedAdded) { 170 | t.Errorf("Random additions weren't sorted") 171 | } 172 | } 173 | 174 | func TestNil(t *testing.T) { 175 | var set *ss.SortedSet[string] 176 | 177 | sortedSlice := set.SortedSlice() 178 | if len(sortedSlice) != 0 { 179 | t.Errorf("want nil SortedSlice to be empty but was: %v", sortedSlice) 180 | } 181 | 182 | if set.Contains("onions") { 183 | t.Errorf("want nil SortedSet to not contain anything but it did") 184 | } 185 | } 186 | 187 | func TestClone(t *testing.T) { 188 | s := ss.NewSortedSet([]string{"cheese", "onions"}) 189 | s2 := s.Clone() 190 | 191 | s.Add("brie") 192 | s2.Add("cheddar") 193 | 194 | want1 := []string{"brie", "cheese", "onions"} 195 | got1 := s.SortedSlice() 196 | want2 := []string{"cheddar", "cheese", "onions"} 197 | got2 := s2.SortedSlice() 198 | if !reflect.DeepEqual(want1, got1) { 199 | t.Errorf("want %v got %v", want1, got1) 200 | } 201 | if !reflect.DeepEqual(want2, got2) { 202 | t.Errorf("want %v got %v", want2, got2) 203 | } 204 | } 205 | 206 | func TestCloneCustomComparator(t *testing.T) { 207 | s := ss.NewSortedSetFn([]string{"cheese", "onions"}, func(l, r string) bool { 208 | // Reverse order 209 | return l > r 210 | }) 211 | s2 := s.Clone() 212 | 213 | { 214 | want1 := []string{"onions", "cheese"} 215 | got1 := s.SortedSlice() 216 | want2 := []string{"onions", "cheese"} 217 | got2 := s2.SortedSlice() 218 | if !reflect.DeepEqual(want1, got1) { 219 | t.Errorf("want %v got %v", want1, got1) 220 | } 221 | if !reflect.DeepEqual(want2, got2) { 222 | t.Errorf("want %v got %v", want2, got2) 223 | } 224 | } 225 | 226 | s.Add("brie") 227 | s2.Add("cheddar") 228 | 229 | { 230 | want1 := []string{"onions", "cheese", "brie"} 231 | got1 := s.SortedSlice() 232 | want2 := []string{"onions", "cheese", "cheddar"} 233 | got2 := s2.SortedSlice() 234 | if !reflect.DeepEqual(want1, got1) { 235 | t.Errorf("want %v got %v", want1, got1) 236 | } 237 | if !reflect.DeepEqual(want2, got2) { 238 | t.Errorf("want %v got %v", want2, got2) 239 | } 240 | } 241 | } 242 | 243 | func TestAddAll(t *testing.T) { 244 | s := ss.NewSortedSet([]string{"cheese", "onions"}) 245 | s2 := ss.NewSortedSet([]string{"brie", "cheese", "cheddar"}) 246 | 247 | s.AddAll(s2) 248 | 249 | want := []string{"brie", "cheddar", "cheese", "onions"} 250 | got := s.SortedSlice() 251 | if !reflect.DeepEqual(want, got) { 252 | t.Errorf("want %v got %v", want, got) 253 | } 254 | } 255 | 256 | func TestFilter(t *testing.T) { 257 | s := ss.NewSortedSet([]string{"brie", "cheese", "cheddar", "onions"}) 258 | 259 | s2 := s.Filter(func(v string) bool { 260 | return strings.HasPrefix(v, "c") 261 | }) 262 | 263 | want1 := []string{"brie", "cheddar", "cheese", "onions"} 264 | got1 := s.SortedSlice() 265 | if !reflect.DeepEqual(want1, got1) { 266 | t.Errorf("want %v got %v", want1, got1) 267 | } 268 | 269 | want2 := []string{"cheddar", "cheese"} 270 | got2 := s2.SortedSlice() 271 | if !reflect.DeepEqual(want2, got2) { 272 | t.Errorf("want %v got %v", want2, got2) 273 | } 274 | } 275 | 276 | func TestFilterCustomComparator(t *testing.T) { 277 | s := ss.NewSortedSetFn([]string{"brie", "cheese", "cheddar", "onions"}, func(l, r string) bool { 278 | // Reverse order 279 | return l > r 280 | }) 281 | 282 | s2 := s.Filter(func(v string) bool { 283 | return strings.HasPrefix(v, "c") 284 | }) 285 | 286 | want1 := []string{"onions", "cheese", "cheddar", "brie"} 287 | got1 := s.SortedSlice() 288 | if !reflect.DeepEqual(want1, got1) { 289 | t.Errorf("want %v got %v", want1, got1) 290 | } 291 | 292 | want2 := []string{"cheese", "cheddar"} 293 | got2 := s2.SortedSlice() 294 | if !reflect.DeepEqual(want2, got2) { 295 | t.Errorf("want %v got %v", want2, got2) 296 | } 297 | } 298 | -------------------------------------------------------------------------------- /common/versions/BUILD.bazel: -------------------------------------------------------------------------------- 1 | load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test") 2 | 3 | go_library( 4 | name = "versions", 5 | srcs = ["versions.go"], 6 | importpath = "github.com/bazel-contrib/target-determinator/common/versions", 7 | visibility = ["//visibility:public"], 8 | deps = ["@com_github_hashicorp_go_version//:go-version"], 9 | ) 10 | 11 | go_test( 12 | name = "versions_test", 13 | srcs = ["versions_test.go"], 14 | embed = [":versions"], 15 | deps = [ 16 | "@com_github_hashicorp_go_version//:go-version", 17 | "@com_github_stretchr_testify//require", 18 | ], 19 | ) 20 | -------------------------------------------------------------------------------- /common/versions/versions.go: -------------------------------------------------------------------------------- 1 | package versions 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/hashicorp/go-version" 8 | ) 9 | 10 | func ReleaseIsInRange(releaseString string, min *version.Version, max *version.Version) (*bool, string) { 11 | releasePrefix := "release " 12 | if !strings.HasPrefix(releaseString, releasePrefix) { 13 | return nil, "Bazel wasn't a released version" 14 | } 15 | 16 | bazelVersion, err := version.NewVersion(releaseString[len(releasePrefix):]) 17 | if err != nil { 18 | return nil, fmt.Sprintf("Failed to parse Bazel version %q", releaseString) 19 | } 20 | if min != nil && !bazelVersion.GreaterThanOrEqual(min) { 21 | return ptr(false), fmt.Sprintf("Bazel version %s was less than minimum %s", bazelVersion, min.String()) 22 | } 23 | if max != nil && !max.GreaterThan(bazelVersion) { 24 | return ptr(false), fmt.Sprintf("Bazel version %s was not less than maximum %s", bazelVersion, max.String()) 25 | } 26 | return ptr(true), "" 27 | } 28 | 29 | func ptr[V any](v V) *V { 30 | return &v 31 | } 32 | -------------------------------------------------------------------------------- /common/versions/versions_test.go: -------------------------------------------------------------------------------- 1 | package versions 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/hashicorp/go-version" 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestReleaseIsInRange(t *testing.T) { 11 | for name, tc := range map[string]struct { 12 | bazelReleaseString string 13 | min string 14 | max string 15 | wantResult *bool 16 | wantExplanation string 17 | }{ 18 | "in_range": { 19 | bazelReleaseString: "release 7.0.0", 20 | min: "6.4.0", 21 | max: "8.0.0", 22 | wantResult: ptr(true), 23 | wantExplanation: "", 24 | }, 25 | "at_max": { 26 | bazelReleaseString: "release 7.0.0", 27 | min: "6.4.0", 28 | max: "7.0.0", 29 | wantResult: ptr(false), 30 | wantExplanation: "Bazel version 7.0.0 was not less than maximum 7.0.0", 31 | }, 32 | "at_min": { 33 | bazelReleaseString: "release 7.0.0", 34 | min: "7.0.0", 35 | max: "8.0.0", 36 | wantResult: ptr(true), 37 | wantExplanation: "", 38 | }, 39 | "above_max": { 40 | bazelReleaseString: "release 7.0.0", 41 | min: "6.4.0", 42 | max: "6.5.0", 43 | wantResult: ptr(false), 44 | wantExplanation: "Bazel version 7.0.0 was not less than maximum 6.5.0", 45 | }, 46 | "below_min": { 47 | bazelReleaseString: "release 6.4.0", 48 | min: "7.0.0", 49 | max: "7.1.0", 50 | wantResult: ptr(false), 51 | wantExplanation: "Bazel version 6.4.0 was less than minimum 7.0.0", 52 | }, 53 | "no_release_prefix": { 54 | bazelReleaseString: "7.0.0", 55 | min: "6.4.0", 56 | max: "8.0.0", 57 | wantResult: nil, 58 | wantExplanation: "Bazel wasn't a released version", 59 | }, 60 | "no_version": { 61 | bazelReleaseString: "release beep", 62 | min: "6.4.0", 63 | max: "8.0.0", 64 | wantResult: nil, 65 | wantExplanation: "Failed to parse Bazel version \"release beep\"", 66 | }, 67 | "prerelease_in_range": { 68 | bazelReleaseString: "release 8.0.0-pre.20240101.1", 69 | min: "7.0.0", 70 | max: "8.0.0", 71 | wantResult: ptr(true), 72 | wantExplanation: "", 73 | }, 74 | "prerelease_below_range": { 75 | bazelReleaseString: "release 8.0.0-pre.20240101.1", 76 | min: "8.0.0", 77 | max: "8.1.0", 78 | wantResult: ptr(false), 79 | wantExplanation: "Bazel version 8.0.0-pre.20240101.1 was less than minimum 8.0.0", 80 | }, 81 | "prerelease_above_range": { 82 | bazelReleaseString: "release 8.0.0-pre.20240101.1", 83 | min: "7.0.0", 84 | max: "7.1.0", 85 | wantResult: ptr(false), 86 | wantExplanation: "Bazel version 8.0.0-pre.20240101.1 was not less than maximum 7.1.0", 87 | }, 88 | "above_only_min": { 89 | bazelReleaseString: "release 7.0.0", 90 | min: "6.4.0", 91 | max: "", 92 | wantResult: ptr(true), 93 | wantExplanation: "", 94 | }, 95 | "at_only_min": { 96 | bazelReleaseString: "release 6.4.0", 97 | min: "6.4.0", 98 | max: "", 99 | wantResult: ptr(true), 100 | wantExplanation: "", 101 | }, 102 | "below_only_max": { 103 | bazelReleaseString: "release 6.4.0", 104 | min: "", 105 | max: "7.0.0", 106 | wantResult: ptr(true), 107 | wantExplanation: "", 108 | }, 109 | "at_only_max": { 110 | bazelReleaseString: "release 7.0.0", 111 | min: "", 112 | max: "7.0.0", 113 | wantResult: ptr(false), 114 | wantExplanation: "Bazel version 7.0.0 was not less than maximum 7.0.0", 115 | }, 116 | } { 117 | t.Run(name, func(t *testing.T) { 118 | var min *version.Version 119 | if tc.min != "" { 120 | min = version.Must(version.NewVersion(tc.min)) 121 | } 122 | var max *version.Version 123 | if tc.max != "" { 124 | max = version.Must(version.NewVersion(tc.max)) 125 | } 126 | result, explanation := ReleaseIsInRange(tc.bazelReleaseString, min, max) 127 | if tc.wantResult == nil { 128 | require.Nil(t, result) 129 | } else { 130 | require.NotNil(t, result) 131 | require.Equal(t, *tc.wantResult, *result) 132 | } 133 | require.Equal(t, tc.wantExplanation, explanation) 134 | }) 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /driver/BUILD.bazel: -------------------------------------------------------------------------------- 1 | load("@io_bazel_rules_go//go:def.bzl", "go_library") 2 | load("//rules:multi_platform_go_binary.bzl", "multi_platform_go_binary") 3 | 4 | go_library( 5 | name = "driver_lib", 6 | srcs = ["driver.go"], 7 | importpath = "github.com/bazel-contrib/target-determinator/driver", 8 | visibility = ["//visibility:private"], 9 | deps = [ 10 | "//cli", 11 | "//pkg", 12 | "//third_party/protobuf/bazel/analysis", 13 | "@bazel_gazelle//label", 14 | ], 15 | ) 16 | 17 | multi_platform_go_binary( 18 | name = "driver", 19 | embed = [":driver_lib"], 20 | visibility = ["//visibility:public"], 21 | ) 22 | -------------------------------------------------------------------------------- /driver/driver.go: -------------------------------------------------------------------------------- 1 | // driver is a binary for driving a CI process based on the affected targets. 2 | // Though the general flow of "determine targets" -> "run tests" -> "package binaries" could ideally 3 | // be modelled as independent processes feeding into each other, in practice it can be useful to 4 | // orchestrate these stages using a single high-context driver. 5 | // For instance, the test phase should ideally be just `bazel test [targets]` but: 6 | // 1. `bazel test [only-buildable-non-testable-targets] errors 7 | // 2. `bazel test [no targets]` errors. 8 | // Accordingly, being able to write logic in a programming language can be useful. 9 | 10 | package main 11 | 12 | import ( 13 | "flag" 14 | "fmt" 15 | "log" 16 | "os" 17 | "path/filepath" 18 | "strings" 19 | 20 | "github.com/bazel-contrib/target-determinator/cli" 21 | "github.com/bazel-contrib/target-determinator/pkg" 22 | "github.com/bazel-contrib/target-determinator/third_party/protobuf/bazel/analysis" 23 | gazelle_label "github.com/bazelbuild/bazel-gazelle/label" 24 | ) 25 | 26 | type driverFlags struct { 27 | commonFlags *cli.CommonFlags 28 | targetPatternFile string 29 | revisionBefore string 30 | manualTestMode string 31 | forceUseOfBuildForTests bool 32 | } 33 | 34 | type config struct { 35 | Context *pkg.Context 36 | RevisionBefore pkg.LabelledGitRev 37 | Targets pkg.TargetsList 38 | // One of "run" or "skip". 39 | ManualTestMode string 40 | TargetPatternFile string 41 | forceUseOfBuildForTests bool 42 | } 43 | 44 | func main() { 45 | flags, err := parseFlags() 46 | if err != nil { 47 | fmt.Fprintf(flag.CommandLine.Output(), "Failed to parse flags: %v\n", err) 48 | fmt.Fprintf(flag.CommandLine.Output(), "Usage of %s:\n", os.Args[0]) 49 | fmt.Fprintf(flag.CommandLine.Output(), " %s \n", filepath.Base(os.Args[0])) 50 | fmt.Fprintf(flag.CommandLine.Output(), "Where may be any commit-like strings - full commit hashes, short commit hashes, tags, branches, etc.\n") 51 | fmt.Fprintf(flag.CommandLine.Output(), "Optional flags:\n") 52 | flag.PrintDefaults() 53 | os.Exit(1) 54 | } 55 | 56 | config, err := resolveConfig(*flags) 57 | if err != nil { 58 | log.Fatalf("Error during preprocessing: %v", err) 59 | } 60 | 61 | var targets []gazelle_label.Label 62 | targetsSet := make(map[gazelle_label.Label]struct{}) 63 | commandVerb := "build" 64 | 65 | log.Println("Discovering affected targets") 66 | callback := func(label gazelle_label.Label, differences []pkg.Difference, configuredTarget *analysis.ConfiguredTarget) { 67 | if config.ManualTestMode == "skip" && isTaggedManual(configuredTarget) { 68 | return 69 | } 70 | if _, seen := targetsSet[label]; seen { 71 | return 72 | } 73 | targets = append(targets, label) 74 | targetsSet[label] = struct{}{} 75 | // This is not an ideal heuristic, ideally cquery would expose to us whether a target is a test target. 76 | if strings.HasSuffix(configuredTarget.GetTarget().GetRule().GetRuleClass(), "_test") && !flags.forceUseOfBuildForTests { 77 | commandVerb = "test" 78 | } 79 | 80 | } 81 | 82 | if err := pkg.WalkAffectedTargets(config.Context, 83 | config.RevisionBefore, 84 | config.Targets, 85 | false, 86 | callback); err != nil { 87 | log.Fatal(err) 88 | } 89 | 90 | if len(targets) == 0 { 91 | log.Println("No targets were affected, not running Bazel") 92 | os.Exit(0) 93 | } 94 | 95 | log.Printf("Discovered %d affected targets", len(targets)) 96 | 97 | var targetPatternFile *os.File 98 | if config.TargetPatternFile != "" { 99 | targetPatternFile, err = os.OpenFile(config.TargetPatternFile, os.O_RDWR|os.O_CREATE, 0755) 100 | if err != nil { 101 | log.Fatalf("Failed to open target pattern file: %v", err) 102 | } 103 | } else { 104 | targetPatternFile, err = os.CreateTemp("", "") 105 | if err != nil { 106 | log.Fatalf("Failed to create temporary file for target patterns: %v", err) 107 | } 108 | } 109 | for _, target := range targets { 110 | if _, err := targetPatternFile.WriteString(target.String()); err != nil { 111 | log.Fatalf("Failed to write target pattern to target pattern file: %v", err) 112 | } 113 | if _, err := targetPatternFile.WriteString("\n"); err != nil { 114 | log.Fatalf("Failed to write target pattern to target pattern file: %v", err) 115 | } 116 | } 117 | if err := targetPatternFile.Sync(); err != nil { 118 | log.Fatalf("Failed to sync target pattern file: %v", err) 119 | } 120 | if err := targetPatternFile.Close(); err != nil { 121 | log.Fatalf("Failed to close target pattern file: %v", err) 122 | } 123 | 124 | log.Printf("Running %s on %d targets", commandVerb, len(targets)) 125 | result, err := config.Context.BazelCmd.Execute( 126 | pkg.BazelCmdConfig{Dir: config.Context.WorkspacePath, Stdout: os.Stdout, Stderr: os.Stderr}, 127 | nil, commandVerb, "--target_pattern_file", targetPatternFile.Name()) 128 | 129 | if result != 0 || err != nil { 130 | log.Fatal(err) 131 | } 132 | } 133 | 134 | func isTaggedManual(target *analysis.ConfiguredTarget) bool { 135 | for _, attr := range target.GetTarget().GetRule().GetAttribute() { 136 | if attr.GetName() == "tags" { 137 | for _, tag := range attr.GetStringListValue() { 138 | if tag == "manual" { 139 | return true 140 | } 141 | } 142 | } 143 | } 144 | return false 145 | } 146 | 147 | func parseFlags() (*driverFlags, error) { 148 | var flags driverFlags 149 | flags.commonFlags = cli.RegisterCommonFlags() 150 | flag.StringVar(&flags.manualTestMode, "manual-test-mode", "skip", "How to handle affected tests tagged manual. Possible values: run|skip") 151 | flag.StringVar(&flags.targetPatternFile, "target-pattern-file", "", "If defined, stores the list of affected targets in the given file.") 152 | flag.BoolVar(&flags.forceUseOfBuildForTests, "force-use-of-build-for-tests", false, "Provide as argument to force bazel subcommand to be \"build\" irrespective of target type. By default, \"build\" or \"test\" is selected based on the target's rule") 153 | flag.Parse() 154 | 155 | if flags.manualTestMode != "run" && flags.manualTestMode != "skip" { 156 | return nil, fmt.Errorf("unexpected value for flag -manual-test-mode - allowed values: run|skip, saw: %s", flags.manualTestMode) 157 | } 158 | 159 | var err error 160 | flags.revisionBefore, err = cli.ValidateCommonFlags("driver", flags.commonFlags) 161 | if err != nil { 162 | return nil, err 163 | } 164 | 165 | return &flags, nil 166 | } 167 | 168 | func resolveConfig(flags driverFlags) (*config, error) { 169 | commonArgs, err := cli.ResolveCommonConfig(flags.commonFlags, flags.revisionBefore) 170 | if err != nil { 171 | return nil, err 172 | } 173 | 174 | return &config{ 175 | Context: commonArgs.Context, 176 | RevisionBefore: commonArgs.RevisionBefore, 177 | Targets: commonArgs.Targets, 178 | ManualTestMode: flags.manualTestMode, 179 | TargetPatternFile: flags.targetPatternFile, 180 | forceUseOfBuildForTests: flags.forceUseOfBuildForTests, 181 | }, nil 182 | } 183 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/bazel-contrib/target-determinator 2 | 3 | go 1.24 4 | 5 | require ( 6 | github.com/aristanetworks/goarista v0.0.0-20220211174905-526022c8b178 7 | github.com/bazelbuild/bazel-gazelle v0.43.0 8 | github.com/google/btree v1.1.2 9 | github.com/google/uuid v1.3.0 10 | github.com/hashicorp/go-version v1.6.0 11 | github.com/otiai10/copy v1.7.1-0.20211223015809-9aae5f77261f 12 | github.com/stretchr/testify v1.8.4 13 | github.com/wI2L/jsondiff v0.2.0 14 | golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d 15 | google.golang.org/protobuf v1.33.0 16 | ) 17 | 18 | require ( 19 | github.com/bazelbuild/buildtools v0.0.0-20240918101019-be1c24cc9a44 // indirect 20 | github.com/davecgh/go-spew v1.1.1 // indirect 21 | github.com/pmezard/go-difflib v1.0.0 // indirect 22 | github.com/tidwall/gjson v1.14.0 // indirect 23 | github.com/tidwall/match v1.1.1 // indirect 24 | github.com/tidwall/pretty v1.2.0 // indirect 25 | golang.org/x/mod v0.20.0 // indirect 26 | golang.org/x/sys v0.26.0 // indirect 27 | golang.org/x/tools/go/vcs v0.1.0-deprecated // indirect 28 | gopkg.in/yaml.v3 v3.0.1 // indirect 29 | ) 30 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/aristanetworks/goarista v0.0.0-20220211174905-526022c8b178 h1:U7Y+d65r8YW6PRIu+YaTqQGkmTx7PMI+oDVic5FTP8c= 2 | github.com/aristanetworks/goarista v0.0.0-20220211174905-526022c8b178/go.mod h1:9zxrD1FatJPUgxIsMwWVrALau7/v1sI1OJETI63r670= 3 | github.com/bazelbuild/bazel-gazelle v0.43.0 h1:NQmf8f7+7OcecUdnAgYoPete6RzAutjEuYjNhE9LU68= 4 | github.com/bazelbuild/bazel-gazelle v0.43.0/go.mod h1:SRCc60YGZ27y+BqLzQ+nMh249+FyZz7YtX/V2ng+/z4= 5 | github.com/bazelbuild/buildtools v0.0.0-20240918101019-be1c24cc9a44 h1:FGzENZi+SX9I7h9xvMtRA3rel8hCEfyzSixteBgn7MU= 6 | github.com/bazelbuild/buildtools v0.0.0-20240918101019-be1c24cc9a44/go.mod h1:PLNUetjLa77TCCziPsz0EI8a6CUxgC+1jgmWv0H25tg= 7 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 8 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 9 | github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw= 10 | github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= 11 | github.com/google/btree v1.1.2 h1:xf4v41cLI2Z6FxbKm+8Bu+m8ifhj15JuZ9sa0jZCMUU= 12 | github.com/google/btree v1.1.2/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= 13 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 14 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 15 | github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= 16 | github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 17 | github.com/hashicorp/go-version v1.6.0 h1:feTTfFNnjP967rlCxM/I9g701jU+RN74YKx2mOkIeek= 18 | github.com/hashicorp/go-version v1.6.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= 19 | github.com/otiai10/copy v1.7.1-0.20211223015809-9aae5f77261f h1:P7Ab27T4In6ExIHmjOe88b1BHpuHlr4Vr75hX2QKAXw= 20 | github.com/otiai10/copy v1.7.1-0.20211223015809-9aae5f77261f/go.mod h1:rmRl6QPdJj6EiUqXQ/4Nn2lLXoNQjFCQbbNrxgc/t3U= 21 | github.com/otiai10/curr v0.0.0-20150429015615-9b4961190c95/go.mod h1:9qAhocn7zKJG+0mI8eUu6xqkFDYS2kb2saOteoSB3cE= 22 | github.com/otiai10/curr v1.0.0/go.mod h1:LskTG5wDwr8Rs+nNQ+1LlxRjAtTZZjtJW4rMXl6j4vs= 23 | github.com/otiai10/mint v1.3.0/go.mod h1:F5AjcsTsWUqX+Na9fpHb52P8pcRX2CI6A3ctIT91xUo= 24 | github.com/otiai10/mint v1.3.3 h1:7JgpsBaN0uMkyju4tbYHu0mnM55hNKVYLsXmwr15NQI= 25 | github.com/otiai10/mint v1.3.3/go.mod h1:/yxELlJQ0ufhjUwhshSj+wFjZ78CnZ48/1wtmBH1OTc= 26 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 27 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 28 | github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= 29 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 30 | github.com/tidwall/gjson v1.14.0 h1:6aeJ0bzojgWLa82gDQHcx3S0Lr/O51I9bJ5nv6JFx5w= 31 | github.com/tidwall/gjson v1.14.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= 32 | github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= 33 | github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= 34 | github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs= 35 | github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= 36 | github.com/wI2L/jsondiff v0.2.0 h1:dE00WemBa1uCjrzQUUTE/17I6m5qAaN0EMFOg2Ynr/k= 37 | github.com/wI2L/jsondiff v0.2.0/go.mod h1:axTcwtBkY4TsKuV+RgoMhHyHKKFRI6nnjRLi8LLYQnA= 38 | golang.org/x/mod v0.20.0 h1:utOm6MM3R3dnawAiJgn0y+xvuYRsm1RKM/4giyfDgV0= 39 | golang.org/x/mod v0.20.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 40 | golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= 41 | golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 42 | golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= 43 | golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 44 | golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= 45 | golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= 46 | golang.org/x/tools/go/vcs v0.1.0-deprecated h1:cOIJqWBl99H1dH5LWizPa+0ImeeJq3t3cJjaeOWUAL4= 47 | golang.org/x/tools/go/vcs v0.1.0-deprecated/go.mod h1:zUrvATBAvEI9535oC0yWYsLsHIV4Z7g63sNPVMtuBy8= 48 | google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= 49 | google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= 50 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 51 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 52 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 53 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 54 | -------------------------------------------------------------------------------- /maven_install.json: -------------------------------------------------------------------------------- 1 | { 2 | "__AUTOGENERATED_FILE_DO_NOT_MODIFY_THIS_FILE_MANUALLY": "THERE_IS_NO_DATA_ONLY_ZUUL", 3 | "__INPUT_ARTIFACTS_HASH": -2137025570, 4 | "__RESOLVED_ARTIFACTS_HASH": -1182922402, 5 | "artifacts": { 6 | "com.google.code.findbugs:jsr305": { 7 | "shasums": { 8 | "jar": "766ad2a0783f2687962c8ad74ceecc38a28b9f72a2d085ee438b7813e928d0c7", 9 | "sources": "1c9e85e272d0708c6a591dc74828c71603053b48cc75ae83cce56912a2aa063b" 10 | }, 11 | "version": "3.0.2" 12 | }, 13 | "com.google.errorprone:error_prone_annotations": { 14 | "shasums": { 15 | "jar": "cd5257c08a246cf8628817ae71cb822be192ef91f6881ca4a3fcff4f1de1cff3", 16 | "sources": "e38921f918b8ad8eabd12bc61de426fa96c72de077054e9147d2f9fe7c648923" 17 | }, 18 | "version": "2.7.1" 19 | }, 20 | "com.google.guava:failureaccess": { 21 | "shasums": { 22 | "jar": "a171ee4c734dd2da837e4b16be9df4661afab72a41adaf31eb84dfdaf936ca26", 23 | "sources": "092346eebbb1657b51aa7485a246bf602bb464cc0b0e2e1c7e7201fadce1e98f" 24 | }, 25 | "version": "1.0.1" 26 | }, 27 | "com.google.guava:guava": { 28 | "shasums": { 29 | "jar": "d5be94d65e87bd219fb3193ad1517baa55a3b88fc91d21cf735826ab5af087b9", 30 | "sources": "fc0fb66f315f10b8713fc43354936d3649a8ad63f789d42fd7c3e55ecf72e092" 31 | }, 32 | "version": "31.0.1-jre" 33 | }, 34 | "com.google.guava:listenablefuture": { 35 | "shasums": { 36 | "jar": "b372a037d4230aa57fbeffdef30fd6123f9c0c2db85d0aced00c91b974f33f99", 37 | "sources": null 38 | }, 39 | "version": "9999.0-empty-to-avoid-conflict-with-guava" 40 | }, 41 | "com.google.j2objc:j2objc-annotations": { 42 | "shasums": { 43 | "jar": "21af30c92267bd6122c0e0b4d20cccb6641a37eaf956c6540ec471d584e64a7b", 44 | "sources": "ba4df669fec153fa4cd0ef8d02c6d3ef0702b7ac4cabe080facf3b6e490bb972" 45 | }, 46 | "version": "1.3" 47 | }, 48 | "com.googlecode.javaewah:JavaEWAH": { 49 | "shasums": { 50 | "jar": "3ecf8b2c602314341f5a2ace171ed04fc86f2d4ddf762180656e9b71134ae68f", 51 | "sources": "e4ee176218d910edd5d3d06e08bd5385ba312886bcf4574f12f59dbee37fc5a2" 52 | }, 53 | "version": "1.1.7" 54 | }, 55 | "junit:junit": { 56 | "shasums": { 57 | "jar": "59721f0805e223d84b90677887d9ff567dc534d7c502ca903c0c2b17f05c116a", 58 | "sources": "9f43fea92033ad82bcad2ae44cec5c82abc9d6ee4b095cab921d11ead98bf2ff" 59 | }, 60 | "version": "4.12" 61 | }, 62 | "org.checkerframework:checker-qual": { 63 | "shasums": { 64 | "jar": "ff10785ac2a357ec5de9c293cb982a2cbb605c0309ea4cc1cb9b9bc6dbe7f3cb", 65 | "sources": "fd99a45195ed893803624d1030387056a96601013f5e61ccabd79abb4ddfa876" 66 | }, 67 | "version": "3.12.0" 68 | }, 69 | "org.eclipse.jgit:org.eclipse.jgit": { 70 | "shasums": { 71 | "jar": "b0f012105d67729a67c7fde546b6e89580f7ddc5bd73c6c7bae7084c50e36a37", 72 | "sources": "23b4f2debe38b2e18cb925ada6639eb78cc029243060f8f8c080ba3e0e70ab71" 73 | }, 74 | "version": "5.11.0.202103091610-r" 75 | }, 76 | "org.hamcrest:hamcrest-all": { 77 | "shasums": { 78 | "jar": "4877670629ab96f34f5f90ab283125fcd9acb7e683e66319a68be6eb2cca60de", 79 | "sources": "c53535c3d25b5bf0b00a324a5583c7dd2fed0fa6d1bbc622e2dec460c24faab3" 80 | }, 81 | "version": "1.3" 82 | }, 83 | "org.hamcrest:hamcrest-core": { 84 | "shasums": { 85 | "jar": "66fdef91e9739348df7a096aa384a5685f4e875584cce89386a7a47251c4d8e9", 86 | "sources": "e223d2d8fbafd66057a8848cc94222d63c3cedd652cc48eddc0ab5c39c0f84df" 87 | }, 88 | "version": "1.3" 89 | }, 90 | "org.slf4j:slf4j-api": { 91 | "shasums": { 92 | "jar": "cdba07964d1bb40a0761485c6b1e8c2f8fd9eb1d19c53928ac0d7f9510105c57", 93 | "sources": "9ee459644577590fed7ea94afae781fa3cc9311d4553faee8a3219ffbd7cc386" 94 | }, 95 | "version": "1.7.30" 96 | } 97 | }, 98 | "dependencies": { 99 | "com.google.guava:guava": [ 100 | "com.google.code.findbugs:jsr305", 101 | "com.google.errorprone:error_prone_annotations", 102 | "com.google.guava:failureaccess", 103 | "com.google.guava:listenablefuture", 104 | "com.google.j2objc:j2objc-annotations", 105 | "org.checkerframework:checker-qual" 106 | ], 107 | "junit:junit": [ 108 | "org.hamcrest:hamcrest-core" 109 | ], 110 | "org.eclipse.jgit:org.eclipse.jgit": [ 111 | "com.googlecode.javaewah:JavaEWAH", 112 | "org.slf4j:slf4j-api" 113 | ] 114 | }, 115 | "packages": { 116 | "com.google.code.findbugs:jsr305": [ 117 | "javax.annotation", 118 | "javax.annotation.concurrent", 119 | "javax.annotation.meta" 120 | ], 121 | "com.google.errorprone:error_prone_annotations": [ 122 | "com.google.errorprone.annotations", 123 | "com.google.errorprone.annotations.concurrent" 124 | ], 125 | "com.google.guava:failureaccess": [ 126 | "com.google.common.util.concurrent.internal" 127 | ], 128 | "com.google.guava:guava": [ 129 | "com.google.common.annotations", 130 | "com.google.common.base", 131 | "com.google.common.base.internal", 132 | "com.google.common.cache", 133 | "com.google.common.collect", 134 | "com.google.common.escape", 135 | "com.google.common.eventbus", 136 | "com.google.common.graph", 137 | "com.google.common.hash", 138 | "com.google.common.html", 139 | "com.google.common.io", 140 | "com.google.common.math", 141 | "com.google.common.net", 142 | "com.google.common.primitives", 143 | "com.google.common.reflect", 144 | "com.google.common.util.concurrent", 145 | "com.google.common.xml", 146 | "com.google.thirdparty.publicsuffix" 147 | ], 148 | "com.google.j2objc:j2objc-annotations": [ 149 | "com.google.j2objc.annotations" 150 | ], 151 | "com.googlecode.javaewah:JavaEWAH": [ 152 | "com.googlecode.javaewah", 153 | "com.googlecode.javaewah.datastructure", 154 | "com.googlecode.javaewah.symmetric", 155 | "com.googlecode.javaewah32", 156 | "com.googlecode.javaewah32.symmetric" 157 | ], 158 | "junit:junit": [ 159 | "junit.extensions", 160 | "junit.framework", 161 | "junit.runner", 162 | "junit.textui", 163 | "org.junit", 164 | "org.junit.experimental", 165 | "org.junit.experimental.categories", 166 | "org.junit.experimental.max", 167 | "org.junit.experimental.results", 168 | "org.junit.experimental.runners", 169 | "org.junit.experimental.theories", 170 | "org.junit.experimental.theories.internal", 171 | "org.junit.experimental.theories.suppliers", 172 | "org.junit.internal", 173 | "org.junit.internal.builders", 174 | "org.junit.internal.matchers", 175 | "org.junit.internal.requests", 176 | "org.junit.internal.runners", 177 | "org.junit.internal.runners.model", 178 | "org.junit.internal.runners.rules", 179 | "org.junit.internal.runners.statements", 180 | "org.junit.matchers", 181 | "org.junit.rules", 182 | "org.junit.runner", 183 | "org.junit.runner.manipulation", 184 | "org.junit.runner.notification", 185 | "org.junit.runners", 186 | "org.junit.runners.model", 187 | "org.junit.runners.parameterized", 188 | "org.junit.validator" 189 | ], 190 | "org.checkerframework:checker-qual": [ 191 | "org.checkerframework.checker.builder.qual", 192 | "org.checkerframework.checker.calledmethods.qual", 193 | "org.checkerframework.checker.compilermsgs.qual", 194 | "org.checkerframework.checker.fenum.qual", 195 | "org.checkerframework.checker.formatter.qual", 196 | "org.checkerframework.checker.guieffect.qual", 197 | "org.checkerframework.checker.i18n.qual", 198 | "org.checkerframework.checker.i18nformatter.qual", 199 | "org.checkerframework.checker.index.qual", 200 | "org.checkerframework.checker.initialization.qual", 201 | "org.checkerframework.checker.interning.qual", 202 | "org.checkerframework.checker.lock.qual", 203 | "org.checkerframework.checker.nullness.qual", 204 | "org.checkerframework.checker.optional.qual", 205 | "org.checkerframework.checker.propkey.qual", 206 | "org.checkerframework.checker.regex.qual", 207 | "org.checkerframework.checker.signature.qual", 208 | "org.checkerframework.checker.signedness.qual", 209 | "org.checkerframework.checker.tainting.qual", 210 | "org.checkerframework.checker.units.qual", 211 | "org.checkerframework.common.aliasing.qual", 212 | "org.checkerframework.common.initializedfields.qual", 213 | "org.checkerframework.common.reflection.qual", 214 | "org.checkerframework.common.returnsreceiver.qual", 215 | "org.checkerframework.common.subtyping.qual", 216 | "org.checkerframework.common.util.report.qual", 217 | "org.checkerframework.common.value.qual", 218 | "org.checkerframework.dataflow.qual", 219 | "org.checkerframework.framework.qual" 220 | ], 221 | "org.eclipse.jgit:org.eclipse.jgit": [ 222 | "org.eclipse.jgit.annotations", 223 | "org.eclipse.jgit.api", 224 | "org.eclipse.jgit.api.errors", 225 | "org.eclipse.jgit.attributes", 226 | "org.eclipse.jgit.blame", 227 | "org.eclipse.jgit.diff", 228 | "org.eclipse.jgit.dircache", 229 | "org.eclipse.jgit.errors", 230 | "org.eclipse.jgit.events", 231 | "org.eclipse.jgit.fnmatch", 232 | "org.eclipse.jgit.gitrepo", 233 | "org.eclipse.jgit.gitrepo.internal", 234 | "org.eclipse.jgit.hooks", 235 | "org.eclipse.jgit.ignore", 236 | "org.eclipse.jgit.ignore.internal", 237 | "org.eclipse.jgit.internal", 238 | "org.eclipse.jgit.internal.fsck", 239 | "org.eclipse.jgit.internal.revwalk", 240 | "org.eclipse.jgit.internal.storage.dfs", 241 | "org.eclipse.jgit.internal.storage.file", 242 | "org.eclipse.jgit.internal.storage.io", 243 | "org.eclipse.jgit.internal.storage.pack", 244 | "org.eclipse.jgit.internal.storage.reftable", 245 | "org.eclipse.jgit.internal.submodule", 246 | "org.eclipse.jgit.internal.transport.connectivity", 247 | "org.eclipse.jgit.internal.transport.http", 248 | "org.eclipse.jgit.internal.transport.parser", 249 | "org.eclipse.jgit.internal.transport.ssh", 250 | "org.eclipse.jgit.lib", 251 | "org.eclipse.jgit.lib.internal", 252 | "org.eclipse.jgit.logging", 253 | "org.eclipse.jgit.merge", 254 | "org.eclipse.jgit.nls", 255 | "org.eclipse.jgit.notes", 256 | "org.eclipse.jgit.patch", 257 | "org.eclipse.jgit.revplot", 258 | "org.eclipse.jgit.revwalk", 259 | "org.eclipse.jgit.revwalk.filter", 260 | "org.eclipse.jgit.storage.file", 261 | "org.eclipse.jgit.storage.pack", 262 | "org.eclipse.jgit.submodule", 263 | "org.eclipse.jgit.transport", 264 | "org.eclipse.jgit.transport.http", 265 | "org.eclipse.jgit.transport.resolver", 266 | "org.eclipse.jgit.treewalk", 267 | "org.eclipse.jgit.treewalk.filter", 268 | "org.eclipse.jgit.util", 269 | "org.eclipse.jgit.util.io", 270 | "org.eclipse.jgit.util.sha1", 271 | "org.eclipse.jgit.util.time" 272 | ], 273 | "org.hamcrest:hamcrest-all": [ 274 | "org.hamcrest", 275 | "org.hamcrest.beans", 276 | "org.hamcrest.collection", 277 | "org.hamcrest.core", 278 | "org.hamcrest.generator", 279 | "org.hamcrest.generator.config", 280 | "org.hamcrest.generator.qdox", 281 | "org.hamcrest.generator.qdox.ant", 282 | "org.hamcrest.generator.qdox.directorywalker", 283 | "org.hamcrest.generator.qdox.junit", 284 | "org.hamcrest.generator.qdox.model", 285 | "org.hamcrest.generator.qdox.model.annotation", 286 | "org.hamcrest.generator.qdox.model.util", 287 | "org.hamcrest.generator.qdox.parser", 288 | "org.hamcrest.generator.qdox.parser.impl", 289 | "org.hamcrest.generator.qdox.parser.structs", 290 | "org.hamcrest.generator.qdox.tools", 291 | "org.hamcrest.integration", 292 | "org.hamcrest.internal", 293 | "org.hamcrest.number", 294 | "org.hamcrest.object", 295 | "org.hamcrest.text", 296 | "org.hamcrest.xml" 297 | ], 298 | "org.hamcrest:hamcrest-core": [ 299 | "org.hamcrest", 300 | "org.hamcrest.core", 301 | "org.hamcrest.internal" 302 | ], 303 | "org.slf4j:slf4j-api": [ 304 | "org.slf4j", 305 | "org.slf4j.event", 306 | "org.slf4j.helpers", 307 | "org.slf4j.spi" 308 | ] 309 | }, 310 | "repositories": { 311 | "https://repo1.maven.org/maven2/": [ 312 | "com.google.code.findbugs:jsr305", 313 | "com.google.code.findbugs:jsr305:jar:sources", 314 | "com.google.errorprone:error_prone_annotations", 315 | "com.google.errorprone:error_prone_annotations:jar:sources", 316 | "com.google.guava:failureaccess", 317 | "com.google.guava:failureaccess:jar:sources", 318 | "com.google.guava:guava", 319 | "com.google.guava:guava:jar:sources", 320 | "com.google.guava:listenablefuture", 321 | "com.google.j2objc:j2objc-annotations", 322 | "com.google.j2objc:j2objc-annotations:jar:sources", 323 | "com.googlecode.javaewah:JavaEWAH", 324 | "com.googlecode.javaewah:JavaEWAH:jar:sources", 325 | "junit:junit", 326 | "junit:junit:jar:sources", 327 | "org.checkerframework:checker-qual", 328 | "org.checkerframework:checker-qual:jar:sources", 329 | "org.eclipse.jgit:org.eclipse.jgit", 330 | "org.eclipse.jgit:org.eclipse.jgit:jar:sources", 331 | "org.hamcrest:hamcrest-all", 332 | "org.hamcrest:hamcrest-all:jar:sources", 333 | "org.hamcrest:hamcrest-core", 334 | "org.hamcrest:hamcrest-core:jar:sources", 335 | "org.slf4j:slf4j-api", 336 | "org.slf4j:slf4j-api:jar:sources" 337 | ] 338 | }, 339 | "services": {}, 340 | "skipped": [ 341 | "com.google.guava:listenablefuture:jar:sources" 342 | ], 343 | "version": "2" 344 | } 345 | -------------------------------------------------------------------------------- /pkg/BUILD.bazel: -------------------------------------------------------------------------------- 1 | load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test") 2 | 3 | go_library( 4 | name = "pkg", 5 | srcs = [ 6 | "bazel.go", 7 | "configurations.go", 8 | "hash_cache.go", 9 | "normalizer.go", 10 | "target_determinator.go", 11 | "targets_list.go", 12 | "walker.go", 13 | ], 14 | importpath = "github.com/bazel-contrib/target-determinator/pkg", 15 | visibility = ["//visibility:public"], 16 | deps = [ 17 | "//common", 18 | "//common/sorted_set", 19 | "//common/versions", 20 | "//third_party/protobuf/bazel/analysis", 21 | "//third_party/protobuf/bazel/build", 22 | "@bazel_gazelle//label", 23 | "@com_github_aristanetworks_goarista//path", 24 | "@com_github_hashicorp_go_version//:go-version", 25 | "@com_github_wi2l_jsondiff//:jsondiff", 26 | "@org_golang_google_protobuf//encoding/protojson", 27 | "@org_golang_google_protobuf//proto", 28 | ], 29 | ) 30 | 31 | go_test( 32 | name = "pkg_test", 33 | srcs = [ 34 | "hash_cache_test.go", 35 | "normalizer_test.go", 36 | "target_determinator_test.go", 37 | ], 38 | data = ["//testdata/HelloWorld:all_srcs"], 39 | embed = [":pkg"], 40 | rundir = ".", 41 | deps = [ 42 | "//common", 43 | "//third_party/protobuf/bazel/analysis", 44 | "//third_party/protobuf/bazel/build", 45 | "@bazel_gazelle//label", 46 | "@com_github_otiai10_copy//:copy", 47 | "@org_golang_google_protobuf//proto", 48 | ], 49 | ) 50 | -------------------------------------------------------------------------------- /pkg/bazel.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "io" 5 | "os/exec" 6 | ) 7 | 8 | type BazelCmdConfig struct { 9 | // Dir represents the working directory to use for the command. 10 | // If Dir is the empty string, use the calling process's current directory. 11 | Dir string 12 | 13 | // Stdout and Stderr specify the process's standard output and error. 14 | // A nil value redirects the output to /dev/null. 15 | // The behavior is the same as the exec.Command struct. 16 | Stdout io.Writer 17 | Stderr io.Writer 18 | } 19 | 20 | type BazelCmd interface { 21 | Execute(config BazelCmdConfig, startupArgs []string, command string, args ...string) (int, error) 22 | } 23 | 24 | type DefaultBazelCmd struct { 25 | BazelPath string 26 | BazelStartupOpts []string 27 | BazelOpts []string 28 | } 29 | 30 | // Commands which we should apply BazelOpts to. 31 | // This is an incomplete list, but includes all of the commands we actually use in the target determinator. 32 | var _buildLikeCommands = map[string]struct{}{ 33 | "build": {}, 34 | "config": {}, 35 | "cquery": {}, 36 | "test": {}, 37 | } 38 | 39 | // Execute calls bazel with the provided arguments. 40 | // It returns the exit status code or -1 if it errored before the process could start. 41 | func (c DefaultBazelCmd) Execute(config BazelCmdConfig, startupArgs []string, command string, args ...string) (int, error) { 42 | bazelArgv := make([]string, 0, len(c.BazelStartupOpts)+len(args)) 43 | bazelArgv = append(bazelArgv, c.BazelStartupOpts...) 44 | bazelArgv = append(bazelArgv, startupArgs...) 45 | bazelArgv = append(bazelArgv, command) 46 | if _, ok := _buildLikeCommands[command]; ok { 47 | bazelArgv = append(bazelArgv, c.BazelOpts...) 48 | } 49 | bazelArgv = append(bazelArgv, args...) 50 | cmd := exec.Command(c.BazelPath, bazelArgv...) 51 | cmd.Dir = config.Dir 52 | cmd.Stdout = config.Stdout 53 | cmd.Stderr = config.Stderr 54 | 55 | if err := cmd.Run(); err != nil { 56 | if exitError, ok := err.(*exec.ExitError); ok { 57 | return exitError.ExitCode(), err 58 | } else { 59 | return -1, err 60 | } 61 | } 62 | return 0, nil 63 | } 64 | -------------------------------------------------------------------------------- /pkg/configurations.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | 8 | "github.com/wI2L/jsondiff" 9 | ) 10 | 11 | type Configuration struct { 12 | inner string 13 | } 14 | 15 | func NormalizeConfiguration(c string) Configuration { 16 | if c == "null" { 17 | return Configuration{ 18 | inner: "", 19 | } 20 | } 21 | return Configuration{ 22 | inner: c, 23 | } 24 | } 25 | 26 | func (c *Configuration) String() string { 27 | return c.inner 28 | } 29 | 30 | func (c *Configuration) ForHashing() []byte { 31 | return []byte(c.inner) 32 | } 33 | 34 | func ConfigurationLess(l, r Configuration) bool { 35 | return l.inner < r.inner 36 | } 37 | 38 | func diffConfigurations(l, r singleConfigurationOutput) (string, error) { 39 | patch, err := jsondiff.Compare(l, r) 40 | if err != nil { 41 | return "", fmt.Errorf("failed to diff configurations %v and %v: %w", l.ConfigHash, r.ConfigHash, err) 42 | } 43 | v, err := json.Marshal(patch) 44 | if err != nil { 45 | return "", fmt.Errorf("failed to marshal patch diffing configurations %v and %v: %w", l.ConfigHash, r.ConfigHash, err) 46 | } 47 | return string(v), nil 48 | } 49 | 50 | // singleConfigurationOutput is a JSON-deserializing struct based on the observed output of `bazel config`. 51 | // There are a few extra fields we don't represent, but they don't seem relevant to how we currently interpret the data. 52 | // Feel free to add more in the future! 53 | type singleConfigurationOutput struct { 54 | ConfigHash string 55 | Fragments json.RawMessage 56 | FragmentOptions json.RawMessage 57 | } 58 | 59 | func getConfigurationDetails(context *Context) (map[Configuration]singleConfigurationOutput, error) { 60 | var stdout bytes.Buffer 61 | var stderr bytes.Buffer 62 | 63 | returnVal, err := context.BazelCmd.Execute( 64 | BazelCmdConfig{Dir: context.WorkspacePath, Stdout: &stdout, Stderr: &stderr}, 65 | []string{"--output_base", context.BazelOutputBase}, "config", "--output=json", "--dump_all") 66 | 67 | if returnVal != 0 || err != nil { 68 | return nil, fmt.Errorf("failed to run bazel config --output=json --dump_all: %w. Stderr:\n%v", err, stderr.String()) 69 | } 70 | 71 | content := stdout.Bytes() 72 | 73 | var configurations []singleConfigurationOutput 74 | if err := json.Unmarshal(content, &configurations); err != nil { 75 | return nil, fmt.Errorf("failed to unmarshal config stdout: %w", err) 76 | } 77 | m := make(map[Configuration]singleConfigurationOutput) 78 | for _, c := range configurations { 79 | configuration := NormalizeConfiguration(c.ConfigHash) 80 | if _, ok := m[configuration]; ok { 81 | return nil, fmt.Errorf("saw duplicate configuration for %q", configuration) 82 | } 83 | m[configuration] = c 84 | } 85 | return m, nil 86 | } 87 | -------------------------------------------------------------------------------- /pkg/hash_cache.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "bytes" 5 | "crypto/sha256" 6 | "encoding/binary" 7 | "errors" 8 | "fmt" 9 | "io" 10 | "log" 11 | "os" 12 | "path/filepath" 13 | "sort" 14 | "strings" 15 | "sync" 16 | 17 | ss "github.com/bazel-contrib/target-determinator/common/sorted_set" 18 | "github.com/bazel-contrib/target-determinator/common/versions" 19 | "github.com/bazel-contrib/target-determinator/third_party/protobuf/bazel/analysis" 20 | "github.com/bazel-contrib/target-determinator/third_party/protobuf/bazel/build" 21 | gazelle_label "github.com/bazelbuild/bazel-gazelle/label" 22 | "github.com/hashicorp/go-version" 23 | "google.golang.org/protobuf/encoding/protojson" 24 | "google.golang.org/protobuf/proto" 25 | ) 26 | 27 | // NewTargetHashCache creates a TargetHashCache which uses context for metadata lookups. 28 | func NewTargetHashCache( 29 | context map[gazelle_label.Label]map[Configuration]*analysis.ConfiguredTarget, 30 | normalizer *Normalizer, 31 | bazelRelease string, 32 | ) *TargetHashCache { 33 | bazelVersionSupportsConfiguredRuleInputs := isConfiguredRuleInputsSupported(bazelRelease) 34 | 35 | return &TargetHashCache{ 36 | context: context, 37 | fileHashCache: &fileHashCache{ 38 | cache: make(map[string]*cacheEntry), 39 | }, 40 | normalizer: normalizer, 41 | bazelRelease: bazelRelease, 42 | bazelVersionSupportsConfiguredRuleInputs: bazelVersionSupportsConfiguredRuleInputs, 43 | cache: make(map[gazelle_label.Label]map[Configuration]*cacheEntry), 44 | frozen: false, 45 | } 46 | } 47 | 48 | func isConfiguredRuleInputsSupported(releaseString string) bool { 49 | isSupportedVersion, explanation := versions.ReleaseIsInRange(releaseString, version.Must(version.NewVersion("7.0.0-pre.20230628.2")), nil) 50 | if isSupportedVersion != nil { 51 | return *isSupportedVersion 52 | } 53 | log.Printf("%s - assuming cquery does not support configured rule inputs (which is supported from bazel 7), which may lead to over-estimates of affected targets", explanation) 54 | return false 55 | } 56 | 57 | // TargetHashCache caches hash computations for targets and files, so that transitive hashes can be 58 | // cheaply computed via dynamic programming. 59 | // Note that a TargetHashCache doesn't eagerly read files, it lazily reads them when they're needed 60 | // for hash computation, so if you're going to mutate filesystem state after creating a 61 | // TargetHashCache (e.g. because you're going to check out a different commit), you should 62 | // pre-compute any hashes you're interested in before mutating the filesystem. 63 | // In the future we may pre-cache file hashes to avoid this hazard (and to allow more efficient 64 | // use of threadpools when hashing files). 65 | type TargetHashCache struct { 66 | context map[gazelle_label.Label]map[Configuration]*analysis.ConfiguredTarget 67 | fileHashCache *fileHashCache 68 | bazelRelease string 69 | bazelVersionSupportsConfiguredRuleInputs bool 70 | 71 | normalizer *Normalizer 72 | 73 | frozen bool 74 | 75 | cacheLock sync.Mutex 76 | cache map[gazelle_label.Label]map[Configuration]*cacheEntry 77 | } 78 | 79 | var labelNotFound = fmt.Errorf("label not found in context") 80 | var notComputedBeforeFrozen = fmt.Errorf("TargetHashCache has already been frozen") 81 | 82 | // Hash hashes a given LabelAndConfiguration, returning a sha256 which will change if any of the 83 | // following change: 84 | // - Values of attributes of the label (if it's a rule) 85 | // - Contents or mode of source files which are direct inputs to the rule (if it's a rule). 86 | // - The name of the rule class (e.g. `java_binary`) of the rule (if it's a rule). 87 | // - The rule definition, if it's a rule which was implemented in starlark. 88 | // Note that this is known to over-estimate - it currently factors in the whole contents of any 89 | // .bzl files loaded to define the rule, where some of this contents may not be relevant. 90 | // - The configuration the label is configured in. 91 | // Note that this is known to over-estimate - per-language fragments are not filtered from this 92 | // configuration, which means C++-affecting options are considered to affect Java. 93 | // - The above recursively for all rules and files which are depended on by the given 94 | // LabelAndConfiguration. 95 | // Note that this is known to over-estimate - the configuration of dependencies isn't easily 96 | // surfaced by Bazel, so if a dependency exists in multiple configurations, all of them will be 97 | // mixed into the hash, even if only one of the configurations is actually relevant. 98 | // See https://github.com/bazelbuild/bazel/issues/14610 99 | func (thc *TargetHashCache) Hash(labelAndConfiguration LabelAndConfiguration) ([]byte, error) { 100 | thc.cacheLock.Lock() 101 | _, ok := thc.cache[labelAndConfiguration.Label] 102 | if !ok { 103 | if thc.frozen { 104 | thc.cacheLock.Unlock() 105 | return nil, fmt.Errorf("didn't have cache entry for label %s: %w", labelAndConfiguration.Label, notComputedBeforeFrozen) 106 | } 107 | thc.cache[labelAndConfiguration.Label] = make(map[Configuration]*cacheEntry) 108 | } 109 | entry, ok := thc.cache[labelAndConfiguration.Label][labelAndConfiguration.Configuration] 110 | if !ok { 111 | newEntry := &cacheEntry{} 112 | thc.cache[labelAndConfiguration.Label][labelAndConfiguration.Configuration] = newEntry 113 | entry = newEntry 114 | } 115 | thc.cacheLock.Unlock() 116 | entry.hashLock.Lock() 117 | defer entry.hashLock.Unlock() 118 | if entry.hash == nil { 119 | if thc.frozen { 120 | return nil, fmt.Errorf("didn't have cache value for label %s in configuration %s: %w", labelAndConfiguration.Label, labelAndConfiguration.Configuration, notComputedBeforeFrozen) 121 | } 122 | hash, err := hashTarget(thc, labelAndConfiguration) 123 | if err != nil { 124 | return nil, err 125 | } 126 | entry.hash = hash 127 | } 128 | return entry.hash, nil 129 | } 130 | 131 | // KnownConfigurations returns the configurations in which a Label is known to be configured. 132 | func (thc *TargetHashCache) KnownConfigurations(label gazelle_label.Label) *ss.SortedSet[Configuration] { 133 | configurations := ss.NewSortedSetFn([]Configuration{}, ConfigurationLess) 134 | entry := thc.context[label] 135 | for c := range entry { 136 | configurations.Add(c) 137 | } 138 | return configurations 139 | } 140 | 141 | // Freeze should be called before the filesystem is mutated to signify to the TargetHashCache that 142 | // any future Hash calls which need to read files should fail, because the files may no longer be 143 | // accurate from when the TargetHashCache was created. 144 | func (thc *TargetHashCache) Freeze() { 145 | thc.frozen = true 146 | } 147 | 148 | func (thc *TargetHashCache) ParseCanonicalLabel(label string) (gazelle_label.Label, error) { 149 | return thc.normalizer.ParseCanonicalLabel(label) 150 | } 151 | 152 | // Difference represents a difference of a target between two commits. 153 | // All fields except Category are optional. 154 | type Difference struct { 155 | // Category is the kind of change, e.g. that the target is new, that a file changed, etc. 156 | Category string 157 | // Key is the thing which changed, e.g. the name of an attribute, or the name of the input file. 158 | Key string 159 | // Before is the value of Key before the change. 160 | Before string 161 | // After is the value of Key after the change. 162 | After string 163 | } 164 | 165 | func (d Difference) String() string { 166 | s := d.Category 167 | if d.Key != "" { 168 | s += "[" + d.Key + "]" 169 | } 170 | if d.Before != "" { 171 | s += " Before: " + d.Before 172 | } 173 | if d.After != "" { 174 | s += " After: " + d.After 175 | } 176 | return s 177 | } 178 | 179 | // WalkDiffs accumulates the differences of a LabelAndConfiguration before and after a change. 180 | func WalkDiffs(before *TargetHashCache, after *TargetHashCache, labelAndConfiguration LabelAndConfiguration) ([]Difference, error) { 181 | beforeHash, err := before.Hash(labelAndConfiguration) 182 | if err != nil { 183 | return nil, err 184 | } 185 | afterHash, err := after.Hash(labelAndConfiguration) 186 | if err != nil { 187 | return nil, err 188 | } 189 | if bytes.Equal(beforeHash, afterHash) { 190 | return nil, nil 191 | } 192 | var differences []Difference 193 | 194 | if before.bazelRelease != after.bazelRelease { 195 | differences = append(differences, Difference{ 196 | Category: "BazelVersion", 197 | Before: before.bazelRelease, 198 | After: after.bazelRelease, 199 | }) 200 | } 201 | 202 | cBefore, okBefore := before.context[labelAndConfiguration.Label] 203 | cAfter, okAfter := after.context[labelAndConfiguration.Label] 204 | 205 | if okBefore && !okAfter { 206 | differences = append(differences, Difference{ 207 | Category: "DeletedTarget", 208 | }) 209 | return differences, nil 210 | } else if !okBefore && okAfter { 211 | differences = append(differences, Difference{ 212 | Category: "AddedTarget", 213 | }) 214 | return differences, nil 215 | } else if !okBefore && !okAfter { 216 | return nil, fmt.Errorf("target %v didn't exist before or after", labelAndConfiguration.Label) 217 | } 218 | 219 | ctBefore, okBefore := cBefore[labelAndConfiguration.Configuration] 220 | ctAfter, okAfter := cAfter[labelAndConfiguration.Configuration] 221 | if !okBefore || !okAfter { 222 | differences = append(differences, Difference{ 223 | Category: "ChangedConfiguration", 224 | }) 225 | return differences, nil 226 | } 227 | 228 | targetBefore := ctBefore.GetTarget() 229 | targetAfter := ctAfter.GetTarget() 230 | 231 | // Did this target's type change? 232 | typeBefore := targetBefore.GetType() 233 | typeAfter := targetAfter.GetType() 234 | if typeBefore != typeAfter { 235 | differences = append(differences, Difference{ 236 | Category: "TargetTypeChanged", 237 | Before: typeBefore.String(), 238 | After: typeAfter.String(), 239 | }) 240 | return differences, nil 241 | } 242 | 243 | if typeBefore != build.Target_RULE { 244 | return differences, nil 245 | } 246 | 247 | ruleBefore := targetBefore.GetRule() 248 | ruleAfter := targetAfter.GetRule() 249 | if ruleBefore.GetRuleClass() != ruleAfter.GetRuleClass() { 250 | differences = append(differences, Difference{ 251 | Category: "RuleKindChanged", 252 | Before: ruleBefore.GetRuleClass(), 253 | After: ruleAfter.GetRuleClass(), 254 | }) 255 | } 256 | if ruleBefore.GetSkylarkEnvironmentHashCode() != ruleAfter.GetSkylarkEnvironmentHashCode() { 257 | differences = append(differences, Difference{ 258 | Category: "RuleImplementationChanged", 259 | Before: ruleBefore.GetSkylarkEnvironmentHashCode(), 260 | After: ruleAfter.GetSkylarkEnvironmentHashCode(), 261 | }) 262 | } 263 | 264 | attributesBefore := indexAttributes(ruleBefore.GetAttribute()) 265 | attributesAfter := indexAttributes(ruleAfter.GetAttribute()) 266 | sortedAttributeNamesBefore := sortKeys(attributesBefore) 267 | for _, attributeName := range sortedAttributeNamesBefore { 268 | attributeBefore := attributesBefore[attributeName] 269 | attributeAfter, ok := attributesAfter[attributeName] 270 | if !ok { 271 | attributeBeforeJson, _ := protojson.Marshal(attributeBefore) 272 | differences = append(differences, Difference{ 273 | Category: "AttributeRemoved", 274 | Key: attributeName, 275 | Before: string(attributeBeforeJson), 276 | }) 277 | } else { 278 | normalizedBeforeAttribute := before.AttributeForSerialization(attributeBefore) 279 | normalizedAfterAttribute := after.AttributeForSerialization(attributeAfter) 280 | if !equivalentAttributes(normalizedBeforeAttribute, normalizedAfterAttribute) { 281 | if attributeName == "$rule_implementation_hash" { 282 | differences = append(differences, Difference{ 283 | Category: "RuleImplementedChanged", 284 | }) 285 | } else { 286 | attributeBeforeJson, _ := protojson.Marshal(normalizedBeforeAttribute) 287 | attributeAfterJson, _ := protojson.Marshal(normalizedAfterAttribute) 288 | differences = append(differences, Difference{ 289 | Category: "AttributeChanged", 290 | Key: attributeName, 291 | Before: string(attributeBeforeJson), 292 | After: string(attributeAfterJson), 293 | }) 294 | } 295 | } 296 | } 297 | } 298 | sortedAttributeNamesAfter := sortKeys(attributesAfter) 299 | for _, attributeName := range sortedAttributeNamesAfter { 300 | if _, ok := attributesBefore[attributeName]; !ok { 301 | attributeAfterJson, _ := protojson.Marshal(after.AttributeForSerialization(attributesAfter[attributeName])) 302 | differences = append(differences, Difference{ 303 | Category: "AttributeAdded", 304 | Key: attributeName, 305 | After: string(attributeAfterJson), 306 | }) 307 | } 308 | } 309 | 310 | ruleInputLabelsAndConfigurationsBefore, err := getConfiguredRuleInputs(before, ruleBefore, labelAndConfiguration.Configuration) 311 | if err != nil { 312 | return nil, err 313 | } 314 | ruleInputLabelsToConfigurationsBefore := indexByLabel(ruleInputLabelsAndConfigurationsBefore) 315 | 316 | ruleInputLabelsAndConfigurationsAfter, err := getConfiguredRuleInputs(after, ruleAfter, labelAndConfiguration.Configuration) 317 | if err != nil { 318 | return nil, err 319 | } 320 | ruleInputLabelsToConfigurationsAfter := indexByLabel(ruleInputLabelsAndConfigurationsAfter) 321 | 322 | for _, ruleInputLabelAndConfigurations := range ruleInputLabelsAndConfigurationsAfter { 323 | ruleInputLabel := ruleInputLabelAndConfigurations.Label 324 | knownConfigurationsBefore, ok := ruleInputLabelsToConfigurationsBefore[ruleInputLabel] 325 | if !ok { 326 | differences = append(differences, Difference{ 327 | Category: "RuleInputAdded", 328 | Key: ruleInputLabel.String(), 329 | }) 330 | } else { 331 | // Ideally we would know the configuration of each of these ruleInputs from the 332 | // query information, so we could filter away e.g. host changes when we only have a target dep. 333 | // Unfortunately, Bazel doesn't currently expose this. 334 | // See https://github.com/bazelbuild/bazel/issues/14610#issuecomment-1024460141 335 | knownConfigurationsAfter := ruleInputLabelsToConfigurationsAfter[ruleInputLabel] 336 | 337 | for _, knownConfigurationAfter := range knownConfigurationsAfter.SortedSlice() { 338 | if knownConfigurationsBefore.Contains(knownConfigurationAfter) { 339 | hashBefore, err := before.Hash(LabelAndConfiguration{Label: ruleInputLabel, Configuration: knownConfigurationAfter}) 340 | if err != nil { 341 | return nil, err 342 | } 343 | hashAfter, err := after.Hash(LabelAndConfiguration{Label: ruleInputLabel, Configuration: knownConfigurationAfter}) 344 | if err != nil { 345 | return nil, err 346 | } 347 | if !bytes.Equal(hashBefore, hashAfter) { 348 | differences = append(differences, Difference{ 349 | Category: "RuleInputChanged", 350 | Key: formatLabelWithConfiguration(ruleInputLabel, knownConfigurationAfter), 351 | }) 352 | } 353 | } else { 354 | differences = append(differences, Difference{ 355 | Category: "RuleInputChanged", 356 | Key: ruleInputLabel.String(), 357 | After: fmt.Sprintf("Configuration: %v", knownConfigurationAfter), 358 | }) 359 | } 360 | } 361 | for _, knownConfigurationBefore := range knownConfigurationsBefore.SortedSlice() { 362 | if !knownConfigurationsAfter.Contains(knownConfigurationBefore) { 363 | differences = append(differences, Difference{ 364 | Category: "RuleInputChanged", 365 | Key: ruleInputLabel.String(), 366 | Before: fmt.Sprintf("Configuration: %v", knownConfigurationBefore), 367 | }) 368 | } 369 | } 370 | } 371 | } 372 | for _, ruleInputLabelAndConfigurations := range ruleInputLabelsAndConfigurationsBefore { 373 | ruleInputLabel := ruleInputLabelAndConfigurations.Label 374 | if _, ok := ruleInputLabelsToConfigurationsAfter[ruleInputLabel]; !ok { 375 | differences = append(differences, Difference{ 376 | Category: "RuleInputRemoved", 377 | Key: ruleInputLabel.String(), 378 | }) 379 | } 380 | } 381 | 382 | return differences, nil 383 | } 384 | 385 | // AttributeForSerialization redacts details about an attribute which don't affect the output of 386 | // building them, and returns equivalent canonical attribute metadata. 387 | // In particular it redacts: 388 | // - Whether an attribute was explicitly specified (because the effective value is all that 389 | // matters). 390 | // - Any attribute named `generator_location`, because these point to absolute paths for 391 | // built-in `cc_toolchain_suite` targets such as `@local_config_cc//:toolchain`. 392 | func (thc *TargetHashCache) AttributeForSerialization(rawAttr *build.Attribute) *build.Attribute { 393 | normalized := *rawAttr 394 | normalized.ExplicitlySpecified = nil 395 | 396 | // Redact generator_location, which typically contains absolute paths but has no bearing on the 397 | // functioning of a rule. 398 | // This is also done in Bazel's internal target hash computation. See: 399 | // https://github.com/bazelbuild/bazel/blob/6971b016f1e258e3bb567a0f9fe7a88ad565d8f2/src/main/java/com/google/devtools/build/lib/query2/query/output/SyntheticAttributeHashCalculator.java#L78-L81 400 | if normalized.Name != nil { 401 | if *normalized.Name == "generator_location" { 402 | normalized.StringValue = nil 403 | } 404 | } 405 | 406 | return thc.normalizer.NormalizeAttribute(&normalized) 407 | } 408 | 409 | func equivalentAttributes(left, right *build.Attribute) bool { 410 | return proto.Equal(left, right) 411 | } 412 | 413 | func indexByLabel(labelsAndConfigurations []LabelAndConfigurations) map[gazelle_label.Label]*ss.SortedSet[Configuration] { 414 | m := make(map[gazelle_label.Label]*ss.SortedSet[Configuration], len(labelsAndConfigurations)) 415 | for _, labelAndConfigurations := range labelsAndConfigurations { 416 | m[labelAndConfigurations.Label] = ss.NewSortedSetFn(labelAndConfigurations.Configurations, ConfigurationLess) 417 | } 418 | return m 419 | } 420 | 421 | func formatLabelWithConfiguration(label gazelle_label.Label, configuration Configuration) string { 422 | s := label.String() 423 | if configuration.String() != "" { 424 | s += "[" + configuration.String() + "]" 425 | } 426 | return s 427 | } 428 | 429 | func indexAttributes(attributes []*build.Attribute) map[string]*build.Attribute { 430 | m := make(map[string]*build.Attribute, len(attributes)) 431 | for _, attribute := range attributes { 432 | m[attribute.GetName()] = attribute 433 | } 434 | return m 435 | } 436 | 437 | func sortKeys(attributes map[string]*build.Attribute) []string { 438 | keys := make([]string, 0, len(attributes)) 439 | for attribute := range attributes { 440 | keys = append(keys, attribute) 441 | } 442 | sort.Strings(keys) 443 | return keys 444 | } 445 | 446 | func hashTarget(thc *TargetHashCache, labelAndConfiguration LabelAndConfiguration) ([]byte, error) { 447 | label := labelAndConfiguration.Label 448 | configurationMap, ok := thc.context[label] 449 | if !ok { 450 | return nil, fmt.Errorf("label %s not found in contxt: %w", label, labelNotFound) 451 | } 452 | configuration := labelAndConfiguration.Configuration 453 | configuredTarget, ok := configurationMap[configuration] 454 | if !ok { 455 | return nil, fmt.Errorf("label %s configuration %s not found in contxt: %w", label, configuration, labelNotFound) 456 | } 457 | target := configuredTarget.Target 458 | switch target.GetType() { 459 | case build.Target_SOURCE_FILE: 460 | absolutePath := AbsolutePath(target) 461 | hash, err := thc.fileHashCache.Hash(absolutePath) 462 | if err != nil { 463 | // Labels may be referred to without existing, and at loading time these are assumed 464 | // to be input files, even if no such file exists. 465 | // https://github.com/bazelbuild/bazel/issues/14611 466 | if os.IsNotExist(err) { 467 | return make([]byte, 0), nil 468 | } 469 | 470 | // Directories (spuriously) listed in srcs show up a SOURCE_FILEs. 471 | // We don't error on this, as Bazel doesn't, but we also don't manually walk the 472 | // directory (as globs should have been used in the BUILD file if this was the intent). 473 | // When this gets mixed into other hashes, that mixing in includes the target name, so 474 | // this sentinel "empty hash" vaguely indicates that a directory occurred. 475 | // We may want to do something more structured here at some point. 476 | // See https://github.com/bazelbuild/bazel/issues/14678 477 | if strings.Contains(err.Error(), "is a directory") { 478 | return make([]byte, 0), nil 479 | } 480 | return nil, fmt.Errorf("failed to hash file %v: %w", absolutePath, err) 481 | } 482 | return hash, nil 483 | case build.Target_RULE: 484 | return hashRule(thc, target.Rule, configuredTarget.Configuration) 485 | case build.Target_GENERATED_FILE: 486 | hasher := sha256.New() 487 | generatingLabel, err := thc.ParseCanonicalLabel(*target.GeneratedFile.GeneratingRule) 488 | if err != nil { 489 | return nil, fmt.Errorf("failed to parse generated file generating rule label %s: %w", *target.GeneratedFile.GeneratingRule, err) 490 | } 491 | writeLabel(hasher, generatingLabel) 492 | hash, err := thc.Hash(LabelAndConfiguration{Label: generatingLabel, Configuration: configuration}) 493 | if err != nil { 494 | return nil, err 495 | } 496 | hasher.Write(hash) 497 | return hasher.Sum(nil), nil 498 | case build.Target_PACKAGE_GROUP: 499 | // Bits of the default local toolchain depend on package groups. We just ignore them. 500 | return make([]byte, 0), nil 501 | default: 502 | return nil, fmt.Errorf("didn't know how to hash target %v with unknown rule type: %v", label, target.GetType()) 503 | } 504 | } 505 | 506 | // If this function changes, so should WalkDiffs. 507 | func hashRule(thc *TargetHashCache, rule *build.Rule, configuration *analysis.Configuration) ([]byte, error) { 508 | hasher := sha256.New() 509 | // Mix in the Bazel version, because Bazel versions changes may cause differences to how rules 510 | // are evaluated even if the rules themselves haven't changed. 511 | hasher.Write([]byte(thc.bazelRelease)) 512 | // Hash own attributes 513 | hasher.Write([]byte(rule.GetRuleClass())) 514 | hasher.Write([]byte(rule.GetSkylarkEnvironmentHashCode())) 515 | hasher.Write([]byte(configuration.GetChecksum())) 516 | 517 | // TODO: Consider using `$internal_attr_hash` from https://github.com/bazelbuild/bazel/blob/6971b016f1e258e3bb567a0f9fe7a88ad565d8f2/src/main/java/com/google/devtools/build/lib/query2/query/output/SyntheticAttributeHashCalculator.java 518 | // rather than hashing attributes ourselves. 519 | // On the plus side, this builds in some heuristics from Bazel (e.g. ignoring `generator_location`). 520 | // On the down side, it would even further decouple our "hashing" and "diffing" procedures. 521 | for _, attr := range rule.GetAttribute() { 522 | normalizedAttribute := thc.AttributeForSerialization(attr) 523 | 524 | protoBytes, err := proto.Marshal(normalizedAttribute) 525 | if err != nil { 526 | return nil, err 527 | } 528 | 529 | hasher.Write(protoBytes) 530 | } 531 | 532 | ownConfiguration := NormalizeConfiguration(configuration.GetChecksum()) 533 | 534 | // Hash rule inputs 535 | labelsAndConfigurations, err := getConfiguredRuleInputs(thc, rule, ownConfiguration) 536 | if err != nil { 537 | return nil, err 538 | } 539 | for _, ruleInputLabelAndConfigurations := range labelsAndConfigurations { 540 | for _, ruleInputConfiguration := range ruleInputLabelAndConfigurations.Configurations { 541 | ruleInputLabel := ruleInputLabelAndConfigurations.Label 542 | ruleInputHash, err := thc.Hash(LabelAndConfiguration{Label: ruleInputLabel, Configuration: ruleInputConfiguration}) 543 | if err != nil { 544 | return nil, fmt.Errorf("failed to hash configuredRuleInput %s %s which is a dependency of %s %s: %w", ruleInputLabel, ruleInputConfiguration, rule.GetName(), configuration.GetChecksum(), err) 545 | } 546 | 547 | writeLabel(hasher, ruleInputLabel) 548 | hasher.Write(ruleInputConfiguration.ForHashing()) 549 | hasher.Write(ruleInputHash) 550 | } 551 | } 552 | 553 | return hasher.Sum(nil), nil 554 | } 555 | 556 | func getConfiguredRuleInputs(thc *TargetHashCache, rule *build.Rule, ownConfiguration Configuration) ([]LabelAndConfigurations, error) { 557 | labelsAndConfigurations := make([]LabelAndConfigurations, 0) 558 | if thc.bazelVersionSupportsConfiguredRuleInputs { 559 | for _, configuredRuleInput := range rule.ConfiguredRuleInput { 560 | ruleInputLabel, err := thc.ParseCanonicalLabel(configuredRuleInput.GetLabel()) 561 | if err != nil { 562 | return nil, fmt.Errorf("failed to parse configuredRuleInput label %s: %w", configuredRuleInput.GetLabel(), err) 563 | } 564 | ruleInputConfiguration := NormalizeConfiguration(configuredRuleInput.GetConfigurationChecksum()) 565 | if ruleInputConfiguration.String() == "" { 566 | // Configured Rule Inputs which aren't transitioned end up with an empty string as their configuration. 567 | // This _either_ means there was no transition, _or_ means that the input was a source file (so didn't have a configuration at all). 568 | // Fortunately, these are mutually exclusive - a target either has a configuration or doesn't, so we look up which one exists. 569 | if _, ok := thc.context[ruleInputLabel][ownConfiguration]; ok { 570 | ruleInputConfiguration = ownConfiguration 571 | } else if _, ok := thc.context[ruleInputLabel][ruleInputConfiguration]; !ok { 572 | return nil, fmt.Errorf("configuredRuleInputs for %s included %s in configuration %s but it couldn't be found either unconfigured or in the depending target's configuration %s. This probably indicates a bug in Bazel - please report it with a git repo that reproduces at https://github.com/bazel-contrib/target-determinator/issues so we can investigate", rule.GetName(), ruleInputLabel, ruleInputConfiguration, ownConfiguration) 573 | } 574 | } 575 | labelsAndConfigurations = append(labelsAndConfigurations, LabelAndConfigurations{ 576 | Label: ruleInputLabel, 577 | Configurations: []Configuration{ruleInputConfiguration}, 578 | }) 579 | } 580 | } else { 581 | for _, ruleInputLabelString := range rule.RuleInput { 582 | ruleInputLabel, err := thc.ParseCanonicalLabel(ruleInputLabelString) 583 | if err != nil { 584 | return nil, fmt.Errorf("failed to parse ruleInput label %s: %w", ruleInputLabelString, err) 585 | } 586 | labelAndConfigurations := LabelAndConfigurations{ 587 | Label: ruleInputLabel, 588 | } 589 | var depConfigurations []Configuration 590 | // Aliases don't transition, and we've seen aliases expanding across configurations cause dependency cycles for nogo targets. 591 | if rule.GetRuleClass() == "alias" { 592 | knownDepConfigurations := thc.context[ruleInputLabel] 593 | isSourceFile := true 594 | for _, ct := range knownDepConfigurations { 595 | if ct.GetTarget().GetType() != build.Target_SOURCE_FILE { 596 | isSourceFile = false 597 | } 598 | } 599 | 600 | if isSourceFile { 601 | // If it's a source file, it doesn't exist in the current configuration, but does exist in the empty configuration. 602 | // Accordingly, we need to explicitly transition to the empty configuration. 603 | depConfigurations = []Configuration{NormalizeConfiguration("")} 604 | } else { 605 | // If it's not a source file, narrow just to the current configuration - we know there was no transition, so we must be in the same configuration. 606 | depConfigurations = []Configuration{ownConfiguration} 607 | } 608 | } else { 609 | depConfigurations = thc.KnownConfigurations(ruleInputLabel).SortedSlice() 610 | } 611 | for _, configuration := range depConfigurations { 612 | if _, err := thc.Hash(LabelAndConfiguration{Label: ruleInputLabel, Configuration: configuration}); err != nil { 613 | if errors.Is(err, labelNotFound) { 614 | // Two issues (so far) have been found which lead to targets being listed in 615 | // ruleInputs but not in the output of a deps query: 616 | // 617 | // cquery doesn't filter ruleInputs according to used configurations, which means 618 | // targets may appear in a Target's ruleInputs even though they weren't returned by 619 | // a transitive `deps` cquery. 620 | // Assume that a missing target should have been pruned, and that we should ignore it. 621 | // See https://github.com/bazelbuild/bazel/issues/14610 622 | // 623 | // Some targets are also just sometimes missing for reasons we don't yet know. 624 | // See https://github.com/bazelbuild/bazel/issues/14617 625 | continue 626 | } 627 | return nil, err 628 | } 629 | labelAndConfigurations.Configurations = append(labelAndConfigurations.Configurations, configuration) 630 | } 631 | labelsAndConfigurations = append(labelsAndConfigurations, labelAndConfigurations) 632 | } 633 | } 634 | return labelsAndConfigurations, nil 635 | } 636 | 637 | type fileHashCache struct { 638 | cacheLock sync.Mutex 639 | cache map[string]*cacheEntry 640 | } 641 | 642 | type cacheEntry struct { 643 | hashLock sync.Mutex 644 | hash []byte 645 | } 646 | 647 | // Hash computes the digest of the contents of a file at the given path, and caches the result. 648 | func (hc *fileHashCache) Hash(path string) ([]byte, error) { 649 | hc.cacheLock.Lock() 650 | entry, ok := hc.cache[path] 651 | if !ok { 652 | newEntry := &cacheEntry{} 653 | hc.cache[path] = newEntry 654 | entry = newEntry 655 | } 656 | hc.cacheLock.Unlock() 657 | entry.hashLock.Lock() 658 | defer entry.hashLock.Unlock() 659 | if entry.hash == nil { 660 | file, err := os.Open(path) 661 | if err != nil { 662 | return nil, err 663 | } 664 | defer file.Close() 665 | hasher := sha256.New() 666 | 667 | // Hash the file mode. 668 | // This is used to detect change such as file exec bit changing. 669 | info, err := file.Stat() 670 | if err != nil { 671 | return nil, err 672 | } 673 | 674 | // Only record the user permissions, and only the execute bit: 675 | // - group and others permissions differences don't affect the build and are not tracked by git. This means that 676 | // a file created as 0775 by a script and then added to git might show up as 0755 when performing a 677 | // `git clone` or a `git checkout`. This can cause issues when TD uses a git worktree for the `before` case. 678 | // - bazel and git don't care if a file is writeable, and the hashing below will fail if the file isn't readable 679 | // anyway. 680 | userExecPerm := getUserExecuteBit(info.Mode()) 681 | if _, err := fmt.Fprintf(hasher, userExecPerm.String()); err != nil { 682 | return nil, err 683 | } 684 | 685 | // Hash the content of the file 686 | if _, err := io.Copy(hasher, file); err != nil { 687 | return nil, err 688 | } 689 | entry.hash = hasher.Sum(nil) 690 | } 691 | return entry.hash, nil 692 | } 693 | 694 | func getUserExecuteBit(info os.FileMode) os.FileMode { 695 | var userPermMask os.FileMode = 0100 696 | return info & userPermMask 697 | } 698 | 699 | // Swallows errors, because assumes you're writing to an infallible Writer like a hasher. 700 | func writeLabel(w io.Writer, label gazelle_label.Label) { 701 | labelStr := label.String() 702 | binary.Write(w, binary.LittleEndian, len(labelStr)) 703 | w.Write([]byte(labelStr)) 704 | } 705 | 706 | // AbsolutePath returns the absolute path to the source file Target. 707 | // It assumes the passed Target is of type Source File. 708 | func AbsolutePath(target *build.Target) string { 709 | colonIndex := strings.IndexByte(target.GetSourceFile().GetLocation(), ':') 710 | location := target.GetSourceFile().GetLocation() 711 | // Before Bazel 5, BUILD.bazel files would not have line/column data in their location fields. 712 | if colonIndex >= 0 { 713 | location = location[:colonIndex] 714 | } 715 | locationBase := filepath.Base(location) 716 | 717 | // Bazel before 5.0.0 (or with incompatible_display_source_file_location disabled) reported 718 | // source files as having a location relative to their BUILD file. 719 | // After, location simply refers to the actual location of the file. 720 | // Sniff for the former case, and perform the processing required to handle it. 721 | if locationBase == "BUILD" || locationBase == "BUILD.bazel" { 722 | location = filepath.Dir(location) 723 | name := target.GetSourceFile().GetName()[strings.LastIndexByte(target.GetSourceFile().GetName(), ':')+1:] 724 | return filepath.Join(location, name) 725 | } 726 | return location 727 | } 728 | -------------------------------------------------------------------------------- /pkg/hash_cache_test.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "encoding/hex" 5 | "fmt" 6 | "io/ioutil" 7 | "os" 8 | "path/filepath" 9 | "reflect" 10 | "testing" 11 | 12 | "github.com/bazel-contrib/target-determinator/third_party/protobuf/bazel/analysis" 13 | "github.com/bazel-contrib/target-determinator/third_party/protobuf/bazel/build" 14 | "github.com/bazelbuild/bazel-gazelle/label" 15 | "github.com/otiai10/copy" 16 | "google.golang.org/protobuf/proto" 17 | ) 18 | 19 | const configurationChecksum = "eed618a573b916b7c6c94b04a4aef1da8c0ebce4c6312065c8b0360fedd8deb9" 20 | 21 | func TestAbsolutifiesSourceFileInBuildDirBazel4(t *testing.T) { 22 | target := build.Target{ 23 | Type: build.Target_SOURCE_FILE.Enum(), 24 | SourceFile: &build.SourceFile{ 25 | Name: proto.String("//java/example/simple:Dep.java"), 26 | Location: proto.String("/some/path/to/java/example/simple/BUILD.bazel:11:20"), 27 | VisibilityLabel: []string{"//visibility:private"}, 28 | }, 29 | } 30 | const want = "/some/path/to/java/example/simple/Dep.java" 31 | got := AbsolutePath(&target) 32 | if want != got { 33 | t.Fatalf("Wrong absolute path: want %v got %v", want, got) 34 | } 35 | } 36 | 37 | func TestAbsolutifiesSourceFileInNestedDirBazel4(t *testing.T) { 38 | target := build.Target{ 39 | Type: build.Target_SOURCE_FILE.Enum(), 40 | SourceFile: &build.SourceFile{ 41 | Name: proto.String("//java/example/simple:just/a/File.java"), 42 | Location: proto.String("/some/path/to/java/example/simple/BUILD.bazel:11:20"), 43 | VisibilityLabel: []string{"//visibility:private"}, 44 | }, 45 | } 46 | const want = "/some/path/to/java/example/simple/just/a/File.java" 47 | got := AbsolutePath(&target) 48 | if want != got { 49 | t.Fatalf("Wrong absolute path: want %v got %v", want, got) 50 | } 51 | } 52 | 53 | func TestAbsolutifiesSourceFileInBuildDirBazel5(t *testing.T) { 54 | target := build.Target{ 55 | Type: build.Target_SOURCE_FILE.Enum(), 56 | SourceFile: &build.SourceFile{ 57 | Name: proto.String("//java/example/simple:Dep.java"), 58 | Location: proto.String("/some/path/to/java/example/simple/Dep.java:1:1"), 59 | VisibilityLabel: []string{"//visibility:private"}, 60 | }, 61 | } 62 | const want = "/some/path/to/java/example/simple/Dep.java" 63 | got := AbsolutePath(&target) 64 | if want != got { 65 | t.Fatalf("Wrong absolute path: want %v got %v", want, got) 66 | } 67 | } 68 | 69 | func TestAbsolutifiesSourceFileInNestedDirBazel5(t *testing.T) { 70 | target := build.Target{ 71 | Type: build.Target_SOURCE_FILE.Enum(), 72 | SourceFile: &build.SourceFile{ 73 | Name: proto.String("//java/example/simple:just/a/File.java"), 74 | Location: proto.String("/some/path/to/java/example/simple/just/a/File.java:1:1"), 75 | VisibilityLabel: []string{"//visibility:private"}, 76 | }, 77 | } 78 | const want = "/some/path/to/java/example/simple/just/a/File.java" 79 | got := AbsolutePath(&target) 80 | if want != got { 81 | t.Fatalf("Wrong absolute path: want %v got %v", want, got) 82 | } 83 | } 84 | 85 | // Before Bazel 5, BUILD.bazel files didn't have line and column information in their Locations. 86 | // Test that we handle this ok. 87 | func TestAbsolutifiesBuildFile(t *testing.T) { 88 | target := build.Target{ 89 | Type: build.Target_SOURCE_FILE.Enum(), 90 | SourceFile: &build.SourceFile{ 91 | Name: proto.String("//java/example/simple:BUILD.bazel"), 92 | Location: proto.String("/some/path/to/BUILD.bazel"), 93 | VisibilityLabel: []string{"//visibility:private"}, 94 | }, 95 | } 96 | const want = "/some/path/to/BUILD.bazel" 97 | got := AbsolutePath(&target) 98 | if want != got { 99 | t.Fatalf("Wrong absolute path: want %v got %v", want, got) 100 | } 101 | } 102 | 103 | func TestDigestsSingleSourceFile(t *testing.T) { 104 | _, cqueryResult := layoutProject(t) 105 | thc := parseResult(t, cqueryResult, "release 5.1.1") 106 | 107 | hash, err := thc.Hash(LabelAndConfiguration{ 108 | Label: mustParseLabel("//HelloWorld:HelloWorld.java"), 109 | }) 110 | if err != nil { 111 | t.Fatalf("Error hashing file: %v", err) 112 | } 113 | const want = "321ef2ce71642ec8f05102359c10fb2cef9feb5719065452ffcd18c76077e3c1" 114 | got := hex.EncodeToString(hash) 115 | if want != got { 116 | t.Fatalf("Wrong hash: want %v got %v", want, got) 117 | } 118 | } 119 | 120 | // Labels may be referred to without existing, and at loading time these are assumed 121 | // to be input files, even if no such file exists. 122 | // https://github.com/bazelbuild/bazel/issues/14611 123 | func TestDigestingMissingSourceFileIsNotError(t *testing.T) { 124 | _, cqueryResult := layoutProject(t) 125 | thc := parseResult(t, cqueryResult, "release 5.1.1") 126 | 127 | _, err := thc.Hash(LabelAndConfiguration{ 128 | Label: mustParseLabel("//HelloWorld:ThereIsNoWorld.java"), 129 | }) 130 | if err != nil { 131 | t.Fatalf("Error hashing file: %v", err) 132 | } 133 | } 134 | 135 | // Directories (spuriously) listed in srcs show up a SOURCE_FILEs. 136 | // We don't error on this, as Bazel doesn't, but we also don't manually walk the 137 | // directory (as globs should have been used in the BUILD file if this was the intent). 138 | // When this gets mixed into other hashes, that mixing in includes the target name, so 139 | // this sentinel "empty hash" vaguely indicates that a directory occurred. 140 | // We may want to do something more structured here at some point. 141 | // See https://github.com/bazelbuild/bazel/issues/14678 142 | func TestDigestingDirectoryIsNotError(t *testing.T) { 143 | _, cqueryResult := layoutProject(t) 144 | thc := parseResult(t, cqueryResult, "release 5.1.1") 145 | 146 | _, err := thc.Hash(LabelAndConfiguration{ 147 | Label: mustParseLabel("//HelloWorld:InhabitedPlanets"), 148 | }) 149 | if err != nil { 150 | t.Fatalf("Error hashing directory: %v", err) 151 | } 152 | } 153 | 154 | func TestDigestTree(t *testing.T) { 155 | // HelloWorld -> GreetingLib -> Greeting.java 156 | // | 157 | // v 158 | // HelloWorld.java 159 | 160 | labelAndConfiguration := LabelAndConfiguration{ 161 | Label: mustParseLabel("//HelloWorld:HelloWorld"), 162 | Configuration: NormalizeConfiguration(configurationChecksum), 163 | } 164 | 165 | const defaultBazelVersion = "release 5.1.1" 166 | 167 | _, cqueryResult := layoutProject(t) 168 | thc := parseResult(t, cqueryResult, defaultBazelVersion) 169 | 170 | originalHash, err := thc.Hash(labelAndConfiguration) 171 | if err != nil { 172 | t.Fatalf("Failed to get original hash: %v", err) 173 | } 174 | 175 | testCases := map[string]func(*testing.T){ 176 | "different directory": func(t *testing.T) { 177 | _, cqueryResult = layoutProject(t) 178 | thc = parseResult(t, cqueryResult, defaultBazelVersion) 179 | differentDirHash, err := thc.Hash(labelAndConfiguration) 180 | if err != nil { 181 | t.Fatalf("Failed to get different dir hash: %v", err) 182 | } 183 | if !areHashesEqual(originalHash, differentDirHash) { 184 | t.Fatalf("Wanted original hash and different dir hash to be the same but were different: %v and %v", hex.EncodeToString(originalHash), hex.EncodeToString(differentDirHash)) 185 | } 186 | }, 187 | "different bazel version": func(t *testing.T) { 188 | _, cqueryResult = layoutProject(t) 189 | thc = parseResult(t, cqueryResult, "release 5.1.0") 190 | differentBazelVersionHash, err := thc.Hash(labelAndConfiguration) 191 | if err != nil { 192 | t.Fatalf("Failed to get different bazel version hash: %v", err) 193 | } 194 | if areHashesEqual(originalHash, differentBazelVersionHash) { 195 | t.Fatalf("Wanted original hash and different bazel version hash to be different but were same: %v", hex.EncodeToString(originalHash)) 196 | } 197 | }, 198 | "change direct file content": func(t *testing.T) { 199 | projectDir, cqueryResult := layoutProject(t) 200 | thc = parseResult(t, cqueryResult, defaultBazelVersion) 201 | if err := ioutil.WriteFile(filepath.Join(projectDir, "HelloWorld.java"), []byte("Not valid java!"), 0644); err != nil { 202 | t.Fatalf("Failed to write changed HelloWorld.java: %v", err) 203 | } 204 | 205 | changedDirectFileHash, err := thc.Hash(labelAndConfiguration) 206 | if err != nil { 207 | t.Fatalf("Failed to get changed direct file hash: %v", err) 208 | } 209 | 210 | if areHashesEqual(originalHash, changedDirectFileHash) { 211 | t.Fatalf("Wanted original hash and changed direct file hash to be different but were same: %v", hex.EncodeToString(originalHash)) 212 | } 213 | }, 214 | "change transitive file content": func(t *testing.T) { 215 | projectDir, cqueryResult := layoutProject(t) 216 | thc = parseResult(t, cqueryResult, defaultBazelVersion) 217 | if err := ioutil.WriteFile(filepath.Join(projectDir, "Greeting.java"), []byte("Also not valid java!"), 0644); err != nil { 218 | t.Fatalf("Failed to write changed Greeting.java: %v", err) 219 | } 220 | 221 | gotHash, err := thc.Hash(labelAndConfiguration) 222 | if err != nil { 223 | t.Fatalf("Failed to get changed transitive file hash: %v", err) 224 | } 225 | 226 | if areHashesEqual(originalHash, gotHash) { 227 | t.Fatalf("Wanted original hash and changed transitive file hash to be different but were same: %v", hex.EncodeToString(originalHash)) 228 | } 229 | }, 230 | "remove dep on GreetingLib": func(t *testing.T) { 231 | // Remove dep on GreetingLib 232 | projectDir, cqueryResult := layoutProject(t) 233 | cqueryResult.Results[0].GetTarget().GetRule().RuleInput = []string{"//HelloWorld:HelloWorld.java"} 234 | thc = parseResult(t, cqueryResult, defaultBazelVersion) 235 | 236 | removedDepFileHash, err := thc.Hash(labelAndConfiguration) 237 | if err != nil { 238 | t.Fatalf("Failed to get removed dep file hash: %v", err) 239 | } 240 | 241 | // Still no dep on GreetingLib 242 | if err := ioutil.WriteFile(filepath.Join(projectDir, "Greeting.java"), []byte("Also not valid java!"), 0o644); err != nil { 243 | t.Fatalf("Failed to write changed Greeting.java: %v", err) 244 | } 245 | thc = parseResult(t, cqueryResult, defaultBazelVersion) 246 | 247 | changedTransitiveFileFromRemovedDepHash, err := thc.Hash(labelAndConfiguration) 248 | if err != nil { 249 | t.Fatalf("Failed to get changed transitive file hash: %v", err) 250 | } 251 | 252 | if !areHashesEqual(removedDepFileHash, changedTransitiveFileFromRemovedDepHash) { 253 | t.Fatalf("Wanted removed dep hash and changed transitive file from removed dep hash to be the same (because file is no longer depended on), but were different. Removed dep hash: %v, Changed transitive file hash: %v", hex.EncodeToString(removedDepFileHash), hex.EncodeToString(changedTransitiveFileFromRemovedDepHash)) 254 | } 255 | }, 256 | "change file mode": func(t *testing.T) { 257 | projectDir, cqueryResult := layoutProject(t) 258 | thc = parseResult(t, cqueryResult, defaultBazelVersion) 259 | 260 | t.Logf("changing the mode of the HelloWorld.java file") 261 | 262 | // Change the file mode but not the content. 263 | // On disk, this file should be 0644 from testdata/HelloWorld/HelloWorld.java 264 | if err := os.Chmod(filepath.Join(projectDir, "HelloWorld.java"), 0755); err != nil { 265 | t.Fatalf(err.Error()) 266 | } 267 | 268 | gotHash, err := thc.Hash(labelAndConfiguration) 269 | if err != nil { 270 | t.Fatalf("Failed to get changed direct file hash: %v", err) 271 | } 272 | 273 | if areHashesEqual(originalHash, gotHash) { 274 | t.Fatalf("Wanted original hash and changed direct file hash to be different but were same: %v", hex.EncodeToString(originalHash)) 275 | } 276 | }, 277 | } 278 | 279 | for name, tc := range testCases { 280 | t.Run(name, tc) 281 | } 282 | } 283 | 284 | // layoutProject setup a canned project layout in a temp directory it creates. 285 | func layoutProject(t *testing.T) (string, *analysis.CqueryResult) { 286 | dir, err := ioutil.TempDir("", "") 287 | if err != nil { 288 | t.Fatalf("Failed to create temporary directory to layout project: %v", err) 289 | } 290 | 291 | pwd, err := os.Getwd() 292 | if err != nil { 293 | t.Fatalf("Error getting working directory to layout project: %v", err) 294 | } 295 | 296 | if err := copy.Copy(filepath.Join(pwd, "testdata/HelloWorld"), dir, copy.Options{ 297 | OnSymlink: func(name string) copy.SymlinkAction { 298 | return copy.Deep 299 | }, 300 | PermissionControl: copy.DoNothing, 301 | }); err != nil { 302 | t.Fatalf("Error copying project to temporary directory: %v", err) 303 | } 304 | 305 | configuration := &analysis.Configuration{ 306 | Checksum: configurationChecksum, 307 | } 308 | 309 | cqueryResult := analysis.CqueryResult{ 310 | Results: []*analysis.ConfiguredTarget{ 311 | { 312 | Target: &build.Target{ 313 | Type: build.Target_RULE.Enum(), 314 | Rule: &build.Rule{ 315 | Name: proto.String("//HelloWorld:HelloWorld"), 316 | RuleClass: proto.String("java_binary"), 317 | Location: proto.String(fmt.Sprintf("%s/BUILD.bazel:1:12", dir)), 318 | RuleInput: []string{ 319 | "//HelloWorld:GreetingLib", 320 | "//HelloWorld:HelloWorld.java", 321 | }, 322 | }, 323 | }, 324 | Configuration: configuration, 325 | }, 326 | { 327 | Target: &build.Target{ 328 | Type: build.Target_RULE.Enum(), 329 | Rule: &build.Rule{ 330 | Name: proto.String("//HelloWorld:GreetingLib"), 331 | RuleClass: proto.String("java_library"), 332 | Location: proto.String(fmt.Sprintf("%s/BUILD.bazel:8:13", dir)), 333 | RuleInput: []string{ 334 | "//HelloWorld:Greeting.java", 335 | }, 336 | }, 337 | }, 338 | Configuration: configuration, 339 | }, 340 | { 341 | Target: &build.Target{ 342 | Type: build.Target_SOURCE_FILE.Enum(), 343 | SourceFile: &build.SourceFile{ 344 | Name: proto.String("//HelloWorld:HelloWorld.java"), 345 | Location: proto.String(fmt.Sprintf("%s/BUILD.bazel:1:12", dir)), 346 | }, 347 | }, 348 | }, 349 | { 350 | Target: &build.Target{ 351 | Type: build.Target_SOURCE_FILE.Enum(), 352 | SourceFile: &build.SourceFile{ 353 | Name: proto.String("//HelloWorld:Greeting.java"), 354 | Location: proto.String(fmt.Sprintf("%s/BUILD.bazel:8:13", dir)), 355 | }, 356 | }, 357 | }, 358 | { 359 | Target: &build.Target{ 360 | Type: build.Target_SOURCE_FILE.Enum(), 361 | SourceFile: &build.SourceFile{ 362 | Name: proto.String("//HelloWorld:InhabitedPlanets"), 363 | Location: proto.String(fmt.Sprintf("%s/HelloWorld/ThereIsNoFile.java:1:1", dir)), 364 | }, 365 | }, 366 | }, 367 | { 368 | Target: &build.Target{ 369 | Type: build.Target_SOURCE_FILE.Enum(), 370 | SourceFile: &build.SourceFile{ 371 | Name: proto.String("//HelloWorld:ThereIsNoWorld.java"), 372 | Location: proto.String(fmt.Sprintf("%s/HelloWorld/ThereIsNoWorld.java:1:1", dir)), 373 | }, 374 | }, 375 | }, 376 | }, 377 | } 378 | 379 | return dir, &cqueryResult 380 | } 381 | 382 | func parseResult(t *testing.T, result *analysis.CqueryResult, bazelRelease string) *TargetHashCache { 383 | n := Normalizer{} 384 | cqueryResult, err := ParseCqueryResult(result, &n) 385 | if err != nil { 386 | t.Fatalf("Failed to parse cquery result: %v", err) 387 | } 388 | return NewTargetHashCache(cqueryResult, &n, bazelRelease) 389 | } 390 | 391 | func areHashesEqual(left, right []byte) bool { 392 | return reflect.DeepEqual(left, right) 393 | } 394 | 395 | func mustParseLabel(s string) label.Label { 396 | n := Normalizer{} 397 | l, err := n.ParseCanonicalLabel(s) 398 | if err != nil { 399 | panic(err) 400 | } 401 | return l 402 | } 403 | 404 | func Test_isConfiguredRuleInputsSupported(t *testing.T) { 405 | for version, want := range map[string]bool{ 406 | "release 6.3.1": false, 407 | "release 7.0.0-pre.20230530.3": false, 408 | "release 7.0.0-pre.20230628.2": true, 409 | "release 7.0.0-pre.20230816.3": true, 410 | "release 7.0.0": true, 411 | } { 412 | t.Run(version, func(t *testing.T) { 413 | got := isConfiguredRuleInputsSupported(version) 414 | if want != got { 415 | t.Fatalf("Incorrect isConfiguredRuleInputsSupported: want %v got %v", want, got) 416 | } 417 | }) 418 | } 419 | } 420 | 421 | func Test_getUserPermission(t *testing.T) { 422 | tests := []struct { 423 | fileInfo os.FileMode 424 | want os.FileMode 425 | }{ 426 | { 427 | fileInfo: 0777, 428 | want: 0100, 429 | }, 430 | { 431 | fileInfo: 0177, 432 | want: 0100, 433 | }, 434 | { 435 | fileInfo: 0111, 436 | want: 0100, 437 | }, 438 | { 439 | fileInfo: 0664, 440 | want: 0000, 441 | }, 442 | { 443 | fileInfo: 0644, 444 | want: 0000, 445 | }, 446 | } 447 | for _, tt := range tests { 448 | t.Run("", func(t *testing.T) { 449 | if got := getUserExecuteBit(tt.fileInfo); got != tt.want { 450 | t.Errorf("getUserExecuteBit() = %v, want %v", got, tt.want) 451 | } 452 | }) 453 | } 454 | } 455 | -------------------------------------------------------------------------------- /pkg/normalizer.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "github.com/bazel-contrib/target-determinator/third_party/protobuf/bazel/build" 5 | "github.com/bazelbuild/bazel-gazelle/label" 6 | ) 7 | 8 | // Normalizer is a struct that contains a mapping of non-canonical repository names to canonical repository names. 9 | type Normalizer struct { 10 | Mapping map[string]string 11 | } 12 | 13 | // ParseCanonicalLabel parses a label from a string, and removes sources of inconsequential difference which would make comparing two labels fail. 14 | // In particular, it treats @// the same as // 15 | // If the label is not canonical, it will attempt to map the repository to its canonical form coming from `bazel mod dump_repo_mapping ""`. 16 | func (n *Normalizer) ParseCanonicalLabel(s string) (label.Label, error) { 17 | l, err := label.Parse(s) 18 | if err != nil { 19 | return l, err 20 | } 21 | 22 | if !l.Canonical && l.Repo != "" { 23 | mappedValue, ok := n.Mapping[l.Repo] 24 | if ok && l.Repo != mappedValue { 25 | l.Repo = mappedValue 26 | l.Canonical = true 27 | } 28 | } 29 | 30 | if l.Repo == "@" { 31 | l.Repo = "" 32 | } 33 | 34 | return l, nil 35 | } 36 | 37 | func (n *Normalizer) NormalizeAttribute(attr *build.Attribute) *build.Attribute { 38 | attrType := attr.GetType() 39 | 40 | // An attribute with a nodep property can also hold labels 41 | // It should be handled as an exception, see https://bazelbuild.slack.com/archives/CDCMRLS23/p1742821059464199 42 | isNoDepAttribute := attrType == build.Attribute_STRING && attr.Nodep != nil && *attr.Nodep 43 | 44 | if attrType == build.Attribute_OUTPUT || attrType == build.Attribute_LABEL || isNoDepAttribute { 45 | keyLabel, parseErr := n.ParseCanonicalLabel(attr.GetStringValue()) 46 | 47 | if parseErr == nil { 48 | value := keyLabel.String() 49 | attr.StringValue = &value 50 | } 51 | } 52 | 53 | isNoDepListAttribute := attrType == build.Attribute_STRING_LIST && attr.Nodep != nil && *attr.Nodep 54 | 55 | if attrType == build.Attribute_OUTPUT_LIST || attrType == build.Attribute_LABEL_LIST || isNoDepListAttribute { 56 | for idx, dep := range attr.GetStringListValue() { 57 | keyLabel, parseErr := n.ParseCanonicalLabel(dep) 58 | 59 | if parseErr == nil { 60 | attr.StringListValue[idx] = keyLabel.String() 61 | } 62 | } 63 | } 64 | 65 | if attrType == build.Attribute_LABEL_DICT_UNARY { 66 | for idx, dep := range attr.GetLabelDictUnaryValue() { 67 | keyLabel, parseErr := n.ParseCanonicalLabel(*dep.Value) 68 | 69 | if parseErr == nil { 70 | newValue := keyLabel.String() 71 | attr.GetLabelDictUnaryValue()[idx].Value = &newValue 72 | } 73 | } 74 | } 75 | 76 | if attrType == build.Attribute_LABEL_LIST_DICT { 77 | for idx, dep := range attr.GetLabelListDictValue() { 78 | for key, value := range dep.Value { 79 | l, parseErr := n.ParseCanonicalLabel(value) 80 | 81 | if parseErr == nil { 82 | attr.GetLabelListDictValue()[idx].Value[key] = l.String() 83 | } 84 | } 85 | } 86 | } 87 | 88 | if attrType == build.Attribute_LABEL_KEYED_STRING_DICT { 89 | for idx, dep := range attr.GetLabelKeyedStringDictValue() { 90 | keyLabel, parseErr := n.ParseCanonicalLabel(*dep.Key) 91 | 92 | if parseErr == nil { 93 | newKey := keyLabel.String() 94 | attr.GetLabelKeyedStringDictValue()[idx].Key = &newKey 95 | } 96 | 97 | } 98 | } 99 | 100 | return attr 101 | } 102 | -------------------------------------------------------------------------------- /pkg/normalizer_test.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/bazel-contrib/target-determinator/third_party/protobuf/bazel/build" 7 | ) 8 | 9 | const ( 10 | NonCanonicalPackage = "org_golang_x_text" 11 | CanonicalPackage = "gazelle++go_deps+org_golang_x_text" 12 | 13 | NonCanonicalLabel = "@org_golang_x_text//pkg:target" 14 | CanonicalLabel = "@@gazelle++go_deps+org_golang_x_text//pkg:target" 15 | 16 | DummyLabel = "@dummy//pkg:target" 17 | ) 18 | 19 | func TestParseCanonicalLabel(t *testing.T) { 20 | n := Normalizer{ 21 | Mapping: map[string]string{ 22 | NonCanonicalPackage: CanonicalPackage, 23 | }, 24 | } 25 | 26 | label, err := n.ParseCanonicalLabel(NonCanonicalLabel) 27 | 28 | if err != nil { 29 | t.Fatalf("Error parsing label: %v", err) 30 | } 31 | 32 | if label.String() != CanonicalLabel { 33 | t.Fatalf("Expected label to be %s, got %v", CanonicalLabel, label.String()) 34 | } 35 | } 36 | 37 | func toPtr[T any](x T) *T { 38 | return &x 39 | } 40 | 41 | func equal[S ~[]E, E comparable](s1, s2 S) bool { 42 | if len(s1) != len(s2) { 43 | return false 44 | } 45 | for i := range s1 { 46 | if s1[i] != s2[i] { 47 | return false 48 | } 49 | } 50 | return true 51 | } 52 | 53 | func TestNormalizeAttributes(t *testing.T) { 54 | n := Normalizer{ 55 | Mapping: map[string]string{ 56 | NonCanonicalPackage: CanonicalPackage, 57 | }, 58 | } 59 | 60 | testCases := map[string]func(*testing.T){ 61 | "nodep_string": func(t *testing.T) { 62 | attr := &build.Attribute{ 63 | Name: toPtr("visibility"), 64 | Type: toPtr(build.Attribute_STRING), 65 | Nodep: toPtr(true), 66 | StringValue: toPtr(NonCanonicalLabel), 67 | } 68 | 69 | norm := n.NormalizeAttribute(attr) 70 | 71 | equality := *norm.StringValue == CanonicalLabel 72 | 73 | if !equality { 74 | t.Fatalf("Expected string value to be %v, got %v", CanonicalLabel, *norm.StringValue) 75 | } 76 | }, "nodep_string_list": func(t *testing.T) { 77 | values := []string{ 78 | NonCanonicalLabel, 79 | DummyLabel, 80 | } 81 | 82 | expectedValues := []string{CanonicalLabel, DummyLabel} 83 | 84 | attr := &build.Attribute{ 85 | Name: toPtr("visibility"), 86 | Type: toPtr(build.Attribute_STRING_LIST), 87 | Nodep: toPtr(true), 88 | StringListValue: values, 89 | } 90 | 91 | norm := n.NormalizeAttribute(attr) 92 | 93 | equality := equal( 94 | norm.StringListValue, 95 | expectedValues, 96 | ) 97 | 98 | if !equality { 99 | t.Fatalf("Expected string list values to be %v, got %v", expectedValues, values) 100 | } 101 | }, 102 | "label_list": func(t *testing.T) { 103 | values := []string{ 104 | NonCanonicalLabel, 105 | DummyLabel, 106 | } 107 | 108 | expectedValues := []string{CanonicalLabel, DummyLabel} 109 | 110 | attr := &build.Attribute{ 111 | Name: toPtr("label_list"), 112 | Type: toPtr(build.Attribute_LABEL_LIST), 113 | StringListValue: values, 114 | } 115 | 116 | norm := n.NormalizeAttribute(attr) 117 | 118 | equality := equal( 119 | norm.StringListValue, 120 | expectedValues, 121 | ) 122 | 123 | if !equality { 124 | t.Fatalf("Expected string list values to be %v, got %v", expectedValues, values) 125 | } 126 | }, 127 | "output_list": func(t *testing.T) { 128 | values := []string{ 129 | NonCanonicalLabel, 130 | DummyLabel, 131 | } 132 | 133 | expectedValues := []string{CanonicalLabel, DummyLabel} 134 | 135 | attr := &build.Attribute{ 136 | Name: toPtr("output_list"), 137 | Type: toPtr(build.Attribute_OUTPUT_LIST), 138 | StringListValue: values, 139 | } 140 | 141 | norm := n.NormalizeAttribute(attr) 142 | 143 | equality := equal( 144 | norm.StringListValue, 145 | expectedValues, 146 | ) 147 | 148 | if !equality { 149 | t.Fatalf("Expected string list values to be %v, got %v", expectedValues, values) 150 | } 151 | }, 152 | "label_dict_unary": func(t *testing.T) { 153 | attr := &build.Attribute{ 154 | Name: toPtr("label_dict_unary"), 155 | Type: toPtr(build.Attribute_LABEL_DICT_UNARY), 156 | LabelDictUnaryValue: []*build.LabelDictUnaryEntry{ 157 | { 158 | Key: toPtr("key"), 159 | Value: toPtr(NonCanonicalLabel), 160 | }, 161 | }, 162 | } 163 | 164 | norm := n.NormalizeAttribute(attr) 165 | value := *norm.GetLabelDictUnaryValue()[0].Value 166 | if value != CanonicalLabel { 167 | t.Fatalf("Expected value to be %v, got %v", CanonicalLabel, value) 168 | } 169 | }, 170 | "label_list_dict": func(t *testing.T) { 171 | values := []string{ 172 | NonCanonicalLabel, 173 | DummyLabel, 174 | } 175 | expectedValues := []string{CanonicalLabel, DummyLabel} 176 | 177 | attr := &build.Attribute{ 178 | Name: toPtr("label_list_dict"), 179 | Type: toPtr(build.Attribute_LABEL_LIST_DICT), 180 | LabelListDictValue: []*build.LabelListDictEntry{ 181 | { 182 | Key: toPtr("key"), 183 | Value: values, 184 | }, 185 | }, 186 | } 187 | 188 | norm := n.NormalizeAttribute(attr) 189 | equality := equal( 190 | norm.LabelListDictValue[0].Value, 191 | expectedValues, 192 | ) 193 | 194 | if !equality { 195 | t.Fatalf("Expected label list dict values to be %v, got %v", expectedValues, values) 196 | } 197 | }, 198 | "label_keyed_string_dict": func(t *testing.T) { 199 | attr := &build.Attribute{ 200 | Name: toPtr("label_keyed_string_list"), 201 | Type: toPtr(build.Attribute_LABEL_KEYED_STRING_DICT), 202 | LabelKeyedStringDictValue: []*build.LabelKeyedStringDictEntry{ 203 | { 204 | Key: toPtr(NonCanonicalLabel), 205 | Value: toPtr("value"), 206 | }, 207 | }, 208 | } 209 | 210 | norm := n.NormalizeAttribute(attr) 211 | value := *norm.GetLabelKeyedStringDictValue()[0].Key 212 | if value != CanonicalLabel { 213 | t.Fatalf("Expected value to be %v, got %v", CanonicalLabel, value) 214 | } 215 | }, 216 | } 217 | 218 | for test, tt := range testCases { 219 | t.Run(test, tt) 220 | } 221 | 222 | } 223 | -------------------------------------------------------------------------------- /pkg/target_determinator_test.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/bazel-contrib/target-determinator/common" 7 | ) 8 | 9 | func Test_stringSliceContainsStartingWith(t *testing.T) { 10 | type args struct { 11 | slice []common.RelPath 12 | element common.RelPath 13 | } 14 | tests := []struct { 15 | name string 16 | args args 17 | want bool 18 | }{ 19 | { 20 | "containsExact", 21 | args{ 22 | []common.RelPath{common.NewRelPath("foo")}, 23 | common.NewRelPath("foo"), 24 | }, 25 | true, 26 | }, 27 | { 28 | "containsDirWithoutTrailingSlash", 29 | args{ 30 | []common.RelPath{common.NewRelPath("foo"), common.NewRelPath("bar/baz")}, 31 | common.NewRelPath("foo/"), 32 | }, 33 | true, 34 | }, 35 | { 36 | "containsDirWithTrailingSlashButIsFile", 37 | args{ 38 | []common.RelPath{common.NewRelPath("foo/")}, 39 | common.NewRelPath("foo"), 40 | }, 41 | false, 42 | }, 43 | { 44 | "containsPrefix", 45 | args{ 46 | []common.RelPath{common.NewRelPath("foo")}, 47 | common.NewRelPath("foo/bar"), 48 | }, 49 | true, 50 | }, 51 | { 52 | "otherIsPrefix", 53 | args{ 54 | []common.RelPath{common.NewRelPath("foo/bar")}, 55 | common.NewRelPath("foo"), 56 | }, 57 | false, 58 | }, 59 | { 60 | "doesNotContain", 61 | args{ 62 | []common.RelPath{common.NewRelPath("foo"), common.NewRelPath("bar/baz")}, 63 | common.NewRelPath("frob"), 64 | }, 65 | false, 66 | }, 67 | { 68 | "stringPrefixButNotPathPrefix", 69 | args{ 70 | []common.RelPath{common.NewRelPath("foo/b")}, 71 | common.NewRelPath("foo/bar"), 72 | }, 73 | false, 74 | }, 75 | } 76 | for _, tt := range tests { 77 | t.Run(tt.name, func(t *testing.T) { 78 | if got := stringSliceContainsStartingWith(tt.args.slice, tt.args.element); got != tt.want { 79 | t.Errorf("stringSliceContainsStartingWith() with (slice = %v, element = %v) returns %v, want %v", 80 | tt.args.slice, tt.args.element.String(), got, tt.want) 81 | } 82 | }) 83 | } 84 | } 85 | 86 | func Test_ParseCanonicalLabel(t *testing.T) { 87 | n := Normalizer{} 88 | for _, tt := range []string{ 89 | "@//label", 90 | "@//label:package", 91 | "//label:package", 92 | ":package", 93 | "@rules_python~0.21.0~pip~pip_boto3//:pkg", 94 | } { 95 | _, err := n.ParseCanonicalLabel(tt) 96 | if err != nil { 97 | t.Errorf("ParseCanonicalLabel() with (label=%s) produces error %s", tt, err) 98 | } 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /pkg/targets_list.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | type TargetsList struct { 4 | targets string 5 | } 6 | 7 | func ParseTargetsList(targets string) (TargetsList, error) { 8 | // TODO: validate against syntax in https://bazel.build/reference/query 9 | return TargetsList{targets: targets}, nil 10 | } 11 | 12 | func (tl *TargetsList) String() string { 13 | return tl.targets 14 | } 15 | -------------------------------------------------------------------------------- /pkg/walker.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "log" 7 | 8 | "github.com/bazel-contrib/target-determinator/third_party/protobuf/bazel/analysis" 9 | "github.com/bazelbuild/bazel-gazelle/label" 10 | ) 11 | 12 | type WalkCallback func(label.Label, []Difference, *analysis.ConfiguredTarget) 13 | 14 | // WalkAffectedTargets computes which targets have changed between two commits, and calls 15 | // callback once for each target which has changed. 16 | // Explanation of the differences may be expensive in both time and memory to compute, so if 17 | // includeDifferences is set to false, the []Difference parameter to the callback will always be nil. 18 | func WalkAffectedTargets(context *Context, revBefore LabelledGitRev, targets TargetsList, includeDifferences bool, callback WalkCallback) error { 19 | // The revAfter revision represents the current state of the working directory, which may contain local changes. 20 | // It is distinct from context.OriginalRevision, which represents the original commit that we want to reset to before exiting. 21 | revAfter, err := NewLabelledGitRev(context.WorkspacePath, "", "after") 22 | if err != nil { 23 | return fmt.Errorf("could not create \"after\" revision: %w", err) 24 | } 25 | 26 | beforeMetadata, afterMetadata, err := FullyProcess(context, revBefore, revAfter, targets) 27 | if err != nil { 28 | return fmt.Errorf("failed to process change: %w", err) 29 | } 30 | 31 | if beforeMetadata.BazelRelease == afterMetadata.BazelRelease && beforeMetadata.BazelRelease == "development version" { 32 | log.Printf("WARN: Bazel was detected to be a development version - if you're using different development versions at the before and after commits, differences between those versions may not be reflected in this output") 33 | } 34 | 35 | for _, l := range afterMetadata.MatchingTargets.Labels() { 36 | if err := DiffSingleLabel(beforeMetadata, afterMetadata, includeDifferences, l, callback); err != nil { 37 | return err 38 | } 39 | } 40 | 41 | return nil 42 | } 43 | 44 | func DiffSingleLabel(beforeMetadata, afterMetadata *QueryResults, includeDifferences bool, label label.Label, callback WalkCallback) error { 45 | for _, configuration := range afterMetadata.MatchingTargets.ConfigurationsFor(label) { 46 | configuredTarget := afterMetadata.TransitiveConfiguredTargets[label][configuration] 47 | 48 | var differences []Difference 49 | 50 | collectDifference := func(d Difference) { 51 | if includeDifferences { 52 | differences = append(differences, d) 53 | } 54 | } 55 | 56 | if len(beforeMetadata.MatchingTargets.ConfigurationsFor(label)) == 0 { 57 | category := "NewLabel" 58 | if beforeMetadata.QueryError != nil { 59 | category = "ErrorInQueryBefore" 60 | } 61 | collectDifference(Difference{ 62 | Category: category, 63 | }) 64 | callback(label, differences, configuredTarget) 65 | return nil 66 | } else if !beforeMetadata.MatchingTargets.ContainsLabelAndConfiguration(label, configuration) { 67 | difference := Difference{ 68 | Category: "NewConfiguration", 69 | } 70 | if includeDifferences { 71 | configurationsBefore := beforeMetadata.MatchingTargets.ConfigurationsFor(label) 72 | configurationsAfter := afterMetadata.MatchingTargets.ConfigurationsFor(label) 73 | if len(configurationsBefore) == 1 && len(configurationsAfter) == 1 { 74 | diff, _ := diffConfigurations(beforeMetadata.configurations[configurationsBefore[0]], afterMetadata.configurations[configurationsAfter[0]]) 75 | difference = Difference{ 76 | Category: "ChangedConfiguration", 77 | Before: configurationsBefore[0].String(), 78 | After: configurationsAfter[0].String(), 79 | Key: diff, 80 | } 81 | } 82 | } 83 | collectDifference(difference) 84 | callback(label, differences, configuredTarget) 85 | return nil 86 | } 87 | _, ok := beforeMetadata.TransitiveConfiguredTargets[label][configuration] 88 | if !ok { 89 | collectDifference(Difference{Category: "NewTarget"}) 90 | callback(label, differences, configuredTarget) 91 | return nil 92 | } else { 93 | labelAndConfiguration := LabelAndConfiguration{ 94 | Label: label, 95 | Configuration: configuration, 96 | } 97 | hashBefore, err := beforeMetadata.TargetHashCache.Hash(labelAndConfiguration) 98 | if err != nil { 99 | return err 100 | } 101 | hashAfter, err := afterMetadata.TargetHashCache.Hash(labelAndConfiguration) 102 | if err != nil { 103 | return err 104 | } 105 | if bytes.Equal(hashBefore, hashAfter) { 106 | continue 107 | } 108 | if includeDifferences { 109 | differences, err = WalkDiffs(beforeMetadata.TargetHashCache, afterMetadata.TargetHashCache, labelAndConfiguration) 110 | if err != nil { 111 | return err 112 | } 113 | } 114 | callback(label, differences, configuredTarget) 115 | } 116 | } 117 | return nil 118 | } 119 | -------------------------------------------------------------------------------- /rules/BUILD.bazel: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bazel-contrib/target-determinator/d9f17b3433292e2470beb11b7b98d829402debaf/rules/BUILD.bazel -------------------------------------------------------------------------------- /rules/copy_proto_output.bzl: -------------------------------------------------------------------------------- 1 | def _copy_proto_output_impl(ctx): 2 | runner_script = ctx.actions.declare_file("{}.copy_proto_output.sh".format(ctx.attr.name)) 3 | 4 | generated = ctx.attr.proto_library[OutputGroupInfo].go_generated_srcs 5 | 6 | srcs = " ".join([f.short_path for f in generated.to_list()]) 7 | 8 | ctx.actions.write( 9 | output = runner_script, 10 | content = """#!/bin/bash 11 | out_dir="${{BUILD_WORKSPACE_DIRECTORY}}/{package}" 12 | cp -f {srcs} "${{out_dir}}/" 13 | echo "Copied Go generated for protos to ${{out_dir}}" 14 | """.format( 15 | srcs = srcs, 16 | package = ctx.label.package, 17 | ), 18 | is_executable = True, 19 | ) 20 | 21 | runfiles = ctx.runfiles([runner_script], transitive_files = generated) 22 | 23 | return [ 24 | DefaultInfo( 25 | files = depset([runner_script]), 26 | runfiles = runfiles, 27 | executable = runner_script, 28 | ), 29 | ] 30 | 31 | copy_proto_output = rule( 32 | executable = True, 33 | implementation = _copy_proto_output_impl, 34 | attrs = { 35 | "proto_library": attr.label(allow_files = True), 36 | }, 37 | ) 38 | -------------------------------------------------------------------------------- /rules/multi_platform_go_binary.bzl: -------------------------------------------------------------------------------- 1 | load("@io_bazel_rules_go//go:def.bzl", "go_binary") 2 | 3 | _PLATFORMS = [ 4 | ("darwin", "amd64"), 5 | ("darwin", "arm64"), 6 | ("linux", "amd64"), 7 | ("linux", "arm64"), 8 | ("windows", "amd64"), 9 | ] 10 | 11 | def multi_platform_go_binary(name, tags = None, **kwargs): 12 | if "visibility" not in kwargs: 13 | kwargs["visibility"] = "//visibility:public" 14 | 15 | if "goos" in kwargs or "goarch" in kwargs: 16 | fail("Can't specify goos or goarch for multi_platform_go_binary") 17 | 18 | unplatformed_binary_tags = [t for t in tags or []] 19 | if "manual" not in unplatformed_binary_tags: 20 | unplatformed_binary_tags.append("manual") 21 | 22 | go_binary( 23 | name = name, 24 | tags = unplatformed_binary_tags, 25 | **kwargs 26 | ) 27 | 28 | for goos, goarch in _PLATFORMS: 29 | go_binary( 30 | name = "{}.{}.{}".format(name, goos, goarch), 31 | goos = goos, 32 | goarch = goarch, 33 | tags = tags, 34 | **kwargs 35 | ) 36 | -------------------------------------------------------------------------------- /scripts/format: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -eufo pipefail 3 | 4 | echo "goimports" >&2 5 | find $(pwd) -type f -name '*.go' | 6 | grep -v ".pb.go" | 7 | xargs bazel run --noshow_progress --ui_event_filters= @org_golang_x_tools//cmd/goimports -- -l -w 8 | echo "importsort" >&2 9 | find $(pwd) -type f -name '*.go' | 10 | grep -v ".pb.go" | 11 | xargs bazel run --noshow_progress --ui_event_filters= @com_github_aristanetworks_goarista//cmd/importsort -- -w -s NOT_SPECIFIED 12 | 13 | bazel run //:gazelle 14 | -------------------------------------------------------------------------------- /scripts/update-dependencies: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -eufo pipefail 3 | 4 | bazel run @go_sdk//:bin/go -- mod tidy 5 | -------------------------------------------------------------------------------- /scripts/workspace-status.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -eu 2 | 3 | echo "VERSION $(git describe --tags --exact-match)" 4 | -------------------------------------------------------------------------------- /target-determinator/BUILD.bazel: -------------------------------------------------------------------------------- 1 | load("@io_bazel_rules_go//go:def.bzl", "go_library") 2 | load("//rules:multi_platform_go_binary.bzl", "multi_platform_go_binary") 3 | 4 | go_library( 5 | name = "target-determinator_lib", 6 | srcs = ["target-determinator.go"], 7 | importpath = "github.com/bazel-contrib/target-determinator/target-determinator", 8 | visibility = ["//visibility:private"], 9 | deps = [ 10 | "//cli", 11 | "//pkg", 12 | "//third_party/protobuf/bazel/analysis", 13 | "@bazel_gazelle//label", 14 | ], 15 | ) 16 | 17 | multi_platform_go_binary( 18 | name = "target-determinator", 19 | embed = [":target-determinator_lib"], 20 | visibility = ["//visibility:public"], 21 | ) 22 | -------------------------------------------------------------------------------- /target-determinator/target-determinator.go: -------------------------------------------------------------------------------- 1 | // target-determinator is a binary to output to stdout a list of targets, one-per-line, which may 2 | // have changed between two commits. 3 | // It goes to some efforts to be both thorough, and minimal, but if in doubt leans towards 4 | // over-building rather than under-building. 5 | // In verbose mode, the first token per line will be the target to run, and after a space character, 6 | // additional information may be printed explaining why a target was detected to be affected. 7 | 8 | package main 9 | 10 | import ( 11 | "flag" 12 | "fmt" 13 | "log" 14 | "os" 15 | "path/filepath" 16 | "time" 17 | 18 | "github.com/bazel-contrib/target-determinator/cli" 19 | "github.com/bazel-contrib/target-determinator/pkg" 20 | "github.com/bazel-contrib/target-determinator/third_party/protobuf/bazel/analysis" 21 | gazelle_label "github.com/bazelbuild/bazel-gazelle/label" 22 | ) 23 | 24 | type targetDeterminatorFlags struct { 25 | commonFlags *cli.CommonFlags 26 | revisionBefore string 27 | verbose bool 28 | } 29 | 30 | type config struct { 31 | Context *pkg.Context 32 | RevisionBefore pkg.LabelledGitRev 33 | Targets pkg.TargetsList 34 | Verbose bool 35 | } 36 | 37 | func main() { 38 | start := time.Now() 39 | defer func() { log.Printf("Finished after %v", time.Since(start)) }() 40 | 41 | flags, err := parseFlags() 42 | if err != nil { 43 | fmt.Fprintf(flag.CommandLine.Output(), "Failed to parse flags: %v\n", err) 44 | fmt.Fprintf(flag.CommandLine.Output(), "Usage of %s:\n", os.Args[0]) 45 | fmt.Fprintf(flag.CommandLine.Output(), " %s \n", filepath.Base(os.Args[0])) 46 | fmt.Fprintf(flag.CommandLine.Output(), "Where may be any commit revision - full commit hashes, short commit hashes, tags, branches, etc.\n") 47 | fmt.Fprintf(flag.CommandLine.Output(), "Optional flags:\n") 48 | flag.PrintDefaults() 49 | os.Exit(1) 50 | } 51 | 52 | // Print something on stdout that will make bazel fail when passed as a target. 53 | config, err := resolveConfig(*flags) 54 | if err != nil { 55 | fmt.Println("Target Determinator invocation Error") 56 | log.Fatalf("Error during preprocessing: %v", err) 57 | } 58 | 59 | seenLabels := make(map[gazelle_label.Label]struct{}) 60 | callback := func(label gazelle_label.Label, differences []pkg.Difference, configuredTarget *analysis.ConfiguredTarget) { 61 | if !config.Verbose { 62 | if _, seen := seenLabels[label]; seen { 63 | return 64 | } 65 | } 66 | fmt.Print(label) 67 | if len(differences) > 0 { 68 | fmt.Printf(" Changes:") 69 | for i, difference := range differences { 70 | if i > 0 { 71 | fmt.Print(",") 72 | } 73 | fmt.Printf(" %v", difference.String()) 74 | } 75 | } 76 | fmt.Println("") 77 | seenLabels[label] = struct{}{} 78 | } 79 | 80 | if err := pkg.WalkAffectedTargets(config.Context, 81 | config.RevisionBefore, 82 | config.Targets, 83 | config.Verbose, 84 | callback); err != nil { 85 | // Print something on stdout that will make bazel fail when passed as a target. 86 | fmt.Println("Target Determinator invocation Error") 87 | log.Fatal(err) 88 | } 89 | } 90 | 91 | func parseFlags() (*targetDeterminatorFlags, error) { 92 | var flags targetDeterminatorFlags 93 | flags.commonFlags = cli.RegisterCommonFlags() 94 | flag.BoolVar(&flags.verbose, "verbose", false, "Whether to explain (messily) why each target is getting run") 95 | 96 | flag.Parse() 97 | 98 | var err error 99 | flags.revisionBefore, err = cli.ValidateCommonFlags("target-determinator", flags.commonFlags) 100 | if err != nil { 101 | return nil, err 102 | } 103 | return &flags, nil 104 | } 105 | 106 | func resolveConfig(flags targetDeterminatorFlags) (*config, error) { 107 | commonArgs, err := cli.ResolveCommonConfig(flags.commonFlags, flags.revisionBefore) 108 | if err != nil { 109 | return nil, err 110 | } 111 | 112 | return &config{ 113 | Context: commonArgs.Context, 114 | RevisionBefore: commonArgs.RevisionBefore, 115 | Targets: commonArgs.Targets, 116 | Verbose: flags.verbose, 117 | }, nil 118 | } 119 | -------------------------------------------------------------------------------- /testdata/HelloWorld/BUILD.bazel: -------------------------------------------------------------------------------- 1 | java_binary( 2 | name = "HelloWorld", 3 | srcs = ["HelloWorld.java"], 4 | main_class = "HelloWorld", 5 | deps = [":GreetingLib"], 6 | ) 7 | 8 | java_library( 9 | name = "GreetingLib", 10 | srcs = ["Greeting.java"], 11 | ) 12 | 13 | filegroup( 14 | name = "all_srcs", 15 | srcs = glob(["**"]) + ["InhabitedPlanets"], 16 | visibility = ["//visibility:public"], 17 | ) 18 | -------------------------------------------------------------------------------- /testdata/HelloWorld/Greeting.java: -------------------------------------------------------------------------------- 1 | public class Greeting { 2 | public static String YO = "Yo"; 3 | } 4 | -------------------------------------------------------------------------------- /testdata/HelloWorld/HelloWorld.java: -------------------------------------------------------------------------------- 1 | public class HelloWorld { 2 | public static void main(String[] args) { 3 | System.out.println(Greeting.YO); 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /testdata/HelloWorld/InhabitedPlanets/Earth: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bazel-contrib/target-determinator/d9f17b3433292e2470beb11b7b98d829402debaf/testdata/HelloWorld/InhabitedPlanets/Earth -------------------------------------------------------------------------------- /tests/integration/README.md: -------------------------------------------------------------------------------- 1 | # Target Determinator Tests 2 | 3 | Tests edge-cases of detecting which targets to run when changing bazel graphs. 4 | 5 | There is a companion git repo hosted at https://github.com/bazel-contrib/target-determinator-testdata with a series of changes on assorted tags. 6 | 7 | The general structure of each test is to hand the determinator two commits, and ask it which targets should be tested. Each test will assert that: 8 | 1. At least the required tests were detected. 9 | 2. None of the forbidden tests were detected. 10 | 3. No extra tests were detected. 11 | 12 | It is reasonable, but not preferable, for the "extra tests detected" check to fail - this will lead to over-building. The other two assertions are very important. 13 | 14 | ## Gaps 15 | 16 | There is currently no coverage for: 17 | * What should happen in the event of invalid build graphs. There are multiple possibly desirable behaviours ("Just error", "Build everything", "Give the best list we can", "Best effort + error signal"). If we agree on one, we can codify it. 18 | * Platform-specific changes. The test suite assumes that wherever the test suite is being run is an equivalent platform to where the tests will actually be run. 19 | 20 | ## Adding more target determinators 21 | 22 | We have two other tools which solve the same problem wired up to our shared test suite: 23 | 24 | * [bazel-diff](https://github.com/Tinder/bazel-diff) 25 | * [bazel-differ](https://github.com/ewhauser/bazel-differ) 26 | 27 | Extra tools can be supported by adding a new class to `com.github.bazel_contrib.target_determinator.integration` which inherits from `Tests` and implementing the abstract `getTargets` method as per its javadoc. 28 | 29 | ## How to handle differences in expectations/behaviors 30 | 31 | Each supported target determinator has its own subclass of `Tests`. `Tests` contains tests which should apply to all target determinators. Implementation-specific tests can be added to subclasses, or to separate unrelated classes. 32 | 33 | For places where more targets are returned than expected, the subclass should override the individual test method, and call the method `allowOverBuilds` with an explanation of why this is expected, before delegating to the superclass. 34 | 35 | For places where a target determinator has a different, equally valid, interpretation of what should be returned, the test method can simply be overridden. 36 | 37 | For places where behavior is not supported, or simply incorrect, the overridden test method should be annotated with an `@Ignore` annotation, with an explanation of why. 38 | 39 | ## Adding new tests 40 | 41 | Adding tests is slightly fiddly, as it involves making coordinated changes across the testdata repo and this one. 42 | 43 | This is roughly the flow to add tests: 44 | 1. Decide whether this is specific to this TD implementation, or applies generally to all target determinators. If it's general, the new test should go in [java/com/github/bazel_contrib/target_determinator/integration/Tests.java], otherwise in [java/com/github/bazel_contrib/target_determinator/integration/TargetDeterminatorSpecificFlagsTest.java] - in both cases, the existing tests should be easy to crib from. 45 | 1. If new commits are needed in the testdata repo (which is most often the case), clone that, and add commits to it. Each test commit is typically in a unique branch based on https://github.com/bazel-contrib/target-determinator-testdata/commit/6682820b4acb455f13bc3cf8f7d254056092e306 - we try to have each branch have the minimal changes needed for each test, rather than amassing lots of unrelated changes on fewer branches. 46 | 47 | When merging new commits to the testdata repo, we create two refs per added commit - a branch (which may be rewritten in the future), and an immutable tag which will stay around forever. Say we're adding a commit testing upper-case target names, we may call the branch `upper-case-targets`, and we'll create the tag `v0/upper-case-targets` matching it. If we change the branch in the future (e.g. to change the bazel version), we'll rewrite history on the branch, and create a new tag `v1/upper-case-targets`. 48 | 1. For actually testing out your new test locally, you can edit [java/com/github/bazel_contrib/target_determinator/integration/TestdataRepo.java#L18](the TestdataRepo helper class in this repo) to clone from a `file://` URI pointing at your local clone. You probably also want to call `.setCloneAllBranches(true)` on the `Git.cloneRepository` call, otherwise your work-in-progress branches won't be cloned when you run the tests 49 | 50 | When sending out new tests for review, feel free to set the clone URI to your fork on GitHub (so the tests actually pass), and include in your PR which commits/branches need to be upstreamed into the testdata repo. The reviewer will push these commits when the code otherwise looks good, and ask you to revert back to the upstream URI. 51 | -------------------------------------------------------------------------------- /tests/integration/java/com/github/bazel_contrib/target_determinator/integration/BUILD.bazel: -------------------------------------------------------------------------------- 1 | load("@bazel_skylib//rules:copy_file.bzl", "copy_file") 2 | load("@rules_jvm_external//:defs.bzl", "artifact") 3 | 4 | java_test( 5 | name = "TargetDeterminatorIntegrationTest", 6 | timeout = "long", 7 | srcs = [ 8 | "TargetDeterminatorIntegrationTest.java", 9 | ], 10 | data = ["//target-determinator"], 11 | env_inherit = ["CC"], 12 | jvm_flags = [ 13 | "-Dtarget_determinator=$(rootpath //target-determinator)", 14 | ], 15 | shard_count = 5, 16 | tags = ["no-sandbox"], 17 | deps = [ 18 | ":tests", 19 | ":util", 20 | artifact("com.google.guava:guava"), 21 | artifact("com.google.code.findbugs:jsr305"), 22 | artifact("junit:junit"), 23 | "//tests/integration/java/com/github/bazel_contrib/target_determinator/label", 24 | ], 25 | ) 26 | 27 | java_test( 28 | name = "TargetDeterminatorSpecificFlagsTest", 29 | srcs = [ 30 | "TargetDeterminatorSpecificFlagsTest.java", 31 | ], 32 | data = ["//target-determinator"], 33 | env_inherit = ["CC"], 34 | jvm_flags = [ 35 | "-Dtarget_determinator=$(rootpath //target-determinator)", 36 | ], 37 | shard_count = 2, 38 | tags = ["no-sandbox"], 39 | deps = [ 40 | ":tests", 41 | ":util", 42 | "//tests/integration/java/com/github/bazel_contrib/target_determinator/label", 43 | artifact("junit:junit"), 44 | artifact("org.eclipse.jgit:org.eclipse.jgit"), 45 | artifact("org.hamcrest:hamcrest-all"), 46 | ], 47 | ) 48 | 49 | java_library( 50 | name = "tests", 51 | srcs = [ 52 | "Tests.java", 53 | ], 54 | exports = [":util"], 55 | deps = [ 56 | ":util", 57 | "//tests/integration/java/com/github/bazel_contrib/target_determinator/label", 58 | artifact("com.google.guava:guava"), 59 | artifact("org.eclipse.jgit:org.eclipse.jgit"), 60 | artifact("org.hamcrest:hamcrest-all"), 61 | artifact("junit:junit"), 62 | ], 63 | ) 64 | 65 | java_library( 66 | name = "util", 67 | srcs = [ 68 | "TargetComputationErrorException.java", 69 | "TargetDeterminator.java", 70 | "TestdataRepo.java", 71 | "Util.java", 72 | ], 73 | deps = [ 74 | "//tests/integration/java/com/github/bazel_contrib/target_determinator/label", 75 | artifact("com.google.guava:guava"), 76 | artifact("org.eclipse.jgit:org.eclipse.jgit"), 77 | artifact("org.hamcrest:hamcrest-all"), 78 | ], 79 | ) 80 | -------------------------------------------------------------------------------- /tests/integration/java/com/github/bazel_contrib/target_determinator/integration/TargetComputationErrorException.java: -------------------------------------------------------------------------------- 1 | package com.github.bazel_contrib.target_determinator.integration; 2 | 3 | /** TargetComputationErrorException represents an error when computing targets. */ 4 | class TargetComputationErrorException extends Exception { 5 | 6 | private final String stdout; 7 | private final String stderr; 8 | 9 | /** 10 | * getStdout returns the stdout of the failed command. 11 | */ 12 | public String getStdout() { 13 | return stdout; 14 | } 15 | 16 | /** 17 | * getStderr returns the stdout of the failed command. 18 | */ 19 | public String getStderr() { 20 | return stderr; 21 | } 22 | 23 | public TargetComputationErrorException(String errorMessage, String stdout, String stderr) { 24 | super(errorMessage); 25 | this.stdout = stdout; 26 | this.stderr = stderr; 27 | } 28 | 29 | @Override 30 | public String getMessage() { 31 | return String.format("%s, stderr: %s", super.getMessage(), stderr); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /tests/integration/java/com/github/bazel_contrib/target_determinator/integration/TargetDeterminator.java: -------------------------------------------------------------------------------- 1 | package com.github.bazel_contrib.target_determinator.integration; 2 | 3 | import com.github.bazel_contrib.target_determinator.label.Label; 4 | import com.google.common.base.Charsets; 5 | import com.google.common.base.Joiner; 6 | import com.google.common.collect.ImmutableSet; 7 | import com.google.common.collect.ImmutableSet.Builder; 8 | import com.google.common.hash.Hashing; 9 | import com.google.common.io.ByteStreams; 10 | import java.io.File; 11 | import java.io.IOException; 12 | import java.lang.ProcessBuilder.Redirect; 13 | import java.nio.charset.StandardCharsets; 14 | import java.nio.file.Path; 15 | import java.nio.file.Paths; 16 | import java.util.Set; 17 | 18 | /** Wrapper around a target-determinator binary. */ 19 | public class TargetDeterminator { 20 | private static final String TARGET_DETERMINATOR = 21 | new File(System.getProperty("target_determinator")).getAbsolutePath(); 22 | 23 | /** Get the targets returned by a run of target-determinator. */ 24 | public static Set