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