Onset: version control where nobody waits and nothing gets lost. The on command, its core and onset-server.
| 5 | use std::sync::OnceLock; |
| 6 | use std::time::Duration; |
| 7 |
| 8 | use anyhow::{Context, Result, anyhow}; |
| 9 | use serde::{Deserialize, Serialize}; |
| 10 | use serde_json::json; |
| 11 | use ureq::Agent; |
| 12 | use onset_core::version::Version; |
| 13 | use onset_core::{Id, tree}; |
| 14 |
| 15 | use crate::app::{App, Session}; |
| 16 | use crate::db::{Change, Db, Project}; |
| 17 | use crate::host::{Host, LandRequest, Landed}; |
| 18 | use crate::ids; |
| 19 | use crate::objects::Store; |
| 20 | use crate::out::{Fail, Out, Report}; |
| 21 | use crate::paths; |
| 22 | use crate::wire::{self, ChangeRecord, ErrorBody, Hold, MineFile, ProjectView, PutChange, Rule, SaveRecord, ShareRequest}; |
| 23 |
| 24 | /// Hosts whose Onset API isn't on the host itself. |
| 25 | const KNOWN: &[(&str, &str)] = &[("upstream.codes", "https://onset.coline.dev/v1"), ("git.coline.app", "https://onset.coline.dev/v1")]; |
| 26 |
| 27 | /// Where Upstream's hosts sign people in (Upstream's CLI sign-in). |
| 28 | const UPSTREAM_LOGIN: &str = "https://coline.app/api/v1/upstream/cli/login"; |
| 29 |
| 30 | /// The HTTP client every request uses. |
| 31 | pub fn http() -> &'static Agent { |
| 32 | agent() |
| 33 | } |
| 34 |
| 35 | /// ONSET_TRACE=1 prints each request to the host with its status and time. |
| 36 | fn trace(req: ureq::http::Request<ureq::SendBody>, next: ureq::middleware::MiddlewareNext) -> Result<ureq::http::Response<ureq::Body>, ureq::Error> { |
| 37 | static ON: OnceLock<bool> = OnceLock::new(); |
| 38 | if !*ON.get_or_init(|| paths::var_os("TRACE").is_some_and(|v| v != "0")) { |
| 39 | return next.handle(req); |
| 40 | } |
| 41 | let (method, uri) = (req.method().clone(), req.uri().clone()); |
| 42 | let started = std::time::Instant::now(); |
| 43 | let result = next.handle(req); |
| 44 | let status = result.as_ref().map(|r| r.status().as_u16().to_string()).unwrap_or_else(|e| e.to_string()); |
| 45 | eprintln!("on: {method} {uri} {status} {} ms", started.elapsed().as_millis()); |
| 46 | result |
| 47 | } |
| 48 |
| 49 | fn agent() -> &'static Agent { |
| 50 | static AGENT: OnceLock<Agent> = OnceLock::new(); |
| 51 | AGENT.get_or_init(|| { |
| 52 | Agent::config_builder() |
| 53 | .http_status_as_error(false) |
| 54 | .timeout_connect(Some(Duration::from_secs(10))) |
| 55 | // A host that hasn't started answering in 2 minutes won't: say so, don't hang. |
| 56 | .timeout_recv_response(Some(Duration::from_secs(120))) |
| 57 | .timeout_global(Some(Duration::from_secs(900))) |
| 58 | .user_agent(format!("onset/{}", crate::VERSION)) |
| 59 | .middleware(trace) |
| 60 | .build() |
| 61 | .into() |
| 62 | }) |
| 63 | } |
| 64 |
| 65 | // Sign-in. |
| 66 |
| 67 | #[derive(Debug, Clone, Default, Serialize, Deserialize)] |
| 68 | pub struct Credential { |
| 69 | pub token: String, |
| 70 | #[serde(default)] |
| 71 | pub handle: String, |
| 72 | #[serde(default)] |
| 73 | pub name: String, |
| 74 | #[serde(default)] |
| 75 | pub email: String, |
| 76 | #[serde(default)] |
| 77 | pub api: String, |
| 78 | } |
| 79 |
| 80 | #[derive(Debug, Default, Serialize, Deserialize)] |
| 81 | struct Credentials { |
| 82 | #[serde(default)] |
| 83 | hosts: HashMap<String, Credential>, |
| 84 | } |
| 85 |
| 86 | fn load_credentials() -> Credentials { |
| 87 | std::fs::read_to_string(paths::credentials()).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default() |
| 88 | } |
| 89 |
| 90 | fn save_credentials(c: &Credentials) -> Result<()> { |
| 91 | let path = paths::credentials(); |
| 92 | if let Some(dir) = path.parent() { |
| 93 | std::fs::create_dir_all(dir)?; |
| 94 | } |
| 95 | let tmp = path.with_extension("tmp"); |
| 96 | std::fs::write(&tmp, serde_json::to_vec_pretty(c)?)?; |
| 97 | crate::sys::owner_only(&tmp, false)?; |
| 98 | std::fs::rename(tmp, path)?; |
| 99 | Ok(()) |
| 100 | } |
| 101 |
| 102 | /// The sign-in for a host. ONSET_TOKEN (for agents and CI) wins. |
| 103 | pub fn credential(host: &str) -> Option<Credential> { |
| 104 | let mut c = load_credentials().hosts.remove(host); |
| 105 | if let Some(token) = paths::var("TOKEN").filter(|t| !t.is_empty()) { |
| 106 | let mut cred = c.unwrap_or_default(); |
| 107 | cred.token = token; |
| 108 | c = Some(cred); |
| 109 | } |
| 110 | c |
| 111 | } |
| 112 |
| 113 | /// The signed-in account for a project's host: (name, email). |
| 114 | pub fn account(project: &Project) -> Option<(String, String)> { |
| 115 | let host = split_address(&project.address).ok()?.0; |
| 116 | let c = credential(&host)?; |
| 117 | if c.name.is_empty() || c.email.is_empty() { |
| 118 | return None; |
| 119 | } |
| 120 | Some((c.name, c.email)) |
| 121 | } |
| 122 |
| 123 | // Addresses and discovery. |
| 124 |
| 125 | /// "host/owner/name" (with or without https://) → (host, owner, name). |
| 126 | pub fn split_address(text: &str) -> Result<(String, String, String), Fail> { |
| 127 | let t = text.trim().trim_end_matches('/'); |
| 128 | let t = t.strip_prefix("https://").or_else(|| t.strip_prefix("http://")).unwrap_or(t); |
| 129 | let t = t.strip_suffix(".git").unwrap_or(t); |
| 130 | let parts: Vec<&str> = t.split('/').collect(); |
| 131 | if parts.len() != 3 || parts.iter().any(|p| p.is_empty()) { |
| 132 | return Err(Fail::new("bad_usage", format!("{text} isn't a project address")) |
| 133 | .state("nothing changed") |
| 134 | .next("projects look like upstream.codes/owner/name")); |
| 135 | } |
| 136 | Ok((parts[0].to_string(), parts[1].to_string(), parts[2].to_string())) |
| 137 | } |
| 138 |
| 139 | fn scheme(host: &str) -> &'static str { |
| 140 | let h = host.split(':').next().unwrap_or(host); |
| 141 | if h == "localhost" || h == "127.0.0.1" || h == "::1" || paths::var("INSECURE").is_some_and(|v| v == "1") { "http" } else { "https" } |
| 142 | } |
| 143 |
| 144 | /// What a host says about itself: `/.well-known/onset.json`, or `vcs.json` on |
| 145 | /// a host from before Onset had its name. |
| 146 | fn well_known(base: &str) -> Result<ureq::http::Response<ureq::Body>, ureq::Error> { |
| 147 | let response = agent().get(format!("{base}/.well-known/onset.json")).call()?; |
| 148 | if response.status().as_u16() == 200 { |
| 149 | return Ok(response); |
| 150 | } |
| 151 | agent().get(format!("{base}/.well-known/vcs.json")).call() |
| 152 | } |
| 153 |
| 154 | /// Onset's API base URL for a host. |
| 155 | pub fn discover(host: &str) -> Result<String> { |
| 156 | if let Some(api) = paths::var("API").filter(|a| !a.is_empty()) { |
| 157 | return Ok(api.trim_end_matches('/').to_string()); |
| 158 | } |
| 159 | if let Some(c) = load_credentials().hosts.get(host) |
| 160 | && !c.api.is_empty() |
| 161 | { |
| 162 | return Ok(c.api.clone()); |
| 163 | } |
| 164 | if let Some((_, api)) = KNOWN.iter().find(|(h, _)| *h == host) { |
| 165 | return Ok(api.to_string()); |
| 166 | } |
| 167 | let base = format!("{}://{host}", scheme(host)); |
| 168 | let mut response = well_known(&base).map_err(|e| Fail::new("offline", format!("can't reach {host}: {e}")).state("nothing changed").into_anyhow())?; |
| 169 | if response.status().as_u16() != 200 { |
| 170 | return Err(Fail::new("not_found", format!("{host} doesn't host Onset projects")).state("nothing changed").into_anyhow()); |
| 171 | } |
| 172 | #[derive(Deserialize)] |
| 173 | struct Known { |
| 174 | api: String, |
| 175 | } |
| 176 | let known: Known = response.body_mut().read_json().context("reading the host's onset.json")?; |
| 177 | Ok(if known.api.starts_with('/') { format!("{base}{}", known.api) } else { known.api }.trim_end_matches('/').to_string()) |
| 178 | } |
| 179 |
| 180 | /// Where a host signs people in through the browser, if it does. |
| 181 | fn login_url(host: &str) -> Option<String> { |
| 182 | if let Some(url) = paths::var("LOGIN").filter(|u| !u.is_empty()) { |
| 183 | return Some(url); |
| 184 | } |
| 185 | if KNOWN.iter().any(|(h, _)| *h == host) { |
| 186 | return Some(UPSTREAM_LOGIN.to_string()); |
| 187 | } |
| 188 | let base = format!("{}://{host}", scheme(host)); |
| 189 | #[derive(Deserialize)] |
| 190 | struct Known { |
| 191 | #[serde(default)] |
| 192 | login: Option<String>, |
| 193 | } |
| 194 | let known: Known = well_known(&base).ok()?.body_mut().read_json().ok()?; |
| 195 | known.login.map(|l| if l.starts_with('/') { format!("{base}{l}") } else { l }) |
| 196 | } |
| 197 |
| 198 | /// A hosted project by address, recorded on this machine. |
| 199 | pub fn project(app: &App, target: &str) -> Result<Project, Fail> { |
| 200 | let (host, owner, name) = split_address(target)?; |
| 201 | let address = format!("{host}/{owner}/{name}"); |
| 202 | let api = match app.db.project_by_address(&address)? { |
| 203 | Some(p) if p.api.is_some() => p.api.unwrap(), |
| 204 | _ => discover(&host)?, |
| 205 | }; |
| 206 | let remote = Remote { api: api.clone(), host: host.clone(), key: format!("{owner}/{name}"), token: credential(&host).map(|c| c.token) }; |
| 207 | remote.view()?; |
| 208 | let existing = app.db.project_by_address(&address)?; |
| 209 | let project = Project { id: existing.map(|p| p.id).unwrap_or_else(|| address.clone()), address, kind: "hosted".into(), api: Some(api), checked: ids::now() }; |
| 210 | app.db.put_project(&project)?; |
| 211 | Ok(project) |
| 212 | } |
| 213 |
| 214 | pub struct Remote { |
| 215 | pub api: String, |
| 216 | pub host: String, |
| 217 | /// owner/name. |
| 218 | pub key: String, |
| 219 | pub token: Option<String>, |
| 220 | } |
| 221 |
| 222 | /// How others see a workspace: "machine:/path". |
| 223 | pub fn label(path: &std::path::Path) -> String { |
| 224 | format!("{}:{}", crate::sys::hostname(), path.display()) |
| 225 | } |
| 226 |
| 227 | type Builder<T> = ureq::RequestBuilder<T>; |
| 228 |
| 229 | impl Remote { |
| 230 | pub fn for_project(project: &Project) -> Remote { |
| 231 | let (host, owner, name) = split_address(&project.address).unwrap_or_else(|_| (String::new(), String::new(), String::new())); |
| 232 | Remote { api: project.api.clone().unwrap_or_default(), token: credential(&host).map(|c| c.token), host, key: format!("{owner}/{name}") } |
| 233 | } |
| 234 |
| 235 | fn url(&self, tail: &str) -> String { |
| 236 | format!("{}/projects/{}{tail}", self.api, self.key) |
| 237 | } |
| 238 |
| 239 | fn auth<T>(&self, req: Builder<T>) -> Builder<T> { |
| 240 | match &self.token { |
| 241 | Some(t) => req.header("authorization", format!("Bearer {t}")), |
| 242 | None => req, |
| 243 | } |
| 244 | } |
| 245 |
| 246 | fn finish(&self, result: Result<ureq::http::Response<ureq::Body>, ureq::Error>) -> Result<Vec<u8>> { |
| 247 | let mut response = result.map_err(|e| { |
| 248 | Fail::new("offline", format!("can't reach {}: {e}", self.host)) |
| 249 | .state("nothing on the host changed; your work is saved here") |
| 250 | .next("run it again when you're online") |
| 251 | .into_anyhow() |
| 252 | })?; |
| 253 | let status = response.status().as_u16(); |
| 254 | let body = response.body_mut().with_config().limit(2 << 30).read_to_vec().map_err(|e| anyhow!("reading {}'s answer: {e}", self.host))?; |
| 255 | if (200..300).contains(&status) { |
| 256 | return Ok(body); |
| 257 | } |
| 258 | let info = serde_json::from_slice::<ErrorBody>(&body).ok().map(|b| b.error); |
| 259 | let message = info.as_ref().map(|i| i.message.clone()).unwrap_or_else(|| format!("{} answered {status}", self.host)); |
| 260 | let fail = match (status, info.as_ref().map(|i| i.code.as_str())) { |
| 261 | (401, _) => Fail::new("not_logged_in", message).state("nothing changed").next(format!("on login {}", self.host)), |
| 262 | (403, _) => Fail::new("not_allowed", message).state("nothing changed"), |
| 263 | (404, _) => { |
| 264 | let f = Fail::new("not_found", message).state("nothing changed"); |
| 265 | if self.token.is_none() { f.next(format!("on login {}", self.host)) } else { f } |
| 266 | } |
| 267 | (409, Some("held_elsewhere")) => { |
| 268 | let holder = info.and_then(|i| i.holder).unwrap_or_else(|| "another workspace".into()); |
| 269 | Fail::new("held_elsewhere", format!("{holder} took this change over")).set("holder", holder) |
| 270 | } |
| 271 | (409, _) => Fail::new("exists", message).state("nothing changed"), |
| 272 | _ => Fail::new("failed", message), |
| 273 | }; |
| 274 | Err(fail.into_anyhow()) |
| 275 | } |
| 276 |
| 277 | fn get(&self, tail: &str) -> Result<Vec<u8>> { |
| 278 | self.finish(self.auth(agent().get(self.url(tail))).call()) |
| 279 | } |
| 280 |
| 281 | pub fn get_json<T: serde::de::DeserializeOwned>(&self, tail: &str) -> Result<T> { |
| 282 | Ok(serde_json::from_slice(&self.get(tail)?)?) |
| 283 | } |
| 284 |
| 285 | /// A GET from the host's API itself, not one project's (an account's keys). |
| 286 | pub fn get_host<T: serde::de::DeserializeOwned>(&self, path: &str) -> Result<T> { |
| 287 | Ok(serde_json::from_slice(&self.finish(self.auth(agent().get(format!("{}{path}", self.api))).call())?)?) |
| 288 | } |
| 289 |
| 290 | /// A POST to the host's API itself, not one project's (land-together). |
| 291 | pub fn post_host<T: serde::de::DeserializeOwned>(&self, path: &str, body: &impl Serialize) -> Result<T> { |
| 292 | Ok(serde_json::from_slice(&self.finish(self.auth(agent().post(format!("{}{path}", self.api))).send_json(body))?)?) |
| 293 | } |
| 294 |
| 295 | pub fn send_json<T: serde::de::DeserializeOwned>(&self, method: &str, tail: &str, body: &impl Serialize) -> Result<T> { |
| 296 | let url = self.url(tail); |
| 297 | let req = if method == "PUT" { agent().put(url) } else { agent().post(url) }; |
| 298 | Ok(serde_json::from_slice(&self.finish(self.auth(req).send_json(body))?)?) |
| 299 | } |
| 300 |
| 301 | /// A GET whose answer is bytes (a bundle). |
| 302 | pub fn get_bytes(&self, tail: &str) -> Result<Vec<u8>> { |
| 303 | self.get(tail) |
| 304 | } |
| 305 |
| 306 | /// A POST of JSON whose answer is bytes (a bundle). |
| 307 | pub fn post_for_bytes(&self, tail: &str, body: &impl Serialize) -> Result<Vec<u8>> { |
| 308 | self.finish(self.auth(agent().post(self.url(tail))).send_json(body)) |
| 309 | } |
| 310 |
| 311 | fn send_bytes(&self, tail: &str, body: &[u8]) -> Result<Vec<u8>> { |
| 312 | self.finish(self.auth(agent().post(self.url(tail))).header("content-type", "application/octet-stream").send(body)) |
| 313 | } |
| 314 |
| 315 | pub fn view(&self) -> Result<ProjectView> { |
| 316 | let url = format!("{}/projects/{}", self.api, self.key); |
| 317 | Ok(serde_json::from_slice(&self.finish(self.auth(agent().get(url)).call())?)?) |
| 318 | } |
| 319 |
| 320 | /// Uploads whatever the host lacks of `ids`. |
| 321 | pub fn upload(&self, store: &Store, ids: &[Id]) -> Result<usize> { |
| 322 | if ids.is_empty() { |
| 323 | return Ok(0); |
| 324 | } |
| 325 | // A little is cheaper to send than to ask about first. |
| 326 | let small = ids.len() <= 256 && ids.iter().map(|i| store.stored_len(i)).sum::<u64>() <= 1 << 20; |
| 327 | let mut missing = Vec::new(); |
| 328 | if small { |
| 329 | missing.extend_from_slice(ids); |
| 330 | } else { |
| 331 | for chunk in ids.chunks(20_000) { |
| 332 | missing.extend(wire::decode_ids(&self.send_bytes("/objects/missing", &wire::encode_ids(chunk))?)); |
| 333 | } |
| 334 | } |
| 335 | let mut sent = 0; |
| 336 | let mut batch = Vec::new(); |
| 337 | let mut size = 0usize; |
| 338 | for id in &missing { |
| 339 | batch.push(*id); |
| 340 | size += store.frame(id)?.map(|f| f.payload.len()).unwrap_or(0); |
| 341 | if size > 16 << 20 { |
| 342 | self.send_bytes("/objects", &store.bundle(&batch)?)?; |
| 343 | sent += batch.len(); |
| 344 | batch.clear(); |
| 345 | size = 0; |
| 346 | } |
| 347 | } |
| 348 | if !batch.is_empty() { |
| 349 | self.send_bytes("/objects", &store.bundle(&batch)?)?; |
| 350 | sent += batch.len(); |
| 351 | } |
| 352 | Ok(sent) |
| 353 | } |
| 354 |
| 355 | /// Objects under `tree` that `base` doesn't share, chunks included. |
| 356 | pub fn objects_since(store: &Store, base: Option<&Id>, tree: &Id) -> Result<Vec<Id>> { |
| 357 | let mut ids = Vec::new(); |
| 358 | tree::new_since(store, base, tree, &mut ids)?; |
| 359 | let mut all = Vec::with_capacity(ids.len()); |
| 360 | for id in ids { |
| 361 | all.extend(store.file_parts(&id)?); |
| 362 | } |
| 363 | Ok(all) |
| 364 | } |
| 365 |
| 366 | /// Uploads what the host lacks of a change's trees. The host has the |
| 367 | /// base already when it's a version of main (the host made it) or a tree |
| 368 | /// this machine uploaded before. |
| 369 | pub fn upload_change(&self, db: &Db, store: &Store, project: &Project, base: &Id, head: Option<&Id>) -> Result<()> { |
| 370 | let host_has_base = db.versions_with_tree(&project.id, base)? || db.uploaded_tree(&project.id, base)?; |
| 371 | let mut ids = if host_has_base { Vec::new() } else { Self::objects_since(store, None, base)? }; |
| 372 | if let Some(h) = head { |
| 373 | ids.extend(Self::objects_since(store, Some(base), h)?); |
| 374 | } |
| 375 | self.upload(store, &ids)?; |
| 376 | db.mark_uploaded_tree(&project.id, base)?; |
| 377 | if let Some(h) = head { |
| 378 | db.mark_uploaded_tree(&project.id, h)?; |
| 379 | } |
| 380 | Ok(()) |
| 381 | } |
| 382 |
| 383 | pub fn record(change: &Change) -> ChangeRecord { |
| 384 | ChangeRecord { |
| 385 | id: change.id.clone(), |
| 386 | title: change.title.clone(), |
| 387 | body: change.body.clone(), |
| 388 | base_tree: change.base_tree, |
| 389 | base_version: change.base_version, |
| 390 | on_change: change.on_change.clone(), |
| 391 | after_change: change.after_change.clone(), |
| 392 | head_tree: change.head_tree, |
| 393 | saves: change.saves, |
| 394 | state: change.state.clone(), |
| 395 | version: change.version, |
| 396 | author: change.author.clone(), |
| 397 | owner: String::new(), |
| 398 | shared: Vec::new(), |
| 399 | holder: None, |
| 400 | updated: change.updated, |
| 401 | review: None, |
| 402 | line: change.line.clone(), |
| 403 | } |
| 404 | } |
| 405 |
| 406 | /// Takes a host record into the local database, keeping newer local edits. |
| 407 | pub fn adopt(db: &Db, project: &Project, r: &ChangeRecord) -> Result<Change> { |
| 408 | db.set_review(&r.id, r.review.as_ref().map(|l| format!("#{} {}", l.number, l.url)).as_deref())?; |
| 409 | if let Some(mut local) = db.change(&r.id)? |
| 410 | && local.updated >= r.updated |
| 411 | && local.state == r.state |
| 412 | { |
| 413 | local.shared = r.shared.iter().map(|s| s.who.clone()).collect(); |
| 414 | db.put_change(&local)?; |
| 415 | return Ok(local); |
| 416 | } |
| 417 | let mut c = Change::new(r.id.clone(), &project.id, r.base_tree, r.base_version, &r.author); |
| 418 | if let Some(local) = db.change(&r.id)? { |
| 419 | c.workspace = local.workspace; |
| 420 | c.conflicts = local.conflicts; |
| 421 | c.created = local.created; |
| 422 | } |
| 423 | c.title = r.title.clone(); |
| 424 | c.body = r.body.clone(); |
| 425 | c.on_change = r.on_change.clone(); |
| 426 | c.after_change = r.after_change.clone(); |
| 427 | c.head_tree = r.head_tree; |
| 428 | c.saves = r.saves; |
| 429 | c.state = r.state.clone(); |
| 430 | c.version = r.version; |
| 431 | c.line = r.line.clone(); |
| 432 | c.shared = r.shared.iter().map(|s| s.who.clone()).collect(); |
| 433 | if !r.owner.is_empty() && r.author.is_empty() { |
| 434 | c.author = r.owner.clone(); |
| 435 | } |
| 436 | db.put_change(&c)?; |
| 437 | Ok(c) |
| 438 | } |
| 439 |
| 440 | pub fn fetch_change(&self, db: &Db, store: &Store, project: &Project, prefix: &str) -> Result<Option<Change>> { |
| 441 | #[derive(Deserialize)] |
| 442 | struct Found { |
| 443 | change: ChangeRecord, |
| 444 | } |
| 445 | let found: Found = match self.get_json(&format!("/changes/{prefix}")) { |
| 446 | Ok(f) => f, |
| 447 | Err(e) => { |
| 448 | let fail = Fail::from(e); |
| 449 | if fail.code == "not_found" { |
| 450 | return Ok(None); |
| 451 | } |
| 452 | return Err(fail.into_anyhow()); |
| 453 | } |
| 454 | }; |
| 455 | let c = Self::adopt(db, project, &found.change)?; |
| 456 | self.fetch_tree(store, project, &c.base_tree, None)?; |
| 457 | self.fetch_tree(store, project, &c.tree(), Some(&c.base_tree))?; |
| 458 | Ok(Some(c)) |
| 459 | } |
| 460 |
| 461 | /// Brings a change's latest content from the host. |
| 462 | pub fn fetch_saves(&self, db: &Db, store: &Store, project: &Project, change: &Change) -> Result<()> { |
| 463 | self.fetch_change(db, store, project, &change.id)?; |
| 464 | Ok(()) |
| 465 | } |
| 466 |
| 467 | pub fn hold(&self, change: &str, workspace: &str, label: &str) -> Result<Option<String>> { |
| 468 | #[derive(Deserialize)] |
| 469 | struct Prev { |
| 470 | previous: Option<String>, |
| 471 | } |
| 472 | let p: Prev = self.send_json("POST", &format!("/changes/{change}/hold"), &Hold { workspace: workspace.into(), label: label.into() })?; |
| 473 | Ok(p.previous) |
| 474 | } |
| 475 |
| 476 | pub fn changes(&self) -> Result<Vec<ChangeRecord>> { |
| 477 | #[derive(Deserialize)] |
| 478 | struct List { |
| 479 | changes: Vec<ChangeRecord>, |
| 480 | } |
| 481 | Ok(self.get_json::<List>("/changes")?.changes) |
| 482 | } |
| 483 |
| 484 | fn is_complete(store: &Store, db: &Db, tree: &Id) -> bool { |
| 485 | if db.conn.query_row("SELECT 1 FROM complete WHERE tree = ?1", [tree.hex()], |_| Ok(())).is_ok() { |
| 486 | return true; |
| 487 | } |
| 488 | if !store.has(tree) { |
| 489 | return false; |
| 490 | } |
| 491 | let mut ids = Vec::new(); |
| 492 | if tree::reachable(store, tree, &mut ids).is_err() { |
| 493 | return false; |
| 494 | } |
| 495 | ids.iter().all(|i| store.file_parts(i).is_ok_and(|parts| parts.iter().all(|p| store.has(p)))) |
| 496 | } |
| 497 | } |
| 498 |
| 499 | fn mark_complete(db: &Db, tree: &Id) { |
| 500 | let _ = db.conn.execute("INSERT OR IGNORE INTO complete (tree) VALUES (?1)", [tree.hex()]); |
| 501 | } |
| 502 |
| 503 | thread_local! { |
| 504 | static OWN_DB: std::cell::RefCell<Option<Db>> = const { std::cell::RefCell::new(None) }; |
| 505 | } |
| 506 |
| 507 | /// The fetch path keeps its own connection, so Host methods needn't carry one. |
| 508 | fn with_db<T>(f: impl FnOnce(&Db) -> T) -> Result<T> { |
| 509 | OWN_DB.with(|cell| { |
| 510 | let mut slot = cell.borrow_mut(); |
| 511 | if slot.is_none() { |
| 512 | *slot = Some(Db::open(&paths::home())?); |
| 513 | } |
| 514 | Ok(f(slot.as_ref().unwrap())) |
| 515 | }) |
| 516 | } |
| 517 |
| 518 | /// Versions of main a new machine starts with; older ones are fetched when |
| 519 | /// a command asks for one. |
| 520 | const HISTORY_KEPT: u64 = 1000; |
| 521 |
| 522 | /// A page of main's versions. |
| 523 | #[derive(Deserialize)] |
| 524 | struct List { |
| 525 | versions: Vec<Version>, |
| 526 | /// How many times paths were purged from the project's history (on purge). |
| 527 | #[serde(default)] |
| 528 | purges: Option<u64>, |
| 529 | /// How many times sealed folders changed (seal.rs): this machine reads them again when it differs. |
| 530 | #[serde(default)] |
| 531 | seals: Option<u64>, |
| 532 | } |
| 533 |
| 534 | impl Host for Remote { |
| 535 | fn refresh(&self, db: &Db, _store: &Store, project: &Project) -> Result<Option<Version>> { |
| 536 | // The newest version is always read again: what someone sees of it |
| 537 | // changes when a path is made private, and an owner can roll the |
| 538 | // whole project back. |
| 539 | let latest = db.latest_version(&project.id)?.map(|v| v.n).unwrap_or(0); |
| 540 | let kept = paths::var("HISTORY_KEPT").and_then(|k| k.parse().ok()).unwrap_or(HISTORY_KEPT).max(1); |
| 541 | if latest == 0 |
| 542 | && let Some(main) = self.view()?.main |
| 543 | && main.n > kept |
| 544 | { |
| 545 | // A new machine starts with recent history; older versions come when a command asks for one. |
| 546 | db.set_history_from(&project.id, main.n - kept + 1)?; |
| 547 | } |
| 548 | let mut since = if latest == 0 { db.history_from(&project.id)? - 1 } else { db.versions_complete_to(&project.id)?.min(latest - 1) }; |
| 549 | let mut newest = 0; |
| 550 | // Pages of up to 5,000 versions; a short page is the last. |
| 551 | loop { |
| 552 | let list: List = self.get_json(&format!("/versions?since={since}"))?; |
| 553 | if let Some(n) = list.purges |
| 554 | && n > db.purges(&project.id)?.0 |
| 555 | { |
| 556 | // History was rewritten: App::refresh takes the paths out here, then reads it again. |
| 557 | db.set_purges(&project.id, None, Some(n))?; |
| 558 | return Ok(db.latest_version(&project.id)?); |
| 559 | } |
| 560 | if let Some(n) = list.seals { |
| 561 | crate::seal::set_state(db, &project.id, None, Some(n))?; |
| 562 | } |
| 563 | let tx = db.conn.unchecked_transaction()?; |
| 564 | for v in &list.versions { |
| 565 | db.put_version(&project.id, v)?; |
| 566 | since = v.n; |
| 567 | newest = newest.max(v.n); |
| 568 | } |
| 569 | tx.commit()?; |
| 570 | if list.versions.len() < 5000 { |
| 571 | break; |
| 572 | } |
| 573 | } |
| 574 | if newest < latest { |
| 575 | // The host's main is behind what this machine saw: rolled back. |
| 576 | let host = if newest > 0 { newest } else { self.view()?.main.map(|m| m.n).unwrap_or(0) }; |
| 577 | db.drop_versions_after(&project.id, host)?; |
| 578 | } |
| 579 | Ok(db.latest_version(&project.id)?) |
| 580 | } |
| 581 |
| 582 | fn fetch_history(&self, db: &Db, project: &Project, from: u64) -> Result<()> { |
| 583 | let start = db.history_from(&project.id)?; |
| 584 | let from = from.max(1); |
| 585 | if from >= start { |
| 586 | return Ok(()); |
| 587 | } |
| 588 | let mut since = from - 1; |
| 589 | while since + 1 < start { |
| 590 | let list: List = self.get_json(&format!("/versions?since={since}&limit={}", (start - 1 - since).min(5000)))?; |
| 591 | let Some(last) = list.versions.last().map(|v| v.n) else { break }; |
| 592 | let tx = db.conn.unchecked_transaction()?; |
| 593 | for v in list.versions.iter().filter(|v| v.n < start) { |
| 594 | db.put_version(&project.id, v)?; |
| 595 | } |
| 596 | tx.commit()?; |
| 597 | since = last; |
| 598 | } |
| 599 | db.set_history_from(&project.id, from)?; |
| 600 | Ok(()) |
| 601 | } |
| 602 |
| 603 | fn fetch_version(&self, db: &Db, project: &Project, n: u64) -> Result<()> { |
| 604 | let list: List = self.get_json(&format!("/versions?since={}&limit=1", n.saturating_sub(1)))?; |
| 605 | for v in list.versions.iter().filter(|v| v.n == n) { |
| 606 | db.put_version(&project.id, v)?; |
| 607 | } |
| 608 | Ok(()) |
| 609 | } |
| 610 |
| 611 | fn fetch_tree(&self, store: &Store, project: &Project, tree: &Id, have: Option<&Id>) -> Result<()> { |
| 612 | if with_db(|db| Self::is_complete(store, db, tree))? { |
| 613 | with_db(|db| mark_complete(db, tree))?; |
| 614 | return Ok(()); |
| 615 | } |
| 616 | // Skip what a tree we already have shares with it. |
| 617 | let have = match have.filter(|h| with_db(|db| Self::is_complete(store, db, h)).unwrap_or(false)) { |
| 618 | Some(h) => Some(*h), |
| 619 | None => with_db(|db| -> Option<Id> { |
| 620 | db.conn |
| 621 | .query_row( |
| 622 | "SELECT v.tree FROM versions v JOIN complete c ON c.tree = v.tree WHERE v.project = ?1 ORDER BY v.n DESC LIMIT 1", |
| 623 | [&project.id], |
| 624 | |r| r.get::<_, String>(0), |
| 625 | ) |
| 626 | .ok() |
| 627 | .and_then(|t| Id::parse(&t)) |
| 628 | })?, |
| 629 | }; |
| 630 | let tail = match have { |
| 631 | Some(h) => format!("/trees/{tree}?have={h}"), |
| 632 | None => format!("/trees/{tree}"), |
| 633 | }; |
| 634 | let bundle = self.get(&tail)?; |
| 635 | store.ingest(&bundle)?; |
| 636 | with_db(|db| mark_complete(db, tree))?; |
| 637 | Ok(()) |
| 638 | } |
| 639 |
| 640 | fn land(&self, db: &Db, store: &Store, project: &Project, request: &LandRequest) -> Result<Landed> { |
| 641 | self.upload_change(db, store, project, &request.base_tree, Some(&request.tree))?; |
| 642 | self.send_json("POST", "/land", request) |
| 643 | } |
| 644 |
| 645 | fn publish_change(&self, db: &Db, store: &Store, project: &Project, change: &Change, workspace: &str) -> Result<()> { |
| 646 | let holder = db.workspace(workspace)?.map(|w| label(&w.path)).unwrap_or_default(); |
| 647 | // Content first, so the record never points at missing objects. |
| 648 | self.upload_change(db, store, project, &change.base_tree, change.head_tree.as_ref())?; |
| 649 | // The record and its newest save in one call. |
| 650 | let pending = db.unuploaded_saves(&change.id)?; |
| 651 | let save = pending.last().map(|last| SaveRecord { n: last.n, tree: last.tree, base_tree: last.base_tree, at: last.at, workspace: workspace.to_string() }); |
| 652 | let _: serde_json::Value = self.send_json( |
| 653 | "PUT", |
| 654 | &format!("/changes/{}", change.id), |
| 655 | &PutChange { change: Self::record(change), workspace: workspace.to_string(), label: holder, save }, |
| 656 | )?; |
| 657 | for s in &pending { |
| 658 | db.mark_uploaded(&change.id, s.n)?; |
| 659 | } |
| 660 | Ok(()) |
| 661 | } |
| 662 | } |
| 663 |
| 664 | /// Changes this person can see in the project: the host's merged into the |
| 665 | /// local database. |
| 666 | pub fn visible_changes(app: &App, project: &Project) -> Result<Vec<Change>> { |
| 667 | if project.hosted() { |
| 668 | let remote = Remote::for_project(project); |
| 669 | for r in remote.changes()? { |
| 670 | Remote::adopt(&app.db, project, &r)?; |
| 671 | } |
| 672 | } |
| 673 | app.db.changes(&project.id) |
| 674 | } |
| 675 |
| 676 | pub fn access_change(app: &App, s: &Session, target: &str, who: Vec<String>, edit: bool) -> Out { |
| 677 | let c = app.find_change(&s.project, target)?; |
| 678 | if !s.project.hosted() { |
| 679 | return Err(Fail::new("bad_usage", "a project on this machine only has you; sharing changes needs a hosted project") |
| 680 | .state("nothing changed") |
| 681 | .next("on publish <host/owner/name>")); |
| 682 | } |
| 683 | let remote = Remote::for_project(&s.project); |
| 684 | let mut r = Report::new(); |
| 685 | if who.is_empty() { |
| 686 | let found = remote.fetch_change(&app.db, &app.store, &s.project, &c.id)?; |
| 687 | let shared = found.map(|f| f.shared).unwrap_or_default(); |
| 688 | r.line("access", format!("change {}: you{}", app.short(&s.project, &c.id), if shared.is_empty() { String::new() } else { format!(", {}", shared.join(", ")) })); |
| 689 | r.set("who", shared); |
| 690 | return Ok(r); |
| 691 | } |
| 692 | remote.publish_change(&app.db, &app.store, &s.project, &app.change(&c.id)?, &s.ws.id)?; |
| 693 | #[derive(Deserialize)] |
| 694 | struct Shared { |
| 695 | shared: Vec<wire::Share>, |
| 696 | } |
| 697 | let shared: Shared = remote.send_json("POST", &format!("/changes/{}/access", c.id), &ShareRequest { who: who.clone(), edit })?; |
| 698 | app.db.add_op(&s.ws.id, "access", &json!({ "target": c.id, "who": who.join(" "), "change_before": c.shared }))?; |
| 699 | let list: Vec<String> = shared.shared.iter().map(|s| if s.edit { format!("{} (can edit)", s.who) } else { s.who.clone() }).collect(); |
| 700 | r.line("access", format!("change {}: you, {}", app.short(&s.project, &c.id), list.join(", "))); |
| 701 | r.set("who", list); |
| 702 | r.next(if edit { format!("they can on get <project> <folder> --change {}", app.short(&s.project, &c.id)) } else { "they see it in on log --changes".into() }); |
| 703 | Ok(r) |
| 704 | } |
| 705 |
| 706 | /// The host's path rules: (path, who). |
| 707 | pub fn path_rules(_app: &App, project: &Project) -> Result<Vec<(String, Vec<String>)>> { |
| 708 | if !project.hosted() { |
| 709 | return Ok(Vec::new()); |
| 710 | } |
| 711 | #[derive(Deserialize)] |
| 712 | struct Rules { |
| 713 | rules: Vec<Rule>, |
| 714 | } |
| 715 | let rules: Rules = Remote::for_project(project).get_json("/rules")?; |
| 716 | Ok(rules.rules.into_iter().map(|r| (r.path, r.who)).collect()) |
| 717 | } |
| 718 |
| 719 | pub fn set_path_rule(app: &App, s: &Session, path: &str, who: Vec<String>) -> Out { |
| 720 | let remote = Remote::for_project(&s.project); |
| 721 | let before = path_rules(app, &s.project)?.into_iter().find(|(p, _)| p == path).map(|(_, w)| w).unwrap_or_default(); |
| 722 | let _: serde_json::Value = remote.send_json("PUT", "/rules", &Rule { path: path.to_string(), who: who.clone(), sealed: false })?; |
| 723 | app.db.add_op(&s.ws.id, "access", &json!({ "target": path, "who": who.join(" "), "rule_before": before }))?; |
| 724 | // A sealed folder given a plain rule is unsealed: its files are kept plain from the next save. |
| 725 | let _ = crate::seal::sync(app, &s.project); |
| 726 | let mut r = Report::new(); |
| 727 | if who.len() == 1 && who[0] == "team" { |
| 728 | r.line("access", format!("{path}: everyone with access to the project")); |
| 729 | } else { |
| 730 | r.line("access", format!("{path}: only {} (and the project's owners)", who.join(", "))); |
| 731 | } |
| 732 | r.set("path", path).set("who", who); |
| 733 | Ok(r) |
| 734 | } |
| 735 |
| 736 | /// Uploads the user's own files. |
| 737 | pub fn push_mine(app: &App, project: &Project) -> Result<()> { |
| 738 | if !project.hosted() { |
| 739 | return Ok(()); |
| 740 | } |
| 741 | let remote = Remote::for_project(project); |
| 742 | let files: Vec<MineFile> = app.db.mine(&project.id)?.into_iter().map(|(path, file, exec)| MineFile { path, file, exec }).collect(); |
| 743 | let mut ids = Vec::new(); |
| 744 | for f in &files { |
| 745 | ids.extend(app.store.file_parts(&f.file)?); |
| 746 | } |
| 747 | remote.upload(&app.store, &ids)?; |
| 748 | let _: serde_json::Value = remote.send_json("PUT", "/mine", &json!({ "files": files }))?; |
| 749 | Ok(()) |
| 750 | } |
| 751 |
| 752 | /// Brings the user's own files from the host into this machine's list. |
| 753 | pub fn pull_mine(app: &App, project: &Project) -> Result<()> { |
| 754 | if !project.hosted() { |
| 755 | return Ok(()); |
| 756 | } |
| 757 | let remote = Remote::for_project(project); |
| 758 | #[derive(Deserialize)] |
| 759 | struct Files { |
| 760 | files: Vec<MineFile>, |
| 761 | } |
| 762 | let files: Files = remote.get_json("/mine")?; |
| 763 | if files.files.is_empty() { |
| 764 | return Ok(()); |
| 765 | } |
| 766 | app.store.ingest(&remote.get("/mine/bundle")?)?; |
| 767 | let local = app.db.mine(&project.id)?; |
| 768 | for f in files.files { |
| 769 | if !local.iter().any(|(p, _, _)| p == &f.path) { |
| 770 | app.db.put_mine(&project.id, &f.path, &f.file, f.exec)?; |
| 771 | } |
| 772 | } |
| 773 | Ok(()) |
| 774 | } |
| 775 |
| 776 | pub fn login(_app: &App, host: Option<String>, token: Option<String>) -> Out { |
| 777 | let host = host.unwrap_or_else(|| "upstream.codes".into()); |
| 778 | let host = host.trim_start_matches("https://").trim_start_matches("http://").trim_end_matches('/').to_string(); |
| 779 | let api = discover(&host)?; |
| 780 | let token = match token.or_else(|| paths::var("TOKEN").filter(|t| !t.is_empty())) { |
| 781 | Some(t) => t, |
| 782 | None => crate::signin::browser(&host, login_url(&host).as_deref())?, |
| 783 | }; |
| 784 | let mut response = agent() |
| 785 | .get(format!("{api}/me")) |
| 786 | .header("authorization", format!("Bearer {token}")) |
| 787 | .call() |
| 788 | .map_err(|e| Fail::new("offline", format!("can't reach {host}: {e}")).state("nothing changed"))?; |
| 789 | if response.status().as_u16() != 200 { |
| 790 | return Err(Fail::new("not_logged_in", format!("{host} didn't accept that token")).state("nothing changed").next(format!("on login {host}"))); |
| 791 | } |
| 792 | let me: wire::Me = response.body_mut().read_json().map_err(|e| anyhow!("{e}"))?; |
| 793 | let mut creds = load_credentials(); |
| 794 | creds.hosts.insert(host.clone(), Credential { token, handle: me.handle.clone(), name: me.name.clone(), email: me.email.clone(), api }); |
| 795 | save_credentials(&creds)?; |
| 796 | let mut r = Report::new(); |
| 797 | r.line("account", format!("{host}: signed in as {} ({})", me.handle, me.email)).set("host", host).set("handle", me.handle); |
| 798 | r.next("on get <host/owner/name>"); |
| 799 | Ok(r) |
| 800 | } |
| 801 |
| 802 | /// `on publish`: puts a local project on a host, history and all. |
| 803 | pub fn publish(app: &App, target: &str) -> Out { |
| 804 | let mut s = app.session()?; |
| 805 | if s.project.hosted() { |
| 806 | return Err(Fail::new("bad_usage", format!("this project is already on {}", s.project.address)).state("nothing changed")); |
| 807 | } |
| 808 | app.save(&mut s)?; |
| 809 | let (host, owner, name) = split_address(target)?; |
| 810 | let api = discover(&host)?; |
| 811 | let token = credential(&host).map(|c| c.token); |
| 812 | if token.is_none() { |
| 813 | return Err(Fail::new("not_logged_in", format!("sign in to {host} first")).state("nothing changed").next(format!("on login {host}"))); |
| 814 | } |
| 815 | let remote = Remote { api: api.clone(), host: host.clone(), key: format!("{owner}/{name}"), token: token.clone() }; |
| 816 | // Every version, oldest first, with its objects. |
| 817 | let mut versions = app.db.versions(&s.project.id, i64::MAX)?; |
| 818 | versions.reverse(); |
| 819 | // A SHA-256 repository's history: the git view is SHA-1, so its commits |
| 820 | // are new ones there (Onset's own), not the originals. |
| 821 | let sha256 = versions.iter().any(|v| v.git.as_ref().is_some_and(|g| g.len() == 64)); |
| 822 | if sha256 { |
| 823 | for v in &mut versions { |
| 824 | v.git = None; |
| 825 | } |
| 826 | app.notes.borrow_mut().push("the history came from a SHA-256 git repository; the git view is SHA-1, so its commits there are new ones".into()); |
| 827 | } |
| 828 | // History imported from git goes to a repository that may already hold it. |
| 829 | let imported = versions.iter().any(|v| v.git.is_some()); |
| 830 | if let Err(e) = remote.finish(remote.auth(agent().post(format!("{api}/projects"))).send_json(json!({ "owner": owner, "name": name, "import": imported }))) { |
| 831 | let fail = Fail::from(e); |
| 832 | if fail.code != "exists" { |
| 833 | return Err(fail); |
| 834 | } |
| 835 | } |
| 836 | let view = remote.view()?; |
| 837 | if view.main.is_some() { |
| 838 | return Err(Fail::new("bad_usage", format!("{target} already has versions; publish only to an empty project")).state("nothing changed")); |
| 839 | } |
| 840 | let mut prev: Option<Id> = None; |
| 841 | for v in &versions { |
| 842 | let ids = Remote::objects_since(&app.store, prev.as_ref(), &v.tree)?; |
| 843 | remote.upload(&app.store, &ids)?; |
| 844 | prev = Some(v.tree); |
| 845 | } |
| 846 | #[derive(Deserialize)] |
| 847 | struct Imported { |
| 848 | main: Option<u64>, |
| 849 | } |
| 850 | let landed: Imported = remote.send_json("POST", "/import", &json!({ "versions": versions }))?; |
| 851 | if imported { |
| 852 | // The git view carries on from the imported commits: the host needs |
| 853 | // their git IDs, and the repository needs the commits. |
| 854 | let ids = app.db.git_ids(&s.project.id)?; |
| 855 | for batch in ids.chunks(20_000) { |
| 856 | let map: serde_json::Map<String, serde_json::Value> = batch.iter().map(|(n, g)| (n.clone(), serde_json::Value::String(g.clone()))).collect(); |
| 857 | let _: serde_json::Value = remote.send_json("POST", "/git/ids", &json!({ "ids": map }))?; |
| 858 | } |
| 859 | if let (Some(url), Some(token)) = (&view.git, &token) |
| 860 | && let Some(note) = crate::bridge::push_history(app, &s.project, url, token)? |
| 861 | { |
| 862 | app.notes.borrow_mut().push(note); |
| 863 | } |
| 864 | } |
| 865 | let address = format!("{host}/{owner}/{name}"); |
| 866 | let project = Project { id: s.project.id.clone(), address: address.clone(), kind: "hosted".into(), api: Some(api), checked: ids::now() }; |
| 867 | app.db.put_project(&project)?; |
| 868 | // Open changes and the user's own files follow. |
| 869 | for c in app.db.changes(&project.id)? { |
| 870 | if c.state == "open" && !c.is_empty() { |
| 871 | let _ = remote.publish_change(&app.db, &app.store, &project, &c, &s.ws.id); |
| 872 | } |
| 873 | } |
| 874 | let _ = push_mine(app, &project); |
| 875 | let mut r = Report::new(); |
| 876 | r.line("published", format!("{} versions to {address}", versions.len())); |
| 877 | r.line("main", format!("{}", landed.main.unwrap_or(0))); |
| 878 | r.set("address", address).set("versions", versions.len()); |
| 879 | r.next("keep working; on land now lands on the host"); |
| 880 | Ok(r) |
| 881 | } |
| 882 |
| 883 | fn hosted_only(s: &Session, what: &str) -> Result<(), Fail> { |
| 884 | if s.project.hosted() { |
| 885 | return Ok(()); |
| 886 | } |
| 887 | Err(Fail::new("bad_usage", format!("{what} belong to hosted projects; a project on this machine only has you")) |
| 888 | .state("nothing changed") |
| 889 | .next("on publish <host/owner/name>")) |
| 890 | } |
| 891 |
| 892 | /// `on approve <change>`: approves someone else's change as it is now. The |
| 893 | /// last approval a project needs lands it. |
| 894 | pub fn approve(app: &App, target: &str) -> Out { |
| 895 | let mut s = app.session()?; |
| 896 | app.save(&mut s)?; |
| 897 | hosted_only(&s, "approvals")?; |
| 898 | for r in Remote::for_project(&s.project).changes()? { |
| 899 | Remote::adopt(&app.db, &s.project, &r)?; |
| 900 | } |
| 901 | let c = app.find_change(&s.project, target)?; |
| 902 | let answer: serde_json::Value = Remote::for_project(&s.project).send_json("POST", &format!("/changes/{}/approve", c.id), &json!({}))?; |
| 903 | let mut r = Report::new(); |
| 904 | let short = app.short(&s.project, &c.id); |
| 905 | match answer["outcome"].as_str() { |
| 906 | Some("landed") => { |
| 907 | let n = answer["version"]["n"].as_u64().unwrap_or(0); |
| 908 | r.line("approved", format!("{short}, the last approval it needed")); |
| 909 | r.line("landed", format!("{short} as main {n} \"{}\"", c.title)); |
| 910 | r.set("landed", n); |
| 911 | r.next("on sync to bring it into this workspace"); |
| 912 | } |
| 913 | Some("conflict") => { |
| 914 | r.line("approved", short.clone()); |
| 915 | r.line("conflict", format!("{short} conflicts with main {}; its owner syncs and lands it again", answer["main"])); |
| 916 | r.exit = 3; |
| 917 | } |
| 918 | Some("approved") => { |
| 919 | let have = answer["approvals"].as_array().map(|a| a.len()).unwrap_or(0); |
| 920 | let need = answer["required"].as_u64().unwrap_or(0); |
| 921 | r.line("approved", format!("{short} ({have} of {need} approvals)")); |
| 922 | if need == 0 { |
| 923 | r.next("this project doesn't require approvals; landing doesn't wait for them"); |
| 924 | } |
| 925 | } |
| 926 | _ => { |
| 927 | r.line("approved", short.clone()); |
| 928 | } |
| 929 | } |
| 930 | r.set("change", c.id.clone()).set("answer", answer); |
| 931 | Ok(r) |
| 932 | } |
| 933 |
| 934 | /// `on check`: every object this machine keeps against its ID, and for a |
| 935 | /// hosted project, the host's check of its versions and of what main needs. |
| 936 | pub fn check(app: &App) -> Out { |
| 937 | let s = app.session()?; |
| 938 | let mut r = Report::new(); |
| 939 | let (count, damaged) = app.store.verify_all()?; |
| 940 | r.line("here", if damaged.is_empty() { format!("{count} objects, each matching its ID") } else { format!("{} of {count} objects are damaged", damaged.len()) }); |
| 941 | r.set("objects", count).set("damaged", damaged.iter().map(|d| d.hex()).collect::<Vec<_>>()); |
| 942 | let mut bad = !damaged.is_empty(); |
| 943 | if s.project.hosted() { |
| 944 | #[derive(Deserialize)] |
| 945 | struct HostCheck { |
| 946 | versions: u64, |
| 947 | chain: Option<String>, |
| 948 | folders: u64, |
| 949 | files: u64, |
| 950 | missing: Vec<String>, |
| 951 | } |
| 952 | let c: HostCheck = Remote::for_project(&s.project).send_json("POST", "/check", &json!({}))?; |
| 953 | r.line("versions", match &c.chain { |
| 954 | None => format!("{} versions, each following the one before", c.versions), |
| 955 | Some(why) => why.clone(), |
| 956 | }); |
| 957 | r.line("main", if c.missing.is_empty() { format!("{} files in {} folders, all stored", c.files, c.folders) } else { format!("missing: {}", c.missing.join(", ")) }); |
| 958 | r.set("versions", c.versions).set("chain", c.chain.clone()).set("missing", c.missing.clone()); |
| 959 | bad |= c.chain.is_some() || !c.missing.is_empty(); |
| 960 | } |
| 961 | if bad { |
| 962 | r.exit = 1; |
| 963 | r.next("damage on this machine: on get the project into a new folder, which fetches good copies; damage on the host: tell its admins"); |
| 964 | } |
| 965 | Ok(r) |
| 966 | } |
| 967 |
| 968 | /// `on rollback --to <time>` puts the whole hosted project back as it was |
| 969 | /// then (the host keeps 30 days); `--undo <bookmark>` reverses one. |
| 970 | pub fn rollback(app: &App, to: Option<String>, undo: Option<String>) -> Out { |
| 971 | let s = app.session()?; |
| 972 | hosted_only(&s, "rollbacks")?; |
| 973 | let body = match (&to, &undo) { |
| 974 | (_, Some(b)) => json!({ "undo": b }), |
| 975 | (Some(t), None) => { |
| 976 | let at = ids::parse_time(t).ok_or_else(|| Fail::new("bad_usage", format!("can't read the time \"{t}\"")).next("use \"2h ago\", \"yesterday\" or 2026-10-27 14:30"))?; |
| 977 | json!({ "to": at * 1000 }) |
| 978 | } |
| 979 | (None, None) => return Err(Fail::new("bad_usage", "say when: --to <time>, or --undo <bookmark>").next("on rollback --to \"2h ago\"")), |
| 980 | }; |
| 981 | #[derive(Deserialize)] |
| 982 | struct Done { |
| 983 | undo: String, |
| 984 | } |
| 985 | let done: Done = Remote::for_project(&s.project).send_json("POST", "/rollback", &body)?; |
| 986 | let mut r = Report::new(); |
| 987 | r.line("rolled back", match &to { |
| 988 | Some(t) if undo.is_none() => format!("{} is as it was {t}", s.project.address), |
| 989 | _ => format!("{} is back as it was before the rollback", s.project.address), |
| 990 | }); |
| 991 | r.set("undo", done.undo.clone()); |
| 992 | r.next(format!("on sync to see it; to reverse this: on rollback --undo {}", done.undo)); |
| 993 | Ok(r) |
| 994 | } |
| 995 |
| 996 | /// `on settings [key=value...]`: the host's settings for the project. |
| 997 | pub fn settings(app: &App, pairs: Vec<String>) -> Out { |
| 998 | let s = app.session()?; |
| 999 | hosted_only(&s, "settings")?; |
| 1000 | let remote = Remote::for_project(&s.project); |
| 1001 | let mut body = serde_json::Map::new(); |
| 1002 | for p in &pairs { |
| 1003 | let (k, v) = p.split_once('=').ok_or_else(|| Fail::new("bad_usage", format!("{p}: settings are key=value")).next("on settings approvals=1"))?; |
| 1004 | match k { |
| 1005 | "approvals" => { |
| 1006 | let n: u32 = v.parse().map_err(|_| Fail::new("bad_usage", "approvals is a number").next("on settings approvals=1"))?; |
| 1007 | body.insert("approvals".into(), json!(n)); |
| 1008 | } |
| 1009 | "git" if v == "on" || v == "off" => { |
| 1010 | body.insert("git".into(), json!(v)); |
| 1011 | } |
| 1012 | "git" => return Err(Fail::new("bad_usage", "git is on or off").next("on settings git=off")), |
| 1013 | "signed" if v == "required" || v == "optional" => { |
| 1014 | body.insert("signed".into(), json!(v)); |
| 1015 | } |
| 1016 | "signed" => return Err(Fail::new("bad_usage", "signed is required or optional").next("on settings signed=required")), |
| 1017 | _ => return Err(Fail::new("bad_usage", format!("there's no setting {k}")).next("on settings")), |
| 1018 | } |
| 1019 | } |
| 1020 | let now: serde_json::Value = if body.is_empty() { remote.get_json("/settings")? } else { remote.send_json("PUT", "/settings", &serde_json::Value::Object(body))? }; |
| 1021 | let mut r = Report::new(); |
| 1022 | let n = now["approvals"].as_u64().unwrap_or(0); |
| 1023 | r.line("approvals", if n == 0 { "none needed to land".to_string() } else { format!("{n} from people other than a change's owner") }); |
| 1024 | r.set("approvals", n); |
| 1025 | if let Some(git) = now["git"].as_str() { |
| 1026 | r.line( |
| 1027 | "git", |
| 1028 | match git { |
| 1029 | "on" => "main is copied to the git repository", |
| 1030 | "off" => "off: main isn't copied to git (on settings git=on catches it up)", |
| 1031 | _ => "this project has no git view", |
| 1032 | }, |
| 1033 | ); |
| 1034 | r.set("git", git); |
| 1035 | } |
| 1036 | if let Some(signed) = now["signed"].as_str() { |
| 1037 | r.line("signed", if signed == "required" { "lands must be signed (on key)" } else { "lands can be signed or not" }); |
| 1038 | r.set("signed", signed); |
| 1039 | } |
| 1040 | Ok(r) |
| 1041 | } |
| 1042 |
| 1043 | /// `on group [name [@person...]]`: the project's groups, for `on access`. |
| 1044 | pub fn group(app: &App, name: Option<String>, members: Vec<String>, remove: bool) -> Out { |
| 1045 | let s = app.session()?; |
| 1046 | hosted_only(&s, "groups")?; |
| 1047 | let remote = Remote::for_project(&s.project); |
| 1048 | #[derive(Deserialize)] |
| 1049 | struct Groups { |
| 1050 | groups: std::collections::BTreeMap<String, Vec<String>>, |
| 1051 | } |
| 1052 | let groups: Groups = match &name { |
| 1053 | Some(n) if remove || !members.is_empty() => { |
| 1054 | let people: Vec<String> = if remove { Vec::new() } else { members.clone() }; |
| 1055 | remote.send_json("PUT", "/groups", &json!({ "name": n.trim_start_matches('@'), "members": people }))? |
| 1056 | } |
| 1057 | _ => remote.get_json("/groups")?, |
| 1058 | }; |
| 1059 | let mut r = Report::new(); |
| 1060 | let shown: Vec<(&String, &Vec<String>)> = groups.groups.iter().filter(|(g, _)| name.as_deref().is_none_or(|n| n.trim_start_matches('@') == g.as_str())).collect(); |
| 1061 | if shown.is_empty() { |
| 1062 | r.line("groups", match &name { |
| 1063 | Some(n) if remove => format!("@{} removed", n.trim_start_matches('@')), |
| 1064 | Some(n) => format!("no group @{}", n.trim_start_matches('@')), |
| 1065 | None => "none yet".to_string(), |
| 1066 | }); |
| 1067 | r.next("on group <name> @person @person"); |
| 1068 | } |
| 1069 | for (g, people) in &shown { |
| 1070 | r.line(&format!("@{g}"), people.iter().map(|p| format!("@{p}")).collect::<Vec<_>>().join(" ")); |
| 1071 | } |
| 1072 | r.set("groups", serde_json::to_value(&groups.groups).unwrap_or_default()); |
| 1073 | Ok(r) |
| 1074 | } |
| 1075 |
| 1076 | /// `on git [resume]`. |
| 1077 | pub fn git_view(app: &App, resume: bool) -> Out { |
| 1078 | let s = app.session()?; |
| 1079 | if !s.project.hosted() { |
| 1080 | return Err(Fail::new("bad_usage", "git views belong to hosted projects").state("nothing changed").next("on publish <host/owner/name>")); |
| 1081 | } |
| 1082 | let remote = Remote::for_project(&s.project); |
| 1083 | let mut r = Report::new(); |
| 1084 | if resume { |
| 1085 | #[derive(Deserialize)] |
| 1086 | struct Resumed { |
| 1087 | adopted: Option<String>, |
| 1088 | } |
| 1089 | let done: Resumed = remote.send_json("POST", "/git/resume", &json!({}))?; |
| 1090 | r.line("resumed", match &done.adopted { |
| 1091 | Some(c) => format!("the next version's commit keeps git main's outside commits (up to {})", &c[..c.len().min(12)]), |
| 1092 | None => "the git view takes new versions again".to_string(), |
| 1093 | }); |
| 1094 | } |
| 1095 | let view = remote.view()?; |
| 1096 | let Some(url) = &view.git else { |
| 1097 | r.line("git", "this project has no git view"); |
| 1098 | return Ok(r); |
| 1099 | }; |
| 1100 | r.line("git", format!("git clone {url}")).set("url", url.clone()); |
| 1101 | if let Some(mine) = &view.git_mine { |
| 1102 | r.line("yours", format!("git clone {mine} (with the private folders you can see; your token is the password)")).set("mine", mine.clone()); |
| 1103 | } |
| 1104 | if let Some(main) = &view.main { |
| 1105 | let state = match &main.git { |
| 1106 | Some(c) => format!("version {} is commit {}", main.n, &c[..c.len().min(12)]), |
| 1107 | None => format!("version {} isn't in the git view yet", main.n), |
| 1108 | }; |
| 1109 | r.line("main", state).set("main", main.n).set("commit", main.git.clone()); |
| 1110 | } |
| 1111 | match &view.git_stopped { |
| 1112 | Some(why) if !resume => { |
| 1113 | r.line("paused", why.clone()).set("paused", why.clone()); |
| 1114 | r.next("once that's sorted, on git resume"); |
| 1115 | } |
| 1116 | _ => { |
| 1117 | r.set("paused", serde_json::Value::Null); |
| 1118 | } |
| 1119 | } |
| 1120 | Ok(r) |
| 1121 | } |
| 1122 |
| 1123 | pub fn import(app: &App, url: &str, to: Option<String>, folder: Option<PathBuf>, change: Option<String>) -> Out { |
| 1124 | crate::bridge::import(app, url, to, folder, change) |
| 1125 | } |
| 1126 |
| 1127 | pub fn saver(app: &App) -> Out { |
| 1128 | crate::saver::run(app) |
| 1129 | } |
| 1130 |