mod types;
pub use types::*;
use std::time::SystemTime;
use anyhow::{Context, Result};
use chrono::{Local, TimeZone};
use git2::{Commit, Diff, Oid, Repository, Signature, Sort, Status, StatusOptions};
use crate::event::{GitEvent, GitEventKind};
pub fn discover_repo() -> Result<Repository> {
Repository::discover(".").context("Gitリポジトリが見つかりません")
}
macro_rules! cached_fn {
(
$(#[$meta:meta])*
$vis:vis fn $name:ident($repo_param:ident: Option<&Repository>) -> Result<$ret:ty> {
from_repo: $from_repo:expr,
}
) => {
$(#[$meta])*
$vis fn $name($repo_param: Option<&Repository>) -> Result<$ret> {
match $repo_param {
Some(r) => ($from_repo)(r),
None => {
let r = discover_repo()?;
($from_repo)(&r)
}
}
}
};
}
fn short_oid(oid: Oid) -> String {
let hex = oid.as_bytes();
let mut result = String::with_capacity(7);
for (i, byte) in hex.iter().take(4).enumerate() {
let hi = (byte >> 4) & 0x0f;
let lo = byte & 0x0f;
if i * 2 < 7 {
result.push(char::from(if hi < 10 { b'0' + hi } else { b'a' + hi - 10 }));
}
if i * 2 + 1 < 7 {
result.push(char::from(if lo < 10 { b'0' + lo } else { b'a' + lo - 10 }));
}
}
result
}
fn validate_path(path: &str) -> Result<()> {
if path.contains('\0') {
return Err(anyhow::anyhow!(
"無効なパス: ヌル文字を含むことはできません"
));
}
let normalized = std::path::Path::new(path);
if normalized
.components()
.any(|c| matches!(c, std::path::Component::ParentDir))
{
return Err(anyhow::anyhow!(
"無効なパス: 親ディレクトリ参照は許可されていません"
));
}
Ok(())
}
fn validate_branch_name(name: &str) -> Result<()> {
if name.is_empty() {
return Err(anyhow::anyhow!(
"無効なブランチ名: 空文字列は許可されていません"
));
}
if name.contains('\0') {
return Err(anyhow::anyhow!(
"無効なブランチ名: ヌル文字を含むことはできません"
));
}
if name.contains("..") {
return Err(anyhow::anyhow!(
"無効なブランチ名: '..' を含むことはできません"
));
}
if name.starts_with('/') || name.ends_with('/') {
return Err(anyhow::anyhow!(
"無効なブランチ名: '/' で始まるまたは終わることはできません"
));
}
if name.ends_with(".lock") {
return Err(anyhow::anyhow!(
"無効なブランチ名: '.lock' で終わることはできません"
));
}
if name.contains(|c: char| c.is_control() || c == ' ' || c == '~' || c == '^' || c == ':') {
return Err(anyhow::anyhow!(
"無効なブランチ名: 制御文字や特殊文字 (スペース, ~, ^, :) を含むことはできません"
));
}
Ok(())
}
pub fn get_head_hash() -> Result<String> {
let repo = discover_repo()?;
get_head_hash_from_repo(&repo)
}
pub fn get_head_hash_from_repo(repo: &Repository) -> Result<String> {
let head = repo.head().context("HEADが見つかりません")?;
let oid = head.target().context("HEADのターゲットが見つかりません")?;
Ok(oid.to_string())
}
pub fn get_index_mtime() -> Result<SystemTime> {
let repo = discover_repo()?;
get_index_mtime_from_repo(&repo)
}
pub fn get_index_mtime_from_repo(repo: &Repository) -> Result<SystemTime> {
let git_dir = repo.path();
let index_path = git_dir.join("index");
let metadata = std::fs::metadata(&index_path).context("indexファイルが見つかりません")?;
metadata.modified().context("更新時刻を取得できません")
}
pub fn get_user_name() -> Option<String> {
let repo = discover_repo().ok()?;
get_user_name_from_repo(&repo)
}
pub fn get_user_name_from_repo(repo: &Repository) -> Option<String> {
let config = repo.config().ok()?;
config.get_string("user.name").ok()
}
fn get_commit_diff_stats(repo: &Repository, commit: &Commit) -> DiffStats {
let tree = match commit.tree() {
Ok(t) => t,
Err(_) => return DiffStats::default(),
};
let parent_tree = commit.parent(0).ok().and_then(|p| p.tree().ok());
let diff: Diff = match repo.diff_tree_to_tree(parent_tree.as_ref(), Some(&tree), None) {
Ok(d) => d,
Err(_) => return DiffStats::default(),
};
match diff.stats() {
Ok(stats) => DiffStats {
files_changed: stats.files_changed(),
insertions: stats.insertions(),
deletions: stats.deletions(),
},
Err(_) => DiffStats::default(),
}
}
pub fn get_commit_diff(commit_hash: &str) -> Result<CommitDiff> {
let repo = discover_repo()?;
get_commit_diff_from_repo(&repo, commit_hash)
}
fn collect_file_changes(diff: &Diff) -> Result<(Vec<FileChange>, Vec<Vec<DiffLine>>)> {
use std::cell::RefCell;
let files: RefCell<Vec<FileChange>> = RefCell::new(Vec::new());
let file_line_stats: RefCell<Vec<(usize, usize)>> = RefCell::new(Vec::new());
let patch_lines: RefCell<Vec<Vec<DiffLine>>> = RefCell::new(Vec::new());
let current_file_idx: RefCell<usize> = RefCell::new(0);
diff.foreach(
&mut |delta, _| {
let path = delta
.new_file()
.path()
.or_else(|| delta.old_file().path())
.and_then(|p| p.to_str())
.unwrap_or("")
.to_string();
let status = match delta.status() {
git2::Delta::Added => FileChangeStatus::Added,
git2::Delta::Deleted => FileChangeStatus::Deleted,
git2::Delta::Modified => FileChangeStatus::Modified,
git2::Delta::Renamed => FileChangeStatus::Renamed,
_ => FileChangeStatus::Modified,
};
let mut f = files.borrow_mut();
let idx = f.len();
f.push(FileChange {
path,
status,
insertions: 0,
deletions: 0,
});
file_line_stats.borrow_mut().push((0, 0));
patch_lines.borrow_mut().push(Vec::new());
*current_file_idx.borrow_mut() = idx;
true
},
None,
None,
Some(&mut |_delta, _hunk, line| {
let idx = *current_file_idx.borrow();
let origin = line.origin();
if let Some(entry) = file_line_stats.borrow_mut().get_mut(idx) {
match origin {
'+' => entry.0 += 1,
'-' => entry.1 += 1,
_ => {}
}
}
let content = String::from_utf8_lossy(line.content()).to_string();
let content = content.trim_end_matches('\n').to_string();
if let Some(lines) = patch_lines.borrow_mut().get_mut(idx) {
lines.push(DiffLine {
origin,
content,
old_lineno: line.old_lineno(),
new_lineno: line.new_lineno(),
});
}
true
}),
)?;
let mut files_vec = files.into_inner();
let line_stats_vec = file_line_stats.into_inner();
for (file, (ins, del)) in files_vec.iter_mut().zip(line_stats_vec.iter()) {
file.insertions = *ins;
file.deletions = *del;
}
Ok((files_vec, patch_lines.into_inner()))
}
fn build_patches(files: &[FileChange], mut patch_lines_vec: Vec<Vec<DiffLine>>) -> Vec<FilePatch> {
files
.iter()
.enumerate()
.filter_map(|(i, f)| {
let lines = std::mem::take(&mut patch_lines_vec[i]);
if lines.is_empty() {
return None;
}
Some(FilePatch {
path: f.path.clone(),
lines,
})
})
.collect()
}
pub fn get_commit_diff_from_repo(repo: &Repository, commit_hash: &str) -> Result<CommitDiff> {
let obj = repo
.revparse_single(commit_hash)
.context("コミットが見つかりません")?;
let commit = obj.peel_to_commit().context("コミットに変換できません")?;
let tree = commit.tree()?;
let parent_tree = commit.parent(0).ok().and_then(|p| p.tree().ok());
let diff = repo.diff_tree_to_tree(parent_tree.as_ref(), Some(&tree), None)?;
let stats = match diff.stats() {
Ok(s) => DiffStats {
files_changed: s.files_changed(),
insertions: s.insertions(),
deletions: s.deletions(),
},
Err(_) => DiffStats::default(),
};
let (files, patch_lines_vec) = collect_file_changes(&diff)?;
let patches = build_patches(&files, patch_lines_vec);
Ok(CommitDiff {
stats,
files,
patches,
})
}
impl RepoInfo {
pub fn from_current_dir() -> Result<Self> {
let repo = discover_repo()?;
Self::from_repo(&repo)
}
pub fn from_repo(repo: &Repository) -> Result<Self> {
let name = repo
.workdir()
.and_then(|p| p.file_name())
.and_then(|n| n.to_str())
.unwrap_or("unknown")
.to_string();
let branch = repo
.head()
.ok()
.and_then(|h| h.shorthand().map(|s| s.to_string()))
.unwrap_or_else(|| "HEAD".to_string());
Ok(Self { name, branch })
}
}
pub fn list_branches() -> Result<Vec<BranchInfo>> {
let repo = discover_repo()?;
list_branches_from_repo(&repo)
}
pub fn list_branches_from_repo(repo: &Repository) -> Result<Vec<BranchInfo>> {
let branches = repo.branches(Some(git2::BranchType::Local))?;
let current_branch = repo
.head()
.ok()
.and_then(|h| h.shorthand().map(|s| s.to_string()));
let mut branch_infos: Vec<BranchInfo> = branches
.filter_map(|b| b.ok())
.filter_map(|(branch, _)| {
let name = branch.name().ok().flatten()?.to_string();
let is_gone = repo
.branch_upstream_name(&format!("refs/heads/{}", name))
.is_ok() && branch.upstream().is_err(); Some(BranchInfo::new(name, is_gone))
})
.collect();
sort_branches(&mut branch_infos, current_branch.as_deref());
Ok(branch_infos)
}
fn sort_branches(branches: &mut [BranchInfo], current_branch: Option<&str>) {
let priority_branches = ["main", "master", "develop"];
branches.sort_by(|a, b| {
let a_is_current = current_branch.is_some_and(|c| c == a.name);
let b_is_current = current_branch.is_some_and(|c| c == b.name);
if a_is_current && !b_is_current {
return std::cmp::Ordering::Less;
}
if !a_is_current && b_is_current {
return std::cmp::Ordering::Greater;
}
match (a.is_gone, b.is_gone) {
(true, false) => return std::cmp::Ordering::Greater,
(false, true) => return std::cmp::Ordering::Less,
_ => {}
}
let a_priority = priority_branches.iter().position(|&p| p == a.name);
let b_priority = priority_branches.iter().position(|&p| p == b.name);
match (a_priority, b_priority) {
(Some(ap), Some(bp)) => ap.cmp(&bp),
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(None, None) => a.name.cmp(&b.name),
}
});
}
pub fn checkout_branch(branch_name: &str) -> Result<()> {
let repo = discover_repo()?;
checkout_branch_in_repo(&repo, branch_name)
}
pub fn checkout_branch_in_repo(repo: &Repository, branch_name: &str) -> Result<()> {
validate_branch_name(branch_name)?;
let obj = repo
.revparse_single(&format!("refs/heads/{}", branch_name))
.context(format!("ブランチ '{}' が見つかりません", branch_name))?;
repo.checkout_tree(&obj, None)?;
repo.set_head(&format!("refs/heads/{}", branch_name))?;
Ok(())
}
pub fn get_status() -> Result<Vec<FileStatus>> {
let repo = discover_repo()?;
get_status_from_repo(&repo)
}
pub fn get_status_in<P: AsRef<std::path::Path>>(path: P) -> Result<Vec<FileStatus>> {
let repo = Repository::discover(path).context("Gitリポジトリが見つかりません")?;
get_status_from_repo(&repo)
}
pub fn get_status_from_repo(repo: &Repository) -> Result<Vec<FileStatus>> {
let mut opts = StatusOptions::new();
opts.include_untracked(true)
.recurse_untracked_dirs(true)
.include_ignored(false);
let statuses = repo.statuses(Some(&mut opts))?;
let mut result = Vec::new();
for entry in statuses.iter() {
let path = entry.path().unwrap_or("").to_string();
let status = entry.status();
let staged_kind = if status.contains(Status::INDEX_NEW) {
Some(FileStatusKind::StagedNew)
} else if status.contains(Status::INDEX_MODIFIED) {
Some(FileStatusKind::StagedModified)
} else if status.contains(Status::INDEX_DELETED) {
Some(FileStatusKind::StagedDeleted)
} else {
None
};
let unstaged_kind = if status.contains(Status::WT_MODIFIED) {
Some(FileStatusKind::Modified)
} else if status.contains(Status::WT_DELETED) {
Some(FileStatusKind::Deleted)
} else if status.contains(Status::WT_NEW) {
Some(FileStatusKind::Untracked)
} else {
None
};
match (staged_kind, unstaged_kind) {
(Some(sk), Some(uk)) => {
result.push(FileStatus {
path: path.clone(),
kind: sk,
});
result.push(FileStatus { path, kind: uk });
}
(Some(sk), None) => {
result.push(FileStatus { path, kind: sk });
}
(None, Some(uk)) => {
result.push(FileStatus { path, kind: uk });
}
(None, None) => {}
}
}
Ok(result)
}
pub fn stage_file(path: &str) -> Result<()> {
let repo = discover_repo()?;
stage_file_in_repo(&repo, path)
}
pub fn stage_file_in<P: AsRef<std::path::Path>>(repo_path: P, path: &str) -> Result<()> {
let repo = Repository::discover(repo_path).context("Gitリポジトリが見つかりません")?;
stage_file_in_repo(&repo, path)
}
pub fn stage_file_in_repo(repo: &Repository, path: &str) -> Result<()> {
validate_path(path)?;
let mut index = repo.index()?;
let workdir = repo
.workdir()
.context("ワーキングディレクトリが見つかりません")?;
let full_path = workdir.join(path);
if full_path.exists() {
index.add_path(std::path::Path::new(path))?;
} else {
index.remove_path(std::path::Path::new(path))?;
}
index.write()?;
Ok(())
}
pub fn unstage_file(path: &str) -> Result<()> {
let repo = discover_repo()?;
unstage_file_in_repo(&repo, path)
}
pub fn unstage_file_in<P: AsRef<std::path::Path>>(repo_path: P, path: &str) -> Result<()> {
let repo = Repository::discover(repo_path).context("Gitリポジトリが見つかりません")?;
unstage_file_in_repo(&repo, path)
}
pub fn unstage_file_in_repo(repo: &Repository, path: &str) -> Result<()> {
validate_path(path)?;
let head = repo.head()?.peel_to_commit()?;
repo.reset_default(Some(&head.into_object()), [std::path::Path::new(path)])?;
Ok(())
}
pub fn stage_all() -> Result<()> {
let repo = discover_repo()?;
stage_all_in_repo(&repo)
}
pub fn stage_all_in<P: AsRef<std::path::Path>>(repo_path: P) -> Result<()> {
let repo = Repository::discover(repo_path).context("Gitリポジトリが見つかりません")?;
stage_all_in_repo(&repo)
}
pub fn stage_all_in_repo(repo: &Repository) -> Result<()> {
let mut index = repo.index()?;
index.add_all(["*"].iter(), git2::IndexAddOption::DEFAULT, None)?;
index.write()?;
Ok(())
}
pub fn unstage_all() -> Result<()> {
let repo = discover_repo()?;
unstage_all_in_repo(&repo)
}
pub fn unstage_all_in_repo(repo: &Repository) -> Result<()> {
let head = repo.head()?.peel_to_commit()?;
repo.reset(&head.into_object(), git2::ResetType::Mixed, None)?;
Ok(())
}
pub fn create_commit(message: &str) -> Result<()> {
let repo = discover_repo()?;
create_commit_in_repo(&repo, message)
}
pub fn create_commit_in_repo(repo: &Repository, message: &str) -> Result<()> {
let sig = Signature::now("gitstack", "gitstack@local")?;
let mut index = repo.index()?;
let tree_id = index.write_tree()?;
let tree = repo.find_tree(tree_id)?;
let parent = repo.head()?.peel_to_commit()?;
repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &[&parent])?;
Ok(())
}
pub fn push() -> Result<()> {
let repo = discover_repo()?;
push_in_repo(&repo)
}
pub fn push_in_repo(repo: &Repository) -> Result<()> {
let head = repo.head()?;
let branch_name = head.shorthand().context("ブランチ名が取得できません")?;
let mut remote = repo
.find_remote("origin")
.context("originリモートが見つかりません")?;
let refspec = format!("refs/heads/{}:refs/heads/{}", branch_name, branch_name);
remote.push(&[&refspec], None)?;
Ok(())
}
pub fn fetch_remote() -> Result<()> {
let repo = discover_repo()?;
fetch_remote_in_repo(&repo)
}
pub fn fetch_remote_at_path(repo_path: &std::path::Path) -> Result<()> {
let repo = Repository::open(repo_path).context("リポジトリを開けません")?;
fetch_remote_in_repo(&repo)
}
pub fn fetch_remote_in_repo(repo: &Repository) -> Result<()> {
let mut remote = repo
.find_remote("origin")
.context("originリモートが見つかりません")?;
let mut fetch_opts = git2::FetchOptions::new();
fetch_opts.prune(git2::FetchPrune::On);
remote.fetch(&[] as &[&str], Some(&mut fetch_opts), None)?;
Ok(())
}
pub fn pull() -> Result<()> {
let repo = discover_repo()?;
pull_in_repo(&repo)
}
pub fn pull_in_repo(repo: &Repository) -> Result<()> {
fetch_remote_in_repo(repo)?;
let head = repo.head()?;
let branch_name = head.shorthand().context("ブランチ名が取得できません")?;
let remote_ref = format!("refs/remotes/origin/{}", branch_name);
let remote_oid = repo
.refname_to_id(&remote_ref)
.context("リモート追跡ブランチが見つかりません")?;
let local_oid = head.target().context("HEADの参照先がありません")?;
if local_oid == remote_oid {
return Ok(());
}
let (merge_analysis, _) = repo.merge_analysis(&[&repo.find_annotated_commit(remote_oid)?])?;
if merge_analysis.is_up_to_date() {
Ok(())
} else if merge_analysis.is_fast_forward() {
let remote_commit = repo.find_commit(remote_oid)?;
let refname = format!("refs/heads/{}", branch_name);
repo.reference(
&refname,
remote_oid,
true,
&format!("pull: fast-forward to {}", remote_oid),
)?;
repo.checkout_tree(remote_commit.as_object(), None)?;
repo.set_head(&refname)?;
Ok(())
} else {
anyhow::bail!("Fast-forward merge not possible. Please resolve conflicts manually.")
}
}
pub fn create_branch(branch_name: &str) -> Result<()> {
let repo = discover_repo()?;
create_branch_in_repo(&repo, branch_name)
}
pub fn create_branch_in_repo(repo: &Repository, branch_name: &str) -> Result<()> {
if branch_name.is_empty() {
anyhow::bail!("Branch name cannot be empty");
}
if branch_name.contains(' ') || branch_name.contains("..") {
anyhow::bail!("Invalid branch name");
}
let head_commit = repo.head()?.peel_to_commit()?;
repo.branch(branch_name, &head_commit, false)
.context("ブランチの作成に失敗しました")?;
Ok(())
}
pub fn delete_branch(branch_name: &str) -> Result<()> {
let repo = discover_repo()?;
delete_branch_in_repo(&repo, branch_name)
}
pub fn delete_branch_in_repo(repo: &Repository, branch_name: &str) -> Result<()> {
let head = repo.head()?;
if let Some(current_branch) = head.shorthand() {
if current_branch == branch_name {
anyhow::bail!("Cannot delete the currently checked out branch");
}
}
let mut branch = repo
.find_branch(branch_name, git2::BranchType::Local)
.context("ブランチが見つかりません")?;
branch.delete().context("ブランチの削除に失敗しました")?;
Ok(())
}
pub fn has_staged_files() -> Result<bool> {
let repo = discover_repo()?;
has_staged_files_in_repo(&repo)
}
pub fn has_staged_files_in_repo(repo: &Repository) -> Result<bool> {
let statuses = get_status_from_repo(repo)?;
Ok(statuses.iter().any(|s| {
matches!(
s.kind,
FileStatusKind::StagedNew
| FileStatusKind::StagedModified
| FileStatusKind::StagedDeleted
)
}))
}
pub fn get_file_patch(commit_hash: &str, file_path: &str) -> Result<FilePatch> {
let repo = discover_repo()?;
get_file_patch_from_repo(&repo, commit_hash, file_path)
}
pub fn get_file_patch_from_repo(
repo: &Repository,
commit_hash: &str,
file_path: &str,
) -> Result<FilePatch> {
validate_path(file_path)?;
use std::cell::RefCell;
let obj = repo
.revparse_single(commit_hash)
.context("コミットが見つかりません")?;
let commit = obj.peel_to_commit().context("コミットに変換できません")?;
let tree = commit.tree()?;
let parent_tree = commit.parent(0).ok().and_then(|p| p.tree().ok());
let mut diff_opts = git2::DiffOptions::new();
diff_opts.context_lines(3);
let diff = repo.diff_tree_to_tree(parent_tree.as_ref(), Some(&tree), Some(&mut diff_opts))?;
let lines: RefCell<Vec<DiffLine>> = RefCell::new(Vec::new());
let path_obj = std::path::Path::new(file_path);
diff.foreach(
&mut |_delta, _| true, None, Some(&mut |delta, _hunk| {
let new_path = delta.new_file().path();
let old_path = delta.old_file().path();
new_path == Some(path_obj) || old_path == Some(path_obj)
}),
Some(&mut |delta, _hunk, line| {
let new_path = delta.new_file().path();
let old_path = delta.old_file().path();
if new_path != Some(path_obj) && old_path != Some(path_obj) {
return true;
}
let origin = line.origin();
let content = String::from_utf8_lossy(line.content()).to_string();
let content = content.trim_end_matches('\n').to_string();
let old_lineno = line.old_lineno();
let new_lineno = line.new_lineno();
lines.borrow_mut().push(DiffLine {
origin,
content,
old_lineno,
new_lineno,
});
true
}),
)?;
Ok(FilePatch {
path: file_path.to_string(),
lines: lines.into_inner(),
})
}
pub fn get_working_file_diff(file_path: &str, is_staged: bool) -> Result<FilePatch> {
let repo = discover_repo()?;
get_working_file_diff_from_repo(&repo, file_path, is_staged)
}
pub fn get_working_file_diff_from_repo(
repo: &Repository,
file_path: &str,
is_staged: bool,
) -> Result<FilePatch> {
validate_path(file_path)?;
use std::cell::RefCell;
let mut diff_opts = git2::DiffOptions::new();
diff_opts.context_lines(3);
diff_opts.pathspec(file_path);
let diff = if is_staged {
let head_tree = repo.head().ok().and_then(|h| h.peel_to_tree().ok());
repo.diff_tree_to_index(head_tree.as_ref(), None, Some(&mut diff_opts))?
} else {
repo.diff_index_to_workdir(None, Some(&mut diff_opts))?
};
let lines: RefCell<Vec<DiffLine>> = RefCell::new(Vec::new());
let path_obj = std::path::Path::new(file_path);
diff.foreach(
&mut |_delta, _| true,
None,
Some(&mut |delta, _hunk| {
let new_path = delta.new_file().path();
let old_path = delta.old_file().path();
new_path == Some(path_obj) || old_path == Some(path_obj)
}),
Some(&mut |delta, _hunk, line| {
let new_path = delta.new_file().path();
let old_path = delta.old_file().path();
if new_path != Some(path_obj) && old_path != Some(path_obj) {
return true;
}
let origin = line.origin();
let content = String::from_utf8_lossy(line.content()).to_string();
let content = content.trim_end_matches('\n').to_string();
let old_lineno = line.old_lineno();
let new_lineno = line.new_lineno();
lines.borrow_mut().push(DiffLine {
origin,
content,
old_lineno,
new_lineno,
});
true
}),
)?;
Ok(FilePatch {
path: file_path.to_string(),
lines: lines.into_inner(),
})
}
pub fn get_commit_files(commit_hash: &str) -> Result<Vec<String>> {
let repo = discover_repo()?;
get_commit_files_from_repo(&repo, commit_hash)
}
pub fn get_commit_files_from_repo(repo: &Repository, commit_hash: &str) -> Result<Vec<String>> {
let obj = repo
.revparse_single(commit_hash)
.context("コミットが見つかりません")?;
let commit = obj.peel_to_commit().context("コミットに変換できません")?;
let tree = commit.tree()?;
let parent_tree = commit.parent(0).ok().and_then(|p| p.tree().ok());
let diff = repo.diff_tree_to_tree(parent_tree.as_ref(), Some(&tree), None)?;
let mut files = Vec::new();
diff.foreach(
&mut |delta, _| {
if let Some(path) = delta.new_file().path() {
if let Some(path_str) = path.to_str() {
files.push(path_str.to_string());
}
}
true
},
None,
None,
None,
)?;
Ok(files)
}
pub fn load_events(limit: usize) -> Result<Vec<GitEvent>> {
let repo = discover_repo()?;
load_events_from_repo(&repo, limit, true)
}
pub fn load_events_fast(limit: usize) -> Result<Vec<GitEvent>> {
let repo = discover_repo()?;
load_events_from_repo(&repo, limit, false)
}
pub fn load_events_from_repo(
repo: &Repository,
limit: usize,
include_diff_stats: bool,
) -> Result<Vec<GitEvent>> {
let mut branch_heads: std::collections::HashMap<String, Vec<String>> =
std::collections::HashMap::new();
if let Ok(branches) = repo.branches(Some(git2::BranchType::Local)) {
for (branch, _) in branches.flatten() {
if let (Some(name), Ok(commit)) =
(branch.name().ok().flatten(), branch.get().peel_to_commit())
{
let short_hash = short_oid(commit.id());
branch_heads
.entry(short_hash)
.or_default()
.push(name.to_string());
}
}
}
let mut revwalk = repo.revwalk()?;
revwalk.set_sorting(Sort::TIME | Sort::TOPOLOGICAL)?;
revwalk.push_glob("refs/heads/*")?;
let mut events = Vec::new();
for oid in revwalk.take(limit) {
let oid = oid?;
let commit = repo.find_commit(oid)?;
let short_hash = short_oid(oid);
let message = commit
.message()
.unwrap_or("")
.lines()
.next()
.unwrap_or("")
.to_string();
let author = commit.author().name().unwrap_or("unknown").to_string();
let timestamp = Local
.timestamp_opt(commit.time().seconds(), 0)
.single()
.unwrap_or_else(Local::now);
let kind = if commit.parent_count() > 1 {
GitEventKind::Merge
} else {
GitEventKind::Commit
};
let parent_hashes: Vec<String> = (0..commit.parent_count())
.filter_map(|i| commit.parent_id(i).ok())
.map(short_oid)
.collect();
let diff_stats = if include_diff_stats {
get_commit_diff_stats(repo, &commit)
} else {
DiffStats::default()
};
let labels = branch_heads.remove(&short_hash).unwrap_or_default();
let event = match kind {
GitEventKind::Merge => GitEvent::merge(short_hash, message, author, timestamp)
.with_parents(parent_hashes)
.with_labels(labels),
_ => GitEvent::commit(
short_hash,
message,
author,
timestamp,
diff_stats.insertions,
diff_stats.deletions,
)
.with_parents(parent_hashes)
.with_labels(labels),
};
events.push(event);
}
Ok(events)
}
cached_fn! {
pub fn get_head_hash_cached(repo: Option<&Repository>) -> Result<String> {
from_repo: get_head_hash_from_repo,
}
}
cached_fn! {
pub fn get_index_mtime_cached(repo: Option<&Repository>) -> Result<SystemTime> {
from_repo: get_index_mtime_from_repo,
}
}
cached_fn! {
pub fn get_status_cached(repo: Option<&Repository>) -> Result<Vec<FileStatus>> {
from_repo: get_status_from_repo,
}
}
cached_fn! {
pub fn list_branches_cached(repo: Option<&Repository>) -> Result<Vec<BranchInfo>> {
from_repo: list_branches_from_repo,
}
}
cached_fn! {
pub fn get_repo_info_cached(repo: Option<&Repository>) -> Result<RepoInfo> {
from_repo: RepoInfo::from_repo,
}
}
pub fn get_file_history(path: &str) -> Result<Vec<FileHistoryEntry>> {
let repo = discover_repo()?;
get_file_history_from_repo(&repo, path)
}
pub fn get_file_history_from_repo(repo: &Repository, path: &str) -> Result<Vec<FileHistoryEntry>> {
let mut revwalk = repo.revwalk()?;
revwalk.set_sorting(Sort::TIME | Sort::TOPOLOGICAL)?;
revwalk.push_head()?;
let mut entries = Vec::new();
let mut diff_opts = git2::DiffOptions::new();
diff_opts.pathspec(path);
for oid in revwalk {
let oid = oid?;
let commit = repo.find_commit(oid)?;
let tree = commit.tree()?;
let parent_tree = commit.parent(0).ok().and_then(|p| p.tree().ok());
let diff =
repo.diff_tree_to_tree(parent_tree.as_ref(), Some(&tree), Some(&mut diff_opts))?;
let stats = diff.stats()?;
if stats.files_changed() == 0 {
continue;
}
let mut insertions = 0usize;
let mut deletions = 0usize;
diff.foreach(
&mut |_delta, _| true,
None,
None,
Some(&mut |_delta, _hunk, line| {
match line.origin() {
'+' => insertions += 1,
'-' => deletions += 1,
_ => {}
}
true
}),
)?;
let short_hash = short_oid(oid);
let message = commit
.message()
.unwrap_or("")
.lines()
.next()
.unwrap_or("")
.to_string();
let author = commit.author().name().unwrap_or("unknown").to_string();
let timestamp = Local
.timestamp_opt(commit.time().seconds(), 0)
.single()
.unwrap_or_else(Local::now);
entries.push(FileHistoryEntry {
hash: short_hash,
author,
date: timestamp,
message,
insertions,
deletions,
});
if entries.len() >= 100 {
break;
}
}
Ok(entries)
}
pub fn get_blame(path: &str) -> Result<Vec<BlameLine>> {
let repo = discover_repo()?;
get_blame_from_repo(&repo, path)
}
pub fn get_blame_from_repo(repo: &Repository, path: &str) -> Result<Vec<BlameLine>> {
validate_path(path)?;
let blame = repo
.blame_file(std::path::Path::new(path), None)
.context("Blameの取得に失敗しました")?;
let mut lines = Vec::new();
let workdir = repo
.workdir()
.context("ワーキングディレクトリが見つかりません")?;
let file_path = workdir.join(path);
let content = std::fs::read_to_string(&file_path).unwrap_or_default();
let file_lines: Vec<&str> = content.lines().collect();
for (line_idx, hunk) in blame.iter().enumerate() {
let oid = hunk.final_commit_id();
let short_hash = oid.to_string()[..7.min(oid.to_string().len())].to_string();
let (author, date) = if let Ok(commit) = repo.find_commit(oid) {
let author_name = commit.author().name().unwrap_or("unknown").to_string();
let timestamp = Local
.timestamp_opt(commit.time().seconds(), 0)
.single()
.unwrap_or_else(Local::now);
(author_name, timestamp)
} else {
("unknown".to_string(), Local::now())
};
let line_content = file_lines.get(line_idx).unwrap_or(&"").to_string();
lines.push(BlameLine {
hash: short_hash,
author,
date,
line_number: line_idx + 1,
content: line_content,
});
}
Ok(lines)
}
pub fn get_stash_list() -> Result<Vec<StashEntry>> {
let mut repo = discover_repo()?;
let mut entries = Vec::new();
repo.stash_foreach(|index, message, _oid| {
entries.push(StashEntry {
index,
message: message.to_string(),
});
true })?;
Ok(entries)
}
pub fn stash_save(message: &str) -> Result<Oid> {
let mut repo = discover_repo()?;
let sig = repo
.signature()
.or_else(|_| Signature::now("gitstack", "gitstack@local"))?;
let stash_id = repo.stash_save(&sig, message, None)?;
Ok(stash_id)
}
pub fn stash_apply(index: usize) -> Result<()> {
let mut repo = discover_repo()?;
repo.stash_apply(index, None)?;
Ok(())
}
pub fn stash_pop(index: usize) -> Result<()> {
let mut repo = discover_repo()?;
repo.stash_pop(index, None)?;
Ok(())
}
pub fn stash_drop(index: usize) -> Result<()> {
let mut repo = discover_repo()?;
repo.stash_drop(index)?;
Ok(())
}
pub fn compare_branches(base: &str, target: &str) -> Result<crate::compare::BranchCompare> {
let repo = discover_repo()?;
compare_branches_from_repo(&repo, base, target)
}
pub fn compare_branches_from_repo(
repo: &Repository,
base: &str,
target: &str,
) -> Result<crate::compare::BranchCompare> {
use crate::compare::BranchCompare;
let base_obj = repo
.revparse_single(base)
.context(format!("ブランチ '{}' が見つかりません", base))?;
let target_obj = repo
.revparse_single(target)
.context(format!("ブランチ '{}' が見つかりません", target))?;
let base_commit = base_obj.peel_to_commit()?;
let target_commit = target_obj.peel_to_commit()?;
let merge_base_oid = repo
.merge_base(base_commit.id(), target_commit.id())
.context("マージベースが見つかりません")?;
let merge_base = short_oid(merge_base_oid);
let ahead_commits = get_commits_between(repo, merge_base_oid, target_commit.id())?;
let behind_commits = get_commits_between(repo, merge_base_oid, base_commit.id())?;
Ok(BranchCompare {
base_branch: base.to_string(),
target_branch: target.to_string(),
ahead_commits,
behind_commits,
merge_base,
})
}
fn get_commits_between(
repo: &Repository,
from: git2::Oid,
to: git2::Oid,
) -> Result<Vec<crate::compare::CompareCommit>> {
use crate::compare::CompareCommit;
if from == to {
return Ok(Vec::new());
}
let mut revwalk = repo.revwalk()?;
revwalk.push(to)?;
revwalk.hide(from)?;
revwalk.set_sorting(Sort::TOPOLOGICAL | Sort::TIME)?;
let mut commits = Vec::new();
for oid_result in revwalk {
let oid = oid_result?;
let commit = repo.find_commit(oid)?;
let message = commit
.message()
.unwrap_or("")
.lines()
.next()
.unwrap_or("")
.to_string();
let author = commit.author().name().unwrap_or("Unknown").to_string();
let date = Local
.timestamp_opt(commit.time().seconds(), 0)
.single()
.context("Invalid commit timestamp")?;
commits.push(CompareCommit {
hash: short_oid(oid),
message,
author,
date,
});
}
Ok(commits)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::path::Path;
use tempfile::TempDir;
fn init_test_repo() -> (TempDir, Repository) {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let repo = Repository::init(temp_dir.path()).expect("Failed to init repo");
let sig = git2::Signature::now("Test Author", "test@example.com").unwrap();
let tree_id = {
let mut index = repo.index().unwrap();
let test_file = temp_dir.path().join("test.txt");
fs::write(&test_file, "test content").unwrap();
index.add_path(Path::new("test.txt")).unwrap();
index.write().unwrap();
index.write_tree().unwrap()
};
{
let tree = repo.find_tree(tree_id).unwrap();
repo.commit(Some("HEAD"), &sig, &sig, "Initial commit", &tree, &[])
.unwrap();
}
(temp_dir, repo)
}
#[test]
fn test_repo_info_from_repo_gets_name() {
let (_temp_dir, repo) = init_test_repo();
let info = RepoInfo::from_repo(&repo).unwrap();
assert!(!info.name.is_empty());
}
#[test]
fn test_repo_info_from_repo_gets_branch() {
let (_temp_dir, repo) = init_test_repo();
let info = RepoInfo::from_repo(&repo).unwrap();
assert!(info.branch == "master" || info.branch == "main");
}
#[test]
fn test_load_events_from_repo_returns_events() {
let (_temp_dir, repo) = init_test_repo();
let events = load_events_from_repo(&repo, 10, true).unwrap();
assert!(!events.is_empty());
}
#[test]
fn test_load_events_from_repo_first_event_is_initial_commit() {
let (_temp_dir, repo) = init_test_repo();
let events = load_events_from_repo(&repo, 10, true).unwrap();
assert_eq!(events[0].message, "Initial commit");
}
#[test]
fn test_load_events_from_repo_respects_limit() {
let (temp_dir, repo) = init_test_repo();
let sig = git2::Signature::now("Test Author", "test@example.com").unwrap();
for i in 1..=5 {
let test_file = temp_dir.path().join(format!("file{}.txt", i));
fs::write(&test_file, format!("content {}", i)).unwrap();
let mut index = repo.index().unwrap();
index
.add_path(Path::new(&format!("file{}.txt", i)))
.unwrap();
index.write().unwrap();
let tree_id = index.write_tree().unwrap();
let tree = repo.find_tree(tree_id).unwrap();
let parent = repo.head().unwrap().peel_to_commit().unwrap();
repo.commit(
Some("HEAD"),
&sig,
&sig,
&format!("Commit {}", i),
&tree,
&[&parent],
)
.unwrap();
}
let events = load_events_from_repo(&repo, 3, true).unwrap();
assert_eq!(events.len(), 3);
}
#[test]
fn test_load_events_from_repo_returns_commits_in_order() {
let (temp_dir, repo) = init_test_repo();
let sig = git2::Signature::now("Test Author", "test@example.com").unwrap();
for i in 1..=3 {
let test_file = temp_dir.path().join(format!("file{}.txt", i));
fs::write(&test_file, format!("content {}", i)).unwrap();
let mut index = repo.index().unwrap();
index
.add_path(Path::new(&format!("file{}.txt", i)))
.unwrap();
index.write().unwrap();
let tree_id = index.write_tree().unwrap();
let tree = repo.find_tree(tree_id).unwrap();
let parent = repo.head().unwrap().peel_to_commit().unwrap();
repo.commit(
Some("HEAD"),
&sig,
&sig,
&format!("Commit {}", i),
&tree,
&[&parent],
)
.unwrap();
}
let events = load_events_from_repo(&repo, 10, true).unwrap();
assert_eq!(events.len(), 4);
assert!(events.iter().any(|e| e.message == "Commit 3"));
assert!(events.iter().any(|e| e.message == "Initial commit"));
}
#[test]
fn test_load_events_from_repo_event_has_short_hash() {
let (_temp_dir, repo) = init_test_repo();
let events = load_events_from_repo(&repo, 10, true).unwrap();
assert_eq!(events[0].short_hash.len(), 7);
}
#[test]
fn test_load_events_from_repo_event_has_author() {
let (_temp_dir, repo) = init_test_repo();
let events = load_events_from_repo(&repo, 10, true).unwrap();
assert_eq!(events[0].author, "Test Author");
}
#[test]
fn test_load_events_from_repo_event_has_file_stats() {
let (_temp_dir, repo) = init_test_repo();
let events = load_events_from_repo(&repo, 10, true).unwrap();
assert!(events[0].files_added > 0);
}
#[test]
fn test_get_commit_diff_stats_returns_stats() {
let (_temp_dir, repo) = init_test_repo();
let commit = repo.head().unwrap().peel_to_commit().unwrap();
let stats = get_commit_diff_stats(&repo, &commit);
assert!(stats.files_changed > 0 || stats.insertions > 0);
}
#[test]
fn test_list_branches_from_repo_returns_branches() {
let (_temp_dir, repo) = init_test_repo();
let branches = list_branches_from_repo(&repo).unwrap();
assert!(!branches.is_empty());
}
#[test]
fn test_list_branches_from_repo_includes_current_branch() {
let (_temp_dir, repo) = init_test_repo();
let branches = list_branches_from_repo(&repo).unwrap();
assert!(branches
.iter()
.any(|b| b.name == "master" || b.name == "main"));
}
#[test]
fn test_checkout_branch_in_repo_switches_branch() {
let (_temp_dir, repo) = init_test_repo();
{
let head = repo.head().unwrap().peel_to_commit().unwrap();
repo.branch("test-branch", &head, false).unwrap();
}
checkout_branch_in_repo(&repo, "test-branch").unwrap();
let info = RepoInfo::from_repo(&repo).unwrap();
assert_eq!(info.branch, "test-branch");
}
#[test]
fn test_get_status_from_repo_empty_on_clean() {
let (_temp_dir, repo) = init_test_repo();
let statuses = get_status_from_repo(&repo).unwrap();
assert!(statuses.is_empty());
}
#[test]
fn test_get_status_from_repo_detects_modified() {
let (temp_dir, repo) = init_test_repo();
fs::write(temp_dir.path().join("test.txt"), "modified content").unwrap();
let statuses = get_status_from_repo(&repo).unwrap();
assert!(statuses.iter().any(|s| s.kind == FileStatusKind::Modified));
}
#[test]
fn test_get_status_from_repo_detects_untracked() {
let (temp_dir, repo) = init_test_repo();
fs::write(temp_dir.path().join("new_file.txt"), "new content").unwrap();
let statuses = get_status_from_repo(&repo).unwrap();
assert!(statuses.iter().any(|s| s.kind == FileStatusKind::Untracked));
}
#[test]
fn test_stage_file_in_repo_stages_file() {
let (temp_dir, repo) = init_test_repo();
fs::write(temp_dir.path().join("new_file.txt"), "new content").unwrap();
stage_file_in_repo(&repo, "new_file.txt").unwrap();
let statuses = get_status_from_repo(&repo).unwrap();
assert!(statuses.iter().any(|s| s.kind == FileStatusKind::StagedNew));
}
#[test]
fn test_unstage_file_in_repo_unstages_file() {
let (temp_dir, repo) = init_test_repo();
fs::write(temp_dir.path().join("new_file.txt"), "new content").unwrap();
stage_file_in_repo(&repo, "new_file.txt").unwrap();
unstage_file_in_repo(&repo, "new_file.txt").unwrap();
let statuses = get_status_from_repo(&repo).unwrap();
assert!(!statuses.iter().any(|s| s.kind == FileStatusKind::StagedNew));
assert!(statuses.iter().any(|s| s.kind == FileStatusKind::Untracked));
}
#[test]
fn test_create_commit_in_repo_creates_commit() {
let (temp_dir, repo) = init_test_repo();
fs::write(temp_dir.path().join("new_file.txt"), "new content").unwrap();
stage_file_in_repo(&repo, "new_file.txt").unwrap();
create_commit_in_repo(&repo, "Test commit").unwrap();
let events = load_events_from_repo(&repo, 10, true).unwrap();
assert!(events.iter().any(|e| e.message == "Test commit"));
}
#[test]
fn test_has_staged_files_in_repo_returns_false_when_empty() {
let (_temp_dir, repo) = init_test_repo();
assert!(!has_staged_files_in_repo(&repo).unwrap());
}
#[test]
fn test_has_staged_files_in_repo_returns_true_when_staged() {
let (temp_dir, repo) = init_test_repo();
fs::write(temp_dir.path().join("new_file.txt"), "new content").unwrap();
stage_file_in_repo(&repo, "new_file.txt").unwrap();
assert!(has_staged_files_in_repo(&repo).unwrap());
}
#[test]
fn test_stage_all_in_repo_stages_all_files() {
let (temp_dir, repo) = init_test_repo();
fs::write(temp_dir.path().join("file1.txt"), "content1").unwrap();
fs::write(temp_dir.path().join("file2.txt"), "content2").unwrap();
stage_all_in_repo(&repo).unwrap();
let statuses = get_status_from_repo(&repo).unwrap();
let staged_count = statuses
.iter()
.filter(|s| matches!(s.kind, FileStatusKind::StagedNew))
.count();
assert_eq!(staged_count, 2);
}
#[test]
fn test_unstage_all_in_repo_unstages_all_files() {
let (temp_dir, repo) = init_test_repo();
fs::write(temp_dir.path().join("file1.txt"), "content1").unwrap();
fs::write(temp_dir.path().join("file2.txt"), "content2").unwrap();
stage_all_in_repo(&repo).unwrap();
unstage_all_in_repo(&repo).unwrap();
let statuses = get_status_from_repo(&repo).unwrap();
let staged_count = statuses
.iter()
.filter(|s| {
matches!(
s.kind,
FileStatusKind::StagedNew
| FileStatusKind::StagedModified
| FileStatusKind::StagedDeleted
)
})
.count();
assert_eq!(staged_count, 0);
}
#[test]
fn test_sort_branches_current_first() {
let mut branches = vec![
BranchInfo::new("develop".to_string(), false),
BranchInfo::new("feature/foo".to_string(), false),
BranchInfo::new("main".to_string(), false),
];
sort_branches(&mut branches, Some("feature/foo"));
assert_eq!(branches[0].name, "feature/foo"); }
#[test]
fn test_sort_branches_priority_order() {
let mut branches = vec![
BranchInfo::new("develop".to_string(), false),
BranchInfo::new("feature/foo".to_string(), false),
BranchInfo::new("main".to_string(), false),
BranchInfo::new("master".to_string(), false),
];
sort_branches(&mut branches, None);
assert_eq!(branches[0].name, "main");
assert_eq!(branches[1].name, "master");
assert_eq!(branches[2].name, "develop");
assert_eq!(branches[3].name, "feature/foo");
}
#[test]
fn test_sort_branches_current_takes_precedence_over_priority() {
let mut branches = vec![
BranchInfo::new("develop".to_string(), false),
BranchInfo::new("main".to_string(), false),
BranchInfo::new("feature/foo".to_string(), false),
];
sort_branches(&mut branches, Some("develop"));
assert_eq!(branches[0].name, "develop");
assert_eq!(branches[1].name, "main");
assert_eq!(branches[2].name, "feature/foo");
}
#[test]
fn test_sort_branches_alphabetical_for_non_priority() {
let mut branches = vec![
BranchInfo::new("feature/xyz".to_string(), false),
BranchInfo::new("feature/abc".to_string(), false),
BranchInfo::new("bugfix/123".to_string(), false),
];
sort_branches(&mut branches, None);
assert_eq!(branches[0].name, "bugfix/123");
assert_eq!(branches[1].name, "feature/abc");
assert_eq!(branches[2].name, "feature/xyz");
}
#[test]
fn test_list_branches_from_repo_current_branch_first() {
let (_temp_dir, repo) = init_test_repo();
{
let head = repo.head().unwrap().peel_to_commit().unwrap();
repo.branch("develop", &head, false).unwrap();
repo.branch("feature/test", &head, false).unwrap();
}
let branches = list_branches_from_repo(&repo).unwrap();
let current = repo.head().unwrap().shorthand().unwrap().to_string();
assert_eq!(branches[0].name, current);
}
#[test]
fn test_validate_path_rejects_parent_dir() {
assert!(validate_path("../etc/passwd").is_err());
assert!(validate_path("foo/../bar").is_err());
}
#[test]
fn test_validate_path_rejects_null_char() {
assert!(validate_path("foo\0bar").is_err());
}
#[test]
fn test_validate_path_accepts_valid_paths() {
assert!(validate_path("src/main.rs").is_ok());
assert!(validate_path("file.txt").is_ok());
}
#[test]
fn test_validate_branch_name_rejects_empty() {
assert!(validate_branch_name("").is_err());
}
#[test]
fn test_validate_branch_name_rejects_null_char() {
assert!(validate_branch_name("main\0").is_err());
}
#[test]
fn test_validate_branch_name_accepts_valid() {
assert!(validate_branch_name("main").is_ok());
assert!(validate_branch_name("feature/new-feature").is_ok());
}
}