use std::{
path::{Path, PathBuf},
vec,
};
use duct::{Expression, cmd};
use miette::{Result, miette};
use crate::{XXError, XXResult, file};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FileStatus {
Staged,
Modified,
Untracked,
Deleted,
Renamed,
Copied,
Conflicted,
}
#[derive(Debug, Clone)]
pub struct StatusEntry {
pub path: PathBuf,
pub status: FileStatus,
pub original_path: Option<PathBuf>,
}
#[derive(Debug, Clone, Default)]
pub struct Status {
pub staged: Vec<StatusEntry>,
pub modified: Vec<StatusEntry>,
pub untracked: Vec<StatusEntry>,
pub deleted: Vec<StatusEntry>,
pub conflicted: Vec<StatusEntry>,
}
impl Status {
pub fn is_clean(&self) -> bool {
self.staged.is_empty()
&& self.modified.is_empty()
&& self.untracked.is_empty()
&& self.deleted.is_empty()
&& self.conflicted.is_empty()
}
pub fn has_staged(&self) -> bool {
!self.staged.is_empty()
}
}
#[derive(Debug, Clone, Default)]
pub struct DiffStat {
pub files_changed: usize,
pub insertions: usize,
pub deletions: usize,
}
#[derive(Debug, Clone)]
pub struct Branch {
pub name: String,
pub is_current: bool,
pub sha: Option<String>,
}
#[derive(Debug, Clone)]
pub struct Tag {
pub name: String,
pub sha: Option<String>,
}
pub struct Git {
pub dir: PathBuf,
}
macro_rules! git_cmd {
( $dir:expr $(, $arg:expr )* $(,)? ) => {
{
let safe = format!("safe.directory={}", $dir.display());
cmd!("git", "-C", $dir, "-c", safe $(, $arg)*)
}
}
}
impl Git {
pub fn new(dir: PathBuf) -> Self {
Self { dir }
}
pub fn is_repo(&self) -> bool {
self.dir.join(".git").is_dir()
}
pub fn update(&self, gitref: Option<String>) -> Result<(String, String)> {
let gitref = gitref.map_or_else(|| self.current_branch(), Ok)?;
debug!("updating {} to {}", self.dir.display(), gitref);
let exec = |cmd: Expression| match cmd.stderr_to_stdout().stdout_capture().unchecked().run()
{
Ok(res) => {
if res.status.success() {
Ok(())
} else {
Err(miette!(
"git failed: {cmd:?} {}",
String::from_utf8(res.stdout).unwrap()
))
}
}
Err(err) => Err(miette!("git failed: {cmd:?} {err:#}")),
};
exec(git_cmd!(
&self.dir,
"fetch",
"--prune",
"--update-head-ok",
"origin",
&format!("+{gitref}:{gitref}"),
))?;
let prev_rev = self.current_sha()?;
exec(git_cmd!(
&self.dir,
"-c",
"advice.detachedHead=false",
"-c",
"advice.objectNameWarning=false",
"checkout",
"--force",
&gitref
))?;
let post_rev = self.current_sha()?;
file::touch_dir(&self.dir)?;
Ok((prev_rev, post_rev))
}
pub fn current_branch(&self) -> XXResult<String> {
let branch = git_cmd!(&self.dir, "branch", "--show-current")
.read()
.map_err(|err| XXError::GitError(err, self.dir.clone()))?;
debug!("current branch for {}: {}", self.dir.display(), &branch);
Ok(branch)
}
pub fn current_sha(&self) -> XXResult<String> {
let sha = git_cmd!(&self.dir, "rev-parse", "HEAD")
.read()
.map_err(|err| XXError::GitError(err, self.dir.clone()))?;
debug!("current sha for {}: {}", self.dir.display(), &sha);
Ok(sha)
}
pub fn current_sha_short(&self) -> XXResult<String> {
let sha = git_cmd!(&self.dir, "rev-parse", "--short", "HEAD")
.read()
.map_err(|err| XXError::GitError(err, self.dir.clone()))?;
debug!("current sha for {}: {}", self.dir.display(), &sha);
Ok(sha)
}
pub fn current_abbrev_ref(&self) -> XXResult<String> {
let aref = git_cmd!(&self.dir, "rev-parse", "--abbrev-ref", "HEAD")
.read()
.map_err(|err| XXError::GitError(err, self.dir.clone()))?;
debug!("current abbrev ref for {}: {}", self.dir.display(), &aref);
Ok(aref)
}
pub fn get_remote_url(&self) -> Option<String> {
if !self.dir.exists() {
return None;
}
let res = git_cmd!(&self.dir, "config", "--get", "remote.origin.url").read();
match res {
Ok(url) => {
debug!("remote url for {}: {}", self.dir.display(), &url);
Some(url)
}
Err(err) => {
warn!(
"failed to get remote url for {}: {:#}",
self.dir.display(),
err
);
None
}
}
}
pub fn split_url_and_ref(url: &str) -> (String, Option<String>) {
match url.split_once('#') {
Some((url, _ref)) => (url.to_string(), Some(_ref.to_string())),
None => (url.to_string(), None),
}
}
pub fn add<P: AsRef<Path>>(&self, paths: &[P]) -> XXResult<()> {
let path_strs: Vec<String> = paths
.iter()
.map(|p| p.as_ref().to_string_lossy().to_string())
.collect();
let safe = format!("safe.directory={}", self.dir.display());
let mut args = vec![
"-C".to_string(),
self.dir.to_string_lossy().to_string(),
"-c".to_string(),
safe,
"add".to_string(),
];
args.extend(path_strs);
let args_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
cmd("git", &args_refs)
.run()
.map_err(|err| XXError::GitError(err, self.dir.clone()))?;
Ok(())
}
pub fn add_all(&self) -> XXResult<()> {
git_cmd!(&self.dir, "add", "-A")
.run()
.map_err(|err| XXError::GitError(err, self.dir.clone()))?;
Ok(())
}
pub fn commit(&self, message: &str) -> XXResult<String> {
git_cmd!(&self.dir, "commit", "-m", message)
.run()
.map_err(|err| XXError::GitError(err, self.dir.clone()))?;
self.current_sha()
}
pub fn commit_allow_empty(&self, message: &str) -> XXResult<String> {
git_cmd!(&self.dir, "commit", "--allow-empty", "-m", message)
.run()
.map_err(|err| XXError::GitError(err, self.dir.clone()))?;
self.current_sha()
}
pub fn push(&self) -> XXResult<()> {
git_cmd!(&self.dir, "push")
.run()
.map_err(|err| XXError::GitError(err, self.dir.clone()))?;
Ok(())
}
pub fn push_to(&self, remote: &str, branch: &str) -> XXResult<()> {
git_cmd!(&self.dir, "push", remote, branch)
.run()
.map_err(|err| XXError::GitError(err, self.dir.clone()))?;
Ok(())
}
pub fn push_set_upstream(&self, remote: &str) -> XXResult<()> {
let branch = self.current_branch()?;
git_cmd!(&self.dir, "push", "-u", remote, &branch)
.run()
.map_err(|err| XXError::GitError(err, self.dir.clone()))?;
Ok(())
}
pub fn pull(&self) -> XXResult<()> {
git_cmd!(&self.dir, "pull")
.run()
.map_err(|err| XXError::GitError(err, self.dir.clone()))?;
Ok(())
}
pub fn pull_from(&self, remote: &str, branch: &str) -> XXResult<()> {
git_cmd!(&self.dir, "pull", remote, branch)
.run()
.map_err(|err| XXError::GitError(err, self.dir.clone()))?;
Ok(())
}
pub fn fetch(&self) -> XXResult<()> {
git_cmd!(&self.dir, "fetch")
.run()
.map_err(|err| XXError::GitError(err, self.dir.clone()))?;
Ok(())
}
pub fn fetch_remote(&self, remote: &str) -> XXResult<()> {
git_cmd!(&self.dir, "fetch", remote)
.run()
.map_err(|err| XXError::GitError(err, self.dir.clone()))?;
Ok(())
}
pub fn fetch_all(&self) -> XXResult<()> {
git_cmd!(&self.dir, "fetch", "--all", "--prune")
.run()
.map_err(|err| XXError::GitError(err, self.dir.clone()))?;
Ok(())
}
pub fn checkout(&self, ref_name: &str) -> XXResult<()> {
git_cmd!(&self.dir, "checkout", ref_name)
.run()
.map_err(|err| XXError::GitError(err, self.dir.clone()))?;
Ok(())
}
pub fn checkout_new_branch(&self, branch: &str) -> XXResult<()> {
git_cmd!(&self.dir, "checkout", "-b", branch)
.run()
.map_err(|err| XXError::GitError(err, self.dir.clone()))?;
Ok(())
}
pub fn status(&self) -> XXResult<Status> {
let output = git_cmd!(&self.dir, "status", "--porcelain=v1", "-z")
.read()
.map_err(|err| XXError::GitError(err, self.dir.clone()))?;
let mut status = Status::default();
let parts: Vec<&str> = output.split('\0').collect();
let mut i = 0;
while i < parts.len() {
let part = parts[i];
if part.len() < 3 {
i += 1;
continue;
}
let index_status = part.chars().next().unwrap_or(' ');
let worktree_status = part.chars().nth(1).unwrap_or(' ');
let path = PathBuf::from(&part[3..]);
let original_path = if index_status == 'R' || index_status == 'C' {
i += 1;
if i < parts.len() {
Some(PathBuf::from(parts[i]))
} else {
None
}
} else {
None
};
match (index_status, worktree_status) {
('?', '?') => {
status.untracked.push(StatusEntry {
path,
status: FileStatus::Untracked,
original_path: None,
});
}
('U', _) | (_, 'U') | ('A', 'A') | ('D', 'D') => {
status.conflicted.push(StatusEntry {
path,
status: FileStatus::Conflicted,
original_path: None,
});
}
(idx, wt) => {
match idx {
'A' | 'M' | 'T' => {
status.staged.push(StatusEntry {
path: path.clone(),
status: FileStatus::Staged,
original_path: None,
});
}
'D' => {
status.staged.push(StatusEntry {
path: path.clone(),
status: FileStatus::Deleted,
original_path: None,
});
}
'R' => {
status.staged.push(StatusEntry {
path: path.clone(),
status: FileStatus::Renamed,
original_path: original_path.clone(),
});
}
'C' => {
status.staged.push(StatusEntry {
path: path.clone(),
status: FileStatus::Copied,
original_path: original_path.clone(),
});
}
_ => {}
}
match wt {
'M' | 'T' => {
status.modified.push(StatusEntry {
path: path.clone(),
status: FileStatus::Modified,
original_path: None,
});
}
'D' => {
status.deleted.push(StatusEntry {
path,
status: FileStatus::Deleted,
original_path: None,
});
}
_ => {}
}
}
}
i += 1;
}
Ok(status)
}
pub fn diff_stat(&self, base: Option<&str>, target: Option<&str>) -> XXResult<DiffStat> {
let output = match (base, target) {
(Some(b), Some(t)) => git_cmd!(&self.dir, "diff", "--shortstat", b, t).read(),
(Some(b), None) => git_cmd!(&self.dir, "diff", "--shortstat", b).read(),
(None, Some(t)) => git_cmd!(&self.dir, "diff", "--shortstat", "HEAD", t).read(),
(None, None) => git_cmd!(&self.dir, "diff", "--shortstat", "HEAD").read(),
}
.map_err(|err| XXError::GitError(err, self.dir.clone()))?;
Ok(parse_diff_stat(&output))
}
pub fn diff_staged(&self) -> XXResult<DiffStat> {
let output = git_cmd!(&self.dir, "diff", "--cached", "--shortstat")
.read()
.map_err(|err| XXError::GitError(err, self.dir.clone()))?;
Ok(parse_diff_stat(&output))
}
pub fn tag(&self, name: &str) -> XXResult<()> {
git_cmd!(&self.dir, "tag", name)
.run()
.map_err(|err| XXError::GitError(err, self.dir.clone()))?;
Ok(())
}
pub fn tag_annotated(&self, name: &str, message: &str) -> XXResult<()> {
git_cmd!(&self.dir, "tag", "-a", name, "-m", message)
.run()
.map_err(|err| XXError::GitError(err, self.dir.clone()))?;
Ok(())
}
pub fn tag_delete(&self, name: &str) -> XXResult<()> {
git_cmd!(&self.dir, "tag", "-d", name)
.run()
.map_err(|err| XXError::GitError(err, self.dir.clone()))?;
Ok(())
}
pub fn list_tags(&self) -> XXResult<Vec<Tag>> {
let output = git_cmd!(
&self.dir,
"tag",
"--format=%(refname:short)\t%(if)%(*objectname:short)%(then)%(*objectname:short)%(else)%(objectname:short)%(end)"
)
.read()
.map_err(|err| XXError::GitError(err, self.dir.clone()))?;
let tags = output
.lines()
.filter(|line| !line.is_empty())
.filter_map(|line| {
let parts: Vec<&str> = line.split('\t').collect();
let name = parts.first()?.to_string();
if name.is_empty() {
return None;
}
Some(Tag {
name,
sha: parts
.get(1)
.filter(|s| !s.is_empty())
.map(|s| s.to_string()),
})
})
.collect();
Ok(tags)
}
pub fn list_branches(&self, include_remote: bool) -> XXResult<Vec<Branch>> {
let output = if include_remote {
git_cmd!(
&self.dir,
"branch",
"-a",
"--format=%(HEAD)\t%(refname:short)\t%(objectname:short)"
)
.read()
} else {
git_cmd!(
&self.dir,
"branch",
"--format=%(HEAD)\t%(refname:short)\t%(objectname:short)"
)
.read()
}
.map_err(|err| XXError::GitError(err, self.dir.clone()))?;
let branches = output
.lines()
.filter(|line| !line.is_empty())
.filter_map(|line| {
let parts: Vec<&str> = line.split('\t').collect();
let name = parts.get(1)?.to_string();
if name.is_empty() {
return None;
}
Some(Branch {
is_current: parts.first() == Some(&"*"),
name,
sha: parts
.get(2)
.filter(|s| !s.is_empty())
.map(|s| s.to_string()),
})
})
.collect();
Ok(branches)
}
pub fn create_branch(&self, name: &str) -> XXResult<()> {
git_cmd!(&self.dir, "branch", name)
.run()
.map_err(|err| XXError::GitError(err, self.dir.clone()))?;
Ok(())
}
pub fn delete_branch(&self, name: &str, force: bool) -> XXResult<()> {
let flag = if force { "-D" } else { "-d" };
git_cmd!(&self.dir, "branch", flag, name)
.run()
.map_err(|err| XXError::GitError(err, self.dir.clone()))?;
Ok(())
}
pub fn reset(&self, ref_name: &str, mode: ResetMode) -> XXResult<()> {
let mode_flag = match mode {
ResetMode::Soft => "--soft",
ResetMode::Mixed => "--mixed",
ResetMode::Hard => "--hard",
};
git_cmd!(&self.dir, "reset", mode_flag, ref_name)
.run()
.map_err(|err| XXError::GitError(err, self.dir.clone()))?;
Ok(())
}
pub fn stash(&self) -> XXResult<()> {
git_cmd!(&self.dir, "stash")
.run()
.map_err(|err| XXError::GitError(err, self.dir.clone()))?;
Ok(())
}
pub fn stash_with_message(&self, message: &str) -> XXResult<()> {
git_cmd!(&self.dir, "stash", "push", "-m", message)
.run()
.map_err(|err| XXError::GitError(err, self.dir.clone()))?;
Ok(())
}
pub fn stash_pop(&self) -> XXResult<()> {
git_cmd!(&self.dir, "stash", "pop")
.run()
.map_err(|err| XXError::GitError(err, self.dir.clone()))?;
Ok(())
}
pub fn stash_apply(&self) -> XXResult<()> {
git_cmd!(&self.dir, "stash", "apply")
.run()
.map_err(|err| XXError::GitError(err, self.dir.clone()))?;
Ok(())
}
pub fn stash_list(&self) -> XXResult<Vec<String>> {
let output = git_cmd!(&self.dir, "stash", "list")
.read()
.map_err(|err| XXError::GitError(err, self.dir.clone()))?;
Ok(output.lines().map(|s| s.to_string()).collect())
}
pub fn root(&self) -> XXResult<PathBuf> {
let output = git_cmd!(&self.dir, "rev-parse", "--show-toplevel")
.read()
.map_err(|err| XXError::GitError(err, self.dir.clone()))?;
Ok(PathBuf::from(output.trim()))
}
pub fn ref_exists(&self, ref_name: &str) -> bool {
git_cmd!(&self.dir, "rev-parse", "--verify", ref_name)
.stderr_null()
.stdout_null()
.run()
.is_ok()
}
pub fn merge_base(&self, ref1: &str, ref2: &str) -> XXResult<String> {
let output = git_cmd!(&self.dir, "merge-base", ref1, ref2)
.read()
.map_err(|err| XXError::GitError(err, self.dir.clone()))?;
Ok(output.trim().to_string())
}
pub fn log(&self, count: usize) -> XXResult<Vec<(String, String)>> {
let output = git_cmd!(&self.dir, "log", &format!("-{}", count), "--format=%H\t%s")
.read()
.map_err(|err| XXError::GitError(err, self.dir.clone()))?;
let commits = output
.lines()
.filter(|line| !line.is_empty())
.map(|line| {
let parts: Vec<&str> = line.splitn(2, '\t').collect();
(
parts.first().unwrap_or(&"").to_string(),
parts.get(1).unwrap_or(&"").to_string(),
)
})
.collect();
Ok(commits)
}
pub fn has_commits(&self) -> bool {
git_cmd!(&self.dir, "rev-parse", "HEAD")
.stderr_null()
.stdout_null()
.run()
.is_ok()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ResetMode {
Soft,
Mixed,
Hard,
}
fn parse_diff_stat(output: &str) -> DiffStat {
let mut stat = DiffStat::default();
let output = output.trim();
if output.is_empty() {
return stat;
}
for part in output.split(", ") {
let part = part.trim();
if part.contains("file")
&& let Some(num) = part.split_whitespace().next()
{
stat.files_changed = num.parse().unwrap_or(0);
} else if part.contains("insertion")
&& let Some(num) = part.split_whitespace().next()
{
stat.insertions = num.parse().unwrap_or(0);
} else if part.contains("deletion")
&& let Some(num) = part.split_whitespace().next()
{
stat.deletions = num.parse().unwrap_or(0);
}
}
stat
}
pub fn init<D: AsRef<Path>>(dir: D) -> XXResult<Git> {
let dir = dir.as_ref().to_path_buf();
file::mkdirp(&dir)?;
cmd!("git", "init", &dir)
.run()
.map_err(|err| XXError::GitError(err, dir.clone()))?;
Ok(Git::new(dir))
}
pub fn clone<D: AsRef<Path>>(url: &str, dir: D, clone_options: &CloneOptions) -> XXResult<Git> {
let dir = dir.as_ref().to_path_buf();
debug!("cloning {} to {}", url, dir.display());
if let Some(parent) = dir.parent() {
file::mkdirp(parent)?;
}
match get_git_version() {
Ok(version) => trace!("git version: {version}"),
Err(err) => warn!("failed to get git version: {err:#}\n Git is required to use mise."),
}
let dir_str = dir.to_string_lossy().to_string();
let mut cmd_args = vec!["clone", "-q", "--depth", "1", &url, &dir_str];
if let Some(branch) = clone_options.branch.as_ref() {
cmd_args.push("--branch");
cmd_args.push(branch);
cmd_args.push("--single-branch");
cmd_args.push("-c");
cmd_args.push("advice.detachedHead=false");
}
cmd("git", &cmd_args)
.run()
.map_err(|err| XXError::GitError(err, dir.clone()))?;
Ok(Git::new(dir))
}
fn get_git_version() -> Result<String> {
let version = cmd!("git", "--version")
.read()
.map_err(|err| XXError::GitError(err, PathBuf::new()))?;
Ok(version.trim().into())
}
#[cfg(test)]
mod tests {
use super::*;
use test_log::test;
#[test]
fn test_git() {
let tmp = tempfile::tempdir().unwrap();
let git = Git::new(tmp.path().to_path_buf());
assert!(!git.is_repo());
assert_eq!(git.get_remote_url(), None);
assert!(git.current_branch().is_err());
assert!(git.current_sha().is_err());
assert!(git.current_sha_short().is_err());
assert!(git.current_abbrev_ref().is_err());
let git = clone(
"https://github.com/jdx/xx",
&git.dir,
&CloneOptions::default(),
)
.unwrap();
assert!(git.is_repo());
assert_eq!(
git.get_remote_url(),
Some("https://github.com/jdx/xx".to_string())
);
file::remove_dir_all(tmp.path()).unwrap();
}
#[test]
fn test_git_with_options() {
let tmp = tempfile::tempdir().unwrap();
let git = Git::new(tmp.path().to_path_buf());
assert!(!git.is_repo());
assert_eq!(git.get_remote_url(), None);
assert!(git.current_branch().is_err());
assert!(git.current_sha().is_err());
assert!(git.current_sha_short().is_err());
assert!(git.current_abbrev_ref().is_err());
let clone_options = CloneOptions::default().branch("v2.0.0");
let git = clone("https://github.com/jdx/xx", &git.dir, &clone_options).unwrap();
assert!(git.is_repo());
assert!(
git.current_sha()
.is_ok_and(|s| s == "e5352617769f0edff7758713d05fff6b6ddf1266")
);
assert_eq!(
git.get_remote_url(),
Some("https://github.com/jdx/xx".to_string())
);
file::remove_dir_all("/tmp/xx").unwrap();
}
#[test]
fn test_git_init() {
let tmp = tempfile::tempdir().unwrap();
let repo_path = tmp.path().join("new-repo");
let git = init(&repo_path).unwrap();
assert!(git.is_repo());
assert!(!git.has_commits());
let root = git.root().unwrap();
assert_eq!(root, repo_path.canonicalize().unwrap());
}
#[test]
fn test_git_add_commit() {
let tmp = tempfile::tempdir().unwrap();
let repo_path = tmp.path().join("test-repo");
let git = init(&repo_path).unwrap();
git_cmd!(&git.dir, "config", "user.email", "test@example.com")
.run()
.unwrap();
git_cmd!(&git.dir, "config", "user.name", "Test User")
.run()
.unwrap();
let test_file = repo_path.join("test.txt");
file::write(&test_file, "hello world").unwrap();
git.add(&[&test_file]).unwrap();
let status = git.status().unwrap();
assert!(status.has_staged());
assert_eq!(status.staged.len(), 1);
assert_eq!(status.staged[0].path, PathBuf::from("test.txt"));
let sha = git.commit("Initial commit").unwrap();
assert!(!sha.is_empty());
assert!(git.has_commits());
let status = git.status().unwrap();
assert!(status.is_clean());
let log = git.log(1).unwrap();
assert_eq!(log.len(), 1);
assert_eq!(log[0].1, "Initial commit");
}
#[test]
fn test_git_add_all() {
let tmp = tempfile::tempdir().unwrap();
let repo_path = tmp.path().join("test-repo");
let git = init(&repo_path).unwrap();
git_cmd!(&git.dir, "config", "user.email", "test@example.com")
.run()
.unwrap();
git_cmd!(&git.dir, "config", "user.name", "Test User")
.run()
.unwrap();
file::write(repo_path.join("file1.txt"), "content1").unwrap();
file::write(repo_path.join("file2.txt"), "content2").unwrap();
file::mkdirp(repo_path.join("subdir")).unwrap();
file::write(repo_path.join("subdir/file3.txt"), "content3").unwrap();
let status = git.status().unwrap();
assert_eq!(status.untracked.len(), 3);
git.add_all().unwrap();
let status = git.status().unwrap();
assert!(status.untracked.is_empty());
assert_eq!(status.staged.len(), 3);
}
#[test]
fn test_git_status_modified() {
let tmp = tempfile::tempdir().unwrap();
let repo_path = tmp.path().join("test-repo");
let git = init(&repo_path).unwrap();
git_cmd!(&git.dir, "config", "user.email", "test@example.com")
.run()
.unwrap();
git_cmd!(&git.dir, "config", "user.name", "Test User")
.run()
.unwrap();
let test_file = repo_path.join("test.txt");
file::write(&test_file, "initial content").unwrap();
git.add_all().unwrap();
git.commit("Initial commit").unwrap();
file::write(&test_file, "modified content").unwrap();
let status = git.status().unwrap();
assert!(!status.is_clean());
assert_eq!(status.modified.len(), 1);
assert_eq!(status.modified[0].path, PathBuf::from("test.txt"));
}
#[test]
fn test_git_branches() {
let tmp = tempfile::tempdir().unwrap();
let repo_path = tmp.path().join("test-repo");
let git = init(&repo_path).unwrap();
git_cmd!(&git.dir, "config", "user.email", "test@example.com")
.run()
.unwrap();
git_cmd!(&git.dir, "config", "user.name", "Test User")
.run()
.unwrap();
file::write(repo_path.join("test.txt"), "content").unwrap();
git.add_all().unwrap();
git.commit("Initial commit").unwrap();
git.create_branch("feature").unwrap();
let branches = git.list_branches(false).unwrap();
assert_eq!(branches.len(), 2);
let current = branches.iter().find(|b| b.is_current).unwrap();
assert!(current.name == "master" || current.name == "main");
let feature = branches.iter().find(|b| b.name == "feature").unwrap();
assert!(!feature.is_current);
git.checkout("feature").unwrap();
let current_branch = git.current_branch().unwrap();
assert_eq!(current_branch, "feature");
git.checkout("master")
.or_else(|_| git.checkout("main"))
.unwrap();
git.delete_branch("feature", false).unwrap();
let branches = git.list_branches(false).unwrap();
assert_eq!(branches.len(), 1);
}
#[test]
fn test_git_checkout_new_branch() {
let tmp = tempfile::tempdir().unwrap();
let repo_path = tmp.path().join("test-repo");
let git = init(&repo_path).unwrap();
git_cmd!(&git.dir, "config", "user.email", "test@example.com")
.run()
.unwrap();
git_cmd!(&git.dir, "config", "user.name", "Test User")
.run()
.unwrap();
file::write(repo_path.join("test.txt"), "content").unwrap();
git.add_all().unwrap();
git.commit("Initial commit").unwrap();
git.checkout_new_branch("feature/test").unwrap();
let current_branch = git.current_branch().unwrap();
assert_eq!(current_branch, "feature/test");
}
#[test]
fn test_git_tags() {
let tmp = tempfile::tempdir().unwrap();
let repo_path = tmp.path().join("test-repo");
let git = init(&repo_path).unwrap();
git_cmd!(&git.dir, "config", "user.email", "test@example.com")
.run()
.unwrap();
git_cmd!(&git.dir, "config", "user.name", "Test User")
.run()
.unwrap();
file::write(repo_path.join("test.txt"), "content").unwrap();
git.add_all().unwrap();
git.commit("Initial commit").unwrap();
git.tag("v1.0.0").unwrap();
git.tag_annotated("v1.1.0", "Release 1.1.0").unwrap();
let tags = git.list_tags().unwrap();
assert_eq!(tags.len(), 2);
let tag_names: Vec<&str> = tags.iter().map(|t| t.name.as_str()).collect();
assert!(tag_names.contains(&"v1.0.0"));
assert!(tag_names.contains(&"v1.1.0"));
git.tag_delete("v1.0.0").unwrap();
let tags = git.list_tags().unwrap();
assert_eq!(tags.len(), 1);
assert_eq!(tags[0].name, "v1.1.0");
}
#[test]
fn test_git_reset() {
let tmp = tempfile::tempdir().unwrap();
let repo_path = tmp.path().join("test-repo");
let git = init(&repo_path).unwrap();
git_cmd!(&git.dir, "config", "user.email", "test@example.com")
.run()
.unwrap();
git_cmd!(&git.dir, "config", "user.name", "Test User")
.run()
.unwrap();
file::write(repo_path.join("test.txt"), "first").unwrap();
git.add_all().unwrap();
let first_sha = git.commit("First commit").unwrap();
file::write(repo_path.join("test.txt"), "second").unwrap();
git.add_all().unwrap();
git.commit("Second commit").unwrap();
git.reset(&first_sha, ResetMode::Soft).unwrap();
let status = git.status().unwrap();
assert!(status.has_staged());
git.reset(&first_sha, ResetMode::Mixed).unwrap();
let status = git.status().unwrap();
assert!(!status.has_staged());
assert!(!status.modified.is_empty());
}
#[test]
fn test_git_stash() {
let tmp = tempfile::tempdir().unwrap();
let repo_path = tmp.path().join("test-repo");
let git = init(&repo_path).unwrap();
git_cmd!(&git.dir, "config", "user.email", "test@example.com")
.run()
.unwrap();
git_cmd!(&git.dir, "config", "user.name", "Test User")
.run()
.unwrap();
file::write(repo_path.join("test.txt"), "initial").unwrap();
git.add_all().unwrap();
git.commit("Initial commit").unwrap();
file::write(repo_path.join("test.txt"), "modified").unwrap();
git.stash_with_message("WIP: test changes").unwrap();
let status = git.status().unwrap();
assert!(status.is_clean());
let content = file::read_to_string(repo_path.join("test.txt")).unwrap();
assert_eq!(content, "initial");
let stashes = git.stash_list().unwrap();
assert_eq!(stashes.len(), 1);
assert!(stashes[0].contains("WIP: test changes"));
git.stash_pop().unwrap();
let content = file::read_to_string(repo_path.join("test.txt")).unwrap();
assert_eq!(content, "modified");
}
#[test]
fn test_git_diff_stat() {
let tmp = tempfile::tempdir().unwrap();
let repo_path = tmp.path().join("test-repo");
let git = init(&repo_path).unwrap();
git_cmd!(&git.dir, "config", "user.email", "test@example.com")
.run()
.unwrap();
git_cmd!(&git.dir, "config", "user.name", "Test User")
.run()
.unwrap();
file::write(repo_path.join("test.txt"), "line1\nline2\nline3\n").unwrap();
git.add_all().unwrap();
git.commit("Initial commit").unwrap();
file::write(
repo_path.join("test.txt"),
"line1\nmodified\nline3\nnew line\n",
)
.unwrap();
let stat = git.diff_stat(None, None).unwrap();
assert_eq!(stat.files_changed, 1);
assert!(stat.insertions > 0 || stat.deletions > 0);
}
#[test]
fn test_git_ref_exists() {
let tmp = tempfile::tempdir().unwrap();
let repo_path = tmp.path().join("test-repo");
let git = init(&repo_path).unwrap();
git_cmd!(&git.dir, "config", "user.email", "test@example.com")
.run()
.unwrap();
git_cmd!(&git.dir, "config", "user.name", "Test User")
.run()
.unwrap();
assert!(!git.ref_exists("HEAD"));
file::write(repo_path.join("test.txt"), "content").unwrap();
git.add_all().unwrap();
git.commit("Initial commit").unwrap();
assert!(git.ref_exists("HEAD"));
assert!(!git.ref_exists("nonexistent-ref"));
}
#[test]
fn test_git_log() {
let tmp = tempfile::tempdir().unwrap();
let repo_path = tmp.path().join("test-repo");
let git = init(&repo_path).unwrap();
git_cmd!(&git.dir, "config", "user.email", "test@example.com")
.run()
.unwrap();
git_cmd!(&git.dir, "config", "user.name", "Test User")
.run()
.unwrap();
file::write(repo_path.join("test.txt"), "1").unwrap();
git.add_all().unwrap();
git.commit("First commit").unwrap();
file::write(repo_path.join("test.txt"), "2").unwrap();
git.add_all().unwrap();
git.commit("Second commit").unwrap();
file::write(repo_path.join("test.txt"), "3").unwrap();
git.add_all().unwrap();
git.commit("Third commit").unwrap();
let log = git.log(2).unwrap();
assert_eq!(log.len(), 2);
assert_eq!(log[0].1, "Third commit");
assert_eq!(log[1].1, "Second commit");
}
#[test]
fn test_git_merge_base() {
let tmp = tempfile::tempdir().unwrap();
let repo_path = tmp.path().join("test-repo");
let git = init(&repo_path).unwrap();
git_cmd!(&git.dir, "config", "user.email", "test@example.com")
.run()
.unwrap();
git_cmd!(&git.dir, "config", "user.name", "Test User")
.run()
.unwrap();
file::write(repo_path.join("test.txt"), "base").unwrap();
git.add_all().unwrap();
let base_sha = git.commit("Base commit").unwrap();
git.checkout_new_branch("feature").unwrap();
file::write(repo_path.join("feature.txt"), "feature").unwrap();
git.add_all().unwrap();
git.commit("Feature commit").unwrap();
git.checkout("master")
.or_else(|_| git.checkout("main"))
.unwrap();
file::write(repo_path.join("main.txt"), "main").unwrap();
git.add_all().unwrap();
git.commit("Main commit").unwrap();
let main_branch = git.current_branch().unwrap();
let merge_base = git.merge_base(&main_branch, "feature").unwrap();
assert_eq!(merge_base, base_sha);
}
#[test]
fn test_parse_diff_stat() {
let stat = parse_diff_stat(" 3 files changed, 10 insertions(+), 5 deletions(-)");
assert_eq!(stat.files_changed, 3);
assert_eq!(stat.insertions, 10);
assert_eq!(stat.deletions, 5);
let stat = parse_diff_stat(" 1 file changed, 1 insertion(+)");
assert_eq!(stat.files_changed, 1);
assert_eq!(stat.insertions, 1);
assert_eq!(stat.deletions, 0);
let stat = parse_diff_stat("");
assert_eq!(stat.files_changed, 0);
assert_eq!(stat.insertions, 0);
assert_eq!(stat.deletions, 0);
}
#[test]
fn test_status_is_clean() {
let status = Status::default();
assert!(status.is_clean());
let mut status = Status::default();
status.modified.push(StatusEntry {
path: PathBuf::from("test.txt"),
status: FileStatus::Modified,
original_path: None,
});
assert!(!status.is_clean());
}
#[test]
fn test_clone_invalid_url() {
let tmp = tempfile::tempdir().unwrap();
let result = clone(
"https://github.com/nonexistent-user-12345/nonexistent-repo-67890",
tmp.path(),
&CloneOptions::default(),
);
assert!(result.is_err());
}
#[test]
fn test_clone_invalid_branch() {
let tmp = tempfile::tempdir().unwrap();
let clone_options = CloneOptions::default().branch("nonexistent-branch-12345");
let result = clone("https://github.com/jdx/xx", tmp.path(), &clone_options);
assert!(result.is_err());
}
#[test]
fn test_current_branch_not_a_repo() {
let tmp = tempfile::tempdir().unwrap();
let git = Git::new(tmp.path().to_path_buf());
let result = git.current_branch();
assert!(result.is_err());
}
#[test]
fn test_current_sha_not_a_repo() {
let tmp = tempfile::tempdir().unwrap();
let git = Git::new(tmp.path().to_path_buf());
let result = git.current_sha();
assert!(result.is_err());
}
#[test]
fn test_current_sha_short_not_a_repo() {
let tmp = tempfile::tempdir().unwrap();
let git = Git::new(tmp.path().to_path_buf());
let result = git.current_sha_short();
assert!(result.is_err());
}
#[test]
fn test_current_abbrev_ref_not_a_repo() {
let tmp = tempfile::tempdir().unwrap();
let git = Git::new(tmp.path().to_path_buf());
let result = git.current_abbrev_ref();
assert!(result.is_err());
}
#[test]
fn test_get_remote_url_not_a_repo() {
let tmp = tempfile::tempdir().unwrap();
let git = Git::new(tmp.path().to_path_buf());
assert_eq!(git.get_remote_url(), None);
}
#[test]
fn test_get_remote_url_nonexistent_dir() {
let git = Git::new(PathBuf::from("/nonexistent/path/12345"));
assert_eq!(git.get_remote_url(), None);
}
#[test]
fn test_add_nonexistent_file() {
let tmp = tempfile::tempdir().unwrap();
let git = init(tmp.path()).unwrap();
let result = git.add(&["nonexistent_file.txt"]);
assert!(result.is_err());
}
#[test]
fn test_split_url_and_ref_with_ref() {
let (url, gitref) = Git::split_url_and_ref("https://github.com/user/repo#v1.0.0");
assert_eq!(url, "https://github.com/user/repo");
assert_eq!(gitref, Some("v1.0.0".to_string()));
}
#[test]
fn test_split_url_and_ref_without_ref() {
let (url, gitref) = Git::split_url_and_ref("https://github.com/user/repo");
assert_eq!(url, "https://github.com/user/repo");
assert_eq!(gitref, None);
}
#[test]
fn test_split_url_and_ref_with_branch_name() {
let (url, gitref) = Git::split_url_and_ref("https://github.com/user/repo#feature/branch");
assert_eq!(url, "https://github.com/user/repo");
assert_eq!(gitref, Some("feature/branch".to_string()));
}
#[test]
fn test_is_repo_true() {
let tmp = tempfile::tempdir().unwrap();
let git = clone(
"https://github.com/jdx/xx",
tmp.path(),
&CloneOptions::default(),
)
.unwrap();
assert!(git.is_repo());
}
#[test]
fn test_is_repo_false() {
let tmp = tempfile::tempdir().unwrap();
let git = Git::new(tmp.path().to_path_buf());
assert!(!git.is_repo());
}
#[test]
fn test_is_repo_nonexistent() {
let git = Git::new(PathBuf::from("/nonexistent/path/12345"));
assert!(!git.is_repo());
}
#[test]
fn test_update() {
let tmp = tempfile::tempdir().unwrap();
let git = clone(
"https://github.com/jdx/xx",
tmp.path(),
&CloneOptions::default(),
)
.unwrap();
let initial_sha = git.current_sha().unwrap();
let result = git.update(None);
assert!(result.is_ok());
let (prev_rev, post_rev) = result.unwrap();
assert!(!prev_rev.is_empty());
assert!(!post_rev.is_empty());
assert_eq!(prev_rev, initial_sha);
assert_eq!(post_rev.len(), 40); }
#[test]
fn test_clone_options_builder() {
let options = CloneOptions::default();
assert!(options.branch.is_none());
let options = CloneOptions::default().branch("main");
assert_eq!(options.branch, Some("main".to_string()));
}
}
#[derive(Default)]
pub struct CloneOptions {
branch: Option<String>,
}
impl CloneOptions {
pub fn branch(mut self, branch: &str) -> Self {
self.branch = Some(branch.to_string());
self
}
}