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 specific files for commit.
31///
32/// Similar to `git add <files>`. Only stages the named paths.
33/// Paths that match `is_internal_agent_artifact` are silently skipped.
34///
35/// # Returns
36///
37/// Returns `Ok(true)` if the index has staged changes after adding the specified
38/// files, `Ok(false)` if there is nothing to commit, or an error if staging failed.
39///
40/// # Errors
41///
42/// Returns error if the operation fails.
43pub fn git_add_specific_in_repo(repo_root: &Path, files: &[&str]) -> io::Result<bool> {
44    let repo = git2::Repository::discover(repo_root).map_err(|e| git2_to_io_error(&e))?;
45    let mut index = repo.index().map_err(|e| git2_to_io_error(&e))?;
46
47    // Strict selective staging: start from a clean index that matches HEAD, so we don't
48    // accidentally commit pre-existing staged changes when a file list is provided.
49    match repo.head() {
50        Ok(head) => {
51            let head_tree = head.peel_to_tree().map_err(|e| git2_to_io_error(&e))?;
52            index
53                .read_tree(&head_tree)
54                .map_err(|e| git2_to_io_error(&e))?;
55        }
56        Err(ref e) if e.code() == git2::ErrorCode::UnbornBranch => {
57            index.clear().map_err(|e| git2_to_io_error(&e))?;
58        }
59        Err(e) => return Err(git2_to_io_error(&e)),
60    }
61
62    for path_str in files {
63        let path = std::path::Path::new(path_str);
64        if is_internal_agent_artifact(path) {
65            continue;
66        }
67
68        match index.add_path(path) {
69            Ok(()) => {}
70            Err(ref e) if e.code() == git2::ErrorCode::NotFound => {
71                // NotFound can mean either:
72                // - the path was deleted (tracked in HEAD, absent in working tree)
73                // - the path is invalid / doesn't exist and is not tracked
74                let tracked_in_head = index.get_path(path, 0).is_some();
75                if !tracked_in_head {
76                    let io_err = git2_to_io_error(e);
77                    return Err(io::Error::new(
78                        io_err.kind(),
79                        format!(
80                            "path '{}' not found for selective staging: {io_err}",
81                            path.display()
82                        ),
83                    ));
84                }
85
86                // Treat as deletion: attempt to stage deletion by removing the path from index.
87                index.remove_path(path).map_err(|remove_err| {
88                    let io_err = git2_to_io_error(&remove_err);
89                    io::Error::new(
90                        io_err.kind(),
91                        format!(
92                            "failed to stage deletion for '{}': {io_err}",
93                            path.display()
94                        ),
95                    )
96                })?;
97            }
98            Err(e) => {
99                let io_err = git2_to_io_error(&e);
100                return Err(io::Error::new(
101                    io_err.kind(),
102                    format!("failed to stage path '{}': {io_err}", path.display()),
103                ));
104            }
105        }
106    }
107
108    index.write().map_err(|e| git2_to_io_error(&e))?;
109    index_has_changes_to_commit(&repo, &index)
110}
111
112/// Stage all changes.
113///
114/// Similar to `git add -A`.
115///
116/// # Returns
117///
118/// Returns `Ok(true)` if files were successfully staged, `Ok(false)` if there
119/// were no files to stage, or an error if staging failed.
120///
121/// # Errors
122///
123/// Returns error if the operation fails.
124pub fn git_add_all() -> io::Result<bool> {
125    git_add_all_in_repo(Path::new("."))
126}
127
128/// Stage all changes in the repository discovered from `repo_root`.
129///
130/// This avoids relying on process-wide CWD and allows callers (including tests)
131/// to control which repository is targeted.
132///
133/// # Errors
134///
135/// Returns error if the operation fails.
136pub fn git_add_all_in_repo(repo_root: &Path) -> io::Result<bool> {
137    let repo = git2::Repository::discover(repo_root).map_err(|e| git2_to_io_error(&e))?;
138    git_add_all_impl(&repo)
139}
140
141/// Result of commit operation with fallback.
142///
143/// This is the fallback-aware version of `CommitResult`.
144#[derive(Debug, Clone, PartialEq, Eq)]
145pub enum CommitResultFallback {
146    /// A commit was successfully created with the given OID.
147    Success(git2::Oid),
148    /// No commit was created because there were no meaningful changes.
149    NoChanges,
150    /// The commit operation failed with an error message.
151    Failed(String),
152}
153
154/// Implementation of git add all.
155fn git_add_all_impl(repo: &git2::Repository) -> io::Result<bool> {
156    let mut index = repo.index().map_err(|e| git2_to_io_error(&e))?;
157
158    // Stage deletions (equivalent to `git add -A` behavior).
159    // libgit2's `add_all` doesn't automatically remove deleted paths.
160    let mut status_opts = git2::StatusOptions::new();
161    status_opts
162        .include_untracked(true)
163        .recurse_untracked_dirs(true)
164        .include_ignored(false);
165    let statuses = repo
166        .statuses(Some(&mut status_opts))
167        .map_err(|e| git2_to_io_error(&e))?;
168    for entry in statuses.iter() {
169        if entry.status().contains(git2::Status::WT_DELETED) {
170            if let Some(path) = entry.path() {
171                index
172                    .remove_path(std::path::Path::new(path))
173                    .map_err(|e| git2_to_io_error(&e))?;
174            }
175        }
176    }
177
178    // Add all files (staged, unstaged, and untracked).
179    // Note: add_all() is required here, not update_all(), to include untracked files.
180    let mut filter_cb = |path: &std::path::Path, _matched: &[u8]| -> i32 {
181        // Return 0 to add the file, non-zero to skip.
182        // We skip (return 1) internal agent artifacts to avoid committing them.
183        i32::from(is_internal_agent_artifact(path))
184    };
185    index
186        .add_all(
187            vec!["."],
188            git2::IndexAddOption::DEFAULT,
189            Some(&mut filter_cb),
190        )
191        .map_err(|e| git2_to_io_error(&e))?;
192
193    index.write().map_err(|e| git2_to_io_error(&e))?;
194
195    // Return true if staging produced something commit-worthy.
196    index_has_changes_to_commit(repo, &index)
197}
198
199fn resolve_commit_identity(
200    repo: &git2::Repository,
201    provided_name: Option<&str>,
202    provided_email: Option<&str>,
203    executor: Option<&dyn crate::executor::ProcessExecutor>,
204) -> GitIdentity {
205    use crate::git_helpers::identity::{default_identity, fallback_email, fallback_username};
206
207    // Priority 1: Git config (via libgit2) - primary source
208    let mut name = String::new();
209    let mut email = String::new();
210    let mut has_git_config = false;
211
212    if let Ok(sig) = repo.signature() {
213        let git_name = sig.name().unwrap_or("");
214        let git_email = sig.email().unwrap_or("");
215        if !git_name.is_empty() && !git_email.is_empty() {
216            name = git_name.to_string();
217            email = git_email.to_string();
218            has_git_config = true;
219        }
220    }
221
222    // Priority order (standard git behavior):
223    // 1. Git config (local .git/config, then global ~/.gitconfig) - primary source
224    // 2. Provided args (provided_name/provided_email) - from Ralph config or CLI override
225    // 3. Env vars (RALPH_GIT_USER_NAME/EMAIL) - fallback if above are missing
226    //
227    // This matches standard git behavior where git config is authoritative.
228    let env_name = std::env::var("RALPH_GIT_USER_NAME").ok();
229    let env_email = std::env::var("RALPH_GIT_USER_EMAIL").ok();
230
231    // Apply in priority order: git config > provided args > env vars.
232    let final_name = if has_git_config && !name.is_empty() {
233        name.as_str()
234    } else {
235        provided_name
236            .filter(|s| !s.is_empty())
237            .or(env_name.as_deref())
238            .filter(|s| !s.is_empty())
239            .unwrap_or("")
240    };
241
242    let final_email = if has_git_config && !email.is_empty() {
243        email.as_str()
244    } else {
245        provided_email
246            .filter(|s| !s.is_empty())
247            .or(env_email.as_deref())
248            .filter(|s| !s.is_empty())
249            .unwrap_or("")
250    };
251
252    if !final_name.is_empty() && !final_email.is_empty() {
253        let identity = GitIdentity::new(final_name.to_string(), final_email.to_string());
254        if identity.validate().is_ok() {
255            return identity;
256        }
257    }
258
259    let username = fallback_username(executor);
260    let system_email = fallback_email(&username, executor);
261    let identity = GitIdentity::new(
262        if final_name.is_empty() {
263            username
264        } else {
265            final_name.to_string()
266        },
267        if final_email.is_empty() {
268            system_email
269        } else {
270            final_email.to_string()
271        },
272    );
273
274    if identity.validate().is_ok() {
275        return identity;
276    }
277
278    default_identity()
279}
280
281/// Create a commit.
282///
283/// Similar to `git commit -m <message>`.
284///
285/// Handles both initial commits (no HEAD yet) and subsequent commits.
286///
287/// # Identity Resolution
288///
289/// The git commit identity (name and email) is resolved using the following priority:
290/// 1. Git config (via libgit2) - primary source
291/// 2. Provided `git_user_name` and `git_user_email` parameters (overrides)
292/// 3. Environment variables (`RALPH_GIT_USER_NAME`, `RALPH_GIT_USER_EMAIL`)
293/// 4. Ralph config file (read by caller, passed as parameters)
294/// 5. System username + derived email (sane fallback)
295/// 6. Default values ("Ralph Workflow", "ralph@localhost") - last resort
296///
297/// Partial overrides are supported: CLI args/env vars/config can override individual
298/// fields (name or email) from git config.
299///
300/// # Errors
301///
302/// Returns error if the operation fails.
303pub fn git_commit(
304    message: &str,
305    git_user_name: Option<&str>,
306    git_user_email: Option<&str>,
307    executor: Option<&dyn crate::executor::ProcessExecutor>,
308) -> io::Result<Option<git2::Oid>> {
309    git_commit_in_repo(
310        Path::new("."),
311        message,
312        git_user_name,
313        git_user_email,
314        executor,
315    )
316}
317
318/// Create a commit in the repository discovered from `repo_root`.
319///
320/// This avoids relying on process-wide CWD and allows callers to select the
321/// repository to operate on.
322///
323/// # Errors
324///
325/// Returns error if the operation fails.
326pub fn git_commit_in_repo(
327    repo_root: &Path,
328    message: &str,
329    git_user_name: Option<&str>,
330    git_user_email: Option<&str>,
331    executor: Option<&dyn crate::executor::ProcessExecutor>,
332) -> io::Result<Option<git2::Oid>> {
333    let repo = git2::Repository::discover(repo_root).map_err(|e| git2_to_io_error(&e))?;
334    git_commit_impl(&repo, message, git_user_name, git_user_email, executor)
335}
336
337fn git_commit_impl(
338    repo: &git2::Repository,
339    message: &str,
340    git_user_name: Option<&str>,
341    git_user_email: Option<&str>,
342    executor: Option<&dyn crate::executor::ProcessExecutor>,
343) -> io::Result<Option<git2::Oid>> {
344    let mut index = repo.index().map_err(|e| git2_to_io_error(&e))?;
345
346    // Don't create empty commits: if the index matches HEAD (or is empty on an unborn branch),
347    // there's nothing to commit.
348    if !index_has_changes_to_commit(repo, &index)? {
349        return Ok(None);
350    }
351
352    let tree_oid = index.write_tree().map_err(|e| git2_to_io_error(&e))?;
353    let tree = repo.find_tree(tree_oid).map_err(|e| git2_to_io_error(&e))?;
354
355    let GitIdentity { name, email } =
356        resolve_commit_identity(repo, git_user_name, git_user_email, executor);
357
358    // Debug logging: identity resolution source.
359    if std::env::var("RALPH_DEBUG").is_ok() {
360        let identity_source = if git_user_name.is_some() || git_user_email.is_some() {
361            "CLI/config override"
362        } else if std::env::var("RALPH_GIT_USER_NAME").is_ok()
363            || std::env::var("RALPH_GIT_USER_EMAIL").is_ok()
364        {
365            "environment variable"
366        } else if repo.signature().is_ok() {
367            "git config"
368        } else {
369            "system/default"
370        };
371        eprintln!("Git identity: {name} <{email}> (source: {identity_source})");
372    }
373
374    let sig = git2::Signature::now(&name, &email).map_err(|e| git2_to_io_error(&e))?;
375
376    let oid = match repo.head() {
377        Ok(head) => {
378            let head_commit = head.peel_to_commit().map_err(|e| git2_to_io_error(&e))?;
379            repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &[&head_commit])
380        }
381        Err(ref e) if e.code() == git2::ErrorCode::UnbornBranch => {
382            let mut has_entries = false;
383            tree.walk(git2::TreeWalkMode::PreOrder, |_, _| {
384                has_entries = true;
385                1 // Stop iteration after first entry.
386            })
387            .ok();
388
389            if !has_entries {
390                return Ok(None);
391            }
392            repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &[])
393        }
394        Err(e) => return Err(git2_to_io_error(&e)),
395    }
396    .map_err(|e| git2_to_io_error(&e))?;
397
398    Ok(Some(oid))
399}