Onset: version control where nobody waits and nothing gets lost. The on command, its core and onset-server.
| 6 |
| 7 | use serde_json::json; |
| 8 |
| 9 | use crate::app::App; |
| 10 | use crate::cmd; |
| 11 | use crate::out::{Fail, Out, Report}; |
| 12 | use crate::ws; |
| 13 |
| 14 | /// `vcs workspaces`: each workspace of this project, its change and state. |
| 15 | pub fn list(app: &App) -> Out { |
| 16 | let s = app.session()?; |
| 17 | let mut r = Report::new(); |
| 18 | let mut all = Vec::new(); |
| 19 | for w in app.db.project_workspaces(&s.project.id)? { |
| 20 | let here = w.id == s.ws.id; |
| 21 | let state = if !w.path.join(ws::DIR).is_dir() { |
| 22 | "folder gone".to_string() |
| 23 | } else { |
| 24 | match app.db.change(&w.change)? { |
| 25 | Some(c) if c.is_empty() => "empty".to_string(), |
| 26 | Some(c) => format!("{}, {}", if c.title.is_empty() { "untitled" } else { &c.title }, c.state), |
| 27 | None => "unknown change".to_string(), |
| 28 | } |
| 29 | }; |
| 30 | let label = format!("{}{}", w.path.display(), if here { " (here)" } else { "" }); |
| 31 | r.line(if all.is_empty() { "workspaces" } else { "" }, format!("{label} {} {state}", app.short(&s.project, &w.change))); |
| 32 | all.push(json!({ "path": w.path, "change": w.change, "state": state, "here": here })); |
| 33 | } |
| 34 | let n = all.len(); |
| 35 | r.set("workspaces", all); |
| 36 | if n > 1 { |
| 37 | r.next("vcs workspaces clean removes the ones whose work is done"); |
| 38 | } else { |
| 39 | r.next("vcs workspaces add <folder> makes another, for another agent"); |
| 40 | } |
| 41 | Ok(r) |
| 42 | } |
| 43 |
| 44 | /// `vcs workspaces add <folder> [--on <change>]`: another workspace of |
| 45 | /// this project, on a new change on main, or on `on` (`.` for this |
| 46 | /// workspace's change) so it starts from work that hasn't landed. |
| 47 | pub fn add(app: &App, folder: PathBuf, on: Option<String>) -> Out { |
| 48 | let mut s = app.session()?; |
| 49 | // The new one starts from this change as it is right now. |
| 50 | app.save(&mut s)?; |
| 51 | let on = on.map(|o| if o == "." { s.ws.change.clone() } else { o }); |
| 52 | let address = s.project.address.clone(); |
| 53 | let only = s.ws.only.clone(); |
| 54 | // The new workspace's commands take their own lock. |
| 55 | drop(s); |
| 56 | cmd::get(app, &address, Some(folder), None, only, on, None) |
| 57 | } |
| 58 |
| 59 | /// `vcs workspaces clean [--idle 2h]`: removes this project's other |
| 60 | /// workspaces whose work is done. Each is saved first; it goes only if its |
| 61 | /// change is then empty (nothing that isn't on main or the change it's on), |
| 62 | /// no vcs command is running in it, and nothing in it changed for `idle`. |
| 63 | /// Its folder goes, ignored files (builds, node_modules) included; its |
| 64 | /// change records stay. |
| 65 | pub fn clean(app: &App, idle: &str) -> Out { |
| 66 | let idle_secs = parse_span(idle).ok_or_else(|| Fail::new("bad_usage", format!("can't read \"{idle}\"")).next("use 30m, 2h or 1d"))?; |
| 67 | let s = app.session()?; |
| 68 | let here = s.ws.id.clone(); |
| 69 | let project = s.project.clone(); |
| 70 | drop(s); |
| 71 | let now_ns = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos() as i64).unwrap_or(0); |
| 72 | let mut removed = Vec::new(); |
| 73 | let mut kept: Vec<(PathBuf, String)> = Vec::new(); |
| 74 | for w in app.db.project_workspaces(&project.id)? { |
| 75 | if w.id == here { |
| 76 | continue; |
| 77 | } |
| 78 | if !w.path.join(ws::DIR).is_dir() { |
| 79 | app.db.delete_workspace(&w.id)?; |
| 80 | removed.push(json!({ "path": w.path, "why": "its folder was already gone" })); |
| 81 | continue; |
| 82 | } |
| 83 | // Someone's working in it right now. |
| 84 | match ws::try_lock(&w.path)? { |
| 85 | Some(lock) => drop(lock), |
| 86 | None => { |
| 87 | kept.push((w.path.clone(), "a vcs command is running in it".into())); |
| 88 | continue; |
| 89 | } |
| 90 | } |
| 91 | // Every vcs command there rewrites its index: when the last one ran. |
| 92 | let last_command = mtime_ns(&w.path.join(ws::DIR).join("index")); |
| 93 | let mut session = app.session_at(&w.path)?; |
| 94 | let change = app.save(&mut session)?; |
| 95 | if !change.is_empty() { |
| 96 | kept.push((w.path.clone(), format!("its change {} has work that hasn't landed", app.short(&project, &change.id)))); |
| 97 | continue; |
| 98 | } |
| 99 | let last_edit = ws::Index::load(&w.path).entries.values().map(|e| e.stat.mtime_ns).max().unwrap_or(0); |
| 100 | if now_ns - last_command.max(last_edit) < idle_secs * 1_000_000_000 { |
| 101 | kept.push((w.path.clone(), format!("used in the last {idle}"))); |
| 102 | continue; |
| 103 | } |
| 104 | let path = w.path.clone(); |
| 105 | drop(session); |
| 106 | std::fs::remove_dir_all(&path)?; |
| 107 | app.db.delete_workspace(&w.id)?; |
| 108 | removed.push(json!({ "path": path, "why": "its change was empty" })); |
| 109 | } |
| 110 | let mut r = Report::new(); |
| 111 | let names: Vec<String> = removed |
| 112 | .iter() |
| 113 | .map(|x| std::path::Path::new(x["path"].as_str().unwrap_or("")).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default()) |
| 114 | .collect(); |
| 115 | r.line("removed", match names.len() { |
| 116 | 0 => "none".to_string(), |
| 117 | n if n <= 6 => names.join(", "), |
| 118 | n => format!("{n} workspaces: {} and {} more", names[..5].join(", "), n - 5), |
| 119 | }); |
| 120 | for (i, (path, why)) in kept.iter().enumerate() { |
| 121 | r.line(if i == 0 { "kept" } else { "" }, format!("{} ({why})", path.display())); |
| 122 | } |
| 123 | r.set("removed", removed).set("kept", kept.iter().map(|(p, why)| json!({ "path": p, "why": why })).collect::<Vec<_>>()); |
| 124 | Ok(r) |
| 125 | } |
| 126 |
| 127 | fn mtime_ns(path: &std::path::Path) -> i64 { |
| 128 | std::fs::metadata(path) |
| 129 | .and_then(|m| m.modified()) |
| 130 | .ok() |
| 131 | .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) |
| 132 | .map(|d| d.as_nanos() as i64) |
| 133 | .unwrap_or(0) |
| 134 | } |
| 135 |
| 136 | /// "30m", "2h", "1d", "45s" in seconds. |
| 137 | fn parse_span(text: &str) -> Option<i64> { |
| 138 | let t = text.trim(); |
| 139 | let digits: String = t.chars().take_while(|c| c.is_ascii_digit()).collect(); |
| 140 | let n: i64 = digits.parse().ok()?; |
| 141 | Some(match t[digits.len()..].trim() { |
| 142 | "s" => n, |
| 143 | "m" | "min" => n * 60, |
| 144 | "h" => n * 3600, |
| 145 | "d" => n * 86_400, |
| 146 | _ => return None, |
| 147 | }) |
| 148 | } |
| 149 |
| 150 | #[cfg(test)] |
| 151 | mod tests { |
| 152 | #[test] |
| 153 | fn spans() { |
| 154 | assert_eq!(super::parse_span("30m"), Some(1800)); |
| 155 | assert_eq!(super::parse_span("2h"), Some(7200)); |
| 156 | assert_eq!(super::parse_span("0s"), Some(0)); |
| 157 | assert_eq!(super::parse_span("soon"), None); |
| 158 | } |
| 159 | } |
| 160 |