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, 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    /// 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 commits that modified a specific file, following renames across history.
478    /// Like `git log --follow`: when the tracked path disappears between commits,
479    /// compute a diff with rename detection to find the old filename and continue.
480    /// Returns commits in reverse chronological order (newest first).
481    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 the file is not in this commit's tree, check if it was renamed.
536            // Compute diff between parent and this commit with rename detection.
537            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                            // The tracked file was renamed FROM old_path
557                            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                    // File truly deleted, stop tracking
574                    break;
575                }
576            }
577        }
578
579        Ok(results)
580    }
581
582    /// Get all file paths changed in a single commit (vs its parent).
583    /// Returns file paths from the new side of each delta.
584    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            // Also include old path for deletions/renames
600            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        // libgit2 stores this as a process-global option; callers hold owner_validation_lock.
713        unsafe { git2::opts::set_verify_owner_validation(false)? };
714        Ok(Self)
715    }
716}
717
718impl Drop for OwnerValidationDisabled {
719    fn drop(&mut self) {
720        // Restore the default before the owner-validation lock is released.
721        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            &not_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, &registry, 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}