Skip to main content

git_perf/git/
size_ops.rs

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
10/// Information about the size of a specific measurement
11pub struct MeasurementSizeInfo {
12    /// Total bytes for this measurement
13    pub total_bytes: u64,
14    /// Number of occurrences
15    pub count: usize,
16}
17
18/// Information about measurement storage size
19pub struct NotesSizeInfo {
20    /// Total size in bytes
21    pub total_bytes: u64,
22    /// Number of commits with measurements
23    pub note_count: usize,
24    /// Optional breakdown by measurement name
25    pub by_measurement: Option<HashMap<String, MeasurementSizeInfo>>,
26}
27
28/// Get size information for all measurement notes
29pub 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    // Create a consolidated read branch to include pending writes
34    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    // Spawn git notes list process using the temporary read branch
43    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    // Spawn git cat-file process
56    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    // Spawn a thread to pipe note OIDs from git notes list to git cat-file
74    // Also collect the note OIDs for later use in detailed breakdown
75    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        // writer is dropped here, closing stdin to cat-file
88        Ok(note_oids)
89    });
90
91    // Read sizes from git cat-file output
92    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    // Wait for processes to complete
105    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 detailed breakdown requested, parse measurement names
137    if let Some(ref mut by_name) = by_measurement {
138        batch_accumulate_measurement_sizes(Path::new(&repo_root), &note_oids, &sizes, by_name)?;
139    }
140
141    Ok(NotesSizeInfo {
142        total_bytes,
143        note_count,
144        by_measurement,
145    })
146}
147
148/// Parse note contents in batch and accumulate sizes by measurement name.
149/// Uses a single `git cat-file --batch` process instead of one process per note.
150fn 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 &note_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        // Consume the trailing newline separator after each object
213        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        // Distribute note size evenly among measurements in this note
226        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
254/// Git repository statistics from count-objects
255pub struct RepoStats {
256    /// Number of loose objects
257    pub loose_objects: u64,
258    /// Size of loose objects in bytes
259    pub loose_size: u64,
260    /// Number of packed objects
261    pub packed_objects: u64,
262    /// Size of pack files in bytes
263    pub pack_size: u64,
264}
265
266/// Get git repository statistics
267pub 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; // in KiB from git
286    let mut packed_objects = 0;
287    let mut pack_size = 0; // in KiB from git
288
289    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, // Convert KiB to bytes
310        packed_objects,
311        pack_size: pack_size * 1024, // Convert KiB to bytes
312    })
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        // Test that get_repo_stats works and returns proper values
323        with_isolated_cwd_git(|_git_dir| {
324            let stats = get_repo_stats().unwrap();
325
326            // Should have some objects after initial commit
327            assert!(stats.loose_objects > 0 || stats.packed_objects > 0);
328
329            // Sizes should be multiples of 1024 (tests * 1024 conversion)
330            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        // Test with a repo that has no notes - exercises the empty case
350        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        // Test that the * 1024 conversion is correctly applied
361        with_isolated_cwd_git(|_git_dir| {
362            let stats = get_repo_stats().unwrap();
363
364            // Test that loose_size and pack_size are properly converted from KiB to bytes
365            // Both should be multiples of 1024
366            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 there are loose objects, the size should be reasonable (not zero, not absurdly large)
378            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        // Test that all fields are properly assigned from git output
394        with_isolated_cwd_git(|_git_dir| {
395            let stats = get_repo_stats().unwrap();
396
397            // Verify that fields are assigned (not just defaulted to 0)
398            // After creating a repo with an initial commit, we should have objects
399            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            // Verify the match arms are working by checking expected field types
406            // loose_objects should be count
407            // loose_size should be size * 1024
408            // packed_objects should be in-pack
409            // pack_size should be size-pack * 1024
410
411            // Verify fields are properly typed as u64 (not negative types)
412            // The fact that we can do arithmetic on them proves the match arms worked
413            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        // Test the full flow: add measurements -> get size with detailed breakdown
427        with_isolated_cwd_git(|_git_dir| {
428            // Add measurements using the public API
429            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            // Get size information with detailed breakdown
434            let result = get_notes_size(true, false).unwrap();
435
436            // Should have measurements now
437            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            // Verify detailed breakdown
447            let by_measurement = result
448                .by_measurement
449                .expect("Should have detailed breakdown");
450
451            // Should have entries for both metrics
452            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            // Test metric 1 should have count of 2
462            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            // Test metric 2 should have count of 1
473            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            // Verify that the size is distributed correctly (note_size / num_measurements)
484            // In this case, 3 measurements total, so each should get roughly 1/3 of note size
485            let total_from_breakdown: u64 =
486                by_measurement.values().map(|info| info.total_bytes).sum();
487
488            // The total from breakdown may not exactly equal total_bytes due to integer division
489            // For example: 121 / 3 = 40 per measurement, 40 * 3 = 120 (loses 1 byte)
490            // So we verify it's within the number of measurements
491            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            // Since we have 3 measurements, each gets result.total_bytes / 3
501            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}