1use anyhow::{Context, Result};
2use std::collections::HashMap;
3use std::io::{BufRead, BufReader, BufWriter, Read, Write};
4use std::path::Path;
5use std::process::{Command, Stdio};
6use std::thread;
7
8use super::git_interop::{create_consolidated_read_branch, get_repository_root};
9
10pub struct MeasurementSizeInfo {
12 pub total_bytes: u64,
14 pub count: usize,
16}
17
18pub struct NotesSizeInfo {
20 pub total_bytes: u64,
22 pub note_count: usize,
24 pub by_measurement: Option<HashMap<String, MeasurementSizeInfo>>,
26}
27
28pub fn get_notes_size(detailed: bool, disk_size: bool) -> Result<NotesSizeInfo> {
30 let repo_root =
31 get_repository_root().map_err(|e| anyhow::anyhow!("Failed to get repo root: {}", e))?;
32
33 let read_branch = create_consolidated_read_branch()?;
35
36 let batch_format = if disk_size {
37 "%(objectsize:disk)"
38 } else {
39 "%(objectsize)"
40 };
41
42 let mut list_notes = Command::new("git")
44 .args(["notes", "--ref", read_branch.ref_name(), "list"])
45 .current_dir(&repo_root)
46 .stdout(Stdio::piped())
47 .spawn()
48 .context("Failed to spawn git notes list")?;
49
50 let notes_out = list_notes
51 .stdout
52 .take()
53 .context("Failed to take stdout from git notes list")?;
54
55 let mut cat_file = Command::new("git")
57 .args(["cat-file", &format!("--batch-check={}", batch_format)])
58 .current_dir(&repo_root)
59 .stdin(Stdio::piped())
60 .stdout(Stdio::piped())
61 .spawn()
62 .context("Failed to spawn git cat-file")?;
63
64 let cat_file_in = cat_file
65 .stdin
66 .take()
67 .context("Failed to take stdin from git cat-file")?;
68 let cat_file_out = cat_file
69 .stdout
70 .take()
71 .context("Failed to take stdout from git cat-file")?;
72
73 let note_oids_handle = thread::spawn(move || -> Result<Vec<String>> {
76 let reader = BufReader::new(notes_out);
77 let mut writer = BufWriter::new(cat_file_in);
78 let mut note_oids = Vec::new();
79
80 for line in reader.lines() {
81 let line = line.context("Failed to read line from git notes list")?;
82 if let Some(note_oid) = line.split_whitespace().next() {
83 writeln!(writer, "{}", note_oid).context("Failed to write OID to git cat-file")?;
84 note_oids.push(note_oid.to_string());
85 }
86 }
87 Ok(note_oids)
89 });
90
91 let reader = BufReader::new(cat_file_out);
93 let mut sizes = Vec::new();
94
95 for line in reader.lines() {
96 let line = line.context("Failed to read line from git cat-file")?;
97 let size = line
98 .trim()
99 .parse::<u64>()
100 .with_context(|| format!("Failed to parse size from: {}", line))?;
101 sizes.push(size);
102 }
103
104 let note_oids = note_oids_handle
106 .join()
107 .map_err(|_| anyhow::anyhow!("Thread panicked"))?
108 .context("Failed to collect note OIDs")?;
109
110 list_notes
111 .wait()
112 .context("Failed to wait for git notes list")?;
113 let cat_file_status = cat_file.wait().context("Failed to wait for git cat-file")?;
114
115 if !cat_file_status.success() {
116 anyhow::bail!("git cat-file process failed");
117 }
118
119 let note_count = note_oids.len();
120 if note_count == 0 {
121 return Ok(NotesSizeInfo {
122 total_bytes: 0,
123 note_count: 0,
124 by_measurement: if detailed { Some(HashMap::new()) } else { None },
125 });
126 }
127
128 if sizes.len() != note_count {
129 anyhow::bail!("Expected {} sizes but got {}", note_count, sizes.len());
130 }
131
132 let total_bytes: u64 = sizes.iter().sum();
133
134 let mut by_measurement = if detailed { Some(HashMap::new()) } else { None };
135
136 if let Some(ref mut by_name) = by_measurement {
138 batch_accumulate_measurement_sizes(Path::new(&repo_root), ¬e_oids, &sizes, by_name)?;
139 }
140
141 Ok(NotesSizeInfo {
142 total_bytes,
143 note_count,
144 by_measurement,
145 })
146}
147
148fn batch_accumulate_measurement_sizes(
151 repo_root: &Path,
152 note_oids: &[String],
153 note_sizes: &[u64],
154 by_name: &mut HashMap<String, MeasurementSizeInfo>,
155) -> Result<()> {
156 use crate::serialization::deserialize;
157
158 debug_assert_eq!(note_oids.len(), note_sizes.len());
159
160 if note_oids.is_empty() {
161 return Ok(());
162 }
163
164 let mut cat_file = Command::new("git")
165 .args(["cat-file", "--batch"])
166 .current_dir(repo_root)
167 .stdin(Stdio::piped())
168 .stdout(Stdio::piped())
169 .spawn()
170 .context("Failed to spawn git cat-file --batch")?;
171
172 let cat_file_in = cat_file
173 .stdin
174 .take()
175 .context("Failed to take stdin from git cat-file --batch")?;
176 let cat_file_out = cat_file
177 .stdout
178 .take()
179 .context("Failed to take stdout from git cat-file --batch")?;
180
181 let oids_owned = note_oids.to_vec();
182 let writer_handle = thread::spawn(move || -> Result<()> {
183 let mut writer = BufWriter::new(cat_file_in);
184 for oid in &oids_owned {
185 writeln!(writer, "{}", oid).context("Failed to write OID to git cat-file --batch")?;
186 }
187 Ok(())
188 });
189
190 let mut reader = BufReader::new(cat_file_out);
191 for ¬e_size in note_sizes {
192 let mut header = String::new();
193 reader
194 .read_line(&mut header)
195 .context("Failed to read header from git cat-file --batch")?;
196 let header = header.trim();
197 let content_len: usize = header
198 .split_whitespace()
199 .nth(2)
200 .and_then(|s| s.parse().ok())
201 .with_context(|| {
202 format!(
203 "Failed to parse object size from git cat-file --batch header: {}",
204 header
205 )
206 })?;
207
208 let mut content_buf = vec![0u8; content_len];
209 reader
210 .read_exact(&mut content_buf)
211 .context("Failed to read object content from git cat-file --batch")?;
212 let mut newline = [0u8; 1];
214 reader
215 .read_exact(&mut newline)
216 .context("Failed to read trailing newline from git cat-file --batch")?;
217
218 let content = String::from_utf8_lossy(&content_buf);
219 let measurements = deserialize(&content);
220
221 if measurements.is_empty() {
222 continue;
223 }
224
225 let size_per_measurement = note_size / measurements.len() as u64;
227
228 for measurement in measurements {
229 let entry = by_name
230 .entry(measurement.name)
231 .or_insert(MeasurementSizeInfo {
232 total_bytes: 0,
233 count: 0,
234 });
235 entry.total_bytes += size_per_measurement;
236 entry.count += 1;
237 }
238 }
239
240 writer_handle
241 .join()
242 .map_err(|_| anyhow::anyhow!("Writer thread panicked"))??;
243
244 let status = cat_file
245 .wait()
246 .context("Failed to wait for git cat-file --batch")?;
247 if !status.success() {
248 anyhow::bail!("git cat-file --batch failed");
249 }
250
251 Ok(())
252}
253
254pub struct RepoStats {
256 pub loose_objects: u64,
258 pub loose_size: u64,
260 pub packed_objects: u64,
262 pub pack_size: u64,
264}
265
266pub fn get_repo_stats() -> Result<RepoStats> {
268 let repo_root =
269 get_repository_root().map_err(|e| anyhow::anyhow!("Failed to get repo root: {}", e))?;
270
271 let output = Command::new("git")
272 .args(["count-objects", "-v"])
273 .current_dir(&repo_root)
274 .output()
275 .context("Failed to execute git count-objects")?;
276
277 if !output.status.success() {
278 let stderr = String::from_utf8_lossy(&output.stderr);
279 anyhow::bail!("git count-objects failed: {}", stderr);
280 }
281
282 let stdout = String::from_utf8_lossy(&output.stdout);
283
284 let mut loose_objects = 0;
285 let mut loose_size = 0; let mut packed_objects = 0;
287 let mut pack_size = 0; for line in stdout.lines() {
290 let parts: Vec<&str> = line.split(':').collect();
291 if parts.len() != 2 {
292 continue;
293 }
294
295 let key = parts[0].trim();
296 let value = parts[1].trim().parse::<u64>().unwrap_or(0);
297
298 match key {
299 "count" => loose_objects = value,
300 "size" => loose_size = value,
301 "in-pack" => packed_objects = value,
302 "size-pack" => pack_size = value,
303 _ => {}
304 }
305 }
306
307 Ok(RepoStats {
308 loose_objects,
309 loose_size: loose_size * 1024, packed_objects,
311 pack_size: pack_size * 1024, })
313}
314
315#[cfg(test)]
316mod tests {
317 use super::*;
318 use crate::test_helpers::with_isolated_cwd_git;
319
320 #[test]
321 fn test_get_repo_stats_basic() {
322 with_isolated_cwd_git(|_git_dir| {
324 let stats = get_repo_stats().unwrap();
325
326 assert!(stats.loose_objects > 0 || stats.packed_objects > 0);
328
329 if stats.loose_size > 0 {
331 assert_eq!(
332 stats.loose_size % 1024,
333 0,
334 "loose_size should be multiple of 1024"
335 );
336 }
337 if stats.pack_size > 0 {
338 assert_eq!(
339 stats.pack_size % 1024,
340 0,
341 "pack_size should be multiple of 1024"
342 );
343 }
344 });
345 }
346
347 #[test]
348 fn test_get_notes_size_empty_repo() {
349 with_isolated_cwd_git(|_git_dir| {
351 let result = get_notes_size(false, false).unwrap();
352 assert_eq!(result.total_bytes, 0);
353 assert_eq!(result.note_count, 0);
354 assert!(result.by_measurement.is_none());
355 });
356 }
357
358 #[test]
359 fn test_get_repo_stats_conversion_factors() {
360 with_isolated_cwd_git(|_git_dir| {
362 let stats = get_repo_stats().unwrap();
363
364 assert_eq!(
367 stats.loose_size % 1024,
368 0,
369 "loose_size must be multiple of 1024 (bytes conversion from KiB)"
370 );
371 assert_eq!(
372 stats.pack_size % 1024,
373 0,
374 "pack_size must be multiple of 1024 (bytes conversion from KiB)"
375 );
376
377 if stats.loose_objects > 0 {
379 assert!(
380 stats.loose_size > 0,
381 "loose_size should be > 0 if loose_objects > 0"
382 );
383 assert!(
384 stats.loose_size < 1_000_000_000,
385 "loose_size should be reasonable"
386 );
387 }
388 });
389 }
390
391 #[test]
392 fn test_get_repo_stats_field_assignments() {
393 with_isolated_cwd_git(|_git_dir| {
395 let stats = get_repo_stats().unwrap();
396
397 let total_objects = stats.loose_objects + stats.packed_objects;
400 assert!(
401 total_objects > 0,
402 "Should have at least one object from initial commit"
403 );
404
405 let _sum =
414 stats.loose_objects + stats.loose_size + stats.packed_objects + stats.pack_size;
415 assert!(
416 _sum >= stats.loose_objects,
417 "Arithmetic should work on u64 fields"
418 );
419 });
420 }
421
422 #[test]
423 fn test_get_notes_size_with_measurements() {
424 use crate::measurement_storage;
425
426 with_isolated_cwd_git(|_git_dir| {
428 measurement_storage::add("test_metric_1", 42.0, &[]).unwrap();
430 measurement_storage::add("test_metric_2", 100.0, &[]).unwrap();
431 measurement_storage::add("test_metric_1", 84.0, &[]).unwrap();
432
433 let result = get_notes_size(true, false).unwrap();
435
436 assert!(
438 result.total_bytes > 0,
439 "total_bytes should be > 0 after adding measurements"
440 );
441 assert_eq!(
442 result.note_count, 1,
443 "Should have 1 note (all measurements on HEAD)"
444 );
445
446 let by_measurement = result
448 .by_measurement
449 .expect("Should have detailed breakdown");
450
451 assert!(
453 by_measurement.contains_key("test_metric_1"),
454 "Should have test_metric_1 in breakdown"
455 );
456 assert!(
457 by_measurement.contains_key("test_metric_2"),
458 "Should have test_metric_2 in breakdown"
459 );
460
461 let metric1_info = &by_measurement["test_metric_1"];
463 assert_eq!(
464 metric1_info.count, 2,
465 "test_metric_1 should have 2 occurrences"
466 );
467 assert!(
468 metric1_info.total_bytes > 0,
469 "test_metric_1 should have non-zero size"
470 );
471
472 let metric2_info = &by_measurement["test_metric_2"];
474 assert_eq!(
475 metric2_info.count, 1,
476 "test_metric_2 should have 1 occurrence"
477 );
478 assert!(
479 metric2_info.total_bytes > 0,
480 "test_metric_2 should have non-zero size"
481 );
482
483 let total_from_breakdown: u64 =
486 by_measurement.values().map(|info| info.total_bytes).sum();
487
488 let num_measurements = 3u64;
492 assert!(
493 result.total_bytes.abs_diff(total_from_breakdown) < num_measurements,
494 "Sum of breakdown ({}) should be within {} bytes of total_bytes ({}) due to integer division",
495 total_from_breakdown,
496 num_measurements,
497 result.total_bytes
498 );
499
500 let expected_per_measurement = result.total_bytes / num_measurements;
502 assert!(
503 metric1_info.total_bytes >= expected_per_measurement,
504 "test_metric_1 appears twice, should have at least 1/3 of total (appears 2/3 times)"
505 );
506 });
507 }
508}