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/// Write start point to file using workspace abstraction.
154///
155/// This is the workspace-aware version that should be used in pipeline code
156/// where a workspace is available.
157#[cfg(any(test, feature = "test-utils"))]
158fn write_start_point_with_workspace(
159    workspace: &dyn Workspace,
160    start_point: StartPoint,
161) -> io::Result<()> {
162    let path = Path::new(START_COMMIT_FILE);
163    let content = match start_point {
164        StartPoint::Commit(oid) => oid.to_string(),
165        StartPoint::EmptyRepo => EMPTY_REPO_SENTINEL.to_string(),
166    };
167    workspace.write(path, &content)
168}
169
170/// Load start point from file using workspace abstraction.
171///
172/// This version reads the file content via workspace but still validates
173/// the commit exists using the provided repository.
174///
175/// This is the workspace-aware version for pipeline code where git operations
176/// are needed for validation.
177#[cfg(any(test, feature = "test-utils"))]
178pub fn load_start_point_with_workspace(
179    workspace: &dyn Workspace,
180    repo: &git2::Repository,
181) -> io::Result<StartPoint> {
182    let path = Path::new(START_COMMIT_FILE);
183    let content = workspace.read(path)?;
184    let raw = content.trim();
185
186    if raw.is_empty() {
187        return Err(io::Error::new(
188            io::ErrorKind::InvalidData,
189            "Starting commit file is empty. Run 'ralph --reset-start-commit' to fix.",
190        ));
191    }
192
193    if raw == EMPTY_REPO_SENTINEL {
194        return Ok(StartPoint::EmptyRepo);
195    }
196
197    // Validate OID format using libgit2.
198    let oid = git2::Oid::from_str(raw).map_err(|_| {
199        io::Error::new(
200            io::ErrorKind::InvalidData,
201            format!(
202                "Invalid OID format in {}: '{}'. Run 'ralph --reset-start-commit' to fix.",
203                START_COMMIT_FILE, raw
204            ),
205        )
206    })?;
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            io::Error::new(
213                io::ErrorKind::NotFound,
214                format!(
215                    "Start commit '{}' no longer exists (history rewritten). \
216                     Run 'ralph --reset-start-commit' to fix.",
217                    raw
218                ),
219            )
220        } else {
221            to_io_error(&e)
222        }
223    })?;
224
225    Ok(StartPoint::Commit(oid))
226}
227
228/// Save start commit using workspace abstraction.
229///
230/// This is the workspace-aware version for pipeline code where a workspace
231/// is available. If a valid start commit already exists, it is preserved.
232#[cfg(any(test, feature = "test-utils"))]
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 path = repo_root.join(START_COMMIT_FILE);
272    let content = fs::read_to_string(&path)?;
273
274    let raw = content.trim();
275
276    if raw.is_empty() {
277        return Err(io::Error::new(
278            io::ErrorKind::InvalidData,
279            "Starting commit file is empty. Run 'ralph --reset-start-commit' to fix.",
280        ));
281    }
282
283    if raw == EMPTY_REPO_SENTINEL {
284        return Ok(StartPoint::EmptyRepo);
285    }
286
287    // Validate OID format using libgit2.
288    // git2::Oid::from_str automatically validates both SHA-1 (40 hex chars) and
289    // SHA-256 (64 hex chars) formats, as well as abbreviated forms.
290    let oid = git2::Oid::from_str(raw).map_err(|_| {
291        io::Error::new(
292            io::ErrorKind::InvalidData,
293            format!(
294                "Invalid OID format in {}: '{}'. Run 'ralph --reset-start-commit' to fix.",
295                START_COMMIT_FILE, raw
296            ),
297        )
298    })?;
299
300    // Ensure the commit still exists in this repository (history may have been rewritten).
301    repo.find_commit(oid).map_err(|e| {
302        let err_msg = e.message();
303        if err_msg.contains("not found") || err_msg.contains("invalid") {
304            io::Error::new(
305                io::ErrorKind::NotFound,
306                format!("Start commit '{}' no longer exists (history rewritten). Run 'ralph --reset-start-commit' to fix.", raw),
307            )
308        } else {
309            to_io_error(&e)
310        }
311    })?;
312
313    Ok(StartPoint::Commit(oid))
314}
315
316/// Result of resetting the start commit.
317#[derive(Debug, Clone)]
318pub struct ResetStartCommitResult {
319    /// The OID that start_commit was set to.
320    pub oid: String,
321    /// The default branch used for merge-base calculation (if applicable).
322    pub default_branch: Option<String>,
323    /// Whether we fell back to HEAD (when on main/master branch).
324    pub fell_back_to_head: bool,
325}
326
327/// Reset the starting commit to merge-base with the default branch.
328///
329/// This is a CLI command that updates `.agent/start_commit` to the merge-base
330/// between HEAD and the default branch (main/master). This provides a better
331/// baseline for feature branch workflows, showing only changes since branching.
332///
333/// If the current branch is main/master itself, falls back to current HEAD.
334///
335/// # Errors
336///
337/// Returns an error if:
338/// - The current HEAD cannot be determined
339/// - The default branch cannot be found
340/// - No common ancestor exists between HEAD and the default branch
341/// - The file cannot be written
342///
343pub fn reset_start_commit() -> io::Result<ResetStartCommitResult> {
344    let repo = git2::Repository::discover(".").map_err(|e| to_io_error(&e))?;
345    let repo_root = repo
346        .workdir()
347        .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "No workdir for repository"))?;
348    reset_start_commit_impl(&repo, repo_root)
349}
350
351/// Implementation of reset_start_commit.
352fn reset_start_commit_impl(
353    repo: &git2::Repository,
354    repo_root: &Path,
355) -> io::Result<ResetStartCommitResult> {
356    // Get current HEAD
357    let head = repo.head().map_err(|e| {
358        if e.code() == git2::ErrorCode::UnbornBranch {
359            io::Error::new(io::ErrorKind::NotFound, "No commits yet (unborn branch)")
360        } else {
361            to_io_error(&e)
362        }
363    })?;
364    let head_commit = head.peel_to_commit().map_err(|e| to_io_error(&e))?;
365
366    // Check if we're on main/master - if so, fall back to HEAD
367    let current_branch = head.shorthand().unwrap_or("HEAD");
368    if current_branch == "main" || current_branch == "master" {
369        let oid = head_commit.id().to_string();
370        write_start_commit_with_oid(repo_root, &oid)?;
371        return Ok(ResetStartCommitResult {
372            oid,
373            default_branch: None,
374            fell_back_to_head: true,
375        });
376    }
377
378    // Get the default branch
379    let default_branch = super::branch::get_default_branch_at(repo_root)?;
380
381    // Find the default branch commit
382    let default_ref = format!("refs/heads/{}", default_branch);
383    let default_commit = match repo.find_reference(&default_ref) {
384        Ok(reference) => reference.peel_to_commit().map_err(|e| to_io_error(&e))?,
385        Err(_) => {
386            // Try origin/<default_branch> as fallback
387            let origin_ref = format!("refs/remotes/origin/{}", default_branch);
388            match repo.find_reference(&origin_ref) {
389                Ok(reference) => reference.peel_to_commit().map_err(|e| to_io_error(&e))?,
390                Err(_) => {
391                    return Err(io::Error::new(
392                        io::ErrorKind::NotFound,
393                        format!(
394                            "Default branch '{}' not found locally or in origin. \
395                             Make sure the branch exists.",
396                            default_branch
397                        ),
398                    ));
399                }
400            }
401        }
402    };
403
404    // Calculate merge-base
405    let merge_base = repo
406        .merge_base(head_commit.id(), default_commit.id())
407        .map_err(|e| {
408            if e.code() == git2::ErrorCode::NotFound {
409                io::Error::new(
410                    io::ErrorKind::NotFound,
411                    format!(
412                        "No common ancestor between current branch and '{}' (unrelated branches)",
413                        default_branch
414                    ),
415                )
416            } else {
417                to_io_error(&e)
418            }
419        })?;
420
421    let oid = merge_base.to_string();
422    write_start_commit_with_oid(repo_root, &oid)?;
423
424    Ok(ResetStartCommitResult {
425        oid,
426        default_branch: Some(default_branch),
427        fell_back_to_head: false,
428    })
429}
430
431/// Start commit summary for display.
432///
433/// Contains information about the start commit for user display.
434#[derive(Debug, Clone)]
435pub struct StartCommitSummary {
436    /// The start commit OID (short form, or None if not set).
437    pub start_oid: Option<String>,
438    /// Number of commits since start commit.
439    pub commits_since: usize,
440    /// Whether the start commit is stale (>10 commits behind).
441    pub is_stale: bool,
442}
443
444impl StartCommitSummary {
445    /// Format a compact version for inline display.
446    pub fn format_compact(&self) -> String {
447        match &self.start_oid {
448            Some(oid) => {
449                let short_oid = &oid[..8.min(oid.len())];
450                if self.is_stale {
451                    format!(
452                        "Start: {} (+{} commits, STALE)",
453                        short_oid, self.commits_since
454                    )
455                } else if self.commits_since > 0 {
456                    format!("Start: {} (+{} commits)", short_oid, self.commits_since)
457                } else {
458                    format!("Start: {}", short_oid)
459                }
460            }
461            None => "Start: not set".to_string(),
462        }
463    }
464}
465
466/// Get a summary of the start commit state for display.
467///
468/// Returns a `StartCommitSummary` containing information about the current
469/// start commit, commits since start, and staleness status.
470///
471pub fn get_start_commit_summary() -> io::Result<StartCommitSummary> {
472    let repo = git2::Repository::discover(".").map_err(|e| to_io_error(&e))?;
473    let repo_root = repo
474        .workdir()
475        .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "No workdir for repository"))?;
476    get_start_commit_summary_impl(&repo, repo_root)
477}
478
479/// Implementation of get_start_commit_summary.
480fn get_start_commit_summary_impl(
481    repo: &git2::Repository,
482    repo_root: &Path,
483) -> io::Result<StartCommitSummary> {
484    let start_oid = match load_start_point_impl(repo, repo_root)? {
485        StartPoint::Commit(oid) => Some(oid.to_string()),
486        StartPoint::EmptyRepo => None,
487    };
488
489    let (commits_since, is_stale) = if let Some(ref oid) = start_oid {
490        // Get HEAD commit
491        let head_oid = get_current_head_oid_impl(repo)?;
492        let head_commit = repo
493            .find_commit(git2::Oid::from_str(&head_oid).map_err(|_| {
494                io::Error::new(io::ErrorKind::InvalidData, "Invalid HEAD OID format")
495            })?)
496            .map_err(|e| to_io_error(&e))?;
497
498        let start_commit_oid = git2::Oid::from_str(oid)
499            .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "Invalid start OID format"))?;
500
501        let start_commit = repo
502            .find_commit(start_commit_oid)
503            .map_err(|e| to_io_error(&e))?;
504
505        // Count commits between start and HEAD
506        let mut revwalk = repo.revwalk().map_err(|e| to_io_error(&e))?;
507        revwalk
508            .push(head_commit.id())
509            .map_err(|e| to_io_error(&e))?;
510
511        let mut count = 0;
512        for commit_id in revwalk {
513            let commit_id = commit_id.map_err(|e| to_io_error(&e))?;
514            if commit_id == start_commit.id() {
515                break;
516            }
517            count += 1;
518            if count > 1000 {
519                break;
520            }
521        }
522
523        let is_stale = count > 10;
524        (count, is_stale)
525    } else {
526        (0, false)
527    };
528
529    Ok(StartCommitSummary {
530        start_oid,
531        commits_since,
532        is_stale,
533    })
534}
535
536#[cfg(test)]
537fn has_start_commit() -> bool {
538    load_start_point().is_ok()
539}
540
541/// Convert git2 error to `io::Error`.
542fn to_io_error(err: &git2::Error) -> io::Error {
543    io::Error::other(err.to_string())
544}
545
546#[cfg(test)]
547mod tests {
548    use super::*;
549
550    #[test]
551    fn test_start_commit_file_path_defined() {
552        // Verify the constant is defined correctly
553        assert_eq!(START_COMMIT_FILE, ".agent/start_commit");
554    }
555
556    #[test]
557    fn test_has_start_commit_returns_bool() {
558        // This test verifies the function exists and returns a bool
559        let result = has_start_commit();
560        // The result depends on whether we're in a Ralph pipeline
561        // We don't assert either way since the test environment varies
562        let _ = result;
563    }
564
565    #[test]
566    fn test_get_current_head_oid_returns_result() {
567        // This test verifies the function exists and returns a Result
568        let result = get_current_head_oid();
569        // Should succeed if we're in a git repo with commits
570        // We don't assert either way since the test environment varies
571        let _ = result;
572    }
573
574    #[test]
575    fn test_load_start_commit_returns_result() {
576        // This test verifies load_start_point returns a Result
577        // It will fail if the file doesn't exist, which is expected
578        let result = load_start_point();
579        assert!(result.is_ok() || result.is_err());
580    }
581
582    #[test]
583    fn test_reset_start_commit_returns_result() {
584        // This test verifies reset_start_commit returns a Result
585        // It will fail if not in a git repo, which is expected
586        let result = reset_start_commit();
587        assert!(result.is_ok() || result.is_err());
588    }
589
590    #[test]
591    fn test_save_start_commit_returns_result() {
592        // This test verifies save_start_commit returns a Result
593        // It will fail if not in a git repo, which is expected
594        let result = save_start_commit();
595        assert!(result.is_ok() || result.is_err());
596    }
597
598    // Integration tests would require a temporary git repository
599    // For full integration tests, see tests/git_workflow.rs
600
601    // =========================================================================
602    // Workspace-aware function tests
603    // =========================================================================
604
605    #[test]
606    fn test_write_start_point_with_workspace_commit() {
607        use crate::workspace::MemoryWorkspace;
608
609        let workspace = MemoryWorkspace::new_test();
610        let oid = git2::Oid::from_str("abcd1234abcd1234abcd1234abcd1234abcd1234").unwrap();
611
612        write_start_point_with_workspace(&workspace, StartPoint::Commit(oid)).unwrap();
613
614        let content = workspace.get_file(".agent/start_commit").unwrap();
615        assert_eq!(content, "abcd1234abcd1234abcd1234abcd1234abcd1234");
616    }
617
618    #[test]
619    fn test_write_start_point_with_workspace_empty_repo() {
620        use crate::workspace::MemoryWorkspace;
621
622        let workspace = MemoryWorkspace::new_test();
623
624        write_start_point_with_workspace(&workspace, StartPoint::EmptyRepo).unwrap();
625
626        let content = workspace.get_file(".agent/start_commit").unwrap();
627        assert_eq!(content, EMPTY_REPO_SENTINEL);
628    }
629}