Skip to main content

ralph_workflow/git_helpers/repo/commit/
io.rs

1// git_helpers/repo/commit/io.rs — boundary module for git commit and staging operations.
2// File stem is `io` — recognized as boundary module by forbid_io_effects lint.
3
4use std::path::Path;
5
6use crate::git_helpers::git2_to_io_error;
7use crate::git_helpers::identity::GitIdentity;
8
9fn is_git2_not_found(err: &git2::Error) -> bool {
10    err.code() == git2::ErrorCode::NotFound
11}
12
13fn is_git2_unborn_branch(err: &git2::Error) -> bool {
14    err.code() == git2::ErrorCode::UnbornBranch
15}
16fn index_has_changes_to_commit(
17    repo: &git2::Repository,
18    index: &git2::Index,
19) -> std::io::Result<bool> {
20    match repo.head() {
21        Ok(head) => {
22            let head_tree = head.peel_to_tree().map_err(|e| git2_to_io_error(&e))?;
23            let diff = repo
24                .diff_tree_to_index(Some(&head_tree), Some(index), None)
25                .map_err(|e| git2_to_io_error(&e))?;
26            Ok(diff.deltas().len() > 0)
27        }
28        Err(ref e) if is_git2_unborn_branch(e) => Ok(!index.is_empty()),
29        Err(e) => Err(git2_to_io_error(&e)),
30    }
31}
32
33fn is_internal_agent_artifact(path: &std::path::Path) -> bool {
34    let path_str = path.to_string_lossy();
35    path_str == ".no_agent_commit"
36        || path_str == ".agent"
37        || path_str.starts_with(".agent/")
38        || path_str == ".git"
39        || path_str.starts_with(".git/")
40}
41
42/// Stage specific files for commit.
43///
44/// Similar to `git add <files>`. Only stages the named paths.
45/// Paths that match `is_internal_agent_artifact` are silently skipped.
46///
47/// # Returns
48///
49/// Returns `Ok(true)` if the index has staged changes after adding the specified
50/// files, `Ok(false)` if there is nothing to commit, or an error if staging failed.
51///
52/// # Errors
53///
54/// Returns error if the operation fails.
55pub fn git_add_specific_in_repo(repo_root: &Path, files: &[&str]) -> std::io::Result<bool> {
56    let repo = git2::Repository::discover(repo_root).map_err(|e| git2_to_io_error(&e))?;
57    let mut index = repo.index().map_err(|e| git2_to_io_error(&e))?;
58
59    // Strict selective staging: start from a clean index that matches HEAD, so we don't
60    // accidentally commit pre-existing staged changes when a file list is provided.
61    match repo.head() {
62        Ok(head) => {
63            let head_tree = head.peel_to_tree().map_err(|e| git2_to_io_error(&e))?;
64            index
65                .read_tree(&head_tree)
66                .map_err(|e| git2_to_io_error(&e))?;
67        }
68        Err(ref e) if is_git2_unborn_branch(e) => {
69            index.clear().map_err(|e| git2_to_io_error(&e))?;
70        }
71        Err(e) => return Err(git2_to_io_error(&e)),
72    }
73
74    files.iter().try_for_each(|path_str| {
75        let path = std::path::Path::new(path_str);
76        if is_internal_agent_artifact(path) {
77            return Ok(());
78        }
79
80        match index.add_path(path) {
81            Ok(()) => Ok(()),
82            Err(ref e) if is_git2_not_found(e) => {
83                let tracked_in_head = index.get_path(path, 0).is_some();
84                if !tracked_in_head {
85                    let io_err = git2_to_io_error(e);
86                    return Err(std::io::Error::new(
87                        io_err.kind(),
88                        format!(
89                            "path '{}' not found for selective staging: {io_err}",
90                            path.display()
91                        ),
92                    ));
93                }
94
95                index.remove_path(path).map_err(|remove_err| {
96                    let io_err = git2_to_io_error(&remove_err);
97                    std::io::Error::new(
98                        io_err.kind(),
99                        format!(
100                            "failed to stage deletion for '{}': {io_err}",
101                            path.display()
102                        ),
103                    )
104                })
105            }
106            Err(e) => {
107                let io_err = git2_to_io_error(&e);
108                Err(std::io::Error::new(
109                    io_err.kind(),
110                    format!("failed to stage path '{}': {io_err}", path.display()),
111                ))
112            }
113        }
114    })?;
115
116    index.write().map_err(|e| git2_to_io_error(&e))?;
117    index_has_changes_to_commit(&repo, &index)
118}
119
120/// Stage all changes.
121///
122/// Similar to `git add -A`.
123///
124/// # Returns
125///
126/// Returns `Ok(true)` if files were successfully staged, `Ok(false)` if there
127/// were no files to stage, or an error if staging failed.
128///
129/// # Errors
130///
131/// Returns error if the operation fails.
132pub fn git_add_all() -> std::io::Result<bool> {
133    git_add_all_in_repo(Path::new("."))
134}
135
136/// Stage all changes in the repository discovered from `repo_root`.
137///
138/// This avoids relying on process-wide CWD and allows callers (including tests)
139/// to control which repository is targeted.
140///
141/// # Errors
142///
143/// Returns error if the operation fails.
144pub fn git_add_all_in_repo(repo_root: &Path) -> std::io::Result<bool> {
145    let repo = git2::Repository::discover(repo_root).map_err(|e| git2_to_io_error(&e))?;
146    git_add_all_impl(&repo)
147}
148
149/// Result of commit operation with fallback.
150///
151/// This is the fallback-aware version of `CommitResult`.
152#[derive(Debug, Clone, PartialEq, Eq)]
153pub enum CommitResultFallback {
154    /// A commit was successfully created with the given OID.
155    Success(git2::Oid),
156    /// No commit was created because there were no meaningful changes.
157    NoChanges,
158    /// The commit operation failed with an error message.
159    Failed(String),
160}
161
162/// Build the status options used when scanning the working tree.
163fn configured_status_options() -> git2::StatusOptions {
164    let mut status_opts = git2::StatusOptions::new();
165    status_opts
166        .include_untracked(true)
167        .recurse_untracked_dirs(true)
168        .include_ignored(false);
169    status_opts
170}
171
172/// Implementation of git add all.
173fn git_add_all_impl(repo: &git2::Repository) -> std::io::Result<bool> {
174    let mut index = repo.index().map_err(|e| git2_to_io_error(&e))?;
175
176    // Stage deletions (equivalent to `git add -A` behavior).
177    // libgit2's `add_all` doesn't automatically remove deleted paths.
178    let mut status_opts = configured_status_options();
179    let statuses = repo
180        .statuses(Some(&mut status_opts))
181        .map_err(|e| git2_to_io_error(&e))?;
182
183    let deletions: Vec<_> = statuses
184        .iter()
185        .filter(|entry| entry.status().contains(git2::Status::WT_DELETED))
186        .filter_map(|entry| entry.path().map(std::path::PathBuf::from))
187        .collect();
188
189    deletions
190        .iter()
191        .try_for_each(|path| index.remove_path(path).map_err(|e| git2_to_io_error(&e)))?;
192
193    // Add all files (staged, unstaged, and untracked).
194    // Note: add_all() is required here, not update_all(), to include untracked files.
195    let mut filter_cb = |path: &std::path::Path, _matched: &[u8]| -> i32 {
196        // Return 0 to add the file, non-zero to skip.
197        // We skip (return 1) internal agent artifacts to avoid committing them.
198        i32::from(is_internal_agent_artifact(path))
199    };
200    index
201        .add_all(
202            vec!["."],
203            git2::IndexAddOption::DEFAULT,
204            Some(&mut filter_cb),
205        )
206        .map_err(|e| git2_to_io_error(&e))?;
207
208    index.write().map_err(|e| git2_to_io_error(&e))?;
209
210    // Return true if staging produced something commit-worthy.
211    index_has_changes_to_commit(repo, &index)
212}
213
214struct GitConfigIdentity {
215    name: String,
216    email: String,
217    has_git_config: bool,
218}
219
220fn extract_sig_fields(sig: &git2::Signature<'_>) -> Option<(String, String)> {
221    let name = sig.name().unwrap_or("");
222    let email = sig.email().unwrap_or("");
223    if name.is_empty() || email.is_empty() {
224        return None;
225    }
226    Some((name.to_string(), email.to_string()))
227}
228
229fn read_git_config_identity(repo: &git2::Repository) -> GitConfigIdentity {
230    repo.signature()
231        .ok()
232        .and_then(|sig| extract_sig_fields(&sig))
233        .map_or(
234            GitConfigIdentity { name: String::new(), email: String::new(), has_git_config: false },
235            |(name, email)| GitConfigIdentity { name, email, has_git_config: true },
236        )
237}
238
239fn resolve_final_field<'a>(
240    git_config_value: &'a str,
241    has_git_config: bool,
242    provided: Option<&'a str>,
243    env_value: Option<&'a str>,
244) -> &'a str {
245    if has_git_config && !git_config_value.is_empty() {
246        return git_config_value;
247    }
248    provided
249        .filter(|s| !s.is_empty())
250        .or(env_value)
251        .filter(|s| !s.is_empty())
252        .unwrap_or("")
253}
254
255fn build_fallback_identity(
256    final_name: &str,
257    final_email: &str,
258    executor: Option<&dyn crate::executor::ProcessExecutor>,
259) -> GitIdentity {
260    use crate::git_helpers::identity::{fallback_email, fallback_username};
261    let username = fallback_username(executor);
262    let system_email = fallback_email(&username, executor);
263    GitIdentity::new(
264        if final_name.is_empty() { username } else { final_name.to_string() },
265        if final_email.is_empty() { system_email } else { final_email.to_string() },
266    )
267}
268
269fn resolve_name_and_email<'a>(
270    git_id: &'a GitConfigIdentity,
271    provided_name: Option<&'a str>,
272    provided_email: Option<&'a str>,
273    env_name: Option<&'a str>,
274    env_email: Option<&'a str>,
275) -> (&'a str, &'a str) {
276    let final_name = resolve_final_field(&git_id.name, git_id.has_git_config, provided_name, env_name);
277    let final_email = resolve_final_field(&git_id.email, git_id.has_git_config, provided_email, env_email);
278    (final_name, final_email)
279}
280
281fn try_validated_identity(name: &str, email: &str) -> Option<GitIdentity> {
282    if name.is_empty() || email.is_empty() {
283        return None;
284    }
285    let identity = GitIdentity::new(name.to_string(), email.to_string());
286    identity.validate().ok().map(|_| identity)
287}
288
289fn resolve_commit_identity(
290    repo: &git2::Repository,
291    provided_name: Option<&str>,
292    provided_email: Option<&str>,
293    executor: Option<&dyn crate::executor::ProcessExecutor>,
294    env: Option<&dyn crate::runtime::environment::Environment>,
295) -> GitIdentity {
296    use crate::git_helpers::identity::default_identity;
297
298    let env = env.unwrap_or(&crate::runtime::environment::RealEnvironment);
299    let git_id = read_git_config_identity(repo);
300    let env_name = env.var("RALPH_GIT_USER_NAME");
301    let env_email = env.var("RALPH_GIT_USER_EMAIL");
302    let (final_name, final_email) =
303        resolve_name_and_email(&git_id, provided_name, provided_email, env_name.as_deref(), env_email.as_deref());
304
305    try_validated_identity(final_name, final_email)
306        .or_else(|| {
307            let identity = build_fallback_identity(final_name, final_email, executor);
308            identity.validate().ok().map(|_| identity)
309        })
310        .unwrap_or_else(default_identity)
311}
312
313/// Create a commit.
314///
315/// Similar to `git commit -m <message>`.
316///
317/// Handles both initial commits (no HEAD yet) and subsequent commits.
318///
319/// # Identity Resolution
320///
321/// The git commit identity (name and email) is resolved using the following priority:
322/// 1. Git config (via libgit2) - primary source
323/// 2. Provided `git_user_name` and `git_user_email` parameters (overrides)
324/// 3. Environment variables (`RALPH_GIT_USER_NAME`, `RALPH_GIT_USER_EMAIL`)
325/// 4. Ralph config file (read by caller, passed as parameters)
326/// 5. System username + derived email (sane fallback)
327/// 6. Default values ("Ralph Workflow", "ralph@localhost") - last resort
328///
329/// Partial overrides are supported: CLI args/env vars/config can override individual
330/// fields (name or email) from git config.
331///
332/// # Errors
333///
334/// Returns error if the operation fails.
335pub fn git_commit(
336    message: &str,
337    git_user_name: Option<&str>,
338    git_user_email: Option<&str>,
339    executor: Option<&dyn crate::executor::ProcessExecutor>,
340    env: Option<&dyn crate::runtime::environment::Environment>,
341) -> std::io::Result<Option<git2::Oid>> {
342    git_commit_in_repo(
343        Path::new("."),
344        message,
345        git_user_name,
346        git_user_email,
347        executor,
348        env,
349    )
350}
351
352/// Create a commit in the repository discovered from `repo_root`.
353///
354/// This avoids relying on process-wide CWD and allows callers to select the
355/// repository to operate on.
356///
357/// # Errors
358///
359/// Returns error if the operation fails.
360pub fn git_commit_in_repo(
361    repo_root: &Path,
362    message: &str,
363    git_user_name: Option<&str>,
364    git_user_email: Option<&str>,
365    executor: Option<&dyn crate::executor::ProcessExecutor>,
366    env: Option<&dyn crate::runtime::environment::Environment>,
367) -> std::io::Result<Option<git2::Oid>> {
368    let repo = git2::Repository::discover(repo_root).map_err(|e| git2_to_io_error(&e))?;
369    git_commit_impl(&repo, message, git_user_name, git_user_email, executor, env)
370}
371
372fn has_cli_override(git_user_name: Option<&str>, git_user_email: Option<&str>) -> bool {
373    git_user_name.is_some() || git_user_email.is_some()
374}
375
376fn has_env_override(env: &dyn crate::runtime::environment::Environment) -> bool {
377    env.var("RALPH_GIT_USER_NAME").is_some() || env.var("RALPH_GIT_USER_EMAIL").is_some()
378}
379
380fn identity_source_from_repo_or_default(repo: &git2::Repository) -> &'static str {
381    if repo.signature().is_ok() { "git config" } else { "system/default" }
382}
383
384fn identity_source_label(
385    repo: &git2::Repository,
386    git_user_name: Option<&str>,
387    git_user_email: Option<&str>,
388    env: &dyn crate::runtime::environment::Environment,
389) -> &'static str {
390    if has_cli_override(git_user_name, git_user_email) {
391        "CLI/config override"
392    } else if has_env_override(env) {
393        "environment variable"
394    } else {
395        identity_source_from_repo_or_default(repo)
396    }
397}
398
399fn log_identity_if_debug(
400    repo: &git2::Repository,
401    name: &str,
402    email: &str,
403    git_user_name: Option<&str>,
404    git_user_email: Option<&str>,
405    env: &dyn crate::runtime::environment::Environment,
406) {
407    if env.var("RALPH_DEBUG").is_some() {
408        let identity_source = identity_source_label(repo, git_user_name, git_user_email, env);
409        let _ = std::io::Write::write_fmt(
410            &mut std::io::stderr(),
411            format_args!("Git identity: {name} <{email}> (source: {identity_source})\n"),
412        );
413    }
414}
415
416fn commit_on_existing_branch(
417    repo: &git2::Repository,
418    sig: &git2::Signature<'_>,
419    message: &str,
420    tree: &git2::Tree<'_>,
421    head: git2::Reference<'_>,
422) -> Result<git2::Oid, git2::Error> {
423    let head_commit = head.peel_to_commit()?;
424    repo.commit(Some("HEAD"), sig, sig, message, tree, &[&head_commit])
425}
426
427fn commit_on_unborn_branch(
428    repo: &git2::Repository,
429    sig: &git2::Signature<'_>,
430    message: &str,
431    tree: &git2::Tree<'_>,
432) -> std::io::Result<Option<Result<git2::Oid, git2::Error>>> {
433    if !tree_has_entries(tree) {
434        return Ok(None);
435    }
436    Ok(Some(repo.commit(Some("HEAD"), sig, sig, message, tree, &[])))
437}
438
439fn commit_with_head(
440    repo: &git2::Repository,
441    sig: &git2::Signature<'_>,
442    message: &str,
443    tree: &git2::Tree<'_>,
444) -> std::io::Result<Option<git2::Oid>> {
445    let git2_result = match repo.head() {
446        Ok(head) => commit_on_existing_branch(repo, sig, message, tree, head),
447        Err(ref e) if is_git2_unborn_branch(e) => {
448            return commit_on_unborn_branch(repo, sig, message, tree)?
449                .map(|r| r.map(Some).map_err(|e| git2_to_io_error(&e)))
450                .transpose()
451                .map(Option::flatten);
452        }
453        Err(e) => return Err(git2_to_io_error(&e)),
454    };
455    Ok(Some(git2_result.map_err(|e| git2_to_io_error(&e))?))
456}
457
458fn git_commit_impl(
459    repo: &git2::Repository,
460    message: &str,
461    git_user_name: Option<&str>,
462    git_user_email: Option<&str>,
463    executor: Option<&dyn crate::executor::ProcessExecutor>,
464    env: Option<&dyn crate::runtime::environment::Environment>,
465) -> std::io::Result<Option<git2::Oid>> {
466    let mut index = repo.index().map_err(|e| git2_to_io_error(&e))?;
467
468    // Don't create empty commits: if the index matches HEAD (or is empty on an unborn branch),
469    // there's nothing to commit.
470    if !index_has_changes_to_commit(repo, &index)? {
471        return Ok(None);
472    }
473
474    let tree_oid = index.write_tree().map_err(|e| git2_to_io_error(&e))?;
475    let tree = repo.find_tree(tree_oid).map_err(|e| git2_to_io_error(&e))?;
476
477    let GitIdentity { name, email } =
478        resolve_commit_identity(repo, git_user_name, git_user_email, executor, env);
479
480    let real_env = env.unwrap_or(&crate::runtime::environment::RealEnvironment);
481    log_identity_if_debug(repo, &name, &email, git_user_name, git_user_email, real_env);
482
483    let sig = git2::Signature::now(&name, &email).map_err(|e| git2_to_io_error(&e))?;
484    commit_with_head(repo, &sig, message, &tree)
485}
486
487fn tree_has_entries(tree: &git2::Tree<'_>) -> bool {
488    tree.iter().next().is_some()
489}
490
491#[cfg(test)]
492mod tests {
493    use std::path::Path;
494
495    fn tree_has_entries_for_paths(paths: &[&str]) -> bool {
496        let repo_dir = tempfile::TempDir::new().expect("create temp git repo dir");
497        let repo = git2::Repository::init(repo_dir.path()).expect("init repo");
498        let mut index = repo.index().expect("open index");
499
500        paths.iter().for_each(|path| {
501            let absolute_path = repo_dir.path().join(path);
502            if let Some(parent) = absolute_path.parent() {
503                std::fs::create_dir_all(parent).expect("create parent dirs");
504            }
505            std::fs::write(&absolute_path, "content\n").expect("write file");
506            index.add_path(Path::new(path)).expect("stage file path");
507        });
508
509        index.write().expect("write index");
510        let tree_oid = index.write_tree().expect("write tree");
511        let tree = repo.find_tree(tree_oid).expect("find tree");
512        super::tree_has_entries(&tree)
513    }
514
515    #[test]
516    fn tree_has_entries_returns_false_for_empty_tree() {
517        assert!(!tree_has_entries_for_paths(&[]));
518    }
519
520    #[test]
521    fn tree_has_entries_returns_true_for_non_empty_tree() {
522        assert!(tree_has_entries_for_paths(&["src/example.rs"]));
523    }
524}