ralph_workflow/git_helpers/
start_commit.rs

1//! Starting commit tracking for incremental diff generation.
2//!
3//! This module manages the starting commit reference that enables incremental
4//! diffs for reviewers while keeping agents isolated from git history.
5//!
6//! # Overview
7//!
8//! When a Ralph pipeline starts, the current HEAD commit is saved as the
9//! "starting commit" in `.agent/start_commit`. This reference is used to:
10//!
11//! 1. Generate incremental diffs for reviewers (changes since pipeline start)
12//! 2. Keep agents unaware of git history (no git context in prompts)
13//! 3. Enable proper review of accumulated changes across iterations
14//!
15//! The starting commit file persists across pipeline runs unless explicitly
16//! reset by the user via the `--reset-start-commit` CLI command.
17
18use std::fs;
19use std::io;
20use std::path::PathBuf;
21
22/// Path to the starting commit file.
23///
24/// Stored in `.agent/start_commit`, this file contains the OID (SHA) of the
25/// commit that was HEAD when the pipeline started.
26const START_COMMIT_FILE: &str = ".agent/start_commit";
27
28/// Sentinel value written to `.agent/start_commit` when the repository has no commits yet.
29///
30/// This enables incremental diffs to work in a single run that starts on an unborn HEAD
31/// by treating the starting point as the empty tree.
32const EMPTY_REPO_SENTINEL: &str = "__EMPTY_REPO__";
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum StartPoint {
36    /// A concrete commit OID to diff from.
37    Commit(git2::Oid),
38    /// An empty repository baseline (diff from the empty tree).
39    EmptyRepo,
40}
41
42/// Get the current HEAD commit OID.
43///
44/// Returns the full SHA-1 hash of the current HEAD commit.
45///
46/// # Errors
47///
48/// Returns an error if:
49/// - Not in a git repository
50/// - HEAD cannot be resolved (e.g., unborn branch)
51/// - HEAD is not a commit (e.g., symbolic ref to tag)
52pub fn get_current_head_oid() -> io::Result<String> {
53    let repo = git2::Repository::discover(".").map_err(|e| to_io_error(&e))?;
54
55    let head = repo.head().map_err(|e| {
56        // Handle UnbornBranch error consistently with git_diff()
57        // This provides a clearer error message for empty repositories
58        if e.code() == git2::ErrorCode::UnbornBranch {
59            io::Error::new(io::ErrorKind::NotFound, "No commits yet (unborn branch)")
60        } else {
61            to_io_error(&e)
62        }
63    })?;
64
65    // Get the commit OID
66    let head_commit = head.peel_to_commit().map_err(|e| to_io_error(&e))?;
67
68    Ok(head_commit.id().to_string())
69}
70
71fn get_current_start_point() -> io::Result<StartPoint> {
72    let repo = git2::Repository::discover(".").map_err(|e| to_io_error(&e))?;
73    let head = repo.head();
74    let start_point = match head {
75        Ok(head) => {
76            let head_commit = head.peel_to_commit().map_err(|e| to_io_error(&e))?;
77            StartPoint::Commit(head_commit.id())
78        }
79        Err(ref e) if e.code() == git2::ErrorCode::UnbornBranch => StartPoint::EmptyRepo,
80        Err(e) => return Err(to_io_error(&e)),
81    };
82    Ok(start_point)
83}
84
85/// Save the current HEAD commit as the starting commit.
86///
87/// Writes the current HEAD OID to `.agent/start_commit` only if it doesn't
88/// already exist. This ensures the `start_commit` persists across pipeline runs
89/// and is only reset when explicitly requested via `--reset-start-commit`.
90///
91/// # Errors
92///
93/// Returns an error if:
94/// - The current HEAD cannot be determined
95/// - The `.agent` directory cannot be created
96/// - The file cannot be written
97pub fn save_start_commit() -> io::Result<()> {
98    // If a start commit exists and is valid, preserve it across runs.
99    // If it exists but is invalid/corrupt, automatically repair it.
100    if load_start_point().is_ok() {
101        return Ok(());
102    }
103
104    write_start_point(get_current_start_point()?)
105}
106
107fn write_start_commit_with_oid(oid: &str) -> io::Result<()> {
108    // Ensure .agent directory exists
109    let path = PathBuf::from(START_COMMIT_FILE);
110    if let Some(parent) = path.parent() {
111        fs::create_dir_all(parent)?;
112    }
113
114    // Write the OID to the file
115    fs::write(START_COMMIT_FILE, oid)?;
116
117    Ok(())
118}
119
120fn write_start_point(start_point: StartPoint) -> io::Result<()> {
121    match start_point {
122        StartPoint::Commit(oid) => write_start_commit_with_oid(&oid.to_string()),
123        StartPoint::EmptyRepo => {
124            // Ensure .agent directory exists
125            let path = PathBuf::from(START_COMMIT_FILE);
126            if let Some(parent) = path.parent() {
127                fs::create_dir_all(parent)?;
128            }
129            fs::write(START_COMMIT_FILE, EMPTY_REPO_SENTINEL)?;
130            Ok(())
131        }
132    }
133}
134
135/// Load the starting commit OID from the file.
136///
137/// Reads the `.agent/start_commit` file and returns the stored OID.
138///
139/// # Errors
140///
141/// Returns an error if:
142/// - The file does not exist
143/// - The file cannot be read
144/// - The file content is invalid
145pub fn load_start_point() -> io::Result<StartPoint> {
146    let content = fs::read_to_string(START_COMMIT_FILE)?;
147
148    let raw = content.trim();
149
150    if raw.is_empty() {
151        return Err(io::Error::new(
152            io::ErrorKind::InvalidData,
153            "Starting commit file is empty",
154        ));
155    }
156
157    if raw == EMPTY_REPO_SENTINEL {
158        return Ok(StartPoint::EmptyRepo);
159    }
160
161    // Validate OID format using libgit2.
162    // git2::Oid::from_str automatically validates both SHA-1 (40 hex chars) and
163    // SHA-256 (64 hex chars) formats, as well as abbreviated forms.
164    let oid = git2::Oid::from_str(raw).map_err(|_| {
165        io::Error::new(
166            io::ErrorKind::InvalidData,
167            format!("Invalid OID format: {raw}"),
168        )
169    })?;
170
171    // Ensure the commit still exists in this repository (history may have been rewritten).
172    let repo = git2::Repository::discover(".").map_err(|e| to_io_error(&e))?;
173    repo.find_commit(oid).map_err(|e| to_io_error(&e))?;
174
175    Ok(StartPoint::Commit(oid))
176}
177
178/// Reset the starting commit to current HEAD.
179///
180/// This is a CLI command that updates `.agent/start_commit` to the current
181/// HEAD commit. It's useful when the user wants to start tracking from a
182/// different baseline.
183///
184/// # Errors
185///
186/// Returns an error if:
187/// - The current HEAD cannot be determined
188/// - The file cannot be written
189pub fn reset_start_commit() -> io::Result<()> {
190    // Unlike `save_start_commit`, a reset is an explicit user request and should
191    // fail on empty repositories where there is no HEAD commit to reference.
192    let oid = get_current_head_oid()?;
193    write_start_commit_with_oid(&oid)
194}
195
196#[cfg(test)]
197fn has_start_commit() -> bool {
198    load_start_point().is_ok()
199}
200
201/// Convert git2 error to `io::Error`.
202fn to_io_error(err: &git2::Error) -> io::Error {
203    io::Error::other(err.to_string())
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209
210    #[test]
211    fn test_start_commit_file_path_defined() {
212        // Verify the constant is defined correctly
213        assert_eq!(START_COMMIT_FILE, ".agent/start_commit");
214    }
215
216    #[test]
217    fn test_has_start_commit_returns_bool() {
218        // This test verifies the function exists and returns a bool
219        let result = has_start_commit();
220        // The result depends on whether we're in a Ralph pipeline
221        // We don't assert either way since the test environment varies
222        let _ = result;
223    }
224
225    #[test]
226    fn test_get_current_head_oid_returns_result() {
227        // This test verifies the function exists and returns a Result
228        let result = get_current_head_oid();
229        // Should succeed if we're in a git repo with commits
230        // We don't assert either way since the test environment varies
231        let _ = result;
232    }
233
234    #[test]
235    fn test_load_start_commit_returns_result() {
236        // This test verifies load_start_point returns a Result
237        // It will fail if the file doesn't exist, which is expected
238        let result = load_start_point();
239        assert!(result.is_ok() || result.is_err());
240    }
241
242    #[test]
243    fn test_reset_start_commit_returns_result() {
244        // This test verifies reset_start_commit returns a Result
245        // It will fail if not in a git repo, which is expected
246        let result = reset_start_commit();
247        assert!(result.is_ok() || result.is_err());
248    }
249
250    #[test]
251    fn test_save_start_commit_returns_result() {
252        // This test verifies save_start_commit returns a Result
253        // It will fail if not in a git repo, which is expected
254        let result = save_start_commit();
255        assert!(result.is_ok() || result.is_err());
256    }
257
258    // Integration tests would require a temporary git repository
259    // For full integration tests, see tests/git_workflow.rs
260}