OXYGENHUB

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