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