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