1use std::env;
2use std::fs;
3use std::path::{Path, PathBuf};
4use std::sync::{Mutex, OnceLock};
5
6use git2::{Blame, Delta, Diff, DiffFindOptions, DiffOptions, ErrorCode, Oid, Repository};
7use thiserror::Error;
8
9use super::types::{CommitInfo, DiffScope, FileChange, FileCommitInfo, FileStatus};
10
11#[derive(Error, Debug)]
12pub enum GitError {
13 #[error("not a git repository")]
14 NotARepo,
15 #[error("git error: {0}")]
16 Git2(#[from] git2::Error),
17 #[error("io error: {0}")]
18 Io(#[from] std::io::Error),
19}
20
21pub struct GitBridge {
22 repo: Repository,
23 repo_root: PathBuf,
24}
25
26impl GitBridge {
27 pub fn open(path: &Path) -> Result<Self, GitError> {
28 let repo = match Repository::discover(path) {
29 Ok(repo) => repo,
30 Err(error) if should_retry_with_command_line_safe_directory(&error, path) => {
31 let _guard = owner_validation_lock()
32 .lock()
33 .unwrap_or_else(|poisoned| poisoned.into_inner());
34 let _owner_validation = OwnerValidationDisabled::new()?;
35 let repo = Repository::discover(path);
36 repo.map_err(map_git_error)?
37 }
38 Err(error) => return Err(map_git_error(error)),
39 };
40 let repo_root = repo
41 .workdir()
42 .ok_or(GitError::NotARepo)?
43 .to_path_buf();
44 Ok(Self { repo, repo_root })
45 }
46
47 pub fn repo_root(&self) -> &Path {
48 &self.repo_root
49 }
50
51 pub fn blame_file(&self, file_path: &Path) -> Result<Blame<'_>, GitError> {
52 Ok(self.repo.blame_file(file_path, None)?)
53 }
54
55 pub fn commit_summary(&self, oid: Oid) -> Option<String> {
56 self.repo
57 .find_commit(oid)
58 .ok()
59 .and_then(|commit| commit.summary().map(String::from))
60 }
61
62 pub fn get_head_sha(&self) -> Result<String, GitError> {
63 let head = self.repo.head()?;
64 let oid = head.target().ok_or_else(|| {
65 git2::Error::from_str("HEAD has no target")
66 })?;
67 Ok(oid.to_string())
68 }
69
70 pub fn detect_and_get_files(&self, pathspecs: &[String]) -> Result<(DiffScope, Vec<FileChange>), GitError> {
74 let mut working_files = self.get_working_diff_files(pathspecs)?;
76 if !working_files.is_empty() {
77 self.populate_contents(&mut working_files, &DiffScope::Working)?;
78 return Ok((DiffScope::Working, working_files));
79 }
80
81 Ok((DiffScope::Working, Vec::new()))
83 }
84
85 pub fn get_changed_files(&self, scope: &DiffScope, pathspecs: &[String]) -> Result<Vec<FileChange>, GitError> {
87 let mut files = match scope {
88 DiffScope::Working => {
89 self.get_working_diff_files(pathspecs)?
90 }
91 DiffScope::Staged => self.get_staged_diff_files(pathspecs)?,
92 DiffScope::Commit { sha } => self.get_commit_diff_files(sha, pathspecs)?,
93 DiffScope::Range { from, to } => self.get_range_diff_files(from, to, pathspecs)?,
94 DiffScope::RefToWorking { refspec } => self.get_ref_to_working_diff_files(refspec, pathspecs)?,
95 };
96
97 files.retain(|f| !f.file_path.starts_with(".sem/"));
99
100 self.populate_contents(&mut files, scope)?;
101 Ok(files)
102 }
103
104 pub fn resolve_merge_base(&self, ref1: &str, ref2: &str) -> Result<String, GitError> {
106 let obj1 = self.repo.revparse_single(ref1)?;
107 let obj2 = self.repo.revparse_single(ref2)?;
108 let oid = self.repo.merge_base(obj1.id(), obj2.id())?;
109 Ok(oid.to_string())
110 }
111
112 pub fn is_valid_rev(&self, refspec: &str) -> bool {
114 self.repo.revparse_single(refspec).is_ok()
115 }
116
117 fn make_diff_opts(pathspecs: &[String]) -> DiffOptions {
118 let mut opts = DiffOptions::new();
119 for spec in pathspecs {
120 opts.pathspec(spec.as_str());
121 }
122 opts
123 }
124
125 fn get_staged_diff_files(&self, pathspecs: &[String]) -> Result<Vec<FileChange>, GitError> {
126 let head_tree = match self.repo.head() {
127 Ok(head) => {
128 let commit = head.peel_to_commit()?;
129 Some(commit.tree()?)
130 }
131 Err(_) => None, };
133
134 let mut opts = Self::make_diff_opts(pathspecs);
135 let mut diff = self.repo.diff_tree_to_index(
136 head_tree.as_ref(),
137 Some(&self.repo.index()?),
138 Some(&mut opts),
139 )?;
140 Self::detect_renames(&mut diff)?;
141
142 Ok(self.diff_to_file_changes(&diff))
143 }
144
145 fn get_working_diff_files(&self, pathspecs: &[String]) -> Result<Vec<FileChange>, GitError> {
146 let mut opts = Self::make_diff_opts(pathspecs);
147 opts.include_untracked(false);
148
149 let mut diff = self.repo.diff_index_to_workdir(None, Some(&mut opts))?;
150 Self::detect_renames(&mut diff)?;
151 Ok(self.diff_to_file_changes(&diff))
152 }
153
154 fn get_commit_diff_files(&self, sha: &str, pathspecs: &[String]) -> Result<Vec<FileChange>, GitError> {
155 let obj = self.repo.revparse_single(sha)?;
156 let commit = obj.peel_to_commit()?;
157 let tree = commit.tree()?;
158
159 let parent_tree = if commit.parent_count() > 0 {
160 Some(commit.parent(0)?.tree()?)
161 } else {
162 None
163 };
164
165 let mut opts = Self::make_diff_opts(pathspecs);
166 let mut diff = self.repo.diff_tree_to_tree(
167 parent_tree.as_ref(),
168 Some(&tree),
169 Some(&mut opts),
170 )?;
171 Self::detect_renames(&mut diff)?;
172
173 Ok(self.diff_to_file_changes(&diff))
174 }
175
176 fn get_range_diff_files(&self, from: &str, to: &str, pathspecs: &[String]) -> Result<Vec<FileChange>, GitError> {
177 let from_obj = self.repo.revparse_single(from)?;
178 let to_obj = self.repo.revparse_single(to)?;
179
180 let from_tree = from_obj.peel_to_commit()?.tree()?;
181 let to_tree = to_obj.peel_to_commit()?.tree()?;
182
183 let mut opts = Self::make_diff_opts(pathspecs);
184 let mut diff = self.repo.diff_tree_to_tree(
185 Some(&from_tree),
186 Some(&to_tree),
187 Some(&mut opts),
188 )?;
189 Self::detect_renames(&mut diff)?;
190
191 Ok(self.diff_to_file_changes(&diff))
192 }
193
194 fn get_ref_to_working_diff_files(&self, refspec: &str, pathspecs: &[String]) -> Result<Vec<FileChange>, GitError> {
195 let tree = self.resolve_tree(refspec)?;
196 let mut opts = Self::make_diff_opts(pathspecs);
197 let mut diff = self.repo.diff_tree_to_workdir_with_index(
198 Some(&tree),
199 Some(&mut opts),
200 )?;
201 Self::detect_renames(&mut diff)?;
202 Ok(self.diff_to_file_changes(&diff))
203 }
204
205 fn detect_renames(diff: &mut Diff) -> Result<(), GitError> {
206 let mut opts = DiffFindOptions::new();
207 opts.renames(true);
208 diff.find_similar(Some(&mut opts))?;
209 Ok(())
210 }
211
212 fn diff_to_file_changes(&self, diff: &Diff) -> Vec<FileChange> {
213 let mut files = Vec::new();
214
215 for delta in diff.deltas() {
216 let (status, file_path, old_file_path) = match delta.status() {
217 Delta::Added => {
218 let path = delta
219 .new_file()
220 .path()
221 .and_then(|p| p.to_str())
222 .unwrap_or("")
223 .to_string();
224 (FileStatus::Added, path, None)
225 }
226 Delta::Deleted => {
227 let path = delta
228 .old_file()
229 .path()
230 .and_then(|p| p.to_str())
231 .unwrap_or("")
232 .to_string();
233 (FileStatus::Deleted, path, None)
234 }
235 Delta::Modified => {
236 let path = delta
237 .new_file()
238 .path()
239 .and_then(|p| p.to_str())
240 .unwrap_or("")
241 .to_string();
242 (FileStatus::Modified, path, None)
243 }
244 Delta::Renamed => {
245 let new_path = delta
246 .new_file()
247 .path()
248 .and_then(|p| p.to_str())
249 .unwrap_or("")
250 .to_string();
251 let old_path = delta
252 .old_file()
253 .path()
254 .and_then(|p| p.to_str())
255 .unwrap_or("")
256 .to_string();
257 (FileStatus::Renamed, new_path, Some(old_path))
258 }
259 _ => continue,
260 };
261
262 if !file_path.starts_with(".sem/") {
263 files.push(FileChange {
264 file_path,
265 status,
266 old_file_path,
267 before_content: None,
268 after_content: None,
269 });
270 }
271 }
272
273 files
274 }
275
276 fn populate_contents(
277 &self,
278 files: &mut [FileChange],
279 scope: &DiffScope,
280 ) -> Result<(), GitError> {
281 match scope {
282 DiffScope::Working => {
283 let head_tree = self.resolve_tree("HEAD").ok();
285 for file in files.iter_mut() {
286 if file.status != FileStatus::Deleted {
287 file.after_content = self.read_working_file(&file.file_path);
288 }
289 if file.status != FileStatus::Added {
290 let path = file
291 .old_file_path
292 .as_deref()
293 .unwrap_or(&file.file_path);
294 file.before_content = head_tree
295 .as_ref()
296 .and_then(|t| self.read_blob_from_tree(t, path));
297 }
298 }
299 }
300 DiffScope::Staged => {
301 let head_tree = self.resolve_tree("HEAD").ok();
302 for file in files.iter_mut() {
303 if file.status != FileStatus::Deleted {
304 file.after_content = self
305 .read_index_file(&file.file_path)
306 .or_else(|| self.read_working_file(&file.file_path));
307 }
308 if file.status != FileStatus::Added {
309 let path = file
310 .old_file_path
311 .as_deref()
312 .unwrap_or(&file.file_path);
313 file.before_content = head_tree
314 .as_ref()
315 .and_then(|t| self.read_blob_from_tree(t, path));
316 }
317 }
318 }
319 DiffScope::Commit { sha } => {
320 let after_tree = self.resolve_tree(sha)?;
322 let before_tree = self.resolve_tree(&format!("{sha}~1")).ok();
323 for file in files.iter_mut() {
324 if file.status != FileStatus::Deleted {
325 file.after_content =
326 self.read_blob_from_tree(&after_tree, &file.file_path);
327 }
328 if file.status != FileStatus::Added {
329 let path = file
330 .old_file_path
331 .as_deref()
332 .unwrap_or(&file.file_path);
333 file.before_content = before_tree
334 .as_ref()
335 .and_then(|t| self.read_blob_from_tree(t, path));
336 }
337 }
338 }
339 DiffScope::Range { from, to } => {
340 let after_tree = self.resolve_tree(to)?;
341 let before_tree = self.resolve_tree(from)?;
342 for file in files.iter_mut() {
343 if file.status != FileStatus::Deleted {
344 file.after_content =
345 self.read_blob_from_tree(&after_tree, &file.file_path);
346 }
347 if file.status != FileStatus::Added {
348 let path = file
349 .old_file_path
350 .as_deref()
351 .unwrap_or(&file.file_path);
352 file.before_content =
353 self.read_blob_from_tree(&before_tree, path);
354 }
355 }
356 }
357 DiffScope::RefToWorking { refspec } => {
358 let before_tree = self.resolve_tree(refspec)?;
359 for file in files.iter_mut() {
360 if file.status != FileStatus::Deleted {
361 file.after_content = self.read_working_file(&file.file_path);
362 }
363 if file.status != FileStatus::Added {
364 let path = file
365 .old_file_path
366 .as_deref()
367 .unwrap_or(&file.file_path);
368 file.before_content =
369 self.read_blob_from_tree(&before_tree, path);
370 }
371 }
372 }
373 }
374 Ok(())
375 }
376
377 fn resolve_tree(&self, refspec: &str) -> Result<git2::Tree<'_>, GitError> {
378 let obj = self.repo.revparse_single(refspec)?;
379 let commit = obj.peel_to_commit()?;
380 Ok(commit.tree()?)
381 }
382
383 fn normalize_line_endings(s: String) -> String {
384 if s.contains('\r') {
385 s.replace("\r\n", "\n").replace('\r', "\n")
386 } else {
387 s
388 }
389 }
390
391 fn read_blob_from_tree(&self, tree: &git2::Tree, file_path: &str) -> Option<String> {
392 let entry = tree.get_path(Path::new(file_path)).ok()?;
393 let blob = self.repo.find_blob(entry.id()).ok()?;
394 std::str::from_utf8(blob.content())
395 .ok()
396 .map(|s| Self::normalize_line_endings(s.to_string()))
397 }
398
399 fn read_working_file(&self, file_path: &str) -> Option<String> {
400 let full_path = self.repo_root.join(file_path);
401 fs::read_to_string(full_path)
402 .ok()
403 .map(Self::normalize_line_endings)
404 }
405
406 fn read_index_file(&self, file_path: &str) -> Option<String> {
407 let index = self.repo.index().ok()?;
408 let entry = index.get_path(Path::new(file_path), 0)?;
409 let blob = self.repo.find_blob(entry.id).ok()?;
410 std::str::from_utf8(blob.content())
411 .ok()
412 .map(|s| Self::normalize_line_endings(s.to_string()))
413 }
414
415
416 pub fn read_file_at_ref(&self, refspec: &str, file_path: &str) -> Result<Option<String>, GitError> {
418 let tree = self.resolve_tree(refspec)?;
419 Ok(self.read_blob_from_tree(&tree, file_path))
420 }
421
422 pub fn get_file_commits(&self, file_path: &str, limit: usize) -> Result<Vec<CommitInfo>, GitError> {
425 let mut revwalk = self.repo.revwalk()?;
426 revwalk.push_head()?;
427 revwalk.set_sorting(git2::Sort::TIME)?;
428
429 let mut commits = Vec::new();
430 let path = Path::new(file_path);
431
432 for oid_result in revwalk {
433 let oid = oid_result?;
434 let commit = self.repo.find_commit(oid)?;
435 let tree = commit.tree()?;
436
437 let file_in_commit = tree.get_path(path).ok().map(|e| e.id());
439
440 let file_in_parent = if commit.parent_count() > 0 {
442 commit.parent(0)
443 .ok()
444 .and_then(|p| p.tree().ok())
445 .and_then(|t| t.get_path(path).ok().map(|e| e.id()))
446 } else {
447 None };
449
450 let changed = match (file_in_commit, file_in_parent) {
452 (Some(cur), Some(prev)) => cur != prev, (Some(_), None) => true, (None, Some(_)) => true, (None, None) => false, };
457
458 if changed {
459 let sha = oid.to_string();
460 commits.push(CommitInfo {
461 short_sha: sha[..7.min(sha.len())].to_string(),
462 sha,
463 author: commit.author().name().unwrap_or("unknown").to_string(),
464 date: commit.time().seconds().to_string(),
465 message: commit.message().unwrap_or("").to_string(),
466 });
467
468 if commits.len() >= limit {
469 break;
470 }
471 }
472 }
473
474 Ok(commits)
475 }
476
477 pub fn get_file_commits_follow_renames(
482 &self,
483 file_path: &str,
484 limit: usize,
485 ) -> Result<Vec<FileCommitInfo>, GitError> {
486 let mut revwalk = self.repo.revwalk()?;
487 revwalk.push_head()?;
488 revwalk.set_sorting(git2::Sort::TIME)?;
489
490 let mut results = Vec::new();
491 let mut tracked_path = file_path.to_string();
492
493 for oid_result in revwalk {
494 let oid = oid_result?;
495 let commit = self.repo.find_commit(oid)?;
496 let tree = commit.tree()?;
497
498 let path = Path::new(&tracked_path);
499 let file_in_commit = tree.get_path(path).ok().map(|e| e.id());
500
501 let (parent_tree_opt, file_in_parent) = if commit.parent_count() > 0 {
502 let parent = commit.parent(0)?;
503 let ptree = parent.tree()?;
504 let fip = ptree.get_path(path).ok().map(|e| e.id());
505 (Some(ptree), fip)
506 } else {
507 (None, None)
508 };
509
510 let changed = match (file_in_commit, file_in_parent) {
511 (Some(cur), Some(prev)) => cur != prev,
512 (Some(_), None) => true,
513 (None, Some(_)) => true,
514 (None, None) => false,
515 };
516
517 if changed {
518 let sha_str = oid.to_string();
519 results.push(FileCommitInfo {
520 commit: CommitInfo {
521 short_sha: sha_str[..7.min(sha_str.len())].to_string(),
522 sha: sha_str,
523 author: commit.author().name().unwrap_or("unknown").to_string(),
524 date: commit.time().seconds().to_string(),
525 message: commit.message().unwrap_or("").to_string(),
526 },
527 file_path: tracked_path.clone(),
528 });
529
530 if results.len() >= limit {
531 break;
532 }
533 }
534
535 if file_in_commit.is_none() && parent_tree_opt.is_some() {
538 let mut diff = self.repo.diff_tree_to_tree(
539 parent_tree_opt.as_ref(),
540 Some(&tree),
541 None,
542 )?;
543 let mut find_opts = DiffFindOptions::new();
544 find_opts.renames(true);
545 diff.find_similar(Some(&mut find_opts))?;
546
547 let mut found_rename = false;
548 for delta in diff.deltas() {
549 if delta.status() == Delta::Renamed {
550 let new_path = delta
551 .new_file()
552 .path()
553 .and_then(|p| p.to_str())
554 .unwrap_or("");
555 if new_path == tracked_path {
556 let old_path = delta
558 .old_file()
559 .path()
560 .and_then(|p| p.to_str())
561 .unwrap_or("")
562 .to_string();
563 if !old_path.is_empty() {
564 tracked_path = old_path;
565 found_rename = true;
566 break;
567 }
568 }
569 }
570 }
571
572 if !found_rename {
573 break;
575 }
576 }
577 }
578
579 Ok(results)
580 }
581
582 pub fn get_commit_changed_files(&self, sha: &str) -> Result<Vec<String>, GitError> {
585 let obj = self.repo.revparse_single(sha)?;
586 let commit = obj.peel_to_commit()?;
587 let tree = commit.tree()?;
588 let parent_tree = if commit.parent_count() > 0 {
589 Some(commit.parent(0)?.tree()?)
590 } else {
591 None
592 };
593 let diff = self.repo.diff_tree_to_tree(parent_tree.as_ref(), Some(&tree), None)?;
594 let mut paths = Vec::new();
595 for delta in diff.deltas() {
596 if let Some(p) = delta.new_file().path().and_then(|p| p.to_str()) {
597 paths.push(p.to_string());
598 }
599 if let Some(p) = delta.old_file().path().and_then(|p| p.to_str()) {
601 if !paths.contains(&p.to_string()) {
602 paths.push(p.to_string());
603 }
604 }
605 }
606 Ok(paths)
607 }
608
609 pub fn get_log(&self, limit: usize) -> Result<Vec<CommitInfo>, GitError> {
610 let mut revwalk = self.repo.revwalk()?;
611 revwalk.push_head()?;
612
613 let mut commits = Vec::new();
614 for (i, oid_result) in revwalk.enumerate() {
615 if i >= limit {
616 break;
617 }
618 let oid = oid_result?;
619 let commit = self.repo.find_commit(oid)?;
620 let sha = oid.to_string();
621 commits.push(CommitInfo {
622 short_sha: sha[..7.min(sha.len())].to_string(),
623 sha,
624 author: commit.author().name().unwrap_or("unknown").to_string(),
625 date: commit.time().seconds().to_string(),
626 message: commit.message().unwrap_or("").to_string(),
627 });
628 }
629
630 Ok(commits)
631 }
632}
633
634fn map_git_error(error: git2::Error) -> GitError {
635 if error.code() == ErrorCode::NotFound {
636 GitError::NotARepo
637 } else {
638 GitError::Git2(error)
639 }
640}
641
642fn should_retry_with_command_line_safe_directory(error: &git2::Error, path: &Path) -> bool {
643 let safe_directories = command_line_safe_directories();
644 should_retry_with_safe_directory(error, path, &safe_directories)
645}
646
647fn should_retry_with_safe_directory(error: &git2::Error, path: &Path, safe_directories: &[String]) -> bool {
648 error.code() == ErrorCode::Owner
649 && nearest_git_root(path).is_some_and(|repo_root| {
650 safe_directories.iter().any(|safe_directory| {
651 safe_directory == "*"
652 || paths_match(&repo_root, Path::new(safe_directory))
653 })
654 })
655}
656
657fn command_line_safe_directories() -> Vec<String> {
658 let count = env::var("GIT_CONFIG_COUNT")
659 .ok()
660 .and_then(|value| value.parse::<usize>().ok())
661 .unwrap_or_default();
662
663 (0..count)
664 .filter_map(|index| {
665 let key = env::var(format!("GIT_CONFIG_KEY_{index}")).ok()?;
666 if key.eq_ignore_ascii_case("safe.directory") {
667 env::var(format!("GIT_CONFIG_VALUE_{index}")).ok()
668 } else {
669 None
670 }
671 })
672 .collect()
673}
674
675fn nearest_git_root(path: &Path) -> Option<PathBuf> {
676 let mut current = if path.is_file() {
677 path.parent()?
678 } else {
679 path
680 };
681
682 loop {
683 if current.join(".git").exists() {
684 return Some(fs::canonicalize(current).unwrap_or_else(|_| current.to_path_buf()));
685 }
686
687 current = current.parent()?;
688 }
689}
690
691fn paths_match(left: &Path, right: &Path) -> bool {
692 let left = fs::canonicalize(left).unwrap_or_else(|_| left.to_path_buf());
693 let right = fs::canonicalize(right).unwrap_or_else(|_| right.to_path_buf());
694
695 if cfg!(windows) {
696 left.to_string_lossy()
697 .eq_ignore_ascii_case(&right.to_string_lossy())
698 } else {
699 left == right
700 }
701}
702
703fn owner_validation_lock() -> &'static Mutex<()> {
704 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
705 LOCK.get_or_init(|| Mutex::new(()))
706}
707
708struct OwnerValidationDisabled;
709
710impl OwnerValidationDisabled {
711 fn new() -> Result<Self, GitError> {
712 unsafe { git2::opts::set_verify_owner_validation(false)? };
714 Ok(Self)
715 }
716}
717
718impl Drop for OwnerValidationDisabled {
719 fn drop(&mut self) {
720 unsafe {
722 let _ = git2::opts::set_verify_owner_validation(true);
723 }
724 }
725}
726
727#[cfg(test)]
728mod tests {
729 use super::*;
730 use crate::model::change::ChangeType;
731 use crate::parser::differ::compute_semantic_diff;
732 use crate::parser::plugins::create_default_registry;
733 use git2::{ErrorClass, Oid, Repository, Signature};
734 use tempfile::TempDir;
735
736 fn commit_file(repo: &Repository, file_path: &str, contents: &str, message: &str) -> Oid {
737 fs::write(repo.workdir().unwrap().join(file_path), contents).unwrap();
738
739 let mut index = repo.index().unwrap();
740 index.add_path(Path::new(file_path)).unwrap();
741 index.write().unwrap();
742
743 let tree_id = index.write_tree().unwrap();
744 let tree = repo.find_tree(tree_id).unwrap();
745 let sig = Signature::now("Test User", "test@example.com").unwrap();
746
747 match repo.head() {
748 Ok(head) => {
749 let parent = repo.find_commit(head.target().unwrap()).unwrap();
750 repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &[&parent])
751 .unwrap()
752 }
753 Err(_) => repo
754 .commit(Some("HEAD"), &sig, &sig, message, &tree, &[])
755 .unwrap(),
756 }
757 }
758
759 #[test]
760 fn clean_worktree_does_not_fall_back_to_head_commit() {
761 let temp = TempDir::new().unwrap();
762 let repo = Repository::init(temp.path()).unwrap();
763
764 commit_file(&repo, "sample.ts", "export function a() {\n return 1;\n}\n", "init");
765 commit_file(
766 &repo,
767 "sample.ts",
768 "export function a() {\n return 2;\n}\n",
769 "change a",
770 );
771
772 let bridge = GitBridge::open(temp.path()).unwrap();
773 let (scope, files) = bridge.detect_and_get_files(&[]).unwrap();
774
775 assert!(matches!(scope, DiffScope::Working));
776 assert!(files.is_empty());
777 }
778
779 #[test]
780 fn owner_error_retries_for_command_line_safe_directory() {
781 let temp = TempDir::new().unwrap();
782 Repository::init(temp.path()).unwrap();
783
784 let owner_error = git2::Error::new(
785 ErrorCode::Owner,
786 ErrorClass::Config,
787 "owner mismatch",
788 );
789 let safe_directories = [temp.path().to_string_lossy().to_string()];
790
791 assert!(should_retry_with_safe_directory(
792 &owner_error,
793 temp.path(),
794 &safe_directories,
795 ));
796
797 let other_directories = [temp.path().join("other").to_string_lossy().to_string()];
798 assert!(!should_retry_with_safe_directory(
799 &owner_error,
800 temp.path(),
801 &other_directories,
802 ));
803
804 let not_found_error = git2::Error::new(
805 ErrorCode::NotFound,
806 ErrorClass::Repository,
807 "not found",
808 );
809 assert!(!should_retry_with_safe_directory(
810 ¬_found_error,
811 temp.path(),
812 &["*".to_string()],
813 ));
814 }
815
816 #[test]
817 fn explicit_commit_scope_still_reads_head_commit_diff() {
818 let temp = TempDir::new().unwrap();
819 let repo = Repository::init(temp.path()).unwrap();
820
821 commit_file(&repo, "sample.ts", "export function a() {\n return 1;\n}\n", "init");
822 let head_oid = commit_file(
823 &repo,
824 "sample.ts",
825 "export function a() {\n return 2;\n}\n",
826 "change a",
827 );
828
829 let bridge = GitBridge::open(temp.path()).unwrap();
830 let files = bridge
831 .get_changed_files(&DiffScope::Commit {
832 sha: head_oid.to_string(),
833 }, &[])
834 .unwrap();
835
836 assert_eq!(files.len(), 1);
837 assert_eq!(files[0].file_path, "sample.ts");
838 assert_eq!(files[0].status, FileStatus::Modified);
839 }
840
841 #[test]
842 fn staged_file_rename_is_reported_as_single_rename_with_old_contents() {
843 let temp = TempDir::new().unwrap();
844 let repo = Repository::init(temp.path()).unwrap();
845
846 let contents = "export function foo() {\n return 1;\n}\n";
847 commit_file(&repo, "old.ts", contents, "init");
848
849 fs::rename(temp.path().join("old.ts"), temp.path().join("new.ts")).unwrap();
850 let mut index = repo.index().unwrap();
851 index.remove_path(Path::new("old.ts")).unwrap();
852 index.add_path(Path::new("new.ts")).unwrap();
853 index.write().unwrap();
854
855 let bridge = GitBridge::open(temp.path()).unwrap();
856 let files = bridge.get_changed_files(&DiffScope::Staged, &[]).unwrap();
857
858 assert_eq!(files.len(), 1);
859 assert_eq!(files[0].status, FileStatus::Renamed);
860 assert_eq!(files[0].file_path, "new.ts");
861 assert_eq!(files[0].old_file_path.as_deref(), Some("old.ts"));
862 assert_eq!(files[0].before_content.as_deref(), Some(contents));
863 assert_eq!(files[0].after_content.as_deref(), Some(contents));
864 }
865
866 #[test]
867 fn staged_file_rename_with_edit_reports_single_moved_entity() {
868 let temp = TempDir::new().unwrap();
869 let repo = Repository::init(temp.path()).unwrap();
870
871 let before = "\
872// shared header 01
873// shared header 02
874// shared header 03
875// shared header 04
876// shared header 05
877// shared header 06
878// shared header 07
879// shared header 08
880// shared header 09
881// shared header 10
882export function foo() {
883 return alpha + beta + gamma;
884}
885";
886 let after = before.replace(
887 "return alpha + beta + gamma;",
888 "return one + two + three;",
889 );
890
891 commit_file(&repo, "old.ts", before, "init");
892 fs::rename(temp.path().join("old.ts"), temp.path().join("new.ts")).unwrap();
893 fs::write(temp.path().join("new.ts"), &after).unwrap();
894
895 let mut index = repo.index().unwrap();
896 index.remove_path(Path::new("old.ts")).unwrap();
897 index.add_path(Path::new("new.ts")).unwrap();
898 index.write().unwrap();
899
900 let bridge = GitBridge::open(temp.path()).unwrap();
901 let files = bridge.get_changed_files(&DiffScope::Staged, &[]).unwrap();
902 assert_eq!(files.len(), 1);
903 assert_eq!(files[0].status, FileStatus::Renamed);
904
905 let registry = create_default_registry();
906 let result = compute_semantic_diff(&files, ®istry, None, None);
907
908 assert_eq!(result.added_count, 0);
909 assert_eq!(result.deleted_count, 0);
910 assert_eq!(result.moved_count, 1);
911 assert_eq!(result.changes.len(), 1);
912 assert_eq!(result.changes[0].change_type, ChangeType::Moved);
913 assert_eq!(result.changes[0].entity_name, "foo");
914 assert_eq!(result.changes[0].old_file_path.as_deref(), Some("old.ts"));
915 }
916
917 #[test]
918 fn crlf_only_difference_in_working_file_is_invisible() {
919 let temp = TempDir::new().unwrap();
920 let repo = Repository::init(temp.path()).unwrap();
921
922 commit_file(&repo, "sample.rs", "fn a() {}\n", "init");
923 fs::write(temp.path().join("sample.rs"), "fn a() {}\r\n").unwrap();
924
925 let bridge = GitBridge::open(temp.path()).unwrap();
926 let files = bridge.get_changed_files(&DiffScope::Working, &[]).unwrap();
927
928 assert_eq!(files.len(), 1, "expected git to detect the CRLF change as modified");
929
930 let before = files[0].before_content.as_deref().unwrap();
931 let after = files[0].after_content.as_deref().unwrap();
932
933 assert_eq!(before, after, "CRLF-only difference should be invisible after normalization");
934 }
935
936 #[test]
937 fn crlf_stored_in_blob_is_normalized_on_read() {
938 let temp = TempDir::new().unwrap();
939 let repo = Repository::init(temp.path()).unwrap();
940
941 repo.config().unwrap().set_str("core.autocrlf", "false").unwrap();
942 commit_file(&repo, "sample.rs", "fn a() {}\r\n", "init");
943 fs::write(temp.path().join("sample.rs"), "fn a() {}\r\nfn b() {}\r\n").unwrap();
944
945 let bridge = GitBridge::open(temp.path()).unwrap();
946 let files = bridge.get_changed_files(&DiffScope::Working, &[]).unwrap();
947
948 assert_eq!(files.len(), 1, "expected git to detect the modification");
949
950 let before = files[0].before_content.as_deref().unwrap();
951 assert!(!before.contains('\r'), "before_content read from CRLF blob should be normalized to LF");
952 }
953}