Onset: version control where nobody waits and nothing gets lost. The on command, its core and onset-server.
| 1 | //! `on watch --run "<check>"`: keeps main working at the speed changes land. |
| 2 | //! |
| 3 | //! Lands don't wait for checks (nobody waits), so something has to notice |
| 4 | //! when main breaks. This follows main from any machine (a CI box, a dev |
| 5 | //! machine, a cloud computer) and runs the check on each new version it |
| 6 | //! sees. When the check fails, it finds the first version that fails it by |
| 7 | //! binary search over the numbered versions since the last one that passed, |
| 8 | //! takes that version back out (a new version, as `on undo <version>`), and |
| 9 | //! its change is open again for its owner, with what failed in the take-back's |
| 10 | //! description. A check that fails then passes on the same version is tried |
| 11 | //! again before anything is blamed. |
| 12 |
| 13 | use std::path::PathBuf; |
| 14 | use std::time::Duration; |
| 15 |
| 16 | use serde_json::json; |
| 17 |
| 18 | use crate::app::App; |
| 19 | use crate::db::Project; |
| 20 | use crate::out::{Fail, Out, Report}; |
| 21 | use crate::ws::{self, Index}; |
| 22 | use crate::{ids, paths}; |
| 23 |
| 24 | /// Where the watcher keeps the last version that passed, per project and check. |
| 25 | fn state_path(project: &Project, check: &str) -> PathBuf { |
| 26 | use sha2::{Digest, Sha256}; |
| 27 | let h: String = Sha256::digest(format!("{}\n{check}", project.id).as_bytes())[..8].iter().map(|b| format!("{b:02x}")).collect(); |
| 28 | paths::home().join("watch").join(format!("{h}.json")) |
| 29 | } |
| 30 |
| 31 | struct Tester<'a> { |
| 32 | app: &'a App, |
| 33 | project: &'a Project, |
| 34 | check: &'a str, |
| 35 | root: PathBuf, |
| 36 | index: Index, |
| 37 | tries: u32, |
| 38 | } |
| 39 |
| 40 | /// How one version did: passed, failed (with the end of its output), or can't tell (exit 125). |
| 41 | enum Outcome { |
| 42 | Pass, |
| 43 | Fail(i32, String), |
| 44 | Skip, |
| 45 | } |
| 46 |
| 47 | impl Tester<'_> { |
| 48 | fn run(&mut self, n: u64) -> Result<Outcome, Fail> { |
| 49 | let v = self.app.version(self.project, n)?.ok_or_else(|| Fail::new("not_found", format!("main has no version {n}")))?; |
| 50 | self.app.host(self.project).fetch_tree(&self.app.store, self.project, &v.tree, None)?; |
| 51 | let skip = crate::seal::Seals::load(&self.app.db, &self.project.id)?.unreadable(); |
| 52 | self.index = ws::materialize(&self.root, &self.app.store, &self.index, &self.app.files(&v.tree)?, &[], &skip, None)?; |
| 53 | let mut last = Outcome::Pass; |
| 54 | for _ in 0..self.tries.max(1) { |
| 55 | last = self.once()?; |
| 56 | if !matches!(last, Outcome::Fail(..)) { |
| 57 | return Ok(last); |
| 58 | } |
| 59 | } |
| 60 | Ok(last) |
| 61 | } |
| 62 |
| 63 | fn once(&self) -> Result<Outcome, Fail> { |
| 64 | let mut shell = if cfg!(windows) { std::process::Command::new("cmd") } else { std::process::Command::new("sh") }; |
| 65 | shell.args(if cfg!(windows) { ["/C", self.check] } else { ["-c", self.check] }); |
| 66 | let out = shell |
| 67 | .current_dir(&self.root) |
| 68 | .stdin(std::process::Stdio::null()) |
| 69 | .output() |
| 70 | .map_err(|e| Fail::new("bad_usage", format!("couldn't run {:?}: {e}", self.check)).state("nothing changed"))?; |
| 71 | Ok(match out.status.code() { |
| 72 | Some(0) => Outcome::Pass, |
| 73 | Some(125) => Outcome::Skip, |
| 74 | code => { |
| 75 | let mut text = String::from_utf8_lossy(&out.stdout).into_owned(); |
| 76 | text.push_str(&String::from_utf8_lossy(&out.stderr)); |
| 77 | let tail: Vec<&str> = text.lines().rev().take(30).collect(); |
| 78 | Outcome::Fail(code.unwrap_or(-1), tail.into_iter().rev().collect::<Vec<_>>().join("\n")) |
| 79 | } |
| 80 | }) |
| 81 | } |
| 82 | } |
| 83 |
| 84 | /// `on watch --run "<check>" [--from <version>] [--once] [--every <secs>] [--tries <n>]`. |
| 85 | pub fn run(app: &App, check: String, from: Option<u64>, once: bool, every: u64, tries: u32) -> Out { |
| 86 | let s = app.session()?; |
| 87 | let project = s.project.clone(); |
| 88 | let state = state_path(&project, &check); |
| 89 | let mut green: Option<u64> = from.or_else(|| { |
| 90 | let text = std::fs::read_to_string(&state).ok()?; |
| 91 | serde_json::from_str::<serde_json::Value>(&text).ok()?["green"].as_u64() |
| 92 | }); |
| 93 | let root = paths::home().join("tmp").join(format!("watch-{}", ids::new_workspace_id())); |
| 94 | std::fs::create_dir_all(&root)?; |
| 95 | let mut tester = Tester { app, project: &project, check: &check, root: root.clone(), index: Index::default(), tries }; |
| 96 | let mut r = Report::new(); |
| 97 | let mut events = Vec::new(); |
| 98 | let result = (|| -> Result<(), Fail> { |
| 99 | loop { |
| 100 | let Some(main) = app.refresh(&project)? else { |
| 101 | if once { |
| 102 | return Ok(()); |
| 103 | } |
| 104 | std::thread::sleep(Duration::from_secs(every)); |
| 105 | continue; |
| 106 | }; |
| 107 | if green.is_some_and(|g| g >= main.n) { |
| 108 | if once { |
| 109 | return Ok(()); |
| 110 | } |
| 111 | std::thread::sleep(Duration::from_secs(every)); |
| 112 | continue; |
| 113 | } |
| 114 | match tester.run(main.n)? { |
| 115 | Outcome::Pass | Outcome::Skip => { |
| 116 | green = Some(main.n); |
| 117 | let _ = std::fs::create_dir_all(state.parent().unwrap()); |
| 118 | let _ = std::fs::write(&state, json!({ "green": main.n }).to_string()); |
| 119 | say(app, once, &mut r, &mut events, "passed", format!("main {}", main.n), json!({ "passed": main.n })); |
| 120 | continue; |
| 121 | } |
| 122 | Outcome::Fail(code, tail) => { |
| 123 | let Some(good) = green else { |
| 124 | return Err(Fail::new("checks_failed", format!("main {} fails {check:?} (exit {code}), and no version is known to pass it", main.n)) |
| 125 | .detail(tail) |
| 126 | .state("main didn't change") |
| 127 | .next(format!("on watch --run {check:?} --from <a version that passes>"))); |
| 128 | }; |
| 129 | // The first failing version since the last that passed. |
| 130 | let (mut lo, mut hi) = (good, main.n); |
| 131 | let mut why = tail; |
| 132 | let mut fail_code = code; |
| 133 | while hi - lo > 1 { |
| 134 | let mid = lo + (hi - lo) / 2; |
| 135 | match tester.run(mid)? { |
| 136 | Outcome::Pass | Outcome::Skip => lo = mid, |
| 137 | Outcome::Fail(c, t) => { |
| 138 | hi = mid; |
| 139 | why = t; |
| 140 | fail_code = c; |
| 141 | } |
| 142 | } |
| 143 | } |
| 144 | let reason = format!("{check} failed on it (exit {fail_code}), and passed on main {lo} before it (on watch).\n\n{why}"); |
| 145 | let (v, back) = crate::cmd::take_back_why(app, &s, hi, Some(&reason)).map_err(|f| { |
| 146 | f.next(format!("main {hi} broke {check:?}: take it back by hand, then on watch --from {}", main.n + 1)) |
| 147 | })?; |
| 148 | // The change that made it is open again (the host does it for a hosted project). |
| 149 | if !project.hosted() |
| 150 | && let Ok(mut c) = app.change(&v.change) |
| 151 | && c.state == "landed" |
| 152 | { |
| 153 | c.state = "open".into(); |
| 154 | c.version = None; |
| 155 | app.db.put_change(&c)?; |
| 156 | } |
| 157 | say( |
| 158 | app, |
| 159 | once, |
| 160 | &mut r, |
| 161 | &mut events, |
| 162 | "took back", |
| 163 | format!("main {hi} (\"{}\", {}): {check} failed on it; main {} takes it out, and its change {} is open again", v.title, v.author, back.n, app.short(&project, &v.change)), |
| 164 | json!({ "failed": hi, "change": v.change, "taken_back_as": back.n }), |
| 165 | ); |
| 166 | // What's after it may pass now; it's checked from the last good one. |
| 167 | green = Some(lo); |
| 168 | } |
| 169 | } |
| 170 | } |
| 171 | })(); |
| 172 | let _ = std::fs::remove_dir_all(&root); |
| 173 | result?; |
| 174 | r.set("events", events); |
| 175 | if let Some(g) = green { |
| 176 | r.set("green", g); |
| 177 | } |
| 178 | Ok(r) |
| 179 | } |
| 180 |
| 181 | /// One thing that happened: in the answer with --once; as it happens otherwise (it runs for good). |
| 182 | fn say(app: &App, once: bool, r: &mut Report, events: &mut Vec<serde_json::Value>, label: &str, text: String, event: serde_json::Value) { |
| 183 | if once { |
| 184 | r.line(label, text); |
| 185 | } else if app.json { |
| 186 | println!("{event}"); |
| 187 | } else { |
| 188 | println!("{label:<10} {text}"); |
| 189 | } |
| 190 | events.push(event); |
| 191 | } |
| 192 |