//! Hosted projects: the vcs protocol client (see `wire` for the endpoints). use std::collections::HashMap; use std::path::PathBuf; use std::sync::OnceLock; use std::time::Duration; use anyhow::{Context, Result, anyhow}; use serde::{Deserialize, Serialize}; use serde_json::json; use ureq::Agent; use vcs_core::version::Version; use vcs_core::{Id, tree}; use crate::app::{App, Session}; use crate::db::{Change, Db, Project}; use crate::host::{Host, LandRequest, Landed}; use crate::ids; use crate::objects::Store; use crate::out::{Fail, Out, Report}; use crate::paths; use crate::wire::{self, ChangeRecord, ErrorBody, Hold, MineFile, ProjectView, PutChange, Rule, SaveRecord, ShareRequest}; /// Hosts whose vcs API isn't on the host itself. const KNOWN: &[(&str, &str)] = &[("upstream.codes", "https://vcs.coline.dev/v1"), ("git.coline.app", "https://vcs.coline.dev/v1")]; fn agent() -> &'static Agent { static AGENT: OnceLock = OnceLock::new(); AGENT.get_or_init(|| { Agent::config_builder() .http_status_as_error(false) .timeout_connect(Some(Duration::from_secs(10))) .timeout_global(Some(Duration::from_secs(900))) .user_agent(format!("vcs/{}", env!("CARGO_PKG_VERSION"))) .build() .into() }) } // Sign-in. #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct Credential { pub token: String, #[serde(default)] pub handle: String, #[serde(default)] pub name: String, #[serde(default)] pub email: String, #[serde(default)] pub api: String, } #[derive(Debug, Default, Serialize, Deserialize)] struct Credentials { #[serde(default)] hosts: HashMap, } fn load_credentials() -> Credentials { std::fs::read_to_string(paths::credentials()).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default() } fn save_credentials(c: &Credentials) -> Result<()> { let path = paths::credentials(); if let Some(dir) = path.parent() { std::fs::create_dir_all(dir)?; } let tmp = path.with_extension("tmp"); std::fs::write(&tmp, serde_json::to_vec_pretty(c)?)?; use std::os::unix::fs::PermissionsExt; std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600))?; std::fs::rename(tmp, path)?; Ok(()) } /// The sign-in for a host. VCS_TOKEN (for agents and CI) wins. pub fn credential(host: &str) -> Option { let mut c = load_credentials().hosts.remove(host); if let Some(token) = std::env::var("VCS_TOKEN").ok().filter(|t| !t.is_empty()) { let mut cred = c.unwrap_or_default(); cred.token = token; c = Some(cred); } c } /// The signed-in account for a project's host: (name, email). pub fn account(project: &Project) -> Option<(String, String)> { let host = split_address(&project.address).ok()?.0; let c = credential(&host)?; if c.name.is_empty() || c.email.is_empty() { return None; } Some((c.name, c.email)) } // Addresses and discovery. /// "host/owner/name" (with or without https://) → (host, owner, name). pub fn split_address(text: &str) -> Result<(String, String, String), Fail> { let t = text.trim().trim_end_matches('/'); let t = t.strip_prefix("https://").or_else(|| t.strip_prefix("http://")).unwrap_or(t); let t = t.strip_suffix(".git").unwrap_or(t); let parts: Vec<&str> = t.split('/').collect(); if parts.len() != 3 || parts.iter().any(|p| p.is_empty()) { return Err(Fail::new("bad_usage", format!("{text} isn't a project address")) .state("nothing changed") .next("projects look like upstream.codes/owner/name")); } Ok((parts[0].to_string(), parts[1].to_string(), parts[2].to_string())) } fn scheme(host: &str) -> &'static str { let h = host.split(':').next().unwrap_or(host); if h == "localhost" || h == "127.0.0.1" || h == "::1" || std::env::var("VCS_INSECURE").is_ok_and(|v| v == "1") { "http" } else { "https" } } /// The vcs API base URL for a host. pub fn discover(host: &str) -> Result { if let Some(api) = std::env::var("VCS_API").ok().filter(|a| !a.is_empty()) { return Ok(api.trim_end_matches('/').to_string()); } if let Some(c) = load_credentials().hosts.get(host) && !c.api.is_empty() { return Ok(c.api.clone()); } if let Some((_, api)) = KNOWN.iter().find(|(h, _)| *h == host) { return Ok(api.to_string()); } let base = format!("{}://{host}", scheme(host)); let mut response = agent() .get(format!("{base}/.well-known/vcs.json")) .call() .map_err(|e| Fail::new("offline", format!("can't reach {host}: {e}")).state("nothing changed").into_anyhow())?; if response.status().as_u16() != 200 { return Err(Fail::new("not_found", format!("{host} doesn't host vcs projects")).state("nothing changed").into_anyhow()); } #[derive(Deserialize)] struct Known { api: String, } let known: Known = response.body_mut().read_json().context("reading the host's vcs.json")?; Ok(if known.api.starts_with('/') { format!("{base}{}", known.api) } else { known.api }.trim_end_matches('/').to_string()) } /// A hosted project by address, recorded on this machine. pub fn project(app: &App, target: &str) -> Result { let (host, owner, name) = split_address(target)?; let address = format!("{host}/{owner}/{name}"); let api = match app.db.project_by_address(&address)? { Some(p) if p.api.is_some() => p.api.unwrap(), _ => discover(&host)?, }; let remote = Remote { api: api.clone(), host: host.clone(), key: format!("{owner}/{name}"), token: credential(&host).map(|c| c.token) }; remote.view()?; let existing = app.db.project_by_address(&address)?; let project = Project { id: existing.map(|p| p.id).unwrap_or_else(|| address.clone()), address, kind: "hosted".into(), api: Some(api), checked: ids::now() }; app.db.put_project(&project)?; Ok(project) } pub struct Remote { pub api: String, pub host: String, /// owner/name. pub key: String, pub token: Option, } /// How others see a workspace: "machine:/path". pub fn label(path: &std::path::Path) -> String { let mut buf = [0u8; 256]; // SAFETY: gethostname writes at most buf.len() bytes into buf. let ok = unsafe { libc::gethostname(buf.as_mut_ptr() as *mut libc::c_char, buf.len()) } == 0; let name = if ok { let end = buf.iter().position(|b| *b == 0).unwrap_or(buf.len()); String::from_utf8_lossy(&buf[..end]).into_owned() } else { "this machine".into() }; format!("{name}:{}", path.display()) } type Builder = ureq::RequestBuilder; impl Remote { pub fn for_project(project: &Project) -> Remote { let (host, owner, name) = split_address(&project.address).unwrap_or_else(|_| (String::new(), String::new(), String::new())); Remote { api: project.api.clone().unwrap_or_default(), token: credential(&host).map(|c| c.token), host, key: format!("{owner}/{name}") } } fn url(&self, tail: &str) -> String { format!("{}/projects/{}{tail}", self.api, self.key) } fn auth(&self, req: Builder) -> Builder { match &self.token { Some(t) => req.header("authorization", format!("Bearer {t}")), None => req, } } fn finish(&self, result: Result, ureq::Error>) -> Result> { let mut response = result.map_err(|e| { Fail::new("offline", format!("can't reach {}: {e}", self.host)) .state("nothing on the host changed; your work is saved here") .next("run it again when you're online") .into_anyhow() })?; let status = response.status().as_u16(); let body = response.body_mut().with_config().limit(2 << 30).read_to_vec().map_err(|e| anyhow!("reading {}'s answer: {e}", self.host))?; if (200..300).contains(&status) { return Ok(body); } let info = serde_json::from_slice::(&body).ok().map(|b| b.error); let message = info.as_ref().map(|i| i.message.clone()).unwrap_or_else(|| format!("{} answered {status}", self.host)); let fail = match (status, info.as_ref().map(|i| i.code.as_str())) { (401, _) => Fail::new("not_logged_in", message).state("nothing changed").next(format!("vcs login {}", self.host)), (403, _) => Fail::new("not_allowed", message).state("nothing changed"), (404, _) => { let f = Fail::new("not_found", message).state("nothing changed"); if self.token.is_none() { f.next(format!("vcs login {}", self.host)) } else { f } } (409, Some("held_elsewhere")) => { let holder = info.and_then(|i| i.holder).unwrap_or_else(|| "another workspace".into()); Fail::new("held_elsewhere", format!("{holder} took this change over")).set("holder", holder) } (409, _) => Fail::new("exists", message).state("nothing changed"), _ => Fail::new("failed", message), }; Err(fail.into_anyhow()) } fn get(&self, tail: &str) -> Result> { self.finish(self.auth(agent().get(self.url(tail))).call()) } fn get_json(&self, tail: &str) -> Result { Ok(serde_json::from_slice(&self.get(tail)?)?) } fn send_json(&self, method: &str, tail: &str, body: &impl Serialize) -> Result { let url = self.url(tail); let req = if method == "PUT" { agent().put(url) } else { agent().post(url) }; Ok(serde_json::from_slice(&self.finish(self.auth(req).send_json(body))?)?) } fn send_bytes(&self, tail: &str, body: &[u8]) -> Result> { self.finish(self.auth(agent().post(self.url(tail))).header("content-type", "application/octet-stream").send(body)) } pub fn view(&self) -> Result { let url = format!("{}/projects/{}", self.api, self.key); Ok(serde_json::from_slice(&self.finish(self.auth(agent().get(url)).call())?)?) } /// Uploads whatever the host lacks of `ids`. pub fn upload(&self, store: &Store, ids: &[Id]) -> Result { if ids.is_empty() { return Ok(0); } let mut missing = Vec::new(); for chunk in ids.chunks(20_000) { missing.extend(wire::decode_ids(&self.send_bytes("/objects/missing", &wire::encode_ids(chunk))?)); } let mut sent = 0; let mut batch = Vec::new(); let mut size = 0usize; for id in &missing { batch.push(*id); size += store.frame(id)?.map(|f| f.payload.len()).unwrap_or(0); if size > 32 << 20 { self.send_bytes("/objects", &store.bundle(&batch)?)?; sent += batch.len(); batch.clear(); size = 0; } } if !batch.is_empty() { self.send_bytes("/objects", &store.bundle(&batch)?)?; sent += batch.len(); } Ok(sent) } /// Objects under `tree` that `base` doesn't share, chunks included. pub fn objects_since(store: &Store, base: Option<&Id>, tree: &Id) -> Result> { let mut ids = Vec::new(); tree::new_since(store, base, tree, &mut ids)?; let mut all = Vec::with_capacity(ids.len()); for id in ids { all.extend(store.file_parts(&id)?); } Ok(all) } fn record(change: &Change) -> ChangeRecord { ChangeRecord { id: change.id.clone(), title: change.title.clone(), body: change.body.clone(), base_tree: change.base_tree, base_version: change.base_version, on_change: change.on_change.clone(), after_change: change.after_change.clone(), head_tree: change.head_tree, saves: change.saves, state: change.state.clone(), version: change.version, author: change.author.clone(), owner: String::new(), shared: Vec::new(), holder: None, updated: change.updated, } } /// Takes a host record into the local database, keeping newer local edits. fn adopt(db: &Db, project: &Project, r: &ChangeRecord) -> Result { if let Some(mut local) = db.change(&r.id)? && local.updated >= r.updated && local.state == r.state { local.shared = r.shared.iter().map(|s| s.who.clone()).collect(); db.put_change(&local)?; return Ok(local); } let mut c = Change::new(r.id.clone(), &project.id, r.base_tree, r.base_version, &r.author); if let Some(local) = db.change(&r.id)? { c.workspace = local.workspace; c.conflicts = local.conflicts; c.created = local.created; } c.title = r.title.clone(); c.body = r.body.clone(); c.on_change = r.on_change.clone(); c.after_change = r.after_change.clone(); c.head_tree = r.head_tree; c.saves = r.saves; c.state = r.state.clone(); c.version = r.version; c.shared = r.shared.iter().map(|s| s.who.clone()).collect(); if !r.owner.is_empty() && r.author.is_empty() { c.author = r.owner.clone(); } db.put_change(&c)?; Ok(c) } pub fn fetch_change(&self, db: &Db, store: &Store, project: &Project, prefix: &str) -> Result> { #[derive(Deserialize)] struct Found { change: ChangeRecord, } let found: Found = match self.get_json(&format!("/changes/{prefix}")) { Ok(f) => f, Err(e) => { let fail = Fail::from(e); if fail.code == "not_found" { return Ok(None); } return Err(fail.into_anyhow()); } }; let c = Self::adopt(db, project, &found.change)?; self.fetch_tree(store, project, &c.base_tree, None)?; self.fetch_tree(store, project, &c.tree(), Some(&c.base_tree))?; Ok(Some(c)) } /// Brings a change's latest content from the host. pub fn fetch_saves(&self, db: &Db, store: &Store, project: &Project, change: &Change) -> Result<()> { self.fetch_change(db, store, project, &change.id)?; Ok(()) } pub fn hold(&self, change: &str, workspace: &str, label: &str) -> Result> { #[derive(Deserialize)] struct Prev { previous: Option, } let p: Prev = self.send_json("POST", &format!("/changes/{change}/hold"), &Hold { workspace: workspace.into(), label: label.into() })?; Ok(p.previous) } pub fn changes(&self) -> Result> { #[derive(Deserialize)] struct List { changes: Vec, } Ok(self.get_json::("/changes")?.changes) } fn is_complete(store: &Store, db: &Db, tree: &Id) -> bool { if db.conn.query_row("SELECT 1 FROM complete WHERE tree = ?1", [tree.hex()], |_| Ok(())).is_ok() { return true; } if !store.has(tree) { return false; } let mut ids = Vec::new(); if tree::reachable(store, tree, &mut ids).is_err() { return false; } ids.iter().all(|i| store.file_parts(i).is_ok_and(|parts| parts.iter().all(|p| store.has(p)))) } } fn mark_complete(db: &Db, tree: &Id) { let _ = db.conn.execute("INSERT OR IGNORE INTO complete (tree) VALUES (?1)", [tree.hex()]); } thread_local! { static OWN_DB: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; } /// The fetch path keeps its own connection, so Host methods needn't carry one. fn with_db(f: impl FnOnce(&Db) -> T) -> Result { OWN_DB.with(|cell| { let mut slot = cell.borrow_mut(); if slot.is_none() { *slot = Some(Db::open(&paths::home())?); } Ok(f(slot.as_ref().unwrap())) }) } impl Host for Remote { fn refresh(&self, db: &Db, _store: &Store, project: &Project) -> Result> { let view = self.view()?; let Some(main) = view.main else { return Ok(None) }; let mut since = db.latest_version(&project.id)?.map(|v| v.n).unwrap_or(0); #[derive(Deserialize)] struct List { versions: Vec, } while since < main.n { let list: List = self.get_json(&format!("/versions?since={since}"))?; if list.versions.is_empty() { break; } let tx = db.conn.unchecked_transaction()?; for v in &list.versions { db.put_version(&project.id, v)?; since = v.n; } tx.commit()?; } Ok(Some(main)) } fn fetch_tree(&self, store: &Store, project: &Project, tree: &Id, have: Option<&Id>) -> Result<()> { if with_db(|db| Self::is_complete(store, db, tree))? { with_db(|db| mark_complete(db, tree))?; return Ok(()); } // Skip what a tree we already have shares with it. let have = match have.filter(|h| with_db(|db| Self::is_complete(store, db, h)).unwrap_or(false)) { Some(h) => Some(*h), None => with_db(|db| -> Option { db.conn .query_row( "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", [&project.id], |r| r.get::<_, String>(0), ) .ok() .and_then(|t| Id::parse(&t)) })?, }; let tail = match have { Some(h) => format!("/trees/{tree}?have={h}"), None => format!("/trees/{tree}"), }; let bundle = self.get(&tail)?; store.ingest(&bundle)?; with_db(|db| mark_complete(db, tree))?; Ok(()) } fn land(&self, _db: &Db, store: &Store, _project: &Project, request: &LandRequest) -> Result { let mut ids = Self::objects_since(store, Some(&request.base_tree), &request.tree)?; ids.push(request.base_tree); self.upload(store, &ids)?; self.send_json("POST", "/land", request) } fn publish_change(&self, db: &Db, store: &Store, _project: &Project, change: &Change, workspace: &str) -> Result<()> { let holder = db.workspace(workspace)?.map(|w| label(&w.path)).unwrap_or_default(); // Content first, so the record never points at missing objects. let mut ids = Self::objects_since(store, None, &change.base_tree)?; if let Some(h) = change.head_tree { ids.extend(Self::objects_since(store, Some(&change.base_tree), &h)?); } self.upload(store, &ids)?; let _: serde_json::Value = self.send_json("PUT", &format!("/changes/{}", change.id), &PutChange { change: Self::record(change), workspace: workspace.to_string(), label: holder })?; let pending = db.unuploaded_saves(&change.id)?; if let Some(last) = pending.last() { let _: serde_json::Value = self.send_json( "POST", &format!("/changes/{}/saves", change.id), &SaveRecord { n: last.n, tree: last.tree, base_tree: last.base_tree, at: last.at, workspace: workspace.to_string() }, )?; for s in &pending { db.mark_uploaded(&change.id, s.n)?; } } Ok(()) } } /// Changes this person can see in the project: the host's merged into the /// local database. pub fn visible_changes(app: &App, project: &Project) -> Result> { if project.hosted() { let remote = Remote::for_project(project); for r in remote.changes()? { Remote::adopt(&app.db, project, &r)?; } } app.db.changes(&project.id) } pub fn access_change(app: &App, s: &Session, target: &str, who: Vec, edit: bool) -> Out { let c = app.find_change(&s.project, target)?; if !s.project.hosted() { return Err(Fail::new("bad_usage", "a project on this machine only has you; sharing changes needs a hosted project") .state("nothing changed") .next("vcs publish ")); } let remote = Remote::for_project(&s.project); let mut r = Report::new(); if who.is_empty() { let found = remote.fetch_change(&app.db, &app.store, &s.project, &c.id)?; let shared = found.map(|f| f.shared).unwrap_or_default(); r.line("access", format!("change {}: you{}", app.short(&s.project, &c.id), if shared.is_empty() { String::new() } else { format!(", {}", shared.join(", ")) })); r.set("who", shared); return Ok(r); } remote.publish_change(&app.db, &app.store, &s.project, &app.change(&c.id)?, &s.ws.id)?; #[derive(Deserialize)] struct Shared { shared: Vec, } let shared: Shared = remote.send_json("POST", &format!("/changes/{}/access", c.id), &ShareRequest { who: who.clone(), edit })?; app.db.add_op(&s.ws.id, "access", &json!({ "target": c.id, "who": who.join(" "), "change_before": c.shared }))?; let list: Vec = shared.shared.iter().map(|s| if s.edit { format!("{} (can edit)", s.who) } else { s.who.clone() }).collect(); r.line("access", format!("change {}: you, {}", app.short(&s.project, &c.id), list.join(", "))); r.set("who", list); r.next(if edit { format!("they can vcs get --change {}", app.short(&s.project, &c.id)) } else { "they see it in vcs log --changes".into() }); Ok(r) } /// The host's path rules: (path, who). pub fn path_rules(_app: &App, project: &Project) -> Result)>> { if !project.hosted() { return Ok(Vec::new()); } #[derive(Deserialize)] struct Rules { rules: Vec, } let rules: Rules = Remote::for_project(project).get_json("/rules")?; Ok(rules.rules.into_iter().map(|r| (r.path, r.who)).collect()) } pub fn set_path_rule(app: &App, s: &Session, path: &str, who: Vec) -> Out { let remote = Remote::for_project(&s.project); let before = path_rules(app, &s.project)?.into_iter().find(|(p, _)| p == path).map(|(_, w)| w).unwrap_or_default(); let _: serde_json::Value = remote.send_json("PUT", "/rules", &Rule { path: path.to_string(), who: who.clone() })?; app.db.add_op(&s.ws.id, "access", &json!({ "target": path, "who": who.join(" "), "rule_before": before }))?; let mut r = Report::new(); if who.len() == 1 && who[0] == "team" { r.line("access", format!("{path}: everyone with access to the project")); } else { r.line("access", format!("{path}: only {} (and the project's owners)", who.join(", "))); } r.set("path", path).set("who", who); Ok(r) } /// Uploads the user's own files. pub fn push_mine(app: &App, project: &Project) -> Result<()> { if !project.hosted() { return Ok(()); } let remote = Remote::for_project(project); let files: Vec = app.db.mine(&project.id)?.into_iter().map(|(path, file, exec)| MineFile { path, file, exec }).collect(); let mut ids = Vec::new(); for f in &files { ids.extend(app.store.file_parts(&f.file)?); } remote.upload(&app.store, &ids)?; let _: serde_json::Value = remote.send_json("PUT", "/mine", &json!({ "files": files }))?; Ok(()) } /// Brings the user's own files from the host into this machine's list. pub fn pull_mine(app: &App, project: &Project) -> Result<()> { if !project.hosted() { return Ok(()); } let remote = Remote::for_project(project); #[derive(Deserialize)] struct Files { files: Vec, } let files: Files = remote.get_json("/mine")?; if files.files.is_empty() { return Ok(()); } app.store.ingest(&remote.get("/mine/bundle")?)?; let local = app.db.mine(&project.id)?; for f in files.files { if !local.iter().any(|(p, _, _)| p == &f.path) { app.db.put_mine(&project.id, &f.path, &f.file, f.exec)?; } } Ok(()) } pub fn login(_app: &App, host: Option, token: Option) -> Out { let host = host.unwrap_or_else(|| "upstream.codes".into()); let host = host.trim_start_matches("https://").trim_start_matches("http://").trim_end_matches('/').to_string(); let api = discover(&host)?; let token = match token.or_else(|| std::env::var("VCS_TOKEN").ok().filter(|t| !t.is_empty())) { Some(t) => t, None => crate::signin::browser(&host, &api)?, }; let mut response = agent() .get(format!("{api}/me")) .header("authorization", format!("Bearer {token}")) .call() .map_err(|e| Fail::new("offline", format!("can't reach {host}: {e}")).state("nothing changed"))?; if response.status().as_u16() != 200 { return Err(Fail::new("not_logged_in", format!("{host} didn't accept that token")).state("nothing changed").next(format!("vcs login {host}"))); } let me: wire::Me = response.body_mut().read_json().map_err(|e| anyhow!("{e}"))?; let mut creds = load_credentials(); creds.hosts.insert(host.clone(), Credential { token, handle: me.handle.clone(), name: me.name.clone(), email: me.email.clone(), api }); save_credentials(&creds)?; let mut r = Report::new(); r.line("signed in", format!("to {host} as {} ({})", me.handle, me.email)).set("host", host).set("handle", me.handle); r.next("vcs get "); Ok(r) } /// `vcs publish`: puts a local project on a host, history and all. pub fn publish(app: &App, target: &str) -> Out { let mut s = app.session()?; if s.project.hosted() { return Err(Fail::new("bad_usage", format!("this project is already on {}", s.project.address)).state("nothing changed")); } app.save(&mut s)?; let (host, owner, name) = split_address(target)?; let api = discover(&host)?; let token = credential(&host).map(|c| c.token); if token.is_none() { return Err(Fail::new("not_logged_in", format!("sign in to {host} first")).state("nothing changed").next(format!("vcs login {host}"))); } let remote = Remote { api: api.clone(), host: host.clone(), key: format!("{owner}/{name}"), token }; if let Err(e) = remote.finish(remote.auth(agent().post(format!("{api}/projects"))).send_json(json!({ "owner": owner, "name": name }))) { let fail = Fail::from(e); if fail.code != "exists" { return Err(fail); } } let view = remote.view()?; if view.main.is_some() { return Err(Fail::new("bad_usage", format!("{target} already has versions; publish only to an empty project")).state("nothing changed")); } // Every version, oldest first, with its objects. let mut versions = app.db.versions(&s.project.id, i64::MAX)?; versions.reverse(); let mut prev: Option = None; for v in &versions { let ids = Remote::objects_since(&app.store, prev.as_ref(), &v.tree)?; remote.upload(&app.store, &ids)?; prev = Some(v.tree); } #[derive(Deserialize)] struct Imported { main: Option, } let imported: Imported = remote.send_json("POST", "/import", &json!({ "versions": versions }))?; let address = format!("{host}/{owner}/{name}"); let project = Project { id: s.project.id.clone(), address: address.clone(), kind: "hosted".into(), api: Some(api), checked: ids::now() }; app.db.put_project(&project)?; // Open changes and the user's own files follow. for c in app.db.changes(&project.id)? { if c.state == "open" && !c.is_empty() { let _ = remote.publish_change(&app.db, &app.store, &project, &c, &s.ws.id); } } let _ = push_mine(app, &project); let mut r = Report::new(); r.line("published", format!("{} versions to {address}", versions.len())); r.line("main", format!("{}", imported.main.unwrap_or(0))); r.set("address", address).set("versions", versions.len()); r.next("keep working; vcs land now lands on the host"); Ok(r) } pub fn import(app: &App, url: &str, to: Option, folder: Option) -> Out { crate::bridge::import(app, url, to, folder) } pub fn saver(app: &App) -> Out { crate::saver::run(app) }