diff --git a/README.md b/README.md index 9875f81..de0b363 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,13 @@ Bazel reads each watched file in full when a new server first uses the repository. Leaving the system volume out spares that check a read of every system tool, which on a freshly booted host can take tens of seconds. +Bazel records a watched link to a directory only as a directory, not where it +points, so the rule watches through such links instead: a search path directory +named by a link, and the `xcode-select` selection, through its `xcodebuild`. +Pointing one at different tools recomputes the key. Pointing one at a copy with +the same names and bytes does not, and the key keeps naming the old path until +something else recomputes it; the tools that run are the same. + ## Comparing two machines The key and every field it digests are in `key.json`: diff --git a/host_key.py b/host_key.py index 9d0f788..b0ff4a0 100755 --- a/host_key.py +++ b/host_key.py @@ -173,8 +173,12 @@ def search_path_identity( digest.update(f"{directory}={resolved}\n".encode()) if directory != resolved: watches.file(directory) - if not on_system_volume(resolved, system_volume): - watches.listing(resolved) + # Watches go through the directory as named. Bazel records a watched + # link to a directory only as a directory, so a watch on the resolved + # path would never see the link moved; through the link, it reaches + # whatever the link points at now. + if not on_system_volume(directory, system_volume): + watches.listing(directory) if resolved in listed: continue listed.add(resolved) @@ -182,7 +186,7 @@ def search_path_identity( digest.update(b"absent\n") continue # PATH search looks at direct entries only, never into subdirectories. - for entry in sorted(resolved.iterdir()): + for entry in sorted(directory.iterdir()): identity = entry_identity(entry, digests, watches, system_volume) digest.update(f"{identity}\n".encode()) return digest.hexdigest() @@ -212,6 +216,10 @@ def developer_tools_version(developer_dir: Path, watches: Watches) -> str: def macos_fields(watches: Watches) -> dict[str, object]: watches.file(MACOS_VERSION) watches.file(XCODE_SELECTION) + # The selection is a link to a directory, which Bazel records only as a + # directory. Moving it is seen through this file instead: its bytes differ + # from one Xcode build to the next, and the Command Line Tools have none. + watches.file(XCODE_SELECTION / "usr" / "bin" / "xcodebuild") try: developer_dir = command_output("/usr/bin/xcode-select", "--print-path") except subprocess.CalledProcessError: diff --git a/tests/check_platform.py b/tests/check_platform.py index eb93c2a..7edcd1d 100755 --- a/tests/check_platform.py +++ b/tests/check_platform.py @@ -154,8 +154,10 @@ def expect(step: str, stderr: str, wanted: str) -> None: def check_watched_changes(bazel: str, module_root: Path, scratch: Path) -> None: + # The search path names the tools through a link to their directory. tools = scratch / "tools" - tools.mkdir() + (scratch / "tools-one").mkdir() + tools.symlink_to("tools-one") tool = tools / "tool" tool.write_bytes(b"one") prefix = scratch / "brew" @@ -195,6 +197,18 @@ def check_watched_changes(bazel: str, module_root: Path, scratch: Path) -> None: tool.write_bytes(b"one") write_sbom(keg, BOTTLE) expect("original tools restored", consumer.build(second), "disk cache hit") + + # Bazel records a watched link to a directory only as a directory, so + # only a watch through the link sees it pointed at other tools. + (scratch / "tools-two").mkdir() + (scratch / "tools-two" / "tool").write_bytes(b"other") + tools.unlink() + tools.symlink_to("tools-two") + expect( + "tool directory link moved, running server", + consumer.build(second), + "executed", + ) finally: consumer.shutdown() diff --git a/tests/host_key_test.py b/tests/host_key_test.py index 0ae71e7..16806c8 100644 --- a/tests/host_key_test.py +++ b/tests/host_key_test.py @@ -172,6 +172,18 @@ def test_retargeted_link_rekeys(self) -> None: link.symlink_to("second") self.assertNotEqual(before, self.identity()) + def test_linked_directory_is_watched_through_its_link(self) -> None: + # Bazel records a watched link to a directory only as a directory, so + # watches under the resolved path would never see the link moved. + (self.directory / "one").mkdir() + (self.directory / "one" / "tool").write_bytes(b"one") + linked = self.directory / "tools" + linked.symlink_to("one") + watches = host_key.Watches() + host_key.search_path_identity([linked], watches, SYSTEM_VOLUME) + self.assertIn(str(linked / "tool"), watches.files) + self.assertIn(str(linked), watches.directories) + @unittest.skipIf(os.geteuid() == 0, "root reads every file") def test_unreadable_file_ignores_timestamps(self) -> None: # An inode or a timestamp would differ between hosts holding the same @@ -235,5 +247,17 @@ def test_path_through_a_movable_directory_link_is_not_covered(self) -> None: self.assertFalse(covered(self.directory / "bin" / "sh", SYSTEM_VOLUME)) +@unittest.skipUnless(sys.platform == "darwin", "only macOS selects an Xcode") +class XcodeSelectionTest(unittest.TestCase): + def test_selection_is_watched_through_its_link(self) -> None: + # xcode-select moves a link to a directory, which Bazel records only as + # a directory; without a file watched through it, a newly selected + # Xcode would keep the old one's key. + watches = host_key.Watches() + host_key.macos_fields(watches) + through = host_key.XCODE_SELECTION / "usr" / "bin" / "xcodebuild" + self.assertIn(str(through), watches.files) + + if __name__ == "__main__": unittest.main()