Skip to main content

ralph_workflow/git_helpers/repo/
diff.rs

1use std::io;
2use std::path::Path;
3
4use crate::git_helpers::git2_to_io_error;
5use crate::workspace::Workspace;
6
7/// Get the diff of all changes (unstaged and staged).
8///
9/// Returns a formatted diff string suitable for LLM analysis.
10/// This is similar to `git diff HEAD`.
11///
12/// Handles the case of an empty repository (no commits yet) by
13/// diffing against an empty tree using a read-only approach.
14///
15/// # Errors
16///
17/// Returns error if the operation fails.
18pub fn git_diff() -> io::Result<String> {
19    let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
20    git_diff_impl(&repo)
21}
22
23/// Get the diff of all changes (unstaged and staged) by discovering from an explicit path.
24///
25/// This avoids coupling diff generation to the process current working directory.
26///
27/// # Errors
28///
29/// Returns error if the operation fails.
30pub fn git_diff_in_repo(repo_root: &Path) -> io::Result<String> {
31    let repo = git2::Repository::discover(repo_root).map_err(|e| git2_to_io_error(&e))?;
32    git_diff_impl(&repo)
33}
34
35/// Generate a diff from a specific starting commit.
36///
37/// Takes a starting commit OID and generates a diff between that commit
38/// and the current working tree. Returns a formatted diff string suitable
39/// for LLM analysis.
40///
41/// # Errors
42///
43/// Returns error if the operation fails.
44pub fn git_diff_from(start_oid: &str) -> io::Result<String> {
45    let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
46
47    // Parse the starting OID.
48    let oid = git2::Oid::from_str(start_oid).map_err(|_| {
49        io::Error::new(
50            io::ErrorKind::InvalidInput,
51            format!("Invalid commit OID: {start_oid}"),
52        )
53    })?;
54
55    git_diff_from_oid(&repo, oid)
56}
57
58/// Get the git diff from the starting commit.
59///
60/// Uses the saved starting commit from `.agent/start_commit` to generate
61/// an incremental diff. Falls back to diffing from HEAD if no start commit
62/// file exists.
63///
64/// # Errors
65///
66/// Returns error if the operation fails.
67pub fn get_git_diff_from_start() -> io::Result<String> {
68    use crate::git_helpers::start_commit::{load_start_point, save_start_commit, StartPoint};
69
70    // Ensure a valid starting point exists. This is expected to persist across runs,
71    // but we also repair missing/corrupt files opportunistically for robustness.
72    save_start_commit()?;
73
74    let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
75
76    match load_start_point()? {
77        StartPoint::Commit(oid) => git_diff_from(&oid.to_string()),
78        StartPoint::EmptyRepo => git_diff_from_empty_tree(&repo),
79    }
80}
81
82/// Get the git diff from the starting commit (workspace-aware).
83///
84/// This uses `.agent/start_commit` as the baseline and generates a diff between that baseline
85/// and the current state on disk, including staged + unstaged changes and untracked files.
86///
87/// Unlike [`get_git_diff_from_start`], this does not rely on the process CWD.
88///
89/// # Errors
90///
91/// Returns error if the operation fails.
92pub fn get_git_diff_from_start_with_workspace(workspace: &dyn Workspace) -> io::Result<String> {
93    use crate::git_helpers::start_commit::{
94        load_start_point_with_workspace, save_start_commit_with_workspace, StartPoint,
95    };
96
97    // Fast path: if the workspace has no on-disk .git, refuse to emit a diff.
98    // This ensures MemoryWorkspace and other in-memory workspaces never accidentally
99    // leak into the process CWD's git repository.
100    if !workspace.exists(std::path::Path::new(".git")) {
101        return Err(io::Error::new(
102            io::ErrorKind::NotFound,
103            "Workspace has no on-disk git repository",
104        ));
105    }
106
107    let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
108
109    // Ensure a valid start point exists. This is expected to persist across runs, but we also
110    // repair missing/corrupt files opportunistically for robustness.
111    save_start_commit_with_workspace(workspace, &repo)?;
112
113    match load_start_point_with_workspace(workspace, &repo)? {
114        StartPoint::Commit(oid) => git_diff_from_oid(&repo, oid),
115        StartPoint::EmptyRepo => git_diff_from_empty_tree(&repo),
116    }
117}
118
119/// Get the diff content that should be shown to reviewers.
120///
121/// Baseline selection:
122/// - If `.agent/review_baseline.txt` is set, diff from that commit.
123/// - Otherwise, diff from `.agent/start_commit` (the initial pipeline baseline).
124///
125/// The diff is always generated against the current state on disk (staged + unstaged + untracked).
126///
127/// Returns `(diff, baseline_oid_for_prompts)` where `baseline_oid_for_prompts` is the commit hash
128/// to mention in fallback instructions (or empty for empty repo baseline).
129///
130/// # Errors
131///
132/// Returns error if the operation fails.
133pub fn get_git_diff_for_review_with_workspace(
134    workspace: &dyn Workspace,
135) -> io::Result<(String, String)> {
136    use crate::git_helpers::review_baseline::{
137        load_review_baseline_with_workspace, ReviewBaseline,
138    };
139    use crate::git_helpers::start_commit::{
140        load_start_point_with_workspace, save_start_commit_with_workspace, StartPoint,
141    };
142
143    // NOTE: We discover the repo from CWD here because the `ReviewBaseline` and `start_commit`
144    // files live in the injected Workspace, but the diff itself must be generated from the real
145    // on-disk git repository.
146    let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
147
148    let baseline = load_review_baseline_with_workspace(workspace).unwrap_or(ReviewBaseline::NotSet);
149    match baseline {
150        ReviewBaseline::Commit(oid) => {
151            let diff = git_diff_from_oid(&repo, oid)?;
152            Ok((diff, oid.to_string()))
153        }
154        ReviewBaseline::NotSet => {
155            // Ensure a valid start point exists.
156            save_start_commit_with_workspace(workspace, &repo)?;
157
158            match load_start_point_with_workspace(workspace, &repo)? {
159                StartPoint::Commit(oid) => {
160                    let diff = git_diff_from_oid(&repo, oid)?;
161                    Ok((diff, oid.to_string()))
162                }
163                StartPoint::EmptyRepo => Ok((git_diff_from_empty_tree(&repo)?, String::new())),
164            }
165        }
166    }
167}
168
169/// Implementation of git diff.
170fn git_diff_impl(repo: &git2::Repository) -> io::Result<String> {
171    // Try to get HEAD tree.
172    let head_tree = match repo.head() {
173        Ok(head) => Some(head.peel_to_tree().map_err(|e| git2_to_io_error(&e))?),
174        Err(ref e) if e.code() == git2::ErrorCode::UnbornBranch => {
175            // No commits yet: diff an empty tree against the workdir.
176            let mut diff_opts = git2::DiffOptions::new();
177            diff_opts.include_untracked(true);
178            diff_opts.recurse_untracked_dirs(true);
179
180            let diff = repo
181                .diff_tree_to_workdir_with_index(None, Some(&mut diff_opts))
182                .map_err(|e| git2_to_io_error(&e))?;
183
184            let mut result = Vec::new();
185            diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
186                result.extend_from_slice(line.content());
187                true
188            })
189            .map_err(|e| git2_to_io_error(&e))?;
190
191            return Ok(String::from_utf8_lossy(&result).to_string());
192        }
193        Err(e) => return Err(git2_to_io_error(&e)),
194    };
195
196    // For repos with commits, diff HEAD against working tree (staged + unstaged + untracked).
197    let mut diff_opts = git2::DiffOptions::new();
198    diff_opts.include_untracked(true);
199    diff_opts.recurse_untracked_dirs(true);
200
201    let diff = repo
202        .diff_tree_to_workdir_with_index(head_tree.as_ref(), Some(&mut diff_opts))
203        .map_err(|e| git2_to_io_error(&e))?;
204
205    let mut result = Vec::new();
206    diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
207        result.extend_from_slice(line.content());
208        true
209    })
210    .map_err(|e| git2_to_io_error(&e))?;
211
212    Ok(String::from_utf8_lossy(&result).to_string())
213}
214
215fn git_diff_from_oid(repo: &git2::Repository, oid: git2::Oid) -> io::Result<String> {
216    let start_commit = repo.find_commit(oid).map_err(|e| git2_to_io_error(&e))?;
217    let start_tree = start_commit.tree().map_err(|e| git2_to_io_error(&e))?;
218
219    let mut diff_opts = git2::DiffOptions::new();
220    diff_opts.include_untracked(true);
221    diff_opts.recurse_untracked_dirs(true);
222
223    let diff = repo
224        .diff_tree_to_workdir_with_index(Some(&start_tree), Some(&mut diff_opts))
225        .map_err(|e| git2_to_io_error(&e))?;
226
227    let mut result = Vec::new();
228    diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
229        result.extend_from_slice(line.content());
230        true
231    })
232    .map_err(|e| git2_to_io_error(&e))?;
233
234    Ok(String::from_utf8_lossy(&result).to_string())
235}
236
237/// Generate a diff from the empty tree (initial commit).
238fn git_diff_from_empty_tree(repo: &git2::Repository) -> io::Result<String> {
239    let mut diff_opts = git2::DiffOptions::new();
240    diff_opts.include_untracked(true);
241    diff_opts.recurse_untracked_dirs(true);
242
243    let diff = repo
244        .diff_tree_to_workdir_with_index(None, Some(&mut diff_opts))
245        .map_err(|e| git2_to_io_error(&e))?;
246
247    let mut result = Vec::new();
248    diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
249        result.extend_from_slice(line.content());
250        true
251    })
252    .map_err(|e| git2_to_io_error(&e))?;
253
254    Ok(String::from_utf8_lossy(&result).to_string())
255}