oxy doctor, and failures that say what to do next 3e5772ccf9da
oxy doctor checks the operation log and stale locks, the signing identity (object, secret key file, expiry), the author name and email, and each remote (plain HTTP off loopback, token, CA file, and, unless --offline, that it answers and is this repository), with the next step for every warning and failure; exit 1 on failure, --json for tools. Failed remote requests anywhere in oxy now carry that next step too: an untrusted certificate points at the hub's oxyhub-ca.pem and remote.<remote>.ca, a 401 at the token, a 403 at repo:write, a 404 at the repository name, and refused connections, unknown hosts and timeouts are named as such. Transport errors no longer repeat the URL. A remote that is another repository, a push that must pull first, a key the policy does not authorize, and running outside a repository now say what to do. Spec Draft 0.8h §21.2.
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-09-28 08:32 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
3e5772ccf9daacd64ace802c1885630b75e7a38812e9dec982bd62e473b8cca3- Depends on
- 05e71ed6cdf1 19555025a2ac 252e8cb23fbc 2b5ee371060e 387335ff7f4d 4213c41dfc64 5b9c6ad45f6e 614541c3cebd 750251b21a96 75167672ebcd 982864068a59 b3c5a59a875a c6aa5bd9d1c8 d82e44097323 ee06a6c557b7 fce44ae9ad29 ff0af1ee299d
- Depended on by
- d3b270909c78 67a4fe1aa2d5 ad2496f7e608 028545dfa7a4 c2e1d09047db d8d9367878a6 ff1f62fee654 57eaac9107df e7ac6c2e84e4 c863c7a7a2d1 62794ecc7f74 d42369abf180
- In bookmarks
- agents-in-readme bare-hashes carried-not-removed carry-identical doctor-trust identical-sides keyless-tags main merge-remote-bookmark only-help-103 paths-for-resolutions-and-amend push-notes show-stat subrepo-pin-state switch-says
- In tags
- v0.1.0 v0.10.0 v0.11.0 v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.4.1 v0.4.2 v0.4.3 v0.4.4 v0.4.5 v0.4.6 v0.5.0 v0.5.1 v0.6.0 v0.7.0 v0.8.0 v0.9.0
- Edits
- 26 atoms, 1 tree operation, 9 files
Browse the tree at this change
added crates/oxy-core/src/repo/doctor.rs +281 −0
| 1 | + | //! `oxy doctor` (spec §19.1): check what commonly goes wrong around a |
|
| 2 | + | //! repository, the identity, the configuration and the remotes, and say |
|
| 3 | + | //! what to do about each problem. It changes nothing. |
|
| 4 | + | ||
| 5 | + | use super::*; |
|
| 6 | + | ||
| 7 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] |
|
| 8 | + | pub enum Health { |
|
| 9 | + | Ok, |
|
| 10 | + | Warn, |
|
| 11 | + | Fail, |
|
| 12 | + | } |
|
| 13 | + | ||
| 14 | + | #[derive(Clone, Debug, PartialEq, Eq)] |
|
| 15 | + | pub struct Check { |
|
| 16 | + | /// What the check is about: `repository`, `identity`, `author`, `remote NAME`. |
|
| 17 | + | pub area: String, |
|
| 18 | + | pub health: Health, |
|
| 19 | + | pub message: String, |
|
| 20 | + | /// The next step, for a warning or failure. |
|
| 21 | + | pub hint: Option<String>, |
|
| 22 | + | } |
|
| 23 | + | ||
| 24 | + | #[derive(Clone, Debug, Default, PartialEq, Eq)] |
|
| 25 | + | pub struct DoctorReport { |
|
| 26 | + | pub checks: Vec<Check>, |
|
| 27 | + | } |
|
| 28 | + | ||
| 29 | + | impl DoctorReport { |
|
| 30 | + | pub fn worst(&self) -> Health { |
|
| 31 | + | self.checks |
|
| 32 | + | .iter() |
|
| 33 | + | .map(|c| c.health) |
|
| 34 | + | .max() |
|
| 35 | + | .unwrap_or(Health::Ok) |
|
| 36 | + | } |
|
| 37 | + | ||
| 38 | + | fn push( |
|
| 39 | + | &mut self, |
|
| 40 | + | area: impl Into<String>, |
|
| 41 | + | health: Health, |
|
| 42 | + | message: impl Into<String>, |
|
| 43 | + | hint: Option<String>, |
|
| 44 | + | ) { |
|
| 45 | + | self.checks.push(Check { |
|
| 46 | + | area: area.into(), |
|
| 47 | + | health, |
|
| 48 | + | message: message.into(), |
|
| 49 | + | hint, |
|
| 50 | + | }); |
|
| 51 | + | } |
|
| 52 | + | } |
|
| 53 | + | ||
| 54 | + | /// A lock file older than this is left over from a process that died. |
|
| 55 | + | const STALE_LOCK_SECS: u64 = 60; |
|
| 56 | + | ||
| 57 | + | impl Repo { |
|
| 58 | + | /// Run every check; `online` also contacts each remote. |
|
| 59 | + | pub fn doctor(&self, online: bool) -> DoctorReport { |
|
| 60 | + | let mut rep = DoctorReport::default(); |
|
| 61 | + | self.doctor_repository(&mut rep); |
|
| 62 | + | self.doctor_identity(&mut rep); |
|
| 63 | + | self.doctor_author(&mut rep); |
|
| 64 | + | for name in self.config.remotes.keys() { |
|
| 65 | + | self.doctor_remote(&mut rep, name, online); |
|
| 66 | + | } |
|
| 67 | + | if self.config.remotes.is_empty() && !self.bare { |
|
| 68 | + | rep.push("remotes", Health::Ok, "none configured", None); |
|
| 69 | + | } |
|
| 70 | + | rep |
|
| 71 | + | } |
|
| 72 | + | ||
| 73 | + | fn doctor_repository(&self, rep: &mut DoctorReport) { |
|
| 74 | + | match self.head_op() { |
|
| 75 | + | Ok(_) => rep.push( |
|
| 76 | + | "repository", |
|
| 77 | + | Health::Ok, |
|
| 78 | + | format!( |
|
| 79 | + | "{} at {}", |
|
| 80 | + | if self.bare { |
|
| 81 | + | "bare repository" |
|
| 82 | + | } else { |
|
| 83 | + | "repository" |
|
| 84 | + | }, |
|
| 85 | + | self.root.display() |
|
| 86 | + | ), |
|
| 87 | + | None, |
|
| 88 | + | ), |
|
| 89 | + | Err(e) => rep.push( |
|
| 90 | + | "repository", |
|
| 91 | + | Health::Fail, |
|
| 92 | + | format!("the operation log does not open: {e}"), |
|
| 93 | + | Some("run `oxy fsck` for details".into()), |
|
| 94 | + | ), |
|
| 95 | + | } |
|
| 96 | + | let mut locks = vec![self.oxy.join("ops").join("lock")]; |
|
| 97 | + | if let Ok(rd) = fs::read_dir(self.oxy.join("workspaces")) { |
|
| 98 | + | locks.extend(rd.flatten().map(|e| e.path().join("lock"))); |
|
| 99 | + | } |
|
| 100 | + | for lock in locks { |
|
| 101 | + | let age = fs::metadata(&lock) |
|
| 102 | + | .and_then(|m| m.modified()) |
|
| 103 | + | .ok() |
|
| 104 | + | .and_then(|t| t.elapsed().ok()); |
|
| 105 | + | if let Some(age) = age.filter(|a| a.as_secs() > STALE_LOCK_SECS) { |
|
| 106 | + | rep.push( |
|
| 107 | + | "repository", |
|
| 108 | + | Health::Warn, |
|
| 109 | + | format!( |
|
| 110 | + | "a lock left by a process that stopped {} s ago: {}", |
|
| 111 | + | age.as_secs(), |
|
| 112 | + | lock.display() |
|
| 113 | + | ), |
|
| 114 | + | Some( |
|
| 115 | + | "it is taken over automatically; delete it if no oxy command is running" |
|
| 116 | + | .into(), |
|
| 117 | + | ), |
|
| 118 | + | ); |
|
| 119 | + | } |
|
| 120 | + | } |
|
| 121 | + | } |
|
| 122 | + | ||
| 123 | + | fn doctor_identity(&self, rep: &mut DoctorReport) { |
|
| 124 | + | let Some(key) = self.config.author_key else { |
|
| 125 | + | rep.push( |
|
| 126 | + | "identity", |
|
| 127 | + | Health::Warn, |
|
| 128 | + | "no signing identity: pushes to a repository with a trust policy will be refused", |
|
| 129 | + | Some("create one with `oxy identity create`".into()), |
|
| 130 | + | ); |
|
| 131 | + | return; |
|
| 132 | + | }; |
|
| 133 | + | let id = match self.identity(&key) { |
|
| 134 | + | Ok(id) => id, |
|
| 135 | + | Err(e) => { |
|
| 136 | + | rep.push( |
|
| 137 | + | "identity", |
|
| 138 | + | Health::Fail, |
|
| 139 | + | format!( |
|
| 140 | + | "identity {} is configured but cannot be read: {e}", |
|
| 141 | + | key.short(12) |
|
| 142 | + | ), |
|
| 143 | + | Some("restore it, or create a new one with `oxy identity create`".into()), |
|
| 144 | + | ); |
|
| 145 | + | return; |
|
| 146 | + | } |
|
| 147 | + | }; |
|
| 148 | + | let secret = self.keys_dir.join(format!("{}.secret", key.to_hex())); |
|
| 149 | + | if !secret.is_file() { |
|
| 150 | + | rep.push( |
|
| 151 | + | "identity", |
|
| 152 | + | Health::Fail, |
|
| 153 | + | format!("the secret key for {} is missing ({})", key.short(12), secret.display()), |
|
| 154 | + | Some("copy the key file back from a backup; without it nothing can be signed with this identity".into()), |
|
| 155 | + | ); |
|
| 156 | + | return; |
|
| 157 | + | } |
|
| 158 | + | let now = Hlc::now().wall_ms; |
|
| 159 | + | match id.expires { |
|
| 160 | + | Some(t) if t <= now => rep.push( |
|
| 161 | + | "identity", |
|
| 162 | + | Health::Fail, |
|
| 163 | + | format!("{} <{}> expired", id.name, id.email), |
|
| 164 | + | Some("renew it with `oxy identity renew`".into()), |
|
| 165 | + | ), |
|
| 166 | + | Some(t) if t - now < 30 * 86_400_000 => rep.push( |
|
| 167 | + | "identity", |
|
| 168 | + | Health::Warn, |
|
| 169 | + | format!( |
|
| 170 | + | "{} <{}> expires in {} days", |
|
| 171 | + | id.name, |
|
| 172 | + | id.email, |
|
| 173 | + | (t - now) / 86_400_000 |
|
| 174 | + | ), |
|
| 175 | + | Some("renew it with `oxy identity renew`".into()), |
|
| 176 | + | ), |
|
| 177 | + | _ => rep.push( |
|
| 178 | + | "identity", |
|
| 179 | + | Health::Ok, |
|
| 180 | + | format!("{} <{}> ({})", id.name, id.email, key.short(12)), |
|
| 181 | + | None, |
|
| 182 | + | ), |
|
| 183 | + | } |
|
| 184 | + | } |
|
| 185 | + | ||
| 186 | + | fn doctor_author(&self, rep: &mut DoctorReport) { |
|
| 187 | + | let defaulted = |
|
| 188 | + | self.config.author_email.ends_with("@localhost") || self.config.author_name.is_empty(); |
|
| 189 | + | if defaulted { |
|
| 190 | + | rep.push( |
|
| 191 | + | "author", |
|
| 192 | + | Health::Warn, |
|
| 193 | + | format!("changes are authored as {} <{}>, a default", self.config.author_name, self.config.author_email), |
|
| 194 | + | Some("set yours with `oxy config --user author.name NAME` and `oxy config --user author.email EMAIL`".into()), |
|
| 195 | + | ); |
|
| 196 | + | } else { |
|
| 197 | + | rep.push( |
|
| 198 | + | "author", |
|
| 199 | + | Health::Ok, |
|
| 200 | + | format!("{} <{}>", self.config.author_name, self.config.author_email), |
|
| 201 | + | None, |
|
| 202 | + | ); |
|
| 203 | + | } |
|
| 204 | + | } |
|
| 205 | + | ||
| 206 | + | fn doctor_remote(&self, rep: &mut DoctorReport, name: &str, online: bool) { |
|
| 207 | + | let area = format!("remote {name}"); |
|
| 208 | + | let url = &self.config.remotes[name]; |
|
| 209 | + | let opts = self.remote_options(name); |
|
| 210 | + | let http = url.starts_with("http://") || url.starts_with("https://"); |
|
| 211 | + | let hint = |h: &str| Some(h.replace("<remote>", name)); |
|
| 212 | + | if url.starts_with("http://") && !is_loopback(url) { |
|
| 213 | + | rep.push( |
|
| 214 | + | &area, |
|
| 215 | + | Health::Warn, |
|
| 216 | + | format!("{url} is plain HTTP: the token travels unencrypted"), |
|
| 217 | + | hint("use the hub's https:// address"), |
|
| 218 | + | ); |
|
| 219 | + | } |
|
| 220 | + | if http && opts.token.is_none() { |
|
| 221 | + | rep.push( |
|
| 222 | + | &area, |
|
| 223 | + | Health::Warn, |
|
| 224 | + | "no token: fetching public repositories works, pushing will not", |
|
| 225 | + | hint("set one with `oxy config remote.<remote>.token TOKEN`"), |
|
| 226 | + | ); |
|
| 227 | + | } |
|
| 228 | + | if let Some(ca) = &opts.ca { |
|
| 229 | + | if !ca.is_file() { |
|
| 230 | + | rep.push(&area, Health::Fail, format!("the CA file {} does not exist", ca.display()), hint("download the hub's certificate from /.well-known/oxyhub-ca.pem and run `oxy config remote.<remote>.ca FILE`")); |
|
| 231 | + | return; |
|
| 232 | + | } |
|
| 233 | + | } |
|
| 234 | + | if !online { |
|
| 235 | + | rep.push(&area, Health::Ok, format!("{url} (not contacted)"), None); |
|
| 236 | + | return; |
|
| 237 | + | } |
|
| 238 | + | let reached = crate::protocol::open_remote(url, &opts).and_then(|r| r.repo_id()); |
|
| 239 | + | match reached { |
|
| 240 | + | Ok(theirs) if theirs == crate::protocol::UNBOUND_ID => rep.push( |
|
| 241 | + | &area, |
|
| 242 | + | Health::Ok, |
|
| 243 | + | format!("{url} is reachable and empty; the first push binds it"), |
|
| 244 | + | None, |
|
| 245 | + | ), |
|
| 246 | + | Ok(theirs) if self.id().is_ok_and(|mine| mine == theirs) => rep.push( |
|
| 247 | + | &area, |
|
| 248 | + | Health::Ok, |
|
| 249 | + | format!("{url} is reachable and is this repository"), |
|
| 250 | + | None, |
|
| 251 | + | ), |
|
| 252 | + | Ok(_) => rep.push( |
|
| 253 | + | &area, |
|
| 254 | + | Health::Fail, |
|
| 255 | + | format!("{url} is a different repository"), |
|
| 256 | + | hint("check the URL; each repository has its own id"), |
|
| 257 | + | ), |
|
| 258 | + | Err(e) => { |
|
| 259 | + | let msg = e.to_string(); |
|
| 260 | + | let first = msg.lines().next().unwrap_or(&msg).to_string(); |
|
| 261 | + | rep.push( |
|
| 262 | + | &area, |
|
| 263 | + | Health::Fail, |
|
| 264 | + | first, |
|
| 265 | + | crate::protocol::hint_for(&msg).map(|h| h.replace("<remote>", name)), |
|
| 266 | + | ); |
|
| 267 | + | } |
|
| 268 | + | } |
|
| 269 | + | } |
|
| 270 | + | } |
|
| 271 | + | ||
| 272 | + | fn is_loopback(url: &str) -> bool { |
|
| 273 | + | let host = url |
|
| 274 | + | .split("://") |
|
| 275 | + | .nth(1) |
|
| 276 | + | .unwrap_or("") |
|
| 277 | + | .split(['/', ':']) |
|
| 278 | + | .next() |
|
| 279 | + | .unwrap_or(""); |
|
| 280 | + | host == "localhost" || host == "127.0.0.1" || host == "[::1]" |
|
| 281 | + | } |
modified crates/oxy-core/src/protocol.rs +46 −8
| … | 298 unchanged lines |
||
| 299 | 299 | } |
|
| 300 | 300 | Err(ureq::Error::Status(code, r)) => { |
|
| 301 | 301 | let text = r.into_string().unwrap_or_default(); |
|
| 302 | - | Err(RepoError::Msg(format!( |
|
| 303 | - | "{url}: HTTP {code}: {}", |
|
| 304 | - | text.trim() |
|
| 305 | - | ))) |
|
| 302 | + | let msg = format!("{url}: HTTP {code}: {}", text.trim()); |
|
| 303 | + | Err(RepoError::Msg(with_hint(msg))) |
|
| 306 | 304 | } |
|
| 307 | - | Err(e) => Err(RepoError::Msg(format!("{url}: {e}"))), |
|
| 305 | + | // The transport error already names the URL. |
|
| 306 | + | Err(e) => Err(RepoError::Msg(with_hint(e.to_string()))), |
|
| 308 | 307 | } |
|
| 309 | 308 | } |
|
| 310 | 309 | } |
|
| 311 | 310 | ||
| 311 | + | /// What to do about a failed request, recognised from its message. |
|
| 312 | + | pub fn hint_for(message: &str) -> Option<&'static str> { |
|
| 313 | + | let m = message.to_ascii_lowercase(); |
|
| 314 | + | let hint = if m.contains("http 401") { |
|
| 315 | + | "the server did not accept the token: set one with `oxy config remote.<remote>.token TOKEN` (a personal access token; pushing needs repo:write)" |
|
| 316 | + | } else if m.contains("http 403") { |
|
| 317 | + | "the token or account lacks permission: pushing needs a token with repo:write and write access to the repository" |
|
| 318 | + | } else if m.contains("http 404") { |
|
| 319 | + | "nothing at this URL: check the owner and repository name, or create the repository on the hub first" |
|
| 320 | + | } else if m.contains("unknownissuer") |
|
| 321 | + | || m.contains("unknown issuer") |
|
| 322 | + | || m.contains("certificate") |
|
| 323 | + | || m.contains("invalidcertificate") |
|
| 324 | + | { |
|
| 325 | + | "the server's certificate is not trusted: for a hub with a self-signed certificate, download it from /.well-known/oxyhub-ca.pem on the hub and run `oxy config remote.<remote>.ca FILE`" |
|
| 326 | + | } else if m.contains("connection refused") { |
|
| 327 | + | "nothing is listening there: check the URL and port, and that the server is running" |
|
| 328 | + | } else if m.contains("dns") |
|
| 329 | + | || m.contains("failed to lookup") |
|
| 330 | + | || m.contains("name or service not known") |
|
| 331 | + | { |
|
| 332 | + | "the host name does not resolve: check the URL" |
|
| 333 | + | } else if m.contains("timed out") || m.contains("timeout") { |
|
| 334 | + | "the server did not answer in time: check the network, or try again" |
|
| 335 | + | } else { |
|
| 336 | + | return None; |
|
| 337 | + | }; |
|
| 338 | + | Some(hint) |
|
| 339 | + | } |
|
| 340 | + | ||
| 341 | + | /// `message`, followed by what to do about it when that is known. |
|
| 342 | + | pub fn with_hint(message: String) -> String { |
|
| 343 | + | match hint_for(&message) { |
|
| 344 | + | Some(h) => format!("{message}\n hint: {h}"), |
|
| 345 | + | None => message, |
|
| 346 | + | } |
|
| 347 | + | } |
|
| 348 | + | ||
| 312 | 349 | /// An authenticated GET of any URL on a host, with the same TLS trust and |
|
| 313 | 350 | /// token as remotes use (for hub APIs: proposals). Returns the body. |
|
| 314 | 351 | pub fn http_get(url: &str, options: &RemoteOptions) -> Result<Vec<u8>> { |
|
| … | 14 unchanged lines |
||
| 329 | 366 | } |
|
| 330 | 367 | Err(ureq::Error::Status(code, r)) => { |
|
| 331 | 368 | let text = r.into_string().unwrap_or_default(); |
|
| 332 | - | Err(RepoError::Msg(format!( |
|
| 369 | + | Err(RepoError::Msg(with_hint(format!( |
|
| 333 | 370 | "{url}: HTTP {code}: {}", |
|
| 334 | 371 | text.trim() |
|
| 335 | - | ))) |
|
| 372 | + | )))) |
|
| 336 | 373 | } |
|
| 337 | - | Err(e) => Err(RepoError::Msg(format!("{url}: {e}"))), |
|
| 374 | + | // The transport error already names the URL. |
|
| 375 | + | Err(e) => Err(RepoError::Msg(with_hint(e.to_string()))), |
|
| 338 | 376 | } |
|
| 339 | 377 | } |
|
| 340 | 378 | ||
| … | 675 unchanged lines |
modified crates/oxy-core/src/repo/mod.rs +3 −1
| … | 61 unchanged lines |
||
| 62 | 62 | mod checkout; |
|
| 63 | 63 | mod checkpoint; |
|
| 64 | 64 | mod config; |
|
| 65 | + | mod doctor; |
|
| 65 | 66 | mod fsck; |
|
| 66 | 67 | mod gc; |
|
| 67 | 68 | mod git; |
|
| … | 15 unchanged lines |
||
| 83 | 84 | pub use bookmarks::{BookmarkInfo, MergeReport}; |
|
| 84 | 85 | pub use checkout::CheckoutReport; |
|
| 85 | 86 | pub use config::{parse_size, read_config_file, Config}; |
|
| 87 | + | pub use doctor::{Check, DoctorReport, Health}; |
|
| 86 | 88 | pub use fsck::{FsckReport, Problem, Severity}; |
|
| 87 | 89 | pub use gc::GcReport; |
|
| 88 | 90 | pub use git::{GitExportReport, GitImportReport}; |
|
| … | 295 unchanged lines |
||
| 384 | 386 | dir = d.parent(); |
|
| 385 | 387 | } |
|
| 386 | 388 | fail(format!( |
|
| 387 | - | "no oxy repository found at or above {}", |
|
| 389 | + | "no oxy repository found at or above {}; start one with `oxy init` or `oxy clone URL`", |
|
| 388 | 390 | start.display() |
|
| 389 | 391 | )) |
|
| 390 | 392 | } |
|
| … | 977 unchanged lines |
modified crates/oxy-core/src/repo/sync.rs +4 −4
| … | 726 unchanged lines |
||
| 727 | 727 | )); |
|
| 728 | 728 | } |
|
| 729 | 729 | fail(format!( |
|
| 730 | - | "remote {remote_name:?} is a different repository (repository IDs differ)" |
|
| 730 | + | "remote {remote_name:?} is a different repository (repository IDs differ); check its URL with `oxy remote list`" |
|
| 731 | 731 | )) |
|
| 732 | 732 | } |
|
| 733 | 733 | ||
| 734 | - | fn remote_options(&self, name: &str) -> RemoteOptions { |
|
| 734 | + | pub(crate) fn remote_options(&self, name: &str) -> RemoteOptions { |
|
| 735 | 735 | RemoteOptions { |
|
| 736 | 736 | repo_id: self.id().ok(), |
|
| 737 | 737 | token: self |
|
| … | 275 unchanged lines |
||
| 1013 | 1013 | .ok_or_else(|| RepoError::Msg("attestation carries no signature".into()))?; |
|
| 1014 | 1014 | if !p.authorizes(&signer, &r.name) { |
|
| 1015 | 1015 | return fail(format!( |
|
| 1016 | - | "{remote_name}/{}: attested by {}, which the policy does not authorize", |
|
| 1016 | + | "{remote_name}/{}: attested by {}, which the policy does not authorize for this bookmark; a policy owner can grant it with `oxy policy grant`", |
|
| 1017 | 1017 | r.name, |
|
| 1018 | 1018 | signer.short(12) |
|
| 1019 | 1019 | )); |
|
| … | 284 unchanged lines |
||
| 1304 | 1304 | for h in &remote_heads { |
|
| 1305 | 1305 | if !self.store.has(h) { |
|
| 1306 | 1306 | return fail(format!( |
|
| 1307 | - | "the remote has change {} which you do not; pull first", |
|
| 1307 | + | "the remote has change {} which you do not; pull first (`oxy pull {remote_name}`), then push again", |
|
| 1308 | 1308 | h.short(12) |
|
| 1309 | 1309 | )); |
|
| 1310 | 1310 | } |
|
| … | 192 unchanged lines |
modified crates/oxy-core/src/repo/tests.rs +99 −0
| … | 4200 unchanged lines |
||
| 4201 | 4201 | ); |
|
| 4202 | 4202 | assert_eq!(repo.fsck().unwrap().errors(), 0); |
|
| 4203 | 4203 | } |
|
| 4204 | + | ||
| 4205 | + | #[test] |
|
| 4206 | + | fn failed_requests_come_with_a_next_step() { |
|
| 4207 | + | use crate::protocol::hint_for; |
|
| 4208 | + | assert!(hint_for("https://h/x/oxy/v1/push: HTTP 401: bad token") |
|
| 4209 | + | .unwrap() |
|
| 4210 | + | .contains("token")); |
|
| 4211 | + | assert!(hint_for("…: HTTP 403: forbidden") |
|
| 4212 | + | .unwrap() |
|
| 4213 | + | .contains("repo:write")); |
|
| 4214 | + | assert!(hint_for("…: HTTP 404: no such repository") |
|
| 4215 | + | .unwrap() |
|
| 4216 | + | .contains("owner and repository name")); |
|
| 4217 | + | assert!(hint_for("invalid peer certificate: UnknownIssuer") |
|
| 4218 | + | .unwrap() |
|
| 4219 | + | .contains("oxyhub-ca.pem")); |
|
| 4220 | + | assert!(hint_for("Connect error: Connection refused (os error 111)") |
|
| 4221 | + | .unwrap() |
|
| 4222 | + | .contains("running")); |
|
| 4223 | + | assert!(hint_for("Dns Failed: failed to lookup address information") |
|
| 4224 | + | .unwrap() |
|
| 4225 | + | .contains("resolve")); |
|
| 4226 | + | assert_eq!(hint_for("something else entirely"), None); |
|
| 4227 | + | } |
|
| 4228 | + | ||
| 4229 | + | #[test] |
|
| 4230 | + | fn doctor_names_problems_and_what_to_do() { |
|
| 4231 | + | hermetic(); |
|
| 4232 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 4233 | + | let root = dir.path(); |
|
| 4234 | + | let origin = root.join("origin"); |
|
| 4235 | + | Repo::init_bare_unbound(&origin).unwrap(); |
|
| 4236 | + | let mut repo = Repo::init(&root.join("r")) |
|
| 4237 | + | .unwrap() |
|
| 4238 | + | .with_home(root.join("home")); |
|
| 4239 | + | let find = |r: &DoctorReport, area: &str, h: Health| { |
|
| 4240 | + | r.checks |
|
| 4241 | + | .iter() |
|
| 4242 | + | .find(|c| c.area == area && c.health == h) |
|
| 4243 | + | .cloned() |
|
| 4244 | + | }; |
|
| 4245 | + | ||
| 4246 | + | let r = repo.doctor(true); |
|
| 4247 | + | assert!(find(&r, "identity", Health::Warn) |
|
| 4248 | + | .unwrap() |
|
| 4249 | + | .hint |
|
| 4250 | + | .unwrap() |
|
| 4251 | + | .contains("oxy identity create")); |
|
| 4252 | + | assert!(find(&r, "repository", Health::Ok).is_some()); |
|
| 4253 | + | ||
| 4254 | + | repo.create_identity("Ada", "[email protected]", IdentityScope::Repo) |
|
| 4255 | + | .unwrap(); |
|
| 4256 | + | repo.config.set("author.name", "Ada").unwrap(); |
|
| 4257 | + | repo.config.set("author.email", "[email protected]").unwrap(); |
|
| 4258 | + | repo.config |
|
| 4259 | + | .set("remote.origin.url", &origin.to_string_lossy()) |
|
| 4260 | + | .unwrap(); |
|
| 4261 | + | repo.config |
|
| 4262 | + | .set("remote.down.url", "http://127.0.0.1:9/a/b") |
|
| 4263 | + | .unwrap(); |
|
| 4264 | + | repo.config.set("remote.down.token", "t").unwrap(); |
|
| 4265 | + | repo.config |
|
| 4266 | + | .set("remote.badca.url", "https://example.invalid/a/b") |
|
| 4267 | + | .unwrap(); |
|
| 4268 | + | repo.config |
|
| 4269 | + | .set( |
|
| 4270 | + | "remote.badca.ca", |
|
| 4271 | + | &root.join("missing.pem").to_string_lossy(), |
|
| 4272 | + | ) |
|
| 4273 | + | .unwrap(); |
|
| 4274 | + | repo.save_config().unwrap(); |
|
| 4275 | + | ||
| 4276 | + | let r = repo.doctor(true); |
|
| 4277 | + | assert!(find(&r, "identity", Health::Ok).is_some(), "{r:?}"); |
|
| 4278 | + | assert!(find(&r, "author", Health::Ok).is_some(), "{r:?}"); |
|
| 4279 | + | assert!(find(&r, "remote origin", Health::Ok) |
|
| 4280 | + | .unwrap() |
|
| 4281 | + | .message |
|
| 4282 | + | .contains("first push binds it")); |
|
| 4283 | + | let down = find(&r, "remote down", Health::Fail).unwrap(); |
|
| 4284 | + | assert!( |
|
| 4285 | + | down.hint.as_deref().unwrap_or("").contains("running"), |
|
| 4286 | + | "{down:?}" |
|
| 4287 | + | ); |
|
| 4288 | + | let badca = find(&r, "remote badca", Health::Fail).unwrap(); |
|
| 4289 | + | assert!( |
|
| 4290 | + | badca.hint.unwrap().contains("remote.badca.ca"), |
|
| 4291 | + | "the remote's own name is in the hint" |
|
| 4292 | + | ); |
|
| 4293 | + | assert_eq!(r.worst(), Health::Fail); |
|
| 4294 | + | ||
| 4295 | + | // Offline, nothing is contacted. |
|
| 4296 | + | let r = repo.doctor(false); |
|
| 4297 | + | assert!(find(&r, "remote down", Health::Fail).is_none()); |
|
| 4298 | + | assert!(find(&r, "remote down", Health::Ok) |
|
| 4299 | + | .unwrap() |
|
| 4300 | + | .message |
|
| 4301 | + | .contains("not contacted")); |
|
| 4302 | + | } |
modified docs/guide.md +1 −0
| … | 400 unchanged lines |
||
| 401 | 401 | oxy gc # pack loose objects, prune unreachable ones past the grace period |
|
| 402 | 402 | oxy gc --now # ignore the grace period (only when nothing else is writing) |
|
| 403 | 403 | oxy fsck # check every object, the op log, references, signatures, checkpoints |
|
| 404 | + | oxy doctor # check identity, config and remotes, with what to do about each problem |
|
| 404 | 405 | oxy checkpoint # serialise the bookmark's state so large histories load instantly |
|
| 405 | 406 | ``` |
|
| 406 | 407 | ||
| … | 24 unchanged lines |
modified docs/spec.md +6 −1
| … | 792 unchanged lines |
||
| 793 | 793 | ||
| 794 | 794 | `oxy fsck` reads everything and changes nothing. It checks (1) every stored object: header, length, ObjectRef, and that the body decodes as its type; (2) the operation log: every operation reachable from the head through all parents is present and decodes, and no operation's clock is earlier than its parent's (a warning); (3) the current view's bookmarks, tags, remote bookmarks, workspaces and policy, and the dependency closure of every change they name, with each change's author identity, blobs and chunks, and each committed change's signature or seal (§14.4); (4) that every bookmark forms a valid state, that every checkpoint restores exactly the state of the changes it names (§11), and that reference and tag attestations carry valid signatures. A change beyond the shallow boundary (§17) may be absent; a blob absent from a partial clone (`filter.max-blob` or a sparse filter) is a warning, not an error. Each problem names the object it concerns. The exit status is 1 when any error is found and 0 otherwise; `--json` gives the counts and problems. |
|
| 795 | 795 | ||
| 796 | + | ### 21.2 Doctor |
|
| 797 | + | ||
| 798 | + | `oxy doctor` checks what commonly stands between a user and a working repository and says, for each problem, what to do next: the operation log opens and no lock was left by a dead process; the signing identity exists, its secret key file is present, and it has not expired (a warning within 30 days); the author name and email are not the defaults; and for each remote, that plain `http://` is only used on loopback, a token is set, the CA file exists, and (unless `--offline`) the remote answers and is this repository or an unbound one. Failures of remote requests anywhere in `oxy` carry the same next step (an untrusted certificate, a rejected or insufficient token, no repository at the URL, nothing listening, an unknown host, a timeout). The exit status is 1 when a check fails; `--json` gives the checks. |
|
| 799 | + | ||
| 796 | 800 | ## 22. Client profiles and retro hardware |
|
| 797 | 801 | ||
| 798 | 802 | | | Full | Lite | Thin | |
|
| … | 49 unchanged lines |
||
| 848 | 852 | oxy checkpoint oxy gc [--now] oxy config [--user] KEY VALUE |
|
| 849 | 853 | oxy root oxy cat PATH [-r CHANGE] oxy version |
|
| 850 | 854 | oxy fsck [--json] check integrity (§21.1) |
|
| 855 | + | oxy doctor [--offline] [--json] check identity, config and remotes (§21.2) |
|
| 851 | 856 | oxy hunks oxy discard PATH.. | --hunks N,M |
|
| 852 | 857 | ``` |
|
| 853 | 858 | ||
| … | 75 unchanged lines |
||
| 929 | 934 | ||
| 930 | 935 | --- |
|
| 931 | 936 | ||
| 932 | - | *Changelog.* Draft 0.8h (2026-09-28): `oxy fsck` (§21.1); durability rules and the operation head as commit point (§13.6); the operation head moves by compare-and-swap and concurrent operations are merged (§12.1). Draft 0.8g (2026-09-27): delegates of a rule's identities may sign reference attestations (§14.5); local-path pushes bind unbound repositories like HTTP ones. Draft 0.8f (2026-09-27): challenge signatures (§14.3) for hosts. Draft 0.8e (2026-09-27): anonymous changes (zero author) are authenticated by a seal from any key, so imported and pre-identity history can be pushed under a policy (§14.4). Draft 0.8d (2026-09-27): unbound hosted repositories adopt the first pusher's id (§16.1). Draft 0.8c (2026-09-27): bare repositories (`oxy init --bare`, §12.2: no workspace, no working copy; hosting) and the reference server as an embeddable library (§15.1). Draft 0.8b (2026-09-27): the machine interface for editors (§24): `root`, `cat`, `version`, `hunks`, `discard`, and `--json` on the reporting commands. |
|
| 937 | + | *Changelog.* Draft 0.8h (2026-09-28): `oxy fsck` (§21.1); durability rules and the operation head as commit point (§13.6); the operation head moves by compare-and-swap and concurrent operations are merged (§12.1); `oxy doctor` (§21.2). Draft 0.8g (2026-09-27): delegates of a rule's identities may sign reference attestations (§14.5); local-path pushes bind unbound repositories like HTTP ones. Draft 0.8f (2026-09-27): challenge signatures (§14.3) for hosts. Draft 0.8e (2026-09-27): anonymous changes (zero author) are authenticated by a seal from any key, so imported and pre-identity history can be pushed under a policy (§14.4). Draft 0.8d (2026-09-27): unbound hosted repositories adopt the first pusher's id (§16.1). Draft 0.8c (2026-09-27): bare repositories (`oxy init --bare`, §12.2: no workspace, no working copy; hosting) and the reference server as an embeddable library (§15.1). Draft 0.8b (2026-09-27): the machine interface for editors (§24): `root`, `cat`, `version`, `hunks`, `discard`, and `--json` on the reporting commands. |
|
| 933 | 938 | ||
| 934 | 939 | Draft 0.8a (2026-09-27): every Draft 0.8 addition is implemented in the reference implementation (§26.1). Clarifications from doing so: an unregistered nested repository is left alone and a tracked subrepo whose directory is absent keeps its pin (§7.8); Oxygen pins are omitted from Git export (§23). |
|
| 935 | 940 | ||
| … | 59 unchanged lines |
modified src/cli/mod.rs +9 −0
| … | 206 unchanged lines |
||
| 207 | 207 | Undo, |
|
| 208 | 208 | /// Write a checkpoint of the current bookmark's state now |
|
| 209 | 209 | Checkpoint, |
|
| 210 | + | /// Check the identity, configuration and remotes, and say what to do about any problem |
|
| 211 | + | Doctor { |
|
| 212 | + | /// Do not contact the remotes |
|
| 213 | + | #[arg(long)] |
|
| 214 | + | offline: bool, |
|
| 215 | + | #[arg(long)] |
|
| 216 | + | json: bool, |
|
| 217 | + | }, |
|
| 210 | 218 | /// Check the repository's integrity: objects, operation log, references, signatures, checkpoints |
|
| 211 | 219 | Fsck { |
|
| 212 | 220 | #[arg(long)] |
|
| … | 473 unchanged lines |
||
| 686 | 694 | Cmd::Undo => workspace::undo(), |
|
| 687 | 695 | Cmd::Gc { now } => workspace::gc(now), |
|
| 688 | 696 | Cmd::Fsck { json } => workspace::fsck(json), |
|
| 697 | + | Cmd::Doctor { offline, json } => workspace::doctor(offline, json), |
|
| 689 | 698 | Cmd::Checkpoint => workspace::checkpoint(), |
|
| 690 | 699 | Cmd::Op { |
|
| 691 | 700 | cmd: OpCmd::Log { json }, |
|
| … | 133 unchanged lines |
modified src/cli/workspace.rs +71 −0
| … | 1011 unchanged lines |
||
| 1012 | 1012 | Ok(0) |
|
| 1013 | 1013 | } |
|
| 1014 | 1014 | ||
| 1015 | + | /// `oxy doctor`: exit 1 when a check fails, 0 otherwise (warnings allowed). |
|
| 1016 | + | pub fn doctor(offline: bool, as_json: bool) -> Result<i32> { |
|
| 1017 | + | use oxy_core::repo::Health; |
|
| 1018 | + | let repo = open()?; |
|
| 1019 | + | let sp = ui::spinner(if offline { |
|
| 1020 | + | "Checking…" |
|
| 1021 | + | } else { |
|
| 1022 | + | "Checking, and contacting remotes…" |
|
| 1023 | + | }); |
|
| 1024 | + | let r = repo.doctor(!offline); |
|
| 1025 | + | sp.clear(); |
|
| 1026 | + | let code = if r.worst() == Health::Fail { 1 } else { 0 }; |
|
| 1027 | + | let word = |h: Health| match h { |
|
| 1028 | + | Health::Ok => "ok", |
|
| 1029 | + | Health::Warn => "warning", |
|
| 1030 | + | Health::Fail => "fail", |
|
| 1031 | + | }; |
|
| 1032 | + | if as_json { |
|
| 1033 | + | let checks: Vec<String> = r |
|
| 1034 | + | .checks |
|
| 1035 | + | .iter() |
|
| 1036 | + | .map(|c| { |
|
| 1037 | + | json::obj([ |
|
| 1038 | + | ("area", json::s(&c.area)), |
|
| 1039 | + | ("health", json::s(word(c.health))), |
|
| 1040 | + | ("message", json::s(&c.message)), |
|
| 1041 | + | ("hint", json::opt(c.hint.as_deref(), json::s)), |
|
| 1042 | + | ]) |
|
| 1043 | + | }) |
|
| 1044 | + | .collect(); |
|
| 1045 | + | println!( |
|
| 1046 | + | "{}", |
|
| 1047 | + | json::obj([("ok", json::b(code == 0)), ("checks", json::arr(checks))]) |
|
| 1048 | + | ); |
|
| 1049 | + | return Ok(code); |
|
| 1050 | + | } |
|
| 1051 | + | let mut last = ""; |
|
| 1052 | + | for c in &r.checks { |
|
| 1053 | + | if c.area != last { |
|
| 1054 | + | println!("{}", ui::section(&c.area)); |
|
| 1055 | + | last = &c.area; |
|
| 1056 | + | } |
|
| 1057 | + | let mark = match c.health { |
|
| 1058 | + | Health::Ok => ui::ok(ui::glyph("✔", "v")), |
|
| 1059 | + | Health::Warn => ui::caution(), |
|
| 1060 | + | Health::Fail => ui::err(ui::glyph("✖", "x")), |
|
| 1061 | + | }; |
|
| 1062 | + | println!(" {mark} {}", c.message); |
|
| 1063 | + | if let Some(h) = &c.hint { |
|
| 1064 | + | println!(" {} {}", ui::dim("→"), ui::dim(h)); |
|
| 1065 | + | } |
|
| 1066 | + | } |
|
| 1067 | + | let fails = r.checks.iter().filter(|c| c.health == Health::Fail).count(); |
|
| 1068 | + | let warns = r.checks.iter().filter(|c| c.health == Health::Warn).count(); |
|
| 1069 | + | if fails + warns == 0 { |
|
| 1070 | + | println!("{}", ui::done("Everything looks right.")); |
|
| 1071 | + | } else { |
|
| 1072 | + | println!( |
|
| 1073 | + | "{} {}, {}.", |
|
| 1074 | + | if fails > 0 { |
|
| 1075 | + | ui::err(ui::glyph("✖", "x")) |
|
| 1076 | + | } else { |
|
| 1077 | + | ui::caution() |
|
| 1078 | + | }, |
|
| 1079 | + | plural(fails, "problem"), |
|
| 1080 | + | plural(warns, "warning") |
|
| 1081 | + | ); |
|
| 1082 | + | } |
|
| 1083 | + | Ok(code) |
|
| 1084 | + | } |
|
| 1085 | + | ||
| 1015 | 1086 | /// `oxy fsck`: exit 0 when sound (warnings allowed), 1 on any error. |
|
| 1016 | 1087 | pub fn fsck(as_json: bool) -> Result<i32> { |
|
| 1017 | 1088 | use oxy_core::repo::Severity; |
|
| … | 629 unchanged lines |