diff --git a/Cargo.lock b/Cargo.lock index aff5c80..bcccaab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1891,6 +1891,7 @@ dependencies = [ "clap", "clap_complete", "clap_mangen", + "graft-auth", "graft-client", "graft-engine", "graft-lsp", diff --git a/README.md b/README.md index b0fed75..0962a49 100644 --- a/README.md +++ b/README.md @@ -171,6 +171,29 @@ graft is early. The design is [docs/design.md](docs/design.md); work lands on short-lived branches through the merge queue. See [CONTRIBUTING.md](CONTRIBUTING.md). +## Documentation + +User guide: + +- [Concepts](docs/guide/concepts.md): glossary and the git-to-graft table. +- [MCP](docs/guide/mcp.md): every MCP tool, with its arguments and results. +- [Server](docs/guide/server.md): config, tokens, repositories, lines, metrics and upgrades. +- [Git migration](docs/guide/git-migration.md): import, follow GitHub, batch pull requests and export. +- [FAQ](docs/guide/faq.md): git compatibility, offline use, lines, CI and the licence. +- [Benchmarks](docs/guide/benchmarks.md): how the published timings were measured. +- [Agents](docs/agents.md): how an agent session uses graft. + +[The same index](docs/guide/README.md) lives with the guide. + +## How it is built + +- [Design](docs/design.md) +- [Plan](docs/plan.md) +- [Decisions](docs/decisions.md) +- [Status](docs/status.md) +- [Trials](docs/trials) +- [Notes](docs/notes) + ## Licence [AGPL-3.0-only](LICENSE). diff --git a/changes/WI-2538.md b/changes/WI-2538.md new file mode 100644 index 0000000..a0fc9b5 --- /dev/null +++ b/changes/WI-2538.md @@ -0,0 +1,3 @@ +# WI-2538: graft doctor checks config, server, endpoint, protocol and token + +`graft doctor` prints `ok`, `fail` or `skip` for the user config, the resolved server, one unauthenticated default-line read, protocol compatibility, and a peeked token (expiry and scopes only). `--json` prints `{checks: [{name, ok, status, detail}]}` with `status` of `pass`, `fail` or `skip`. Exit 0 when every check passes, 1 when any fails or is skipped, and 2 for a usage error or an unreadable working copy before the checks run. The token is never printed. diff --git a/changes/WI-2549.md b/changes/WI-2549.md new file mode 100644 index 0000000..6609d23 --- /dev/null +++ b/changes/WI-2549.md @@ -0,0 +1 @@ +- User guide pages are indexed from the README and `docs/guide/README.md`, with the engineering record under "How it is built". `scripts/check-guide-names.sh` fails when the guide names an estate host, tracker, trial or internal id. diff --git a/crates/graft-cli/Cargo.toml b/crates/graft-cli/Cargo.toml index 33abbc0..74a99c7 100644 --- a/crates/graft-cli/Cargo.toml +++ b/crates/graft-cli/Cargo.toml @@ -21,6 +21,7 @@ base64.workspace = true clap.workspace = true clap_complete.workspace = true clap_mangen.workspace = true +graft-auth.workspace = true graft-client.workspace = true graft-mirror = { path = "../graft-mirror" } graft-node = { path = "../graft-node" } diff --git a/crates/graft-cli/src/cli.rs b/crates/graft-cli/src/cli.rs index d8d5620..4a7eb98 100644 --- a/crates/graft-cli/src/cli.rs +++ b/crates/graft-cli/src/cli.rs @@ -62,6 +62,14 @@ pub(crate) enum Command { Logout(LogoutArgs), /// Inspect user configuration Config(ConfigArgs), + /// Check config, server, endpoint, protocol and token + /// + /// Exit status: + /// + /// * 0 every check passes + /// * 1 any check fails or is skipped + /// * 2 a usage error, or the working copy cannot be read, before the checks run + Doctor, /// Create a local repository and working copy Init(InitArgs), /// Open a workspace and lay out a sparse working copy diff --git a/crates/graft-cli/src/cmd/doctor.rs b/crates/graft-cli/src/cmd/doctor.rs new file mode 100644 index 0000000..f20932f --- /dev/null +++ b/crates/graft-cli/src/cmd/doctor.rs @@ -0,0 +1,369 @@ +//! `graft doctor`: config, server, endpoint, protocol and token checks. +//! +//! The endpoint probe calls `resolve_ref` with no token, so an expired +//! token does not hide a server that is down. Claims are peeked only to +//! show expiry and scopes. The token itself is never printed. + +use std::time::{SystemTime, UNIX_EPOCH}; + +use graft_auth::{Claims, Token}; +use graft_client::{Endpoint, Remote}; +use graft_proto::PROTOCOL_VERSION; +use graft_proto::api::RepoApi; +use graft_proto::context::{CallContext, Principal, PrincipalKind}; +use graft_proto::types::ResolveRef; +use serde_json::{Value, json}; + +use crate::cli::{DEFAULT_LINE, Global}; +use crate::config::{self, UserConfig}; +use crate::conn::{self, WcView, resolve_connection_from_sources}; +use crate::error::Failure; +use crate::render; + +struct Check { + name: &'static str, + status: Status, + detail: String, +} + +enum Status { + Ok, + Fail, + Skip, +} + +impl Status { + fn label(&self) -> &'static str { + match self { + Self::Ok => "ok", + Self::Fail => "fail", + Self::Skip => "skip", + } + } + + fn json_status(&self) -> &'static str { + match self { + Self::Ok => "pass", + Self::Fail => "fail", + Self::Skip => "skip", + } + } + + fn passed(&self) -> bool { + matches!(self, Self::Ok) + } +} + +/// Runs the checks. Exit 0 only when every check passes. +pub(crate) fn run(g: &Global) -> Result<(), Failure> { + let mut checks = Vec::new(); + let Some(config) = config_check(&mut checks) else { + return finish(g, checks); + }; + let wc = conn::working_copy_info()?; + let Some(resolved) = server_check(g, wc.as_ref(), &config, &mut checks) else { + skip(&mut checks, "endpoint", "no server"); + skip(&mut checks, "protocol", "no server"); + skip(&mut checks, "token", "no server"); + return finish(g, checks); + }; + if !endpoint_check(&resolved, &mut checks) { + skip(&mut checks, "protocol", "endpoint unreachable"); + skip(&mut checks, "token", "endpoint unreachable"); + return finish(g, checks); + } + token_check(g, wc.as_ref(), &config, &resolved.server, &mut checks)?; + finish(g, checks) +} + +fn config_check(checks: &mut Vec) -> Option { + let path = match config::config_path() { + Ok(path) => path, + Err(failure) => { + push(checks, "config", Status::Fail, failure.to_string()); + return None; + } + }; + if !path.is_file() { + push( + checks, + "config", + Status::Fail, + format!("user config is missing at {}", path.display()), + ); + return None; + } + match config::load() { + Ok(config) => { + push(checks, "config", Status::Ok, path.display().to_string()); + Some(config) + } + Err(failure) => { + push(checks, "config", Status::Fail, failure.to_string()); + None + } + } +} + +struct Resolved { + endpoint: Endpoint, + server: Option, +} + +fn server_check( + g: &Global, + wc: Option<&impl WcView>, + user: &UserConfig, + checks: &mut Vec, +) -> Option { + let endpoint_override = endpoint_override(g); + let selected = g.server.as_deref().or_else(|| { + wc.and_then(WcView::server) + .or(user.default_server.as_deref()) + }); + let named = selected.and_then(|name| user.servers.get(name)); + let legacy = wc + .filter(|view| view.server().is_none()) + .and_then(|view| view.config().endpoint.as_deref()) + .map(Endpoint::parse); + if endpoint_override.is_none() && legacy.is_none() && selected.is_some() && named.is_none() { + let name = selected.unwrap_or_default(); + push( + checks, + "server", + Status::Fail, + format!("server {name} is not configured; run `graft login {name} --endpoint URL`"), + ); + return None; + } + let named_endpoint = named.map(|server| Endpoint::parse(&server.endpoint)); + match resolve_connection_from_sources( + wc.map(|view| (view.config(), view.server())), + selected, + named_endpoint.as_ref(), + legacy, + endpoint_override, + ) { + Ok((endpoint, server)) => { + let detail = match &server { + Some(name) => format!("{name} at {endpoint}"), + None => endpoint.to_string(), + }; + push(checks, "server", Status::Ok, detail); + Some(Resolved { endpoint, server }) + } + Err(failure) => { + push(checks, "server", Status::Fail, failure.to_string()); + None + } + } +} + +fn endpoint_override(g: &Global) -> Option { + g.endpoint.as_deref().map(Endpoint::parse).or_else(|| { + std::env::var(graft_client::ENDPOINT_ENV) + .ok() + .filter(|value| !value.trim().is_empty()) + .map(|value| Endpoint::parse(&value)) + }) +} + +fn endpoint_check(resolved: &Resolved, checks: &mut Vec) -> bool { + let endpoint = resolved.endpoint.clone(); + let shown = endpoint.to_string(); + let probed = conn::runtime().and_then(|rt| { + rt.block_on(async move { + let remote = Remote::connect(endpoint).await?; + remote + .resolve_ref(&probe_context(), probe_request()) + .await + .map(|_| ()) + .map_err(graft_client::Error::from) + }) + .map_err(Failure::from) + }); + match probed { + Ok(()) => { + push( + checks, + "endpoint", + Status::Ok, + format!("reachable at {shown}"), + ); + push(checks, "protocol", Status::Ok, protocol_ok()); + true + } + Err(failure) => classify_probe(&failure, &shown, checks), + } +} + +fn probe_context() -> CallContext { + CallContext { + principal: Principal { + subject: String::new(), + kind: PrincipalKind::Human, + session: None, + work_item: None, + scopes: Vec::new(), + }, + request_id: "doctor".to_owned(), + deadline_ms: None, + } +} + +fn probe_request() -> ResolveRef { + ResolveRef { + repo: String::new(), + name: DEFAULT_LINE.to_owned(), + } +} + +fn protocol_ok() -> String { + format!( + "client {}.{} binds", + PROTOCOL_VERSION.major, PROTOCOL_VERSION.minor + ) +} + +fn classify_probe(failure: &Failure, endpoint: &str, checks: &mut Vec) -> bool { + if failure.kind() == "unavailable" { + push( + checks, + "endpoint", + Status::Fail, + format!("server down or unreachable at {endpoint}"), + ); + return false; + } + if failure.kind() == "incompatible_protocol" { + push( + checks, + "endpoint", + Status::Ok, + format!("reachable at {endpoint}"), + ); + let server = failure + .to_string() + .split_whitespace() + .find(|word| word.chars().next().is_some_and(|c| c.is_ascii_digit())) + .unwrap_or("unknown") + .to_owned(); + push( + checks, + "protocol", + Status::Fail, + format!( + "client {}.{} server {server}; upgrade graft", + PROTOCOL_VERSION.major, PROTOCOL_VERSION.minor + ), + ); + return true; + } + // Any other typed answer means the server spoke this protocol. + push( + checks, + "endpoint", + Status::Ok, + format!("reachable at {endpoint}"), + ); + push(checks, "protocol", Status::Ok, protocol_ok()); + true +} + +fn token_check( + g: &Global, + wc: Option<&impl WcView>, + user: &UserConfig, + server: &Option, + checks: &mut Vec, +) -> Result<(), Failure> { + let settings = conn::resolve_settings(g, wc, user)?; + let Some(raw) = settings.token.filter(|token| !token.is_empty()) else { + let where_ = server.as_deref().unwrap_or("the resolved server"); + push( + checks, + "token", + Status::Fail, + format!("no token for {where_}; run `graft login` again"), + ); + return Ok(()); + }; + let claims = match Claims::peek_unverified(&Token::from_encoded(raw)) { + Ok(claims) => claims, + Err(_) => { + push( + checks, + "token", + Status::Fail, + "token is malformed; run `graft login` again".to_owned(), + ); + return Ok(()); + } + }; + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| i64::try_from(duration.as_secs()).unwrap_or(i64::MAX)) + .unwrap_or(0); + if claims.exp <= now { + push( + checks, + "token", + Status::Fail, + "token expired; run `graft login` again".to_owned(), + ); + return Ok(()); + } + let scopes = if claims.principal.scopes.is_empty() { + "none".to_owned() + } else { + claims.principal.scopes.join(",") + }; + push( + checks, + "token", + Status::Ok, + format!("expires {} scopes {scopes}", claims.exp), + ); + Ok(()) +} + +fn skip(checks: &mut Vec, name: &'static str, detail: &str) { + push(checks, name, Status::Skip, detail.to_owned()); +} + +fn push(checks: &mut Vec, name: &'static str, status: Status, detail: String) { + checks.push(Check { + name, + status, + detail, + }); +} + +fn finish(g: &Global, checks: Vec) -> Result<(), Failure> { + let ok = checks.iter().all(|check| check.status.passed()); + if g.json { + let rows: Vec = checks + .iter() + .map(|check| { + json!({ + "name": check.name, + "ok": check.status.passed(), + "status": check.status.json_status(), + "detail": check.detail, + }) + }) + .collect(); + render::print_json(&json!({ "checks": rows }))?; + } else { + for check in &checks { + outln!("{} {} {}", check.status.label(), check.name, check.detail); + } + } + if ok { + Ok(()) + } else { + // The report is already printed. A second error line would mix + // shapes, and under --json it would not be one JSON document. + Err(Failure::silent(crate::Exit::Failure)) + } +} diff --git a/crates/graft-cli/src/cmd/mod.rs b/crates/graft-cli/src/cmd/mod.rs index 937b1b9..6289ad7 100644 --- a/crates/graft-cli/src/cmd/mod.rs +++ b/crates/graft-cli/src/cmd/mod.rs @@ -10,6 +10,7 @@ pub(crate) mod commit; pub(crate) mod completions; pub(crate) mod config; pub(crate) mod diff; +pub(crate) mod doctor; pub(crate) mod init; pub(crate) mod land; pub(crate) mod line; diff --git a/crates/graft-cli/src/conn.rs b/crates/graft-cli/src/conn.rs index 4c1c05e..8e9a781 100644 --- a/crates/graft-cli/src/conn.rs +++ b/crates/graft-cli/src/conn.rs @@ -299,41 +299,57 @@ fn resolve_settings_from_sources( )); } let named_endpoint = named.map(|server| Endpoint::parse(&server.endpoint)); + let (endpoint, server) = resolve_connection_from_sources( + wc, + selected_server, + named_endpoint.as_ref(), + legacy_endpoint, + endpoint_override, + )?; + let token = match token_override { + Some(token) => Some(token), + None => server + .as_ref() + .and(named) + .map(|server| token_from_file(&server.token_file)) + .transpose()?, + }; + Ok(ResolvedSettings { + server, + endpoint, + token, + }) +} + +pub(crate) fn resolve_connection_from_sources( + wc: Option<(&WcConfig, Option<&str>)>, + selected_server: Option<&str>, + named_endpoint: Option<&Endpoint>, + legacy_endpoint: Option, + endpoint_override: Option, +) -> Result<(Endpoint, Option), Failure> { let endpoint = endpoint_override .clone() .or_else(|| { wc.and_then(|(_, server)| server) - .and(named_endpoint.clone()) + .and(named_endpoint.cloned()) }) .or_else(|| { let socket = Path::new(NODE_SOCKET); socket.exists().then(|| Endpoint::NodeSocket(socket.to_owned())) }) .or(legacy_endpoint) - .or(named_endpoint.clone()) + .or(named_endpoint.cloned()) .ok_or_else(|| { Failure::usage( "no endpoint: pass --endpoint, set GRAFT_ENDPOINT, run `graft login`, or use a configured working copy", ) })?; let server = named_endpoint - .as_ref() .filter(|named_endpoint| **named_endpoint == endpoint) .and(selected_server) .map(str::to_owned); - let token = match token_override { - Some(token) => Some(token), - None => server - .as_ref() - .and(named) - .map(|server| token_from_file(&server.token_file)) - .transpose()?, - }; - Ok(ResolvedSettings { - server, - endpoint, - token, - }) + Ok((endpoint, server)) } /// The bearer token from `--token-file`, else `GRAFT_TOKEN`. Surrounding diff --git a/crates/graft-cli/src/main.rs b/crates/graft-cli/src/main.rs index 842256c..3a7454e 100644 --- a/crates/graft-cli/src/main.rs +++ b/crates/graft-cli/src/main.rs @@ -267,6 +267,7 @@ fn dispatch(cli: &Cli) -> Result<(), Failure> { Command::Login(args) => cmd::login::run(g, args), Command::Logout(args) => cmd::login::logout(g, args), Command::Config(args) => cmd::config::run(g, args), + Command::Doctor => cmd::doctor::run(g), Command::Init(args) => cmd::init::run(g, args), Command::Clone(args) => cmd::clone::run(g, args), Command::Status(args) => cmd::status::run(g, args), diff --git a/crates/graft-cli/tests/cli.rs b/crates/graft-cli/tests/cli.rs index 564d47a..b08c1bd 100644 --- a/crates/graft-cli/tests/cli.rs +++ b/crates/graft-cli/tests/cli.rs @@ -118,6 +118,7 @@ fn help_is_public_facing_for_every_command() { &["login"], &["logout"], &["config"], + &["doctor"], &["init"], &["mcp"], &["lsp"], diff --git a/crates/graft-cli/tests/completions.rs b/crates/graft-cli/tests/completions.rs index d42ca66..41a6619 100644 --- a/crates/graft-cli/tests/completions.rs +++ b/crates/graft-cli/tests/completions.rs @@ -45,6 +45,14 @@ fn completions_cover_public_commands_and_options() { "{shell} completion exposed hidden command {hidden}" ); } + if shell == "bash" { + for hidden in HIDDEN_COMMANDS { + assert!( + !script.split_whitespace().any(|word| word == *hidden), + "bash completion exposed hidden command {hidden}" + ); + } + } assert_no_internal_references(&["completions", shell], &script); } } diff --git a/crates/graft-cli/tests/doctor.rs b/crates/graft-cli/tests/doctor.rs new file mode 100644 index 0000000..0a390e3 --- /dev/null +++ b/crates/graft-cli/tests/doctor.rs @@ -0,0 +1,217 @@ +//! `graft doctor` against the testkit server. + +mod support; + +use std::fs; +use std::os::unix::fs::PermissionsExt; + +use graft_auth::{Claims, SigningKey, Token, mint, new_jti}; +use graft_proto::context::{Principal, PrincipalKind}; +use serde_json::Value; +use support::{graft, json_out, scratch, serve}; + +const TOKEN: &str = "doctor-token-must-stay-hidden"; + +fn write_token(path: &std::path::Path, raw: &str) { + fs::write(path, raw).unwrap(); + let mut perms = fs::metadata(path).unwrap().permissions(); + perms.set_mode(0o600); + fs::set_permissions(path, perms).unwrap(); +} + +fn minted(exp: i64) -> String { + let key = SigningKey::generate().expect("key"); + let claims = Claims { + principal: Principal { + subject: "doctor".into(), + kind: PrincipalKind::Human, + session: None, + work_item: None, + scopes: vec!["repo:demo:read".into()], + }, + exp, + jti: new_jti().expect("jti"), + }; + mint(&key, &claims).expect("mint").expose().to_owned() +} + +fn login(home: &std::path::Path, config: &std::path::Path, url: &str, token: &str) { + graft(home) + .env("GRAFT_CONFIG", config) + .args(["login", "dev", "--endpoint", url]) + .write_stdin(format!("{token}\n")) + .assert() + .success(); +} + +fn assert_redacted(text: &str) { + assert!(!text.contains(TOKEN), "doctor output contained the token"); +} + +#[test] +fn healthy_server_every_check_ok() { + let served = serve(); + let home = scratch(); + let config = home.path().join("config.toml"); + let raw = minted(4_102_444_800); + login(home.path(), &config, &served.url, &raw); + let out = graft(home.path()) + .env("GRAFT_CONFIG", &config) + .args(["doctor"]) + .assert() + .success(); + let text = String::from_utf8(out.get_output().stdout.clone()).unwrap(); + for name in ["config", "server", "endpoint", "protocol", "token"] { + assert!( + text.lines() + .any(|line| line.starts_with(&format!("ok {name} "))), + "{text}" + ); + } + assert!(text.contains("scopes repo:demo:read"), "{text}"); + assert_redacted(&text); + assert_redacted(&String::from_utf8_lossy(&out.get_output().stderr)); + let _ = Token::from_encoded(raw); +} + +#[test] +fn stopped_server_names_the_endpoint() { + let url = "http://127.0.0.1:1".to_owned(); + let home = scratch(); + let config = home.path().join("config.toml"); + let token_path = home.path().join("tokens").join("dev"); + fs::create_dir_all(token_path.parent().unwrap()).unwrap(); + write_token(&token_path, TOKEN); + let body = format!( + "default_server = \"dev\"\n\n[servers.dev]\nendpoint = \"{url}\"\ntoken_file = \"{}\"\n", + token_path.display() + ); + fs::write(&config, body).unwrap(); + let out = graft(home.path()) + .env("GRAFT_CONFIG", &config) + .args(["doctor"]) + .assert() + .code(1); + let text = String::from_utf8(out.get_output().stdout.clone()).unwrap(); + assert!( + text.contains(&format!( + "fail endpoint server down or unreachable at {url}" + )), + "{text}" + ); + assert!(text.contains("skip protocol"), "{text}"); + assert!(text.contains("skip token"), "{text}"); + assert_redacted(&text); + assert_redacted(&String::from_utf8_lossy(&out.get_output().stderr)); +} + +#[test] +fn expired_token_asks_to_log_in_again() { + let served = serve(); + let home = scratch(); + let config = home.path().join("config.toml"); + login(home.path(), &config, &served.url, TOKEN); + let parsed: toml::Value = toml::from_str(&fs::read_to_string(&config).unwrap()).unwrap(); + let token_path = std::path::PathBuf::from( + parsed["servers"]["dev"]["token_file"] + .as_str() + .expect("token file"), + ); + write_token(&token_path, &minted(1)); + let out = graft(home.path()) + .env("GRAFT_CONFIG", &config) + .args(["doctor"]) + .assert() + .code(1); + let text = String::from_utf8(out.get_output().stdout.clone()).unwrap(); + assert!( + text.contains("fail token token expired; run `graft login` again"), + "{text}" + ); + assert!(text.contains("ok endpoint"), "{text}"); + assert_redacted(&text); + assert_redacted(&String::from_utf8_lossy(&out.get_output().stderr)); +} + +#[test] +fn json_lists_checks_and_redacts_the_token() { + let served = serve(); + let home = scratch(); + let config = home.path().join("config.toml"); + let raw = minted(4_102_444_800); + login(home.path(), &config, &served.url, &raw); + let out = graft(home.path()) + .env("GRAFT_CONFIG", &config) + .args(["--json", "doctor"]) + .assert() + .success(); + let value: Value = json_out(out.get_output()); + let checks = value["checks"].as_array().expect("checks"); + assert_eq!(checks.len(), 5); + assert!(checks.iter().all(|check| check["ok"] == true)); + let rendered = value.to_string(); + assert_redacted(&rendered); + assert_redacted(&String::from_utf8_lossy(&out.get_output().stderr)); + assert!(!rendered.contains(&raw)); +} + +#[test] +fn help_lists_exit_status_and_json_status() { + let out = graft(scratch().path()) + .args(["doctor", "--help"]) + .assert() + .success(); + let text = String::from_utf8(out.get_output().stdout.clone()).unwrap(); + assert!(text.contains("Exit status"), "{text}"); + assert!(text.contains("0 every check passes"), "{text}"); + assert!(text.contains("1 any check fails or is skipped"), "{text}"); + assert!( + text.contains("2 a usage error, or the working copy cannot be read"), + "{text}" + ); + + let served = serve(); + let home = scratch(); + let config = home.path().join("config.toml"); + login(home.path(), &config, &served.url, &minted(4_102_444_800)); + let out = graft(home.path()) + .env("GRAFT_CONFIG", &config) + .args(["--json", "doctor"]) + .assert() + .success(); + let value: Value = json_out(out.get_output()); + let checks = value["checks"].as_array().expect("checks"); + assert!( + checks.iter().all(|check| check["status"] == "pass"), + "{value}" + ); + + let url = "http://127.0.0.1:1".to_owned(); + let home = scratch(); + let config = home.path().join("config.toml"); + let token_path = home.path().join("tokens").join("dev"); + fs::create_dir_all(token_path.parent().unwrap()).unwrap(); + write_token(&token_path, TOKEN); + let body = format!( + "default_server = \"dev\"\n\n[servers.dev]\nendpoint = \"{url}\"\ntoken_file = \"{}\"\n", + token_path.display() + ); + fs::write(&config, body).unwrap(); + let out = graft(home.path()) + .env("GRAFT_CONFIG", &config) + .args(["--json", "doctor"]) + .assert() + .code(1); + let value: Value = json_out(out.get_output()); + let by_name: std::collections::BTreeMap<_, _> = value["checks"] + .as_array() + .expect("checks") + .iter() + .map(|check| (check["name"].as_str().unwrap().to_owned(), check.clone())) + .collect(); + assert_eq!(by_name["endpoint"]["status"], "fail"); + assert_eq!(by_name["endpoint"]["ok"], false); + assert_eq!(by_name["protocol"]["status"], "skip"); + assert_eq!(by_name["token"]["status"], "skip"); + assert_eq!(by_name["config"]["status"], "pass"); +} diff --git a/crates/graft-cli/tests/snapshots/cli__top_level_help.snap b/crates/graft-cli/tests/snapshots/cli__top_level_help.snap index 9ab3352..f49ab36 100644 --- a/crates/graft-cli/tests/snapshots/cli__top_level_help.snap +++ b/crates/graft-cli/tests/snapshots/cli__top_level_help.snap @@ -1,6 +1,6 @@ --- source: crates/graft-cli/tests/cli.rs -assertion_line: 154 +assertion_line: 155 expression: text(out.get_output()) --- The graft command-line client. @@ -11,6 +11,7 @@ Commands: login Store credentials for a named server, or list configured servers logout Remove a named server and its stored token config Inspect user configuration + doctor Check config, server, endpoint, protocol and token init Create a local repository and working copy clone Open a workspace and lay out a sparse working copy status Working-copy changes against the head draft, else the base (no server needed) diff --git a/docs/guide/README.md b/docs/guide/README.md new file mode 100644 index 0000000..e79f2e9 --- /dev/null +++ b/docs/guide/README.md @@ -0,0 +1,23 @@ +# User guide + +- [Concepts](concepts.md): glossary and the git-to-graft table. +- [MCP](mcp.md): every MCP tool, with its arguments and results. +- [Server](server.md): config, tokens, repositories, lines, metrics and upgrades. +- [Git migration](git-migration.md): import, follow GitHub, batch pull requests and export. +- [FAQ](faq.md): git compatibility, offline use, lines, CI and the licence. +- [Benchmarks](benchmarks.md): how the published timings were measured. +- [Bench chart](bench.svg): the rendered timings the methodology describes. +- [Agents](../agents.md): how an agent session uses graft. + +The same list is in the repository [README](../../README.md#documentation). + +## How it is built + +Engineering record, not part of the user guide: + +- [Design](../design.md) +- [Plan](../plan.md) +- [Decisions](../decisions.md) +- [Status](../status.md) +- [Trials](../trials) +- [Notes](../notes) diff --git a/docs/guide/bench.svg b/docs/guide/bench.svg index 12eedf3..982ce70 100644 --- a/docs/guide/bench.svg +++ b/docs/guide/bench.svg @@ -80,7 +80,7 @@ rdpapp-split: 9 PRs, clean 1=1, conflicts 8=8, rt 17/19 rdpapp-stack: 11 PRs, clean 0=0, conflicts 1=1, rt 2/2 fluent2-registration: 34 PRs, clean 18=18, conflicts 16=16, rt 50/64 -rusttorrent-qbit: 4 PRs, clean 0=0, conflicts 4=4, rt 8/8 +qbit-importer: 4 PRs, clean 0=0, conflicts 4=4, rt 8/8 graft-merge-back: 57 PRs, clean 39=39, conflicts 18=18, rt 75/153 Branching strategies @@ -88,6 +88,6 @@ trunk: round trips 85 / 60, conflict blocks 4 / 5 gitflow: round trips 293 / 154, conflict blocks 29 / 18 release: round trips 173 / 106, conflict blocks 11 / 11 -Not on this chart: the WI-2341 twelve-run read-path medians (D31 floor). They are in docs/status.md and docs/decisions.md only, not in a results file. +Not on this chart: the twelve-run read-path medians (the read-latency floor). They are in the engineering record only, not in a results file. diff --git a/docs/guide/benchmarks.md b/docs/guide/benchmarks.md index eb55d5c..0072dbb 100644 --- a/docs/guide/benchmarks.md +++ b/docs/guide/benchmarks.md @@ -25,9 +25,9 @@ as a speed change. ## What is not on the chart -The twelve-run read-path medians recorded by WI-2341 on 2026-10-24 are -not in any file under `bench/results/`. They are written in -[docs/status.md](../status.md) and [docs/decisions.md](../decisions.md) (D31): +The twelve-run read-path medians recorded on 2026-10-24 are +not in any file under `bench/results/`. They are written in the engineering +record listed under "How it is built": | | 1k graft / git | 10k graft / git | | --- | ---: | ---: | @@ -48,17 +48,17 @@ graft is faster on full clone, sparse clone, `log --path` and blame at both recorded scales, and faster on concurrent clean lands at 10k (238 ms vs 365 ms). It is slower on `log -50`, status and, at 10k, sync. -Read latency is a no-regression floor, not a phase-1 target (D31, -2026-10-25). No read path may be slower than the WI-2341 medians above, -and every operation stays O(change). Being 1.3× to 2.4× slower than git -on log, status and sync in the single-run files is recorded, not a -defect to close in phase 1. 2× faster than git is a later-phase target. -WI-2400 is wont_do. +Read latency is a no-regression floor, not a phase-1 target +(recorded 2026-10-25). No read path may be slower than the twelve-run +medians above, and every operation stays O(change). Being 1.3× to 2.4× +slower than git on log, status and sync in the single-run files is +recorded, not a defect to close in phase 1. 2× faster than git is a +later-phase target. Closing that gap is out of scope for phase 1. ## Real-PR replay `bench/results/2026-10-24-real-prs/results.json`, 2026-10-24, 115 PRs in -five cases (rdpapp split and stack, fluent2 registration, rustTorrent +five cases (rdpapp split and stack, fluent2 registration, the qBittorrent importer qBittorrent, graft merge-back). Clean merges and conflicts match git on every case: 58 clean, 47 conflicts, graft-only and git-only clean both zero. One case (`rdpapp-stack`) has no `agreement` object; its graft and @@ -73,8 +73,8 @@ The chart uses the agreement block. `bench/results/2026-10-24-branching/results.json`, seed 2362. The chart shows round trips and conflict blocks for trunk, git flow and release -branches, graft-tracked against git. This file predates merge points -(WI-2372). [docs/status.md](../status.md) records the later hand-off: with merge points, +branches, graft-tracked against git. This file predates merge points. +The engineering record lists the later hand-off: with merge points, graft reports the same conflicts as git (18 of 18 blocks) and merges `release/1.1` into `main` clean. That later run is not in this file, so it is not on the chart. The file will be regenerated; until then the diff --git a/scripts/check-guide-names.sh b/scripts/check-guide-names.sh new file mode 100755 index 0000000..f7a73ac --- /dev/null +++ b/scripts/check-guide-names.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# Fail if the user guide names estate hosts, trackers, or internal ids. +# Run by hand. Not part of CI, and not part of graft's land path. +set -euo pipefail +root=$(cd "$(dirname "$0")/.." && pwd) +cd "$root" +shopt -s nullglob +files=(docs/guide/* docs/agents.md) +pattern='graft-dev|node-b|hetzner|hertz|proxmox|edgerouter|komodo|cadastre|infisical|winrarhost|vogt|trial-[0-9]|rusttorrent|\bWI-[0-9]+|\bD[0-9]{2}\b|\bG[0-9]\.[0-9]+' +if grep -nEiH "$pattern" "${files[@]}"; then + exit 1 +fi