This repository has no description
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}