package api
import (
"context"
"fmt"
"slices"
"strings"
"craftdesign.group/quarry/internal/index"
"craftdesign.group/quarry/internal/lease"
"craftdesign.group/quarry/internal/ops"
)
type landingRetargetIn struct {
Repo string `json:"repo" validate:"required,max=64"`
ID string `json:"id" validate:"required,max=64"`
To string `json:"to" validate:"required,max=120"`
}
type landingRetargetOut struct {
ID string `json:"id"`
Target string `json:"target"`
}
// registerLandingRetarget points an open record at a different target.
//
// It exists because a stack can strand itself. Landings on a stack are
// proposed onto the rung below, and when that rung lands on its own the
// target's work is already in the destination — so zit refuses the
// merge and says to retarget. zit has the verb; quarry had no way to
// reach it, which made a stranded landing unrecoverable from anything
// but a shell. Somebody holding a phone could read the instruction and
// not act on it.
//
// It takes accept's letter rather than the forum one. Retargeting does
// not write a merge, but it decides WHERE the next merge is written,
// and a record that can be aimed by anyone who may post in its thread
// is a way to point somebody else's work at a branch they never chose.
// Protection on the NEW target is still checked at accept time, where
// it belongs; who may aim the record is the author or whoever could
// accept it where it stands.
func registerLandingRetarget(r *ops.Registry, d Deps) error {
return ops.Register(r, ops.Op[landingRetargetIn, landingRetargetOut]{
Name: "landing.retarget",
Caps: "i",
Doc: "Point an open record at a different target branch, or stack it on another open landing by naming that landing as !N: it then lands after that one, whether or not its branch was built on it. Unstacking is a retarget to the branch the strata lands on. The record's author may move it unless rungs opened by others stand on it and would move with it; whoever owns the branch its strata lands on may move it, both where it stands and where it would go. Refused when the move would make a loop, or when the two branches share no history.",
Kind: ops.Command,
Auth: ops.Token,
Route: "POST /api/v1/r/{repo}/landing/{id}/retarget",
Run: func(ctx context.Context, in landingRetargetIn) (landingRetargetOut, error) {
if err := d.refuseUnlessWritable(in.Repo); err != nil {
return landingRetargetOut{}, err
}
repo, err := d.repoFor(ctx, in.Repo)
if err != nil {
return landingRetargetOut{}, err
}
// !12 or 12 is the landing's number, as landing.get reads it.
in.ID = d.landingRef(in.Repo, repo, in.ID)
l, ok, err := repo.Index.LandingByPrefix(in.ID)
if err != nil {
return landingRetargetOut{}, err
}
if !ok {
return landingRetargetOut{}, fmt.Errorf("no landing %q: %w", in.ID, ops.ErrNotFound)
}
if l.State != "open" {
return landingRetargetOut{}, fmt.Errorf(
"the record is %s; a settled record does not move: %w", l.State, ops.ErrInvalid)
}
to := strings.TrimSpace(in.To)
if to == "" {
return landingRetargetOut{}, fmt.Errorf(
"name the branch to land on: %w", ops.ErrInvalid)
}
if to, err = d.stackTarget(in.Repo, repo, to); err != nil {
return landingRetargetOut{}, err
}
all, err := repo.Index.Landings("", 0)
if err != nil {
return landingRetargetOut{}, err
}
// WHO may move it: its author, or an owner of the branch its
// strata lands on — where it stands now and where the move
// would put it, so nobody can carry somebody else's work
// out of a destination they do not own or into one. A
// stacked rung's own target is a feature branch that nobody
// protects; asking it would let any developer reorder a
// strata. dmestas ruled it so (ae900759, then 2026-10-25 for
// strata): the people who own the destination decide what
// lands there and in what order, and the author may always
// arrange their own proposal.
//
// An anonymous caller is nobody rather than everybody: an
// empty author matches an empty record author by string
// alone, which is two absences and not one person.
//
// A rung whose base was accepted and not yet retargeted lands
// where that base did (landedBaseTarget), not on the base's
// branch, which nobody protects.
now, _, _ := index.LandsOn(all, l.Target)
if landed := landedBaseTarget(repo, l); landed != "" {
now = landed
}
if now == "" {
now = l.Target
}
after, through, ok := index.LandsOn(all, to)
who := author(ctx)
owns := func(branch string) bool { return d.Protect == nil || d.Protect.MayJoin(in.Repo, who, branch) }
// Moving a landing moves every rung stacked on it. The author
// may arrange their own proposal, but carrying somebody else's
// rungs with it is the destination owner's act.
mine := who == l.Author && !carriesOthers(all, l, who)
if who == "" || (!mine && !(owns(now) && (!ok || owns(after)))) {
if who != "" && who == l.Author {
return landingRetargetOut{}, fmt.Errorf(
"%w: rungs opened by others stand on %.12s and would move with it; that is for whoever owns %s, where they land",
ops.ErrForbidden, l.ID, now)
}
return landingRetargetOut{}, fmt.Errorf(
"%w: %.12s was opened by %s; moving somebody else's record is for whoever owns %s, where it lands",
ops.ErrForbidden, l.ID, landingAuthorName(l.Author), now)
}
if to == l.Branch {
return landingRetargetOut{}, fmt.Errorf(
"a branch cannot land on itself: %w", ops.ErrInvalid)
}
if to == l.Target {
// Already where it was asked to go. The end state the
// caller wanted, so this succeeds and writes nothing.
return landingRetargetOut{ID: l.ID, Target: l.Target}, nil
}
if err := carriesBelow(all, repo, l, to); err != nil {
return landingRetargetOut{}, err
}
if err := stackHazard(ctx, repo, l.Branch, to, through, ok); err != nil {
return landingRetargetOut{}, err
}
if err := repo.write(func(tok lease.Token) error {
_, werr := repo.Zit.LandingRetarget(tok, l.ID, author(ctx), to)
return werr
}); err != nil {
return landingRetargetOut{}, err
}
return landingRetargetOut{ID: l.ID, Target: to}, nil
},
})
}
// landingAuthorName names a record's opener for a refusal without
// turning an anonymous proposal into a person called "".
func landingAuthorName(user string) string {
if user == "" {
return "an anonymous visitor"
}
return user
}
// strataVerdict is the verdict of a whole strata: green only when every
// rung is. A rung's checks test it merged onto the rung below, so the
// top's verdict covers the strata only when each rung was built on the
// one under it; a rung stacked on a sibling it never contained leaves
// the rungs beneath that one untested by the top's run. The worst rung
// answers for the stack, a failure before anything else.
func strataVerdict(segs []index.StackSegment) string {
worst := "ci-pass"
for _, sg := range segs {
switch {
case sg.CI == "ci-fail":
return "ci-fail"
case sg.CI != "ci-pass" && worst == "ci-pass":
worst = sg.CI
}
}
if len(segs) == 0 {
return ""
}
return worst
}
// strataRelations says, for each rung above the bottom, whether its
// branch was built on the rung below it or only stacked there. Both
// land the same way, but a reader who sees "on #12" assumes the first;
// the page says "after" when it is the second.
func strataRelations(ctx context.Context, repo *Repo, st *index.Strata) {
if repo == nil || repo.Index == nil || repo.Zit == nil {
return
}
for i := range st.Segments {
sg := &st.Segments[i]
if i == 0 || sg.State != "open" {
continue
}
tip, _ := repo.Index.BranchTip(sg.Branch)
under, _ := repo.Index.BranchTip(sg.Target)
if tip == "" || under == "" {
continue
}
base, err := repo.Zit.MergeBase(ctx, tip, under)
if err != nil || base == "" {
continue
}
// Built on the rung below when the two last met on that rung's
// branch — at its tip, or at an earlier check-in of it if it has
// moved on since. Meeting anywhere else (the strata's target,
// typically) means this branch never held any of that rung.
on, err := repo.Index.BranchOf([]string{base})
if err != nil {
continue
}
if base == under || on[base] == sg.Target {
sg.Relation = "built-on"
} else {
sg.Relation = "after"
}
}
}
// stackTarget reads a target that names another landing — "!12", or "!"
// and a hash prefix — as that landing's branch, so a landing can be
// stacked on another by naming it. Anything else is a branch name and
// comes back as it is. A settled landing has no strata to join.
func (d Deps) stackTarget(name string, repo *Repo, to string) (string, error) {
if !strings.HasPrefix(to, "!") {
return to, nil
}
ref := d.landingRef(name, repo, to)
if ref == to {
ref = to[1:]
}
if ref == "" {
return "", fmt.Errorf("name the landing to stack on after the !: %w", ops.ErrInvalid)
}
under, found, err := repo.Index.LandingByPrefix(ref)
if err != nil {
return "", err
}
if !found {
return "", fmt.Errorf("no landing %s: %w", to, ops.ErrNotFound)
}
if under.State != "open" {
return "", fmt.Errorf("landing %s is %s; stack on an open landing: %w", to, under.State, ops.ErrInvalid)
}
return under.Branch, nil
}
// stackHazard refuses aiming branch at to when no walk could land it:
// a loop, where to's strata already stands on branch (through and ok
// are index.LandsOn's reading of to), or two branches that share no
// history, between which no merge exists. Being unrelated by ANCESTRY
// is fine — a branch may land after another it was never built on, and
// its checks test it merged onto that one — so only history is asked.
func stackHazard(ctx context.Context, repo *Repo, branch, to string, through []string, ok bool) error {
if !ok {
return fmt.Errorf("%s's strata is a loop already; nothing on it can land: %w", to, ops.ErrInvalid)
}
if slices.Contains(through, branch) {
return fmt.Errorf("%s already lands on %s; aiming %s at it would make a loop: %w", to, branch, branch, ops.ErrInvalid)
}
if repo.Zit == nil || repo.Index == nil {
return nil
}
tip, _ := repo.Index.BranchTip(branch)
onto, _ := repo.Index.BranchTip(to)
if tip == "" || onto == "" {
return nil
}
if base, err := repo.Zit.MergeBase(ctx, tip, onto); err == nil && base == "" {
return fmt.Errorf("%s and %s share no history, so %s cannot land on it: %w", branch, to, branch, ops.ErrInvalid)
}
return nil
}
// carriesOthers reports whether some open landing by somebody other
// than who stands on l, directly or further up its strata, and so would
// move with it.
func carriesOthers(all []index.LandingSummary, l index.LandingSummary, who string) bool {
for _, o := range all {
if o.ID == l.ID || o.State != "open" || o.SupersededBy != "" || o.Author == who {
continue
}
if _, through, _ := index.LandsOn(all, o.Target); slices.Contains(through, l.Branch) {
return true
}
}
return false
}
// carriesBelow refuses moving a landing off an open landing its branch
// is built on, onto a target that lacks that landing's work. The branch
// would carry the lower landing's check-ins with it, past that landing
// and its checks, and leave it open (jva, ^2641: Unstack on a built-on
// rung put the rung below on trunk). The way out is to land the lower
// one first, or to rebuild this branch from the new target.
func carriesBelow(all []index.LandingSummary, repo *Repo, l index.LandingSummary, to string) error {
if repo.Index == nil {
return nil
}
for _, below := range all {
if below.State != "open" || below.Branch != l.Target {
continue
}
built, err := repo.Index.Behind(l.Branch, below.Branch)
if err != nil || built > 0 {
return nil // not built on it: it lands after it, alone
}
lacks, err := repo.Index.Behind(to, below.Branch)
if err != nil || lacks == 0 {
return nil
}
return fmt.Errorf("%s is built on %s, whose %d check-in(s) are not on %s: moving it there would land them past %s and its checks; land %s first, or rebuild %s from %s: %w",
l.Branch, landingName(below.Number, below.Branch), lacks, to, landingName(below.Number, below.Branch),
landingName(below.Number, below.Branch), l.Branch, to, ops.ErrInvalid)
}
return nil
}