ralph_workflow/git_helpers/
review_baseline.rs1use 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
29include!("review_baseline/io.rs");
31
32use super::start_commit::get_current_head_oid;
33
34pub 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#[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}