Onset: version control where nobody waits and nothing gets lost. The on command, its core and onset-server.
| 6 | use serde::{Deserialize, Serialize}; |
| 7 | use onset_core::merge::{self, Conflict, Mode}; |
| 8 | use onset_core::version::Version; |
| 9 | use onset_core::{Id, tree}; |
| 10 |
| 11 | use crate::db::{Change, Db, Project}; |
| 12 | use crate::ids; |
| 13 | use crate::objects::Store; |
| 14 |
| 15 | /// What landing a change asks for. |
| 16 | #[derive(Debug, Clone, Serialize, Deserialize)] |
| 17 | pub struct LandRequest { |
| 18 | pub change: String, |
| 19 | pub base_tree: Id, |
| 20 | pub tree: Id, |
| 21 | pub title: String, |
| 22 | pub body: String, |
| 23 | pub author: String, |
| 24 | /// Allow something the secret check flagged (recorded). |
| 25 | #[serde(default)] |
| 26 | pub allow_secrets: bool, |
| 27 | /// The change itself, recorded first (a hosted project's own change), so |
| 28 | /// landing is one call. |
| 29 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 30 | pub record: Option<crate::wire::PutChange>, |
| 31 | /// When it should land (seconds since the epoch); the host keeps it until then. |
| 32 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 33 | pub at: Option<i64>, |
| 34 | /// The lander's signature over the change (on key). |
| 35 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 36 | pub signature: Option<LandSignature>, |
| 37 | /// The release line it lands on (None: main). |
| 38 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 39 | pub line: Option<String>, |
| 40 | /// The version this land takes back out (`12`, or `1.x/3` on a line): |
| 41 | /// the change that made it is open again, so it can land again. |
| 42 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 43 | pub undoes: Option<String>, |
| 44 | } |
| 45 |
| 46 | impl LandRequest { |
| 47 | /// The version this land takes back out: from `undoes`, or from the |
| 48 | /// body CLIs before it wrote ("Undoes main 12." / "Undoes 1.x/3."). |
| 49 | pub fn takes_back(&self) -> Option<(Option<String>, u64)> { |
| 50 | let text = match &self.undoes { |
| 51 | Some(u) => u.clone(), |
| 52 | None if self.title.starts_with("Take back ") => self.body.strip_prefix("Undoes ")?.strip_suffix('.')?.trim_start_matches("main ").to_string(), |
| 53 | None => return None, |
| 54 | }; |
| 55 | match text.rsplit_once('/') { |
| 56 | Some((line, n)) => Some((Some(line.to_string()), n.parse().ok()?)), |
| 57 | None => Some((None, text.parse().ok()?)), |
| 58 | } |
| 59 | } |
| 60 | } |
| 61 |
| 62 | #[derive(Debug, Clone, Serialize, Deserialize)] |
| 63 | pub struct LandSignature { |
| 64 | pub key: String, |
| 65 | pub sig: String, |
| 66 | } |
| 67 |
| 68 | impl LandSignature { |
| 69 | /// This machine's signature for a land on a hosted project, if it has a key. |
| 70 | pub fn for_land(address: &str, change: &str, base: &Id, tree: &Id, title: &str, body: &str, author: &str) -> Option<LandSignature> { |
| 71 | crate::keys::sign(address, change, base, tree, title, body, author).map(|(key, sig)| LandSignature { key, sig }) |
| 72 | } |
| 73 | } |
| 74 |
| 75 | #[derive(Debug, Clone, Serialize, Deserialize)] |
| 76 | #[serde(tag = "outcome", rename_all = "snake_case")] |
| 77 | pub enum Landed { |
| 78 | Landed { version: Version }, |
| 79 | Conflict { main: u64, conflicts: Vec<Conflict> }, |
| 80 | Waiting { asked: Vec<String>, reason: String }, |
| 81 | ChecksFailed { check: String, log: Option<String> }, |
| 82 | /// The repository's rules send it through review: a change request on |
| 83 | /// the host that lands once its approvals and checks pass. |
| 84 | Review { |
| 85 | number: u64, |
| 86 | url: String, |
| 87 | #[serde(default)] |
| 88 | blockers: Vec<String>, |
| 89 | /// It can't merge before this time (land --at). |
| 90 | #[serde(default)] |
| 91 | at: Option<i64>, |
| 92 | }, |
| 93 | /// The host lands it at `at` (land --at), as it is now. |
| 94 | Scheduled { at: i64 }, |
| 95 | /// Landed on a release line, as its version `n`. |
| 96 | OnLine { line: String, n: u64, title: String }, |
| 97 | /// It conflicts with the release line's version `n`. |
| 98 | LineConflict { line: String, n: u64, conflicts: Vec<Conflict> }, |
| 99 | } |
| 100 |
| 101 | pub trait Host { |
| 102 | /// Brings the local copy of main up to date and returns its latest |
| 103 | /// version (None: the project has no versions yet). |
| 104 | fn refresh(&self, db: &Db, store: &Store, project: &Project) -> Result<Option<Version>>; |
| 105 |
| 106 | /// Makes sure every object under `tree` is in the store. `have` names a |
| 107 | /// tree whose objects are already here, to skip what they share. |
| 108 | fn fetch_tree(&self, store: &Store, project: &Project, tree: &Id, have: Option<&Id>) -> Result<()>; |
| 109 |
| 110 | fn land(&self, db: &Db, store: &Store, project: &Project, request: &LandRequest) -> Result<Landed>; |
| 111 |
| 112 | /// Fetches main's versions from `from` up to the first one this |
| 113 | /// machine keeps (local projects keep them all). |
| 114 | fn fetch_history(&self, _db: &Db, _project: &Project, _from: u64) -> Result<()> { |
| 115 | Ok(()) |
| 116 | } |
| 117 |
| 118 | /// Fetches one version of main older than this machine keeps. |
| 119 | fn fetch_version(&self, _db: &Db, _project: &Project, _n: u64) -> Result<()> { |
| 120 | Ok(()) |
| 121 | } |
| 122 |
| 123 | /// Records a change's state with the host so other machines and people |
| 124 | /// can see it (no-op for local projects). |
| 125 | fn publish_change(&self, _db: &Db, _store: &Store, _project: &Project, _change: &Change, _workspace: &str) -> Result<()> { |
| 126 | Ok(()) |
| 127 | } |
| 128 | } |
| 129 |
| 130 | /// A project that lives only on this machine. Landing is a merge plus one |
| 131 | /// row in the versions table, decided under SQLite's write lock. |
| 132 | pub struct Local; |
| 133 |
| 134 | impl Host for Local { |
| 135 | fn refresh(&self, db: &Db, _store: &Store, project: &Project) -> Result<Option<Version>> { |
| 136 | db.latest_version(&project.id) |
| 137 | } |
| 138 |
| 139 | fn fetch_tree(&self, _store: &Store, _project: &Project, _tree: &Id, _have: Option<&Id>) -> Result<()> { |
| 140 | Ok(()) |
| 141 | } |
| 142 |
| 143 | fn land(&self, db: &Db, store: &Store, project: &Project, request: &LandRequest) -> Result<Landed> { |
| 144 | self.land_on(db, store, &project.id, request) |
| 145 | } |
| 146 | } |
| 147 |
| 148 | impl Local { |
| 149 | /// Lands under SQLite's write lock, so landings on one main never race. |
| 150 | pub fn land_on(&self, db: &Db, store: &Store, project: &str, request: &LandRequest) -> Result<Landed> { |
| 151 | db.conn.execute_batch("BEGIN IMMEDIATE")?; |
| 152 | let result = land_in(db, store, project, request); |
| 153 | match &result { |
| 154 | Ok(_) => db.conn.execute_batch("COMMIT")?, |
| 155 | Err(_) => { |
| 156 | let _ = db.conn.execute_batch("ROLLBACK"); |
| 157 | } |
| 158 | } |
| 159 | result |
| 160 | } |
| 161 | } |
| 162 |
| 163 | /// The landing itself, for local projects and the self-hosted server: merge |
| 164 | /// the change onto the latest main and add the next version. |
| 165 | pub fn land_in(db: &Db, store: &Store, project: &str, request: &LandRequest) -> Result<Landed> { |
| 166 | if let Some(name) = &request.line { |
| 167 | return land_on_line(db, store, project, name, request); |
| 168 | } |
| 169 | let main = db.latest_version(project)?; |
| 170 | let main_tree = main.as_ref().map(|v| v.tree).unwrap_or_else(tree::empty_id); |
| 171 | let tree = if main_tree == request.base_tree { |
| 172 | request.tree |
| 173 | } else { |
| 174 | let m = merge::trees( |
| 175 | store, |
| 176 | Some(&request.base_tree), |
| 177 | &main_tree, |
| 178 | &request.tree, |
| 179 | Mode::Land, |
| 180 | (&format!("main {}", main.as_ref().map(|v| v.n).unwrap_or(0)), "change"), |
| 181 | )?; |
| 182 | if !m.clean() { |
| 183 | return Ok(Landed::Conflict { main: main.as_ref().map(|v| v.n).unwrap_or(0), conflicts: m.conflicts }); |
| 184 | } |
| 185 | // Keep the objects the merge made. |
| 186 | for o in m.objects.objects.values() { |
| 187 | store.put(o)?; |
| 188 | } |
| 189 | m.tree |
| 190 | }; |
| 191 | if main.is_some() && tree == main_tree { |
| 192 | return Err(already_on_main("main")); |
| 193 | } |
| 194 | let version = Version { |
| 195 | n: main.as_ref().map(|v| v.n + 1).unwrap_or(1), |
| 196 | tree, |
| 197 | prev: main.as_ref().map(|v| v.id(project)), |
| 198 | change: request.change.clone(), |
| 199 | author: request.author.clone(), |
| 200 | time: ids::now(), |
| 201 | title: request.title.clone(), |
| 202 | body: request.body.clone(), |
| 203 | git: None, |
| 204 | }; |
| 205 | db.put_version(project, &version)?; |
| 206 | Ok(Landed::Landed { version }) |
| 207 | } |
| 208 |
| 209 | /// A change whose edits `place` (main or a line) already has: landing it |
| 210 | /// would add a version that changes nothing. |
| 211 | fn already_on_main(place: &str) -> anyhow::Error { |
| 212 | crate::out::Fail::new("empty_change", format!("{place} already has everything in this change")) |
| 213 | .state("nothing landed") |
| 214 | .next("on drop, or keep editing and land again") |
| 215 | .into_anyhow() |
| 216 | } |
| 217 |
| 218 | /// A land on a release line: merged onto the line's latest version, as its next one. |
| 219 | fn land_on_line(db: &Db, store: &Store, project: &str, name: &str, request: &LandRequest) -> Result<Landed> { |
| 220 | let line = db.line(project, name)?.ok_or_else(|| anyhow::anyhow!("there's no line {name}"))?; |
| 221 | let head = db.line_tree(project, &line, line.head)?.ok_or_else(|| anyhow::anyhow!("line {name}'s start, main {}, isn't here", line.from))?; |
| 222 | let tree = if head == request.base_tree { |
| 223 | request.tree |
| 224 | } else { |
| 225 | let m = merge::trees(store, Some(&request.base_tree), &head, &request.tree, Mode::Land, (&format!("{name}/{}", line.head), "change"))?; |
| 226 | if !m.clean() { |
| 227 | return Ok(Landed::LineConflict { line: name.to_string(), n: line.head, conflicts: m.conflicts }); |
| 228 | } |
| 229 | for o in m.objects.objects.values() { |
| 230 | store.put(o)?; |
| 231 | } |
| 232 | m.tree |
| 233 | }; |
| 234 | if tree == head { |
| 235 | return Err(already_on_main(name)); |
| 236 | } |
| 237 | let v = crate::db::LineVersion { |
| 238 | line: name.to_string(), |
| 239 | n: line.head + 1, |
| 240 | tree, |
| 241 | change: request.change.clone(), |
| 242 | author: request.author.clone(), |
| 243 | time: ids::now(), |
| 244 | title: request.title.clone(), |
| 245 | body: request.body.clone(), |
| 246 | }; |
| 247 | db.put_line_version(project, &v)?; |
| 248 | Ok(Landed::OnLine { line: v.line, n: v.n, title: v.title }) |
| 249 | } |
| 250 |