Artifact 5f9608fdb2bb01eb92fcb9c65375cbe962d5e3d52d9e4f0af95e21c2c04d4df4:
- File
internal/index/landing.go
— part of check-in
[06475f5aee]
at
2026-08-26 11:19:27
on branch landing-merge
— Link a landing to the check-in it produced
settled_by names the reply that decided the state, not the commit, so this was assumed to need an upstream change. It does not: a merge check-in 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. Structural, not a text or time heuristic. Resolves 18 of 18 accepted landings in this store.
Bounded by when the landing OPENED, not when it was accepted: the merge lands before the artifact recording it is written.
The landing page says what it landed as; a merge row on the timeline names the proposal and who approved it. Decorated, not collapsed — a merge is a real DAG node and hiding it would leave the graph drawing edges to a row the timeline does not show. (user: dmestas, size: 21406) [annotate] [blame] [check-ins using] [more...]
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:" // 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 "" } // 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 FROM landing WHERE id LIKE ? || '%' LIMIT 2`, prefix) 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); 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) } // 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{} state, settledBy := "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 { state, settledBy = s, hash verdicts[hash] = s } } if err := rows.Err(); err != nil { return err } if _, err := tx.Exec(`INSERT OR REPLACE INTO landing (id, branch, target, title, author, opened, updated, state, settled_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, thread, branch, target, title, user, opened, updated, state, settledBy); err != nil { return err } return refileLandingEvents(tx, thread, title, verdicts) } // 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] != "": 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. // // Once, at open, and only when there is nothing to lose: a non-empty table is // left alone. func (ix *Index) backfillLandings() error { var landings, posts int if err := ix.db.QueryRow(`SELECT count(*) FROM landing`).Scan(&landings); err != nil { return err } if err := ix.db.QueryRow(`SELECT count(*) FROM forum_post`).Scan(&posts); err != nil { return err } if landings > 0 || posts == 0 { return nil } rows, err := ix.db.Query(`SELECT hash FROM forum_post WHERE hash = thread AND body LIKE ?`, 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.db.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() } // 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 } out[id] = m } return out, nil } // 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 } stmt, err := ix.db.Prepare(`SELECT l.id, l.title, COALESCE((SELECT f.user FROM forum_post f WHERE f.hash = l.settled_by), l.author) 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.db.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"` } // 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. func (ix *Index) Landings(state string, limit int) ([]LandingSummary, error) { q := `SELECT id, branch, target, title, author, opened, updated, state, settled_by FROM landing` var args []any if state != "" { q += ` WHERE state = ?` args = append(args, state) } 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); 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 err := ix.db.QueryRow(`SELECT id, branch, target, title, author, opened, updated, state, settled_by FROM landing WHERE id = ?`, id).Scan(&l.ID, &l.Branch, &l.Target, &l.Title, &l.Author, &l.Opened, &l.Updated, &l.State, &l.SettledBy) if err == sql.ErrNoRows { return LandingSummary{}, false, nil } if err != nil { return LandingSummary{}, false, err } return l, true, nil }