From c939804826bf60a3d95a45f9ea4d3a1a41ec74dc Mon Sep 17 00:00:00 2001 From: Yannik Tausch Date: Wed, 17 Jun 2026 16:53:47 +0200 Subject: [PATCH] fix(git): Remove `Files.isHidden` guard in `AddExcludedFilesToIndex::prepare` The goal of prepare() is to prepare a TreeSet `toExclude` which covers a set of arguments to pass to `git add` that cover all *destination-only* files in HEAD, without sweeping in any origin-owned file. A check in line 73 defensively influences the algorithm to not assume that a `git add dir` also adds `dir/.file`, which is indicated in a surrounding comment. Therefore, in a minimal example, where `tools` is destination-owned and contains `tools/.keep` and `tools/script.sh`, we would end up with `toExclude = {tools/.keep, tools/script.sh}`. However, git does not behave like indicated and a `git add dir` also adds any hidden subdirectories or files. Thus, it would be sufficient to run `git add tools` in the example above and not `git add tools/.keep tools/script.sh`. The code is overdefensive. The reason why I am fixing this is that, also, `Files.isHidden(relative)` is broken on Windows: While on POSIX, `isHidden` simply checks if the basename starts with a dot, it checks a filesystem attribute. However, since the file path is relative, this happens relative to the current working directory of the JVM, which is not the checked-out repository. This issue could alternatively be resolved by resolving the file path first, but removing the `isHidden` check is the correct fix. --- .../copybara/git/AddExcludedFilesToIndex.java | 4 --- .../copybara/git/GitDestinationTest.java | 29 +++++++++++++++++++ 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/java/com/google/copybara/git/AddExcludedFilesToIndex.java b/java/com/google/copybara/git/AddExcludedFilesToIndex.java index 5cba84790..ad8144478 100644 --- a/java/com/google/copybara/git/AddExcludedFilesToIndex.java +++ b/java/com/google/copybara/git/AddExcludedFilesToIndex.java @@ -70,10 +70,6 @@ void prepare(Path workdir) throws RepoException, IOException { addPathAndParents(included, relative); } else { prevExcluded.add(relative); - if (Files.isHidden(relative)) { - // File is not included but 'git add dir' doesn't work for 'dir/.file'. - addPathAndParents(included, relative.getParent()); - } } } diff --git a/javatests/com/google/copybara/git/GitDestinationTest.java b/javatests/com/google/copybara/git/GitDestinationTest.java index 8175cc73d..4c6f1121b 100644 --- a/javatests/com/google/copybara/git/GitDestinationTest.java +++ b/javatests/com/google/copybara/git/GitDestinationTest.java @@ -905,6 +905,35 @@ public void testExcludes_delete() throws Exception { assertThat(entry.files()).containsExactly("sub/tools/foo/other"); } + /** + * Regression test for previously-excluded files inside a dot-directory. Earlier code called + * {@link java.nio.file.Files#isHidden} on each tree entry's relative path; on Windows that + * resolves against the JVM's current working directory and throws {@code NoSuchFileException} + * for any entry under e.g. {@code .github/}. The dot-directory case must produce a commit that + * preserves the destination-only files unchanged, on every platform. + */ + @Test + public void testExcludes_dotDirectory_preserved() throws Exception { + fetch = primaryBranch; + push = primaryBranch; + GitTestUtil.writeFile(workdir, ".github/PULL_REQUEST_TEMPLATE.md", "destination-only"); + GitTestUtil.writeFile(workdir, "src/included", "first"); + repo().withWorkTree(workdir).add().all().run(); + repo().withWorkTree(workdir).simpleCommand("commit", "-m", "first commit"); + + Files.delete(workdir.resolve("src/included")); + GitTestUtil.writeFile(workdir, "src/included", "second"); + destinationFiles = + Glob.createGlob(ImmutableList.of("**"), ImmutableList.of(".github/**")); + + process(newWriter(), new DummyRevision("origin_ref")); + + assertThatCheckout(repo(), primaryBranch) + .containsFile(".github/PULL_REQUEST_TEMPLATE.md", "destination-only") + .containsFile("src/included", "second") + .containsNoMoreFiles(); + } + @Test public void processFetchRefDoesntExist() throws Exception { fetch = "testPullFromRef";