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
9fn validate_path(path: &str) -> Result<()> {
11 if path.contains('\0') {
13 return Err(anyhow::anyhow!(
14 "無効なパス: ヌル文字を含むことはできません"
15 ));
16 }
17
18 let normalized = std::path::Path::new(path);
20 if normalized
21 .components()
22 .any(|c| matches!(c, std::path::Component::ParentDir))
23 {
24 return Err(anyhow::anyhow!(
25 "無効なパス: 親ディレクトリ参照は許可されていません"
26 ));
27 }
28
29 Ok(())
30}
31
32fn validate_branch_name(name: &str) -> Result<()> {
34 if name.is_empty() {
35 return Err(anyhow::anyhow!(
36 "無効なブランチ名: 空文字列は許可されていません"
37 ));
38 }
39 if name.contains('\0') {
40 return Err(anyhow::anyhow!(
41 "無効なブランチ名: ヌル文字を含むことはできません"
42 ));
43 }
44 Ok(())
45}
46
47pub fn get_head_hash() -> Result<String> {
49 let repo = Repository::discover(".").context("Gitリポジトリが見つかりません")?;
50 get_head_hash_from_repo(&repo)
51}
52
53pub fn get_head_hash_from_repo(repo: &Repository) -> Result<String> {
55 let head = repo.head().context("HEADが見つかりません")?;
56 let oid = head.target().context("HEADのターゲットが見つかりません")?;
57 Ok(oid.to_string())
58}
59
60pub fn get_index_mtime() -> Result<SystemTime> {
62 let repo = Repository::discover(".").context("Gitリポジトリが見つかりません")?;
63 get_index_mtime_from_repo(&repo)
64}
65
66pub fn get_index_mtime_from_repo(repo: &Repository) -> Result<SystemTime> {
68 let git_dir = repo.path();
69 let index_path = git_dir.join("index");
70 let metadata = std::fs::metadata(&index_path).context("indexファイルが見つかりません")?;
71 metadata.modified().context("更新時刻を取得できません")
72}
73
74pub fn get_user_name() -> Option<String> {
76 let repo = Repository::discover(".").ok()?;
77 get_user_name_from_repo(&repo)
78}
79
80pub fn get_user_name_from_repo(repo: &Repository) -> Option<String> {
82 let config = repo.config().ok()?;
83 config.get_string("user.name").ok()
84}
85
86#[derive(Debug, Clone, PartialEq)]
88pub enum FileStatusKind {
89 StagedNew,
91 StagedModified,
93 StagedDeleted,
95 Modified,
97 Deleted,
99 Untracked,
101}
102
103impl FileStatusKind {
104 pub fn is_staged(&self) -> bool {
106 matches!(
107 self,
108 FileStatusKind::StagedNew
109 | FileStatusKind::StagedModified
110 | FileStatusKind::StagedDeleted
111 )
112 }
113}
114
115#[derive(Debug, Clone)]
117pub struct FileStatus {
118 pub path: String,
120 pub kind: FileStatusKind,
122}
123
124#[derive(Debug, Clone, Default)]
126pub struct DiffStats {
127 pub files_changed: usize,
128 pub insertions: usize,
129 pub deletions: usize,
130}
131
132#[derive(Debug, Clone, Copy, PartialEq)]
134pub enum FileChangeStatus {
135 Added,
137 Modified,
139 Deleted,
141 Renamed,
143}
144
145impl FileChangeStatus {
146 pub fn as_char(&self) -> char {
148 match self {
149 Self::Added => 'A',
150 Self::Modified => 'M',
151 Self::Deleted => 'D',
152 Self::Renamed => 'R',
153 }
154 }
155}
156
157#[derive(Debug, Clone)]
159pub struct FileChange {
160 pub path: String,
162 pub status: FileChangeStatus,
164 pub insertions: usize,
166 pub deletions: usize,
168}
169
170#[derive(Debug, Clone, Default)]
172pub struct CommitDiff {
173 pub stats: DiffStats,
175 pub files: Vec<FileChange>,
177}
178
179fn get_commit_diff_stats(repo: &Repository, commit: &Commit) -> DiffStats {
181 let tree = match commit.tree() {
182 Ok(t) => t,
183 Err(_) => return DiffStats::default(),
184 };
185
186 let parent_tree = commit.parent(0).ok().and_then(|p| p.tree().ok());
187
188 let diff: Diff = match repo.diff_tree_to_tree(parent_tree.as_ref(), Some(&tree), None) {
189 Ok(d) => d,
190 Err(_) => return DiffStats::default(),
191 };
192
193 match diff.stats() {
194 Ok(stats) => DiffStats {
195 files_changed: stats.files_changed(),
196 insertions: stats.insertions(),
197 deletions: stats.deletions(),
198 },
199 Err(_) => DiffStats::default(),
200 }
201}
202
203pub fn get_commit_diff(commit_hash: &str) -> Result<CommitDiff> {
205 let repo = Repository::discover(".").context("Gitリポジトリが見つかりません")?;
206 get_commit_diff_from_repo(&repo, commit_hash)
207}
208
209pub fn get_commit_diff_from_repo(repo: &Repository, commit_hash: &str) -> Result<CommitDiff> {
211 use std::cell::RefCell;
212
213 let obj = repo
215 .revparse_single(commit_hash)
216 .context("コミットが見つかりません")?;
217 let commit = obj.peel_to_commit().context("コミットに変換できません")?;
218
219 let tree = commit.tree()?;
220 let parent_tree = commit.parent(0).ok().and_then(|p| p.tree().ok());
221
222 let diff = repo.diff_tree_to_tree(parent_tree.as_ref(), Some(&tree), None)?;
223
224 let stats = match diff.stats() {
226 Ok(s) => DiffStats {
227 files_changed: s.files_changed(),
228 insertions: s.insertions(),
229 deletions: s.deletions(),
230 },
231 Err(_) => DiffStats::default(),
232 };
233
234 let files: RefCell<Vec<FileChange>> = RefCell::new(Vec::new());
236 let file_stats: RefCell<std::collections::HashMap<String, (usize, usize)>> =
237 RefCell::new(std::collections::HashMap::new());
238
239 diff.foreach(
241 &mut |delta, _| {
242 let path = delta
243 .new_file()
244 .path()
245 .or_else(|| delta.old_file().path())
246 .and_then(|p| p.to_str())
247 .unwrap_or("")
248 .to_string();
249
250 let status = match delta.status() {
251 git2::Delta::Added => FileChangeStatus::Added,
252 git2::Delta::Deleted => FileChangeStatus::Deleted,
253 git2::Delta::Modified => FileChangeStatus::Modified,
254 git2::Delta::Renamed => FileChangeStatus::Renamed,
255 _ => FileChangeStatus::Modified,
256 };
257
258 files.borrow_mut().push(FileChange {
259 path,
260 status,
261 insertions: 0,
262 deletions: 0,
263 });
264 true
265 },
266 None,
267 None,
268 Some(&mut |delta, _hunk, line| {
269 if let Some(path) = delta
270 .new_file()
271 .path()
272 .or_else(|| delta.old_file().path())
273 .and_then(|p| p.to_str())
274 {
275 let mut stats = file_stats.borrow_mut();
276 let entry = stats.entry(path.to_string()).or_insert((0, 0));
277 match line.origin() {
278 '+' => entry.0 += 1,
279 '-' => entry.1 += 1,
280 _ => {}
281 }
282 }
283 true
284 }),
285 )?;
286
287 let mut files_vec = files.into_inner();
289 let stats_map = file_stats.into_inner();
290 for file in &mut files_vec {
291 if let Some((ins, del)) = stats_map.get(&file.path) {
292 file.insertions = *ins;
293 file.deletions = *del;
294 }
295 }
296
297 Ok(CommitDiff {
298 stats,
299 files: files_vec,
300 })
301}
302
303#[derive(Debug, Clone)]
305pub struct RepoInfo {
306 pub name: String,
308 pub branch: String,
310}
311
312impl RepoInfo {
313 pub fn from_current_dir() -> Result<Self> {
315 let repo = Repository::discover(".").context("Gitリポジトリが見つかりません")?;
316 Self::from_repo(&repo)
317 }
318
319 pub fn from_repo(repo: &Repository) -> Result<Self> {
321 let name = repo
322 .workdir()
323 .and_then(|p| p.file_name())
324 .and_then(|n| n.to_str())
325 .unwrap_or("unknown")
326 .to_string();
327
328 let branch = repo
329 .head()
330 .ok()
331 .and_then(|h| h.shorthand().map(|s| s.to_string()))
332 .unwrap_or_else(|| "HEAD".to_string());
333
334 Ok(Self { name, branch })
335 }
336}
337
338pub fn list_branches() -> Result<Vec<String>> {
340 let repo = Repository::discover(".").context("Gitリポジトリが見つかりません")?;
341 list_branches_from_repo(&repo)
342}
343
344pub fn list_branches_from_repo(repo: &Repository) -> Result<Vec<String>> {
347 let branches = repo.branches(Some(git2::BranchType::Local))?;
348
349 let current_branch = repo
351 .head()
352 .ok()
353 .and_then(|h| h.shorthand().map(|s| s.to_string()));
354
355 let mut branch_names: Vec<String> = branches
358 .filter_map(|b| b.ok())
359 .filter_map(|(branch, _)| {
360 let name = branch.name().ok().flatten()?.to_string();
361 Some(name)
362 })
363 .collect();
364
365 sort_branches(&mut branch_names, current_branch.as_deref());
367
368 Ok(branch_names)
369}
370
371fn sort_branches(branches: &mut [String], current_branch: Option<&str>) {
376 let priority_branches = ["main", "master", "develop"];
377
378 branches.sort_by(|a, b| {
379 let a_is_current = current_branch.is_some_and(|c| c == a);
380 let b_is_current = current_branch.is_some_and(|c| c == b);
381
382 if a_is_current && !b_is_current {
384 return std::cmp::Ordering::Less;
385 }
386 if !a_is_current && b_is_current {
387 return std::cmp::Ordering::Greater;
388 }
389
390 let a_priority = priority_branches.iter().position(|&p| p == a);
392 let b_priority = priority_branches.iter().position(|&p| p == b);
393
394 match (a_priority, b_priority) {
395 (Some(ap), Some(bp)) => ap.cmp(&bp),
396 (Some(_), None) => std::cmp::Ordering::Less,
397 (None, Some(_)) => std::cmp::Ordering::Greater,
398 (None, None) => a.cmp(b),
399 }
400 });
401}
402
403pub fn checkout_branch(branch_name: &str) -> Result<()> {
405 let repo = Repository::discover(".").context("Gitリポジトリが見つかりません")?;
406 checkout_branch_in_repo(&repo, branch_name)
407}
408
409pub fn checkout_branch_in_repo(repo: &Repository, branch_name: &str) -> Result<()> {
411 validate_branch_name(branch_name)?;
412
413 let obj = repo
414 .revparse_single(&format!("refs/heads/{}", branch_name))
415 .context(format!("ブランチ '{}' が見つかりません", branch_name))?;
416 repo.checkout_tree(&obj, None)?;
417 repo.set_head(&format!("refs/heads/{}", branch_name))?;
418 Ok(())
419}
420
421pub fn get_status() -> Result<Vec<FileStatus>> {
423 let repo = Repository::discover(".").context("Gitリポジトリが見つかりません")?;
424 get_status_from_repo(&repo)
425}
426
427pub fn get_status_from_repo(repo: &Repository) -> Result<Vec<FileStatus>> {
429 let mut opts = StatusOptions::new();
430 opts.include_untracked(true)
431 .recurse_untracked_dirs(true)
432 .include_ignored(false);
433
434 let statuses = repo.statuses(Some(&mut opts))?;
435 let mut result = Vec::new();
436
437 for entry in statuses.iter() {
438 let path = entry.path().unwrap_or("").to_string();
439 let status = entry.status();
440
441 if status.contains(Status::INDEX_NEW) {
443 result.push(FileStatus {
444 path: path.clone(),
445 kind: FileStatusKind::StagedNew,
446 });
447 } else if status.contains(Status::INDEX_MODIFIED) {
448 result.push(FileStatus {
449 path: path.clone(),
450 kind: FileStatusKind::StagedModified,
451 });
452 } else if status.contains(Status::INDEX_DELETED) {
453 result.push(FileStatus {
454 path: path.clone(),
455 kind: FileStatusKind::StagedDeleted,
456 });
457 }
458
459 if status.contains(Status::WT_MODIFIED) {
461 result.push(FileStatus {
462 path: path.clone(),
463 kind: FileStatusKind::Modified,
464 });
465 } else if status.contains(Status::WT_DELETED) {
466 result.push(FileStatus {
467 path: path.clone(),
468 kind: FileStatusKind::Deleted,
469 });
470 } else if status.contains(Status::WT_NEW) {
471 result.push(FileStatus {
472 path,
473 kind: FileStatusKind::Untracked,
474 });
475 }
476 }
477
478 Ok(result)
479}
480
481pub fn stage_file(path: &str) -> Result<()> {
483 let repo = Repository::discover(".").context("Gitリポジトリが見つかりません")?;
484 stage_file_in_repo(&repo, path)
485}
486
487pub fn stage_file_in_repo(repo: &Repository, path: &str) -> Result<()> {
489 validate_path(path)?;
490
491 let mut index = repo.index()?;
492 let workdir = repo
493 .workdir()
494 .context("ワーキングディレクトリが見つかりません")?;
495 let full_path = workdir.join(path);
496
497 if full_path.exists() {
498 index.add_path(std::path::Path::new(path))?;
499 } else {
500 index.remove_path(std::path::Path::new(path))?;
502 }
503 index.write()?;
504 Ok(())
505}
506
507pub fn unstage_file(path: &str) -> Result<()> {
509 let repo = Repository::discover(".").context("Gitリポジトリが見つかりません")?;
510 unstage_file_in_repo(&repo, path)
511}
512
513pub fn unstage_file_in_repo(repo: &Repository, path: &str) -> Result<()> {
515 validate_path(path)?;
516
517 let head = repo.head()?.peel_to_commit()?;
518 repo.reset_default(Some(&head.into_object()), [std::path::Path::new(path)])?;
519 Ok(())
520}
521
522pub fn stage_all() -> Result<()> {
524 let repo = Repository::discover(".").context("Gitリポジトリが見つかりません")?;
525 stage_all_in_repo(&repo)
526}
527
528pub fn stage_all_in_repo(repo: &Repository) -> Result<()> {
530 let mut index = repo.index()?;
531 index.add_all(["*"].iter(), git2::IndexAddOption::DEFAULT, None)?;
532 index.write()?;
533 Ok(())
534}
535
536pub fn unstage_all() -> Result<()> {
538 let repo = Repository::discover(".").context("Gitリポジトリが見つかりません")?;
539 unstage_all_in_repo(&repo)
540}
541
542pub fn unstage_all_in_repo(repo: &Repository) -> Result<()> {
544 let head = repo.head()?.peel_to_commit()?;
545 repo.reset(&head.into_object(), git2::ResetType::Mixed, None)?;
546 Ok(())
547}
548
549pub fn create_commit(message: &str) -> Result<()> {
551 let repo = Repository::discover(".").context("Gitリポジトリが見つかりません")?;
552 create_commit_in_repo(&repo, message)
553}
554
555pub fn create_commit_in_repo(repo: &Repository, message: &str) -> Result<()> {
557 let sig = Signature::now("gitstack", "gitstack@local")?;
558 let mut index = repo.index()?;
559 let tree_id = index.write_tree()?;
560 let tree = repo.find_tree(tree_id)?;
561 let parent = repo.head()?.peel_to_commit()?;
562
563 repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &[&parent])?;
564 Ok(())
565}
566
567pub fn push() -> Result<()> {
569 let repo = Repository::discover(".").context("Gitリポジトリが見つかりません")?;
570 push_in_repo(&repo)
571}
572
573pub fn push_in_repo(repo: &Repository) -> Result<()> {
575 let head = repo.head()?;
576 let branch_name = head.shorthand().context("ブランチ名が取得できません")?;
577
578 let mut remote = repo
579 .find_remote("origin")
580 .context("originリモートが見つかりません")?;
581 let refspec = format!("refs/heads/{}:refs/heads/{}", branch_name, branch_name);
582
583 remote.push(&[&refspec], None)?;
584 Ok(())
585}
586
587pub fn fetch_remote() -> Result<()> {
589 let repo = Repository::discover(".").context("Gitリポジトリが見つかりません")?;
590 fetch_remote_in_repo(&repo)
591}
592
593pub fn fetch_remote_at_path(repo_path: &std::path::Path) -> Result<()> {
595 let repo = Repository::open(repo_path).context("リポジトリを開けません")?;
596 fetch_remote_in_repo(&repo)
597}
598
599pub fn fetch_remote_in_repo(repo: &Repository) -> Result<()> {
601 let mut remote = repo
602 .find_remote("origin")
603 .context("originリモートが見つかりません")?;
604
605 remote.fetch(&[] as &[&str], None, None)?;
607 Ok(())
608}
609
610pub fn has_staged_files() -> Result<bool> {
612 let repo = Repository::discover(".").context("Gitリポジトリが見つかりません")?;
613 has_staged_files_in_repo(&repo)
614}
615
616pub fn has_staged_files_in_repo(repo: &Repository) -> Result<bool> {
618 let statuses = get_status_from_repo(repo)?;
619 Ok(statuses.iter().any(|s| {
620 matches!(
621 s.kind,
622 FileStatusKind::StagedNew
623 | FileStatusKind::StagedModified
624 | FileStatusKind::StagedDeleted
625 )
626 }))
627}
628
629#[derive(Debug, Clone)]
635pub struct DiffLine {
636 pub origin: char,
638 pub content: String,
640 pub old_lineno: Option<u32>,
642 pub new_lineno: Option<u32>,
644}
645
646#[derive(Debug, Clone)]
648pub struct FilePatch {
649 pub path: String,
651 pub lines: Vec<DiffLine>,
653}
654
655pub fn get_file_patch(commit_hash: &str, file_path: &str) -> Result<FilePatch> {
657 let repo = Repository::discover(".").context("Gitリポジトリが見つかりません")?;
658 get_file_patch_from_repo(&repo, commit_hash, file_path)
659}
660
661pub fn get_file_patch_from_repo(
663 repo: &Repository,
664 commit_hash: &str,
665 file_path: &str,
666) -> Result<FilePatch> {
667 validate_path(file_path)?;
668
669 use std::cell::RefCell;
670
671 let obj = repo
673 .revparse_single(commit_hash)
674 .context("コミットが見つかりません")?;
675 let commit = obj.peel_to_commit().context("コミットに変換できません")?;
676
677 let tree = commit.tree()?;
678 let parent_tree = commit.parent(0).ok().and_then(|p| p.tree().ok());
679
680 let mut diff_opts = git2::DiffOptions::new();
682 diff_opts.context_lines(3);
683
684 let diff = repo.diff_tree_to_tree(parent_tree.as_ref(), Some(&tree), Some(&mut diff_opts))?;
685
686 let lines: RefCell<Vec<DiffLine>> = RefCell::new(Vec::new());
687 let path_obj = std::path::Path::new(file_path);
688
689 diff.foreach(
690 &mut |_delta, _| true, None, Some(&mut |delta, _hunk| {
693 let new_path = delta.new_file().path();
695 let old_path = delta.old_file().path();
696 new_path == Some(path_obj) || old_path == Some(path_obj)
697 }),
698 Some(&mut |delta, _hunk, line| {
699 let new_path = delta.new_file().path();
701 let old_path = delta.old_file().path();
702 if new_path != Some(path_obj) && old_path != Some(path_obj) {
703 return true;
704 }
705
706 let origin = line.origin();
707 let content = String::from_utf8_lossy(line.content()).to_string();
708 let content = content.trim_end_matches('\n').to_string();
710
711 let old_lineno = line.old_lineno();
712 let new_lineno = line.new_lineno();
713
714 lines.borrow_mut().push(DiffLine {
715 origin,
716 content,
717 old_lineno,
718 new_lineno,
719 });
720
721 true
722 }),
723 )?;
724
725 Ok(FilePatch {
726 path: file_path.to_string(),
727 lines: lines.into_inner(),
728 })
729}
730
731pub fn get_commit_files(commit_hash: &str) -> Result<Vec<String>> {
733 let repo = Repository::discover(".").context("Gitリポジトリが見つかりません")?;
734 get_commit_files_from_repo(&repo, commit_hash)
735}
736
737pub fn get_commit_files_from_repo(repo: &Repository, commit_hash: &str) -> Result<Vec<String>> {
739 let obj = repo
741 .revparse_single(commit_hash)
742 .context("コミットが見つかりません")?;
743 let commit = obj.peel_to_commit().context("コミットに変換できません")?;
744
745 let tree = commit.tree()?;
746 let parent_tree = commit.parent(0).ok().and_then(|p| p.tree().ok());
747
748 let diff = repo.diff_tree_to_tree(parent_tree.as_ref(), Some(&tree), None)?;
749
750 let mut files = Vec::new();
751 diff.foreach(
752 &mut |delta, _| {
753 if let Some(path) = delta.new_file().path() {
754 if let Some(path_str) = path.to_str() {
755 files.push(path_str.to_string());
756 }
757 }
758 true
759 },
760 None,
761 None,
762 None,
763 )?;
764
765 Ok(files)
766}
767
768pub fn load_events(limit: usize) -> Result<Vec<GitEvent>> {
770 let repo = Repository::discover(".").context("Gitリポジトリが見つかりません")?;
771 load_events_from_repo(&repo, limit)
772}
773
774pub fn load_events_from_repo(repo: &Repository, limit: usize) -> Result<Vec<GitEvent>> {
776 let mut branch_heads: std::collections::HashMap<String, Vec<String>> =
778 std::collections::HashMap::new();
779
780 if let Ok(branches) = repo.branches(Some(git2::BranchType::Local)) {
781 for (branch, _) in branches.flatten() {
782 if let (Some(name), Ok(commit)) =
783 (branch.name().ok().flatten(), branch.get().peel_to_commit())
784 {
785 let short_hash = commit.id().to_string()[..7].to_string();
786 branch_heads
787 .entry(short_hash)
788 .or_default()
789 .push(name.to_string());
790 }
791 }
792 }
793
794 let mut revwalk = repo.revwalk()?;
795 revwalk.set_sorting(Sort::TIME | Sort::TOPOLOGICAL)?;
797 revwalk.push_glob("refs/heads/*")?;
799
800 let mut events = Vec::new();
801
802 for oid in revwalk.take(limit) {
803 let oid = oid?;
804 let commit = repo.find_commit(oid)?;
805
806 let short_hash = oid.to_string()[..7].to_string();
807 let message = commit
808 .message()
809 .unwrap_or("")
810 .lines()
811 .next()
812 .unwrap_or("")
813 .to_string();
814 let author = commit.author().name().unwrap_or("unknown").to_string();
815 let timestamp = Local
816 .timestamp_opt(commit.time().seconds(), 0)
817 .single()
818 .unwrap_or_else(Local::now);
819
820 let kind = if commit.parent_count() > 1 {
822 GitEventKind::Merge
823 } else {
824 GitEventKind::Commit
825 };
826
827 let parent_hashes: Vec<String> = (0..commit.parent_count())
829 .filter_map(|i| commit.parent_id(i).ok())
830 .map(|oid| oid.to_string()[..7].to_string())
831 .collect();
832
833 let diff_stats = get_commit_diff_stats(repo, &commit);
835
836 let labels = branch_heads.get(&short_hash).cloned().unwrap_or_default();
838
839 let event = match kind {
840 GitEventKind::Merge => GitEvent::merge(short_hash, message, author, timestamp)
841 .with_parents(parent_hashes)
842 .with_labels(labels),
843 _ => GitEvent::commit(
844 short_hash,
845 message,
846 author,
847 timestamp,
848 diff_stats.insertions,
849 diff_stats.deletions,
850 )
851 .with_parents(parent_hashes)
852 .with_labels(labels),
853 };
854
855 events.push(event);
856 }
857
858 Ok(events)
859}
860
861pub fn get_head_hash_cached(repo: Option<&Repository>) -> Result<String> {
868 match repo {
869 Some(r) => get_head_hash_from_repo(r),
870 None => get_head_hash(),
871 }
872}
873
874pub fn get_index_mtime_cached(repo: Option<&Repository>) -> Result<SystemTime> {
876 match repo {
877 Some(r) => get_index_mtime_from_repo(r),
878 None => get_index_mtime(),
879 }
880}
881
882pub fn get_status_cached(repo: Option<&Repository>) -> Result<Vec<FileStatus>> {
884 match repo {
885 Some(r) => get_status_from_repo(r),
886 None => get_status(),
887 }
888}
889
890pub fn list_branches_cached(repo: Option<&Repository>) -> Result<Vec<String>> {
892 match repo {
893 Some(r) => list_branches_from_repo(r),
894 None => list_branches(),
895 }
896}
897
898pub fn get_repo_info_cached(repo: Option<&Repository>) -> Result<RepoInfo> {
900 match repo {
901 Some(r) => RepoInfo::from_repo(r),
902 None => RepoInfo::from_current_dir(),
903 }
904}
905
906#[derive(Debug, Clone)]
908pub struct BlameLine {
909 pub hash: String,
911 pub author: String,
913 pub date: chrono::DateTime<Local>,
915 pub line_number: usize,
917 pub content: String,
919}
920
921#[derive(Debug, Clone)]
923pub struct FileHistoryEntry {
924 pub hash: String,
926 pub author: String,
928 pub date: chrono::DateTime<Local>,
930 pub message: String,
932 pub insertions: usize,
934 pub deletions: usize,
936}
937
938pub fn get_file_history(path: &str) -> Result<Vec<FileHistoryEntry>> {
940 let repo = Repository::discover(".").context("Gitリポジトリが見つかりません")?;
941 get_file_history_from_repo(&repo, path)
942}
943
944pub fn get_file_history_from_repo(repo: &Repository, path: &str) -> Result<Vec<FileHistoryEntry>> {
946 use std::cell::RefCell;
947
948 let mut revwalk = repo.revwalk()?;
949 revwalk.set_sorting(Sort::TIME | Sort::TOPOLOGICAL)?;
950 revwalk.push_head()?;
951
952 let mut entries = Vec::new();
953 let path_obj = std::path::Path::new(path);
954
955 for oid in revwalk {
956 let oid = oid?;
957 let commit = repo.find_commit(oid)?;
958
959 let tree = commit.tree()?;
960 let parent_tree = commit.parent(0).ok().and_then(|p| p.tree().ok());
961
962 let diff = repo.diff_tree_to_tree(parent_tree.as_ref(), Some(&tree), None)?;
964
965 let found: RefCell<bool> = RefCell::new(false);
966 let insertions: RefCell<usize> = RefCell::new(0);
967 let deletions: RefCell<usize> = RefCell::new(0);
968
969 diff.foreach(
970 &mut |delta, _| {
971 let new_path = delta.new_file().path();
972 let old_path = delta.old_file().path();
973 if new_path == Some(path_obj) || old_path == Some(path_obj) {
974 *found.borrow_mut() = true;
975 }
976 true
977 },
978 None,
979 None,
980 Some(&mut |delta, _hunk, line| {
981 let new_path = delta.new_file().path();
982 let old_path = delta.old_file().path();
983 if new_path == Some(path_obj) || old_path == Some(path_obj) {
984 match line.origin() {
985 '+' => *insertions.borrow_mut() += 1,
986 '-' => *deletions.borrow_mut() += 1,
987 _ => {}
988 }
989 }
990 true
991 }),
992 )?;
993
994 if *found.borrow() {
995 let short_hash = oid.to_string()[..7].to_string();
996 let message = commit
997 .message()
998 .unwrap_or("")
999 .lines()
1000 .next()
1001 .unwrap_or("")
1002 .to_string();
1003 let author = commit.author().name().unwrap_or("unknown").to_string();
1004 let timestamp = Local
1005 .timestamp_opt(commit.time().seconds(), 0)
1006 .single()
1007 .unwrap_or_else(Local::now);
1008
1009 entries.push(FileHistoryEntry {
1010 hash: short_hash,
1011 author,
1012 date: timestamp,
1013 message,
1014 insertions: *insertions.borrow(),
1015 deletions: *deletions.borrow(),
1016 });
1017 }
1018
1019 if entries.len() >= 100 {
1021 break;
1022 }
1023 }
1024
1025 Ok(entries)
1026}
1027
1028pub fn get_blame(path: &str) -> Result<Vec<BlameLine>> {
1030 let repo = Repository::discover(".").context("Gitリポジトリが見つかりません")?;
1031 get_blame_from_repo(&repo, path)
1032}
1033
1034pub fn get_blame_from_repo(repo: &Repository, path: &str) -> Result<Vec<BlameLine>> {
1036 validate_path(path)?;
1037
1038 let blame = repo
1039 .blame_file(std::path::Path::new(path), None)
1040 .context("Blameの取得に失敗しました")?;
1041
1042 let mut lines = Vec::new();
1043
1044 let workdir = repo
1046 .workdir()
1047 .context("ワーキングディレクトリが見つかりません")?;
1048 let file_path = workdir.join(path);
1049 let content = std::fs::read_to_string(&file_path).unwrap_or_default();
1050 let file_lines: Vec<&str> = content.lines().collect();
1051
1052 for (line_idx, hunk) in blame.iter().enumerate() {
1053 let oid = hunk.final_commit_id();
1054 let short_hash = oid.to_string()[..7.min(oid.to_string().len())].to_string();
1055
1056 let (author, date) = if let Ok(commit) = repo.find_commit(oid) {
1058 let author_name = commit.author().name().unwrap_or("unknown").to_string();
1059 let timestamp = Local
1060 .timestamp_opt(commit.time().seconds(), 0)
1061 .single()
1062 .unwrap_or_else(Local::now);
1063 (author_name, timestamp)
1064 } else {
1065 ("unknown".to_string(), Local::now())
1066 };
1067
1068 let line_content = file_lines.get(line_idx).unwrap_or(&"").to_string();
1069
1070 lines.push(BlameLine {
1071 hash: short_hash,
1072 author,
1073 date,
1074 line_number: line_idx + 1,
1075 content: line_content,
1076 });
1077 }
1078
1079 Ok(lines)
1080}
1081
1082use git2::Oid;
1087
1088#[derive(Debug, Clone)]
1090pub struct StashEntry {
1091 pub index: usize,
1093 pub message: String,
1095}
1096
1097pub fn get_stash_list() -> Result<Vec<StashEntry>> {
1099 let mut repo = Repository::discover(".").context("Gitリポジトリが見つかりません")?;
1100 let mut entries = Vec::new();
1101
1102 repo.stash_foreach(|index, message, _oid| {
1103 entries.push(StashEntry {
1104 index,
1105 message: message.to_string(),
1106 });
1107 true })?;
1109
1110 Ok(entries)
1111}
1112
1113pub fn stash_save(message: &str) -> Result<Oid> {
1115 let mut repo = Repository::discover(".").context("Gitリポジトリが見つかりません")?;
1116 let sig = repo
1117 .signature()
1118 .or_else(|_| Signature::now("gitstack", "gitstack@local"))?;
1119
1120 let stash_id = repo.stash_save(&sig, message, None)?;
1121 Ok(stash_id)
1122}
1123
1124pub fn stash_apply(index: usize) -> Result<()> {
1126 let mut repo = Repository::discover(".").context("Gitリポジトリが見つかりません")?;
1127 repo.stash_apply(index, None)?;
1128 Ok(())
1129}
1130
1131pub fn stash_pop(index: usize) -> Result<()> {
1133 let mut repo = Repository::discover(".").context("Gitリポジトリが見つかりません")?;
1134 repo.stash_pop(index, None)?;
1135 Ok(())
1136}
1137
1138pub fn stash_drop(index: usize) -> Result<()> {
1140 let mut repo = Repository::discover(".").context("Gitリポジトリが見つかりません")?;
1141 repo.stash_drop(index)?;
1142 Ok(())
1143}
1144
1145pub fn compare_branches(base: &str, target: &str) -> Result<crate::compare::BranchCompare> {
1147 let repo = Repository::discover(".").context("Gitリポジトリが見つかりません")?;
1148 compare_branches_from_repo(&repo, base, target)
1149}
1150
1151pub fn compare_branches_from_repo(
1153 repo: &Repository,
1154 base: &str,
1155 target: &str,
1156) -> Result<crate::compare::BranchCompare> {
1157 use crate::compare::BranchCompare;
1158
1159 let base_obj = repo
1161 .revparse_single(base)
1162 .context(format!("ブランチ '{}' が見つかりません", base))?;
1163 let target_obj = repo
1164 .revparse_single(target)
1165 .context(format!("ブランチ '{}' が見つかりません", target))?;
1166
1167 let base_commit = base_obj.peel_to_commit()?;
1168 let target_commit = target_obj.peel_to_commit()?;
1169
1170 let merge_base_oid = repo
1172 .merge_base(base_commit.id(), target_commit.id())
1173 .context("マージベースが見つかりません")?;
1174 let merge_base = merge_base_oid.to_string()[..7].to_string();
1175
1176 let ahead_commits = get_commits_between(repo, merge_base_oid, target_commit.id())?;
1178
1179 let behind_commits = get_commits_between(repo, merge_base_oid, base_commit.id())?;
1181
1182 Ok(BranchCompare {
1183 base_branch: base.to_string(),
1184 target_branch: target.to_string(),
1185 ahead_commits,
1186 behind_commits,
1187 merge_base,
1188 })
1189}
1190
1191fn get_commits_between(
1193 repo: &Repository,
1194 from: git2::Oid,
1195 to: git2::Oid,
1196) -> Result<Vec<crate::compare::CompareCommit>> {
1197 use crate::compare::CompareCommit;
1198
1199 if from == to {
1200 return Ok(Vec::new());
1201 }
1202
1203 let mut revwalk = repo.revwalk()?;
1204 revwalk.push(to)?;
1205 revwalk.hide(from)?;
1206 revwalk.set_sorting(Sort::TOPOLOGICAL | Sort::TIME)?;
1207
1208 let mut commits = Vec::new();
1209 for oid_result in revwalk {
1210 let oid = oid_result?;
1211 let commit = repo.find_commit(oid)?;
1212
1213 let message = commit
1214 .message()
1215 .unwrap_or("")
1216 .lines()
1217 .next()
1218 .unwrap_or("")
1219 .to_string();
1220 let author = commit.author().name().unwrap_or("Unknown").to_string();
1221 let date = Local.timestamp_opt(commit.time().seconds(), 0).unwrap();
1222
1223 commits.push(CompareCommit {
1224 hash: oid.to_string()[..7].to_string(),
1225 message,
1226 author,
1227 date,
1228 });
1229 }
1230
1231 Ok(commits)
1232}
1233
1234#[cfg(test)]
1235mod tests {
1236 use super::*;
1237 use std::fs;
1238 use std::path::Path;
1239 use tempfile::TempDir;
1240
1241 fn init_test_repo() -> (TempDir, Repository) {
1242 let temp_dir = TempDir::new().expect("Failed to create temp dir");
1243 let repo = Repository::init(temp_dir.path()).expect("Failed to init repo");
1244
1245 let sig = git2::Signature::now("Test Author", "test@example.com").unwrap();
1247 let tree_id = {
1248 let mut index = repo.index().unwrap();
1249 let test_file = temp_dir.path().join("test.txt");
1250 fs::write(&test_file, "test content").unwrap();
1251 index.add_path(Path::new("test.txt")).unwrap();
1252 index.write().unwrap();
1253 index.write_tree().unwrap()
1254 };
1255 {
1256 let tree = repo.find_tree(tree_id).unwrap();
1257 repo.commit(Some("HEAD"), &sig, &sig, "Initial commit", &tree, &[])
1258 .unwrap();
1259 }
1260
1261 (temp_dir, repo)
1262 }
1263
1264 #[test]
1265 fn test_repo_info_from_repo_gets_name() {
1266 let (_temp_dir, repo) = init_test_repo();
1267 let info = RepoInfo::from_repo(&repo).unwrap();
1268 assert!(!info.name.is_empty());
1269 }
1270
1271 #[test]
1272 fn test_repo_info_from_repo_gets_branch() {
1273 let (_temp_dir, repo) = init_test_repo();
1274 let info = RepoInfo::from_repo(&repo).unwrap();
1275 assert!(info.branch == "master" || info.branch == "main");
1277 }
1278
1279 #[test]
1280 fn test_load_events_from_repo_returns_events() {
1281 let (_temp_dir, repo) = init_test_repo();
1282 let events = load_events_from_repo(&repo, 10).unwrap();
1283 assert!(!events.is_empty());
1284 }
1285
1286 #[test]
1287 fn test_load_events_from_repo_first_event_is_initial_commit() {
1288 let (_temp_dir, repo) = init_test_repo();
1289 let events = load_events_from_repo(&repo, 10).unwrap();
1290 assert_eq!(events[0].message, "Initial commit");
1291 }
1292
1293 #[test]
1294 fn test_load_events_from_repo_respects_limit() {
1295 let (temp_dir, repo) = init_test_repo();
1296
1297 let sig = git2::Signature::now("Test Author", "test@example.com").unwrap();
1299 for i in 1..=5 {
1300 let test_file = temp_dir.path().join(format!("file{}.txt", i));
1301 fs::write(&test_file, format!("content {}", i)).unwrap();
1302 let mut index = repo.index().unwrap();
1303 index
1304 .add_path(Path::new(&format!("file{}.txt", i)))
1305 .unwrap();
1306 index.write().unwrap();
1307 let tree_id = index.write_tree().unwrap();
1308 let tree = repo.find_tree(tree_id).unwrap();
1309 let parent = repo.head().unwrap().peel_to_commit().unwrap();
1310 repo.commit(
1311 Some("HEAD"),
1312 &sig,
1313 &sig,
1314 &format!("Commit {}", i),
1315 &tree,
1316 &[&parent],
1317 )
1318 .unwrap();
1319 }
1320
1321 let events = load_events_from_repo(&repo, 3).unwrap();
1322 assert_eq!(events.len(), 3);
1323 }
1324
1325 #[test]
1326 fn test_load_events_from_repo_returns_commits_in_order() {
1327 let (temp_dir, repo) = init_test_repo();
1328
1329 let sig = git2::Signature::now("Test Author", "test@example.com").unwrap();
1331 for i in 1..=3 {
1332 let test_file = temp_dir.path().join(format!("file{}.txt", i));
1333 fs::write(&test_file, format!("content {}", i)).unwrap();
1334 let mut index = repo.index().unwrap();
1335 index
1336 .add_path(Path::new(&format!("file{}.txt", i)))
1337 .unwrap();
1338 index.write().unwrap();
1339 let tree_id = index.write_tree().unwrap();
1340 let tree = repo.find_tree(tree_id).unwrap();
1341 let parent = repo.head().unwrap().peel_to_commit().unwrap();
1342 repo.commit(
1343 Some("HEAD"),
1344 &sig,
1345 &sig,
1346 &format!("Commit {}", i),
1347 &tree,
1348 &[&parent],
1349 )
1350 .unwrap();
1351 }
1352
1353 let events = load_events_from_repo(&repo, 10).unwrap();
1354 assert_eq!(events.len(), 4);
1356 assert!(events.iter().any(|e| e.message == "Commit 3"));
1358 assert!(events.iter().any(|e| e.message == "Initial commit"));
1360 }
1361
1362 #[test]
1363 fn test_load_events_from_repo_event_has_short_hash() {
1364 let (_temp_dir, repo) = init_test_repo();
1365 let events = load_events_from_repo(&repo, 10).unwrap();
1366 assert_eq!(events[0].short_hash.len(), 7);
1367 }
1368
1369 #[test]
1370 fn test_load_events_from_repo_event_has_author() {
1371 let (_temp_dir, repo) = init_test_repo();
1372 let events = load_events_from_repo(&repo, 10).unwrap();
1373 assert_eq!(events[0].author, "Test Author");
1374 }
1375
1376 #[test]
1377 fn test_load_events_from_repo_event_has_file_stats() {
1378 let (_temp_dir, repo) = init_test_repo();
1379 let events = load_events_from_repo(&repo, 10).unwrap();
1380 assert!(events[0].files_added > 0);
1382 }
1383
1384 #[test]
1385 fn test_get_commit_diff_stats_returns_stats() {
1386 let (_temp_dir, repo) = init_test_repo();
1387 let commit = repo.head().unwrap().peel_to_commit().unwrap();
1388 let stats = get_commit_diff_stats(&repo, &commit);
1389 assert!(stats.files_changed > 0 || stats.insertions > 0);
1391 }
1392
1393 #[test]
1394 fn test_list_branches_from_repo_returns_branches() {
1395 let (_temp_dir, repo) = init_test_repo();
1396 let branches = list_branches_from_repo(&repo).unwrap();
1397 assert!(!branches.is_empty());
1398 }
1399
1400 #[test]
1401 fn test_list_branches_from_repo_includes_current_branch() {
1402 let (_temp_dir, repo) = init_test_repo();
1403 let branches = list_branches_from_repo(&repo).unwrap();
1404 assert!(branches.contains(&"master".to_string()) || branches.contains(&"main".to_string()));
1406 }
1407
1408 #[test]
1409 fn test_checkout_branch_in_repo_switches_branch() {
1410 let (_temp_dir, repo) = init_test_repo();
1411
1412 {
1414 let head = repo.head().unwrap().peel_to_commit().unwrap();
1415 repo.branch("test-branch", &head, false).unwrap();
1416 }
1417
1418 checkout_branch_in_repo(&repo, "test-branch").unwrap();
1420
1421 let info = RepoInfo::from_repo(&repo).unwrap();
1423 assert_eq!(info.branch, "test-branch");
1424 }
1425
1426 #[test]
1427 fn test_get_status_from_repo_empty_on_clean() {
1428 let (_temp_dir, repo) = init_test_repo();
1429 let statuses = get_status_from_repo(&repo).unwrap();
1430 assert!(statuses.is_empty());
1431 }
1432
1433 #[test]
1434 fn test_get_status_from_repo_detects_modified() {
1435 let (temp_dir, repo) = init_test_repo();
1436 fs::write(temp_dir.path().join("test.txt"), "modified content").unwrap();
1437
1438 let statuses = get_status_from_repo(&repo).unwrap();
1439 assert!(statuses.iter().any(|s| s.kind == FileStatusKind::Modified));
1440 }
1441
1442 #[test]
1443 fn test_get_status_from_repo_detects_untracked() {
1444 let (temp_dir, repo) = init_test_repo();
1445 fs::write(temp_dir.path().join("new_file.txt"), "new content").unwrap();
1446
1447 let statuses = get_status_from_repo(&repo).unwrap();
1448 assert!(statuses.iter().any(|s| s.kind == FileStatusKind::Untracked));
1449 }
1450
1451 #[test]
1452 fn test_stage_file_in_repo_stages_file() {
1453 let (temp_dir, repo) = init_test_repo();
1454 fs::write(temp_dir.path().join("new_file.txt"), "new content").unwrap();
1455
1456 stage_file_in_repo(&repo, "new_file.txt").unwrap();
1457
1458 let statuses = get_status_from_repo(&repo).unwrap();
1459 assert!(statuses.iter().any(|s| s.kind == FileStatusKind::StagedNew));
1460 }
1461
1462 #[test]
1463 fn test_unstage_file_in_repo_unstages_file() {
1464 let (temp_dir, repo) = init_test_repo();
1465 fs::write(temp_dir.path().join("new_file.txt"), "new content").unwrap();
1466
1467 stage_file_in_repo(&repo, "new_file.txt").unwrap();
1468 unstage_file_in_repo(&repo, "new_file.txt").unwrap();
1469
1470 let statuses = get_status_from_repo(&repo).unwrap();
1471 assert!(!statuses.iter().any(|s| s.kind == FileStatusKind::StagedNew));
1472 assert!(statuses.iter().any(|s| s.kind == FileStatusKind::Untracked));
1473 }
1474
1475 #[test]
1476 fn test_create_commit_in_repo_creates_commit() {
1477 let (temp_dir, repo) = init_test_repo();
1478 fs::write(temp_dir.path().join("new_file.txt"), "new content").unwrap();
1479 stage_file_in_repo(&repo, "new_file.txt").unwrap();
1480
1481 create_commit_in_repo(&repo, "Test commit").unwrap();
1482
1483 let events = load_events_from_repo(&repo, 10).unwrap();
1484 assert!(events.iter().any(|e| e.message == "Test commit"));
1485 }
1486
1487 #[test]
1488 fn test_has_staged_files_in_repo_returns_false_when_empty() {
1489 let (_temp_dir, repo) = init_test_repo();
1490 assert!(!has_staged_files_in_repo(&repo).unwrap());
1491 }
1492
1493 #[test]
1494 fn test_has_staged_files_in_repo_returns_true_when_staged() {
1495 let (temp_dir, repo) = init_test_repo();
1496 fs::write(temp_dir.path().join("new_file.txt"), "new content").unwrap();
1497 stage_file_in_repo(&repo, "new_file.txt").unwrap();
1498
1499 assert!(has_staged_files_in_repo(&repo).unwrap());
1500 }
1501
1502 #[test]
1503 fn test_stage_all_in_repo_stages_all_files() {
1504 let (temp_dir, repo) = init_test_repo();
1505 fs::write(temp_dir.path().join("file1.txt"), "content1").unwrap();
1506 fs::write(temp_dir.path().join("file2.txt"), "content2").unwrap();
1507
1508 stage_all_in_repo(&repo).unwrap();
1509
1510 let statuses = get_status_from_repo(&repo).unwrap();
1511 let staged_count = statuses
1512 .iter()
1513 .filter(|s| matches!(s.kind, FileStatusKind::StagedNew))
1514 .count();
1515 assert_eq!(staged_count, 2);
1516 }
1517
1518 #[test]
1519 fn test_unstage_all_in_repo_unstages_all_files() {
1520 let (temp_dir, repo) = init_test_repo();
1521 fs::write(temp_dir.path().join("file1.txt"), "content1").unwrap();
1522 fs::write(temp_dir.path().join("file2.txt"), "content2").unwrap();
1523 stage_all_in_repo(&repo).unwrap();
1524
1525 unstage_all_in_repo(&repo).unwrap();
1526
1527 let statuses = get_status_from_repo(&repo).unwrap();
1528 let staged_count = statuses
1529 .iter()
1530 .filter(|s| {
1531 matches!(
1532 s.kind,
1533 FileStatusKind::StagedNew
1534 | FileStatusKind::StagedModified
1535 | FileStatusKind::StagedDeleted
1536 )
1537 })
1538 .count();
1539 assert_eq!(staged_count, 0);
1540 }
1541
1542 #[test]
1543 fn test_sort_branches_current_first() {
1544 let mut branches = vec![
1545 "develop".to_string(),
1546 "feature/foo".to_string(),
1547 "main".to_string(),
1548 ];
1549 sort_branches(&mut branches, Some("feature/foo"));
1550 assert_eq!(branches[0], "feature/foo"); }
1552
1553 #[test]
1554 fn test_sort_branches_priority_order() {
1555 let mut branches = vec![
1556 "develop".to_string(),
1557 "feature/foo".to_string(),
1558 "main".to_string(),
1559 "master".to_string(),
1560 ];
1561 sort_branches(&mut branches, None);
1562 assert_eq!(branches[0], "main");
1564 assert_eq!(branches[1], "master");
1565 assert_eq!(branches[2], "develop");
1566 assert_eq!(branches[3], "feature/foo");
1567 }
1568
1569 #[test]
1570 fn test_sort_branches_current_takes_precedence_over_priority() {
1571 let mut branches = vec![
1572 "develop".to_string(),
1573 "main".to_string(),
1574 "feature/foo".to_string(),
1575 ];
1576 sort_branches(&mut branches, Some("develop"));
1577 assert_eq!(branches[0], "develop");
1579 assert_eq!(branches[1], "main");
1580 assert_eq!(branches[2], "feature/foo");
1581 }
1582
1583 #[test]
1584 fn test_sort_branches_alphabetical_for_non_priority() {
1585 let mut branches = vec![
1586 "feature/xyz".to_string(),
1587 "feature/abc".to_string(),
1588 "bugfix/123".to_string(),
1589 ];
1590 sort_branches(&mut branches, None);
1591 assert_eq!(branches[0], "bugfix/123");
1593 assert_eq!(branches[1], "feature/abc");
1594 assert_eq!(branches[2], "feature/xyz");
1595 }
1596
1597 #[test]
1598 fn test_list_branches_from_repo_current_branch_first() {
1599 let (_temp_dir, repo) = init_test_repo();
1600
1601 {
1603 let head = repo.head().unwrap().peel_to_commit().unwrap();
1604 repo.branch("develop", &head, false).unwrap();
1605 repo.branch("feature/test", &head, false).unwrap();
1606 }
1607
1608 let branches = list_branches_from_repo(&repo).unwrap();
1609 let current = repo.head().unwrap().shorthand().unwrap().to_string();
1611 assert_eq!(branches[0], current);
1612 }
1613
1614 #[test]
1615 fn test_validate_path_rejects_parent_dir() {
1616 assert!(validate_path("../etc/passwd").is_err());
1617 assert!(validate_path("foo/../bar").is_err());
1618 }
1619
1620 #[test]
1621 fn test_validate_path_rejects_null_char() {
1622 assert!(validate_path("foo\0bar").is_err());
1623 }
1624
1625 #[test]
1626 fn test_validate_path_accepts_valid_paths() {
1627 assert!(validate_path("src/main.rs").is_ok());
1628 assert!(validate_path("file.txt").is_ok());
1629 }
1630
1631 #[test]
1632 fn test_validate_branch_name_rejects_empty() {
1633 assert!(validate_branch_name("").is_err());
1634 }
1635
1636 #[test]
1637 fn test_validate_branch_name_rejects_null_char() {
1638 assert!(validate_branch_name("main\0").is_err());
1639 }
1640
1641 #[test]
1642 fn test_validate_branch_name_accepts_valid() {
1643 assert!(validate_branch_name("main").is_ok());
1644 assert!(validate_branch_name("feature/new-feature").is_ok());
1645 }
1646}