Skip to main content

sem_core/git/
bridge.rs

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    /// Combined detect scope + get files in one call (fast path).
71    /// Matches `git diff` behavior: shows working tree changes by default.
72    /// Use `--staged` for staged changes only.
73    pub fn detect_and_get_files(&self, pathspecs: &[String]) -> Result<(DiffScope, Vec<FileChange>), GitError> {
74        // Show working tree changes (unstaged), matching git diff behavior
75        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        // Clean worktree = no changes
82        Ok((DiffScope::Working, Vec::new()))
83    }
84
85    /// Get changed files for a specific scope
86    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        // Filter .sem/ files
98        files.retain(|f| !f.file_path.starts_with(".sem/"));
99
100        self.populate_contents(&mut files, scope)?;
101        Ok(files)
102    }
103
104    /// Resolve the merge base between two refs
105    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    /// Check if a string resolves to a valid git revision
113    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, // No commits yet
132        };
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                // Resolve HEAD tree once for all before_content reads
284                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                // Resolve both trees once instead of per-file
321                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    /// Read file content at a specific git ref (commit SHA, branch, tag, etc.)
417    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    /// Get commits that modified a specific file, walking history from HEAD.
423    /// Returns commits in reverse chronological order (newest first).
424    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            // Check if this file exists in this commit's tree
438            let file_in_commit = tree.get_path(path).ok().map(|e| e.id());
439
440            // Compare with parent to see if the file changed
441            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 // No parent = initial commit, file was added
448            };
449
450            // Include if file changed between parent and this commit
451            let changed = match (file_in_commit, file_in_parent) {
452                (Some(cur), Some(prev)) => cur != prev,  // content changed
453                (Some(_), None) => true,                   // file added
454                (None, Some(_)) => true,                   // file deleted
455                (None, None) => false,                     // file not present in either
456            };
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    /// Get all file paths changed in a single commit (vs its parent).
478    /// Returns file paths from the new side of each delta.
479    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            // Also include old path for deletions/renames
495            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        // libgit2 stores this as a process-global option; callers hold owner_validation_lock.
608        unsafe { git2::opts::set_verify_owner_validation(false)? };
609        Ok(Self)
610    }
611}
612
613impl Drop for OwnerValidationDisabled {
614    fn drop(&mut self) {
615        // Restore the default before the owner-validation lock is released.
616        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            &not_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, &registry, 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}