Onset: version control where nobody waits and nothing gets lost. The on command, its core and onset-server.
| 1 | //! `vcs purge <path>...`: takes paths out of the project's whole history, |
| 2 | //! for a secret or a big file that should never have landed. Every version |
| 3 | //! of main and of each release line, every change and save, loses them, and |
| 4 | //! their content is deleted. Versions keep their numbers; their IDs change. |
| 5 |
| 6 | //! does it (owners only) and writes the git view's main again (a force |
| 7 | //! push); other machines take the paths out when they next sync. The files |
| 8 | //! go from this machine's workspaces of the project too. |
| 9 |
| 10 | use std::collections::{BTreeMap, BTreeSet}; |
| 11 |
| 12 | use serde::{Deserialize, Serialize}; |
| 13 | use serde_json::json; |
| 14 | use vcs_core::{Id, purge, tree}; |
| 15 |
| 16 | use crate::app::App; |
| 17 | use crate::db::Project; |
| 18 | use crate::out::{Fail, Out, Report}; |
| 19 | use crate::remote::Remote; |
| 20 | use crate::ws; |
| 21 |
| 22 | /// What a purge did (or, with `done` false, would do). |
| 23 | #[derive(Debug, Default, Serialize, Deserialize)] |
| 24 | pub struct Purged { |
| 25 | pub paths: Vec<String>, |
| 26 | /// Versions of main that had them, and the first. |
| 27 | pub versions: u64, |
| 28 | pub first: Option<u64>, |
| 29 | #[serde(default)] |
| 30 | pub lines: u64, |
| 31 | pub changes: u64, |
| 32 | /// Objects that were at them, and their bytes. |
| 33 | pub objects: u64, |
| 34 | pub bytes: u64, |
| 35 | pub done: bool, |
| 36 | } |
| 37 |
| 38 | #[derive(Deserialize)] |
| 39 | struct HostPurge { |
| 40 | n: u64, |
| 41 | paths: Vec<String>, |
| 42 | } |
| 43 |
| 44 | pub fn run(app: &App, paths: Vec<String>, yes: bool) -> Out { |
| 45 | let s = app.session()?; |
| 46 | let paths: Vec<String> = paths.iter().map(|p| p.trim_matches('/').to_string()).collect::<BTreeSet<_>>().into_iter().collect(); |
| 47 | if paths.is_empty() { |
| 48 | return Err(Fail::new("bad_usage", "name the files or folders to take out of history").state("nothing changed")); |
| 49 | } |
| 50 | if let Some(bad) = paths.iter().find(|p| !tree::valid_path(p)) { |
| 51 | return Err(Fail::new("bad_usage", format!("{bad:?} isn't a path in the project")).state("nothing changed")); |
| 52 | } |
| 53 | let p = if s.project.hosted() { |
| 54 | let p: Purged = Remote::for_project(&s.project).send_json("POST", "/purge", &json!({ "paths": paths, "dry_run": !yes }))?; |
| 55 | if p.done { |
| 56 | catch_up(app, &s.project)?; |
| 57 | } |
| 58 | p |
| 59 | } else { |
| 60 | apply(app, &s.project, &paths, !yes, true)? |
| 61 | }; |
| 62 | let list = paths.join(", "); |
| 63 | let mut r = Report::new(); |
| 64 | r.set("purge", serde_json::to_value(&p).unwrap_or_default()); |
| 65 | if p.versions == 0 && p.lines == 0 && p.changes == 0 && p.objects == 0 { |
| 66 | r.line("purge", format!("nothing in this project's history has {list}")); |
| 67 | return Ok(r); |
| 68 | } |
| 69 | let what = format!( |
| 70 | "{} {} of main{}{}, {} {}", |
| 71 | p.versions, |
| 72 | if p.versions == 1 { "version" } else { "versions" }, |
| 73 | p.first.map(|f| format!(" (from main {f})")).unwrap_or_default(), |
| 74 | if p.lines > 0 { format!(", {} release line versions", p.lines) } else { String::new() }, |
| 75 | p.changes, |
| 76 | if p.changes == 1 { "change" } else { "changes" }, |
| 77 | ); |
| 78 | let size = format!("{} {} ({})", p.objects, if p.objects == 1 { "object" } else { "objects" }, crate::gc::size(p.bytes)); |
| 79 | if !p.done { |
| 80 | r.line("would", format!("take {list} out of {what}")); |
| 81 | r.line("would", format!("delete {size}, and the files from this machine's workspaces")); |
| 82 | if s.project.hosted() { |
| 83 | r.line("git", "the git view's main is written again from the first of those versions (a force push)"); |
| 84 | } |
| 85 | r.line("note", "it can't be undone: copy anything you still need first"); |
| 86 | r.next(format!("vcs purge {} --yes", paths.join(" "))); |
| 87 | return Ok(r); |
| 88 | } |
| 89 | r.line("purged", format!("{list} from {what}")); |
| 90 | r.line("deleted", size); |
| 91 | if s.project.hosted() { |
| 92 | r.line("git", "the git view's main is being written again without them"); |
| 93 | r.line("others", "other machines take them out when they next sync"); |
| 94 | } |
| 95 | r.next("put real secrets in .env (it stays yours) and change any that leaked"); |
| 96 | Ok(r) |
| 97 | } |
| 98 |
| 99 | /// Takes in the host's purges this machine hasn't: each one's paths out of |
| 100 | /// what's here, then main's history is read again. |
| 101 | pub fn catch_up(app: &App, project: &Project) -> anyhow::Result<()> { |
| 102 | #[derive(Deserialize)] |
| 103 | struct List { |
| 104 | purges: Vec<HostPurge>, |
| 105 | } |
| 106 | let (seen, _) = app.db.purges(&project.id)?; |
| 107 | let list: List = Remote::for_project(project).get_json("/purges")?; |
| 108 | let paths: Vec<String> = list.purges.iter().filter(|p| p.n > seen).flat_map(|p| p.paths.clone()).collect(); |
| 109 | let newest = list.purges.iter().map(|p| p.n).max().unwrap_or(seen); |
| 110 | if !paths.is_empty() { |
| 111 | apply(app, project, &paths, false, false)?; |
| 112 | } |
| 113 | // Main's versions have new trees: read again from the host. |
| 114 | app.db.drop_versions_after(&project.id, 0)?; |
| 115 | app.db.set_history_from(&project.id, 1)?; |
| 116 | app.db.set_purges(&project.id, Some(newest), Some(newest))?; |
| 117 | Ok(()) |
| 118 | } |
| 119 |
| 120 | /// Takes `paths` out of what this machine keeps of `project`: main and its |
| 121 | /// lines (`history`, for a project that lives here), changes, saves, undo |
| 122 | /// steps and workspaces; then deletes what was at them unless something |
| 123 | /// kept still has it. |
| 124 | pub fn apply(app: &App, project: &Project, paths: &[String], dry_run: bool, history: bool) -> anyhow::Result<Purged> { |
| 125 | let db = &app.db; |
| 126 | let mut versions = if history { db.versions(&project.id, i64::MAX)? } else { Vec::new() }; |
| 127 | versions.reverse(); |
| 128 | let mut line_rows: Vec<(String, u64, Id)> = Vec::new(); |
| 129 | if history { |
| 130 | for l in db.lines(&project.id)? { |
| 131 | for v in db.line_versions(&project.id, &l.name, i64::MAX)? { |
| 132 | line_rows.push((v.line, v.n, v.tree)); |
| 133 | } |
| 134 | } |
| 135 | } |
| 136 | let changes = db.changes(&project.id)?; |
| 137 | let mut saves: Vec<(String, i64, Id, Id)> = Vec::new(); |
| 138 | for c in &changes { |
| 139 | let mut stmt = db.conn.prepare("SELECT n, tree, base_tree FROM saves WHERE change = ?1")?; |
| 140 | for row in stmt.query_map([&c.id], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?, r.get::<_, String>(2)?)))? { |
| 141 | let (n, t, b) = row?; |
| 142 | if let (Some(t), Some(b)) = (Id::parse(&t), Id::parse(&b)) { |
| 143 | saves.push((c.id.clone(), n, t, b)); |
| 144 | } |
| 145 | } |
| 146 | } |
| 147 | let workspaces = db.project_workspaces(&project.id)?; |
| 148 | let mut trees: BTreeSet<Id> = BTreeSet::new(); |
| 149 | trees.extend(versions.iter().map(|v| v.tree)); |
| 150 | trees.extend(line_rows.iter().map(|r| r.2)); |
| 151 | for c in &changes { |
| 152 | trees.insert(c.base_tree); |
| 153 | trees.extend(c.head_tree); |
| 154 | } |
| 155 | for (_, _, t, b) in &saves { |
| 156 | trees.insert(*t); |
| 157 | trees.insert(*b); |
| 158 | } |
| 159 | trees.extend(workspaces.iter().filter_map(|w| ws::Index::load(&w.path).tree)); |
| 160 | // Each without the paths; one this machine doesn't have all of stays as it is. |
| 161 | let mut map: BTreeMap<Id, Id> = BTreeMap::new(); |
| 162 | let mut gone = BTreeSet::new(); |
| 163 | for t in &trees { |
| 164 | match purge::without(&app.store, t, paths, &mut gone) { |
| 165 | Ok((after, objects)) if after != *t => { |
| 166 | if !dry_run { |
| 167 | for o in &objects { |
| 168 | app.store.put(o)?; |
| 169 | } |
| 170 | } |
| 171 | map.insert(*t, after); |
| 172 | } |
| 173 | Ok(_) => {} |
| 174 | Err(vcs_core::Error::Missing(_)) => {} |
| 175 | Err(e) => anyhow::bail!("couldn't read the project's history: {e}"), |
| 176 | } |
| 177 | } |
| 178 | // A big file's parts go with it. |
| 179 | for id in gone.clone() { |
| 180 | for part in app.store.file_parts(&id).unwrap_or_default() { |
| 181 | gone.insert(part); |
| 182 | } |
| 183 | } |
| 184 | let first = versions.iter().find(|v| map.contains_key(&v.tree)).map(|v| v.n); |
| 185 | let mut purged = Purged { |
| 186 | paths: paths.to_vec(), |
| 187 | versions: versions.iter().filter(|v| map.contains_key(&v.tree)).count() as u64, |
| 188 | first, |
| 189 | lines: line_rows.iter().filter(|r| map.contains_key(&r.2)).count() as u64, |
| 190 | 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, |
| 191 | objects: gone.iter().filter(|id| app.store.has(id)).count() as u64, |
| 192 | bytes: gone.iter().map(|id| app.store.stored_len(id)).sum(), |
| 193 | done: false, |
| 194 | }; |
| 195 | if dry_run { |
| 196 | return Ok(purged); |
| 197 | } |
| 198 | let swap = |id: &Id| map.get(id).copied().unwrap_or(*id); |
| 199 | let tx = db.conn.unchecked_transaction()?; |
| 200 | // Main from the first version that had them: new trees, so new IDs, each on the one before. |
| 201 | if let Some(first) = first { |
| 202 | let mut prev = versions.iter().find(|v| v.n + 1 == first).map(|v| v.id(&project.id)); |
| 203 | for v in versions.iter().filter(|v| v.n >= first) { |
| 204 | let mut v = v.clone(); |
| 205 | v.tree = swap(&v.tree); |
| 206 | v.prev = prev; |
| 207 | v.git = None; |
| 208 | prev = Some(v.id(&project.id)); |
| 209 | db.put_version(&project.id, &v)?; |
| 210 | } |
| 211 | } |
| 212 | for (line, n, t) in &line_rows { |
| 213 | if map.contains_key(t) { |
| 214 | 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()])?; |
| 215 | } |
| 216 | } |
| 217 | for c in &changes { |
| 218 | if map.contains_key(&c.base_tree) || c.head_tree.is_some_and(|h| map.contains_key(&h)) { |
| 219 | let mut c = c.clone(); |
| 220 | c.base_tree = swap(&c.base_tree); |
| 221 | c.head_tree = c.head_tree.map(|h| swap(&h)); |
| 222 | db.put_change(&c)?; |
| 223 | } |
| 224 | } |
| 225 | for (change, n, t, b) in &saves { |
| 226 | if map.contains_key(t) || map.contains_key(b) { |
| 227 | 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()])?; |
| 228 | } |
| 229 | } |
| 230 | // Undo steps name trees they'd go back to. |
| 231 | let text_swap = |text: &str| -> String { |
| 232 | let mut out = text.to_string(); |
| 233 | for (a, b) in &map { |
| 234 | out = out.replace(&a.hex(), &b.hex()); |
| 235 | } |
| 236 | out |
| 237 | }; |
| 238 | for w in &workspaces { |
| 239 | 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::<rusqlite::Result<_>>()?; |
| 240 | for (id, data) in steps { |
| 241 | let swapped = text_swap(&data); |
| 242 | if swapped != data { |
| 243 | db.conn.execute("UPDATE ops SET data = ?2 WHERE id = ?1", rusqlite::params![id, swapped])?; |
| 244 | } |
| 245 | } |
| 246 | } |
| 247 | db.conn.execute("DELETE FROM complete", [])?; |
| 248 | tx.commit()?; |
| 249 | // The files go from this machine's workspaces (a save would put them back). |
| 250 | for w in &workspaces { |
| 251 | for p in paths { |
| 252 | let at = w.path.join(p); |
| 253 | if at.is_dir() { |
| 254 | let _ = std::fs::remove_dir_all(&at); |
| 255 | } else if at.symlink_metadata().is_ok() { |
| 256 | let _ = std::fs::remove_file(&at); |
| 257 | } |
| 258 | // Folders it leaves empty go too, as a version without it has none. |
| 259 | let mut up = at.parent(); |
| 260 | while let Some(dir) = up.filter(|d| d.starts_with(&w.path) && *d != w.path) { |
| 261 | if std::fs::remove_dir(dir).is_err() { |
| 262 | break; |
| 263 | } |
| 264 | up = dir.parent(); |
| 265 | } |
| 266 | } |
| 267 | let mut index = ws::Index::load(&w.path); |
| 268 | index.entries.retain(|path, _| !paths.iter().any(|p| tree::under(path, p))); |
| 269 | index.tree = index.tree.map(|t| swap(&t)); |
| 270 | index.save(&w.path)?; |
| 271 | } |
| 272 | // What was at them goes, unless something kept still has it. |
| 273 | let live = crate::gc::live_objects(app, 0)?; |
| 274 | purged.objects = 0; |
| 275 | purged.bytes = 0; |
| 276 | for id in gone.iter().filter(|id| !live.contains(id)) { |
| 277 | let freed = app.store.remove(id); |
| 278 | if freed > 0 { |
| 279 | purged.objects += 1; |
| 280 | purged.bytes += freed; |
| 281 | } |
| 282 | } |
| 283 | purged.done = true; |
| 284 | Ok(purged) |
| 285 | } |
| 286 |