ralph_workflow/git_helpers/
repo.rs1use std::io;
15use std::path::PathBuf;
16
17use super::git2_to_io_error;
18use super::identity::GitIdentity;
19use crate::workspace::Workspace;
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
26pub enum DiffTruncationLevel {
27 #[default]
29 Full,
30 Abbreviated,
32 FileList,
34 FileListAbbreviated,
36}
37
38#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct DiffReviewContent {
44 pub content: String,
46 pub truncation_level: DiffTruncationLevel,
48 pub total_file_count: usize,
50 pub shown_file_count: Option<usize>,
52 pub baseline_oid: Option<String>,
54 pub baseline_short: Option<String>,
56 pub baseline_description: String,
58}
59
60impl DiffReviewContent {
61 pub fn format_context_header(&self) -> String {
76 let mut lines = Vec::new();
77
78 if let Some(short) = &self.baseline_short {
79 lines.push(format!(
80 "Diff Context: Compared against {} {}",
81 self.baseline_description, short
82 ));
83 } else {
84 lines.push("Diff Context: Version information not available".to_string());
85 }
86
87 match self.truncation_level {
89 DiffTruncationLevel::Full => {
90 }
92 DiffTruncationLevel::Abbreviated => {
93 lines.push(format!(
94 "Note: Diff abbreviated - {}/{} files shown",
95 self.shown_file_count.unwrap_or(0),
96 self.total_file_count
97 ));
98 }
99 DiffTruncationLevel::FileList => {
100 lines.push(format!(
101 "Note: Only file list shown - {} files changed",
102 self.total_file_count
103 ));
104 }
105 DiffTruncationLevel::FileListAbbreviated => {
106 lines.push(format!(
107 "Note: File list abbreviated - {}/{} files shown",
108 self.shown_file_count.unwrap_or(0),
109 self.total_file_count
110 ));
111 }
112 }
113
114 if lines.is_empty() {
115 String::new()
116 } else {
117 format!("{}\n", lines.join("\n"))
118 }
119 }
120}
121
122pub fn require_git_repo() -> io::Result<()> {
124 git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
125 Ok(())
126}
127
128pub fn get_repo_root() -> io::Result<PathBuf> {
130 let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
131 repo.workdir()
132 .map(PathBuf::from)
133 .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "No workdir for repository"))
134}
135
136pub fn get_hooks_dir() -> io::Result<PathBuf> {
142 let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
143 Ok(repo.path().join("hooks"))
144}
145
146pub fn git_snapshot() -> io::Result<String> {
151 let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
152 git_snapshot_impl(&repo)
153}
154
155fn git_snapshot_impl(repo: &git2::Repository) -> io::Result<String> {
157 let mut opts = git2::StatusOptions::new();
158 opts.include_untracked(true).recurse_untracked_dirs(true);
159 let statuses = repo
160 .statuses(Some(&mut opts))
161 .map_err(|e| git2_to_io_error(&e))?;
162
163 let mut result = String::new();
164 for entry in statuses.iter() {
165 let status = entry.status();
166 let path = entry.path().unwrap_or("").to_string();
167
168 if status.contains(git2::Status::WT_NEW) {
171 result.push('?');
172 result.push('?');
173 result.push(' ');
174 result.push_str(&path);
175 result.push('\n');
176 continue;
177 }
178
179 let index_status = if status.contains(git2::Status::INDEX_NEW) {
181 'A'
182 } else if status.contains(git2::Status::INDEX_MODIFIED) {
183 'M'
184 } else if status.contains(git2::Status::INDEX_DELETED) {
185 'D'
186 } else if status.contains(git2::Status::INDEX_RENAMED) {
187 'R'
188 } else if status.contains(git2::Status::INDEX_TYPECHANGE) {
189 'T'
190 } else {
191 ' '
192 };
193
194 let wt_status = if status.contains(git2::Status::WT_MODIFIED) {
196 'M'
197 } else if status.contains(git2::Status::WT_DELETED) {
198 'D'
199 } else if status.contains(git2::Status::WT_RENAMED) {
200 'R'
201 } else if status.contains(git2::Status::WT_TYPECHANGE) {
202 'T'
203 } else {
204 ' '
205 };
206
207 result.push(index_status);
208 result.push(wt_status);
209 result.push(' ');
210 result.push_str(&path);
211 result.push('\n');
212 }
213
214 Ok(result)
215}
216
217pub fn git_diff() -> io::Result<String> {
226 let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
227 git_diff_impl(&repo)
228}
229
230fn git_diff_impl(repo: &git2::Repository) -> io::Result<String> {
232 let head_tree = match repo.head() {
234 Ok(head) => Some(head.peel_to_tree().map_err(|e| git2_to_io_error(&e))?),
235 Err(ref e) if e.code() == git2::ErrorCode::UnbornBranch => {
236 let mut diff_opts = git2::DiffOptions::new();
242 diff_opts.include_untracked(true);
243 diff_opts.recurse_untracked_dirs(true);
244
245 let diff = repo
246 .diff_tree_to_workdir_with_index(None, Some(&mut diff_opts))
247 .map_err(|e| git2_to_io_error(&e))?;
248
249 let mut result = Vec::new();
250 diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
251 result.extend_from_slice(line.content());
252 true
253 })
254 .map_err(|e| git2_to_io_error(&e))?;
255
256 return Ok(String::from_utf8_lossy(&result).to_string());
257 }
258 Err(e) => return Err(git2_to_io_error(&e)),
259 };
260
261 let mut diff_opts = git2::DiffOptions::new();
264 diff_opts.include_untracked(true);
265 diff_opts.recurse_untracked_dirs(true);
266
267 let diff = repo
268 .diff_tree_to_workdir_with_index(head_tree.as_ref(), Some(&mut diff_opts))
269 .map_err(|e| git2_to_io_error(&e))?;
270
271 let mut result = Vec::new();
273 diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
274 result.extend_from_slice(line.content());
275 true
276 })
277 .map_err(|e| git2_to_io_error(&e))?;
278
279 Ok(String::from_utf8_lossy(&result).to_string())
280}
281
282fn index_has_changes_to_commit(repo: &git2::Repository, index: &git2::Index) -> io::Result<bool> {
283 match repo.head() {
284 Ok(head) => {
285 let head_tree = head.peel_to_tree().map_err(|e| git2_to_io_error(&e))?;
286 let diff = repo
287 .diff_tree_to_index(Some(&head_tree), Some(index), None)
288 .map_err(|e| git2_to_io_error(&e))?;
289 Ok(diff.deltas().len() > 0)
290 }
291 Err(ref e) if e.code() == git2::ErrorCode::UnbornBranch => Ok(!index.is_empty()),
292 Err(e) => Err(git2_to_io_error(&e)),
293 }
294}
295
296fn is_internal_agent_artifact(path: &std::path::Path) -> bool {
297 let path_str = path.to_string_lossy();
298 path_str == ".no_agent_commit"
299 || path_str == ".agent"
300 || path_str.starts_with(".agent/")
301 || path_str == ".git"
302 || path_str.starts_with(".git/")
303}
304
305pub fn git_add_all() -> io::Result<bool> {
315 let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
316 git_add_all_impl(&repo)
317}
318
319fn git_add_all_impl(repo: &git2::Repository) -> io::Result<bool> {
321 let mut index = repo.index().map_err(|e| git2_to_io_error(&e))?;
322
323 let mut status_opts = git2::StatusOptions::new();
326 status_opts
327 .include_untracked(true)
328 .recurse_untracked_dirs(true)
329 .include_ignored(false);
330 let statuses = repo
331 .statuses(Some(&mut status_opts))
332 .map_err(|e| git2_to_io_error(&e))?;
333 for entry in statuses.iter() {
334 if entry.status().contains(git2::Status::WT_DELETED) {
335 if let Some(path) = entry.path() {
336 index
337 .remove_path(std::path::Path::new(path))
338 .map_err(|e| git2_to_io_error(&e))?;
339 }
340 }
341 }
342
343 let mut filter_cb = |path: &std::path::Path, _matched: &[u8]| -> i32 {
346 i32::from(is_internal_agent_artifact(path))
349 };
350 index
351 .add_all(
352 vec!["."],
353 git2::IndexAddOption::DEFAULT,
354 Some(&mut filter_cb),
355 )
356 .map_err(|e| git2_to_io_error(&e))?;
357
358 index.write().map_err(|e| git2_to_io_error(&e))?;
359
360 index_has_changes_to_commit(repo, &index)
362}
363
364fn resolve_commit_identity(
388 repo: &git2::Repository,
389 provided_name: Option<&str>,
390 provided_email: Option<&str>,
391 executor: Option<&dyn crate::executor::ProcessExecutor>,
392) -> GitIdentity {
393 use super::identity::{default_identity, fallback_email, fallback_username};
394
395 let mut name = String::new();
397 let mut email = String::new();
398 let mut has_git_config = false;
399
400 if let Ok(sig) = repo.signature() {
401 let git_name = sig.name().unwrap_or("");
402 let git_email = sig.email().unwrap_or("");
403 if !git_name.is_empty() && !git_email.is_empty() {
404 name = git_name.to_string();
405 email = git_email.to_string();
406 has_git_config = true;
407 }
408 }
409
410 let env_name = std::env::var("RALPH_GIT_USER_NAME").ok();
417 let env_email = std::env::var("RALPH_GIT_USER_EMAIL").ok();
418
419 let final_name = if has_git_config && !name.is_empty() {
422 name.as_str()
423 } else {
424 provided_name
425 .filter(|s| !s.is_empty())
426 .or(env_name.as_deref())
427 .filter(|s| !s.is_empty())
428 .unwrap_or("")
429 };
430
431 let final_email = if has_git_config && !email.is_empty() {
432 email.as_str()
433 } else {
434 provided_email
435 .filter(|s| !s.is_empty())
436 .or(env_email.as_deref())
437 .filter(|s| !s.is_empty())
438 .unwrap_or("")
439 };
440
441 if !final_name.is_empty() && !final_email.is_empty() {
443 let identity = GitIdentity::new(final_name.to_string(), final_email.to_string());
444 if identity.validate().is_ok() {
445 return identity;
446 }
447 }
448
449 let username = fallback_username(executor);
452 let system_email = fallback_email(&username, executor);
453 let identity = GitIdentity::new(
454 if final_name.is_empty() {
455 username
456 } else {
457 final_name.to_string()
458 },
459 if final_email.is_empty() {
460 system_email
461 } else {
462 final_email.to_string()
463 },
464 );
465
466 if identity.validate().is_ok() {
467 return identity;
468 }
469
470 default_identity()
472}
473
474pub fn git_commit(
506 message: &str,
507 git_user_name: Option<&str>,
508 git_user_email: Option<&str>,
509 executor: Option<&dyn crate::executor::ProcessExecutor>,
510) -> io::Result<Option<git2::Oid>> {
511 let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
512 git_commit_impl(&repo, message, git_user_name, git_user_email, executor)
513}
514
515fn git_commit_impl(
517 repo: &git2::Repository,
518 message: &str,
519 git_user_name: Option<&str>,
520 git_user_email: Option<&str>,
521 executor: Option<&dyn crate::executor::ProcessExecutor>,
522) -> io::Result<Option<git2::Oid>> {
523 let mut index = repo.index().map_err(|e| git2_to_io_error(&e))?;
525
526 if !index_has_changes_to_commit(repo, &index)? {
529 return Ok(None);
530 }
531
532 let tree_oid = index.write_tree().map_err(|e| git2_to_io_error(&e))?;
534
535 let tree = repo.find_tree(tree_oid).map_err(|e| git2_to_io_error(&e))?;
536
537 let GitIdentity { name, email } =
540 resolve_commit_identity(repo, git_user_name, git_user_email, executor);
541
542 if std::env::var("RALPH_DEBUG").is_ok() {
545 let identity_source = if git_user_name.is_some() || git_user_email.is_some() {
546 "CLI/config override"
547 } else if std::env::var("RALPH_GIT_USER_NAME").is_ok()
548 || std::env::var("RALPH_GIT_USER_EMAIL").is_ok()
549 {
550 "environment variable"
551 } else if repo.signature().is_ok() {
552 "git config"
553 } else {
554 "system/default"
555 };
556 eprintln!("Git identity: {name} <{email}> (source: {identity_source})");
557 }
558
559 let sig = git2::Signature::now(&name, &email).map_err(|e| git2_to_io_error(&e))?;
561
562 let oid = match repo.head() {
563 Ok(head) => {
564 let head_commit = head.peel_to_commit().map_err(|e| git2_to_io_error(&e))?;
566 repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &[&head_commit])
567 }
568 Err(ref e) if e.code() == git2::ErrorCode::UnbornBranch => {
569 let mut has_entries = false;
572 tree.walk(git2::TreeWalkMode::PreOrder, |_, _| {
573 has_entries = true;
574 1 })
576 .ok(); if !has_entries {
579 return Ok(None);
581 }
582 repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &[])
583 }
584 Err(e) => return Err(git2_to_io_error(&e)),
585 }
586 .map_err(|e| git2_to_io_error(&e))?;
587
588 Ok(Some(oid))
589}
590
591pub fn git_diff_from(start_oid: &str) -> io::Result<String> {
611 let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
612
613 let oid = git2::Oid::from_str(start_oid).map_err(|_| {
615 io::Error::new(
616 io::ErrorKind::InvalidInput,
617 format!("Invalid commit OID: {start_oid}"),
618 )
619 })?;
620
621 git_diff_from_oid(&repo, oid)
622}
623
624fn git_diff_from_oid(repo: &git2::Repository, oid: git2::Oid) -> io::Result<String> {
625 let start_commit = repo.find_commit(oid).map_err(|e| git2_to_io_error(&e))?;
627 let start_tree = start_commit.tree().map_err(|e| git2_to_io_error(&e))?;
628
629 let mut diff_opts = git2::DiffOptions::new();
632 diff_opts.include_untracked(true);
633 diff_opts.recurse_untracked_dirs(true);
634
635 let diff = repo
636 .diff_tree_to_workdir_with_index(Some(&start_tree), Some(&mut diff_opts))
637 .map_err(|e| git2_to_io_error(&e))?;
638
639 let mut result = Vec::new();
641 diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
642 result.extend_from_slice(line.content());
643 true
644 })
645 .map_err(|e| git2_to_io_error(&e))?;
646
647 Ok(String::from_utf8_lossy(&result).to_string())
648}
649
650fn git_diff_from_empty_tree(repo: &git2::Repository) -> io::Result<String> {
656 let mut diff_opts = git2::DiffOptions::new();
657 diff_opts.include_untracked(true);
658 diff_opts.recurse_untracked_dirs(true);
659
660 let diff = repo
661 .diff_tree_to_workdir_with_index(None, Some(&mut diff_opts))
662 .map_err(|e| git2_to_io_error(&e))?;
663
664 let mut result = Vec::new();
665 diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
666 result.extend_from_slice(line.content());
667 true
668 })
669 .map_err(|e| git2_to_io_error(&e))?;
670
671 Ok(String::from_utf8_lossy(&result).to_string())
672}
673
674pub fn get_git_diff_from_start() -> io::Result<String> {
687 use crate::git_helpers::start_commit::{load_start_point, save_start_commit, StartPoint};
688
689 save_start_commit()?;
692
693 let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
694
695 match load_start_point()? {
696 StartPoint::Commit(oid) => git_diff_from(&oid.to_string()),
697 StartPoint::EmptyRepo => git_diff_from_empty_tree(&repo),
698 }
699}
700
701pub fn get_git_diff_from_start_with_workspace(workspace: &dyn Workspace) -> io::Result<String> {
708 use crate::git_helpers::start_commit::{
709 load_start_point_with_workspace, save_start_commit_with_workspace, StartPoint,
710 };
711
712 let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
716
717 save_start_commit_with_workspace(workspace, &repo)?;
720
721 match load_start_point_with_workspace(workspace, &repo)? {
722 StartPoint::Commit(oid) => git_diff_from_oid(&repo, oid),
723 StartPoint::EmptyRepo => git_diff_from_empty_tree(&repo),
724 }
725}
726
727pub fn get_git_diff_for_review_with_workspace(
738 workspace: &dyn Workspace,
739) -> io::Result<(String, String)> {
740 use crate::git_helpers::review_baseline::{
741 load_review_baseline_with_workspace, ReviewBaseline,
742 };
743 use crate::git_helpers::start_commit::{
744 load_start_point_with_workspace, save_start_commit_with_workspace, StartPoint,
745 };
746
747 let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
749
750 let baseline = load_review_baseline_with_workspace(workspace).unwrap_or(ReviewBaseline::NotSet);
751 match baseline {
752 ReviewBaseline::Commit(oid) => {
753 let diff = git_diff_from_oid(&repo, oid)?;
754 Ok((diff, oid.to_string()))
755 }
756 ReviewBaseline::NotSet => {
757 save_start_commit_with_workspace(workspace, &repo)?;
759
760 match load_start_point_with_workspace(workspace, &repo)? {
761 StartPoint::Commit(oid) => {
762 let diff = git_diff_from_oid(&repo, oid)?;
763 Ok((diff, oid.to_string()))
764 }
765 StartPoint::EmptyRepo => Ok((git_diff_from_empty_tree(&repo)?, String::new())),
766 }
767 }
768 }
769}
770
771#[derive(Debug, Clone, PartialEq, Eq)]
775pub enum CommitResultFallback {
776 Success(git2::Oid),
778 NoChanges,
780 Failed(String),
782}
783
784#[cfg(test)]
785mod tests {
786 use super::*;
787
788 #[test]
789 fn test_git_diff_returns_string() {
790 let result = git_diff();
793 assert!(result.is_ok() || result.is_err());
794 }
795
796 #[test]
797 fn test_require_git_repo() {
798 let result = require_git_repo();
800 let _ = result;
803 }
804
805 #[test]
806 fn test_get_repo_root() {
807 let result = get_repo_root();
809 if let Ok(path) = result {
811 assert!(path.exists());
813 assert!(path.is_dir());
814 let git_dir = path.join(".git");
816 assert!(git_dir.exists() || path.ancestors().any(|p| p.join(".git").exists()));
817 }
818 }
819
820 #[test]
821 fn test_git_diff_from_returns_result() {
822 let result = git_diff_from("invalid_oid_that_does_not_exist");
825 assert!(result.is_err());
826 }
827
828 #[test]
829 fn test_git_snapshot_returns_result() {
830 let result = git_snapshot();
832 assert!(result.is_ok() || result.is_err());
833 }
834
835 #[test]
836 fn test_git_add_all_returns_result() {
837 let result = git_add_all();
839 assert!(result.is_ok() || result.is_err());
840 }
841
842 #[test]
843 fn test_get_git_diff_from_start_returns_result() {
844 let result = get_git_diff_from_start();
847 assert!(result.is_ok() || result.is_err());
848 }
849}