Artifact 94b1626963d20b18f8db901fcf8b56195aa4ed24250a624cc74ef500b7429782:
- File
internal/oltp/repo.go
— part of check-in
[58c34af8f6]
at
2026-08-30 13:18:09
on branch trunk
— imports are visible: a cloning repository exists from the first second
repo.add registers the row, returns, and copies behind it. State is cloning until the store lands, failed with the reason if it does not, and a restart settles rows it interrupted rather than leaving them claiming to clone forever. The fleet page grows an Importing section. (user: dmestas, size: 13246) [annotate] [blame] [check-ins using]
// repo.go stores which repositories the forge serves. 0001 §8 lists the // repo registry as forge OLTP state alongside accounts and tokens; until // now it lived only in argv, so adding a repository meant editing the // server's command line and restarting every other repository with it. package oltp import ( "database/sql" "errors" "fmt" "path/filepath" "regexp" "strings" "time" ) const repoSchema = ` CREATE TABLE IF NOT EXISTS repo ( name TEXT PRIMARY KEY, -- the forge's name, as it appears in URLs path TEXT NOT NULL, -- directory holding .zit, on this node description TEXT NOT NULL DEFAULT '', -- 'public' or 'private'. Public by DEFAULT and on migration: an -- upgrade must never silently hide what was already being served, -- and taking a repository private is the deliberate act. visibility TEXT NOT NULL DEFAULT 'public', homepage TEXT NOT NULL DEFAULT '', topics TEXT NOT NULL DEFAULT '', -- space-separated -- RFC 0009 §verdict-tags: '' follows the fleet default, 'on' and -- 'off' are this repository's own choice. ci_tags TEXT NOT NULL DEFAULT '', created TEXT NOT NULL );` // repoMigrations are additive columns for registries written by an // earlier schema. A duplicate-column error means it is already there. var repoMigrations = []string{ `ALTER TABLE repo ADD COLUMN description TEXT NOT NULL DEFAULT ''`, `ALTER TABLE repo ADD COLUMN visibility TEXT NOT NULL DEFAULT 'public'`, `ALTER TABLE repo ADD COLUMN homepage TEXT NOT NULL DEFAULT ''`, `ALTER TABLE repo ADD COLUMN topics TEXT NOT NULL DEFAULT ''`, `ALTER TABLE repo ADD COLUMN ci_tags TEXT NOT NULL DEFAULT ''`, // An import is minutes of network for a large repository. Before // these, a cloning repository did not exist anywhere a reader could // see it — no row, no state, no progress — because it was only // registered once the clone returned. State makes the repository // real from the first second, which is the whole point. `ALTER TABLE repo ADD COLUMN state TEXT NOT NULL DEFAULT 'ready'`, `ALTER TABLE repo ADD COLUMN import_url TEXT NOT NULL DEFAULT ''`, `ALTER TABLE repo ADD COLUMN import_error TEXT NOT NULL DEFAULT ''`, } // Repository states. Ready is the ordinary one and the default, so // every row written before importing existed reads correctly. const ( Ready = "ready" // serving normally Cloning = "cloning" // an import is running; the store is incomplete Failed = "failed" // the import did not finish; the row is a record, not a repository ) // Visibility values. Private means only an identity with a role on the // repository may see it at all — including that it exists. const ( Public = "public" Private = "private" ) // RepoName is the accepted repository name. Same rule the --repo flag // enforces, kept here so a name that arrives through the API cannot be // one the flag would have refused. // // It is deliberately narrow: the name appears in a URL path segment, so // anything needing escaping to be safe there is better rejected than // encoded. var RepoName = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$`) // Repo is one registered repository. type Repo struct { Name string Path string // Visibility is Public or Private. Visibility string // Description is the one line that says what this repository is. // Every forge has it and quarry had nowhere to put it, so the // repository list could only ever show names and counts. Description string // Homepage and Topics are the About-panel settings a maintainer // edits in place, GitHub-style; .quarry/repo.json remains the // in-tree alternative and these override it when set. Homepage string Topics string // space-separated Created string // State is Ready, Cloning or Failed. ImportURL is what a Cloning or // Failed row is (or was) copying; ImportError is why a Failed one // stopped — kept rather than discarded, because an import that // vanished without saying why is the thing this replaces. State string ImportURL string ImportError string } // AddRepo registers a repository. The path is stored absolute: a // relative one would resolve against whatever directory the server // happened to start in, which is not a property anybody intends to // depend on and changes silently between a shell and a service manager. // AddImportingRepo registers a repository whose store is still being // copied. It exists from this moment: listed, navigable, and honestly // labelled as incomplete, rather than appearing only once the clone // finishes minutes later. func (d *DB) AddImportingRepo(name, path, description, visibility, url string) error { if err := d.AddRepo(name, path, description, visibility); err != nil { return err } _, err := d.db.Exec(`UPDATE repo SET state = ?, import_url = ? WHERE name = ?`, Cloning, url, name) return err } // SetRepoState moves a repository between Ready, Cloning and Failed. // The error text is stored only for Failed and cleared otherwise, so a // repository that succeeds on a retry does not keep explaining a // failure that no longer applies. func (d *DB) SetRepoState(name, state, importErr string) error { if state != Failed { importErr = "" } _, err := d.db.Exec(`UPDATE repo SET state = ?, import_error = ? WHERE name = ?`, state, importErr, name) return err } // ImportingRepos lists repositories whose state says an import was in // flight. Used at startup: a clone runs as a child of the server, so a // restart kills it and leaves the row claiming to be cloning forever. func (d *DB) ImportingRepos() ([]string, error) { rows, err := d.db.Query(`SELECT name FROM repo WHERE state = ?`, Cloning) if err != nil { return nil, err } defer rows.Close() var out []string for rows.Next() { var n string if err := rows.Scan(&n); err != nil { return nil, err } out = append(out, n) } return out, rows.Err() } func (d *DB) AddRepo(name, path, description, visibility string) error { if !RepoName.MatchString(name) { return fmt.Errorf("oltp: repository name %q: use letters, digits, dot, dash, underscore", name) } if path == "" { return errors.New("oltp: repository needs a path") } abs, err := filepath.Abs(path) if err != nil { return err } if visibility != Private { visibility = Public } _, err = d.db.Exec(`INSERT INTO repo (name, path, description, visibility, created) VALUES (?, ?, ?, ?, ?)`, name, abs, description, visibility, time.Now().UTC().Format(time.RFC3339)) if err != nil && strings.Contains(err.Error(), "UNIQUE") { return fmt.Errorf("oltp: repository %q already registered", name) } return err } // DeleteRepo unregisters a repository. It removes the registration only // — the store on disk and its derived index are left alone, because // "stop serving this" and "destroy this" are different intentions and // only one of them is reversible. func (d *DB) DeleteRepo(name string) error { _, err := d.db.Exec(`DELETE FROM repo WHERE name = ?`, name) return err } // SetRepoAbout updates the About-panel settings: the project homepage // and its space-separated topic list. func (d *DB) SetRepoAbout(name, homepage, topics string) error { _, err := d.db.Exec(`UPDATE repo SET homepage = ?, topics = ? WHERE name = ?`, homepage, topics, name) return err } // SetRepoDescription updates the one-line summary. func (d *DB) SetRepoDescription(name, description string) error { _, err := d.db.Exec(`UPDATE repo SET description = ? WHERE name = ?`, description, name) return err } // SetRepoVisibility takes a repository public or private. // // It REFUSES a repository with no registry row rather than updating // nothing and reporting success. That is not hypothetical: repositories // named by --repo had no row, so taking one private silently did // nothing and told the operator it had worked — a security control that // reports success while doing nothing is worse than one that is absent. func (d *DB) SetRepoVisibility(name, visibility string) error { if visibility != Private { visibility = Public } res, err := d.db.Exec(`UPDATE repo SET visibility = ? WHERE name = ?`, visibility, name) if err != nil { return err } if n, _ := res.RowsAffected(); n == 0 { return fmt.Errorf("no repository named %q", name) } // RFC 0004 §Private: the flag IS the two blank category strings. // Private blanks them; public with both blank returns the // repository to the fleet default rather than inventing strings. if visibility == Private { return d.SetVisibility(name, "Private") } if d.CategoryCaps(name, "nobody") == "" && d.CategoryCaps(name, "anonymous") == "" { _, err = d.db.Exec(`DELETE FROM repo_category_caps WHERE repo = ? AND category IN ('nobody', 'anonymous')`, name) } return err } // EnsureRepo records a repository that is being served but was never // registered — the --repo bootstrap path. Existing rows are left alone, // so a description or visibility set through the API survives a restart // that also names the repository on the command line. func (d *DB) EnsureRepo(name, path string) error { abs, err := filepath.Abs(path) if err != nil { return err } _, err = d.db.Exec(`INSERT INTO repo (name, path, description, visibility, created) VALUES (?, ?, '', 'public', ?) ON CONFLICT (name) DO NOTHING`, name, abs, time.Now().UTC().Format(time.RFC3339)) return err } // CITagsMode reads a repository's verdict-tag choice: ” follows the // fleet default, 'on' and 'off' are its own (RFC 0009 §verdict-tags). func (d *DB) CITagsMode(name string) string { var m string if err := d.db.QueryRow(`SELECT ci_tags FROM repo WHERE name = ?`, name).Scan(&m); err != nil { return "" } return m } // SetCITagsMode writes it; anything but on/off means the fleet default. func (d *DB) SetCITagsMode(name, mode string) error { if mode != "on" && mode != "off" { mode = "" } _, err := d.db.Exec(`UPDATE repo SET ci_tags = ? WHERE name = ?`, mode, name) return err } // PrivateRepos returns the set of private repository names. A SET rather // than a per-name query because the read gate runs on every request and // the answer changes only when an admin changes it. func (d *DB) PrivateRepos() (map[string]bool, error) { rows, err := d.db.Query(`SELECT name FROM repo WHERE visibility = 'private'`) if err != nil { return nil, err } defer rows.Close() out := map[string]bool{} for rows.Next() { var n string if err := rows.Scan(&n); err != nil { return nil, err } out[n] = true } return out, rows.Err() } // PurgeRepo removes a repository's registration and everything keyed to // it: grants, sync users, its mirror, its webhooks and its CI records. // // One transaction and one place, because the failure mode of scattering // this is silent: a deletion that misses a table leaves credentials or // an outbound webhook pointing at a repository that no longer exists, // and if a repository of the same name is later created it inherits // them. When a new repo-keyed table is added, it is added here. func (d *DB) PurgeRepo(name string) error { tx, err := d.db.Begin() if err != nil { return err } defer tx.Rollback() for _, q := range []string{ `DELETE FROM sync_user WHERE repo = ?`, `DELETE FROM mirror WHERE repo = ?`, `DELETE FROM mirror_chatbridge WHERE repo = ?`, `DELETE FROM mirror_chatbridge_msg WHERE repo = ?`, `DELETE FROM webhook WHERE repo = ?`, `DELETE FROM ci_run WHERE repo = ?`, `DELETE FROM ci_watermark WHERE repo = ?`, `DELETE FROM forum_vote WHERE repo = ?`, `DELETE FROM forum_thread_category WHERE repo = ?`, `DELETE FROM poll_vote WHERE repo = ?`, `DELETE FROM forum_moderation WHERE repo = ?`, `DELETE FROM forum_staged WHERE repo = ?`, `DELETE FROM forum_modlog WHERE repo = ?`, `DELETE FROM forum_hidden WHERE repo = ?`, `DELETE FROM forum_sub WHERE repo = ?`, `DELETE FROM repo_caps WHERE repo = ?`, `DELETE FROM branch_protection WHERE repo = ?`, `DELETE FROM repo_category_caps WHERE repo = ?`, `DELETE FROM repo WHERE name = ?`, } { if _, err := tx.Exec(q, name); err != nil { return err } } return tx.Commit() } // Repos lists registered repositories, name-ordered. func (d *DB) Repos() ([]Repo, error) { rows, err := d.db.Query(`SELECT name, path, description, visibility, homepage, topics, created, state, import_url, import_error FROM repo ORDER BY name`) if err != nil { return nil, err } defer rows.Close() var out []Repo for rows.Next() { var r Repo if err := rows.Scan(&r.Name, &r.Path, &r.Description, &r.Visibility, &r.Homepage, &r.Topics, &r.Created, &r.State, &r.ImportURL, &r.ImportError); err != nil { return nil, err } out = append(out, r) } return out, rows.Err() } // RepoByName returns one registration. func (d *DB) RepoByName(name string) (Repo, bool, error) { var r Repo err := d.db.QueryRow(`SELECT name, path, description, visibility, homepage, topics, created, state, import_url, import_error FROM repo WHERE name = ?`, name). Scan(&r.Name, &r.Path, &r.Description, &r.Visibility, &r.Homepage, &r.Topics, &r.Created, &r.State, &r.ImportURL, &r.ImportError) if errors.Is(err, sql.ErrNoRows) { return Repo{}, false, nil } return r, err == nil, err }