feat:(first commit)created repository and complete 0.1.0
This commit is contained in:
590
src/git/mod.rs
Normal file
590
src/git/mod.rs
Normal file
@@ -0,0 +1,590 @@
|
||||
use anyhow::{bail, Context, Result};
|
||||
use git2::{Repository, Signature, StatusOptions, DiffOptions};
|
||||
use std::path::Path;
|
||||
|
||||
pub mod changelog;
|
||||
pub mod commit;
|
||||
pub mod tag;
|
||||
|
||||
pub use changelog::ChangelogGenerator;
|
||||
pub use commit::CommitBuilder;
|
||||
pub use tag::TagBuilder;
|
||||
|
||||
/// Git repository wrapper
|
||||
pub struct GitRepo {
|
||||
repo: Repository,
|
||||
path: std::path::PathBuf,
|
||||
}
|
||||
|
||||
impl GitRepo {
|
||||
/// Open a git repository
|
||||
pub fn open<P: AsRef<Path>>(path: P) -> Result<Self> {
|
||||
let path = path.as_ref().canonicalize()
|
||||
.unwrap_or_else(|_| path.as_ref().to_path_buf());
|
||||
|
||||
let repo = Repository::open(&path)
|
||||
.with_context(|| format!("Failed to open git repository: {:?}", path))?;
|
||||
|
||||
Ok(Self { repo, path })
|
||||
}
|
||||
|
||||
/// Get repository path
|
||||
pub fn path(&self) -> &Path {
|
||||
&self.path
|
||||
}
|
||||
|
||||
/// Get internal git2 repository
|
||||
pub fn inner(&self) -> &Repository {
|
||||
&self.repo
|
||||
}
|
||||
|
||||
/// Check if this is a valid git repository
|
||||
pub fn is_valid(&self) -> bool {
|
||||
!self.repo.is_bare()
|
||||
}
|
||||
|
||||
/// Check if there are uncommitted changes
|
||||
pub fn has_changes(&self) -> Result<bool> {
|
||||
let statuses = self.repo.statuses(Some(
|
||||
StatusOptions::new()
|
||||
.include_untracked(true)
|
||||
.renames_head_to_index(true)
|
||||
.renames_index_to_workdir(true),
|
||||
))?;
|
||||
|
||||
Ok(!statuses.is_empty())
|
||||
}
|
||||
|
||||
/// Get staged diff
|
||||
pub fn get_staged_diff(&self) -> Result<String> {
|
||||
let head = self.repo.head().ok();
|
||||
let head_tree = head.as_ref()
|
||||
.and_then(|h| h.peel_to_tree().ok());
|
||||
|
||||
let mut index = self.repo.index()?;
|
||||
let index_tree = index.write_tree()?;
|
||||
let index_tree = self.repo.find_tree(index_tree)?;
|
||||
|
||||
let diff = if let Some(head) = head_tree {
|
||||
self.repo.diff_tree_to_index(Some(&head), Some(&index), None)?
|
||||
} else {
|
||||
self.repo.diff_tree_to_index(None, Some(&index), None)?
|
||||
};
|
||||
|
||||
let mut diff_text = String::new();
|
||||
diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
|
||||
if let Ok(content) = std::str::from_utf8(line.content()) {
|
||||
diff_text.push_str(content);
|
||||
}
|
||||
true
|
||||
})?;
|
||||
|
||||
Ok(diff_text)
|
||||
}
|
||||
|
||||
/// Get unstaged diff
|
||||
pub fn get_unstaged_diff(&self) -> Result<String> {
|
||||
let diff = self.repo.diff_index_to_workdir(None, None)?;
|
||||
|
||||
let mut diff_text = String::new();
|
||||
diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
|
||||
if let Ok(content) = std::str::from_utf8(line.content()) {
|
||||
diff_text.push_str(content);
|
||||
}
|
||||
true
|
||||
})?;
|
||||
|
||||
Ok(diff_text)
|
||||
}
|
||||
|
||||
/// Get complete diff (staged + unstaged)
|
||||
pub fn get_full_diff(&self) -> Result<String> {
|
||||
let staged = self.get_staged_diff().unwrap_or_default();
|
||||
let unstaged = self.get_unstaged_diff().unwrap_or_default();
|
||||
|
||||
Ok(format!("{}{}", staged, unstaged))
|
||||
}
|
||||
|
||||
/// Get list of changed files
|
||||
pub fn get_changed_files(&self) -> Result<Vec<String>> {
|
||||
let statuses = self.repo.statuses(Some(
|
||||
StatusOptions::new()
|
||||
.include_untracked(true)
|
||||
.renames_head_to_index(true)
|
||||
.renames_index_to_workdir(true),
|
||||
))?;
|
||||
|
||||
let mut files = vec![];
|
||||
for entry in statuses.iter() {
|
||||
if let Some(path) = entry.path() {
|
||||
files.push(path.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
/// Get list of staged files
|
||||
pub fn get_staged_files(&self) -> Result<Vec<String>> {
|
||||
let statuses = self.repo.statuses(Some(
|
||||
StatusOptions::new()
|
||||
.include_untracked(false),
|
||||
))?;
|
||||
|
||||
let mut files = vec![];
|
||||
for entry in statuses.iter() {
|
||||
let status = entry.status();
|
||||
if status.is_index_new() || status.is_index_modified() || status.is_index_deleted() || status.is_index_renamed() || status.is_index_typechange() {
|
||||
if let Some(path) = entry.path() {
|
||||
files.push(path.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
/// Stage files
|
||||
pub fn stage_files<P: AsRef<Path>>(&self, paths: &[P]) -> Result<()> {
|
||||
let mut index = self.repo.index()?;
|
||||
|
||||
for path in paths {
|
||||
index.add_path(path.as_ref())?;
|
||||
}
|
||||
|
||||
index.write()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stage all changes
|
||||
pub fn stage_all(&self) -> Result<()> {
|
||||
let mut index = self.repo.index()?;
|
||||
|
||||
// Get list of all files in working directory
|
||||
let mut paths = Vec::new();
|
||||
for entry in std::fs::read_dir(".")? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if path.is_file() {
|
||||
paths.push(path.to_path_buf());
|
||||
}
|
||||
}
|
||||
|
||||
for path_buf in paths {
|
||||
if let Ok(_) = index.add_path(&path_buf) {
|
||||
// File added successfully
|
||||
}
|
||||
}
|
||||
|
||||
index.write()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Unstage files
|
||||
pub fn unstage_files<P: AsRef<Path>>(&self, paths: &[P]) -> Result<()> {
|
||||
let head = self.repo.head()?;
|
||||
let head_commit = head.peel_to_commit()?;
|
||||
let head_tree = head_commit.tree()?;
|
||||
|
||||
let mut index = self.repo.index()?;
|
||||
|
||||
for path in paths {
|
||||
// For now, just reset the index to HEAD
|
||||
// This removes all staged changes
|
||||
index.clear()?;
|
||||
}
|
||||
|
||||
index.write()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create a commit
|
||||
pub fn commit(&self, message: &str, sign: bool) -> Result<git2::Oid> {
|
||||
let signature = self.repo.signature()?;
|
||||
let head = self.repo.head().ok();
|
||||
|
||||
let parents = if let Some(ref head) = head {
|
||||
vec![head.peel_to_commit()?]
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
|
||||
let parent_refs: Vec<&git2::Commit> = parents.iter().collect();
|
||||
|
||||
let oid = if sign {
|
||||
// For GPG signing, we need to use the command-line git
|
||||
self.commit_signed(message, &signature)?
|
||||
} else {
|
||||
let tree_id = self.repo.index()?.write_tree()?;
|
||||
let tree = self.repo.find_tree(tree_id)?;
|
||||
|
||||
self.repo.commit(
|
||||
Some("HEAD"),
|
||||
&signature,
|
||||
&signature,
|
||||
message,
|
||||
&tree,
|
||||
&parent_refs,
|
||||
)?
|
||||
};
|
||||
|
||||
Ok(oid)
|
||||
}
|
||||
|
||||
/// Create a signed commit using git command
|
||||
fn commit_signed(&self, message: &str, _signature: &git2::Signature) -> Result<git2::Oid> {
|
||||
use std::process::Command;
|
||||
|
||||
// Write message to temp file
|
||||
let temp_file = tempfile::NamedTempFile::new()?;
|
||||
std::fs::write(temp_file.path(), message)?;
|
||||
|
||||
// Use git CLI for signed commit
|
||||
let output = Command::new("git")
|
||||
.args(&["commit", "-S", "-F", temp_file.path().to_str().unwrap()])
|
||||
.current_dir(&self.path)
|
||||
.output()?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
bail!("Failed to create signed commit: {}", stderr);
|
||||
}
|
||||
|
||||
// Get the new HEAD
|
||||
let head = self.repo.head()?;
|
||||
Ok(head.target().unwrap())
|
||||
}
|
||||
|
||||
/// Get current branch name
|
||||
pub fn current_branch(&self) -> Result<String> {
|
||||
let head = self.repo.head()?;
|
||||
|
||||
if head.is_branch() {
|
||||
let name = head.shorthand()
|
||||
.ok_or_else(|| anyhow::anyhow!("Invalid branch name"))?;
|
||||
Ok(name.to_string())
|
||||
} else {
|
||||
bail!("HEAD is not pointing to a branch")
|
||||
}
|
||||
}
|
||||
|
||||
/// Get current commit hash (short)
|
||||
pub fn current_commit_short(&self) -> Result<String> {
|
||||
let head = self.repo.head()?;
|
||||
let oid = head.target()
|
||||
.ok_or_else(|| anyhow::anyhow!("No target for HEAD"))?;
|
||||
Ok(oid.to_string()[..8].to_string())
|
||||
}
|
||||
|
||||
/// Get current commit hash (full)
|
||||
pub fn current_commit(&self) -> Result<String> {
|
||||
let head = self.repo.head()?;
|
||||
let oid = head.target()
|
||||
.ok_or_else(|| anyhow::anyhow!("No target for HEAD"))?;
|
||||
Ok(oid.to_string())
|
||||
}
|
||||
|
||||
/// Get commit history
|
||||
pub fn get_commits(&self, count: usize) -> Result<Vec<CommitInfo>> {
|
||||
let mut revwalk = self.repo.revwalk()?;
|
||||
revwalk.push_head()?;
|
||||
|
||||
let mut commits = vec![];
|
||||
for (i, oid) in revwalk.enumerate() {
|
||||
if i >= count {
|
||||
break;
|
||||
}
|
||||
|
||||
let oid = oid?;
|
||||
let commit = self.repo.find_commit(oid)?;
|
||||
|
||||
commits.push(CommitInfo {
|
||||
id: oid.to_string(),
|
||||
short_id: oid.to_string()[..8].to_string(),
|
||||
message: commit.message().unwrap_or("").to_string(),
|
||||
author: commit.author().name().unwrap_or("").to_string(),
|
||||
email: commit.author().email().unwrap_or("").to_string(),
|
||||
time: commit.time().seconds(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(commits)
|
||||
}
|
||||
|
||||
/// Get commits between two references
|
||||
pub fn get_commits_between(&self, from: &str, to: &str) -> Result<Vec<CommitInfo>> {
|
||||
let from_obj = self.repo.revparse_single(from)?;
|
||||
let to_obj = self.repo.revparse_single(to)?;
|
||||
|
||||
let from_commit = from_obj.peel_to_commit()?;
|
||||
let to_commit = to_obj.peel_to_commit()?;
|
||||
|
||||
let mut revwalk = self.repo.revwalk()?;
|
||||
revwalk.push(to_commit.id())?;
|
||||
revwalk.hide(from_commit.id())?;
|
||||
|
||||
let mut commits = vec![];
|
||||
for oid in revwalk {
|
||||
let oid = oid?;
|
||||
let commit = self.repo.find_commit(oid)?;
|
||||
|
||||
commits.push(CommitInfo {
|
||||
id: oid.to_string(),
|
||||
short_id: oid.to_string()[..8].to_string(),
|
||||
message: commit.message().unwrap_or("").to_string(),
|
||||
author: commit.author().name().unwrap_or("").to_string(),
|
||||
email: commit.author().email().unwrap_or("").to_string(),
|
||||
time: commit.time().seconds(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(commits)
|
||||
}
|
||||
|
||||
/// Get tags
|
||||
pub fn get_tags(&self) -> Result<Vec<TagInfo>> {
|
||||
let mut tags = vec![];
|
||||
|
||||
self.repo.tag_foreach(|oid, name| {
|
||||
let name = String::from_utf8_lossy(name);
|
||||
let name = name.strip_prefix("refs/tags/").unwrap_or(&name);
|
||||
|
||||
if let Ok(commit) = self.repo.find_commit(oid) {
|
||||
tags.push(TagInfo {
|
||||
name: name.to_string(),
|
||||
target: oid.to_string(),
|
||||
message: commit.message().unwrap_or("").to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
true
|
||||
})?;
|
||||
|
||||
Ok(tags)
|
||||
}
|
||||
|
||||
/// Create a tag
|
||||
pub fn create_tag(&self, name: &str, message: Option<&str>, sign: bool) -> Result<()> {
|
||||
let head = self.repo.head()?;
|
||||
let target = head.peel_to_commit()?;
|
||||
|
||||
if let Some(msg) = message {
|
||||
// Annotated tag
|
||||
let sig = self.repo.signature()?;
|
||||
|
||||
if sign {
|
||||
// Use git CLI for signed tags
|
||||
self.create_signed_tag(name, msg)?;
|
||||
} else {
|
||||
self.repo.tag(
|
||||
name,
|
||||
target.as_object(),
|
||||
&sig,
|
||||
msg,
|
||||
false,
|
||||
)?;
|
||||
}
|
||||
} else {
|
||||
// Lightweight tag
|
||||
self.repo.tag(
|
||||
name,
|
||||
target.as_object(),
|
||||
&self.repo.signature()?,
|
||||
"",
|
||||
false,
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create signed tag using git CLI
|
||||
fn create_signed_tag(&self, name: &str, message: &str) -> Result<()> {
|
||||
use std::process::Command;
|
||||
|
||||
let output = Command::new("git")
|
||||
.args(&["tag", "-s", name, "-m", message])
|
||||
.current_dir(&self.path)
|
||||
.output()?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
bail!("Failed to create signed tag: {}", stderr);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete a tag
|
||||
pub fn delete_tag(&self, name: &str) -> Result<()> {
|
||||
self.repo.tag_delete(name)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Push to remote
|
||||
pub fn push(&self, remote: &str, refspec: &str) -> Result<()> {
|
||||
use std::process::Command;
|
||||
|
||||
let output = Command::new("git")
|
||||
.args(&["push", remote, refspec])
|
||||
.current_dir(&self.path)
|
||||
.output()?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
bail!("Push failed: {}", stderr);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get remote URL
|
||||
pub fn get_remote_url(&self, remote: &str) -> Result<String> {
|
||||
let remote = self.repo.find_remote(remote)?;
|
||||
let url = remote.url()
|
||||
.ok_or_else(|| anyhow::anyhow!("Remote has no URL"))?;
|
||||
Ok(url.to_string())
|
||||
}
|
||||
|
||||
/// Check if working directory is clean
|
||||
pub fn is_clean(&self) -> Result<bool> {
|
||||
Ok(!self.has_changes()?)
|
||||
}
|
||||
|
||||
/// Get repository status summary
|
||||
pub fn status_summary(&self) -> Result<StatusSummary> {
|
||||
let statuses = self.repo.statuses(Some(StatusOptions::new().include_untracked(true)))?;
|
||||
|
||||
let mut staged = 0;
|
||||
let mut unstaged = 0;
|
||||
let mut untracked = 0;
|
||||
let mut conflicted = 0;
|
||||
|
||||
for entry in statuses.iter() {
|
||||
let status = entry.status();
|
||||
|
||||
if status.is_index_new() || status.is_index_modified() ||
|
||||
status.is_index_deleted() || status.is_index_renamed() ||
|
||||
status.is_index_typechange() {
|
||||
staged += 1;
|
||||
}
|
||||
|
||||
if status.is_wt_modified() || status.is_wt_deleted() ||
|
||||
status.is_wt_renamed() || status.is_wt_typechange() {
|
||||
unstaged += 1;
|
||||
}
|
||||
|
||||
if status.is_wt_new() {
|
||||
untracked += 1;
|
||||
}
|
||||
|
||||
if status.is_conflicted() {
|
||||
conflicted += 1;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(StatusSummary {
|
||||
staged,
|
||||
unstaged,
|
||||
untracked,
|
||||
conflicted,
|
||||
clean: staged == 0 && unstaged == 0 && untracked == 0 && conflicted == 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Commit information
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CommitInfo {
|
||||
pub id: String,
|
||||
pub short_id: String,
|
||||
pub message: String,
|
||||
pub author: String,
|
||||
pub email: String,
|
||||
pub time: i64,
|
||||
}
|
||||
|
||||
impl CommitInfo {
|
||||
/// Get commit message subject (first line)
|
||||
pub fn subject(&self) -> &str {
|
||||
self.message.lines().next().unwrap_or("")
|
||||
}
|
||||
|
||||
/// Get commit type from conventional commit
|
||||
pub fn commit_type(&self) -> Option<String> {
|
||||
let subject = self.subject();
|
||||
if let Some(colon) = subject.find(':') {
|
||||
let type_part = &subject[..colon];
|
||||
let type_name = type_part.split('(').next()?;
|
||||
Some(type_name.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tag information
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TagInfo {
|
||||
pub name: String,
|
||||
pub target: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// Repository status summary
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StatusSummary {
|
||||
pub staged: usize,
|
||||
pub unstaged: usize,
|
||||
pub untracked: usize,
|
||||
pub conflicted: usize,
|
||||
pub clean: bool,
|
||||
}
|
||||
|
||||
impl StatusSummary {
|
||||
/// Format as human-readable string
|
||||
pub fn format(&self) -> String {
|
||||
if self.clean {
|
||||
"working tree clean".to_string()
|
||||
} else {
|
||||
let mut parts = vec![];
|
||||
if self.staged > 0 {
|
||||
parts.push(format!("{} staged", self.staged));
|
||||
}
|
||||
if self.unstaged > 0 {
|
||||
parts.push(format!("{} unstaged", self.unstaged));
|
||||
}
|
||||
if self.untracked > 0 {
|
||||
parts.push(format!("{} untracked", self.untracked));
|
||||
}
|
||||
if self.conflicted > 0 {
|
||||
parts.push(format!("{} conflicted", self.conflicted));
|
||||
}
|
||||
parts.join(", ")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Find git repository starting from path and walking up
|
||||
pub fn find_repo<P: AsRef<Path>>(start_path: P) -> Result<GitRepo> {
|
||||
let start_path = start_path.as_ref();
|
||||
|
||||
if let Ok(repo) = GitRepo::open(start_path) {
|
||||
return Ok(repo);
|
||||
}
|
||||
|
||||
let mut current = start_path;
|
||||
while let Some(parent) = current.parent() {
|
||||
if let Ok(repo) = GitRepo::open(parent) {
|
||||
return Ok(repo);
|
||||
}
|
||||
current = parent;
|
||||
}
|
||||
|
||||
bail!("No git repository found starting from {:?}", start_path)
|
||||
}
|
||||
|
||||
/// Check if path is inside a git repository
|
||||
pub fn is_git_repo<P: AsRef<Path>>(path: P) -> bool {
|
||||
find_repo(path).is_ok()
|
||||
}
|
||||
Reference in New Issue
Block a user