1use 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
28pub struct GitVfs {
34 local: LocalFs,
36 repo: Mutex<Repository>,
38 root: PathBuf,
40}
41
42impl GitVfs {
43 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 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 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 pub fn root(&self) -> &Path {
87 &self.root
88 }
89
90 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 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 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 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 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 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 let head = repo.head()?;
187 let head_commit = head.peel_to_commit()?;
188 let tree = head_commit.tree()?;
189
190 repo.reset_default(Some(head_commit.as_object()), [path])?;
192
193 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 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 let sig = if let Some(author) = author {
219 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 let parent = match repo.head() {
231 Ok(head) => Some(head.peel_to_commit()?),
232 Err(_) => None, };
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 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 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 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' => "", 'F' => "", 'B' => "", _ => "",
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 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 Ok(None)
341 }
342 }
343 Err(_) => Ok(None), }
345 }
346
347 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 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 pub fn checkout(&self, target: &str) -> Result<(), GitError> {
383 self.checkout_with_options(target, true)
384 }
385
386 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 let reference = match repo.find_branch(target, git2::BranchType::Local) {
404 Ok(branch) => branch.into_reference(),
405 Err(_) => {
406 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 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 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, path: main_path.to_path_buf(),
447 head: head_name,
448 locked: false,
449 prunable: false,
450 });
451
452 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 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 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 let resolved_ref;
506 if let Some(target) = committish {
507 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 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 else if let Ok(obj) = repo.revparse_single(target) {
519 let wt = repo.worktree(name, path, None)?;
522
523 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 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 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 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 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 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 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 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#[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 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#[derive(Debug, Clone)]
701pub struct WorktreeInfo {
702 pub name: Option<String>,
704 pub path: PathBuf,
706 pub head: Option<String>,
708 pub locked: bool,
710 pub prunable: bool,
712}
713
714#[derive(Debug, Clone)]
716pub struct FileStatus {
717 pub path: String,
719 pub status: Status,
721}
722
723impl FileStatus {
724 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#[derive(Debug, Clone, Default)]
746pub struct StatusSummary {
747 pub staged: usize,
749 pub modified: usize,
751 pub untracked: usize,
753}
754
755#[derive(Debug, Clone)]
757pub struct LogEntry {
758 pub oid: String,
760 pub short_id: String,
762 pub message: String,
764 pub author: String,
766 pub email: String,
768 pub time: i64,
770}
771
772fn 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 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 git_fs
829 .write(Path::new("test.txt"), b"hello git")
830 .await
831 .unwrap();
832
833 let content = git_fs.read(Path::new("test.txt")).await.unwrap();
835 assert_eq!(content, b"hello git");
836
837 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 git_fs
852 .write(Path::new("test.txt"), b"hello git")
853 .await
854 .unwrap();
855 git_fs.add(&["test.txt"]).unwrap();
856
857 let status = git_fs.status().unwrap();
859 assert_eq!(status.len(), 1);
860 assert!(status[0].status.is_index_new());
861
862 let oid = git_fs.commit("Initial commit", None).unwrap();
864 assert!(!oid.is_zero());
865
866 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 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 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 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 let branch = git_fs.current_branch().unwrap();
907 assert!(branch.is_some()); git_fs.create_branch("feature").unwrap();
911
912 let branches = git_fs.branches().unwrap();
914 assert!(branches.len() >= 2);
915 assert!(branches.contains(&"feature".to_string()));
916
917 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 git_fs
931 .write(Path::new("test.txt"), b"content")
932 .await
933 .unwrap();
934
935 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 #[tokio::test]
958 async fn test_worktrees_lists_main() {
959 let (git_fs, dir) = setup_repo().await;
960
961 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 let worktrees = git_fs.worktrees().unwrap();
971 assert_eq!(worktrees.len(), 1);
972 assert!(worktrees[0].name.is_none()); assert!(worktrees[0].head.is_some()); 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 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 let wt_path = temp_dir();
992
993 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 let worktrees = git_fs.worktrees().unwrap();
1000 assert_eq!(worktrees.len(), 2);
1001
1002 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 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 git_fs.create_branch("feature").unwrap();
1021
1022 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 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 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 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 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 assert!(info.head.is_some());
1063
1064 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 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 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 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 let wt_path = temp_dir();
1106 git_fs.worktree_add("wt-lock", &wt_path, None).unwrap();
1107
1108 git_fs.worktree_lock("wt-lock", Some("testing")).unwrap();
1110
1111 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 git_fs.worktree_unlock("wt-lock").unwrap();
1119
1120 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 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 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 let wt_path = temp_dir();
1144 git_fs.worktree_add("wt-remove", &wt_path, None).unwrap();
1145
1146 assert_eq!(git_fs.worktrees().unwrap().len(), 2);
1148
1149 git_fs.worktree_remove("wt-remove", true).unwrap();
1151
1152 assert_eq!(git_fs.worktrees().unwrap().len(), 1);
1154
1155 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 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 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 let result = git_fs.worktree_remove("wt-locked", false);
1179 assert!(result.is_err());
1180 assert!(result.unwrap_err().message().contains("locked"));
1181
1182 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 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 let wt_path = temp_dir();
1203 git_fs.worktree_add("wt-prune", &wt_path, None).unwrap();
1204
1205 assert_eq!(git_fs.worktrees().unwrap().len(), 2);
1207
1208 fs::remove_dir_all(&wt_path).await.unwrap();
1210
1211 let pruned = git_fs.worktree_prune().unwrap();
1213 assert_eq!(pruned, 1);
1214
1215 assert_eq!(git_fs.worktrees().unwrap().len(), 1);
1217
1218 cleanup(&dir).await;
1219 }
1220}