Merge "Add canonical cross-browser favicon package"
diff --git a/java/com/google/gitiles/blame/cache/BlameCache.java b/java/com/google/gitiles/blame/cache/BlameCache.java
index 57a9166..7cb7bad 100644
--- a/java/com/google/gitiles/blame/cache/BlameCache.java
+++ b/java/com/google/gitiles/blame/cache/BlameCache.java
@@ -16,6 +16,7 @@
 
 import java.io.IOException;
 import java.util.List;
+import java.util.Set;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
 
@@ -28,6 +29,20 @@
   List<Region> get(Repository repo, ObjectId commitId, String path) throws IOException;
 
   /**
+   * Gets the blame of a path at a given commit, ignoring the specified revisions.
+   *
+   * @return the blame of a path at a given commit.
+   */
+  default List<Region> get(
+      Repository repo, ObjectId commitId, String path, Set<ObjectId> ignoreIds)
+      throws IOException {
+    if (ignoreIds == null || ignoreIds.isEmpty()) {
+      return get(repo, commitId, path);
+    }
+    throw new UnsupportedOperationException();
+  }
+
+  /**
    * Gets the last commit that modified a path.
    *
    * @return the last commit that modified a path, starting at the given commit.
diff --git a/java/com/google/gitiles/blame/cache/BlameCacheImpl.java b/java/com/google/gitiles/blame/cache/BlameCacheImpl.java
index ba21aaa..1bed1c2 100644
--- a/java/com/google/gitiles/blame/cache/BlameCacheImpl.java
+++ b/java/com/google/gitiles/blame/cache/BlameCacheImpl.java
@@ -139,8 +139,15 @@
 
   @Override
   public List<Region> get(Repository repo, ObjectId commitId, String path) throws IOException {
+    return get(repo, commitId, path, ImmutableSet.of());
+  }
+
+  @Override
+  public List<Region> get(
+      Repository repo, ObjectId commitId, String path, Set<ObjectId> ignoreIds)
+      throws IOException {
     try {
-      Key key = new Key(commitId, path);
+      Key key = new Key(commitId, path, ignoreIds);
       return cache.get(key, newLoader(key, repo));
     } catch (ExecutionException e) {
       throw new IOException(e);
@@ -170,6 +177,9 @@
     }
 
     try (BlameGenerator gen = new BlameGenerator(repo, key.path)) {
+      if (!key.getIgnoreIds().isEmpty()) {
+        gen.setIgnoreRevs(key.getIgnoreIds());
+      }
       gen.push(null, blameCommit);
       if (gen.getResultContents() == null) {
         return ImmutableList.of();
diff --git a/java/com/google/gitiles/doc/SimpleMermaidRenderer.java b/java/com/google/gitiles/doc/SimpleMermaidRenderer.java
index d4d5d56..576b72f 100644
--- a/java/com/google/gitiles/doc/SimpleMermaidRenderer.java
+++ b/java/com/google/gitiles/doc/SimpleMermaidRenderer.java
@@ -218,8 +218,10 @@
       return isEof() ? '\0' : text.charAt(pos);
     }
 
-    char next() {
-      return isEof() ? '\0' : text.charAt(pos++);
+    void advance() {
+      if (!isEof()) {
+        pos++;
+      }
     }
 
     boolean startsWith(String prefix) {
@@ -231,7 +233,19 @@
       return text.substring(pos, pos + prefix.length()).equalsIgnoreCase(prefix);
     }
 
-    boolean consume(String prefix) {
+    void skip(String prefix) {
+      if (startsWith(prefix)) {
+        pos += prefix.length();
+      }
+    }
+
+    void skipIgnoreCase(String prefix) {
+      if (startsWithIgnoreCase(prefix)) {
+        pos += prefix.length();
+      }
+    }
+
+    boolean tryConsume(String prefix) {
       if (startsWith(prefix)) {
         pos += prefix.length();
         return true;
@@ -239,7 +253,7 @@
       return false;
     }
 
-    boolean consumeIgnoreCase(String prefix) {
+    boolean tryConsumeIgnoreCase(String prefix) {
       if (startsWithIgnoreCase(prefix)) {
         pos += prefix.length();
         return true;
@@ -365,7 +379,7 @@
         continue;
       }
 
-      if (s.consumeIgnoreCase("graph") || s.consumeIgnoreCase("flowchart")) {
+      if (s.tryConsumeIgnoreCase("graph") || s.tryConsumeIgnoreCase("flowchart")) {
         s.skipWhitespace();
         String dirStr = s.scanIdentifier().toUpperCase();
         try {
@@ -434,7 +448,7 @@
         continue;
       }
 
-      if (s.consumeIgnoreCase("direction")) {
+      if (s.tryConsumeIgnoreCase("direction")) {
         s.skipWhitespace();
         String dirStr = s.scanIdentifier().toUpperCase();
         if (!subgraphStack.isEmpty() && !dirStr.isEmpty()) {
@@ -448,7 +462,7 @@
         continue;
       }
 
-      if (s.consumeIgnoreCase("end")) {
+      if (s.tryConsumeIgnoreCase("end")) {
         char nextC = s.peek();
         if (nextC == '\0' || Character.isWhitespace(nextC) || nextC == ';') {
           if (!subgraphStack.isEmpty()) {
@@ -459,7 +473,7 @@
         }
       }
 
-      if (s.consumeIgnoreCase("subgraph")) {
+      if (s.tryConsumeIgnoreCase("subgraph")) {
         parseSubgraphHeader(s, graph, subgraphStack);
         s.skipToStatementEnd();
         continue;
@@ -480,33 +494,33 @@
 
     // Check for `subgraph "Title Only"`
     if (s.startsWith("\"")) {
-      s.consume("\"");
+      s.skip("\"");
       int start = s.pos;
-      while (!s.isEof() && !s.startsWith("\"")) s.next();
+      while (!s.isEof() && !s.startsWith("\"")) s.advance();
       sgTitle = s.text.substring(start, s.pos);
-      s.consume("\"");
+      s.skip("\"");
       sgId = "sg_" + graph.allSubgraphs.size();
     } else {
       int start = s.pos;
       while (!s.isEof() && !s.startsWith("[") && !s.startsWith("\"") && s.peek() != '\n' && s.peek() != ';') {
-        s.next();
+        s.advance();
       }
       String rawName = s.text.substring(start, s.pos).trim();
       s.skipWhitespace();
       if (s.startsWith("[")) {
-        s.consume("[");
+        s.skip("[");
         s.skipWhitespace();
-        boolean quoted = s.consume("\"");
+        boolean quoted = s.tryConsume("\"");
         int tstart = s.pos;
         if (quoted) {
-          while (!s.isEof() && !s.startsWith("\"]") && !s.startsWith("\"")) s.next();
+          while (!s.isEof() && !s.startsWith("\"]") && !s.startsWith("\"")) s.advance();
           sgTitle = s.text.substring(tstart, s.pos);
-          s.consume("\"");
-          s.consume("]");
+          s.skip("\"");
+          s.skip("]");
         } else {
-          while (!s.isEof() && !s.startsWith("]")) s.next();
+          while (!s.isEof() && !s.startsWith("]")) s.advance();
           sgTitle = s.text.substring(tstart, s.pos);
-          s.consume("]");
+          s.skip("]");
         }
         sgId = rawName;
       } else {
@@ -532,7 +546,7 @@
   }
 
   private static void parseStyleDirective(CharScanner s, MermaidGraph graph) {
-    s.consumeIgnoreCase("style");
+    s.skipIgnoreCase("style");
     s.skipWhitespace();
     String targetId = s.scanIdentifier();
     if (targetId.isEmpty()) {
@@ -678,7 +692,7 @@
     while (!s.isEof()) {
       s.skipWhitespace();
       if (s.startsWith("&")) {
-        s.consume("&");
+        s.skip("&");
         s.skipWhitespace();
         RawNodeToken next = scanNodeToken(s);
         if (next != null) {
@@ -781,11 +795,11 @@
       String close = d[1];
       String shapeName = d[2];
       if (s.startsWith(open)) {
-        s.consume(open);
+        s.skip(open);
         shape = NodeShape.valueOf(shapeName);
         s.skipWhitespace();
         if (s.startsWith("\"")) {
-          s.consume("\"");
+          s.skip("\"");
           int start = s.pos;
           while (!s.isEof() && s.peek() != '\n' && s.peek() != '\r') {
             if (s.startsWith("\\\"")) {
@@ -793,20 +807,20 @@
             } else if (s.startsWith("\"")) {
               break;
             } else {
-              s.next();
+              s.advance();
             }
           }
           label = s.text.substring(start, s.pos);
-          s.consume("\"");
+          s.skip("\"");
           s.skipWhitespace();
-          s.consume(close);
+          s.skip(close);
         } else {
           int start = s.pos;
           while (!s.isEof() && s.peek() != '\n' && !s.startsWith(close)) {
-            s.next();
+            s.advance();
           }
           label = s.text.substring(start, s.pos);
-          s.consume(close);
+          s.skip(close);
         }
         break;
       }
@@ -823,43 +837,43 @@
     if ((s.startsWith("-- ") || s.startsWith("--\"") || s.startsWith("--\t"))
         && !s.startsWith("-->")
         && !s.startsWith("---|")) {
-      s.consume("--");
+      s.skip("--");
       s.skipWhitespace();
       int start = s.pos;
       while (!s.isEof() && s.peek() != '\n' && !s.startsWith("-->") && !s.startsWith("---")) {
-        s.next();
+        s.advance();
       }
       String label = cleanLabel(s.text.substring(start, s.pos));
-      boolean arrow = s.consume("-->");
-      if (!arrow) s.consume("---");
+      boolean arrow = s.tryConsume("-->");
+      if (!arrow) s.skip("---");
       return new RawEdgeToken(EdgeStroke.SOLID, arrow, label);
     }
 
     if ((s.startsWith("== ") || s.startsWith("==\"") || s.startsWith("==\t"))
         && !s.startsWith("==>")
         && !s.startsWith("===|")) {
-      s.consume("==");
+      s.skip("==");
       s.skipWhitespace();
       int start = s.pos;
       while (!s.isEof() && s.peek() != '\n' && !s.startsWith("==>") && !s.startsWith("===")) {
-        s.next();
+        s.advance();
       }
       String label = cleanLabel(s.text.substring(start, s.pos));
-      boolean arrow = s.consume("==>");
-      if (!arrow) s.consume("===");
+      boolean arrow = s.tryConsume("==>");
+      if (!arrow) s.skip("===");
       return new RawEdgeToken(EdgeStroke.THICK, arrow, label);
     }
 
     if (s.startsWith("-. ") || s.startsWith("-.\"") || s.startsWith("-.\t")) {
-      s.consume("-.");
+      s.skip("-.");
       s.skipWhitespace();
       int start = s.pos;
       while (!s.isEof() && s.peek() != '\n' && !s.startsWith(".->") && !s.startsWith(".-")) {
-        s.next();
+        s.advance();
       }
       String label = cleanLabel(s.text.substring(start, s.pos));
-      boolean arrow = s.consume(".->");
-      if (!arrow) s.consume(".-");
+      boolean arrow = s.tryConsume(".->");
+      if (!arrow) s.skip(".-");
       return new RawEdgeToken(EdgeStroke.DASHED, arrow, label);
     }
 
@@ -879,17 +893,17 @@
       EdgeStroke stroke = EdgeStroke.valueOf(op[1]);
       boolean arrow = Boolean.parseBoolean(op[2]);
       if (s.startsWith(prefix)) {
-        s.consume(prefix);
+        s.skip(prefix);
         s.skipWhitespace();
         String label = null;
         if (s.startsWith("|")) {
-          s.consume("|");
+          s.skip("|");
           int start = s.pos;
           while (!s.isEof() && s.peek() != '\n' && !s.startsWith("|")) {
-            s.next();
+            s.advance();
           }
           label = cleanLabel(s.text.substring(start, s.pos));
-          s.consume("|");
+          s.skip("|");
         }
         return new RawEdgeToken(stroke, arrow, label);
       }
diff --git a/javatests/com/google/gitiles/blame/cache/BlameCacheTest.java b/javatests/com/google/gitiles/blame/cache/BlameCacheTest.java
index 1ead02a..10ff25d 100644
--- a/javatests/com/google/gitiles/blame/cache/BlameCacheTest.java
+++ b/javatests/com/google/gitiles/blame/cache/BlameCacheTest.java
@@ -15,16 +15,37 @@
 package com.google.gitiles.blame.cache;
 
 import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
 
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
+import java.io.IOException;
+import java.util.List;
+import java.util.Set;
+import org.eclipse.jgit.internal.storage.dfs.DfsRepository;
+import org.eclipse.jgit.internal.storage.dfs.DfsRepositoryDescription;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Before;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.junit.runners.JUnit4;
 
-/** Unit tests for {@link BlameCacheImpl.Key}. */
+/** Unit tests for {@link BlameCache} and {@link BlameCacheImpl}. */
 @RunWith(JUnit4.class)
 public class BlameCacheTest {
+  private TestRepository<DfsRepository> repo;
+  private BlameCacheImpl blameCache;
+
+  @Before
+  public void setUp() throws Exception {
+    repo = new TestRepository<>(new InMemoryRepository(new DfsRepositoryDescription("test")));
+    blameCache = new BlameCacheImpl();
+  }
+
   @Test
   public void keyEqualsAndHashCode() {
     ObjectId c1 = ObjectId.fromString("1111111111111111111111111111111111111111");
@@ -64,4 +85,104 @@
                 + " ignore=[2222222222222222222222222222222222222222,"
                 + " 3333333333333333333333333333333333333333]");
   }
