ralph_workflow/git_helpers/
review_baseline.rs

1//! Per-review-cycle baseline tracking.
2//!
3//! This module manages the baseline commit for each review cycle, ensuring that
4//! reviewers only see changes from the current cycle rather than cumulative changes
5//! from previous fix commits.
6//!
7//! # Overview
8//!
9//! During the review-fix phase, each cycle should:
10//! 1. Capture baseline before review (current HEAD)
11//! 2. Review sees diff from that baseline
12//! 3. Fixer makes changes
13//! 4. Baseline is updated after fix pass
14//! 5. Next review cycle sees only new changes
15//!
16//! This prevents "diff scope creep" where previous fix commits pollute
17//! subsequent review passes.
18
19use std::fs;
20use std::io;
21use std::path::PathBuf;
22
23use super::start_commit::get_current_head_oid;
24
25/// Path to the review baseline file.
26///
27/// Stored in `.agent/review_baseline.txt`, this file contains the OID (SHA) of the
28/// commit that serves as the baseline for the current review cycle.
29const REVIEW_BASELINE_FILE: &str = ".agent/review_baseline.txt";
30
31/// Sentinel value when review baseline is not set.
32const BASELINE_NOT_SET: &str = "__BASELINE_NOT_SET__";
33
34/// Review baseline state.
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub enum ReviewBaseline {
37    /// A concrete commit OID to diff from.
38    Commit(git2::Oid),
39    /// Baseline not set (first review cycle).
40    NotSet,
41}
42
43/// Update the review baseline to current HEAD.
44///
45/// This should be called AFTER each fix pass to update the baseline so
46/// the next review cycle sees only new changes.
47///
48/// # Errors
49///
50/// Returns an error if:
51/// - The current HEAD cannot be determined
52/// - The file cannot be written
53pub fn update_review_baseline() -> io::Result<()> {
54    let oid = get_current_head_oid()?;
55    write_review_baseline(&oid)
56}
57
58/// Load the review baseline.
59///
60/// Returns the baseline commit for the current review cycle.
61///
62/// # Errors
63///
64/// Returns an error if:
65/// - The file cannot be read
66/// - The file content is invalid
67pub fn load_review_baseline() -> io::Result<ReviewBaseline> {
68    let path = PathBuf::from(REVIEW_BASELINE_FILE);
69
70    if !path.exists() {
71        return Ok(ReviewBaseline::NotSet);
72    }
73
74    let content = fs::read_to_string(&path)?;
75    let raw = content.trim();
76
77    if raw.is_empty() || raw == BASELINE_NOT_SET {
78        return Ok(ReviewBaseline::NotSet);
79    }
80
81    // Parse the OID
82    let oid = git2::Oid::from_str(raw).map_err(|_| {
83        io::Error::new(
84            io::ErrorKind::InvalidData,
85            format!("Invalid OID format in {}: '{}'. The review baseline will be reset. Run 'ralph --reset-start-commit' if this persists.", REVIEW_BASELINE_FILE, raw),
86        )
87    })?;
88
89    Ok(ReviewBaseline::Commit(oid))
90}
91
92/// Get the diff from the review baseline (or start commit if baseline not set).
93///
94/// This function provides a per-review-cycle diff, falling back to the
95/// original start_commit if no review baseline has been set.
96///
97/// # Returns
98///
99/// Returns a formatted diff string, or an error if:
100/// - The repository cannot be opened
101/// - The baseline commit cannot be found
102/// - The diff cannot be generated
103pub fn get_git_diff_from_review_baseline() -> io::Result<String> {
104    match load_review_baseline()? {
105        ReviewBaseline::Commit(oid) => {
106            // Use the existing git_diff_from function from repo module
107            super::repo::git_diff_from(&oid.to_string())
108        }
109        ReviewBaseline::NotSet => {
110            // Fall back to start commit if review baseline not set
111            super::repo::get_git_diff_from_start()
112        }
113    }
114}
115
116/// Get information about the current review baseline.
117///
118/// Returns a tuple of (baseline_oid, commits_since_baseline, is_stale).
119/// - `baseline_oid`: The OID of the baseline commit (or None if not set)
120/// - `commits_since_baseline`: Number of commits since baseline
121/// - `is_stale`: true if baseline is old (>10 commits behind)
122pub fn get_review_baseline_info() -> io::Result<(Option<String>, usize, bool)> {
123    let repo = git2::Repository::discover(".").map_err(|e| to_io_error(&e))?;
124
125    let baseline_oid = match load_review_baseline()? {
126        ReviewBaseline::Commit(oid) => Some(oid.to_string()),
127        ReviewBaseline::NotSet => None,
128    };
129
130    let commits_since = if let Some(ref oid) = baseline_oid {
131        count_commits_since(&repo, oid)?
132    } else {
133        0
134    };
135
136    let is_stale = commits_since > 10;
137
138    Ok((baseline_oid, commits_since, is_stale))
139}
140
141/// Write the review baseline to disk.
142fn write_review_baseline(oid: &str) -> io::Result<()> {
143    let path = PathBuf::from(REVIEW_BASELINE_FILE);
144    if let Some(parent) = path.parent() {
145        fs::create_dir_all(parent)?;
146    }
147    fs::write(&path, oid)?;
148    Ok(())
149}
150
151/// Count commits since a given baseline.
152fn count_commits_since(repo: &git2::Repository, baseline_oid: &str) -> io::Result<usize> {
153    let oid = git2::Oid::from_str(baseline_oid).map_err(|_| {
154        io::Error::new(
155            io::ErrorKind::InvalidInput,
156            format!("Invalid baseline OID: {baseline_oid}"),
157        )
158    })?;
159
160    let baseline = repo.find_commit(oid).map_err(|e| to_io_error(&e))?;
161
162    // Try to get HEAD and count commits
163    match repo.head() {
164        Ok(head) => {
165            let head_commit = head.peel_to_commit().map_err(|e| to_io_error(&e))?;
166
167            // Use revwalk to count commits
168            let mut revwalk = repo.revwalk().map_err(|e| to_io_error(&e))?;
169            revwalk
170                .push(head_commit.id())
171                .map_err(|e| to_io_error(&e))?;
172
173            let mut count = 0;
174            for commit_id in revwalk {
175                let commit_id = commit_id.map_err(|e| to_io_error(&e))?;
176                if commit_id == baseline.id() {
177                    break;
178                }
179                count += 1;
180                // Safety limit to prevent infinite loops
181                if count > 1000 {
182                    break;
183                }
184            }
185            Ok(count)
186        }
187        Err(_) => Ok(0),
188    }
189}
190
191/// Diff statistics for the changes since baseline.
192#[derive(Debug, Clone, Default)]
193pub struct DiffStats {
194    /// Number of files changed.
195    pub files_changed: usize,
196    /// Number of lines added.
197    pub lines_added: usize,
198    /// Number of lines deleted.
199    pub lines_deleted: usize,
200    /// List of changed file paths (up to 10 for display).
201    pub changed_files: Vec<String>,
202}
203
204/// Baseline summary information for display.
205#[derive(Debug, Clone)]
206pub struct BaselineSummary {
207    /// The baseline OID (short form).
208    pub baseline_oid: Option<String>,
209    /// Number of commits since baseline.
210    pub commits_since: usize,
211    /// Whether the baseline is stale (>10 commits behind).
212    pub is_stale: bool,
213    /// Diff statistics for changes since baseline.
214    pub diff_stats: DiffStats,
215}
216
217impl BaselineSummary {
218    /// Format a compact version for inline display.
219    pub fn format_compact(&self) -> String {
220        match &self.baseline_oid {
221            Some(oid) => {
222                let short_oid = &oid[..8.min(oid.len())];
223                if self.is_stale {
224                    format!(
225                        "Baseline: {} (+{} commits since, {} files changed)",
226                        short_oid, self.commits_since, self.diff_stats.files_changed
227                    )
228                } else if self.commits_since > 0 {
229                    format!(
230                        "Baseline: {} ({} commits since, {} files changed)",
231                        short_oid, self.commits_since, self.diff_stats.files_changed
232                    )
233                } else {
234                    format!(
235                        "Baseline: {} ({} files: +{}/-{} lines)",
236                        short_oid,
237                        self.diff_stats.files_changed,
238                        self.diff_stats.lines_added,
239                        self.diff_stats.lines_deleted
240                    )
241                }
242            }
243            None => {
244                format!(
245                    "Baseline: start_commit ({} files: +{}/-{} lines)",
246                    self.diff_stats.files_changed,
247                    self.diff_stats.lines_added,
248                    self.diff_stats.lines_deleted
249                )
250            }
251        }
252    }
253
254    /// Format a detailed version for verbose display.
255    pub fn format_detailed(&self) -> String {
256        let mut lines = Vec::new();
257
258        lines.push("Review Baseline Summary:".to_string());
259        lines.push("─".repeat(40));
260
261        match &self.baseline_oid {
262            Some(oid) => {
263                let short_oid = &oid[..8.min(oid.len())];
264                lines.push(format!("  Commit: {}", short_oid));
265                if self.commits_since > 0 {
266                    lines.push(format!("  Commits since baseline: {}", self.commits_since));
267                }
268            }
269            None => {
270                lines.push("  Commit: start_commit (initial baseline)".to_string());
271            }
272        }
273
274        lines.push(format!(
275            "  Files changed: {}",
276            self.diff_stats.files_changed
277        ));
278        lines.push(format!("  Lines added: {}", self.diff_stats.lines_added));
279        lines.push(format!(
280            "  Lines deleted: {}",
281            self.diff_stats.lines_deleted
282        ));
283
284        if !self.diff_stats.changed_files.is_empty() {
285            lines.push(String::new());
286            lines.push("  Changed files:".to_string());
287            for file in &self.diff_stats.changed_files {
288                lines.push(format!("    - {}", file));
289            }
290            if self.diff_stats.changed_files.len() < self.diff_stats.files_changed {
291                let remaining = self.diff_stats.files_changed - self.diff_stats.changed_files.len();
292                lines.push(format!("    ... and {} more", remaining));
293            }
294        }
295
296        if self.is_stale {
297            lines.push(String::new());
298            lines.push(
299                "  ⚠ WARNING: Baseline is stale. Consider updating with --reset-start-commit."
300                    .to_string(),
301            );
302        }
303
304        lines.join("\n")
305    }
306}
307
308/// Get a summary of the baseline state for display.
309///
310/// Returns a `BaselineSummary` containing information about the current
311/// baseline, commits since baseline, staleness, and diff statistics.
312pub fn get_baseline_summary() -> io::Result<BaselineSummary> {
313    let repo = git2::Repository::discover(".").map_err(|e| to_io_error(&e))?;
314
315    let baseline_oid = match load_review_baseline()? {
316        ReviewBaseline::Commit(oid) => Some(oid.to_string()),
317        ReviewBaseline::NotSet => None,
318    };
319
320    let commits_since = if let Some(ref oid) = baseline_oid {
321        count_commits_since(&repo, oid)?
322    } else {
323        0
324    };
325
326    let is_stale = commits_since > 10;
327
328    // Get diff statistics
329    let diff_stats = get_diff_stats(&repo, &baseline_oid)?;
330
331    Ok(BaselineSummary {
332        baseline_oid,
333        commits_since,
334        is_stale,
335        diff_stats,
336    })
337}
338
339/// Count lines in a blob content.
340///
341/// Returns the number of lines, matching the behavior of counting
342/// newlines and adding 1 (so empty content returns 0, but any content
343/// returns at least 1).
344fn count_lines_in_blob(content: &[u8]) -> usize {
345    if content.is_empty() {
346        return 0;
347    }
348    // Count newlines and add 1 to get the line count
349    // This matches the previous behavior and ensures that even files
350    // without trailing newlines are counted correctly
351    content.iter().filter(|&&c| c == b'\n').count() + 1
352}
353
354/// Get diff statistics for changes since the baseline.
355fn get_diff_stats(repo: &git2::Repository, baseline_oid: &Option<String>) -> io::Result<DiffStats> {
356    let baseline_tree = match baseline_oid {
357        Some(oid) => {
358            let oid = git2::Oid::from_str(oid).map_err(|_| {
359                io::Error::new(
360                    io::ErrorKind::InvalidInput,
361                    format!("Invalid baseline OID: {}", oid),
362                )
363            })?;
364            let commit = repo.find_commit(oid).map_err(|e| to_io_error(&e))?;
365            commit.tree().map_err(|e| to_io_error(&e))?
366        }
367        None => {
368            // No baseline set, use empty tree
369            repo.find_tree(git2::Oid::zero())
370                .map_err(|e| to_io_error(&e))?
371        }
372    };
373
374    // Get the current HEAD tree
375    let head_tree = match repo.head() {
376        Ok(head) => {
377            let commit = head.peel_to_commit().map_err(|e| to_io_error(&e))?;
378            commit.tree().map_err(|e| to_io_error(&e))?
379        }
380        Err(_) => {
381            // No HEAD yet, use empty tree
382            repo.find_tree(git2::Oid::zero())
383                .map_err(|e| to_io_error(&e))?
384        }
385    };
386
387    // Generate diff
388    let diff = repo
389        .diff_tree_to_tree(Some(&baseline_tree), Some(&head_tree), None)
390        .map_err(|e| to_io_error(&e))?;
391
392    // Collect statistics
393    let mut stats = DiffStats::default();
394    let mut delta_ids = Vec::new();
395
396    diff.foreach(
397        &mut |delta, _progress| {
398            use git2::Delta;
399
400            stats.files_changed += 1;
401
402            if let Some(path) = delta.new_file().path() {
403                let path_str = path.to_string_lossy().to_string();
404                if stats.changed_files.len() < 10 {
405                    stats.changed_files.push(path_str);
406                }
407            } else if let Some(path) = delta.old_file().path() {
408                let path_str = path.to_string_lossy().to_string();
409                if stats.changed_files.len() < 10 {
410                    stats.changed_files.push(path_str);
411                }
412            }
413
414            match delta.status() {
415                Delta::Added => {
416                    delta_ids.push((delta.new_file().id(), true));
417                }
418                Delta::Deleted => {
419                    delta_ids.push((delta.old_file().id(), false));
420                }
421                Delta::Modified => {
422                    delta_ids.push((delta.new_file().id(), true));
423                }
424                _ => {}
425            }
426
427            true
428        },
429        None,
430        None,
431        None,
432    )
433    .map_err(|e| to_io_error(&e))?;
434
435    // Count lines added/deleted
436    for (blob_id, is_new_or_modified) in delta_ids {
437        if let Ok(blob) = repo.find_blob(blob_id) {
438            let line_count = count_lines_in_blob(blob.content());
439
440            if is_new_or_modified {
441                stats.lines_added += line_count;
442            } else {
443                stats.lines_deleted += line_count;
444            }
445        }
446    }
447
448    Ok(stats)
449}
450
451/// Convert git2 error to `io::Error`.
452fn to_io_error(err: &git2::Error) -> io::Error {
453    io::Error::other(err.to_string())
454}
455
456#[cfg(test)]
457mod tests {
458    use super::*;
459
460    #[test]
461    fn test_review_baseline_file_path_defined() {
462        assert_eq!(REVIEW_BASELINE_FILE, ".agent/review_baseline.txt");
463    }
464
465    #[test]
466    fn test_load_review_baseline_returns_result() {
467        let result = load_review_baseline();
468        assert!(result.is_ok() || result.is_err());
469    }
470
471    #[test]
472    fn test_get_review_baseline_info_returns_result() {
473        let result = get_review_baseline_info();
474        assert!(result.is_ok() || result.is_err());
475    }
476
477    #[test]
478    fn test_get_git_diff_from_review_baseline_returns_result() {
479        let result = get_git_diff_from_review_baseline();
480        assert!(result.is_ok() || result.is_err());
481    }
482}