ralph_workflow/git_helpers/
repo.rs1use std::io;
15use std::path::PathBuf;
16
17use super::git2_to_io_error;
18use super::identity::GitIdentity;
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
25pub enum DiffTruncationLevel {
26 #[default]
28 Full,
29 Abbreviated,
31 FileList,
33 FileListAbbreviated,
35}
36
37#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct DiffReviewContent {
43 pub content: String,
45 pub truncation_level: DiffTruncationLevel,
47 pub total_file_count: usize,
49 pub shown_file_count: Option<usize>,
51 pub baseline_oid: Option<String>,
53 pub baseline_short: Option<String>,
55 pub baseline_description: String,
57}
58
59impl DiffReviewContent {
60 pub fn format_context_header(&self) -> String {
75 let mut lines = Vec::new();
76
77 if let Some(short) = &self.baseline_short {
78 lines.push(format!(
79 "Diff Context: Compared against {} {}",
80 self.baseline_description, short
81 ));
82 } else {
83 lines.push("Diff Context: Version information not available".to_string());
84 }
85
86 match self.truncation_level {
88 DiffTruncationLevel::Full => {
89 }
91 DiffTruncationLevel::Abbreviated => {
92 lines.push(format!(
93 "Note: Diff abbreviated - {}/{} files shown",
94 self.shown_file_count.unwrap_or(0),
95 self.total_file_count
96 ));
97 }
98 DiffTruncationLevel::FileList => {
99 lines.push(format!(
100 "Note: Only file list shown - {} files changed",
101 self.total_file_count
102 ));
103 }
104 DiffTruncationLevel::FileListAbbreviated => {
105 lines.push(format!(
106 "Note: File list abbreviated - {}/{} files shown",
107 self.shown_file_count.unwrap_or(0),
108 self.total_file_count
109 ));
110 }
111 }
112
113 if lines.is_empty() {
114 String::new()
115 } else {
116 format!("{}\n", lines.join("\n"))
117 }
118 }
119}
120
121pub fn require_git_repo() -> io::Result<()> {
123 git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
124 Ok(())
125}
126
127pub fn get_repo_root() -> io::Result<PathBuf> {
129 let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
130 repo.workdir()
131 .map(PathBuf::from)
132 .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "No workdir for repository"))
133}
134
135pub fn get_hooks_dir() -> io::Result<PathBuf> {
141 let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
142 Ok(repo.path().join("hooks"))
143}
144
145pub fn git_snapshot() -> io::Result<String> {
150 let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
151 git_snapshot_impl(&repo)
152}
153
154fn git_snapshot_impl(repo: &git2::Repository) -> io::Result<String> {
156 let mut opts = git2::StatusOptions::new();
157 opts.include_untracked(true).recurse_untracked_dirs(true);
158 let statuses = repo
159 .statuses(Some(&mut opts))
160 .map_err(|e| git2_to_io_error(&e))?;
161
162 let mut result = String::new();
163 for entry in statuses.iter() {
164 let status = entry.status();
165 let path = entry.path().unwrap_or("").to_string();
166
167 if status.contains(git2::Status::WT_NEW) {
170 result.push('?');
171 result.push('?');
172 result.push(' ');
173 result.push_str(&path);
174 result.push('\n');
175 continue;
176 }
177
178 let index_status = if status.contains(git2::Status::INDEX_NEW) {
180 'A'
181 } else if status.contains(git2::Status::INDEX_MODIFIED) {
182 'M'
183 } else if status.contains(git2::Status::INDEX_DELETED) {
184 'D'
185 } else if status.contains(git2::Status::INDEX_RENAMED) {
186 'R'
187 } else if status.contains(git2::Status::INDEX_TYPECHANGE) {
188 'T'
189 } else {
190 ' '
191 };
192
193 let wt_status = if status.contains(git2::Status::WT_MODIFIED) {
195 'M'
196 } else if status.contains(git2::Status::WT_DELETED) {
197 'D'
198 } else if status.contains(git2::Status::WT_RENAMED) {
199 'R'
200 } else if status.contains(git2::Status::WT_TYPECHANGE) {
201 'T'
202 } else {
203 ' '
204 };
205
206 result.push(index_status);
207 result.push(wt_status);
208 result.push(' ');
209 result.push_str(&path);
210 result.push('\n');
211 }
212
213 Ok(result)
214}
215
216pub fn git_diff() -> io::Result<String> {
225 let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
226 git_diff_impl(&repo)
227}
228
229fn git_diff_impl(repo: &git2::Repository) -> io::Result<String> {
231 let head_tree = match repo.head() {
233 Ok(head) => Some(head.peel_to_tree().map_err(|e| git2_to_io_error(&e))?),
234 Err(ref e) if e.code() == git2::ErrorCode::UnbornBranch => {
235 let mut diff_opts = git2::DiffOptions::new();
241 diff_opts.include_untracked(true);
242 diff_opts.recurse_untracked_dirs(true);
243
244 let diff = repo
245 .diff_tree_to_workdir_with_index(None, Some(&mut diff_opts))
246 .map_err(|e| git2_to_io_error(&e))?;
247
248 let mut result = Vec::new();
249 diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
250 result.extend_from_slice(line.content());
251 true
252 })
253 .map_err(|e| git2_to_io_error(&e))?;
254
255 return Ok(String::from_utf8_lossy(&result).to_string());
256 }
257 Err(e) => return Err(git2_to_io_error(&e)),
258 };
259
260 let mut diff_opts = git2::DiffOptions::new();
263 diff_opts.include_untracked(true);
264 diff_opts.recurse_untracked_dirs(true);
265
266 let diff = repo
267 .diff_tree_to_workdir_with_index(head_tree.as_ref(), Some(&mut diff_opts))
268 .map_err(|e| git2_to_io_error(&e))?;
269
270 let mut result = Vec::new();
272 diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
273 result.extend_from_slice(line.content());
274 true
275 })
276 .map_err(|e| git2_to_io_error(&e))?;
277
278 Ok(String::from_utf8_lossy(&result).to_string())
279}
280
281fn index_has_changes_to_commit(repo: &git2::Repository, index: &git2::Index) -> io::Result<bool> {
282 match repo.head() {
283 Ok(head) => {
284 let head_tree = head.peel_to_tree().map_err(|e| git2_to_io_error(&e))?;
285 let diff = repo
286 .diff_tree_to_index(Some(&head_tree), Some(index), None)
287 .map_err(|e| git2_to_io_error(&e))?;
288 Ok(diff.deltas().len() > 0)
289 }
290 Err(ref e) if e.code() == git2::ErrorCode::UnbornBranch => Ok(!index.is_empty()),
291 Err(e) => Err(git2_to_io_error(&e)),
292 }
293}
294
295fn is_internal_agent_artifact(path: &std::path::Path) -> bool {
296 let path_str = path.to_string_lossy();
297 path_str == ".no_agent_commit"
298 || path_str == ".agent"
299 || path_str.starts_with(".agent/")
300 || path_str == ".git"
301 || path_str.starts_with(".git/")
302}
303
304pub fn git_add_all() -> io::Result<bool> {
314 let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
315 git_add_all_impl(&repo)
316}
317
318fn git_add_all_impl(repo: &git2::Repository) -> io::Result<bool> {
320 let mut index = repo.index().map_err(|e| git2_to_io_error(&e))?;
321
322 let mut status_opts = git2::StatusOptions::new();
325 status_opts
326 .include_untracked(true)
327 .recurse_untracked_dirs(true)
328 .include_ignored(false);
329 let statuses = repo
330 .statuses(Some(&mut status_opts))
331 .map_err(|e| git2_to_io_error(&e))?;
332 for entry in statuses.iter() {
333 if entry.status().contains(git2::Status::WT_DELETED) {
334 if let Some(path) = entry.path() {
335 index
336 .remove_path(std::path::Path::new(path))
337 .map_err(|e| git2_to_io_error(&e))?;
338 }
339 }
340 }
341
342 let mut filter_cb = |path: &std::path::Path, _matched: &[u8]| -> i32 {
345 i32::from(is_internal_agent_artifact(path))
348 };
349 index
350 .add_all(
351 vec!["."],
352 git2::IndexAddOption::DEFAULT,
353 Some(&mut filter_cb),
354 )
355 .map_err(|e| git2_to_io_error(&e))?;
356
357 index.write().map_err(|e| git2_to_io_error(&e))?;
358
359 index_has_changes_to_commit(repo, &index)
361}
362
363fn resolve_commit_identity(
387 repo: &git2::Repository,
388 provided_name: Option<&str>,
389 provided_email: Option<&str>,
390 executor: Option<&dyn crate::executor::ProcessExecutor>,
391) -> GitIdentity {
392 use super::identity::{default_identity, fallback_email, fallback_username};
393
394 let mut name = String::new();
396 let mut email = String::new();
397 let mut has_git_config = false;
398
399 if let Ok(sig) = repo.signature() {
400 let git_name = sig.name().unwrap_or("");
401 let git_email = sig.email().unwrap_or("");
402 if !git_name.is_empty() && !git_email.is_empty() {
403 name = git_name.to_string();
404 email = git_email.to_string();
405 has_git_config = true;
406 }
407 }
408
409 let env_name = std::env::var("RALPH_GIT_USER_NAME").ok();
416 let env_email = std::env::var("RALPH_GIT_USER_EMAIL").ok();
417
418 let final_name = if has_git_config && !name.is_empty() {
421 name.as_str()
422 } else {
423 provided_name
424 .filter(|s| !s.is_empty())
425 .or(env_name.as_deref())
426 .filter(|s| !s.is_empty())
427 .unwrap_or("")
428 };
429
430 let final_email = if has_git_config && !email.is_empty() {
431 email.as_str()
432 } else {
433 provided_email
434 .filter(|s| !s.is_empty())
435 .or(env_email.as_deref())
436 .filter(|s| !s.is_empty())
437 .unwrap_or("")
438 };
439
440 if !final_name.is_empty() && !final_email.is_empty() {
442 let identity = GitIdentity::new(final_name.to_string(), final_email.to_string());
443 if identity.validate().is_ok() {
444 return identity;
445 }
446 }
447
448 let username = fallback_username(executor);
451 let system_email = fallback_email(&username, executor);
452 let identity = GitIdentity::new(
453 if final_name.is_empty() {
454 username
455 } else {
456 final_name.to_string()
457 },
458 if final_email.is_empty() {
459 system_email
460 } else {
461 final_email.to_string()
462 },
463 );
464
465 if identity.validate().is_ok() {
466 return identity;
467 }
468
469 default_identity()
471}
472
473pub fn git_commit(
505 message: &str,
506 git_user_name: Option<&str>,
507 git_user_email: Option<&str>,
508 executor: Option<&dyn crate::executor::ProcessExecutor>,
509) -> io::Result<Option<git2::Oid>> {
510 let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
511 git_commit_impl(&repo, message, git_user_name, git_user_email, executor)
512}
513
514fn git_commit_impl(
516 repo: &git2::Repository,
517 message: &str,
518 git_user_name: Option<&str>,
519 git_user_email: Option<&str>,
520 executor: Option<&dyn crate::executor::ProcessExecutor>,
521) -> io::Result<Option<git2::Oid>> {
522 let mut index = repo.index().map_err(|e| git2_to_io_error(&e))?;
524
525 if !index_has_changes_to_commit(repo, &index)? {
528 return Ok(None);
529 }
530
531 let tree_oid = index.write_tree().map_err(|e| git2_to_io_error(&e))?;
533
534 let tree = repo.find_tree(tree_oid).map_err(|e| git2_to_io_error(&e))?;
535
536 let GitIdentity { name, email } =
539 resolve_commit_identity(repo, git_user_name, git_user_email, executor);
540
541 if std::env::var("RALPH_DEBUG").is_ok() {
544 let identity_source = if git_user_name.is_some() || git_user_email.is_some() {
545 "CLI/config override"
546 } else if std::env::var("RALPH_GIT_USER_NAME").is_ok()
547 || std::env::var("RALPH_GIT_USER_EMAIL").is_ok()
548 {
549 "environment variable"
550 } else if repo.signature().is_ok() {
551 "git config"
552 } else {
553 "system/default"
554 };
555 eprintln!("Git identity: {name} <{email}> (source: {identity_source})");
556 }
557
558 let sig = git2::Signature::now(&name, &email).map_err(|e| git2_to_io_error(&e))?;
560
561 let oid = match repo.head() {
562 Ok(head) => {
563 let head_commit = head.peel_to_commit().map_err(|e| git2_to_io_error(&e))?;
565 repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &[&head_commit])
566 }
567 Err(ref e) if e.code() == git2::ErrorCode::UnbornBranch => {
568 let mut has_entries = false;
571 tree.walk(git2::TreeWalkMode::PreOrder, |_, _| {
572 has_entries = true;
573 1 })
575 .ok(); if !has_entries {
578 return Ok(None);
580 }
581 repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &[])
582 }
583 Err(e) => return Err(git2_to_io_error(&e)),
584 }
585 .map_err(|e| git2_to_io_error(&e))?;
586
587 Ok(Some(oid))
588}
589
590#[cfg(any(test, feature = "test-utils"))]
612pub fn git_diff_from(start_oid: &str) -> io::Result<String> {
613 let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
614
615 let oid = git2::Oid::from_str(start_oid).map_err(|_| {
617 io::Error::new(
618 io::ErrorKind::InvalidInput,
619 format!("Invalid commit OID: {start_oid}"),
620 )
621 })?;
622
623 let start_commit = repo.find_commit(oid).map_err(|e| git2_to_io_error(&e))?;
625 let start_tree = start_commit.tree().map_err(|e| git2_to_io_error(&e))?;
626
627 let mut diff_opts = git2::DiffOptions::new();
630 diff_opts.include_untracked(true);
631 diff_opts.recurse_untracked_dirs(true);
632
633 let diff = repo
634 .diff_tree_to_workdir_with_index(Some(&start_tree), Some(&mut diff_opts))
635 .map_err(|e| git2_to_io_error(&e))?;
636
637 let mut result = Vec::new();
639 diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
640 result.extend_from_slice(line.content());
641 true
642 })
643 .map_err(|e| git2_to_io_error(&e))?;
644
645 Ok(String::from_utf8_lossy(&result).to_string())
646}
647
648#[cfg(any(test, feature = "test-utils"))]
656fn git_diff_from_empty_tree(repo: &git2::Repository) -> io::Result<String> {
657 let mut diff_opts = git2::DiffOptions::new();
658 diff_opts.include_untracked(true);
659 diff_opts.recurse_untracked_dirs(true);
660
661 let diff = repo
662 .diff_tree_to_workdir_with_index(None, Some(&mut diff_opts))
663 .map_err(|e| git2_to_io_error(&e))?;
664
665 let mut result = Vec::new();
666 diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
667 result.extend_from_slice(line.content());
668 true
669 })
670 .map_err(|e| git2_to_io_error(&e))?;
671
672 Ok(String::from_utf8_lossy(&result).to_string())
673}
674
675#[cfg(any(test, feature = "test-utils"))]
690pub fn get_git_diff_from_start() -> io::Result<String> {
691 use crate::git_helpers::start_commit::{load_start_point, save_start_commit, StartPoint};
692
693 save_start_commit()?;
696
697 let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
698
699 match load_start_point()? {
700 StartPoint::Commit(oid) => git_diff_from(&oid.to_string()),
701 StartPoint::EmptyRepo => git_diff_from_empty_tree(&repo),
702 }
703}
704
705#[derive(Debug, Clone, PartialEq, Eq)]
709pub enum CommitResultFallback {
710 Success(git2::Oid),
712 NoChanges,
714 Failed(String),
716}
717
718#[cfg(test)]
719mod tests {
720 use super::*;
721
722 #[test]
723 fn test_git_diff_returns_string() {
724 let result = git_diff();
727 assert!(result.is_ok() || result.is_err());
728 }
729
730 #[test]
731 fn test_require_git_repo() {
732 let result = require_git_repo();
734 let _ = result;
737 }
738
739 #[test]
740 fn test_get_repo_root() {
741 let result = get_repo_root();
743 if let Ok(path) = result {
745 assert!(path.exists());
747 assert!(path.is_dir());
748 let git_dir = path.join(".git");
750 assert!(git_dir.exists() || path.ancestors().any(|p| p.join(".git").exists()));
751 }
752 }
753
754 #[test]
755 fn test_git_diff_from_returns_result() {
756 let result = git_diff_from("invalid_oid_that_does_not_exist");
759 assert!(result.is_err());
760 }
761
762 #[test]
763 fn test_git_snapshot_returns_result() {
764 let result = git_snapshot();
766 assert!(result.is_ok() || result.is_err());
767 }
768
769 #[test]
770 fn test_git_add_all_returns_result() {
771 let result = git_add_all();
773 assert!(result.is_ok() || result.is_err());
774 }
775
776 #[test]
777 fn test_get_git_diff_from_start_returns_result() {
778 let result = get_git_diff_from_start();
781 assert!(result.is_ok() || result.is_err());
782 }
783}