ralph_workflow/git_helpers/
repo.rs1use std::io;
15use std::path::PathBuf;
16
17use super::identity::GitIdentity;
18
19const MAX_DIFF_SIZE_WARNING: usize = 100 * 1024;
22
23const MAX_DIFF_SIZE_HARD: usize = 1024 * 1024;
26
27const DIFF_TRUNCATED_MARKER: &str =
29 "\n\n[Diff truncated due to size. Showing first portion above.]";
30
31fn git2_to_io_error(err: &git2::Error) -> io::Error {
33 io::Error::other(err.to_string())
34}
35
36pub fn require_git_repo() -> io::Result<()> {
38 git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
39 Ok(())
40}
41
42pub 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
50pub 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
59pub 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 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 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 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
125pub fn git_diff() -> io::Result<String> {
133 let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
134
135 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 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 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 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
185pub fn validate_and_truncate_diff(diff: String) -> (String, bool) {
200 let diff_size = diff.len();
201
202 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 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
252pub 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 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 let mut filter_cb = |path: &std::path::Path, _matched: &[u8]| -> i32 {
288 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 index_has_changes_to_commit(&repo, &index)
304}
305
306fn 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 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 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 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 !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 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 default_identity()
411}
412
413pub 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 let mut index = repo.index().map_err(|e| git2_to_io_error(&e))?;
451
452 if !index_has_changes_to_commit(&repo, &index)? {
455 return Ok(None);
456 }
457
458 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 let GitIdentity { name, email } = resolve_commit_identity(&repo, git_user_name, git_user_email);
466
467 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 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 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 let mut has_entries = false;
497 tree.walk(git2::TreeWalkMode::PreOrder, |_, _| {
498 has_entries = true;
499 1 })
501 .ok(); if !has_entries {
504 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
516pub 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 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 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 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 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
587pub 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 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#[derive(Debug, Clone, PartialEq, Eq)]
617pub enum CommitResultFallback {
618 Success(git2::Oid),
620 NoChanges,
622 Failed(String),
624}
625
626#[cfg(test)]
627mod tests {
628 use super::*;
629
630 #[test]
631 fn test_git_diff_returns_string() {
632 let result = git_diff();
635 assert!(result.is_ok() || result.is_err());
636 }
637
638 #[test]
639 fn test_require_git_repo() {
640 let result = require_git_repo();
642 let _ = result;
645 }
646
647 #[test]
648 fn test_get_repo_root() {
649 let result = get_repo_root();
651 if let Ok(path) = result {
653 assert!(path.exists());
655 assert!(path.is_dir());
656 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 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 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 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 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 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 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 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}