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