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
| 1 | // Scheduled maintenance. |
| 2 | // |
| 3 | // Runs in its own process (`<binary> jobs`) behind its own service unit, not in the |
| 4 | // web entrypoint — the house rule, and here it also means a long repack cannot stall |
| 5 | // request handling. |
| 6 | // |
| 7 | // Three jobs, each of which fixes something the service otherwise leaks forever: |
| 8 | // garbage collection (loose objects accumulate at roughly three per commit), |
| 9 | // the deleted-repository sweep (a soft delete reclaims nothing without it), and |
| 10 | // fsck (metadata and disk can disagree, and nothing else reports it). |
| 11 | |
| 12 | pub mod fsck; |
| 13 | pub mod gc; |
| 14 | pub mod sweep; |
| 15 | |
| 16 | use crate::config::Config; |
| 17 | use std::sync::Arc; |
| 18 | use std::time::Instant; |
| 19 | |
| 20 | /// Run every maintenance job once. |
| 21 | pub fn run_once(config: &Config) -> Report { |
| 22 | let started = Instant::now(); |
| 23 | let gc = gc::run(config); |
| 24 | let swept = sweep::run(config); |
| 25 | let checked = fsck::run(config, false); |
| 26 | |
| 27 | Report { |
| 28 | repos_packed: gc.packed, |
| 29 | bytes_reclaimed: gc.reclaimed, |
| 30 | repos_swept: swept.removed, |
| 31 | bytes_swept: swept.bytes, |
| 32 | orphans: checked.orphans.len(), |
| 33 | elapsed_ms: started.elapsed().as_millis() as u64, |
| 34 | } |
| 35 | } |
| 36 | |
| 37 | #[derive(Debug, Default)] |
| 38 | pub struct Report { |
| 39 | pub repos_packed: usize, |
| 40 | pub bytes_reclaimed: u64, |
| 41 | pub repos_swept: usize, |
| 42 | pub bytes_swept: u64, |
| 43 | pub orphans: usize, |
| 44 | pub elapsed_ms: u64, |
| 45 | } |
| 46 | |
| 47 | impl std::fmt::Display for Report { |
| 48 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
| 49 | write!( |
| 50 | f, |
| 51 | "packed={} reclaimed={}B swept={} swept_bytes={}B orphans={} in {}ms", |
| 52 | self.repos_packed, |
| 53 | self.bytes_reclaimed, |
| 54 | self.repos_swept, |
| 55 | self.bytes_swept, |
| 56 | self.orphans, |
| 57 | self.elapsed_ms |
| 58 | ) |
| 59 | } |
| 60 | } |
| 61 | |
| 62 | /// Run maintenance on an interval until the process is stopped. |
| 63 | pub async fn serve(config: Arc<Config>) { |
| 64 | let interval = config.gc_interval; |
| 65 | eprintln!("[jobs] maintenance every {interval:?}"); |
| 66 | |
| 67 | loop { |
| 68 | let config = Arc::clone(&config); |
| 69 | let outcome = tokio::task::spawn_blocking(move || run_once(&config)).await; |
| 70 | |
| 71 | match outcome { |
| 72 | Ok(report) => eprintln!("[jobs] {report}"), |
| 73 | // A panic in one cycle must not stop the scheduler; the next cycle is |
| 74 | // the retry. |
| 75 | Err(e) => eprintln!("[jobs] cycle failed: {e}"), |
| 76 | } |
| 77 | tokio::time::sleep(interval).await; |
| 78 | } |
| 79 | } |
| 80 | |
| 81 | /// Every bare repository under the git root, as `(account, repo, path)`. |
| 82 | pub fn walk_repos(git_root: &std::path::Path) -> Vec<(String, String, std::path::PathBuf)> { |
| 83 | let mut found = Vec::new(); |
| 84 | let Ok(accounts) = std::fs::read_dir(git_root) else { |
| 85 | return found; |
| 86 | }; |
| 87 | |
| 88 | for account in accounts.flatten() { |
| 89 | if !account.path().is_dir() { |
| 90 | continue; |
| 91 | } |
| 92 | let account_name = account.file_name().to_string_lossy().to_string(); |
| 93 | |
| 94 | let Ok(repos) = std::fs::read_dir(account.path()) else { |
| 95 | continue; |
| 96 | }; |
| 97 | for repo in repos.flatten() { |
| 98 | let path = repo.path(); |
| 99 | let name = repo.file_name().to_string_lossy().to_string(); |
| 100 | let Some(stripped) = name.strip_suffix(".git") else { |
| 101 | continue; |
| 102 | }; |
| 103 | if path.join("HEAD").is_file() { |
| 104 | found.push((account_name.clone(), stripped.to_string(), path)); |
| 105 | } |
| 106 | } |
| 107 | } |
| 108 | found.sort(); |
| 109 | found |
| 110 | } |
| 111 | |
| 112 | /// Total bytes under a directory, following no symlinks. |
| 113 | pub fn dir_bytes(path: &std::path::Path) -> u64 { |
| 114 | let mut total = 0u64; |
| 115 | let mut stack = vec![path.to_path_buf()]; |
| 116 | |
| 117 | while let Some(dir) = stack.pop() { |
| 118 | let Ok(entries) = std::fs::read_dir(&dir) else { |
| 119 | continue; |
| 120 | }; |
| 121 | for entry in entries.flatten() { |
| 122 | let Ok(meta) = entry.metadata() else { continue }; |
| 123 | if meta.is_dir() { |
| 124 | stack.push(entry.path()); |
| 125 | } else if meta.is_file() { |
| 126 | total = total.saturating_add(meta.len()); |
| 127 | } |
| 128 | } |
| 129 | } |
| 130 | total |
| 131 | } |
| 132 | |
| 133 | #[cfg(test)] |
| 134 | mod tests { |
| 135 | use super::*; |
| 136 | use std::process::Command; |
| 137 | |
| 138 | #[test] |
| 139 | fn walks_only_bare_repositories() { |
| 140 | let dir = tempfile::tempdir().unwrap(); |
| 141 | let root = dir.path(); |
| 142 | |
| 143 | std::fs::create_dir_all(root.join("alice")).unwrap(); |
| 144 | Command::new("git") |
| 145 | .args(["init", "--bare", "--quiet"]) |
| 146 | .arg(root.join("alice").join("site.git")) |
| 147 | .status() |
| 148 | .unwrap(); |
| 149 | |
| 150 | // A directory that is not a repository must be ignored, not walked into. |
| 151 | std::fs::create_dir_all(root.join("alice").join("notarepo.git")).unwrap(); |
| 152 | std::fs::create_dir_all(root.join("alice").join("plain")).unwrap(); |
| 153 | |
| 154 | let found = walk_repos(root); |
| 155 | assert_eq!(found.len(), 1); |
| 156 | assert_eq!(found[0].0, "alice"); |
| 157 | assert_eq!(found[0].1, "site"); |
| 158 | } |
| 159 | |
| 160 | #[test] |
| 161 | fn an_absent_root_walks_to_nothing() { |
| 162 | assert!(walk_repos(std::path::Path::new("/nonexistent")).is_empty()); |
| 163 | } |
| 164 | |
| 165 | #[test] |
| 166 | fn dir_bytes_sums_files_recursively() { |
| 167 | let dir = tempfile::tempdir().unwrap(); |
| 168 | std::fs::write(dir.path().join("a"), b"12345").unwrap(); |
| 169 | std::fs::create_dir(dir.path().join("sub")).unwrap(); |
| 170 | std::fs::write(dir.path().join("sub").join("b"), b"123").unwrap(); |
| 171 | |
| 172 | assert_eq!(dir_bytes(dir.path()), 8); |
| 173 | } |
| 174 | } |