+
+  @Test
+  public void getWithoutIgnoreIdsAttributesToLastCommit() throws Exception {
+    RevCommit c1 = repo.commit().add("foo.txt", "line1\nline2\n").create();
+    RevCommit c2 =
+        repo.commit().parent(c1).add("foo.txt", "line1_formatted\nline2_formatted\n").create();
+
+    List<Region> regions = blameCache.get(repo.getRepository(), c2, "foo.txt");
+    assertThat(regions).hasSize(1);
+    assertThat(regions.get(0).getSourceCommit()).isEqualTo(c2);
+    assertThat(regions.get(0).getStart()).isEqualTo(0);
+    assertThat(regions.get(0).getEnd()).isEqualTo(2);
+  }
+
+  @Test
+  public void getWithIgnoredCommitAttributesToParentCommit() throws Exception {
+    RevCommit c1 = repo.commit().add("foo.txt", "line1\nline2\n").create();
+    RevCommit c2 =
+        repo.commit().parent(c1).add("foo.txt", "line1_formatted\nline2_formatted\n").create();
+
+    List<Region> regions =
+        blameCache.get(repo.getRepository(), c2, "foo.txt", ImmutableSet.of(c2));
+    assertThat(regions).hasSize(1);
+    assertThat(regions.get(0).getSourceCommit()).isEqualTo(c1);
+    assertThat(regions.get(0).getStart()).isEqualTo(0);
+    assertThat(regions.get(0).getEnd()).isEqualTo(2);
+  }
+
+  @Test
+  public void cacheIsolationWithDifferentIgnoreIds() throws Exception {
+    RevCommit c1 = repo.commit().add("foo.txt", "line1\nline2\n").create();
+    RevCommit c2 =
+        repo.commit().parent(c1).add("foo.txt", "line1_formatted\nline2_formatted\n").create();
+
+    // Query 1: standard blame
+    List<Region> unignored = blameCache.get(repo.getRepository(), c2, "foo.txt");
+    assertThat(unignored.get(0).getSourceCommit()).isEqualTo(c2);
+
+    // Query 2: ignored blame
+    List<Region> ignored =
+        blameCache.get(repo.getRepository(), c2, "foo.txt", ImmutableSet.of(c2));
+    assertThat(ignored.get(0).getSourceCommit()).isEqualTo(c1);
+
+    // Verify both are cached under separate keys
+    assertThat(blameCache.getCache().asMap()).containsKey(new BlameCacheImpl.Key(c2, "foo.txt"));
+    assertThat(blameCache.getCache().asMap())
+        .containsKey(new BlameCacheImpl.Key(c2, "foo.txt", ImmutableSet.of(c2)));
+    assertThat(blameCache.getCache().size()).isEqualTo(2);
+  }
+
+  @Test
+  public void getWithMultipleConsecutiveIgnoredCommits() throws Exception {
+    RevCommit c1 = repo.commit().add("foo.txt", "line1\nline2\n").create();
+    RevCommit c2 =
+        repo.commit().parent(c1).add("foo.txt", "line1_format1\nline2_format1\n").create();
+    RevCommit c3 =
+        repo.commit().parent(c2).add("foo.txt", "line1_format2\nline2_format2\n").create();
+
+    // Ignore both c2 and c3 -> blame should walk back across both to c1
+    List<Region> regions =
+        blameCache.get(repo.getRepository(), c3, "foo.txt", ImmutableSet.of(c2, c3));
+    assertThat(regions).hasSize(1);
+    assertThat(regions.get(0).getSourceCommit()).isEqualTo(c1);
+    assertThat(regions.get(0).getStart()).isEqualTo(0);
+    assertThat(regions.get(0).getEnd()).isEqualTo(2);
+  }
+
+  @Test
+  public void defaultInterfaceMethodDelegatesWhenEmpty() throws Exception {
+    BlameCache customCache =
+        new BlameCache() {
+          @Override
+          public List<Region> get(Repository repo, ObjectId commitId, String path) {
+            return ImmutableList.of(new Region(null, null, null, 0, 1));
+          }
+
+          @Override
+          public ObjectId findLastCommit(Repository repo, ObjectId commitId, String path) {
+            return ObjectId.zeroId();
+          }
+        };
+
+    // When ignoreIds is null or empty, default method delegates to get(repo, commit, path)
+    List<Region> res1 = customCache.get(repo.getRepository(), ObjectId.zeroId(), "foo.txt", null);
+    assertThat(res1).hasSize(1);
+
+    List<Region> res2 =
+        customCache.get(repo.getRepository(), ObjectId.zeroId(), "foo.txt", ImmutableSet.of());
+    assertThat(res2).hasSize(1);
+
+    // When ignoreIds is non-empty, default method throws UnsupportedOperationException
+    assertThrows(
+        UnsupportedOperationException.class,
+        () ->
+            customCache.get(
+                repo.getRepository(),
+                ObjectId.zeroId(),
+                "foo.txt",
+                ImmutableSet.of(ObjectId.zeroId())));
+  }
 }
