Onset: version control where nobody waits and nothing gets lost. The on command, its core and onset-server.
| 6 |
| use anyhow::{Result, anyhow}; |
| 7 | use base64::Engine; |
| 8 | use serde::Deserialize; |
| 9 | use serde_json::json; |
| 10 | use vcs_core::history::{self, Found, Kind, Touched}; |
| 11 | use vcs_core::{Id, Source, chunk}; |
| 12 |
| 13 | use crate::app::App; |
| 14 | use crate::db::{Db, Project}; |
| 15 | use crate::ids; |
| 16 | use crate::out::{Out, Report}; |
| 17 | use crate::remote::Remote; |
| 18 |
| 19 | /// (version, kind, the path's ID after it), newest first. |
| 20 | pub type PathChanges = Vec<(u64, Kind, Option<Id>)>; |
| 21 |
| 22 | /// Runs of lines last changed by one version: (version, lines). |
| 23 | pub type Owners = Vec<(u64, u32)>; |
| 24 |
| 25 | /// A version that changed more than this many paths keeps `*` for them: |
| 26 | /// its trees are looked in instead. |
| 27 | const MOST_TOUCHED: usize = 1000; |
| 28 |
| 29 | /// Works out the paths each version changed, for versions that don't have |
| 30 | /// them yet (a project's older versions, imports): once, then kept. |
| 31 | pub fn fill_touched(src: &dyn Source, db: &Db, project: &str) -> Result<()> { |
| 32 | loop { |
| 33 | let rows = db.untouched(project, 1000)?; |
| 34 | if rows.is_empty() { |
| 35 | return Ok(()); |
| 36 | } |
| 37 | let pairs: Vec<(Option<Id>, Id)> = rows.iter().map(|(_, tree, before)| (*before, *tree)).collect(); |
| 38 | let touched = match history::touched(src, &pairs, MOST_TOUCHED)? { |
| 39 | Touched::All(t) => t, |
| 40 | Touched::Need(need) => return Err(anyhow!("object {} isn't here", need[0].short())), |
| 41 | }; |
| 42 | // A path with a line break in its name can't be told apart: look in the trees. |
| 43 | let kept: Vec<(u64, String)> = rows |
| 44 | .iter() |
| 45 | .zip(touched) |
| 46 | .map(|((n, ..), t)| (*n, t.filter(|t| !t.iter().any(|p| p.contains('\n'))).map(|t| t.join("\n")).unwrap_or_else(|| "*".into()))) |
| 47 | .collect(); |
| 48 | db.set_touched(project, &kept)?; |
| 49 | } |
| 50 | } |
| 51 |
| 52 | /// The versions of main that changed `path`, newest first, and whether |
| 53 | /// there are more than `limit`. Reads only the versions whose changed |
| 54 | /// paths say they may have, each with the version before it. |
| 55 | pub fn changes(src: &dyn Source, db: &Db, project: &str, path: &str, limit: usize) -> Result<(PathChanges, bool)> { |
| 56 | fill_touched(src, db, project)?; |
| 57 | let page = (limit + 1).max(64); |
| 58 | let mut out: PathChanges = Vec::new(); |
| 59 | let mut before = u64::MAX; |
| 60 | loop { |
| 61 | let found = db.may_touch(project, path, before, page as i64)?; |
| 62 | let Some(&(oldest, _)) = found.last() else { return Ok((out, false)) }; |
| 63 | let mut trees: std::collections::BTreeMap<u64, Id> = std::collections::BTreeMap::new(); |
| 64 | for (n, tree) in &found { |
| 65 | trees.insert(*n, *tree); |
| 66 | if let Some(t) = db.version_tree(project, n - 1)? { |
| 67 | trees.insert(n - 1, t); |
| 68 | } |
| 69 | } |
| 70 | let (ns, list): (Vec<u64>, Vec<Id>) = trees.into_iter().rev().unzip(); |
| 71 | let entries = match history::entries(src, &list, path)? { |
| 72 | Found::All(f) => f, |
| 73 | Found::Need(need) => return Err(anyhow!("object {} isn't here", need[0].short())), |
| 74 | }; |
| 75 | let asked: std::collections::HashSet<u64> = found.iter().map(|(n, _)| *n).collect(); |
| 76 | for (n, kind, after) in history::changes(&ns, &entries, ns.last() == Some(&1)) { |
| 77 | if asked.contains(&n) { |
| 78 | out.push((n, kind, after.map(|e| e.id))); |
| 79 | if out.len() > limit { |
| 80 | out.truncate(limit); |
| 81 | return Ok((out, true)); |
| 82 | } |
| 83 | } |
| 84 | } |
| 85 | if found.len() < page { |
| 86 | return Ok((out, false)); |
| 87 | } |
| 88 | before = oldest; |
| 89 | } |
| 90 | } |
| 91 |
| 92 | /// Which version last changed each line of `path` in the newest version, |
| 93 | /// as runs of (version, lines), and the file's content. |
| 94 | pub fn blame(src: &dyn Source, db: &Db, project: &str, path: &str) -> Result<Option<(Owners, Vec<u8>)>> { |
| 95 | let (changes, _) = changes(src, db, project, path, 500)?; |
| 96 | let Some((_, kind, Some(_))) = changes.first() else { return Ok(None) }; |
| 97 | if *kind == Kind::Removed { |
| 98 | return Ok(None); |
| 99 | } |
| 100 | let mut points = Vec::new(); |
| 101 | for (n, kind, id) in &changes { |
| 102 | let content = match id { |
| 103 | Some(id) if *kind != Kind::Removed => chunk::read_file(src, id)?, |
| 104 | _ => Vec::new(), |
| 105 | }; |
| 106 | points.push((*n, content)); |
| 107 | if *kind == Kind::Added { |
| 108 | break; |
| 109 | } |
| 110 | } |
| 111 | let refs: Vec<(u64, &[u8])> = points.iter().map(|(n, c)| (*n, c.as_slice())).collect(); |
| 112 | let mut runs: Vec<(u64, u32)> = Vec::new(); |
| 113 | for n in history::blame(&refs) { |
| 114 | match runs.last_mut() { |
| 115 | Some((last, count)) if *last == n => *count += 1, |
| 116 | _ => runs.push((n, 1)), |
| 117 | } |
| 118 | } |
| 119 | Ok(Some((runs, points.swap_remove(0).1))) |
| 120 | } |
| 121 |
| 122 | fn kind_name(k: Kind) -> &'static str { |
| 123 | match k { |
| 124 | Kind::Added => "added", |
| 125 | Kind::Modified => "modified", |
| 126 | Kind::Removed => "removed", |
| 127 | } |
| 128 | } |
| 129 |
| 130 | #[derive(Deserialize)] |
| 131 | struct HostHistory { |
| 132 | versions: Vec<HostVersion>, |
| 133 | more: bool, |
| 134 | } |
| 135 |
| 136 | #[derive(Deserialize)] |
| 137 | struct HostVersion { |
| 138 | n: u64, |
| 139 | title: String, |
| 140 | author: String, |
| 141 | time: i64, |
| 142 | kind: String, |
| 143 | } |
| 144 |
| 145 | /// `vcs log <path>`. |
| 146 | pub fn log_path(app: &App, project: &Project, path: &str, limit: usize) -> Out { |
| 147 | let path = path.trim_matches('/').to_string(); |
| 148 | let (rows, more): (Vec<HostVersion>, bool) = if project.hosted() { |
| 149 | let h: HostHistory = Remote::for_project(project).get_json(&format!("/history?path={}&limit={limit}", encode(&path)))?; |
| 150 | (h.versions, h.more) |
| 151 | } else { |
| 152 | let (changes, more) = changes(&app.store, &app.db, &project.id, &path, limit)?; |
| 153 | let mut rows = Vec::with_capacity(changes.len()); |
| 154 | for (n, k, _) in &changes { |
| 155 | let v = app.db.version(&project.id, *n)?.ok_or_else(|| anyhow!("version {n} isn't here"))?; |
| 156 | rows.push(HostVersion { n: *n, title: v.title, author: v.author, time: v.time, kind: kind_name(*k).into() }); |
| 157 | } |
| 158 | (rows, more) |
| 159 | }; |
| 160 | let mut r = Report::new(); |
| 161 | for v in &rows { |
| 162 | r.raw(format!("{:>6} {:<8} {} {} {}\n", v.n, v.kind, v.title, short_author(&v.author), ids::ago(v.time))); |
| 163 | } |
| 164 | if rows.is_empty() { |
| 165 | r.line("history", format!("no version of main changed {path}")); |
| 166 | } else if more { |
| 167 | r.next(format!("vcs log {path} -n {} for more", limit * 4)); |
| 168 | } |
| 169 | r.set("path", path.clone()).set("more", more).set( |
| 170 | "versions", |
| 171 | rows.iter().map(|v| json!({ "n": v.n, "kind": v.kind, "title": v.title, "author": v.author, "time": v.time })).collect::<Vec<_>>(), |
| 172 | ); |
| 173 | Ok(r) |
| 174 | } |
| 175 |
| 176 | #[derive(Deserialize)] |
| 177 | struct HostBlame { |
| 178 | owners: Owners, |
| 179 | versions: std::collections::HashMap<String, HostBlameVersion>, |
| 180 | base64: String, |
| 181 | } |
| 182 |
| 183 | #[derive(Deserialize)] |
| 184 | struct HostBlameVersion { |
| 185 | title: String, |
| 186 | author: String, |
| 187 | time: i64, |
| 188 | } |
| 189 |
| 190 | /// `vcs blame <file>`: each line of the file in main's latest version, with |
| 191 | /// the version that last changed it. |
| 192 | pub fn blame_cmd(app: &App, path: String) -> Out { |
| 193 | let s = app.session()?; |
| 194 | let path = workspace_path(app, &s.root, &path); |
| 195 | let (owners, info, content): (Owners, std::collections::HashMap<String, HostBlameVersion>, Vec<u8>) = if s.project.hosted() { |
| 196 | let b: HostBlame = Remote::for_project(&s.project).get_json(&format!("/blame?path={}", encode(&path)))?; |
| 197 | (b.owners, b.versions, base64::engine::general_purpose::STANDARD.decode(b.base64).map_err(|e| anyhow!("the host sent a bad file: {e}"))?) |
| 198 | } else { |
| 199 | let Some((owners, content)) = blame(&app.store, &app.db, &s.project.id, &path)? else { |
| 200 | return Err(crate::out::Fail::new("not_found", format!("there's no file {path} in main")).state("nothing changed")); |
| 201 | }; |
| 202 | let mut info = std::collections::HashMap::new(); |
| 203 | for (n, _) in &owners { |
| 204 | if let Some(v) = app.db.version(&s.project.id, *n)? { |
| 205 | info.insert(n.to_string(), HostBlameVersion { title: v.title, author: v.author, time: v.time }); |
| 206 | } |
| 207 | } |
| 208 | (owners, info, content) |
| 209 | }; |
| 210 | let lines = vcs_core::diff::lines(&content); |
| 211 | let mut r = Report::new(); |
| 212 | let mut at = 0usize; |
| 213 | let mut out = Vec::new(); |
| 214 | for (n, count) in &owners { |
| 215 | let who = info.get(&n.to_string()).map(|v| short_author(&v.author)).unwrap_or_default(); |
| 216 | for _ in 0..*count { |
| 217 | let text = lines.get(at).map(|l| String::from_utf8_lossy(l).trim_end_matches(['\n', '\r']).to_string()).unwrap_or_default(); |
| 218 | r.raw(format!("{n:>6} {who:<12} {:>5} {text}\n", at + 1)); |
| 219 | out.push(json!({ "line": at + 1, "version": n })); |
| 220 | at += 1; |
| 221 | } |
| 222 | } |
| 223 | r.set("path", path).set("lines", out).set( |
| 224 | "versions", |
| 225 | info.iter().map(|(n, v)| (n.clone(), json!({ "title": v.title, "author": v.author, "time": v.time }))).collect::<serde_json::Map<_, _>>(), |
| 226 | ); |
| 227 | Ok(r) |
| 228 | } |
| 229 |
| 230 | fn short_author(author: &str) -> String { |
| 231 | author.split(" <").next().unwrap_or(author).chars().take(12).collect() |
| 232 | } |
| 233 |
| 234 | /// A path for a URL's query. |
| 235 | pub fn encode(path: &str) -> String { |
| 236 | path.bytes() |
| 237 | .map(|b| if b.is_ascii_alphanumeric() || b"-_./~".contains(&b) { (b as char).to_string() } else { format!("%{b:02X}") }) |
| 238 | .collect() |
| 239 | } |
| 240 |
| 241 | /// Whether a `vcs log` argument names a path rather than a change: it has |
| 242 | /// a slash or a dot, can't be a change ID, or is a file or folder here. |
| 243 | pub fn is_path(here: &std::path::Path, text: &str) -> bool { |
| 244 | text.contains('/') || text.contains('.') || !ids::looks_like_change(text) || here.join(text).exists() |
| 245 | } |
| 246 |
| 247 | /// A path as typed (from the current folder) as the workspace names it. |
| 248 | pub fn workspace_path(app: &App, root: &std::path::Path, text: &str) -> String { |
| 249 | let abs = app.cwd.join(text); |
| 250 | abs.strip_prefix(root).ok().and_then(crate::sys::tree_path).unwrap_or_else(|| text.replace('\\', "/")).trim_matches('/').to_string() |
| 251 | } |
| 252 |