This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

core / knot2 / crates / knot-git / src / maintenance.rs
13 kB 355 lines
1use std::path::Path; 2 3use gix::lock::acquire::Fail; 4use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog}; 5use gix::refs::{FullName, Target, file::transaction::PackedRefs}; 6use knot_types::UnixSeconds; 7 8use crate::error::{GitError, backend}; 9use crate::repo::{Repo, fsync_if_present}; 10 11#[derive(Debug, Clone, Copy, PartialEq, Eq)] 12pub struct PackRefsReport { 13 pub packed: usize, 14} 15 16#[derive(Debug, Clone, Copy, PartialEq, Eq)] 17pub struct ReflogReport { 18 pub files: usize, 19 pub dropped: usize, 20} 21 22impl Repo { 23 pub fn pack_refs(&self) -> Result<PackRefsReport, GitError> { 24 self.locked(|| self.pack_refs_locked()) 25 } 26 27 fn pack_refs_locked(&self) -> Result<PackRefsReport, GitError> { 28 clear_stale_ref_locks(self.git().git_dir()); 29 let references = self.git().references().map_err(backend)?; 30 let edits: Vec<RefEdit> = references 31 .all() 32 .map_err(backend)? 33 .filter_map(Result::ok) 34 .filter_map(|reference| { 35 let oid = reference.target().try_id()?.to_owned(); 36 let name: FullName = reference.name().to_owned(); 37 Some(RefEdit { 38 change: Change::Update { 39 log: LogChange { 40 mode: RefLog::AndReference, 41 force_create_reflog: false, 42 message: "knot pack-refs".into(), 43 }, 44 expected: PreviousValue::Any, 45 new: Target::Object(oid), 46 }, 47 name, 48 deref: false, 49 }) 50 }) 51 .collect(); 52 let packed = edits.len(); 53 if packed == 0 { 54 return Ok(PackRefsReport { packed }); 55 } 56 let committer: Option<gix::actor::SignatureRef<'_>> = None; 57 self.git() 58 .refs 59 .transaction() 60 .packed_refs( 61 PackedRefs::DeletionsAndNonSymbolicUpdatesRemoveLooseSourceReference(Box::new( 62 &self.git().objects, 63 )), 64 ) 65 .prepare(edits, Fail::Immediately, Fail::Immediately) 66 .map_err(backend)? 67 .commit(committer) 68 .map_err(backend)?; 69 let git_dir = self.git().git_dir(); 70 fsync_if_present(&git_dir.join("packed-refs"))?; 71 fsync_if_present(&git_dir.join("refs"))?; 72 fsync_if_present(git_dir)?; 73 Ok(PackRefsReport { packed }) 74 } 75 76 pub fn expire_reflogs(&self, floor_seconds: UnixSeconds) -> Result<ReflogReport, GitError> { 77 self.with_ref_lock(|| self.expire_reflogs_locked(floor_seconds)) 78 } 79 80 fn expire_reflogs_locked(&self, floor_seconds: UnixSeconds) -> Result<ReflogReport, GitError> { 81 let logs_dir = self.git().git_dir().join("logs"); 82 if !logs_dir.exists() { 83 return Ok(ReflogReport { 84 files: 0, 85 dropped: 0, 86 }); 87 } 88 let touched_dirs = walkdir::WalkDir::new(&logs_dir) 89 .into_iter() 90 .filter_map(Result::ok) 91 .filter(|entry| entry.file_type().is_file()) 92 .map(|entry| entry.into_path()) 93 .filter(|path| { 94 if is_maintenance_temp(path) { 95 let _ = std::fs::remove_file(path); 96 false 97 } else { 98 true 99 } 100 }) 101 .try_fold( 102 (0usize, 0usize, std::collections::BTreeSet::new()), 103 |(files, dropped, mut dirs), path| { 104 let removed = expire_reflog_file(&path, floor_seconds)?; 105 if let Some(parent) = path.parent() { 106 dirs.insert(parent.to_path_buf()); 107 } 108 Ok::<_, GitError>((files + 1, dropped + removed, dirs)) 109 }, 110 )?; 111 let (files, dropped, dirs) = touched_dirs; 112 dirs.iter().try_for_each(|dir| fsync_if_present(dir))?; 113 Ok(ReflogReport { files, dropped }) 114 } 115} 116 117fn clear_stale_ref_locks(git_dir: &Path) { 118 let _ = std::fs::remove_file(git_dir.join("packed-refs.lock")); 119 walkdir::WalkDir::new(git_dir.join("refs")) 120 .into_iter() 121 .filter_map(Result::ok) 122 .filter(|entry| entry.file_type().is_file()) 123 .filter(|entry| entry.path().extension().is_some_and(|ext| ext == "lock")) 124 .for_each(|entry| { 125 let _ = std::fs::remove_file(entry.path()); 126 }); 127} 128 129fn is_maintenance_temp(path: &Path) -> bool { 130 path.file_name() 131 .and_then(|name| name.to_str()) 132 .is_some_and(|name| name.contains(".knot-tmp.")) 133} 134 135fn expire_reflog_file(path: &Path, floor_seconds: UnixSeconds) -> Result<usize, GitError> { 136 let raw = std::fs::read(path).map_err(|error| GitError::Maintenance(error.to_string()))?; 137 if raw.is_empty() { 138 return Ok(0); 139 } 140 let lines: Vec<&[u8]> = split_keep_lines(&raw); 141 let total = lines.len(); 142 let last_index = total - 1; 143 let kept: Vec<&[u8]> = lines 144 .iter() 145 .enumerate() 146 .filter(|(index, line)| { 147 *index == last_index 148 || reflog_line_seconds(line).is_none_or(|secs| secs >= floor_seconds) 149 }) 150 .map(|(_, line)| *line) 151 .collect(); 152 let dropped = total - kept.len(); 153 if dropped == 0 { 154 return Ok(0); 155 } 156 let rewritten: Vec<u8> = kept.concat(); 157 rewrite_atomic(path, &rewritten)?; 158 Ok(dropped) 159} 160 161fn split_keep_lines(raw: &[u8]) -> Vec<&[u8]> { 162 let mut out = Vec::new(); 163 let mut start = 0usize; 164 raw.iter().enumerate().for_each(|(index, byte)| { 165 if *byte == b'\n' { 166 out.push(&raw[start..=index]); 167 start = index + 1; 168 } 169 }); 170 if start < raw.len() { 171 out.push(&raw[start..]); 172 } 173 out 174} 175 176fn reflog_line_seconds(line: &[u8]) -> Option<UnixSeconds> { 177 let tab = line.iter().position(|byte| *byte == b'\t')?; 178 let before = &line[..tab]; 179 let text = std::str::from_utf8(before).ok()?; 180 let mut tokens = text.split_whitespace().rev(); 181 let _tz = tokens.next()?; 182 tokens.next()?.parse::<i64>().ok().map(UnixSeconds::new) 183} 184 185fn rewrite_atomic(path: &Path, contents: &[u8]) -> Result<(), GitError> { 186 knot_resource::atomic_write_bytes(path, contents, knot_resource::FileMode::Inherited)?; 187 fsync_if_present(path) 188} 189 190#[cfg(test)] 191mod tests { 192 use knot_types::{AuthorName, BranchName, Email, Oid, RefName, RepoDid, UnixSeconds}; 193 194 use crate::{EntryKind, Identity, Layout, NewCommit, RefUpdate, StagedAction, StagedChange}; 195 196 // ah yes, of course, little johnny 4b 197 const EMPTY_TREE: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"; 198 199 fn identity() -> Identity { 200 Identity { 201 name: AuthorName::new("nel"), 202 email: Email::new("nel@oyster.cafe"), 203 time: UnixSeconds::new(1_700_000_000), 204 offset_seconds: 0, 205 } 206 } 207 208 fn commit_on(repo: &crate::Repo, body: u8, parent: Option<Oid>) -> Oid { 209 let tree = repo 210 .write_staged_tree( 211 Oid::from_hex(EMPTY_TREE).unwrap(), 212 &[StagedChange { 213 path: knot_types::RepoPath::new(format!("file{body}.txt")).unwrap(), 214 action: StagedAction::Put { 215 content: vec![body], 216 kind: EntryKind::Blob, 217 }, 218 }], 219 ) 220 .unwrap(); 221 repo.write_commit(&NewCommit { 222 tree, 223 parents: parent.into_iter().collect(), 224 author: identity(), 225 committer: identity(), 226 message: format!("commit {body}"), 227 extra_headers: Vec::new(), 228 }) 229 .unwrap() 230 } 231 232 #[test] 233 fn pack_refs_moves_loose_refs_into_packed_refs_and_resolves() { 234 let scan = tempfile::tempdir().unwrap(); 235 let layout = Layout::new(scan.path()).with_default_branch(BranchName::new("main").unwrap()); 236 let did = RepoDid::new("did:plc:squid").unwrap(); 237 let repo = layout.create(&did).unwrap(); 238 let main = RefName::new("refs/heads/main").unwrap(); 239 let side = RefName::new("refs/heads/side").unwrap(); 240 241 let base = commit_on(&repo, 0, None); 242 let tip = commit_on(&repo, 1, Some(base)); 243 repo.update_ref(&RefUpdate::Create { 244 name: main.clone(), 245 new: tip, 246 }) 247 .unwrap(); 248 repo.update_ref(&RefUpdate::Create { 249 name: side.clone(), 250 new: base, 251 }) 252 .unwrap(); 253 254 let git_dir = repo.git().git_dir().to_path_buf(); 255 assert!(git_dir.join("refs/heads/main").exists()); 256 257 let report = repo.pack_refs().unwrap(); 258 assert!(report.packed >= 2, "both branches are packed"); 259 assert!( 260 git_dir.join("packed-refs").exists(), 261 "packed-refs file is written" 262 ); 263 assert!( 264 !git_dir.join("refs/heads/main").exists(), 265 "loose ref file is removed once packed" 266 ); 267 assert_eq!(repo.find_ref(&main).unwrap(), Some(tip)); 268 assert_eq!(repo.find_ref(&side).unwrap(), Some(base)); 269 } 270 271 #[test] 272 fn pack_refs_clears_stale_lock_files_left_by_a_crash() { 273 let scan = tempfile::tempdir().unwrap(); 274 let layout = Layout::new(scan.path()).with_default_branch(BranchName::new("main").unwrap()); 275 let did = RepoDid::new("did:plc:barnacle").unwrap(); 276 let repo = layout.create(&did).unwrap(); 277 let main = RefName::new("refs/heads/main").unwrap(); 278 279 let tip = commit_on(&repo, 0, None); 280 repo.update_ref(&RefUpdate::Create { 281 name: main.clone(), 282 new: tip, 283 }) 284 .unwrap(); 285 286 let git_dir = repo.git().git_dir().to_path_buf(); 287 std::fs::write(git_dir.join("packed-refs.lock"), b"").unwrap(); 288 std::fs::write(git_dir.join("refs/heads/main.lock"), b"").unwrap(); 289 290 let report = repo 291 .pack_refs() 292 .expect("crashed prior run's stale locks mustn't wedge next pack-refs"); 293 assert!(report.packed >= 1); 294 assert!( 295 !git_dir.join("packed-refs.lock").exists(), 296 "stale packed-refs lock is cleared" 297 ); 298 assert!( 299 !git_dir.join("refs/heads/main.lock").exists(), 300 "stale per-ref lock is cleared" 301 ); 302 assert_eq!(repo.find_ref(&main).unwrap(), Some(tip)); 303 } 304 305 #[test] 306 fn expire_reflogs_drops_old_entries_but_keeps_the_newest() { 307 let scan = tempfile::tempdir().unwrap(); 308 let layout = Layout::new(scan.path()).with_default_branch(BranchName::new("main").unwrap()); 309 let did = RepoDid::new("did:plc:limpet").unwrap(); 310 let repo = layout.create(&did).unwrap(); 311 let main = RefName::new("refs/heads/main").unwrap(); 312 313 let c0 = commit_on(&repo, 0, None); 314 let c1 = commit_on(&repo, 1, Some(c0)); 315 let c2 = commit_on(&repo, 2, Some(c1)); 316 repo.update_ref(&RefUpdate::Create { 317 name: main.clone(), 318 new: c0, 319 }) 320 .unwrap(); 321 repo.update_ref(&RefUpdate::Update { 322 name: main.clone(), 323 old: c0, 324 new: c1, 325 }) 326 .unwrap(); 327 repo.update_ref(&RefUpdate::Update { 328 name: main.clone(), 329 old: c1, 330 new: c2, 331 }) 332 .unwrap(); 333 334 let log_path = repo.git().git_dir().join("logs/refs/heads/main"); 335 let before = std::fs::read(&log_path).unwrap(); 336 let line_count = before.iter().filter(|byte| **byte == b'\n').count(); 337 assert_eq!(line_count, 3, "three ref updates leave three reflog lines"); 338 339 let report = repo.expire_reflogs(UnixSeconds::new(i64::MAX / 2)).unwrap(); 340 assert!( 341 report.files >= 2, 342 "main branch reflog and HEAD reflog are both rewritten" 343 ); 344 assert_eq!( 345 report.dropped, 4, 346 "future floor drops all but newest line of each of two reflogs" 347 ); 348 let after = std::fs::read(&log_path).unwrap(); 349 assert_eq!(after.iter().filter(|byte| **byte == b'\n').count(), 1); 350 assert_eq!(repo.find_ref(&main).unwrap(), Some(c2)); 351 352 let untouched = repo.expire_reflogs(UnixSeconds::new(0)).unwrap(); 353 assert_eq!(untouched.dropped, 0, "zero floor keeps everything"); 354 } 355}