1use std::time::SystemTime;
2
3use anyhow::{Context, Result};
4use chrono::{Local, TimeZone};
5use git2::{Commit, Diff, Repository, Signature, Sort, Status, StatusOptions};
6
7use crate::event::{GitEvent, GitEventKind};
8
9pub fn get_head_hash() -> Result<String> {
11 let repo = Repository::discover(".").context("Gitリポジトリが見つかりません")?;
12 get_head_hash_from_repo(&repo)
13}
14
15pub fn get_head_hash_from_repo(repo: &Repository) -> Result<String> {
17 let head = repo.head().context("HEADが見つかりません")?;
18 let oid = head.target().context("HEADのターゲットが見つかりません")?;
19 Ok(oid.to_string())
20}
21
22pub fn get_index_mtime() -> Result<SystemTime> {
24 let repo = Repository::discover(".").context("Gitリポジトリが見つかりません")?;
25 get_index_mtime_from_repo(&repo)
26}
27
28pub fn get_index_mtime_from_repo(repo: &Repository) -> Result<SystemTime> {
30 let git_dir = repo.path();
31 let index_path = git_dir.join("index");
32 let metadata = std::fs::metadata(&index_path).context("indexファイルが見つかりません")?;
33 metadata.modified().context("更新時刻を取得できません")
34}
35
36pub fn get_user_name() -> Option<String> {
38 let repo = Repository::discover(".").ok()?;
39 get_user_name_from_repo(&repo)
40}
41
42pub fn get_user_name_from_repo(repo: &Repository) -> Option<String> {
44 let config = repo.config().ok()?;
45 config.get_string("user.name").ok()
46}
47
48#[derive(Debug, Clone, PartialEq)]
50pub enum FileStatusKind {
51 StagedNew,
53 StagedModified,
55 StagedDeleted,
57 Modified,
59 Deleted,
61 Untracked,
63}
64
65#[derive(Debug, Clone)]
67pub struct FileStatus {
68 pub path: String,
70 pub kind: FileStatusKind,
72}
73
74#[derive(Debug, Clone, Default)]
76#[allow(dead_code)]
77pub struct DiffStats {
78 pub files_changed: usize,
79 pub insertions: usize,
80 pub deletions: usize,
81}
82
83fn get_commit_diff_stats(repo: &Repository, commit: &Commit) -> DiffStats {
85 let tree = match commit.tree() {
86 Ok(t) => t,
87 Err(_) => return DiffStats::default(),
88 };
89
90 let parent_tree = commit.parent(0).ok().and_then(|p| p.tree().ok());
91
92 let diff: Diff = match repo.diff_tree_to_tree(parent_tree.as_ref(), Some(&tree), None) {
93 Ok(d) => d,
94 Err(_) => return DiffStats::default(),
95 };
96
97 match diff.stats() {
98 Ok(stats) => DiffStats {
99 files_changed: stats.files_changed(),
100 insertions: stats.insertions(),
101 deletions: stats.deletions(),
102 },
103 Err(_) => DiffStats::default(),
104 }
105}
106
107#[derive(Debug, Clone)]
109pub struct RepoInfo {
110 pub name: String,
112 pub branch: String,
114}
115
116impl RepoInfo {
117 pub fn from_current_dir() -> Result<Self> {
119 let repo = Repository::discover(".").context("Gitリポジトリが見つかりません")?;
120 Self::from_repo(&repo)
121 }
122
123 pub fn from_repo(repo: &Repository) -> Result<Self> {
125 let name = repo
126 .workdir()
127 .and_then(|p| p.file_name())
128 .and_then(|n| n.to_str())
129 .unwrap_or("unknown")
130 .to_string();
131
132 let branch = repo
133 .head()
134 .ok()
135 .and_then(|h| h.shorthand().map(|s| s.to_string()))
136 .unwrap_or_else(|| "HEAD".to_string());
137
138 Ok(Self { name, branch })
139 }
140}
141
142pub fn list_branches() -> Result<Vec<String>> {
144 let repo = Repository::discover(".").context("Gitリポジトリが見つかりません")?;
145 list_branches_from_repo(&repo)
146}
147
148pub fn list_branches_from_repo(repo: &Repository) -> Result<Vec<String>> {
150 let branches = repo.branches(Some(git2::BranchType::Local))?;
151 let mut branch_names: Vec<String> = branches
152 .filter_map(|b| b.ok())
153 .filter_map(|(branch, _)| branch.name().ok().flatten().map(|s| s.to_string()))
154 .collect();
155 branch_names.sort();
156 Ok(branch_names)
157}
158
159pub fn checkout_branch(branch_name: &str) -> Result<()> {
161 let repo = Repository::discover(".").context("Gitリポジトリが見つかりません")?;
162 checkout_branch_in_repo(&repo, branch_name)
163}
164
165pub fn checkout_branch_in_repo(repo: &Repository, branch_name: &str) -> Result<()> {
167 let obj = repo
168 .revparse_single(&format!("refs/heads/{}", branch_name))
169 .context("ブランチが見つかりません")?;
170 repo.checkout_tree(&obj, None)?;
171 repo.set_head(&format!("refs/heads/{}", branch_name))?;
172 Ok(())
173}
174
175pub fn get_status() -> Result<Vec<FileStatus>> {
177 let repo = Repository::discover(".").context("Gitリポジトリが見つかりません")?;
178 get_status_from_repo(&repo)
179}
180
181pub fn get_status_from_repo(repo: &Repository) -> Result<Vec<FileStatus>> {
183 let mut opts = StatusOptions::new();
184 opts.include_untracked(true)
185 .recurse_untracked_dirs(true)
186 .include_ignored(false);
187
188 let statuses = repo.statuses(Some(&mut opts))?;
189 let mut result = Vec::new();
190
191 for entry in statuses.iter() {
192 let path = entry.path().unwrap_or("").to_string();
193 let status = entry.status();
194
195 if status.contains(Status::INDEX_NEW) {
197 result.push(FileStatus {
198 path: path.clone(),
199 kind: FileStatusKind::StagedNew,
200 });
201 } else if status.contains(Status::INDEX_MODIFIED) {
202 result.push(FileStatus {
203 path: path.clone(),
204 kind: FileStatusKind::StagedModified,
205 });
206 } else if status.contains(Status::INDEX_DELETED) {
207 result.push(FileStatus {
208 path: path.clone(),
209 kind: FileStatusKind::StagedDeleted,
210 });
211 }
212
213 if status.contains(Status::WT_MODIFIED) {
215 result.push(FileStatus {
216 path: path.clone(),
217 kind: FileStatusKind::Modified,
218 });
219 } else if status.contains(Status::WT_DELETED) {
220 result.push(FileStatus {
221 path: path.clone(),
222 kind: FileStatusKind::Deleted,
223 });
224 } else if status.contains(Status::WT_NEW) {
225 result.push(FileStatus {
226 path,
227 kind: FileStatusKind::Untracked,
228 });
229 }
230 }
231
232 Ok(result)
233}
234
235pub fn stage_file(path: &str) -> Result<()> {
237 let repo = Repository::discover(".").context("Gitリポジトリが見つかりません")?;
238 stage_file_in_repo(&repo, path)
239}
240
241pub fn stage_file_in_repo(repo: &Repository, path: &str) -> Result<()> {
243 let mut index = repo.index()?;
244 let workdir = repo
245 .workdir()
246 .context("ワーキングディレクトリが見つかりません")?;
247 let full_path = workdir.join(path);
248
249 if full_path.exists() {
250 index.add_path(std::path::Path::new(path))?;
251 } else {
252 index.remove_path(std::path::Path::new(path))?;
254 }
255 index.write()?;
256 Ok(())
257}
258
259pub fn unstage_file(path: &str) -> Result<()> {
261 let repo = Repository::discover(".").context("Gitリポジトリが見つかりません")?;
262 unstage_file_in_repo(&repo, path)
263}
264
265pub fn unstage_file_in_repo(repo: &Repository, path: &str) -> Result<()> {
267 let head = repo.head()?.peel_to_commit()?;
268 repo.reset_default(Some(&head.into_object()), [std::path::Path::new(path)])?;
269 Ok(())
270}
271
272pub fn create_commit(message: &str) -> Result<()> {
274 let repo = Repository::discover(".").context("Gitリポジトリが見つかりません")?;
275 create_commit_in_repo(&repo, message)
276}
277
278pub fn create_commit_in_repo(repo: &Repository, message: &str) -> Result<()> {
280 let sig = Signature::now("gitstack", "gitstack@local")?;
281 let mut index = repo.index()?;
282 let tree_id = index.write_tree()?;
283 let tree = repo.find_tree(tree_id)?;
284 let parent = repo.head()?.peel_to_commit()?;
285
286 repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &[&parent])?;
287 Ok(())
288}
289
290pub fn push() -> Result<()> {
292 let repo = Repository::discover(".").context("Gitリポジトリが見つかりません")?;
293 push_in_repo(&repo)
294}
295
296pub fn push_in_repo(repo: &Repository) -> Result<()> {
298 let head = repo.head()?;
299 let branch_name = head.shorthand().context("ブランチ名が取得できません")?;
300
301 let mut remote = repo
302 .find_remote("origin")
303 .context("originリモートが見つかりません")?;
304 let refspec = format!("refs/heads/{}:refs/heads/{}", branch_name, branch_name);
305
306 remote.push(&[&refspec], None)?;
307 Ok(())
308}
309
310pub fn fetch_remote() -> Result<()> {
312 let repo = Repository::discover(".").context("Gitリポジトリが見つかりません")?;
313 fetch_remote_in_repo(&repo)
314}
315
316pub fn fetch_remote_at_path(repo_path: &std::path::Path) -> Result<()> {
318 let repo = Repository::open(repo_path).context("リポジトリを開けません")?;
319 fetch_remote_in_repo(&repo)
320}
321
322pub fn fetch_remote_in_repo(repo: &Repository) -> Result<()> {
324 let mut remote = repo
325 .find_remote("origin")
326 .context("originリモートが見つかりません")?;
327
328 remote.fetch(&[] as &[&str], None, None)?;
330 Ok(())
331}
332
333pub fn has_staged_files() -> Result<bool> {
335 let repo = Repository::discover(".").context("Gitリポジトリが見つかりません")?;
336 has_staged_files_in_repo(&repo)
337}
338
339pub fn has_staged_files_in_repo(repo: &Repository) -> Result<bool> {
341 let statuses = get_status_from_repo(repo)?;
342 Ok(statuses.iter().any(|s| {
343 matches!(
344 s.kind,
345 FileStatusKind::StagedNew
346 | FileStatusKind::StagedModified
347 | FileStatusKind::StagedDeleted
348 )
349 }))
350}
351
352pub fn load_events(limit: usize) -> Result<Vec<GitEvent>> {
354 let repo = Repository::discover(".").context("Gitリポジトリが見つかりません")?;
355 load_events_from_repo(&repo, limit)
356}
357
358pub fn load_events_from_repo(repo: &Repository, limit: usize) -> Result<Vec<GitEvent>> {
360 let mut revwalk = repo.revwalk()?;
361 revwalk.set_sorting(Sort::TIME | Sort::TOPOLOGICAL)?;
363 revwalk.push_head()?;
364
365 let mut events = Vec::new();
366
367 for oid in revwalk.take(limit) {
368 let oid = oid?;
369 let commit = repo.find_commit(oid)?;
370
371 let short_hash = oid.to_string()[..7].to_string();
372 let message = commit
373 .message()
374 .unwrap_or("")
375 .lines()
376 .next()
377 .unwrap_or("")
378 .to_string();
379 let author = commit.author().name().unwrap_or("unknown").to_string();
380 let timestamp = Local
381 .timestamp_opt(commit.time().seconds(), 0)
382 .single()
383 .unwrap_or_else(Local::now);
384
385 let kind = if commit.parent_count() > 1 {
387 GitEventKind::Merge
388 } else {
389 GitEventKind::Commit
390 };
391
392 let diff_stats = get_commit_diff_stats(repo, &commit);
394
395 let event = match kind {
396 GitEventKind::Merge => GitEvent::merge(short_hash, message, author, timestamp),
397 _ => GitEvent::commit(
398 short_hash,
399 message,
400 author,
401 timestamp,
402 diff_stats.insertions,
403 diff_stats.deletions,
404 ),
405 };
406
407 events.push(event);
408 }
409
410 Ok(events)
411}
412
413#[cfg(test)]
414mod tests {
415 use super::*;
416 use std::fs;
417 use std::path::Path;
418 use tempfile::TempDir;
419
420 fn init_test_repo() -> (TempDir, Repository) {
421 let temp_dir = TempDir::new().expect("Failed to create temp dir");
422 let repo = Repository::init(temp_dir.path()).expect("Failed to init repo");
423
424 let sig = git2::Signature::now("Test Author", "test@example.com").unwrap();
426 let tree_id = {
427 let mut index = repo.index().unwrap();
428 let test_file = temp_dir.path().join("test.txt");
429 fs::write(&test_file, "test content").unwrap();
430 index.add_path(Path::new("test.txt")).unwrap();
431 index.write().unwrap();
432 index.write_tree().unwrap()
433 };
434 {
435 let tree = repo.find_tree(tree_id).unwrap();
436 repo.commit(Some("HEAD"), &sig, &sig, "Initial commit", &tree, &[])
437 .unwrap();
438 }
439
440 (temp_dir, repo)
441 }
442
443 #[test]
444 fn test_repo_info_from_repo_gets_name() {
445 let (_temp_dir, repo) = init_test_repo();
446 let info = RepoInfo::from_repo(&repo).unwrap();
447 assert!(!info.name.is_empty());
448 }
449
450 #[test]
451 fn test_repo_info_from_repo_gets_branch() {
452 let (_temp_dir, repo) = init_test_repo();
453 let info = RepoInfo::from_repo(&repo).unwrap();
454 assert!(info.branch == "master" || info.branch == "main");
456 }
457
458 #[test]
459 fn test_load_events_from_repo_returns_events() {
460 let (_temp_dir, repo) = init_test_repo();
461 let events = load_events_from_repo(&repo, 10).unwrap();
462 assert!(!events.is_empty());
463 }
464
465 #[test]
466 fn test_load_events_from_repo_first_event_is_initial_commit() {
467 let (_temp_dir, repo) = init_test_repo();
468 let events = load_events_from_repo(&repo, 10).unwrap();
469 assert_eq!(events[0].message, "Initial commit");
470 }
471
472 #[test]
473 fn test_load_events_from_repo_respects_limit() {
474 let (temp_dir, repo) = init_test_repo();
475
476 let sig = git2::Signature::now("Test Author", "test@example.com").unwrap();
478 for i in 1..=5 {
479 let test_file = temp_dir.path().join(format!("file{}.txt", i));
480 fs::write(&test_file, format!("content {}", i)).unwrap();
481 let mut index = repo.index().unwrap();
482 index
483 .add_path(Path::new(&format!("file{}.txt", i)))
484 .unwrap();
485 index.write().unwrap();
486 let tree_id = index.write_tree().unwrap();
487 let tree = repo.find_tree(tree_id).unwrap();
488 let parent = repo.head().unwrap().peel_to_commit().unwrap();
489 repo.commit(
490 Some("HEAD"),
491 &sig,
492 &sig,
493 &format!("Commit {}", i),
494 &tree,
495 &[&parent],
496 )
497 .unwrap();
498 }
499
500 let events = load_events_from_repo(&repo, 3).unwrap();
501 assert_eq!(events.len(), 3);
502 }
503
504 #[test]
505 fn test_load_events_from_repo_returns_commits_in_order() {
506 let (temp_dir, repo) = init_test_repo();
507
508 let sig = git2::Signature::now("Test Author", "test@example.com").unwrap();
510 for i in 1..=3 {
511 let test_file = temp_dir.path().join(format!("file{}.txt", i));
512 fs::write(&test_file, format!("content {}", i)).unwrap();
513 let mut index = repo.index().unwrap();
514 index
515 .add_path(Path::new(&format!("file{}.txt", i)))
516 .unwrap();
517 index.write().unwrap();
518 let tree_id = index.write_tree().unwrap();
519 let tree = repo.find_tree(tree_id).unwrap();
520 let parent = repo.head().unwrap().peel_to_commit().unwrap();
521 repo.commit(
522 Some("HEAD"),
523 &sig,
524 &sig,
525 &format!("Commit {}", i),
526 &tree,
527 &[&parent],
528 )
529 .unwrap();
530 }
531
532 let events = load_events_from_repo(&repo, 10).unwrap();
533 assert_eq!(events.len(), 4);
535 assert!(events.iter().any(|e| e.message == "Commit 3"));
537 assert!(events.iter().any(|e| e.message == "Initial commit"));
539 }
540
541 #[test]
542 fn test_load_events_from_repo_event_has_short_hash() {
543 let (_temp_dir, repo) = init_test_repo();
544 let events = load_events_from_repo(&repo, 10).unwrap();
545 assert_eq!(events[0].short_hash.len(), 7);
546 }
547
548 #[test]
549 fn test_load_events_from_repo_event_has_author() {
550 let (_temp_dir, repo) = init_test_repo();
551 let events = load_events_from_repo(&repo, 10).unwrap();
552 assert_eq!(events[0].author, "Test Author");
553 }
554
555 #[test]
556 fn test_load_events_from_repo_event_has_file_stats() {
557 let (_temp_dir, repo) = init_test_repo();
558 let events = load_events_from_repo(&repo, 10).unwrap();
559 assert!(events[0].files_added > 0);
561 }
562
563 #[test]
564 fn test_get_commit_diff_stats_returns_stats() {
565 let (_temp_dir, repo) = init_test_repo();
566 let commit = repo.head().unwrap().peel_to_commit().unwrap();
567 let stats = get_commit_diff_stats(&repo, &commit);
568 assert!(stats.files_changed > 0 || stats.insertions > 0);
570 }
571
572 #[test]
573 fn test_list_branches_from_repo_returns_branches() {
574 let (_temp_dir, repo) = init_test_repo();
575 let branches = list_branches_from_repo(&repo).unwrap();
576 assert!(!branches.is_empty());
577 }
578
579 #[test]
580 fn test_list_branches_from_repo_includes_current_branch() {
581 let (_temp_dir, repo) = init_test_repo();
582 let branches = list_branches_from_repo(&repo).unwrap();
583 assert!(branches.contains(&"master".to_string()) || branches.contains(&"main".to_string()));
585 }
586
587 #[test]
588 fn test_checkout_branch_in_repo_switches_branch() {
589 let (_temp_dir, repo) = init_test_repo();
590
591 {
593 let head = repo.head().unwrap().peel_to_commit().unwrap();
594 repo.branch("test-branch", &head, false).unwrap();
595 }
596
597 checkout_branch_in_repo(&repo, "test-branch").unwrap();
599
600 let info = RepoInfo::from_repo(&repo).unwrap();
602 assert_eq!(info.branch, "test-branch");
603 }
604
605 #[test]
606 fn test_get_status_from_repo_empty_on_clean() {
607 let (_temp_dir, repo) = init_test_repo();
608 let statuses = get_status_from_repo(&repo).unwrap();
609 assert!(statuses.is_empty());
610 }
611
612 #[test]
613 fn test_get_status_from_repo_detects_modified() {
614 let (temp_dir, repo) = init_test_repo();
615 fs::write(temp_dir.path().join("test.txt"), "modified content").unwrap();
616
617 let statuses = get_status_from_repo(&repo).unwrap();
618 assert!(statuses.iter().any(|s| s.kind == FileStatusKind::Modified));
619 }
620
621 #[test]
622 fn test_get_status_from_repo_detects_untracked() {
623 let (temp_dir, repo) = init_test_repo();
624 fs::write(temp_dir.path().join("new_file.txt"), "new content").unwrap();
625
626 let statuses = get_status_from_repo(&repo).unwrap();
627 assert!(statuses.iter().any(|s| s.kind == FileStatusKind::Untracked));
628 }
629
630 #[test]
631 fn test_stage_file_in_repo_stages_file() {
632 let (temp_dir, repo) = init_test_repo();
633 fs::write(temp_dir.path().join("new_file.txt"), "new content").unwrap();
634
635 stage_file_in_repo(&repo, "new_file.txt").unwrap();
636
637 let statuses = get_status_from_repo(&repo).unwrap();
638 assert!(statuses.iter().any(|s| s.kind == FileStatusKind::StagedNew));
639 }
640
641 #[test]
642 fn test_unstage_file_in_repo_unstages_file() {
643 let (temp_dir, repo) = init_test_repo();
644 fs::write(temp_dir.path().join("new_file.txt"), "new content").unwrap();
645
646 stage_file_in_repo(&repo, "new_file.txt").unwrap();
647 unstage_file_in_repo(&repo, "new_file.txt").unwrap();
648
649 let statuses = get_status_from_repo(&repo).unwrap();
650 assert!(!statuses.iter().any(|s| s.kind == FileStatusKind::StagedNew));
651 assert!(statuses.iter().any(|s| s.kind == FileStatusKind::Untracked));
652 }
653
654 #[test]
655 fn test_create_commit_in_repo_creates_commit() {
656 let (temp_dir, repo) = init_test_repo();
657 fs::write(temp_dir.path().join("new_file.txt"), "new content").unwrap();
658 stage_file_in_repo(&repo, "new_file.txt").unwrap();
659
660 create_commit_in_repo(&repo, "Test commit").unwrap();
661
662 let events = load_events_from_repo(&repo, 10).unwrap();
663 assert!(events.iter().any(|e| e.message == "Test commit"));
664 }
665
666 #[test]
667 fn test_has_staged_files_in_repo_returns_false_when_empty() {
668 let (_temp_dir, repo) = init_test_repo();
669 assert!(!has_staged_files_in_repo(&repo).unwrap());
670 }
671
672 #[test]
673 fn test_has_staged_files_in_repo_returns_true_when_staged() {
674 let (temp_dir, repo) = init_test_repo();
675 fs::write(temp_dir.path().join("new_file.txt"), "new content").unwrap();
676 stage_file_in_repo(&repo, "new_file.txt").unwrap();
677
678 assert!(has_staged_files_in_repo(&repo).unwrap());
679 }
680}