//! A project's workspaces on this machine. Agents each work in their own //! (subagents too), so a machine can have dozens: listing them, adding one //! next to this one, and removing the ones whose work is done. use std::path::PathBuf; use serde_json::json; use crate::app::App; use crate::cmd; use crate::out::{Fail, Out, Report}; use crate::ws; /// `on workspaces`: each workspace of this project, its change and state. pub fn list(app: &App) -> Out { let s = app.session()?; let mut r = Report::new(); let mut all = Vec::new(); for w in app.db.project_workspaces(&s.project.id)? { let here = w.id == s.ws.id; let state = if !ws::is_workspace(&w.path) { "folder gone".to_string() } else { match app.db.change(&w.change)? { Some(c) if c.is_empty() => "empty".to_string(), Some(c) => format!("{}, {}", if c.title.is_empty() { "untitled" } else { &c.title }, c.state), None => "unknown change".to_string(), } }; let label = format!("{}{}", w.path.display(), if here { " (here)" } else { "" }); r.line(if all.is_empty() { "workspaces" } else { "" }, format!("{label} {} {state}", app.short(&s.project, &w.change))); all.push(json!({ "path": w.path, "change": w.change, "state": state, "here": here })); } let n = all.len(); r.set("workspaces", all); if n > 1 { r.next("on workspaces clean removes the ones whose work is done"); } else { r.next("on workspaces add makes another, for another agent"); } Ok(r) } /// `on workspaces add [--on ]`: another workspace of /// this project, on a new change on main, or on `on` (`.` for this /// workspace's change) so it starts from work that hasn't landed. pub fn add(app: &App, folder: PathBuf, on: Option) -> Out { let mut s = app.session()?; // The new one starts from this change as it is right now. app.save(&mut s)?; let on = on.map(|o| if o == "." { s.ws.change.clone() } else { o }); let address = s.project.address.clone(); let only = s.ws.only.clone(); // The new workspace's commands take their own lock. drop(s); cmd::get(app, &address, Some(folder), None, only, on, None, false) } /// `on workspaces clean [--idle 2h]`: removes this project's other /// workspaces whose work is done. Each is saved first; it goes only if its /// change is then empty (nothing that isn't on main or the change it's on), /// no Onset command is running in it, and nothing in it changed for `idle`. /// Its folder goes, ignored files (builds, node_modules) included; its /// change records stay. pub fn clean(app: &App, idle: &str) -> Out { let idle_secs = parse_span(idle).ok_or_else(|| Fail::new("bad_usage", format!("can't read \"{idle}\"")).next("use 30m, 2h or 1d"))?; let s = app.session()?; let here = s.ws.id.clone(); let project = s.project.clone(); drop(s); let now_ns = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos() as i64).unwrap_or(0); let mut removed = Vec::new(); let mut kept: Vec<(PathBuf, String)> = Vec::new(); for w in app.db.project_workspaces(&project.id)? { if w.id == here { continue; } if !ws::is_workspace(&w.path) { app.db.delete_workspace(&w.id)?; crate::gitdir::forget(&project.id, &w.id); removed.push(json!({ "path": w.path, "why": "its folder was already gone" })); continue; } // Someone's working in it right now. match ws::try_lock(&w.path)? { Some(lock) => drop(lock), None => { kept.push((w.path.clone(), "an Onset command is running in it".into())); continue; } } // Every Onset command there rewrites its index: when the last one ran. let last_command = mtime_ns(&w.path.join(ws::DIR).join("index")); let mut session = app.session_at(&w.path)?; let change = app.save(&mut session)?; if !change.is_empty() { kept.push((w.path.clone(), format!("its change {} has work that hasn't landed", app.short(&project, &change.id)))); continue; } let last_edit = ws::Index::load(&w.path).entries.values().map(|e| e.stat.mtime_ns).max().unwrap_or(0); if now_ns - last_command.max(last_edit) < idle_secs * 1_000_000_000 { kept.push((w.path.clone(), format!("used in the last {idle}"))); continue; } let path = w.path.clone(); drop(session); // A lazy workspace: its mount first, then its backing. if crate::lazy::is_lazy(&w.id) { crate::lazy::unmount(&path)?; crate::lazy::forget(&path); std::fs::remove_dir_all(crate::lazy::backing(&w.id))?; } std::fs::remove_dir_all(&path)?; app.db.delete_workspace(&w.id)?; crate::gitdir::forget(&project.id, &w.id); removed.push(json!({ "path": path, "why": "its change was empty" })); } let mut r = Report::new(); let names: Vec = removed .iter() .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()) .collect(); r.line("removed", match names.len() { 0 => "none".to_string(), n if n <= 6 => names.join(", "), n => format!("{n} workspaces: {} and {} more", names[..5].join(", "), n - 5), }); for (i, (path, why)) in kept.iter().enumerate() { r.line(if i == 0 { "kept" } else { "" }, format!("{} ({why})", path.display())); } r.set("removed", removed).set("kept", kept.iter().map(|(p, why)| json!({ "path": p, "why": why })).collect::>()); Ok(r) } fn mtime_ns(path: &std::path::Path) -> i64 { std::fs::metadata(path) .and_then(|m| m.modified()) .ok() .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) .map(|d| d.as_nanos() as i64) .unwrap_or(0) } /// "30m", "2h", "1d", "45s" in seconds. fn parse_span(text: &str) -> Option { let t = text.trim(); let digits: String = t.chars().take_while(|c| c.is_ascii_digit()).collect(); let n: i64 = digits.parse().ok()?; Some(match t[digits.len()..].trim() { "s" => n, "m" | "min" => n * 60, "h" => n * 3600, "d" => n * 86_400, _ => return None, }) } #[cfg(test)] mod tests { #[test] fn spans() { assert_eq!(super::parse_span("30m"), Some(1800)); assert_eq!(super::parse_span("2h"), Some(7200)); assert_eq!(super::parse_span("0s"), Some(0)); assert_eq!(super::parse_span("soon"), None); } }