ralph_workflow/git_helpers/
review_baseline.rs1use std::fs;
20use std::io;
21use std::path::Path;
22
23#[cfg(any(test, feature = "test-utils"))]
24use crate::workspace::Workspace;
25
26use super::start_commit::get_current_head_oid;
27
28const REVIEW_BASELINE_FILE: &str = ".agent/review_baseline.txt";
33
34const BASELINE_NOT_SET: &str = "__BASELINE_NOT_SET__";
36
37#[derive(Debug, Clone, PartialEq, Eq)]
39pub enum ReviewBaseline {
40 Commit(git2::Oid),
42 NotSet,
44}
45
46pub fn update_review_baseline() -> io::Result<()> {
60 let oid = get_current_head_oid()?;
61 write_review_baseline_cwd(&oid)
62}
63
64pub fn load_review_baseline() -> io::Result<ReviewBaseline> {
75 let path = Path::new(REVIEW_BASELINE_FILE);
76 load_review_baseline_impl(path)
77}
78
79fn load_review_baseline_impl(path: &Path) -> io::Result<ReviewBaseline> {
81 if !path.exists() {
82 return Ok(ReviewBaseline::NotSet);
83 }
84
85 let content = fs::read_to_string(path)?;
86 let raw = content.trim();
87
88 if raw.is_empty() || raw == BASELINE_NOT_SET {
89 return Ok(ReviewBaseline::NotSet);
90 }
91
92 let oid = git2::Oid::from_str(raw).map_err(|_| {
94 io::Error::new(
95 io::ErrorKind::InvalidData,
96 format!("Invalid OID format in {}: '{}'. The review baseline will be reset. Run 'ralph --reset-start-commit' if this persists.", REVIEW_BASELINE_FILE, raw),
97 )
98 })?;
99
100 Ok(ReviewBaseline::Commit(oid))
101}
102
103pub fn get_review_baseline_info() -> io::Result<(Option<String>, usize, bool)> {
111 let repo = git2::Repository::discover(".").map_err(|e| to_io_error(&e))?;
112 get_review_baseline_info_impl(&repo, load_review_baseline()?)
113}
114
115fn get_review_baseline_info_impl(
117 repo: &git2::Repository,
118 baseline: ReviewBaseline,
119) -> io::Result<(Option<String>, usize, bool)> {
120 let baseline_oid = match baseline {
121 ReviewBaseline::Commit(oid) => Some(oid.to_string()),
122 ReviewBaseline::NotSet => None,
123 };
124
125 let commits_since = if let Some(ref oid) = baseline_oid {
126 count_commits_since(repo, oid)?
127 } else {
128 0
129 };
130
131 let is_stale = commits_since > 10;
132
133 Ok((baseline_oid, commits_since, is_stale))
134}
135
136fn write_review_baseline_cwd(oid: &str) -> io::Result<()> {
138 let path = Path::new(REVIEW_BASELINE_FILE);
139 if let Some(parent) = path.parent() {
140 fs::create_dir_all(parent)?;
141 }
142 fs::write(path, oid)?;
143 Ok(())
144}
145
146#[cfg(any(test, feature = "test-utils"))]
150pub fn load_review_baseline_with_workspace(
151 workspace: &dyn Workspace,
152) -> io::Result<ReviewBaseline> {
153 let path = Path::new(REVIEW_BASELINE_FILE);
154
155 if !workspace.exists(path) {
156 return Ok(ReviewBaseline::NotSet);
157 }
158
159 let content = workspace.read(path)?;
160 let raw = content.trim();
161
162 if raw.is_empty() || raw == BASELINE_NOT_SET {
163 return Ok(ReviewBaseline::NotSet);
164 }
165
166 let oid = git2::Oid::from_str(raw).map_err(|_| {
168 io::Error::new(
169 io::ErrorKind::InvalidData,
170 format!(
171 "Invalid OID format in {}: '{}'. The review baseline will be reset. \
172 Run 'ralph --reset-start-commit' if this persists.",
173 REVIEW_BASELINE_FILE, raw
174 ),
175 )
176 })?;
177
178 Ok(ReviewBaseline::Commit(oid))
179}
180
181fn count_commits_since(repo: &git2::Repository, baseline_oid: &str) -> io::Result<usize> {
183 let oid = git2::Oid::from_str(baseline_oid).map_err(|_| {
184 io::Error::new(
185 io::ErrorKind::InvalidInput,
186 format!("Invalid baseline OID: {baseline_oid}"),
187 )
188 })?;
189
190 let baseline = repo.find_commit(oid).map_err(|e| to_io_error(&e))?;
191
192 match repo.head() {
194 Ok(head) => {
195 let head_commit = head.peel_to_commit().map_err(|e| to_io_error(&e))?;
196
197 let mut revwalk = repo.revwalk().map_err(|e| to_io_error(&e))?;
199 revwalk
200 .push(head_commit.id())
201 .map_err(|e| to_io_error(&e))?;
202
203 let mut count = 0;
204 for commit_id in revwalk {
205 let commit_id = commit_id.map_err(|e| to_io_error(&e))?;
206 if commit_id == baseline.id() {
207 break;
208 }
209 count += 1;
210 if count > 1000 {
212 break;
213 }
214 }
215 Ok(count)
216 }
217 Err(_) => Ok(0),
218 }
219}
220
221#[derive(Debug, Clone, Default)]
223pub struct DiffStats {
224 pub files_changed: usize,
226 pub lines_added: usize,
228 pub lines_deleted: usize,
230 pub changed_files: Vec<String>,
232}
233
234#[derive(Debug, Clone)]
236pub struct BaselineSummary {
237 pub baseline_oid: Option<String>,
239 pub commits_since: usize,
241 pub is_stale: bool,
243 pub diff_stats: DiffStats,
245}
246
247impl BaselineSummary {
248 pub fn format_compact(&self) -> String {
250 match &self.baseline_oid {
251 Some(oid) => {
252 let short_oid = &oid[..8.min(oid.len())];
253 if self.is_stale {
254 format!(
255 "Baseline: {} (+{} commits since, {} files changed)",
256 short_oid, self.commits_since, self.diff_stats.files_changed
257 )
258 } else if self.commits_since > 0 {
259 format!(
260 "Baseline: {} ({} commits since, {} files changed)",
261 short_oid, self.commits_since, self.diff_stats.files_changed
262 )
263 } else {
264 format!(
265 "Baseline: {} ({} files: +{}/-{} lines)",
266 short_oid,
267 self.diff_stats.files_changed,
268 self.diff_stats.lines_added,
269 self.diff_stats.lines_deleted
270 )
271 }
272 }
273 None => {
274 format!(
275 "Baseline: start_commit ({} files: +{}/-{} lines)",
276 self.diff_stats.files_changed,
277 self.diff_stats.lines_added,
278 self.diff_stats.lines_deleted
279 )
280 }
281 }
282 }
283
284 pub fn format_detailed(&self) -> String {
286 let mut lines = Vec::new();
287
288 lines.push("Review Baseline Summary:".to_string());
289 lines.push("─".repeat(40));
290
291 match &self.baseline_oid {
292 Some(oid) => {
293 let short_oid = &oid[..8.min(oid.len())];
294 lines.push(format!(" Commit: {}", short_oid));
295 if self.commits_since > 0 {
296 lines.push(format!(" Commits since baseline: {}", self.commits_since));
297 }
298 }
299 None => {
300 lines.push(" Commit: start_commit (initial baseline)".to_string());
301 }
302 }
303
304 lines.push(format!(
305 " Files changed: {}",
306 self.diff_stats.files_changed
307 ));
308 lines.push(format!(" Lines added: {}", self.diff_stats.lines_added));
309 lines.push(format!(
310 " Lines deleted: {}",
311 self.diff_stats.lines_deleted
312 ));
313
314 if !self.diff_stats.changed_files.is_empty() {
315 lines.push(String::new());
316 lines.push(" Changed files:".to_string());
317 for file in &self.diff_stats.changed_files {
318 lines.push(format!(" - {}", file));
319 }
320 if self.diff_stats.changed_files.len() < self.diff_stats.files_changed {
321 let remaining = self.diff_stats.files_changed - self.diff_stats.changed_files.len();
322 lines.push(format!(" ... and {} more", remaining));
323 }
324 }
325
326 if self.is_stale {
327 lines.push(String::new());
328 lines.push(
329 " ⚠ WARNING: Baseline is stale. Consider updating with --reset-start-commit."
330 .to_string(),
331 );
332 }
333
334 lines.join("\n")
335 }
336}
337
338pub fn get_baseline_summary() -> io::Result<BaselineSummary> {
344 let repo = git2::Repository::discover(".").map_err(|e| to_io_error(&e))?;
345 get_baseline_summary_impl(&repo, load_review_baseline()?)
346}
347
348fn get_baseline_summary_impl(
350 repo: &git2::Repository,
351 baseline: ReviewBaseline,
352) -> io::Result<BaselineSummary> {
353 let baseline_oid = match baseline {
354 ReviewBaseline::Commit(oid) => Some(oid.to_string()),
355 ReviewBaseline::NotSet => None,
356 };
357
358 let commits_since = if let Some(ref oid) = baseline_oid {
359 count_commits_since(repo, oid)?
360 } else {
361 0
362 };
363
364 let is_stale = commits_since > 10;
365
366 let diff_stats = get_diff_stats(repo, &baseline_oid)?;
368
369 Ok(BaselineSummary {
370 baseline_oid,
371 commits_since,
372 is_stale,
373 diff_stats,
374 })
375}
376
377fn count_lines_in_blob(content: &[u8]) -> usize {
383 if content.is_empty() {
384 return 0;
385 }
386 content.iter().filter(|&&c| c == b'\n').count() + 1
390}
391
392fn get_diff_stats(repo: &git2::Repository, baseline_oid: &Option<String>) -> io::Result<DiffStats> {
394 let baseline_tree = match baseline_oid {
395 Some(oid) => {
396 let oid = git2::Oid::from_str(oid).map_err(|_| {
397 io::Error::new(
398 io::ErrorKind::InvalidInput,
399 format!("Invalid baseline OID: {}", oid),
400 )
401 })?;
402 let commit = repo.find_commit(oid).map_err(|e| to_io_error(&e))?;
403 commit.tree().map_err(|e| to_io_error(&e))?
404 }
405 None => {
406 repo.find_tree(git2::Oid::zero())
408 .map_err(|e| to_io_error(&e))?
409 }
410 };
411
412 let head_tree = match repo.head() {
414 Ok(head) => {
415 let commit = head.peel_to_commit().map_err(|e| to_io_error(&e))?;
416 commit.tree().map_err(|e| to_io_error(&e))?
417 }
418 Err(_) => {
419 repo.find_tree(git2::Oid::zero())
421 .map_err(|e| to_io_error(&e))?
422 }
423 };
424
425 let diff = repo
427 .diff_tree_to_tree(Some(&baseline_tree), Some(&head_tree), None)
428 .map_err(|e| to_io_error(&e))?;
429
430 let mut stats = DiffStats::default();
432 let mut delta_ids = Vec::new();
433
434 diff.foreach(
435 &mut |delta, _progress| {
436 use git2::Delta;
437
438 stats.files_changed += 1;
439
440 if let Some(path) = delta.new_file().path() {
441 let path_str = path.to_string_lossy().to_string();
442 if stats.changed_files.len() < 10 {
443 stats.changed_files.push(path_str);
444 }
445 } else if let Some(path) = delta.old_file().path() {
446 let path_str = path.to_string_lossy().to_string();
447 if stats.changed_files.len() < 10 {
448 stats.changed_files.push(path_str);
449 }
450 }
451
452 match delta.status() {
453 Delta::Added => {
454 delta_ids.push((delta.new_file().id(), true));
455 }
456 Delta::Deleted => {
457 delta_ids.push((delta.old_file().id(), false));
458 }
459 Delta::Modified => {
460 delta_ids.push((delta.new_file().id(), true));
461 }
462 _ => {}
463 }
464
465 true
466 },
467 None,
468 None,
469 None,
470 )
471 .map_err(|e| to_io_error(&e))?;
472
473 for (blob_id, is_new_or_modified) in delta_ids {
475 if let Ok(blob) = repo.find_blob(blob_id) {
476 let line_count = count_lines_in_blob(blob.content());
477
478 if is_new_or_modified {
479 stats.lines_added += line_count;
480 } else {
481 stats.lines_deleted += line_count;
482 }
483 }
484 }
485
486 Ok(stats)
487}
488
489fn to_io_error(err: &git2::Error) -> io::Error {
491 io::Error::other(err.to_string())
492}
493
494#[cfg(test)]
495mod tests {
496 use super::*;
497
498 #[test]
499 fn test_review_baseline_file_path_defined() {
500 assert_eq!(REVIEW_BASELINE_FILE, ".agent/review_baseline.txt");
501 }
502
503 #[test]
504 fn test_load_review_baseline_returns_result() {
505 let result = load_review_baseline();
506 assert!(result.is_ok() || result.is_err());
507 }
508
509 #[test]
510 fn test_get_review_baseline_info_returns_result() {
511 let result = get_review_baseline_info();
512 assert!(result.is_ok() || result.is_err());
513 }
514
515 #[test]
520 fn test_load_review_baseline_with_workspace_not_set() {
521 use crate::workspace::MemoryWorkspace;
522
523 let workspace = MemoryWorkspace::new_test();
524
525 let result = load_review_baseline_with_workspace(&workspace).unwrap();
526 assert_eq!(result, ReviewBaseline::NotSet);
527 }
528
529 #[test]
530 fn test_load_review_baseline_with_workspace_sentinel() {
531 use crate::workspace::MemoryWorkspace;
532
533 let workspace =
534 MemoryWorkspace::new_test().with_file(".agent/review_baseline.txt", BASELINE_NOT_SET);
535
536 let result = load_review_baseline_with_workspace(&workspace).unwrap();
537 assert_eq!(result, ReviewBaseline::NotSet);
538 }
539
540 #[test]
541 fn test_load_review_baseline_with_workspace_empty() {
542 use crate::workspace::MemoryWorkspace;
543
544 let workspace = MemoryWorkspace::new_test().with_file(".agent/review_baseline.txt", "");
545
546 let result = load_review_baseline_with_workspace(&workspace).unwrap();
547 assert_eq!(result, ReviewBaseline::NotSet);
548 }
549
550 #[test]
551 fn test_load_review_baseline_with_workspace_valid_oid() {
552 use crate::workspace::MemoryWorkspace;
553
554 let workspace = MemoryWorkspace::new_test().with_file(
555 ".agent/review_baseline.txt",
556 "abcd1234abcd1234abcd1234abcd1234abcd1234",
557 );
558
559 let result = load_review_baseline_with_workspace(&workspace).unwrap();
560 let expected_oid = git2::Oid::from_str("abcd1234abcd1234abcd1234abcd1234abcd1234").unwrap();
561 assert_eq!(result, ReviewBaseline::Commit(expected_oid));
562 }
563
564 #[test]
565 fn test_load_review_baseline_with_workspace_invalid_oid() {
566 use crate::workspace::MemoryWorkspace;
567
568 let workspace =
569 MemoryWorkspace::new_test().with_file(".agent/review_baseline.txt", "invalid");
570
571 let result = load_review_baseline_with_workspace(&workspace);
572 assert!(result.is_err());
573 assert_eq!(result.unwrap_err().kind(), io::ErrorKind::InvalidData);
574 }
575}