Skip to main content

kaish_kernel/vfs/
git.rs

1//! Git-aware filesystem backend.
2//!
3//! GitVfs wraps LocalFs with git2 integration, providing:
4//! - Standard filesystem operations (delegated to LocalFs)
5//! - Git repository state tracking
6//! - Git operations (status, add, commit, etc.)
7//!
8//! # Example
9//!
10//! ```ignore
11//! let git_fs = GitVfs::open("/path/to/repo")?;
12//! git_fs.write(Path::new("src/main.rs"), b"new content").await?;
13//! let status = git_fs.status()?;
14//! ```
15
16use async_trait::async_trait;
17use git2::{
18    Commit, DiffOptions, Error as GitError, IndexAddOption, Oid, Repository, Signature, Status,
19    StatusOptions, StatusShow, WorktreeAddOptions, WorktreeLockStatus, WorktreePruneOptions,
20};
21use std::io;
22use std::path::{Path, PathBuf};
23use std::sync::Mutex;
24
25use super::local::LocalFs;
26use super::traits::{DirEntry, Filesystem, Metadata};
27
28/// Git-aware filesystem backend.
29///
30/// Wraps LocalFs for file operations while tracking git repository state.
31/// All file operations go through the local filesystem, with git operations
32/// available through dedicated methods.
33pub struct GitVfs {
34    /// Underlying local filesystem for file operations.
35    local: LocalFs,
36    /// Git repository handle.
37    repo: Mutex<Repository>,
38    /// Root path of the repository.
39    root: PathBuf,
40}
41
42impl GitVfs {
43    /// Open an existing git repository.
44    ///
45    /// The path should point to a directory containing a `.git` folder
46    /// (or the `.git` folder itself for bare repos).
47    pub fn open(path: impl Into<PathBuf>) -> Result<Self, GitError> {
48        let root: PathBuf = path.into();
49        let repo = Repository::open(&root)?;
50        let local = LocalFs::new(&root);
51
52        Ok(Self {
53            local,
54            repo: Mutex::new(repo),
55            root,
56        })
57    }
58
59    /// Clone a repository from a URL.
60    pub fn clone(url: &str, path: impl Into<PathBuf>) -> Result<Self, GitError> {
61        let root: PathBuf = path.into();
62        let repo = Repository::clone(url, &root)?;
63        let local = LocalFs::new(&root);
64
65        Ok(Self {
66            local,
67            repo: Mutex::new(repo),
68            root,
69        })
70    }
71
72    /// Initialize a new git repository.
73    pub fn init(path: impl Into<PathBuf>) -> Result<Self, GitError> {
74        let root: PathBuf = path.into();
75        let repo = Repository::init(&root)?;
76        let local = LocalFs::new(&root);
77
78        Ok(Self {
79            local,
80            repo: Mutex::new(repo),
81            root,
82        })
83    }
84
85    /// Get the root path of the repository.
86    pub fn root(&self) -> &Path {
87        &self.root
88    }
89
90    // ═══════════════════════════════════════════════════════════════════════════
91    // Git Status Operations
92    // ═══════════════════════════════════════════════════════════════════════════
93
94    /// Get the current status of the working tree.
95    pub fn status(&self) -> Result<Vec<FileStatus>, GitError> {
96        let repo = self.repo.lock().map_err(|_| {
97            GitError::from_str("failed to acquire repository lock")
98        })?;
99
100        let mut opts = StatusOptions::new();
101        opts.include_untracked(true)
102            .recurse_untracked_dirs(true)
103            .show(StatusShow::IndexAndWorkdir);
104
105        let statuses = repo.statuses(Some(&mut opts))?;
106        let mut result = Vec::with_capacity(statuses.len());
107
108        for entry in statuses.iter() {
109            if let Some(path) = entry.path() {
110                result.push(FileStatus {
111                    path: path.to_string(),
112                    status: entry.status(),
113                });
114            }
115        }
116
117        Ok(result)
118    }
119
120    /// Get a simplified status summary.
121    pub fn status_summary(&self) -> Result<StatusSummary, GitError> {
122        let statuses = self.status()?;
123        let mut summary = StatusSummary::default();
124
125        for file in &statuses {
126            if file.status.is_index_new() || file.status.is_index_modified() {
127                summary.staged += 1;
128            }
129            if file.status.is_wt_modified() || file.status.is_wt_new() {
130                summary.modified += 1;
131            }
132            if file.status.is_wt_new() && !file.status.is_index_new() {
133                summary.untracked += 1;
134            }
135        }
136
137        Ok(summary)
138    }
139
140    // ═══════════════════════════════════════════════════════════════════════════
141    // Git Index Operations
142    // ═══════════════════════════════════════════════════════════════════════════
143
144    /// Add files to the git index (staging area).
145    ///
146    /// Supports glob patterns (e.g., "*.rs", "src/**/*.rs").
147    pub fn add(&self, pathspec: &[&str]) -> Result<(), GitError> {
148        let repo = self.repo.lock().map_err(|_| {
149            GitError::from_str("failed to acquire repository lock")
150        })?;
151
152        let mut index = repo.index()?;
153
154        // Convert pathspecs to owned strings for callback
155        let specs: Vec<String> = pathspec.iter().map(|s| s.to_string()).collect();
156
157        index.add_all(
158            specs.iter().map(|s| s.as_str()),
159            IndexAddOption::DEFAULT,
160            None,
161        )?;
162
163        index.write()?;
164        Ok(())
165    }
166
167    /// Add a specific file to the index.
168    pub fn add_path(&self, path: &Path) -> Result<(), GitError> {
169        let repo = self.repo.lock().map_err(|_| {
170            GitError::from_str("failed to acquire repository lock")
171        })?;
172
173        let mut index = repo.index()?;
174        index.add_path(path)?;
175        index.write()?;
176        Ok(())
177    }
178
179    /// Remove a file from the index (unstage).
180    pub fn reset_path(&self, path: &Path) -> Result<(), GitError> {
181        let repo = self.repo.lock().map_err(|_| {
182            GitError::from_str("failed to acquire repository lock")
183        })?;
184
185        // Get HEAD commit
186        let head = repo.head()?;
187        let head_commit = head.peel_to_commit()?;
188        let tree = head_commit.tree()?;
189
190        // Reset the path in the index to match HEAD
191        repo.reset_default(Some(head_commit.as_object()), [path])?;
192
193        // If the file doesn't exist in HEAD, remove it from index
194        if tree.get_path(path).is_err() {
195            let mut index = repo.index()?;
196            let _ = index.remove_path(path);
197            index.write()?;
198        }
199
200        Ok(())
201    }
202
203    // ═══════════════════════════════════════════════════════════════════════════
204    // Git Commit Operations
205    // ═══════════════════════════════════════════════════════════════════════════
206
207    /// Create a commit with the staged changes.
208    pub fn commit(&self, message: &str, author: Option<&str>) -> Result<Oid, GitError> {
209        let repo = self.repo.lock().map_err(|_| {
210            GitError::from_str("failed to acquire repository lock")
211        })?;
212
213        let mut index = repo.index()?;
214        let tree_oid = index.write_tree()?;
215        let tree = repo.find_tree(tree_oid)?;
216
217        // Get or create signature
218        let sig = if let Some(author) = author {
219            // Parse "Name <email>" format
220            if let Some((name, email)) = parse_author(author) {
221                Signature::now(&name, &email)?
222            } else {
223                repo.signature()?
224            }
225        } else {
226            repo.signature()?
227        };
228
229        // Get parent commit (if any)
230        let parent = match repo.head() {
231            Ok(head) => Some(head.peel_to_commit()?),
232            Err(_) => None, // First commit
233        };
234
235        let parents: Vec<&Commit> = parent.iter().collect();
236
237        let oid = repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &parents)?;
238
239        Ok(oid)
240    }
241
242    // ═══════════════════════════════════════════════════════════════════════════
243    // Git Log Operations
244    // ═══════════════════════════════════════════════════════════════════════════
245
246    /// Get recent commit log entries.
247    pub fn log(&self, count: usize) -> Result<Vec<LogEntry>, GitError> {
248        let repo = self.repo.lock().map_err(|_| {
249            GitError::from_str("failed to acquire repository lock")
250        })?;
251
252        // Check if HEAD exists (no commits yet returns empty)
253        if repo.head().is_err() {
254            return Ok(Vec::new());
255        }
256
257        let mut revwalk = repo.revwalk()?;
258        revwalk.push_head()?;
259
260        let mut entries = Vec::with_capacity(count);
261
262        for (i, oid) in revwalk.enumerate() {
263            if i >= count {
264                break;
265            }
266
267            let oid = oid?;
268            let commit = repo.find_commit(oid)?;
269
270            entries.push(LogEntry {
271                oid: oid.to_string(),
272                short_id: oid.to_string()[..7].to_string(),
273                message: commit.message().unwrap_or("").to_string(),
274                author: commit.author().name().unwrap_or("").to_string(),
275                email: commit.author().email().unwrap_or("").to_string(),
276                time: commit.time().seconds(),
277            });
278        }
279
280        Ok(entries)
281    }
282
283    // ═══════════════════════════════════════════════════════════════════════════
284    // Git Diff Operations
285    // ═══════════════════════════════════════════════════════════════════════════
286
287    /// Get diff between working tree and HEAD.
288    pub fn diff(&self) -> Result<String, GitError> {
289        let repo = self.repo.lock().map_err(|_| {
290            GitError::from_str("failed to acquire repository lock")
291        })?;
292
293        let head = repo.head()?;
294        let head_tree = head.peel_to_tree()?;
295
296        let mut opts = DiffOptions::new();
297        opts.include_untracked(true);
298
299        let diff = repo.diff_tree_to_workdir_with_index(Some(&head_tree), Some(&mut opts))?;
300
301        let mut output = String::new();
302        diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
303            let origin = match line.origin() {
304                '+' => "+",
305                '-' => "-",
306                ' ' => " ",
307                'H' => "", // Header
308                'F' => "", // File header
309                'B' => "", // Binary
310                _ => "",
311            };
312            if !origin.is_empty() {
313                output.push_str(origin);
314            }
315            if let Ok(content) = std::str::from_utf8(line.content()) {
316                output.push_str(content);
317            }
318            true
319        })?;
320
321        Ok(output)
322    }
323
324    // ═══════════════════════════════════════════════════════════════════════════
325    // Git Branch Operations
326    // ═══════════════════════════════════════════════════════════════════════════
327
328    /// Get the current branch name.
329    pub fn current_branch(&self) -> Result<Option<String>, GitError> {
330        let repo = self.repo.lock().map_err(|_| {
331            GitError::from_str("failed to acquire repository lock")
332        })?;
333
334        match repo.head() {
335            Ok(head) => {
336                if head.is_branch() {
337                    Ok(head.shorthand().map(|s| s.to_string()))
338                } else {
339                    // Detached HEAD
340                    Ok(None)
341                }
342            }
343            Err(_) => Ok(None), // No commits yet
344        }
345    }
346
347    /// List all local branches.
348    pub fn branches(&self) -> Result<Vec<String>, GitError> {
349        let repo = self.repo.lock().map_err(|_| {
350            GitError::from_str("failed to acquire repository lock")
351        })?;
352
353        let branches = repo.branches(Some(git2::BranchType::Local))?;
354        let mut result = Vec::new();
355
356        for branch in branches {
357            let (branch, _) = branch?;
358            if let Some(name) = branch.name()? {
359                result.push(name.to_string());
360            }
361        }
362
363        Ok(result)
364    }
365
366    /// Create a new branch.
367    pub fn create_branch(&self, name: &str) -> Result<(), GitError> {
368        let repo = self.repo.lock().map_err(|_| {
369            GitError::from_str("failed to acquire repository lock")
370        })?;
371
372        let head = repo.head()?;
373        let commit = head.peel_to_commit()?;
374        repo.branch(name, &commit, false)?;
375        Ok(())
376    }
377
378    /// Checkout a branch or commit.
379    ///
380    /// If `force` is true, overwrites local changes. If false, fails if there
381    /// are uncommitted changes that would be overwritten.
382    pub fn checkout(&self, target: &str) -> Result<(), GitError> {
383        self.checkout_with_options(target, true)
384    }
385
386    /// Checkout with explicit force option.
387    ///
388    /// If `force` is false, checkout will fail if there are uncommitted changes
389    /// that would be overwritten by the checkout.
390    pub fn checkout_with_options(&self, target: &str, force: bool) -> Result<(), GitError> {
391        let repo = self.repo.lock().map_err(|_| {
392            GitError::from_str("failed to acquire repository lock")
393        })?;
394
395        let mut checkout_opts = git2::build::CheckoutBuilder::new();
396        if force {
397            checkout_opts.force();
398        } else {
399            checkout_opts.safe();
400        }
401
402        // Try as branch first
403        let reference = match repo.find_branch(target, git2::BranchType::Local) {
404            Ok(branch) => branch.into_reference(),
405            Err(_) => {
406                // Try as commit
407                let obj = repo.revparse_single(target)?;
408                let commit = obj.peel_to_commit()?;
409                repo.set_head_detached(commit.id())?;
410                repo.checkout_head(Some(&mut checkout_opts))?;
411                return Ok(());
412            }
413        };
414
415        repo.set_head(reference.name().ok_or_else(|| {
416            GitError::from_str("invalid reference name")
417        })?)?;
418
419        repo.checkout_head(Some(&mut checkout_opts))?;
420        Ok(())
421    }
422
423    // ═══════════════════════════════════════════════════════════════════════════
424    // Git Worktree Operations
425    // ═══════════════════════════════════════════════════════════════════════════
426
427    /// List all worktrees attached to this repository.
428    ///
429    /// Returns the main worktree plus any linked worktrees.
430    pub fn worktrees(&self) -> Result<Vec<WorktreeInfo>, GitError> {
431        let repo = self.repo.lock().map_err(|_| {
432            GitError::from_str("failed to acquire repository lock")
433        })?;
434
435        let mut result = Vec::new();
436
437        // Add the main worktree (the repository itself)
438        let main_path = repo.workdir().unwrap_or(self.root.as_path());
439        let head_name = repo
440            .head()
441            .ok()
442            .and_then(|h| h.shorthand().map(String::from));
443
444        result.push(WorktreeInfo {
445            name: None, // Main worktree has no name
446            path: main_path.to_path_buf(),
447            head: head_name,
448            locked: false,
449            prunable: false,
450        });
451
452        // Add linked worktrees
453        let worktree_names = repo.worktrees()?;
454        for name in worktree_names.iter() {
455            if let Some(name) = name
456                && let Ok(wt) = repo.find_worktree(name) {
457                    let locked = matches!(wt.is_locked(), Ok(WorktreeLockStatus::Locked(_)));
458                    let prunable = wt.is_prunable(None).unwrap_or(false);
459
460                    // Get the HEAD of this worktree by opening it as a repo
461                    let wt_head = Repository::open_from_worktree(&wt)
462                        .ok()
463                        .and_then(|r| {
464                            r.head().ok().and_then(|h| h.shorthand().map(String::from))
465                        });
466
467                    result.push(WorktreeInfo {
468                        name: Some(name.to_string()),
469                        path: wt.path().to_path_buf(),
470                        head: wt_head,
471                        locked,
472                        prunable,
473                    });
474                }
475        }
476
477        Ok(result)
478    }
479
480    /// Add a new worktree.
481    ///
482    /// Creates a new worktree at `path` with the given `name`.
483    ///
484    /// The `committish` parameter accepts:
485    /// - Local branch names (`feature`, `main`)
486    /// - Remote tracking branches (`origin/main`)
487    /// - Commit SHAs (`abc1234`)
488    /// - Tags (`v1.0.0`)
489    /// - Any git revision (`HEAD~3`)
490    ///
491    /// If `committish` is None, creates a new branch with the worktree name.
492    pub fn worktree_add(
493        &self,
494        name: &str,
495        path: &Path,
496        committish: Option<&str>,
497    ) -> Result<WorktreeInfo, GitError> {
498        let repo = self.repo.lock().map_err(|_| {
499            GitError::from_str("failed to acquire repository lock")
500        })?;
501
502        let mut opts = WorktreeAddOptions::new();
503
504        // If a committish is specified, resolve it
505        let resolved_ref;
506        if let Some(target) = committish {
507            // Try as local branch first
508            if let Ok(br) = repo.find_branch(target, git2::BranchType::Local) {
509                resolved_ref = Some(br.into_reference());
510                opts.reference(resolved_ref.as_ref());
511            }
512            // Try as remote tracking branch (e.g., "origin/main")
513            else if let Ok(br) = repo.find_branch(target, git2::BranchType::Remote) {
514                resolved_ref = Some(br.into_reference());
515                opts.reference(resolved_ref.as_ref());
516            }
517            // Try as any other reference (tag, commit, HEAD~N, etc.)
518            else if let Ok(obj) = repo.revparse_single(target) {
519                // For non-branch references, we create the worktree first,
520                // then checkout the specific commit (detached HEAD)
521                let wt = repo.worktree(name, path, None)?;
522
523                // Open the worktree as a repository and checkout the commit
524                let wt_repo = Repository::open_from_worktree(&wt)?;
525                let commit = obj.peel_to_commit()?;
526                wt_repo.set_head_detached(commit.id())?;
527
528                let mut checkout_opts = git2::build::CheckoutBuilder::new();
529                checkout_opts.force();
530                wt_repo.checkout_head(Some(&mut checkout_opts))?;
531
532                return Ok(WorktreeInfo {
533                    name: Some(name.to_string()),
534                    path: wt.path().to_path_buf(),
535                    head: Some(commit.id().to_string()[..7].to_string()),
536                    locked: false,
537                    prunable: false,
538                });
539            } else {
540                return Err(GitError::from_str(&format!(
541                    "cannot resolve '{}': not a branch, tag, or commit",
542                    target
543                )));
544            }
545        }
546
547        let wt = repo.worktree(name, path, Some(&opts))?;
548
549        // Get info about the newly created worktree
550        let locked = matches!(wt.is_locked(), Ok(WorktreeLockStatus::Locked(_)));
551        let wt_head = Repository::open_from_worktree(&wt)
552            .ok()
553            .and_then(|r| {
554                r.head().ok().and_then(|h| h.shorthand().map(String::from))
555            });
556
557        Ok(WorktreeInfo {
558            name: Some(name.to_string()),
559            path: wt.path().to_path_buf(),
560            head: wt_head,
561            locked,
562            prunable: false,
563        })
564    }
565
566    /// Remove a worktree.
567    ///
568    /// The worktree must not be locked and must be prunable (no uncommitted changes).
569    /// Use `force` to remove even if it has changes.
570    pub fn worktree_remove(&self, name: &str, force: bool) -> Result<(), GitError> {
571        let repo = self.repo.lock().map_err(|_| {
572            GitError::from_str("failed to acquire repository lock")
573        })?;
574
575        let wt = repo.find_worktree(name)?;
576
577        // Check if locked
578        if let Ok(WorktreeLockStatus::Locked(reason)) = wt.is_locked() {
579            let msg = reason
580                .map(|r| format!("worktree '{}' is locked: {}", name, r))
581                .unwrap_or_else(|| format!("worktree '{}' is locked", name));
582            return Err(GitError::from_str(&msg));
583        }
584
585        let mut prune_opts = WorktreePruneOptions::new();
586        if force {
587            prune_opts.valid(true);
588            prune_opts.working_tree(true);
589        }
590
591        wt.prune(Some(&mut prune_opts))?;
592        Ok(())
593    }
594
595    /// Lock a worktree to prevent it from being pruned.
596    pub fn worktree_lock(&self, name: &str, reason: Option<&str>) -> Result<(), GitError> {
597        let repo = self.repo.lock().map_err(|_| {
598            GitError::from_str("failed to acquire repository lock")
599        })?;
600
601        let wt = repo.find_worktree(name)?;
602        wt.lock(reason)?;
603        Ok(())
604    }
605
606    /// Unlock a worktree.
607    pub fn worktree_unlock(&self, name: &str) -> Result<(), GitError> {
608        let repo = self.repo.lock().map_err(|_| {
609            GitError::from_str("failed to acquire repository lock")
610        })?;
611
612        let wt = repo.find_worktree(name)?;
613        wt.unlock()?;
614        Ok(())
615    }
616
617    /// Prune stale worktree information.
618    ///
619    /// Removes worktree entries that no longer exist on disk.
620    pub fn worktree_prune(&self) -> Result<usize, GitError> {
621        let repo = self.repo.lock().map_err(|_| {
622            GitError::from_str("failed to acquire repository lock")
623        })?;
624
625        let mut pruned = 0;
626        let worktree_names = repo.worktrees()?;
627
628        for name in worktree_names.iter() {
629            if let Some(name) = name
630                && let Ok(wt) = repo.find_worktree(name) {
631                    // Check if the worktree path still exists
632                    if wt.validate().is_err() {
633                        let mut opts = WorktreePruneOptions::new();
634                        if wt.prune(Some(&mut opts)).is_ok() {
635                            pruned += 1;
636                        }
637                    }
638                }
639        }
640
641        Ok(pruned)
642    }
643}
644
645// ═══════════════════════════════════════════════════════════════════════════
646// Filesystem Trait Implementation
647// ═══════════════════════════════════════════════════════════════════════════
648
649#[async_trait]
650impl Filesystem for GitVfs {
651    async fn read(&self, path: &Path) -> io::Result<Vec<u8>> {
652        self.local.read(path).await
653    }
654
655    async fn write(&self, path: &Path, data: &[u8]) -> io::Result<()> {
656        self.local.write(path, data).await
657    }
658
659    async fn list(&self, path: &Path) -> io::Result<Vec<DirEntry>> {
660        // Filter out .git directory from listings
661        let mut entries = self.local.list(path).await?;
662        entries.retain(|e| e.name != ".git");
663        Ok(entries)
664    }
665
666    async fn stat(&self, path: &Path) -> io::Result<Metadata> {
667        self.local.stat(path).await
668    }
669
670    async fn mkdir(&self, path: &Path) -> io::Result<()> {
671        self.local.mkdir(path).await
672    }
673
674    async fn remove(&self, path: &Path) -> io::Result<()> {
675        self.local.remove(path).await
676    }
677
678    async fn rename(&self, from: &Path, to: &Path) -> io::Result<()> {
679        self.local.rename(from, to).await
680    }
681
682    fn read_only(&self) -> bool {
683        self.local.read_only()
684    }
685}
686
687impl std::fmt::Debug for GitVfs {
688    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
689        f.debug_struct("GitVfs")
690            .field("root", &self.root)
691            .finish()
692    }
693}
694
695// ═══════════════════════════════════════════════════════════════════════════
696// Helper Types
697// ═══════════════════════════════════════════════════════════════════════════
698
699/// Information about a worktree.
700#[derive(Debug, Clone)]
701pub struct WorktreeInfo {
702    /// Name of the worktree (None for main worktree).
703    pub name: Option<String>,
704    /// Path to the worktree directory.
705    pub path: PathBuf,
706    /// Current HEAD reference (branch name or commit).
707    pub head: Option<String>,
708    /// Whether the worktree is locked.
709    pub locked: bool,
710    /// Whether the worktree can be pruned.
711    pub prunable: bool,
712}
713
714/// Status of a single file in the working tree.
715#[derive(Debug, Clone)]
716pub struct FileStatus {
717    /// Path relative to repository root.
718    pub path: String,
719    /// Git status flags.
720    pub status: Status,
721}
722
723impl FileStatus {
724    /// Get a human-readable status character (like git status --porcelain).
725    pub fn status_char(&self) -> &'static str {
726        if self.status.is_index_new() {
727            "A "
728        } else if self.status.is_index_modified() {
729            "M "
730        } else if self.status.is_index_deleted() {
731            "D "
732        } else if self.status.is_wt_modified() {
733            " M"
734        } else if self.status.is_wt_new() {
735            "??"
736        } else if self.status.is_wt_deleted() {
737            " D"
738        } else {
739            "  "
740        }
741    }
742}
743
744/// Summary of repository status.
745#[derive(Debug, Clone, Default)]
746pub struct StatusSummary {
747    /// Number of staged files.
748    pub staged: usize,
749    /// Number of modified files (not staged).
750    pub modified: usize,
751    /// Number of untracked files.
752    pub untracked: usize,
753}
754
755/// A single log entry (commit).
756#[derive(Debug, Clone)]
757pub struct LogEntry {
758    /// Full commit OID.
759    pub oid: String,
760    /// Short (7-char) commit ID.
761    pub short_id: String,
762    /// Commit message.
763    pub message: String,
764    /// Author name.
765    pub author: String,
766    /// Author email.
767    pub email: String,
768    /// Commit timestamp (Unix seconds).
769    pub time: i64,
770}
771
772/// Parse "Name <email>" format.
773fn parse_author(s: &str) -> Option<(String, String)> {
774    if let Some(lt_pos) = s.find('<')
775        && let Some(gt_pos) = s.find('>') {
776            let name = s[..lt_pos].trim().to_string();
777            let email = s[lt_pos + 1..gt_pos].trim().to_string();
778            return Some((name, email));
779        }
780    None
781}
782
783#[cfg(test)]
784mod tests {
785    use super::*;
786    use std::env;
787    use std::sync::atomic::{AtomicU64, Ordering};
788    use tokio::fs;
789
790    static TEST_COUNTER: AtomicU64 = AtomicU64::new(0);
791
792    fn temp_dir() -> PathBuf {
793        let id = TEST_COUNTER.fetch_add(1, Ordering::SeqCst);
794        env::temp_dir().join(format!("kaish-git-test-{}-{}", std::process::id(), id))
795    }
796
797    async fn setup_repo() -> (GitVfs, PathBuf) {
798        let dir = temp_dir();
799        let _ = fs::remove_dir_all(&dir).await;
800        fs::create_dir_all(&dir).await.unwrap();
801
802        // Configure git identity for tests
803        let repo = Repository::init(&dir).unwrap();
804        {
805            let mut config = repo.config().unwrap();
806            config.set_str("user.name", "Test User").unwrap();
807            config.set_str("user.email", "test@example.com").unwrap();
808        }
809
810        let git_fs = GitVfs {
811            local: LocalFs::new(&dir),
812            repo: Mutex::new(repo),
813            root: dir.clone(),
814        };
815
816        (git_fs, dir)
817    }
818
819    async fn cleanup(dir: &Path) {
820        let _ = fs::remove_dir_all(dir).await;
821    }
822
823    #[tokio::test]
824    async fn test_init_and_write() {
825        let (git_fs, dir) = setup_repo().await;
826
827        // Write a file
828        git_fs
829            .write(Path::new("test.txt"), b"hello git")
830            .await
831            .unwrap();
832
833        // Verify file was written
834        let content = git_fs.read(Path::new("test.txt")).await.unwrap();
835        assert_eq!(content, b"hello git");
836
837        // Check status shows untracked file
838        let status = git_fs.status().unwrap();
839        assert_eq!(status.len(), 1);
840        assert_eq!(status[0].path, "test.txt");
841        assert!(status[0].status.is_wt_new());
842
843        cleanup(&dir).await;
844    }
845
846    #[tokio::test]
847    async fn test_add_and_commit() {
848        let (git_fs, dir) = setup_repo().await;
849
850        // Write and add a file
851        git_fs
852            .write(Path::new("test.txt"), b"hello git")
853            .await
854            .unwrap();
855        git_fs.add(&["test.txt"]).unwrap();
856
857        // Verify file is staged
858        let status = git_fs.status().unwrap();
859        assert_eq!(status.len(), 1);
860        assert!(status[0].status.is_index_new());
861
862        // Commit
863        let oid = git_fs.commit("Initial commit", None).unwrap();
864        assert!(!oid.is_zero());
865
866        // Verify clean status after commit
867        let status = git_fs.status().unwrap();
868        assert!(status.is_empty());
869
870        cleanup(&dir).await;
871    }
872
873    #[tokio::test]
874    async fn test_log() {
875        let (git_fs, dir) = setup_repo().await;
876
877        // Create a commit
878        git_fs
879            .write(Path::new("test.txt"), b"content")
880            .await
881            .unwrap();
882        git_fs.add(&["test.txt"]).unwrap();
883        git_fs.commit("Test commit", None).unwrap();
884
885        // Check log
886        let log = git_fs.log(10).unwrap();
887        assert_eq!(log.len(), 1);
888        assert!(log[0].message.contains("Test commit"));
889
890        cleanup(&dir).await;
891    }
892
893    #[tokio::test]
894    async fn test_branch_operations() {
895        let (git_fs, dir) = setup_repo().await;
896
897        // Create initial commit
898        git_fs
899            .write(Path::new("test.txt"), b"content")
900            .await
901            .unwrap();
902        git_fs.add(&["test.txt"]).unwrap();
903        git_fs.commit("Initial commit", None).unwrap();
904
905        // Get current branch
906        let branch = git_fs.current_branch().unwrap();
907        assert!(branch.is_some()); // Should be master or main
908
909        // Create new branch
910        git_fs.create_branch("feature").unwrap();
911
912        // List branches
913        let branches = git_fs.branches().unwrap();
914        assert!(branches.len() >= 2);
915        assert!(branches.contains(&"feature".to_string()));
916
917        // Checkout feature branch
918        git_fs.checkout("feature").unwrap();
919        let branch = git_fs.current_branch().unwrap();
920        assert_eq!(branch, Some("feature".to_string()));
921
922        cleanup(&dir).await;
923    }
924
925    #[tokio::test]
926    async fn test_list_hides_git_dir() {
927        let (git_fs, dir) = setup_repo().await;
928
929        // Write a file
930        git_fs
931            .write(Path::new("test.txt"), b"content")
932            .await
933            .unwrap();
934
935        // List should not include .git
936        let entries = git_fs.list(Path::new("")).await.unwrap();
937        let names: Vec<&str> = entries.iter().map(|e| e.name.as_str()).collect();
938        assert!(names.contains(&"test.txt"));
939        assert!(!names.contains(&".git"));
940
941        cleanup(&dir).await;
942    }
943
944    #[test]
945    fn test_parse_author() {
946        assert_eq!(
947            parse_author("John Doe <john@example.com>"),
948            Some(("John Doe".to_string(), "john@example.com".to_string()))
949        );
950        assert_eq!(parse_author("invalid"), None);
951    }
952
953    // ═══════════════════════════════════════════════════════════════════════
954    // Worktree Tests
955    // ═══════════════════════════════════════════════════════════════════════
956
957    #[tokio::test]
958    async fn test_worktrees_lists_main() {
959        let (git_fs, dir) = setup_repo().await;
960
961        // Create initial commit (required for worktrees)
962        git_fs
963            .write(Path::new("README.md"), b"# Test")
964            .await
965            .unwrap();
966        git_fs.add(&["README.md"]).unwrap();
967        git_fs.commit("Initial commit", None).unwrap();
968
969        // List worktrees - should have main worktree
970        let worktrees = git_fs.worktrees().unwrap();
971        assert_eq!(worktrees.len(), 1);
972        assert!(worktrees[0].name.is_none()); // Main worktree has no name
973        assert!(worktrees[0].head.is_some()); // Should have a branch
974
975        cleanup(&dir).await;
976    }
977
978    #[tokio::test]
979    async fn test_worktree_add_with_new_branch() {
980        let (git_fs, dir) = setup_repo().await;
981
982        // Create initial commit
983        git_fs
984            .write(Path::new("README.md"), b"# Test")
985            .await
986            .unwrap();
987        git_fs.add(&["README.md"]).unwrap();
988        git_fs.commit("Initial commit", None).unwrap();
989
990        // Create worktree path (unique per test run)
991        let wt_path = temp_dir();
992
993        // Add worktree (no branch specified = new branch)
994        let info = git_fs.worktree_add("test-wt", &wt_path, None).unwrap();
995        assert_eq!(info.name, Some("test-wt".to_string()));
996        assert!(info.path.exists());
997
998        // List should now show 2 worktrees
999        let worktrees = git_fs.worktrees().unwrap();
1000        assert_eq!(worktrees.len(), 2);
1001
1002        // Cleanup
1003        let _ = fs::remove_dir_all(&wt_path).await;
1004        cleanup(&dir).await;
1005    }
1006
1007    #[tokio::test]
1008    async fn test_worktree_add_with_existing_branch() {
1009        let (git_fs, dir) = setup_repo().await;
1010
1011        // Create initial commit
1012        git_fs
1013            .write(Path::new("README.md"), b"# Test")
1014            .await
1015            .unwrap();
1016        git_fs.add(&["README.md"]).unwrap();
1017        git_fs.commit("Initial commit", None).unwrap();
1018
1019        // Create a branch
1020        git_fs.create_branch("feature").unwrap();
1021
1022        // Create worktree for existing branch
1023        let wt_path = temp_dir();
1024        let info = git_fs.worktree_add("wt-feature", &wt_path, Some("feature")).unwrap();
1025
1026        assert_eq!(info.name, Some("wt-feature".to_string()));
1027        assert!(info.path.exists());
1028
1029        // Cleanup
1030        let _ = fs::remove_dir_all(&wt_path).await;
1031        cleanup(&dir).await;
1032    }
1033
1034    #[tokio::test]
1035    async fn test_worktree_add_with_commit() {
1036        let (git_fs, dir) = setup_repo().await;
1037
1038        // Create initial commit
1039        git_fs
1040            .write(Path::new("README.md"), b"# Test")
1041            .await
1042            .unwrap();
1043        git_fs.add(&["README.md"]).unwrap();
1044        let oid = git_fs.commit("Initial commit", None).unwrap();
1045
1046        // Create second commit
1047        git_fs
1048            .write(Path::new("README.md"), b"# Updated")
1049            .await
1050            .unwrap();
1051        git_fs.add(&["README.md"]).unwrap();
1052        git_fs.commit("Second commit", None).unwrap();
1053
1054        // Create worktree at specific commit (first commit)
1055        let wt_path = temp_dir();
1056        let short_oid = &oid.to_string()[..7];
1057        let info = git_fs.worktree_add("wt-commit", &wt_path, Some(short_oid)).unwrap();
1058
1059        assert_eq!(info.name, Some("wt-commit".to_string()));
1060        assert!(info.path.exists());
1061        // HEAD should be the short commit id (detached)
1062        assert!(info.head.is_some());
1063
1064        // Cleanup
1065        let _ = fs::remove_dir_all(&wt_path).await;
1066        cleanup(&dir).await;
1067    }
1068
1069    #[tokio::test]
1070    async fn test_worktree_add_invalid_ref() {
1071        let (git_fs, dir) = setup_repo().await;
1072
1073        // Create initial commit
1074        git_fs
1075            .write(Path::new("README.md"), b"# Test")
1076            .await
1077            .unwrap();
1078        git_fs.add(&["README.md"]).unwrap();
1079        git_fs.commit("Initial commit", None).unwrap();
1080
1081        // Try to create worktree with invalid ref
1082        let wt_path = temp_dir();
1083        let result = git_fs.worktree_add("wt-bad", &wt_path, Some("nonexistent-branch"));
1084
1085        assert!(result.is_err());
1086        let err = result.unwrap_err();
1087        assert!(err.message().contains("cannot resolve"));
1088
1089        cleanup(&dir).await;
1090    }
1091
1092    #[tokio::test]
1093    async fn test_worktree_lock_unlock() {
1094        let (git_fs, dir) = setup_repo().await;
1095
1096        // Create initial commit
1097        git_fs
1098            .write(Path::new("README.md"), b"# Test")
1099            .await
1100            .unwrap();
1101        git_fs.add(&["README.md"]).unwrap();
1102        git_fs.commit("Initial commit", None).unwrap();
1103
1104        // Create worktree
1105        let wt_path = temp_dir();
1106        git_fs.worktree_add("wt-lock", &wt_path, None).unwrap();
1107
1108        // Lock it
1109        git_fs.worktree_lock("wt-lock", Some("testing")).unwrap();
1110
1111        // Verify locked in list
1112        let worktrees = git_fs.worktrees().unwrap();
1113        let locked_wt = worktrees.iter().find(|w| w.name.as_deref() == Some("wt-lock"));
1114        assert!(locked_wt.is_some());
1115        assert!(locked_wt.unwrap().locked);
1116
1117        // Unlock it
1118        git_fs.worktree_unlock("wt-lock").unwrap();
1119
1120        // Verify unlocked
1121        let worktrees = git_fs.worktrees().unwrap();
1122        let unlocked_wt = worktrees.iter().find(|w| w.name.as_deref() == Some("wt-lock"));
1123        assert!(!unlocked_wt.unwrap().locked);
1124
1125        // Cleanup
1126        let _ = fs::remove_dir_all(&wt_path).await;
1127        cleanup(&dir).await;
1128    }
1129
1130    #[tokio::test]
1131    async fn test_worktree_remove() {
1132        let (git_fs, dir) = setup_repo().await;
1133
1134        // Create initial commit
1135        git_fs
1136            .write(Path::new("README.md"), b"# Test")
1137            .await
1138            .unwrap();
1139        git_fs.add(&["README.md"]).unwrap();
1140        git_fs.commit("Initial commit", None).unwrap();
1141
1142        // Create worktree
1143        let wt_path = temp_dir();
1144        git_fs.worktree_add("wt-remove", &wt_path, None).unwrap();
1145
1146        // Verify it exists
1147        assert_eq!(git_fs.worktrees().unwrap().len(), 2);
1148
1149        // Remove it (force because it's valid)
1150        git_fs.worktree_remove("wt-remove", true).unwrap();
1151
1152        // Verify removed from list
1153        assert_eq!(git_fs.worktrees().unwrap().len(), 1);
1154
1155        // Cleanup (path may or may not exist after remove)
1156        let _ = fs::remove_dir_all(&wt_path).await;
1157        cleanup(&dir).await;
1158    }
1159
1160    #[tokio::test]
1161    async fn test_worktree_remove_locked_fails() {
1162        let (git_fs, dir) = setup_repo().await;
1163
1164        // Create initial commit
1165        git_fs
1166            .write(Path::new("README.md"), b"# Test")
1167            .await
1168            .unwrap();
1169        git_fs.add(&["README.md"]).unwrap();
1170        git_fs.commit("Initial commit", None).unwrap();
1171
1172        // Create and lock worktree
1173        let wt_path = temp_dir();
1174        git_fs.worktree_add("wt-locked", &wt_path, None).unwrap();
1175        git_fs.worktree_lock("wt-locked", None).unwrap();
1176
1177        // Try to remove - should fail
1178        let result = git_fs.worktree_remove("wt-locked", false);
1179        assert!(result.is_err());
1180        assert!(result.unwrap_err().message().contains("locked"));
1181
1182        // Cleanup
1183        git_fs.worktree_unlock("wt-locked").unwrap();
1184        git_fs.worktree_remove("wt-locked", true).unwrap();
1185        let _ = fs::remove_dir_all(&wt_path).await;
1186        cleanup(&dir).await;
1187    }
1188
1189    #[tokio::test]
1190    async fn test_worktree_prune() {
1191        let (git_fs, dir) = setup_repo().await;
1192
1193        // Create initial commit
1194        git_fs
1195            .write(Path::new("README.md"), b"# Test")
1196            .await
1197            .unwrap();
1198        git_fs.add(&["README.md"]).unwrap();
1199        git_fs.commit("Initial commit", None).unwrap();
1200
1201        // Create worktree
1202        let wt_path = temp_dir();
1203        git_fs.worktree_add("wt-prune", &wt_path, None).unwrap();
1204
1205        // Verify 2 worktrees
1206        assert_eq!(git_fs.worktrees().unwrap().len(), 2);
1207
1208        // Manually delete the worktree directory (simulating stale state)
1209        fs::remove_dir_all(&wt_path).await.unwrap();
1210
1211        // Prune should clean up the stale entry
1212        let pruned = git_fs.worktree_prune().unwrap();
1213        assert_eq!(pruned, 1);
1214
1215        // Should be back to 1 worktree
1216        assert_eq!(git_fs.worktrees().unwrap().len(), 1);
1217
1218        cleanup(&dir).await;
1219    }
1220}