ralph_workflow/git_helpers/repo/diff.rs
1// Git diff operations.
2//
3// Thin I/O primitives live in `diff/io.rs` (boundary module).
4// This module contains orchestration logic that calls those primitives.
5
6mod io;
7
8use std::path::Path;
9
10use crate::git_helpers::git_oid_to_git2_oid;
11use crate::workspace::Workspace;
12
13/// Get the diff of all changes (unstaged and staged).
14///
15/// Returns a formatted diff string suitable for LLM analysis.
16/// This is similar to `git diff HEAD`.
17///
18/// Handles the case of an empty repository (no commits yet) by
19/// diffing against an empty tree using a read-only approach.
20///
21/// # Errors
22///
23/// Returns error if the operation fails.
24pub fn git_diff() -> std::io::Result<String> {
25 let repo = io::discover_repo(Path::new("."))?;
26 diff_against_head(&repo)
27}
28
29/// Get the diff of all changes (unstaged and staged) by discovering from an explicit path.
30///
31/// This avoids coupling diff generation to the process current working directory.
32///
33/// # Errors
34///
35/// Returns error if the operation fails.
36pub fn git_diff_in_repo(repo_root: &Path) -> std::io::Result<String> {
37 let repo = io::discover_repo(repo_root)?;
38 diff_against_head(&repo)
39}
40
41/// Diff the current working directory against HEAD, handling the unborn-branch case.
42fn diff_against_head(repo: &git2::Repository) -> std::io::Result<String> {
43 match io::resolve_head_tree_oid(repo)? {
44 io::HeadTreeOid::Tree(tree_oid) => io::diff_from_tree_oid_impl(repo, tree_oid),
45 io::HeadTreeOid::UnbornBranch => io::diff_from_empty_tree_impl(repo),
46 }
47}
48
49/// Generate a diff from a specific starting commit.
50///
51/// Takes a starting commit OID and generates a diff between that commit
52/// and the current working tree. Returns a formatted diff string suitable
53/// for LLM analysis.
54///
55/// # Errors
56///
57/// Returns error if the operation fails.
58pub fn git_diff_from(start_oid: &str) -> std::io::Result<String> {
59 let repo = io::discover_repo(Path::new("."))?;
60 let oid = git2::Oid::from_str(start_oid).map_err(|_| {
61 std::io::Error::new(
62 std::io::ErrorKind::InvalidInput,
63 format!("Invalid commit OID: {start_oid}"),
64 )
65 })?;
66 io::diff_from_oid_impl(&repo, oid)
67}
68
69/// Get the git diff from the starting commit.
70///
71/// Uses the saved starting commit from `.agent/start_commit` to generate
72/// an incremental diff. Falls back to diffing from HEAD if no start commit
73/// file exists.
74///
75/// # Errors
76///
77/// Returns error if the operation fails.
78pub fn get_git_diff_from_start() -> std::io::Result<String> {
79 use crate::git_helpers::start_commit::{load_start_point, save_start_commit, StartPoint};
80
81 // Ensure a valid starting point exists. This is expected to persist across runs,
82 // but we also repair missing/corrupt files opportunistically for robustness.
83 save_start_commit()?;
84
85 let repo = io::discover_repo(Path::new("."))?;
86
87 match load_start_point()? {
88 StartPoint::Commit(oid) => {
89 let git2_oid = git_oid_to_git2_oid(&oid)?;
90 io::diff_from_oid_impl(&repo, git2_oid)
91 }
92 StartPoint::EmptyRepo => io::diff_from_empty_tree_impl(&repo),
93 }
94}
95
96/// Get the git diff from the starting commit (workspace-aware).
97///
98/// This uses `.agent/start_commit` as the baseline and generates a diff between that baseline
99/// and the current state on disk, including staged + unstaged changes and untracked files.
100///
101/// Unlike [`get_git_diff_from_start`], this does not rely on the process CWD.
102///
103/// # Errors
104///
105/// Returns error if the operation fails.
106pub fn get_git_diff_from_start_with_workspace(
107 workspace: &dyn Workspace,
108) -> std::io::Result<String> {
109 use crate::git_helpers::start_commit::{
110 load_start_point_with_workspace, save_start_commit_with_workspace, StartPoint,
111 };
112
113 // Fast path: if the workspace has no on-disk .git, refuse to emit a diff.
114 // This ensures MemoryWorkspace and other in-memory workspaces never accidentally
115 // leak into the process CWD's git repository.
116 if !workspace.exists(std::path::Path::new(".git")) {
117 return Err(std::io::Error::new(
118 std::io::ErrorKind::NotFound,
119 "Workspace has no on-disk git repository",
120 ));
121 }
122
123 let repo = io::discover_repo(Path::new("."))?;
124
125 // Ensure a valid start point exists. This is expected to persist across runs, but we also
126 // repair missing/corrupt files opportunistically for robustness.
127 save_start_commit_with_workspace(workspace, &repo)?;
128
129 match load_start_point_with_workspace(workspace, &repo)? {
130 StartPoint::Commit(oid) => {
131 let git2_oid = git_oid_to_git2_oid(&oid).map_err(|err| {
132 std::io::Error::new(std::io::ErrorKind::InvalidData, err.to_string())
133 })?;
134 io::diff_from_oid_impl(&repo, git2_oid)
135 }
136 StartPoint::EmptyRepo => io::diff_from_empty_tree_impl(&repo),
137 }
138}
139
140/// Get the diff content that should be shown to reviewers.
141///
142/// Baseline selection:
143/// - If `.agent/review_baseline.txt` is set, diff from that commit.
144/// - Otherwise, diff from `.agent/start_commit` (the initial pipeline baseline).
145///
146/// The diff is always generated against the current state on disk (staged + unstaged + untracked).
147///
148/// Returns `(diff, baseline_oid_for_prompts)` where `baseline_oid_for_prompts` is the commit hash
149/// to mention in fallback instructions (or empty for empty repo baseline).
150///
151/// # Errors
152///
153/// Returns error if the operation fails.
154pub fn get_git_diff_for_review_with_workspace(
155 workspace: &dyn Workspace,
156) -> std::io::Result<(String, String)> {
157 use crate::git_helpers::review_baseline::{
158 load_review_baseline_with_workspace, ReviewBaseline,
159 };
160 use crate::git_helpers::start_commit::{
161 load_start_point_with_workspace, save_start_commit_with_workspace, StartPoint,
162 };
163
164 // NOTE: We discover the repo from CWD here because the `ReviewBaseline` and `start_commit`
165 // files live in the injected Workspace, but the diff itself must be generated from the real
166 // on-disk git repository.
167 let repo = io::discover_repo(Path::new("."))?;
168
169 let baseline = load_review_baseline_with_workspace(workspace).unwrap_or(ReviewBaseline::NotSet);
170 match baseline {
171 ReviewBaseline::Commit(oid) => {
172 let diff = io::diff_from_oid_impl(&repo, oid)?;
173 Ok((diff, oid.to_string()))
174 }
175 ReviewBaseline::NotSet => {
176 // Ensure a valid start point exists.
177 save_start_commit_with_workspace(workspace, &repo)?;
178
179 match load_start_point_with_workspace(workspace, &repo)? {
180 StartPoint::Commit(oid) => {
181 let git2_oid = git_oid_to_git2_oid(&oid)?;
182 let diff = io::diff_from_oid_impl(&repo, git2_oid)?;
183 Ok((diff, oid.to_string()))
184 }
185 StartPoint::EmptyRepo => Ok((io::diff_from_empty_tree_impl(&repo)?, String::new())),
186 }
187 }
188 }
189}