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