ralph_workflow/git_helpers/
review_baseline.rs1use std::fs;
20use std::io;
21use std::path::PathBuf;
22
23use super::start_commit::get_current_head_oid;
24
25const REVIEW_BASELINE_FILE: &str = ".agent/review_baseline.txt";
30
31const BASELINE_NOT_SET: &str = "__BASELINE_NOT_SET__";
33
34#[derive(Debug, Clone, PartialEq, Eq)]
36pub enum ReviewBaseline {
37 Commit(git2::Oid),
39 NotSet,
41}
42
43pub fn update_review_baseline() -> io::Result<()> {
54 let oid = get_current_head_oid()?;
55 write_review_baseline(&oid)
56}
57
58pub 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 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
92pub fn get_git_diff_from_review_baseline() -> io::Result<String> {
104 match load_review_baseline()? {
105 ReviewBaseline::Commit(oid) => {
106 super::repo::git_diff_from(&oid.to_string())
108 }
109 ReviewBaseline::NotSet => {
110 super::repo::get_git_diff_from_start()
112 }
113 }
114}
115
116pub 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
141fn 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
151fn 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 match repo.head() {
164 Ok(head) => {
165 let head_commit = head.peel_to_commit().map_err(|e| to_io_error(&e))?;
166
167 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 if count > 1000 {
182 break;
183 }
184 }
185 Ok(count)
186 }
187 Err(_) => Ok(0),
188 }
189}
190
191#[derive(Debug, Clone, Default)]
193pub struct DiffStats {
194 pub files_changed: usize,
196 pub lines_added: usize,
198 pub lines_deleted: usize,
200 pub changed_files: Vec<String>,
202}
203
204#[derive(Debug, Clone)]
206pub struct BaselineSummary {
207 pub baseline_oid: Option<String>,
209 pub commits_since: usize,
211 pub is_stale: bool,
213 pub diff_stats: DiffStats,
215}
216
217impl BaselineSummary {
218 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 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
308pub 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 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
339fn count_lines_in_blob(content: &[u8]) -> usize {
345 if content.is_empty() {
346 return 0;
347 }
348 content.iter().filter(|&&c| c == b'\n').count() + 1
352}
353
354fn 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 repo.find_tree(git2::Oid::zero())
370 .map_err(|e| to_io_error(&e))?
371 }
372 };
373
374 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 repo.find_tree(git2::Oid::zero())
383 .map_err(|e| to_io_error(&e))?
384 }
385 };
386
387 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 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 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
451fn 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}