Onset: version control where nobody waits and nothing gets lost. The on command, its core and onset-server.
| 6 | //! main, since this machine has the only copy. The saver runs it once a day. |
| 7 |
| 8 | use std::collections::HashSet; |
| 9 | use std::time::{Duration, SystemTime}; |
| 10 |
| 11 | use anyhow::Result; |
| 12 | use vcs_core::{Id, tree}; |
| 13 |
| 14 | use crate::app::App; |
| 15 | use crate::ids; |
| 16 | use crate::objects::Store; |
| 17 | use crate::out::{Fail, Out, Report}; |
| 18 | use crate::ws; |
| 19 |
| 20 | /// Objects written this recently stay: a command may be about to use them |
| 21 | /// (VCS_GC_FRESH, in seconds, for tests). |
| 22 | fn fresh() -> Duration { |
| 23 | Duration::from_secs(std::env::var("VCS_GC_FRESH").ok().and_then(|s| s.parse().ok()).unwrap_or(3600)) |
| 24 | } |
| 25 |
| 26 | pub struct Cleaned { |
| 27 | pub saves: usize, |
| 28 | pub steps: usize, |
| 29 | pub objects: usize, |
| 30 | pub bytes: u64, |
| 31 | } |
| 32 |
| 33 | pub fn run(app: &App, older: Option<String>, dry_run: bool) -> Out { |
| 34 | let words = older.unwrap_or_else(|| "30d".into()); |
| 35 | let cutoff = ids::parse_time(&format!("{words} ago")) |
| 36 | .ok_or_else(|| Fail::new("bad_usage", format!("can't read --older {words:?}; try 30d, 12h or 2w")).state("nothing changed"))?; |
| 37 | let done = clean(app, cutoff, dry_run)?; |
| 38 | let mut r = Report::new(); |
| 39 | let verb = if dry_run { "would remove" } else { "removed" }; |
| 40 | r.line(verb, format!("{} saves and {} undo steps from before {}", done.saves, done.steps, ids::clock(cutoff))); |
| 41 | r.line(verb, format!("{} objects nothing needs ({})", done.objects, size(done.bytes))); |
| 42 | r.set("saves", done.saves).set("steps", done.steps).set("objects", done.objects).set("bytes", done.bytes).set("dry_run", dry_run); |
| 43 | if dry_run { |
| 44 | r.next("vcs gc to remove them"); |
| 45 | } |
| 46 | Ok(r) |
| 47 | } |
| 48 |
| 49 | pub(crate) fn size(bytes: u64) -> String { |
| 50 | match bytes { |
| 51 | b if b >= 1 << 30 => format!("{:.1} GB", b as f64 / (1u64 << 30) as f64), |
| 52 | b if b >= 1 << 20 => format!("{:.1} MB", b as f64 / (1u64 << 20) as f64), |
| 53 | b => format!("{} KB", b.div_ceil(1024)), |
| 54 | } |
| 55 | } |
| 56 |
| 57 | /// Removes saves, undo steps and objects from before `cutoff` (seconds since the epoch). |
| 58 | pub fn clean(app: &App, cutoff: i64, dry_run: bool) -> Result<Cleaned> { |
| 59 | let db = &app.db; |
| 60 | // Saves of changes that are over: the change's content is in main, or it was dropped. |
| 61 | let over = OVER; |
| 62 | let saves: usize = db.conn.query_row(&format!("SELECT count(*) FROM saves WHERE change IN ({over})"), [cutoff], |r| r.get(0))?; |
| 63 | let steps: usize = db.conn.query_row("SELECT count(*) FROM ops WHERE at <= ?1", [cutoff], |r| r.get(0))?; |
| 64 | if !dry_run { |
| 65 | db.conn.execute(&format!("DELETE FROM saves WHERE change IN ({over})"), [cutoff])?; |
| 66 | db.conn.execute("DELETE FROM ops WHERE at <= ?1", [cutoff])?; |
| 67 | } |
| 68 | let live = live_objects(app, cutoff)?; |
| 69 | let (objects, bytes) = sweep(&app.store, &live, dry_run)?; |
| 70 | if objects > 0 && !dry_run { |
| 71 | // Which trees are whole here is worked out again as needed. |
| 72 | db.conn.execute("DELETE FROM complete", [])?; |
| 73 | } |
| 74 | Ok(Cleaned { saves, steps, objects, bytes }) |
| 75 | } |
| 76 |
| 77 | /// Changes that are over: landed or dropped by `?1`. |
| 78 | const OVER: &str = "SELECT id FROM changes WHERE state IN ('landed', 'dropped') AND updated <= ?1"; |
| 79 |
| 80 | /// The trees that are kept, and everything they reach that's here: what |
| 81 | /// changes that aren't over, and undo steps after `cutoff`, still need. |
| 82 | pub(crate) fn live_objects(app: &App, cutoff: i64) -> Result<HashSet<Id>> { |
| 83 | let db = &app.db; |
| 84 | let mut roots: Vec<Id> = Vec::new(); |
| 85 | let mut hex = |text: &str| { |
| 86 | if let Some(id) = Id::parse(text) { |
| 87 | roots.push(id); |
| 88 | } |
| 89 | }; |
| 90 | // Main: all of it for a project on this machine only, the latest version for a hosted one. |
| 91 | for row in db.conn.prepare("SELECT v.tree FROM versions v JOIN projects p ON p.id = v.project WHERE p.kind != 'hosted'")?.query_map([], |r| r.get::<_, String>(0))? { |
| 92 | hex(&row?); |
| 93 | } |
| 94 | for row in db |
| 95 | .conn |
| 96 | .prepare("SELECT v.tree FROM versions v JOIN projects p ON p.id = v.project WHERE p.kind = 'hosted' AND v.n = (SELECT max(n) FROM versions WHERE project = v.project)")? |
| 97 | .query_map([], |r| r.get::<_, String>(0))? |
| 98 | { |
| 99 | hex(&row?); |
| 100 | } |
| 101 | for row in db |
| 102 | .conn |
| 103 | .prepare(&format!("SELECT base_tree, head_tree FROM changes WHERE id NOT IN ({OVER})"))? |
| 104 | .query_map([cutoff], |r| Ok((r.get::<_, String>(0)?, r.get::<_, Option<String>>(1)?)))? |
| 105 | { |
| 106 | let (base, head) = row?; |
| 107 | hex(&base); |
| 108 | if let Some(h) = head { |
| 109 | hex(&h); |
| 110 | } |
| 111 | } |
| 112 | let saves = db |
| 113 | .conn |
| 114 | .prepare(&format!("SELECT tree, base_tree FROM saves WHERE change NOT IN ({OVER})"))? |
| 115 | .query_map([cutoff], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))? |
| 116 | .collect::<rusqlite::Result<Vec<_>>>()?; |
| 117 | for (t, b) in saves { |
| 118 | hex(&t); |
| 119 | hex(&b); |
| 120 | } |
| 121 | // Undo steps name the trees they'd go back to. |
| 122 | let steps: Vec<String> = db.conn.prepare("SELECT data FROM ops WHERE at > ?1")?.query_map([cutoff], |r| r.get(0))?.collect::<rusqlite::Result<_>>()?; |
| 123 | for data in steps { |
| 124 | for word in data.split(|c: char| !c.is_ascii_hexdigit()) { |
| 125 | if word.len() == 64 { |
| 126 | hex(word); |
| 127 | } |
| 128 | } |
| 129 | } |
| 130 | for w in db.all_workspaces()? { |
| 131 | if let Some(t) = ws::Index::load(&w.path).tree { |
| 132 | roots.push(t); |
| 133 | } |
| 134 | } |
| 135 | let mut live: HashSet<Id> = HashSet::new(); |
| 136 | for row in db.conn.prepare("SELECT file FROM mine")?.query_map([], |r| r.get::<_, String>(0))? { |
| 137 | if let Some(id) = Id::parse(&row?) { |
| 138 | add_file(&app.store, &id, &mut live); |
| 139 | } |
| 140 | } |
| 141 | for root in roots { |
| 142 | walk(&app.store, &root, &mut live); |
| 143 | } |
| 144 | Ok(live) |
| 145 | } |
| 146 |
| 147 | fn add_file(store: &Store, id: &Id, live: &mut HashSet<Id>) { |
| 148 | for part in store.file_parts(id).unwrap_or_else(|_| vec![*id]) { |
| 149 | live.insert(part); |
| 150 | } |
| 151 | } |
| 152 |
| 153 | /// A tree and what it reaches, as far as this machine has it. |
| 154 | fn walk(store: &Store, root: &Id, live: &mut HashSet<Id>) { |
| 155 | let mut stack = vec![*root]; |
| 156 | while let Some(t) = stack.pop() { |
| 157 | if !live.insert(t) { |
| 158 | continue; |
| 159 | } |
| 160 | let Ok(entries) = tree::read(store, &t) else { continue }; |
| 161 | for e in entries { |
| 162 | match e.kind { |
| 163 | tree::EntryKind::Tree => stack.push(e.id), |
| 164 | tree::EntryKind::Gitlink => {} |
| 165 | _ => add_file(store, &e.id, live), |
| 166 | } |
| 167 | } |
| 168 | } |
| 169 | } |
| 170 |
| 171 | /// Removes objects that aren't live (and weren't written in the last hour). |
| 172 | fn sweep(store: &Store, live: &HashSet<Id>, dry_run: bool) -> Result<(usize, u64)> { |
| 173 | let now = SystemTime::now(); |
| 174 | let (mut count, mut bytes) = (0, 0); |
| 175 | for (id, path) in store.all()? { |
| 176 | if live.contains(&id) { |
| 177 | continue; |
| 178 | } |
| 179 | let Ok(meta) = std::fs::metadata(&path) else { continue }; |
| 180 | if meta.modified().ok().and_then(|m| now.duration_since(m).ok()).is_none_or(|age| age < fresh()) { |
| 181 | continue; |
| 182 | } |
| 183 | count += 1; |
| 184 | bytes += meta.len(); |
| 185 | if !dry_run { |
| 186 | let _ = std::fs::remove_file(&path); |
| 187 | } |
| 188 | } |
| 189 | Ok((count, bytes)) |
| 190 | } |
| 191 |
| 192 | /// The saver's daily run, with the defaults; quiet unless something fails. |
| 193 | pub fn daily(app: &App) { |
| 194 | let mark = crate::paths::home().join("gc-at"); |
| 195 | let last = std::fs::read_to_string(&mark).ok().and_then(|t| t.trim().parse::<i64>().ok()).unwrap_or(0); |
| 196 | if ids::now() - last < 86_400 { |
| 197 | return; |
| 198 | } |
| 199 | let _ = std::fs::write(&mark, ids::now().to_string()); |
| 200 | if let Err(e) = clean(app, ids::now() - 30 * 86_400, false) { |
| 201 | eprintln!("vcs: cleaning up: {e:#}"); |
| 202 | } |
| 203 | } |
| 204 |