//! `vcs purge ...`: takes paths out of the project's whole history, //! for a secret or a big file that should never have landed. Every version //! of main and of each release line, every change and save, loses them, and //! their content is deleted. Versions keep their numbers; their IDs change. //! Without --yes it only says what it would do. On a hosted project the host //! does it (owners only) and writes the git view's main again (a force //! push); other machines take the paths out when they next sync. The files //! go from this machine's workspaces of the project too. use std::collections::{BTreeMap, BTreeSet}; use serde::{Deserialize, Serialize}; use serde_json::json; use vcs_core::{Id, purge, tree}; use crate::app::App; use crate::db::Project; use crate::out::{Fail, Out, Report}; use crate::remote::Remote; use crate::ws; /// What a purge did (or, with `done` false, would do). #[derive(Debug, Default, Serialize, Deserialize)] pub struct Purged { pub paths: Vec, /// Versions of main that had them, and the first. pub versions: u64, pub first: Option, #[serde(default)] pub lines: u64, pub changes: u64, /// Objects that were at them, and their bytes. pub objects: u64, pub bytes: u64, pub done: bool, } #[derive(Deserialize)] struct HostPurge { n: u64, paths: Vec, } pub fn run(app: &App, paths: Vec, yes: bool) -> Out { let s = app.session()?; let paths: Vec = paths.iter().map(|p| p.trim_matches('/').to_string()).collect::>().into_iter().collect(); if paths.is_empty() { return Err(Fail::new("bad_usage", "name the files or folders to take out of history").state("nothing changed")); } if let Some(bad) = paths.iter().find(|p| !tree::valid_path(p)) { return Err(Fail::new("bad_usage", format!("{bad:?} isn't a path in the project")).state("nothing changed")); } let p = if s.project.hosted() { let p: Purged = Remote::for_project(&s.project).send_json("POST", "/purge", &json!({ "paths": paths, "dry_run": !yes }))?; if p.done { catch_up(app, &s.project)?; } p } else { apply(app, &s.project, &paths, !yes, true)? }; let list = paths.join(", "); let mut r = Report::new(); r.set("purge", serde_json::to_value(&p).unwrap_or_default()); if p.versions == 0 && p.lines == 0 && p.changes == 0 && p.objects == 0 { r.line("purge", format!("nothing in this project's history has {list}")); return Ok(r); } let what = format!( "{} {} of main{}{}, {} {}", p.versions, if p.versions == 1 { "version" } else { "versions" }, p.first.map(|f| format!(" (from main {f})")).unwrap_or_default(), if p.lines > 0 { format!(", {} release line versions", p.lines) } else { String::new() }, p.changes, if p.changes == 1 { "change" } else { "changes" }, ); let size = format!("{} {} ({})", p.objects, if p.objects == 1 { "object" } else { "objects" }, crate::gc::size(p.bytes)); if !p.done { r.line("would", format!("take {list} out of {what}")); r.line("would", format!("delete {size}, and the files from this machine's workspaces")); if s.project.hosted() { r.line("git", "the git view's main is written again from the first of those versions (a force push)"); } r.line("note", "it can't be undone: copy anything you still need first"); r.next(format!("vcs purge {} --yes", paths.join(" "))); return Ok(r); } r.line("purged", format!("{list} from {what}")); r.line("deleted", size); if s.project.hosted() { r.line("git", "the git view's main is being written again without them"); r.line("others", "other machines take them out when they next sync"); } r.next("put real secrets in .env (it stays yours) and change any that leaked"); Ok(r) } /// Takes in the host's purges this machine hasn't: each one's paths out of /// what's here, then main's history is read again. pub fn catch_up(app: &App, project: &Project) -> anyhow::Result<()> { #[derive(Deserialize)] struct List { purges: Vec, } let (seen, _) = app.db.purges(&project.id)?; let list: List = Remote::for_project(project).get_json("/purges")?; let paths: Vec = list.purges.iter().filter(|p| p.n > seen).flat_map(|p| p.paths.clone()).collect(); let newest = list.purges.iter().map(|p| p.n).max().unwrap_or(seen); if !paths.is_empty() { apply(app, project, &paths, false, false)?; } // Main's versions have new trees: read again from the host. app.db.drop_versions_after(&project.id, 0)?; app.db.set_history_from(&project.id, 1)?; app.db.set_purges(&project.id, Some(newest), Some(newest))?; Ok(()) } /// Takes `paths` out of what this machine keeps of `project`: main and its /// lines (`history`, for a project that lives here), changes, saves, undo /// steps and workspaces; then deletes what was at them unless something /// kept still has it. pub fn apply(app: &App, project: &Project, paths: &[String], dry_run: bool, history: bool) -> anyhow::Result { let db = &app.db; let mut versions = if history { db.versions(&project.id, i64::MAX)? } else { Vec::new() }; versions.reverse(); let mut line_rows: Vec<(String, u64, Id)> = Vec::new(); if history { for l in db.lines(&project.id)? { for v in db.line_versions(&project.id, &l.name, i64::MAX)? { line_rows.push((v.line, v.n, v.tree)); } } } let changes = db.changes(&project.id)?; let mut saves: Vec<(String, i64, Id, Id)> = Vec::new(); for c in &changes { let mut stmt = db.conn.prepare("SELECT n, tree, base_tree FROM saves WHERE change = ?1")?; for row in stmt.query_map([&c.id], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?, r.get::<_, String>(2)?)))? { let (n, t, b) = row?; if let (Some(t), Some(b)) = (Id::parse(&t), Id::parse(&b)) { saves.push((c.id.clone(), n, t, b)); } } } let workspaces = db.project_workspaces(&project.id)?; let mut trees: BTreeSet = BTreeSet::new(); trees.extend(versions.iter().map(|v| v.tree)); trees.extend(line_rows.iter().map(|r| r.2)); for c in &changes { trees.insert(c.base_tree); trees.extend(c.head_tree); } for (_, _, t, b) in &saves { trees.insert(*t); trees.insert(*b); } trees.extend(workspaces.iter().filter_map(|w| ws::Index::load(&w.path).tree)); // Each without the paths; one this machine doesn't have all of stays as it is. let mut map: BTreeMap = BTreeMap::new(); let mut gone = BTreeSet::new(); for t in &trees { match purge::without(&app.store, t, paths, &mut gone) { Ok((after, objects)) if after != *t => { if !dry_run { for o in &objects { app.store.put(o)?; } } map.insert(*t, after); } Ok(_) => {} Err(vcs_core::Error::Missing(_)) => {} Err(e) => anyhow::bail!("couldn't read the project's history: {e}"), } } // A big file's parts go with it. for id in gone.clone() { for part in app.store.file_parts(&id).unwrap_or_default() { gone.insert(part); } } let first = versions.iter().find(|v| map.contains_key(&v.tree)).map(|v| v.n); let mut purged = Purged { paths: paths.to_vec(), versions: versions.iter().filter(|v| map.contains_key(&v.tree)).count() as u64, first, lines: line_rows.iter().filter(|r| map.contains_key(&r.2)).count() as u64, changes: changes.iter().filter(|c| map.contains_key(&c.base_tree) || c.head_tree.is_some_and(|h| map.contains_key(&h))).count() as u64, objects: gone.iter().filter(|id| app.store.has(id)).count() as u64, bytes: gone.iter().map(|id| app.store.stored_len(id)).sum(), done: false, }; if dry_run { return Ok(purged); } let swap = |id: &Id| map.get(id).copied().unwrap_or(*id); let tx = db.conn.unchecked_transaction()?; // Main from the first version that had them: new trees, so new IDs, each on the one before. if let Some(first) = first { let mut prev = versions.iter().find(|v| v.n + 1 == first).map(|v| v.id(&project.id)); for v in versions.iter().filter(|v| v.n >= first) { let mut v = v.clone(); v.tree = swap(&v.tree); v.prev = prev; v.git = None; prev = Some(v.id(&project.id)); db.put_version(&project.id, &v)?; } } for (line, n, t) in &line_rows { if map.contains_key(t) { db.conn.execute("UPDATE line_versions SET tree = ?4 WHERE project = ?1 AND line = ?2 AND n = ?3", rusqlite::params![project.id, line, *n as i64, swap(t).hex()])?; } } for c in &changes { if map.contains_key(&c.base_tree) || c.head_tree.is_some_and(|h| map.contains_key(&h)) { let mut c = c.clone(); c.base_tree = swap(&c.base_tree); c.head_tree = c.head_tree.map(|h| swap(&h)); db.put_change(&c)?; } } for (change, n, t, b) in &saves { if map.contains_key(t) || map.contains_key(b) { db.conn.execute("UPDATE saves SET tree = ?3, base_tree = ?4 WHERE change = ?1 AND n = ?2", rusqlite::params![change, n, swap(t).hex(), swap(b).hex()])?; } } // Undo steps name trees they'd go back to. let text_swap = |text: &str| -> String { let mut out = text.to_string(); for (a, b) in &map { out = out.replace(&a.hex(), &b.hex()); } out }; for w in &workspaces { let steps: Vec<(i64, String)> = db.conn.prepare("SELECT id, data FROM ops WHERE workspace = ?1")?.query_map([&w.id], |r| Ok((r.get(0)?, r.get(1)?)))?.collect::>()?; for (id, data) in steps { let swapped = text_swap(&data); if swapped != data { db.conn.execute("UPDATE ops SET data = ?2 WHERE id = ?1", rusqlite::params![id, swapped])?; } } } db.conn.execute("DELETE FROM complete", [])?; tx.commit()?; // The files go from this machine's workspaces (a save would put them back). for w in &workspaces { for p in paths { let at = w.path.join(p); if at.is_dir() { let _ = std::fs::remove_dir_all(&at); } else if at.symlink_metadata().is_ok() { let _ = std::fs::remove_file(&at); } // Folders it leaves empty go too, as a version without it has none. let mut up = at.parent(); while let Some(dir) = up.filter(|d| d.starts_with(&w.path) && *d != w.path) { if std::fs::remove_dir(dir).is_err() { break; } up = dir.parent(); } } let mut index = ws::Index::load(&w.path); index.entries.retain(|path, _| !paths.iter().any(|p| tree::under(path, p))); index.tree = index.tree.map(|t| swap(&t)); index.save(&w.path)?; } // What was at them goes, unless something kept still has it. let live = crate::gc::live_objects(app, 0)?; purged.objects = 0; purged.bytes = 0; for id in gone.iter().filter(|id| !live.contains(id)) { let freed = app.store.remove(id); if freed > 0 { purged.objects += 1; purged.bytes += freed; } } purged.done = true; Ok(purged) }