Skip to main content

ralph_workflow/git_helpers/repo/
commit.rs

1use std::io;
2use std::path::Path;
3
4use crate::git_helpers::git2_to_io_error;
5use crate::git_helpers::identity::GitIdentity;
6
7fn index_has_changes_to_commit(repo: &git2::Repository, index: &git2::Index) -> io::Result<bool> {
8    match repo.head() {
9        Ok(head) => {
10            let head_tree = head.peel_to_tree().map_err(|e| git2_to_io_error(&e))?;
11            let diff = repo
12                .diff_tree_to_index(Some(&head_tree), Some(index), None)
13                .map_err(|e| git2_to_io_error(&e))?;
14            Ok(diff.deltas().len() > 0)
15        }
16        Err(ref e) if e.code() == git2::ErrorCode::UnbornBranch => Ok(!index.is_empty()),
17        Err(e) => Err(git2_to_io_error(&e)),
18    }
19}
20
21fn is_internal_agent_artifact(path: &std::path::Path) -> bool {
22    let path_str = path.to_string_lossy();
23    path_str == ".no_agent_commit"
24        || path_str == ".agent"
25        || path_str.starts_with(".agent/")
26        || path_str == ".git"
27        || path_str.starts_with(".git/")
28}
29
30/// Stage all changes.
31///
32/// Similar to `git add -A`.
33///
34/// # Returns
35///
36/// Returns `Ok(true)` if files were successfully staged, `Ok(false)` if there
37/// were no files to stage, or an error if staging failed.
38pub fn git_add_all() -> io::Result<bool> {
39    git_add_all_in_repo(Path::new("."))
40}
41
42/// Stage all changes in the repository discovered from `repo_root`.
43///
44/// This avoids relying on process-wide CWD and allows callers (including tests)
45/// to control which repository is targeted.
46pub fn git_add_all_in_repo(repo_root: &Path) -> io::Result<bool> {
47    let repo = git2::Repository::discover(repo_root).map_err(|e| git2_to_io_error(&e))?;
48    git_add_all_impl(&repo)
49}
50
51/// Result of commit operation with fallback.
52///
53/// This is the fallback-aware version of `CommitResult`.
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub enum CommitResultFallback {
56    /// A commit was successfully created with the given OID.
57    Success(git2::Oid),
58    /// No commit was created because there were no meaningful changes.
59    NoChanges,
60    /// The commit operation failed with an error message.
61    Failed(String),
62}
63
64/// Implementation of git add all.
65fn git_add_all_impl(repo: &git2::Repository) -> io::Result<bool> {
66    let mut index = repo.index().map_err(|e| git2_to_io_error(&e))?;
67
68    // Stage deletions (equivalent to `git add -A` behavior).
69    // libgit2's `add_all` doesn't automatically remove deleted paths.
70    let mut status_opts = git2::StatusOptions::new();
71    status_opts
72        .include_untracked(true)
73        .recurse_untracked_dirs(true)
74        .include_ignored(false);
75    let statuses = repo
76        .statuses(Some(&mut status_opts))
77        .map_err(|e| git2_to_io_error(&e))?;
78    for entry in statuses.iter() {
79        if entry.status().contains(git2::Status::WT_DELETED) {
80            if let Some(path) = entry.path() {
81                index
82                    .remove_path(std::path::Path::new(path))
83                    .map_err(|e| git2_to_io_error(&e))?;
84            }
85        }
86    }
87
88    // Add all files (staged, unstaged, and untracked).
89    // Note: add_all() is required here, not update_all(), to include untracked files.
90    let mut filter_cb = |path: &std::path::Path, _matched: &[u8]| -> i32 {
91        // Return 0 to add the file, non-zero to skip.
92        // We skip (return 1) internal agent artifacts to avoid committing them.
93        i32::from(is_internal_agent_artifact(path))
94    };
95    index
96        .add_all(
97            vec!["."],
98            git2::IndexAddOption::DEFAULT,
99            Some(&mut filter_cb),
100        )
101        .map_err(|e| git2_to_io_error(&e))?;
102
103    index.write().map_err(|e| git2_to_io_error(&e))?;
104
105    // Return true if staging produced something commit-worthy.
106    index_has_changes_to_commit(repo, &index)
107}
108
109fn resolve_commit_identity(
110    repo: &git2::Repository,
111    provided_name: Option<&str>,
112    provided_email: Option<&str>,
113    executor: Option<&dyn crate::executor::ProcessExecutor>,
114) -> GitIdentity {
115    use crate::git_helpers::identity::{default_identity, fallback_email, fallback_username};
116
117    // Priority 1: Git config (via libgit2) - primary source
118    let mut name = String::new();
119    let mut email = String::new();
120    let mut has_git_config = false;
121
122    if let Ok(sig) = repo.signature() {
123        let git_name = sig.name().unwrap_or("");
124        let git_email = sig.email().unwrap_or("");
125        if !git_name.is_empty() && !git_email.is_empty() {
126            name = git_name.to_string();
127            email = git_email.to_string();
128            has_git_config = true;
129        }
130    }
131
132    // Priority order (standard git behavior):
133    // 1. Git config (local .git/config, then global ~/.gitconfig) - primary source
134    // 2. Provided args (provided_name/provided_email) - from Ralph config or CLI override
135    // 3. Env vars (RALPH_GIT_USER_NAME/EMAIL) - fallback if above are missing
136    //
137    // This matches standard git behavior where git config is authoritative.
138    let env_name = std::env::var("RALPH_GIT_USER_NAME").ok();
139    let env_email = std::env::var("RALPH_GIT_USER_EMAIL").ok();
140
141    // Apply in priority order: git config > provided args > env vars.
142    let final_name = if has_git_config && !name.is_empty() {
143        name.as_str()
144    } else {
145        provided_name
146            .filter(|s| !s.is_empty())
147            .or(env_name.as_deref())
148            .filter(|s| !s.is_empty())
149            .unwrap_or("")
150    };
151
152    let final_email = if has_git_config && !email.is_empty() {
153        email.as_str()
154    } else {
155        provided_email
156            .filter(|s| !s.is_empty())
157            .or(env_email.as_deref())
158            .filter(|s| !s.is_empty())
159            .unwrap_or("")
160    };
161
162    if !final_name.is_empty() && !final_email.is_empty() {
163        let identity = GitIdentity::new(final_name.to_string(), final_email.to_string());
164        if identity.validate().is_ok() {
165            return identity;
166        }
167    }
168
169    let username = fallback_username(executor);
170    let system_email = fallback_email(&username, executor);
171    let identity = GitIdentity::new(
172        if final_name.is_empty() {
173            username
174        } else {
175            final_name.to_string()
176        },
177        if final_email.is_empty() {
178            system_email
179        } else {
180            final_email.to_string()
181        },
182    );
183
184    if identity.validate().is_ok() {
185        return identity;
186    }
187
188    default_identity()
189}
190
191/// Create a commit.
192///
193/// Similar to `git commit -m <message>`.
194///
195/// Handles both initial commits (no HEAD yet) and subsequent commits.
196///
197/// # Identity Resolution
198///
199/// The git commit identity (name and email) is resolved using the following priority:
200/// 1. Git config (via libgit2) - primary source
201/// 2. Provided `git_user_name` and `git_user_email` parameters (overrides)
202/// 3. Environment variables (`RALPH_GIT_USER_NAME`, `RALPH_GIT_USER_EMAIL`)
203/// 4. Ralph config file (read by caller, passed as parameters)
204/// 5. System username + derived email (sane fallback)
205/// 6. Default values ("Ralph Workflow", "ralph@localhost") - last resort
206///
207/// Partial overrides are supported: CLI args/env vars/config can override individual
208/// fields (name or email) from git config.
209pub fn git_commit(
210    message: &str,
211    git_user_name: Option<&str>,
212    git_user_email: Option<&str>,
213    executor: Option<&dyn crate::executor::ProcessExecutor>,
214) -> io::Result<Option<git2::Oid>> {
215    git_commit_in_repo(
216        Path::new("."),
217        message,
218        git_user_name,
219        git_user_email,
220        executor,
221    )
222}
223
224/// Create a commit in the repository discovered from `repo_root`.
225///
226/// This avoids relying on process-wide CWD and allows callers to select the
227/// repository to operate on.
228pub fn git_commit_in_repo(
229    repo_root: &Path,
230    message: &str,
231    git_user_name: Option<&str>,
232    git_user_email: Option<&str>,
233    executor: Option<&dyn crate::executor::ProcessExecutor>,
234) -> io::Result<Option<git2::Oid>> {
235    let repo = git2::Repository::discover(repo_root).map_err(|e| git2_to_io_error(&e))?;
236    git_commit_impl(&repo, message, git_user_name, git_user_email, executor)
237}
238
239fn git_commit_impl(
240    repo: &git2::Repository,
241    message: &str,
242    git_user_name: Option<&str>,
243    git_user_email: Option<&str>,
244    executor: Option<&dyn crate::executor::ProcessExecutor>,
245) -> io::Result<Option<git2::Oid>> {
246    let mut index = repo.index().map_err(|e| git2_to_io_error(&e))?;
247
248    // Don't create empty commits: if the index matches HEAD (or is empty on an unborn branch),
249    // there's nothing to commit.
250    if !index_has_changes_to_commit(repo, &index)? {
251        return Ok(None);
252    }
253
254    let tree_oid = index.write_tree().map_err(|e| git2_to_io_error(&e))?;
255    let tree = repo.find_tree(tree_oid).map_err(|e| git2_to_io_error(&e))?;
256
257    let GitIdentity { name, email } =
258        resolve_commit_identity(repo, git_user_name, git_user_email, executor);
259
260    // Debug logging: identity resolution source.
261    if std::env::var("RALPH_DEBUG").is_ok() {
262        let identity_source = if git_user_name.is_some() || git_user_email.is_some() {
263            "CLI/config override"
264        } else if std::env::var("RALPH_GIT_USER_NAME").is_ok()
265            || std::env::var("RALPH_GIT_USER_EMAIL").is_ok()
266        {
267            "environment variable"
268        } else if repo.signature().is_ok() {
269            "git config"
270        } else {
271            "system/default"
272        };
273        eprintln!("Git identity: {name} <{email}> (source: {identity_source})");
274    }
275
276    let sig = git2::Signature::now(&name, &email).map_err(|e| git2_to_io_error(&e))?;
277
278    let oid = match repo.head() {
279        Ok(head) => {
280            let head_commit = head.peel_to_commit().map_err(|e| git2_to_io_error(&e))?;
281            repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &[&head_commit])
282        }
283        Err(ref e) if e.code() == git2::ErrorCode::UnbornBranch => {
284            let mut has_entries = false;
285            tree.walk(git2::TreeWalkMode::PreOrder, |_, _| {
286                has_entries = true;
287                1 // Stop iteration after first entry.
288            })
289            .ok();
290
291            if !has_entries {
292                return Ok(None);
293            }
294            repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &[])
295        }
296        Err(e) => return Err(git2_to_io_error(&e)),
297    }
298    .map_err(|e| git2_to_io_error(&e))?;
299
300    Ok(Some(oid))
301}