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("Vibe Graph", "vibe-graph@local", &Time::new(chrono_timestamp(), 0))
414 })
415 .context("Failed to get signature")?;
416
417 let parent_commit = match repo.head() {
419 Ok(head) => Some(head.peel_to_commit().context("Failed to get parent commit")?),
420 Err(_) => None, };
422
423 let parents: Vec<&git2::Commit> = parent_commit.iter().collect();
424
425 let commit_id = repo
427 .commit(
428 Some("HEAD"),
429 &signature,
430 &signature,
431 message,
432 &tree,
433 &parents,
434 )
435 .context("Failed to create commit")?;
436
437 Ok(GitCommitResult {
438 commit_id: commit_id.to_string(),
439 message: message.to_string(),
440 file_count: staged_count,
441 })
442}
443
444pub fn git_reset(repo_path: &Path, paths: &[PathBuf]) -> Result<GitResetResult> {
448 let repo = Repository::open(repo_path)
449 .with_context(|| format!("Failed to open repository at {:?}", repo_path))?;
450
451 let head = repo.head().ok();
452 let head_commit = head.as_ref().and_then(|h| h.peel_to_commit().ok());
453
454 let unstaged_files = if paths.is_empty() {
455 let statuses = repo.statuses(None)?;
457 let staged_files: Vec<PathBuf> = statuses
458 .iter()
459 .filter(|e| {
460 let s = e.status();
461 s.contains(Status::INDEX_NEW)
462 || s.contains(Status::INDEX_MODIFIED)
463 || s.contains(Status::INDEX_DELETED)
464 })
465 .filter_map(|e| e.path().map(PathBuf::from))
466 .collect();
467
468 if !staged_files.is_empty() {
470 if let Some(commit) = &head_commit {
471 let path_refs: Vec<&Path> = staged_files.iter().map(|p| p.as_path()).collect();
472 repo.reset_default(Some(commit.as_object()), path_refs.iter().copied())
473 .context("Failed to reset index")?;
474 } else {
475 let mut index = repo.index()?;
477 for path in &staged_files {
478 let _ = index.remove_path(path);
479 }
480 index.write()?;
481 }
482 }
483
484 staged_files
485 } else {
486 if let Some(commit) = &head_commit {
488 let path_refs: Vec<&Path> = paths.iter().map(|p| p.as_path()).collect();
489 repo.reset_default(Some(commit.as_object()), path_refs.iter().copied())
490 .context("Failed to reset files")?;
491 }
492 paths.to_vec()
493 };
494
495 let count = unstaged_files.len();
496 Ok(GitResetResult {
497 unstaged_files,
498 count,
499 })
500}
501
502pub fn git_list_branches(repo_path: &Path) -> Result<GitBranchListResult> {
504 let repo = Repository::open(repo_path)
505 .with_context(|| format!("Failed to open repository at {:?}", repo_path))?;
506
507 let mut branches = Vec::new();
508 let mut current_branch = None;
509
510 if let Ok(head) = repo.head() {
512 if head.is_branch() {
513 current_branch = head.shorthand().map(String::from);
514 }
515 }
516
517 for branch_result in repo.branches(None)? {
519 let (branch, branch_type) = branch_result?;
520 let name = branch.name()?.unwrap_or("").to_string();
521 let is_remote = matches!(branch_type, git2::BranchType::Remote);
522 let is_current = Some(&name) == current_branch.as_ref();
523
524 let commit_id = branch
525 .get()
526 .peel_to_commit()
527 .ok()
528 .map(|c| c.id().to_string());
529
530 branches.push(GitBranch {
531 name,
532 is_current,
533 is_remote,
534 commit_id,
535 });
536 }
537
538 Ok(GitBranchListResult {
539 branches,
540 current: current_branch,
541 })
542}
543
544pub fn git_checkout_branch(repo_path: &Path, branch_name: &str) -> Result<()> {
546 let repo = Repository::open(repo_path)
547 .with_context(|| format!("Failed to open repository at {:?}", repo_path))?;
548
549 let branch = repo
551 .find_branch(branch_name, git2::BranchType::Local)
552 .with_context(|| format!("Branch '{}' not found", branch_name))?;
553
554 let reference = branch.get();
555 let commit = reference
556 .peel_to_commit()
557 .context("Failed to get commit for branch")?;
558
559 let tree = commit.tree().context("Failed to get tree")?;
561 repo.checkout_tree(tree.as_object(), None)
562 .context("Failed to checkout tree")?;
563
564 repo.set_head(reference.name().unwrap_or(""))
566 .context("Failed to set HEAD")?;
567
568 Ok(())
569}
570
571pub fn git_log(repo_path: &Path, limit: usize) -> Result<GitLogResult> {
573 let repo = Repository::open(repo_path)
574 .with_context(|| format!("Failed to open repository at {:?}", repo_path))?;
575
576 let mut revwalk = repo.revwalk().context("Failed to create revwalk")?;
577 revwalk.push_head().context("Failed to push HEAD")?;
578
579 let mut commits = Vec::new();
580
581 for (i, oid_result) in revwalk.enumerate() {
582 if i >= limit {
583 break;
584 }
585
586 let oid = oid_result.context("Failed to get commit OID")?;
587 let commit = repo.find_commit(oid).context("Failed to find commit")?;
588
589 let author = commit.author();
590 commits.push(GitLogEntry {
591 commit_id: oid.to_string(),
592 short_id: oid.to_string()[..7].to_string(),
593 message: commit.message().unwrap_or("").to_string(),
594 author: author.name().unwrap_or("Unknown").to_string(),
595 author_email: author.email().unwrap_or("").to_string(),
596 timestamp: author.when().seconds(),
597 });
598 }
599
600 Ok(GitLogResult { commits })
601}
602
603pub fn git_diff(repo_path: &Path, staged: bool) -> Result<GitDiffResult> {
605 let repo = Repository::open(repo_path)
606 .with_context(|| format!("Failed to open repository at {:?}", repo_path))?;
607
608 let mut diff_opts = git2::DiffOptions::new();
609 diff_opts.include_untracked(true);
610
611 let diff = if staged {
612 let head_tree = repo
614 .head()
615 .ok()
616 .and_then(|h| h.peel_to_tree().ok());
617 repo.diff_tree_to_index(head_tree.as_ref(), None, Some(&mut diff_opts))
618 .context("Failed to get staged diff")?
619 } else {
620 repo.diff_index_to_workdir(None, Some(&mut diff_opts))
622 .context("Failed to get working directory diff")?
623 };
624
625 let stats = diff.stats().context("Failed to get diff stats")?;
626
627 let mut diff_text = String::new();
628 diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
629 let prefix = match line.origin() {
630 '+' => "+",
631 '-' => "-",
632 ' ' => " ",
633 _ => "",
634 };
635 diff_text.push_str(prefix);
636 diff_text.push_str(std::str::from_utf8(line.content()).unwrap_or(""));
637 true
638 })
639 .context("Failed to print diff")?;
640
641 Ok(GitDiffResult {
642 diff: diff_text,
643 files_changed: stats.files_changed(),
644 insertions: stats.insertions(),
645 deletions: stats.deletions(),
646 })
647}
648
649fn chrono_timestamp() -> i64 {
651 std::time::SystemTime::now()
652 .duration_since(std::time::UNIX_EPOCH)
653 .map(|d| d.as_secs() as i64)
654 .unwrap_or(0)
655}
656
657#[cfg(test)]
658mod tests {
659 use super::*;
660 use std::fs;
661 use tempfile::TempDir;
662
663 fn init_test_repo() -> Result<(TempDir, Repository)> {
664 let dir = TempDir::new()?;
665 let repo = Repository::init(dir.path())?;
666 Ok((dir, repo))
667 }
668
669 #[test]
670 fn test_watcher_empty_repo() -> Result<()> {
671 let (dir, _repo) = init_test_repo()?;
672 let mut watcher = GitWatcher::new(dir.path());
673 let snapshot = watcher.force_poll()?;
674 assert!(snapshot.changes.is_empty());
675 Ok(())
676 }
677
678 #[test]
679 fn test_watcher_detects_new_file() -> Result<()> {
680 let (dir, _repo) = init_test_repo()?;
681 fs::write(dir.path().join("new_file.txt"), "hello")?;
682
683 let mut watcher = GitWatcher::new(dir.path());
684 let snapshot = watcher.force_poll()?;
685
686 assert_eq!(snapshot.changes.len(), 1);
687 assert_eq!(snapshot.changes[0].kind, GitChangeKind::Untracked);
688 assert!(!snapshot.changes[0].staged);
689 Ok(())
690 }
691
692 #[test]
693 fn test_watcher_rate_limiting() -> Result<()> {
694 let (dir, _repo) = init_test_repo()?;
695 let config = GitWatcherConfig {
696 min_poll_interval: Duration::from_secs(60), ..Default::default()
698 };
699 let mut watcher = GitWatcher::with_config(dir.path(), config);
700
701 assert!(watcher.should_poll());
703 watcher.poll()?;
704
705 assert!(!watcher.should_poll());
707 Ok(())
708 }
709
710 #[test]
715 fn test_git_add_and_commit() -> Result<()> {
716 let (dir, repo) = init_test_repo()?;
717
718 let mut config = repo.config()?;
720 config.set_str("user.name", "Test User")?;
721 config.set_str("user.email", "test@example.com")?;
722
723 fs::write(dir.path().join("test.txt"), "hello world")?;
725
726 let add_result = git_add(dir.path(), &[])?;
728 assert_eq!(add_result.count, 1);
729
730 let commit_result = git_commit(dir.path(), "Initial commit")?;
732 assert_eq!(commit_result.message, "Initial commit");
733 assert!(!commit_result.commit_id.is_empty());
734
735 Ok(())
736 }
737
738 #[test]
739 fn test_git_commit_fails_without_staged() -> Result<()> {
740 let (dir, repo) = init_test_repo()?;
741
742 let mut config = repo.config()?;
744 config.set_str("user.name", "Test User")?;
745 config.set_str("user.email", "test@example.com")?;
746
747 let result = git_commit(dir.path(), "Empty commit");
749 assert!(result.is_err());
750 assert!(result
751 .unwrap_err()
752 .to_string()
753 .contains("Nothing to commit"));
754
755 Ok(())
756 }
757
758 #[test]
759 fn test_git_branches() -> Result<()> {
760 let (dir, repo) = init_test_repo()?;
761
762 let mut config = repo.config()?;
764 config.set_str("user.name", "Test User")?;
765 config.set_str("user.email", "test@example.com")?;
766
767 fs::write(dir.path().join("test.txt"), "hello")?;
769 git_add(dir.path(), &[])?;
770 git_commit(dir.path(), "Initial")?;
771
772 let branches = git_list_branches(dir.path())?;
774 assert!(!branches.branches.is_empty());
775
776 let current = branches.branches.iter().find(|b| b.is_current);
778 assert!(current.is_some());
779
780 Ok(())
781 }
782
783 #[test]
784 fn test_git_log() -> Result<()> {
785 let (dir, repo) = init_test_repo()?;
786
787 let mut config = repo.config()?;
789 config.set_str("user.name", "Test User")?;
790 config.set_str("user.email", "test@example.com")?;
791
792 fs::write(dir.path().join("test.txt"), "hello")?;
794 git_add(dir.path(), &[])?;
795 git_commit(dir.path(), "First commit")?;
796
797 fs::write(dir.path().join("test.txt"), "hello world")?;
798 git_add(dir.path(), &[])?;
799 git_commit(dir.path(), "Second commit")?;
800
801 let log = git_log(dir.path(), 10)?;
803 assert_eq!(log.commits.len(), 2);
804 assert_eq!(log.commits[0].message.trim(), "Second commit");
805 assert_eq!(log.commits[1].message.trim(), "First commit");
806
807 Ok(())
808 }
809
810 #[test]
811 fn test_git_diff() -> Result<()> {
812 let (dir, repo) = init_test_repo()?;
813
814 let mut config = repo.config()?;
816 config.set_str("user.name", "Test User")?;
817 config.set_str("user.email", "test@example.com")?;
818
819 fs::write(dir.path().join("test.txt"), "hello")?;
821 git_add(dir.path(), &[])?;
822 git_commit(dir.path(), "Initial")?;
823
824 fs::write(dir.path().join("test.txt"), "hello world")?;
826
827 let diff = git_diff(dir.path(), false)?;
829 assert_eq!(diff.files_changed, 1);
830 assert!(diff.insertions > 0 || diff.deletions > 0);
831
832 git_add(dir.path(), &[])?;
834 let staged_diff = git_diff(dir.path(), true)?;
835 assert_eq!(staged_diff.files_changed, 1);
836
837 Ok(())
838 }
839}