ralph_workflow/git_helpers/
repo.rs1use std::io;
15use std::path::PathBuf;
16
17use super::identity::GitIdentity;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
24pub enum DiffTruncationLevel {
25 #[default]
27 Full,
28 Abbreviated,
30 FileList,
32 FileListAbbreviated,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct DiffReviewContent {
42 pub content: String,
44 pub truncation_level: DiffTruncationLevel,
46 pub total_file_count: usize,
48 pub shown_file_count: Option<usize>,
50 pub baseline_oid: Option<String>,
52 pub baseline_short: Option<String>,
54 pub baseline_description: String,
56}
57
58impl DiffReviewContent {
59 pub fn format_context_header(&self) -> String {
74 let mut lines = Vec::new();
75
76 if let Some(short) = &self.baseline_short {
77 lines.push(format!(
78 "Diff Context: Compared against {} {}",
79 self.baseline_description, short
80 ));
81 } else {
82 lines.push("Diff Context: Version information not available".to_string());
83 }
84
85 match self.truncation_level {
87 DiffTruncationLevel::Full => {
88 }
90 DiffTruncationLevel::Abbreviated => {
91 lines.push(format!(
92 "Note: Diff abbreviated - {}/{} files shown",
93 self.shown_file_count.unwrap_or(0),
94 self.total_file_count
95 ));
96 }
97 DiffTruncationLevel::FileList => {
98 lines.push(format!(
99 "Note: Only file list shown - {} files changed",
100 self.total_file_count
101 ));
102 }
103 DiffTruncationLevel::FileListAbbreviated => {
104 lines.push(format!(
105 "Note: File list abbreviated - {}/{} files shown",
106 self.shown_file_count.unwrap_or(0),
107 self.total_file_count
108 ));
109 }
110 }
111
112 if lines.is_empty() {
113 String::new()
114 } else {
115 format!("{}\n", lines.join("\n"))
116 }
117 }
118}
119
120fn git2_to_io_error(err: &git2::Error) -> io::Error {
122 io::Error::other(err.to_string())
123}
124
125pub fn require_git_repo() -> io::Result<()> {
127 git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
128 Ok(())
129}
130
131pub fn get_repo_root() -> io::Result<PathBuf> {
133 let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
134 repo.workdir()
135 .map(PathBuf::from)
136 .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "No workdir for repository"))
137}
138
139pub fn get_hooks_dir() -> io::Result<PathBuf> {
145 let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
146 Ok(repo.path().join("hooks"))
147}
148
149pub fn git_snapshot() -> io::Result<String> {
154 let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
155 git_snapshot_impl(&repo)
156}
157
158fn git_snapshot_impl(repo: &git2::Repository) -> io::Result<String> {
160 let mut opts = git2::StatusOptions::new();
161 opts.include_untracked(true).recurse_untracked_dirs(true);
162 let statuses = repo
163 .statuses(Some(&mut opts))
164 .map_err(|e| git2_to_io_error(&e))?;
165
166 let mut result = String::new();
167 for entry in statuses.iter() {
168 let status = entry.status();
169 let path = entry.path().unwrap_or("").to_string();
170
171 if status.contains(git2::Status::WT_NEW) {
174 result.push('?');
175 result.push('?');
176 result.push(' ');
177 result.push_str(&path);
178 result.push('\n');
179 continue;
180 }
181
182 let index_status = if status.contains(git2::Status::INDEX_NEW) {
184 'A'
185 } else if status.contains(git2::Status::INDEX_MODIFIED) {
186 'M'
187 } else if status.contains(git2::Status::INDEX_DELETED) {
188 'D'
189 } else if status.contains(git2::Status::INDEX_RENAMED) {
190 'R'
191 } else if status.contains(git2::Status::INDEX_TYPECHANGE) {
192 'T'
193 } else {
194 ' '
195 };
196
197 let wt_status = if status.contains(git2::Status::WT_MODIFIED) {
199 'M'
200 } else if status.contains(git2::Status::WT_DELETED) {
201 'D'
202 } else if status.contains(git2::Status::WT_RENAMED) {
203 'R'
204 } else if status.contains(git2::Status::WT_TYPECHANGE) {
205 'T'
206 } else {
207 ' '
208 };
209
210 result.push(index_status);
211 result.push(wt_status);
212 result.push(' ');
213 result.push_str(&path);
214 result.push('\n');
215 }
216
217 Ok(result)
218}
219
220pub fn git_diff() -> io::Result<String> {
229 let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
230 git_diff_impl(&repo)
231}
232
233fn git_diff_impl(repo: &git2::Repository) -> io::Result<String> {
235 let head_tree = match repo.head() {
237 Ok(head) => Some(head.peel_to_tree().map_err(|e| git2_to_io_error(&e))?),
238 Err(ref e) if e.code() == git2::ErrorCode::UnbornBranch => {
239 let mut diff_opts = git2::DiffOptions::new();
245 diff_opts.include_untracked(true);
246 diff_opts.recurse_untracked_dirs(true);
247
248 let diff = repo
249 .diff_tree_to_workdir_with_index(None, Some(&mut diff_opts))
250 .map_err(|e| git2_to_io_error(&e))?;
251
252 let mut result = Vec::new();
253 diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
254 result.extend_from_slice(line.content());
255 true
256 })
257 .map_err(|e| git2_to_io_error(&e))?;
258
259 return Ok(String::from_utf8_lossy(&result).to_string());
260 }
261 Err(e) => return Err(git2_to_io_error(&e)),
262 };
263
264 let mut diff_opts = git2::DiffOptions::new();
267 diff_opts.include_untracked(true);
268 diff_opts.recurse_untracked_dirs(true);
269
270 let diff = repo
271 .diff_tree_to_workdir_with_index(head_tree.as_ref(), Some(&mut diff_opts))
272 .map_err(|e| git2_to_io_error(&e))?;
273
274 let mut result = Vec::new();
276 diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
277 result.extend_from_slice(line.content());
278 true
279 })
280 .map_err(|e| git2_to_io_error(&e))?;
281
282 Ok(String::from_utf8_lossy(&result).to_string())
283}
284
285fn index_has_changes_to_commit(repo: &git2::Repository, index: &git2::Index) -> io::Result<bool> {
286 match repo.head() {
287 Ok(head) => {
288 let head_tree = head.peel_to_tree().map_err(|e| git2_to_io_error(&e))?;
289 let diff = repo
290 .diff_tree_to_index(Some(&head_tree), Some(index), None)
291 .map_err(|e| git2_to_io_error(&e))?;
292 Ok(diff.deltas().len() > 0)
293 }
294 Err(ref e) if e.code() == git2::ErrorCode::UnbornBranch => Ok(!index.is_empty()),
295 Err(e) => Err(git2_to_io_error(&e)),
296 }
297}
298
299fn is_internal_agent_artifact(path: &std::path::Path) -> bool {
300 let path_str = path.to_string_lossy();
301 path_str == ".no_agent_commit"
302 || path_str == ".agent"
303 || path_str.starts_with(".agent/")
304 || path_str == ".git"
305 || path_str.starts_with(".git/")
306}
307
308pub fn git_add_all() -> io::Result<bool> {
318 let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
319 git_add_all_impl(&repo)
320}
321
322fn git_add_all_impl(repo: &git2::Repository) -> io::Result<bool> {
324 let mut index = repo.index().map_err(|e| git2_to_io_error(&e))?;
325
326 let mut status_opts = git2::StatusOptions::new();
329 status_opts
330 .include_untracked(true)
331 .recurse_untracked_dirs(true)
332 .include_ignored(false);
333 let statuses = repo
334 .statuses(Some(&mut status_opts))
335 .map_err(|e| git2_to_io_error(&e))?;
336 for entry in statuses.iter() {
337 if entry.status().contains(git2::Status::WT_DELETED) {
338 if let Some(path) = entry.path() {
339 index
340 .remove_path(std::path::Path::new(path))
341 .map_err(|e| git2_to_io_error(&e))?;
342 }
343 }
344 }
345
346 let mut filter_cb = |path: &std::path::Path, _matched: &[u8]| -> i32 {
349 i32::from(is_internal_agent_artifact(path))
352 };
353 index
354 .add_all(
355 vec!["."],
356 git2::IndexAddOption::DEFAULT,
357 Some(&mut filter_cb),
358 )
359 .map_err(|e| git2_to_io_error(&e))?;
360
361 index.write().map_err(|e| git2_to_io_error(&e))?;
362
363 index_has_changes_to_commit(repo, &index)
365}
366
367fn resolve_commit_identity(
391 repo: &git2::Repository,
392 provided_name: Option<&str>,
393 provided_email: Option<&str>,
394 executor: Option<&dyn crate::executor::ProcessExecutor>,
395) -> GitIdentity {
396 use super::identity::{default_identity, fallback_email, fallback_username};
397
398 let mut name = String::new();
400 let mut email = String::new();
401 let mut has_git_config = false;
402
403 if let Ok(sig) = repo.signature() {
404 let git_name = sig.name().unwrap_or("");
405 let git_email = sig.email().unwrap_or("");
406 if !git_name.is_empty() && !git_email.is_empty() {
407 name = git_name.to_string();
408 email = git_email.to_string();
409 has_git_config = true;
410 }
411 }
412
413 let env_name = std::env::var("RALPH_GIT_USER_NAME").ok();
420 let env_email = std::env::var("RALPH_GIT_USER_EMAIL").ok();
421
422 let final_name = if has_git_config && !name.is_empty() {
425 name.as_str()
426 } else {
427 provided_name
428 .filter(|s| !s.is_empty())
429 .or(env_name.as_deref())
430 .filter(|s| !s.is_empty())
431 .unwrap_or("")
432 };
433
434 let final_email = if has_git_config && !email.is_empty() {
435 email.as_str()
436 } else {
437 provided_email
438 .filter(|s| !s.is_empty())
439 .or(env_email.as_deref())
440 .filter(|s| !s.is_empty())
441 .unwrap_or("")
442 };
443
444 if !final_name.is_empty() && !final_email.is_empty() {
446 let identity = GitIdentity::new(final_name.to_string(), final_email.to_string());
447 if identity.validate().is_ok() {
448 return identity;
449 }
450 }
451
452 let username = fallback_username(executor);
455 let system_email = fallback_email(&username, executor);
456 let identity = GitIdentity::new(
457 if final_name.is_empty() {
458 username
459 } else {
460 final_name.to_string()
461 },
462 if final_email.is_empty() {
463 system_email
464 } else {
465 final_email.to_string()
466 },
467 );
468
469 if identity.validate().is_ok() {
470 return identity;
471 }
472
473 default_identity()
475}
476
477pub fn git_commit(
509 message: &str,
510 git_user_name: Option<&str>,
511 git_user_email: Option<&str>,
512 executor: Option<&dyn crate::executor::ProcessExecutor>,
513) -> io::Result<Option<git2::Oid>> {
514 let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
515 git_commit_impl(&repo, message, git_user_name, git_user_email, executor)
516}
517
518fn git_commit_impl(
520 repo: &git2::Repository,
521 message: &str,
522 git_user_name: Option<&str>,
523 git_user_email: Option<&str>,
524 executor: Option<&dyn crate::executor::ProcessExecutor>,
525) -> io::Result<Option<git2::Oid>> {
526 let mut index = repo.index().map_err(|e| git2_to_io_error(&e))?;
528
529 if !index_has_changes_to_commit(repo, &index)? {
532 return Ok(None);
533 }
534
535 let tree_oid = index.write_tree().map_err(|e| git2_to_io_error(&e))?;
537
538 let tree = repo.find_tree(tree_oid).map_err(|e| git2_to_io_error(&e))?;
539
540 let GitIdentity { name, email } =
543 resolve_commit_identity(repo, git_user_name, git_user_email, executor);
544
545 if std::env::var("RALPH_DEBUG").is_ok() {
548 let identity_source = if git_user_name.is_some() || git_user_email.is_some() {
549 "CLI/config override"
550 } else if std::env::var("RALPH_GIT_USER_NAME").is_ok()
551 || std::env::var("RALPH_GIT_USER_EMAIL").is_ok()
552 {
553 "environment variable"
554 } else if repo.signature().is_ok() {
555 "git config"
556 } else {
557 "system/default"
558 };
559 eprintln!("Git identity: {name} <{email}> (source: {identity_source})");
560 }
561
562 let sig = git2::Signature::now(&name, &email).map_err(|e| git2_to_io_error(&e))?;
564
565 let oid = match repo.head() {
566 Ok(head) => {
567 let head_commit = head.peel_to_commit().map_err(|e| git2_to_io_error(&e))?;
569 repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &[&head_commit])
570 }
571 Err(ref e) if e.code() == git2::ErrorCode::UnbornBranch => {
572 let mut has_entries = false;
575 tree.walk(git2::TreeWalkMode::PreOrder, |_, _| {
576 has_entries = true;
577 1 })
579 .ok(); if !has_entries {
582 return Ok(None);
584 }
585 repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &[])
586 }
587 Err(e) => return Err(git2_to_io_error(&e)),
588 }
589 .map_err(|e| git2_to_io_error(&e))?;
590
591 Ok(Some(oid))
592}
593
594#[cfg(any(test, feature = "test-utils"))]
616pub fn git_diff_from(start_oid: &str) -> io::Result<String> {
617 let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
618
619 let oid = git2::Oid::from_str(start_oid).map_err(|_| {
621 io::Error::new(
622 io::ErrorKind::InvalidInput,
623 format!("Invalid commit OID: {start_oid}"),
624 )
625 })?;
626
627 let start_commit = repo.find_commit(oid).map_err(|e| git2_to_io_error(&e))?;
629 let start_tree = start_commit.tree().map_err(|e| git2_to_io_error(&e))?;
630
631 let mut diff_opts = git2::DiffOptions::new();
634 diff_opts.include_untracked(true);
635 diff_opts.recurse_untracked_dirs(true);
636
637 let diff = repo
638 .diff_tree_to_workdir_with_index(Some(&start_tree), Some(&mut diff_opts))
639 .map_err(|e| git2_to_io_error(&e))?;
640
641 let mut result = Vec::new();
643 diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
644 result.extend_from_slice(line.content());
645 true
646 })
647 .map_err(|e| git2_to_io_error(&e))?;
648
649 Ok(String::from_utf8_lossy(&result).to_string())
650}
651
652#[cfg(any(test, feature = "test-utils"))]
660fn git_diff_from_empty_tree(repo: &git2::Repository) -> io::Result<String> {
661 let mut diff_opts = git2::DiffOptions::new();
662 diff_opts.include_untracked(true);
663 diff_opts.recurse_untracked_dirs(true);
664
665 let diff = repo
666 .diff_tree_to_workdir_with_index(None, Some(&mut diff_opts))
667 .map_err(|e| git2_to_io_error(&e))?;
668
669 let mut result = Vec::new();
670 diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
671 result.extend_from_slice(line.content());
672 true
673 })
674 .map_err(|e| git2_to_io_error(&e))?;
675
676 Ok(String::from_utf8_lossy(&result).to_string())
677}
678
679#[cfg(any(test, feature = "test-utils"))]
694pub fn get_git_diff_from_start() -> io::Result<String> {
695 use crate::git_helpers::start_commit::{load_start_point, save_start_commit, StartPoint};
696
697 save_start_commit()?;
700
701 let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
702
703 match load_start_point()? {
704 StartPoint::Commit(oid) => git_diff_from(&oid.to_string()),
705 StartPoint::EmptyRepo => git_diff_from_empty_tree(&repo),
706 }
707}
708
709#[derive(Debug, Clone, PartialEq, Eq)]
713pub enum CommitResultFallback {
714 Success(git2::Oid),
716 NoChanges,
718 Failed(String),
720}
721
722#[cfg(test)]
723mod tests {
724 use super::*;
725
726 #[test]
727 fn test_git_diff_returns_string() {
728 let result = git_diff();
731 assert!(result.is_ok() || result.is_err());
732 }
733
734 #[test]
735 fn test_require_git_repo() {
736 let result = require_git_repo();
738 let _ = result;
741 }
742
743 #[test]
744 fn test_get_repo_root() {
745 let result = get_repo_root();
747 if let Ok(path) = result {
749 assert!(path.exists());
751 assert!(path.is_dir());
752 let git_dir = path.join(".git");
754 assert!(git_dir.exists() || path.ancestors().any(|p| p.join(".git").exists()));
755 }
756 }
757
758 #[test]
759 fn test_git_diff_from_returns_result() {
760 let result = git_diff_from("invalid_oid_that_does_not_exist");
763 assert!(result.is_err());
764 }
765
766 #[test]
767 fn test_git_snapshot_returns_result() {
768 let result = git_snapshot();
770 assert!(result.is_ok() || result.is_err());
771 }
772
773 #[test]
774 fn test_git_add_all_returns_result() {
775 let result = git_add_all();
777 assert!(result.is_ok() || result.is_err());
778 }
779
780 #[test]
781 fn test_get_git_diff_from_start_returns_result() {
782 let result = get_git_diff_from_start();
785 assert!(result.is_ok() || result.is_err());
786 }
787}