//! The fsck run: `git fsck` on every repo the database lists, then `PRAGMA integrity_check` on the //! live database. Used by the scheduler, the admin page, and the CLI and internal route. use forge_core::{Result, Store}; use forge_git::GitEnv; use serde::{Deserialize, Serialize}; /// Longest git output kept per repo in a problem line. const MAX_OUTPUT_CHARS: usize = 2000; /// Outcome of [`run_fsck`]. `problems` lists one line per failed repo, plus one for the database /// when its check fails. The run is clean when the list is empty. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct FsckSummary { pub full: bool, pub repos: usize, pub problems: Vec, } impl FsckSummary { pub fn is_ok(&self) -> bool { self.problems.is_empty() } } /// Checks every repo and the database. A problem with one repo does not stop the others. Only a /// failure to query the database or to start git is an error. pub async fn run_fsck(store: &Store, git: &GitEnv, full: bool) -> Result { let ids = store.repo_ids().await?; let mut problems = Vec::new(); for id in &ids { let outcome = match forge_git::repo::repo_path(&git.data_dir, id) { Ok(path) => forge_git::fsck::fsck(git, &path, full).await, Err(e) => Err(e), }; match outcome { Ok(report) if report.ok => {} Ok(report) => problems.push(format!( "repo {id}: {}", truncate(&report.output, MAX_OUTPUT_CHARS) )), Err(e) => problems.push(format!("repo {id}: {e}")), } // A wiki that exists is checked the same way, as its own repo. if let Ok(wiki) = forge_git::repo::wiki_path(&git.data_dir, id) && wiki.join("HEAD").is_file() { match forge_git::fsck::fsck(git, &wiki, full).await { Ok(report) if report.ok => {} Ok(report) => problems.push(format!( "repo {id} wiki: {}", truncate(&report.output, MAX_OUTPUT_CHARS) )), Err(e) => problems.push(format!("repo {id} wiki: {e}")), } } } let db = store.integrity_check().await?; if db.as_slice() != ["ok"] { problems.push(format!( "database: {}", truncate(&db.join("; "), MAX_OUTPUT_CHARS) )); } Ok(FsckSummary { full, repos: ids.len(), problems, }) } /// The first `max` characters of `text`, with an ellipsis when cut. fn truncate(text: &str, max: usize) -> String { if text.chars().count() <= max { return text.to_owned(); } let mut cut: String = text.chars().take(max).collect(); cut.push('…'); cut } #[cfg(test)] mod tests { use super::*; use forge_core::model::Visibility; use forge_git::repo::{init_bare, repo_path}; #[tokio::test] async fn clean_instance_passes_and_a_damaged_repo_is_reported() { let dir = tempfile::tempdir().expect("tempdir"); let data_dir = dir.path().join("data"); std::fs::create_dir_all(&data_dir).expect("data dir"); let store = Store::open(&data_dir.join("forge.db")).await.expect("open"); let alice = store .create_user("alice", None, "hash", false) .await .expect("user"); let git = GitEnv { git_bin: "git".into(), data_dir: data_dir.clone(), }; let repo = store .create_repo(&alice.id, "demo", "", Visibility::Private, "main") .await .expect("repo"); let path = repo_path(&data_dir, &repo.id).expect("path"); init_bare(&git, &path, "main").await.expect("init"); let clean = run_fsck(&store, &git, false).await.expect("fsck"); assert_eq!(clean.repos, 1); assert!(clean.is_ok(), "{:?}", clean.problems); // Remove the repo from disk: the run reports it and keeps going. std::fs::remove_dir_all(&path).expect("remove"); let broken = run_fsck(&store, &git, true).await.expect("fsck"); assert!(broken.full); assert_eq!(broken.problems.len(), 1); assert!(broken.problems[0].starts_with(&format!("repo {}:", repo.id))); } #[test] fn truncate_keeps_short_text_and_cuts_long_text_on_char_boundaries() { assert_eq!(truncate("abc", 3), "abc"); assert_eq!(truncate("héllo", 2), "hé…"); } }