Artifact fd7cf715d748bddfdc8e068bf50b8c2d942708390a22c3ff6147362047aa16bc:
- File
internal/tag/propagate.go
— part of check-in
[58aabe243a]
at
2026-10-12 00:24:04
on branch main
— fix(tag): keep a propagated-tag cancel from deleting newer inherited rows (#288) (#328)
* fix(tag): keep a propagated-tag cancel from deleting newer inherited rows (#288)
The cancel branch of the propagation walk used COALESCE(srcid=0, 0), deleting any inherited row whatever its mtime. fossil's tag_propagate guards both the propagate and cancel cases with srcid=0 AND tagxref.mtime < :mtime: an inherited row newer than the tag is left in place and the walk stops there. Unify the two queries on that guard, parameterizing only the default for a child with no row.
* test(tag): cancel keeps newer inherited rows; bind the propagate default (#288)
Bind the per-type doit default as a query parameter instead of Sprintf-ing it into the SQL, and drop the redundant var rows/var err. Add unit coverage: a cancel skips an inherited row newer than itself and halts the walk there, and re-propagation at equal mtime keeps the first value. (user: [email protected], size: 7240) [annotate] [blame] [check-ins using]
package tag import ( "container/heap" "database/sql" "fmt" "github.com/danmestas/go-libfossil/db" libfossil "github.com/danmestas/go-libfossil/internal/fsltype" ) // propagate walks the plink DAG from a target artifact to all descendants, // inserting/deleting tagxref rows to cascade propagating tags. // This matches Fossil's tag_propagate in tag.c:34-113. // // For propagating tags (type 2), it inserts tagxref rows with srcid=0. // For cancel tags (type 0), it deletes all tagxref entries for that tag. // Special case: if tagName is "bgcolor", also updates event.bgcolor. func propagate(q db.Querier, tagid int64, tagType int, origID libfossil.FslID, mtime float64, value string, tagName string, pid libfossil.FslID) error { if q == nil { panic("tag.propagate: q must not be nil") } // Priority queue seeded with the target artifact (mtime=0.0 for seed) pq := &mtimeQueue{} heap.Init(pq) heap.Push(pq, &queueItem{rid: pid, mtime: 0.0}) // Guard against DAG cycles or pathologically deep chains. // Fossil repos typically have <100K checkins; 1M is a safe upper bound. const maxIterations = 1_000_000 // Visited set prevents re-processing nodes reachable via multiple paths. visited := make(map[libfossil.FslID]bool) iterations := 0 for pq.Len() > 0 { iterations++ if iterations > maxIterations { return fmt.Errorf("tag.propagate: exceeded %d iterations (possible cycle)", maxIterations) } item := heap.Pop(pq).(*queueItem) currentRid := item.rid if visited[currentRid] { continue } visited[currentRid] = true // Query primary children via LEFT JOIN with tagxref. doit is whether // to propagate the tag to (type 2) or block it on (type 0) the child. // Both cases carry the same guard as fossil's tag_propagate: act on an // inherited row (srcid=0) only when it is OLDER than this tag // (tagxref.mtime < ?); the default when the child has no row for the tag // is 1 for a propagating tag (give it the row) and 0 for a cancel // (nothing to remove), bound as a parameter. A newer inherited row is // left in place and the walk does not descend past it (#288). defaultDoit := 0 if tagType == TagPropagating { defaultDoit = 1 } const query = ` SELECT cid, plink.mtime, COALESCE(srcid=0 AND tagxref.mtime < ?, ?) AS doit FROM plink LEFT JOIN tagxref ON cid=tagxref.rid AND tagxref.tagid=? WHERE pid=? AND isprim=1 ` rows, err := q.Query(query, mtime, defaultDoit, tagid, currentRid) if err != nil { return fmt.Errorf("query children of %d: %w", currentRid, err) } children := []struct { cid libfossil.FslID childMtime float64 doit bool }{} for rows.Next() { var cid int64 var childMtimeRaw any var doitInt int if err := rows.Scan(&cid, &childMtimeRaw, &doitInt); err != nil { rows.Close() return fmt.Errorf("scan child: %w", err) } childMtime, ok := db.ScanTextJulianDay(childMtimeRaw) // plink.mtime if !ok { rows.Close() return fmt.Errorf("child %d: unreadable plink.mtime %T", cid, childMtimeRaw) } children = append(children, struct { cid libfossil.FslID childMtime float64 doit bool }{libfossil.FslID(cid), childMtime, doitInt != 0}) } rows.Close() if err := rows.Err(); err != nil { return fmt.Errorf("rows error: %w", err) } // Process each child for _, child := range children { if child.doit { if tagType == TagPropagating { // Insert propagating tagxref entry if _, err := q.Exec( `REPLACE INTO tagxref(tagid, tagtype, srcid, origid, value, mtime, rid) VALUES(?, 2, 0, ?, ?, ?, ?)`, tagid, origID, nullableValue(value), mtime, child.cid, ); err != nil { return fmt.Errorf("propagate to %d: %w", child.cid, err) } // Special case: bgcolor updates event table if tagName == "bgcolor" { if _, err := q.Exec("UPDATE event SET bgcolor=? WHERE objid=?", value, child.cid); err != nil { return fmt.Errorf("update event bgcolor for %d: %w", child.cid, err) } } } else { // Cancel: delete all tagxref entries for this tag and rid if _, err := q.Exec("DELETE FROM tagxref WHERE tagid=? AND rid=?", tagid, child.cid); err != nil { return fmt.Errorf("cancel at %d: %w", child.cid, err) } // Special case: bgcolor cancellation clears event.bgcolor if tagName == "bgcolor" { if _, err := q.Exec("UPDATE event SET bgcolor=NULL WHERE objid=?", child.cid); err != nil { return fmt.Errorf("clear event bgcolor for %d: %w", child.cid, err) } } } // Queue child for further processing heap.Push(pq, &queueItem{rid: child.cid, mtime: child.childMtime}) } } } return nil } // queueItem represents a node in the priority queue. type queueItem struct { rid libfossil.FslID mtime float64 index int // heap index } // mtimeQueue implements heap.Interface for mtime-ordered priority queue. type mtimeQueue []*queueItem func (pq mtimeQueue) Len() int { return len(pq) } func (pq mtimeQueue) Less(i, j int) bool { return pq[i].mtime < pq[j].mtime } func (pq mtimeQueue) Swap(i, j int) { pq[i], pq[j] = pq[j], pq[i] pq[i].index = i pq[j].index = j } func (pq *mtimeQueue) Push(x any) { n := len(*pq) item := x.(*queueItem) item.index = n *pq = append(*pq, item) } func (pq *mtimeQueue) Pop() any { old := *pq n := len(old) item := old[n-1] old[n-1] = nil item.index = -1 *pq = old[0 : n-1] return item } // PropagateAll re-propagates all tags from rid to its descendants. // Matches Fossil's tag_propagate_all (tag.c:118-135). // Singleton tags (type 1) are treated as cancel (type 0) during propagation. func PropagateAll(q db.Querier, rid libfossil.FslID) error { if q == nil { panic("tag.PropagateAll: q must not be nil") } if rid <= 0 { return nil } rows, err := q.Query( "SELECT tagid, tagtype, mtime, value, origid FROM tagxref WHERE rid=?", rid, ) if err != nil { return fmt.Errorf("tag.PropagateAll query: %w", err) } defer rows.Close() type entry struct { tagid int64 tagtype int mtime float64 value string origid libfossil.FslID } var entries []entry for rows.Next() { var e entry var mtimeRaw any var value sql.NullString // tagxref.value is nullable (e.g. CrosslinkCluster's cluster tag) if err := rows.Scan(&e.tagid, &e.tagtype, &mtimeRaw, &value, &e.origid); err != nil { return fmt.Errorf("tag.PropagateAll scan: %w", err) } e.value = value.String e.mtime, _ = db.ScanJulianDay(mtimeRaw) entries = append(entries, e) } if err := rows.Err(); err != nil { return fmt.Errorf("tag.PropagateAll rows: %w", err) } for _, e := range entries { tagtype := e.tagtype if tagtype == TagSingleton { tagtype = TagCancel // Matches Fossil: if(tagtype==1) tagtype=0; } var tagname string if err := q.QueryRow("SELECT tagname FROM tag WHERE tagid=?", e.tagid).Scan(&tagname); err != nil { return fmt.Errorf("tag.PropagateAll tagname: %w", err) } if err := propagate(q, e.tagid, tagtype, e.origid, e.mtime, e.value, tagname, rid); err != nil { return fmt.Errorf("tag.PropagateAll propagate tagid=%d: %w", e.tagid, err) } } return nil }