1use std::path::{Path, PathBuf};
4use std::time::{Duration, Instant};
5
6use anyhow::{Context, Result};
7use git2::{Repository, Status, StatusOptions};
8use vibe_graph_core::{GitChangeKind, GitChangeSnapshot, GitFileChange, Snapshot};
9
10pub trait GitFossilStore {
12 fn commit_snapshot(&self, snapshot: &Snapshot) -> Result<()>;
14
15 fn get_latest_snapshot(&self) -> Result<Option<Snapshot>>;
17}
18
19pub struct GitBackend {
21 pub repo_path: PathBuf,
23}
24
25impl GitBackend {
26 pub fn new(repo_path: PathBuf) -> Self {
28 Self { repo_path }
29 }
30}
31
32impl GitFossilStore for GitBackend {
33 fn commit_snapshot(&self, _snapshot: &Snapshot) -> Result<()> {
34 Ok(())
36 }
37
38 fn get_latest_snapshot(&self) -> Result<Option<Snapshot>> {
39 Ok(None)
40 }
41}
42
43#[derive(Debug, Clone)]
49pub struct GitWatcherConfig {
50 pub min_poll_interval: Duration,
52 pub include_untracked: bool,
54 pub include_ignored: bool,
56 pub recurse_submodules: bool,
58}
59
60impl Default for GitWatcherConfig {
61 fn default() -> Self {
62 Self {
63 min_poll_interval: Duration::from_millis(500),
64 include_untracked: true,
65 include_ignored: false,
66 recurse_submodules: false,
67 }
68 }
69}
70
71pub struct GitWatcher {
75 repo_path: PathBuf,
77 config: GitWatcherConfig,
79 last_poll: Option<Instant>,
81 cached_snapshot: GitChangeSnapshot,
83}
84
85impl GitWatcher {
86 pub fn new(repo_path: impl Into<PathBuf>) -> Self {
88 Self {
89 repo_path: repo_path.into(),
90 config: GitWatcherConfig::default(),
91 last_poll: None,
92 cached_snapshot: GitChangeSnapshot::default(),
93 }
94 }
95
96 pub fn with_config(repo_path: impl Into<PathBuf>, config: GitWatcherConfig) -> Self {
98 Self {
99 repo_path: repo_path.into(),
100 config,
101 last_poll: None,
102 cached_snapshot: GitChangeSnapshot::default(),
103 }
104 }
105
106 pub fn repo_path(&self) -> &Path {
108 &self.repo_path
109 }
110
111 pub fn should_poll(&self) -> bool {
113 match self.last_poll {
114 Some(last) => last.elapsed() >= self.config.min_poll_interval,
115 None => true,
116 }
117 }
118
119 pub fn cached_snapshot(&self) -> &GitChangeSnapshot {
121 &self.cached_snapshot
122 }
123
124 pub fn poll(&mut self) -> Result<&GitChangeSnapshot> {
129 if !self.should_poll() {
130 return Ok(&self.cached_snapshot);
131 }
132
133 self.cached_snapshot = self.fetch_changes()?;
134 self.last_poll = Some(Instant::now());
135 Ok(&self.cached_snapshot)
136 }
137
138 pub fn force_poll(&mut self) -> Result<&GitChangeSnapshot> {
140 self.cached_snapshot = self.fetch_changes()?;
141 self.last_poll = Some(Instant::now());
142 Ok(&self.cached_snapshot)
143 }
144
145 fn fetch_changes(&self) -> Result<GitChangeSnapshot> {
147 let repo = Repository::open(&self.repo_path)
148 .with_context(|| format!("Failed to open repository at {:?}", self.repo_path))?;
149
150 let mut opts = StatusOptions::new();
151 opts.include_untracked(self.config.include_untracked)
152 .include_ignored(self.config.include_ignored)
153 .recurse_untracked_dirs(true)
154 .exclude_submodules(true);
155
156 let statuses = repo
157 .statuses(Some(&mut opts))
158 .context("Failed to get repository status")?;
159
160 let mut changes = Vec::new();
161
162 for entry in statuses.iter() {
163 let path = match entry.path() {
164 Some(p) => PathBuf::from(p),
165 None => continue,
166 };
167
168 let status = entry.status();
169
170 if status.contains(Status::INDEX_NEW) {
173 changes.push(GitFileChange {
174 path: path.clone(),
175 kind: GitChangeKind::Added,
176 staged: true,
177 });
178 } else if status.contains(Status::INDEX_MODIFIED) {
179 changes.push(GitFileChange {
180 path: path.clone(),
181 kind: GitChangeKind::Modified,
182 staged: true,
183 });
184 } else if status.contains(Status::INDEX_DELETED) {
185 changes.push(GitFileChange {
186 path: path.clone(),
187 kind: GitChangeKind::Deleted,
188 staged: true,
189 });
190 } else if status.contains(Status::INDEX_RENAMED) {
191 changes.push(GitFileChange {
192 path: path.clone(),
193 kind: GitChangeKind::RenamedTo,
194 staged: true,
195 });
196 }
197
198 if status.contains(Status::WT_NEW) {
200 changes.push(GitFileChange {
201 path: path.clone(),
202 kind: GitChangeKind::Untracked,
203 staged: false,
204 });
205 } else if status.contains(Status::WT_MODIFIED) {
206 changes.push(GitFileChange {
207 path: path.clone(),
208 kind: GitChangeKind::Modified,
209 staged: false,
210 });
211 } else if status.contains(Status::WT_DELETED) {
212 changes.push(GitFileChange {
213 path: path.clone(),
214 kind: GitChangeKind::Deleted,
215 staged: false,
216 });
217 } else if status.contains(Status::WT_RENAMED) {
218 changes.push(GitFileChange {
219 path: path.clone(),
220 kind: GitChangeKind::RenamedTo,
221 staged: false,
222 });
223 }
224 }
225
226 Ok(GitChangeSnapshot {
227 changes,
228 captured_at: Some(Instant::now()),
229 })
230 }
231}
232
233pub fn get_git_changes(repo_path: &Path) -> Result<GitChangeSnapshot> {
235 let mut watcher = GitWatcher::new(repo_path);
236 watcher.force_poll().cloned()
237}
238
239use git2::{IndexAddOption, Signature, Time};
244use serde::{Deserialize, Serialize};
245
246#[derive(Debug, Clone, Serialize, Deserialize)]
248pub struct GitAddResult {
249 pub staged_files: Vec<PathBuf>,
251 pub count: usize,
253}
254
255#[derive(Debug, Clone, Serialize, Deserialize)]
257pub struct GitCommitResult {
258 pub commit_id: String,
260 pub message: String,
262 pub file_count: usize,
264}
265
266#[derive(Debug, Clone, Serialize, Deserialize)]
268pub struct GitResetResult {
269 pub unstaged_files: Vec<PathBuf>,
271 pub count: usize,
273}
274
275#[derive(Debug, Clone, Serialize, Deserialize)]
277pub struct GitBranch {
278 pub name: String,
280 pub is_current: bool,
282 pub is_remote: bool,
284 pub commit_id: Option<String>,
286}
287
288#[derive(Debug, Clone, Serialize, Deserialize)]
290pub struct GitBranchListResult {
291 pub branches: Vec<GitBranch>,
293 pub current: Option<String>,
295}
296
297#[derive(Debug, Clone, Serialize, Deserialize)]
299pub struct GitLogEntry {
300 pub commit_id: String,
302 pub short_id: String,
304 pub message: String,
306 pub author: String,
308 pub author_email: String,
310 pub timestamp: i64,
312}
313
314#[derive(Debug, Clone, Serialize, Deserialize)]
316pub struct GitLogResult {
317 pub commits: Vec<GitLogEntry>,
319}
320
321#[derive(Debug, Clone, Serialize, Deserialize)]
323pub struct GitDiffResult {
324 pub diff: String,
326 pub files_changed: usize,
328 pub insertions: usize,
330 pub deletions: usize,
332}
333
334pub fn git_add(repo_path: &Path, paths: &[PathBuf]) -> Result<GitAddResult> {
338 let repo = Repository::open(repo_path)
339 .with_context(|| format!("Failed to open repository at {:?}", repo_path))?;
340
341 let mut index = repo.index().context("Failed to get repository index")?;
342
343 let staged_files = if paths.is_empty() {
344 index
346 .add_all(["*"].iter(), IndexAddOption::DEFAULT, None)
347 .context("Failed to add all files to index")?;
348
349 let statuses = repo.statuses(None)?;
351 statuses
352 .iter()
353 .filter_map(|e| e.path().map(PathBuf::from))
354 .collect()
355 } else {
356 for path in paths {
358 let rel_path = if path.is_absolute() {
360 path.strip_prefix(repo_path).unwrap_or(path)
361 } else {
362 path.as_path()
363 };
364 index
365 .add_path(rel_path)
366 .with_context(|| format!("Failed to add {:?} to index", rel_path))?;
367 }
368 paths.to_vec()
369 };
370
371 index.write().context("Failed to write index")?;
372
373 let count = staged_files.len();
374 Ok(GitAddResult {
375 staged_files,
376 count,
377 })
378}
379
380pub fn git_commit(repo_path: &Path, message: &str) -> Result<GitCommitResult> {
382 let repo = Repository::open(repo_path)
383 .with_context(|| format!("Failed to open repository at {:?}", repo_path))?;
384
385 let mut index = repo.index().context("Failed to get repository index")?;
386
387 let statuses = repo.statuses(None)?;
389 let staged_count = statuses
390 .iter()
391 .filter(|e| {
392 let s = e.status();
393 s.contains(Status::INDEX_NEW)
394 || s.contains(Status::INDEX_MODIFIED)
395 || s.contains(Status::INDEX_DELETED)
396 || s.contains(Status::INDEX_RENAMED)
397 })
398 .count();
399
400 if staged_count == 0 {
401 anyhow::bail!("Nothing to commit - no staged changes");
402 }
403
404 let tree_id = index.write_tree().context("Failed to write tree")?;
406 let tree = repo.find_tree(tree_id).context("Failed to find tree")?;
407
408 let signature = repo
410 .signature()
411 .or_else(|_| {
412 Signature::new(
414 "Vibe Graph",
415 "vibe-graph@local",
416 &Time::new(chrono_timestamp(), 0),
417 )
418 })
419 .context("Failed to get signature")?;
420
421 let parent_commit = match repo.head() {
423 Ok(head) => Some(
424 head.peel_to_commit()
425 .context("Failed to get parent commit")?,
426 ),
427 Err(_) => None, };
429
430 let parents: Vec<&git2::Commit> = parent_commit.iter().collect();
431
432 let commit_id = repo
434 .commit(
435 Some("HEAD"),
436 &signature,
437 &signature,
438 message,
439 &tree,
440 &parents,
441 )
442 .context("Failed to create commit")?;
443
444 Ok(GitCommitResult {
445 commit_id: commit_id.to_string(),
446 message: message.to_string(),
447 file_count: staged_count,
448 })
449}
450
451pub fn git_reset(repo_path: &Path, paths: &[PathBuf]) -> Result<GitResetResult> {
455 let repo = Repository::open(repo_path)
456 .with_context(|| format!("Failed to open repository at {:?}", repo_path))?;
457
458 let head = repo.head().ok();
459 let head_commit = head.as_ref().and_then(|h| h.peel_to_commit().ok());
460
461 let unstaged_files = if paths.is_empty() {
462 let statuses = repo.statuses(None)?;
464 let staged_files: Vec<PathBuf> = statuses
465 .iter()
466 .filter(|e| {
467 let s = e.status();
468 s.contains(Status::INDEX_NEW)
469 || s.contains(Status::INDEX_MODIFIED)
470 || s.contains(Status::INDEX_DELETED)
471 })
472 .filter_map(|e| e.path().map(PathBuf::from))
473 .collect();
474
475 if !staged_files.is_empty() {
477 if let Some(commit) = &head_commit {
478 let path_refs: Vec<&Path> = staged_files.iter().map(|p| p.as_path()).collect();
479 repo.reset_default(Some(commit.as_object()), path_refs.iter().copied())
480 .context("Failed to reset index")?;
481 } else {
482 let mut index = repo.index()?;
484 for path in &staged_files {
485 let _ = index.remove_path(path);
486 }
487 index.write()?;
488 }
489 }
490
491 staged_files
492 } else {
493 if let Some(commit) = &head_commit {
495 let path_refs: Vec<&Path> = paths.iter().map(|p| p.as_path()).collect();
496 repo.reset_default(Some(commit.as_object()), path_refs.iter().copied())
497 .context("Failed to reset files")?;
498 }
499 paths.to_vec()
500 };
501
502 let count = unstaged_files.len();
503 Ok(GitResetResult {
504 unstaged_files,
505 count,
506 })
507}
508
509pub fn git_list_branches(repo_path: &Path) -> Result<GitBranchListResult> {
511 let repo = Repository::open(repo_path)
512 .with_context(|| format!("Failed to open repository at {:?}", repo_path))?;
513
514 let mut branches = Vec::new();
515 let mut current_branch = None;
516
517 if let Ok(head) = repo.head() {
519 if head.is_branch() {
520 current_branch = head.shorthand().map(String::from);
521 }
522 }
523
524 for branch_result in repo.branches(None)? {
526 let (branch, branch_type) = branch_result?;
527 let name = branch.name()?.unwrap_or("").to_string();
528 let is_remote = matches!(branch_type, git2::BranchType::Remote);
529 let is_current = Some(&name) == current_branch.as_ref();
530
531 let commit_id = branch
532 .get()
533 .peel_to_commit()
534 .ok()
535 .map(|c| c.id().to_string());
536
537 branches.push(GitBranch {
538 name,
539 is_current,
540 is_remote,
541 commit_id,
542 });
543 }
544
545 Ok(GitBranchListResult {
546 branches,
547 current: current_branch,
548 })
549}
550
551pub fn git_checkout_branch(repo_path: &Path, branch_name: &str) -> Result<()> {
553 let repo = Repository::open(repo_path)
554 .with_context(|| format!("Failed to open repository at {:?}", repo_path))?;
555
556 let branch = repo
558 .find_branch(branch_name, git2::BranchType::Local)
559 .with_context(|| format!("Branch '{}' not found", branch_name))?;
560
561 let reference = branch.get();
562 let commit = reference
563 .peel_to_commit()
564 .context("Failed to get commit for branch")?;
565
566 let tree = commit.tree().context("Failed to get tree")?;
568 repo.checkout_tree(tree.as_object(), None)
569 .context("Failed to checkout tree")?;
570
571 repo.set_head(reference.name().unwrap_or(""))
573 .context("Failed to set HEAD")?;
574
575 Ok(())
576}
577
578pub fn git_log(repo_path: &Path, limit: usize) -> Result<GitLogResult> {
580 let repo = Repository::open(repo_path)
581 .with_context(|| format!("Failed to open repository at {:?}", repo_path))?;
582
583 let mut revwalk = repo.revwalk().context("Failed to create revwalk")?;
584 revwalk.push_head().context("Failed to push HEAD")?;
585
586 let mut commits = Vec::new();
587
588 for (i, oid_result) in revwalk.enumerate() {
589 if i >= limit {
590 break;
591 }
592
593 let oid = oid_result.context("Failed to get commit OID")?;
594 let commit = repo.find_commit(oid).context("Failed to find commit")?;
595
596 let author = commit.author();
597 commits.push(GitLogEntry {
598 commit_id: oid.to_string(),
599 short_id: oid.to_string()[..7].to_string(),
600 message: commit.message().unwrap_or("").to_string(),
601 author: author.name().unwrap_or("Unknown").to_string(),
602 author_email: author.email().unwrap_or("").to_string(),
603 timestamp: author.when().seconds(),
604 });
605 }
606
607 Ok(GitLogResult { commits })
608}
609
610pub fn git_diff(repo_path: &Path, staged: bool) -> Result<GitDiffResult> {
612 let repo = Repository::open(repo_path)
613 .with_context(|| format!("Failed to open repository at {:?}", repo_path))?;
614
615 let mut diff_opts = git2::DiffOptions::new();
616 diff_opts.include_untracked(true);
617
618 let diff = if staged {
619 let head_tree = repo.head().ok().and_then(|h| h.peel_to_tree().ok());
621 repo.diff_tree_to_index(head_tree.as_ref(), None, Some(&mut diff_opts))
622 .context("Failed to get staged diff")?
623 } else {
624 repo.diff_index_to_workdir(None, Some(&mut diff_opts))
626 .context("Failed to get working directory diff")?
627 };
628
629 let stats = diff.stats().context("Failed to get diff stats")?;
630
631 let mut diff_text = String::new();
632 diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
633 let prefix = match line.origin() {
634 '+' => "+",
635 '-' => "-",
636 ' ' => " ",
637 _ => "",
638 };
639 diff_text.push_str(prefix);
640 diff_text.push_str(std::str::from_utf8(line.content()).unwrap_or(""));
641 true
642 })
643 .context("Failed to print diff")?;
644
645 Ok(GitDiffResult {
646 diff: diff_text,
647 files_changed: stats.files_changed(),
648 insertions: stats.insertions(),
649 deletions: stats.deletions(),
650 })
651}
652
653fn chrono_timestamp() -> i64 {
655 std::time::SystemTime::now()
656 .duration_since(std::time::UNIX_EPOCH)
657 .map(|d| d.as_secs() as i64)
658 .unwrap_or(0)
659}
660
661#[cfg(test)]
662mod tests {
663 use super::*;
664 use std::fs;
665 use tempfile::TempDir;
666
667 fn init_test_repo() -> Result<(TempDir, Repository)> {
668 let dir = TempDir::new()?;
669 let repo = Repository::init(dir.path())?;
670 Ok((dir, repo))
671 }
672
673 #[test]
674 fn test_watcher_empty_repo() -> Result<()> {
675 let (dir, _repo) = init_test_repo()?;
676 let mut watcher = GitWatcher::new(dir.path());
677 let snapshot = watcher.force_poll()?;
678 assert!(snapshot.changes.is_empty());
679 Ok(())
680 }
681
682 #[test]
683 fn test_watcher_detects_new_file() -> Result<()> {
684 let (dir, _repo) = init_test_repo()?;
685 fs::write(dir.path().join("new_file.txt"), "hello")?;
686
687 let mut watcher = GitWatcher::new(dir.path());
688 let snapshot = watcher.force_poll()?;
689
690 assert_eq!(snapshot.changes.len(), 1);
691 assert_eq!(snapshot.changes[0].kind, GitChangeKind::Untracked);
692 assert!(!snapshot.changes[0].staged);
693 Ok(())
694 }
695
696 #[test]
697 fn test_watcher_rate_limiting() -> Result<()> {
698 let (dir, _repo) = init_test_repo()?;
699 let config = GitWatcherConfig {
700 min_poll_interval: Duration::from_secs(60), ..Default::default()
702 };
703 let mut watcher = GitWatcher::with_config(dir.path(), config);
704
705 assert!(watcher.should_poll());
707 watcher.poll()?;
708
709 assert!(!watcher.should_poll());
711 Ok(())
712 }
713
714 #[test]
719 fn test_git_add_and_commit() -> Result<()> {
720 let (dir, repo) = init_test_repo()?;
721
722 let mut config = repo.config()?;
724 config.set_str("user.name", "Test User")?;
725 config.set_str("user.email", "test@example.com")?;
726
727 fs::write(dir.path().join("test.txt"), "hello world")?;
729
730 let add_result = git_add(dir.path(), &[])?;
732 assert_eq!(add_result.count, 1);
733
734 let commit_result = git_commit(dir.path(), "Initial commit")?;
736 assert_eq!(commit_result.message, "Initial commit");
737 assert!(!commit_result.commit_id.is_empty());
738
739 Ok(())
740 }
741
742 #[test]
743 fn test_git_commit_fails_without_staged() -> Result<()> {
744 let (dir, repo) = init_test_repo()?;
745
746 let mut config = repo.config()?;
748 config.set_str("user.name", "Test User")?;
749 config.set_str("user.email", "test@example.com")?;
750
751 let result = git_commit(dir.path(), "Empty commit");
753 assert!(result.is_err());
754 assert!(result
755 .unwrap_err()
756 .to_string()
757 .contains("Nothing to commit"));
758
759 Ok(())
760 }
761
762 #[test]
763 fn test_git_branches() -> Result<()> {
764 let (dir, repo) = init_test_repo()?;
765
766 let mut config = repo.config()?;
768 config.set_str("user.name", "Test User")?;
769 config.set_str("user.email", "test@example.com")?;
770
771 fs::write(dir.path().join("test.txt"), "hello")?;
773 git_add(dir.path(), &[])?;
774 git_commit(dir.path(), "Initial")?;
775
776 let branches = git_list_branches(dir.path())?;
778 assert!(!branches.branches.is_empty());
779
780 let current = branches.branches.iter().find(|b| b.is_current);
782 assert!(current.is_some());
783
784 Ok(())
785 }
786
787 #[test]
788 fn test_git_log() -> Result<()> {
789 let (dir, repo) = init_test_repo()?;
790
791 let mut config = repo.config()?;
793 config.set_str("user.name", "Test User")?;
794 config.set_str("user.email", "test@example.com")?;
795
796 fs::write(dir.path().join("test.txt"), "hello")?;
798 git_add(dir.path(), &[])?;
799 git_commit(dir.path(), "First commit")?;
800
801 fs::write(dir.path().join("test.txt"), "hello world")?;
802 git_add(dir.path(), &[])?;
803 git_commit(dir.path(), "Second commit")?;
804
805 let log = git_log(dir.path(), 10)?;
807 assert_eq!(log.commits.len(), 2);
808 assert_eq!(log.commits[0].message.trim(), "Second commit");
809 assert_eq!(log.commits[1].message.trim(), "First commit");
810
811 Ok(())
812 }
813
814 #[test]
815 fn test_git_diff() -> Result<()> {
816 let (dir, repo) = init_test_repo()?;
817
818 let mut config = repo.config()?;
820 config.set_str("user.name", "Test User")?;
821 config.set_str("user.email", "test@example.com")?;
822
823 fs::write(dir.path().join("test.txt"), "hello")?;
825 git_add(dir.path(), &[])?;
826 git_commit(dir.path(), "Initial")?;
827
828 fs::write(dir.path().join("test.txt"), "hello world")?;
830
831 let diff = git_diff(dir.path(), false)?;
833 assert_eq!(diff.files_changed, 1);
834 assert!(diff.insertions > 0 || diff.deletions > 0);
835
836 git_add(dir.path(), &[])?;
838 let staged_diff = git_diff(dir.path(), true)?;
839 assert_eq!(staged_diff.files_changed, 1);
840
841 Ok(())
842 }
843}