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, 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_commit_changed_files(&self, sha: &str) -> Result<Vec<String>, GitError> {
480 let obj = self.repo.revparse_single(sha)?;
481 let commit = obj.peel_to_commit()?;
482 let tree = commit.tree()?;
483 let parent_tree = if commit.parent_count() > 0 {
484 Some(commit.parent(0)?.tree()?)
485 } else {
486 None
487 };
488 let diff = self.repo.diff_tree_to_tree(parent_tree.as_ref(), Some(&tree), None)?;
489 let mut paths = Vec::new();
490 for delta in diff.deltas() {
491 if let Some(p) = delta.new_file().path().and_then(|p| p.to_str()) {
492 paths.push(p.to_string());
493 }
494 if let Some(p) = delta.old_file().path().and_then(|p| p.to_str()) {
496 if !paths.contains(&p.to_string()) {
497 paths.push(p.to_string());
498 }
499 }
500 }
501 Ok(paths)
502 }
503
504 pub fn get_log(&self, limit: usize) -> Result<Vec<CommitInfo>, GitError> {
505 let mut revwalk = self.repo.revwalk()?;
506 revwalk.push_head()?;
507
508 let mut commits = Vec::new();
509 for (i, oid_result) in revwalk.enumerate() {
510 if i >= limit {
511 break;
512 }
513 let oid = oid_result?;
514 let commit = self.repo.find_commit(oid)?;
515 let sha = oid.to_string();
516 commits.push(CommitInfo {
517 short_sha: sha[..7.min(sha.len())].to_string(),
518 sha,
519 author: commit.author().name().unwrap_or("unknown").to_string(),
520 date: commit.time().seconds().to_string(),
521 message: commit.message().unwrap_or("").to_string(),
522 });
523 }
524
525 Ok(commits)
526 }
527}
528
529fn map_git_error(error: git2::Error) -> GitError {
530 if error.code() == ErrorCode::NotFound {
531 GitError::NotARepo
532 } else {
533 GitError::Git2(error)
534 }
535}
536
537fn should_retry_with_command_line_safe_directory(error: &git2::Error, path: &Path) -> bool {
538 let safe_directories = command_line_safe_directories();
539 should_retry_with_safe_directory(error, path, &safe_directories)
540}
541
542fn should_retry_with_safe_directory(error: &git2::Error, path: &Path, safe_directories: &[String]) -> bool {
543 error.code() == ErrorCode::Owner
544 && nearest_git_root(path).is_some_and(|repo_root| {
545 safe_directories.iter().any(|safe_directory| {
546 safe_directory == "*"
547 || paths_match(&repo_root, Path::new(safe_directory))
548 })
549 })
550}
551
552fn command_line_safe_directories() -> Vec<String> {
553 let count = env::var("GIT_CONFIG_COUNT")
554 .ok()
555 .and_then(|value| value.parse::<usize>().ok())
556 .unwrap_or_default();
557
558 (0..count)
559 .filter_map(|index| {
560 let key = env::var(format!("GIT_CONFIG_KEY_{index}")).ok()?;
561 if key.eq_ignore_ascii_case("safe.directory") {
562 env::var(format!("GIT_CONFIG_VALUE_{index}")).ok()
563 } else {
564 None
565 }
566 })
567 .collect()
568}
569
570fn nearest_git_root(path: &Path) -> Option<PathBuf> {
571 let mut current = if path.is_file() {
572 path.parent()?
573 } else {
574 path
575 };
576
577 loop {
578 if current.join(".git").exists() {
579 return Some(fs::canonicalize(current).unwrap_or_else(|_| current.to_path_buf()));
580 }
581
582 current = current.parent()?;
583 }
584}
585
586fn paths_match(left: &Path, right: &Path) -> bool {
587 let left = fs::canonicalize(left).unwrap_or_else(|_| left.to_path_buf());
588 let right = fs::canonicalize(right).unwrap_or_else(|_| right.to_path_buf());
589
590 if cfg!(windows) {
591 left.to_string_lossy()
592 .eq_ignore_ascii_case(&right.to_string_lossy())
593 } else {
594 left == right
595 }
596}
597
598fn owner_validation_lock() -> &'static Mutex<()> {
599 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
600 LOCK.get_or_init(|| Mutex::new(()))
601}
602
603struct OwnerValidationDisabled;
604
605impl OwnerValidationDisabled {
606 fn new() -> Result<Self, GitError> {
607 unsafe { git2::opts::set_verify_owner_validation(false)? };
609 Ok(Self)
610 }
611}
612
613impl Drop for OwnerValidationDisabled {
614 fn drop(&mut self) {
615 unsafe {
617 let _ = git2::opts::set_verify_owner_validation(true);
618 }
619 }
620}
621
622#[cfg(test)]
623mod tests {
624 use super::*;
625 use crate::model::change::ChangeType;
626 use crate::parser::differ::compute_semantic_diff;
627 use crate::parser::plugins::create_default_registry;
628 use git2::{ErrorClass, Oid, Repository, Signature};
629 use tempfile::TempDir;
630
631 fn commit_file(repo: &Repository, file_path: &str, contents: &str, message: &str) -> Oid {
632 fs::write(repo.workdir().unwrap().join(file_path), contents).unwrap();
633
634 let mut index = repo.index().unwrap();
635 index.add_path(Path::new(file_path)).unwrap();
636 index.write().unwrap();
637
638 let tree_id = index.write_tree().unwrap();
639 let tree = repo.find_tree(tree_id).unwrap();
640 let sig = Signature::now("Test User", "test@example.com").unwrap();
641
642 match repo.head() {
643 Ok(head) => {
644 let parent = repo.find_commit(head.target().unwrap()).unwrap();
645 repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &[&parent])
646 .unwrap()
647 }
648 Err(_) => repo
649 .commit(Some("HEAD"), &sig, &sig, message, &tree, &[])
650 .unwrap(),
651 }
652 }
653
654 #[test]
655 fn clean_worktree_does_not_fall_back_to_head_commit() {
656 let temp = TempDir::new().unwrap();
657 let repo = Repository::init(temp.path()).unwrap();
658
659 commit_file(&repo, "sample.ts", "export function a() {\n return 1;\n}\n", "init");
660 commit_file(
661 &repo,
662 "sample.ts",
663 "export function a() {\n return 2;\n}\n",
664 "change a",
665 );
666
667 let bridge = GitBridge::open(temp.path()).unwrap();
668 let (scope, files) = bridge.detect_and_get_files(&[]).unwrap();
669
670 assert!(matches!(scope, DiffScope::Working));
671 assert!(files.is_empty());
672 }
673
674 #[test]
675 fn owner_error_retries_for_command_line_safe_directory() {
676 let temp = TempDir::new().unwrap();
677 Repository::init(temp.path()).unwrap();
678
679 let owner_error = git2::Error::new(
680 ErrorCode::Owner,
681 ErrorClass::Config,
682 "owner mismatch",
683 );
684 let safe_directories = [temp.path().to_string_lossy().to_string()];
685
686 assert!(should_retry_with_safe_directory(
687 &owner_error,
688 temp.path(),
689 &safe_directories,
690 ));
691
692 let other_directories = [temp.path().join("other").to_string_lossy().to_string()];
693 assert!(!should_retry_with_safe_directory(
694 &owner_error,
695 temp.path(),
696 &other_directories,
697 ));
698
699 let not_found_error = git2::Error::new(
700 ErrorCode::NotFound,
701 ErrorClass::Repository,
702 "not found",
703 );
704 assert!(!should_retry_with_safe_directory(
705 ¬_found_error,
706 temp.path(),
707 &["*".to_string()],
708 ));
709 }
710
711 #[test]
712 fn explicit_commit_scope_still_reads_head_commit_diff() {
713 let temp = TempDir::new().unwrap();
714 let repo = Repository::init(temp.path()).unwrap();
715
716 commit_file(&repo, "sample.ts", "export function a() {\n return 1;\n}\n", "init");
717 let head_oid = commit_file(
718 &repo,
719 "sample.ts",
720 "export function a() {\n return 2;\n}\n",
721 "change a",
722 );
723
724 let bridge = GitBridge::open(temp.path()).unwrap();
725 let files = bridge
726 .get_changed_files(&DiffScope::Commit {
727 sha: head_oid.to_string(),
728 }, &[])
729 .unwrap();
730
731 assert_eq!(files.len(), 1);
732 assert_eq!(files[0].file_path, "sample.ts");
733 assert_eq!(files[0].status, FileStatus::Modified);
734 }
735
736 #[test]
737 fn staged_file_rename_is_reported_as_single_rename_with_old_contents() {
738 let temp = TempDir::new().unwrap();
739 let repo = Repository::init(temp.path()).unwrap();
740
741 let contents = "export function foo() {\n return 1;\n}\n";
742 commit_file(&repo, "old.ts", contents, "init");
743
744 fs::rename(temp.path().join("old.ts"), temp.path().join("new.ts")).unwrap();
745 let mut index = repo.index().unwrap();
746 index.remove_path(Path::new("old.ts")).unwrap();
747 index.add_path(Path::new("new.ts")).unwrap();
748 index.write().unwrap();
749
750 let bridge = GitBridge::open(temp.path()).unwrap();
751 let files = bridge.get_changed_files(&DiffScope::Staged, &[]).unwrap();
752
753 assert_eq!(files.len(), 1);
754 assert_eq!(files[0].status, FileStatus::Renamed);
755 assert_eq!(files[0].file_path, "new.ts");
756 assert_eq!(files[0].old_file_path.as_deref(), Some("old.ts"));
757 assert_eq!(files[0].before_content.as_deref(), Some(contents));
758 assert_eq!(files[0].after_content.as_deref(), Some(contents));
759 }
760
761 #[test]
762 fn staged_file_rename_with_edit_reports_single_moved_entity() {
763 let temp = TempDir::new().unwrap();
764 let repo = Repository::init(temp.path()).unwrap();
765
766 let before = "\
767// shared header 01
768// shared header 02
769// shared header 03
770// shared header 04
771// shared header 05
772// shared header 06
773// shared header 07
774// shared header 08
775// shared header 09
776// shared header 10
777export function foo() {
778 return alpha + beta + gamma;
779}
780";
781 let after = before.replace(
782 "return alpha + beta + gamma;",
783 "return one + two + three;",
784 );
785
786 commit_file(&repo, "old.ts", before, "init");
787 fs::rename(temp.path().join("old.ts"), temp.path().join("new.ts")).unwrap();
788 fs::write(temp.path().join("new.ts"), &after).unwrap();
789
790 let mut index = repo.index().unwrap();
791 index.remove_path(Path::new("old.ts")).unwrap();
792 index.add_path(Path::new("new.ts")).unwrap();
793 index.write().unwrap();
794
795 let bridge = GitBridge::open(temp.path()).unwrap();
796 let files = bridge.get_changed_files(&DiffScope::Staged, &[]).unwrap();
797 assert_eq!(files.len(), 1);
798 assert_eq!(files[0].status, FileStatus::Renamed);
799
800 let registry = create_default_registry();
801 let result = compute_semantic_diff(&files, ®istry, None, None);
802
803 assert_eq!(result.added_count, 0);
804 assert_eq!(result.deleted_count, 0);
805 assert_eq!(result.moved_count, 1);
806 assert_eq!(result.changes.len(), 1);
807 assert_eq!(result.changes[0].change_type, ChangeType::Moved);
808 assert_eq!(result.changes[0].entity_name, "foo");
809 assert_eq!(result.changes[0].old_file_path.as_deref(), Some("old.ts"));
810 }
811
812 #[test]
813 fn crlf_only_difference_in_working_file_is_invisible() {
814 let temp = TempDir::new().unwrap();
815 let repo = Repository::init(temp.path()).unwrap();
816
817 commit_file(&repo, "sample.rs", "fn a() {}\n", "init");
818 fs::write(temp.path().join("sample.rs"), "fn a() {}\r\n").unwrap();
819
820 let bridge = GitBridge::open(temp.path()).unwrap();
821 let files = bridge.get_changed_files(&DiffScope::Working, &[]).unwrap();
822
823 assert_eq!(files.len(), 1, "expected git to detect the CRLF change as modified");
824
825 let before = files[0].before_content.as_deref().unwrap();
826 let after = files[0].after_content.as_deref().unwrap();
827
828 assert_eq!(before, after, "CRLF-only difference should be invisible after normalization");
829 }
830
831 #[test]
832 fn crlf_stored_in_blob_is_normalized_on_read() {
833 let temp = TempDir::new().unwrap();
834 let repo = Repository::init(temp.path()).unwrap();
835
836 repo.config().unwrap().set_str("core.autocrlf", "false").unwrap();
837 commit_file(&repo, "sample.rs", "fn a() {}\r\n", "init");
838 fs::write(temp.path().join("sample.rs"), "fn a() {}\r\nfn b() {}\r\n").unwrap();
839
840 let bridge = GitBridge::open(temp.path()).unwrap();
841 let files = bridge.get_changed_files(&DiffScope::Working, &[]).unwrap();
842
843 assert_eq!(files.len(), 1, "expected git to detect the modification");
844
845 let before = files[0].before_content.as_deref().unwrap();
846 assert!(!before.contains('\r'), "before_content read from CRLF blob should be normalized to LF");
847 }
848}