//! Where a project's main lives. A local project's main is in this //! machine's database; a hosted project's main is on its host, reached //! over Onset's protocol (remote.rs). Commands only talk to [`Host`]. use anyhow::Result; use serde::{Deserialize, Serialize}; use onset_core::merge::{self, Conflict, Mode}; use onset_core::version::Version; use onset_core::{Id, tree}; use crate::db::{Change, Db, Project}; use crate::ids; use crate::objects::Store; /// What landing a change asks for. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LandRequest { pub change: String, pub base_tree: Id, pub tree: Id, pub title: String, pub body: String, pub author: String, /// Allow something the secret check flagged (recorded). #[serde(default)] pub allow_secrets: bool, /// The change itself, recorded first (a hosted project's own change), so /// landing is one call. #[serde(default, skip_serializing_if = "Option::is_none")] pub record: Option, /// When it should land (seconds since the epoch); the host keeps it until then. #[serde(default, skip_serializing_if = "Option::is_none")] pub at: Option, /// The lander's signature over the change (on key). #[serde(default, skip_serializing_if = "Option::is_none")] pub signature: Option, /// The release line it lands on (None: main). #[serde(default, skip_serializing_if = "Option::is_none")] pub line: Option, /// The version this land takes back out (`12`, or `1.x/3` on a line): /// the change that made it is open again, so it can land again. #[serde(default, skip_serializing_if = "Option::is_none")] pub undoes: Option, } impl LandRequest { /// The version this land takes back out: from `undoes`, or from the /// body CLIs before it wrote ("Undoes main 12." / "Undoes 1.x/3."). pub fn takes_back(&self) -> Option<(Option, u64)> { let text = match &self.undoes { Some(u) => u.clone(), None if self.title.starts_with("Take back ") => self.body.strip_prefix("Undoes ")?.strip_suffix('.')?.trim_start_matches("main ").to_string(), None => return None, }; match text.rsplit_once('/') { Some((line, n)) => Some((Some(line.to_string()), n.parse().ok()?)), None => Some((None, text.parse().ok()?)), } } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LandSignature { pub key: String, pub sig: String, } impl LandSignature { /// This machine's signature for a land on a hosted project, if it has a key. pub fn for_land(address: &str, change: &str, base: &Id, tree: &Id, title: &str, body: &str, author: &str) -> Option { crate::keys::sign(address, change, base, tree, title, body, author).map(|(key, sig)| LandSignature { key, sig }) } } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "outcome", rename_all = "snake_case")] pub enum Landed { Landed { version: Version }, Conflict { main: u64, conflicts: Vec }, Waiting { asked: Vec, reason: String }, ChecksFailed { check: String, log: Option }, /// The repository's rules send it through review: a change request on /// the host that lands once its approvals and checks pass. Review { number: u64, url: String, #[serde(default)] blockers: Vec, /// It can't merge before this time (land --at). #[serde(default)] at: Option, }, /// The host lands it at `at` (land --at), as it is now. Scheduled { at: i64 }, /// Landed on a release line, as its version `n`. OnLine { line: String, n: u64, title: String }, /// It conflicts with the release line's version `n`. LineConflict { line: String, n: u64, conflicts: Vec }, } pub trait Host { /// Brings the local copy of main up to date and returns its latest /// version (None: the project has no versions yet). fn refresh(&self, db: &Db, store: &Store, project: &Project) -> Result>; /// Makes sure every object under `tree` is in the store. `have` names a /// tree whose objects are already here, to skip what they share. fn fetch_tree(&self, store: &Store, project: &Project, tree: &Id, have: Option<&Id>) -> Result<()>; fn land(&self, db: &Db, store: &Store, project: &Project, request: &LandRequest) -> Result; /// Fetches main's versions from `from` up to the first one this /// machine keeps (local projects keep them all). fn fetch_history(&self, _db: &Db, _project: &Project, _from: u64) -> Result<()> { Ok(()) } /// Fetches one version of main older than this machine keeps. fn fetch_version(&self, _db: &Db, _project: &Project, _n: u64) -> Result<()> { Ok(()) } /// Records a change's state with the host so other machines and people /// can see it (no-op for local projects). fn publish_change(&self, _db: &Db, _store: &Store, _project: &Project, _change: &Change, _workspace: &str) -> Result<()> { Ok(()) } } /// A project that lives only on this machine. Landing is a merge plus one /// row in the versions table, decided under SQLite's write lock. pub struct Local; impl Host for Local { fn refresh(&self, db: &Db, _store: &Store, project: &Project) -> Result> { db.latest_version(&project.id) } fn fetch_tree(&self, _store: &Store, _project: &Project, _tree: &Id, _have: Option<&Id>) -> Result<()> { Ok(()) } fn land(&self, db: &Db, store: &Store, project: &Project, request: &LandRequest) -> Result { self.land_on(db, store, &project.id, request) } } impl Local { /// Lands under SQLite's write lock, so landings on one main never race. pub fn land_on(&self, db: &Db, store: &Store, project: &str, request: &LandRequest) -> Result { db.conn.execute_batch("BEGIN IMMEDIATE")?; let result = land_in(db, store, project, request); match &result { Ok(_) => db.conn.execute_batch("COMMIT")?, Err(_) => { let _ = db.conn.execute_batch("ROLLBACK"); } } result } } /// The landing itself, for local projects and the self-hosted server: merge /// the change onto the latest main and add the next version. pub fn land_in(db: &Db, store: &Store, project: &str, request: &LandRequest) -> Result { if let Some(name) = &request.line { return land_on_line(db, store, project, name, request); } let main = db.latest_version(project)?; let main_tree = main.as_ref().map(|v| v.tree).unwrap_or_else(tree::empty_id); let tree = if main_tree == request.base_tree { request.tree } else { let m = merge::trees( store, Some(&request.base_tree), &main_tree, &request.tree, Mode::Land, (&format!("main {}", main.as_ref().map(|v| v.n).unwrap_or(0)), "change"), )?; if !m.clean() { return Ok(Landed::Conflict { main: main.as_ref().map(|v| v.n).unwrap_or(0), conflicts: m.conflicts }); } // Keep the objects the merge made. for o in m.objects.objects.values() { store.put(o)?; } m.tree }; if main.is_some() && tree == main_tree { return Err(already_on_main("main")); } let version = Version { n: main.as_ref().map(|v| v.n + 1).unwrap_or(1), tree, prev: main.as_ref().map(|v| v.id(project)), change: request.change.clone(), author: request.author.clone(), time: ids::now(), title: request.title.clone(), body: request.body.clone(), git: None, }; db.put_version(project, &version)?; Ok(Landed::Landed { version }) } /// A change whose edits `place` (main or a line) already has: landing it /// would add a version that changes nothing. fn already_on_main(place: &str) -> anyhow::Error { crate::out::Fail::new("empty_change", format!("{place} already has everything in this change")) .state("nothing landed") .next("on drop, or keep editing and land again") .into_anyhow() } /// A land on a release line: merged onto the line's latest version, as its next one. fn land_on_line(db: &Db, store: &Store, project: &str, name: &str, request: &LandRequest) -> Result { let line = db.line(project, name)?.ok_or_else(|| anyhow::anyhow!("there's no line {name}"))?; let head = db.line_tree(project, &line, line.head)?.ok_or_else(|| anyhow::anyhow!("line {name}'s start, main {}, isn't here", line.from))?; let tree = if head == request.base_tree { request.tree } else { let m = merge::trees(store, Some(&request.base_tree), &head, &request.tree, Mode::Land, (&format!("{name}/{}", line.head), "change"))?; if !m.clean() { return Ok(Landed::LineConflict { line: name.to_string(), n: line.head, conflicts: m.conflicts }); } for o in m.objects.objects.values() { store.put(o)?; } m.tree }; if tree == head { return Err(already_on_main(name)); } let v = crate::db::LineVersion { line: name.to_string(), n: line.head + 1, tree, change: request.change.clone(), author: request.author.clone(), time: ids::now(), title: request.title.clone(), body: request.body.clone(), }; db.put_line_version(project, &v)?; Ok(Landed::OnLine { line: v.line, n: v.n, title: v.title }) }