Skip to main content

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::Path;
21
22#[cfg(any(test, feature = "test-utils"))]
23use crate::workspace::Workspace;
24
25/// Path to the starting commit file.
26///
27/// Stored in `.agent/start_commit`, this file contains the OID (SHA) of the
28/// commit that was HEAD when the pipeline started.
29const START_COMMIT_FILE: &str = ".agent/start_commit";
30
31/// Sentinel value written to `.agent/start_commit` when the repository has no commits yet.
32///
33/// This enables incremental diffs to work in a single run that starts on an unborn HEAD
34/// by treating the starting point as the empty tree.
35const EMPTY_REPO_SENTINEL: &str = "__EMPTY_REPO__";
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum StartPoint {
39    /// A concrete commit OID to diff from.
40    Commit(git2::Oid),
41    /// An empty repository baseline (diff from the empty tree).
42    EmptyRepo,
43}
44
45/// Get the current HEAD commit OID.
46///
47/// Returns the full SHA-1 hash of the current HEAD commit.
48///
49/// # Errors
50///
51/// Returns an error if:
52/// - Not in a git repository
53/// - HEAD cannot be resolved (e.g., unborn branch)
54/// - HEAD is not a commit (e.g., symbolic ref to tag)
55///
56/// **Note:** This function uses the current working directory to discover the repo.
57pub fn get_current_head_oid() -> io::Result<String> {
58    let repo = git2::Repository::discover(".").map_err(|e| to_io_error(&e))?;
59    get_current_head_oid_impl(&repo)
60}
61
62/// Implementation of get_current_head_oid.
63fn get_current_head_oid_impl(repo: &git2::Repository) -> io::Result<String> {
64    let head = repo.head().map_err(|e| {
65        // Handle UnbornBranch error consistently with git_diff()
66        // This provides a clearer error message for empty repositories
67        if e.code() == git2::ErrorCode::UnbornBranch {
68            io::Error::new(io::ErrorKind::NotFound, "No commits yet (unborn branch)")
69        } else {
70            to_io_error(&e)
71        }
72    })?;
73
74    // Get the commit OID
75    let head_commit = head.peel_to_commit().map_err(|e| to_io_error(&e))?;
76
77    Ok(head_commit.id().to_string())
78}
79
80fn get_current_start_point(repo: &git2::Repository) -> io::Result<StartPoint> {
81    let head = repo.head();
82    let start_point = match head {
83        Ok(head) => {
84            let head_commit = head.peel_to_commit().map_err(|e| to_io_error(&e))?;
85            StartPoint::Commit(head_commit.id())
86        }
87        Err(ref e) if e.code() == git2::ErrorCode::UnbornBranch => StartPoint::EmptyRepo,
88        Err(e) => return Err(to_io_error(&e)),
89    };
90    Ok(start_point)
91}
92
93/// Save the current HEAD commit as the starting commit.
94///
95/// Writes the current HEAD OID to `.agent/start_commit` only if it doesn't
96/// already exist. This ensures the `start_commit` persists across pipeline runs
97/// and is only reset when explicitly requested via `--reset-start-commit`.
98///
99/// # Errors
100///
101/// Returns an error if:
102/// - The current HEAD cannot be determined
103/// - The `.agent` directory cannot be created
104/// - The file cannot be written
105///
106pub fn save_start_commit() -> io::Result<()> {
107    let repo = git2::Repository::discover(".").map_err(|e| to_io_error(&e))?;
108    let repo_root = repo
109        .workdir()
110        .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "No workdir for repository"))?;
111    save_start_commit_impl(&repo, repo_root)
112}
113
114/// Implementation of save_start_commit.
115fn save_start_commit_impl(repo: &git2::Repository, repo_root: &Path) -> io::Result<()> {
116    // If a start commit exists and is valid, preserve it across runs.
117    // If it exists but is invalid/corrupt, automatically repair it.
118    if load_start_point_impl(repo, repo_root).is_ok() {
119        return Ok(());
120    }
121
122    write_start_point(repo_root, get_current_start_point(repo)?)
123}
124
125fn write_start_commit_with_oid(repo_root: &Path, oid: &str) -> io::Result<()> {
126    // Ensure .agent directory exists
127    let path = repo_root.join(START_COMMIT_FILE);
128    if let Some(parent) = path.parent() {
129        fs::create_dir_all(parent)?;
130    }
131
132    // Write the OID to the file
133    fs::write(&path, oid)?;
134
135    Ok(())
136}
137
138fn write_start_point(repo_root: &Path, start_point: StartPoint) -> io::Result<()> {
139    match start_point {
140        StartPoint::Commit(oid) => write_start_commit_with_oid(repo_root, &oid.to_string()),
141        StartPoint::EmptyRepo => {
142            // Ensure .agent directory exists
143            let path = repo_root.join(START_COMMIT_FILE);
144            if let Some(parent) = path.parent() {
145                fs::create_dir_all(parent)?;
146            }
147            fs::write(&path, EMPTY_REPO_SENTINEL)?;
148            Ok(())
149        }
150    }
151}
152
153/// Load start point from file using workspace abstraction.
154///
155/// This version reads the file content via workspace but still validates
156/// the commit exists using the provided repository.
157///
158/// This is the workspace-aware version for pipeline code where git operations
159/// are needed for validation.
160#[cfg(any(test, feature = "test-utils"))]
161pub fn load_start_point_with_workspace(
162    workspace: &dyn Workspace,
163    repo: &git2::Repository,
164) -> io::Result<StartPoint> {
165    let path = Path::new(START_COMMIT_FILE);
166    let content = workspace.read(path)?;
167    let raw = content.trim();
168
169    if raw.is_empty() {
170        return Err(io::Error::new(
171            io::ErrorKind::InvalidData,
172            "Starting commit file is empty. Run 'ralph --reset-start-commit' to fix.",
173        ));
174    }
175
176    if raw == EMPTY_REPO_SENTINEL {
177        return Ok(StartPoint::EmptyRepo);
178    }
179
180    // Validate OID format using libgit2.
181    let oid = git2::Oid::from_str(raw).map_err(|_| {
182        io::Error::new(
183            io::ErrorKind::InvalidData,
184            format!(
185                "Invalid OID format in {}: '{}'. Run 'ralph --reset-start-commit' to fix.",
186                START_COMMIT_FILE, raw
187            ),
188        )
189    })?;
190
191    // Ensure the commit still exists in this repository.
192    repo.find_commit(oid).map_err(|e| {
193        let err_msg = e.message();
194        if err_msg.contains("not found") || err_msg.contains("invalid") {
195            io::Error::new(
196                io::ErrorKind::NotFound,
197                format!(
198                    "Start commit '{}' no longer exists (history rewritten). \
199                     Run 'ralph --reset-start-commit' to fix.",
200                    raw
201                ),
202            )
203        } else {
204            to_io_error(&e)
205        }
206    })?;
207
208    Ok(StartPoint::Commit(oid))
209}
210
211/// Load the starting commit OID from the file.
212///
213/// Reads the `.agent/start_commit` file and returns the stored OID.
214///
215/// # Errors
216///
217/// Returns an error if:
218/// - The file does not exist
219/// - The file cannot be read
220/// - The file content is invalid
221///
222pub fn load_start_point() -> io::Result<StartPoint> {
223    let repo = git2::Repository::discover(".").map_err(|e| {
224        io::Error::new(
225            io::ErrorKind::NotFound,
226            format!("Git repository error: {e}. Run 'ralph --reset-start-commit' to fix."),
227        )
228    })?;
229    let repo_root = repo
230        .workdir()
231        .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "No workdir for repository"))?;
232    load_start_point_impl(&repo, repo_root)
233}
234
235/// Implementation of load_start_point.
236fn load_start_point_impl(repo: &git2::Repository, repo_root: &Path) -> io::Result<StartPoint> {
237    let path = repo_root.join(START_COMMIT_FILE);
238    let content = fs::read_to_string(&path)?;
239
240    let raw = content.trim();
241
242    if raw.is_empty() {
243        return Err(io::Error::new(
244            io::ErrorKind::InvalidData,
245            "Starting commit file is empty. Run 'ralph --reset-start-commit' to fix.",
246        ));
247    }
248
249    if raw == EMPTY_REPO_SENTINEL {
250        return Ok(StartPoint::EmptyRepo);
251    }
252
253    // Validate OID format using libgit2.
254    // git2::Oid::from_str automatically validates both SHA-1 (40 hex chars) and
255    // SHA-256 (64 hex chars) formats, as well as abbreviated forms.
256    let oid = git2::Oid::from_str(raw).map_err(|_| {
257        io::Error::new(
258            io::ErrorKind::InvalidData,
259            format!(
260                "Invalid OID format in {}: '{}'. Run 'ralph --reset-start-commit' to fix.",
261                START_COMMIT_FILE, raw
262            ),
263        )
264    })?;
265
266    // Ensure the commit still exists in this repository (history may have been rewritten).
267    repo.find_commit(oid).map_err(|e| {
268        let err_msg = e.message();
269        if err_msg.contains("not found") || err_msg.contains("invalid") {
270            io::Error::new(
271                io::ErrorKind::NotFound,
272                format!("Start commit '{}' no longer exists (history rewritten). Run 'ralph --reset-start-commit' to fix.", raw),
273            )
274        } else {
275            to_io_error(&e)
276        }
277    })?;
278
279    Ok(StartPoint::Commit(oid))
280}
281
282/// Result of resetting the start commit.
283#[derive(Debug, Clone)]
284pub struct ResetStartCommitResult {
285    /// The OID that start_commit was set to.
286    pub oid: String,
287    /// The default branch used for merge-base calculation (if applicable).
288    pub default_branch: Option<String>,
289    /// Whether we fell back to HEAD (when on main/master branch).
290    pub fell_back_to_head: bool,
291}
292
293/// Reset the starting commit to merge-base with the default branch.
294///
295/// This is a CLI command that updates `.agent/start_commit` to the merge-base
296/// between HEAD and the default branch (main/master). This provides a better
297/// baseline for feature branch workflows, showing only changes since branching.
298///
299/// If the current branch is main/master itself, falls back to current HEAD.
300///
301/// # Errors
302///
303/// Returns an error if:
304/// - The current HEAD cannot be determined
305/// - The default branch cannot be found
306/// - No common ancestor exists between HEAD and the default branch
307/// - The file cannot be written
308///
309pub fn reset_start_commit() -> io::Result<ResetStartCommitResult> {
310    let repo = git2::Repository::discover(".").map_err(|e| to_io_error(&e))?;
311    let repo_root = repo
312        .workdir()
313        .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "No workdir for repository"))?;
314    reset_start_commit_impl(&repo, repo_root)
315}
316
317/// Implementation of reset_start_commit.
318fn reset_start_commit_impl(
319    repo: &git2::Repository,
320    repo_root: &Path,
321) -> io::Result<ResetStartCommitResult> {
322    // Get current HEAD
323    let head = repo.head().map_err(|e| {
324        if e.code() == git2::ErrorCode::UnbornBranch {
325            io::Error::new(io::ErrorKind::NotFound, "No commits yet (unborn branch)")
326        } else {
327            to_io_error(&e)
328        }
329    })?;
330    let head_commit = head.peel_to_commit().map_err(|e| to_io_error(&e))?;
331
332    // Check if we're on main/master - if so, fall back to HEAD
333    let current_branch = head.shorthand().unwrap_or("HEAD");
334    if current_branch == "main" || current_branch == "master" {
335        let oid = head_commit.id().to_string();
336        write_start_commit_with_oid(repo_root, &oid)?;
337        return Ok(ResetStartCommitResult {
338            oid,
339            default_branch: None,
340            fell_back_to_head: true,
341        });
342    }
343
344    // Get the default branch
345    let default_branch = super::branch::get_default_branch_at(repo_root)?;
346
347    // Find the default branch commit
348    let default_ref = format!("refs/heads/{}", default_branch);
349    let default_commit = match repo.find_reference(&default_ref) {
350        Ok(reference) => reference.peel_to_commit().map_err(|e| to_io_error(&e))?,
351        Err(_) => {
352            // Try origin/<default_branch> as fallback
353            let origin_ref = format!("refs/remotes/origin/{}", default_branch);
354            match repo.find_reference(&origin_ref) {
355                Ok(reference) => reference.peel_to_commit().map_err(|e| to_io_error(&e))?,
356                Err(_) => {
357                    return Err(io::Error::new(
358                        io::ErrorKind::NotFound,
359                        format!(
360                            "Default branch '{}' not found locally or in origin. \
361                             Make sure the branch exists.",
362                            default_branch
363                        ),
364                    ));
365                }
366            }
367        }
368    };
369
370    // Calculate merge-base
371    let merge_base = repo
372        .merge_base(head_commit.id(), default_commit.id())
373        .map_err(|e| {
374            if e.code() == git2::ErrorCode::NotFound {
375                io::Error::new(
376                    io::ErrorKind::NotFound,
377                    format!(
378                        "No common ancestor between current branch and '{}' (unrelated branches)",
379                        default_branch
380                    ),
381                )
382            } else {
383                to_io_error(&e)
384            }
385        })?;
386
387    let oid = merge_base.to_string();
388    write_start_commit_with_oid(repo_root, &oid)?;
389
390    Ok(ResetStartCommitResult {
391        oid,
392        default_branch: Some(default_branch),
393        fell_back_to_head: false,
394    })
395}
396
397/// Start commit summary for display.
398///
399/// Contains information about the start commit for user display.
400#[derive(Debug, Clone)]
401pub struct StartCommitSummary {
402    /// The start commit OID (short form, or None if not set).
403    pub start_oid: Option<String>,
404    /// Number of commits since start commit.
405    pub commits_since: usize,
406    /// Whether the start commit is stale (>10 commits behind).
407    pub is_stale: bool,
408}
409
410impl StartCommitSummary {
411    /// Format a compact version for inline display.
412    pub fn format_compact(&self) -> String {
413        match &self.start_oid {
414            Some(oid) => {
415                let short_oid = &oid[..8.min(oid.len())];
416                if self.is_stale {
417                    format!(
418                        "Start: {} (+{} commits, STALE)",
419                        short_oid, self.commits_since
420                    )
421                } else if self.commits_since > 0 {
422                    format!("Start: {} (+{} commits)", short_oid, self.commits_since)
423                } else {
424                    format!("Start: {}", short_oid)
425                }
426            }
427            None => "Start: not set".to_string(),
428        }
429    }
430}
431
432/// Get a summary of the start commit state for display.
433///
434/// Returns a `StartCommitSummary` containing information about the current
435/// start commit, commits since start, and staleness status.
436///
437pub fn get_start_commit_summary() -> io::Result<StartCommitSummary> {
438    let repo = git2::Repository::discover(".").map_err(|e| to_io_error(&e))?;
439    let repo_root = repo
440        .workdir()
441        .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "No workdir for repository"))?;
442    get_start_commit_summary_impl(&repo, repo_root)
443}
444
445/// Implementation of get_start_commit_summary.
446fn get_start_commit_summary_impl(
447    repo: &git2::Repository,
448    repo_root: &Path,
449) -> io::Result<StartCommitSummary> {
450    let start_oid = match load_start_point_impl(repo, repo_root)? {
451        StartPoint::Commit(oid) => Some(oid.to_string()),
452        StartPoint::EmptyRepo => None,
453    };
454
455    let (commits_since, is_stale) = if let Some(ref oid) = start_oid {
456        // Get HEAD commit
457        let head_oid = get_current_head_oid_impl(repo)?;
458        let head_commit = repo
459            .find_commit(git2::Oid::from_str(&head_oid).map_err(|_| {
460                io::Error::new(io::ErrorKind::InvalidData, "Invalid HEAD OID format")
461            })?)
462            .map_err(|e| to_io_error(&e))?;
463
464        let start_commit_oid = git2::Oid::from_str(oid)
465            .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "Invalid start OID format"))?;
466
467        let start_commit = repo
468            .find_commit(start_commit_oid)
469            .map_err(|e| to_io_error(&e))?;
470
471        // Count commits between start and HEAD
472        let mut revwalk = repo.revwalk().map_err(|e| to_io_error(&e))?;
473        revwalk
474            .push(head_commit.id())
475            .map_err(|e| to_io_error(&e))?;
476
477        let mut count = 0;
478        for commit_id in revwalk {
479            let commit_id = commit_id.map_err(|e| to_io_error(&e))?;
480            if commit_id == start_commit.id() {
481                break;
482            }
483            count += 1;
484            if count > 1000 {
485                break;
486            }
487        }
488
489        let is_stale = count > 10;
490        (count, is_stale)
491    } else {
492        (0, false)
493    };
494
495    Ok(StartCommitSummary {
496        start_oid,
497        commits_since,
498        is_stale,
499    })
500}
501
502#[cfg(test)]
503fn has_start_commit() -> bool {
504    load_start_point().is_ok()
505}
506
507/// Convert git2 error to `io::Error`.
508fn to_io_error(err: &git2::Error) -> io::Error {
509    io::Error::other(err.to_string())
510}
511
512#[cfg(test)]
513mod tests {
514    use super::*;
515
516    #[test]
517    fn test_start_commit_file_path_defined() {
518        // Verify the constant is defined correctly
519        assert_eq!(START_COMMIT_FILE, ".agent/start_commit");
520    }
521
522    #[test]
523    fn test_has_start_commit_returns_bool() {
524        // This test verifies the function exists and returns a bool
525        let result = has_start_commit();
526        // The result depends on whether we're in a Ralph pipeline
527        // We don't assert either way since the test environment varies
528        let _ = result;
529    }
530
531    #[test]
532    fn test_get_current_head_oid_returns_result() {
533        // This test verifies the function exists and returns a Result
534        let result = get_current_head_oid();
535        // Should succeed if we're in a git repo with commits
536        // We don't assert either way since the test environment varies
537        let _ = result;
538    }
539
540    #[test]
541    fn test_load_start_commit_returns_result() {
542        // This test verifies load_start_point returns a Result
543        // It will fail if the file doesn't exist, which is expected
544        let result = load_start_point();
545        assert!(result.is_ok() || result.is_err());
546    }
547
548    #[test]
549    fn test_reset_start_commit_returns_result() {
550        // This test verifies reset_start_commit returns a Result
551        // It will fail if not in a git repo, which is expected
552        let result = reset_start_commit();
553        assert!(result.is_ok() || result.is_err());
554    }
555
556    #[test]
557    fn test_save_start_commit_returns_result() {
558        // This test verifies save_start_commit returns a Result
559        // It will fail if not in a git repo, which is expected
560        let result = save_start_commit();
561        assert!(result.is_ok() || result.is_err());
562    }
563
564    // Integration tests would require a temporary git repository
565    // For full integration tests, see tests/git_workflow.rs
566}