Skip to main content

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 (reviewer agent by default)
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::path::Path;
20
21use crate::workspace::{Workspace, WorkspaceFs};
22
23mod iot {
24    pub type Result<T> = std::io::Result<T>;
25    pub type Error = std::io::Error;
26    pub type ErrorKind = std::io::ErrorKind;
27}
28
29// Boundary module for libgit2 revwalk operations.
30include!("review_baseline/io.rs");
31
32use super::start_commit::get_current_head_oid;
33
34// =============================================================================
35// Baseline Persistence (from review_baseline/baseline_persistence.rs)
36// =============================================================================
37
38pub const REVIEW_BASELINE_FILE: &str = ".agent/review_baseline.txt";
39pub const BASELINE_NOT_SET: &str = "__BASELINE_NOT_SET__";
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum ReviewBaseline {
43    Commit(git2::Oid),
44    NotSet,
45}
46
47pub fn load_review_baseline() -> iot::Result<ReviewBaseline> {
48    let repo = git2::Repository::discover(".").map_err(|e| to_io_error(&e))?;
49    let repo_root = repo
50        .workdir()
51        .ok_or_else(|| iot::Error::new(iot::ErrorKind::NotFound, "No workdir for repository"))?;
52    let workspace = WorkspaceFs::new(repo_root.to_path_buf());
53    load_review_baseline_with_workspace(&workspace)
54}
55
56pub fn load_review_baseline_with_workspace(
57    workspace: &dyn Workspace,
58) -> iot::Result<ReviewBaseline> {
59    let path = Path::new(REVIEW_BASELINE_FILE);
60    if !workspace.exists(path) {
61        return Ok(ReviewBaseline::NotSet);
62    }
63
64    let content = workspace.read(path)?;
65    let raw = content.trim();
66
67    if raw.is_empty() || raw == BASELINE_NOT_SET {
68        return Ok(ReviewBaseline::NotSet);
69    }
70
71    let oid = git2::Oid::from_str(raw).map_err(|_| {
72        iot::Error::new(
73            iot::ErrorKind::InvalidData,
74            format!("Invalid baseline OID in {REVIEW_BASELINE_FILE}: '{raw}'"),
75        )
76    })?;
77
78    Ok(ReviewBaseline::Commit(oid))
79}
80
81pub fn update_review_baseline() -> iot::Result<()> {
82    let repo = git2::Repository::discover(".").map_err(|e| to_io_error(&e))?;
83    let repo_root = repo
84        .workdir()
85        .ok_or_else(|| iot::Error::new(iot::ErrorKind::NotFound, "No workdir for repository"))?;
86    let workspace = WorkspaceFs::new(repo_root.to_path_buf());
87    update_review_baseline_with_workspace(&workspace)
88}
89
90pub fn update_review_baseline_with_workspace(workspace: &dyn Workspace) -> iot::Result<()> {
91    let path = Path::new(REVIEW_BASELINE_FILE);
92    match get_current_head_oid() {
93        Ok(oid) => workspace.write(path, oid.trim()),
94        Err(e) if e.kind() == iot::ErrorKind::NotFound => workspace.write(path, BASELINE_NOT_SET),
95        Err(e) => Err(e),
96    }
97}
98
99pub fn get_review_baseline_info() -> iot::Result<(Option<String>, usize, bool)> {
100    let repo = git2::Repository::discover(".").map_err(|e| to_io_error(&e))?;
101    match load_review_baseline()? {
102        ReviewBaseline::Commit(oid) => {
103            let oid_str = oid.to_string();
104            let commits_since = count_commits_since(&repo, &oid_str)?;
105            let is_stale = commits_since > 10;
106            Ok((Some(oid_str), commits_since, is_stale))
107        }
108        ReviewBaseline::NotSet => Ok((None, 0, false)),
109    }
110}
111
112fn count_commits_since(repo: &git2::Repository, baseline_oid: &str) -> iot::Result<usize> {
113    let baseline = git2::Oid::from_str(baseline_oid).map_err(|_| {
114        iot::Error::new(
115            iot::ErrorKind::InvalidInput,
116            format!("Invalid baseline OID: {baseline_oid}"),
117        )
118    })?;
119
120    let head_oid = match repo.head() {
121        Ok(head) => head.peel_to_commit().map_err(|e| to_io_error(&e))?.id(),
122        Err(ref e) if e.code() == git2::ErrorCode::UnbornBranch => return Ok(0),
123        Err(e) => return Err(to_io_error(&e)),
124    };
125
126    if let Ok((ahead, _behind)) = repo.graph_ahead_behind(head_oid, baseline) {
127        return Ok(ahead);
128    }
129
130    revwalk_count_commits(repo, head_oid, baseline)
131}
132
133fn to_io_error(err: &git2::Error) -> iot::Error {
134    iot::Error::other(err.to_string())
135}
136
137// =============================================================================
138// Diff Stats (from review_baseline/diff_stats.rs)
139// =============================================================================
140
141#[derive(Debug, Clone, Default)]
142pub struct DiffStats {
143    pub files_changed: usize,
144    pub lines_added: usize,
145    pub lines_deleted: usize,
146    pub changed_files: Vec<String>,
147}
148
149#[derive(Debug, Clone)]
150pub struct BaselineSummary {
151    pub baseline_oid: Option<String>,
152    pub commits_since: usize,
153    pub is_stale: bool,
154    pub diff_stats: DiffStats,
155}
156
157impl BaselineSummary {
158    pub fn format_compact(&self) -> String {
159        self.baseline_oid.as_ref().map_or_else(
160            || {
161                format!(
162                    "Baseline: start_commit ({} files: +{}/-{} lines)",
163                    self.diff_stats.files_changed,
164                    self.diff_stats.lines_added,
165                    self.diff_stats.lines_deleted
166                )
167            },
168            |oid| {
169                let short_oid = &oid[..8.min(oid.len())];
170                if self.is_stale {
171                    format!(
172                        "Baseline: {} (+{} commits since, {} files changed)",
173                        short_oid, self.commits_since, self.diff_stats.files_changed
174                    )
175                } else if self.commits_since > 0 {
176                    format!(
177                        "Baseline: {} ({} commits since, {} files changed)",
178                        short_oid, self.commits_since, self.diff_stats.files_changed
179                    )
180                } else {
181                    format!(
182                        "Baseline: {} ({} files: +{}/-{} lines)",
183                        short_oid,
184                        self.diff_stats.files_changed,
185                        self.diff_stats.lines_added,
186                        self.diff_stats.lines_deleted
187                    )
188                }
189            },
190        )
191    }
192
193    pub fn format_detailed(&self) -> String {
194        let baseline_info: Vec<String> = match &self.baseline_oid {
195            Some(oid) => {
196                let short_oid = &oid[..8.min(oid.len())];
197                let lines = vec![format!("  Commit: {short_oid}")];
198                if self.commits_since > 0 {
199                    lines
200                        .into_iter()
201                        .chain(std::iter::once(format!(
202                            "  Commits since baseline: {}",
203                            self.commits_since
204                        )))
205                        .collect()
206                } else {
207                    lines
208                }
209            }
210            None => vec!["  Commit: start_commit (initial baseline)".to_string()],
211        };
212
213        let file_info: Vec<String> = if !self.diff_stats.changed_files.is_empty() {
214            let file_lines: Vec<String> = self
215                .diff_stats
216                .changed_files
217                .iter()
218                .map(|file| format!("    - {file}"))
219                .collect();
220            let remaining = self.diff_stats.files_changed - self.diff_stats.changed_files.len();
221            let remaining_line = (remaining > 0).then(|| format!("    ... and {remaining} more"));
222            std::iter::once(String::new())
223                .chain(std::iter::once("  Changed files:".to_string()))
224                .chain(file_lines)
225                .chain(remaining_line)
226                .collect()
227        } else {
228            Vec::new()
229        };
230
231        let stale_warning: Vec<String> = if self.is_stale {
232            vec![
233                String::new(),
234                "  WARNING: Baseline is stale. Consider updating with --reset-start-commit."
235                    .to_string(),
236            ]
237        } else {
238            Vec::new()
239        };
240
241        let lines: Vec<String> = std::iter::once("Review Baseline Summary:".to_string())
242            .chain(std::iter::once("".to_string()))
243            .chain(baseline_info)
244            .chain(std::iter::once(format!(
245                "  Files changed: {}",
246                self.diff_stats.files_changed
247            )))
248            .chain(std::iter::once(format!(
249                "  Lines added: {}",
250                self.diff_stats.lines_added
251            )))
252            .chain(std::iter::once(format!(
253                "  Lines deleted: {}",
254                self.diff_stats.lines_deleted
255            )))
256            .chain(file_info)
257            .chain(stale_warning)
258            .collect();
259
260        lines.join("\n")
261    }
262}
263
264pub fn get_baseline_summary() -> iot::Result<BaselineSummary> {
265    let repo = git2::Repository::discover(".").map_err(|e| to_io_error(&e))?;
266    get_baseline_summary_impl(&repo, load_review_baseline()?)
267}
268
269fn get_baseline_summary_impl(
270    repo: &git2::Repository,
271    baseline: ReviewBaseline,
272) -> iot::Result<BaselineSummary> {
273    let baseline_oid = match baseline {
274        ReviewBaseline::Commit(oid) => Some(oid.to_string()),
275        ReviewBaseline::NotSet => None,
276    };
277
278    let commits_since = if let Some(ref oid) = baseline_oid {
279        count_commits_since(repo, oid)?
280    } else {
281        0
282    };
283
284    let is_stale = commits_since > 10;
285
286    let diff_stats = get_diff_stats(repo, baseline_oid.as_ref())?;
287
288    Ok(BaselineSummary {
289        baseline_oid,
290        commits_since,
291        is_stale,
292        diff_stats,
293    })
294}
295
296fn count_lines_in_blob(content: &[u8]) -> usize {
297    if content.is_empty() {
298        return 0;
299    }
300    content.iter().copied().filter(|&c| c == b'\n').count() + 1
301}
302
303fn get_diff_stats(
304    repo: &git2::Repository,
305    baseline_oid: Option<&String>,
306) -> iot::Result<DiffStats> {
307    let baseline_tree = match baseline_oid {
308        Some(oid) => {
309            let oid = git2::Oid::from_str(oid).map_err(|_| {
310                iot::Error::new(
311                    iot::ErrorKind::InvalidInput,
312                    format!("Invalid baseline OID: {oid}"),
313                )
314            })?;
315            let commit = repo.find_commit(oid).map_err(|e| to_io_error(&e))?;
316            commit.tree().map_err(|e| to_io_error(&e))?
317        }
318        None => repo
319            .find_tree(git2::Oid::zero())
320            .map_err(|e| to_io_error(&e))?,
321    };
322
323    let head_tree = match repo.head() {
324        Ok(head) => {
325            let commit = head.peel_to_commit().map_err(|e| to_io_error(&e))?;
326            commit.tree().map_err(|e| to_io_error(&e))?
327        }
328        Err(_) => repo
329            .find_tree(git2::Oid::zero())
330            .map_err(|e| to_io_error(&e))?,
331    };
332
333    let diff = repo
334        .diff_tree_to_tree(Some(&baseline_tree), Some(&head_tree), None)
335        .map_err(|e| to_io_error(&e))?;
336
337    #[derive(Debug, Clone)]
338    struct DeltaInfo {
339        path: Option<String>,
340        is_added_or_modified: bool,
341        blob_id: git2::Oid,
342    }
343
344    let deltas: Vec<DeltaInfo> = diff
345        .deltas()
346        .filter_map(|delta| {
347            use git2::Delta;
348
349            let path = delta
350                .new_file()
351                .path()
352                .or(delta.old_file().path())
353                .map(|p: &std::path::Path| p.to_string_lossy().to_string());
354
355            let (is_new_or_modified, blob_id) = match delta.status() {
356                Delta::Added | Delta::Modified => (true, delta.new_file().id()),
357                Delta::Deleted => (false, delta.old_file().id()),
358                _ => return None,
359            };
360
361            Some(DeltaInfo {
362                path,
363                is_added_or_modified: is_new_or_modified,
364                blob_id,
365            })
366        })
367        .collect();
368
369    let files_changed = deltas.len();
370    let changed_files: Vec<String> = deltas
371        .iter()
372        .filter_map(|d| d.path.clone())
373        .take(10)
374        .collect();
375
376    let (lines_added, lines_deleted) = deltas
377        .iter()
378        .filter_map(|d| {
379            repo.find_blob(d.blob_id)
380                .ok()
381                .map(|blob| (d.is_added_or_modified, count_lines_in_blob(blob.content())))
382        })
383        .fold((0usize, 0usize), |(add, del), (is_new, count)| {
384            if is_new {
385                (add.saturating_add(count), del)
386            } else {
387                (add, del.saturating_add(count))
388            }
389        });
390
391    let stats = DiffStats {
392        files_changed,
393        lines_added,
394        lines_deleted,
395        changed_files,
396    };
397
398    Ok(stats)
399}
400
401#[cfg(test)]
402mod tests {
403    use super::*;
404
405    #[test]
406    fn test_review_baseline_file_path_defined() {
407        assert_eq!(REVIEW_BASELINE_FILE, ".agent/review_baseline.txt");
408    }
409
410    #[test]
411    fn test_load_review_baseline_with_workspace_not_set() {
412        use crate::workspace::MemoryWorkspace;
413
414        let workspace = MemoryWorkspace::new_test();
415
416        let result = load_review_baseline_with_workspace(&workspace).unwrap();
417        assert_eq!(result, ReviewBaseline::NotSet);
418    }
419
420    #[test]
421    fn test_load_review_baseline_with_workspace_sentinel() {
422        use crate::workspace::MemoryWorkspace;
423
424        let workspace =
425            MemoryWorkspace::new_test().with_file(".agent/review_baseline.txt", BASELINE_NOT_SET);
426
427        let result = load_review_baseline_with_workspace(&workspace).unwrap();
428        assert_eq!(result, ReviewBaseline::NotSet);
429    }
430
431    #[test]
432    fn test_load_review_baseline_with_workspace_empty() {
433        use crate::workspace::MemoryWorkspace;
434
435        let workspace = MemoryWorkspace::new_test().with_file(".agent/review_baseline.txt", "");
436
437        let result = load_review_baseline_with_workspace(&workspace).unwrap();
438        assert_eq!(result, ReviewBaseline::NotSet);
439    }
440
441    #[test]
442    fn test_load_review_baseline_with_workspace_valid_oid() {
443        use crate::workspace::MemoryWorkspace;
444
445        let workspace = MemoryWorkspace::new_test().with_file(
446            ".agent/review_baseline.txt",
447            "abcd1234abcd1234abcd1234abcd1234abcd1234",
448        );
449
450        let result = load_review_baseline_with_workspace(&workspace).unwrap();
451        let expected_oid = git2::Oid::from_str("abcd1234abcd1234abcd1234abcd1234abcd1234").unwrap();
452        assert_eq!(result, ReviewBaseline::Commit(expected_oid));
453    }
454
455    #[test]
456    fn test_load_review_baseline_with_workspace_invalid_oid() {
457        use crate::workspace::MemoryWorkspace;
458
459        let workspace =
460            MemoryWorkspace::new_test().with_file(".agent/review_baseline.txt", "invalid");
461
462        let result = load_review_baseline_with_workspace(&workspace);
463        assert!(result.is_err());
464        assert_eq!(result.unwrap_err().kind(), iot::ErrorKind::InvalidData);
465    }
466}