//! A path's history: `vcs log ` (the versions that changed it) and //! `vcs blame ` (the version that last changed each line). A host //! works them out where the versions are; a project on this machine (and //! vcs-server) uses the same core here. use anyhow::{Result, anyhow}; use base64::Engine; use serde::Deserialize; use serde_json::json; use vcs_core::history::{self, Found, Kind, Touched}; use vcs_core::{Id, Source, chunk}; use crate::app::App; use crate::db::{Db, Project}; use crate::ids; use crate::out::{Out, Report}; use crate::remote::Remote; /// (version, kind, the path's ID after it), newest first. pub type PathChanges = Vec<(u64, Kind, Option)>; /// Runs of lines last changed by one version: (version, lines). pub type Owners = Vec<(u64, u32)>; /// A version that changed more than this many paths keeps `*` for them: /// its trees are looked in instead. const MOST_TOUCHED: usize = 1000; /// Works out the paths each version changed, for versions that don't have /// them yet (a project's older versions, imports): once, then kept. pub fn fill_touched(src: &dyn Source, db: &Db, project: &str) -> Result<()> { loop { let rows = db.untouched(project, 1000)?; if rows.is_empty() { return Ok(()); } let pairs: Vec<(Option, Id)> = rows.iter().map(|(_, tree, before)| (*before, *tree)).collect(); let touched = match history::touched(src, &pairs, MOST_TOUCHED)? { Touched::All(t) => t, Touched::Need(need) => return Err(anyhow!("object {} isn't here", need[0].short())), }; // A path with a line break in its name can't be told apart: look in the trees. let kept: Vec<(u64, String)> = rows .iter() .zip(touched) .map(|((n, ..), t)| (*n, t.filter(|t| !t.iter().any(|p| p.contains('\n'))).map(|t| t.join("\n")).unwrap_or_else(|| "*".into()))) .collect(); db.set_touched(project, &kept)?; } } /// The versions of main that changed `path`, newest first, and whether /// there are more than `limit`. Reads only the versions whose changed /// paths say they may have, each with the version before it. pub fn changes(src: &dyn Source, db: &Db, project: &str, path: &str, limit: usize) -> Result<(PathChanges, bool)> { changes_below(src, db, project, path, limit, u64::MAX) } /// Changes in versions before `below`. fn changes_below(src: &dyn Source, db: &Db, project: &str, path: &str, limit: usize, below: u64) -> Result<(PathChanges, bool)> { fill_touched(src, db, project)?; let page = (limit + 1).max(64); let mut out: PathChanges = Vec::new(); let mut before = below; loop { let found = db.may_touch(project, path, before, page as i64)?; let Some(&(oldest, _)) = found.last() else { return Ok((out, false)) }; let mut trees: std::collections::BTreeMap = std::collections::BTreeMap::new(); for (n, tree) in &found { trees.insert(*n, *tree); if let Some(t) = db.version_tree(project, n - 1)? { trees.insert(n - 1, t); } } let (ns, list): (Vec, Vec) = trees.into_iter().rev().unzip(); let entries = match history::entries(src, &list, path)? { Found::All(f) => f, Found::Need(need) => return Err(anyhow!("object {} isn't here", need[0].short())), }; let asked: std::collections::HashSet = found.iter().map(|(n, _)| *n).collect(); for (n, kind, after) in history::changes(&ns, &entries, ns.last() == Some(&1)) { if asked.contains(&n) { out.push((n, kind, after.map(|e| e.id))); if out.len() > limit { out.truncate(limit); return Ok((out, true)); } } } if found.len() < page { return Ok((out, false)); } before = oldest; } } /// A path's history across renames, newest first: where it was added by a /// move, it goes on at the path it moved from. Each change has the path the /// file had then, and for a move the path it came from. pub type Followed = Vec<(u64, Kind, Option, String, Option)>; pub fn following(src: &dyn Source, db: &Db, project: &str, path: &str, limit: usize) -> Result<(Followed, bool)> { let mut out: Followed = Vec::new(); let mut current = path.to_string(); let mut below = u64::MAX; for _ in 0..50 { let (found, more) = changes_below(src, db, project, ¤t, limit - out.len(), below)?; out.extend(found.into_iter().map(|(n, k, id)| (n, k, id, current.clone(), None))); if more || out.len() >= limit { return Ok((out, more)); } let Some(last) = out.last_mut() else { break }; if last.1 != Kind::Added || last.0 <= 1 { break; } let (Some(old), Some(new)) = (db.version_tree(project, last.0 - 1)?, db.version_tree(project, last.0)?) else { break }; let Some(from) = vcs_core::rename::renamed_from(src, &old, &new, ¤t)? else { break }; last.4 = Some(from.clone()); below = last.0; current = from; } Ok((out, false)) } /// Which version last changed each line of `path` in the newest version, /// as runs of (version, lines), and the file's content. pub fn blame(src: &dyn Source, db: &Db, project: &str, path: &str) -> Result)>> { let (changes, _) = following(src, db, project, path, 500)?; let Some((_, kind, Some(_), _, _)) = changes.first() else { return Ok(None) }; if *kind == Kind::Removed { return Ok(None); } let mut points = Vec::new(); for (n, kind, id, _, from) in &changes { let content = match id { Some(id) if *kind != Kind::Removed => chunk::read_file(src, id)?, _ => Vec::new(), }; points.push((*n, content)); // Added by a move: its lines go on in the file it moved from. if *kind == Kind::Added && from.is_none() { break; } } let refs: Vec<(u64, &[u8])> = points.iter().map(|(n, c)| (*n, c.as_slice())).collect(); let mut runs: Vec<(u64, u32)> = Vec::new(); for n in history::blame(&refs) { match runs.last_mut() { Some((last, count)) if *last == n => *count += 1, _ => runs.push((n, 1)), } } Ok(Some((runs, points.swap_remove(0).1))) } fn kind_name(k: Kind) -> &'static str { match k { Kind::Added => "added", Kind::Modified => "modified", Kind::Removed => "removed", } } #[derive(Deserialize)] struct HostHistory { versions: Vec, more: bool, } #[derive(Deserialize)] struct HostVersion { n: u64, title: String, author: String, time: i64, kind: String, /// For a move: the path the file came from. #[serde(default)] from: Option, } /// `vcs log `. pub fn log_path(app: &App, project: &Project, path: &str, limit: usize) -> Out { let path = path.trim_matches('/').to_string(); let (rows, more): (Vec, bool) = if project.hosted() { let h: HostHistory = Remote::for_project(project).get_json(&format!("/history?path={}&limit={limit}", encode(&path)))?; (h.versions, h.more) } else { let (changes, more) = following(&app.store, &app.db, &project.id, &path, limit)?; let mut rows = Vec::with_capacity(changes.len()); for (n, k, _, _, from) in &changes { let v = app.db.version(&project.id, *n)?.ok_or_else(|| anyhow!("version {n} isn't here"))?; let kind = if from.is_some() { "renamed".into() } else { kind_name(*k).into() }; rows.push(HostVersion { n: *n, title: v.title, author: v.author, time: v.time, kind, from: from.clone() }); } (rows, more) }; let mut r = Report::new(); for v in &rows { let moved = v.from.as_ref().map(|f| format!(" (from {f})")).unwrap_or_default(); r.raw(format!("{:>6} {:<8} {} {} {}{moved}\n", v.n, v.kind, v.title, short_author(&v.author), ids::ago(v.time))); } if rows.is_empty() { r.line("history", format!("no version of main changed {path}")); } else if more { r.next(format!("vcs log {path} -n {} for more", limit * 4)); } r.set("path", path.clone()).set("more", more).set( "versions", rows.iter().map(|v| json!({ "n": v.n, "kind": v.kind, "title": v.title, "author": v.author, "time": v.time, "from": v.from })).collect::>(), ); Ok(r) } #[derive(Deserialize)] struct HostBlame { owners: Owners, versions: std::collections::HashMap, base64: String, } #[derive(Deserialize)] struct HostBlameVersion { title: String, author: String, time: i64, } /// `vcs blame `: each line of the file in main's latest version, with /// the version that last changed it. pub fn blame_cmd(app: &App, path: String) -> Out { let s = app.session()?; let path = workspace_path(app, &s.root, &path); let (owners, info, content): (Owners, std::collections::HashMap, Vec) = if s.project.hosted() { let b: HostBlame = Remote::for_project(&s.project).get_json(&format!("/blame?path={}", encode(&path)))?; (b.owners, b.versions, base64::engine::general_purpose::STANDARD.decode(b.base64).map_err(|e| anyhow!("the host sent a bad file: {e}"))?) } else { let Some((owners, content)) = blame(&app.store, &app.db, &s.project.id, &path)? else { return Err(crate::out::Fail::new("not_found", format!("there's no file {path} in main")).state("nothing changed")); }; let mut info = std::collections::HashMap::new(); for (n, _) in &owners { if let Some(v) = app.db.version(&s.project.id, *n)? { info.insert(n.to_string(), HostBlameVersion { title: v.title, author: v.author, time: v.time }); } } (owners, info, content) }; let lines = vcs_core::diff::lines(&content); let mut r = Report::new(); let mut at = 0usize; let mut out = Vec::new(); for (n, count) in &owners { let who = info.get(&n.to_string()).map(|v| short_author(&v.author)).unwrap_or_default(); for _ in 0..*count { let text = lines.get(at).map(|l| String::from_utf8_lossy(l).trim_end_matches(['\n', '\r']).to_string()).unwrap_or_default(); r.raw(format!("{n:>6} {who:<12} {:>5} {text}\n", at + 1)); out.push(json!({ "line": at + 1, "version": n })); at += 1; } } r.set("path", path).set("lines", out).set( "versions", info.iter().map(|(n, v)| (n.clone(), json!({ "title": v.title, "author": v.author, "time": v.time }))).collect::>(), ); Ok(r) } fn short_author(author: &str) -> String { author.split(" <").next().unwrap_or(author).chars().take(12).collect() } /// A path for a URL's query. pub fn encode(path: &str) -> String { path.bytes() .map(|b| if b.is_ascii_alphanumeric() || b"-_./~".contains(&b) { (b as char).to_string() } else { format!("%{b:02X}") }) .collect() } /// Whether a `vcs log` argument names a path rather than a change: it has /// a slash or a dot, can't be a change ID, or is a file or folder here. pub fn is_path(here: &std::path::Path, text: &str) -> bool { text.contains('/') || text.contains('.') || !ids::looks_like_change(text) || here.join(text).exists() } /// A path as typed (from the current folder) as the workspace names it. pub fn workspace_path(app: &App, root: &std::path::Path, text: &str) -> String { let abs = app.cwd.join(text); abs.strip_prefix(root).ok().and_then(crate::sys::tree_path).unwrap_or_else(|| text.replace('\\', "/")).trim_matches('/').to_string() }