package index
import (
"database/sql"
"strings"
)
// A landing (zit#127) is a branch proposed to land on a target, carried
// entirely by artifacts fossil already understands: the record is a forum
// thread whose root body opens with a marker line, every state change is a
// reply in that thread, and the outcome is a raw tag on the check-in that
// landed. zit mints no card type for it, so there is nothing here to fold
// from a new artifact kind — a landing is a READING of forum_post rows.
//
// That is why this table is derived and recomputed rather than written once
// on arrival. Folding is ORDER-INDEPENDENT (0001 §5): a reply can be indexed
// before the root it belongs to, a root can arrive after its own acceptance,
// and either order must converge on the same row. So any forum post in a
// thread recomputes that thread's landing from the forum_post rows present,
// instead of trying to patch a row incrementally from the artifact in hand.
//
// The fold rule matches zit's exactly — latest transition wins, ordered by
// (date, hash) — because two readers of the same store disagreeing about a
// landing's state is the failure this whole feature exists to avoid.
// landingMarker opens the body of a thread root that is a landing record.
// Versioned at the source; a body announcing a version this code does not
// know is NOT a landing here, which is a refusal to guess rather than an
// oversight. Reading it as a landing anyway would mean rendering fields we
// only think we recognise.
const landingMarker = "zit-landing: 1"
// IsLandingBody reports whether a forum post opens a landing. Exported
// so the ingest fold can name the event correctly: a landing IS a forum
// thread, and calling its opening post forum.posted would be true but
// useless — nothing wanting landings could select them.
func IsLandingBody(body string) bool {
return strings.Contains(body, landingMarker)
}
// IsLandingStateBody reports whether a post carries a landing verdict.
func IsLandingStateBody(body string) bool {
return strings.Contains(body, landingStateMarker)
}
// landingStateMarker opens the body of a reply carrying a state transition.
const landingStateMarker = "zit-landing-state:"
// landingTargetMarker opens the body of a reply RETARGETING the record
// (zit#144). A landing's target is not fixed at open: when the branch
// below a stack lands, zit retargets whatever was stacked on it onto
// the branch that just landed, and records that as a reply — the same
// latest-wins shape a state transition uses. Reading the target from
// the opening post alone leaves the record pointing at a branch that
// has already landed, and every question asked of it after that — most
// of all "did this merge?" — is asked about the wrong branch.
const landingTargetMarker = "zit-landing-target:"
// parseLandingTarget reads a retarget reply. Unlike a state, a branch
// name is not a closed set, so anything non-empty on the marker line is
// the new target; an empty one is a comment with an odd first line.
func parseLandingTarget(body string) (string, bool) {
if !strings.HasPrefix(body, landingTargetMarker) {
return "", false
}
rest := body[len(landingTargetMarker):]
if i := strings.IndexByte(rest, '\n'); i >= 0 {
rest = rest[:i]
}
if t := strings.TrimSpace(rest); t != "" {
return t, true
}
return "", false
}
// landingStates is the closed set. A reply naming anything else is a comment
// with an odd first line, not a transition — an unknown state must never
// settle a record.
var landingStates = map[string]bool{"open": true, "accepted": true, "rejected": true}
// parseLandingHeader reads branch and target out of a thread root's body.
// ok is false for every ordinary forum post, which is the common case.
func parseLandingHeader(body string) (branch, target string, ok bool) {
if !strings.HasPrefix(body, landingMarker) {
return "", "", false
}
lines := strings.Split(body, "\n")
for _, line := range lines[1:] {
if line == "" || line == "\r" {
break // the blank line ends the header
}
switch {
case strings.HasPrefix(line, "branch: "):
branch = strings.TrimSpace(line[len("branch: "):])
case strings.HasPrefix(line, "target: "):
target = strings.TrimSpace(line[len("target: "):])
default:
// An unrecognised header line under a version we DO claim to
// know is a dialect mismatch. Refuse the record rather than
// index a partial reading of it.
return "", "", false
}
}
if branch == "" || target == "" {
return "", "", false
}
return branch, target, true
}
// parseLandingState reads the state a reply carries, or ok=false when the
// reply is an ordinary comment.
func parseLandingState(body string) (string, bool) {
if !strings.HasPrefix(body, landingStateMarker) {
return "", false
}
rest := body[len(landingStateMarker):]
if i := strings.IndexByte(rest, '\n'); i >= 0 {
rest = rest[:i]
}
s := strings.TrimSpace(rest)
if !landingStates[s] {
return "", false
}
return s, true
}
// LandingProse is a landing root's body with its header block removed — the
// message the author actually wrote.
//
// The header is ENCODING, and zit's landing module is explicit that the
// interface hides it ("if a thread hash leaks into the interface ... this has
// become a pass-through wrapper"). A page rendering the raw root body shows
// `zit-landing: 1` to a human, which is the encoding leaking through the
// interface built to hide it. Stripping it belongs here, beside the parser
// that defines where the header ends, rather than in each reader.
func LandingProse(body string) string {
if !strings.HasPrefix(body, landingMarker) {
return body
}
// The blank line ends the header, exactly as parseLandingHeader reads it.
if i := strings.Index(body, "\n\n"); i >= 0 {
return strings.TrimLeft(body[i+2:], "\n")
}
return ""
}
// subjectMax is the longest a first line can be and still read as a
// headline. Commit subjects run to 72; the longest anyone has written on
// this forge ran to 96; a paragraph starts well past this.
const subjectMax = 120
// IsSubjectLine says whether a record's title is a headline its author
// wrote — one line, subject length — rather than the opening paragraph
// zit put in the title card because nothing shorter preceded the blank
// line.
func IsSubjectLine(title string) bool {
title = strings.TrimSpace(title)
return len(title) <= subjectMax && !strings.Contains(title, "\n")
}
// notALanding is the clause that keeps landing records off the forum.
//
// A landing IS a forum thread — that is the compatibility story, and it is
// why a stock fossil clone can read one. But it is not forum CONTENT: it is a
// decision record with its own surface, its own controls and its own listing,
// and a reader scanning discussions has no use for a thread whose first line
// is a marker.
//
// The clause lives here, once, because a listing and the count above it
// disagreeing about what a forum holds is the confirmation-leak shape in
// miniature: the number would promise a thread the list refuses to show.
func notALanding(col string) string {
return col + ` NOT IN (SELECT id FROM landing)`
}
// LandingByPrefix resolves a landing from an id prefix, the way every other
// hash in this forge resolves: a prefix matching two records matches neither,
// because answering arbitrarily is worse than refusing.
func (ix *Index) LandingByPrefix(prefix string) (LandingSummary, bool, error) {
rows, err := ix.db.Query(`SELECT id, branch, target, title, author, opened, updated, state, settled_by, superseded_by, claimed_by
FROM landing WHERE id >= ? AND id < ? LIMIT 2`, prefix, prefix+"g")
if err != nil {
return LandingSummary{}, false, err
}
defer rows.Close()
var out LandingSummary
n := 0
for rows.Next() {
n++
if n > 1 {
return LandingSummary{}, false, rows.Err()
}
if err := rows.Scan(&out.ID, &out.Branch, &out.Target, &out.Title, &out.Author,
&out.Opened, &out.Updated, &out.State, &out.SettledBy, &out.SupersededBy, &out.ClaimedBy); err != nil {
return LandingSummary{}, false, err
}
}
return out, n == 1, rows.Err()
}
// LandingTransition reports whether a post body is a state transition rather
// than a remark, and which state it carries.
//
// Exported because the API has to tell the two apart to render them
// differently: a transition is an EVENT in the record ("dan accepted this"),
// the way every forge renders a state change, while a remark is a comment.
// Reading the marker in a second place would be a second dialect, so the
// classification stays with the fold that defines it.
func LandingTransition(body string) (state string, ok bool) {
return parseLandingState(body)
}
// LandingRetarget reports the new target a reply announces, so a reader
// sees the retarget as the EVENT it is rather than as a remark whose
// first line happens to be machine text.
func LandingRetarget(body string) (target string, ok bool) {
return parseLandingTarget(body)
}
// landingUpdatedMarker opens the body of a reply recording that the
// target was brought INTO the branch on the store (zit#152, `landing
// update`): the merge check-in's hash follows it. An event, not a
// remark — rendering it as prose puts the marker in the discussion.
const landingUpdatedMarker = "zit-landing-updated:"
// LandingReplyProse is a reply's body with its event header removed: the
// state, retarget or update line zit writes first, and the blank line
// after it. What is left is whatever the author said alongside the
// event, often nothing. The event itself is already reported in its own
// field (LandingTransition, LandingRetarget, LandingUpdate), so a reader
// of the reply's text never sees the marker (G-45).
func LandingReplyProse(body string) string {
for _, m := range []string{landingStateMarker, landingTargetMarker, landingUpdatedMarker} {
if !strings.HasPrefix(body, m) {
continue
}
i := strings.IndexByte(body, '\n')
if i < 0 {
return ""
}
return strings.TrimLeft(body[i+1:], "\n")
}
return body
}
// LandingUpdate reads an update reply: the hash of the merge it wrote.
func LandingUpdate(body string) (merge string, ok bool) {
if !strings.HasPrefix(body, landingUpdatedMarker) {
return "", false
}
rest := body[len(landingUpdatedMarker):]
if i := strings.IndexByte(rest, '\n'); i >= 0 {
rest = rest[:i]
}
if h := strings.TrimSpace(rest); h != "" {
return h, true
}
return "", false
}
// foldLanding recomputes one thread's landing row from the forum_post rows
// currently present, and is called for every forum post folded in that
// thread. Cheap: one thread is a handful of rows, and the alternative —
// patching incrementally — cannot be made order-independent.
//
// A thread that is not (or is no longer) a landing has its row DELETED, so a
// record can never outlive the post it was read from.
func foldLanding(tx *sql.Tx, thread string) error {
var body, title, user, opened string
err := tx.QueryRow(`SELECT body, title, user, date FROM forum_post WHERE hash = ?`,
thread).Scan(&body, &title, &user, &opened)
if err == sql.ErrNoRows {
// The root has not been folded yet. Not an error and not a gap:
// whichever post arrives last recomputes the whole thread, so the
// row appears as soon as the root does.
return nil
}
if err != nil {
return err
}
branch, target, ok := parseLandingHeader(body)
if !ok {
if _, err := tx.Exec(`DELETE FROM landing WHERE id = ?`, thread); err != nil {
return err
}
// Whatever this thread is, it is forum content again. Only
// reachable by a refold that saw the root differently, but the
// re-filing below is not reversible any other way and a one-way
// classification is the kind that leaves a store wrong for ever.
_, err := tx.Exec(`UPDATE event SET kind = 'f', ref = ?,
comment = (SELECT 'Forum: ' || CASE WHEN p.title != '' THEN p.title
ELSE substr(p.body, 1, 60) END FROM forum_post p WHERE p.hash = event.hash)
WHERE hash IN (SELECT hash FROM forum_post WHERE thread = ?)`, thread, thread)
return err
}
// Ascending, so the LAST transition seen wins — zit's own fold rule,
// deliberately mirrored rather than re-derived.
rows, err := tx.Query(`SELECT hash, body, date FROM forum_post
WHERE thread = ? AND hash <> ? ORDER BY date, hash`, thread, thread)
if err != nil {
return err
}
defer rows.Close()
// verdicts holds each reply that ANNOUNCES a state, by its own
// state rather than the thread's final one: a timeline row says
// what happened at that moment, not how the story ended.
verdicts := map[string]string{}
// replyState is what the THREAD says, read the way zit's own fold
// reads it: the last transition wins.
replyState, replyBy := "open", ""
updated := opened
for rows.Next() {
var hash, replyBody, date string
if err := rows.Scan(&hash, &replyBody, &date); err != nil {
return err
}
if date > updated {
updated = date
}
if s, ok := parseLandingState(replyBody); ok {
replyState, replyBy = s, hash
verdicts[hash] = s
}
// Latest wins, exactly as the state above does: the rows are
// already ordered ascending for that reason.
if t, ok := parseLandingTarget(replyBody); ok {
target = t
}
if _, ok := LandingUpdate(replyBody); ok {
verdicts[hash] = "updated"
}
}
if err := rows.Err(); err != nil {
return err
}
// RFC 0007: the merge is the fact and the reply is the claim. A
// landing is accepted when the DAG holds the check-in that merged
// it — whoever posted what. A reply asserting acceptance with no
// merge behind it leaves the record open and is recorded as a
// claim, so the page can say one was made. Unconditional on
// purpose: a fact about the DAG is the same fact under any policy,
// which is what lets Disposability hold by construction.
//
// Rejection has no DAG fact to read, so there the reply stays the
// record — as it was.
merge, err := mergeOf(tx, branch, target, opened)
if err != nil {
return err
}
state, settledBy, claimedBy := "open", "", ""
// A reply says accepted and no merge check-in exists. Before calling
// that a claim, one more DAG fact: the branch may already be
// CONTAINED in the target — zit settles such a landing as accepted
// with nothing to merge ("merges up-to-date"), and its work is on
// the target. Tip ancestry is the wrong general test (a branch
// continued past its merge un-settles) but the right validation of
// a claim, and it runs only then.
contained := false
if merge == "" && replyState == "accepted" {
var err error
if contained, err = containedIn(tx, branch, target); err != nil {
return err
}
}
switch {
case merge != "" || contained:
state = "accepted"
if replyState == "accepted" {
settledBy = replyBy
}
case replyState == "rejected":
state, settledBy = "rejected", replyBy
case replyState == "accepted":
claimedBy = replyBy
verdicts[replyBy] = "claimed"
}
if _, err := tx.Exec(`INSERT OR REPLACE INTO landing
(id, branch, target, title, author, opened, updated, state, settled_by, claimed_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
thread, branch, target, title, user, opened, updated, state, settledBy, claimedBy); err != nil {
return err
}
if err := refileLandingEvents(tx, thread, title, verdicts); err != nil {
return err
}
return supersedeLandings(tx, branch)
}
// mergeOf finds the check-in that landed a proposal: the earliest on
// the target, opened or later, whose non-primary parent is on the
// proposed branch. Structural — the P card zit wrote when it merged —
// not tip ancestry, which un-settles a branch continued past its
// merge. The same shape MergesForLandings reads, inside the fold's
// transaction.
//
// Empty when the branch column is not yet computed for the tip, which
// is the case for check-ins folded in the current batch; the refresh
// after recomputeBranches closes that gap.
func mergeOf(tx *sql.Tx, branch, target, opened string) (string, error) {
var hash string
err := tx.QueryRow(`SELECT c.hash FROM checkin c
JOIN checkin_parent p ON p.child = c.hash AND p.ord > 0
JOIN checkin tip ON tip.hash = p.parent
WHERE tip.branch = ? AND c.branch = ? AND c.date >= ?
ORDER BY c.date, c.hash LIMIT 1`, branch, target, opened).Scan(&hash)
if err == sql.ErrNoRows {
return "", nil
}
return hash, err
}
// containedIn reports whether branch's tip is an ancestor of target's
// tip — the DAG fact behind "already on the target". Bounded, because
// a walk from trunk's tip on a large store visits every check-in ever
// made; the bound is far above any real branch depth and turns a
// pathological store into a "no" rather than a hang.
func containedIn(tx *sql.Tx, branch, target string) (bool, error) {
tip := func(b string) (string, error) {
var h string
err := tx.QueryRow(`SELECT c.hash FROM checkin c
WHERE c.branch = ? AND NOT EXISTS (
SELECT 1 FROM checkin_parent p JOIN checkin k ON k.hash = p.child
WHERE p.parent = c.hash AND k.branch = ?)
ORDER BY c.date DESC, c.hash DESC LIMIT 1`, b, b).Scan(&h)
if err == sql.ErrNoRows {
return "", nil
}
return h, err
}
bt, err := tip(branch)
if err != nil || bt == "" {
return false, err
}
tt, err := tip(target)
if err != nil || tt == "" {
return false, err
}
var one int
err = tx.QueryRow(`WITH RECURSIVE anc(hash, d) AS (
SELECT ?, 0
UNION
SELECT p.parent, anc.d + 1 FROM checkin_parent p JOIN anc ON p.child = anc.hash
WHERE anc.d < 20000)
SELECT 1 FROM anc WHERE hash = ? LIMIT 1`, tt, bt).Scan(&one)
if err == sql.ErrNoRows {
return false, nil
}
return err == nil, err
}
// refreshLandingStates re-derives every unsettled landing after the
// branch columns are recomputed. A merge check-in folded in the same
// batch as the reply that announced it — or pushed from a clone with
// no reply at all — is only findable once its tip's branch is known,
// and this is the first moment it is. Rejected records are left alone:
// they have no DAG fact to converge on.
//
// Every fold of a landing thread recomputes it from scratch, so this is
// the same function the posts run, not a second rule beside it.
func refreshLandingStates(tx *sql.Tx) error {
rows, err := tx.Query(`SELECT id FROM landing WHERE state <> 'rejected'`)
if err != nil {
return err
}
var ids []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
rows.Close()
return err
}
ids = append(ids, id)
}
rows.Close()
if err := rows.Err(); err != nil {
return err
}
for _, id := range ids {
if err := foldLanding(tx, id); err != nil {
return err
}
}
return nil
}
// supersedeLandings decides which of several open landings on one
// branch is the current one.
//
// zit#127 left this as "probably yes, refused by name on the second"
// and neither side enforces it. Refusing at open is the courtesy and it
// belongs in the writer; it cannot be the whole answer, because two
// people can open a landing for one branch in separate clones and both
// are ordinary forum threads that are valid artifacts when they meet.
// Sync makes prevention unenforceable, so the readers have to agree.
//
// The newest root wins, by (opened, id). Newest rather than oldest
// because a second landing is the author saying something about the
// branch NOW, and because it is the rule the state fold already uses —
// two folds in one file disagreeing about which artifact supersedes
// which is a worse thing to own than either rule.
//
// SETTLED landings never take part. An accepted landing is history and
// its check-in is on the target; a later proposal for the same branch
// is a new proposal, not a correction of a finished one.
//
// The losers keep their rows and their discussion — nothing is deleted,
// because a record somebody argued in is not noise. They are marked, so
// the queue can show one open landing per branch and the page can say
// where the conversation moved.
func supersedeLandings(tx *sql.Tx, branch string) error {
if branch == "" {
return nil
}
rows, err := tx.Query(`SELECT id FROM landing
WHERE branch = ? AND state = 'open' ORDER BY opened DESC, id DESC`, branch)
if err != nil {
return err
}
var open []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
rows.Close()
return err
}
open = append(open, id)
}
rows.Close()
if err := rows.Err(); err != nil {
return err
}
if len(open) == 0 {
return nil
}
// Recomputed from scratch every fold, never accumulated: a landing
// that was superseded and then settled elsewhere must be able to
// become current again when the winner is settled or withdrawn.
winner := open[0]
for _, id := range open {
to := winner
if id == winner {
to = ""
}
if _, err := tx.Exec(`UPDATE landing SET superseded_by = ? WHERE id = ?`,
to, id); err != nil {
return err
}
}
// A settled landing is never superseded by an open one.
_, err = tx.Exec(`UPDATE landing SET superseded_by = ''
WHERE branch = ? AND state <> 'open' AND superseded_by <> ''`, branch)
return err
}
// refileLandingEvents moves a landing's posts off the forum kind.
//
// A landing IS a forum thread, so the forum trigger files every post in
// one as kind 'f' with a "Forum: " prefix — which produced timeline
// rows reading "Forum: zit-landing-state: accepted Accepted. The forge
// now tells y", a prefix over a marker over a truncated body. quarry
// had already decided these are not forum content: notALanding keeps
// them out of the forum listing and its count. The timeline was the
// surface that missed the memo, and surfaces disagreeing about what a
// forum holds is the same shape as a count promising a thread the list
// refuses to show.
//
// This runs HERE, not in the trigger, because the trigger fires while
// the forum post is being inserted and the landing row does not exist
// yet — the root post is what the landing row is derived FROM. Any post
// in a thread recomputes the record, so classification converges no
// matter which order a clone delivers them in.
//
// Verdicts are 'l' and plain comments are 'lc'. A verdict changes the
// target branch and belongs in a mixed timeline; a comment is
// conversation, and three landings produced six rows of it.
func refileLandingEvents(tx *sql.Tx, thread, title string, verdicts map[string]string) error {
hashes, err := tx.Query(`SELECT hash FROM forum_post WHERE thread = ?`, thread)
if err != nil {
return err
}
defer hashes.Close()
var posts []string
for hashes.Next() {
var h string
if err := hashes.Scan(&h); err != nil {
return err
}
posts = append(posts, h)
}
if err := hashes.Err(); err != nil {
return err
}
for _, h := range posts {
kind, label := "lc", "Comment on landing: "+title
switch {
case h == thread:
kind, label = "l", "Landing: "+title
case verdicts[h] == "claimed":
// Asserted acceptance with nothing merged. Conversation,
// not a verdict: it changed no branch.
kind, label = "lc", "Acceptance claimed on landing: "+title
case verdicts[h] == "updated":
// The target brought into the branch. Housekeeping on the
// proposal, not a decision about it.
kind, label = "lc", "Landing updated from its target: "+title
case verdicts[h] != "":
kind, label = "l", "Landing "+verdicts[h]+": "+title
}
if _, err := tx.Exec(`UPDATE event SET kind = ?, ref = ?, comment = ?
WHERE hash = ?`, kind, thread, label, h); err != nil {
return err
}
}
return nil
}
// backfillLandings populates the table for indexes written before it
// existed, for the reason backfillTickets gives: the fold only writes it on
// newly arriving posts, so an upgraded index would show an EMPTY landing list
// until someone reindexed a store whose forum_post rows are already complete.
//
// IT ASKS WHAT IS MISSING, NOT WHETHER THE TABLE IS EMPTY, and the
// difference is a Severe bug this used to have. The guard was
// `landings > 0 || posts == 0`, which treats "has any row" as "is
// complete": once a single landing existed, the recovery never ran
// again, so a thread the one-shot did not see was never seen. Two open
// landings sat invisible on this forge for nineteen and nine days —
// state=open answered 0 while the DAG held both, the operator reported
// "I don't see those landings to reject", and the rows only appeared
// when somebody wrote to the threads and the ordinary fold picked them
// up (bbbc0a4da2e5).
//
// An empty table is not evidence of an unwritten table. The right
// question is which landing roots have no row, and backfillLandingEvents
// two functions below was already asking its version of it — it counts
// STALE rows rather than checking for none.
//
// The cost is one indexed anti-join per open instead of two counts,
// against a table whose rows are thread ROOTS carrying a marker. On a
// forge with 468 landings it finds nothing and does nothing.
func (ix *Index) backfillLandings() error {
var posts int
if err := ix.db.QueryRow(`SELECT count(*) FROM forum_post`).Scan(&posts); err != nil {
return err
}
if posts == 0 {
return nil
}
rows, err := ix.db.Query(`SELECT p.hash FROM forum_post p
WHERE p.hash = p.thread AND p.body LIKE ?
AND NOT EXISTS (SELECT 1 FROM landing l WHERE l.id = p.hash)`,
landingMarker+"%")
if err != nil {
return err
}
var roots []string
for rows.Next() {
var h string
if err := rows.Scan(&h); err != nil {
rows.Close()
return err
}
roots = append(roots, h)
}
rows.Close()
if err := rows.Err(); err != nil {
return err
}
if len(roots) == 0 {
return nil
}
// The LIKE above is a cheap prefilter; parseLandingHeader inside
// foldLanding is the real gate, so a body that merely starts with the
// marker but carries an unknown header is still refused.
tx, err := ix.w.Begin()
if err != nil {
return err
}
defer tx.Rollback()
for _, root := range roots {
if err := foldLanding(tx, root); err != nil {
return err
}
}
return tx.Commit()
}
// VerdictsForLandings reports each landing's CI verdict — "ci-pass",
// "ci-fail", or absent when no run has recorded one.
//
// The ticket asked which shape a landing verdict should take, forum
// reply or tag, before wiring the display. Neither, because the shape
// already exists: a verdict is a tag artifact on a CHECK-IN, quarry
// already folds and reads them, and a landing proposes a branch whose
// tip is a check-in. So a landing's verdict is its branch tip's
// verdict, and there is nothing new to mint.
//
// That is also the better answer on its own terms. The verdict stays
// attached to the thing that was actually tested rather than to a
// discussion record; it keeps the zit#127 invariant with no new
// vocabulary, because a stock fossil clone already understands the tag;
// and a landing retargeted or reopened inherits the right answer
// without anyone copying it across.
func (ix *Index) VerdictsForLandings(branches []string) (map[string]string, error) {
out := map[string]string{}
for _, b := range branches {
if b == "" || out[b] != "" {
continue
}
tip, err := ix.branchTip(b)
if err != nil {
return nil, err
}
if tip == "" {
continue
}
st, err := ix.tagState([]string{tip})
if err != nil {
return nil, err
}
if v := st[tip].ci; v != "" {
out[b] = v
}
}
return out, nil
}
// OpenLandingOn reports the current open landing for a branch, if
// there is one. The courtesy check a writer makes before opening a
// second: see supersedeLandings for why it cannot be the whole answer.
// Behind counts the target's check-ins the branch has not seen — how
// far the target has moved since the branch last carried it. Zero
// means an update has nothing to bring in; the decision card offers
// the verb only when this says it would do something (RFC 0009).
func (ix *Index) Behind(branch, target string) (int, error) {
if branch == "" || target == "" || branch == target {
return 0, nil
}
branchTip, err := ix.branchTip(branch)
if err != nil || branchTip == "" {
return 0, err
}
targetTip, err := ix.branchTip(target)
if err != nil || targetTip == "" {
return 0, err
}
// Ancestors of the branch tip, bounded: a branch that diverged more
// than 5000 check-ins back still reads as behind, just not exactly
// how far. The set is keyed on the hash ALONE. A first version
// carried the depth in the recursion, so UNION could not fold a
// check-in reached along two paths of different length — and in a
// history where trunk is merged into branches again and again,
// every ancestor is reached along many paths. On quarry's own
// 1,150 check-ins that was 85,000 rows and 400ms per landing page.
rows, err := ix.db.Query(`
WITH RECURSIVE seen(hash) AS (
SELECT ?
UNION
SELECT p.parent
FROM checkin_parent p
JOIN seen s ON p.child = s.hash
)
SELECT hash FROM seen LIMIT 5000`, branchTip)
if err != nil {
return 0, err
}
ancestors := map[string]bool{}
for rows.Next() {
var h string
if err := rows.Scan(&h); err != nil {
rows.Close()
return 0, err
}
ancestors[h] = true
}
rows.Close()
if err := rows.Err(); err != nil {
return 0, err
}
// Walk the target back from its tip, counting until the branch has
// seen it. The walk stays on reachable parents, not branch labels,
// so merges from elsewhere count as the movement they are.
behind := 0
frontier := []string{targetTip}
visited := map[string]bool{}
for len(frontier) > 0 && behind <= 500 {
next := []string{}
for _, h := range frontier {
if visited[h] || ancestors[h] {
continue
}
visited[h] = true
behind++
ps, err := ix.ParentsOf(h)
if err != nil {
return behind, err
}
next = append(next, ps...)
}
frontier = next
}
return behind, nil
}
// VerdictOf reads one check-in's recorded CI verdict — "ci-pass",
// "ci-fail", or "" — the per-check-in reading VerdictsForLandings
// gives per branch (RFC 0009 §verdict-tags, carry-forward's guard).
func (ix *Index) VerdictOf(checkin string) (string, error) {
if checkin == "" {
return "", nil
}
st, err := ix.tagState([]string{checkin})
if err != nil {
return "", err
}
return st[checkin].ci, nil
}
// ParentsOf lists a check-in's parents — for a merge, the two tips it
// joined, which is how carry-forward reconstructs the tested pair.
func (ix *Index) ParentsOf(checkin string) ([]string, error) {
rows, err := ix.db.Query(`SELECT parent FROM checkin_parent WHERE child = ?`, checkin)
if err != nil {
return nil, err
}
defer rows.Close()
var out []string
for rows.Next() {
var p string
if err := rows.Scan(&p); err != nil {
return nil, err
}
out = append(out, p)
}
return out, rows.Err()
}
func (ix *Index) OpenLandingOn(branch string) (LandingSummary, bool, error) {
var l LandingSummary
err := ix.db.QueryRow(`SELECT id, branch, target, title, author, opened, updated, state, settled_by, superseded_by, claimed_by
FROM landing WHERE branch = ? AND state = 'open' AND superseded_by = ''
ORDER BY opened DESC, id DESC LIMIT 1`, branch).Scan(&l.ID, &l.Branch, &l.Target,
&l.Title, &l.Author, &l.Opened, &l.Updated, &l.State, &l.SettledBy, &l.SupersededBy, &l.ClaimedBy)
if err == sql.ErrNoRows {
return LandingSummary{}, false, nil
}
if err != nil {
return LandingSummary{}, false, err
}
return l, true, nil
}
// BranchCommit is one check-in a landing proposes.
type BranchCommit struct {
Hash string `json:"hash"`
Date string `json:"date"`
User string `json:"user"`
Comment string `json:"comment"`
}
// BranchCommits returns a branch's own check-ins in ANCESTRY order,
// parents before children.
//
// The landing page used to list them through the timeline with a branch
// filter, which orders by (date DESC, hash DESC). Two check-ins made in
// the same second tie on date and then order by hash, which has nothing
// to do with which is the parent — so a branch's own commits rendered
// in an order that is arbitrary with respect to ancestry. Commits land
// back-to-back routinely here, so this is not a rare shape, and it is
// exactly the quiet kind of wrong a reviewer would trust.
//
// zit hit the same bug in `landing show` and its fix is the one worth
// inheriting: stop hand-rolling the order. The verb owns no ancestry
// ordering of its own; the DAG does.
//
// So this walks parents from the branch tip and orders by depth. A
// check-in's depth is its LONGEST path back from the tip, which is what
// makes a parent always deeper than its child even where the branch
// forked and rejoined. Dates break ties between unrelated commits at
// the same depth, where any order is as good as another, and never
// between a parent and its child.
//
// The walk stops where the branch does: a parent on another branch is
// the point the proposal diverged, and everything beyond it is shared
// history the target already has.
// BranchTip names a branch's newest check-in — the subject CI runs
// and external checks attach to (RFC 0009). Exported because taking
// the last element of a LIMITed BranchCommits silently answers with
// the wrong commit once a branch outgrows the limit.
func (ix *Index) BranchTip(branch string) (string, error) {
return ix.branchTip(branch)
}
func (ix *Index) BranchCommits(branch string, limit int) ([]BranchCommit, error) {
if branch == "" {
return nil, nil
}
if limit <= 0 {
limit = 50
}
tip, err := ix.branchTip(branch)
if err != nil || tip == "" {
return nil, err
}
rows, err := ix.db.Query(`
WITH RECURSIVE walk(hash, depth) AS (
SELECT ?, 0
UNION
SELECT p.parent, w.depth + 1
FROM checkin_parent p
JOIN walk w ON p.child = w.hash
JOIN checkin c ON c.hash = p.parent
WHERE c.branch = ?
)
SELECT c.hash, c.date, c.user, c.comment, max(w.depth) AS d
FROM walk w JOIN checkin c ON c.hash = w.hash
WHERE c.branch = ?
GROUP BY c.hash
ORDER BY d DESC, c.date, c.hash
LIMIT ?`, tip, branch, branch, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []BranchCommit
for rows.Next() {
var c BranchCommit
var depth int
if err := rows.Scan(&c.Hash, &c.Date, &c.User, &c.Comment, &depth); err != nil {
return nil, err
}
out = append(out, c)
}
if err := rows.Err(); err != nil {
return nil, err
}
hashes := make([]string, len(out))
for i, c := range out {
hashes[i] = c.Hash
}
amended, err := ix.amendedComments(hashes)
if err != nil {
return nil, err
}
for i := range out {
if c, ok := amended[out[i].Hash]; ok {
out[i].Comment = c
}
}
return out, nil
}
// amendedComments answers the standing comment correction (`zit amend
// --comment`) for each check-in that has one, by the rule the timeline
// applies in AttachCI. Every list that prints a check-in's comment
// routes through it, so no surface shows a superseded one.
func (ix *Index) amendedComments(hashes []string) (map[string]string, error) {
state, err := ix.tagState(hashes)
if err != nil {
return nil, err
}
out := map[string]string{}
for h, st := range state {
if st.comment != "" {
out[h] = st.comment
}
}
return out, nil
}
// branchTip is the check-in on a branch that nothing else on that
// branch calls a parent.
//
// Not "the newest by date", which is the same mistake one level up:
// two tips tying on a second would pick by hash. A tip is a structural
// property — a leaf within the branch — and only a branch that genuinely
// forked has more than one, where the newest is the honest guess.
// branchTip resolves a branch NAME to its newest leaf.
//
// A name reaches a check-in two ways, and reading only the first was
// the bug (6833076f). checkin.branch is the branch a check-in landed
// ON — one column, one name, computed by recomputeBranches. But fossil
// also lets a check-in carry a propagating `sym-NAME` tag for a name
// that is NOT its branch field, which is how a branch is renamed
// without orphaning the old name: the tip says branch=main and keeps a
// propagating sym-trunk, so both resolve to it. fossil and zit answer
// that way; quarry answered from the column alone, so an old name
// stopped at the last check-in whose field still said it. On the tcl
// mirror `trunk` served a 2020 tree for exactly this reason.
//
// The name reaches a check-in by INHERITANCE, not only by carrying:
// a propagating sym tag applies to every descendant until one cancels
// it, which is why this asks checkin_sym (derived in the fold by
// recomputeSymbols) rather than reading raw tag rows. Reading the
// carriers alone was the first, wrong version of this fix: it passed
// its own test, because the test put the tag on the tip, and left the
// tcl mirror answering exactly as before.
func (ix *Index) branchTip(branch string) (string, error) {
var tip string
// on_branch is computed once and joined twice: the leaf test has to
// ask the same question of a candidate's children that it asked of
// the candidate, or a rename would look like a leaf forever.
err := ix.db.QueryRow(`
WITH on_branch AS (
SELECT c.hash, c.date FROM checkin c
WHERE c.branch = ?1
OR EXISTS (SELECT 1 FROM checkin_sym s
WHERE s.checkin = c.hash AND s.name = ?1)
)
SELECT o.hash FROM on_branch o
WHERE NOT EXISTS (
SELECT 1 FROM checkin_parent p JOIN on_branch k ON k.hash = p.child
WHERE p.parent = o.hash)
ORDER BY o.date DESC, o.hash DESC LIMIT 1`, branch).Scan(&tip)
if err == sql.ErrNoRows {
return "", nil
}
return tip, err
}
// LandingMerge is the check-in a landing produced.
type LandingMerge struct {
Checkin string `json:"checkin"`
Date string `json:"date"`
User string `json:"user"`
Comment string `json:"comment"`
}
// MergesForLandings resolves each landing to the check-in that merged
// it, for the landings named. Landings that never landed are absent.
//
// landing.settled_by holds the reply that DECIDED the state — a forum
// post hash, not a commit — so it was read as meaning nothing connects
// a landing to the check-in it produced, and the durable fix was
// assumed to be upstream. It is not: the answer is already in the DAG.
// A merge check-in's P card lists its primary parent first and the
// branches it merged after, so ord > 0 IS the branch tip that landed,
// and the landing knows which branch it proposed.
//
// That is a structural fact zit wrote when it merged, not a guess.
// Matching on comment text would have been a guess — those matched only
// because the accept message and the merge message were written the
// same way by habit — and branch-plus-time is a heuristic that is
// eventually wrong.
//
// The search is bounded by when the landing was OPENED, not when it was
// accepted. The merge lands before the state artifact recording it is
// written — pager-shape merged at 12:46:55 and its verdict was filed at
// 12:49:51 — so a verdict bound silently loses exactly the check-ins
// this exists to find. A proposal cannot be landed before it is made,
// which is the bound that actually holds.
//
// The remaining ambiguity is a branch landed more than once; the
// earliest qualifying merge wins, which is the one that first landing
// produced.
func (ix *Index) MergesForLandings(ids []string) (map[string]LandingMerge, error) {
out := map[string]LandingMerge{}
if len(ids) == 0 {
return out, nil
}
stmt, err := ix.db.Prepare(`SELECT c.hash, c.date, c.user, c.comment
FROM checkin c
JOIN checkin_parent p ON p.child = c.hash AND p.ord > 0
JOIN checkin tip ON tip.hash = p.parent
WHERE tip.branch = ? AND c.branch = ? AND c.date >= ?
ORDER BY c.date, c.hash LIMIT 1`)
if err != nil {
return nil, err
}
defer stmt.Close()
for _, id := range ids {
var branch, target, opened string
err := ix.db.QueryRow(`SELECT l.branch, l.target, l.opened
FROM landing l WHERE l.id = ? AND l.state = 'accepted'`,
id).Scan(&branch, &target, &opened)
if err == sql.ErrNoRows {
continue // open, rejected, or unknown: nothing landed
}
if err != nil {
return nil, err
}
var m LandingMerge
err = stmt.QueryRow(branch, target, opened).Scan(&m.Checkin, &m.Date, &m.User, &m.Comment)
if err == sql.ErrNoRows {
continue
}
if err != nil {
return nil, err
}
if amended, err := ix.amendedComments([]string{m.Checkin}); err != nil {
return nil, err
} else if c, ok := amended[m.Checkin]; ok {
m.Comment = c
}
out[id] = m
}
return out, nil
}
// LandedBy names the landing behind a check-in: Role "merge" for the
// check-in an accept wrote, "branch" for one on the branch a landing
// carried. The zero value means no landing is involved.
type LandedBy struct {
ID string `json:"id"`
Title string `json:"title"`
State string `json:"state"`
Role string `json:"role"`
}
// LandedByCheckin derives the landing behind one check-in from the same
// facts the landing page reads (the merge's second parent, the landing's
// branch), so both directions of the link have one source and no new
// artifact. A merge wins over a branch match; a check-in on a landing's
// target is never "carried" by it.
func (ix *Index) LandedByCheckin(hash string) (LandedBy, error) {
var out LandedBy
err := ix.db.QueryRow(`SELECT l.id, l.title, l.state
FROM checkin c
JOIN checkin_parent p ON p.child = c.hash AND p.ord > 0
JOIN checkin tip ON tip.hash = p.parent
JOIN landing l ON l.branch = tip.branch AND l.target = c.branch
AND l.state = 'accepted' AND c.date >= l.opened
WHERE c.hash = ?
ORDER BY l.updated DESC LIMIT 1`, hash).Scan(&out.ID, &out.Title, &out.State)
if err == nil {
out.Role = "merge"
return out, nil
}
if err != sql.ErrNoRows {
return LandedBy{}, err
}
err = ix.db.QueryRow(`SELECT l.id, l.title, l.state
FROM checkin c JOIN landing l ON l.branch = c.branch AND l.target <> c.branch
WHERE c.hash = ?
ORDER BY l.opened DESC LIMIT 1`, hash).Scan(&out.ID, &out.Title, &out.State)
if err == sql.ErrNoRows {
return LandedBy{}, nil
}
if err != nil {
return LandedBy{}, err
}
out.Role = "branch"
return out, nil
}
// IsLandingMerge reports whether a check-in is the merge that landed a
// proposal: a check-in on a landing's target whose merged parent is on
// that landing's branch, made after it was opened. The same structural
// fact MergesForLandings reads, asked from the other end.
//
// An OPEN landing counts as well as an accepted one, because the merge
// is written before the verdict that records it (see MergesForLandings)
// and a fold can fall between the two.
func (ix *Index) IsLandingMerge(checkin string) (bool, error) {
var one int
err := ix.db.QueryRow(`SELECT 1 FROM checkin c
JOIN checkin_parent p ON p.child = c.hash AND p.ord > 0
JOIN checkin tip ON tip.hash = p.parent
JOIN landing l ON l.branch = tip.branch AND l.target = c.branch
WHERE c.hash = ? AND l.state IN ('open', 'accepted') AND c.date >= l.opened
LIMIT 1`, checkin).Scan(&one)
if err == sql.ErrNoRows {
return false, nil
}
return err == nil, err
}
// attachLandings decorates the MERGE check-ins in a timeline page with
// the landing each one settled, and who approved it.
//
// Evaluated for the returned rows only, and only for the merges among
// them — the same rule the page's other per-row extras follow. A
// timeline of check-ins on a forge with no landings pays one nothing.
func (ix *Index) attachLandings(out []TimelineEntry) error {
var merges []int
for i := range out {
if out[i].Kind == "ci" && out[i].IsMerge {
merges = append(merges, i)
}
}
if len(merges) == 0 {
return nil
}
// The approver is whoever posted the accept; failing that — a
// merge pushed from a clone with no reply — whoever wrote the merge.
stmt, err := ix.db.Prepare(`SELECT l.id, l.title,
COALESCE(NULLIF((SELECT f.user FROM forum_post f WHERE f.hash = l.settled_by), ''), c.user)
FROM checkin c
JOIN checkin_parent p ON p.child = c.hash AND p.ord > 0
JOIN checkin tip ON tip.hash = p.parent
JOIN landing l ON l.branch = tip.branch AND l.target = c.branch
AND l.state = 'accepted' AND c.date >= l.opened
WHERE c.hash = ?
ORDER BY l.updated DESC LIMIT 1`)
if err != nil {
return err
}
defer stmt.Close()
for _, i := range merges {
var id, title, approver string
err := stmt.QueryRow(out[i].Hash).Scan(&id, &title, &approver)
if err == sql.ErrNoRows {
continue // a merge somebody made by hand
}
if err != nil {
return err
}
out[i].Landing, out[i].LandingTitle, out[i].LandingApprover = id, title, approver
}
return nil
}
// backfillLandingEvents re-files landing posts that an older index filed
// as forum events.
//
// The classification moved from the forum trigger into the fold, so a
// store folded before that change carries "Forum: " rows for every
// landing comment and verdict it already holds. Without this, the
// timeline stays wrong until somebody happens to reindex — and derived
// data that is only correct after a manual step is not derived data,
// it is a chore.
//
// Idempotent and cheap to skip: the count is an indexed lookup, and a
// store with nothing to fix pays only for that.
func (ix *Index) backfillLandingEvents() error {
var stale int
if err := ix.db.QueryRow(`SELECT count(*) FROM event e
JOIN forum_post p ON p.hash = e.hash
WHERE e.kind = 'f' AND p.thread IN (SELECT id FROM landing)`).Scan(&stale); err != nil {
return err
}
if stale == 0 {
return nil
}
rows, err := ix.db.Query(`SELECT id FROM landing`)
if err != nil {
return err
}
var roots []string
for rows.Next() {
var h string
if err := rows.Scan(&h); err != nil {
rows.Close()
return err
}
roots = append(roots, h)
}
rows.Close()
if err := rows.Err(); err != nil {
return err
}
tx, err := ix.w.Begin()
if err != nil {
return err
}
defer tx.Rollback()
// foldLanding rather than refileLandingEvents directly: it is the
// one place that decides which reply is a verdict, and a backfill
// that re-derived that rule beside it would be a second answer to
// the same question.
for _, root := range roots {
if err := foldLanding(tx, root); err != nil {
return err
}
}
return tx.Commit()
}
// LandingSummary is the list-surface row: everything the queue and the
// detail header need, without a second query or a DAG walk.
type LandingSummary struct {
ID string `json:"id"`
Branch string `json:"branch"`
Target string `json:"target"`
Title string `json:"title"`
Author string `json:"author"`
Opened string `json:"opened"`
Updated string `json:"updated"`
State string `json:"state"`
// SettledBy is the reply that decided State; empty while open.
SettledBy string `json:"settled_by,omitempty"`
// Replies counts the thread's remarks: every post but the opener,
// minus the state-transition posts, which are events rather than
// things anyone said.
Replies int `json:"replies"`
// SupersededBy names the landing this one defers to when two are
// open on the same branch. Empty is the ordinary case.
SupersededBy string `json:"superseded_by,omitempty"`
// ClaimedBy is a reply that asserted acceptance while nothing was
// merged. The record is still open; the page says a claim was made.
ClaimedBy string `json:"claimed_by,omitempty"`
// CI is the branch tip's recorded verdict — "ci-pass", "ci-fail",
// or empty when no run has written one. Not stored on the landing:
// filled by the reader from the tag, so it cannot go stale against
// the branch it describes.
CI string `json:"ci,omitempty"`
// Stack, Position and Depth are RFC 0006's reading of an open
// landing: the base's id, this landing's rung counted from 1 at the
// base, and the stack's height. Derived at read time by
// AttachStrata, never stored. Broken names a chain that cannot be
// walked: a cycle, or a rejected base.
Stack string `json:"stack,omitempty"`
Position int `json:"position,omitempty"`
Depth int `json:"depth,omitempty"`
Broken string `json:"broken,omitempty"`
}
// Landings lists landings, newest activity first. state filters to one of
// open/accepted/rejected; empty returns every landing.
//
// limit of 0 means no limit — the caller is a page that already knows how
// many rows it wants, and a silent default would truncate a listing that
// looked complete.
// landingCols is the landing row's plain columns, in scan order.
const landingCols = `id, branch, target, title, author, opened, updated, state, settled_by, superseded_by, claimed_by`
// landingRepliesCol is the reply count, defined ONCE.
//
// It was written into the listing and not into the single read, so
// landing.list reported three remarks on a thread while landing.get
// reported none — the same object described two ways, and no way for a
// reader to tell which surface was lying. That is the identical shape
// to repo.get's contributors, which read one of its two numbers and
// left the other at zero.
//
// One const rather than two copies because two copies is how they came
// to disagree: the next change to what counts as a remark now reaches
// both, and cannot reach one.
const landingRepliesCol = `(SELECT count(*) FROM forum_post p
WHERE p.thread = landing.id AND p.hash <> landing.id
AND p.body NOT LIKE ?)`
func (ix *Index) Landings(state string, limit int) ([]LandingSummary, error) {
q := `SELECT ` + landingCols + `, ` + landingRepliesCol + `
FROM landing`
// The marker holds no LIKE wildcards, so a bare prefix pattern is exact.
args := []any{landingStateMarker + "%"}
if state != "" {
q += ` WHERE state = ?`
args = append(args, state)
// The open queue is a work list, and a superseded proposal is
// not work: its branch already has a current landing. It stays
// reachable, and the unfiltered listing still shows it.
if state == "open" {
q += ` AND superseded_by = ''`
}
}
q += ` ORDER BY updated DESC, id DESC`
if limit > 0 {
q += ` LIMIT ?`
args = append(args, limit)
}
rows, err := ix.db.Query(q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var out []LandingSummary
for rows.Next() {
var l LandingSummary
if err := rows.Scan(&l.ID, &l.Branch, &l.Target, &l.Title, &l.Author,
&l.Opened, &l.Updated, &l.State, &l.SettledBy, &l.SupersededBy, &l.ClaimedBy,
&l.Replies); err != nil {
return nil, err
}
out = append(out, l)
}
return out, rows.Err()
}
// Landing returns one landing by id, or ok=false when no such record is
// indexed. Prefix matching belongs to the caller's hash resolution, not
// here: this table's key is the full thread-root hash.
func (ix *Index) Landing(id string) (LandingSummary, bool, error) {
var l LandingSummary
// The reply count comes along, from the same const the listing uses.
// Reading a landing and reading the landings LIST is how one object
// gets described twice, so the two go through one definition of what
// a remark is.
err := ix.db.QueryRow(`SELECT `+landingCols+`, `+landingRepliesCol+`
FROM landing WHERE id = ?`, landingStateMarker+"%", id).
Scan(&l.ID, &l.Branch, &l.Target, &l.Title,
&l.Author, &l.Opened, &l.Updated, &l.State, &l.SettledBy, &l.SupersededBy, &l.ClaimedBy,
&l.Replies)
if err == sql.ErrNoRows {
return LandingSummary{}, false, nil
}
if err != nil {
return LandingSummary{}, false, err
}
return l, true, nil
}