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 }
191 }
192
193 fn make_repo(config: &Config, account: &str, name: &str) {
194 let path = config
195 .data_dir
196 .join("git")
197 .join(account)
198 .join(format!("{name}.git"));
199 std::fs::create_dir_all(&path).unwrap();
200 std::process::Command::new("git")
201 .args(["init", "--bare", "--quiet"])
202 .arg(&path)
203 .status()
204 .unwrap();
205 }
206
207 #[test]
208 fn a_broken_object_graph_is_reported() {
209 let (_dir, config) = setup();
210 make_repo(&config, "alice", "site");
211 FsStore::open(config.meta_dir())
212 .unwrap()
213 .put_repo(&record("alice", "site", RepoState::Ready))
214 .unwrap();
215
216 // A ref pointing at an object that does not exist.
217 let git_dir = config.data_dir.join("git/alice/site.git");
218 std::fs::create_dir_all(git_dir.join("refs/heads")).unwrap();
219 std::fs::write(
220 git_dir.join("refs/heads/broken"),
221 format!("{}\n", "0".repeat(39) + "1"),
222 )
223 .unwrap();
224
225 let outcome = run(&config, false);
226 assert!(
227 outcome.orphans.iter().any(|o| o.kind == Orphan::Corrupt),
228 "a dangling ref must be reported: {:?}",
229 outcome.orphans
230 );
231 }
232
233 #[test]
234 fn a_consistent_pair_reports_nothing() {
235 let (_dir, config) = setup();
236 make_repo(&config, "alice", "site");
237 FsStore::open(config.meta_dir())
238 .unwrap()
239 .put_repo(&record("alice", "site", RepoState::Ready))
240 .unwrap();
241
242 let outcome = run(&config, false);
243 assert_eq!(outcome.checked, 1);
244 assert!(outcome.orphans.is_empty(), "{:?}", outcome.orphans);
245 }
246
247 #[test]
248 fn a_repository_with_no_record_is_reported_and_kept() {
249 let (_dir, config) = setup();
250 make_repo(&config, "alice", "site");
251
252 let outcome = run(&config, true);
253 assert_eq!(outcome.orphans.len(), 1);
254 assert_eq!(outcome.orphans[0].kind, Orphan::RepoWithoutRecord);
255 assert_eq!(outcome.repaired, 0, "repair must never delete real bytes");
256 assert!(config.data_dir.join("git/alice/site.git/HEAD").is_file());
257 }
258
259 #[test]
260 fn a_record_with_no_repository_is_reported_and_removable() {
261 let (_dir, config) = setup();
262 let meta = FsStore::open(config.meta_dir()).unwrap();
263 meta.put_repo(&record("alice", "ghost", RepoState::Ready))
264 .unwrap();
265
266 let found = run(&config, false);
267 assert_eq!(found.orphans.len(), 1);
268 assert_eq!(found.orphans[0].kind, Orphan::RecordWithoutRepo);
269 assert_eq!(found.repaired, 0, "a dry run must change nothing");
270
271 let repaired = run(&config, true);
272 assert_eq!(repaired.repaired, 1);
273 assert!(
274 run(&config, false).orphans.is_empty(),
275 "repair must be durable"
276 );
277 }
278
279 #[test]
280 fn a_crash_during_create_is_reported_as_stuck() {
281 let (_dir, config) = setup();
282 make_repo(&config, "alice", "half");
283 FsStore::open(config.meta_dir())
284 .unwrap()
285 .put_repo(&record("alice", "half", RepoState::Creating))
286 .unwrap();
287
288 let outcome = run(&config, false);
289 assert_eq!(outcome.orphans.len(), 1);
290 assert_eq!(outcome.orphans[0].kind, Orphan::StuckCreating);
291 }
292}