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