1 /*
2 ** Copyright (c) 2011 D. Richard Hipp
3 **
4 ** This program is free software; you can redistribute it and/or
5 ** modify it under the terms of the Simplified BSD License (also
6 ** known as the "2-Clause License" or "FreeBSD License".)
7 
8 ** This program is distributed in the hope that it will be useful,
9 ** but without any warranty; without even the implied warranty of
10 ** merchantability or fitness for a particular purpose.
11 **
12 ** Author contact information:
13 **   drh@sqlite.org
14 **
15 *******************************************************************************
16 **
17 ** This file contains code used to trace paths of through the
18 ** directed acyclic graph (DAG) of check-ins.
19 */
20 #include "config.h"
21 #include "path.h"
22 #include <assert.h>
23 
24 #if INTERFACE
25 /* Nodes for the paths through the DAG.
26 */
27 struct PathNode {
28   int rid;                 /* ID for this node */
29   u8 fromIsParent;         /* True if pFrom is the parent of rid */
30   u8 isPrim;               /* True if primary side of common ancestor */
31   u8 isHidden;             /* Abbreviate output in "fossil bisect ls" */
32   PathNode *pFrom;         /* Node we came from */
33   union {
34     PathNode *pPeer;       /* List of nodes of the same generation */
35     PathNode *pTo;         /* Next on path from beginning to end */
36   } u;
37   PathNode *pAll;        /* List of all nodes */
38 };
39 #endif
40 
41 /*
42 ** Local variables for this module
43 */
44 static struct {
45   PathNode *pCurrent;   /* Current generation of nodes */
46   PathNode *pAll;       /* All nodes */
47   Bag seen;             /* Nodes seen before */
48   int nStep;            /* Number of steps from first to last */
49   int nNotHidden;       /* Number of steps not counting hidden nodes */
50   PathNode *pStart;     /* Earliest node */
51   PathNode *pEnd;       /* Most recent */
52 } path;
53 
54 /*
55 ** Return the first (last) element of the computed path.
56 */
path_first(void)57 PathNode *path_first(void){ return path.pStart; }
path_last(void)58 PathNode *path_last(void){ return path.pEnd; }
59 
60 /*
61 ** Return the number of steps in the computed path.
62 */
path_length(void)63 int path_length(void){ return path.nStep; }
64 
65 /*
66 ** Return the number of non-hidden steps in the computed path.
67 */
path_length_not_hidden(void)68 int path_length_not_hidden(void){ return path.nNotHidden; }
69 
70 /*
71 ** Create a new node
72 */
path_new_node(int rid,PathNode * pFrom,int isParent)73 static PathNode *path_new_node(int rid, PathNode *pFrom, int isParent){
74   PathNode *p;
75 
76   p = fossil_malloc( sizeof(*p) );
77   memset(p, 0, sizeof(*p));
78   p->rid = rid;
79   p->fromIsParent = isParent;
80   p->pFrom = pFrom;
81   p->u.pPeer = path.pCurrent;
82   path.pCurrent = p;
83   p->pAll = path.pAll;
84   path.pAll = p;
85   bag_insert(&path.seen, rid);
86   return p;
87 }
88 
89 /*
90 ** Reset memory used by the shortest path algorithm.
91 */
path_reset(void)92 void path_reset(void){
93   PathNode *p;
94   while( path.pAll ){
95     p = path.pAll;
96     path.pAll = p->pAll;
97     fossil_free(p);
98   }
99   bag_clear(&path.seen);
100   memset(&path, 0, sizeof(path));
101 }
102 
103 /*
104 ** Construct the path from path.pStart to path.pEnd in the u.pTo fields.
105 */
path_reverse_path(void)106 static void path_reverse_path(void){
107   PathNode *p;
108   assert( path.pEnd!=0 );
109   for(p=path.pEnd; p && p->pFrom; p = p->pFrom){
110     p->pFrom->u.pTo = p;
111   }
112   path.pEnd->u.pTo = 0;
113   assert( p==path.pStart );
114 }
115 
116 /*
117 ** Compute the shortest path from iFrom to iTo
118 **
119 ** If directOnly is true, then use only the "primary" links from parent to
120 ** child.  In other words, ignore merges.
121 **
122 ** Return a pointer to the beginning of the path (the iFrom node).
123 ** Elements of the path can be traversed by following the PathNode.u.pTo
124 ** pointer chain.
125 **
126 ** Return NULL if no path is found.
127 */
path_shortest(int iFrom,int iTo,int directOnly,int oneWayOnly,Bag * pHidden)128 PathNode *path_shortest(
129   int iFrom,          /* Path starts here */
130   int iTo,            /* Path ends here */
131   int directOnly,     /* No merge links if true */
132   int oneWayOnly,     /* Parent->child only if true */
133   Bag *pHidden        /* Hidden nodes */
134 ){
135   Stmt s;
136   PathNode *pPrev;
137   PathNode *p;
138 
139   path_reset();
140   path.pStart = path_new_node(iFrom, 0, 0);
141   if( iTo==iFrom ){
142     path.pEnd = path.pStart;
143     return path.pStart;
144   }
145   if( oneWayOnly && directOnly ){
146     db_prepare(&s,
147         "SELECT cid, 1 FROM plink WHERE pid=:pid AND isprim"
148     );
149   }else if( oneWayOnly ){
150     db_prepare(&s,
151         "SELECT cid, 1 FROM plink WHERE pid=:pid "
152     );
153   }else if( directOnly ){
154     db_prepare(&s,
155         "SELECT cid, 1 FROM plink WHERE pid=:pid AND isprim "
156         "UNION ALL "
157         "SELECT pid, 0 FROM plink WHERE cid=:pid AND isprim"
158     );
159   }else{
160     db_prepare(&s,
161         "SELECT cid, 1 FROM plink WHERE pid=:pid "
162         "UNION ALL "
163         "SELECT pid, 0 FROM plink WHERE cid=:pid"
164     );
165   }
166   while( path.pCurrent ){
167     path.nStep++;
168     pPrev = path.pCurrent;
169     path.pCurrent = 0;
170     while( pPrev ){
171       db_bind_int(&s, ":pid", pPrev->rid);
172       while( db_step(&s)==SQLITE_ROW ){
173         int cid = db_column_int(&s, 0);
174         int isParent = db_column_int(&s, 1);
175         if( bag_find(&path.seen, cid) ) continue;
176         p = path_new_node(cid, pPrev, isParent);
177         if( pHidden && bag_find(pHidden,cid) ) p->isHidden = 1;
178         if( cid==iTo ){
179           db_finalize(&s);
180           path.pEnd = p;
181           path_reverse_path();
182           for(p=path.pStart->u.pTo; p; p=p->u.pTo ){
183             if( !p->isHidden ) path.nNotHidden++;
184           }
185           return path.pStart;
186         }
187       }
188       db_reset(&s);
189       pPrev = pPrev->u.pPeer;
190     }
191   }
192   db_finalize(&s);
193   path_reset();
194   return 0;
195 }
196 
197 /*
198 ** Find the mid-point of the path.  If the path contains fewer than
199 ** 2 steps, return 0.
200 */
path_midpoint(void)201 PathNode *path_midpoint(void){
202   PathNode *p;
203   int i;
204   if( path.nNotHidden<2 ) return 0;
205   for(p=path.pEnd, i=0; p && (p->isHidden || i<path.nNotHidden/2); p=p->pFrom){
206     if( !p->isHidden ) i++;
207   }
208   return p;
209 }
210 
211 /*
212 ** Return an estimate of the number of comparisons remaining in order
213 ** to bisect path.  This is based on the log2() of path.nStep.
214 */
path_search_depth(void)215 int path_search_depth(void){
216   int i, j;
217   for(i=0, j=1; j<path.nNotHidden; i++, j+=j){}
218   return i;
219 }
220 
221 /*
222 ** Compute the shortest path between two check-ins and then transfer
223 ** that path into the "ancestor" table.  This is a utility used by
224 ** both /annotate and /finfo.  See also: compute_direct_ancestors().
225 */
path_shortest_stored_in_ancestor_table(int origid,int cid)226 void path_shortest_stored_in_ancestor_table(
227   int origid,     /* RID for check-in at start of the path */
228   int cid         /* RID for check-in at the end of the path */
229 ){
230   PathNode *pPath;
231   int gen = 0;
232   Stmt ins;
233   pPath = path_shortest(cid, origid, 1, 0, 0);
234   db_multi_exec(
235     "CREATE TEMP TABLE IF NOT EXISTS ancestor("
236     "  rid INT UNIQUE,"
237     "  generation INTEGER PRIMARY KEY"
238     ");"
239     "DELETE FROM ancestor;"
240   );
241   db_prepare(&ins, "INSERT INTO ancestor(rid, generation) VALUES(:rid,:gen)");
242   while( pPath ){
243     db_bind_int(&ins, ":rid", pPath->rid);
244     db_bind_int(&ins, ":gen", ++gen);
245     db_step(&ins);
246     db_reset(&ins);
247     pPath = pPath->u.pTo;
248   }
249   db_finalize(&ins);
250   path_reset();
251 }
252 
253 /*
254 ** COMMAND: test-shortest-path
255 **
256 ** Usage: %fossil test-shortest-path ?--no-merge? VERSION1 VERSION2
257 **
258 ** Report the shortest path between two check-ins.  If the --no-merge flag
259 ** is used, follow only direct parent-child paths and omit merge links.
260 */
shortest_path_test_cmd(void)261 void shortest_path_test_cmd(void){
262   int iFrom;
263   int iTo;
264   PathNode *p;
265   int n;
266   int directOnly;
267   int oneWay;
268 
269   db_find_and_open_repository(0,0);
270   directOnly = find_option("no-merge",0,0)!=0;
271   oneWay = find_option("one-way",0,0)!=0;
272   if( g.argc!=4 ) usage("VERSION1 VERSION2");
273   iFrom = name_to_rid(g.argv[2]);
274   iTo = name_to_rid(g.argv[3]);
275   p = path_shortest(iFrom, iTo, directOnly, oneWay, 0);
276   if( p==0 ){
277     fossil_fatal("no path from %s to %s", g.argv[1], g.argv[2]);
278   }
279   for(n=1, p=path.pStart; p; p=p->u.pTo, n++){
280     char *z;
281     z = db_text(0,
282       "SELECT substr(uuid,1,12) || ' ' || datetime(mtime)"
283       "  FROM blob, event"
284       " WHERE blob.rid=%d AND event.objid=%d AND event.type='ci'",
285       p->rid, p->rid);
286     fossil_print("%4d: %5d %s", n, p->rid, z);
287     fossil_free(z);
288     if( p->u.pTo ){
289       fossil_print(" is a %s of\n",
290                    p->u.pTo->fromIsParent ? "parent" : "child");
291     }else{
292       fossil_print("\n");
293     }
294   }
295 }
296 
297 /*
298 ** Find the closest common ancestor of two nodes.  "Closest" means the
299 ** fewest number of arcs.
300 */
path_common_ancestor(int iMe,int iYou)301 int path_common_ancestor(int iMe, int iYou){
302   Stmt s;
303   PathNode *pPrev;
304   PathNode *p;
305   Bag me, you;
306 
307   if( iMe==iYou ) return iMe;
308   if( iMe==0 || iYou==0 ) return 0;
309   path_reset();
310   path.pStart = path_new_node(iMe, 0, 0);
311   path.pStart->isPrim = 1;
312   path.pEnd = path_new_node(iYou, 0, 0);
313   db_prepare(&s, "SELECT pid FROM plink WHERE cid=:cid");
314   bag_init(&me);
315   bag_insert(&me, iMe);
316   bag_init(&you);
317   bag_insert(&you, iYou);
318   while( path.pCurrent ){
319     pPrev = path.pCurrent;
320     path.pCurrent = 0;
321     while( pPrev ){
322       db_bind_int(&s, ":cid", pPrev->rid);
323       while( db_step(&s)==SQLITE_ROW ){
324         int pid = db_column_int(&s, 0);
325         if( bag_find(pPrev->isPrim ? &you : &me, pid) ){
326           /* pid is the common ancestor */
327           PathNode *pNext;
328           for(p=path.pAll; p && p->rid!=pid; p=p->pAll){}
329           assert( p!=0 );
330           pNext = p;
331           while( pNext ){
332             pNext = p->pFrom;
333             p->pFrom = pPrev;
334             pPrev = p;
335             p = pNext;
336           }
337           if( pPrev==path.pStart ) path.pStart = path.pEnd;
338           path.pEnd = pPrev;
339           path_reverse_path();
340           db_finalize(&s);
341           return pid;
342         }else if( bag_find(&path.seen, pid) ){
343           /* pid is just an alternative path on one of the legs */
344           continue;
345         }
346         p = path_new_node(pid, pPrev, 0);
347         p->isPrim = pPrev->isPrim;
348         bag_insert(pPrev->isPrim ? &me : &you, pid);
349       }
350       db_reset(&s);
351       pPrev = pPrev->u.pPeer;
352     }
353   }
354   db_finalize(&s);
355   path_reset();
356   return 0;
357 }
358 
359 /*
360 ** COMMAND: test-ancestor-path
361 **
362 ** Usage: %fossil test-ancestor-path VERSION1 VERSION2
363 **
364 ** Report the path from VERSION1 to VERSION2 through their most recent
365 ** common ancestor.
366 */
ancestor_path_test_cmd(void)367 void ancestor_path_test_cmd(void){
368   int iFrom;
369   int iTo;
370   int iPivot;
371   PathNode *p;
372   int n;
373 
374   db_find_and_open_repository(0,0);
375   if( g.argc!=4 ) usage("VERSION1 VERSION2");
376   iFrom = name_to_rid(g.argv[2]);
377   iTo = name_to_rid(g.argv[3]);
378   iPivot = path_common_ancestor(iFrom, iTo);
379   for(n=1, p=path.pStart; p; p=p->u.pTo, n++){
380     char *z;
381     z = db_text(0,
382       "SELECT substr(uuid,1,12) || ' ' || datetime(mtime)"
383       "  FROM blob, event"
384       " WHERE blob.rid=%d AND event.objid=%d AND event.type='ci'",
385       p->rid, p->rid);
386     fossil_print("%4d: %5d %s", n, p->rid, z);
387     fossil_free(z);
388     if( p->rid==iFrom ) fossil_print(" VERSION1");
389     if( p->rid==iTo ) fossil_print(" VERSION2");
390     if( p->rid==iPivot ) fossil_print(" PIVOT");
391     fossil_print("\n");
392   }
393 }
394 
395 
396 /*
397 ** A record of a file rename operation.
398 */
399 typedef struct NameChange NameChange;
400 struct NameChange {
401   int origName;        /* Original name of file */
402   int curName;         /* Current name of the file */
403   int newName;         /* Name of file in next version */
404   NameChange *pNext;   /* List of all name changes */
405 };
406 
407 /*
408 ** Compute all file name changes that occur going from check-in iFrom
409 ** to check-in iTo.
410 **
411 ** The number of name changes is written into *pnChng.  For each name
412 ** change, two integers are allocated for *piChng.  The first is the
413 ** filename.fnid for the original name as seen in check-in iFrom and
414 ** the second is for new name as it is used in check-in iTo.
415 **
416 ** Space to hold *piChng is obtained from fossil_malloc() and should
417 ** be released by the caller.
418 **
419 ** This routine really has nothing to do with path.  It is located
420 ** in this path.c module in order to leverage some of the path
421 ** infrastructure.
422 */
find_filename_changes(int iFrom,int iTo,int revOK,int * pnChng,int ** aiChng,const char * zDebug)423 void find_filename_changes(
424   int iFrom,               /* Ancestor check-in */
425   int iTo,                 /* Recent check-in */
426   int revOK,               /* OK to move backwards (child->parent) if true */
427   int *pnChng,             /* Number of name changes along the path */
428   int **aiChng,            /* Name changes */
429   const char *zDebug       /* Generate trace output if no NULL */
430 ){
431   PathNode *p;             /* For looping over path from iFrom to iTo */
432   NameChange *pAll = 0;    /* List of all name changes seen so far */
433   NameChange *pChng;       /* For looping through the name change list */
434   int nChng = 0;           /* Number of files whose names have changed */
435   int *aChng;              /* Two integers per name change */
436   int i;                   /* Loop counter */
437   Stmt q1;                 /* Query of name changes */
438 
439   *pnChng = 0;
440   *aiChng = 0;
441   if(0==iFrom){
442     fossil_fatal("Invalid 'from' RID: 0");
443   }else if(0==iTo){
444     fossil_fatal("Invalid 'to' RID: 0");
445   }
446   if( iFrom==iTo ) return;
447   path_reset();
448   p = path_shortest(iFrom, iTo, 1, revOK==0, 0);
449   if( p==0 ) return;
450   path_reverse_path();
451   db_prepare(&q1,
452      "SELECT pfnid, fnid FROM mlink"
453      " WHERE mid=:mid AND (pfnid>0 OR fid==0)"
454      " ORDER BY pfnid"
455   );
456   for(p=path.pStart; p; p=p->u.pTo){
457     int fnid, pfnid;
458     if( !p->fromIsParent && (p->u.pTo==0 || p->u.pTo->fromIsParent) ){
459       /* Skip nodes where the parent is not on the path */
460       continue;
461     }
462     db_bind_int(&q1, ":mid", p->rid);
463     while( db_step(&q1)==SQLITE_ROW ){
464       fnid = db_column_int(&q1, 1);
465       pfnid = db_column_int(&q1, 0);
466       if( pfnid==0 ){
467         pfnid = fnid;
468         fnid = 0;
469       }
470       if( !p->fromIsParent ){
471         int t = fnid;
472         fnid = pfnid;
473         pfnid = t;
474       }
475       if( zDebug ){
476         fossil_print("%s at %d%s %.10z: %d[%z] -> %d[%z]\n",
477            zDebug, p->rid, p->fromIsParent ? ">" : "<",
478            db_text(0, "SELECT uuid FROM blob WHERE rid=%d", p->rid),
479            pfnid,
480            db_text(0, "SELECT name FROM filename WHERE fnid=%d", pfnid),
481            fnid,
482            db_text(0, "SELECT name FROM filename WHERE fnid=%d", fnid));
483       }
484       for(pChng=pAll; pChng; pChng=pChng->pNext){
485         if( pChng->curName==pfnid ){
486           pChng->newName = fnid;
487           break;
488         }
489       }
490       if( pChng==0 && fnid>0 ){
491         pChng = fossil_malloc( sizeof(*pChng) );
492         pChng->pNext = pAll;
493         pAll = pChng;
494         pChng->origName = pfnid;
495         pChng->curName = pfnid;
496         pChng->newName = fnid;
497         nChng++;
498       }
499     }
500     for(pChng=pAll; pChng; pChng=pChng->pNext){
501       pChng->curName = pChng->newName;
502     }
503     db_reset(&q1);
504   }
505   db_finalize(&q1);
506   if( nChng ){
507     aChng = *aiChng = fossil_malloc( nChng*2*sizeof(int) );
508     for(pChng=pAll, i=0; pChng; pChng=pChng->pNext){
509       if( pChng->newName==0 ) continue;
510       if( pChng->origName==0 ) continue;
511       aChng[i] = pChng->origName;
512       aChng[i+1] = pChng->newName;
513       if( zDebug ){
514         fossil_print("%s summary %d[%z] -> %d[%z]\n",
515            zDebug,
516            aChng[i],
517            db_text(0, "SELECT name FROM filename WHERE fnid=%d", aChng[i]),
518            aChng[i+1],
519            db_text(0, "SELECT name FROM filename WHERE fnid=%d", aChng[i+1]));
520       }
521       i += 2;
522     }
523     *pnChng = i/2;
524     while( pAll ){
525       pChng = pAll;
526       pAll = pAll->pNext;
527       fossil_free(pChng);
528     }
529   }
530 }
531 
532 /*
533 ** COMMAND: test-name-changes
534 **
535 ** Usage: %fossil test-name-changes [--debug] VERSION1 VERSION2
536 **
537 ** Show all filename changes that occur going from VERSION1 to VERSION2
538 */
test_name_change(void)539 void test_name_change(void){
540   int iFrom;
541   int iTo;
542   int *aChng;
543   int nChng;
544   int i;
545   const char *zDebug = 0;
546   int revOK = 0;
547 
548   db_find_and_open_repository(0,0);
549   zDebug = find_option("debug",0,0)!=0 ? "debug" : 0;
550   revOK = find_option("bidirectional",0,0)!=0;
551   if( g.argc<4 ) usage("VERSION1 VERSION2");
552   while( g.argc>=4 ){
553     iFrom = name_to_rid(g.argv[2]);
554     iTo = name_to_rid(g.argv[3]);
555     find_filename_changes(iFrom, iTo, revOK, &nChng, &aChng, zDebug);
556     fossil_print("------ Changes for (%d) %s -> (%d) %s\n",
557                  iFrom, g.argv[2], iTo, g.argv[3]);
558     for(i=0; i<nChng; i++){
559       char *zFrom, *zTo;
560 
561       zFrom = db_text(0, "SELECT name FROM filename WHERE fnid=%d", aChng[i*2]);
562       zTo = db_text(0, "SELECT name FROM filename WHERE fnid=%d", aChng[i*2+1]);
563       fossil_print("[%s] -> [%s]\n", zFrom, zTo);
564       fossil_free(zFrom);
565       fossil_free(zTo);
566     }
567     fossil_free(aChng);
568     g.argv += 2;
569     g.argc -= 2;
570   }
571 }
572 
573 /* Query to extract all rename operations */
574 static const char zRenameQuery[] =
575 @ CREATE TEMP TABLE renames AS
576 @ SELECT
577 @     datetime(event.mtime) AS date,
578 @     F.name AS old_name,
579 @     T.name AS new_name,
580 @     blob.uuid AS checkin
581 @   FROM mlink, filename F, filename T, event, blob
582 @  WHERE coalesce(mlink.pfnid,0)!=0 AND mlink.pfnid!=mlink.fnid
583 @    AND F.fnid=mlink.pfnid
584 @    AND T.fnid=mlink.fnid
585 @    AND event.objid=mlink.mid
586 @    AND event.type='ci'
587 @    AND blob.rid=mlink.mid;
588 ;
589 
590 /* Query to extract distinct rename operations */
591 static const char zDistinctRenameQuery[] =
592 @ CREATE TEMP TABLE renames AS
593 @ SELECT
594 @     min(datetime(event.mtime)) AS date,
595 @     F.name AS old_name,
596 @     T.name AS new_name,
597 @     blob.uuid AS checkin
598 @   FROM mlink, filename F, filename T, event, blob
599 @  WHERE coalesce(mlink.pfnid,0)!=0 AND mlink.pfnid!=mlink.fnid
600 @    AND F.fnid=mlink.pfnid
601 @    AND T.fnid=mlink.fnid
602 @    AND event.objid=mlink.mid
603 @    AND event.type='ci'
604 @    AND blob.rid=mlink.mid
605 @  GROUP BY 2, 3;
606 ;
607 
608 /*
609 ** WEBPAGE: test-rename-list
610 **
611 ** Print a list of all file rename operations throughout history.
612 ** This page is intended for testing purposes only and may change
613 ** or be discontinued without notice.
614 */
test_rename_list_page(void)615 void test_rename_list_page(void){
616   Stmt q;
617   int nRename;
618   int nCheckin;
619 
620   login_check_credentials();
621   if( !g.perm.Read ){ login_needed(g.anon.Read); return; }
622   style_set_current_feature("test");
623   if( P("all")!=0 ){
624     style_header("List Of All Filename Changes");
625     db_multi_exec("%s", zRenameQuery/*safe-for-%s*/);
626     style_submenu_element("Distinct", "%R/test-rename-list");
627   }else{
628     style_header("List Of Distinct Filename Changes");
629     db_multi_exec("%s", zDistinctRenameQuery/*safe-for-%s*/);
630     style_submenu_element("All", "%R/test-rename-list?all");
631   }
632   nRename = db_int(0, "SELECT count(*) FROM renames;");
633   nCheckin = db_int(0, "SELECT count(DISTINCT checkin) FROM renames;");
634   db_prepare(&q, "SELECT date, old_name, new_name, checkin FROM renames"
635                  " ORDER BY date DESC, old_name ASC");
636   @ <h1>%d(nRename) filename changes in %d(nCheckin) check-ins</h1>
637   @ <table class='sortable' data-column-types='tttt' data-init-sort='1'\
638   @  border="1" cellpadding="2" cellspacing="0">
639   @ <thead><tr><th>Date &amp; Time</th>
640   @ <th>Old Name</th>
641   @ <th>New Name</th>
642   @ <th>Check-in</th></tr></thead><tbody>
643   while( db_step(&q)==SQLITE_ROW ){
644     const char *zDate = db_column_text(&q, 0);
645     const char *zOld = db_column_text(&q, 1);
646     const char *zNew = db_column_text(&q, 2);
647     const char *zUuid = db_column_text(&q, 3);
648     @ <tr>
649     @ <td>%z(href("%R/timeline?c=%t",zDate))%s(zDate)</a></td>
650     @ <td>%z(href("%R/finfo?name=%t",zOld))%h(zOld)</a></td>
651     @ <td>%z(href("%R/finfo?name=%t",zNew))%h(zNew)</a></td>
652     @ <td>%z(href("%R/info/%!S",zUuid))%S(zUuid)</a></td></tr>
653   }
654   @ </tbody></table>
655   db_finalize(&q);
656   style_table_sorter();
657   style_finish_page();
658 }
659