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 | changes_below(src, db, project, path, limit, u64::MAX) |
| 57 | } |
| 58 |
| 59 | /// Changes in versions before `below`. |
| 60 | fn changes_below(src: &dyn Source, db: &Db, project: &str, path: &str, limit: usize, below: u64) -> Result<(PathChanges, bool)> { |
| 61 | fill_touched(src, db, project)?; |
| 62 | let page = (limit + 1).max(64); |
| 63 | let mut out: PathChanges = Vec::new(); |
| 64 | let mut before = below; |
| 65 | loop { |
| 66 | let found = db.may_touch(project, path, before, page as i64)?; |
| 67 | let Some(&(oldest, _)) = found.last() else { return Ok((out, false)) }; |
| 68 | let mut trees: std::collections::BTreeMap<u64, Id> = std::collections::BTreeMap::new(); |
| 69 | for (n, tree) in &found { |
| 70 | trees.insert(*n, *tree); |
| 71 | if let Some(t) = db.version_tree(project, n - 1)? { |
| 72 | trees.insert(n - 1, t); |
| 73 | } |
| 74 | } |
| 75 | let (ns, list): (Vec<u64>, Vec<Id>) = trees.into_iter().rev().unzip(); |
| 76 | let entries = match history::entries(src, &list, path)? { |
| 77 | Found::All(f) => f, |
| 78 | Found::Need(need) => return Err(anyhow!("object {} isn't here", need[0].short())), |
| 79 | }; |
| 80 | let asked: std::collections::HashSet<u64> = found.iter().map(|(n, _)| *n).collect(); |
| 81 | for (n, kind, after) in history::changes(&ns, &entries, ns.last() == Some(&1)) { |
| 82 | if asked.contains(&n) { |
| 83 | out.push((n, kind, after.map(|e| e.id))); |
| 84 | if out.len() > limit { |
| 85 | out.truncate(limit); |
| 86 | return Ok((out, true)); |
| 87 | } |
| 88 | } |
| 89 | } |
| 90 | if found.len() < page { |
| 91 | return Ok((out, false)); |
| 92 | } |
| 93 | before = oldest; |
| 94 | } |
| 95 | } |
| 96 |
| 97 | /// A path's history across renames, newest first: where it was added by a |
| 98 | /// move, it goes on at the path it moved from. Each change has the path the |
| 99 | /// file had then, and for a move the path it came from. |
| 100 | pub type Followed = Vec<(u64, Kind, Option<Id>, String, Option<String>)>; |
| 101 |
| 102 | pub fn following(src: &dyn Source, db: &Db, project: &str, path: &str, limit: usize) -> Result<(Followed, bool)> { |
| 103 | let mut out: Followed = Vec::new(); |
| 104 | let mut current = path.to_string(); |
| 105 | let mut below = u64::MAX; |
| 106 | for _ in 0..50 { |
| 107 | let (found, more) = changes_below(src, db, project, ¤t, limit - out.len(), below)?; |
| 108 | out.extend(found.into_iter().map(|(n, k, id)| (n, k, id, current.clone(), None))); |
| 109 | if more || out.len() >= limit { |
| 110 | return Ok((out, more)); |
| 111 | } |
| 112 | let Some(last) = out.last_mut() else { break }; |
| 113 | if last.1 != Kind::Added || last.0 <= 1 { |
| 114 | break; |
| 115 | } |
| 116 | let (Some(old), Some(new)) = (db.version_tree(project, last.0 - 1)?, db.version_tree(project, last.0)?) else { break }; |
| 117 | let Some(from) = vcs_core::rename::renamed_from(src, &old, &new, ¤t)? else { break }; |
| 118 | last.4 = Some(from.clone()); |
| 119 | below = last.0; |
| 120 | current = from; |
| 121 | } |
| 122 | Ok((out, false)) |
| 123 | } |
| 124 |
| 125 | /// Which version last changed each line of `path` in the newest version, |
| 126 | /// as runs of (version, lines), and the file's content. |
| 127 | pub fn blame(src: &dyn Source, db: &Db, project: &str, path: &str) -> Result<Option<(Owners, Vec<u8>)>> { |
| 128 | let (changes, _) = following(src, db, project, path, 500)?; |
| 129 | let Some((_, kind, Some(_), _, _)) = changes.first() else { return Ok(None) }; |
| 130 | if *kind == Kind::Removed { |
| 131 | return Ok(None); |
| 132 | } |
| 133 | let mut points = Vec::new(); |
| 134 | for (n, kind, id, _, from) in &changes { |
| 135 | let content = match id { |
| 136 | Some(id) if *kind != Kind::Removed => chunk::read_file(src, id)?, |
| 137 | _ => Vec::new(), |
| 138 | }; |
| 139 | points.push((*n, content)); |
| 140 | // Added by a move: its lines go on in the file it moved from. |
| 141 | if *kind == Kind::Added && from.is_none() { |
| 142 | break; |
| 143 | } |
| 144 | } |
| 145 | let refs: Vec<(u64, &[u8])> = points.iter().map(|(n, c)| (*n, c.as_slice())).collect(); |
| 146 | let mut runs: Vec<(u64, u32)> = Vec::new(); |
| 147 | for n in history::blame(&refs) { |
| 148 | match runs.last_mut() { |
| 149 | Some((last, count)) if *last == n => *count += 1, |
| 150 | _ => runs.push((n, 1)), |
| 151 | } |
| 152 | } |
| 153 | Ok(Some((runs, points.swap_remove(0).1))) |
| 154 | } |
| 155 |
| 156 | fn kind_name(k: Kind) -> &'static str { |
| 157 | match k { |
| 158 | Kind::Added => "added", |
| 159 | Kind::Modified => "modified", |
| 160 | Kind::Removed => "removed", |
| 161 | } |
| 162 | } |
| 163 |
| 164 | #[derive(Deserialize)] |
| 165 | struct HostHistory { |
| 166 | versions: Vec<HostVersion>, |
| 167 | more: bool, |
| 168 | } |
| 169 |
| 170 | #[derive(Deserialize)] |
| 171 | struct HostVersion { |
| 172 | n: u64, |
| 173 | title: String, |
| 174 | author: String, |
| 175 | time: i64, |
| 176 | kind: String, |
| 177 | /// For a move: the path the file came from. |
| 178 | #[serde(default)] |
| 179 | from: Option<String>, |
| 180 | } |
| 181 |
| 182 | /// `vcs log <path>`. |
| 183 | pub fn log_path(app: &App, project: &Project, path: &str, limit: usize) -> Out { |
| 184 | let path = path.trim_matches('/').to_string(); |
| 185 | let (rows, more): (Vec<HostVersion>, bool) = if project.hosted() { |
| 186 | let h: HostHistory = Remote::for_project(project).get_json(&format!("/history?path={}&limit={limit}", encode(&path)))?; |
| 187 | (h.versions, h.more) |
| 188 | } else { |
| 189 | let (changes, more) = following(&app.store, &app.db, &project.id, &path, limit)?; |
| 190 | let mut rows = Vec::with_capacity(changes.len()); |
| 191 | for (n, k, _, _, from) in &changes { |
| 192 | let v = app.db.version(&project.id, *n)?.ok_or_else(|| anyhow!("version {n} isn't here"))?; |
| 193 | let kind = if from.is_some() { "renamed".into() } else { kind_name(*k).into() }; |
| 194 | rows.push(HostVersion { n: *n, title: v.title, author: v.author, time: v.time, kind, from: from.clone() }); |
| 195 | } |
| 196 | (rows, more) |
| 197 | }; |
| 198 | let mut r = Report::new(); |
| 199 | for v in &rows { |
| 200 | let moved = v.from.as_ref().map(|f| format!(" (from {f})")).unwrap_or_default(); |
| 201 | r.raw(format!("{:>6} {:<8} {} {} {}{moved}\n", v.n, v.kind, v.title, short_author(&v.author), ids::ago(v.time))); |
| 202 | } |
| 203 | if rows.is_empty() { |
| 204 | r.line("history", format!("no version of main changed {path}")); |
| 205 | } else if more { |
| 206 | r.next(format!("vcs log {path} -n {} for more", limit * 4)); |
| 207 | } |
| 208 | r.set("path", path.clone()).set("more", more).set( |
| 209 | "versions", |
| 210 | rows.iter().map(|v| json!({ "n": v.n, "kind": v.kind, "title": v.title, "author": v.author, "time": v.time, "from": v.from })).collect::<Vec<_>>(), |
| 211 | ); |
| 212 | Ok(r) |
| 213 | } |
| 214 |
| 215 | #[derive(Deserialize)] |
| 216 | struct HostBlame { |
| 217 | owners: Owners, |
| 218 | versions: std::collections::HashMap<String, HostBlameVersion>, |
| 219 | base64: String, |
| 220 | } |
| 221 |
| 222 | #[derive(Deserialize)] |
| 223 | struct HostBlameVersion { |
| 224 | title: String, |
| 225 | author: String, |
| 226 | time: i64, |
| 227 | } |
| 228 |
| 229 | /// `vcs blame <file>`: each line of the file in main's latest version, with |
| 230 | /// the version that last changed it. |
| 231 | pub fn blame_cmd(app: &App, path: String) -> Out { |
| 232 | let s = app.session()?; |
| 233 | let path = workspace_path(app, &s.root, &path); |
| 234 | let (owners, info, content): (Owners, std::collections::HashMap<String, HostBlameVersion>, Vec<u8>) = if s.project.hosted() { |
| 235 | let b: HostBlame = Remote::for_project(&s.project).get_json(&format!("/blame?path={}", encode(&path)))?; |
| 236 | (b.owners, b.versions, base64::engine::general_purpose::STANDARD.decode(b.base64).map_err(|e| anyhow!("the host sent a bad file: {e}"))?) |
| 237 | } else { |
| 238 | let Some((owners, content)) = blame(&app.store, &app.db, &s.project.id, &path)? else { |
| 239 | return Err(crate::out::Fail::new("not_found", format!("there's no file {path} in main")).state("nothing changed")); |
| 240 | }; |
| 241 | let mut info = std::collections::HashMap::new(); |
| 242 | for (n, _) in &owners { |
| 243 | if let Some(v) = app.db.version(&s.project.id, *n)? { |
| 244 | info.insert(n.to_string(), HostBlameVersion { title: v.title, author: v.author, time: v.time }); |
| 245 | } |
| 246 | } |
| 247 | (owners, info, content) |
| 248 | }; |
| 249 | let lines = vcs_core::diff::lines(&content); |
| 250 | let mut r = Report::new(); |
| 251 | let mut at = 0usize; |
| 252 | let mut out = Vec::new(); |
| 253 | for (n, count) in &owners { |
| 254 | let who = info.get(&n.to_string()).map(|v| short_author(&v.author)).unwrap_or_default(); |
| 255 | for _ in 0..*count { |
| 256 | let text = lines.get(at).map(|l| String::from_utf8_lossy(l).trim_end_matches(['\n', '\r']).to_string()).unwrap_or_default(); |
| 257 | r.raw(format!("{n:>6} {who:<12} {:>5} {text}\n", at + 1)); |
| 258 | out.push(json!({ "line": at + 1, "version": n })); |
| 259 | at += 1; |
| 260 | } |
| 261 | } |
| 262 | r.set("path", path).set("lines", out).set( |
| 263 | "versions", |
| 264 | info.iter().map(|(n, v)| (n.clone(), json!({ "title": v.title, "author": v.author, "time": v.time }))).collect::<serde_json::Map<_, _>>(), |
| 265 | ); |
| 266 | Ok(r) |
| 267 | } |
| 268 |
| 269 | fn short_author(author: &str) -> String { |
| 270 | author.split(" <").next().unwrap_or(author).chars().take(12).collect() |
| 271 | } |
| 272 |
| 273 | /// A path for a URL's query. |
| 274 | pub fn encode(path: &str) -> String { |
| 275 | path.bytes() |
| 276 | .map(|b| if b.is_ascii_alphanumeric() || b"-_./~".contains(&b) { (b as char).to_string() } else { format!("%{b:02X}") }) |
| 277 | .collect() |
| 278 | } |
| 279 |
| 280 | /// Whether a `vcs log` argument names a path rather than a change: it has |
| 281 | /// a slash or a dot, can't be a change ID, or is a file or folder here. |
| 282 | pub fn is_path(here: &std::path::Path, text: &str) -> bool { |
| 283 | text.contains('/') || text.contains('.') || !ids::looks_like_change(text) || here.join(text).exists() |
| 284 | } |
| 285 |
| 286 | /// A path as typed (from the current folder) as the workspace names it. |
| 287 | pub fn workspace_path(app: &App, root: &std::path::Path, text: &str) -> String { |
| 288 | let abs = app.cwd.join(text); |
| 289 | abs.strip_prefix(root).ok().and_then(crate::sys::tree_path).unwrap_or_else(|| text.replace('\\', "/")).trim_matches('/').to_string() |
| 290 | } |
| 291 |