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