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 vcs_core::version::Version; |
| 13 | use vcs_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 vcs API isn't on the host itself. |
| 25 | const KNOWN: &[(&str, &str)] = &[("upstream.codes", "https://vcs.coline.dev/v1"), ("git.coline.app", "https://vcs.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 | fn agent() -> &'static Agent { |
| 36 | static AGENT: OnceLock<Agent> = OnceLock::new(); |
| 37 | AGENT.get_or_init(|| { |
| 38 | Agent::config_builder() |
| 39 | .http_status_as_error(false) |
| 40 | .timeout_connect(Some(Duration::from_secs(10))) |
| 41 | .timeout_global(Some(Duration::from_secs(900))) |
| 42 | .user_agent(format!("vcs/{}", env!("CARGO_PKG_VERSION"))) |
| 43 | .build() |
| 44 | .into() |
| 45 | }) |
| 46 | } |
| 47 |
| 48 | // Sign-in. |
| 49 |
| 50 | #[derive(Debug, Clone, Default, Serialize, Deserialize)] |
| 51 | pub struct Credential { |
| 52 | pub token: String, |
| 53 | #[serde(default)] |
| 54 | pub handle: String, |
| 55 | #[serde(default)] |
| 56 | pub name: String, |
| 57 | #[serde(default)] |
| 58 | pub email: String, |
| 59 | #[serde(default)] |
| 60 | pub api: String, |
| 61 | } |
| 62 |
| 63 | #[derive(Debug, Default, Serialize, Deserialize)] |
| 64 | struct Credentials { |
| 65 | #[serde(default)] |
| 66 | hosts: HashMap<String, Credential>, |
| 67 | } |
| 68 |
| 69 | fn load_credentials() -> Credentials { |
| 70 | std::fs::read_to_string(paths::credentials()).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default() |
| 71 | } |
| 72 |
| 73 | fn save_credentials(c: &Credentials) -> Result<()> { |
| 74 | let path = paths::credentials(); |
| 75 | if let Some(dir) = path.parent() { |
| 76 | std::fs::create_dir_all(dir)?; |
| 77 | } |
| 78 | let tmp = path.with_extension("tmp"); |
| 79 | std::fs::write(&tmp, serde_json::to_vec_pretty(c)?)?; |
| 80 | use std::os::unix::fs::PermissionsExt; |
| 81 | std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600))?; |
| 82 | std::fs::rename(tmp, path)?; |
| 83 | Ok(()) |
| 84 | } |
| 85 |
| 86 | /// The sign-in for a host. VCS_TOKEN (for agents and CI) wins. |
| 87 | pub fn credential(host: &str) -> Option<Credential> { |
| 88 | let mut c = load_credentials().hosts.remove(host); |
| 89 | if let Some(token) = std::env::var("VCS_TOKEN").ok().filter(|t| !t.is_empty()) { |
| 90 | let mut cred = c.unwrap_or_default(); |
| 91 | cred.token = token; |
| 92 | c = Some(cred); |
| 93 | } |
| 94 | c |
| 95 | } |
| 96 |
| 97 | /// The signed-in account for a project's host: (name, email). |
| 98 | pub fn account(project: &Project) -> Option<(String, String)> { |
| 99 | let host = split_address(&project.address).ok()?.0; |
| 100 | let c = credential(&host)?; |
| 101 | if c.name.is_empty() || c.email.is_empty() { |
| 102 | return None; |
| 103 | } |
| 104 | Some((c.name, c.email)) |
| 105 | } |
| 106 |
| 107 | // Addresses and discovery. |
| 108 |
| 109 | /// "host/owner/name" (with or without https://) → (host, owner, name). |
| 110 | pub fn split_address(text: &str) -> Result<(String, String, String), Fail> { |
| 111 | let t = text.trim().trim_end_matches('/'); |
| 112 | let t = t.strip_prefix("https://").or_else(|| t.strip_prefix("http://")).unwrap_or(t); |
| 113 | let t = t.strip_suffix(".git").unwrap_or(t); |
| 114 | let parts: Vec<&str> = t.split('/').collect(); |
| 115 | if parts.len() != 3 || parts.iter().any(|p| p.is_empty()) { |
| 116 | return Err(Fail::new("bad_usage", format!("{text} isn't a project address")) |
| 117 | .state("nothing changed") |
| 118 | .next("projects look like upstream.codes/owner/name")); |
| 119 | } |
| 120 | Ok((parts[0].to_string(), parts[1].to_string(), parts[2].to_string())) |
| 121 | } |
| 122 |
| 123 | fn scheme(host: &str) -> &'static str { |
| 124 | let h = host.split(':').next().unwrap_or(host); |
| 125 | if h == "localhost" || h == "127.0.0.1" || h == "::1" || std::env::var("VCS_INSECURE").is_ok_and(|v| v == "1") { "http" } else { "https" } |
| 126 | } |
| 127 |
| 128 | /// The vcs API base URL for a host. |
| 129 | pub fn discover(host: &str) -> Result<String> { |
| 130 | if let Some(api) = std::env::var("VCS_API").ok().filter(|a| !a.is_empty()) { |
| 131 | return Ok(api.trim_end_matches('/').to_string()); |
| 132 | } |
| 133 | if let Some(c) = load_credentials().hosts.get(host) |
| 134 | && !c.api.is_empty() |
| 135 | { |
| 136 | return Ok(c.api.clone()); |
| 137 | } |
| 138 | if let Some((_, api)) = KNOWN.iter().find(|(h, _)| *h == host) { |
| 139 | return Ok(api.to_string()); |
| 140 | } |
| 141 | let base = format!("{}://{host}", scheme(host)); |
| 142 | let mut response = agent() |
| 143 | .get(format!("{base}/.well-known/vcs.json")) |
| 144 | .call() |
| 145 | .map_err(|e| Fail::new("offline", format!("can't reach {host}: {e}")).state("nothing changed").into_anyhow())?; |
| 146 | if response.status().as_u16() != 200 { |
| 147 | return Err(Fail::new("not_found", format!("{host} doesn't host vcs projects")).state("nothing changed").into_anyhow()); |
| 148 | } |
| 149 | #[derive(Deserialize)] |
| 150 | struct Known { |
| 151 | api: String, |
| 152 | } |
| 153 | let known: Known = response.body_mut().read_json().context("reading the host's vcs.json")?; |
| 154 | Ok(if known.api.starts_with('/') { format!("{base}{}", known.api) } else { known.api }.trim_end_matches('/').to_string()) |
| 155 | } |
| 156 |
| 157 | /// Where a host signs people in through the browser, if it does. |
| 158 | fn login_url(host: &str) -> Option<String> { |
| 159 | if let Some(url) = std::env::var("VCS_LOGIN").ok().filter(|u| !u.is_empty()) { |
| 160 | return Some(url); |
| 161 | } |
| 162 | if KNOWN.iter().any(|(h, _)| *h == host) { |
| 163 | return Some(UPSTREAM_LOGIN.to_string()); |
| 164 | } |
| 165 | let base = format!("{}://{host}", scheme(host)); |
| 166 | #[derive(Deserialize)] |
| 167 | struct Known { |
| 168 | #[serde(default)] |
| 169 | login: Option<String>, |
| 170 | } |
| 171 | let known: Known = agent().get(format!("{base}/.well-known/vcs.json")).call().ok()?.body_mut().read_json().ok()?; |
| 172 | known.login.map(|l| if l.starts_with('/') { format!("{base}{l}") } else { l }) |
| 173 | } |
| 174 |
| 175 | /// A hosted project by address, recorded on this machine. |
| 176 | pub fn project(app: &App, target: &str) -> Result<Project, Fail> { |
| 177 | let (host, owner, name) = split_address(target)?; |
| 178 | let address = format!("{host}/{owner}/{name}"); |
| 179 | let api = match app.db.project_by_address(&address)? { |
| 180 | Some(p) if p.api.is_some() => p.api.unwrap(), |
| 181 | _ => discover(&host)?, |
| 182 | }; |
| 183 | let remote = Remote { api: api.clone(), host: host.clone(), key: format!("{owner}/{name}"), token: credential(&host).map(|c| c.token) }; |
| 184 | remote.view()?; |
| 185 | let existing = app.db.project_by_address(&address)?; |
| 186 | let project = Project { id: existing.map(|p| p.id).unwrap_or_else(|| address.clone()), address, kind: "hosted".into(), api: Some(api), checked: ids::now() }; |
| 187 | app.db.put_project(&project)?; |
| 188 | Ok(project) |
| 189 | } |
| 190 |
| 191 | pub struct Remote { |
| 192 | pub api: String, |
| 193 | pub host: String, |
| 194 | /// owner/name. |
| 195 | pub key: String, |
| 196 | pub token: Option<String>, |
| 197 | } |
| 198 |
| 199 | /// How others see a workspace: "machine:/path". |
| 200 | pub fn label(path: &std::path::Path) -> String { |
| 201 | let mut buf = [0u8; 256]; |
| 202 | // SAFETY: gethostname writes at most buf.len() bytes into buf. |
| 203 | let ok = unsafe { libc::gethostname(buf.as_mut_ptr() as *mut libc::c_char, buf.len()) } == 0; |
| 204 | let name = if ok { |
| 205 | let end = buf.iter().position(|b| *b == 0).unwrap_or(buf.len()); |
| 206 | String::from_utf8_lossy(&buf[..end]).into_owned() |
| 207 | } else { |
| 208 | "this machine".into() |
| 209 | }; |
| 210 | format!("{name}:{}", path.display()) |
| 211 | } |
| 212 |
| 213 | type Builder<T> = ureq::RequestBuilder<T>; |
| 214 |
| 215 | impl Remote { |
| 216 | pub fn for_project(project: &Project) -> Remote { |
| 217 | let (host, owner, name) = split_address(&project.address).unwrap_or_else(|_| (String::new(), String::new(), String::new())); |
| 218 | Remote { api: project.api.clone().unwrap_or_default(), token: credential(&host).map(|c| c.token), host, key: format!("{owner}/{name}") } |
| 219 | } |
| 220 |
| 221 | fn url(&self, tail: &str) -> String { |
| 222 | format!("{}/projects/{}{tail}", self.api, self.key) |
| 223 | } |
| 224 |
| 225 | fn auth<T>(&self, req: Builder<T>) -> Builder<T> { |
| 226 | match &self.token { |
| 227 | Some(t) => req.header("authorization", format!("Bearer {t}")), |
| 228 | None => req, |
| 229 | } |
| 230 | } |
| 231 |
| 232 | fn finish(&self, result: Result<ureq::http::Response<ureq::Body>, ureq::Error>) -> Result<Vec<u8>> { |
| 233 | let mut response = result.map_err(|e| { |
| 234 | Fail::new("offline", format!("can't reach {}: {e}", self.host)) |
| 235 | .state("nothing on the host changed; your work is saved here") |
| 236 | .next("run it again when you're online") |
| 237 | .into_anyhow() |
| 238 | })?; |
| 239 | let status = response.status().as_u16(); |
| 240 | let body = response.body_mut().with_config().limit(2 << 30).read_to_vec().map_err(|e| anyhow!("reading {}'s answer: {e}", self.host))?; |
| 241 | if (200..300).contains(&status) { |
| 242 | return Ok(body); |
| 243 | } |
| 244 | let info = serde_json::from_slice::<ErrorBody>(&body).ok().map(|b| b.error); |
| 245 | let message = info.as_ref().map(|i| i.message.clone()).unwrap_or_else(|| format!("{} answered {status}", self.host)); |
| 246 | let fail = match (status, info.as_ref().map(|i| i.code.as_str())) { |
| 247 | (401, _) => Fail::new("not_logged_in", message).state("nothing changed").next(format!("vcs login {}", self.host)), |
| 248 | (403, _) => Fail::new("not_allowed", message).state("nothing changed"), |
| 249 | (404, _) => { |
| 250 | let f = Fail::new("not_found", message).state("nothing changed"); |
| 251 | if self.token.is_none() { f.next(format!("vcs login {}", self.host)) } else { f } |
| 252 | } |
| 253 | (409, Some("held_elsewhere")) => { |
| 254 | let holder = info.and_then(|i| i.holder).unwrap_or_else(|| "another workspace".into()); |
| 255 | Fail::new("held_elsewhere", format!("{holder} took this change over")).set("holder", holder) |
| 256 | } |
| 257 | (409, _) => Fail::new("exists", message).state("nothing changed"), |
| 258 | _ => Fail::new("failed", message), |
| 259 | }; |
| 260 | Err(fail.into_anyhow()) |
| 261 | } |
| 262 |
| 263 | fn get(&self, tail: &str) -> Result<Vec<u8>> { |
| 264 | self.finish(self.auth(agent().get(self.url(tail))).call()) |
| 265 | } |
| 266 |
| 267 | fn get_json<T: serde::de::DeserializeOwned>(&self, tail: &str) -> Result<T> { |
| 268 | Ok(serde_json::from_slice(&self.get(tail)?)?) |
| 269 | } |
| 270 |
| 271 | fn send_json<T: serde::de::DeserializeOwned>(&self, method: &str, tail: &str, body: &impl Serialize) -> Result<T> { |
| 272 | let url = self.url(tail); |
| 273 | let req = if method == "PUT" { agent().put(url) } else { agent().post(url) }; |
| 274 | Ok(serde_json::from_slice(&self.finish(self.auth(req).send_json(body))?)?) |
| 275 | } |
| 276 |
| 277 | fn send_bytes(&self, tail: &str, body: &[u8]) -> Result<Vec<u8>> { |
| 278 | self.finish(self.auth(agent().post(self.url(tail))).header("content-type", "application/octet-stream").send(body)) |
| 279 | } |
| 280 |
| 281 | pub fn view(&self) -> Result<ProjectView> { |
| 282 | let url = format!("{}/projects/{}", self.api, self.key); |
| 283 | Ok(serde_json::from_slice(&self.finish(self.auth(agent().get(url)).call())?)?) |
| 284 | } |
| 285 |
| 286 | /// Uploads whatever the host lacks of `ids`. |
| 287 | pub fn upload(&self, store: &Store, ids: &[Id]) -> Result<usize> { |
| 288 | if ids.is_empty() { |
| 289 | return Ok(0); |
| 290 | } |
| 291 | let mut missing = Vec::new(); |
| 292 | for chunk in ids.chunks(20_000) { |
| 293 | missing.extend(wire::decode_ids(&self.send_bytes("/objects/missing", &wire::encode_ids(chunk))?)); |
| 294 | } |
| 295 | let mut sent = 0; |
| 296 | let mut batch = Vec::new(); |
| 297 | let mut size = 0usize; |
| 298 | for id in &missing { |
| 299 | batch.push(*id); |
| 300 | size += store.frame(id)?.map(|f| f.payload.len()).unwrap_or(0); |
| 301 | if size > 16 << 20 { |
| 302 | self.send_bytes("/objects", &store.bundle(&batch)?)?; |
| 303 | sent += batch.len(); |
| 304 | batch.clear(); |
| 305 | size = 0; |
| 306 | } |
| 307 | } |
| 308 | if !batch.is_empty() { |
| 309 | self.send_bytes("/objects", &store.bundle(&batch)?)?; |
| 310 | sent += batch.len(); |
| 311 | } |
| 312 | Ok(sent) |
| 313 | } |
| 314 |
| 315 | /// Objects under `tree` that `base` doesn't share, chunks included. |
| 316 | pub fn objects_since(store: &Store, base: Option<&Id>, tree: &Id) -> Result<Vec<Id>> { |
| 317 | let mut ids = Vec::new(); |
| 318 | tree::new_since(store, base, tree, &mut ids)?; |
| 319 | let mut all = Vec::with_capacity(ids.len()); |
| 320 | for id in ids { |
| 321 | all.extend(store.file_parts(&id)?); |
| 322 | } |
| 323 | Ok(all) |
| 324 | } |
| 325 |
| 326 | fn record(change: &Change) -> ChangeRecord { |
| 327 | ChangeRecord { |
| 328 | id: change.id.clone(), |
| 329 | title: change.title.clone(), |
| 330 | body: change.body.clone(), |
| 331 | base_tree: change.base_tree, |
| 332 | base_version: change.base_version, |
| 333 | on_change: change.on_change.clone(), |
| 334 | after_change: change.after_change.clone(), |
| 335 | head_tree: change.head_tree, |
| 336 | saves: change.saves, |
| 337 | state: change.state.clone(), |
| 338 | version: change.version, |
| 339 | author: change.author.clone(), |
| 340 | owner: String::new(), |
| 341 | shared: Vec::new(), |
| 342 | holder: None, |
| 343 | updated: change.updated, |
| 344 | } |
| 345 | } |
| 346 |
| 347 | /// Takes a host record into the local database, keeping newer local edits. |
| 348 | fn adopt(db: &Db, project: &Project, r: &ChangeRecord) -> Result<Change> { |
| 349 | if let Some(mut local) = db.change(&r.id)? |
| 350 | && local.updated >= r.updated |
| 351 | && local.state == r.state |
| 352 | { |
| 353 | local.shared = r.shared.iter().map(|s| s.who.clone()).collect(); |
| 354 | db.put_change(&local)?; |
| 355 | return Ok(local); |
| 356 | } |
| 357 | let mut c = Change::new(r.id.clone(), &project.id, r.base_tree, r.base_version, &r.author); |
| 358 | if let Some(local) = db.change(&r.id)? { |
| 359 | c.workspace = local.workspace; |
| 360 | c.conflicts = local.conflicts; |
| 361 | c.created = local.created; |
| 362 | } |
| 363 | c.title = r.title.clone(); |
| 364 | c.body = r.body.clone(); |
| 365 | c.on_change = r.on_change.clone(); |
| 366 | c.after_change = r.after_change.clone(); |
| 367 | c.head_tree = r.head_tree; |
| 368 | c.saves = r.saves; |
| 369 | c.state = r.state.clone(); |
| 370 | c.version = r.version; |
| 371 | c.shared = r.shared.iter().map(|s| s.who.clone()).collect(); |
| 372 | if !r.owner.is_empty() && r.author.is_empty() { |
| 373 | c.author = r.owner.clone(); |
| 374 | } |
| 375 | db.put_change(&c)?; |
| 376 | Ok(c) |
| 377 | } |
| 378 |
| 379 | pub fn fetch_change(&self, db: &Db, store: &Store, project: &Project, prefix: &str) -> Result<Option<Change>> { |
| 380 | #[derive(Deserialize)] |
| 381 | struct Found { |
| 382 | change: ChangeRecord, |
| 383 | } |
| 384 | let found: Found = match self.get_json(&format!("/changes/{prefix}")) { |
| 385 | Ok(f) => f, |
| 386 | Err(e) => { |
| 387 | let fail = Fail::from(e); |
| 388 | if fail.code == "not_found" { |
| 389 | return Ok(None); |
| 390 | } |
| 391 | return Err(fail.into_anyhow()); |
| 392 | } |
| 393 | }; |
| 394 | let c = Self::adopt(db, project, &found.change)?; |
| 395 | self.fetch_tree(store, project, &c.base_tree, None)?; |
| 396 | self.fetch_tree(store, project, &c.tree(), Some(&c.base_tree))?; |
| 397 | Ok(Some(c)) |
| 398 | } |
| 399 |
| 400 | /// Brings a change's latest content from the host. |
| 401 | pub fn fetch_saves(&self, db: &Db, store: &Store, project: &Project, change: &Change) -> Result<()> { |
| 402 | self.fetch_change(db, store, project, &change.id)?; |
| 403 | Ok(()) |
| 404 | } |
| 405 |
| 406 | pub fn hold(&self, change: &str, workspace: &str, label: &str) -> Result<Option<String>> { |
| 407 | #[derive(Deserialize)] |
| 408 | struct Prev { |
| 409 | previous: Option<String>, |
| 410 | } |
| 411 | let p: Prev = self.send_json("POST", &format!("/changes/{change}/hold"), &Hold { workspace: workspace.into(), label: label.into() })?; |
| 412 | Ok(p.previous) |
| 413 | } |
| 414 |
| 415 | pub fn changes(&self) -> Result<Vec<ChangeRecord>> { |
| 416 | #[derive(Deserialize)] |
| 417 | struct List { |
| 418 | changes: Vec<ChangeRecord>, |
| 419 | } |
| 420 | Ok(self.get_json::<List>("/changes")?.changes) |
| 421 | } |
| 422 |
| 423 | fn is_complete(store: &Store, db: &Db, tree: &Id) -> bool { |
| 424 | if db.conn.query_row("SELECT 1 FROM complete WHERE tree = ?1", [tree.hex()], |_| Ok(())).is_ok() { |
| 425 | return true; |
| 426 | } |
| 427 | if !store.has(tree) { |
| 428 | return false; |
| 429 | } |
| 430 | let mut ids = Vec::new(); |
| 431 | if tree::reachable(store, tree, &mut ids).is_err() { |
| 432 | return false; |
| 433 | } |
| 434 | ids.iter().all(|i| store.file_parts(i).is_ok_and(|parts| parts.iter().all(|p| store.has(p)))) |
| 435 | } |
| 436 | } |
| 437 |
| 438 | fn mark_complete(db: &Db, tree: &Id) { |
| 439 | let _ = db.conn.execute("INSERT OR IGNORE INTO complete (tree) VALUES (?1)", [tree.hex()]); |
| 440 | } |
| 441 |
| 442 | thread_local! { |
| 443 | static OWN_DB: std::cell::RefCell<Option<Db>> = const { std::cell::RefCell::new(None) }; |
| 444 | } |
| 445 |
| 446 | /// The fetch path keeps its own connection, so Host methods needn't carry one. |
| 447 | fn with_db<T>(f: impl FnOnce(&Db) -> T) -> Result<T> { |
| 448 | OWN_DB.with(|cell| { |
| 449 | let mut slot = cell.borrow_mut(); |
| 450 | if slot.is_none() { |
| 451 | *slot = Some(Db::open(&paths::home())?); |
| 452 | } |
| 453 | Ok(f(slot.as_ref().unwrap())) |
| 454 | }) |
| 455 | } |
| 456 |
| 457 | impl Host for Remote { |
| 458 | fn refresh(&self, db: &Db, _store: &Store, project: &Project) -> Result<Option<Version>> { |
| 459 | let view = self.view()?; |
| 460 | let Some(main) = view.main else { return Ok(None) }; |
| 461 | let mut since = db.latest_version(&project.id)?.map(|v| v.n).unwrap_or(0); |
| 462 | #[derive(Deserialize)] |
| 463 | struct List { |
| 464 | versions: Vec<Version>, |
| 465 | } |
| 466 | while since < main.n { |
| 467 | let list: List = self.get_json(&format!("/versions?since={since}"))?; |
| 468 | if list.versions.is_empty() { |
| 469 | break; |
| 470 | } |
| 471 | let tx = db.conn.unchecked_transaction()?; |
| 472 | for v in &list.versions { |
| 473 | db.put_version(&project.id, v)?; |
| 474 | since = v.n; |
| 475 | } |
| 476 | tx.commit()?; |
| 477 | } |
| 478 | Ok(Some(main)) |
| 479 | } |
| 480 |
| 481 | fn fetch_tree(&self, store: &Store, project: &Project, tree: &Id, have: Option<&Id>) -> Result<()> { |
| 482 | if with_db(|db| Self::is_complete(store, db, tree))? { |
| 483 | with_db(|db| mark_complete(db, tree))?; |
| 484 | return Ok(()); |
| 485 | } |
| 486 | // Skip what a tree we already have shares with it. |
| 487 | let have = match have.filter(|h| with_db(|db| Self::is_complete(store, db, h)).unwrap_or(false)) { |
| 488 | Some(h) => Some(*h), |
| 489 | None => with_db(|db| -> Option<Id> { |
| 490 | db.conn |
| 491 | .query_row( |
| 492 | "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", |
| 493 | [&project.id], |
| 494 | |r| r.get::<_, String>(0), |
| 495 | ) |
| 496 | .ok() |
| 497 | .and_then(|t| Id::parse(&t)) |
| 498 | })?, |
| 499 | }; |
| 500 | let tail = match have { |
| 501 | Some(h) => format!("/trees/{tree}?have={h}"), |
| 502 | None => format!("/trees/{tree}"), |
| 503 | }; |
| 504 | let bundle = self.get(&tail)?; |
| 505 | store.ingest(&bundle)?; |
| 506 | with_db(|db| mark_complete(db, tree))?; |
| 507 | Ok(()) |
| 508 | } |
| 509 |
| 510 | fn land(&self, db: &Db, store: &Store, project: &Project, request: &LandRequest) -> Result<Landed> { |
| 511 | let mut ids = Self::objects_since(store, Some(&request.base_tree), &request.tree)?; |
| 512 | ids.push(request.base_tree); |
| 513 | self.upload(store, &ids)?; |
| 514 | db.mark_uploaded_tree(&project.id, &request.tree)?; |
| 515 | self.send_json("POST", "/land", request) |
| 516 | } |
| 517 |
| 518 | fn publish_change(&self, db: &Db, store: &Store, project: &Project, change: &Change, workspace: &str) -> Result<()> { |
| 519 | let holder = db.workspace(workspace)?.map(|w| label(&w.path)).unwrap_or_default(); |
| 520 | // Content first, so the record never points at missing objects. The |
| 521 | // host has the base already when it's a version of main (the host |
| 522 | // made it) or a tree this machine uploaded before. |
| 523 | let host_has_base = change.base_version.and_then(|n| db.version(&project.id, n).ok().flatten()).is_some_and(|v| v.tree == change.base_tree) |
| 524 | || db.uploaded_tree(&project.id, &change.base_tree)?; |
| 525 | let mut ids = if host_has_base { Vec::new() } else { Self::objects_since(store, None, &change.base_tree)? }; |
| 526 | if let Some(h) = change.head_tree { |
| 527 | ids.extend(Self::objects_since(store, Some(&change.base_tree), &h)?); |
| 528 | } |
| 529 | self.upload(store, &ids)?; |
| 530 | db.mark_uploaded_tree(&project.id, &change.base_tree)?; |
| 531 | if let Some(h) = change.head_tree { |
| 532 | db.mark_uploaded_tree(&project.id, &h)?; |
| 533 | } |
| 534 | let _: serde_json::Value = |
| 535 | self.send_json("PUT", &format!("/changes/{}", change.id), &PutChange { change: Self::record(change), workspace: workspace.to_string(), label: holder })?; |
| 536 | let pending = db.unuploaded_saves(&change.id)?; |
| 537 | if let Some(last) = pending.last() { |
| 538 | let _: serde_json::Value = self.send_json( |
| 539 | "POST", |
| 540 | &format!("/changes/{}/saves", change.id), |
| 541 | &SaveRecord { n: last.n, tree: last.tree, base_tree: last.base_tree, at: last.at, workspace: workspace.to_string() }, |
| 542 | )?; |
| 543 | for s in &pending { |
| 544 | db.mark_uploaded(&change.id, s.n)?; |
| 545 | } |
| 546 | } |
| 547 | Ok(()) |
| 548 | } |
| 549 | } |
| 550 |
| 551 | /// Changes this person can see in the project: the host's merged into the |
| 552 | /// local database. |
| 553 | pub fn visible_changes(app: &App, project: &Project) -> Result<Vec<Change>> { |
| 554 | if project.hosted() { |
| 555 | let remote = Remote::for_project(project); |
| 556 | for r in remote.changes()? { |
| 557 | Remote::adopt(&app.db, project, &r)?; |
| 558 | } |
| 559 | } |
| 560 | app.db.changes(&project.id) |
| 561 | } |
| 562 |
| 563 | pub fn access_change(app: &App, s: &Session, target: &str, who: Vec<String>, edit: bool) -> Out { |
| 564 | let c = app.find_change(&s.project, target)?; |
| 565 | if !s.project.hosted() { |
| 566 | return Err(Fail::new("bad_usage", "a project on this machine only has you; sharing changes needs a hosted project") |
| 567 | .state("nothing changed") |
| 568 | .next("vcs publish <host/owner/name>")); |
| 569 | } |
| 570 | let remote = Remote::for_project(&s.project); |
| 571 | let mut r = Report::new(); |
| 572 | if who.is_empty() { |
| 573 | let found = remote.fetch_change(&app.db, &app.store, &s.project, &c.id)?; |
| 574 | let shared = found.map(|f| f.shared).unwrap_or_default(); |
| 575 | r.line("access", format!("change {}: you{}", app.short(&s.project, &c.id), if shared.is_empty() { String::new() } else { format!(", {}", shared.join(", ")) })); |
| 576 | r.set("who", shared); |
| 577 | return Ok(r); |
| 578 | } |
| 579 | remote.publish_change(&app.db, &app.store, &s.project, &app.change(&c.id)?, &s.ws.id)?; |
| 580 | #[derive(Deserialize)] |
| 581 | struct Shared { |
| 582 | shared: Vec<wire::Share>, |
| 583 | } |
| 584 | let shared: Shared = remote.send_json("POST", &format!("/changes/{}/access", c.id), &ShareRequest { who: who.clone(), edit })?; |
| 585 | app.db.add_op(&s.ws.id, "access", &json!({ "target": c.id, "who": who.join(" "), "change_before": c.shared }))?; |
| 586 | let list: Vec<String> = shared.shared.iter().map(|s| if s.edit { format!("{} (can edit)", s.who) } else { s.who.clone() }).collect(); |
| 587 | r.line("access", format!("change {}: you, {}", app.short(&s.project, &c.id), list.join(", "))); |
| 588 | r.set("who", list); |
| 589 | r.next(if edit { format!("they can vcs get <project> <folder> --change {}", app.short(&s.project, &c.id)) } else { "they see it in vcs log --changes".into() }); |
| 590 | Ok(r) |
| 591 | } |
| 592 |
| 593 | /// The host's path rules: (path, who). |
| 594 | pub fn path_rules(_app: &App, project: &Project) -> Result<Vec<(String, Vec<String>)>> { |
| 595 | if !project.hosted() { |
| 596 | return Ok(Vec::new()); |
| 597 | } |
| 598 | #[derive(Deserialize)] |
| 599 | struct Rules { |
| 600 | rules: Vec<Rule>, |
| 601 | } |
| 602 | let rules: Rules = Remote::for_project(project).get_json("/rules")?; |
| 603 | Ok(rules.rules.into_iter().map(|r| (r.path, r.who)).collect()) |
| 604 | } |
| 605 |
| 606 | pub fn set_path_rule(app: &App, s: &Session, path: &str, who: Vec<String>) -> Out { |
| 607 | let remote = Remote::for_project(&s.project); |
| 608 | let before = path_rules(app, &s.project)?.into_iter().find(|(p, _)| p == path).map(|(_, w)| w).unwrap_or_default(); |
| 609 | let _: serde_json::Value = remote.send_json("PUT", "/rules", &Rule { path: path.to_string(), who: who.clone() })?; |
| 610 | app.db.add_op(&s.ws.id, "access", &json!({ "target": path, "who": who.join(" "), "rule_before": before }))?; |
| 611 | let mut r = Report::new(); |
| 612 | if who.len() == 1 && who[0] == "team" { |
| 613 | r.line("access", format!("{path}: everyone with access to the project")); |
| 614 | } else { |
| 615 | r.line("access", format!("{path}: only {} (and the project's owners)", who.join(", "))); |
| 616 | } |
| 617 | r.set("path", path).set("who", who); |
| 618 | Ok(r) |
| 619 | } |
| 620 |
| 621 | /// Uploads the user's own files. |
| 622 | pub fn push_mine(app: &App, project: &Project) -> Result<()> { |
| 623 | if !project.hosted() { |
| 624 | return Ok(()); |
| 625 | } |
| 626 | let remote = Remote::for_project(project); |
| 627 | let files: Vec<MineFile> = app.db.mine(&project.id)?.into_iter().map(|(path, file, exec)| MineFile { path, file, exec }).collect(); |
| 628 | let mut ids = Vec::new(); |
| 629 | for f in &files { |
| 630 | ids.extend(app.store.file_parts(&f.file)?); |
| 631 | } |
| 632 | remote.upload(&app.store, &ids)?; |
| 633 | let _: serde_json::Value = remote.send_json("PUT", "/mine", &json!({ "files": files }))?; |
| 634 | Ok(()) |
| 635 | } |
| 636 |
| 637 | /// Brings the user's own files from the host into this machine's list. |
| 638 | pub fn pull_mine(app: &App, project: &Project) -> Result<()> { |
| 639 | if !project.hosted() { |
| 640 | return Ok(()); |
| 641 | } |
| 642 | let remote = Remote::for_project(project); |
| 643 | #[derive(Deserialize)] |
| 644 | struct Files { |
| 645 | files: Vec<MineFile>, |
| 646 | } |
| 647 | let files: Files = remote.get_json("/mine")?; |
| 648 | if files.files.is_empty() { |
| 649 | return Ok(()); |
| 650 | } |
| 651 | app.store.ingest(&remote.get("/mine/bundle")?)?; |
| 652 | let local = app.db.mine(&project.id)?; |
| 653 | for f in files.files { |
| 654 | if !local.iter().any(|(p, _, _)| p == &f.path) { |
| 655 | app.db.put_mine(&project.id, &f.path, &f.file, f.exec)?; |
| 656 | } |
| 657 | } |
| 658 | Ok(()) |
| 659 | } |
| 660 |
| 661 | pub fn login(_app: &App, host: Option<String>, token: Option<String>) -> Out { |
| 662 | let host = host.unwrap_or_else(|| "upstream.codes".into()); |
| 663 | let host = host.trim_start_matches("https://").trim_start_matches("http://").trim_end_matches('/').to_string(); |
| 664 | let api = discover(&host)?; |
| 665 | let token = match token.or_else(|| std::env::var("VCS_TOKEN").ok().filter(|t| !t.is_empty())) { |
| 666 | Some(t) => t, |
| 667 | None => crate::signin::browser(&host, login_url(&host).as_deref())?, |
| 668 | }; |
| 669 | let mut response = agent() |
| 670 | .get(format!("{api}/me")) |
| 671 | .header("authorization", format!("Bearer {token}")) |
| 672 | .call() |
| 673 | .map_err(|e| Fail::new("offline", format!("can't reach {host}: {e}")).state("nothing changed"))?; |
| 674 | if response.status().as_u16() != 200 { |
| 675 | return Err(Fail::new("not_logged_in", format!("{host} didn't accept that token")).state("nothing changed").next(format!("vcs login {host}"))); |
| 676 | } |
| 677 | let me: wire::Me = response.body_mut().read_json().map_err(|e| anyhow!("{e}"))?; |
| 678 | let mut creds = load_credentials(); |
| 679 | creds.hosts.insert(host.clone(), Credential { token, handle: me.handle.clone(), name: me.name.clone(), email: me.email.clone(), api }); |
| 680 | save_credentials(&creds)?; |
| 681 | let mut r = Report::new(); |
| 682 | r.line("signed in", format!("to {host} as {} ({})", me.handle, me.email)).set("host", host).set("handle", me.handle); |
| 683 | r.next("vcs get <host/owner/name>"); |
| 684 | Ok(r) |
| 685 | } |
| 686 |
| 687 | /// `vcs publish`: puts a local project on a host, history and all. |
| 688 | pub fn publish(app: &App, target: &str) -> Out { |
| 689 | let mut s = app.session()?; |
| 690 | if s.project.hosted() { |
| 691 | return Err(Fail::new("bad_usage", format!("this project is already on {}", s.project.address)).state("nothing changed")); |
| 692 | } |
| 693 | app.save(&mut s)?; |
| 694 | let (host, owner, name) = split_address(target)?; |
| 695 | let api = discover(&host)?; |
| 696 | let token = credential(&host).map(|c| c.token); |
| 697 | if token.is_none() { |
| 698 | return Err(Fail::new("not_logged_in", format!("sign in to {host} first")).state("nothing changed").next(format!("vcs login {host}"))); |
| 699 | } |
| 700 | let remote = Remote { api: api.clone(), host: host.clone(), key: format!("{owner}/{name}"), token: token.clone() }; |
| 701 | // Every version, oldest first, with its objects. |
| 702 | let mut versions = app.db.versions(&s.project.id, i64::MAX)?; |
| 703 | versions.reverse(); |
| 704 | // History imported from git goes to a repository that may already hold it. |
| 705 | let imported = versions.iter().any(|v| v.git.is_some()); |
| 706 | if let Err(e) = remote.finish(remote.auth(agent().post(format!("{api}/projects"))).send_json(json!({ "owner": owner, "name": name, "import": imported }))) { |
| 707 | let fail = Fail::from(e); |
| 708 | if fail.code != "exists" { |
| 709 | return Err(fail); |
| 710 | } |
| 711 | } |
| 712 | let view = remote.view()?; |
| 713 | if view.main.is_some() { |
| 714 | return Err(Fail::new("bad_usage", format!("{target} already has versions; publish only to an empty project")).state("nothing changed")); |
| 715 | } |
| 716 | let mut prev: Option<Id> = None; |
| 717 | for v in &versions { |
| 718 | let ids = Remote::objects_since(&app.store, prev.as_ref(), &v.tree)?; |
| 719 | remote.upload(&app.store, &ids)?; |
| 720 | prev = Some(v.tree); |
| 721 | } |
| 722 | #[derive(Deserialize)] |
| 723 | struct Imported { |
| 724 | main: Option<u64>, |
| 725 | } |
| 726 | let landed: Imported = remote.send_json("POST", "/import", &json!({ "versions": versions }))?; |
| 727 | if imported { |
| 728 | // The git view carries on from the imported commits: the host needs |
| 729 | // their git IDs, and the repository needs the commits. |
| 730 | let ids = app.db.git_ids(&s.project.id)?; |
| 731 | for batch in ids.chunks(20_000) { |
| 732 | let map: serde_json::Map<String, serde_json::Value> = batch.iter().map(|(n, g)| (n.clone(), serde_json::Value::String(g.clone()))).collect(); |
| 733 | let _: serde_json::Value = remote.send_json("POST", "/git/ids", &json!({ "ids": map }))?; |
| 734 | } |
| 735 | if let (Some(url), Some(token)) = (&view.git, &token) |
| 736 | && let Some(note) = crate::bridge::push_history(app, &s.project, url, token)? |
| 737 | { |
| 738 | app.notes.borrow_mut().push(note); |
| 739 | } |
| 740 | } |
| 741 | let address = format!("{host}/{owner}/{name}"); |
| 742 | let project = Project { id: s.project.id.clone(), address: address.clone(), kind: "hosted".into(), api: Some(api), checked: ids::now() }; |
| 743 | app.db.put_project(&project)?; |
| 744 | // Open changes and the user's own files follow. |
| 745 | for c in app.db.changes(&project.id)? { |
| 746 | if c.state == "open" && !c.is_empty() { |
| 747 | let _ = remote.publish_change(&app.db, &app.store, &project, &c, &s.ws.id); |
| 748 | } |
| 749 | } |
| 750 | let _ = push_mine(app, &project); |
| 751 | let mut r = Report::new(); |
| 752 | r.line("published", format!("{} versions to {address}", versions.len())); |
| 753 | r.line("main", format!("{}", landed.main.unwrap_or(0))); |
| 754 | r.set("address", address).set("versions", versions.len()); |
| 755 | r.next("keep working; vcs land now lands on the host"); |
| 756 | Ok(r) |
| 757 | } |
| 758 |
| 759 | /// `vcs git [resume]`. |
| 760 | pub fn git_view(app: &App, resume: bool) -> Out { |
| 761 | let s = app.session()?; |
| 762 | if !s.project.hosted() { |
| 763 | return Err(Fail::new("bad_usage", "git views belong to hosted projects").state("nothing changed").next("vcs publish <host/owner/name>")); |
| 764 | } |
| 765 | let remote = Remote::for_project(&s.project); |
| 766 | let mut r = Report::new(); |
| 767 | if resume { |
| 768 | #[derive(Deserialize)] |
| 769 | struct Resumed { |
| 770 | adopted: Option<String>, |
| 771 | } |
| 772 | let done: Resumed = remote.send_json("POST", "/git/resume", &json!({}))?; |
| 773 | r.line("resumed", match &done.adopted { |
| 774 | Some(c) => format!("the next version's commit keeps git main's outside commits (up to {})", &c[..c.len().min(12)]), |
| 775 | None => "the git view takes new versions again".to_string(), |
| 776 | }); |
| 777 | } |
| 778 | let view = remote.view()?; |
| 779 | let Some(url) = &view.git else { |
| 780 | r.line("git", "this project has no git view"); |
| 781 | return Ok(r); |
| 782 | }; |
| 783 | r.line("git", format!("git clone {url}")).set("url", url.clone()); |
| 784 | if let Some(main) = &view.main { |
| 785 | let state = match &main.git { |
| 786 | Some(c) => format!("version {} is commit {}", main.n, &c[..c.len().min(12)]), |
| 787 | None => format!("version {} isn't in the git view yet", main.n), |
| 788 | }; |
| 789 | r.line("main", state).set("main", main.n).set("commit", main.git.clone()); |
| 790 | } |
| 791 | match &view.git_stopped { |
| 792 | Some(why) if !resume => { |
| 793 | r.line("paused", why.clone()).set("paused", why.clone()); |
| 794 | r.next("bring the outside commits in with vcs import <git url> --change main and land them, then vcs git resume"); |
| 795 | } |
| 796 | _ => { |
| 797 | r.set("paused", serde_json::Value::Null); |
| 798 | } |
| 799 | } |
| 800 | Ok(r) |
| 801 | } |
| 802 |
| 803 | pub fn import(app: &App, url: &str, to: Option<String>, folder: Option<PathBuf>, change: Option<String>) -> Out { |
| 804 | crate::bridge::import(app, url, to, folder, change) |
| 805 | } |
| 806 |
| 807 | pub fn saver(app: &App) -> Out { |
| 808 | crate::saver::run(app) |
| 809 | } |
| 810 |