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. Run 'ralph --reset-start-commit' to fix.",
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!(
168                "Invalid OID format in {}: '{}'. Run 'ralph --reset-start-commit' to fix.",
169                START_COMMIT_FILE, raw
170            ),
171        )
172    })?;
173
174    // Ensure the commit still exists in this repository (history may have been rewritten).
175    let repo = git2::Repository::discover(".").map_err(|e| {
176        io::Error::new(
177            io::ErrorKind::NotFound,
178            format!("Git repository error: {e}. Run 'ralph --reset-start-commit' to fix."),
179        )
180    })?;
181
182    repo.find_commit(oid).map_err(|e| {
183        let err_msg = e.message();
184        if err_msg.contains("not found") || err_msg.contains("invalid") {
185            io::Error::new(
186                io::ErrorKind::NotFound,
187                format!("Start commit '{}' no longer exists (history rewritten). Run 'ralph --reset-start-commit' to fix.", raw),
188            )
189        } else {
190            to_io_error(&e)
191        }
192    })?;
193
194    Ok(StartPoint::Commit(oid))
195}
196
197/// Result of resetting the start commit.
198#[derive(Debug, Clone)]
199pub struct ResetStartCommitResult {
200    /// The OID that start_commit was set to.
201    pub oid: String,
202    /// The default branch used for merge-base calculation (if applicable).
203    pub default_branch: Option<String>,
204    /// Whether we fell back to HEAD (when on main/master branch).
205    pub fell_back_to_head: bool,
206}
207
208/// Reset the starting commit to merge-base with the default branch.
209///
210/// This is a CLI command that updates `.agent/start_commit` to the merge-base
211/// between HEAD and the default branch (main/master). This provides a better
212/// baseline for feature branch workflows, showing only changes since branching.
213///
214/// If the current branch is main/master itself, falls back to current HEAD.
215///
216/// # Errors
217///
218/// Returns an error if:
219/// - The current HEAD cannot be determined
220/// - The default branch cannot be found
221/// - No common ancestor exists between HEAD and the default branch
222/// - The file cannot be written
223pub fn reset_start_commit() -> io::Result<ResetStartCommitResult> {
224    let repo = git2::Repository::discover(".").map_err(|e| to_io_error(&e))?;
225
226    // Get current HEAD
227    let head = repo.head().map_err(|e| {
228        if e.code() == git2::ErrorCode::UnbornBranch {
229            io::Error::new(io::ErrorKind::NotFound, "No commits yet (unborn branch)")
230        } else {
231            to_io_error(&e)
232        }
233    })?;
234    let head_commit = head.peel_to_commit().map_err(|e| to_io_error(&e))?;
235
236    // Check if we're on main/master - if so, fall back to HEAD
237    let current_branch = head.shorthand().unwrap_or("HEAD");
238    if current_branch == "main" || current_branch == "master" {
239        let oid = head_commit.id().to_string();
240        write_start_commit_with_oid(&oid)?;
241        return Ok(ResetStartCommitResult {
242            oid,
243            default_branch: None,
244            fell_back_to_head: true,
245        });
246    }
247
248    // Get the default branch
249    let default_branch = super::branch::get_default_branch()?;
250
251    // Find the default branch commit
252    let default_ref = format!("refs/heads/{}", default_branch);
253    let default_commit = match repo.find_reference(&default_ref) {
254        Ok(reference) => reference.peel_to_commit().map_err(|e| to_io_error(&e))?,
255        Err(_) => {
256            // Try origin/<default_branch> as fallback
257            let origin_ref = format!("refs/remotes/origin/{}", default_branch);
258            match repo.find_reference(&origin_ref) {
259                Ok(reference) => reference.peel_to_commit().map_err(|e| to_io_error(&e))?,
260                Err(_) => {
261                    return Err(io::Error::new(
262                        io::ErrorKind::NotFound,
263                        format!(
264                            "Default branch '{}' not found locally or in origin. \
265                             Make sure the branch exists.",
266                            default_branch
267                        ),
268                    ));
269                }
270            }
271        }
272    };
273
274    // Calculate merge-base
275    let merge_base = repo
276        .merge_base(head_commit.id(), default_commit.id())
277        .map_err(|e| {
278            if e.code() == git2::ErrorCode::NotFound {
279                io::Error::new(
280                    io::ErrorKind::NotFound,
281                    format!(
282                        "No common ancestor between current branch and '{}' (unrelated branches)",
283                        default_branch
284                    ),
285                )
286            } else {
287                to_io_error(&e)
288            }
289        })?;
290
291    let oid = merge_base.to_string();
292    write_start_commit_with_oid(&oid)?;
293
294    Ok(ResetStartCommitResult {
295        oid,
296        default_branch: Some(default_branch),
297        fell_back_to_head: false,
298    })
299}
300
301/// Start commit summary for display.
302///
303/// Contains information about the start commit for user display.
304#[derive(Debug, Clone)]
305pub struct StartCommitSummary {
306    /// The start commit OID (short form, or None if not set).
307    pub start_oid: Option<String>,
308    /// Number of commits since start commit.
309    pub commits_since: usize,
310    /// Whether the start commit is stale (>10 commits behind).
311    pub is_stale: bool,
312}
313
314impl StartCommitSummary {
315    /// Format a compact version for inline display.
316    pub fn format_compact(&self) -> String {
317        match &self.start_oid {
318            Some(oid) => {
319                let short_oid = &oid[..8.min(oid.len())];
320                if self.is_stale {
321                    format!(
322                        "Start: {} (+{} commits, STALE)",
323                        short_oid, self.commits_since
324                    )
325                } else if self.commits_since > 0 {
326                    format!("Start: {} (+{} commits)", short_oid, self.commits_since)
327                } else {
328                    format!("Start: {}", short_oid)
329                }
330            }
331            None => "Start: not set".to_string(),
332        }
333    }
334}
335
336/// Get a summary of the start commit state for display.
337///
338/// Returns a `StartCommitSummary` containing information about the current
339/// start commit, commits since start, and staleness status.
340pub fn get_start_commit_summary() -> io::Result<StartCommitSummary> {
341    let start_oid = match load_start_point()? {
342        StartPoint::Commit(oid) => Some(oid.to_string()),
343        StartPoint::EmptyRepo => None,
344    };
345
346    let (commits_since, is_stale) = if let Some(ref oid) = start_oid {
347        let repo = git2::Repository::discover(".").map_err(|e| to_io_error(&e))?;
348
349        // Get HEAD commit
350        let head_oid = get_current_head_oid()?;
351        let head_commit = repo
352            .find_commit(git2::Oid::from_str(&head_oid).map_err(|_| {
353                io::Error::new(io::ErrorKind::InvalidData, "Invalid HEAD OID format")
354            })?)
355            .map_err(|e| to_io_error(&e))?;
356
357        let start_commit_oid = git2::Oid::from_str(oid)
358            .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "Invalid start OID format"))?;
359
360        let start_commit = repo
361            .find_commit(start_commit_oid)
362            .map_err(|e| to_io_error(&e))?;
363
364        // Count commits between start and HEAD
365        let mut revwalk = repo.revwalk().map_err(|e| to_io_error(&e))?;
366        revwalk
367            .push(head_commit.id())
368            .map_err(|e| to_io_error(&e))?;
369
370        let mut count = 0;
371        for commit_id in revwalk {
372            let commit_id = commit_id.map_err(|e| to_io_error(&e))?;
373            if commit_id == start_commit.id() {
374                break;
375            }
376            count += 1;
377            if count > 1000 {
378                break;
379            }
380        }
381
382        let is_stale = count > 10;
383        (count, is_stale)
384    } else {
385        (0, false)
386    };
387
388    Ok(StartCommitSummary {
389        start_oid,
390        commits_since,
391        is_stale,
392    })
393}
394
395#[cfg(test)]
396fn has_start_commit() -> bool {
397    load_start_point().is_ok()
398}
399
400/// Convert git2 error to `io::Error`.
401fn to_io_error(err: &git2::Error) -> io::Error {
402    io::Error::other(err.to_string())
403}
404
405#[cfg(test)]
406mod tests {
407    use super::*;
408
409    #[test]
410    fn test_start_commit_file_path_defined() {
411        // Verify the constant is defined correctly
412        assert_eq!(START_COMMIT_FILE, ".agent/start_commit");
413    }
414
415    #[test]
416    fn test_has_start_commit_returns_bool() {
417        // This test verifies the function exists and returns a bool
418        let result = has_start_commit();
419        // The result depends on whether we're in a Ralph pipeline
420        // We don't assert either way since the test environment varies
421        let _ = result;
422    }
423
424    #[test]
425    fn test_get_current_head_oid_returns_result() {
426        // This test verifies the function exists and returns a Result
427        let result = get_current_head_oid();
428        // Should succeed if we're in a git repo with commits
429        // We don't assert either way since the test environment varies
430        let _ = result;
431    }
432
433    #[test]
434    fn test_load_start_commit_returns_result() {
435        // This test verifies load_start_point returns a Result
436        // It will fail if the file doesn't exist, which is expected
437        let result = load_start_point();
438        assert!(result.is_ok() || result.is_err());
439    }
440
441    #[test]
442    fn test_reset_start_commit_returns_result() {
443        // This test verifies reset_start_commit returns a Result
444        // It will fail if not in a git repo, which is expected
445        let result = reset_start_commit();
446        assert!(result.is_ok() || result.is_err());
447    }
448
449    #[test]
450    fn test_save_start_commit_returns_result() {
451        // This test verifies save_start_commit returns a Result
452        // It will fail if not in a git repo, which is expected
453        let result = save_start_commit();
454        assert!(result.is_ok() || result.is_err());
455    }
456
457    // Integration tests would require a temporary git repository
458    // For full integration tests, see tests/git_workflow.rs
459}