Skip to main content

gitstack/
git.rs

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
9/// パストラバーサル攻撃を防ぐためのパス検証
10fn validate_path(path: &str) -> Result<()> {
11    // ヌル文字チェック
12    if path.contains('\0') {
13        return Err(anyhow::anyhow!(
14            "無効なパス: ヌル文字を含むことはできません"
15        ));
16    }
17
18    // 親ディレクトリ参照チェック
19    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
32/// ブランチ名の検証
33fn 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
47/// 現在のHEADのハッシュを取得
48pub fn get_head_hash() -> Result<String> {
49    let repo = Repository::discover(".").context("Gitリポジトリが見つかりません")?;
50    get_head_hash_from_repo(&repo)
51}
52
53/// RepositoryからHEADのハッシュを取得
54pub 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
60/// .git/indexファイルの更新時刻を取得(ワーキングツリー変更検知用)
61pub fn get_index_mtime() -> Result<SystemTime> {
62    let repo = Repository::discover(".").context("Gitリポジトリが見つかりません")?;
63    get_index_mtime_from_repo(&repo)
64}
65
66/// Repositoryから.git/indexの更新時刻を取得
67pub 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
74/// git config user.name を取得
75pub fn get_user_name() -> Option<String> {
76    let repo = Repository::discover(".").ok()?;
77    get_user_name_from_repo(&repo)
78}
79
80/// Repositoryからgit config user.nameを取得
81pub 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/// ファイルのステータス種別
87#[derive(Debug, Clone, PartialEq)]
88pub enum FileStatusKind {
89    /// ステージ済み(新規追加)
90    StagedNew,
91    /// ステージ済み(変更)
92    StagedModified,
93    /// ステージ済み(削除)
94    StagedDeleted,
95    /// 未ステージ(変更)
96    Modified,
97    /// 未ステージ(削除)
98    Deleted,
99    /// 未追跡
100    Untracked,
101}
102
103impl FileStatusKind {
104    /// ステージ済みかどうかを判定
105    pub fn is_staged(&self) -> bool {
106        matches!(
107            self,
108            FileStatusKind::StagedNew
109                | FileStatusKind::StagedModified
110                | FileStatusKind::StagedDeleted
111        )
112    }
113}
114
115/// ファイルステータス
116#[derive(Debug, Clone)]
117pub struct FileStatus {
118    /// ファイルパス
119    pub path: String,
120    /// ステータス種別
121    pub kind: FileStatusKind,
122}
123
124/// コミットのファイル変更統計
125#[derive(Debug, Clone, Default)]
126pub struct DiffStats {
127    pub files_changed: usize,
128    pub insertions: usize,
129    pub deletions: usize,
130}
131
132/// ファイル変更のステータス
133#[derive(Debug, Clone, Copy, PartialEq)]
134pub enum FileChangeStatus {
135    /// 新規追加
136    Added,
137    /// 変更
138    Modified,
139    /// 削除
140    Deleted,
141    /// リネーム
142    Renamed,
143}
144
145impl FileChangeStatus {
146    /// 表示用の短縮文字を取得
147    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/// コミット内の個別ファイル変更
158#[derive(Debug, Clone)]
159pub struct FileChange {
160    /// ファイルパス
161    pub path: String,
162    /// 変更ステータス
163    pub status: FileChangeStatus,
164    /// 追加行数
165    pub insertions: usize,
166    /// 削除行数
167    pub deletions: usize,
168}
169
170/// コミットのdiff情報(統計 + ファイル一覧)
171#[derive(Debug, Clone, Default)]
172pub struct CommitDiff {
173    /// 統計情報
174    pub stats: DiffStats,
175    /// ファイル変更一覧
176    pub files: Vec<FileChange>,
177}
178
179/// コミットのdiff統計を取得
180fn 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
203/// コミットの詳細なdiff情報を取得(統計 + ファイル一覧)
204pub 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
209/// Repositoryからコミットの詳細なdiff情報を取得
210pub fn get_commit_diff_from_repo(repo: &Repository, commit_hash: &str) -> Result<CommitDiff> {
211    use std::cell::RefCell;
212
213    // コミットを解決
214    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    // 統計情報を取得
225    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    // ファイル変更一覧を構築(RefCellで内部可変性を使用)
235    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    // 各ハンクを走査して行数を集計
240    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    // ファイルに行数統計を反映
288    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/// リポジトリ情報
304#[derive(Debug, Clone)]
305pub struct RepoInfo {
306    /// リポジトリ名
307    pub name: String,
308    /// 現在のブランチ名
309    pub branch: String,
310}
311
312impl RepoInfo {
313    /// カレントディレクトリからリポジトリ情報を取得
314    pub fn from_current_dir() -> Result<Self> {
315        let repo = Repository::discover(".").context("Gitリポジトリが見つかりません")?;
316        Self::from_repo(&repo)
317    }
318
319    /// Repositoryからリポジトリ情報を取得
320    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
338/// ローカルブランチ一覧を取得
339pub fn list_branches() -> Result<Vec<String>> {
340    let repo = Repository::discover(".").context("Gitリポジトリが見つかりません")?;
341    list_branches_from_repo(&repo)
342}
343
344/// Repositoryからローカルブランチ一覧を取得
345/// 並び順: 現在のブランチ → main/master/develop → その他(アルファベット順)
346pub fn list_branches_from_repo(repo: &Repository) -> Result<Vec<String>> {
347    let branches = repo.branches(Some(git2::BranchType::Local))?;
348
349    // 現在のブランチ名を取得
350    let current_branch = repo
351        .head()
352        .ok()
353        .and_then(|h| h.shorthand().map(|s| s.to_string()));
354
355    // TODO: staleブランチ(リモートで削除済み)の除外は
356    // git2での正確な検出が複雑なため、将来の課題とする
357    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    // 並び替え: 現在ブランチ → 優先ブランチ → その他(アルファベット順)
366    sort_branches(&mut branch_names, current_branch.as_deref());
367
368    Ok(branch_names)
369}
370
371/// ブランチ名を優先順位に従ってソート
372/// 1. 現在のブランチ(最上部)
373/// 2. main / master / develop(存在する場合)
374/// 3. その他のブランチ(アルファベット順)
375fn 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        // 現在のブランチを最優先
383        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        // 優先ブランチの順序を取得
391        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
403/// ブランチを切り替え
404pub fn checkout_branch(branch_name: &str) -> Result<()> {
405    let repo = Repository::discover(".").context("Gitリポジトリが見つかりません")?;
406    checkout_branch_in_repo(&repo, branch_name)
407}
408
409/// Repositoryでブランチを切り替え
410pub 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
421/// ワーキングディレクトリのステータスを取得
422pub fn get_status() -> Result<Vec<FileStatus>> {
423    let repo = Repository::discover(".").context("Gitリポジトリが見つかりません")?;
424    get_status_from_repo(&repo)
425}
426
427/// Repositoryからワーキングディレクトリのステータスを取得
428pub 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        // ステージ済みファイル
442        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        // 未ステージファイル(ステージ済みと重複する場合もある)
460        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
481/// ファイルをステージング
482pub fn stage_file(path: &str) -> Result<()> {
483    let repo = Repository::discover(".").context("Gitリポジトリが見つかりません")?;
484    stage_file_in_repo(&repo, path)
485}
486
487/// Repositoryでファイルをステージング
488pub 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        // ファイルが削除された場合
501        index.remove_path(std::path::Path::new(path))?;
502    }
503    index.write()?;
504    Ok(())
505}
506
507/// ファイルをアンステージング
508pub fn unstage_file(path: &str) -> Result<()> {
509    let repo = Repository::discover(".").context("Gitリポジトリが見つかりません")?;
510    unstage_file_in_repo(&repo, path)
511}
512
513/// Repositoryでファイルをアンステージング
514pub 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
522/// 全ファイルをステージング
523pub fn stage_all() -> Result<()> {
524    let repo = Repository::discover(".").context("Gitリポジトリが見つかりません")?;
525    stage_all_in_repo(&repo)
526}
527
528/// Repositoryで全ファイルをステージング
529pub 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
536/// 全ファイルをアンステージング
537pub fn unstage_all() -> Result<()> {
538    let repo = Repository::discover(".").context("Gitリポジトリが見つかりません")?;
539    unstage_all_in_repo(&repo)
540}
541
542/// Repositoryで全ファイルをアンステージング
543pub 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
549/// コミットを作成
550pub fn create_commit(message: &str) -> Result<()> {
551    let repo = Repository::discover(".").context("Gitリポジトリが見つかりません")?;
552    create_commit_in_repo(&repo, message)
553}
554
555/// Repositoryでコミットを作成
556pub 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
567/// リモートにプッシュ
568pub fn push() -> Result<()> {
569    let repo = Repository::discover(".").context("Gitリポジトリが見つかりません")?;
570    push_in_repo(&repo)
571}
572
573/// Repositoryでリモートにプッシュ
574pub 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
587/// リモートからフェッチ
588pub fn fetch_remote() -> Result<()> {
589    let repo = Repository::discover(".").context("Gitリポジトリが見つかりません")?;
590    fetch_remote_in_repo(&repo)
591}
592
593/// パスを指定してリモートからフェッチ(バックグラウンドスレッド用)
594pub 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
599/// Repositoryでリモートからフェッチ
600pub fn fetch_remote_in_repo(repo: &Repository) -> Result<()> {
601    let mut remote = repo
602        .find_remote("origin")
603        .context("originリモートが見つかりません")?;
604
605    // デフォルトのrefspecでフェッチ
606    remote.fetch(&[] as &[&str], None, None)?;
607    Ok(())
608}
609
610/// ステージ済みファイルがあるかどうか
611pub fn has_staged_files() -> Result<bool> {
612    let repo = Repository::discover(".").context("Gitリポジトリが見つかりません")?;
613    has_staged_files_in_repo(&repo)
614}
615
616/// Repositoryでステージ済みファイルがあるかどうか
617pub 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// ============================================================
630// Diff/Patch 関連構造体と関数
631// ============================================================
632
633/// Diffの1行分のデータ
634#[derive(Debug, Clone)]
635pub struct DiffLine {
636    /// 行の種類('+': 追加, '-': 削除, ' ': 変更なし)
637    pub origin: char,
638    /// 行の内容
639    pub content: String,
640    /// 旧ファイルの行番号(削除行・コンテキスト行で有効)
641    pub old_lineno: Option<u32>,
642    /// 新ファイルの行番号(追加行・コンテキスト行で有効)
643    pub new_lineno: Option<u32>,
644}
645
646/// ファイルのパッチ情報
647#[derive(Debug, Clone)]
648pub struct FilePatch {
649    /// ファイルパス
650    pub path: String,
651    /// パッチの行一覧
652    pub lines: Vec<DiffLine>,
653}
654
655/// 特定コミットの特定ファイルのパッチを取得
656pub 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
661/// Repositoryから特定コミットの特定ファイルのパッチを取得
662pub 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    // コミットを解決
672    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    // コンテキスト行を含めるためのオプション
681    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, // ファイルコールバック
691        None,                  // バイナリコールバック
692        Some(&mut |delta, _hunk| {
693            // 対象ファイルのみ処理
694            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            // 対象ファイルのみ処理
700            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            // 末尾の改行を除去
709            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
731/// コミットの変更ファイル一覧を取得
732pub 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
737/// Repositoryからコミットの変更ファイル一覧を取得
738pub fn get_commit_files_from_repo(repo: &Repository, commit_hash: &str) -> Result<Vec<String>> {
739    // コミットを解決
740    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
768/// コミット履歴をGitEventとして取得
769pub fn load_events(limit: usize) -> Result<Vec<GitEvent>> {
770    let repo = Repository::discover(".").context("Gitリポジトリが見つかりません")?;
771    load_events_from_repo(&repo, limit)
772}
773
774/// Repositoryからコミット履歴をGitEventとして取得
775pub fn load_events_from_repo(repo: &Repository, limit: usize) -> Result<Vec<GitEvent>> {
776    // 全ブランチのHEADコミットを収集(ラベル付け用)
777    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    // TIME | TOPOLOGICAL で、同じ秒内のコミットもトポロジカル順で正しくソート
796    revwalk.set_sorting(Sort::TIME | Sort::TOPOLOGICAL)?;
797    // 全ローカルブランチのコミットを取得
798    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        // マージコミットかどうかを判定(親が2つ以上)
821        let kind = if commit.parent_count() > 1 {
822            GitEventKind::Merge
823        } else {
824            GitEventKind::Commit
825        };
826
827        // 親コミットのハッシュを取得
828        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        // diff統計を取得
834        let diff_stats = get_commit_diff_stats(repo, &commit);
835
836        // ブランチラベルを取得
837        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
861// ============================================================
862// キャッシュ対応版関数(パフォーマンス最適化用)
863// Option<&Repository>を受け取り、Noneの場合はフォールバックでdiscover()を使用
864// ============================================================
865
866/// HEADのハッシュを取得(キャッシュ対応版)
867pub 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
874/// インデックスの更新時刻を取得(キャッシュ対応版)
875pub 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
882/// ワーキングディレクトリのステータスを取得(キャッシュ対応版)
883pub 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
890/// ローカルブランチ一覧を取得(キャッシュ対応版)
891pub 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
898/// リポジトリ情報を取得(キャッシュ対応版)
899pub 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/// Blameの1行分のデータ
907#[derive(Debug, Clone)]
908pub struct BlameLine {
909    /// コミットハッシュ(短縮)
910    pub hash: String,
911    /// 著者名
912    pub author: String,
913    /// コミット日時
914    pub date: chrono::DateTime<Local>,
915    /// 行番号(1始まり)
916    pub line_number: usize,
917    /// 行の内容
918    pub content: String,
919}
920
921/// ファイル履歴のエントリ
922#[derive(Debug, Clone)]
923pub struct FileHistoryEntry {
924    /// コミットハッシュ(短縮)
925    pub hash: String,
926    /// 著者名
927    pub author: String,
928    /// コミット日時
929    pub date: chrono::DateTime<Local>,
930    /// コミットメッセージ(1行目)
931    pub message: String,
932    /// 追加行数
933    pub insertions: usize,
934    /// 削除行数
935    pub deletions: usize,
936}
937
938/// 特定ファイルの変更履歴を取得
939pub 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
944/// Repositoryから特定ファイルの変更履歴を取得
945pub 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        // diffを取得してパスが含まれているかチェック
963        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        // 最大100件まで
1020        if entries.len() >= 100 {
1021            break;
1022        }
1023    }
1024
1025    Ok(entries)
1026}
1027
1028/// ファイルのblame情報を取得
1029pub fn get_blame(path: &str) -> Result<Vec<BlameLine>> {
1030    let repo = Repository::discover(".").context("Gitリポジトリが見つかりません")?;
1031    get_blame_from_repo(&repo, path)
1032}
1033
1034/// Repositoryからファイルのblame情報を取得
1035pub 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    // ファイルの内容を読み込む
1045    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        // コミット情報を取得
1057        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
1082// ============================================================
1083// Stash 関連関数
1084// ============================================================
1085
1086use git2::Oid;
1087
1088/// Stashエントリ
1089#[derive(Debug, Clone)]
1090pub struct StashEntry {
1091    /// インデックス(0始まり)
1092    pub index: usize,
1093    /// メッセージ
1094    pub message: String,
1095}
1096
1097/// Stash一覧を取得
1098pub 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 // 全エントリを走査
1108    })?;
1109
1110    Ok(entries)
1111}
1112
1113/// 現在の変更をStashに保存
1114pub 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
1124/// Stashを適用(削除しない)
1125pub fn stash_apply(index: usize) -> Result<()> {
1126    let mut repo = Repository::discover(".").context("Gitリポジトリが見つかりません")?;
1127    repo.stash_apply(index, None)?;
1128    Ok(())
1129}
1130
1131/// Stashをpop(適用して削除)
1132pub fn stash_pop(index: usize) -> Result<()> {
1133    let mut repo = Repository::discover(".").context("Gitリポジトリが見つかりません")?;
1134    repo.stash_pop(index, None)?;
1135    Ok(())
1136}
1137
1138/// Stashを削除
1139pub fn stash_drop(index: usize) -> Result<()> {
1140    let mut repo = Repository::discover(".").context("Gitリポジトリが見つかりません")?;
1141    repo.stash_drop(index)?;
1142    Ok(())
1143}
1144
1145/// 2つのブランチを比較
1146pub 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
1151/// Repositoryから2つのブランチを比較
1152pub 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    // ブランチのコミットを取得
1160    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    // マージベースを計算
1171    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    // aheadコミット(target にあって base にない)
1177    let ahead_commits = get_commits_between(repo, merge_base_oid, target_commit.id())?;
1178
1179    // behindコミット(base にあって target にない)
1180    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
1191/// 2つのコミット間のコミット一覧を取得(from は含まず、to は含む)
1192fn 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        // 初期コミットを作成
1246        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        // 初期ブランチはmasterまたはmain
1276        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        // 追加のコミットを作成
1298        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        // 追加のコミットを作成
1330        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        // 4つのコミットがある
1355        assert_eq!(events.len(), 4);
1356        // 最新のコミット(Commit 3)が含まれている
1357        assert!(events.iter().any(|e| e.message == "Commit 3"));
1358        // Initial commitが含まれている
1359        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        // Initial commitは1ファイル追加(test content = 12文字 + 改行なし)
1381        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        // Initial commitは1ファイル、1行追加
1390        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        // 初期ブランチはmasterまたはmain
1405        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        // 新しいブランチを作成
1413        {
1414            let head = repo.head().unwrap().peel_to_commit().unwrap();
1415            repo.branch("test-branch", &head, false).unwrap();
1416        }
1417
1418        // ブランチを切り替え
1419        checkout_branch_in_repo(&repo, "test-branch").unwrap();
1420
1421        // 現在のブランチを確認
1422        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"); // 現在のブランチが最初
1551    }
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        // main → master → develop → feature/foo の順
1563        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        // develop(現在)→ main → feature/foo の順
1578        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        // アルファベット順
1592        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        // 追加のブランチを作成
1602        {
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        // 現在のブランチ(master または main)が最初
1610        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}