Skip to main content

gcop_rs/git/
repository.rs

1use chrono::{DateTime, Local, TimeZone};
2use git2::{DiffOptions, Repository, Sort};
3use std::io::Write;
4
5use crate::config::FileConfig;
6use crate::error::{GcopError, Result};
7use crate::git::{CommitInfo, DiffStats, GitOperations};
8
9/// Default maximum file size (10MB)
10const DEFAULT_MAX_FILE_SIZE: u64 = 10 * 1024 * 1024;
11
12/// `git2`-based repository implementation used by gcop-rs.
13pub struct GitRepository {
14    pub(crate) repo: Repository,
15    max_file_size: u64,
16}
17
18impl GitRepository {
19    /// Open the git repository of the current directory
20    ///
21    /// # Arguments
22    /// * `file_config` - optional file configuration, None uses default value
23    pub fn open(file_config: Option<&FileConfig>) -> Result<Self> {
24        let repo = Repository::discover(".")?;
25        let max_file_size = file_config
26            .map(|c| c.max_size)
27            .unwrap_or(DEFAULT_MAX_FILE_SIZE);
28        Ok(Self {
29            repo,
30            max_file_size,
31        })
32    }
33
34    /// Convert git2::Diff to string
35    fn diff_to_string(&self, diff: &git2::Diff) -> Result<String> {
36        let mut output = Vec::new();
37        diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
38            // Get the type tag (origin) of the row
39            let origin = line.origin();
40
41            // If origin is a printable character (+, -, space, etc.), write it first
42            match origin {
43                '+' | '-' | ' ' => {
44                    let _ = output.write_all(&[origin as u8]);
45                }
46                _ => {}
47            }
48
49            // Then write the row content
50            let _ = output.write_all(line.content());
51            true
52        })?;
53        Ok(String::from_utf8_lossy(&output).to_string())
54    }
55}
56
57impl GitOperations for GitRepository {
58    fn get_staged_diff(&self) -> Result<String> {
59        // Read index.
60        let index = self.repo.index()?;
61
62        // For an empty repository, compare empty tree (None) against the index.
63        if self.is_empty()? {
64            let mut opts = DiffOptions::new();
65            let diff = self
66                .repo
67                .diff_tree_to_index(None, Some(&index), Some(&mut opts))?;
68            return self.diff_to_string(&diff);
69        }
70
71        // Read HEAD tree.
72        let head = self.repo.head()?;
73        let head_tree = head.peel_to_tree()?;
74
75        // Create diff (HEAD tree vs index)
76        let mut opts = DiffOptions::new();
77        let diff = self
78            .repo
79            .diff_tree_to_index(Some(&head_tree), Some(&index), Some(&mut opts))?;
80
81        self.diff_to_string(&diff)
82    }
83
84    fn get_uncommitted_diff(&self) -> Result<String> {
85        // Read index.
86        let index = self.repo.index()?;
87
88        // Create diff (index vs workdir)
89        let mut opts = DiffOptions::new();
90        let diff = self
91            .repo
92            .diff_index_to_workdir(Some(&index), Some(&mut opts))?;
93
94        self.diff_to_string(&diff)
95    }
96
97    fn get_commit_diff(&self, commit_hash: &str) -> Result<String> {
98        // Find commit — accept both hex SHA and refs (e.g. "HEAD")
99        let commit = self
100            .repo
101            .revparse_single(commit_hash)
102            .and_then(|obj| obj.peel_to_commit())
103            .map_err(|_| {
104                GcopError::InvalidInput(
105                    rust_i18n::t!("git.invalid_commit_hash", hash = commit_hash).to_string(),
106                )
107            })?;
108
109        let commit_tree = commit.tree()?;
110
111        // Get the parent commit (if any)
112        let parent_tree = if commit.parent_count() > 0 {
113            Some(commit.parent(0)?.tree()?)
114        } else {
115            None
116        };
117
118        // Build diff.
119        let mut opts = DiffOptions::new();
120        let diff = self.repo.diff_tree_to_tree(
121            parent_tree.as_ref(),
122            Some(&commit_tree),
123            Some(&mut opts),
124        )?;
125
126        self.diff_to_string(&diff)
127    }
128
129    fn get_range_diff(&self, range: &str) -> Result<String> {
130        // Parse range expression (for example "main..feature").
131        let parts: Vec<&str> = range.split("..").collect();
132        if parts.len() != 2 {
133            return Err(GcopError::InvalidInput(
134                rust_i18n::t!("git.invalid_range_format", range = range).to_string(),
135            ));
136        }
137
138        let base_commit = self.repo.revparse_single(parts[0])?.peel_to_commit()?;
139        let head_commit = self.repo.revparse_single(parts[1])?.peel_to_commit()?;
140
141        let base_tree = base_commit.tree()?;
142        let head_tree = head_commit.tree()?;
143
144        let mut opts = DiffOptions::new();
145        let diff =
146            self.repo
147                .diff_tree_to_tree(Some(&base_tree), Some(&head_tree), Some(&mut opts))?;
148
149        self.diff_to_string(&diff)
150    }
151
152    fn get_file_content(&self, path: &str) -> Result<String> {
153        let metadata = std::fs::metadata(path)?;
154        if metadata.len() > self.max_file_size {
155            return Err(GcopError::InvalidInput(
156                rust_i18n::t!(
157                    "git.file_too_large",
158                    size = metadata.len(),
159                    max = self.max_file_size
160                )
161                .to_string(),
162            ));
163        }
164
165        let content = std::fs::read_to_string(path)?;
166        Ok(content)
167    }
168
169    fn commit(&self, message: &str) -> Result<()> {
170        crate::git::commit::commit_changes(message)
171    }
172
173    fn commit_amend(&self, message: &str) -> Result<()> {
174        crate::git::commit::commit_amend_changes(message)
175    }
176
177    fn get_current_branch(&self) -> Result<Option<String>> {
178        // Unborn branch has no real branch information
179        if self.is_empty()? {
180            return Ok(None);
181        }
182
183        let head = self.repo.head()?;
184
185        if head.is_branch() {
186            // Read branch name.
187            let branch_name = head.shorthand().map(|s| s.to_string());
188            Ok(branch_name)
189        } else {
190            // HEAD is in detached state
191            Ok(None)
192        }
193    }
194
195    fn get_diff_stats(&self, diff: &str) -> Result<DiffStats> {
196        crate::git::diff::parse_diff_stats(diff)
197    }
198
199    fn has_staged_changes(&self) -> Result<bool> {
200        let diff = self.get_staged_diff()?;
201        Ok(!diff.trim().is_empty())
202    }
203
204    fn get_commit_history(&self) -> Result<Vec<CommitInfo>> {
205        // Empty repository has no history.
206        if self.is_empty()? {
207            return Ok(Vec::new());
208        }
209
210        let mut revwalk = self.repo.revwalk()?;
211        revwalk.push_head()?;
212        revwalk.set_sorting(Sort::TIME)?;
213
214        let mut commits = Vec::new();
215
216        for oid in revwalk {
217            let oid = oid?;
218            let commit = self.repo.find_commit(oid)?;
219
220            let hash = oid.to_string();
221            let parent_count = commit.parent_count();
222            let author = commit.author();
223            let author_name = author.name().unwrap_or("Unknown").to_string();
224            let author_email = author.email().unwrap_or("").to_string();
225
226            // Convert git2::Time to chrono::DateTime<Local>
227            let git_time = commit.time();
228            let timestamp: DateTime<Local> = Local
229                .timestamp_opt(git_time.seconds(), 0)
230                .single()
231                .unwrap_or_else(|| {
232                    tracing::warn!(
233                        "Invalid git timestamp {} for commit {}",
234                        git_time.seconds(),
235                        commit.id()
236                    );
237                    Local::now()
238                });
239
240            let message = commit
241                .message()
242                .unwrap_or("")
243                .lines()
244                .next()
245                .unwrap_or("")
246                .to_string();
247
248            commits.push(CommitInfo {
249                hash,
250                parent_count,
251                author_name,
252                author_email,
253                timestamp,
254                message,
255            });
256        }
257
258        Ok(commits)
259    }
260
261    fn get_commit_line_stats(&self, hash: &str) -> Result<(usize, usize)> {
262        let commit = self
263            .repo
264            .revparse_single(hash)
265            .and_then(|obj| obj.peel_to_commit())
266            .map_err(|_| {
267                GcopError::InvalidInput(
268                    rust_i18n::t!("git.invalid_commit_hash", hash = hash).to_string(),
269                )
270            })?;
271
272        let commit_tree = commit.tree()?;
273        let parent_tree = if commit.parent_count() > 0 {
274            Some(commit.parent(0)?.tree()?)
275        } else {
276            None
277        };
278
279        let mut opts = DiffOptions::new();
280        let diff = self.repo.diff_tree_to_tree(
281            parent_tree.as_ref(),
282            Some(&commit_tree),
283            Some(&mut opts),
284        )?;
285
286        let stats = diff.stats()?;
287        Ok((stats.insertions(), stats.deletions()))
288    }
289
290    fn is_empty(&self) -> Result<bool> {
291        // Detect unborn branch: if `head()` fails with `UnbornBranch`, the repository is empty.
292        match self.repo.head() {
293            Ok(_) => Ok(false),
294            Err(e) if e.code() == git2::ErrorCode::UnbornBranch => Ok(true),
295            Err(e) => Err(e.into()),
296        }
297    }
298
299    fn get_staged_files(&self) -> Result<Vec<String>> {
300        let mut index = self.repo.index()?;
301        // Force-reload from disk so that changes made by external git processes
302        // (e.g. `git reset HEAD` in unstage_all) are visible.
303        index.read(true)?;
304        let tree = if self.is_empty()? {
305            None
306        } else {
307            let head = self.repo.head()?;
308            Some(head.peel_to_tree()?)
309        };
310        let mut opts = DiffOptions::new();
311        let diff = self
312            .repo
313            .diff_tree_to_index(tree.as_ref(), Some(&index), Some(&mut opts))?;
314
315        Ok(diff
316            .deltas()
317            .filter_map(|delta| delta.new_file().path())
318            .map(|p| p.to_string_lossy().into_owned())
319            .collect())
320    }
321
322    fn unstage_all(&self) -> Result<()> {
323        use std::process::Command;
324
325        let workdir = self.get_workdir()?;
326
327        if self.is_empty()? {
328            // Empty repo: no HEAD to reset to, use git rm --cached
329            let output = Command::new("git")
330                .current_dir(workdir)
331                .args(["rm", "--cached", "-r", "."])
332                .output()?;
333            if !output.status.success() {
334                let stderr = String::from_utf8_lossy(&output.stderr);
335                return Err(crate::error::GcopError::GitCommand(
336                    stderr.trim().to_string(),
337                ));
338            }
339        } else {
340            let output = Command::new("git")
341                .current_dir(workdir)
342                .args(["reset", "HEAD"])
343                .output()?;
344            if !output.status.success() {
345                let stderr = String::from_utf8_lossy(&output.stderr);
346                return Err(crate::error::GcopError::GitCommand(
347                    stderr.trim().to_string(),
348                ));
349            }
350        }
351        Ok(())
352    }
353
354    fn stage_files(&self, files: &[String]) -> Result<()> {
355        use std::process::Command;
356
357        if files.is_empty() {
358            return Ok(());
359        }
360
361        let workdir = self.get_workdir()?;
362
363        let output = Command::new("git")
364            .current_dir(workdir)
365            .env("GIT_LITERAL_PATHSPECS", "1")
366            .arg("add")
367            .args(files)
368            .output()?;
369
370        if !output.status.success() {
371            let stderr = String::from_utf8_lossy(&output.stderr);
372            return Err(crate::error::GcopError::GitCommand(
373                stderr.trim().to_string(),
374            ));
375        }
376        Ok(())
377    }
378
379    fn get_workdir(&self) -> Result<std::path::PathBuf> {
380        self.repo
381            .workdir()
382            .ok_or_else(|| crate::error::GcopError::GitCommand("bare repository".to_string()))
383            .map(|p| p.to_path_buf())
384    }
385}
386
387#[cfg(test)]
388mod tests {
389    use super::*;
390    use std::fs;
391    use std::path::Path;
392    use tempfile::TempDir;
393
394    /// Create a temporary git repository for testing
395    fn create_test_repo() -> (TempDir, GitRepository) {
396        let dir = TempDir::new().unwrap();
397        let repo = Repository::init(dir.path()).unwrap();
398
399        // Set user information
400        let mut config = repo.config().unwrap();
401        config.set_str("user.name", "Test User").unwrap();
402        config.set_str("user.email", "test@example.com").unwrap();
403
404        let git_repo = GitRepository {
405            repo,
406            max_file_size: DEFAULT_MAX_FILE_SIZE,
407        };
408
409        (dir, git_repo)
410    }
411
412    /// Create files in the repository
413    fn create_file(dir: &Path, name: &str, content: &str) {
414        let file_path = dir.join(name);
415        fs::write(&file_path, content).unwrap();
416    }
417
418    /// Temporary files
419    fn stage_file(repo: &Repository, name: &str) {
420        let mut index = repo.index().unwrap();
421        index.add_path(Path::new(name)).unwrap();
422        index.write().unwrap();
423    }
424
425    /// Create commit
426    fn create_commit(repo: &Repository, message: &str) {
427        let mut index = repo.index().unwrap();
428        let oid = index.write_tree().unwrap();
429        let tree = repo.find_tree(oid).unwrap();
430        let sig = repo.signature().unwrap();
431
432        let parent_commit = repo.head().ok().and_then(|h| h.peel_to_commit().ok());
433
434        if let Some(parent) = parent_commit {
435            repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &[&parent])
436                .unwrap();
437        } else {
438            repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &[])
439                .unwrap();
440        }
441    }
442
443    // === Test is_empty ===
444
445    #[test]
446    fn test_is_empty_true_for_new_repo() {
447        let (_dir, git_repo) = create_test_repo();
448        assert!(git_repo.is_empty().unwrap());
449    }
450
451    #[test]
452    fn test_is_empty_false_after_commit() {
453        let (dir, git_repo) = create_test_repo();
454        create_file(dir.path(), "test.txt", "hello");
455        stage_file(&git_repo.repo, "test.txt");
456        create_commit(&git_repo.repo, "Initial commit");
457
458        assert!(!git_repo.is_empty().unwrap());
459    }
460
461    // === Test get_current_branch ===
462
463    #[test]
464    fn test_get_current_branch_empty_repo() {
465        let (_dir, git_repo) = create_test_repo();
466        assert_eq!(git_repo.get_current_branch().unwrap(), None);
467    }
468
469    #[test]
470    fn test_get_current_branch_normal() {
471        let (dir, git_repo) = create_test_repo();
472        create_file(dir.path(), "test.txt", "hello");
473        stage_file(&git_repo.repo, "test.txt");
474        create_commit(&git_repo.repo, "Initial commit");
475
476        let branch = git_repo.get_current_branch().unwrap();
477        assert!(branch.is_some());
478        // The default branch is master or main
479        let branch_name = branch.unwrap();
480        assert!(branch_name == "master" || branch_name == "main");
481    }
482
483    #[test]
484    fn test_get_current_branch_detached_head() {
485        let (dir, git_repo) = create_test_repo();
486        create_file(dir.path(), "test.txt", "hello");
487        stage_file(&git_repo.repo, "test.txt");
488        create_commit(&git_repo.repo, "Initial commit");
489
490        // Get commit hash and checkout to detached HEAD
491        let head = git_repo.repo.head().unwrap();
492        let commit = head.peel_to_commit().unwrap();
493        git_repo.repo.set_head_detached(commit.id()).unwrap();
494
495        assert_eq!(git_repo.get_current_branch().unwrap(), None);
496    }
497
498    // === Test has_staged_changes ===
499
500    #[test]
501    fn test_has_staged_changes_false_empty_repo() {
502        let (_dir, git_repo) = create_test_repo();
503        assert!(!git_repo.has_staged_changes().unwrap());
504    }
505
506    #[test]
507    fn test_has_staged_changes_true() {
508        let (dir, git_repo) = create_test_repo();
509        create_file(dir.path(), "test.txt", "hello");
510        stage_file(&git_repo.repo, "test.txt");
511
512        assert!(git_repo.has_staged_changes().unwrap());
513    }
514
515    #[test]
516    fn test_has_staged_changes_false_after_commit() {
517        let (dir, git_repo) = create_test_repo();
518        create_file(dir.path(), "test.txt", "hello");
519        stage_file(&git_repo.repo, "test.txt");
520        create_commit(&git_repo.repo, "Initial commit");
521
522        assert!(!git_repo.has_staged_changes().unwrap());
523    }
524
525    // === Test get_staged_diff ===
526
527    #[test]
528    fn test_get_staged_diff_empty_repo() {
529        let (dir, git_repo) = create_test_repo();
530        create_file(dir.path(), "test.txt", "hello world");
531        stage_file(&git_repo.repo, "test.txt");
532
533        let diff = git_repo.get_staged_diff().unwrap();
534        assert!(diff.contains("hello world"));
535        assert!(diff.contains("+hello world"));
536    }
537
538    #[test]
539    fn test_get_staged_diff_normal() {
540        let (dir, git_repo) = create_test_repo();
541        create_file(dir.path(), "test.txt", "hello");
542        stage_file(&git_repo.repo, "test.txt");
543        create_commit(&git_repo.repo, "Initial commit");
544
545        // Modify files and save temporarily
546        create_file(dir.path(), "test.txt", "hello world");
547        stage_file(&git_repo.repo, "test.txt");
548
549        let diff = git_repo.get_staged_diff().unwrap();
550        assert!(diff.contains("-hello"));
551        assert!(diff.contains("+hello world"));
552    }
553
554    // === Test get_uncommitted_diff ===
555
556    #[test]
557    fn test_get_uncommitted_diff() {
558        let (dir, git_repo) = create_test_repo();
559        create_file(dir.path(), "test.txt", "hello");
560        stage_file(&git_repo.repo, "test.txt");
561        create_commit(&git_repo.repo, "Initial commit");
562
563        // Modify files but don't stage them
564        create_file(dir.path(), "test.txt", "hello world");
565
566        let diff = git_repo.get_uncommitted_diff().unwrap();
567        assert!(diff.contains("-hello"));
568        assert!(diff.contains("+hello world"));
569    }
570
571    // === Test get_commit_diff ===
572
573    #[test]
574    fn test_get_commit_diff_initial_commit() {
575        let (dir, git_repo) = create_test_repo();
576        create_file(dir.path(), "test.txt", "hello");
577        stage_file(&git_repo.repo, "test.txt");
578        create_commit(&git_repo.repo, "Initial commit");
579
580        let head = git_repo.repo.head().unwrap();
581        let commit = head.peel_to_commit().unwrap();
582        let hash = commit.id().to_string();
583
584        let diff = git_repo.get_commit_diff(&hash).unwrap();
585        assert!(diff.contains("+hello"));
586    }
587
588    #[test]
589    fn test_get_commit_diff_normal() {
590        let (dir, git_repo) = create_test_repo();
591        create_file(dir.path(), "test.txt", "hello");
592        stage_file(&git_repo.repo, "test.txt");
593        create_commit(&git_repo.repo, "Initial commit");
594
595        // Second submission
596        create_file(dir.path(), "test.txt", "hello world");
597        stage_file(&git_repo.repo, "test.txt");
598        create_commit(&git_repo.repo, "Second commit");
599
600        let head = git_repo.repo.head().unwrap();
601        let commit = head.peel_to_commit().unwrap();
602        let hash = commit.id().to_string();
603
604        let diff = git_repo.get_commit_diff(&hash).unwrap();
605        assert!(diff.contains("-hello"));
606        assert!(diff.contains("+hello world"));
607    }
608
609    #[test]
610    fn test_get_commit_diff_invalid_hash() {
611        let (_dir, git_repo) = create_test_repo();
612        let result = git_repo.get_commit_diff("invalid_hash");
613        assert!(result.is_err());
614    }
615
616    // === Test get_range_diff ===
617
618    #[test]
619    fn test_get_range_diff() {
620        let (dir, git_repo) = create_test_repo();
621        create_file(dir.path(), "test.txt", "version1");
622        stage_file(&git_repo.repo, "test.txt");
623        create_commit(&git_repo.repo, "First commit");
624
625        let first_commit = git_repo.repo.head().unwrap().peel_to_commit().unwrap();
626
627        create_file(dir.path(), "test.txt", "version2");
628        stage_file(&git_repo.repo, "test.txt");
629        create_commit(&git_repo.repo, "Second commit");
630
631        let second_commit = git_repo.repo.head().unwrap().peel_to_commit().unwrap();
632
633        let range = format!("{}..{}", first_commit.id(), second_commit.id());
634        let diff = git_repo.get_range_diff(&range).unwrap();
635
636        assert!(diff.contains("-version1"));
637        assert!(diff.contains("+version2"));
638    }
639
640    #[test]
641    fn test_get_range_diff_invalid_format() {
642        let (dir, git_repo) = create_test_repo();
643        create_file(dir.path(), "test.txt", "hello");
644        stage_file(&git_repo.repo, "test.txt");
645        create_commit(&git_repo.repo, "Initial commit");
646
647        let result = git_repo.get_range_diff("invalid_range");
648        assert!(result.is_err());
649    }
650
651    // === Test get_file_content ===
652
653    #[test]
654    fn test_get_file_content() {
655        let (dir, git_repo) = create_test_repo();
656        let file_path = dir.path().join("test.txt");
657        fs::write(&file_path, "hello world").unwrap();
658
659        let content = git_repo
660            .get_file_content(file_path.to_str().unwrap())
661            .unwrap();
662        assert_eq!(content, "hello world");
663    }
664
665    #[test]
666    fn test_get_file_content_too_large() {
667        let (dir, git_repo) = create_test_repo();
668        let file_path = dir.path().join("large.txt");
669
670        // Create files larger than max_file_size
671        let large_content = "x".repeat((DEFAULT_MAX_FILE_SIZE + 1) as usize);
672        fs::write(&file_path, large_content).unwrap();
673
674        let result = git_repo.get_file_content(file_path.to_str().unwrap());
675        assert!(result.is_err());
676    }
677
678    // === Test get_commit_history ===
679
680    #[test]
681    fn test_get_commit_history_empty_repo() {
682        let (_dir, git_repo) = create_test_repo();
683        let commits = git_repo.get_commit_history().unwrap();
684        assert!(commits.is_empty());
685    }
686
687    #[test]
688    fn test_get_commit_history() {
689        let (dir, git_repo) = create_test_repo();
690
691        create_file(dir.path(), "test.txt", "v1");
692        stage_file(&git_repo.repo, "test.txt");
693        create_commit(&git_repo.repo, "First commit");
694
695        create_file(dir.path(), "test.txt", "v2");
696        stage_file(&git_repo.repo, "test.txt");
697        create_commit(&git_repo.repo, "Second commit");
698
699        let commits = git_repo.get_commit_history().unwrap();
700        assert_eq!(commits.len(), 2);
701        assert_eq!(commits[0].message, "Second commit");
702        assert_eq!(commits[1].message, "First commit");
703        assert_eq!(commits[0].author_name, "Test User");
704        assert_eq!(commits[0].author_email, "test@example.com");
705    }
706
707    // === Test get_diff_stats ===
708
709    #[test]
710    fn test_get_diff_stats() {
711        let (_dir, git_repo) = create_test_repo();
712        let diff = r#"
713diff --git a/test.txt b/test.txt
714index 1234567..abcdefg 100644
715--- a/test.txt
716+++ b/test.txt
717@@ -1,1 +1,2 @@
718 hello
719+world
720"#;
721        let stats = git_repo.get_diff_stats(diff).unwrap();
722        assert_eq!(stats.files_changed.len(), 1);
723        assert_eq!(stats.insertions, 1);
724        assert_eq!(stats.deletions, 0);
725    }
726
727    // === Test stage_files ===
728
729    #[test]
730    fn test_stage_files_literal_glob_path() {
731        // Verify that stage_files treats paths with bracket characters literally.
732        // Without GIT_LITERAL_PATHSPECS=1, git would interpret `[locale]` as a
733        // character-class glob and might stage unintended files.
734        let (dir, git_repo) = create_test_repo();
735
736        // Create initial commit so the repo is non-empty.
737        create_file(dir.path(), "init.txt", "init");
738        stage_file(&git_repo.repo, "init.txt");
739        create_commit(&git_repo.repo, "initial");
740
741        // Create a directory named literally `[locale]` and a sibling `l/`.
742        let bracket_dir = dir.path().join("[locale]");
743        let sibling_dir = dir.path().join("l");
744        fs::create_dir_all(&bracket_dir).unwrap();
745        fs::create_dir_all(&sibling_dir).unwrap();
746
747        fs::write(bracket_dir.join("page.tsx"), "bracket content").unwrap();
748        fs::write(sibling_dir.join("page.tsx"), "sibling content").unwrap();
749
750        // Stage only the bracket-path file via git2 directly.
751        let mut index = git_repo.repo.index().unwrap();
752        index
753            .add_path(std::path::Path::new("[locale]/page.tsx"))
754            .unwrap();
755        index.write().unwrap();
756
757        // Now unstage everything and re-stage via stage_files.
758        git_repo.unstage_all().unwrap();
759
760        git_repo
761            .stage_files(&["[locale]/page.tsx".to_string()])
762            .unwrap();
763
764        // Only `[locale]/page.tsx` should be staged; `l/page.tsx` must NOT be.
765        let staged = git_repo.get_staged_files().unwrap();
766        assert!(
767            staged.contains(&"[locale]/page.tsx".to_string()),
768            "expected [locale]/page.tsx to be staged"
769        );
770        assert!(
771            !staged.contains(&"l/page.tsx".to_string()),
772            "l/page.tsx should NOT be staged (glob expansion guard)"
773        );
774    }
775
776    #[test]
777    fn test_stage_files_glob_path_missing_literal_errors_not_sibling() {
778        // When the literal `[locale]/page.tsx` does NOT exist but a sibling `l/page.tsx` does,
779        // stage_files must return an error rather than silently staging the sibling.
780        let (dir, git_repo) = create_test_repo();
781
782        create_file(dir.path(), "init.txt", "init");
783        stage_file(&git_repo.repo, "init.txt");
784        create_commit(&git_repo.repo, "initial");
785
786        // Only create `l/page.tsx` — NOT `[locale]/page.tsx`.
787        let sibling_dir = dir.path().join("l");
788        fs::create_dir_all(&sibling_dir).unwrap();
789        fs::write(sibling_dir.join("page.tsx"), "sibling").unwrap();
790
791        // Staging the non-existent literal path must fail.
792        let result = git_repo.stage_files(&["[locale]/page.tsx".to_string()]);
793        assert!(
794            result.is_err(),
795            "staging a non-existent literal path should fail"
796        );
797
798        // The sibling must not have been staged as a side-effect.
799        let staged = git_repo.get_staged_files().unwrap();
800        assert!(
801            !staged.contains(&"l/page.tsx".to_string()),
802            "l/page.tsx must NOT be staged as a glob side-effect"
803        );
804    }
805
806    #[test]
807    fn test_unstage_all_then_stage_subset_does_not_touch_unstaged_file() {
808        // Simulate split commit: after unstage_all + stage_files(subset),
809        // only the requested files should be staged.
810        // Files with purely unstaged modifications must never be staged.
811        let (dir, git_repo) = create_test_repo();
812
813        // Initial commit with three files.
814        create_file(dir.path(), "a.rs", "v1");
815        create_file(dir.path(), "b.rs", "v1");
816        create_file(dir.path(), "c.rs", "v1");
817        stage_file(&git_repo.repo, "a.rs");
818        stage_file(&git_repo.repo, "b.rs");
819        stage_file(&git_repo.repo, "c.rs");
820        create_commit(&git_repo.repo, "initial");
821
822        // Stage a.rs and b.rs; leave c.rs unstaged.
823        create_file(dir.path(), "a.rs", "v2");
824        create_file(dir.path(), "b.rs", "v2");
825        create_file(dir.path(), "c.rs", "v2");
826        stage_file(&git_repo.repo, "a.rs");
827        stage_file(&git_repo.repo, "b.rs");
828        // c.rs intentionally NOT staged.
829
830        let staged_before = git_repo.get_staged_files().unwrap();
831        assert!(staged_before.contains(&"a.rs".to_string()));
832        assert!(staged_before.contains(&"b.rs".to_string()));
833        assert!(!staged_before.contains(&"c.rs".to_string()));
834
835        // Split commit simulation: unstage all, then re-stage only a.rs.
836        git_repo.unstage_all().unwrap();
837        git_repo.stage_files(&["a.rs".to_string()]).unwrap();
838
839        let staged_after = git_repo.get_staged_files().unwrap();
840        assert!(
841            staged_after.contains(&"a.rs".to_string()),
842            "a.rs should be staged"
843        );
844        assert!(
845            !staged_after.contains(&"b.rs".to_string()),
846            "b.rs should NOT be staged (belongs to a different group)"
847        );
848        assert!(
849            !staged_after.contains(&"c.rs".to_string()),
850            "c.rs should NOT be staged (was never in the staging area)"
851        );
852    }
853}