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}