1use 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
42pub 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 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
120pub fn git_add_all() -> std::io::Result<bool> {
133 git_add_all_in_repo(Path::new("."))
134}
135
136pub 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#[derive(Debug, Clone, PartialEq, Eq)]
153pub enum CommitResultFallback {
154 Success(git2::Oid),
156 NoChanges,
158 Failed(String),
160}
161
162fn 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
172fn 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 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 let mut filter_cb = |path: &std::path::Path, _matched: &[u8]| -> i32 {
196 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 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
313pub 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
352pub 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 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}