Skip to main content

ralph_workflow/git_helpers/
repo.rs

1//! Basic git repository operations.
2//!
3//! Provides fundamental git operations used throughout the application:
4//!
5//! - Repository detection and root path resolution
6//! - Working tree status snapshots (porcelain format)
7//! - Staging and committing changes
8//! - Diff generation for commit messages
9//! - Automated commit message generation and committing
10//!
11//! Operations use libgit2 directly to avoid CLI dependencies and work
12//! even when git is not installed.
13
14use std::io;
15use std::path::PathBuf;
16
17use super::git2_to_io_error;
18use super::identity::GitIdentity;
19use crate::workspace::Workspace;
20
21/// The level of truncation applied to a diff for review.
22///
23/// This enum tracks how much a diff has been abbreviated and determines
24/// what instructions should be given to the reviewer agent.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
26pub enum DiffTruncationLevel {
27    /// No truncation - full diff is included
28    #[default]
29    Full,
30    /// Diff was semantically truncated - high-priority files shown, instruction to explore
31    Abbreviated,
32    /// Only file paths listed - instruction to explore each file's diff
33    FileList,
34    /// File list was abbreviated - instruction to explore and discover files
35    FileListAbbreviated,
36}
37
38/// The result of diff truncation for review purposes.
39///
40/// Contains both the potentially-truncated content and metadata about
41/// what truncation was applied, along with version context information.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct DiffReviewContent {
44    /// The content to include in the review prompt
45    pub content: String,
46    /// The level of truncation applied
47    pub truncation_level: DiffTruncationLevel,
48    /// Total number of files in the full diff (for context in messages)
49    pub total_file_count: usize,
50    /// Number of files shown in the abbreviated content (if applicable)
51    pub shown_file_count: Option<usize>,
52    /// The OID (commit SHA) that this diff is compared against (baseline)
53    pub baseline_oid: Option<String>,
54    /// Short form (first 8 chars) of the baseline OID for display
55    pub baseline_short: Option<String>,
56    /// Description of what the baseline represents (e.g., "review_baseline", "start_commit")
57    pub baseline_description: String,
58}
59
60impl DiffReviewContent {
61    /// Generate a human-readable header describing the diff's version context.
62    ///
63    /// This header is meant to be included at the beginning of the diff content
64    /// to provide clarity about what state of the code the diff represents.
65    ///
66    /// # Returns
67    ///
68    /// A formatted string like:
69    /// ```text
70    /// Diff Context: Compared against review_baseline abc12345
71    /// Current state: Working directory (includes unstaged changes)
72    /// ```
73    ///
74    /// If no baseline information is available, returns a generic message.
75    pub fn format_context_header(&self) -> String {
76        let mut lines = Vec::new();
77
78        if let Some(short) = &self.baseline_short {
79            lines.push(format!(
80                "Diff Context: Compared against {} {}",
81                self.baseline_description, short
82            ));
83        } else {
84            lines.push("Diff Context: Version information not available".to_string());
85        }
86
87        // Add information about truncation if applicable
88        match self.truncation_level {
89            DiffTruncationLevel::Full => {
90                // No truncation - full diff
91            }
92            DiffTruncationLevel::Abbreviated => {
93                lines.push(format!(
94                    "Note: Diff abbreviated - {}/{} files shown",
95                    self.shown_file_count.unwrap_or(0),
96                    self.total_file_count
97                ));
98            }
99            DiffTruncationLevel::FileList => {
100                lines.push(format!(
101                    "Note: Only file list shown - {} files changed",
102                    self.total_file_count
103                ));
104            }
105            DiffTruncationLevel::FileListAbbreviated => {
106                lines.push(format!(
107                    "Note: File list abbreviated - {}/{} files shown",
108                    self.shown_file_count.unwrap_or(0),
109                    self.total_file_count
110                ));
111            }
112        }
113
114        if lines.is_empty() {
115            String::new()
116        } else {
117            format!("{}\n", lines.join("\n"))
118        }
119    }
120}
121
122/// Check if we're in a git repository.
123pub fn require_git_repo() -> io::Result<()> {
124    git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
125    Ok(())
126}
127
128/// Get the git repository root.
129pub fn get_repo_root() -> io::Result<PathBuf> {
130    let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
131    repo.workdir()
132        .map(PathBuf::from)
133        .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "No workdir for repository"))
134}
135
136/// Get the git hooks directory path.
137///
138/// Returns the path to the hooks directory inside .git (or the equivalent
139/// for worktrees and other configurations).
140///
141pub fn get_hooks_dir() -> io::Result<PathBuf> {
142    let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
143    Ok(repo.path().join("hooks"))
144}
145
146/// Get a snapshot of the current git status.
147///
148/// Returns status in porcelain format (similar to `git status --porcelain=v1`).
149///
150pub fn git_snapshot() -> io::Result<String> {
151    let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
152    git_snapshot_impl(&repo)
153}
154
155/// Implementation of git snapshot.
156fn git_snapshot_impl(repo: &git2::Repository) -> io::Result<String> {
157    let mut opts = git2::StatusOptions::new();
158    opts.include_untracked(true).recurse_untracked_dirs(true);
159    let statuses = repo
160        .statuses(Some(&mut opts))
161        .map_err(|e| git2_to_io_error(&e))?;
162
163    let mut result = String::new();
164    for entry in statuses.iter() {
165        let status = entry.status();
166        let path = entry.path().unwrap_or("").to_string();
167
168        // Convert git2 status to porcelain format
169        // Untracked files are represented as "??" in porcelain v1.
170        if status.contains(git2::Status::WT_NEW) {
171            result.push('?');
172            result.push('?');
173            result.push(' ');
174            result.push_str(&path);
175            result.push('\n');
176            continue;
177        }
178
179        // Index status
180        let index_status = if status.contains(git2::Status::INDEX_NEW) {
181            'A'
182        } else if status.contains(git2::Status::INDEX_MODIFIED) {
183            'M'
184        } else if status.contains(git2::Status::INDEX_DELETED) {
185            'D'
186        } else if status.contains(git2::Status::INDEX_RENAMED) {
187            'R'
188        } else if status.contains(git2::Status::INDEX_TYPECHANGE) {
189            'T'
190        } else {
191            ' '
192        };
193
194        // Worktree status
195        let wt_status = if status.contains(git2::Status::WT_MODIFIED) {
196            'M'
197        } else if status.contains(git2::Status::WT_DELETED) {
198            'D'
199        } else if status.contains(git2::Status::WT_RENAMED) {
200            'R'
201        } else if status.contains(git2::Status::WT_TYPECHANGE) {
202            'T'
203        } else {
204            ' '
205        };
206
207        result.push(index_status);
208        result.push(wt_status);
209        result.push(' ');
210        result.push_str(&path);
211        result.push('\n');
212    }
213
214    Ok(result)
215}
216
217/// Get the diff of all changes (unstaged and staged).
218///
219/// Returns a formatted diff string suitable for LLM analysis.
220/// This is similar to `git diff HEAD`.
221///
222/// Handles the case of an empty repository (no commits yet) by
223/// diffing against an empty tree using a read-only approach.
224///
225pub fn git_diff() -> io::Result<String> {
226    let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
227    git_diff_impl(&repo)
228}
229
230/// Implementation of git diff.
231fn git_diff_impl(repo: &git2::Repository) -> io::Result<String> {
232    // Try to get HEAD tree
233    let head_tree = match repo.head() {
234        Ok(head) => Some(head.peel_to_tree().map_err(|e| git2_to_io_error(&e))?),
235        Err(ref e) if e.code() == git2::ErrorCode::UnbornBranch => {
236            // No commits yet - we need to show all untracked files as new files
237            // Since there's no HEAD, we diff an empty tree against the workdir
238
239            // Create a diff with an empty tree (no parent tree)
240            // This is a read-only operation that doesn't modify the index
241            let mut diff_opts = git2::DiffOptions::new();
242            diff_opts.include_untracked(true);
243            diff_opts.recurse_untracked_dirs(true);
244
245            let diff = repo
246                .diff_tree_to_workdir_with_index(None, Some(&mut diff_opts))
247                .map_err(|e| git2_to_io_error(&e))?;
248
249            let mut result = Vec::new();
250            diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
251                result.extend_from_slice(line.content());
252                true
253            })
254            .map_err(|e| git2_to_io_error(&e))?;
255
256            return Ok(String::from_utf8_lossy(&result).to_string());
257        }
258        Err(e) => return Err(git2_to_io_error(&e)),
259    };
260
261    // For repos with commits, diff HEAD against working tree
262    // This includes both staged and unstaged changes
263    let mut diff_opts = git2::DiffOptions::new();
264    diff_opts.include_untracked(true);
265    diff_opts.recurse_untracked_dirs(true);
266
267    let diff = repo
268        .diff_tree_to_workdir_with_index(head_tree.as_ref(), Some(&mut diff_opts))
269        .map_err(|e| git2_to_io_error(&e))?;
270
271    // Generate diff text
272    let mut result = Vec::new();
273    diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
274        result.extend_from_slice(line.content());
275        true
276    })
277    .map_err(|e| git2_to_io_error(&e))?;
278
279    Ok(String::from_utf8_lossy(&result).to_string())
280}
281
282fn index_has_changes_to_commit(repo: &git2::Repository, index: &git2::Index) -> io::Result<bool> {
283    match repo.head() {
284        Ok(head) => {
285            let head_tree = head.peel_to_tree().map_err(|e| git2_to_io_error(&e))?;
286            let diff = repo
287                .diff_tree_to_index(Some(&head_tree), Some(index), None)
288                .map_err(|e| git2_to_io_error(&e))?;
289            Ok(diff.deltas().len() > 0)
290        }
291        Err(ref e) if e.code() == git2::ErrorCode::UnbornBranch => Ok(!index.is_empty()),
292        Err(e) => Err(git2_to_io_error(&e)),
293    }
294}
295
296fn is_internal_agent_artifact(path: &std::path::Path) -> bool {
297    let path_str = path.to_string_lossy();
298    path_str == ".no_agent_commit"
299        || path_str == ".agent"
300        || path_str.starts_with(".agent/")
301        || path_str == ".git"
302        || path_str.starts_with(".git/")
303}
304
305/// Stage all changes.
306///
307/// Similar to `git add -A`.
308///
309/// # Returns
310///
311/// Returns `Ok(true)` if files were successfully staged, `Ok(false)` if there
312/// were no files to stage, or an error if staging failed.
313///
314pub fn git_add_all() -> io::Result<bool> {
315    let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
316    git_add_all_impl(&repo)
317}
318
319/// Implementation of git add all.
320fn git_add_all_impl(repo: &git2::Repository) -> io::Result<bool> {
321    let mut index = repo.index().map_err(|e| git2_to_io_error(&e))?;
322
323    // Stage deletions (equivalent to `git add -A` behavior).
324    // libgit2's `add_all` doesn't automatically remove deleted paths.
325    let mut status_opts = git2::StatusOptions::new();
326    status_opts
327        .include_untracked(true)
328        .recurse_untracked_dirs(true)
329        .include_ignored(false);
330    let statuses = repo
331        .statuses(Some(&mut status_opts))
332        .map_err(|e| git2_to_io_error(&e))?;
333    for entry in statuses.iter() {
334        if entry.status().contains(git2::Status::WT_DELETED) {
335            if let Some(path) = entry.path() {
336                index
337                    .remove_path(std::path::Path::new(path))
338                    .map_err(|e| git2_to_io_error(&e))?;
339            }
340        }
341    }
342
343    // Add all files (staged, unstaged, and untracked)
344    // Note: add_all() is required here, not update_all(), to include untracked files
345    let mut filter_cb = |path: &std::path::Path, _matched: &[u8]| -> i32 {
346        // Return 0 to add the file, non-zero to skip.
347        // We skip (return 1) internal agent artifacts to avoid committing them.
348        i32::from(is_internal_agent_artifact(path))
349    };
350    index
351        .add_all(
352            vec!["."],
353            git2::IndexAddOption::DEFAULT,
354            Some(&mut filter_cb),
355        )
356        .map_err(|e| git2_to_io_error(&e))?;
357
358    index.write().map_err(|e| git2_to_io_error(&e))?;
359
360    // Return true if staging produced something commit-worthy.
361    index_has_changes_to_commit(repo, &index)
362}
363
364/// Resolve git commit identity with the full priority chain.
365///
366/// This function implements the identity resolution priority chain:
367/// 1. Git config (via libgit2's `repo.signature()`) - primary source
368/// 2. Provided name/email parameters (from Ralph config, CLI args, or env vars)
369/// 3. Environment variables (`RALPH_GIT_USER_NAME`, `RALPH_GIT_USER_EMAIL`)
370/// 4. Ralph config file values (passed through)
371/// 5. System username + derived email
372/// 6. Default values ("Ralph Workflow", "ralph@localhost")
373///
374/// Partial overrides are supported: CLI args/env vars/config can override
375/// individual fields (name or email) from git config.
376///
377/// # Arguments
378///
379/// * `repo` - The git repository (for git config)
380/// * `provided_name` - Optional name from Ralph config or CLI
381/// * `provided_email` - Optional email from Ralph config or CLI
382/// * `executor` - Optional process executor for system username/hostname lookup
383///
384/// # Returns
385///
386/// Returns `GitIdentity` with the resolved name and email.
387fn resolve_commit_identity(
388    repo: &git2::Repository,
389    provided_name: Option<&str>,
390    provided_email: Option<&str>,
391    executor: Option<&dyn crate::executor::ProcessExecutor>,
392) -> GitIdentity {
393    use super::identity::{default_identity, fallback_email, fallback_username};
394
395    // Priority 1: Git config (via libgit2) - primary source
396    let mut name = String::new();
397    let mut email = String::new();
398    let mut has_git_config = false;
399
400    if let Ok(sig) = repo.signature() {
401        let git_name = sig.name().unwrap_or("");
402        let git_email = sig.email().unwrap_or("");
403        if !git_name.is_empty() && !git_email.is_empty() {
404            name = git_name.to_string();
405            email = git_email.to_string();
406            has_git_config = true;
407        }
408    }
409
410    // Priority order (standard git behavior):
411    // 1. Git config (local .git/config, then global ~/.gitconfig) - primary source
412    // 2. Provided args (provided_name/provided_email) - from Ralph config or CLI override
413    // 3. Env vars (RALPH_GIT_USER_NAME/EMAIL) - fallback if above are missing
414    //
415    // This matches standard git behavior where git config is authoritative.
416    let env_name = std::env::var("RALPH_GIT_USER_NAME").ok();
417    let env_email = std::env::var("RALPH_GIT_USER_EMAIL").ok();
418
419    // Apply in priority order: git config > provided args > env vars
420    // Git config takes highest priority (standard git behavior)
421    let final_name = if has_git_config && !name.is_empty() {
422        name.as_str()
423    } else {
424        provided_name
425            .filter(|s| !s.is_empty())
426            .or(env_name.as_deref())
427            .filter(|s| !s.is_empty())
428            .unwrap_or("")
429    };
430
431    let final_email = if has_git_config && !email.is_empty() {
432        email.as_str()
433    } else {
434        provided_email
435            .filter(|s| !s.is_empty())
436            .or(env_email.as_deref())
437            .filter(|s| !s.is_empty())
438            .unwrap_or("")
439    };
440
441    // If we have both name and email from git config + overrides, use them
442    if !final_name.is_empty() && !final_email.is_empty() {
443        let identity = GitIdentity::new(final_name.to_string(), final_email.to_string());
444        if identity.validate().is_ok() {
445            return identity;
446        }
447    }
448
449    // Priority 5: System username + derived email
450    // Use None for executor - fallback will use environment variables
451    let username = fallback_username(executor);
452    let system_email = fallback_email(&username, executor);
453    let identity = GitIdentity::new(
454        if final_name.is_empty() {
455            username
456        } else {
457            final_name.to_string()
458        },
459        if final_email.is_empty() {
460            system_email
461        } else {
462            final_email.to_string()
463        },
464    );
465
466    if identity.validate().is_ok() {
467        return identity;
468    }
469
470    // Priority 6: Default values (last resort)
471    default_identity()
472}
473
474/// Create a commit.
475///
476/// Similar to `git commit -m <message>`.
477///
478/// Handles both initial commits (no HEAD yet) and subsequent commits.
479///
480/// # Identity Resolution
481///
482/// The git commit identity (name and email) is resolved using the following priority:
483/// 1. Git config (via libgit2) - primary source
484/// 2. Provided `git_user_name` and `git_user_email` parameters (overrides)
485/// 3. Environment variables (`RALPH_GIT_USER_NAME`, `RALPH_GIT_USER_EMAIL`)
486/// 4. Ralph config file (read by caller, passed as parameters)
487/// 5. System username + derived email (sane fallback)
488/// 6. Default values ("Ralph Workflow", "ralph@localhost") - last resort
489///
490/// Partial overrides are supported: CLI args/env vars/config can override individual
491/// fields (name or email) from git config.
492///
493/// # Arguments
494///
495/// * `message` - The commit message
496/// * `git_user_name` - Optional git user name (overrides git config)
497/// * `git_user_email` - Optional git user email (overrides git config)
498/// * `executor` - Optional process executor for system username/hostname lookup
499///
500/// # Returns
501///
502/// Returns `Ok(Some(oid))` with the commit OID if successful, `Ok(None)` if the
503/// OID is zero (no commit created), or an error if the operation failed.
504///
505pub fn git_commit(
506    message: &str,
507    git_user_name: Option<&str>,
508    git_user_email: Option<&str>,
509    executor: Option<&dyn crate::executor::ProcessExecutor>,
510) -> io::Result<Option<git2::Oid>> {
511    let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
512    git_commit_impl(&repo, message, git_user_name, git_user_email, executor)
513}
514
515/// Implementation of git commit.
516fn git_commit_impl(
517    repo: &git2::Repository,
518    message: &str,
519    git_user_name: Option<&str>,
520    git_user_email: Option<&str>,
521    executor: Option<&dyn crate::executor::ProcessExecutor>,
522) -> io::Result<Option<git2::Oid>> {
523    // Get the index
524    let mut index = repo.index().map_err(|e| git2_to_io_error(&e))?;
525
526    // Don't create empty commits: if the index matches HEAD (or is empty on an unborn branch),
527    // there's nothing to commit.
528    if !index_has_changes_to_commit(repo, &index)? {
529        return Ok(None);
530    }
531
532    // Get the tree from the index
533    let tree_oid = index.write_tree().map_err(|e| git2_to_io_error(&e))?;
534
535    let tree = repo.find_tree(tree_oid).map_err(|e| git2_to_io_error(&e))?;
536
537    // Resolve git identity using the identity resolution system.
538    // This implements the full priority chain with proper fallbacks.
539    let GitIdentity { name, email } =
540        resolve_commit_identity(repo, git_user_name, git_user_email, executor);
541
542    // Debug logging: identity resolution source
543    // Only log if RALPH_DEBUG or similar debug mode is enabled
544    if std::env::var("RALPH_DEBUG").is_ok() {
545        let identity_source = if git_user_name.is_some() || git_user_email.is_some() {
546            "CLI/config override"
547        } else if std::env::var("RALPH_GIT_USER_NAME").is_ok()
548            || std::env::var("RALPH_GIT_USER_EMAIL").is_ok()
549        {
550            "environment variable"
551        } else if repo.signature().is_ok() {
552            "git config"
553        } else {
554            "system/default"
555        };
556        eprintln!("Git identity: {name} <{email}> (source: {identity_source})");
557    }
558
559    // Create the signature with the resolved identity
560    let sig = git2::Signature::now(&name, &email).map_err(|e| git2_to_io_error(&e))?;
561
562    let oid = match repo.head() {
563        Ok(head) => {
564            // Normal commit: has a parent
565            let head_commit = head.peel_to_commit().map_err(|e| git2_to_io_error(&e))?;
566            repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &[&head_commit])
567        }
568        Err(ref e) if e.code() == git2::ErrorCode::UnbornBranch => {
569            // Initial commit: no parents, but verify tree is not empty
570            // An empty tree can happen in edge cases where files were staged and then removed
571            let mut has_entries = false;
572            tree.walk(git2::TreeWalkMode::PreOrder, |_, _| {
573                has_entries = true;
574                1 // Stop iteration after first entry
575            })
576            .ok(); // Ignore errors, we just want to know if there's at least one entry
577
578            if !has_entries {
579                // Tree is empty, return None instead of creating empty commit
580                return Ok(None);
581            }
582            repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &[])
583        }
584        Err(e) => return Err(git2_to_io_error(&e)),
585    }
586    .map_err(|e| git2_to_io_error(&e))?;
587
588    Ok(Some(oid))
589}
590
591/// Generate a diff from a specific starting commit.
592///
593/// Takes a starting commit OID and generates a diff between that commit
594/// and the current working tree. Returns a formatted diff string suitable
595/// for LLM analysis.
596///
597/// # Arguments
598///
599/// * `start_oid` - The OID of the commit to diff from
600///
601/// # Returns
602///
603/// Returns a formatted diff string, or an error if:
604/// - The repository cannot be opened
605/// - The starting commit cannot be found
606/// - The diff cannot be generated
607///
608/// # Note
609///
610pub fn git_diff_from(start_oid: &str) -> io::Result<String> {
611    let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
612
613    // Parse the starting OID
614    let oid = git2::Oid::from_str(start_oid).map_err(|_| {
615        io::Error::new(
616            io::ErrorKind::InvalidInput,
617            format!("Invalid commit OID: {start_oid}"),
618        )
619    })?;
620
621    git_diff_from_oid(&repo, oid)
622}
623
624fn git_diff_from_oid(repo: &git2::Repository, oid: git2::Oid) -> io::Result<String> {
625    // Find the starting commit
626    let start_commit = repo.find_commit(oid).map_err(|e| git2_to_io_error(&e))?;
627    let start_tree = start_commit.tree().map_err(|e| git2_to_io_error(&e))?;
628
629    // Diff between start commit and current working tree, including staged + unstaged
630    // changes and untracked files.
631    let mut diff_opts = git2::DiffOptions::new();
632    diff_opts.include_untracked(true);
633    diff_opts.recurse_untracked_dirs(true);
634
635    let diff = repo
636        .diff_tree_to_workdir_with_index(Some(&start_tree), Some(&mut diff_opts))
637        .map_err(|e| git2_to_io_error(&e))?;
638
639    // Generate diff text
640    let mut result = Vec::new();
641    diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
642        result.extend_from_slice(line.content());
643        true
644    })
645    .map_err(|e| git2_to_io_error(&e))?;
646
647    Ok(String::from_utf8_lossy(&result).to_string())
648}
649
650/// Generate a diff from the empty tree (initial commit).
651///
652/// This is a helper function for `get_git_diff_from_start` that handles the
653/// case of a repository with no commits yet.
654///
655fn git_diff_from_empty_tree(repo: &git2::Repository) -> io::Result<String> {
656    let mut diff_opts = git2::DiffOptions::new();
657    diff_opts.include_untracked(true);
658    diff_opts.recurse_untracked_dirs(true);
659
660    let diff = repo
661        .diff_tree_to_workdir_with_index(None, Some(&mut diff_opts))
662        .map_err(|e| git2_to_io_error(&e))?;
663
664    let mut result = Vec::new();
665    diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
666        result.extend_from_slice(line.content());
667        true
668    })
669    .map_err(|e| git2_to_io_error(&e))?;
670
671    Ok(String::from_utf8_lossy(&result).to_string())
672}
673
674/// Get the git diff from the starting commit.
675///
676/// Uses the saved starting commit from `.agent/start_commit` to generate
677/// an incremental diff. Falls back to diffing from HEAD if no start commit
678/// file exists.
679///
680/// # Returns
681///
682/// Returns a formatted diff string, or an error if:
683/// - The diff cannot be generated
684/// - The starting commit file exists but is invalid
685///
686pub fn get_git_diff_from_start() -> io::Result<String> {
687    use crate::git_helpers::start_commit::{load_start_point, save_start_commit, StartPoint};
688
689    // Ensure a valid starting point exists. This is expected to persist across runs,
690    // but we also repair missing/corrupt files opportunistically for robustness.
691    save_start_commit()?;
692
693    let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
694
695    match load_start_point()? {
696        StartPoint::Commit(oid) => git_diff_from(&oid.to_string()),
697        StartPoint::EmptyRepo => git_diff_from_empty_tree(&repo),
698    }
699}
700
701/// Get the git diff from the starting commit (workspace-aware).
702///
703/// This uses `.agent/start_commit` as the baseline and generates a diff between that baseline
704/// and the current state on disk, including staged + unstaged changes and untracked files.
705///
706/// Unlike [`get_git_diff_from_start`], this does not rely on the process CWD.
707pub fn get_git_diff_from_start_with_workspace(workspace: &dyn Workspace) -> io::Result<String> {
708    use crate::git_helpers::start_commit::{
709        load_start_point_with_workspace, save_start_commit_with_workspace, StartPoint,
710    };
711
712    // NOTE: We intentionally discover the repository from the process CWD.
713    // The pipeline sets CWD to the repo root early, and many test harnesses use a
714    // mock workspace root that doesn't exist on disk.
715    let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
716
717    // Ensure a valid start point exists. This is expected to persist across runs, but we also
718    // repair missing/corrupt files opportunistically for robustness.
719    save_start_commit_with_workspace(workspace, &repo)?;
720
721    match load_start_point_with_workspace(workspace, &repo)? {
722        StartPoint::Commit(oid) => git_diff_from_oid(&repo, oid),
723        StartPoint::EmptyRepo => git_diff_from_empty_tree(&repo),
724    }
725}
726
727/// Get the diff content that should be shown to reviewers.
728///
729/// Baseline selection:
730/// - If `.agent/review_baseline.txt` is set, diff from that commit.
731/// - Otherwise, diff from `.agent/start_commit` (the initial pipeline baseline).
732///
733/// The diff is always generated against the current state on disk (staged + unstaged + untracked).
734///
735/// Returns `(diff, baseline_oid_for_prompts)` where `baseline_oid_for_prompts` is the commit hash
736/// to mention in fallback instructions (or empty for empty repo baseline).
737pub fn get_git_diff_for_review_with_workspace(
738    workspace: &dyn Workspace,
739) -> io::Result<(String, String)> {
740    use crate::git_helpers::review_baseline::{
741        load_review_baseline_with_workspace, ReviewBaseline,
742    };
743    use crate::git_helpers::start_commit::{
744        load_start_point_with_workspace, save_start_commit_with_workspace, StartPoint,
745    };
746
747    // NOTE: See comment in get_git_diff_from_start_with_workspace.
748    let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
749
750    let baseline = load_review_baseline_with_workspace(workspace).unwrap_or(ReviewBaseline::NotSet);
751    match baseline {
752        ReviewBaseline::Commit(oid) => {
753            let diff = git_diff_from_oid(&repo, oid)?;
754            Ok((diff, oid.to_string()))
755        }
756        ReviewBaseline::NotSet => {
757            // Ensure a valid start point exists.
758            save_start_commit_with_workspace(workspace, &repo)?;
759
760            match load_start_point_with_workspace(workspace, &repo)? {
761                StartPoint::Commit(oid) => {
762                    let diff = git_diff_from_oid(&repo, oid)?;
763                    Ok((diff, oid.to_string()))
764                }
765                StartPoint::EmptyRepo => Ok((git_diff_from_empty_tree(&repo)?, String::new())),
766            }
767        }
768    }
769}
770
771/// Result of commit operation with fallback.
772///
773/// This is the fallback-aware version of `CommitResult`.
774#[derive(Debug, Clone, PartialEq, Eq)]
775pub enum CommitResultFallback {
776    /// A commit was successfully created with the given OID.
777    Success(git2::Oid),
778    /// No commit was created because there were no meaningful changes.
779    NoChanges,
780    /// The commit operation failed with an error message.
781    Failed(String),
782}
783
784#[cfg(test)]
785mod tests {
786    use super::*;
787
788    #[test]
789    fn test_git_diff_returns_string() {
790        // This test verifies the function exists and returns a Result
791        // The actual content depends on the git state
792        let result = git_diff();
793        assert!(result.is_ok() || result.is_err());
794    }
795
796    #[test]
797    fn test_require_git_repo() {
798        // This test verifies we can detect a git repository
799        let result = require_git_repo();
800        // Should succeed if we're in a git repo, fail otherwise
801        // We don't assert either way since the test environment varies
802        let _ = result;
803    }
804
805    #[test]
806    fn test_get_repo_root() {
807        // This test verifies we can get the repo root
808        let result = get_repo_root();
809        // Only validate if we're in a git repo
810        if let Ok(path) = result {
811            // The path should exist and be a directory
812            assert!(path.exists());
813            assert!(path.is_dir());
814            // Should contain a .git directory or be inside one
815            let git_dir = path.join(".git");
816            assert!(git_dir.exists() || path.ancestors().any(|p| p.join(".git").exists()));
817        }
818    }
819
820    #[test]
821    fn test_git_diff_from_returns_result() {
822        // Test that git_diff_from returns a Result
823        // We use an invalid OID to test error handling
824        let result = git_diff_from("invalid_oid_that_does_not_exist");
825        assert!(result.is_err());
826    }
827
828    #[test]
829    fn test_git_snapshot_returns_result() {
830        // Test that git_snapshot returns a Result
831        let result = git_snapshot();
832        assert!(result.is_ok() || result.is_err());
833    }
834
835    #[test]
836    fn test_git_add_all_returns_result() {
837        // Test that git_add_all returns a Result
838        let result = git_add_all();
839        assert!(result.is_ok() || result.is_err());
840    }
841
842    #[test]
843    fn test_get_git_diff_from_start_returns_result() {
844        // Test that get_git_diff_from_start returns a Result
845        // It should fall back to git_diff() if no start commit file exists
846        let result = get_git_diff_from_start();
847        assert!(result.is_ok() || result.is_err());
848    }
849}