Add test for null build with Ninja execution.

Also, fix symlink forest creation to not delete special not-symlinked
directories under the exec root, since they should be treated as special
output directories.

Closes #10719.

PiperOrigin-RevId: 293606157
diff --git a/src/main/java/com/google/devtools/build/lib/buildtool/SymlinkForest.java b/src/main/java/com/google/devtools/build/lib/buildtool/SymlinkForest.java
index bfd5d92..eda2aaa 100644
--- a/src/main/java/com/google/devtools/build/lib/buildtool/SymlinkForest.java
+++ b/src/main/java/com/google/devtools/build/lib/buildtool/SymlinkForest.java
@@ -102,14 +102,16 @@
   }
 
   /**
-   * Delete all dir trees under a given 'dir' that don't start with a given 'prefix'. Does not
-   * follow any symbolic links.
+   * Delete all dir trees under a given 'dir' that don't start with a given 'prefix', and is not
+   * special case of not symlinked to exec root directories (those directories are special case of
+   * output roots, so they must be kept before commands). Does not follow any symbolic links.
    */
   @VisibleForTesting
   @ThreadSafety.ThreadSafe
-  static void deleteTreesBelowNotPrefixed(Path dir, String prefix) throws IOException {
+  void deleteTreesBelowNotPrefixed(Path dir, String prefix) throws IOException {
     for (Path p : dir.getDirectoryEntries()) {
-      if (!p.getBaseName().startsWith(prefix)) {
+      if (!p.getBaseName().startsWith(prefix)
+          && !notSymlinkedInExecrootDirectories.contains(p.getBaseName())) {
         p.deleteTree();
       }
     }
diff --git a/src/test/java/com/google/devtools/build/lib/blackbox/tests/BUILD b/src/test/java/com/google/devtools/build/lib/blackbox/tests/BUILD
index 40d58a5..4d98089 100644
--- a/src/test/java/com/google/devtools/build/lib/blackbox/tests/BUILD
+++ b/src/test/java/com/google/devtools/build/lib/blackbox/tests/BUILD
@@ -53,7 +53,7 @@
 
 java_test(
     name = "NinjaBlackBoxTest",
-    size = "small",
+    timeout = "moderate",
     srcs = ["NinjaBlackBoxTest.java"],
     tags = ["black_box_test"],
     deps = [
diff --git a/src/test/java/com/google/devtools/build/lib/blackbox/tests/NinjaBlackBoxTest.java b/src/test/java/com/google/devtools/build/lib/blackbox/tests/NinjaBlackBoxTest.java
index 2ff1af6..59d38d6 100644
--- a/src/test/java/com/google/devtools/build/lib/blackbox/tests/NinjaBlackBoxTest.java
+++ b/src/test/java/com/google/devtools/build/lib/blackbox/tests/NinjaBlackBoxTest.java
@@ -21,6 +21,7 @@
 import com.google.devtools.build.lib.blackbox.junit.AbstractBlackBoxTest;
 import java.nio.file.Files;
 import java.nio.file.Path;
+import java.util.List;
 import org.junit.Test;
 
 /** Integration test for Ninja execution functionality. */
@@ -201,4 +202,42 @@
         .doesNotContain(
             "INFO: Analyzed target //:graph (0 packages loaded, 0 targets configured).");
   }
+
+  @Test
+  public void testNullBuild() throws Exception {
+    context().write(".bazelignore", "build_config");
+    context()
+        .write(
+            WORKSPACE,
+            "workspace(name = 'test')",
+            "dont_symlink_directories_in_execroot(paths = ['build_config'])");
+    // Print nanoseconds fraction of the current time into the output file.
+    context()
+        .write(
+            "build_config/build.ninja",
+            "rule echo_time",
+            "  command = date +%N >> ${out}",
+            "build nano.txt: echo_time");
+    context()
+        .write(
+            "BUILD",
+            "ninja_graph(name = 'graph', ",
+            "output_root = 'build_config',",
+            " working_directory = 'build_config',",
+            " main = 'build_config/build.ninja',",
+            " output_groups = {'main': ['nano.txt']})");
+
+    BuilderRunner bazel = context().bazel().withFlags("--experimental_ninja_actions");
+    assertConfigured(bazel.build("//:graph"));
+    Path path = context().resolveExecRootPath(bazel, "build_config/nano.txt");
+    assertThat(path.toFile().exists()).isTrue();
+    List<String> text = Files.readAllLines(path);
+    assertThat(text).isNotEmpty();
+    long lastModified = path.toFile().lastModified();
+
+    // Should be null build, as nothing changed.
+    assertNothingConfigured(bazel.build("//:graph"));
+    assertThat(Files.readAllLines(path)).containsExactly(text.get(0));
+    assertThat(path.toFile().lastModified()).isEqualTo(lastModified);
+  }
 }
diff --git a/src/test/java/com/google/devtools/build/lib/buildtool/SymlinkForestTest.java b/src/test/java/com/google/devtools/build/lib/buildtool/SymlinkForestTest.java
index 9792ccc..49278d9 100644
--- a/src/test/java/com/google/devtools/build/lib/buildtool/SymlinkForestTest.java
+++ b/src/test/java/com/google/devtools/build/lib/buildtool/SymlinkForestTest.java
@@ -36,6 +36,7 @@
 import com.google.devtools.build.lib.vfs.Symlinks;
 import com.google.devtools.build.lib.vfs.inmemoryfs.InMemoryFileSystem;
 import java.io.IOException;
+import java.nio.charset.StandardCharsets;
 import org.junit.Before;
 import org.junit.Test;
 import org.junit.runner.RunWith;
@@ -129,7 +130,7 @@
   @Test
   public void testDeleteTreesBelowNotPrefixed() throws IOException {
     createTestDirectoryTree();
-    SymlinkForest.deleteTreesBelowNotPrefixed(topDir, "file-");
+    new SymlinkForest(ImmutableMap.of(), topDir, "").deleteTreesBelowNotPrefixed(topDir, "file-");
     assertThat(file1.exists()).isTrue();
     assertThat(file2.exists()).isTrue();
     assertThat(aDir.exists()).isFalse();
@@ -427,6 +428,51 @@
   }
 
   @Test
+  public void testNotSymlinkedDirectoriesNotDeletedBetweenCommands() throws Exception {
+    Root outputBase = Root.fromPath(fileSystem.getPath("/ob"));
+    Root mainRepo = Root.fromPath(fileSystem.getPath("/my_repo"));
+    Path linkRoot = outputBase.getRelative("execroot/ws_name");
+
+    linkRoot.createDirectoryAndParents();
+    mainRepo.asPath().createDirectoryAndParents();
+    mainRepo.getRelative("build").createDirectoryAndParents();
+
+    ImmutableMap<PackageIdentifier, Root> packageRootMap =
+        ImmutableMap.<PackageIdentifier, Root>builder()
+            .put(createMainPkg(mainRepo, "dir1/pkg"), mainRepo)
+            // Empty package will cause every top-level files to be linked, except external/
+            .put(createMainPkg(mainRepo, ""), mainRepo)
+            .build();
+
+    SymlinkForest symlinkForest =
+        new SymlinkForest(
+            packageRootMap, linkRoot, TestConstants.PRODUCT_NAME, ImmutableSortedSet.of("build"));
+    symlinkForest.plantSymlinkForest();
+
+    assertLinksTo(linkRoot, mainRepo, "dir1");
+    assertThat(linkRoot.getChild("build").exists()).isFalse();
+
+    // Create some file in 'build' directory under exec root.
+    Path notSymlinkedDir = linkRoot.getChild("build");
+    notSymlinkedDir.createDirectoryAndParents();
+
+    byte[] bytes = "text".getBytes(StandardCharsets.ISO_8859_1);
+    Path childPath = notSymlinkedDir.getChild("child.txt");
+    FileSystemUtils.writeContent(childPath, bytes);
+
+    symlinkForest.plantSymlinkForest();
+
+    assertLinksTo(linkRoot, mainRepo, "dir1");
+    // Exists because it was explicitly created.
+    assertThat(linkRoot.getChild("build").exists()).isTrue();
+    // The presence of the manually added file indicates that SymlinkForest did not delete
+    // the directory it's in.
+    assertThat(childPath.exists()).isTrue();
+    assertThat(FileSystemUtils.readContent(childPath, StandardCharsets.ISO_8859_1))
+        .isEqualTo("text");
+  }
+
+  @Test
   public void testNotSymlinkedDirectoriesInExecRootPartialMainRepo1() throws Exception {
     Root outputBase = Root.fromPath(fileSystem.getPath("/ob"));
     Root mainRepo = Root.fromPath(fileSystem.getPath("/my_repo"));