Repository: kythe/kythe
Pull request #4710 (2020-10-13 17:25:18)

feat(serving): plumb revision data for XRefService responses


Patch:
diff --git a/kythe/go/serving/xrefs/columnar.go b/kythe/go/serving/xrefs/columnar.go
index 6e32b7e34..efd2831d8 100644
--- a/kythe/go/serving/xrefs/columnar.go
+++ b/kythe/go/serving/xrefs/columnar.go
@@ -83,6 +83,7 @@ func (c *ColumnarTable) Decorations(ctx context.Context, req *xpb.DecorationsReq
 
 	// TODO(schroederc): handle SPAN requests
 	// TODO(schroederc): handle dirty buffers
+	// TODO(schroederc): file infos
 
 	fileURI, err := kytheuri.Parse(ticket)
 	if err != nil {
@@ -221,7 +222,7 @@ func (c *ColumnarTable) Decorations(ctx context.Context, req *xpb.DecorationsReq
 			if !defs.Contains(def.Location.Ticket) {
 				continue
 			}
-			reply.DefinitionLocations[def.Location.Ticket] = a2a(def.Location, emitSnippets).Anchor
+			reply.DefinitionLocations[def.Location.Ticket] = a2a(def.Location, nil, emitSnippets).Anchor
 		case *xspb.FileDecorations_Override_:
 			// TODO(schroederc): handle
 		case *xspb.FileDecorations_Diagnostic_:
@@ -270,6 +271,7 @@ func (c *ColumnarTable) CrossReferences(ctx context.Context, req *xpb.CrossRefer
 	}
 	emitSnippets := req.Snippets != xpb.SnippetsKind_NONE
 
+	// TODO(schroederc): file infos
 	// TODO(schroederc): implement paging xrefs in large CrossReferencesReply messages
 
 	for _, ticket := range req.Ticket {
@@ -343,7 +345,7 @@ func (c *ColumnarTable) CrossReferences(ctx context.Context, req *xpb.CrossRefer
 					anchors = &set.Reference
 				}
 				if anchors != nil {
-					a := a2a(ref.Location, emitSnippets).Anchor
+					a := a2a(ref.Location, nil, emitSnippets).Anchor
 					a.Ticket = ""
 					ra := &xpb.CrossReferencesReply_RelatedAnchor{Anchor: a}
 					*anchors = append(*anchors, ra)
@@ -383,7 +385,7 @@ func (c *ColumnarTable) CrossReferences(ctx context.Context, req *xpb.CrossRefer
 				if node := reply.Nodes[relatedNode]; node != nil {
 					loc := e.NodeDefinition.Location
 					node.Definition = loc.Ticket
-					a := a2a(loc, emitSnippets).Anchor
+					a := a2a(loc, nil, emitSnippets).Anchor
 					reply.DefinitionLocations[loc.Ticket] = a
 				}
 			case *xspb.CrossReferences_Caller_:
@@ -391,7 +393,7 @@ func (c *ColumnarTable) CrossReferences(ctx context.Context, req *xpb.CrossRefer
 					continue
 				}
 				c := e.Caller
-				a := a2a(c.Location, emitSnippets).Anchor
+				a := a2a(c.Location, nil, emitSnippets).Anchor
 				a.Ticket = ""
 				callerTicket := kytheuri.ToString(c.Caller)
 				caller := &xpb.CrossReferencesReply_RelatedAnchor{
@@ -412,7 +414,7 @@ func (c *ColumnarTable) CrossReferences(ctx context.Context, req *xpb.CrossRefer
 					log.Printf("WARNING: missing Caller for callsite: %+v", c)
 					continue
 				}
-				a := a2a(c.Location, emitSnippets).Anchor
+				a := a2a(c.Location, nil, emitSnippets).Anchor
 				a.Ticket = ""
 				// TODO(schroederc): set anchor kind to differentiate kinds?
 				caller.Site = append(caller.Site, a)
diff --git a/kythe/go/serving/xrefs/xrefs.go b/kythe/go/serving/xrefs/xrefs.go
index 3a436b009..84cf38788 100644
--- a/kythe/go/serving/xrefs/xrefs.go
+++ b/kythe/go/serving/xrefs/xrefs.go
@@ -196,6 +196,8 @@ func nodeToInfo(patterns []*regexp.Regexp, n *srvpb.Node) *cpb.NodeInfo {
 	return ni
 }
 
+func corpusPathTicket(cp *cpb.CorpusPath) string { return kytheuri.FromCorpusPath(cp).String() }
+
 // Decorations implements part of the xrefs Service interface.
 func (t *Table) Decorations(ctx context.Context, req *xpb.DecorationsRequest) (*xpb.DecorationsReply, error) {
 	if req.GetLocation() == nil || req.GetLocation().Ticket == "" {
@@ -244,9 +246,27 @@ func (t *Table) Decorations(ctx context.Context, req *xpb.DecorationsRequest) (*
 		return nil, err
 	}
 
+	fileInfos := makeFileInfoMap(decor.FileInfo)
+
 	reply := &xpb.DecorationsReply{
 		Location:    loc,
 		GeneratedBy: decor.GeneratedBy,
+		Revision:    fileInfos[loc.GetTicket()].GetRevision(),
+	}
+
+	for _, g := range decor.GeneratedBy {
+		uri, err := kytheuri.Parse(g)
+		if err != nil {
+			return nil, fmt.Errorf("unable to parse generated_by ticket %q: %w", g, err)
+		}
+		reply.GeneratedByFile = append(reply.GeneratedByFile, &xpb.File{
+			CorpusPath: &cpb.CorpusPath{
+				Corpus: uri.Corpus,
+				Root:   uri.Root,
+				Path:   uri.Path,
+			},
+			Revision: fileInfos[g].GetRevision(),
+		})
 	}
 
 	if req.SourceText {
@@ -299,7 +319,7 @@ func (t *Table) Decorations(ctx context.Context, req *xpb.DecorationsRequest) (*
 		// All known definition locations (Anchor.Ticket -> Anchor)
 		defs := make(map[string]*xpb.Anchor, len(decor.TargetDefinitions))
 		for _, def := range decor.TargetDefinitions {
-			defs[def.Ticket] = a2a(def, false).Anchor
+			defs[def.Ticket] = a2a(def, fileInfos, false).Anchor
 		}
 		if req.TargetDefinitions {
 			reply.DefinitionLocations = make(map[string]*xpb.Anchor, len(decor.TargetDefinitions))
@@ -337,6 +357,9 @@ func (t *Table) Decorations(ctx context.Context, req *xpb.DecorationsRequest) (*
 				r.SemanticScope = ""
 			}
 
+			// Populate any target revision, if known
+			r.TargetRevision = fileInfos[r.TargetTicket].GetRevision()
+
 			if req.ExtendsOverrides && (r.Kind == edges.Defines || r.Kind == edges.DefinesBinding) {
 				bindings.Add(r.TargetTicket)
 			}
@@ -417,6 +440,14 @@ func (t *Table) Decorations(ctx context.Context, req *xpb.DecorationsRequest) (*
 	return reply, nil
 }
 
+func makeFileInfoMap(infos []*srvpb.FileInfo) map[string]*srvpb.FileInfo {
+	fileInfos := make(map[string]*srvpb.FileInfo, len(infos))
+	for _, info := range infos {
+		fileInfos[corpusPathTicket(info.CorpusPath)] = info
+	}
+	return fileInfos
+}
+
 func decorationToReference(norm *span.Normalizer, d *srvpb.FileDecorations_Decoration) *xpb.DecorationsReply_Reference {
 	span := norm.SpanOffsets(d.Anchor.StartOffset, d.Anchor.EndOffset)
 	return &xpb.DecorationsReply_Reference{
@@ -774,6 +805,7 @@ func (s *refStats) skipPage(idx *srvpb.PagedCrossReferences_PageIndex) bool {
 
 func (s *refStats) addCallers(crs *xpb.CrossReferencesReply_CrossReferenceSet, grp *srvpb.PagedCrossReferences_Group) bool {
 	cs := grp.Caller
+	fileInfos := makeFileInfoMap(grp.FileInfo)
 
 	if s.done() {
 		// We've already hit our cap; return true that we're done.
@@ -794,13 +826,13 @@ func (s *refStats) addCallers(crs *xpb.CrossReferencesReply_CrossReferenceSet, g
 	s.total += len(cs)
 	for _, c := range cs {
 		ra := &xpb.CrossReferencesReply_RelatedAnchor{
-			Anchor: a2a(c.Caller, false).Anchor,
+			Anchor: a2a(c.Caller, fileInfos, false).Anchor,
 			Ticket: c.SemanticCaller,
 			Site:   make([]*xpb.Anchor, 0, len(c.Callsite)),
 		}
 		ra.MarkedSource = c.MarkedSource
 		for _, site := range c.Callsite {
-			ra.Site = append(ra.Site, a2a(site, false).Anchor)
+			ra.Site = append(ra.Site, a2a(site, fileInfos, false).Anchor)
 		}
 		crs.Caller = append(crs.Caller, ra)
 	}
@@ -811,6 +843,7 @@ func (s *refStats) addRelatedNodes(reply *xpb.CrossReferencesReply, crs *xpb.Cro
 	ns := grp.RelatedNode
 	nodes := reply.Nodes
 	defs := reply.DefinitionLocations
+	fileInfos := makeFileInfoMap(grp.FileInfo)
 
 	if s.total == s.max {
 		// We've already hit our cap; return true that we're done.
@@ -835,7 +868,7 @@ func (s *refStats) addRelatedNodes(reply *xpb.CrossReferencesReply, crs *xpb.Cro
 				nodes[rn.Node.Ticket] = info
 				if defs != nil && rn.Node.DefinitionLocation != nil {
 					nodes[rn.Node.Ticket].Definition = rn.Node.DefinitionLocation.Ticket
-					defs[rn.Node.DefinitionLocation.Ticket] = a2a(rn.Node.DefinitionLocation, false).Anchor
+					defs[rn.Node.DefinitionLocation.Ticket] = a2a(rn.Node.DefinitionLocation, fileInfos, false).Anchor
 				}
 			}
 		}
@@ -851,6 +884,7 @@ func (s *refStats) addRelatedNodes(reply *xpb.CrossReferencesReply, crs *xpb.Cro
 func (s *refStats) addAnchors(to *[]*xpb.CrossReferencesReply_RelatedAnchor, grp *srvpb.PagedCrossReferences_Group, anchorText bool) bool {
 	kind := edges.Canonical(grp.Kind)
 	as := grp.Anchor
+	fileInfos := makeFileInfoMap(grp.FileInfo)
 
 	if s.total == s.max {
 		return true
@@ -867,14 +901,14 @@ func (s *refStats) addAnchors(to *[]*xpb.CrossReferencesReply_RelatedAnchor, grp
 	}
 	s.total += len(as)
 	for _, a := range as {
-		ra := a2a(a, anchorText)
+		ra := a2a(a, fileInfos, anchorText)
 		ra.Anchor.Kind = kind
 		*to = append(*to, ra)
 	}
 	return s.total == s.max
 }
 
-func a2a(a *srvpb.ExpandedAnchor, anchorText bool) *xpb.CrossReferencesReply_RelatedAnchor {
+func a2a(a *srvpb.ExpandedAnchor, fileInfos map[string]*srvpb.FileInfo, anchorText bool) *xpb.CrossReferencesReply_RelatedAnchor {
 	var text string
 	if anchorText {
 		text = a.Text
@@ -892,10 +926,11 @@ func a2a(a *srvpb.ExpandedAnchor, anchorText bool) *xpb.CrossReferencesReply_Rel
 		Snippet:     a.Snippet,
 		SnippetSpan: a.SnippetSpan,
 		BuildConfig: a.BuildConfiguration,
+		Revision:    fileInfos[parent].GetRevision(),
 	}}
 }
 
-func d2d(d *srvpb.Document, patterns []*regexp.Regexp, nodes map[string]*cpb.NodeInfo, defs map[string]*xpb.Anchor) *xpb.DocumentationReply_Document {
+func d2d(d *srvpb.Document, patterns []*regexp.Regexp, nodes map[string]*cpb.NodeInfo, defs map[string]*xpb.Anchor, fileInfos map[string]*srvpb.FileInfo) *xpb.DocumentationReply_Document {
 	for _, node := range d.Node {
 		if _, ok := nodes[node.Ticket]; ok {
 			continue
@@ -911,7 +946,7 @@ func d2d(d *srvpb.Document, patterns []*regexp.Regexp, nodes map[string]*cpb.Nod
 
 			n.Definition = def.Ticket
 			if _, ok := defs[def.Ticket]; !ok {
-				defs[def.Ticket] = a2a(def, false).Anchor
+				defs[def.Ticket] = a2a(def, fileInfos, false).Anchor
 			}
 		}
 
@@ -976,6 +1011,7 @@ func (t *Table) Documentation(ctx context.Context, req *xpb.DocumentationRequest
 		// Match all facts if given no filters
 		patterns = xrefs.ConvertFilters([]string{"**"})
 	}
+	fileInfos := make(map[string]*srvpb.FileInfo)
 
 	for _, ticket := range tickets {
 		d, err := t.lookupDocument(ctx, ticket)
@@ -985,7 +1021,7 @@ func (t *Table) Documentation(ctx context.Context, req *xpb.DocumentationRequest
 			return nil, canonicalError(err, "documentation", ticket)
 		}
 
-		doc := d2d(d, patterns, reply.Nodes, reply.DefinitionLocations)
+		doc := d2d(d, patterns, reply.Nodes, reply.DefinitionLocations, fileInfos)
 		if req.IncludeChildren {
 			for _, child := range d.ChildTicket {
 				// TODO(schroederc): store children with root of documentation tree
@@ -996,7 +1032,7 @@ func (t *Table) Documentation(ctx context.Context, req *xpb.DocumentationRequest
 					return nil, canonicalError(err, "documentation child", ticket)
 				}
 
-				doc.Children = append(doc.Children, d2d(cd, patterns, reply.Nodes, reply.DefinitionLocations))
+				doc.Children = append(doc.Children, d2d(cd, patterns, reply.Nodes, reply.DefinitionLocations, fileInfos))
 			}
 			tracePrintf(ctx, "Children: %d", len(d.ChildTicket))
 		}


Test patch:
diff --git a/kythe/go/serving/xrefs/xrefs_test.go b/kythe/go/serving/xrefs/xrefs_test.go
index e4a02f177..b4b60a476 100644
--- a/kythe/go/serving/xrefs/xrefs_test.go
+++ b/kythe/go/serving/xrefs/xrefs_test.go
@@ -160,6 +160,9 @@ var (
 		}, {
 			Ticket: "kythe://someCorpus?lang=otpl#withRelated",
 			Fact:   makeFactList("/kythe/node/kind", "testNode"),
+		}, {
+			Ticket: "kythe://someCorpus?lang=otpl#withInfos",
+			Fact:   makeFactList("/kythe/node/kind", "testNode"),
 		}, {
 			Ticket: "kythe:#aliasNode",
 			Fact:   makeFactList("/kythe/node/kind", "talias"),
@@ -286,6 +289,48 @@ var (
 				},
 				GeneratedBy: []string{"kythe://corpus?path=/some/proto.proto"},
 			},
+			{
+				File: &srvpb.File{
+					Ticket:   "kythe://corpus?path=file/infos",
+					Text:     []byte(`some file with infos`),
+					Encoding: "utf-8",
+				},
+				Decoration: []*srvpb.FileDecorations_Decoration{
+					{
+						Anchor: &srvpb.RawAnchor{
+							Ticket:      "kythe://corpus?lang=lang?path=file/infos#0-4",
+							StartOffset: 0,
+							EndOffset:   4,
+						},
+						Kind:   "/kythe/edge/includes/ref",
+						Target: "kythe://corpus?path=another/file",
+					},
+					{
+						Anchor: &srvpb.RawAnchor{
+							Ticket:      "kythe://corpus?lang=lang?path=file/infos#5-9",
+							StartOffset: 5,
+							EndOffset:   9,
+						},
+						Kind:             "/kythe/edge/ref",
+						Target:           "kythe://corpus?path=def/file#node",
+						TargetDefinition: "kythe://corpus?path=def/file#anchor",
+					},
+				},
+				TargetDefinitions: []*srvpb.ExpandedAnchor{{
+					Ticket: "kythe://corpus?path=def/file#anchor",
+					Span: &cpb.Span{
+						Start: &cpb.Point{LineNumber: 1},
+						End:   &cpb.Point{ByteOffset: 4, LineNumber: 1, ColumnOffset: 4},
+					},
+				}},
+				GeneratedBy: []string{"kythe://corpus?path=some/proto.proto"},
+				FileInfo: []*srvpb.FileInfo{
+					fi(cp("corpus", "", "file/infos"), "overallFileRev"),
+					fi(cp("corpus", "", "some/proto.proto"), "generatedRev"),
+					fi(cp("corpus", "", "another/file"), "anotherFileRev"),
+					fi(cp("corpus", "", "def/file"), "defFileRev"),
+				},
+			},
 		},
 
 		RefSets: []*srvpb.PagedCrossReferences{{
@@ -360,6 +405,33 @@ var (
 					},
 				}},
 			}},
+		}, {
+			SourceTicket: "kythe://someCorpus?lang=otpl#withInfos",
+			SourceNode:   getNode("kythe://someCorpus?lang=otpl#withInfos"),
+
+			Group: []*srvpb.PagedCrossReferences_Group{{
+				Kind: "%/kythe/edge/ref",
+				Anchor: []*srvpb.ExpandedAnchor{{
+					Ticket: "kythe://corpus?lang=otpl?path=some/file#27-33",
+
+					Span: &cpb.Span{
+						Start: &cpb.Point{
+							ByteOffset:   27,
+							LineNumber:   2,
+							ColumnOffset: 10,
+						},
+						End: &cpb.Point{
+							ByteOffset:   33,
+							LineNumber:   3,
+							ColumnOffset: 5,
+						},
+					},
+				}},
+
+				FileInfo: []*srvpb.FileInfo{
+					fi(cp("corpus", "", "some/file"), "someFileRev"),
+				},
+			}},
 		}, {
 			SourceTicket: "kythe://someCorpus?lang=otpl#withMerge",
 			SourceNode:   getNode("kythe://someCorpus?lang=otpl#withMerge"),
@@ -599,7 +671,7 @@ func TestDecorationsRefs(t *testing.T) {
 		t.Errorf("Unexpected encoding: %q", reply.Encoding)
 	}
 
-	expected := refs(span.NewNormalizer(d.File.Text), d.Decoration)
+	expected := refs(span.NewNormalizer(d.File.Text), d.Decoration, d.FileInfo)
 	for _, ref := range expected {
 		ref.SemanticScope = "" // not requested
 	}
@@ -624,7 +696,7 @@ func TestDecorationsRefScopes(t *testing.T) {
 	})
 	testutil.FatalOnErrT(t, "DecorationsRequest error: %v", err)
 
-	expected := refs(span.NewNormalizer(d.File.Text), d.Decoration)
+	expected := refs(span.NewNormalizer(d.File.Text), d.Decoration, d.FileInfo)
 	if err := testutil.DeepEqual(expected, reply.Reference); err != nil {
 		t.Fatal(err)
 	}
@@ -722,7 +794,7 @@ func TestDecorationsBuildConfig(t *testing.T) {
 		})
 		testutil.FatalOnErrT(t, "DecorationsRequest error: %v", err)
 
-		expected := refs(span.NewNormalizer(d.File.Text), d.Decoration[:1])
+		expected := refs(span.NewNormalizer(d.File.Text), d.Decoration[:1], d.FileInfo)
 		if err := testutil.DeepEqual(expected, reply.Reference); err != nil {
 			t.Fatal(err)
 		}
@@ -899,6 +971,12 @@ func TestDecorationsGeneratedBy(t *testing.T) {
 	expected := &xpb.DecorationsReply{
 		Location:    &xpb.Location{Ticket: "kythe://corpus?path=/some/proto.proto?root=generated"},
 		GeneratedBy: []string{"kythe://corpus?path=/some/proto.proto"},
+		GeneratedByFile: []*xpb.File{{
+			CorpusPath: &cpb.CorpusPath{
+				Corpus: "corpus",
+				Path:   "/some/proto.proto",
+			},
+		}},
 	}
 
 	if diff := compare.ProtoDiff(expected, reply); diff != "" {
@@ -906,6 +984,161 @@ func TestDecorationsGeneratedBy(t *testing.T) {
 	}
 }
 
+func TestDecorationsRevisions(t *testing.T) {
+	st := tbl.Construct(t)
+
+	t.Run("file/generated_by", func(t *testing.T) {
+		reply, err := st.Decorations(ctx, &xpb.DecorationsRequest{
+			Location: &xpb.Location{Ticket: "kythe://corpus?path=file/infos"},
+		})
+		testutil.FatalOnErrT(t, "DecorationsRequest error: %v", err)
+
+		expected := &xpb.DecorationsReply{
+			Location:    &xpb.Location{Ticket: "kythe://corpus?path=file/infos"},
+			Revision:    "overallFileRev",
+			GeneratedBy: []string{"kythe://corpus?path=some/proto.proto"},
+			GeneratedByFile: []*xpb.File{{
+				CorpusPath: &cpb.CorpusPath{
+					Corpus: "corpus",
+					Path:   "some/proto.proto",
+				},
+				Revision: "generatedRev",
+			}},
+		}
+
+		if diff := compare.ProtoDiff(expected, reply); diff != "" {
+			t.Fatalf("Unexpected diff (- expected; + found):\n%s", diff)
+		}
+	})
+
+	t.Run("target_revision", func(t *testing.T) {
+		reply, err := st.Decorations(ctx, &xpb.DecorationsRequest{
+			Location:   &xpb.Location{Ticket: "kythe://corpus?path=file/infos"},
+			References: true,
+		})
+		testutil.FatalOnErrT(t, "DecorationsRequest error: %v", err)
+
+		expected := &xpb.DecorationsReply{
+			Location:    &xpb.Location{Ticket: "kythe://corpus?path=file/infos"},
+			Revision:    "overallFileRev",
+			GeneratedBy: []string{"kythe://corpus?path=some/proto.proto"},
+			GeneratedByFile: []*xpb.File{{
+				CorpusPath: &cpb.CorpusPath{
+					Corpus: "corpus",
+					Path:   "some/proto.proto",
+				},
+				Revision: "generatedRev",
+			}},
+			Reference: []*xpb.DecorationsReply_Reference{{
+				TargetTicket:   "kythe://corpus?path=another/file",
+				Kind:           "/kythe/edge/includes/ref",
+				TargetRevision: "anotherFileRev",
+				Span: &cpb.Span{
+					Start: &cpb.Point{
+						ByteOffset:   0,
+						LineNumber:   1,
+						ColumnOffset: 0,
+					},
+					End: &cpb.Point{
+						ByteOffset:   4,
+						LineNumber:   1,
+						ColumnOffset: 4,
+					},
+				},
+			}, {
+				TargetTicket: "kythe://corpus?path=def/file#node",
+				Kind:         "/kythe/edge/ref",
+				Span: &cpb.Span{
+					Start: &cpb.Point{
+						ByteOffset:   5,
+						LineNumber:   1,
+						ColumnOffset: 5,
+					},
+					End: &cpb.Point{
+						ByteOffset:   9,
+						LineNumber:   1,
+						ColumnOffset: 9,
+					},
+				},
+			}},
+		}
+
+		if diff := compare.ProtoDiff(expected, reply); diff != "" {
+			t.Fatalf("Unexpected diff (- expected; + found):\n%s", diff)
+		}
+	})
+
+	t.Run("target_defs", func(t *testing.T) {
+		reply, err := st.Decorations(ctx, &xpb.DecorationsRequest{
+			Location:          &xpb.Location{Ticket: "kythe://corpus?path=file/infos"},
+			References:        true,
+			TargetDefinitions: true,
+		})
+		testutil.FatalOnErrT(t, "DecorationsRequest error: %v", err)
+
+		expected := &xpb.DecorationsReply{
+			Location:    &xpb.Location{Ticket: "kythe://corpus?path=file/infos"},
+			Revision:    "overallFileRev",
+			GeneratedBy: []string{"kythe://corpus?path=some/proto.proto"},
+			GeneratedByFile: []*xpb.File{{
+				CorpusPath: &cpb.CorpusPath{
+					Corpus: "corpus",
+					Path:   "some/proto.proto",
+				},
+				Revision: "generatedRev",
+			}},
+			Reference: []*xpb.DecorationsReply_Reference{{
+				TargetTicket:   "kythe://corpus?path=another/file",
+				Kind:           "/kythe/edge/includes/ref",
+				TargetRevision: "anotherFileRev",
+				Span: &cpb.Span{
+					Start: &cpb.Point{
+						ByteOffset:   0,
+						LineNumber:   1,
+						ColumnOffset: 0,
+					},
+					End: &cpb.Point{
+						ByteOffset:   4,
+						LineNumber:   1,
+						ColumnOffset: 4,
+					},
+				},
+			}, {
+				TargetTicket:     "kythe://corpus?path=def/file#node",
+				TargetDefinition: "kythe://corpus?path=def/file#anchor",
+				Kind:             "/kythe/edge/ref",
+				Span: &cpb.Span{
+					Start: &cpb.Point{
+						ByteOffset:   5,
+						LineNumber:   1,
+						ColumnOffset: 5,
+					},
+					End: &cpb.Point{
+						ByteOffset:   9,
+						LineNumber:   1,
+						ColumnOffset: 9,
+					},
+				},
+			}},
+			DefinitionLocations: map[string]*xpb.Anchor{
+				"kythe://corpus?path=def/file#anchor": &xpb.Anchor{
+					Ticket:   "kythe://corpus?path=def/file#anchor",
+					Parent:   "kythe://corpus?path=def/file",
+					Revision: "defFileRev",
+					Span: &cpb.Span{
+						Start: &cpb.Point{LineNumber: 1},
+						End:   &cpb.Point{ByteOffset: 4, LineNumber: 1, ColumnOffset: 4},
+					},
+				},
+			},
+		}
+
+		if diff := compare.ProtoDiff(expected, reply); diff != "" {
+			t.Fatalf("Unexpected diff (- expected; + found):\n%s", diff)
+		}
+	})
+}
+
 func TestDecorationsDiagnostics(t *testing.T) {
 	d := tbl.Decorations[1]
 
@@ -1714,6 +1947,56 @@ func TestCrossReferencesOverrideCallers(t *testing.T) {
 	}
 }
 
+func TestCrossReferencesRevisions(t *testing.T) {
+	ticket := "kythe://someCorpus?lang=otpl#withInfos"
+
+	st := tbl.Construct(t)
+	reply, err := st.CrossReferences(ctx, &xpb.CrossReferencesRequest{
+		Ticket:         []string{ticket},
+		DefinitionKind: xpb.CrossReferencesRequest_ALL_DEFINITIONS,
+		ReferenceKind:  xpb.CrossReferencesRequest_ALL_REFERENCES,
+		Snippets:       xpb.SnippetsKind_NONE,
+	})
+	testutil.FatalOnErrT(t, "CrossReferencesRequest error: %v", err)
+
+	expected := &xpb.CrossReferencesReply{
+		Total: &xpb.CrossReferencesReply_Total{
+			References: 1,
+		},
+		CrossReferences: map[string]*xpb.CrossReferencesReply_CrossReferenceSet{
+			ticket: &xpb.CrossReferencesReply_CrossReferenceSet{
+				Ticket: ticket,
+
+				Reference: []*xpb.CrossReferencesReply_RelatedAnchor{{
+					Anchor: &xpb.Anchor{
+						Ticket:   "kythe://corpus?lang=otpl?path=some/file#27-33",
+						Kind:     "/kythe/edge/ref",
+						Parent:   "kythe://corpus?path=some/file",
+						Revision: "someFileRev", // ⟵ this is expected now
+
+						Span: &cpb.Span{
+							Start: &cpb.Point{
+								ByteOffset:   27,
+								LineNumber:   2,
+								ColumnOffset: 10,
+							},
+							End: &cpb.Point{
+								ByteOffset:   33,
+								LineNumber:   3,
+								ColumnOffset: 5,
+							},
+						},
+					},
+				}},
+			},
+		},
+	}
+
+	if diff := compare.ProtoDiff(expected, reply); diff != "" {
+		t.Fatalf("(-expected; +found):\n%s", diff)
+	}
+}
+
 func nodeInfos(nss ...[]*srvpb.Node) map[string]*cpb.NodeInfo {
 	m := make(map[string]*cpb.NodeInfo)
 	for _, ns := range nss {
@@ -1896,9 +2179,12 @@ func makeFactList(keyVals ...string) []*cpb.Fact {
 	return facts
 }
 
-func refs(norm *span.Normalizer, ds []*srvpb.FileDecorations_Decoration) (refs []*xpb.DecorationsReply_Reference) {
+func refs(norm *span.Normalizer, ds []*srvpb.FileDecorations_Decoration, infos []*srvpb.FileInfo) (refs []*xpb.DecorationsReply_Reference) {
+	fileInfos := makeFileInfoMap(infos)
 	for _, d := range ds {
-		refs = append(refs, decorationToReference(norm, d))
+		r := decorationToReference(norm, d)
+		r.TargetRevision = fileInfos[r.TargetTicket].GetRevision()
+		refs = append(refs, r)
 	}
 	return
 }
@@ -1955,3 +2241,18 @@ func (t testProtoTable) Lookup(_ context.Context, key []byte, msg proto.Message)
 func (t testProtoTable) Buffered() table.BufferedProto { panic("UNIMPLEMENTED") }
 
 func (t testProtoTable) Close(_ context.Context) error { return nil }
+
+func fi(cp *cpb.CorpusPath, rev string) *srvpb.FileInfo {
+	return &srvpb.FileInfo{
+		CorpusPath: cp,
+		Revision:   rev,
+	}
+}
+
+func cp(corpus, root, path string) *cpb.CorpusPath {
+	return &cpb.CorpusPath{
+		Corpus: corpus,
+		Root:   root,
+		Path:   path,
+	}
+}