zuka
zuka/src/jobs/fsck.rs

worklyn / zukapublic

Agent-first git hosting. One Rust binary: git over HTTP and SSH, a REST API, MCP, CI, and multi-tenant isolation.

Get a copy: git clone https://zuka.worklyn.com/worklyn/zuka.git
zuka/src/jobs/fsck.rs
RSfsck.rs9.6 KBDownload
1// Reconciliation between metadata and disk.
2//
3// The two can disagree, and one path deliberately creates the disagreement: delete
4// moves the repository aside first and removes its metadata second, so a crash
5// between the two leaves a live record pointing at nothing. Create has the mirror
6// case — a `Creating` marker written before `git init`.
7//
8// SPEC §9.3: disk is authoritative for whether a repository exists, metadata is
9// authoritative for access control. So a repository with no record is reported and
10// left alone (its bytes are real and someone may want them back), while a record
11// with no repository is removable.
12
13use crate::config::Config;
14use crate::git::validate;
15use crate::jobs::walk_repos;
16use crate::store::fs::FsStore;
17use crate::store::RepoState;
18use serde::Serialize;
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
21#[serde(rename_all = "kebab-case")]
22pub enum Orphan {
23 /// A repository on disk that no metadata record describes.
24 RepoWithoutRecord,
25 /// A metadata record whose repository is not on disk.
26 RecordWithoutRepo,
27 /// A record left mid-create by a crash.
28 StuckCreating,
29 /// git itself reports the object graph as broken.
30 Corrupt,
31}
32
33#[derive(Debug, Serialize)]
34pub struct Finding {
35 pub kind: Orphan,
36 pub account: String,
37 pub repo: String,
38 pub detail: String,
39}
40
41#[derive(Debug, Default, Serialize)]
42pub struct Outcome {
43 pub checked: usize,
44 pub orphans: Vec<Finding>,
45 pub repaired: usize,
46}
47
48/// Compare metadata against disk. With `repair`, remove records that describe
49/// nothing — never the reverse, because deleting real bytes on a mismatch is how a
50/// reconciler turns a small inconsistency into data loss.
51pub fn run(config: &Config, repair: bool) -> Outcome {
52 let git_root = config.data_dir.join("git");
53 let Ok(meta) = FsStore::open(config.meta_dir()) else {
54 return Outcome::default();
55 };
56
57 let mut outcome = Outcome::default();
58 let on_disk = walk_repos(&git_root);
59 outcome.checked = on_disk.len();
60
61 // Disk -> metadata.
62 for (account, repo, path) in &on_disk {
63 let (Ok(a), Ok(r)) = (validate::name(account), validate::name(repo)) else {
64 outcome.orphans.push(Finding {
65 kind: Orphan::RepoWithoutRecord,
66 account: account.clone(),
67 repo: repo.clone(),
68 detail: format!("{} has a name no longer considered valid", path.display()),
69 });
70 continue;
71 };
72
73 match meta.get_repo(&a, &r) {
74 Ok(None) => outcome.orphans.push(Finding {
75 kind: Orphan::RepoWithoutRecord,
76 account: account.clone(),
77 repo: repo.clone(),
78 detail: "on disk with no metadata record; not removed".into(),
79 }),
80 Ok(Some(record)) if record.state == RepoState::Creating => {
81 outcome.orphans.push(Finding {
82 kind: Orphan::StuckCreating,
83 account: account.clone(),
84 repo: repo.clone(),
85 detail: "record still marked creating; a create crashed part-way".into(),
86 })
87 }
88 _ => {}
89 }
90
91 // Connectivity only: a full object-content check reads every byte in the
92 // repository, which is not something to do on a schedule across a whole
93 // host. A broken graph is the failure that actually loses data.
94 if let Err(e) = crate::jobs::gc::verify(path) {
95 outcome.orphans.push(Finding {
96 kind: Orphan::Corrupt,
97 account: account.clone(),
98 repo: repo.clone(),
99 detail: e.to_string().lines().next().unwrap_or("fsck failed").into(),
100 });
101 }
102 }
103
104 // Metadata -> disk.
105 let accounts: Vec<String> = on_disk
106 .iter()
107 .map(|(a, _, _)| a.clone())
108 .chain(account_dirs(&config.meta_dir()))
109 .collect::<std::collections::BTreeSet<_>>()
110 .into_iter()
111 .collect();
112
113 for account in accounts {
114 let Ok(a) = validate::name(&account) else {
115 continue;
116 };
117 let Ok(records) = meta.list_repos(&a) else {
118 continue;
119 };
120
121 for record in records {
122 let Ok(r) = validate::name(&record.name) else {
123 continue;
124 };
125 let present = git_root
126 .join(a.as_str())
127 .join(format!("{}.git", r.as_str()))
128 .join("HEAD")
129 .is_file();
130 if present {
131 continue;
132 }
133
134 outcome.orphans.push(Finding {
135 kind: Orphan::RecordWithoutRepo,
136 account: account.clone(),
137 repo: record.name.clone(),
138 detail: if repair {
139 "record described nothing on disk; removed".into()
140 } else {
141 "record describes nothing on disk; run with --repair to remove".into()
142 },
143 });
144
145 if repair {
146 match meta.delete_repo(&a, &r) {
147 Ok(_) => outcome.repaired += 1,
148 Err(e) => eprintln!("[jobs] fsck could not remove record: {e}"),
149 }
150 }
151 }
152 }
153
154 outcome
155}
156
157fn account_dirs(meta_dir: &std::path::Path) -> Vec<String> {
158 let Ok(entries) = std::fs::read_dir(meta_dir) else {
159 return Vec::new();
160 };
161 entries
162 .flatten()
163 .filter(|e| e.path().is_dir())
164 .map(|e| e.file_name().to_string_lossy().to_string())
165 .collect()
166}
167
168#[cfg(test)]
169mod tests {
170 use super::*;
171 use crate::store::RepoRecord;
172
173 fn setup() -> (tempfile::TempDir, Config) {
174 let dir = tempfile::tempdir().unwrap();
175 let mut config = Config::from_env().unwrap_or_else(|_| panic!("config"));
176 config.data_dir = dir.path().to_path_buf();
177 std::fs::create_dir_all(dir.path().join("git")).unwrap();
178 (dir, config)
179 }
180
181 fn record(account: &str, name: &str, state: RepoState) -> RepoRecord {
182 RepoRecord {
183 account: account.into(),
184 name: name.into(),
185 display_name: name.into(),
186 default_branch: "refs/heads/main".into(),
187 description: None,
188 created_at: 0,
189 state,
190 visibility: Default::default(),
191 }
192 }
193
194 fn make_repo(config: &Config, account: &str, name: &str) {
195 let path = config
196 .data_dir
197 .join("git")
198 .join(account)
199 .join(format!("{name}.git"));
200 std::fs::create_dir_all(&path).unwrap();
201 std::process::Command::new("git")
202 .args(["init", "--bare", "--quiet"])
203 .arg(&path)
204 .status()
205 .unwrap();
206 }
207
208 #[test]
209 fn a_broken_object_graph_is_reported() {
210 let (_dir, config) = setup();
211 make_repo(&config, "alice", "site");
212 FsStore::open(config.meta_dir())
213 .unwrap()
214 .put_repo(&record("alice", "site", RepoState::Ready))
215 .unwrap();
216
217 // A ref pointing at an object that does not exist.
218 let git_dir = config.data_dir.join("git/alice/site.git");
219 std::fs::create_dir_all(git_dir.join("refs/heads")).unwrap();
220 std::fs::write(
221 git_dir.join("refs/heads/broken"),
222 format!("{}\n", "0".repeat(39) + "1"),
223 )
224 .unwrap();
225
226 let outcome = run(&config, false);
227 assert!(
228 outcome.orphans.iter().any(|o| o.kind == Orphan::Corrupt),
229 "a dangling ref must be reported: {:?}",
230 outcome.orphans
231 );
232 }
233
234 #[test]
235 fn a_consistent_pair_reports_nothing() {
236 let (_dir, config) = setup();
237 make_repo(&config, "alice", "site");
238 FsStore::open(config.meta_dir())
239 .unwrap()
240 .put_repo(&record("alice", "site", RepoState::Ready))
241 .unwrap();
242
243 let outcome = run(&config, false);
244 assert_eq!(outcome.checked, 1);
245 assert!(outcome.orphans.is_empty(), "{:?}", outcome.orphans);
246 }
247
248 #[test]
249 fn a_repository_with_no_record_is_reported_and_kept() {
250 let (_dir, config) = setup();
251 make_repo(&config, "alice", "site");
252
253 let outcome = run(&config, true);
254 assert_eq!(outcome.orphans.len(), 1);
255 assert_eq!(outcome.orphans[0].kind, Orphan::RepoWithoutRecord);
256 assert_eq!(outcome.repaired, 0, "repair must never delete real bytes");
257 assert!(config.data_dir.join("git/alice/site.git/HEAD").is_file());
258 }
259
260 #[test]
261 fn a_record_with_no_repository_is_reported_and_removable() {
262 let (_dir, config) = setup();
263 let meta = FsStore::open(config.meta_dir()).unwrap();
264 meta.put_repo(&record("alice", "ghost", RepoState::Ready))
265 .unwrap();
266
267 let found = run(&config, false);
268 assert_eq!(found.orphans.len(), 1);
269 assert_eq!(found.orphans[0].kind, Orphan::RecordWithoutRepo);
270 assert_eq!(found.repaired, 0, "a dry run must change nothing");
271
272 let repaired = run(&config, true);
273 assert_eq!(repaired.repaired, 1);
274 assert!(
275 run(&config, false).orphans.is_empty(),
276 "repair must be durable"
277 );
278 }
279
280 #[test]
281 fn a_crash_during_create_is_reported_as_stuck() {
282 let (_dir, config) = setup();
283 make_repo(&config, "alice", "half");
284 FsStore::open(config.meta_dir())
285 .unwrap()
286 .put_repo(&record("alice", "half", RepoState::Creating))
287 .unwrap();
288
289 let outcome = run(&config, false);
290 assert_eq!(outcome.orphans.len(), 1);
291 assert_eq!(outcome.orphans[0].kind, Orphan::StuckCreating);
292 }
293}