1use std::cell::{Cell, RefCell};
29use std::path::Path;
30
31use kernel::{FileChange, GitOid, How, Lines};
32
33use crate::error::StorageError;
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum Head {
42 Commit(GitOid),
43 WorkingTree,
44}
45
46fn git_err(op: &'static str) -> impl FnOnce(git2::Error) -> StorageError {
47 move |err| StorageError::Checkpoint {
48 op,
49 detail: err.message().to_owned(),
50 }
51}
52
53pub fn between(
63 city_root: &Path,
64 base: GitOid,
65 head: Head,
66) -> Result<Vec<FileChange>, StorageError> {
67 let repo = git2::Repository::open(city_root).map_err(git_err("open the city repository"))?;
68 let find = |oid: GitOid| -> Result<git2::Tree<'_>, StorageError> {
69 let parsed =
70 git2::Oid::from_str(&oid.to_string()).map_err(git_err("parse a checkpoint"))?;
71 repo.find_commit(parsed)
72 .map_err(git_err("find a checkpoint"))?
73 .tree()
74 .map_err(git_err("read a checkpoint tree"))
75 };
76 let old = find(base)?;
77 let mut options = git2::DiffOptions::new();
78 options.context_lines(0);
81 let mut diff = match head {
82 Head::Commit(oid) => {
83 let new = find(oid)?;
84 repo.diff_tree_to_tree(Some(&old), Some(&new), Some(&mut options))
85 }
86 Head::WorkingTree => repo.diff_tree_to_workdir_with_index(Some(&old), Some(&mut options)),
90 }
91 .map_err(git_err("compare two checkpoints"))?;
92 diff.find_similar(None)
93 .map_err(git_err("look for renames"))?;
94 collect(&diff)
95}
96
97pub fn parents_of(
105 city_root: &Path,
106 oids: &[GitOid],
107) -> Result<Vec<Option<Vec<GitOid>>>, StorageError> {
108 let repo = git2::Repository::open(city_root).map_err(git_err("open the city repository"))?;
109 oids.iter()
110 .map(|oid| {
111 let parsed =
112 git2::Oid::from_str(&oid.to_string()).map_err(git_err("parse a commit"))?;
113 let commit = match repo.find_commit(parsed) {
114 Ok(commit) => commit,
115 Err(absent) if absent.code() == git2::ErrorCode::NotFound => return Ok(None),
116 Err(failed) => return Err(git_err("find a commit")(failed)),
117 };
118 commit
119 .parent_ids()
120 .map(|id| {
121 let spelled = id.to_string();
122 GitOid::parse(&spelled).ok_or_else(|| StorageError::Checkpoint {
123 op: "read a commit's parents",
124 detail: format!("git named a parent {spelled}, which is not 40 hex digits"),
125 })
126 })
127 .collect::<Result<Vec<GitOid>, StorageError>>()
128 .map(Some)
129 })
130 .collect()
131}
132
133pub(crate) fn collect(diff: &git2::Diff<'_>) -> Result<Vec<FileChange>, StorageError> {
142 let mut rows: Vec<FileChange> = Vec::new();
143 let stats: Vec<(u32, u32)> = line_counts(diff)?;
144 for (at, delta) in diff.deltas().enumerate() {
145 let Some(path) = delta
146 .new_file()
147 .path()
148 .or_else(|| delta.old_file().path())
149 .and_then(Path::to_str)
150 else {
151 continue;
155 };
156 #[expect(
157 clippy::wildcard_enum_match_arm,
158 reason = "git2::Delta is an upstream open enum; every status we do not name is a change to content"
159 )]
160 let how = match delta.status() {
161 git2::Delta::Added | git2::Delta::Copied | git2::Delta::Untracked => How::Added,
165 git2::Delta::Deleted => How::Deleted,
166 git2::Delta::Renamed => How::Renamed {
167 from: delta
168 .old_file()
169 .path()
170 .and_then(Path::to_str)
171 .unwrap_or_default()
172 .to_owned(),
173 },
174 _ => How::Modified,
175 };
176 let binary = delta.new_file().is_binary() || delta.old_file().is_binary();
179 let lines = match (binary, stats.get(at)) {
180 (false, Some(&(added, removed))) => Lines::Counted { added, removed },
181 _ => Lines::Binary,
182 };
183 rows.push(FileChange {
184 path: path.to_owned(),
185 how,
186 lines,
187 });
188 }
189 rows.sort_by(|left, right| left.path.cmp(&right.path));
190 Ok(rows)
191}
192
193fn line_counts(diff: &git2::Diff<'_>) -> Result<Vec<(u32, u32)>, StorageError> {
199 let counts = RefCell::new(vec![(0u32, 0u32); diff.deltas().len()]);
203 let at = Cell::new(0usize);
204 let started = Cell::new(false);
205 diff.foreach(
206 &mut |_, _| {
207 if started.replace(true) {
210 at.set(at.get().saturating_add(1));
211 }
212 true
213 },
214 None,
215 None,
216 Some(&mut |_, _, line| {
217 if let Ok(mut rows) = counts.try_borrow_mut()
218 && let Some(row) = rows.get_mut(at.get())
219 {
220 match line.origin() {
221 '+' => row.0 = row.0.saturating_add(1),
222 '-' => row.1 = row.1.saturating_add(1),
223 _ => {}
224 }
225 }
226 true
227 }),
228 )
229 .map_err(git_err("count the lines that moved"))?;
230 Ok(counts.into_inner())
231}
232
233#[cfg(test)]
234#[allow(
235 clippy::unwrap_used,
236 clippy::expect_used,
237 clippy::panic,
238 clippy::indexing_slicing,
239 reason = "test code"
240)]
241mod tests {
242 use super::*;
243 use kernel::TimeMs;
244
245 use crate::checkpoint::Checkpoint;
246
247 fn write(root: &Path, rel: &str, body: &str) {
248 let path = root.join(rel);
249 if let Some(parent) = path.parent() {
250 std::fs::create_dir_all(parent).unwrap();
251 }
252 std::fs::write(path, body).unwrap();
253 }
254
255 fn resident() -> crate::checkpoint::Provenance {
256 crate::checkpoint::Provenance::new(
257 kernel::RunId::CITY,
258 kernel::Address::parse("lab/parser").unwrap(),
259 kernel::B3Hash::digest(b"a city"),
260 crate::checkpoint::ModelChoice {
261 id: "test-model".to_owned(),
262 effort: None,
263 },
264 )
265 }
266
267 fn oid_of(payload: &kernel::Payload) -> GitOid {
268 let raw = serde_json::to_value(payload).unwrap()["oid"]
269 .as_str()
270 .unwrap()
271 .to_owned();
272 GitOid::parse(&raw).expect("a checkpoint names a git object")
273 }
274
275 #[test]
278 fn two_checkpoints_report_the_files_between_them_and_how_much_moved() {
279 let dir = tempfile::tempdir().unwrap();
280 let root = dir.path();
281 write(root, "lab/lex.rs", "one\ntwo\nthree\n");
282 write(root, "lab/keep.rs", "unchanged\n");
283 let mut checkpoint = Checkpoint::open(root).unwrap();
284 let base = oid_of(
285 &checkpoint
286 .wave_pre(&["lab".to_owned()], TimeMs::new(1_000), &resident())
287 .unwrap(),
288 );
289
290 write(root, "lab/lex.rs", "one\ntwo\nthree\nfour\nfive\n");
291 write(root, "lab/new.rs", "fresh\n");
292 std::fs::remove_file(root.join("lab/keep.rs")).unwrap();
293 let head = oid_of(
294 &checkpoint
295 .wave_pre(&["lab".to_owned()], TimeMs::new(2_000), &resident())
296 .unwrap(),
297 );
298
299 let rows = between(root, base, Head::Commit(head)).unwrap();
300 let paths: Vec<&str> = rows.iter().map(|row| row.path.as_str()).collect();
301 assert_eq!(paths, vec!["lab/keep.rs", "lab/lex.rs", "lab/new.rs"]);
302
303 assert_eq!(rows[0].how, How::Deleted);
304 assert_eq!(rows[1].how, How::Modified);
305 assert_eq!(rows[2].how, How::Added);
306 assert_eq!(
307 rows[1].lines,
308 Lines::Counted {
309 added: 2,
310 removed: 0
311 },
312 "two lines were appended to the lexer"
313 );
314 }
315
316 #[test]
318 fn two_identical_checkpoints_report_nothing_rather_than_failing() {
319 let dir = tempfile::tempdir().unwrap();
320 let root = dir.path();
321 write(root, "lab/lex.rs", "one\n");
322 let mut checkpoint = Checkpoint::open(root).unwrap();
323 let base = oid_of(
324 &checkpoint
325 .wave_pre(&["lab".to_owned()], TimeMs::new(1), &resident())
326 .unwrap(),
327 );
328 let head = oid_of(
329 &checkpoint
330 .wave_pre(&["lab".to_owned()], TimeMs::new(2), &resident())
331 .unwrap(),
332 );
333 assert!(between(root, base, Head::Commit(head)).unwrap().is_empty());
334 }
335
336 #[test]
341 fn work_not_yet_checkpointed_still_counts_against_the_working_tree() {
342 let dir = tempfile::tempdir().unwrap();
343 let root = dir.path();
344 write(root, "lab/lex.rs", "one\n");
345 let mut checkpoint = Checkpoint::open(root).unwrap();
346 let base = oid_of(
347 &checkpoint
348 .wave_pre(&["lab".to_owned()], TimeMs::new(1), &resident())
349 .unwrap(),
350 );
351
352 write(root, "lab/lex.rs", "one\ntwo\n");
353 let rows = between(root, base, Head::WorkingTree).unwrap();
354 assert_eq!(rows.len(), 1);
355 assert_eq!(rows[0].path, "lab/lex.rs");
356 assert_eq!(
357 rows[0].lines,
358 Lines::Counted {
359 added: 1,
360 removed: 0
361 }
362 );
363 }
364
365 #[test]
368 fn a_binary_file_says_so_rather_than_reporting_nothing_moved() {
369 let dir = tempfile::tempdir().unwrap();
370 let root = dir.path();
371 std::fs::create_dir_all(root.join("lab")).unwrap();
372 std::fs::write(root.join("lab/blob.bin"), [0u8, 1, 2, 0, 3]).unwrap();
373 let mut checkpoint = Checkpoint::open(root).unwrap();
374 let base = oid_of(
375 &checkpoint
376 .wave_pre(&["lab".to_owned()], TimeMs::new(1), &resident())
377 .unwrap(),
378 );
379
380 std::fs::write(root.join("lab/blob.bin"), [0u8, 9, 9, 0, 7, 7]).unwrap();
381 let rows = between(root, base, Head::WorkingTree).unwrap();
382 assert_eq!(rows.len(), 1);
383 assert_eq!(rows[0].lines, Lines::Binary, "{:?}", rows[0]);
384 }
385
386 #[test]
390 fn a_checkpoint_this_city_does_not_hold_is_refused_rather_than_read_as_empty() {
391 let dir = tempfile::tempdir().unwrap();
392 let root = dir.path();
393 write(root, "lab/lex.rs", "one\n");
394 let mut checkpoint = Checkpoint::open(root).unwrap();
395 let base = oid_of(
396 &checkpoint
397 .wave_pre(&["lab".to_owned()], TimeMs::new(1), &resident())
398 .unwrap(),
399 );
400 let stranger = GitOid::from_bytes([7u8; 20]);
401 assert!(between(root, stranger, Head::Commit(base)).is_err());
402 }
403}