diff --git a/javatests/com/google/gitiles/doc/SimpleMermaidRendererTest.java b/javatests/com/google/gitiles/doc/SimpleMermaidRendererTest.java
index 6ae35c1..093b8f0 100644
--- a/javatests/com/google/gitiles/doc/SimpleMermaidRendererTest.java
+++ b/javatests/com/google/gitiles/doc/SimpleMermaidRendererTest.java
@@ -17,7 +17,6 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import com.google.common.base.Splitter;
-import com.google.common.primitives.Doubles;
 import java.util.List;
 import java.util.Optional;
 import org.junit.Test;
@@ -621,7 +620,7 @@
 
     // Mutual same-layer edge with label
     String code5 = "graph TD\n  A --> B\n  B --> A\n  A -->|Golden Star| B\n  C --> D\n";
-    render(code5);
+    assertThat(render(code5)).isNotNull();
 
     // Trailing non-edge characters to hit scanEdgeToken default return null
     String code6 = "graph TD\n  A 12345\n";
@@ -1303,10 +1302,14 @@
       String d = p.getAttribute("d");
       if (d.contains(" C ")) {
         for (String part : Splitter.onPattern("[,\\s]+").omitEmptyStrings().split(d)) {
-          Double val = Doubles.tryParse(part);
-          if (val != null && val > dRight) {
-            foundClearanceLoop = true;
-            break;
+          try {
+            double val = Double.parseDouble(part);
+            if (val > dRight) {
+              foundClearanceLoop = true;
+              break;
+            }
+          } catch (NumberFormatException e) {
+            // Ignore non-numeric path commands (e.g. "C", "M")
           }
         }
       }