Skip to main content

ralph_workflow/git_helpers/review_baseline/
baseline_persistence.rs

1// Review baseline persistence, parsing, and commit-distance calculation.
2
3/// Path to the review baseline file.
4///
5/// Stored in `.agent/review_baseline.txt`, this file contains the OID (SHA)
6/// for the baseline commit used for per-review-cycle diffs.
7pub const REVIEW_BASELINE_FILE: &str = ".agent/review_baseline.txt";
8
9/// Sentinel value for "baseline not set".
10///
11/// This is written to the baseline file when a baseline cannot be determined
12/// (e.g., empty repository / unborn HEAD) or when explicitly cleared.
13pub const BASELINE_NOT_SET: &str = "__BASELINE_NOT_SET__";
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum ReviewBaseline {
17    /// A concrete commit OID to diff from.
18    Commit(git2::Oid),
19    /// No baseline set; fall back to `start_commit`.
20    NotSet,
21}
22
23/// Load the review baseline from the working directory.
24///
25/// # Errors
26///
27/// Returns error if the operation fails.
28pub fn load_review_baseline() -> io::Result<ReviewBaseline> {
29    let repo = git2::Repository::discover(".").map_err(|e| to_io_error(&e))?;
30    let repo_root = repo
31        .workdir()
32        .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "No workdir for repository"))?;
33    let workspace = WorkspaceFs::new(repo_root.to_path_buf());
34    load_review_baseline_with_workspace(&workspace)
35}
36
37/// Load the review baseline using the workspace abstraction.
38///
39/// # Errors
40///
41/// Returns error if the operation fails.
42pub fn load_review_baseline_with_workspace(
43    workspace: &dyn Workspace,
44) -> io::Result<ReviewBaseline> {
45    let path = Path::new(REVIEW_BASELINE_FILE);
46    if !workspace.exists(path) {
47        return Ok(ReviewBaseline::NotSet);
48    }
49
50    let content = workspace.read(path)?;
51    let raw = content.trim();
52
53    if raw.is_empty() || raw == BASELINE_NOT_SET {
54        return Ok(ReviewBaseline::NotSet);
55    }
56
57    let oid = git2::Oid::from_str(raw).map_err(|_| {
58        io::Error::new(
59            io::ErrorKind::InvalidData,
60            format!(
61                "Invalid baseline OID in {REVIEW_BASELINE_FILE}: '{raw}'"
62            ),
63        )
64    })?;
65
66    Ok(ReviewBaseline::Commit(oid))
67}
68
69/// Update the review baseline to the current HEAD.
70///
71/// # Errors
72///
73/// Returns error if the operation fails.
74pub fn update_review_baseline() -> io::Result<()> {
75    let repo = git2::Repository::discover(".").map_err(|e| to_io_error(&e))?;
76    let repo_root = repo
77        .workdir()
78        .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "No workdir for repository"))?;
79    let workspace = WorkspaceFs::new(repo_root.to_path_buf());
80    update_review_baseline_with_workspace(&workspace)
81}
82
83/// Update the review baseline using the workspace abstraction.
84///
85/// # Errors
86///
87/// Returns error if the operation fails.
88pub fn update_review_baseline_with_workspace(workspace: &dyn Workspace) -> io::Result<()> {
89    let path = Path::new(REVIEW_BASELINE_FILE);
90    match get_current_head_oid() {
91        Ok(oid) => workspace.write(path, oid.trim()),
92        Err(e) if e.kind() == io::ErrorKind::NotFound => workspace.write(path, BASELINE_NOT_SET),
93        Err(e) => Err(e),
94    }
95}
96
97/// Get review baseline info: (`baseline_oid`, `commits_since`, `is_stale`).
98///
99/// If no baseline is set, returns `(None, 0, false)`.
100///
101/// # Errors
102///
103/// Returns error if the operation fails.
104pub fn get_review_baseline_info() -> io::Result<(Option<String>, usize, bool)> {
105    let repo = git2::Repository::discover(".").map_err(|e| to_io_error(&e))?;
106    match load_review_baseline()? {
107        ReviewBaseline::Commit(oid) => {
108            let oid_str = oid.to_string();
109            let commits_since = count_commits_since(&repo, &oid_str)?;
110            let is_stale = commits_since > 10;
111            Ok((Some(oid_str), commits_since, is_stale))
112        }
113        ReviewBaseline::NotSet => Ok((None, 0, false)),
114    }
115}
116
117fn count_commits_since(repo: &git2::Repository, baseline_oid: &str) -> io::Result<usize> {
118    let baseline = git2::Oid::from_str(baseline_oid).map_err(|_| {
119        io::Error::new(
120            io::ErrorKind::InvalidInput,
121            format!("Invalid baseline OID: {baseline_oid}"),
122        )
123    })?;
124
125    let head_oid = match repo.head() {
126        Ok(head) => head.peel_to_commit().map_err(|e| to_io_error(&e))?.id(),
127        Err(ref e) if e.code() == git2::ErrorCode::UnbornBranch => return Ok(0),
128        Err(e) => return Err(to_io_error(&e)),
129    };
130
131    // Prefer libgit2 graph calculation when possible.
132    if let Ok((ahead, _behind)) = repo.graph_ahead_behind(head_oid, baseline) {
133        return Ok(ahead);
134    }
135
136    // Fallback: count commits reachable from HEAD excluding those reachable from baseline.
137    let mut walk = repo.revwalk().map_err(|e| to_io_error(&e))?;
138    walk.push(head_oid).map_err(|e| to_io_error(&e))?;
139    walk.hide(baseline).map_err(|e| to_io_error(&e))?;
140    Ok(walk.count())
141}
142
143fn to_io_error(err: &git2::Error) -> io::Error {
144    io::Error::other(err.to_string())
145}